mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-06 07:12:12 +03:00
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:
committed by
GitHub
parent
ce2d79765f
commit
fed64abc2e
1
changelog.d/fixes/9193-context-window-suffix.md
Normal file
1
changelog.d/fixes/9193-context-window-suffix.md
Normal file
@@ -0,0 +1 @@
|
||||
- **fix(model):** normalize client context-window suffixes for combo routing. (thanks @b1nhm1nh)
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
@@ -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"] },
|
||||
|
||||
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user