From 9c2fbf577cddc22057f75ddd35bce6206019d9d5 Mon Sep 17 00:00:00 2001 From: Arthur Bodera Date: Sat, 4 Jul 2026 06:08:45 +1000 Subject: [PATCH] fix(models): normalize GLM-5.2 provider context (#6091) Hosted GLM-5.2 provider aliases now respect their declared context caps instead of inheriting the native 1M; native/bare + verified OpenCode/ZenMux routes stay at 1M. Regression guards added. Co-authored-by: diegosouzapw --- CHANGELOG.md | 2 + src/lib/modelCapabilities.ts | 32 ++++++- src/lib/modelMetadataRegistry.ts | 14 ++- src/shared/constants/modelSpecs.ts | 59 ++++++++++-- .../unit/model-capabilities-registry.test.ts | 50 ++++++++++ tests/unit/models-catalog-route.test.ts | 93 +++++++++++++++++++ 6 files changed, 237 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5e295dd84c..2364442213 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,8 @@ ### 🔧 Bug Fixes +- **models (GLM-5.2 context normalization):** stop treating every hosted GLM-5.2 provider alias as the native 1M-context model. Native/bare GLM-5.2 and verified OpenCode / ZenMux routes keep their 1,000,000-token context, while hosted-provider aliases now respect the caps declared in their provider metadata instead of inheriting the native max. Regression guards: `tests/unit/model-capabilities-registry.test.ts`, `tests/unit/models-catalog-route.test.ts`. ([#6091](https://github.com/diegosouzapw/OmniRoute/pull/6091) — thanks @Thinkscape) + - **providers (Gemini Web):** refresh the Gemini Web cookie handling and model catalog so live Gemini Web sessions keep authenticating and routing to current models. Regression guard: `tests/unit/gemini-web.test.ts`. ([#6095](https://github.com/diegosouzapw/OmniRoute/pull/6095) — thanks @backryun) - **providers (Perplexity Web):** refresh the Perplexity Web model catalog to the current set (GPT-5.4/5.5, Claude Sonnet 5.0 / Opus 4.8, GLM-5.2, Kimi K2.6, Nemotron 3 Ultra) and update the internal mode / `model_preference` mappings and thinking variants so requests resolve to live upstream models. Regression guard: `tests/unit/perplexity-web.test.ts`. ([#6106](https://github.com/diegosouzapw/OmniRoute/pull/6106) — thanks @backryun) diff --git a/src/lib/modelCapabilities.ts b/src/lib/modelCapabilities.ts index 906a99ec38..866d073643 100644 --- a/src/lib/modelCapabilities.ts +++ b/src/lib/modelCapabilities.ts @@ -3,7 +3,13 @@ import { PROVIDER_MODELS, } from "@omniroute/open-sse/config/providerModels.ts"; import { parseModel, resolveCanonicalProviderModel } from "@omniroute/open-sse/services/model.ts"; -import { MODEL_SPECS, getModelSpec, type ModelSpec } from "@/shared/constants/modelSpecs"; +import { + MODEL_SPECS, + getAuthoritativeContextWindow, + getAuthoritativeProviderContextWindow, + getModelSpec, + type ModelSpec, +} from "@/shared/constants/modelSpecs"; import { getSyncedCapability } from "@/lib/modelsDevSync"; import { getModelContextOverride } from "@/lib/db/modelContextOverrides"; import { isVisionModelId } from "@/shared/constants/visionModels"; @@ -178,6 +184,22 @@ function getStaticSpec(modelId: string | null, rawModel: string | null): ModelSp return undefined; } +function getAuthoritativeStaticContextWindow( + provider: string | null, + modelId: string | null, + rawModel: string | null +): number | null { + for (const candidate of [modelId, rawModel]) { + const providerContextWindow = getAuthoritativeProviderContextWindow(provider, candidate); + if (typeof providerContextWindow === "number") return providerContextWindow; + } + for (const candidate of [modelId, rawModel]) { + const contextWindow = getAuthoritativeContextWindow(candidate); + if (typeof contextWindow === "number") return contextWindow; + } + return null; +} + function getStaticSpecCanonicalModelId(modelId: string | null, rawModel: string | null) { const candidates = [modelId, rawModel].filter( (candidate): candidate is string => typeof candidate === "string" && candidate.length > 0 @@ -351,7 +373,13 @@ export function getResolvedModelCapabilities(input: CapabilityInput): ResolvedMo : null) ?? (typeof spec?.supportsThinking === "boolean" ? spec.supportsThinking : null)); + const authoritativeContextWindow = getAuthoritativeStaticContextWindow( + resolved.provider, + resolved.model, + resolved.rawModel + ); const contextWindow = + authoritativeContextWindow ?? synced?.limit_context ?? (typeof registryModel?.contextLength === "number" ? registryModel.contextLength : null) ?? spec?.contextWindow ?? @@ -378,7 +406,7 @@ export function getResolvedModelCapabilities(input: CapabilityInput): ResolvedMo structuredOutput: synced?.structured_output ?? null, temperature: synced?.temperature ?? null, contextWindow, - maxInputTokens: synced?.limit_input ?? contextWindow, + maxInputTokens: authoritativeContextWindow ?? synced?.limit_input ?? contextWindow, maxOutputTokens: synced?.limit_output ?? (typeof registryModel?.maxOutputTokens === "number" ? registryModel.maxOutputTokens : null) ?? diff --git a/src/lib/modelMetadataRegistry.ts b/src/lib/modelMetadataRegistry.ts index 8e9ae8133c..c635aaad31 100644 --- a/src/lib/modelMetadataRegistry.ts +++ b/src/lib/modelMetadataRegistry.ts @@ -4,6 +4,8 @@ import { getModelInfo } from "@/sse/services/model"; import { getModelAliases } from "@/lib/db/models"; import { getResolvedModelCapabilities } from "@/lib/modelCapabilities"; import { + getAuthoritativeContextWindow, + getAuthoritativeProviderContextWindow, getModelSpec, resolveModelAlias as resolveStaticModelAlias, } from "@/shared/constants/modelSpecs"; @@ -273,6 +275,11 @@ export function enrichCatalogModelEntry( const nextEntry: JsonRecord = { ...entry }; const existingName = asNonEmptyString(entry.name); + const authoritativeContextWindow = + getAuthoritativeProviderContextWindow(metadata.provider, metadata.model) ?? + getAuthoritativeProviderContextWindow(provider, model) ?? + getAuthoritativeContextWindow(metadata.model) ?? + getAuthoritativeContextWindow(model); const capabilityFields = { ...(typeof metadata.capabilities.vision === "boolean" ? { vision: metadata.capabilities.vision } @@ -309,7 +316,7 @@ export function enrichCatalogModelEntry( } if ( - typeof nextEntry.context_length !== "number" && + (typeof nextEntry.context_length !== "number" || authoritativeContextWindow !== null) && typeof metadata.limits.contextWindow === "number" ) { nextEntry.context_length = metadata.limits.contextWindow; @@ -319,7 +326,10 @@ export function enrichCatalogModelEntry( nextEntry.max_output_tokens = metadata.limits.maxOutputTokens; } - if (typeof metadata.limits.maxInputTokens === "number") { + if ( + typeof metadata.limits.maxInputTokens === "number" && + (typeof nextEntry.max_input_tokens !== "number" || authoritativeContextWindow !== null) + ) { nextEntry.max_input_tokens = metadata.limits.maxInputTokens; } diff --git a/src/shared/constants/modelSpecs.ts b/src/shared/constants/modelSpecs.ts index c0a6ff402c..8be768089c 100644 --- a/src/shared/constants/modelSpecs.ts +++ b/src/shared/constants/modelSpecs.ts @@ -48,6 +48,23 @@ const BEDROCK_CLAUDE_ALIASES = (...modelIds: string[]) => [ ), ]; +// Provider discovery/sync sources can under-report GLM-5.2 IDs as 128K. +// Keep native/bare Z.AI GLM-5.2 context authoritative, but do not blindly apply +// it to every provider-wrapped alias: hosted providers can and do cap lower. +const AUTHORITATIVE_CONTEXT_WINDOW_MODEL_IDS = new Set(["glm-5.2", "glm-5.2-high", "glm-5.2-max"]); +const AUTHORITATIVE_PROVIDER_CONTEXT_WINDOWS = new Map([ + ["cloudflare-ai/@cf/zai-org/glm-5.2", 262144], + // Hugging Face Router has 1M-capable backends, but bare routing can select + // lower-context providers (notably Together at 262K), so advertise a safe floor + // unless the caller can pin a 1M-capable backend. + ["huggingface/zai-org/glm-5.2", 262144], + ["opencode/glm-5.2", 1000000], + ["opencode-zen/glm-5.2", 1000000], + ["opencode-go/glm-5.2", 1000000], + ["zenmux/z-ai/glm-5.2", 1000000], + ["zenmux/z-ai/glm-5.2-free", 1000000], +]); + export const MODEL_SPECS: Record = { "gpt-5.5": { maxOutputTokens: 128000, @@ -476,29 +493,53 @@ export const MODEL_SPECS: Record = { __default__: {}, }; -export function getModelSpec(modelId: string): ModelSpec | undefined { - if (MODEL_SPECS[modelId]) return MODEL_SPECS[modelId]; +export function getCanonicalModelSpecId(modelId: string): string | null { + if (MODEL_SPECS[modelId]) return modelId; // Case-insensitive lookups: upstream model ids are often capitalized // (e.g. "MiniMax-M2.7") while specs/aliases use lowercase ids (#3141). const lower = modelId.toLowerCase(); // Exact match (case-insensitive) - for (const [canonical, spec] of Object.entries(MODEL_SPECS)) { - if (canonical.toLowerCase() === lower) return spec; + for (const canonical of Object.keys(MODEL_SPECS)) { + if (canonical.toLowerCase() === lower) return canonical; } // Buscas por alias (case-insensitive) - for (const [, spec] of Object.entries(MODEL_SPECS)) { - if (spec.aliases?.some((alias) => alias.toLowerCase() === lower)) return spec; + for (const [canonical, spec] of Object.entries(MODEL_SPECS)) { + if (spec.aliases?.some((alias) => alias.toLowerCase() === lower)) return canonical; } // Prefix matching (case-insensitive) - for (const [key, spec] of Object.entries(MODEL_SPECS)) { - if (key !== "__default__" && lower.startsWith(key.toLowerCase())) return spec; + for (const key of Object.keys(MODEL_SPECS)) { + if (key !== "__default__" && lower.startsWith(key.toLowerCase())) return key; } - return undefined; + return null; +} + +export function getModelSpec(modelId: string): ModelSpec | undefined { + const canonical = getCanonicalModelSpecId(modelId); + return canonical ? MODEL_SPECS[canonical] : undefined; +} + +export function getAuthoritativeContextWindow(modelId: string | null | undefined): number | null { + if (typeof modelId !== "string" || modelId.length === 0) return null; + const normalized = modelId.toLowerCase(); + for (const canonical of AUTHORITATIVE_CONTEXT_WINDOW_MODEL_IDS) { + if (canonical.toLowerCase() === normalized) + return MODEL_SPECS[canonical]?.contextWindow ?? null; + } + return null; +} + +export function getAuthoritativeProviderContextWindow( + provider: string | null | undefined, + modelId: string | null | undefined +): number | null { + if (typeof provider !== "string" || typeof modelId !== "string") return null; + const key = `${provider}/${modelId}`.toLowerCase(); + return AUTHORITATIVE_PROVIDER_CONTEXT_WINDOWS.get(key) ?? null; } /** diff --git a/tests/unit/model-capabilities-registry.test.ts b/tests/unit/model-capabilities-registry.test.ts index 53f0364c09..b6582a1ca6 100644 --- a/tests/unit/model-capabilities-registry.test.ts +++ b/tests/unit/model-capabilities-registry.test.ts @@ -207,3 +207,53 @@ test("Kimi K2.7 Code resolves full capabilities instead of the degraded import d assert.notEqual(ollama.contextWindow, 128000); assert.notEqual(ollama.maxOutputTokens, 8192); }); + +test("GLM-5.2 context limits respect provider-hosted caps", () => { + modelsDevSync.saveModelsDevCapabilities({ + huggingface: { + "zai-org/GLM-5.2": buildCapability({ + limit_context: 128000, + limit_input: 128000, + limit_output: 128000, + }), + }, + "cloudflare-ai": { + "@cf/zai-org/glm-5.2": buildCapability({ + limit_context: 128000, + limit_input: 128000, + limit_output: 128000, + }), + }, + zenmux: { + "z-ai/glm-5.2": buildCapability({ + limit_context: 128000, + limit_input: 128000, + limit_output: 128000, + }), + }, + }); + + for (const modelId of ["glm-5.2", "opencode-go/glm-5.2", "opencode/glm-5.2", "oc/glm-5.2"]) { + const capabilities = modelCapabilities.getResolvedModelCapabilities(modelId); + assert.equal(capabilities.contextWindow, 1000000, modelId); + assert.equal(capabilities.maxInputTokens, 1000000, modelId); + } + + for (const modelId of ["zenmux/z-ai/glm-5.2", "zenmux/z-ai/glm-5.2-free"]) { + const capabilities = modelCapabilities.getResolvedModelCapabilities(modelId); + assert.equal(capabilities.contextWindow, 1000000, modelId); + assert.equal(capabilities.maxInputTokens, 1000000, modelId); + } + + for (const modelId of ["cloudflare-ai/@cf/zai-org/glm-5.2", "cf/@cf/zai-org/glm-5.2"]) { + const capabilities = modelCapabilities.getResolvedModelCapabilities(modelId); + assert.equal(capabilities.contextWindow, 262144, modelId); + assert.equal(capabilities.maxInputTokens, 262144, modelId); + } + + for (const modelId of ["huggingface/zai-org/GLM-5.2", "hf/zai-org/GLM-5.2"]) { + const capabilities = modelCapabilities.getResolvedModelCapabilities(modelId); + assert.equal(capabilities.contextWindow, 262144, modelId); + assert.equal(capabilities.maxInputTokens, 262144, modelId); + } +}); diff --git a/tests/unit/models-catalog-route.test.ts b/tests/unit/models-catalog-route.test.ts index fdb855c2dc..c6d8a1c9af 100644 --- a/tests/unit/models-catalog-route.test.ts +++ b/tests/unit/models-catalog-route.test.ts @@ -843,6 +843,99 @@ test("v1 models catalog includes synced non-Gemini provider models from discover assert.equal(syncedModel.context_length, 262144); }); +test("v1 models catalog advertises GLM-5.2 provider aliases with hosted context limits", async () => { + const hfConnection = await seedConnection("huggingface", { + name: "huggingface-glm52", + apiKey: "hf-key", + }); + const cfConnection = await seedConnection("cloudflare-ai", { + name: "cloudflare-glm52", + apiKey: "cf-key", + }); + const zenmuxConnection = await seedConnection("zenmux", { + name: "zenmux-glm52", + apiKey: "zen-key", + }); + await seedConnection("opencode-go", { + name: "opencode-go-glm52", + apiKey: "go-key", + }); + + await modelsDb.replaceSyncedAvailableModelsForConnection( + "huggingface", + (hfConnection as any).id, + [ + { + id: "zai-org/GLM-5.2", + name: "GLM 5.2", + source: "imported", + supportedEndpoints: ["chat"], + inputTokenLimit: 128000, + outputTokenLimit: 128000, + }, + ] + ); + await modelsDb.replaceSyncedAvailableModelsForConnection( + "cloudflare-ai", + (cfConnection as any).id, + [ + { + id: "@cf/zai-org/glm-5.2", + name: "GLM 5.2", + source: "imported", + supportedEndpoints: ["chat"], + inputTokenLimit: 128000, + outputTokenLimit: 128000, + }, + ] + ); + await modelsDb.replaceSyncedAvailableModelsForConnection("zenmux", (zenmuxConnection as any).id, [ + { + id: "z-ai/glm-5.2", + name: "GLM 5.2", + source: "imported", + supportedEndpoints: ["chat"], + inputTokenLimit: 128000, + outputTokenLimit: 128000, + }, + ]); + + try { + modelsDevSync.saveModelsDevCapabilities({ + huggingface: { + "zai-org/GLM-5.2": capability({ limit_context: 128000, limit_input: 128000 }), + }, + "cloudflare-ai": { + "@cf/zai-org/glm-5.2": capability({ limit_context: 128000, limit_input: 128000 }), + }, + zenmux: { + "z-ai/glm-5.2": capability({ limit_context: 128000, limit_input: 128000 }), + }, + }); + + const response = await v1ModelsCatalog.getUnifiedModelsResponse( + new Request("http://localhost/api/v1/models") + ); + const body = (await response.json()) as any; + const byId = new Map(body.data.map((item) => [item.id, item])); + + for (const [id, expectedContext] of [ + ["huggingface/zai-org/GLM-5.2", 262144], + ["cloudflare-ai/@cf/zai-org/glm-5.2", 262144], + ["opencode-go/glm-5.2", 1000000], + ["zenmux/z-ai/glm-5.2", 1000000], + ] as const) { + const model = byId.get(id) as any; + assert.ok(model, `expected ${id} in catalog`); + assert.equal(model.context_length, expectedContext, id); + assert.equal(model.max_input_tokens, expectedContext, id); + assert.notEqual(model.context_length, 128000, id); + } + } finally { + modelsDevSync.saveModelsDevCapabilities({}); + } +}); + test("v1 models catalog includes media, moderation, rerank, video, and music models for active providers", async () => { await seedConnection("openai", { name: "openai-media" }); await seedConnection("cohere", { name: "cohere-rerank" });