diff --git a/.agents/skills/unidesk-sub2api/references/codex-pool.md b/.agents/skills/unidesk-sub2api/references/codex-pool.md index fe92f752..810e9280 100644 --- a/.agents/skills/unidesk-sub2api/references/codex-pool.md +++ b/.agents/skills/unidesk-sub2api/references/codex-pool.md @@ -86,6 +86,10 @@ bun scripts/cli.ts platform-infra sub2api codex-pool runtime apply --target PK01 bun scripts/cli.ts platform-infra sub2api codex-pool runtime delete --target PK01 --account --kind temp-unschedulable bun scripts/cli.ts platform-infra sub2api codex-pool runtime apply --target PK01 --account --kind model-mapping --model --upstream-model bun scripts/cli.ts platform-infra sub2api codex-pool runtime delete --target PK01 --account --kind model-mapping --model +bun scripts/cli.ts platform-infra sub2api codex-pool error-passthrough list --target PK01 +bun scripts/cli.ts platform-infra sub2api codex-pool error-passthrough apply --target PK01 --template +bun scripts/cli.ts platform-infra sub2api codex-pool error-passthrough apply --target PK01 --template --confirm +bun scripts/cli.ts platform-infra sub2api codex-pool error-passthrough delete --target PK01 --template --confirm bun scripts/cli.ts platform-infra sub2api codex-pool profit --target PK01 --since 24h bun scripts/cli.ts platform-infra sub2api codex-pool profit --target PK01 --since 24h --json bun scripts/cli.ts platform-infra sub2api codex-pool profit --target PK01 --since 24h --accounts @@ -98,6 +102,14 @@ bun scripts/cli.ts platform-infra sub2api codex-pool sentinel-report --target D6 bun scripts/cli.ts platform-infra sub2api codex-pool cleanup-probes --target D601 --confirm ``` +- `error-passthrough` 只管理 `runtime.errorPassthroughRules` 声明的官方全局错误透传规则: + - 使用 Sub2API 原生 `/api/v1/admin/error-passthrough-rules` CRUD,不修改 Sub2API 源码; + - 使用 YAML `id` 选择模板,使用唯一 `name` 与运行面规则对账; + - `apply` / `delete` 默认 dry-run,只有 `--confirm` 才写入,写后必须自动回读并显示 `synced` / `absent`; + - 错误码与关键词需要同时约束时使用 `matchMode: all`,避免仅凭通用 400/502 扩大匹配; + - `passthroughBody: true` 可保留上游具体客户错误,`skipMonitoring: false` 保持原生运维事件可见; + - 规则是 Sub2API 全局事实,不按账号或分组复制,也不得用于把应当 failover 的真实非 auth 上游故障伪装成客户错误。 + - `profit` 只读聚合 Sub2API 原生 `/api/v1/admin/usage`: - 默认窗口、盈利分组、自用分组、排除分组、人民币售价和账号采购成本来源由 `config/platform-infra/sub2api-codex-pool.yaml#profit` 声明; - 原生 `actual_cost` 是标准 API 美元计费基数;`saleRateCnyPerApiUsd` 和账号名称末尾成本都表示每 1 美元标准 API 计费对应的人民币单价,不做汇率换算; diff --git a/config/platform-infra/sub2api-codex-pool.yaml b/config/platform-infra/sub2api-codex-pool.yaml index 2ff205f5..bde5b7e8 100644 --- a/config/platform-infra/sub2api-codex-pool.yaml +++ b/config/platform-infra/sub2api-codex-pool.yaml @@ -9,6 +9,21 @@ metadata: - 340 runtime: + errorPassthroughRules: + - id: openai-context-window-client-error + name: UniDesk OpenAI context window client error + enabled: true + priority: 100 + errorCodes: [400, 502] + keywords: [context_length_exceeded, input exceeds the context window] + matchMode: all + platforms: [openai] + passthroughCode: false + responseCode: 400 + passthroughBody: true + customMessage: null + skipMonitoring: false + description: 将确定的 OpenAI 上下文超限错误作为客户请求错误返回,保留原始错误信息并继续记录运维事件。 templates: - id: codex-upstream-failover kind: temp-unschedulable diff --git a/scripts/src/platform-infra-sub2api-codex/config.ts b/scripts/src/platform-infra-sub2api-codex/config.ts index 539808c7..c314f0a0 100644 --- a/scripts/src/platform-infra-sub2api-codex/config.ts +++ b/scripts/src/platform-infra-sub2api-codex/config.ts @@ -271,7 +271,59 @@ export function readRuntimeConfig(value: unknown): CodexRuntimeConfig { if (templatesById[template.id] !== undefined) throw new Error(`${codexPoolConfigPath}.runtime.templates duplicates id ${template.id}`); templatesById[template.id] = template; } - return { templates, templatesById }; + if (!Array.isArray(value.errorPassthroughRules)) { + throw new Error(`${codexPoolConfigPath}.runtime.errorPassthroughRules must be a YAML array`); + } + const errorPassthroughRules = value.errorPassthroughRules.map((item, index) => { + const path = `runtime.errorPassthroughRules[${index}]`; + if (!isRecord(item)) throw new Error(`${codexPoolConfigPath}.${path} must be a YAML object`); + const id = requiredStringConfigField(item, "id", path); + if (!/^[a-z0-9](?:[a-z0-9._-]*[a-z0-9])?$/u.test(id)) throw new Error(`${codexPoolConfigPath}.${path}.id has an unsupported format`); + const matchMode = requiredStringConfigField(item, "matchMode", path); + if (matchMode !== "any" && matchMode !== "all") throw new Error(`${codexPoolConfigPath}.${path}.matchMode must be any or all`); + const errorCodes = integerArrayConfigField(item, "errorCodes", path); + const keywords = readUniqueStringArray(item.keywords, `${path}.keywords`, true); + if (errorCodes.length === 0 && keywords.length === 0) throw new Error(`${codexPoolConfigPath}.${path} must declare errorCodes or keywords`); + const passthroughCode = item.passthroughCode; + const passthroughBody = item.passthroughBody; + const enabled = item.enabled; + const skipMonitoring = item.skipMonitoring; + if (typeof enabled !== "boolean") throw new Error(`${codexPoolConfigPath}.${path}.enabled must be boolean`); + if (typeof passthroughCode !== "boolean") throw new Error(`${codexPoolConfigPath}.${path}.passthroughCode must be boolean`); + if (typeof passthroughBody !== "boolean") throw new Error(`${codexPoolConfigPath}.${path}.passthroughBody must be boolean`); + if (typeof skipMonitoring !== "boolean") throw new Error(`${codexPoolConfigPath}.${path}.skipMonitoring must be boolean`); + const responseCode = item.responseCode === null ? null : integerConfigField(item, "responseCode", path); + if (!passthroughCode && (responseCode === null || responseCode < 100 || responseCode > 599)) { + throw new Error(`${codexPoolConfigPath}.${path}.responseCode must be an HTTP status when passthroughCode=false`); + } + const customMessage = item.customMessage === null || item.customMessage === undefined ? null : requiredStringConfigField(item, "customMessage", path); + if (!passthroughBody && customMessage === null) throw new Error(`${codexPoolConfigPath}.${path}.customMessage is required when passthroughBody=false`); + return { + id, + name: requiredStringConfigField(item, "name", path), + enabled, + priority: integerConfigField(item, "priority", path), + errorCodes, + keywords, + matchMode, + platforms: readUniqueStringArray(item.platforms, `${path}.platforms`, false), + passthroughCode, + responseCode, + passthroughBody, + customMessage, + skipMonitoring, + description: readRequiredDescription(item.description, `${path}.description`, 300), + }; + }); + const errorPassthroughRulesById: Record = {}; + const errorPassthroughRuleNames = new Set(); + for (const rule of errorPassthroughRules) { + if (errorPassthroughRulesById[rule.id] !== undefined) throw new Error(`${codexPoolConfigPath}.runtime.errorPassthroughRules duplicates id ${rule.id}`); + if (errorPassthroughRuleNames.has(rule.name)) throw new Error(`${codexPoolConfigPath}.runtime.errorPassthroughRules duplicates name ${rule.name}`); + errorPassthroughRulesById[rule.id] = rule; + errorPassthroughRuleNames.add(rule.name); + } + return { templates, templatesById, errorPassthroughRules, errorPassthroughRulesById }; } export function readRuntimeTemplateRef(value: unknown, key: string, runtime: CodexRuntimeConfig): string { diff --git a/scripts/src/platform-infra-sub2api-codex/error-passthrough.ts b/scripts/src/platform-infra-sub2api-codex/error-passthrough.ts new file mode 100644 index 00000000..4062b7cc --- /dev/null +++ b/scripts/src/platform-infra-sub2api-codex/error-passthrough.ts @@ -0,0 +1,266 @@ +import type { UniDeskConfig } from "../config"; +import type { RenderedCliResult } from "../output"; + +import { readCodexPoolConfig } from "./config"; +import { boolField, compactCapture, parseJsonOutput, runRemoteCodexPoolScript } from "./remote"; +import { codexPoolRuntimeTarget, defaultCodexPoolRuntimeTargetId } from "./runtime-target"; +import { codexPoolConfigPath, type CodexErrorPassthroughRule } from "./types"; + +type Action = "list" | "apply" | "delete"; + +interface Options { + action: Action; + template: string | null; + confirm: boolean; + json: boolean; + targetId: string; +} + +export async function codexPoolErrorPassthrough(config: UniDeskConfig, args: string[]): Promise | RenderedCliResult> { + if (args.includes("--help")) return help(); + const options = parseOptions(args); + const pool = readCodexPoolConfig(); + const target = codexPoolRuntimeTarget(options.targetId); + const selected = options.template === null ? null : pool.runtime.errorPassthroughRulesById[options.template]; + if (options.template !== null && selected === undefined) { + throw new Error(`unknown error-passthrough template ${options.template}; available: ${pool.runtime.errorPassthroughRules.map((item) => item.id).join(", ")}`); + } + const payload = { + action: options.action, + confirm: options.confirm, + selected: selected === null || selected === undefined ? null : apiRule(selected), + managed: pool.runtime.errorPassthroughRules.map((rule) => ({ id: rule.id, ...apiRule(rule) })), + }; + const result = await runRemoteCodexPoolScript(config, "error-passthrough", remoteScript(payload, target), target); + const parsed = parseJsonOutput(result.stdout); + const ok = result.exitCode === 0 && boolField(parsed, "ok", false); + const response = { + ok, + action: `platform-infra-sub2api-codex-pool-error-passthrough-${options.action}`, + target: { id: target.id, route: target.route, runtimeMode: target.runtimeMode, endpoint: target.serviceDns }, + source: { configPath: `${codexPoolConfigPath}#runtime.errorPassthroughRules`, api: "/api/v1/admin/error-passthrough-rules", official: true }, + request: { template: options.template, confirm: options.confirm }, + result: parsed, + remote: compactCapture(result, { full: parsed === null || result.exitCode !== 0 }), + valuesPrinted: false, + }; + return options.json ? response : render(response); +} + +function parseOptions(args: string[]): Options { + const [actionRaw = "list", ...rest] = args; + if (actionRaw !== "list" && actionRaw !== "apply" && actionRaw !== "delete") throw new Error("error-passthrough usage: list|apply|delete [--template ] [--confirm] [--target ] [--json]"); + let template: string | null = null; + let confirm = false; + let json = false; + let targetId = defaultCodexPoolRuntimeTargetId(); + for (let index = 0; index < rest.length; index += 1) { + const arg = rest[index]!; + const readValue = (name: string): string => { + const value = rest[index + 1]; + if (!value || value.startsWith("--")) throw new Error(`${name} requires a value`); + index += 1; + return value; + }; + if (arg === "--template") template = readValue("--template"); + else if (arg.startsWith("--template=")) template = arg.slice("--template=".length); + else if (arg === "--target") targetId = readValue("--target"); + else if (arg.startsWith("--target=")) targetId = arg.slice("--target=".length); + else if (arg === "--confirm") confirm = true; + else if (arg === "--json") json = true; + else throw new Error(`unsupported error-passthrough option: ${arg}`); + } + if ((actionRaw === "apply" || actionRaw === "delete") && template === null) throw new Error(`error-passthrough ${actionRaw} requires --template `); + if (actionRaw === "list" && (template !== null || confirm)) throw new Error("error-passthrough list does not accept --template or --confirm"); + return { action: actionRaw, template, confirm, json, targetId }; +} + +function apiRule(rule: CodexErrorPassthroughRule): Record { + return { + name: rule.name, + enabled: rule.enabled, + priority: rule.priority, + error_codes: rule.errorCodes, + keywords: rule.keywords, + match_mode: rule.matchMode, + platforms: rule.platforms, + passthrough_code: rule.passthroughCode, + response_code: rule.responseCode, + passthrough_body: rule.passthroughBody, + custom_message: rule.customMessage, + skip_monitoring: rule.skipMonitoring, + description: rule.description, + }; +} + +function help(): RenderedCliResult { + return { + ok: true, + command: "platform-infra sub2api codex-pool error-passthrough --help", + renderedText: [ + "SUB2API ERROR PASSTHROUGH", + "Usage:", + " bun scripts/cli.ts platform-infra sub2api codex-pool error-passthrough list [--target PK01] [--json]", + " bun scripts/cli.ts platform-infra sub2api codex-pool error-passthrough apply --template [--target PK01] [--confirm] [--json]", + " bun scripts/cli.ts platform-infra sub2api codex-pool error-passthrough delete --template [--target PK01] [--confirm] [--json]", + "Writes use the official Sub2API admin error-passthrough-rules API. Without --confirm, apply/delete are dry-run.", + ].join("\n"), + contentType: "text/plain", + }; +} + +function render(response: Record): RenderedCliResult { + const result = record(response.result); + const rows = Array.isArray(result.rules) ? result.rules.map(record) : []; + const lines = [ + `SUB2API ERROR PASSTHROUGH ok=${response.ok === true ? "true" : "false"} mode=${text(result.mode)} mutation=${text(result.mutation)}`, + "TEMPLATE RULE_ID ENABLED MATCH ERROR_CODES RESPONSE PLATFORMS STATE", + ]; + for (const row of rows) { + lines.push([ + text(row.templateId).padEnd(36), + text(row.ruleId).padEnd(7), + text(row.enabled).padEnd(7), + text(row.matchMode).padEnd(5), + text(row.errorCodes).padEnd(11), + text(row.responseCode).padEnd(8), + text(row.platforms).padEnd(9), + text(row.state), + ].join(" ").trimEnd()); + } + if (result.unmanagedCount !== undefined) lines.push(`UNMANAGED ${text(result.unmanagedCount)}`); + if (result.message) lines.push(`MESSAGE ${text(result.message)}`); + return { ok: response.ok === true, command: String(response.action), renderedText: lines.join("\n"), contentType: "text/plain", projection: response }; +} + +function record(value: unknown): Record { + return typeof value === "object" && value !== null && !Array.isArray(value) ? value as Record : {}; +} + +function text(value: unknown): string { + if (value === null || value === undefined || value === "") return "-"; + if (Array.isArray(value)) return value.join(","); + if (typeof value === "boolean") return value ? "yes" : "no"; + return String(value).replace(/[\r\n\t]+/gu, " "); +} + +function remoteScript(payload: unknown, target: ReturnType): string { + const encoded = Buffer.from(JSON.stringify(payload), "utf8").toString("base64"); + return `set -u +python3 - <<'PY' +import base64, json, subprocess +from urllib.parse import urlencode + +PAYLOAD = json.loads(base64.b64decode(${JSON.stringify(encoded)}).decode("utf-8")) +RUNTIME_MODE = ${JSON.stringify(target.runtimeMode)} +NAMESPACE = ${JSON.stringify(target.namespace)} +APP_SECRET_NAME = ${JSON.stringify(target.appSecretName)} +HOST_PORT = ${JSON.stringify(target.hostDockerAppPort)} +APP_POD = None + +def run(cmd, body=None): + return subprocess.run(cmd, input=body, stdout=subprocess.PIPE, stderr=subprocess.PIPE) + +def docker(args): + proc = run(["docker", *args]) + return proc if proc.returncode == 0 else run(["sudo", "-n", "docker", *args]) + +def kubectl(args, body=None): + return run(["kubectl", *args], body) + +def kube_json(args): + proc = kubectl([*args, "-o", "json"]) + if proc.returncode != 0: raise RuntimeError("kubectl read failed") + return json.loads(proc.stdout.decode()) + +if RUNTIME_MODE != "host-docker": + pods = kube_json(["-n", NAMESPACE, "get", "pods", "-l", "app=sub2api"]).get("items") or [] + running = [x for x in pods if x.get("status", {}).get("phase") == "Running"] + if not running: raise RuntimeError("sub2api app pod not found") + APP_POD = running[0]["metadata"]["name"] + +def env_value(key, default=None): + if RUNTIME_MODE == "host-docker": + proc = docker(["inspect", "sub2api-app", "--format", "{{json .Config.Env}}"]) + if proc.returncode != 0: return default + for item in json.loads(proc.stdout.decode()): + if item.startswith(key + "="): return item.split("=", 1)[1] + return default + cm = kube_json(["-n", NAMESPACE, "get", "configmap", "sub2api-config"]).get("data") or {} + return cm.get(key, default) + +def secret_value(key): + if RUNTIME_MODE == "host-docker": return env_value(key) + data = kube_json(["-n", NAMESPACE, "get", "secret", APP_SECRET_NAME]).get("data") or {} + return base64.b64decode(data[key]).decode() if key in data else None + +def api(method, path, token=None, payload=None): + body = b"" if payload is None else json.dumps(payload, separators=(",", ":")).encode() + script = '''set -eu +method="$1"; url="$2"; token="$3"; tmp="$(mktemp)"; trap 'rm -f "$tmp"' EXIT; cat >"$tmp" +args=""; [ -n "$token" ] && args="Authorization: Bearer $token" +if [ -n "$args" ] && [ -s "$tmp" ]; then curl -sS -w '\n__CODE__:%{http_code}' -X "$method" -H "$args" -H 'Content-Type: application/json' --data-binary @"$tmp" "$url" +elif [ -n "$args" ]; then curl -sS -w '\n__CODE__:%{http_code}' -X "$method" -H "$args" "$url" +elif [ -s "$tmp" ]; then curl -sS -w '\n__CODE__:%{http_code}' -X "$method" -H 'Content-Type: application/json' --data-binary @"$tmp" "$url" +else curl -sS -w '\n__CODE__:%{http_code}' -X "$method" "$url"; fi''' + base = f"http://127.0.0.1:{HOST_PORT}" if RUNTIME_MODE == "host-docker" else "http://127.0.0.1:8080" + cmd = ["sh", "-c", script, "sh", method, base + path, token or ""] + proc = run(cmd, body) if RUNTIME_MODE == "host-docker" else kubectl(["-n", NAMESPACE, "exec", "-i", APP_POD, "--", *cmd], body) + out = proc.stdout.decode(errors="replace"); marker = "\\n__CODE__:"; pos = out.rfind(marker) + code = int(out[pos + len(marker):].strip()) if pos >= 0 else 0; raw = out[:pos] if pos >= 0 else out + parsed = json.loads(raw) if raw.strip() else None + if proc.returncode != 0 or not 200 <= code < 300 or (isinstance(parsed, dict) and parsed.get("code") not in (None, 0)): + raise RuntimeError(f"official admin API failed method={method} path={path} http={code}") + return parsed.get("data") if isinstance(parsed, dict) and "data" in parsed else parsed + +def login(): + password = secret_value("ADMIN_PASSWORD") + if not password: raise RuntimeError("ADMIN_PASSWORD missing") + data = api("POST", "/api/v1/auth/login", payload={"email": env_value("ADMIN_EMAIL", "admin@example.com"), "password": password}) + token = data.get("access_token") or data.get("token") if isinstance(data, dict) else None + if not token: raise RuntimeError("admin login response has no token") + return token + +FIELDS = ("name","enabled","priority","error_codes","keywords","match_mode","platforms","passthrough_code","response_code","passthrough_body","custom_message","skip_monitoring","description") +def normalized(rule): return {k: rule.get(k) for k in FIELDS} +def state(current, desired): + if current is None: return "missing" + return "synced" if normalized(current) == normalized(desired) else "drift" + +def execute(): + token = login(); current = api("GET", "/api/v1/admin/error-passthrough-rules", token=token) or [] + if not isinstance(current, list): raise RuntimeError("official rule list is not an array") + managed = PAYLOAD.get("managed") or []; selected = PAYLOAD.get("selected") + action = PAYLOAD["action"]; confirm = bool(PAYLOAD.get("confirm")); mutation = False + if action in ("apply", "delete"): + matches = [r for r in current if r.get("name") == selected.get("name")] + if len(matches) > 1: raise RuntimeError("duplicate official rules with managed name") + existing = matches[0] if matches else None + if action == "apply" and state(existing, selected) != "synced" and confirm: + if existing is None: api("POST", "/api/v1/admin/error-passthrough-rules", token, selected) + else: api("PUT", f"/api/v1/admin/error-passthrough-rules/{existing['id']}", token, selected) + mutation = True + if action == "delete" and existing is not None and confirm: + api("DELETE", f"/api/v1/admin/error-passthrough-rules/{existing['id']}", token) + mutation = True + current = api("GET", "/api/v1/admin/error-passthrough-rules", token=token) or [] + rows = [] + names = {m["name"] for m in managed} + for desired in managed: + found = [r for r in current if r.get("name") == desired["name"]] + actual = found[0] if len(found) == 1 else None + row_state = "duplicate" if len(found) > 1 else state(actual, desired) + if action == "delete" and selected and desired["name"] == selected["name"] and actual is None: row_state = "absent" + rows.append({"templateId": desired["id"], "ruleId": actual.get("id") if actual else None, "enabled": actual.get("enabled") if actual else desired.get("enabled"), "matchMode": desired.get("match_mode"), "errorCodes": desired.get("error_codes"), "responseCode": desired.get("response_code"), "platforms": desired.get("platforms"), "state": row_state}) + target_row = next((x for x in rows if selected and x["templateId"] == next((m["id"] for m in managed if m["name"] == selected["name"]), None)), None) + ok = all(x["state"] not in ("duplicate",) for x in rows) + if action == "apply" and confirm: ok = ok and target_row is not None and target_row["state"] == "synced" + if action == "delete" and confirm: ok = ok and target_row is not None and target_row["state"] == "absent" + return {"ok": ok, "mode": "confirm" if confirm else "dry-run" if action != "list" else "list", "mutation": mutation, "rules": rows, "unmanagedCount": sum(1 for r in current if r.get("name") not in names), "message": "official Sub2API error-passthrough-rules API"} + +try: output = execute() +except Exception as exc: output = {"ok": False, "error": str(exc), "mutation": False, "rules": []} +print(json.dumps(output, ensure_ascii=False, indent=2)) +raise SystemExit(0 if output.get("ok") else 1) +PY`; +} diff --git a/scripts/src/platform-infra-sub2api-codex/index.ts b/scripts/src/platform-infra-sub2api-codex/index.ts index 903b1c0f..c57d2b5d 100644 --- a/scripts/src/platform-infra-sub2api-codex/index.ts +++ b/scripts/src/platform-infra-sub2api-codex/index.ts @@ -15,3 +15,4 @@ export * from "./remote-python-sync"; export * from "./remote"; export * from "./feedback"; export * from "./profit"; +export * from "./error-passthrough"; diff --git a/scripts/src/platform-infra-sub2api-codex/options.ts b/scripts/src/platform-infra-sub2api-codex/options.ts index fdb01884..b7a0df1f 100644 --- a/scripts/src/platform-infra-sub2api-codex/options.ts +++ b/scripts/src/platform-infra-sub2api-codex/options.ts @@ -28,6 +28,7 @@ import { renderCodexPoolPlan } from "./render"; import { codexPoolRuntime } from "./runtime"; import { codexPoolFaults } from "./faults"; import { codexPoolProfit } from "./profit"; +import { codexPoolErrorPassthrough } from "./error-passthrough"; import { defaultCodexPoolRuntimeTargetId } from "./runtime-target"; import { codexPoolHelp } from "./types"; @@ -43,6 +44,7 @@ export async function runCodexPoolCommand(config: UniDeskConfig, args: string[]) if (action === "runtime") return await codexPoolRuntime(config, args.slice(1)); if (action === "faults") return await codexPoolFaults(config, args.slice(1)); if (action === "profit") return await codexPoolProfit(config, args.slice(1)); + if (action === "error-passthrough") return await codexPoolErrorPassthrough(config, args.slice(1)); if (action === "trace") return await codexPoolTrace(config, parseTraceOptions(args.slice(1))); if (action === "feedback") return await codexPoolFeedback(config, parseFeedbackOptions(args.slice(1))); if (action === "sentinel-image") return await codexPoolSentinelImage(config, parseSentinelImageOptions(args.slice(1))); diff --git a/scripts/src/platform-infra-sub2api-codex/remote.ts b/scripts/src/platform-infra-sub2api-codex/remote.ts index 90507e26..a6e0aca3 100644 --- a/scripts/src/platform-infra-sub2api-codex/remote.ts +++ b/scripts/src/platform-infra-sub2api-codex/remote.ts @@ -28,7 +28,7 @@ export async function capture(config: UniDeskConfig, target: string, args: strin return await runSshCommandCapture(config, target, args, input); } -export type RemoteCodexPoolMode = "sync" | "validate" | "runtime" | "faults" | "profit" | "sentinel-probe" | "sentinel-image-status" | "sentinel-image-build"; +export type RemoteCodexPoolMode = "sync" | "validate" | "runtime" | "faults" | "profit" | "error-passthrough" | "sentinel-probe" | "sentinel-image-status" | "sentinel-image-build"; export async function runRemoteCodexPoolScript(config: UniDeskConfig, mode: RemoteCodexPoolMode, script: string, target = codexPoolRuntimeTarget()): Promise { const jobName = `codex-pool-${mode}-${Date.now().toString(36)}`.slice(0, 63); @@ -85,6 +85,7 @@ export function codexPoolModeCommand(mode: RemoteCodexPoolMode): string { if (mode === "sync") return "bun scripts/cli.ts platform-infra sub2api codex-pool sync --confirm"; if (mode === "runtime") return "bun scripts/cli.ts platform-infra sub2api codex-pool runtime list"; if (mode === "profit") return "bun scripts/cli.ts platform-infra sub2api codex-pool profit --since 24h"; + if (mode === "error-passthrough") return "bun scripts/cli.ts platform-infra sub2api codex-pool error-passthrough list"; if (mode === "sentinel-probe") return "bun scripts/cli.ts platform-infra sub2api codex-pool sentinel-probe --account --confirm"; if (mode === "sentinel-image-status") return "bun scripts/cli.ts platform-infra sub2api codex-pool sentinel-image status"; if (mode === "sentinel-image-build") return "bun scripts/cli.ts platform-infra sub2api codex-pool sentinel-image build --confirm"; diff --git a/scripts/src/platform-infra-sub2api-codex/types.ts b/scripts/src/platform-infra-sub2api-codex/types.ts index 63b669be..69c3614e 100644 --- a/scripts/src/platform-infra-sub2api-codex/types.ts +++ b/scripts/src/platform-infra-sub2api-codex/types.ts @@ -176,9 +176,28 @@ export interface CodexRuntimeTemplate { tempUnschedulable: CodexTempUnschedulablePolicy; } +export interface CodexErrorPassthroughRule { + id: string; + name: string; + enabled: boolean; + priority: number; + errorCodes: number[]; + keywords: string[]; + matchMode: "any" | "all"; + platforms: string[]; + passthroughCode: boolean; + responseCode: number | null; + passthroughBody: boolean; + customMessage: string | null; + skipMonitoring: boolean; + description: string; +} + export interface CodexRuntimeConfig { templates: CodexRuntimeTemplate[]; templatesById: Record; + errorPassthroughRules: CodexErrorPassthroughRule[]; + errorPassthroughRulesById: Record; } export interface CodexPoolProfitConfig { @@ -363,7 +382,7 @@ export function codexPoolHelp(): unknown { const pool = readCodexPoolConfig(); const runtimeTarget = codexPoolRuntimeTarget(); return { - command: "platform-infra sub2api codex-pool plan|sync|validate|runtime|faults|profit|trace|feedback|sentinel-image|sentinel-probe|sentinel-report|cleanup-probes|expose|configure-local", + command: "platform-infra sub2api codex-pool plan|sync|validate|runtime|faults|profit|error-passthrough|trace|feedback|sentinel-image|sentinel-probe|sentinel-report|cleanup-probes|expose|configure-local", output: "json, except trace, feedback, and sentinel-report default to low-noise text tables", usage: [ "bun scripts/cli.ts platform-infra sub2api codex-pool plan", @@ -375,6 +394,7 @@ export function codexPoolHelp(): unknown { "bun scripts/cli.ts platform-infra sub2api codex-pool runtime errors [--group |--all-groups] [--platform ] [--page-token ] [--account ] [--since 24h] [--tail 50000] [--target PK01] [--json|--full|--raw]", "bun scripts/cli.ts platform-infra sub2api codex-pool faults [--level P0|P1|P2] [--group ] [--account ] [--model ] [--stream sync|stream] [--endpoint ] [--request-id ] [--page-token ] [--target PK01] [--json]", "bun scripts/cli.ts platform-infra sub2api codex-pool profit [--since 24h] [--target PK01] [--accounts [--page-token ]|--missing-costs] [--json]", + "bun scripts/cli.ts platform-infra sub2api codex-pool error-passthrough list|apply|delete [--template ] [--target PK01] [--confirm] [--json]", "bun scripts/cli.ts platform-infra sub2api codex-pool runtime apply --account --template [--target PK01] [--confirm]", "bun scripts/cli.ts platform-infra sub2api codex-pool runtime apply --accounts --template [--target PK01] [--confirm] [--full|--raw]", "bun scripts/cli.ts platform-infra sub2api codex-pool runtime delete --account --kind temp-unschedulable [--target PK01] [--confirm]",