fix(model): normalize client context-window suffixes (#9193)

Validated in local merge-train (devbox-vm-06-dev002) @ combined-tip (FAST gates — only pre-existing audit.test.ts flake).
This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-08-05 22:39:52 -03:00
committed by GitHub
parent ce2d79765f
commit fed64abc2e
9 changed files with 132 additions and 19 deletions

View File

@@ -0,0 +1 @@
- **fix(model):** normalize client context-window suffixes for combo routing. (thanks @b1nhm1nh)

View File

@@ -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);
}

View File

@@ -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<string, string> = {};
@@ -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

View File

@@ -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/<provider>/<model>`, `claude/combo/<name>`):
// resolve back to the real id before any combo lookup / resolveModelOrError()

View File

@@ -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;
}

View File

@@ -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;

View File

@@ -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");
});

View File

@@ -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"] },

View File

@@ -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");