diff --git a/open-sse/executors/zed-hosted.ts b/open-sse/executors/zed-hosted.ts index ba66706f10..03209e00ca 100644 --- a/open-sse/executors/zed-hosted.ts +++ b/open-sse/executors/zed-hosted.ts @@ -81,6 +81,49 @@ function normalizeZedProvider(value: unknown, model: unknown): ZedProviderName { return ZED_PROVIDER.openai; } +function asMutableRecord(value: unknown): Record | null { + return value && typeof value === "object" ? (value as Record) : null; +} + +// Zed's Google proxy enums are narrower than Google's own (#13363): the safety +// threshold only accepts BLOCK_NONE (not "OFF"), and FunctionCallingMode is +// lowercase auto/any/none (no VALIDATED). +const ZED_FUNCTION_CALLING_MODES: Record = { + VALIDATED: "auto", + AUTO: "auto", + ANY: "any", + NONE: "none", +}; + +function adaptGeminiRequestForZed(request: unknown): unknown { + const record = asMutableRecord(request); + if (!record) return request; + if (Array.isArray(record.safetySettings)) { + for (const entry of record.safetySettings) { + const setting = asMutableRecord(entry); + if (setting?.threshold === "OFF") setting.threshold = "BLOCK_NONE"; + } + } + const callingConfig = asMutableRecord(asMutableRecord(record.toolConfig)?.functionCallingConfig); + const mappedMode = callingConfig + ? ZED_FUNCTION_CALLING_MODES[String(callingConfig.mode || "").toUpperCase()] + : undefined; + if (callingConfig && mappedMode) callingConfig.mode = mappedMode; + return request; +} + +// Zed's OpenAI proxy Role enum only has user/assistant/system/tool — no +// "developer" (#13362) — so developer-role input items go back to system. +function adaptResponsesRequestForZed(request: unknown): unknown { + const input = asMutableRecord(request)?.input; + if (!Array.isArray(input)) return request; + for (const entry of input) { + const item = asMutableRecord(entry); + if (item?.role === "developer") item.role = "system"; + } + return request; +} + function buildProviderRequest( provider: ZedProviderName, model: string, @@ -92,10 +135,14 @@ function buildProviderRequest( return openaiToClaudeRequest(model, body, true); } if (provider === ZED_PROVIDER.google) { - return openaiToGeminiRequest(model, body as Record, true, credentials); + return adaptGeminiRequestForZed( + openaiToGeminiRequest(model, body as Record, true, credentials) + ); } if (provider === ZED_PROVIDER.openai) { - return openaiToOpenAIResponsesRequest(model, body, true, credentials); + return adaptResponsesRequestForZed( + openaiToOpenAIResponsesRequest(model, body, true, credentials) + ); } return { ...(body as Record), @@ -551,6 +598,8 @@ export class ZedHostedExecutor extends BaseExecutor { export default ZedHostedExecutor; export const __test__ = { + adaptGeminiRequestForZed, + adaptResponsesRequestForZed, normalizeZedProvider, unwrapZedLine, wrapZedCompletionStream, diff --git a/tests/unit/zed-hosted-proxy-enums-13362.test.ts b/tests/unit/zed-hosted-proxy-enums-13362.test.ts new file mode 100644 index 0000000000..675afd3c6a --- /dev/null +++ b/tests/unit/zed-hosted-proxy-enums-13362.test.ts @@ -0,0 +1,57 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +const { __test__ } = await import("../../open-sse/executors/zed-hosted.ts"); +const { adaptGeminiRequestForZed, adaptResponsesRequestForZed } = __test__; + +// #13362 / #13363: Zed's hosted proxy validates narrower enums than the upstream +// APIs, so requests translated for Google/OpenAI must be re-mapped before dispatch. + +test("#13363 Gemini safety threshold OFF becomes BLOCK_NONE for Zed", () => { + const request = { + safetySettings: [ + { category: "HARM_CATEGORY_HARASSMENT", threshold: "OFF" }, + { category: "HARM_CATEGORY_HATE_SPEECH", threshold: "BLOCK_ONLY_HIGH" }, + ], + }; + adaptGeminiRequestForZed(request); + assert.deepEqual( + request.safetySettings.map((s) => s.threshold), + ["BLOCK_NONE", "BLOCK_ONLY_HIGH"] + ); +}); + +test("#13363 Gemini function-calling modes map to Zed's lowercase enum", () => { + for (const [mode, expected] of [ + ["VALIDATED", "auto"], + ["AUTO", "auto"], + ["ANY", "any"], + ["NONE", "none"], + ]) { + const request = { toolConfig: { functionCallingConfig: { mode } } }; + adaptGeminiRequestForZed(request); + assert.equal(request.toolConfig.functionCallingConfig.mode, expected, mode); + } +}); + +test("#13363 Gemini request without safety or tool config passes through untouched", () => { + const request = { contents: [{ role: "user", parts: [{ text: "hi" }] }] }; + const before = JSON.stringify(request); + assert.equal(adaptGeminiRequestForZed(request), request); + assert.equal(JSON.stringify(request), before); + assert.equal(adaptGeminiRequestForZed(null), null); +}); + +test("#13362 Responses developer-role input items become system for Zed", () => { + const request = { + input: [ + { role: "developer", content: "be terse" }, + { role: "user", content: "hello" }, + ], + }; + adaptResponsesRequestForZed(request); + assert.deepEqual( + request.input.map((item) => item.role), + ["system", "user"] + ); +});