feat: manage codex pool account pruning
This commit is contained in:
@@ -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": {
|
||||
|
||||
Reference in New Issue
Block a user