Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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",
});
});
});
30 changes: 30 additions & 0 deletions packages/llm-mapper/transform/providers/openai/request/toGoogle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, any> | undefined): Record<string, any> | undefined {
if (!schema || typeof schema !== 'object' || Array.isArray(schema)) {
Expand All @@ -363,6 +367,32 @@ function stripOpenAISchemaFields(schema: Record<string, any> | 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') {
Expand Down
Loading