diff --git a/changelog.d/fixes/9193-context-window-suffix.md b/changelog.d/fixes/9193-context-window-suffix.md new file mode 100644 index 0000000000..3cd229937d --- /dev/null +++ b/changelog.d/fixes/9193-context-window-suffix.md @@ -0,0 +1 @@ +- **fix(model):** normalize client context-window suffixes for combo routing. (thanks @b1nhm1nh) diff --git a/open-sse/services/combo/comboStructure.ts b/open-sse/services/combo/comboStructure.ts index 4bb3aabc33..554e162985 100644 --- a/open-sse/services/combo/comboStructure.ts +++ b/open-sse/services/combo/comboStructure.ts @@ -13,13 +13,10 @@ import { getModelContextLimit } from "../../../src/lib/modelCapabilities"; import { getComboModelString, normalizeComboStep } from "../../../src/lib/combos/steps.ts"; -import { - getProviderByAlias, - getProviderById, -} from "../../../src/shared/constants/providers.ts"; +import { getProviderByAlias, getProviderById } from "../../../src/shared/constants/providers.ts"; import { estimateTokens } from "../contextManager.ts"; import { getResolvedModelCapabilities } from "../modelCapabilities.ts"; -import { parseModel } from "../model.ts"; +import { parseModel, stripContextWindowSuffix } from "../model.ts"; import { dedupeTargetsByExecutionKey, isRecord } from "./comboData.ts"; import { getTargetProvider, MAX_COMBO_DEPTH } from "./comboPredicates.ts"; import { evaluateContextLimit } from "./contextOverrideGate.ts"; @@ -323,7 +320,8 @@ export function getComboModelsFromData( modelStr: string, combosData: ComboCollectionLike ): string[] | null { - const combo = getComboFromData(modelStr, combosData); + const baseModelStr = stripContextWindowSuffix(modelStr); + const combo = getComboFromData(baseModelStr || modelStr, combosData); if (!combo) return null; return combo.models.map((m) => normalizeModelEntry(m).model); } diff --git a/open-sse/services/model.ts b/open-sse/services/model.ts index 3249c72b41..9bfc53a451 100644 --- a/open-sse/services/model.ts +++ b/open-sse/services/model.ts @@ -16,6 +16,16 @@ type ResolvedModelTarget = { model: string | null; }; +// Client context-window tags are routing hints, not part of provider model IDs. +const CONTEXT_WINDOW_SUFFIX_RE = /\[(\d+)([kKmM])?\]\s*$/; + +export function stripContextWindowSuffix( + modelStr: string | null | undefined +): string | null | undefined { + if (typeof modelStr !== "string" || !modelStr) return modelStr; + return modelStr.replace(CONTEXT_WINDOW_SUFFIX_RE, "").trimEnd(); +} + // Derive alias→provider mapping from the single source of truth (PROVIDER_ID_TO_ALIAS) // This prevents the two maps from drifting out of sync const ALIAS_TO_PROVIDER_ID: Record = {}; @@ -428,12 +438,12 @@ export function parseModel(modelStr: string | null | undefined): ParsedModel { }; } - // Extract [1m] suffix before parsing provider/model + // Extract the legacy [1m] marker while stripping all client context tags. let extendedContext = false; - let cleanStr = modelStr; - if (cleanStr.endsWith("[1m]")) { + const cleanStripped = stripContextWindowSuffix(modelStr) as string; + let cleanStr = cleanStripped; + if (/\[1m\]\s*$/i.test(modelStr)) { extendedContext = true; - cleanStr = cleanStr.slice(0, -4); } cleanStr = cleanStr.trim(); @@ -665,7 +675,9 @@ async function resolveModelByProviderInference(modelId: string, extendedContext: // Canonicalize candidates (deduplicate alias providers pointing to the same provider ID) const canonicalCandidates = Array.from( - new Set(candidatesToUse.map((p) => resolveProviderAlias(p)).filter((p): p is string => p !== null)) + new Set( + candidatesToUse.map((p) => resolveProviderAlias(p)).filter((p): p is string => p !== null) + ) ); // Filter candidates by active connections configured in the database diff --git a/src/sse/handlers/chat.ts b/src/sse/handlers/chat.ts index 8a03241b6c..eb8f952596 100644 --- a/src/sse/handlers/chat.ts +++ b/src/sse/handlers/chat.ts @@ -20,7 +20,8 @@ import { recordModelLockoutFailure, isDailyQuotaExhausted, } from "@omniroute/open-sse/services/accountFallback.ts"; -import { getModelInfo, getComboForModel } from "../services/model"; +import { getCombo, getComboForModel, getModelInfo } from "../services/model"; +import { stripContextWindowSuffix } from "@omniroute/open-sse/services/model.ts"; import { resolveBareModelToConnectionDefault } from "@omniroute/open-sse/services/model.ts"; import { errorResponse } from "@omniroute/open-sse/utils/error.ts"; import { getImageModelEntry } from "@omniroute/open-sse/config/imageRegistry.ts"; @@ -391,6 +392,17 @@ async function handleChatImplementation( // resolveRoutingModel). The resolved model still passes through // enforceApiKeyPolicy below, so it cannot bypass per-key allowlists. let modelStr = resolveRoutingModel(request, body); + if (typeof modelStr === "string") { + // Preserve literal combo names such as "Claude [1m]". Context tags are + // stripped only when the exact request does not identify a combo. + const exactCombo = await getCombo(modelStr); + if (!exactCombo) { + modelStr = stripContextWindowSuffix(modelStr) || modelStr; + if (body?.model !== modelStr) { + body = { ...body, model: modelStr }; + } + } + } // cc discovery alias (`claude//`, `claude/combo/`): // resolve back to the real id before any combo lookup / resolveModelOrError() diff --git a/src/sse/services/model.ts b/src/sse/services/model.ts index 590d0e9257..a1e02a4a44 100644 --- a/src/sse/services/model.ts +++ b/src/sse/services/model.ts @@ -13,10 +13,11 @@ import { parseModel, getModelInfoCore, splitSyncedEffortSuffix, + stripContextWindowSuffix, } from "@omniroute/open-sse/services/model.ts"; import { REGISTRY } from "@omniroute/open-sse/config/providerRegistry.ts"; -export { parseModel }; +export { parseModel, stripContextWindowSuffix }; /** * Reserved provider prefixes — built-in provider ids + aliases. User-defined @@ -140,7 +141,10 @@ function resolveSyncedModelIdAndEffort( } if (findSyncedModelMeta(syncedModels, modelId)) return { modelId, effort: null }; - for (const candidate of syncedModels as Array<{ id?: unknown; supportedThinkingEfforts?: unknown }>) { + for (const candidate of syncedModels as Array<{ + id?: unknown; + supportedThinkingEfforts?: unknown; + }>) { if (typeof candidate?.id !== "string" || !Array.isArray(candidate.supportedThinkingEfforts)) { continue; } @@ -396,13 +400,21 @@ export async function getCombo(modelStr) { */ export async function getComboForModel(modelStr) { // 1. Existing behavior — exact combo name match - const combo = await getCombo(modelStr); + let combo = await getCombo(modelStr); if (combo) return combo; + // Client context tags are ignored only after exact lookup, preserving literal + // combo names such as "Claude [1m]" while allowing "Claude[500k]" to use "Claude". + const baseModelStr = stripContextWindowSuffix(modelStr); + if (baseModelStr && baseModelStr !== modelStr) { + combo = await getCombo(baseModelStr); + if (combo) return combo; + } + // 2. NEW — check model-combo mappings table (pattern match) try { const { resolveComboForModel } = await import("@/lib/localDb"); - const mapped = await resolveComboForModel(modelStr); + const mapped = await resolveComboForModel(baseModelStr || modelStr); if (mapped && (mapped as any).models?.length > 0) { return mapped; } diff --git a/tests/unit/chat-route-edge-cases.test.ts b/tests/unit/chat-route-edge-cases.test.ts index def316ad9f..aea04b4b01 100644 --- a/tests/unit/chat-route-edge-cases.test.ts +++ b/tests/unit/chat-route-edge-cases.test.ts @@ -15,6 +15,7 @@ const { settingsDb, idempotencyLayerModule, semanticCacheModule, + combosDb, } = harness; const { getBackgroundDegradationConfig } = @@ -66,6 +67,61 @@ test("handleChat resolves model alias before routing", async () => { assert.equal(seenModels[0], "gpt-4.1", "Model alias should be resolved to gpt-4.1"); }); +test("handleChat strips client context-window tags before combo routing and dispatch", async () => { + await seedConnection("openai", { apiKey: "sk-openai-context-tag" }); + await combosDb.createCombo({ + name: "context-tag-combo", + models: ["openai/gpt-4.1"], + }); + + const seenModels = []; + globalThis.fetch = async (_url, init = {}) => { + const body = JSON.parse(String(init.body)); + seenModels.push(body.model); + return buildOpenAIResponse("Context tag response"); + }; + + const response = await handleChat( + buildRequest({ + body: { + model: "context-tag-combo[500k]", + stream: false, + messages: [{ role: "user", content: "Use the context-tagged combo" }], + }, + }) + ); + + assert.equal(response.status, 200); + assert.deepEqual(seenModels, ["gpt-4.1"]); +}); + +test("handleChat preserves a literal context-tagged combo name", async () => { + await seedConnection("openai", { apiKey: "sk-openai-literal-context-tag" }); + await combosDb.createCombo({ + name: "literal [1m]", + models: ["openai/gpt-4.1"], + }); + + const seenModels = []; + globalThis.fetch = async (_url, init = {}) => { + seenModels.push(JSON.parse(String(init.body)).model); + return buildOpenAIResponse("Literal combo response"); + }; + + const response = await handleChat( + buildRequest({ + body: { + model: "literal [1m]", + stream: false, + messages: [{ role: "user", content: "Use the literal combo" }], + }, + }) + ); + + assert.equal(response.status, 200); + assert.deepEqual(seenModels, ["gpt-4.1"]); +}); + test("Test 3: handleChat returns cached response directly for Semantic Cache hits", async () => { await seedConnection("openai", { apiKey: "sk-openai-semantic" }); let fetchCount = 0; diff --git a/tests/unit/combo-bracket-names.test.ts b/tests/unit/combo-bracket-names.test.ts index d427ae919e..32844173cc 100644 --- a/tests/unit/combo-bracket-names.test.ts +++ b/tests/unit/combo-bracket-names.test.ts @@ -65,7 +65,7 @@ test("getComboForModel treats an exact bracketed name as a combo before model su assert.equal(parsedAsModel.model, "Claude"); }); -test("getComboForModel does not strip bracket suffix when no exact bracketed combo exists", async () => { +test("getComboForModel falls back to the base combo when no exact context-tagged combo exists", async () => { await combosDb.createCombo({ name: "Claude", models: [{ provider: "claude", model: "claude-sonnet-4-6" }], @@ -74,7 +74,7 @@ test("getComboForModel does not strip bracket suffix when no exact bracketed com const resolved = await sseModelService.getComboForModel("Claude [1m]"); const parsedAsModel = sseModelService.parseModel("Claude [1m]"); - assert.equal(resolved, null); + assert.equal(resolved?.name, "Claude"); assert.equal(parsedAsModel.extendedContext, true); assert.equal(parsedAsModel.model, "Claude"); }); diff --git a/tests/unit/combo-routing-engine.test.ts b/tests/unit/combo-routing-engine.test.ts index f84230ebf1..6ac942da8f 100644 --- a/tests/unit/combo-routing-engine.test.ts +++ b/tests/unit/combo-routing-engine.test.ts @@ -187,6 +187,14 @@ test("getComboFromData and getComboModelsFromData resolve combos from array and assert.deepEqual(models, ["openai/gpt-4o-mini", "claude/sonnet"]); }); +test("getComboModelsFromData strips context-window tags before matching a combo", () => { + const combos = [{ name: "alpha", models: ["openai/gpt-4o-mini"] }]; + + assert.deepEqual(getComboModelsFromData("alpha[500k]", combos), ["openai/gpt-4o-mini"]); + assert.deepEqual(getComboModelsFromData("alpha[1M]", combos), ["openai/gpt-4o-mini"]); + assert.equal(getComboModelsFromData("alpha[beta]", combos), null); +}); + test("validateComboDAG rejects circular references and resolveNestedComboModels expands nested combos", () => { const combos = [ { name: "root", models: ["child-a", "openai/gpt-4o-mini"] }, diff --git a/tests/unit/model-parse.test.ts b/tests/unit/model-parse.test.ts index 7f05dbf2d5..1ac2ca1088 100644 --- a/tests/unit/model-parse.test.ts +++ b/tests/unit/model-parse.test.ts @@ -1,6 +1,14 @@ import { test } from "node:test"; import assert from "node:assert/strict"; -import { normalizeCrossProxyModelId, parseModel } from "../../open-sse/services/model.ts"; +import { + normalizeCrossProxyModelId, + parseModel, + stripContextWindowSuffix, +} from "../../open-sse/services/model.ts"; + +test("context-window suffix helper strips client tags without changing the base model", () => { + assert.strictEqual(stripContextWindowSuffix("my-glm52[500k]"), "my-glm52"); +}); // [1m] extended context suffix — PR #311 (DavyMassoneto) test("[1m] suffix: strips suffix and sets extendedContext=true", () => { @@ -9,6 +17,12 @@ test("[1m] suffix: strips suffix and sets extendedContext=true", () => { assert.strictEqual(result.extendedContext, true); }); +test("[1M] suffix: preserves the extended-context flag case-insensitively", () => { + const result = parseModel("claude-sonnet-4-6[1M]"); + assert.strictEqual(result.model, "claude-sonnet-4-6"); + assert.strictEqual(result.extendedContext, true); +}); + test("[1m] suffix: normal model has extendedContext=false", () => { const result = parseModel("claude-sonnet-4-6"); assert.strictEqual(result.model, "claude-sonnet-4-6");