From 18f177db6b3e160e08008cd3e68c60421eb32bcf Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza Date: Sat, 11 Jul 2026 10:01:43 -0300 Subject: [PATCH] fix(providers): strip redundant node prefix on connId-addressed custom models (#6772) --- .../fixes/6772-6772-connid-model-400.md | 1 + src/sse/services/model.ts | 32 ++++++- ...l-connid-prefix-normalization-6772.test.ts | 86 +++++++++++++++++++ 3 files changed, 115 insertions(+), 4 deletions(-) create mode 100644 changelog.d/fixes/6772-6772-connid-model-400.md create mode 100644 tests/unit/model-connid-prefix-normalization-6772.test.ts diff --git a/changelog.d/fixes/6772-6772-connid-model-400.md b/changelog.d/fixes/6772-6772-connid-model-400.md new file mode 100644 index 0000000000..795c840317 --- /dev/null +++ b/changelog.d/fixes/6772-6772-connid-model-400.md @@ -0,0 +1 @@ +- fix(providers): strip redundant node prefix when resolving custom OpenAI/Anthropic-compatible connections by raw connection id, preventing double-namespaced model ids from 400ing upstream (#6772) diff --git a/src/sse/services/model.ts b/src/sse/services/model.ts index a587d6a556..9c8bbfe134 100644 --- a/src/sse/services/model.ts +++ b/src/sse/services/model.ts @@ -85,6 +85,22 @@ async function lookupCustomModelMeta( } } +/** + * When a custom provider node is matched by its raw internal `node.id` (e.g. a combo + * step addressing `/...` — see #2778), `parsed.model` was never split on the + * node's own `prefix`, unlike the alias-addressing path where `parseModel` already + * strips it. If the caller naively concatenates `owned_by` (the node's prefix, as + * listed by /api/models) with the raw model id, the resulting model string carries a + * redundant leading `${node.prefix}/` segment that the upstream provider does not + * recognize, causing a 400. Strip it so `//` normalizes to + * the same `` the bare alias form resolves to (#6772). + */ +function stripRedundantNodePrefix(model: string, nodePrefix: unknown): string { + if (typeof nodePrefix !== "string" || !nodePrefix) return model; + const redundant = `${nodePrefix}/`; + return model.startsWith(redundant) ? model.slice(redundant.length) : model; +} + /** * Get full model info (parse or resolve) */ @@ -133,13 +149,17 @@ export async function getModelInfo(modelStr) { (node) => node.prefix === prefixToCheck || node.id === prefixToCheck ); if (matchedOpenAI) { + const normalizedModel = stripRedundantNodePrefix( + parsed.model as string, + matchedOpenAI.prefix + ); const { apiFormat, targetFormat } = await lookupCustomModelMeta( matchedOpenAI.id as string, - parsed.model as string + normalizedModel ); return { provider: matchedOpenAI.id, - model: parsed.model, + model: normalizedModel, extendedContext, ...(apiFormat && { apiFormat }), ...(targetFormat && { targetFormat }), @@ -152,13 +172,17 @@ export async function getModelInfo(modelStr) { (node) => node.prefix === prefixToCheck || node.id === prefixToCheck ); if (matchedAnthropic) { + const normalizedModel = stripRedundantNodePrefix( + parsed.model as string, + matchedAnthropic.prefix + ); const { apiFormat, targetFormat } = await lookupCustomModelMeta( matchedAnthropic.id as string, - parsed.model as string + normalizedModel ); return { provider: matchedAnthropic.id, - model: parsed.model, + model: normalizedModel, extendedContext, ...(apiFormat && { apiFormat }), ...(targetFormat && { targetFormat }), diff --git a/tests/unit/model-connid-prefix-normalization-6772.test.ts b/tests/unit/model-connid-prefix-normalization-6772.test.ts new file mode 100644 index 0000000000..de9b1c5107 --- /dev/null +++ b/tests/unit/model-connid-prefix-normalization-6772.test.ts @@ -0,0 +1,86 @@ +/** + * PROBE for issue #6772 — custom OpenAI-compat / 400s when the + * connection has a user-defined `prefix` and the listed model id (from /api/models) + * already carries that prefix baked in ("fta/vova/gpt-5.5"). + * + * Root cause hypothesis: in src/sse/services/model.ts getModelInfo(), when a client + * addresses the connection by its raw internal node id (`/...`), the matching + * branch finds the node via `node.id === prefixToCheck` but returns `parsed.model` + * UNSTRIPPED of the node's own `prefix` — so `//` resolves + * to `{ provider: connId, model: "/" }` instead of stripping the + * redundant prefix down to the actual registered custom model id ``. + */ +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-probe-6772-")); +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"); + +const CONN_ID = "openai-compatible-chat-97b0e595-probe6772"; +const PREFIX = "fta"; +const RAW_MODEL_ID = "vova/gpt-5.5"; // upstream's own model id already has a slash + +test.before(async () => { + await providersDb.createProviderNode({ + id: CONN_ID, + type: "openai-compatible", + name: "freetheai (probe)", + prefix: PREFIX, + baseUrl: "https://proxy.example.com", + chatPath: "/v1/chat/completions", + modelsPath: "/v1/models", + }); + await modelsDb.addCustomModel( + CONN_ID, + RAW_MODEL_ID, + "vova gpt-5.5", + "manual", + "chat-completions", + ["chat"] + ); +}); + +test.after(() => { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); +}); + +test("#6772 baseline: bare alias form `fta/vova/gpt-5.5` resolves to the raw model id", async () => { + const info = (await getModelInfo(`${PREFIX}/${RAW_MODEL_ID}`)) as { + provider?: string; + model?: string; + }; + assert.equal(info.provider, CONN_ID, "must resolve to the custom node via its prefix"); + assert.equal(info.model, RAW_MODEL_ID, "model must be the raw registered custom model id"); +}); + +test("#6772 baseline: `/vova/gpt-5.5` (no namespace) resolves to the raw model id", async () => { + const info = (await getModelInfo(`${CONN_ID}/${RAW_MODEL_ID}`)) as { + provider?: string; + model?: string; + }; + assert.equal(info.provider, CONN_ID, "must resolve to the custom node via its internal id"); + assert.equal(info.model, RAW_MODEL_ID, "model must be the raw registered custom model id"); +}); + +test("#6772 RED: `//` (naive owned_by+id concat) must normalize to the raw model id, not double-prefix", async () => { + const info = (await getModelInfo(`${CONN_ID}/${PREFIX}/${RAW_MODEL_ID}`)) as { + provider?: string; + model?: string; + }; + assert.equal(info.provider, CONN_ID, "must resolve to the custom node via its internal id"); + assert.equal( + info.model, + RAW_MODEL_ID, + `model must strip the node's own prefix "${PREFIX}/" so it matches the registered custom model id ` + + `"${RAW_MODEL_ID}" — got "${info.model}" instead (double-namespaced, will 400 upstream)` + ); +});