fix(ai): gemini not working in ask ai (#21898)
Upstream issue: https://github.com/vercel/ai/issues/14369 Gemini 400s whenever a tool result contains JSON Schema `$ref`/`$defs` (it reads `$ref` as a function declaration name and finds no match). We hit this because `learn_tools` returns tool input schemas, and our recursive filter schema emits `$ref`/`$defs`. Other providers accept it fine, so this only blocks Gemini. Adds a Google-only `wrapLanguageModel` middleware that serializes ref-bearing tool results to text before they reach Gemini, so the pointers travel as a string instead of structured keys. The model still reads the full schema (same as the MCP path). Guarded so normal tool results pass through untouched. <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/21898?utm_source=github" target="_blank" rel="noopener noreferrer" data-no-image-dialog="true"><picture><source media="(prefers-color-scheme: dark)" srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img alt="Review in cubic" src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a> <!-- End of auto-generated description by cubic. --> --------- Co-authored-by: Charles Bochet <charles@twenty.com>
This commit is contained in:
+12
@@ -0,0 +1,12 @@
|
||||
import { type LanguageModelMiddleware } from 'ai';
|
||||
|
||||
import { sanitizeToolResultRefs } from 'src/engine/metadata-modules/ai/ai-models/utils/sanitize-tool-result-refs.util';
|
||||
|
||||
// Gemini 400s on JSON Schema `$ref`/`$defs` in tool results, so serialize them to text (Google only): https://github.com/vercel/ai/issues/14369
|
||||
export const sanitizeGeminiToolResultRefsMiddleware: LanguageModelMiddleware = {
|
||||
specificationVersion: 'v3',
|
||||
transformParams: async ({ params }) => ({
|
||||
...params,
|
||||
prompt: sanitizeToolResultRefs(params.prompt),
|
||||
}),
|
||||
};
|
||||
+17
-4
@@ -9,7 +9,11 @@ import { createOpenAI, type OpenAIProvider } from '@ai-sdk/openai';
|
||||
import { createOpenAICompatible } from '@ai-sdk/openai-compatible';
|
||||
import { createXai, type XaiProvider } from '@ai-sdk/xai';
|
||||
import { fromNodeProviderChain } from '@aws-sdk/credential-providers';
|
||||
import { type LanguageModel } from 'ai';
|
||||
import {
|
||||
wrapLanguageModel,
|
||||
type LanguageModel,
|
||||
type LanguageModelMiddleware,
|
||||
} from 'ai';
|
||||
import { type AiSdkPackage } from 'twenty-shared/ai';
|
||||
|
||||
import {
|
||||
@@ -22,6 +26,7 @@ import {
|
||||
AI_SDK_OPENAI_COMPATIBLE,
|
||||
AI_SDK_XAI,
|
||||
} from 'src/engine/metadata-modules/ai/ai-models/constants/ai-sdk-package.const';
|
||||
import { sanitizeGeminiToolResultRefsMiddleware } from 'src/engine/metadata-modules/ai/ai-models/middleware/sanitize-gemini-tool-result-refs.middleware';
|
||||
import { type AiProviderConfig } from 'src/engine/metadata-modules/ai/ai-models/types/ai-provider-config.type';
|
||||
|
||||
export type AiSdkProviderInstance = {
|
||||
@@ -92,7 +97,9 @@ export class SdkProviderFactoryService {
|
||||
case AI_SDK_ANTHROPIC:
|
||||
return this.buildStandardProvider(config, createAnthropic);
|
||||
case AI_SDK_GOOGLE:
|
||||
return this.buildStandardProvider(config, createGoogleGenerativeAI);
|
||||
return this.buildStandardProvider(config, createGoogleGenerativeAI, {
|
||||
middleware: sanitizeGeminiToolResultRefsMiddleware,
|
||||
});
|
||||
case AI_SDK_MISTRAL:
|
||||
return this.buildStandardProvider(config, createMistral);
|
||||
case AI_SDK_XAI:
|
||||
@@ -111,6 +118,7 @@ export class SdkProviderFactoryService {
|
||||
private buildStandardProvider(
|
||||
config: AiProviderConfig,
|
||||
factory: (opts: { apiKey?: string; baseURL?: string }) => CallableFunction,
|
||||
options?: { middleware?: LanguageModelMiddleware },
|
||||
): AiSdkProviderInstance {
|
||||
const provider = factory({
|
||||
...(config.apiKey && { apiKey: config.apiKey }),
|
||||
@@ -118,8 +126,13 @@ export class SdkProviderFactoryService {
|
||||
});
|
||||
|
||||
return {
|
||||
createModel: (modelId: string) =>
|
||||
(provider as CallableFunction)(modelId) as LanguageModel,
|
||||
createModel: (modelId: string) => {
|
||||
const model = (provider as CallableFunction)(modelId);
|
||||
|
||||
return options?.middleware
|
||||
? wrapLanguageModel({ model, middleware: options.middleware })
|
||||
: model;
|
||||
},
|
||||
rawProvider: provider,
|
||||
sdkPackage: config.npm,
|
||||
};
|
||||
|
||||
+148
@@ -0,0 +1,148 @@
|
||||
import {
|
||||
type LanguageModelV3Prompt,
|
||||
type LanguageModelV3ToolResultPart,
|
||||
} from '@ai-sdk/provider';
|
||||
|
||||
import { sanitizeToolResultRefs } from 'src/engine/metadata-modules/ai/ai-models/utils/sanitize-tool-result-refs.util';
|
||||
|
||||
const SCHEMA_WITH_REFS = {
|
||||
tools: [
|
||||
{
|
||||
name: 'find_many_companies',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: { filter: { $ref: '#/$defs/__schema0' } },
|
||||
$defs: { __schema0: { type: 'object' } },
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const getFirstToolResultPart = (
|
||||
prompt: LanguageModelV3Prompt,
|
||||
): LanguageModelV3ToolResultPart => {
|
||||
const toolMessage = prompt.find((message) => message.role === 'tool');
|
||||
|
||||
if (toolMessage?.role !== 'tool') {
|
||||
throw new Error('Expected a tool message in the prompt');
|
||||
}
|
||||
|
||||
const toolResultPart = toolMessage.content.find(
|
||||
(part) => part.type === 'tool-result',
|
||||
);
|
||||
|
||||
if (toolResultPart?.type !== 'tool-result') {
|
||||
throw new Error('Expected a tool-result part in the tool message');
|
||||
}
|
||||
|
||||
return toolResultPart;
|
||||
};
|
||||
|
||||
describe('sanitizeToolResultRefs', () => {
|
||||
it('should serialize tool-result json output containing $ref/$defs to text', () => {
|
||||
const prompt: LanguageModelV3Prompt = [
|
||||
{
|
||||
role: 'tool',
|
||||
content: [
|
||||
{
|
||||
type: 'tool-result',
|
||||
toolCallId: 'call-1',
|
||||
toolName: 'learn_tools',
|
||||
output: { type: 'json', value: SCHEMA_WITH_REFS },
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const part = getFirstToolResultPart(sanitizeToolResultRefs(prompt));
|
||||
|
||||
if (part.output.type !== 'text') {
|
||||
throw new Error('Expected the output to be serialized to text');
|
||||
}
|
||||
|
||||
expect(JSON.parse(part.output.value)).toEqual(SCHEMA_WITH_REFS);
|
||||
});
|
||||
|
||||
it('should preserve output-level providerOptions when serializing to text', () => {
|
||||
const providerOptions = { google: { cacheControl: { type: 'ephemeral' } } };
|
||||
const prompt: LanguageModelV3Prompt = [
|
||||
{
|
||||
role: 'tool',
|
||||
content: [
|
||||
{
|
||||
type: 'tool-result',
|
||||
toolCallId: 'call-1',
|
||||
toolName: 'learn_tools',
|
||||
output: { type: 'json', value: SCHEMA_WITH_REFS, providerOptions },
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const part = getFirstToolResultPart(sanitizeToolResultRefs(prompt));
|
||||
|
||||
if (part.output.type !== 'text') {
|
||||
throw new Error('Expected the output to be serialized to text');
|
||||
}
|
||||
|
||||
expect(part.output.providerOptions).toEqual(providerOptions);
|
||||
});
|
||||
|
||||
it('should convert error-json output containing refs to error-text', () => {
|
||||
const prompt: LanguageModelV3Prompt = [
|
||||
{
|
||||
role: 'tool',
|
||||
content: [
|
||||
{
|
||||
type: 'tool-result',
|
||||
toolCallId: 'call-1',
|
||||
toolName: 'learn_tools',
|
||||
output: { type: 'error-json', value: SCHEMA_WITH_REFS },
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const part = getFirstToolResultPart(sanitizeToolResultRefs(prompt));
|
||||
|
||||
if (part.output.type !== 'error-text') {
|
||||
throw new Error('Expected the output to be serialized to error-text');
|
||||
}
|
||||
|
||||
expect(JSON.parse(part.output.value)).toEqual(SCHEMA_WITH_REFS);
|
||||
});
|
||||
|
||||
it('should leave tool results without refs untouched', () => {
|
||||
const value = { success: true, result: { id: '1', name: 'Acme' } };
|
||||
const prompt: LanguageModelV3Prompt = [
|
||||
{
|
||||
role: 'tool',
|
||||
content: [
|
||||
{
|
||||
type: 'tool-result',
|
||||
toolCallId: 'call-1',
|
||||
toolName: 'find_one_company',
|
||||
output: { type: 'json', value },
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const part = getFirstToolResultPart(sanitizeToolResultRefs(prompt));
|
||||
|
||||
expect(part.output.type).toBe('json');
|
||||
expect(part.output).toEqual({ type: 'json', value });
|
||||
});
|
||||
|
||||
it('should not touch non-tool messages', () => {
|
||||
const prompt: LanguageModelV3Prompt = [
|
||||
{ role: 'system', content: 'You are helpful.' },
|
||||
{
|
||||
role: 'user',
|
||||
content: [{ type: 'text', text: 'hello' }],
|
||||
},
|
||||
];
|
||||
|
||||
expect(sanitizeToolResultRefs(prompt)).toEqual(prompt);
|
||||
});
|
||||
});
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
import {
|
||||
type LanguageModelV3Prompt,
|
||||
type LanguageModelV3ToolResultPart,
|
||||
} from '@ai-sdk/provider';
|
||||
import { isArray, isObject, isString } from '@sniptt/guards';
|
||||
|
||||
const containsJsonSchemaDefsRef = (value: unknown): boolean => {
|
||||
if (isArray(value)) {
|
||||
return value.some(containsJsonSchemaDefsRef);
|
||||
}
|
||||
|
||||
if (!isObject(value)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (
|
||||
('$ref' in value && isString(value.$ref)) ||
|
||||
'$defs' in value ||
|
||||
Object.values(value).some(containsJsonSchemaDefsRef)
|
||||
);
|
||||
};
|
||||
|
||||
const sanitizeToolResultPart = (
|
||||
part: LanguageModelV3ToolResultPart,
|
||||
): LanguageModelV3ToolResultPart => {
|
||||
if (
|
||||
(part.output.type !== 'json' && part.output.type !== 'error-json') ||
|
||||
!containsJsonSchemaDefsRef(part.output.value)
|
||||
) {
|
||||
return part;
|
||||
}
|
||||
|
||||
const value = JSON.stringify(part.output.value);
|
||||
|
||||
const providerOptions = part.output.providerOptions
|
||||
? { providerOptions: part.output.providerOptions }
|
||||
: {};
|
||||
|
||||
return {
|
||||
...part,
|
||||
output:
|
||||
part.output.type === 'error-json'
|
||||
? { type: 'error-text', value, ...providerOptions }
|
||||
: { type: 'text', value, ...providerOptions },
|
||||
};
|
||||
};
|
||||
|
||||
export const sanitizeToolResultRefs = (
|
||||
prompt: LanguageModelV3Prompt,
|
||||
): LanguageModelV3Prompt =>
|
||||
prompt.map((message) =>
|
||||
message.role === 'tool'
|
||||
? { ...message, content: message.content.map(sanitizeToolResultPart) }
|
||||
: message,
|
||||
);
|
||||
Reference in New Issue
Block a user