Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.d/fixes/9193-context-window-suffix.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- **fix(model):** normalize client context-window suffixes for combo routing. (thanks @b1nhm1nh)
10 changes: 4 additions & 6 deletions open-sse/services/combo/comboStructure.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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);
}
Expand Down
22 changes: 17 additions & 5 deletions open-sse/services/model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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> = {};
Expand Down Expand Up @@ -391,12 +401,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();

Expand Down Expand Up @@ -600,7 +610,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
Expand Down
14 changes: 13 additions & 1 deletion src/sse/handlers/chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,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";
Expand Down Expand Up @@ -394,6 +395,17 @@ export async function handleChat(
// 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()
Expand Down
20 changes: 16 additions & 4 deletions src/sse/services/model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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;
}
Expand Down
56 changes: 56 additions & 0 deletions tests/unit/chat-route-edge-cases.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ const {
settingsDb,
idempotencyLayerModule,
semanticCacheModule,
combosDb,
} = harness;

const { getBackgroundDegradationConfig } =
Expand Down Expand Up @@ -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;
Expand Down
4 changes: 2 additions & 2 deletions tests/unit/combo-bracket-names.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" }],
Expand All @@ -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");
});
8 changes: 8 additions & 0 deletions tests/unit/combo-routing-engine.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"] },
Expand Down
16 changes: 15 additions & 1 deletion tests/unit/model-parse.test.ts
Original file line number Diff line number Diff line change
@@ -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", () => {
Expand All @@ -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");
Expand Down
Loading