Skip to content

Commit 3dc26c4

Browse files
authored
fix: strip $schema and convert array types in Gemini schema conversion (#5585)
1 parent 2026876 commit 3dc26c4

File tree

2 files changed

+106
-0
lines changed

2 files changed

+106
-0
lines changed

packages/__tests__/llm-mapper/openai-to-google-response-format.test.ts

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -184,4 +184,80 @@ describe("toGoogle response_format transformation", () => {
184184
});
185185
expect(result.generationConfig?.responseSchema?.additionalProperties).toBeUndefined();
186186
});
187+
188+
it("should strip $schema from json_schema when converting", () => {
189+
const openAIRequest = {
190+
model: "gemini-2.5-flash",
191+
messages: [
192+
{
193+
role: "user" as const,
194+
content: "Generate structured output",
195+
},
196+
],
197+
response_format: {
198+
type: "json_schema" as const,
199+
json_schema: {
200+
name: "result",
201+
schema: {
202+
$schema: "http://json-schema.org/draft-07/schema#",
203+
type: "object",
204+
properties: {
205+
name: { type: "string" },
206+
},
207+
additionalProperties: false,
208+
},
209+
},
210+
},
211+
};
212+
213+
const result = toGoogle(openAIRequest);
214+
215+
expect(result.generationConfig?.responseSchema).toBeDefined();
216+
// $schema should be stripped
217+
expect(result.generationConfig?.responseSchema?.$schema).toBeUndefined();
218+
expect(result.generationConfig?.responseSchema?.type).toBe("object");
219+
});
220+
221+
it("should convert array type with null to nullable for Gemini", () => {
222+
const openAIRequest = {
223+
model: "gemini-2.5-flash",
224+
messages: [
225+
{
226+
role: "user" as const,
227+
content: "Generate structured output",
228+
},
229+
],
230+
response_format: {
231+
type: "json_schema" as const,
232+
json_schema: {
233+
name: "result",
234+
schema: {
235+
type: "object",
236+
properties: {
237+
name: { type: "string" },
238+
nickname: { type: ["string", "null"] },
239+
age: { type: ["number", "null"] },
240+
},
241+
},
242+
},
243+
},
244+
};
245+
246+
const result = toGoogle(openAIRequest);
247+
248+
expect(result.generationConfig?.responseSchema).toBeDefined();
249+
// Array types should be converted to single type with nullable
250+
expect(result.generationConfig?.responseSchema?.properties?.nickname).toEqual({
251+
type: "string",
252+
nullable: true,
253+
});
254+
expect(result.generationConfig?.responseSchema?.properties?.age).toEqual({
255+
type: "number",
256+
nullable: true,
257+
});
258+
// Non-nullable field should remain unchanged
259+
expect(result.generationConfig?.responseSchema?.properties?.name).toEqual({
260+
type: "string",
261+
});
262+
});
187263
});

packages/llm-mapper/transform/providers/openai/request/toGoogle.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -352,6 +352,10 @@ function buildImageConfig(body: HeliconeChatCreateParams): GeminiImageConfig | u
352352
* OpenAI's strict mode requires additionalProperties: false on all object schemas,
353353
* but Gemini's API rejects this field with:
354354
* "Unknown name 'additionalProperties' at 'tools[0].function_declarations[0].parameters'"
355+
*
356+
* Also handles:
357+
* - $schema: JSON Schema version identifier (not supported by Gemini)
358+
* - type as array: OpenAI uses ["string", "null"] for nullable, Gemini uses nullable: true
355359
*/
356360
function stripOpenAISchemaFields(schema: Record<string, any> | undefined): Record<string, any> | undefined {
357361
if (!schema || typeof schema !== 'object' || Array.isArray(schema)) {
@@ -363,6 +367,32 @@ function stripOpenAISchemaFields(schema: Record<string, any> | undefined): Recor
363367

364368
// Remove OpenAI-specific fields
365369
delete cleaned.additionalProperties;
370+
delete cleaned.$schema;
371+
372+
// Handle type as array (e.g., ["string", "null"] for nullable types)
373+
// Gemini expects type as a single string and uses nullable: true separately
374+
if (Array.isArray(cleaned.type)) {
375+
const types = cleaned.type as string[];
376+
const hasNull = types.includes("null");
377+
const nonNullTypes = types.filter((t) => t !== "null");
378+
379+
if (nonNullTypes.length === 1) {
380+
cleaned.type = nonNullTypes[0];
381+
if (hasNull) {
382+
cleaned.nullable = true;
383+
}
384+
} else if (nonNullTypes.length > 1) {
385+
// Multiple non-null types - just take the first one as Gemini doesn't support union types
386+
cleaned.type = nonNullTypes[0];
387+
if (hasNull) {
388+
cleaned.nullable = true;
389+
}
390+
} else if (hasNull && nonNullTypes.length === 0) {
391+
// Only null type - shouldn't happen but handle gracefully
392+
cleaned.type = "string";
393+
cleaned.nullable = true;
394+
}
395+
}
366396

367397
// Recurse into properties
368398
if (cleaned.properties && typeof cleaned.properties === 'object') {

0 commit comments

Comments
 (0)