feat: manage sub2api codex pool
This commit is contained in:
@@ -4,3 +4,43 @@ pool:
|
||||
apiKeySecretName: sub2api-codex-pool-api-key
|
||||
apiKeySecretKey: API_KEY
|
||||
minOwnerBalanceUsd: 1000
|
||||
profiles:
|
||||
entries:
|
||||
- profile: default
|
||||
accountName: unidesk-codex-default
|
||||
configFile: config.toml.pre-sub2api
|
||||
authFile: auth.json.pre-sub2api
|
||||
fallbackConfigFile: config.toml
|
||||
fallbackAuthFile: auth.json
|
||||
- profile: HY
|
||||
accountName: unidesk-codex-hy
|
||||
configFile: config.toml.HY
|
||||
authFile: auth.json.HY
|
||||
- profile: gptclub
|
||||
accountName: unidesk-codex-gptclub
|
||||
configFile: config.toml.gptclub
|
||||
authFile: auth.json.gptclub
|
||||
- profile: pinche
|
||||
accountName: unidesk-codex-pinche
|
||||
configFile: config.toml.pinche
|
||||
authFile: auth.json.pinche
|
||||
publicExposure:
|
||||
enabled: true
|
||||
proxyName: platform-infra-sub2api
|
||||
configMapName: sub2api-frpc-config
|
||||
deploymentName: sub2api-frpc
|
||||
frpcImage: fatedier/frpc:v0.68.1
|
||||
serverAddr: 74.48.78.17
|
||||
serverPort: 7000
|
||||
remotePort: 21880
|
||||
localIP: sub2api.platform-infra.svc.cluster.local
|
||||
localPort: 8080
|
||||
publicBaseUrl: http://74.48.78.17:21880
|
||||
masterBaseUrl: http://127.0.0.1:21880
|
||||
masterFrps:
|
||||
configPath: /opt/hwlab-frp/frps.dev.toml
|
||||
containerName: hwlab-frps-dev
|
||||
localCodex:
|
||||
backupSuffix: pre-sub2api
|
||||
providerName: OpenAI
|
||||
wireApi: responses
|
||||
|
||||
+1
-1
@@ -63,7 +63,7 @@ export function rootHelp(): unknown {
|
||||
{ command: "hwlab nodes control-plane|git-mirror|secret --node G14 --lane v03", description: "Manage HWLAB node/lane runtime prerequisites for v0.3+ with the node identity passed as data instead of a command family." },
|
||||
{ command: "hwlab g14 monitor-prs | hwlab g14 control-plane status|apply|trigger-current|runtime-migration|cleanup-runs|cleanup-released-pvs | hwlab g14 git-mirror status|apply|sync|flush | hwlab g14 tools-image status|build", description: "Start the legacy G14 PR monitor, run bounded v0.2 Tekton/Argo control-plane, manual PipelineRun trigger, runtime migration, CI workspace retention, manual devops-infra git mirror/relay maintenance, or fixed HWLAB CI tools image actions; long confirmed trigger/sync/flush actions return async jobs by default." },
|
||||
{ command: "agentrun v01 queue|sessions|control-plane|git-mirror", description: "Use AgentRun v0.1 Queue and Sessions as the active commander entry through the official G14 CLI bridge, plus bounded Tekton/Argo and git-mirror operations." },
|
||||
{ command: "platform-infra sub2api plan|apply|status|validate", description: "Deploy and validate the G14 k3s internal-only Sub2API service in the platform-infra namespace through the controlled UniDesk CLI." },
|
||||
{ command: "platform-infra sub2api plan|apply|status|validate|codex-pool", description: "Deploy Sub2API in G14 platform-infra, manage the YAML-controlled Codex upstream pool, expose the unified API through FRP when needed, and configure master ~/.codex without printing API keys." },
|
||||
{ command: "hwlab cd audit --env dev | hwlab cd status --env dev | hwlab cd apply --env dev --dry-run", description: "Legacy D601 HWLAB DEV CD wrapper kept for explicit old-path diagnostics; current HWLAB rollout uses G14 GitOps." },
|
||||
{ command: "code-agent-sandbox", description: "Independent Code Agent Sandbox service skeleton for adapter, mode, and credential-boundary diagnostics." },
|
||||
{ command: "schedule list|get|runs|run|retry-run|delete", description: "Manage backend-core scheduled tasks and run history; schedule run <id> supports --wait-ms N and retry-run reuses the failed run's schedule." },
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { existsSync, readFileSync, readdirSync } from "node:fs";
|
||||
import { chmodSync, copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
|
||||
import { homedir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import type { UniDeskConfig } from "./config";
|
||||
@@ -28,6 +28,10 @@ interface SyncOptions extends DisclosureOptions {
|
||||
confirm: boolean;
|
||||
}
|
||||
|
||||
interface ConfirmOptions extends DisclosureOptions {
|
||||
confirm: boolean;
|
||||
}
|
||||
|
||||
interface CodexProfile {
|
||||
profile: string;
|
||||
accountName: string;
|
||||
@@ -51,19 +55,58 @@ interface CodexPoolConfig {
|
||||
apiKeySecretName: string;
|
||||
apiKeySecretKey: string;
|
||||
minOwnerBalanceUsd: number;
|
||||
profiles: CodexPoolProfileConfig[];
|
||||
publicExposure: CodexPoolPublicExposureConfig;
|
||||
localCodex: CodexPoolLocalCodexConfig;
|
||||
}
|
||||
|
||||
interface CodexPoolProfileConfig {
|
||||
profile: string;
|
||||
accountName: string | null;
|
||||
configFile: string;
|
||||
authFile: string;
|
||||
fallbackConfigFile: string | null;
|
||||
fallbackAuthFile: string | null;
|
||||
}
|
||||
|
||||
interface CodexPoolPublicExposureConfig {
|
||||
enabled: boolean;
|
||||
proxyName: string;
|
||||
configMapName: string;
|
||||
deploymentName: string;
|
||||
frpcImage: string;
|
||||
serverAddr: string;
|
||||
serverPort: number;
|
||||
remotePort: number;
|
||||
localIP: string;
|
||||
localPort: number;
|
||||
publicBaseUrl: string;
|
||||
masterBaseUrl: string;
|
||||
masterFrps: {
|
||||
configPath: string;
|
||||
containerName: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface CodexPoolLocalCodexConfig {
|
||||
backupSuffix: string;
|
||||
providerName: string;
|
||||
wireApi: string;
|
||||
}
|
||||
|
||||
export function codexPoolHelp(): unknown {
|
||||
const pool = readCodexPoolConfig();
|
||||
return {
|
||||
command: "platform-infra sub2api codex-pool plan|sync|validate",
|
||||
command: "platform-infra sub2api codex-pool plan|sync|validate|expose|configure-local",
|
||||
output: "json",
|
||||
usage: [
|
||||
"bun scripts/cli.ts platform-infra sub2api codex-pool plan",
|
||||
"bun scripts/cli.ts platform-infra sub2api codex-pool sync --confirm",
|
||||
"bun scripts/cli.ts platform-infra sub2api codex-pool validate [--full|--raw]",
|
||||
"bun scripts/cli.ts platform-infra sub2api codex-pool expose --confirm",
|
||||
"bun scripts/cli.ts platform-infra sub2api codex-pool configure-local --confirm",
|
||||
],
|
||||
description: "Import ~/.codex/config.toml plus config.toml.* API-key profiles into one Sub2API OpenAI pool and expose one internal API_KEY stored in a k3s Secret.",
|
||||
description: "Import YAML-selected ~/.codex API-key profiles into one Sub2API OpenAI pool, expose one unified API_KEY, and optionally configure this master server's ~/.codex consumer endpoint.",
|
||||
target: {
|
||||
route: g14K3sRoute,
|
||||
namespace,
|
||||
@@ -72,6 +115,8 @@ export function codexPoolHelp(): unknown {
|
||||
poolGroupName: pool.groupName,
|
||||
poolApiKeySecretName: pool.apiKeySecretName,
|
||||
poolApiKeySecretKey: pool.apiKeySecretKey,
|
||||
publicBaseUrl: pool.publicExposure.enabled ? pool.publicExposure.publicBaseUrl : null,
|
||||
masterBaseUrl: pool.publicExposure.enabled ? pool.publicExposure.masterBaseUrl : null,
|
||||
secretValuesPrinted: false,
|
||||
},
|
||||
};
|
||||
@@ -82,6 +127,8 @@ export async function runCodexPoolCommand(config: UniDeskConfig, args: string[])
|
||||
if (action === "plan") return codexPoolPlan();
|
||||
if (action === "sync") return await codexPoolSync(config, parseSyncOptions(args.slice(1)));
|
||||
if (action === "validate") return await codexPoolValidate(config, parseDisclosureOptions(args.slice(1)));
|
||||
if (action === "expose") return await codexPoolExpose(config, parseConfirmOptions(args.slice(1)));
|
||||
if (action === "configure-local") return await codexPoolConfigureLocal(config, parseConfirmOptions(args.slice(1)));
|
||||
return {
|
||||
ok: false,
|
||||
error: "unsupported-platform-infra-sub2api-codex-pool-command",
|
||||
@@ -96,6 +143,12 @@ function parseSyncOptions(args: string[]): SyncOptions {
|
||||
return { ...disclosure, confirm: args.includes("--confirm") };
|
||||
}
|
||||
|
||||
function parseConfirmOptions(args: string[]): ConfirmOptions {
|
||||
validateOptions(args, new Set(["--confirm", "--full", "--raw"]));
|
||||
const disclosure = parseDisclosureOptions(args.filter((arg) => arg !== "--confirm"));
|
||||
return { ...disclosure, confirm: args.includes("--confirm") };
|
||||
}
|
||||
|
||||
function parseDisclosureOptions(args: string[]): DisclosureOptions {
|
||||
validateOptions(args, new Set(["--full", "--raw"]));
|
||||
const raw = args.includes("--raw");
|
||||
@@ -118,8 +171,8 @@ function codexPoolPlan(): Record<string, unknown> {
|
||||
action: "platform-infra-sub2api-codex-pool-plan",
|
||||
source: {
|
||||
directory: join(homedir(), ".codex"),
|
||||
configPattern: "config.toml and config.toml.*",
|
||||
authPattern: "auth.json and auth.json.*",
|
||||
configPattern: "YAML-selected config files under ~/.codex",
|
||||
authPattern: "YAML-selected auth files under ~/.codex",
|
||||
valuesPrinted: false,
|
||||
},
|
||||
target: poolTarget(),
|
||||
@@ -132,6 +185,9 @@ function codexPoolPlan(): Record<string, unknown> {
|
||||
accountType: "openai/apikey",
|
||||
grouping: `All discovered Codex profiles are bound to one Sub2API group named ${pool.groupName}.`,
|
||||
unifiedApiKey: `The client-facing API_KEY is controlled by k3s Secret ${namespace}/${pool.apiKeySecretName}.${pool.apiKeySecretKey}.`,
|
||||
publicExposure: pool.publicExposure.enabled
|
||||
? `Master server consumers use ${pool.publicExposure.masterBaseUrl}; FRP proxy ${pool.publicExposure.proxyName} maps public ${pool.publicExposure.publicBaseUrl} to ${pool.publicExposure.localIP}:${pool.publicExposure.localPort}.`
|
||||
: "Public FRP exposure is disabled by YAML.",
|
||||
idempotency: "sync reuses the group, account names, and k3s Secret when they already exist; credentials are updated from the current local Codex files.",
|
||||
configPolicy: "UniDesk-owned durable configuration remains YAML-first; local ~/.codex files and runtime Secrets are not committed.",
|
||||
},
|
||||
@@ -222,22 +278,126 @@ async function codexPoolValidate(config: UniDeskConfig, options: DisclosureOptio
|
||||
};
|
||||
}
|
||||
|
||||
async function codexPoolExpose(config: UniDeskConfig, options: ConfirmOptions): Promise<Record<string, unknown>> {
|
||||
const pool = readCodexPoolConfig();
|
||||
if (!pool.publicExposure.enabled) {
|
||||
return {
|
||||
ok: true,
|
||||
action: "platform-infra-sub2api-codex-pool-expose",
|
||||
mode: "disabled-by-yaml",
|
||||
target: poolTarget(pool),
|
||||
};
|
||||
}
|
||||
if (!options.confirm) {
|
||||
return {
|
||||
ok: true,
|
||||
action: "platform-infra-sub2api-codex-pool-expose",
|
||||
mode: "dry-run",
|
||||
target: poolTarget(pool),
|
||||
publicExposure: publicExposureSummary(pool),
|
||||
next: {
|
||||
confirm: "bun scripts/cli.ts platform-infra sub2api codex-pool expose --confirm",
|
||||
},
|
||||
};
|
||||
}
|
||||
const masterResult = await applyMasterFrpsAllowPort(pool);
|
||||
const remoteResult = await capture(config, g14K3sRoute, ["script"], exposeScript(pool));
|
||||
const parsed = parseJsonOutput(remoteResult.stdout);
|
||||
const publicProbe = await probePublicModels(pool, "without-api-key");
|
||||
const ok = masterResult.ok && remoteResult.exitCode === 0 && boolField(parsed, "ok", false) && publicProbe.httpStatus === 401;
|
||||
if (options.raw) {
|
||||
return {
|
||||
ok,
|
||||
action: "platform-infra-sub2api-codex-pool-expose",
|
||||
masterFrps: masterResult,
|
||||
remote: compactCapture(remoteResult, { full: true }),
|
||||
parsed,
|
||||
publicProbe,
|
||||
};
|
||||
}
|
||||
return {
|
||||
ok,
|
||||
action: "platform-infra-sub2api-codex-pool-expose",
|
||||
mode: "confirmed",
|
||||
publicExposure: publicExposureSummary(pool),
|
||||
masterFrps: masterResult,
|
||||
remote: parsed ?? compactCapture(remoteResult, { full: options.full || remoteResult.exitCode !== 0 }),
|
||||
publicProbe,
|
||||
next: {
|
||||
configureLocal: "bun scripts/cli.ts platform-infra sub2api codex-pool configure-local --confirm",
|
||||
validate: "bun scripts/cli.ts platform-infra sub2api codex-pool validate",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function codexPoolConfigureLocal(config: UniDeskConfig, options: ConfirmOptions): Promise<Record<string, unknown>> {
|
||||
const pool = readCodexPoolConfig();
|
||||
const codexDir = join(homedir(), ".codex");
|
||||
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 (!options.confirm) {
|
||||
return {
|
||||
ok: true,
|
||||
action: "platform-infra-sub2api-codex-pool-configure-local",
|
||||
mode: "dry-run",
|
||||
target: {
|
||||
codexDir,
|
||||
configPath,
|
||||
authPath,
|
||||
backupConfigPath,
|
||||
backupAuthPath,
|
||||
baseUrl: pool.publicExposure.masterBaseUrl,
|
||||
providerName: pool.localCodex.providerName,
|
||||
wireApi: pool.localCodex.wireApi,
|
||||
valuesPrinted: false,
|
||||
},
|
||||
next: {
|
||||
confirm: "bun scripts/cli.ts platform-infra sub2api codex-pool configure-local --confirm",
|
||||
},
|
||||
};
|
||||
}
|
||||
if (!pool.publicExposure.enabled) throw new Error(`${codexPoolConfigPath}.publicExposure.enabled must be true before configure-local`);
|
||||
const keyResult = await fetchPoolApiKey(config, pool);
|
||||
if (keyResult.apiKey === null) {
|
||||
return {
|
||||
ok: false,
|
||||
action: "platform-infra-sub2api-codex-pool-configure-local",
|
||||
error: keyResult.error ?? "pool API key missing",
|
||||
valuesPrinted: false,
|
||||
};
|
||||
}
|
||||
const writeResult = writeLocalCodexConfig(pool, keyResult.apiKey);
|
||||
const validateResult = await validatePublicGatewayWithKey(pool, keyResult.apiKey);
|
||||
return {
|
||||
ok: writeResult.ok && validateResult.ok,
|
||||
action: "platform-infra-sub2api-codex-pool-configure-local",
|
||||
mode: "confirmed",
|
||||
local: writeResult,
|
||||
validation: validateResult,
|
||||
apiKey: {
|
||||
secret: `${namespace}/${pool.apiKeySecretName}.${pool.apiKeySecretKey}`,
|
||||
keyPreview: apiKeyPreview(keyResult.apiKey),
|
||||
apiKeyFingerprint: fingerprint(keyResult.apiKey),
|
||||
valuesPrinted: false,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function collectCodexProfiles(): CodexProfile[] {
|
||||
const codexDir = join(homedir(), ".codex");
|
||||
const pool = readCodexPoolConfig();
|
||||
if (!existsSync(codexDir)) return [];
|
||||
const configFiles = readdirSync(codexDir)
|
||||
.filter((file) => file === "config.toml" || file.startsWith("config.toml."))
|
||||
.sort((a, b) => {
|
||||
if (a === "config.toml") return -1;
|
||||
if (b === "config.toml") return 1;
|
||||
return a.localeCompare(b);
|
||||
});
|
||||
const seenAccountNames = new Set<string>();
|
||||
return configFiles.map((configFile) => {
|
||||
const suffix = configFile === "config.toml" ? "" : configFile.slice("config.toml.".length);
|
||||
const profile = suffix === "" ? "default" : suffix;
|
||||
const accountName = uniqueAccountName(profile, seenAccountNames);
|
||||
const authFile = suffix === "" ? "auth.json" : `auth.json.${suffix}`;
|
||||
const configs = pool.profiles.length > 0 ? pool.profiles : discoverCodexProfileConfigs(codexDir);
|
||||
return configs.map((entry) => {
|
||||
const resolved = resolveProfileFiles(codexDir, entry);
|
||||
const profile = entry.profile;
|
||||
const accountName = entry.accountName ?? uniqueAccountName(profile, seenAccountNames);
|
||||
seenAccountNames.add(accountName);
|
||||
const configFile = resolved.configFile;
|
||||
const authFile = resolved.authFile;
|
||||
const configPath = join(codexDir, configFile);
|
||||
const authPath = join(codexDir, authFile);
|
||||
const base: CodexProfile = {
|
||||
@@ -258,6 +418,7 @@ function collectCodexProfiles(): CodexProfile[] {
|
||||
};
|
||||
|
||||
try {
|
||||
if (!existsSync(configPath)) throw new Error(`config file ${configFile} is missing`);
|
||||
const parsed = Bun.TOML.parse(readFileSync(configPath, "utf8")) as unknown;
|
||||
if (!isRecord(parsed)) throw new Error("config is not a TOML object");
|
||||
const providers = parsed.model_providers;
|
||||
@@ -295,14 +456,40 @@ function collectCodexProfiles(): CodexProfile[] {
|
||||
});
|
||||
}
|
||||
|
||||
function discoverCodexProfileConfigs(codexDir: string): CodexPoolProfileConfig[] {
|
||||
return readdirSync(codexDir)
|
||||
.filter((file) => file === "config.toml" || file.startsWith("config.toml."))
|
||||
.sort((a, b) => {
|
||||
if (a === "config.toml") return -1;
|
||||
if (b === "config.toml") return 1;
|
||||
return a.localeCompare(b);
|
||||
})
|
||||
.map((configFile) => {
|
||||
const suffix = configFile === "config.toml" ? "" : configFile.slice("config.toml.".length);
|
||||
const profile = suffix === "" ? "default" : suffix;
|
||||
return {
|
||||
profile,
|
||||
accountName: null,
|
||||
configFile,
|
||||
authFile: suffix === "" ? "auth.json" : `auth.json.${suffix}`,
|
||||
fallbackConfigFile: null,
|
||||
fallbackAuthFile: null,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function resolveProfileFiles(codexDir: string, profile: CodexPoolProfileConfig): { configFile: string; authFile: string } {
|
||||
const configFile = existsSync(join(codexDir, profile.configFile)) || profile.fallbackConfigFile === null
|
||||
? profile.configFile
|
||||
: profile.fallbackConfigFile;
|
||||
const authFile = existsSync(join(codexDir, profile.authFile)) || profile.fallbackAuthFile === null
|
||||
? profile.authFile
|
||||
: profile.fallbackAuthFile;
|
||||
return { configFile, authFile };
|
||||
}
|
||||
|
||||
function readCodexPoolConfig(): CodexPoolConfig {
|
||||
const defaults: CodexPoolConfig = {
|
||||
groupName: defaultPoolGroupName,
|
||||
apiKeyName: defaultPoolApiKeyName,
|
||||
apiKeySecretName: defaultPoolApiKeySecretName,
|
||||
apiKeySecretKey: defaultPoolApiKeySecretKey,
|
||||
minOwnerBalanceUsd: defaultMinOwnerBalanceUsd,
|
||||
};
|
||||
const defaults = defaultCodexPoolConfig();
|
||||
if (!existsSync(codexPoolConfigPath)) return defaults;
|
||||
const parsed = Bun.YAML.parse(readFileSync(codexPoolConfigPath, "utf8")) as unknown;
|
||||
if (!isRecord(parsed)) throw new Error(`${codexPoolConfigPath} must contain a YAML object`);
|
||||
@@ -314,6 +501,9 @@ function readCodexPoolConfig(): CodexPoolConfig {
|
||||
apiKeySecretName: stringValue(pool.apiKeySecretName) ?? defaults.apiKeySecretName,
|
||||
apiKeySecretKey: stringValue(pool.apiKeySecretKey) ?? defaults.apiKeySecretKey,
|
||||
minOwnerBalanceUsd: numberValue(pool.minOwnerBalanceUsd) ?? defaults.minOwnerBalanceUsd,
|
||||
profiles: readProfileConfig(parsed.profiles, defaults.profiles),
|
||||
publicExposure: readPublicExposureConfig(parsed.publicExposure, defaults.publicExposure),
|
||||
localCodex: readLocalCodexConfig(parsed.localCodex, defaults.localCodex),
|
||||
};
|
||||
validateKubernetesName(config.groupName, "pool.groupName", false);
|
||||
validateKubernetesName(config.apiKeySecretName, "pool.apiKeySecretName", true);
|
||||
@@ -324,6 +514,133 @@ function readCodexPoolConfig(): CodexPoolConfig {
|
||||
return config;
|
||||
}
|
||||
|
||||
function defaultCodexPoolConfig(): CodexPoolConfig {
|
||||
return {
|
||||
groupName: defaultPoolGroupName,
|
||||
apiKeyName: defaultPoolApiKeyName,
|
||||
apiKeySecretName: defaultPoolApiKeySecretName,
|
||||
apiKeySecretKey: defaultPoolApiKeySecretKey,
|
||||
minOwnerBalanceUsd: defaultMinOwnerBalanceUsd,
|
||||
profiles: [],
|
||||
publicExposure: {
|
||||
enabled: false,
|
||||
proxyName: "platform-infra-sub2api",
|
||||
configMapName: "sub2api-frpc-config",
|
||||
deploymentName: "sub2api-frpc",
|
||||
frpcImage: "fatedier/frpc:v0.68.1",
|
||||
serverAddr: "74.48.78.17",
|
||||
serverPort: 7000,
|
||||
remotePort: 21880,
|
||||
localIP: serviceDns.split(":")[0],
|
||||
localPort: 8080,
|
||||
publicBaseUrl: "http://74.48.78.17:21880",
|
||||
masterBaseUrl: "http://127.0.0.1:21880",
|
||||
masterFrps: {
|
||||
configPath: "/opt/hwlab-frp/frps.dev.toml",
|
||||
containerName: "hwlab-frps-dev",
|
||||
},
|
||||
},
|
||||
localCodex: {
|
||||
backupSuffix: "pre-sub2api",
|
||||
providerName: "OpenAI",
|
||||
wireApi: "responses",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function readProfileConfig(value: unknown, defaults: CodexPoolProfileConfig[]): CodexPoolProfileConfig[] {
|
||||
if (!isRecord(value)) return defaults;
|
||||
const entries = value.entries;
|
||||
if (!Array.isArray(entries)) throw new Error(`${codexPoolConfigPath}.profiles.entries must be a YAML array`);
|
||||
return entries.map((entry, index) => {
|
||||
if (!isRecord(entry)) throw new Error(`${codexPoolConfigPath}.profiles.entries[${index}] must be an object`);
|
||||
const profile = stringValue(entry.profile);
|
||||
const configFile = stringValue(entry.configFile);
|
||||
const authFile = stringValue(entry.authFile);
|
||||
if (profile === null || configFile === null || authFile === null) {
|
||||
throw new Error(`${codexPoolConfigPath}.profiles.entries[${index}] requires profile, configFile, and authFile`);
|
||||
}
|
||||
const accountName = stringValue(entry.accountName);
|
||||
if (accountName !== null) validateKubernetesName(accountName, `profiles.entries[${index}].accountName`, false);
|
||||
validateCodexFileName(configFile, `profiles.entries[${index}].configFile`);
|
||||
validateCodexFileName(authFile, `profiles.entries[${index}].authFile`);
|
||||
const fallbackConfigFile = stringValue(entry.fallbackConfigFile);
|
||||
const fallbackAuthFile = stringValue(entry.fallbackAuthFile);
|
||||
if (fallbackConfigFile !== null) validateCodexFileName(fallbackConfigFile, `profiles.entries[${index}].fallbackConfigFile`);
|
||||
if (fallbackAuthFile !== null) validateCodexFileName(fallbackAuthFile, `profiles.entries[${index}].fallbackAuthFile`);
|
||||
return {
|
||||
profile,
|
||||
accountName,
|
||||
configFile,
|
||||
authFile,
|
||||
fallbackConfigFile,
|
||||
fallbackAuthFile,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function readPublicExposureConfig(value: unknown, defaults: CodexPoolPublicExposureConfig): CodexPoolPublicExposureConfig {
|
||||
if (!isRecord(value)) return defaults;
|
||||
const masterFrpsValue = isRecord(value.masterFrps) ? value.masterFrps : {};
|
||||
const config: CodexPoolPublicExposureConfig = {
|
||||
enabled: booleanValue(value.enabled) ?? defaults.enabled,
|
||||
proxyName: stringValue(value.proxyName) ?? defaults.proxyName,
|
||||
configMapName: stringValue(value.configMapName) ?? defaults.configMapName,
|
||||
deploymentName: stringValue(value.deploymentName) ?? defaults.deploymentName,
|
||||
frpcImage: stringValue(value.frpcImage) ?? defaults.frpcImage,
|
||||
serverAddr: stringValue(value.serverAddr) ?? defaults.serverAddr,
|
||||
serverPort: numberValue(value.serverPort) ?? defaults.serverPort,
|
||||
remotePort: numberValue(value.remotePort) ?? defaults.remotePort,
|
||||
localIP: stringValue(value.localIP) ?? defaults.localIP,
|
||||
localPort: numberValue(value.localPort) ?? defaults.localPort,
|
||||
publicBaseUrl: normalizeBaseUrl(stringValue(value.publicBaseUrl)) ?? defaults.publicBaseUrl,
|
||||
masterBaseUrl: normalizeBaseUrl(stringValue(value.masterBaseUrl)) ?? defaults.masterBaseUrl,
|
||||
masterFrps: {
|
||||
configPath: stringValue(masterFrpsValue.configPath) ?? defaults.masterFrps.configPath,
|
||||
containerName: stringValue(masterFrpsValue.containerName) ?? defaults.masterFrps.containerName,
|
||||
},
|
||||
};
|
||||
validateKubernetesName(config.configMapName, "publicExposure.configMapName", true);
|
||||
validateKubernetesName(config.deploymentName, "publicExposure.deploymentName", true);
|
||||
validateProxyName(config.proxyName, "publicExposure.proxyName");
|
||||
validatePort(config.serverPort, "publicExposure.serverPort");
|
||||
validatePort(config.remotePort, "publicExposure.remotePort");
|
||||
validatePort(config.localPort, "publicExposure.localPort");
|
||||
if (!/^[A-Za-z0-9._:/-]+$/u.test(config.frpcImage)) throw new Error(`${codexPoolConfigPath}.publicExposure.frpcImage has an unsupported format`);
|
||||
if (!/^[A-Za-z0-9._:-]+$/u.test(config.serverAddr)) throw new Error(`${codexPoolConfigPath}.publicExposure.serverAddr has an unsupported format`);
|
||||
if (!/^[A-Za-z0-9._:-]+$/u.test(config.localIP)) throw new Error(`${codexPoolConfigPath}.publicExposure.localIP has an unsupported format`);
|
||||
if (!config.masterFrps.configPath.startsWith("/")) throw new Error(`${codexPoolConfigPath}.publicExposure.masterFrps.configPath must be absolute`);
|
||||
validateProxyName(config.masterFrps.containerName, "publicExposure.masterFrps.containerName");
|
||||
return config;
|
||||
}
|
||||
|
||||
function readLocalCodexConfig(value: unknown, defaults: CodexPoolLocalCodexConfig): CodexPoolLocalCodexConfig {
|
||||
if (!isRecord(value)) return defaults;
|
||||
const config = {
|
||||
backupSuffix: stringValue(value.backupSuffix) ?? defaults.backupSuffix,
|
||||
providerName: stringValue(value.providerName) ?? defaults.providerName,
|
||||
wireApi: stringValue(value.wireApi) ?? defaults.wireApi,
|
||||
};
|
||||
if (!/^[A-Za-z0-9._-]+$/u.test(config.backupSuffix)) throw new Error(`${codexPoolConfigPath}.localCodex.backupSuffix has an unsupported format`);
|
||||
validateProxyName(config.providerName, "localCodex.providerName");
|
||||
validateProxyName(config.wireApi, "localCodex.wireApi");
|
||||
return config;
|
||||
}
|
||||
|
||||
function validateCodexFileName(value: string, key: string): void {
|
||||
if (!/^(config\.toml|config\.toml\.[A-Za-z0-9._-]+|auth\.json|auth\.json\.[A-Za-z0-9._-]+)$/u.test(value)) {
|
||||
throw new Error(`${codexPoolConfigPath}.${key} has an unsupported file name`);
|
||||
}
|
||||
}
|
||||
|
||||
function validateProxyName(value: string, key: string): void {
|
||||
if (!/^[A-Za-z0-9._-]+$/u.test(value)) throw new Error(`${codexPoolConfigPath}.${key} has an unsupported format`);
|
||||
}
|
||||
|
||||
function validatePort(value: number, key: string): void {
|
||||
if (!Number.isInteger(value) || value < 1 || value > 65535) throw new Error(`${codexPoolConfigPath}.${key} must be an integer port`);
|
||||
}
|
||||
|
||||
function readAuthAPIKey(authPath: string): { apiKey: string | null; shape: string } {
|
||||
if (!existsSync(authPath)) return { apiKey: null, shape: "missing" };
|
||||
const parsed = JSON.parse(readFileSync(authPath, "utf8")) as unknown;
|
||||
@@ -385,6 +702,451 @@ function poolTarget(pool = readCodexPoolConfig()): Record<string, unknown> {
|
||||
};
|
||||
}
|
||||
|
||||
function publicExposureSummary(pool: CodexPoolConfig): Record<string, unknown> {
|
||||
return {
|
||||
enabled: pool.publicExposure.enabled,
|
||||
proxyName: pool.publicExposure.proxyName,
|
||||
namespace,
|
||||
configMapName: pool.publicExposure.configMapName,
|
||||
deploymentName: pool.publicExposure.deploymentName,
|
||||
frpcImage: pool.publicExposure.frpcImage,
|
||||
frps: {
|
||||
serverAddr: pool.publicExposure.serverAddr,
|
||||
serverPort: pool.publicExposure.serverPort,
|
||||
remotePort: pool.publicExposure.remotePort,
|
||||
masterConfigPath: pool.publicExposure.masterFrps.configPath,
|
||||
masterContainerName: pool.publicExposure.masterFrps.containerName,
|
||||
},
|
||||
upstream: {
|
||||
localIP: pool.publicExposure.localIP,
|
||||
localPort: pool.publicExposure.localPort,
|
||||
serviceDns,
|
||||
},
|
||||
urls: {
|
||||
publicBaseUrl: pool.publicExposure.publicBaseUrl,
|
||||
masterBaseUrl: pool.publicExposure.masterBaseUrl,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function applyMasterFrpsAllowPort(pool: CodexPoolConfig): Promise<Record<string, unknown>> {
|
||||
const path = pool.publicExposure.masterFrps.configPath;
|
||||
const port = pool.publicExposure.remotePort;
|
||||
const container = pool.publicExposure.masterFrps.containerName;
|
||||
if (!existsSync(path)) {
|
||||
return { ok: false, error: "master-frps-config-missing", path, valuesPrinted: false };
|
||||
}
|
||||
const before = readFileSync(path, "utf8");
|
||||
const alreadyAllowed = frpsAllowPortExists(before, port);
|
||||
let backupPath: string | null = null;
|
||||
let action = "kept-existing";
|
||||
if (!alreadyAllowed) {
|
||||
const stamp = new Date().toISOString().replace(/[-:]/gu, "").replace(/\..*$/u, "Z");
|
||||
backupPath = `${path}.bak-sub2api-${stamp}`;
|
||||
copyFileSync(path, backupPath);
|
||||
const next = before.replace(/\s*$/u, "") + `\n\n[[allowPorts]]\nstart = ${port}\nend = ${port}\n`;
|
||||
writeFileSync(path, next, "utf8");
|
||||
chmodSync(path, statSync(backupPath).mode & 0o777);
|
||||
action = "added-allow-port";
|
||||
}
|
||||
const restart = alreadyAllowed ? null : Bun.spawnSync(["docker", "restart", container]);
|
||||
const inspect = Bun.spawnSync(["docker", "inspect", "-f", "{{.State.Running}}", container]);
|
||||
const ok = alreadyAllowed || (restart?.exitCode === 0 && inspect.exitCode === 0 && String(inspect.stdout).trim() === "true");
|
||||
return {
|
||||
ok,
|
||||
action,
|
||||
path,
|
||||
backupPath,
|
||||
remotePort: port,
|
||||
containerName: container,
|
||||
restart: restart === null ? null : {
|
||||
exitCode: restart.exitCode,
|
||||
stdoutTail: Buffer.from(restart.stdout).toString("utf8").slice(-1000),
|
||||
stderrTail: Buffer.from(restart.stderr).toString("utf8").slice(-1000),
|
||||
},
|
||||
inspect: {
|
||||
exitCode: inspect.exitCode,
|
||||
running: String(inspect.stdout).trim() === "true",
|
||||
stderrTail: Buffer.from(inspect.stderr).toString("utf8").slice(-1000),
|
||||
},
|
||||
valuesPrinted: false,
|
||||
};
|
||||
}
|
||||
|
||||
function frpsAllowPortExists(toml: string, port: number): boolean {
|
||||
const sections = toml.split(/(?=\[\[allowPorts\]\])/u);
|
||||
return sections.some((section) => {
|
||||
if (!section.includes("[[allowPorts]]")) return false;
|
||||
const start = section.match(/^\s*start\s*=\s*(\d+)\s*$/mu);
|
||||
const end = section.match(/^\s*end\s*=\s*(\d+)\s*$/mu);
|
||||
return Number(start?.[1]) === port && Number(end?.[1]) === port;
|
||||
});
|
||||
}
|
||||
|
||||
function exposeScript(pool: CodexPoolConfig): string {
|
||||
const manifest = frpcManifest(pool);
|
||||
const encoded = Buffer.from(manifest, "utf8").toString("base64");
|
||||
return `
|
||||
set -u
|
||||
tmp="$(mktemp -d)"
|
||||
trap 'rm -rf "$tmp"' EXIT
|
||||
manifest="$tmp/sub2api-frpc.yaml"
|
||||
printf '%s' '${encoded}' | base64 -d > "$manifest"
|
||||
ns_out="$tmp/ns.out"
|
||||
ns_err="$tmp/ns.err"
|
||||
apply_out="$tmp/apply.out"
|
||||
apply_err="$tmp/apply.err"
|
||||
rollout_out="$tmp/rollout.out"
|
||||
rollout_err="$tmp/rollout.err"
|
||||
kubectl get namespace ${namespace} >"$ns_out" 2>"$ns_err"
|
||||
ns_rc=$?
|
||||
apply_rc=1
|
||||
rollout_rc=1
|
||||
if [ "$ns_rc" -eq 0 ]; then
|
||||
kubectl apply --server-side --force-conflicts --field-manager=${fieldManager} -f "$manifest" >"$apply_out" 2>"$apply_err"
|
||||
apply_rc=$?
|
||||
if [ "$apply_rc" -eq 0 ]; then
|
||||
kubectl -n ${namespace} rollout status deployment/${pool.publicExposure.deploymentName} --timeout=30s >"$rollout_out" 2>"$rollout_err"
|
||||
rollout_rc=$?
|
||||
else
|
||||
printf '%s\\n' 'skipped because apply failed' >"$rollout_err"
|
||||
fi
|
||||
else
|
||||
: >"$apply_out"
|
||||
printf '%s\\n' 'skipped because namespace is missing' >"$apply_err"
|
||||
: >"$rollout_out"
|
||||
printf '%s\\n' 'skipped because namespace is missing' >"$rollout_err"
|
||||
fi
|
||||
pods_json="$tmp/pods.json"
|
||||
pods_err="$tmp/pods.err"
|
||||
kubectl -n ${namespace} get pods -l app.kubernetes.io/name=${pool.publicExposure.deploymentName} -o json >"$pods_json" 2>"$pods_err"
|
||||
pods_rc=$?
|
||||
logs_out="$tmp/logs.out"
|
||||
logs_err="$tmp/logs.err"
|
||||
kubectl -n ${namespace} logs deployment/${pool.publicExposure.deploymentName} --tail=80 >"$logs_out" 2>"$logs_err"
|
||||
logs_rc=$?
|
||||
python3 - "$tmp" "$ns_rc" "$apply_rc" "$rollout_rc" "$pods_rc" "$logs_rc" <<'PY'
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
tmp = sys.argv[1]
|
||||
ns_rc, apply_rc, rollout_rc, pods_rc, logs_rc = [int(value) for value in sys.argv[2:]]
|
||||
|
||||
def text(name, limit=4000):
|
||||
path = os.path.join(tmp, name)
|
||||
try:
|
||||
return open(path, encoding="utf-8", errors="replace").read()[-limit:]
|
||||
except FileNotFoundError:
|
||||
return ""
|
||||
|
||||
def load_json(name):
|
||||
path = os.path.join(tmp, name)
|
||||
try:
|
||||
return json.load(open(path, encoding="utf-8"))
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
pods_obj = load_json("pods.json")
|
||||
pods = []
|
||||
if isinstance(pods_obj, dict):
|
||||
for item in pods_obj.get("items") or []:
|
||||
status = item.get("status") or {}
|
||||
statuses = status.get("containerStatuses") or []
|
||||
pods.append({
|
||||
"name": item.get("metadata", {}).get("name"),
|
||||
"phase": status.get("phase"),
|
||||
"ready": all(cs.get("ready") is True for cs in statuses) if statuses else False,
|
||||
"restarts": sum(int(cs.get("restartCount") or 0) for cs in statuses),
|
||||
})
|
||||
|
||||
logs = text("logs.out", 4000)
|
||||
payload = {
|
||||
"ok": ns_rc == 0 and apply_rc == 0 and rollout_rc == 0 and pods_rc == 0 and len(pods) > 0 and all(p["ready"] for p in pods),
|
||||
"namespace": "${namespace}",
|
||||
"proxy": {
|
||||
"name": "${pool.publicExposure.proxyName}",
|
||||
"remotePort": ${JSON.stringify(pool.publicExposure.remotePort)},
|
||||
"publicBaseUrl": "${pool.publicExposure.publicBaseUrl}",
|
||||
"masterBaseUrl": "${pool.publicExposure.masterBaseUrl}",
|
||||
"localIP": "${pool.publicExposure.localIP}",
|
||||
"localPort": ${JSON.stringify(pool.publicExposure.localPort)},
|
||||
},
|
||||
"resources": {
|
||||
"configMap": "${pool.publicExposure.configMapName}",
|
||||
"deployment": "${pool.publicExposure.deploymentName}",
|
||||
"image": "${pool.publicExposure.frpcImage}",
|
||||
},
|
||||
"steps": {
|
||||
"namespace": {"exitCode": ns_rc, "stdout": text("ns.out"), "stderr": text("ns.err")},
|
||||
"apply": {"exitCode": apply_rc, "stdout": text("apply.out"), "stderr": text("apply.err")},
|
||||
"rollout": {"exitCode": rollout_rc, "stdout": text("rollout.out"), "stderr": text("rollout.err")},
|
||||
"pods": {"exitCode": pods_rc, "items": pods, "stderr": text("pods.err")},
|
||||
"logs": {
|
||||
"exitCode": logs_rc,
|
||||
"startProxySuccess": "start proxy success" in logs,
|
||||
"stdoutTail": logs,
|
||||
"stderrTail": text("logs.err"),
|
||||
},
|
||||
},
|
||||
}
|
||||
print(json.dumps(payload, ensure_ascii=False, indent=2))
|
||||
sys.exit(0 if payload["ok"] else 1)
|
||||
PY
|
||||
`;
|
||||
}
|
||||
|
||||
function frpcManifest(pool: CodexPoolConfig): string {
|
||||
return `apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: ${pool.publicExposure.configMapName}
|
||||
namespace: ${namespace}
|
||||
labels:
|
||||
app.kubernetes.io/name: ${pool.publicExposure.deploymentName}
|
||||
app.kubernetes.io/part-of: platform-infra
|
||||
app.kubernetes.io/managed-by: unidesk
|
||||
data:
|
||||
frpc.toml: |
|
||||
serverAddr = "${pool.publicExposure.serverAddr}"
|
||||
serverPort = ${pool.publicExposure.serverPort}
|
||||
loginFailExit = true
|
||||
|
||||
[[proxies]]
|
||||
name = "${pool.publicExposure.proxyName}"
|
||||
type = "tcp"
|
||||
localIP = "${pool.publicExposure.localIP}"
|
||||
localPort = ${pool.publicExposure.localPort}
|
||||
remotePort = ${pool.publicExposure.remotePort}
|
||||
---
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: ${pool.publicExposure.deploymentName}
|
||||
namespace: ${namespace}
|
||||
labels:
|
||||
app.kubernetes.io/name: ${pool.publicExposure.deploymentName}
|
||||
app.kubernetes.io/part-of: platform-infra
|
||||
app.kubernetes.io/managed-by: unidesk
|
||||
spec:
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: ${pool.publicExposure.deploymentName}
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app.kubernetes.io/name: ${pool.publicExposure.deploymentName}
|
||||
app.kubernetes.io/part-of: platform-infra
|
||||
app.kubernetes.io/managed-by: unidesk
|
||||
spec:
|
||||
containers:
|
||||
- name: frpc
|
||||
image: ${pool.publicExposure.frpcImage}
|
||||
imagePullPolicy: IfNotPresent
|
||||
args:
|
||||
- -c
|
||||
- /etc/frp/frpc.toml
|
||||
volumeMounts:
|
||||
- name: config
|
||||
mountPath: /etc/frp
|
||||
readOnly: true
|
||||
volumes:
|
||||
- name: config
|
||||
configMap:
|
||||
name: ${pool.publicExposure.configMapName}
|
||||
`;
|
||||
}
|
||||
|
||||
async function fetchPoolApiKey(config: UniDeskConfig, pool: CodexPoolConfig): Promise<{ apiKey: string | null; error: string | null }> {
|
||||
const result = await capture(config, g14K3sRoute, ["script"], `
|
||||
set -u
|
||||
kubectl -n ${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: `${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) };
|
||||
}
|
||||
}
|
||||
|
||||
function writeLocalCodexConfig(pool: CodexPoolConfig, apiKey: string): 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);
|
||||
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: pool.publicExposure.masterBaseUrl,
|
||||
wireApi: pool.localCodex.wireApi,
|
||||
},
|
||||
valuesPrinted: false,
|
||||
};
|
||||
}
|
||||
|
||||
function copyIfMissing(source: string, target: string): "created" | "kept-existing" {
|
||||
if (existsSync(target)) return "kept-existing";
|
||||
copyFileSync(source, target);
|
||||
chmodSync(target, 0o600);
|
||||
return "created";
|
||||
}
|
||||
|
||||
function updateCodexConfigToml(current: string, pool: CodexPoolConfig): string {
|
||||
let next = upsertTopLevelTomlString(current, "model_provider", pool.localCodex.providerName);
|
||||
next = upsertProviderSection(next, pool.localCodex.providerName, pool.publicExposure.masterBaseUrl, pool.localCodex.wireApi);
|
||||
return next.endsWith("\n") ? next : `${next}\n`;
|
||||
}
|
||||
|
||||
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");
|
||||
}
|
||||
|
||||
function upsertProviderSection(text: string, providerName: string, baseUrl: string, wireApi: string): string {
|
||||
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",
|
||||
];
|
||||
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|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");
|
||||
}
|
||||
|
||||
async function validatePublicGatewayWithKey(pool: CodexPoolConfig, apiKey: string): Promise<Record<string, unknown>> {
|
||||
const probe = await probePublicModels(pool, "with-api-key", apiKey);
|
||||
return {
|
||||
...probe,
|
||||
ok: probe.ok === true,
|
||||
apiKeyFingerprint: fingerprint(apiKey),
|
||||
keyPreview: apiKeyPreview(apiKey),
|
||||
valuesPrinted: false,
|
||||
};
|
||||
}
|
||||
|
||||
async function probePublicModels(pool: CodexPoolConfig, mode: "with-api-key" | "without-api-key", apiKey?: string): Promise<Record<string, unknown>> {
|
||||
const url = `${pool.publicExposure.masterBaseUrl.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: pool.publicExposure.masterBaseUrl,
|
||||
httpStatus: response.status,
|
||||
modelCount,
|
||||
bodyPreview: response.ok ? "" : body.slice(0, 500),
|
||||
valuesPrinted: false,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false,
|
||||
mode,
|
||||
method: "GET /v1/models",
|
||||
baseUrl: pool.publicExposure.masterBaseUrl,
|
||||
httpStatus: 0,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
valuesPrinted: false,
|
||||
};
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
function apiKeyPreview(apiKey: string): string {
|
||||
if (apiKey.length <= 14) return "***";
|
||||
return `${apiKey.slice(0, 10)}...${apiKey.slice(-4)}`;
|
||||
}
|
||||
|
||||
function tomlString(value: string): string {
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
function escapeRegExp(value: string): string {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&");
|
||||
}
|
||||
|
||||
function normalizeBaseUrl(value: string | null): string | null {
|
||||
if (value === null) return null;
|
||||
const trimmed = value.trim().replace(/\/+$/u, "");
|
||||
@@ -411,6 +1173,15 @@ function numberValue(value: unknown): number | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
function booleanValue(value: unknown): boolean | null {
|
||||
if (typeof value === "boolean") return value;
|
||||
if (typeof value === "string") {
|
||||
if (value.trim() === "true") return true;
|
||||
if (value.trim() === "false") return false;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function validateKubernetesName(value: string, key: string, strictDns: boolean): void {
|
||||
const pattern = strictDns ? /^[a-z0-9]([-a-z0-9]*[a-z0-9])?$/u : /^[A-Za-z0-9]([A-Za-z0-9._-]*[A-Za-z0-9])?$/u;
|
||||
if (!pattern.test(value)) throw new Error(`${codexPoolConfigPath}.${key} has an unsupported format`);
|
||||
|
||||
Reference in New Issue
Block a user