feat: manage codex pool account pruning

This commit is contained in:
Codex
2026-06-09 05:56:28 +00:00
parent 480a7a7b37
commit 23e95b888e
4 changed files with 67 additions and 13 deletions
+59 -7
View File
@@ -45,6 +45,7 @@ interface CodexProfile {
apiKey: string | null;
apiKeySource: "auth-json" | "env" | null;
openaiResponsesWebSocketsV2Mode: OpenAIResponsesWebSocketsV2Mode | null;
upstreamUserAgent: string | null;
authOpenAIKeyShape: string;
ok: boolean;
error: string | null;
@@ -71,6 +72,7 @@ interface CodexPoolProfileConfig {
fallbackConfigFile: string | null;
fallbackAuthFile: string | null;
openaiResponsesWebSocketsV2Mode: OpenAIResponsesWebSocketsV2Mode | null;
upstreamUserAgent: string | null;
}
interface CodexPoolPublicExposureConfig {
@@ -192,7 +194,7 @@ function codexPoolPlan(): Record<string, unknown> {
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.",
idempotency: "sync reuses the group, account names, and k3s Secret when they already exist; credentials are updated from the current local Codex files; UniDesk-managed accounts removed from YAML are deleted from Sub2API.",
configPolicy: "UniDesk-owned durable configuration remains YAML-first; local ~/.codex files and runtime Secrets are not committed.",
},
next: ok
@@ -237,6 +239,7 @@ async function codexPoolSync(config: UniDeskConfig, options: SyncOptions): Promi
apiKeySource: profile.apiKeySource,
apiKeyFingerprint: fingerprint(profile.apiKey ?? ""),
openaiResponsesWebSocketsV2Mode: profile.openaiResponsesWebSocketsV2Mode,
upstreamUserAgent: profile.upstreamUserAgent,
})),
};
const result = await capture(config, g14K3sRoute, ["script"], syncScript(payload, pool));
@@ -418,6 +421,7 @@ function collectCodexProfiles(): CodexProfile[] {
apiKey: null,
apiKeySource: null,
openaiResponsesWebSocketsV2Mode: entry.openaiResponsesWebSocketsV2Mode,
upstreamUserAgent: entry.upstreamUserAgent,
authOpenAIKeyShape: existsSync(authPath) ? "unknown" : "missing",
ok: false,
error: null,
@@ -481,6 +485,7 @@ function discoverCodexProfileConfigs(codexDir: string): CodexPoolProfileConfig[]
fallbackConfigFile: null,
fallbackAuthFile: null,
openaiResponsesWebSocketsV2Mode: null,
upstreamUserAgent: null,
};
});
}
@@ -576,6 +581,7 @@ function readProfileConfig(value: unknown, defaults: CodexPoolProfileConfig[]):
if (fallbackConfigFile !== null) validateCodexFileName(fallbackConfigFile, `profiles.entries[${index}].fallbackConfigFile`);
if (fallbackAuthFile !== null) validateCodexFileName(fallbackAuthFile, `profiles.entries[${index}].fallbackAuthFile`);
const openaiResponsesWebSocketsV2Mode = readOpenAIResponsesWebSocketsV2Mode(entry.openaiResponsesWebSocketsV2Mode, `profiles.entries[${index}].openaiResponsesWebSocketsV2Mode`);
const upstreamUserAgent = readUpstreamUserAgent(entry.upstreamUserAgent, `profiles.entries[${index}].upstreamUserAgent`);
return {
profile,
accountName,
@@ -584,6 +590,7 @@ function readProfileConfig(value: unknown, defaults: CodexPoolProfileConfig[]):
fallbackConfigFile,
fallbackAuthFile,
openaiResponsesWebSocketsV2Mode,
upstreamUserAgent,
};
});
}
@@ -596,6 +603,15 @@ function readOpenAIResponsesWebSocketsV2Mode(value: unknown, key: string): OpenA
throw new Error(`${codexPoolConfigPath}.${key} must be one of off, ctx_pool, passthrough`);
}
function readUpstreamUserAgent(value: unknown, key: string): string | null {
if (value === undefined || value === null) return null;
const text = stringValue(value);
if (text === null) throw new Error(`${codexPoolConfigPath}.${key} must be a string`);
if (text.length > 512) throw new Error(`${codexPoolConfigPath}.${key} must be at most 512 characters`);
if (/[\r\n]/u.test(text)) throw new Error(`${codexPoolConfigPath}.${key} must not contain newlines`);
return text;
}
function readPublicExposureConfig(value: unknown, defaults: CodexPoolPublicExposureConfig): CodexPoolPublicExposureConfig {
if (!isRecord(value)) return defaults;
const masterFrpsValue = isRecord(value.masterFrps) ? value.masterFrps : {};
@@ -696,6 +712,7 @@ function redactProfile(profile: CodexProfile): Record<string, unknown> {
envKey: profile.envKey,
apiKeySource: profile.apiKeySource,
openaiResponsesWebSocketsV2Mode: profile.openaiResponsesWebSocketsV2Mode,
upstreamUserAgent: profile.upstreamUserAgent,
apiKeyPresent: profile.apiKey !== null && profile.apiKey.length > 0,
apiKeyBytes: profile.apiKey === null ? 0 : Buffer.byteLength(profile.apiKey, "utf8"),
apiKeyFingerprint: profile.apiKey === null ? null : fingerprint(profile.apiKey),
@@ -1450,15 +1467,19 @@ def account_payload(profile, group_id):
if ws_mode:
extra["openai_apikey_responses_websockets_v2_mode"] = ws_mode
extra["openai_apikey_responses_websockets_v2_enabled"] = ws_mode != "off"
credentials = {
"api_key": profile["apiKey"],
"base_url": profile["baseUrl"],
}
upstream_user_agent = profile.get("upstreamUserAgent")
if upstream_user_agent:
credentials["user_agent"] = upstream_user_agent
return {
"name": profile["accountName"],
"notes": f"UniDesk-managed Codex profile {profile['profile']} from {profile['configFile']} and {profile['authFile']}; secret source={profile['apiKeySource']}; fingerprint={profile['apiKeyFingerprint']}.",
"platform": "openai",
"type": "apikey",
"credentials": {
"api_key": profile["apiKey"],
"base_url": profile["baseUrl"],
},
"credentials": credentials,
"extra": extra,
"concurrency": 1,
"priority": 1,
@@ -1469,7 +1490,9 @@ def account_payload(profile, group_id):
}
def ensure_accounts(token, profiles, group_id):
existing = {item.get("name"): item for item in list_accounts(token)}
existing_accounts = list_accounts(token)
existing = {item.get("name"): item for item in existing_accounts}
desired_names = {profile["accountName"] for profile in profiles}
results = []
for profile in profiles:
payload = account_payload(profile, group_id)
@@ -1493,6 +1516,33 @@ def ensure_accounts(token, profiles, group_id):
"apiKeySource": profile["apiKeySource"],
"apiKeyFingerprint": profile["apiKeyFingerprint"],
"openaiResponsesWebSocketsV2Mode": profile.get("openaiResponsesWebSocketsV2Mode"),
"upstreamUserAgentConfigured": bool(profile.get("upstreamUserAgent")),
"valuesPrinted": False,
})
prune_results = prune_removed_accounts(token, existing_accounts, desired_names)
return results, prune_results
def prune_removed_accounts(token, existing_accounts, desired_names):
results = []
for account in existing_accounts:
name = account.get("name")
account_id = account.get("id")
extra = account.get("extra") if isinstance(account.get("extra"), dict) else {}
if not isinstance(name, str) or not name.startswith("unidesk-codex-"):
continue
if extra.get("unidesk_managed") is not True:
continue
if name in desired_names:
continue
if account_id is None:
raise RuntimeError(f"removed account {name} has no id")
ensure_success(curl_api("DELETE", f"/api/v1/admin/accounts/{account_id}", bearer=token), f"delete removed account {name}")
results.append({
"accountName": name,
"accountId": account_id,
"profile": extra.get("unidesk_codex_profile"),
"action": "deleted",
"reason": "removed-from-yaml",
"valuesPrinted": False,
})
return results
@@ -1634,7 +1684,7 @@ def run_sync():
group_id = group.get("id") if isinstance(group, dict) else None
if group_id is None:
raise RuntimeError("pool group id missing after ensure")
account_results = ensure_accounts(token, profiles, group_id)
account_results, pruned_account_results = ensure_accounts(token, profiles, group_id)
api_key, secret_action, secret_apply_stdout = ensure_api_key_secret(group_id)
api_key_result = ensure_sub2api_api_key(token, api_key, group_id)
owner_balance = ensure_pool_owner_balance(token, api_key_result["userId"])
@@ -1651,7 +1701,9 @@ def run_sync():
"desired": len(profiles),
"created": sum(1 for item in account_results if item["action"] == "created"),
"updated": sum(1 for item in account_results if item["action"] == "updated"),
"pruned": len(pruned_account_results),
"items": account_results,
"prunedItems": pruned_account_results,
"valuesPrinted": False,
},
"apiKey": {