Propagate provider context for Fusion model discovery

This commit is contained in:
musistudio 2026-08-05 20:06:58 +08:00
parent 9943ab273b
commit df42fe8683
3 changed files with 148 additions and 10 deletions

View file

@ -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<AppConfig, "Providers" | "virtualModelProfiles">
): 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<AppConfig, "Providers" | "virtualModelProfiles">
): string | undefined {
const resolved = claudeAppGatewayResolvedProviderModel(model, config);
return resolved ? `${resolved.provider.name}/${resolved.model}` : undefined;
}
function claudeAppGatewayResolvedProviderModel(
model: string,
config: Pick<AppConfig, "Providers" | "virtualModelProfiles">
) {
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)

View file

@ -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<ReturnType<typeof providerModelResolutionForSelector>>
): 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);
}
}

View file

@ -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: [