diff --git a/changelog.d/fixes/7364-zai-glm-target-format.md b/changelog.d/fixes/7364-zai-glm-target-format.md new file mode 100644 index 0000000000..a2f6fef807 --- /dev/null +++ b/changelog.d/fixes/7364-zai-glm-target-format.md @@ -0,0 +1 @@ +- fix(sse): honor per-model targetFormat override for zai/glm-coding-apikey buildUrl and make custom-model id lookup case-insensitive (#7364) diff --git a/open-sse/executors/default.ts b/open-sse/executors/default.ts index f3bd3930c8..9fad2305a4 100644 --- a/open-sse/executors/default.ts +++ b/open-sse/executors/default.ts @@ -52,6 +52,7 @@ import { normalizeGigachatChatUrl, } from "@/lib/providers/validation/urlHelpers"; import { forwardOpencodeClientHeaders } from "../utils/opencodeHeaders.ts"; +import { resolveZaiUrl } from "./default/zaiFormatOverride.ts"; import type { PoolConfig } from "../services/sessionPool/types.ts"; @@ -242,10 +243,9 @@ export class DefaultExecutor extends BaseExecutor { return normalizeOpenAIChatUrl(baseUrl); } case "zai": - case "glm-coding-apikey": { - const zaiBaseUrl = this.resolveBaseUrl(credentials); - return `${zaiBaseUrl}?beta=true`; - } + case "glm-coding-apikey": + // #7364: format override extracted to zaiFormatOverride.ts (file-size ratchet). + return resolveZaiUrl(credentials, (fallback) => this.resolveBaseUrl(credentials, fallback)); case "claude": case "glm": case "glmt": diff --git a/open-sse/executors/default/zaiFormatOverride.ts b/open-sse/executors/default/zaiFormatOverride.ts new file mode 100644 index 0000000000..535e3cf34b --- /dev/null +++ b/open-sse/executors/default/zaiFormatOverride.ts @@ -0,0 +1,25 @@ +import { GLM_DEFAULT_BASE_URLS } from "../../config/glmProvider.ts"; + +type ZaiCredentialsLike = { + providerSpecificData?: { targetFormat?: unknown } | null; +} | null; + +/** + * #7364: "zai"/"glm-coding-apikey" default to the Anthropic Messages wire format + * (registry format:"claude"), but a per-model `targetFormat` override (custom-model + * dropdown, #2905) can resolve to "openai" — e.g. for a vision model like glm-4.6v + * that the operator wants routed through the OpenAI-compatible endpoint instead. + * chatCore/executionCredentials.ts threads that resolved override onto + * `providerSpecificData.targetFormat`; DefaultExecutor.buildUrl() has no other way + * to see it, so without this check every zai/glm-coding-apikey request silently hit + * the Claude-format endpoint regardless of the override. + */ +export function resolveZaiUrl( + credentials: ZaiCredentialsLike, + resolveBaseUrl: (fallback?: string) => string +): string { + if (credentials?.providerSpecificData?.targetFormat === "openai") { + return resolveBaseUrl(GLM_DEFAULT_BASE_URLS.international); + } + return `${resolveBaseUrl()}?beta=true`; +} diff --git a/open-sse/handlers/chatCore/executionCredentials.ts b/open-sse/handlers/chatCore/executionCredentials.ts index 3d0d7dcf93..573411e9b7 100644 --- a/open-sse/handlers/chatCore/executionCredentials.ts +++ b/open-sse/handlers/chatCore/executionCredentials.ts @@ -55,6 +55,16 @@ export function resolveExecutionCredentials(opts: { providerSpecificData._omnirouteForceResponsesUpstream = true; } + // #7364: "zai"/"glm-coding-apikey" default to the Anthropic Messages wire format + // (registry format:"claude"), but a per-model targetFormat override (custom-model + // dropdown, #2905) can resolve targetFormat to "openai" — e.g. for a vision model + // like glm-4.6v that the operator wants routed through the OpenAI-compatible + // endpoint. DefaultExecutor.buildUrl()'s "zai" branch has no other way to see that + // override, so surface it on providerSpecificData for buildUrl to read. + if (targetFormat === FORMATS.OPENAI && (provider === "zai" || provider === "glm-coding-apikey")) { + providerSpecificData.targetFormat = targetFormat; + } + const withApiType = { ...nextCredentials, providerSpecificData, diff --git a/src/lib/db/models.ts b/src/lib/db/models.ts index 02aebf9428..2face5cc31 100644 --- a/src/lib/db/models.ts +++ b/src/lib/db/models.ts @@ -705,10 +705,22 @@ function getCustomModelRow(providerId: string, modelId: string): JsonRecord | nu try { const models = JSON.parse(value) as unknown; if (!Array.isArray(models)) return null; - const m = models.find((x: unknown) => { + const isIdMatch = (x: unknown, id: string): boolean => { if (!x || typeof x !== "object" || Array.isArray(x)) return false; - return (x as { id?: string }).id === modelId; - }) as JsonRecord | undefined; + return (x as { id?: string }).id === id; + }; + // #7364: exact match first; case-insensitive fallback so "glm-4.6V" resolves a + // custom model saved as "glm-4.6v" (see lookupCustomModelMeta in + // src/sse/services/model.ts for the sibling lookup this mirrors). + const m = (models.find((x: unknown) => isIdMatch(x, modelId)) ?? + models.find( + (x: unknown) => + x && + typeof x === "object" && + !Array.isArray(x) && + typeof (x as { id?: string }).id === "string" && + ((x as { id: string }).id as string).toLowerCase() === modelId.toLowerCase() + )) as JsonRecord | undefined; return m ?? null; } catch { return null; diff --git a/src/sse/services/model.ts b/src/sse/services/model.ts index 9c8bbfe134..8dd2c23871 100644 --- a/src/sse/services/model.ts +++ b/src/sse/services/model.ts @@ -74,7 +74,15 @@ async function lookupCustomModelMeta( try { const models = await getCustomModels(providerId); if (!Array.isArray(models)) return {}; - const match = models.find((m: any) => m.id === modelId); + // #7364: exact match first (preserves existing behavior/perf); fall back to a + // case-insensitive match so a model saved as "glm-4.6v" is still found when the + // caller (dashboard, combo target, direct call) requests "glm-4.6V" — several + // reporters typed the uppercase "V" from Z.AI's own docs/marketing. + const match = + models.find((m: any) => m.id === modelId) ?? + models.find( + (m: any) => typeof m.id === "string" && m.id.toLowerCase() === modelId.toLowerCase() + ); if (!match) return {}; return { apiFormat: match.apiFormat === "responses" ? "responses" : undefined, diff --git a/tests/unit/zai-execution-credentials-target-format-7364.test.ts b/tests/unit/zai-execution-credentials-target-format-7364.test.ts new file mode 100644 index 0000000000..d97865b823 --- /dev/null +++ b/tests/unit/zai-execution-credentials-target-format-7364.test.ts @@ -0,0 +1,52 @@ +// tests/unit/zai-execution-credentials-target-format-7364.test.ts +// #7364 Defect A: resolveExecutionCredentials must thread a resolved "openai" +// targetFormat onto providerSpecificData for the "zai"/"glm-coding-apikey" providers, +// so DefaultExecutor.buildUrl()'s zai branch (open-sse/executors/default/zaiFormatOverride.ts) +// can see the per-model custom-model targetFormat override (#2905) and route to the +// OpenAI-compatible endpoint instead of the default Anthropic Messages URL. +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { resolveExecutionCredentials } from "../../open-sse/handlers/chatCore/executionCredentials.ts"; + +const base = { + credentials: { providerSpecificData: { foo: "bar" } } as Record, + nativeCodexPassthrough: false, + endpointPath: "/v1/messages", + ccSessionId: null, +}; + +test("zai + resolved openai targetFormat threads providerSpecificData.targetFormat", () => { + const out = resolveExecutionCredentials({ + ...base, + provider: "zai", + targetFormat: "openai", + }) as Record; + assert.deepEqual(out.providerSpecificData, { foo: "bar", targetFormat: "openai" }); +}); + +test("glm-coding-apikey + resolved openai targetFormat threads providerSpecificData.targetFormat", () => { + const out = resolveExecutionCredentials({ + ...base, + provider: "glm-coding-apikey", + targetFormat: "openai", + }) as Record; + assert.deepEqual(out.providerSpecificData, { foo: "bar", targetFormat: "openai" }); +}); + +test("zai + default claude targetFormat does NOT inject a targetFormat override", () => { + const out = resolveExecutionCredentials({ + ...base, + provider: "zai", + targetFormat: "claude", + }) as Record; + assert.deepEqual(out.providerSpecificData, { foo: "bar" }); +}); + +test("unrelated provider (openai) with targetFormat=openai is untouched by the zai branch", () => { + const out = resolveExecutionCredentials({ + ...base, + provider: "openai", + targetFormat: "openai", + }) as Record; + assert.deepEqual(out.providerSpecificData, { foo: "bar" }); +}); diff --git a/tests/unit/zai-glm-target-format-override.test.ts b/tests/unit/zai-glm-target-format-override.test.ts new file mode 100644 index 0000000000..44ef3d5033 --- /dev/null +++ b/tests/unit/zai-glm-target-format-override.test.ts @@ -0,0 +1,54 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-7364-zai-target-format-")); +process.env.DATA_DIR = TEST_DATA_DIR; + +const core = await import("../../src/lib/db/core.ts"); +const modelsDb = await import("../../src/lib/db/models.ts"); +const { getModelInfo } = await import("../../src/sse/services/model.ts"); +const { DefaultExecutor } = await import("../../open-sse/executors/default.ts"); + +test.after(() => { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); +}); + +test("#7364 Defect A (URL): DefaultExecutor.buildUrl('zai', ...) ignores a per-model targetFormat:'openai' override and still returns the Anthropic Messages URL", () => { + const executor = new DefaultExecutor("zai"); + const credentialsWithOpenAIOverride = { + apiKey: "test-key", + providerSpecificData: { targetFormat: "openai" }, + }; + const url = executor.buildUrl("glm-4.6v", false, 0, credentialsWithOpenAIOverride); + assert.notEqual( + url, + "https://api.z.ai/api/anthropic/v1/messages?beta=true", + "BUG #7364 Defect A: an 'openai' targetFormat override must not hit the Anthropic Messages URL, but it does" + ); +}); + +test("#7364 Defect A (case-sensitivity): a custom model saved as 'glm-4.6v' is not found when looked up as 'glm-4.6V'", async () => { + await modelsDb.addCustomModel( + "zai", + "glm-4.6v", + "GLM 4.6V (vision)", + "manual", + "chat-completions", + ["chat"], + "openai" // explicit targetFormat override, mirroring the dashboard dropdown + ); + + const exact = (await getModelInfo("zai/glm-4.6v")) as { targetFormat?: string }; + assert.equal(exact.targetFormat, "openai", "sanity check: exact-case lookup must surface the saved targetFormat"); + + const mixedCase = (await getModelInfo("zai/glm-4.6V")) as { targetFormat?: string }; + assert.equal( + mixedCase.targetFormat, + "openai", + "BUG #7364 Defect A: case-mismatched lookup ('glm-4.6V' vs stored 'glm-4.6v') must still surface the targetFormat override, but it doesn't" + ); +});