From 97dff405d9b566644547b02cace4fc1a7662bf88 Mon Sep 17 00:00:00 2001 From: Yansu Date: Mon, 9 Feb 2026 22:05:04 +0800 Subject: [PATCH] fix(provider): fix property order bug in Gemini numeric enum stringification The sanitizeGemini() function checked result.type when processing enum values, but result.type may not be set yet if the "enum" key appears before the "type" key during object iteration. This caused numeric enum values to be stringified but the type to remain as "integer"/"number" instead of being changed to "string", which Gemini API rejects. Move the type conversion to after all properties are iterated so it works regardless of property order. Fixes #12784 Co-Authored-By: Claude --- packages/opencode/src/provider/transform.ts | 10 +- .../opencode/test/provider/transform.test.ts | 143 ++++++++++++++++++ 2 files changed, 149 insertions(+), 4 deletions(-) diff --git a/packages/opencode/src/provider/transform.ts b/packages/opencode/src/provider/transform.ts index 01291491d32..b7d5094e09e 100644 --- a/packages/opencode/src/provider/transform.ts +++ b/packages/opencode/src/provider/transform.ts @@ -783,10 +783,6 @@ export namespace ProviderTransform { if (key === "enum" && Array.isArray(value)) { // Convert all enum values to strings result[key] = value.map((v) => String(v)) - // If we have integer type with enum, change type to string - if (result.type === "integer" || result.type === "number") { - result.type = "string" - } } else if (typeof value === "object" && value !== null) { result[key] = sanitizeGemini(value) } else { @@ -794,6 +790,12 @@ export namespace ProviderTransform { } } + // If we have integer/number type with enum, change type to string + // (done after iteration so property order doesn't matter) + if (Array.isArray(result.enum) && (result.type === "integer" || result.type === "number")) { + result.type = "string" + } + // Filter required array to only include fields that exist in properties if (result.type === "object" && result.properties && Array.isArray(result.required)) { result.required = result.required.filter((field: any) => field in result.properties) diff --git a/packages/opencode/test/provider/transform.test.ts b/packages/opencode/test/provider/transform.test.ts index 0e0bb440aa8..9c67203ca45 100644 --- a/packages/opencode/test/provider/transform.test.ts +++ b/packages/opencode/test/provider/transform.test.ts @@ -269,6 +269,149 @@ describe("ProviderTransform.maxOutputTokens", () => { }) }) +describe("ProviderTransform.schema - gemini numeric enum stringification", () => { + const geminiModel = { + providerID: "google", + api: { + id: "gemini-3-pro", + }, + } as any + + test("converts numeric enum values to strings", () => { + const schema = { + type: "object", + properties: { + level: { + type: "integer", + enum: [0, 1, 2, 3, 4], + }, + }, + } as any + + const result = ProviderTransform.schema(geminiModel, schema) as any + + expect(result.properties.level.enum).toEqual(["0", "1", "2", "3", "4"]) + expect(result.properties.level.type).toBe("string") + }) + + test("converts numeric enum values regardless of property order", () => { + // Simulate a schema where "enum" appears before "type" in iteration order + const prop: any = {} + prop.enum = [1, 2, 3] + prop.type = "integer" + + const schema = { + type: "object", + properties: { + priority: prop, + }, + } as any + + const result = ProviderTransform.schema(geminiModel, schema) as any + + expect(result.properties.priority.enum).toEqual(["1", "2", "3"]) + expect(result.properties.priority.type).toBe("string") + }) + + test("changes number type to string for enums", () => { + const schema = { + type: "object", + properties: { + score: { + type: "number", + enum: [0.5, 1.0, 1.5], + }, + }, + } as any + + const result = ProviderTransform.schema(geminiModel, schema) as any + + expect(result.properties.score.enum).toEqual(["0.5", "1", "1.5"]) + expect(result.properties.score.type).toBe("string") + }) + + test("preserves string enum values unchanged", () => { + const schema = { + type: "object", + properties: { + color: { + type: "string", + enum: ["red", "green", "blue"], + }, + }, + } as any + + const result = ProviderTransform.schema(geminiModel, schema) as any + + expect(result.properties.color.enum).toEqual(["red", "green", "blue"]) + expect(result.properties.color.type).toBe("string") + }) + + test("handles mixed enum values (numbers and strings)", () => { + const schema = { + type: "object", + properties: { + value: { + type: "integer", + enum: [0, "auto", 1, "none"], + }, + }, + } as any + + const result = ProviderTransform.schema(geminiModel, schema) as any + + expect(result.properties.value.enum).toEqual(["0", "auto", "1", "none"]) + expect(result.properties.value.type).toBe("string") + }) + + test("converts nested numeric enums", () => { + const schema = { + type: "object", + properties: { + config: { + type: "object", + properties: { + mode: { + type: "integer", + enum: [1, 2, 3], + }, + }, + }, + }, + } as any + + const result = ProviderTransform.schema(geminiModel, schema) as any + + expect(result.properties.config.properties.mode.enum).toEqual(["1", "2", "3"]) + expect(result.properties.config.properties.mode.type).toBe("string") + }) + + test("does not affect non-gemini providers", () => { + const openaiModel = { + providerID: "openai", + api: { + id: "gpt-4", + }, + } as any + + const schema = { + type: "object", + properties: { + level: { + type: "integer", + enum: [0, 1, 2], + }, + }, + } as any + + const result = ProviderTransform.schema(openaiModel, schema) as any + + // OpenAI should keep numeric enums as-is + expect(result.properties.level.enum).toEqual([0, 1, 2]) + expect(result.properties.level.type).toBe("integer") + }) +}) + describe("ProviderTransform.schema - gemini array items", () => { test("adds missing items for array properties", () => { const geminiModel = {