mirror of
https://github.com/musistudio/claude-code-router.git
synced 2026-08-29 03:12:10 +08:00
Merge pull request #1589 from jesieleo/fix/zcode-model-context-metadata
fix(zcode): 保证模型同步正确的上下文长度
This commit is contained in:
@@ -8,6 +8,7 @@ import { buildCodexModelCatalog, type CodexModelCatalog, type CodexModelCatalogI
|
||||
import { prepareCodexAppCdpUserDataDir } from "@ccr/core/agents/codex/media-preview-bridge";
|
||||
import { buildProfileLaunchPlan, resolveCodexConfigFile } from "@ccr/core/profiles/launch-core";
|
||||
import { normalizeWindowsDesktopAppCandidate, windowsDesktopAppCandidates } from "@ccr/core/platform/windows-app-discovery";
|
||||
import { buildZcodeModelCatalog } from "@ccr/core/agents/zcode/model-catalog";
|
||||
import { writeZcodeGatewayConfig, zcodeHomeFromConfigFile } from "@ccr/core/agents/zcode/profile-config";
|
||||
|
||||
export type CodexAppLookupResult = {
|
||||
@@ -203,7 +204,7 @@ export function writeCodexCompatibleAppModelCatalog(
|
||||
const userDataDir = codexElectronUserDataDir(codexHome, profile, spec);
|
||||
mkdirSync(userDataDir, { recursive: true });
|
||||
const file = codexAppModelCatalogFile(userDataDir, spec);
|
||||
const content = codexCompatibleAppModelCatalogJson(config, profile.model);
|
||||
const content = codexCompatibleAppModelCatalogJson(config, profile.model, spec.kind);
|
||||
const previous = existsSync(file) ? readFileSync(file, "utf8") : undefined;
|
||||
if (previous !== content) {
|
||||
writeFileSync(file, content, "utf8");
|
||||
@@ -211,12 +212,22 @@ export function writeCodexCompatibleAppModelCatalog(
|
||||
return { changed: previous !== content, file, userDataDir };
|
||||
}
|
||||
|
||||
function codexCompatibleAppModelCatalogJson(config?: CodexCompatibleAppModelCatalogConfig, selectedModel?: string): string {
|
||||
return `${JSON.stringify(codexCompatibleAppModelCatalog(config, selectedModel), null, 2)}\n`;
|
||||
function codexCompatibleAppModelCatalogJson(
|
||||
config?: CodexCompatibleAppModelCatalogConfig,
|
||||
selectedModel?: string,
|
||||
kind: CodexCompatibleAppKind = "codex"
|
||||
): string {
|
||||
return `${JSON.stringify(codexCompatibleAppModelCatalog(config, selectedModel, kind), null, 2)}\n`;
|
||||
}
|
||||
|
||||
function codexCompatibleAppModelCatalog(config?: CodexCompatibleAppModelCatalogConfig, selectedModel?: string): CodexModelCatalog {
|
||||
const catalog = buildCodexModelCatalog(config, selectedModel);
|
||||
function codexCompatibleAppModelCatalog(
|
||||
config?: CodexCompatibleAppModelCatalogConfig,
|
||||
selectedModel?: string,
|
||||
kind: CodexCompatibleAppKind = "codex"
|
||||
): CodexModelCatalog {
|
||||
const catalog = kind === "zcode"
|
||||
? buildZcodeModelCatalog(config, selectedModel)
|
||||
: buildCodexModelCatalog(config, selectedModel);
|
||||
return {
|
||||
models: catalog.models.map((model) => codexCompatibleAppModelCatalogItem(model, config))
|
||||
};
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import type { AppConfig } from "@ccr/core/contracts/app";
|
||||
import {
|
||||
buildCodexModelCatalog,
|
||||
type CodexModelCatalog
|
||||
} from "@ccr/core/agents/codex/model-catalog";
|
||||
import {
|
||||
findModelCatalogEntry,
|
||||
type ModelCatalogEntry,
|
||||
modelCatalogMaxInputTokens
|
||||
} from "@ccr/core/gateway/model-catalog";
|
||||
import { modelRegistryForConfig } from "@ccr/core/routing/model-registry";
|
||||
import { resolveUsageModelAttribution } from "@ccr/core/usage/model-attribution";
|
||||
|
||||
type ZcodeModelCatalogConfig = Partial<Pick<
|
||||
AppConfig,
|
||||
"Providers" | "Router" | "virtualModelProfiles"
|
||||
>>;
|
||||
|
||||
type ZcodeModelResolutionConfig = Pick<
|
||||
AppConfig,
|
||||
"Providers" | "virtualModelProfiles"
|
||||
>;
|
||||
|
||||
export function buildZcodeModelCatalog(
|
||||
config?: ZcodeModelCatalogConfig,
|
||||
selectedModel?: string
|
||||
): CodexModelCatalog {
|
||||
const catalog = buildCodexModelCatalog(config, selectedModel);
|
||||
const resolutionConfig = config
|
||||
? {
|
||||
Providers: config.Providers ?? [],
|
||||
virtualModelProfiles: config.virtualModelProfiles ?? []
|
||||
}
|
||||
: undefined;
|
||||
return {
|
||||
models: catalog.models.map((item) => {
|
||||
const contextWindow = Math.max(
|
||||
item.context_window,
|
||||
modelCatalogMaxInputTokens(zcodeModelCatalogEntry(resolutionConfig, item.slug))
|
||||
);
|
||||
return {
|
||||
...item,
|
||||
context_window: contextWindow,
|
||||
max_context_window: Math.max(item.max_context_window, contextWindow)
|
||||
};
|
||||
})
|
||||
};
|
||||
}
|
||||
|
||||
function zcodeModelCatalogEntry(
|
||||
config: ZcodeModelResolutionConfig | undefined,
|
||||
model: string
|
||||
): ModelCatalogEntry | undefined {
|
||||
if (!config) {
|
||||
return findModelCatalogEntry(model);
|
||||
}
|
||||
|
||||
const registry = modelRegistryForConfig(config);
|
||||
const attribution = resolveUsageModelAttribution(config, model);
|
||||
const physicalModel = attribution.model?.trim();
|
||||
const physicalProvider = registry.findProvider(attribution.provider);
|
||||
if (physicalProvider && physicalModel) {
|
||||
return findModelCatalogEntry(`${physicalProvider.name}/${physicalModel}`);
|
||||
}
|
||||
|
||||
// Request-routed virtual models do not have one physical model until the
|
||||
// request is evaluated, so retain the context already produced by Codex.
|
||||
if (registry.resolve(model)?.kind === "gateway") {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return findModelCatalogEntry(physicalModel || model);
|
||||
}
|
||||
|
||||
export function zcodeModelCatalogJson(
|
||||
config?: ZcodeModelCatalogConfig,
|
||||
selectedModel?: string
|
||||
): string {
|
||||
return `${JSON.stringify(buildZcodeModelCatalog(config, selectedModel), null, 2)}\n`;
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { isGatewayProviderEnabled, type AppConfig, type ProfileConfig } from "@ccr/core/contracts/app";
|
||||
import { normalizeRouteSelector } from "@ccr/core/gateway/claude-code-router-plugin";
|
||||
import { buildCodexModelCatalogIds } from "@ccr/core/agents/codex/model-catalog";
|
||||
import { buildZcodeModelCatalog } from "@ccr/core/agents/zcode/model-catalog";
|
||||
|
||||
export type ZcodeProfileConfigWriteResult = {
|
||||
backupFile?: string;
|
||||
@@ -15,6 +15,7 @@ export type ZcodeProfileConfigWriteResult = {
|
||||
type ZcodeGatewayConfigValues = {
|
||||
baseUrl: string;
|
||||
model: string;
|
||||
modelContextWindows: Record<string, number>;
|
||||
providerId: string;
|
||||
providerName: string;
|
||||
token: string;
|
||||
@@ -25,6 +26,8 @@ const legacyZcodeTomlConfigFile = "~/.zcode/config.toml";
|
||||
const defaultZcodeConfigFile = "~/.zcode/cli/config.json";
|
||||
const originalBackupSuffix = ".ccr-original";
|
||||
const originalMissingSuffix = ".ccr-original-missing";
|
||||
const defaultZcodeContextWindow = 128_000;
|
||||
const defaultZcodeMaxOutputTokens = 8_192;
|
||||
|
||||
export function resolveZcodeConfigFile(profile: Pick<ProfileConfig, "codexHome" | "configFile">): string {
|
||||
const configured = profile.configFile?.trim();
|
||||
@@ -51,10 +54,15 @@ export function writeZcodeGatewayConfig(
|
||||
const file = resolveZcodeConfigFile(profile);
|
||||
const model = normalizeClientModel(profile.model) || defaultClientModel(config);
|
||||
const providerId = sanitizeZcodeProviderId(profile.providerId || "") || "claude-code-router";
|
||||
const modelCatalog = buildZcodeModelCatalog(config, model);
|
||||
const values: ZcodeGatewayConfigValues = {
|
||||
baseUrl: gatewayEndpoint(config),
|
||||
model,
|
||||
models: buildCodexModelCatalogIds(config, model),
|
||||
modelContextWindows: Object.fromEntries(modelCatalog.models.map((item) => [
|
||||
item.slug,
|
||||
positiveNumber(item.context_window) ?? defaultZcodeContextWindow
|
||||
])),
|
||||
models: modelCatalog.models.map((item) => item.slug),
|
||||
providerId,
|
||||
providerName: profile.providerName?.trim() || "Claude Code Router",
|
||||
token
|
||||
@@ -117,7 +125,10 @@ function zcodeConfigProvider(values: ZcodeGatewayConfigValues): Record<string, u
|
||||
apiKeyRequired: true,
|
||||
baseURL: values.baseUrl
|
||||
},
|
||||
models: Object.fromEntries(uniqueStrings(values.models).map((model) => [model, zcodeModelConfig(model)]))
|
||||
models: Object.fromEntries(uniqueStrings(values.models).map((model) => [
|
||||
model,
|
||||
zcodeModelConfig(model, values.modelContextWindows[model])
|
||||
]))
|
||||
};
|
||||
}
|
||||
|
||||
@@ -145,13 +156,13 @@ function buildZcodeV2ModelCache(source: Record<string, unknown>, values: ZcodeGa
|
||||
};
|
||||
}
|
||||
|
||||
function zcodeModelConfig(model: string): Record<string, unknown> {
|
||||
function zcodeModelConfig(model: string, contextWindow: number | undefined): Record<string, unknown> {
|
||||
return {
|
||||
id: model,
|
||||
name: model,
|
||||
limit: {
|
||||
context: 128_000,
|
||||
output: 8_192
|
||||
context: contextWindow ?? defaultZcodeContextWindow,
|
||||
output: defaultZcodeMaxOutputTokens
|
||||
},
|
||||
modalities: {
|
||||
input: ["text", "image"],
|
||||
@@ -185,13 +196,16 @@ function zcodeV2ModelCacheProvider(
|
||||
apiKey: "__zcode_cached_api_key_present__",
|
||||
apiKeyRequired: true,
|
||||
defaultKind: "anthropic",
|
||||
models: uniqueStrings(values.models).map((model) => zcodeV2ModelCacheModel(model)),
|
||||
models: uniqueStrings(values.models).map((model) => zcodeV2ModelCacheModel(
|
||||
model,
|
||||
values.modelContextWindows[model]
|
||||
)),
|
||||
createdAt: positiveNumber(previousProvider?.createdAt) ?? now,
|
||||
updatedAt: now
|
||||
};
|
||||
}
|
||||
|
||||
function zcodeV2ModelCacheModel(model: string): Record<string, unknown> {
|
||||
function zcodeV2ModelCacheModel(model: string, contextWindow: number | undefined): Record<string, unknown> {
|
||||
return {
|
||||
id: model,
|
||||
name: model,
|
||||
@@ -201,8 +215,8 @@ function zcodeV2ModelCacheModel(model: string): Record<string, unknown> {
|
||||
input: ["text", "image"],
|
||||
output: ["text"]
|
||||
},
|
||||
contextWindow: 128_000,
|
||||
maxOutputTokens: 8_192,
|
||||
contextWindow: contextWindow ?? defaultZcodeContextWindow,
|
||||
maxOutputTokens: defaultZcodeMaxOutputTokens,
|
||||
supportsStructuredOutput: true,
|
||||
supportsTools: true
|
||||
};
|
||||
|
||||
@@ -173,6 +173,56 @@ test("ChatGPT model catalog write gives gateway GPT models reasoning effort fall
|
||||
}
|
||||
});
|
||||
|
||||
test("ZCode app model catalog uses the public model context window", () => {
|
||||
const configDir = mkdtempSync(path.join(os.tmpdir(), "ccr-zcode-app-catalog-"));
|
||||
try {
|
||||
const config = {
|
||||
Providers: [{
|
||||
api_base_url: "https://chatgpt.com/backend-api/codex",
|
||||
modelMetadata: {
|
||||
"gpt-5.6-sol": {
|
||||
contextWindow: 272_000,
|
||||
maxContextWindow: 272_000
|
||||
}
|
||||
},
|
||||
models: ["gpt-5.6-sol"],
|
||||
name: "Codex API",
|
||||
type: "openai_responses"
|
||||
}],
|
||||
virtualModelProfiles: [{
|
||||
baseModel: { fixedModel: "Codex API/gpt-5.6-sol", mode: "fixed" },
|
||||
enabled: true,
|
||||
match: { exactAliases: ["catalog-context"], prefixes: [], suffixes: [] },
|
||||
materialization: { enabled: true, includeInGatewayModels: true }
|
||||
}]
|
||||
};
|
||||
const profile = {
|
||||
agent: "zcode",
|
||||
codexHome: configDir,
|
||||
enabled: true,
|
||||
id: "zcode-main",
|
||||
model: "Codex API/gpt-5.6-sol",
|
||||
name: "ZCode Main",
|
||||
providerId: "claude-code-router",
|
||||
scope: "global",
|
||||
surface: "app"
|
||||
};
|
||||
|
||||
const result = writeCodexCompatibleAppModelCatalog(configDir, profile, config);
|
||||
const catalog = JSON.parse(readFileSync(result.file, "utf8"));
|
||||
const fusionModel = catalog.models.find((item) => item.slug === "Fusion/catalog-context");
|
||||
const model = catalog.models.find((item) => item.slug === "Codex API/gpt-5.6-sol");
|
||||
|
||||
assert.equal(path.basename(result.file), "ccr-zcode-model-catalog.json");
|
||||
assert.equal(fusionModel.context_window, 1_050_000);
|
||||
assert.equal(fusionModel.max_context_window, 1_050_000);
|
||||
assert.equal(model.context_window, 1_050_000);
|
||||
assert.equal(model.max_context_window, 1_050_000);
|
||||
} finally {
|
||||
rmSync(configDir, { force: true, recursive: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("ChatGPT desktop app path override discovers the renamed executable", () => {
|
||||
const root = mkdtempSync(path.join(os.tmpdir(), "ccr-chatgpt-app-"));
|
||||
const previous = process.env.CHATGPT_APP_PATH;
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdtempSync, readFileSync, rmSync } from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import test from "node:test";
|
||||
import { buildZcodeModelCatalog } from "@ccr/core/agents/zcode/model-catalog.ts";
|
||||
import { writeZcodeGatewayConfig } from "@ccr/core/agents/zcode/profile-config.ts";
|
||||
import { createDefaultAppConfig } from "@ccr/core/config/default-config.ts";
|
||||
|
||||
const fusionModel = "Fusion/catalog-context";
|
||||
const knownModel = "Codex API/gpt-5.6-sol";
|
||||
const unknownModel = "Codex API/unknown-model";
|
||||
|
||||
function testConfig(root) {
|
||||
const config = createDefaultAppConfig({ generatedConfigFile: path.join(root, "gateway.config.json") });
|
||||
config.Providers = [{
|
||||
api_base_url: "https://example.test/v1",
|
||||
modelMetadata: {
|
||||
"gpt-5.6-sol": {
|
||||
contextWindow: 272_000,
|
||||
maxContextWindow: 272_000
|
||||
}
|
||||
},
|
||||
models: ["gpt-5.6-sol", "unknown-model"],
|
||||
name: "Codex API",
|
||||
type: "openai_responses"
|
||||
}];
|
||||
config.virtualModelProfiles = [{
|
||||
baseModel: { fixedModel: knownModel, mode: "fixed" },
|
||||
enabled: true,
|
||||
match: { exactAliases: ["catalog-context"], prefixes: [], suffixes: [] },
|
||||
materialization: { enabled: true, includeInGatewayModels: true }
|
||||
}];
|
||||
config.preferredProvider = "Codex API";
|
||||
config.gateway.host = "127.0.0.1";
|
||||
config.gateway.port = 4567;
|
||||
return config;
|
||||
}
|
||||
|
||||
function testProfile(root) {
|
||||
return {
|
||||
agent: "zcode",
|
||||
codexHome: root,
|
||||
enabled: true,
|
||||
env: {},
|
||||
id: "zcode-main",
|
||||
model: "Codex API,gpt-5.6-sol",
|
||||
name: "ZCode Main",
|
||||
providerId: "claude-code-router",
|
||||
providerName: "Claude Code Router",
|
||||
scope: "global",
|
||||
surface: "app"
|
||||
};
|
||||
}
|
||||
|
||||
test("ZCode profile config writes resolved model limits instead of fixed defaults", () => {
|
||||
const root = mkdtempSync(path.join(os.tmpdir(), "ccr-zcode-profile-"));
|
||||
try {
|
||||
const result = writeZcodeGatewayConfig(
|
||||
testConfig(root),
|
||||
testProfile(root),
|
||||
"ccr-profile-key",
|
||||
{ backup: false }
|
||||
);
|
||||
const cliConfig = JSON.parse(readFileSync(result.file, "utf8"));
|
||||
const v2Config = JSON.parse(readFileSync(path.join(root, "v2", "config.json"), "utf8"));
|
||||
const cache = JSON.parse(readFileSync(path.join(root, "v2", "bots-model-cache.v2.json"), "utf8"));
|
||||
|
||||
for (const config of [cliConfig, v2Config]) {
|
||||
const models = config.provider["claude-code-router"].models;
|
||||
assert.equal(models[fusionModel].limit.context, 1_050_000);
|
||||
assert.equal(models[fusionModel].limit.output, 8_192);
|
||||
assert.equal(models[knownModel].limit.context, 1_050_000);
|
||||
assert.equal(models[knownModel].limit.output, 8_192);
|
||||
assert.equal(models[unknownModel].limit.context, 128_000);
|
||||
assert.equal(models[unknownModel].limit.output, 8_192);
|
||||
}
|
||||
|
||||
const cachedProvider = cache.providers.find((provider) => provider.id === "claude-code-router");
|
||||
const cachedModels = Object.fromEntries(cachedProvider.models.map((model) => [model.id, model]));
|
||||
assert.equal(cachedModels[fusionModel].contextWindow, 1_050_000);
|
||||
assert.equal(cachedModels[fusionModel].maxOutputTokens, 8_192);
|
||||
assert.equal(cachedModels[knownModel].contextWindow, 1_050_000);
|
||||
assert.equal(cachedModels[knownModel].maxOutputTokens, 8_192);
|
||||
assert.equal(cachedModels[unknownModel].contextWindow, 128_000);
|
||||
assert.equal(cachedModels[unknownModel].maxOutputTokens, 8_192);
|
||||
} finally {
|
||||
rmSync(root, { force: true, recursive: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("ZCode model catalog resolves physical models for materialized Fusion selectors", () => {
|
||||
const root = mkdtempSync(path.join(os.tmpdir(), "ccr-zcode-catalog-"));
|
||||
try {
|
||||
const config = testConfig(root);
|
||||
config.Providers.push({
|
||||
api_base_url: "https://api.deepseek.com",
|
||||
modelMetadata: {
|
||||
"deepseek-v4-flash": {
|
||||
contextWindow: 128_000,
|
||||
maxContextWindow: 128_000
|
||||
}
|
||||
},
|
||||
models: ["deepseek-v4-flash"],
|
||||
name: "DeepSeek",
|
||||
type: "openai_responses"
|
||||
});
|
||||
config.virtualModelProfiles.push(
|
||||
{
|
||||
baseModel: { fixedModel: "DeepSeek/deepseek-v4-flash", mode: "fixed" },
|
||||
enabled: true,
|
||||
match: { exactAliases: ["deepseek-context"], prefixes: [], suffixes: [] },
|
||||
materialization: { enabled: true, includeInGatewayModels: true }
|
||||
},
|
||||
{
|
||||
baseModel: { mode: "request" },
|
||||
enabled: true,
|
||||
match: { exactAliases: [], prefixes: ["fusion-"], suffixes: [] },
|
||||
materialization: { enabled: true, includeInGatewayModels: true }
|
||||
},
|
||||
{
|
||||
baseModel: { mode: "request" },
|
||||
enabled: true,
|
||||
match: { exactAliases: [], prefixes: [], suffixes: ["-fusion"] },
|
||||
materialization: { enabled: true, includeInGatewayModels: true }
|
||||
},
|
||||
{
|
||||
baseModel: { mode: "request" },
|
||||
enabled: true,
|
||||
match: { exactAliases: ["dynamic-route"], prefixes: [], suffixes: [] },
|
||||
materialization: { enabled: true, includeInGatewayModels: true }
|
||||
}
|
||||
);
|
||||
|
||||
const models = Object.fromEntries(
|
||||
buildZcodeModelCatalog(config).models.map((model) => [model.slug, model])
|
||||
);
|
||||
|
||||
assert.equal(models[fusionModel].context_window, 1_050_000);
|
||||
assert.equal(models["Fusion/deepseek-context"].context_window, 1_050_000);
|
||||
assert.equal(models["Codex API/fusion-gpt-5.6-sol"].context_window, 1_050_000);
|
||||
assert.equal(models["Codex API/gpt-5.6-sol-fusion"].context_window, 1_050_000);
|
||||
assert.equal(models["Fusion/dynamic-route"].context_window, 128_000);
|
||||
} finally {
|
||||
rmSync(root, { force: true, recursive: true });
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user