From d4045e74b5ee986f430396bd41bb1532d75abb27 Mon Sep 17 00:00:00 2001 From: diegosouzapw Date: Sun, 31 May 2026 11:16:48 -0300 Subject: [PATCH] fix(models): honor per-model targetFormat for custom models (#2905) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Custom models (added via the UI) on opencode-go / openai-compatible nodes always routed as OpenAI-compatible because there was no per-model targetFormat: addCustomModel didn't accept it, the API schema stripped it, and getModelTargetFormat (static-registry only) never saw it — so a custom model needing the Anthropic Messages shape fell back to the provider default 'openai'. Thread an optional targetFormat through addCustomModel / replaceCustomModels / updateCustomModel + the providerModelMutationSchema + the POST/PUT route, surface it from getModelInfo (one combined custom-model metadata lookup alongside apiFormat), and use it in chatCore's targetFormat resolution before the provider default. Closes #2905 --- CHANGELOG.md | 6 ++ open-sse/handlers/chatCore.ts | 13 ++- src/app/api/provider-models/route.ts | 8 +- src/lib/db/models.ts | 15 +++- src/shared/validation/schemas.ts | 5 ++ src/sse/services/model.ts | 35 +++++--- tests/unit/custom-model-target-format.test.ts | 83 +++++++++++++++++++ 7 files changed, 149 insertions(+), 16 deletions(-) create mode 100644 tests/unit/custom-model-target-format.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index e43e8e0630..e443d6e308 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -59,6 +59,12 @@ ### Fixed +- **models/custom:** custom models can now carry a per-model `targetFormat` + override (e.g. an opencode-go custom model that must use the Anthropic Messages + shape). Previously custom models always routed as OpenAI-compatible because + `targetFormat` was neither persisted nor consulted at routing time. Threaded + through `addCustomModel`/`replaceCustomModels`/`updateCustomModel`, the API + schema/route, `getModelInfo`, and chatCore's targetFormat resolution. (#2905) - **providers/pollinations:** route to `gen.pollinations.ai/v1` instead of the retired `text.pollinations.ai` host, which now returns `404 "legacy API"` for all models. The gen gateway is the current OpenAI-compatible endpoint. (#2987) diff --git a/open-sse/handlers/chatCore.ts b/open-sse/handlers/chatCore.ts index 763e7c4721..a3209c41fc 100644 --- a/open-sse/handlers/chatCore.ts +++ b/open-sse/handlers/chatCore.ts @@ -1495,6 +1495,15 @@ export async function handleChatCore({ ? ((modelInfo as { apiFormat?: string }).apiFormat as string) : undefined : undefined; + // #2905: per-model wire-format override for custom models, injected by + // getModelInfo. Custom models are not in the static registry, so + // getModelTargetFormat() can't see this — use it before the provider default. + const customModelTargetFormat: string | undefined = + modelInfo && typeof modelInfo === "object" && "targetFormat" in modelInfo + ? typeof (modelInfo as { targetFormat?: unknown }).targetFormat === "string" + ? ((modelInfo as { targetFormat?: string }).targetFormat as string) + : undefined + : undefined; const requestedModel = typeof body?.model === "string" && body.model.trim().length > 0 ? body.model : model; const isModelScope = () => isModelScopeProvider(provider, credentials?.providerSpecificData); @@ -1876,7 +1885,9 @@ export async function handleChatCore({ const targetFormat = apiFormat === "responses" ? FORMATS.OPENAI_RESPONSES - : modelTargetFormat || getTargetFormat(provider, credentials?.providerSpecificData); + : modelTargetFormat || + customModelTargetFormat || + getTargetFormat(provider, credentials?.providerSpecificData); const initialProviderRequest = body && typeof body === "object" && !Array.isArray(body) diff --git a/src/app/api/provider-models/route.ts b/src/app/api/provider-models/route.ts index 4956aaf962..46997bc24a 100644 --- a/src/app/api/provider-models/route.ts +++ b/src/app/api/provider-models/route.ts @@ -96,7 +96,8 @@ export async function POST(request) { if (isValidationFailure(validation)) { return Response.json({ error: validation.error }, { status: 400 }); } - const { provider, modelId, modelName, source, apiFormat, supportedEndpoints } = validation.data; + const { provider, modelId, modelName, source, apiFormat, supportedEndpoints, targetFormat } = + validation.data; const model = await addCustomModel( provider, @@ -104,7 +105,8 @@ export async function POST(request) { modelName, source || "manual", apiFormat, - supportedEndpoints + supportedEndpoints, + targetFormat ); return Response.json({ model }); } catch (error) { @@ -150,6 +152,7 @@ export async function PUT(request) { modelName, apiFormat, supportedEndpoints, + targetFormat, normalizeToolCallId, preserveOpenAIDeveloperRole, upstreamHeaders, @@ -161,6 +164,7 @@ export async function PUT(request) { if ("modelName" in raw) updates.modelName = modelName; if ("apiFormat" in raw) updates.apiFormat = apiFormat; if ("supportedEndpoints" in raw) updates.supportedEndpoints = supportedEndpoints; + if ("targetFormat" in raw) updates.targetFormat = targetFormat; if ("normalizeToolCallId" in raw) updates.normalizeToolCallId = normalizeToolCallId; if ("preserveOpenAIDeveloperRole" in raw) updates.preserveOpenAIDeveloperRole = preserveOpenAIDeveloperRole; diff --git a/src/lib/db/models.ts b/src/lib/db/models.ts index 33e6a59c34..f8206565fd 100644 --- a/src/lib/db/models.ts +++ b/src/lib/db/models.ts @@ -355,7 +355,11 @@ export async function addCustomModel( | "audio-transcriptions" | "audio-speech" | "images-generations" = "chat-completions", - supportedEndpoints: string[] = ["chat"] + supportedEndpoints: string[] = ["chat"], + // #2905: optional per-model wire format override (e.g. "claude" for an + // opencode-go custom model). When unset, routing falls back to the provider + // default format. + targetFormat?: string ) { const db = getDbInstance(); const row = db @@ -373,6 +377,7 @@ export async function addCustomModel( source, apiFormat, supportedEndpoints, + ...(targetFormat ? { targetFormat } : {}), }; models.push(model); db.prepare( @@ -398,6 +403,7 @@ export async function replaceCustomModels( outputTokenLimit?: number; description?: string; supportsThinking?: boolean; + targetFormat?: string; }>, { allowEmpty = false }: { allowEmpty?: boolean } = {} ) { @@ -427,6 +433,12 @@ export async function replaceCustomModels( source: m.source || "auto-sync", apiFormat: m.apiFormat || (prev as any)?.apiFormat || "chat-completions", supportedEndpoints: m.supportedEndpoints || (prev as any)?.supportedEndpoints || ["chat"], + // #2905: preserve a per-model targetFormat override (new value wins, else prev). + ...(m.targetFormat + ? { targetFormat: m.targetFormat } + : (prev as any)?.targetFormat + ? { targetFormat: (prev as any).targetFormat } + : {}), // Preserve metadata from provider API (or previous sync) ...(m.inputTokenLimit != null ? { inputTokenLimit: m.inputTokenLimit } @@ -815,6 +827,7 @@ export async function updateCustomModel( ...current, ...(updates.modelName !== undefined ? { name: updates.modelName || current.name } : {}), ...(updates.apiFormat !== undefined ? { apiFormat: updates.apiFormat } : {}), + ...(updates.targetFormat !== undefined ? { targetFormat: updates.targetFormat } : {}), ...(updates.supportedEndpoints !== undefined ? { supportedEndpoints: updates.supportedEndpoints } : {}), diff --git a/src/shared/validation/schemas.ts b/src/shared/validation/schemas.ts index bd0f87f2a5..aa73375d74 100644 --- a/src/shared/validation/schemas.ts +++ b/src/shared/validation/schemas.ts @@ -1048,6 +1048,11 @@ export const providerModelMutationSchema = z.object({ ]) ) .default(["chat"]), + // #2905: optional per-model wire format override for custom models (e.g. a + // custom opencode-go model that must use the Anthropic Messages shape). + targetFormat: z + .enum(["openai", "openai-responses", "claude", "gemini", "gemini-cli", "antigravity"]) + .optional(), normalizeToolCallId: z.boolean().optional(), preserveOpenAIDeveloperRole: z.boolean().nullable().optional(), upstreamHeaders: upstreamHeadersRecordSchema.nullable().optional(), diff --git a/src/sse/services/model.ts b/src/sse/services/model.ts index 25fdfd82b7..0bb3588906 100644 --- a/src/sse/services/model.ts +++ b/src/sse/services/model.ts @@ -48,20 +48,25 @@ export async function resolveModelAlias(alias) { } /** - * Look up the apiFormat for a custom model from the DB. - * Returns "responses" if the model is configured for the Responses API, otherwise undefined. + * Look up custom-model metadata from the DB in a single read: + * - apiFormat: "responses" when the model is configured for the Responses API. + * - targetFormat: the optional per-model wire format override (#2905). */ -async function lookupCustomModelApiFormat( +async function lookupCustomModelMeta( providerId: string, modelId: string -): Promise { +): Promise<{ apiFormat?: string; targetFormat?: string }> { try { const models = await getCustomModels(providerId); - if (!Array.isArray(models)) return undefined; + if (!Array.isArray(models)) return {}; const match = models.find((m: any) => m.id === modelId); - return match?.apiFormat === "responses" ? "responses" : undefined; + if (!match) return {}; + return { + apiFormat: match.apiFormat === "responses" ? "responses" : undefined, + targetFormat: typeof match.targetFormat === "string" ? match.targetFormat : undefined, + }; } catch { - return undefined; + return {}; } } @@ -74,11 +79,15 @@ export async function getModelInfo(modelStr) { const attachCustomApiFormat = async (info: any) => { if (!info?.provider || !info?.model) return info; - const apiFormat = await lookupCustomModelApiFormat(String(info.provider), String(info.model)); - if (apiFormat) { + const { apiFormat, targetFormat } = await lookupCustomModelMeta( + String(info.provider), + String(info.model) + ); + if (apiFormat || targetFormat) { return { ...info, - apiFormat, + ...(apiFormat && { apiFormat }), + ...(targetFormat && { targetFormat }), }; } return info; @@ -98,7 +107,7 @@ export async function getModelInfo(modelStr) { (node) => node.prefix === prefixToCheck || node.id === prefixToCheck ); if (matchedOpenAI) { - const apiFormat = await lookupCustomModelApiFormat( + const { apiFormat, targetFormat } = await lookupCustomModelMeta( matchedOpenAI.id as string, parsed.model as string ); @@ -107,6 +116,7 @@ export async function getModelInfo(modelStr) { model: parsed.model, extendedContext, ...(apiFormat && { apiFormat }), + ...(targetFormat && { targetFormat }), }; } @@ -116,7 +126,7 @@ export async function getModelInfo(modelStr) { (node) => node.prefix === prefixToCheck || node.id === prefixToCheck ); if (matchedAnthropic) { - const apiFormat = await lookupCustomModelApiFormat( + const { apiFormat, targetFormat } = await lookupCustomModelMeta( matchedAnthropic.id as string, parsed.model as string ); @@ -125,6 +135,7 @@ export async function getModelInfo(modelStr) { model: parsed.model, extendedContext, ...(apiFormat && { apiFormat }), + ...(targetFormat && { targetFormat }), }; } diff --git a/tests/unit/custom-model-target-format.test.ts b/tests/unit/custom-model-target-format.test.ts new file mode 100644 index 0000000000..062a97566d --- /dev/null +++ b/tests/unit/custom-model-target-format.test.ts @@ -0,0 +1,83 @@ +/** + * Issue #2905 — custom models (added via the UI) on opencode-go / openai-compatible + * nodes always routed as OpenAI-compatible because there was no way to set a + * per-model `targetFormat`. `addCustomModel` didn't accept it, the API schema + * stripped it, and routing (`getModelTargetFormat`, static-registry-only) never + * saw it — so a custom model that needs the Anthropic Messages shape fell back + * to the provider default ("openai"). + * + * This test verifies the persistence + getModelInfo-injection chain: a custom + * model saved with targetFormat: "claude" is surfaced on the resolved modelInfo + * (which chatCore then uses before the provider default). + */ +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-custom-target-format-")); +process.env.DATA_DIR = TEST_DATA_DIR; + +const core = await import("../../src/lib/db/core.ts"); +const providersDb = await import("../../src/lib/db/providers.ts"); +const modelsDb = await import("../../src/lib/db/models.ts"); +const { getModelInfo } = await import("../../src/sse/services/model.ts"); + +test.before(async () => { + await providersDb.createProviderNode({ + id: "openai-compatible-2905", + type: "openai-compatible", + name: "Gateway 2905", + prefix: "g29", + baseUrl: "https://proxy.example.com", + chatPath: "/v1/chat/completions", + modelsPath: "/v1/models", + }); +}); + +test.after(() => { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); +}); + +test("#2905 addCustomModel persists targetFormat", async () => { + await modelsDb.addCustomModel( + "openai-compatible-2905", + "my-claude-model", + "My Claude Model", + "manual", + "chat-completions", + ["chat"], + "claude" + ); + const models = (await modelsDb.getCustomModels("openai-compatible-2905")) as Array<{ + id: string; + targetFormat?: string; + }>; + const saved = models.find((m) => m.id === "my-claude-model"); + assert.ok(saved, "custom model must be saved"); + assert.equal(saved.targetFormat, "claude", "targetFormat must be persisted"); +}); + +test("#2905 getModelInfo surfaces the custom model targetFormat", async () => { + const info = (await getModelInfo("g29/my-claude-model")) as { + provider?: string; + targetFormat?: string; + }; + assert.equal(info.provider, "openai-compatible-2905", "must resolve to the custom node"); + assert.equal(info.targetFormat, "claude", "getModelInfo must inject the custom targetFormat"); +}); + +test("#2905 a custom model without targetFormat surfaces none (provider default applies)", async () => { + await modelsDb.addCustomModel( + "openai-compatible-2905", + "plain-model", + "Plain Model", + "manual", + "chat-completions", + ["chat"] + ); + const info = (await getModelInfo("g29/plain-model")) as { targetFormat?: string }; + assert.equal(info.targetFormat, undefined, "no targetFormat → falls back to provider default"); +});