fix: 接入 dsflash-go model catalog

This commit is contained in:
Codex
2026-06-08 23:31:33 +08:00
parent 1bd65a4d1a
commit 6dd8c75528
27 changed files with 485 additions and 123 deletions
+95 -5
View File
@@ -1,6 +1,6 @@
import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
import { createHash } from "node:crypto";
import { accessSync, constants as fsConstants } from "node:fs";
import { accessSync, constants as fsConstants, readdirSync, readFileSync } from "node:fs";
import { chmod, copyFile, mkdir } from "node:fs/promises";
import path from "node:path";
import * as readline from "node:readline";
@@ -351,6 +351,7 @@ export class CodexStdioBackendSession {
...backendMetadata(options),
protocol: codexProtocol,
runtime: runtimeSummary(options, env, resolveCodexHome(options)),
config: codexConfigSummary(resolveCodexHome(options), options.backendProfile ?? "codex"),
},
});
const clientOptions: ConstructorParameters<typeof CodexStdioClient>[0] = {
@@ -386,7 +387,7 @@ async function runCodexStdioTurnWithSession(options: CodexStdioTurnOptions, sess
const codexHome = resolveCodexHome(options);
const projectionFailure = await prepareProjectedCodexHome(codexHome, options.env?.AGENTRUN_CODEX_SECRET_HOME ?? process.env.AGENTRUN_CODEX_SECRET_HOME);
if (projectionFailure) return projectionFailure;
const secretFailure = codexHomeReadiness(codexHome);
const secretFailure = codexHomeReadiness(codexHome, options.backendProfile ?? "codex");
if (secretFailure) return secretFailure;
const env = childEnv(options, codexHome);
const events: BackendEvent[] = [];
@@ -561,7 +562,7 @@ async function prepareProjectedCodexHome(codexHome: string, projectedHome: strin
if (path.resolve(projectedHome) === path.resolve(codexHome)) return null;
try {
await mkdir(codexHome, { recursive: true, mode: 0o700 });
for (const fileName of ["auth.json", "config.toml"]) {
for (const fileName of projectedCodexHomeFiles(projectedHome)) {
await copyFile(path.join(projectedHome, fileName), path.join(codexHome, fileName));
await chmod(path.join(codexHome, fileName), 0o600);
}
@@ -588,16 +589,18 @@ async function prepareProjectedCodexHome(codexHome: string, projectedHome: strin
}
}
function codexHomeReadiness(codexHome: string): BackendTurnResult | null {
function codexHomeReadiness(codexHome: string, profile: BackendProfile): BackendTurnResult | null {
const auth = fileReadable(`${codexHome}/auth.json`);
const config = fileReadable(`${codexHome}/config.toml`);
if (auth.readable && config.readable) return null;
const modelCatalog = auth.readable && config.readable ? modelCatalogReadiness(codexHome, profile) : null;
if (auth.readable && config.readable && !modelCatalog) return null;
const payload = {
failureKind: "secret-unavailable",
projection: {
codexHome: pathSummary(codexHome),
authJson: auth,
configToml: config,
...(modelCatalog ? { modelCatalogJson: modelCatalog } : {}),
valuesPrinted: false,
},
} satisfies JsonRecord;
@@ -612,6 +615,88 @@ function codexHomeReadiness(codexHome: string): BackendTurnResult | null {
};
}
function projectedCodexHomeFiles(projectedHome: string): string[] {
const required = ["auth.json", "config.toml"];
try {
const optionalFiles = readdirSync(projectedHome).filter((name) => name === "model-catalog.json");
return [...required, ...optionalFiles];
} catch {
return required;
}
}
function modelCatalogReadiness(codexHome: string, profile: BackendProfile): JsonRecord | null {
let configToml = "";
try {
configToml = readFileSync(path.join(codexHome, "config.toml"), "utf8");
} catch {
return null;
}
const modelCatalogPath = modelCatalogJsonPathFromConfig(configToml, codexHome);
if (!modelCatalogPath) {
return profile === "dsflash-go" ? { present: false, requiredByConfig: false, requiredByProfile: true, valuesPrinted: false } : null;
}
const readiness = fileReadable(modelCatalogPath);
return readiness.readable ? null : { ...readiness, requiredByConfig: true, valuesPrinted: false };
}
function modelCatalogJsonPathFromConfig(configToml: string, codexHome: string): string | null {
const match = configToml.match(/^\s*model_catalog_json\s*=\s*"([^"]+)"\s*$/mu);
if (!match?.[1]) return null;
return path.isAbsolute(match[1]) ? match[1] : path.join(codexHome, match[1]);
}
function codexConfigSummary(codexHome: string, profile: BackendProfile): JsonRecord {
const configPath = path.join(codexHome, "config.toml");
let configToml = "";
try {
configToml = readFileSync(configPath, "utf8");
} catch {
return { available: false, valuesPrinted: false };
}
const model = tomlStringValue(configToml, "model");
const providerName = tomlStringValue(configToml, "model_provider");
const baseUrl = tomlStringValue(configToml, "base_url");
const modelCatalogPath = modelCatalogJsonPathFromConfig(configToml, codexHome);
return {
available: true,
profile,
model,
providerName,
baseUrl: baseUrl ? redactedUrlSummary(baseUrl) : null,
wireApi: tomlStringValue(configToml, "wire_api"),
contextWindow: tomlNumberValue(configToml, "model_context_window"),
autoCompactTokenLimit: tomlNumberValue(configToml, "model_auto_compact_token_limit"),
modelCatalogJson: modelCatalogPath ? { ...pathSummary(modelCatalogPath), readable: fileReadable(modelCatalogPath).readable } : null,
valuesPrinted: false,
};
}
function tomlStringValue(configToml: string, key: string): string | null {
const match = configToml.match(new RegExp(`^\\s*${escapeRegExp(key)}\\s*=\\s*"([^"]*)"\\s*$`, "mu"));
return match?.[1] ?? null;
}
function tomlNumberValue(configToml: string, key: string): number | null {
const match = configToml.match(new RegExp(`^\\s*${escapeRegExp(key)}\\s*=\\s*([0-9][0-9_]*)\\s*$`, "mu"));
if (!match?.[1]) return null;
const value = Number(match[1].replace(/_/gu, ""));
return Number.isFinite(value) ? value : null;
}
function redactedUrlSummary(value: string): JsonRecord {
try {
const url = new URL(value);
return { protocol: url.protocol.replace(/:$/u, ""), hostname: url.hostname, port: url.port || null, pathname: url.pathname, valuesPrinted: false };
} catch {
return { valid: false, fingerprint: shortHash(value), valuesPrinted: false };
}
}
function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&");
}
function normalizeCodexNotification(message: JsonRecord, suppressed: SuppressedNotificationSummary): { events: BackendEvent[]; assistantDelta?: { itemId: string | null; text: string }; completedAssistantMessage?: CompletedAssistantMessage; threadId?: string; turnId?: string; terminal?: { status: TerminalStatus; failureKind: FailureKind | null; message: string | null } } {
const method = typeof message.method === "string" ? message.method : "unknown";
const params = asRecordAt(message, "params");
@@ -1063,6 +1148,7 @@ function classifyCodexErrorRecord(error: JsonRecord, fallback: FailureKind): Fai
function classifyMessageFailureKind(message: string, fallback: FailureKind): FailureKind {
const text = String(message || "").toLowerCase();
if (isProviderCompactUnsupportedMessage(text)) return "provider-compact-unsupported";
if (/invalid[_ -]?prompt/u.test(text) && /invalid function arguments json string|tool_call_id/u.test(text)) return "provider-invalid-tool-call";
if (/invalid function arguments json string/u.test(text)) return "provider-invalid-tool-call";
if (/rate.?limit|too many requests|\b429\b/u.test(text)) return "provider-rate-limited";
@@ -1073,6 +1159,10 @@ function classifyMessageFailureKind(message: string, fallback: FailureKind): Fai
return fallback;
}
function isProviderCompactUnsupportedMessage(text: string): boolean {
return /responses\/compact|\/compact\b/u.test(text) && /\b404\b|not found|unsupported|no route|not implemented/u.test(text);
}
function isProviderUnavailableMessage(text: string): boolean {
if (/\b(?:http(?:\s+status)?|status(?:\s+code)?|code)\s*[:=]?\s*5\d\d\b/u.test(text)) return true;
if (/\b5\d\d\b/u.test(text) && /service unavailable|bad gateway|gateway timeout|internal server error|provider|upstream|response\s*stream\s*disconnected|responsestreamdisconnected/u.test(text)) return true;