Agents_Team/server/src/codexConfig.ts
2026-03-16 15:22:39 +08:00

338 lines
11 KiB
TypeScript

import os from "node:os";
import path from "node:path";
import { existsSync, readFileSync } from "node:fs";
import { spawn } from "node:child_process";
import type { CodexCliInfo, CodexModelOption, RuntimeSession } from "./types.js";
import { buildCodexExecCommand, buildCodexTakeoverCommand, resolveCodexInvocation } from "./runtime.js";
const fallbackModels = ["gpt-5.4", "gpt-5.2", "gpt-5.2-codex"];
const CODEX_INFO_CACHE_TTL_MS = 5 * 60 * 1000;
let codexCliInfoCache: { value: CodexCliInfo; fetchedAt: number } | null = null;
const stripQuotes = (value: string): string => value.replace(/^['"]|['"]$/g, "");
const quoteForDisplay = (value: string): string => `"${value.replace(/"/g, '\\"')}"`;
const createConfigModelOption = (model: string, isDefault = false): CodexModelOption => ({
model,
displayName: model,
description: "从本机 Codex 配置读取到的模型。",
defaultReasoningEffort: "medium",
supportedReasoningEfforts: [],
supportsPersonality: true,
isDefault,
hidden: false,
source: "config"
});
const mergeModelOptions = (
preferred: CodexModelOption[],
fallback: CodexModelOption[],
defaultModel: string
): CodexModelOption[] => {
const merged = new Map<string, CodexModelOption>();
for (const item of [...fallback, ...preferred]) {
merged.set(item.model, {
...item,
isDefault: item.model === defaultModel
});
}
if (!merged.has(defaultModel)) {
merged.set(defaultModel, createConfigModelOption(defaultModel, true));
}
return [...merged.values()].sort((left, right) => {
if (left.model === defaultModel) {
return -1;
}
if (right.model === defaultModel) {
return 1;
}
if (left.isDefault && !right.isDefault) {
return -1;
}
if (!left.isDefault && right.isDefault) {
return 1;
}
return left.model.localeCompare(right.model);
});
};
const readCodexCliInfoFromConfig = (): CodexCliInfo => {
const configPath = path.join(os.homedir(), ".codex", "config.toml");
const info: CodexCliInfo = {
defaultModel: "gpt-5.4",
activeProfile: "",
provider: "",
contextWindow: null,
autoCompactTokenLimit: null,
suggestedModels: [...fallbackModels],
availableModels: fallbackModels.map((model, index) => createConfigModelOption(model, index === 0))
};
if (!existsSync(configPath)) {
return info;
}
const raw = readFileSync(configPath, "utf8");
let currentSection = "";
const discoveredModels = new Set<string>(fallbackModels);
for (const originalLine of raw.split(/\r?\n/)) {
const line = originalLine.replace(/\s+#.*$/, "").trim();
if (!line) {
continue;
}
const sectionMatch = line.match(/^\[(.+)\]$/);
if (sectionMatch) {
currentSection = sectionMatch[1];
continue;
}
const kvMatch = line.match(/^([A-Za-z0-9_.-]+)\s*=\s*(.+)$/);
if (!kvMatch) {
continue;
}
const key = kvMatch[1];
const value = stripQuotes(kvMatch[2].trim());
if (!currentSection) {
if (key === "model") {
info.defaultModel = value;
discoveredModels.add(value);
}
if (key === "profile") {
info.activeProfile = value;
}
if (key === "model_provider") {
info.provider = value;
}
if (key === "model_context_window") {
const parsed = Number(value);
info.contextWindow = Number.isFinite(parsed) ? parsed : info.contextWindow;
}
if (key === "model_auto_compact_token_limit") {
const parsed = Number(value);
info.autoCompactTokenLimit = Number.isFinite(parsed) ? parsed : info.autoCompactTokenLimit;
}
}
if (key === "model") {
discoveredModels.add(value);
}
if (currentSection === "notice.model_migrations") {
discoveredModels.add(stripQuotes(key));
discoveredModels.add(value);
}
}
info.availableModels = mergeModelOptions(
[],
Array.from(new Set([info.defaultModel, ...discoveredModels])).map((model) => createConfigModelOption(model, model === info.defaultModel)),
info.defaultModel
);
info.suggestedModels = info.availableModels.map((item) => item.model);
return info;
};
type AppServerRpcResponse =
| { id: number; result?: unknown; error?: { code?: number; message?: string } }
| { method: string; params?: unknown };
const parseJsonLines = (buffer: string): AppServerRpcResponse[] =>
buffer
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean)
.map((line) => {
try {
return JSON.parse(line) as AppServerRpcResponse;
} catch {
return null;
}
})
.filter((item): item is AppServerRpcResponse => item !== null);
const readModelsFromAppServer = async (): Promise<CodexModelOption[]> =>
new Promise((resolve, reject) => {
const invocation = resolveCodexInvocation();
const child = spawn(invocation.command, [...invocation.prefixArgs, "app-server"], {
stdio: ["pipe", "pipe", "pipe"],
windowsHide: true
});
let stdout = "";
let stderr = "";
let settled = false;
let modelListRequested = false;
const timeoutHandle = setTimeout(() => {
finish(new Error(stderr.trim() || "读取 Codex app-server 模型列表超时"));
}, 15000);
const finish = (value: CodexModelOption[] | Error) => {
if (settled) {
return;
}
settled = true;
clearTimeout(timeoutHandle);
child.kill();
if (value instanceof Error) {
reject(value);
} else {
resolve(value);
}
};
child.stdout.on("data", (chunk) => {
stdout += chunk.toString();
const messages = parseJsonLines(stdout);
const initializeAck = messages.find((message) => "id" in message && message.id === 1);
if (initializeAck && !modelListRequested) {
modelListRequested = true;
child.stdin.write(
`${JSON.stringify({
jsonrpc: "2.0",
id: 2,
method: "model/list",
params: { limit: 100, includeHidden: false }
})}\n`
);
}
const modelList = messages.find((message) => "id" in message && message.id === 2);
if (modelList && "result" in modelList) {
const data = Array.isArray((modelList.result as { data?: unknown[] })?.data)
? ((modelList.result as {
data: Array<{
model?: string;
displayName?: string;
description?: string;
defaultReasoningEffort?: string;
supportedReasoningEfforts?: Array<{ reasoningEffort?: string; description?: string }>;
supportsPersonality?: boolean;
isDefault?: boolean;
hidden?: boolean;
}>;
}).data || [])
: [];
const models: CodexModelOption[] = [];
for (const item of data) {
const model = typeof item.model === "string" ? item.model : "";
if (!model) {
continue;
}
models.push({
model,
displayName: typeof item.displayName === "string" && item.displayName ? item.displayName : model,
description: typeof item.description === "string" ? item.description : "",
defaultReasoningEffort:
typeof item.defaultReasoningEffort === "string" && item.defaultReasoningEffort ? item.defaultReasoningEffort : "medium",
supportedReasoningEfforts: Array.isArray(item.supportedReasoningEfforts)
? item.supportedReasoningEfforts
.map((effort) => ({
reasoningEffort:
typeof effort?.reasoningEffort === "string" && effort.reasoningEffort ? effort.reasoningEffort : "",
description: typeof effort?.description === "string" ? effort.description : ""
}))
.filter((effort) => effort.reasoningEffort)
: [],
supportsPersonality: Boolean(item.supportsPersonality),
isDefault: Boolean(item.isDefault),
hidden: Boolean(item.hidden),
source: "app-server"
});
}
finish(models);
}
if (modelList && "error" in modelList && modelList.error) {
finish(new Error(modelList.error.message || "Codex app-server model/list 失败"));
}
});
child.stderr.on("data", (chunk) => {
stderr += chunk.toString();
});
child.on("error", (error) => finish(error));
child.on("exit", (code) => {
if (!settled && code !== 0) {
finish(new Error(stderr.trim() || `Codex app-server 退出码 ${code}`));
}
});
child.stdin.write(
`${JSON.stringify({
jsonrpc: "2.0",
id: 1,
method: "initialize",
params: {
clientInfo: {
name: "agents-team-workbench",
title: "Agents Team Workbench",
version: "0.1.0"
},
capabilities: {
experimentalApi: true
}
}
})}\n`
);
});
export const getCodexCliInfo = (): CodexCliInfo => codexCliInfoCache?.value || readCodexCliInfoFromConfig();
export const refreshCodexCliInfo = async (force = false): Promise<CodexCliInfo> => {
if (!force && codexCliInfoCache && Date.now() - codexCliInfoCache.fetchedAt < CODEX_INFO_CACHE_TTL_MS) {
return codexCliInfoCache.value;
}
const base = readCodexCliInfoFromConfig();
try {
const appServerModels = await readModelsFromAppServer();
const availableModels = mergeModelOptions(appServerModels, base.availableModels, base.defaultModel);
const merged: CodexCliInfo = {
...base,
availableModels,
suggestedModels: availableModels.map((item) => item.model)
};
codexCliInfoCache = { value: merged, fetchedAt: Date.now() };
return merged;
} catch {
codexCliInfoCache = { value: base, fetchedAt: Date.now() };
return base;
}
};
export const applyCodexConfigToSession = (
session: RuntimeSession,
codex: CodexCliInfo,
projectPath: string
): RuntimeSession => {
const effectiveModel = session.modelSource === "session-override" && session.model ? session.model : codex.defaultModel;
const resumeCommand = session.lastThreadId
? `codex resume ${quoteForDisplay(session.lastThreadId)} -m ${quoteForDisplay(effectiveModel)} -C ${quoteForDisplay(projectPath)}`
: session.resumeCommand;
const identityState =
session.lastThreadId
? "resume_ready"
: session.status === "blocked" || session.status === "offline"
? "needs_rebuild"
: "fresh";
return {
...session,
identityState,
model: effectiveModel,
lastCommand: buildCodexExecCommand(projectPath, `以 ${session.roleLabel} 身份接手当前项目上下文`, effectiveModel),
takeoverCommand: buildCodexTakeoverCommand(projectPath, effectiveModel),
resumeCommand
};
};