diff --git a/packages/__tests__/llm-mapper/openai-to-google-response-format.test.ts b/packages/__tests__/llm-mapper/openai-to-google-response-format.test.ts index f4fedc313b..ffbdb79113 100644 --- a/packages/__tests__/llm-mapper/openai-to-google-response-format.test.ts +++ b/packages/__tests__/llm-mapper/openai-to-google-response-format.test.ts @@ -184,4 +184,80 @@ describe("toGoogle response_format transformation", () => { }); expect(result.generationConfig?.responseSchema?.additionalProperties).toBeUndefined(); }); + + it("should strip $schema from json_schema when converting", () => { + const openAIRequest = { + model: "gemini-2.5-flash", + messages: [ + { + role: "user" as const, + content: "Generate structured output", + }, + ], + response_format: { + type: "json_schema" as const, + json_schema: { + name: "result", + schema: { + $schema: "http://json-schema.org/draft-07/schema#", + type: "object", + properties: { + name: { type: "string" }, + }, + additionalProperties: false, + }, + }, + }, + }; + + const result = toGoogle(openAIRequest); + + expect(result.generationConfig?.responseSchema).toBeDefined(); + // $schema should be stripped + expect(result.generationConfig?.responseSchema?.$schema).toBeUndefined(); + expect(result.generationConfig?.responseSchema?.type).toBe("object"); + }); + + it("should convert array type with null to nullable for Gemini", () => { + const openAIRequest = { + model: "gemini-2.5-flash", + messages: [ + { + role: "user" as const, + content: "Generate structured output", + }, + ], + response_format: { + type: "json_schema" as const, + json_schema: { + name: "result", + schema: { + type: "object", + properties: { + name: { type: "string" }, + nickname: { type: ["string", "null"] }, + age: { type: ["number", "null"] }, + }, + }, + }, + }, + }; + + const result = toGoogle(openAIRequest); + + expect(result.generationConfig?.responseSchema).toBeDefined(); + // Array types should be converted to single type with nullable + expect(result.generationConfig?.responseSchema?.properties?.nickname).toEqual({ + type: "string", + nullable: true, + }); + expect(result.generationConfig?.responseSchema?.properties?.age).toEqual({ + type: "number", + nullable: true, + }); + // Non-nullable field should remain unchanged + expect(result.generationConfig?.responseSchema?.properties?.name).toEqual({ + type: "string", + }); + }); }); diff --git a/packages/llm-mapper/transform/providers/openai/request/toGoogle.ts b/packages/llm-mapper/transform/providers/openai/request/toGoogle.ts index 16cef0992c..6ca66ef528 100644 --- a/packages/llm-mapper/transform/providers/openai/request/toGoogle.ts +++ b/packages/llm-mapper/transform/providers/openai/request/toGoogle.ts @@ -352,6 +352,10 @@ function buildImageConfig(body: HeliconeChatCreateParams): GeminiImageConfig | u * OpenAI's strict mode requires additionalProperties: false on all object schemas, * but Gemini's API rejects this field with: * "Unknown name 'additionalProperties' at 'tools[0].function_declarations[0].parameters'" + * + * Also handles: + * - $schema: JSON Schema version identifier (not supported by Gemini) + * - type as array: OpenAI uses ["string", "null"] for nullable, Gemini uses nullable: true */ function stripOpenAISchemaFields(schema: Record | undefined): Record | undefined { if (!schema || typeof schema !== 'object' || Array.isArray(schema)) { @@ -363,6 +367,32 @@ function stripOpenAISchemaFields(schema: Record | undefined): Recor // Remove OpenAI-specific fields delete cleaned.additionalProperties; + delete cleaned.$schema; + + // Handle type as array (e.g., ["string", "null"] for nullable types) + // Gemini expects type as a single string and uses nullable: true separately + if (Array.isArray(cleaned.type)) { + const types = cleaned.type as string[]; + const hasNull = types.includes("null"); + const nonNullTypes = types.filter((t) => t !== "null"); + + if (nonNullTypes.length === 1) { + cleaned.type = nonNullTypes[0]; + if (hasNull) { + cleaned.nullable = true; + } + } else if (nonNullTypes.length > 1) { + // Multiple non-null types - just take the first one as Gemini doesn't support union types + cleaned.type = nonNullTypes[0]; + if (hasNull) { + cleaned.nullable = true; + } + } else if (hasNull && nonNullTypes.length === 0) { + // Only null type - shouldn't happen but handle gracefully + cleaned.type = "string"; + cleaned.nullable = true; + } + } // Recurse into properties if (cleaned.properties && typeof cleaned.properties === 'object') {