From df42fe86836149d2cc484b2498c8ec182f3f10e2 Mon Sep 17 00:00:00 2001 From: musistudio Date: Wed, 5 Aug 2026 20:06:58 +0800 Subject: [PATCH] Propagate provider context for Fusion model discovery --- .../src/agents/claude-app/gateway-routes.ts | 40 +++++++++++- .../src/gateway/features/model-discovery.ts | 63 ++++++++++++++++--- .../agents/claude-app-gateway-models.test.mjs | 55 ++++++++++++++++ 3 files changed, 148 insertions(+), 10 deletions(-) diff --git a/packages/core/src/agents/claude-app/gateway-routes.ts b/packages/core/src/agents/claude-app/gateway-routes.ts index 000c5839..290fd864 100644 --- a/packages/core/src/agents/claude-app/gateway-routes.ts +++ b/packages/core/src/agents/claude-app/gateway-routes.ts @@ -1,6 +1,7 @@ import type { AppConfig } from "@ccr/core/contracts/app"; import { availableGatewayModelIds, normalizeProfileScopeValue } from "@ccr/core/contracts/app"; import { modelRegistryForConfig } from "@ccr/core/routing/model-registry"; +import { resolveUsageModelAttribution } from "@ccr/core/usage/model-attribution"; export const CLAUDE_APP_ONE_MILLION_CONTEXT_SUFFIX = "[1m]"; const CLAUDE_APP_ENCODED_ROUTE_PREFIX = "anthropic/claude-ccr-h"; @@ -165,14 +166,21 @@ function claudeAppGatewaySupportsOneMillionContext( } const providerOverride = claudeAppGatewayProviderSupportsOneMillionContext(baseModel, config); - return providerOverride ?? Boolean(options.supportsOneMillionContext?.(baseModel)); + if (providerOverride !== undefined) { + return providerOverride; + } + const physicalSelector = claudeAppGatewayPhysicalModelSelector(baseModel, config); + return Boolean( + options.supportsOneMillionContext?.(physicalSelector ?? baseModel) || + (physicalSelector && physicalSelector !== baseModel && options.supportsOneMillionContext?.(baseModel)) + ); } function claudeAppGatewayProviderSupportsOneMillionContext( model: string, config: Pick ): boolean | undefined { - const resolved = modelRegistryForConfig(config).resolveProviderModel(model); + const resolved = claudeAppGatewayResolvedProviderModel(model, config); if (!resolved) { return undefined; } @@ -188,6 +196,34 @@ function claudeAppGatewayProviderSupportsOneMillionContext( return Math.floor((contextWindow * effectivePercent) / 100) >= 1_000_000; } +function claudeAppGatewayPhysicalModelSelector( + model: string, + config: Pick +): string | undefined { + const resolved = claudeAppGatewayResolvedProviderModel(model, config); + return resolved ? `${resolved.provider.name}/${resolved.model}` : undefined; +} + +function claudeAppGatewayResolvedProviderModel( + model: string, + config: Pick +) { + const registry = modelRegistryForConfig(config); + const direct = registry.resolveProviderModel(model); + if (direct) { + return direct; + } + + const attribution = resolveUsageModelAttribution(config, model); + if (!attribution.provider || !attribution.model) { + return undefined; + } + const resolved = registry.resolve(`${attribution.provider}/${attribution.model}`); + return resolved?.kind === "provider" + ? { model: resolved.model, provider: resolved.provider } + : undefined; +} + function positiveInteger(value: number | undefined): number | undefined { return value !== undefined && Number.isFinite(value) && value > 0 ? Math.trunc(value) diff --git a/packages/core/src/gateway/features/model-discovery.ts b/packages/core/src/gateway/features/model-discovery.ts index d12df914..8a9e4eff 100644 --- a/packages/core/src/gateway/features/model-discovery.ts +++ b/packages/core/src/gateway/features/model-discovery.ts @@ -15,6 +15,7 @@ import type { ClaudeCodeDiscoverableModel } from "@ccr/core/gateway/internal/sha import { parseJsonObjectSafe, serializeJsonBodyWithModel } from "@ccr/core/gateway/http/body"; import { uniqueStrings } from "@ccr/core/gateway/internal/collections"; import { contextArchiveConfigForApiKey, contextArchiveMcpEnabled } from "@ccr/core/gateway/context-archive"; +import { resolveUsageModelAttribution } from "@ccr/core/usage/model-attribution"; export function shouldServeGatewayModelsResponse(method: string, path: string): boolean { @@ -128,8 +129,9 @@ function createClaudeAppGatewayModelsResponse( const routes = buildClaudeAppGatewayModelRoutes(config, claudeAppGatewayModelRouteOptions); const data = routes.map((route) => { const catalogId = stripClaudeCodeOneMillionContextSuffix(route.targetModel); - const catalogEntry = findModelCatalogEntry(catalogId); - const modelMetadata = providerModelMetadataForSelector(config, catalogId); + const modelDiscovery = providerModelDiscoveryForSelector(config, catalogId); + const catalogEntry = modelDiscovery.catalogEntry; + const modelMetadata = modelDiscovery.metadata; const maxInputTokens = claudeGatewayModelContextWindow(catalogEntry, route.oneMillionContext, modelMetadata); const maxOutputTokens = modelCatalogMaxOutputTokens(catalogEntry); const exposeOneMillionContextVariant = options.claudeCode && route.oneMillionContext; @@ -165,8 +167,9 @@ function createClaudeCodeModelsResponse(config: AppConfig, contextArchiveCompact const data = models.map((model) => { const claudeId = claudeCodeDiscoveryModelId(model.id); const catalogId = stripClaudeCodeOneMillionContextSuffix(model.id); - const catalogEntry = findModelCatalogEntry(catalogId); - const modelMetadata = providerModelMetadataForSelector(config, catalogId); + const modelDiscovery = providerModelDiscoveryForSelector(config, catalogId); + const catalogEntry = modelDiscovery.catalogEntry; + const modelMetadata = modelDiscovery.metadata; const maxInputTokens = claudeGatewayModelContextWindow(catalogEntry, model.oneMillionContext, modelMetadata); const maxOutputTokens = modelCatalogMaxOutputTokens(catalogEntry); return { @@ -226,11 +229,27 @@ function effectiveProviderContextWindow(metadata: ProviderModelMetadata | undefi } -function providerModelMetadataForSelector(config: AppConfig, selector: string): ProviderModelMetadata | undefined { - const resolved = modelRegistryForConfig(config).resolveProviderModel(selector); +function providerModelDiscoveryForSelector( + config: AppConfig, + selector: string +): { catalogEntry?: ModelCatalogEntry; metadata?: ProviderModelMetadata } { + const resolved = providerModelResolutionForSelector(config, selector); if (!resolved) { - return undefined; + return { + catalogEntry: findModelCatalogEntry(selector) + }; } + const physicalSelector = `${resolved.provider.name}/${resolved.model}`; + return { + catalogEntry: findModelCatalogEntry(physicalSelector) ?? findModelCatalogEntry(selector), + metadata: providerModelMetadataForResolvedModel(resolved) + }; +} + + +function providerModelMetadataForResolvedModel( + resolved: NonNullable> +): ProviderModelMetadata | undefined { const metadata = resolved.provider.modelMetadata ?? {}; const direct = metadata[resolved.model]; if (direct) { @@ -241,6 +260,34 @@ function providerModelMetadataForSelector(config: AppConfig, selector: string): } +function providerModelResolutionForSelector(config: AppConfig, selector: string) { + const registry = modelRegistryForConfig(config); + const direct = registry.resolveProviderModel(selector); + if (direct) { + return direct; + } + + const attribution = resolveUsageModelAttribution(config, selector); + if (!attribution.provider || !attribution.model) { + return undefined; + } + const resolved = registry.resolve(`${attribution.provider}/${attribution.model}`); + return resolved?.kind === "provider" + ? { model: resolved.model, provider: resolved.provider } + : undefined; +} + + +function gatewayModelSupportsOneMillionContext(config: AppConfig, selector: string): boolean { + const discovery = providerModelDiscoveryForSelector(config, selector); + const metadataContextWindow = effectiveProviderContextWindow(discovery.metadata); + return Boolean( + (metadataContextWindow && metadataContextWindow >= 1_000_000) || + discovery.catalogEntry?.limits?.supports1MContext + ); +} + + function percentage(value: number | undefined): number | undefined { return value !== undefined && Number.isFinite(value) && value > 0 && value <= 100 ? value @@ -336,7 +383,7 @@ function buildClaudeCodeDiscoverableModels(config: AppConfig): ClaudeCodeDiscove for (const id of buildClaudeCodeDiscoverableModelIds(config)) { pushModel(id, hasClaudeCodeOneMillionContextSuffix(id)); const baseId = stripClaudeCodeOneMillionContextSuffix(id); - if (!hasClaudeCodeOneMillionContextSuffix(id) && findModelCatalogEntry(baseId)?.limits?.supports1MContext) { + if (!hasClaudeCodeOneMillionContextSuffix(id) && gatewayModelSupportsOneMillionContext(config, baseId)) { pushModel(claudeCodeOneMillionContextModelId(baseId), true); } } diff --git a/packages/core/test/unit/agents/claude-app-gateway-models.test.mjs b/packages/core/test/unit/agents/claude-app-gateway-models.test.mjs index 89d9b777..10d5308f 100644 --- a/packages/core/test/unit/agents/claude-app-gateway-models.test.mjs +++ b/packages/core/test/unit/agents/claude-app-gateway-models.test.mjs @@ -276,6 +276,61 @@ test("Claude Code exposes a configured 1M context window as a visible model vari assert.equal(rewrite?.routedModel, "Zhipu AI (China) - Coding Plan/aaa"); }); +test("issue 1632 Claude discovery inherits Fusion fixed model provider context", () => { + const config = createConfig({ + providers: [ + { + modelMetadata: { + "glm-5.2": { + contextWindow: 1_000_000, + maxContextWindow: 1_000_000 + } + }, + models: ["glm-5.2"], + name: "Zhipu AI (China) - Coding Plan", + type: "openai_chat_completions" + } + ], + virtualModelProfiles: [ + { + baseModel: { fixedModel: "Zhipu AI (China) - Coding Plan/glm-5.2", mode: "fixed" }, + displayName: "GLM 5.2 Fusion", + enabled: true, + execution: { clientToolsPolicy: "allow", mode: "tool_loop", streamMode: "optimistic" }, + id: "glm-5-2-fusion", + key: "glm-5.2-fusion", + match: { exactAliases: ["glm-5.2-fusion"], prefixes: [], suffixes: [] }, + materialization: { enabled: true, includeInGatewayModels: true }, + tools: [] + } + ] + }); + const route = buildClaudeAppGatewayModelRoutes(config).find((item) => item.targetModel === "Fusion/glm-5.2-fusion"); + const inferenceModel = buildClaudeAppGatewayInferenceModels(config).find((item) => item.name === route?.id); + const appModel = createClaudeModelsResponse(config).data.find((item) => item.id === route?.id); + const codeModel = createClaudeCodeModelsResponse(config).data.find((item) => + item.display_name === "Fusion/glm-5.2-fusion (1M context)" + ); + + assert.ok(route); + assert.equal(route.oneMillionContext, true); + assert.equal(inferenceModel?.supports1m, true); + assert.equal(appModel?.max_input_tokens, 1_000_000); + assert.equal(appModel?.capabilities.context_window.max_input_tokens, 1_000_000); + assert.equal(appModel?.capabilities.context_window.supports_1m_context, true); + assert.ok(codeModel); + assert.match(codeModel.id, /\[1m\]$/); + assert.equal(codeModel.max_input_tokens, 1_000_000); + + const rewrite = prepareClaudeAppDiscoveredModelRequest( + config, + "POST", + "/v1/messages", + Buffer.from(JSON.stringify({ messages: [], model: codeModel.id })) + ); + assert.equal(rewrite?.routedModel, "Fusion/glm-5.2-fusion"); +}); + test("Claude App discovery publishes the effective provider context for uncatalogued models", () => { const config = createConfig({ providers: [