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 <diegosouza.pw@gmail.com>
This commit is contained in:
Arthur Bodera
2026-07-04 06:08:45 +10:00
committed by GitHub
parent 91cda11c83
commit 9c2fbf577c
6 changed files with 237 additions and 13 deletions

View File

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

View File

@@ -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) ??

View File

@@ -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<T extends JsonRecord>(
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<T extends JsonRecord>(
}
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<T extends JsonRecord>(
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;
}

View File

@@ -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<string, number>([
["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<string, ModelSpec> = {
"gpt-5.5": {
maxOutputTokens: 128000,
@@ -476,29 +493,53 @@ export const MODEL_SPECS: Record<string, ModelSpec> = {
__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;
}
/**

View File

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

View File

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