Merge pull request #2002 from pikasTech/feat/sub2api-runtime-batch-reconcile
Pipelines as Code CI / hwlab-web-probe-sentinel-nc01- Success
Pipelines as Code CI / platform-infra-gitea-nc01- Success
Pipelines as Code CI / unidesk-host- Success

feat: Sub2API runtime 批量写后自动对账
This commit is contained in:
Lyon
2026-07-14 18:53:05 +08:00
committed by GitHub
3 changed files with 204 additions and 49 deletions
+23 -7
View File
@@ -12,22 +12,38 @@ runtime:
templates:
- id: codex-upstream-failover
kind: temp-unschedulable
description: Reusable Sub2API request-path cooling and failover policy for Codex-compatible API-key accounts.
description: Codex 兼容 API-key 账号的请求路径短时冷却与换号策略。
spec:
enabled: true
rules:
- statusCode: 429
keywords: [concurrency limit exceeded, upstream rate limit exceeded]
durationMinutes: 1
description: 明确命中上游并发或速率限制时短时冷却当前账号。
- statusCode: 400
keywords: [input must be a list]
durationMinutes: 1
description: 明确命中 Responses input 列表兼容错误时短时冷却并切换账号。
- statusCode: 500
keywords: [failed to validate api key, do request failed]
durationMinutes: 1
description: 上游 API-key 查询依赖或请求传输内部失败时短时冷却当前账号。
- statusCode: 502
keywords: [upstream service temporarily unavailable, upstream request failed, context window, stream usage incomplete, missing terminal event]
keywords: [upstream service temporarily unavailable, upstream request failed, service temporarily unavailable, overloaded, concurrency limit exceeded]
durationMinutes: 1
description: Observed 502 upstream availability, context-window, and incomplete-stream failures briefly cool down the selected account.
description: 明确命中 502 上游不可用、过载或并发限制时短时冷却当前账号。
- statusCode: 503
keywords: [upstream service temporarily unavailable, upstream request failed, temporarily unavailable]
keywords: [upstream service temporarily unavailable, upstream request failed, service temporarily unavailable, temporarily unavailable, overloaded, concurrency limit exceeded]
durationMinutes: 1
description: Observed 503 temporary upstream availability failures briefly cool down the selected account.
description: 明确命中 503 上游暂时不可用、过载或并发限制时短时冷却当前账号。
- statusCode: 504
keywords: [concurrency limit exceeded, upstream service temporarily unavailable, input must be a list]
durationMinutes: 1
description: 明确命中 504 上游并发限制、暂时不可用或 Responses input 列表兼容错误时短时冷却当前账号。
- statusCode: 524
keywords: [upstream request failed, missing_required_parameter, encrypted_content]
keywords: [upstream request failed, service temporarily unavailable, overloaded, concurrency limit exceeded]
durationMinutes: 1
description: Observed 524 upstream request and encrypted-content failures briefly cool down the selected account.
description: 明确命中 524 上游不可用、过载或并发限制时短时冷却当前账号。
pool:
groupName: unidesk-codex-pool
@@ -1,17 +1,20 @@
import type { UniDeskConfig } from "../config";
import type { RenderedCliResult } from "../output";
import { desiredAccountNames } from "./accounts";
import { readCodexPoolConfig } from "./config";
import { isRecord, stringValue } from "./config-utils";
import { protectedManualAccountsForTarget } from "./public-exposure";
import { renderSub2ApiTempUnschedulableCredentials } from "./redaction";
import { boolField, compactCapture, parseJsonOutput, runRemoteCodexPoolScript } from "./remote";
import { renderTable } from "./render";
import { codexPoolRuntimeTarget, defaultCodexPoolRuntimeTargetId } from "./runtime-target";
type RuntimeAction = "list" | "get" | "errors" | "apply" | "delete";
interface RuntimeOptions {
action: RuntimeAction;
account: string | null;
accounts: string[];
template: string | null;
kind: "temp-unschedulable";
confirm: boolean;
@@ -22,7 +25,7 @@ interface RuntimeOptions {
targetId: string;
}
export async function codexPoolRuntime(config: UniDeskConfig, args: string[]): Promise<Record<string, unknown>> {
export async function codexPoolRuntime(config: UniDeskConfig, args: string[]): Promise<Record<string, unknown> | RenderedCliResult> {
const options = parseRuntimeOptions(args);
const pool = readCodexPoolConfig();
const target = codexPoolRuntimeTarget(options.targetId);
@@ -32,7 +35,8 @@ export async function codexPoolRuntime(config: UniDeskConfig, args: string[]): P
}
const payload = {
action: options.action,
account: options.account,
account: options.accounts.length === 1 ? options.accounts[0] : null,
accounts: options.accounts,
kind: options.kind,
confirm: options.confirm,
full: options.full,
@@ -57,6 +61,9 @@ export async function codexPoolRuntime(config: UniDeskConfig, args: string[]): P
const result = await runRemoteCodexPoolScript(config, "runtime", runtimeScript(payload, target), target);
const parsed = parseJsonOutput(result.stdout);
const ok = result.exitCode === 0 && boolField(parsed, "ok", false);
if (!options.full && !options.raw && (options.action === "apply" || options.action === "delete")) {
return renderRuntimeMutation(ok, options, parsed, result.exitCode);
}
if (options.raw) {
return {
ok,
@@ -78,7 +85,8 @@ export async function codexPoolRuntime(config: UniDeskConfig, args: string[]): P
},
request: {
operation: options.action,
account: options.account,
account: options.accounts.length === 1 ? options.accounts[0] : undefined,
accounts: options.accounts.length > 1 ? options.accounts : undefined,
template: options.template,
kind: options.kind,
confirm: options.confirm,
@@ -100,9 +108,10 @@ export async function codexPoolRuntime(config: UniDeskConfig, args: string[]): P
export function parseRuntimeOptions(args: string[]): RuntimeOptions {
const [actionRaw = "list", ...rest] = args;
if (actionRaw !== "list" && actionRaw !== "get" && actionRaw !== "errors" && actionRaw !== "apply" && actionRaw !== "delete") {
throw new Error("runtime usage: list|get|errors|apply|delete [--account <name-or-id>] [--since 24h] [--tail 50000] [--template <id>] [--kind temp-unschedulable] [--confirm] [--target <id>] [--full|--raw]");
throw new Error("runtime usage: list|get|errors|apply|delete [--account <name-or-id>|--accounts <selector,...>] [--since 24h] [--tail 50000] [--template <id>] [--kind temp-unschedulable] [--confirm] [--target <id>] [--full|--raw]");
}
let account: string | null = null;
let accounts: string[] | null = null;
let template: string | null = null;
let kind = "temp-unschedulable" as const;
let confirm = false;
@@ -124,6 +133,8 @@ export function parseRuntimeOptions(args: string[]): RuntimeOptions {
else if (arg === "--raw") raw = true;
else if (arg === "--account") account = readValue("--account");
else if (arg.startsWith("--account=")) account = arg.slice("--account=".length).trim();
else if (arg === "--accounts") accounts = parseRuntimeAccounts(readValue("--accounts"));
else if (arg.startsWith("--accounts=")) accounts = parseRuntimeAccounts(arg.slice("--accounts=".length));
else if (arg === "--template") template = readValue("--template");
else if (arg.startsWith("--template=")) template = arg.slice("--template=".length).trim();
else if (arg === "--kind") kind = parseRuntimeKind(readValue("--kind"));
@@ -136,17 +147,27 @@ export function parseRuntimeOptions(args: string[]): RuntimeOptions {
else if (arg.startsWith("--tail=")) tail = parseRuntimeTail(arg.slice("--tail=".length));
else throw new Error(`unsupported runtime option: ${arg}`);
}
if ((actionRaw === "get" || actionRaw === "apply" || actionRaw === "delete") && !account) {
if (account !== null && accounts !== null) throw new Error("use only one of --account or --accounts");
const selectors = accounts ?? (account === null ? [] : [account]);
if ((actionRaw === "get" || actionRaw === "apply" || actionRaw === "delete") && selectors.length === 0) {
throw new Error(`runtime ${actionRaw} requires --account <name-or-id>`);
}
if (account !== null && (account.length > 256 || /[\r\n]/u.test(account))) throw new Error("--account has an unsupported format");
if (actionRaw !== "apply" && actionRaw !== "delete" && accounts !== null) throw new Error(`runtime ${actionRaw} does not accept --accounts`);
if (selectors.some((selector) => selector.length > 256 || /[\r\n]/u.test(selector))) throw new Error("account selector has an unsupported format");
if (actionRaw === "apply" && !template) throw new Error("runtime apply requires --template <id>");
if (actionRaw !== "apply" && template !== null) throw new Error(`runtime ${actionRaw} does not accept --template`);
if (actionRaw !== "errors" && (since !== "24h" || tail !== 50_000)) throw new Error(`runtime ${actionRaw} does not accept --since or --tail`);
if (!/^\d+[mhd]$/u.test(since)) throw new Error("--since must use <number>m, <number>h, or <number>d");
if ((actionRaw === "list" || actionRaw === "get" || actionRaw === "errors") && confirm) throw new Error(`runtime ${actionRaw} does not accept --confirm`);
if (full && raw) throw new Error("use only one of --full or --raw");
return { action: actionRaw, account, template, kind, confirm, full, raw, since, tail, targetId };
return { action: actionRaw, accounts: selectors, template, kind, confirm, full, raw, since, tail, targetId };
}
function parseRuntimeAccounts(value: string): string[] {
const accounts = value.split(",").map((item) => item.trim());
if (accounts.length === 0 || accounts.some((item) => item.length === 0)) throw new Error("--accounts requires comma-separated selectors");
if (new Set(accounts).size !== accounts.length) throw new Error("--accounts contains duplicate selectors");
return accounts;
}
function parseRuntimeKind(value: string): "temp-unschedulable" {
@@ -160,6 +181,60 @@ function parseRuntimeTail(value: string): number {
return parsed;
}
function renderRuntimeMutation(ok: boolean, options: RuntimeOptions, parsed: Record<string, unknown> | null, exitCode: number): RenderedCliResult {
const summary = parsed !== null && isRecord(parsed.summary) ? parsed.summary : {};
const items = parsed !== null && Array.isArray(parsed.items) ? parsed.items.filter(isRecord) : [];
const lines = [[
"SUB2API RUNTIME",
`operation=${options.action}`,
`mode=${stringValue(parsed?.mode) ?? (options.confirm ? "confirmed" : "dry-run")}`,
`ok=${ok ? "true" : "false"}`,
`selected=${summary.selected ?? items.length}`,
`changed=${summary.changed ?? "?"}`,
`writeSucceeded=${summary.writeSucceeded ?? "?"}`,
`writeFailed=${summary.writeFailed ?? "?"}`,
`reconciled=${summary.reconciled ?? "?"}`,
`mismatched=${summary.mismatched ?? "?"}`,
].join(" ")];
if (items.length > 0) {
lines.push(renderTable([
["ACCOUNT", "ID", "CHANGE", "WRITE", "RECONCILED", "BEFORE", "DESIRED", "ACTUAL", "BEFORE_CODES", "DESIRED_CODES", "ACTUAL_CODES", "FAILURE"],
...items.map((item) => {
const write = isRecord(item.write) ? item.write : {};
const before = isRecord(item.before) ? item.before : {};
const desired = isRecord(item.desired) ? item.desired : {};
const actual = isRecord(item.actual) ? item.actual : {};
const codes = (value: unknown): string => Array.isArray(value) ? value.join(",") || "-" : "-";
return [
stringValue(item.accountName) ?? "-",
String(item.accountId ?? "-"),
stringValue(item.change) ?? "-",
stringValue(write.status) ?? "-",
item.reconciled === true ? "true" : item.reconciled === false ? "false" : "-",
String(before.ruleCount ?? "-"),
String(desired.ruleCount ?? "-"),
String(actual.ruleCount ?? "-"),
codes(before.statusCodes),
codes(desired.statusCodes),
codes(actual.statusCodes),
stringValue(write.error) ?? stringValue(item.reconcileError) ?? "-",
];
}),
]));
} else if (parsed !== null && typeof parsed.error === "string") {
lines.push(`ERROR ${parsed.error}`);
} else if (parsed === null) {
lines.push(`ERROR runtime output unavailable remote_exit=${exitCode}`);
}
return {
ok,
command: "platform-infra sub2api codex-pool runtime",
renderedText: lines.join("\n"),
contentType: "text/plain",
projection: parsed ?? undefined,
};
}
function pyJson(value: unknown): string {
return `json.loads(${JSON.stringify(JSON.stringify(value))})`;
}
@@ -487,6 +562,78 @@ def observed_runtime_errors(accounts):
"message": "observedStatusCodes comes only from account_upstream_error; forward failures are supplemental message evidence",
}
def selected_account_details(token, accounts):
selectors = PAYLOAD.get("accounts")
if not isinstance(selectors, list) or not selectors:
selector = PAYLOAD.get("account")
selectors = [selector] if isinstance(selector, str) and selector else []
details = []
selected_ids = set()
for raw_selector in selectors:
selector = str(raw_selector)
detail = find_account(token, accounts, selector)
account_id = detail.get("id")
if account_id in selected_ids:
raise RuntimeError("duplicate-account-selector: " + selector)
if detail.get("type") != "apikey":
raise RuntimeError("runtime temp-unschedulable policy requires an apikey account: " + selector)
selected_ids.add(account_id)
details.append(detail)
return details
def mutation_plan(detail, action, include_rules):
credentials = dict(detail.get("credentials") or {})
before = normalize_policy(credentials)
if action == "apply":
template = PAYLOAD.get("selectedTemplate")
if not isinstance(template, dict):
raise RuntimeError("selected runtime template missing")
desired = dict(credentials)
desired.update(template.get("credentials") or {})
desired_policy = normalize_policy(desired)
exists = "temp_unschedulable_enabled" in credentials or "temp_unschedulable_rules" in credentials
change = "noop" if before == desired_policy else ("update" if exists else "create")
else:
desired = dict(credentials)
desired.pop("temp_unschedulable_enabled", None)
desired.pop("temp_unschedulable_rules", None)
desired_policy = normalize_policy(desired)
change = "delete" if before != desired_policy or "temp_unschedulable_enabled" in credentials or "temp_unschedulable_rules" in credentials else "noop"
return {
"accountName": detail.get("name"),
"accountId": detail.get("id"),
"change": change,
"before": policy_summary(before, include_rules),
"desired": policy_summary(desired_policy, include_rules),
"template": PAYLOAD.get("selectedTemplate", {}).get("id") if isinstance(PAYLOAD.get("selectedTemplate"), dict) else None,
"_desiredCredentials": desired,
"_desiredPolicy": desired_policy,
}
def write_mutation_plan(token, plan):
if plan["change"] == "noop":
return {"attempted": False, "status": "noop", "error": None}
try:
data_of(curl_api("PUT", f"/api/v1/admin/accounts/{plan['accountId']}", bearer=token, payload={"credentials": plan["_desiredCredentials"]}), "update account runtime config")
return {"attempted": True, "status": "succeeded", "error": None}
except Exception as exc:
return {"attempted": True, "status": "failed", "error": compact_error(str(exc))}
def reconcile_mutation_plan(token, plan, write, include_rules):
item = {key: value for key, value in plan.items() if not key.startswith("_")}
item["write"] = write
try:
actual_detail = account_detail(token, plan["accountId"])
actual_policy = normalize_policy(actual_detail.get("credentials"))
item["actual"] = policy_summary(actual_policy, include_rules)
item["reconciled"] = actual_policy == plan["_desiredPolicy"]
item["reconcileError"] = None
except Exception as exc:
item["actual"] = None
item["reconciled"] = False
item["reconcileError"] = compact_error(str(exc))
return item
def runtime_result():
token = login()
group = next((item for item in list_groups(token) if item.get("name") == PAYLOAD["groupName"]), None)
@@ -506,43 +653,33 @@ def runtime_result():
if action == "errors":
errors = observed_runtime_errors(accounts)
return {**base, "ok": True, "operation": "errors", "mutation": False, "errors": errors}
detail = find_account(token, accounts, str(PAYLOAD["account"]))
detail = find_account(token, accounts, str(PAYLOAD["account"])) if action == "get" else None
if action == "get":
return {**base, "ok": True, "operation": "get", "mutation": False, "account": account_summary(detail, True)}
if detail.get("type") != "apikey":
raise RuntimeError("runtime temp-unschedulable policy requires an apikey account")
credentials = dict(detail.get("credentials") or {})
before = normalize_policy(credentials)
if action == "apply":
template = PAYLOAD.get("selectedTemplate")
if not isinstance(template, dict):
raise RuntimeError("selected runtime template missing")
desired_fields = template.get("credentials") or {}
desired = dict(credentials)
desired.update(desired_fields)
after_plan = normalize_policy(desired)
exists = "temp_unschedulable_enabled" in credentials or "temp_unschedulable_rules" in credentials
change = "noop" if before == after_plan else ("update" if exists else "create")
else:
desired = dict(credentials)
desired.pop("temp_unschedulable_enabled", None)
desired.pop("temp_unschedulable_rules", None)
after_plan = normalize_policy(desired)
change = "delete" if before != after_plan or "temp_unschedulable_enabled" in credentials or "temp_unschedulable_rules" in credentials else "noop"
include_plan_rules = PAYLOAD.get("full") is True
plan = {
"change": change,
"account": account_summary(detail, include_plan_rules),
"before": policy_summary(before, include_plan_rules),
"after": policy_summary(after_plan, include_plan_rules),
"template": PAYLOAD.get("selectedTemplate", {}).get("id") if isinstance(PAYLOAD.get("selectedTemplate"), dict) else None,
"credentialsPrinted": False,
}
if PAYLOAD.get("confirm") is not True or change == "noop":
return {**base, "ok": True, "operation": action, "mode": "dry-run" if PAYLOAD.get("confirm") is not True else "confirmed-noop", "mutation": False, "plan": plan}
updated = data_of(curl_api("PUT", f"/api/v1/admin/accounts/{detail['id']}", bearer=token, payload={"credentials": desired}), "update account runtime config")
after_detail = updated if isinstance(updated, dict) else account_detail(token, detail["id"])
return {**base, "ok": True, "operation": action, "mode": "confirmed", "mutation": True, "plan": plan, "account": account_summary(after_detail, include_plan_rules)}
details = selected_account_details(token, accounts)
plans = [mutation_plan(item, action, include_plan_rules) for item in details]
changed = sum(1 for plan in plans if plan["change"] != "noop")
if PAYLOAD.get("confirm") is not True:
items = []
for plan in plans:
item = {key: value for key, value in plan.items() if not key.startswith("_")}
item["write"] = {"attempted": False, "status": "dry-run", "error": None}
item["actual"] = item["before"]
item["reconciled"] = None
item["reconcileError"] = None
items.append(item)
summary = {"selected": len(items), "changed": changed, "writeSucceeded": 0, "writeFailed": 0, "reconciled": 0, "mismatched": 0}
return {**base, "ok": True, "operation": action, "mode": "dry-run", "mutation": False, "partialWrite": False, "summary": summary, "items": items, "credentialsPrinted": False}
writes = [write_mutation_plan(token, plan) for plan in plans]
items = [reconcile_mutation_plan(token, plan, write, include_plan_rules) for plan, write in zip(plans, writes)]
write_succeeded = sum(1 for item in items if item["write"]["status"] == "succeeded")
write_failed = sum(1 for item in items if item["write"]["status"] == "failed")
reconciled = sum(1 for item in items if item["reconciled"] is True)
mismatched = sum(1 for item in items if item["reconciled"] is False)
summary = {"selected": len(items), "changed": changed, "writeSucceeded": write_succeeded, "writeFailed": write_failed, "reconciled": reconciled, "mismatched": mismatched}
ok = write_failed == 0 and mismatched == 0
return {**base, "ok": ok, "operation": action, "mode": "confirmed", "mutation": write_succeeded > 0, "partialWrite": write_succeeded > 0 and write_failed > 0, "summary": summary, "items": items, "credentialsPrinted": False}
try:
result = runtime_result()
@@ -362,7 +362,9 @@ export function codexPoolHelp(): unknown {
"bun scripts/cli.ts platform-infra sub2api codex-pool runtime errors [--account <name-or-id>] [--since 24h] [--tail 50000] [--target PK01] [--full|--raw]",
"bun scripts/cli.ts platform-infra sub2api codex-pool faults [--level P0|P1|P2] [--group <name-or-id>] [--account <name-or-id>] [--model <model>] [--stream sync|stream] [--endpoint <path>] [--request-id <id>] [--page-token <token>] [--target PK01] [--json]",
"bun scripts/cli.ts platform-infra sub2api codex-pool runtime apply --account <name-or-id> --template <id> [--target PK01] [--confirm]",
"bun scripts/cli.ts platform-infra sub2api codex-pool runtime apply --accounts <name-or-id,...> --template <id> [--target PK01] [--confirm] [--full|--raw]",
"bun scripts/cli.ts platform-infra sub2api codex-pool runtime delete --account <name-or-id> --kind temp-unschedulable [--target PK01] [--confirm]",
"bun scripts/cli.ts platform-infra sub2api codex-pool runtime delete --accounts <name-or-id,...> --kind temp-unschedulable [--target PK01] [--confirm] [--full|--raw]",
"bun scripts/cli.ts platform-infra sub2api codex-pool trace [--target D601] --request-id <requestId> [--since 24h|--tail 20000|--context-seconds 300|--show-lines|--raw]",
"bun scripts/cli.ts platform-infra sub2api codex-pool feedback --user <email-or-id> [--window 30m] [--page-token <token>|--request-id <client-or-internal-id>] [--target PK01] [--json]",
"bun scripts/cli.ts platform-infra sub2api codex-pool sentinel-image status [--target D601]",