Files
pikasTech-unidesk/scripts/src/platform-infra-sub2api-codex/local-codex.ts
T
2026-06-25 16:16:25 +00:00

293 lines
12 KiB
TypeScript

// SPEC: PJ2026-01060307 控制面模块化 draft-2026-06-25-p0. local-codex module for scripts/src/platform-infra-sub2api-codex.ts.
// Moved mechanically from scripts/src/platform-infra-sub2api-codex.ts:3422-3685 for #903.
import { chmodSync, copyFileSync, existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
import { homedir } from "node:os";
import { join } from "node:path";
import type { UniDeskConfig } from "../config";
import { rootPath } from "../config";
import type { RenderedCliResult } from "../output";
import { applyPk01CaddyBlock, prepareFrpcSecret, renderFrpcManifest, type PublicServiceExposure, type PublicServiceTarget } from "../platform-infra-public-service";
import { shortSha256Fingerprint } from "../platform-infra-ops-library";
import {
codexPoolSentinelSummary,
codexPoolSentinelRuntimeImage,
readCodexPoolSentinelConfig,
renderCodexPoolSentinelManifest,
type CodexPoolSentinelConfig,
type CodexPoolSentinelProfileSecret,
} from "../platform-infra-sub2api-codex-sentinel";
import { parseEnvFile, readTextFile, redactRepoPath, requiredEnvValue } from "../secrets";
import { runSshCommandCapture, type SshCaptureResult } from "../ssh";
import type { CodexLocalConsumerTomlOptions, CodexPoolConfig } from "./types";
import { fingerprint, isRecord } from "./config-utils";
import { capture, parseJsonOutput } from "./remote";
import { codexPoolRuntimeTarget } from "./runtime-target";
import { sub2apiConfigPath } from "./types";
export async function fetchPoolApiKey(config: UniDeskConfig, pool: CodexPoolConfig, target = codexPoolRuntimeTarget()): Promise<{ apiKey: string | null; error: string | null }> {
const result = await capture(config, target.route, ["sh"], `
set -u
kubectl -n ${target.namespace} get secret ${pool.apiKeySecretName} -o json
`);
if (result.exitCode !== 0) {
return { apiKey: null, error: `read pool API key secret failed: ${result.stderr.slice(-1000)}` };
}
const parsed = parseJsonOutput(result.stdout);
const data = isRecord(parsed?.data) ? parsed.data : null;
const encoded = typeof data?.[pool.apiKeySecretKey] === "string" ? data[pool.apiKeySecretKey] : null;
if (encoded === null) return { apiKey: null, error: `${target.namespace}/${pool.apiKeySecretName}.${pool.apiKeySecretKey} missing` };
try {
const apiKey = Buffer.from(encoded, "base64").toString("utf8");
return apiKey.length > 0 ? { apiKey, error: null } : { apiKey: null, error: "decoded API key is empty" };
} catch (error) {
return { apiKey: null, error: error instanceof Error ? error.message : String(error) };
}
}
export function writeLocalCodexConfig(pool: CodexPoolConfig, apiKey: string, target = codexPoolRuntimeTarget()): Record<string, unknown> {
const codexDir = join(homedir(), ".codex");
mkdirSync(codexDir, { recursive: true, mode: 0o700 });
const configPath = join(codexDir, "config.toml");
const authPath = join(codexDir, "auth.json");
const backupConfigPath = join(codexDir, `config.toml.${pool.localCodex.backupSuffix}`);
const backupAuthPath = join(codexDir, `auth.json.${pool.localCodex.backupSuffix}`);
if (!existsSync(configPath)) throw new Error(`${configPath} missing`);
if (!existsSync(authPath)) throw new Error(`${authPath} missing`);
const configBackupAction = copyIfMissing(configPath, backupConfigPath);
const authBackupAction = copyIfMissing(authPath, backupAuthPath);
const currentToml = readFileSync(configPath, "utf8");
const nextToml = updateCodexConfigToml(currentToml, pool, target);
writeFileSync(configPath, nextToml, { encoding: "utf8", mode: 0o600 });
chmodSync(configPath, 0o600);
writeFileSync(authPath, `${JSON.stringify({ OPENAI_API_KEY: apiKey }, null, 2)}\n`, { encoding: "utf8", mode: 0o600 });
chmodSync(authPath, 0o600);
return {
ok: true,
codexDir,
config: {
path: configPath,
bytes: statSync(configPath).size,
backupPath: backupConfigPath,
backupAction: configBackupAction,
backupBytes: statSync(backupConfigPath).size,
},
auth: {
path: authPath,
bytes: statSync(authPath).size,
backupPath: backupAuthPath,
backupAction: authBackupAction,
backupBytes: statSync(backupAuthPath).size,
openaiApiKeyShape: "string",
openaiApiKeyBytes: Buffer.byteLength(apiKey, "utf8"),
openaiApiKeyFingerprint: fingerprint(apiKey),
valuesPrinted: false,
},
provider: {
name: pool.localCodex.providerName,
baseUrl: codexConsumerBaseUrl(pool, target),
wireApi: pool.localCodex.wireApi,
modelContextWindow: pool.localCodex.modelContextWindow,
modelAutoCompactTokenLimit: pool.localCodex.modelAutoCompactTokenLimit,
supportsWebSockets: pool.localCodex.supportsWebSockets,
responsesWebSocketsV2: pool.localCodex.responsesWebSocketsV2,
},
valuesPrinted: false,
};
}
export function copyIfMissing(source: string, target: string): "created" | "kept-existing" {
if (existsSync(target)) return "kept-existing";
copyFileSync(source, target);
chmodSync(target, 0o600);
return "created";
}
export function updateCodexConfigToml(current: string, pool: CodexPoolConfig, target = codexPoolRuntimeTarget()): string {
return renderCodexLocalConsumerToml(current, {
providerName: pool.localCodex.providerName,
baseUrl: codexConsumerBaseUrl(pool, target),
wireApi: pool.localCodex.wireApi,
modelContextWindow: pool.localCodex.modelContextWindow,
modelAutoCompactTokenLimit: pool.localCodex.modelAutoCompactTokenLimit,
supportsWebSockets: pool.localCodex.supportsWebSockets,
responsesWebSocketsV2: pool.localCodex.responsesWebSocketsV2,
});
}
export function codexConsumerBaseUrl(pool: CodexPoolConfig, target = codexPoolRuntimeTarget()): string {
void pool;
const baseUrl = target.publicBaseUrl;
if (baseUrl === null) throw new Error(`${sub2apiConfigPath}.targets[${target.id}].publicExposure.enabled must be true before resolving the Codex consumer base URL`);
return `${baseUrl.replace(/\/+$/u, "")}/`;
}
export function renderCodexLocalConsumerToml(current: string, options: CodexLocalConsumerTomlOptions): string {
let next = upsertTopLevelTomlString(current, "model_provider", options.providerName);
next = upsertTopLevelTomlInteger(next, "model_context_window", options.modelContextWindow);
next = upsertTopLevelTomlInteger(next, "model_auto_compact_token_limit", options.modelAutoCompactTokenLimit);
next = upsertProviderSection(next, options);
next = upsertTomlSectionBoolean(next, "features", "responses_websockets_v2", options.responsesWebSocketsV2);
return next.endsWith("\n") ? next : `${next}\n`;
}
export function upsertTopLevelTomlString(text: string, key: string, value: string): string {
const lines = text.split(/\r?\n/u);
const firstSection = lines.findIndex((line) => /^\s*\[/.test(line));
const end = firstSection === -1 ? lines.length : firstSection;
for (let index = 0; index < end; index += 1) {
if (new RegExp(`^\\s*${escapeRegExp(key)}\\s*=`).test(lines[index])) {
lines[index] = `${key} = ${tomlString(value)}`;
return lines.join("\n");
}
}
lines.splice(0, 0, `${key} = ${tomlString(value)}`);
return lines.join("\n");
}
export function upsertTopLevelTomlInteger(text: string, key: string, value: number): string {
const lines = text.split(/\r?\n/u);
const firstSection = lines.findIndex((line) => /^\s*\[/.test(line));
const end = firstSection === -1 ? lines.length : firstSection;
const assignment = `${key} = ${value}`;
for (let index = 0; index < end; index += 1) {
if (new RegExp(`^\\s*${escapeRegExp(key)}\\s*=`).test(lines[index])) {
lines[index] = assignment;
return lines.join("\n");
}
}
lines.splice(end, 0, assignment);
return lines.join("\n");
}
export function upsertProviderSection(text: string, options: CodexLocalConsumerTomlOptions): string {
const { providerName, baseUrl, wireApi, supportsWebSockets } = options;
const header = `[model_providers.${providerName}]`;
const lines = text.split(/\r?\n/u);
const start = lines.findIndex((line) => line.trim() === header);
const canonical = [
`name = ${tomlString(providerName)}`,
`base_url = ${tomlString(baseUrl)}`,
`wire_api = ${tomlString(wireApi)}`,
"requires_openai_auth = true",
`supports_websockets = ${tomlBoolean(supportsWebSockets)}`,
];
if (start === -1) {
const needsBlank = lines.length > 0 && lines[lines.length - 1].trim() !== "";
return [...lines, ...(needsBlank ? [""] : []), header, ...canonical].join("\n");
}
let end = lines.length;
for (let index = start + 1; index < lines.length; index += 1) {
if (/^\s*\[/.test(lines[index])) {
end = index;
break;
}
}
const preserved = lines.slice(start + 1, end).filter((line) => {
return !/^\s*(name|base_url|wire_api|requires_openai_auth|supports_websockets|env_key)\s*=/u.test(line);
});
const replacement = [header, ...canonical, ...preserved.filter((line) => line.trim() !== "")];
return [...lines.slice(0, start), ...replacement, ...lines.slice(end)].join("\n");
}
export function upsertTomlSectionBoolean(text: string, sectionName: string, key: string, value: boolean): string {
const header = `[${sectionName}]`;
const lines = text.split(/\r?\n/u);
const start = lines.findIndex((line) => line.trim() === header);
const assignment = `${key} = ${tomlBoolean(value)}`;
if (start === -1) {
const needsBlank = lines.length > 0 && lines[lines.length - 1].trim() !== "";
return [...lines, ...(needsBlank ? [""] : []), header, assignment].join("\n");
}
let end = lines.length;
for (let index = start + 1; index < lines.length; index += 1) {
if (/^\s*\[/.test(lines[index])) {
end = index;
break;
}
}
for (let index = start + 1; index < end; index += 1) {
if (new RegExp(`^\\s*${escapeRegExp(key)}\\s*=`).test(lines[index])) {
lines[index] = assignment;
return lines.join("\n");
}
}
return [...lines.slice(0, start + 1), assignment, ...lines.slice(start + 1)].join("\n");
}
export async function validatePublicGatewayWithKey(pool: CodexPoolConfig, apiKey: string, target = codexPoolRuntimeTarget()): Promise<Record<string, unknown>> {
const probe = await probePublicModels(pool, "with-api-key", apiKey, target);
return {
...probe,
ok: probe.ok === true,
apiKeyFingerprint: fingerprint(apiKey),
keyPreview: apiKeyPreview(apiKey),
valuesPrinted: false,
};
}
export async function probePublicModels(pool: CodexPoolConfig, mode: "with-api-key" | "without-api-key", apiKey?: string, target = codexPoolRuntimeTarget()): Promise<Record<string, unknown>> {
void pool;
if (target.publicBaseUrl === null) throw new Error(`${sub2apiConfigPath}.targets[${target.id}].publicExposure.enabled must be true before probing the public gateway`);
const baseUrl = target.publicBaseUrl;
const url = `${baseUrl.replace(/\/+$/u, "")}/v1/models`;
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 8000);
try {
const response = await fetch(url, {
method: "GET",
headers: apiKey ? { Authorization: `Bearer ${apiKey}` } : undefined,
signal: controller.signal,
});
const body = await response.text();
let parsed: unknown = null;
try {
parsed = body.trim().length > 0 ? JSON.parse(body) : null;
} catch {
parsed = null;
}
const modelCount = isRecord(parsed) && Array.isArray(parsed.data) ? parsed.data.length : null;
return {
ok: response.ok,
mode,
method: "GET /v1/models",
baseUrl,
httpStatus: response.status,
modelCount,
bodyPreview: response.ok ? "" : body.slice(0, 500),
valuesPrinted: false,
};
} catch (error) {
return {
ok: false,
mode,
method: "GET /v1/models",
baseUrl,
httpStatus: 0,
error: error instanceof Error ? error.message : String(error),
valuesPrinted: false,
};
} finally {
clearTimeout(timeout);
}
}
export function apiKeyPreview(apiKey: string): string {
if (apiKey.length <= 14) return "***";
return `${apiKey.slice(0, 10)}...${apiKey.slice(-4)}`;
}
export function tomlString(value: string): string {
return JSON.stringify(value);
}
export function tomlBoolean(value: boolean): string {
return value ? "true" : "false";
}
export function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&");
}