diff --git a/.agents/skills/unidesk-sub2api/SKILL.md b/.agents/skills/unidesk-sub2api/SKILL.md index 09b390ec..3972d0dc 100644 --- a/.agents/skills/unidesk-sub2api/SKILL.md +++ b/.agents/skills/unidesk-sub2api/SKILL.md @@ -13,6 +13,7 @@ UniDesk 在 G14 k3s `platform-infra` namespace 运维 Sub2API。日常操作统 - 仓库长期开发边界见 `docs/reference/platform-infra.md`,本 skill 承担日常操作手册。 - 配置真相是 YAML:`config/platform-infra/sub2api.yaml` 和 `config/platform-infra/sub2api-codex-pool.yaml`。 +- 本 skill 目录下若存在 `agents/*.yaml`,只作为 skill/agent 展示与调用元数据,不是 Sub2API 或 Codex pool 运行配置;不要在 skill 目录维护第二份账号、capacity、priority、endpoint 或 Secret 配置。 - Runtime 在 `G14:k3s` 的 `platform-infra` namespace;master server 只是控制端和消费者,不部署 Sub2API/PostgreSQL/Redis。 - Secret、`~/.codex/config.toml*`、`~/.codex/auth.json*` 是运行时输入或本地状态,不提交。 - 输出只能包含 Secret 路径、长度、preview/fingerprint;禁止打印完整 API key、admin password、JWT secret、TOTP key。 diff --git a/config/platform-infra/sub2api-codex-pool.yaml b/config/platform-infra/sub2api-codex-pool.yaml index ef8115de..bdbc1dac 100644 --- a/config/platform-infra/sub2api-codex-pool.yaml +++ b/config/platform-infra/sub2api-codex-pool.yaml @@ -5,20 +5,53 @@ pool: apiKeySecretKey: API_KEY minOwnerBalanceUsd: 1000 defaultAccountCapacity: 5 + defaultTempUnschedulable: + enabled: true + rules: + - statusCode: 401 + keywords: [unauthorized, invalid api key, invalid_api_key, authentication] + durationMinutes: 30 + description: Credential/auth failures should leave the scheduler quickly and retry after a cooldown. + - statusCode: 403 + keywords: [forbidden, access denied, quota, billing, capacity] + durationMinutes: 30 + description: Permission, quota, or account-state failures should fail over to another account. + - statusCode: 429 + keywords: [capacity, rate limit, rate_limit, quota, too many requests, overloaded, resource_exhausted] + durationMinutes: 10 + description: Capacity and rate-limit responses should cool down this account and use another account. + - statusCode: 500 + keywords: [capacity, overloaded, temporarily unavailable, temporary, upstream] + durationMinutes: 5 + description: Transient upstream server failures should prefer another account for a short period. + - statusCode: 502 + keywords: [capacity, overloaded, temporarily unavailable, temporary, upstream] + durationMinutes: 5 + description: Gateway upstream failures should prefer another account for a short period. + - statusCode: 503 + keywords: [capacity, overloaded, temporarily unavailable, temporary, upstream] + durationMinutes: 5 + description: Service unavailable responses should prefer another account for a short period. + - statusCode: 529 + keywords: [capacity, overloaded, temporarily unavailable, temporary] + durationMinutes: 10 + description: Provider overloaded responses should cool down this account and use another account. profiles: entries: - profile: default - accountName: unidesk-codex-default + accountName: unidesk-codex-403de5 configFile: config.toml.pre-sub2api authFile: auth.json.pre-sub2api fallbackConfigFile: config.toml fallbackAuthFile: auth.json openaiResponsesWebSocketsV2Mode: ctx_pool + priority: 10 - profile: HY accountName: unidesk-codex-hy configFile: config.toml.HY authFile: auth.json.HY openaiResponsesWebSocketsV2Mode: passthrough + priority: 1 - profile: gptclub accountName: unidesk-codex-gptclub configFile: config.toml.gptclub @@ -34,14 +67,17 @@ profiles: configFile: config.toml.fixwikihub authFile: auth.json.fixwikihub openaiResponsesWebSocketsV2Mode: off + priority: 10 - profile: zakuzaku accountName: unidesk-codex-zakuzaku configFile: config.toml.zakuzaku authFile: auth.json.zakuzaku + priority: 10 - profile: 17nas accountName: unidesk-codex-17nas configFile: config.toml.17nas authFile: auth.json.17nas + priority: 10 upstreamUserAgent: codex_cli_rs/0.137.0 (Ubuntu 22.04; x86_64) xterm-256color publicExposure: enabled: true diff --git a/docs/reference/platform-infra.md b/docs/reference/platform-infra.md index c11c8c58..d949026f 100644 --- a/docs/reference/platform-infra.md +++ b/docs/reference/platform-infra.md @@ -25,13 +25,14 @@ - `pool.groupName` names the Sub2API group that represents the pool. - `pool.apiKeySecretName` and `pool.apiKeySecretKey` name the k3s Secret that stores the single consumer API key. +- `pool.defaultTempUnschedulable` declares Sub2API account-level temporary unschedulable rules. Keep 429/overload/capacity failures in this YAML policy so the scheduler can cool down a failing account and choose another candidate instead of hard-pinning one provider. - `profiles.entries` selects local Codex profile files from `~/.codex/` and maps them to Sub2API account names. - `profiles.entries[].openaiResponsesWebSocketsV2Mode` is the account-level Responses WebSocket v2 switch for OpenAI-compatible upstreams that require WebSocket transport. Allowed values are `off`, `ctx_pool`, and `passthrough`; omit the field unless that upstream needs it. - `profiles.entries[].upstreamUserAgent` is an optional account-level upstream request User-Agent override. Use it only for upstreams that require a Codex CLI compatible User-Agent; keep the value YAML-controlled and newline-free. - `publicExposure` controls the optional FRP bridge from master server to the G14 ClusterIP service. - `localCodex` controls how the master server's current `~/.codex` consumer files are backed up and rewritten. Codex consumers using Sub2API must keep `supportsWebSockets` and `responsesWebSocketsV2` enabled so compacted long sessions can continue through the Responses WebSocket v2 response chain instead of falling back to HTTP-only summary context. -Enable account-level WebSocket v2 only for upstream profiles that have passed a direct Codex WSv2 probe. `codex-pool validate` reports each managed account's runtime WebSocket v2 mode and whether it matches YAML, so stale `ctx_pool` settings cannot silently keep routing Codex WS sessions to an upstream that closes with `no available account`. +Enable account-level WebSocket v2 only for upstream profiles that have passed a direct Codex WSv2 probe. Treat this as a YAML-declared capability set, not a hard scheduling pin to one profile; `codex-pool validate` must show at least one current `webSocketsV2.schedulableEnabled` account, and runtime smoke remains the availability proof. The same validation reports each managed account's runtime WebSocket v2 mode and whether it matches YAML, so stale `ctx_pool` settings cannot silently keep routing Codex WS sessions to an upstream that closes with `no available account`. The request path is: diff --git a/scripts/platform-infra-sub2api-codex-routing-contract-test.ts b/scripts/platform-infra-sub2api-codex-routing-contract-test.ts new file mode 100644 index 00000000..4947b1de --- /dev/null +++ b/scripts/platform-infra-sub2api-codex-routing-contract-test.ts @@ -0,0 +1,42 @@ +import { readFileSync } from "node:fs"; +import { rootPath } from "./src/config"; + +function assertCondition(condition: unknown, message: string, detail: unknown = {}): void { + if (!condition) throw new Error(`${message}: ${JSON.stringify(detail)}`); +} + +const configPath = rootPath("config", "platform-infra", "sub2api-codex-pool.yaml"); +const parsed = Bun.YAML.parse(readFileSync(configPath, "utf8")) as { + pool?: { + defaultAccountCapacity?: number; + defaultTempUnschedulable?: { + enabled?: boolean; + rules?: Array<{ statusCode?: number; keywords?: string[]; durationMinutes?: number }>; + }; + }; + profiles?: { entries?: Array<{ profile?: string; accountName?: string; capacity?: number; openaiResponsesWebSocketsV2Mode?: string | null }> }; +}; + +const entries = parsed.profiles?.entries ?? []; +const hy = entries.find((entry) => entry.profile === "HY"); +const wsEnabled = entries.filter((entry) => entry.openaiResponsesWebSocketsV2Mode && entry.openaiResponsesWebSocketsV2Mode !== "off"); +const rules = parsed.pool?.defaultTempUnschedulable?.rules ?? []; +const overload429 = rules.find((rule) => rule.statusCode === 429); + +assertCondition(parsed.pool?.defaultAccountCapacity === 5, "Codex pool default capacity should remain explicit YAML", parsed.pool); +assertCondition(hy !== undefined, "HY profile should remain declared as a candidate, not a special scheduler target", entries); +assertCondition(hy?.capacity === undefined, "HY must not carry a hard-coded capacity override", hy); +assertCondition(wsEnabled.length >= 2, "Responses WSv2 should be a capability set with multiple candidates", wsEnabled); +assertCondition(parsed.pool?.defaultTempUnschedulable?.enabled === true, "temporary unschedulable policy must be enabled from YAML", parsed.pool?.defaultTempUnschedulable); +assertCondition(overload429 !== undefined, "temporary unschedulable policy must include 429 handling", rules); +assertCondition(overload429?.keywords?.includes("too many requests"), "429 handling must catch provider concurrency/rate-limit text", overload429); +assertCondition((overload429?.durationMinutes ?? 0) > 0, "429 handling must cool down for a positive duration", overload429); + +console.log(JSON.stringify({ + ok: true, + checks: [ + "HY is not capacity-pinned", + "WSv2 routing is represented as a multi-account capability set", + "temporary unschedulable rules are YAML-controlled", + ], +})); diff --git a/scripts/src/check.ts b/scripts/src/check.ts index 6e5db9f1..92274fb4 100644 --- a/scripts/src/check.ts +++ b/scripts/src/check.ts @@ -16,6 +16,7 @@ const syntaxFiles = [ "scripts/playwright-cli.ts", "scripts/playwright-cli-contract-test.ts", "scripts/platform-infra-sub2api-codex-local-config-contract-test.ts", + "scripts/platform-infra-sub2api-codex-routing-contract-test.ts", "scripts/src/playwright-cli.ts", "scripts/src/check.ts", "scripts/src/artifact-registry.ts", @@ -384,6 +385,7 @@ export function runChecks(config: UniDeskConfig, options: CheckOptions = default fileItem("scripts/gh-cli-pr-contract-test.ts"), fileItem("scripts/playwright-cli-contract-test.ts"), fileItem("scripts/platform-infra-sub2api-codex-local-config-contract-test.ts"), + fileItem("scripts/platform-infra-sub2api-codex-routing-contract-test.ts"), fileItem("scripts/code-queue-pr-preflight-example.ts"), fileItem("scripts/schedule-cli-contract-test.ts"), fileItem("scripts/server-cleanup-plan-contract-test.ts"), @@ -449,6 +451,7 @@ export function runChecks(config: UniDeskConfig, options: CheckOptions = default items.push(commandItem("gh:pr-contract", ["bun", "scripts/gh-cli-pr-contract-test.ts"], 30_000)); items.push(commandItem("playwright:cli-wrapper-contract", ["bun", "scripts/playwright-cli-contract-test.ts"], 30_000)); items.push(commandItem("platform-infra:sub2api-codex-local-config-contract", ["bun", "scripts/platform-infra-sub2api-codex-local-config-contract-test.ts"], 30_000)); + items.push(commandItem("platform-infra:sub2api-codex-routing-contract", ["bun", "scripts/platform-infra-sub2api-codex-routing-contract-test.ts"], 30_000)); items.push(commandItem("auth-broker:p0-contract", ["bun", "scripts/auth-broker-contract-test.ts"], 30_000)); items.push(commandItem("d601:recovery-guardrails-contract", ["bun", "scripts/d601-recovery-guardrails-contract-test.ts"], 30_000)); items.push(commandItem("hwlab:cd-wrapper-contract", ["bun", "scripts/hwlab-cd-wrapper-contract-test.ts"], 30_000)); diff --git a/scripts/src/platform-infra-sub2api-codex.ts b/scripts/src/platform-infra-sub2api-codex.ts index e390a638..4cbc1609 100644 --- a/scripts/src/platform-infra-sub2api-codex.ts +++ b/scripts/src/platform-infra-sub2api-codex.ts @@ -48,6 +48,7 @@ interface CodexProfile { upstreamUserAgent: string | null; priority: number; capacity: number; + tempUnschedulable: CodexTempUnschedulablePolicy; authOpenAIKeyShape: string; ok: boolean; error: string | null; @@ -55,6 +56,18 @@ interface CodexProfile { type OpenAIResponsesWebSocketsV2Mode = "off" | "ctx_pool" | "passthrough"; +export interface CodexTempUnschedulableRule { + statusCode: number; + keywords: string[]; + durationMinutes: number; + description: string | null; +} + +export interface CodexTempUnschedulablePolicy { + enabled: boolean; + rules: CodexTempUnschedulableRule[]; +} + interface CodexPoolConfig { groupName: string; apiKeyName: string; @@ -62,6 +75,7 @@ interface CodexPoolConfig { apiKeySecretKey: string; minOwnerBalanceUsd: number; defaultAccountCapacity: number; + defaultTempUnschedulable: CodexTempUnschedulablePolicy; profiles: CodexPoolProfileConfig[]; publicExposure: CodexPoolPublicExposureConfig; localCodex: CodexPoolLocalCodexConfig; @@ -78,6 +92,7 @@ interface CodexPoolProfileConfig { upstreamUserAgent: string | null; priority: number; capacity: number | null; + tempUnschedulable: CodexTempUnschedulablePolicy; } interface CodexPoolPublicExposureConfig { @@ -265,6 +280,7 @@ async function codexPoolSync(config: UniDeskConfig, options: SyncOptions): Promi upstreamUserAgent: profile.upstreamUserAgent, priority: profile.priority, capacity: profile.capacity, + tempUnschedulable: profile.tempUnschedulable, })), }; const result = await capture(config, g14K3sRoute, ["script"], syncScript(payload, pool)); @@ -454,6 +470,7 @@ function collectCodexProfiles(): CodexProfile[] { upstreamUserAgent: entry.upstreamUserAgent, priority: entry.priority, capacity: entry.capacity ?? pool.defaultAccountCapacity, + tempUnschedulable: entry.tempUnschedulable, authOpenAIKeyShape: existsSync(authPath) ? "unknown" : "missing", ok: false, error: null, @@ -498,7 +515,7 @@ function collectCodexProfiles(): CodexProfile[] { }); } -function discoverCodexProfileConfigs(codexDir: string): CodexPoolProfileConfig[] { +function discoverCodexProfileConfigs(codexDir: string, defaultTempUnschedulable = defaultCodexTempUnschedulablePolicy()): CodexPoolProfileConfig[] { return readdirSync(codexDir) .filter((file) => file === "config.toml" || file.startsWith("config.toml.")) .sort((a, b) => { @@ -520,6 +537,7 @@ function discoverCodexProfileConfigs(codexDir: string): CodexPoolProfileConfig[] upstreamUserAgent: null, priority: 1, capacity: null, + tempUnschedulable: defaultTempUnschedulable, }; }); } @@ -541,6 +559,7 @@ function readCodexPoolConfig(): CodexPoolConfig { if (!isRecord(parsed)) throw new Error(`${codexPoolConfigPath} must contain a YAML object`); const pool = parsed.pool; if (!isRecord(pool)) throw new Error(`${codexPoolConfigPath}.pool must be a YAML object`); + const defaultTempUnschedulable = readTempUnschedulablePolicy(pool.defaultTempUnschedulable, "pool.defaultTempUnschedulable", defaults.defaultTempUnschedulable); const config: CodexPoolConfig = { groupName: stringValue(pool.groupName) ?? defaults.groupName, apiKeyName: stringValue(pool.apiKeyName) ?? defaults.apiKeyName, @@ -548,7 +567,8 @@ function readCodexPoolConfig(): CodexPoolConfig { apiKeySecretKey: stringValue(pool.apiKeySecretKey) ?? defaults.apiKeySecretKey, minOwnerBalanceUsd: numberValue(pool.minOwnerBalanceUsd) ?? defaults.minOwnerBalanceUsd, defaultAccountCapacity: readAccountCapacity(pool.defaultAccountCapacity, "pool.defaultAccountCapacity"), - profiles: readProfileConfig(parsed.profiles, defaults.profiles), + defaultTempUnschedulable, + profiles: readProfileConfig(parsed.profiles, defaults.profiles, defaultTempUnschedulable), publicExposure: readPublicExposureConfig(parsed.publicExposure, defaults.publicExposure), localCodex: readLocalCodexConfig(parsed.localCodex, defaults.localCodex), }; @@ -569,6 +589,7 @@ function defaultCodexPoolConfig(): CodexPoolConfig { apiKeySecretKey: defaultPoolApiKeySecretKey, minOwnerBalanceUsd: defaultMinOwnerBalanceUsd, defaultAccountCapacity: 5, + defaultTempUnschedulable: defaultCodexTempUnschedulablePolicy(), profiles: [], publicExposure: { enabled: false, @@ -605,7 +626,57 @@ function defaultCodexPoolConfig(): CodexPoolConfig { }; } -function readProfileConfig(value: unknown, defaults: CodexPoolProfileConfig[]): CodexPoolProfileConfig[] { +export function defaultCodexTempUnschedulablePolicy(): CodexTempUnschedulablePolicy { + return { + enabled: true, + rules: [ + { + statusCode: 401, + keywords: ["unauthorized", "invalid api key", "invalid_api_key", "authentication"], + durationMinutes: 30, + description: "Credential/auth failures should leave the scheduler quickly and retry after a cooldown.", + }, + { + statusCode: 403, + keywords: ["forbidden", "access denied", "quota", "billing", "capacity"], + durationMinutes: 30, + description: "Permission, quota, or account-state failures should fail over to another account.", + }, + { + statusCode: 429, + keywords: ["capacity", "rate limit", "rate_limit", "quota", "too many requests", "overloaded", "resource_exhausted"], + durationMinutes: 10, + description: "Capacity and rate-limit responses should cool down this account and use another account.", + }, + { + statusCode: 500, + keywords: ["capacity", "overloaded", "temporarily unavailable", "temporary", "upstream"], + durationMinutes: 5, + description: "Transient upstream server failures should prefer another account for a short period.", + }, + { + statusCode: 502, + keywords: ["capacity", "overloaded", "temporarily unavailable", "temporary", "upstream"], + durationMinutes: 5, + description: "Gateway upstream failures should prefer another account for a short period.", + }, + { + statusCode: 503, + keywords: ["capacity", "overloaded", "temporarily unavailable", "temporary", "upstream"], + durationMinutes: 5, + description: "Service unavailable responses should prefer another account for a short period.", + }, + { + statusCode: 529, + keywords: ["capacity", "overloaded", "temporarily unavailable", "temporary"], + durationMinutes: 10, + description: "Provider overloaded responses should cool down this account and use another account.", + }, + ], + }; +} + +function readProfileConfig(value: unknown, defaults: CodexPoolProfileConfig[], defaultTempUnschedulable: CodexTempUnschedulablePolicy): 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`); @@ -629,6 +700,7 @@ function readProfileConfig(value: unknown, defaults: CodexPoolProfileConfig[]): const upstreamUserAgent = readUpstreamUserAgent(entry.upstreamUserAgent, `profiles.entries[${index}].upstreamUserAgent`); const priority = readAccountPriority(entry.priority, `profiles.entries[${index}].priority`); const capacity = entry.capacity === undefined || entry.capacity === null ? null : readAccountCapacity(entry.capacity, `profiles.entries[${index}].capacity`); + const tempUnschedulable = readTempUnschedulablePolicy(entry.tempUnschedulable, `profiles.entries[${index}].tempUnschedulable`, defaultTempUnschedulable); return { profile, accountName, @@ -640,6 +712,7 @@ function readProfileConfig(value: unknown, defaults: CodexPoolProfileConfig[]): upstreamUserAgent, priority, capacity, + tempUnschedulable, }; }); } @@ -678,6 +751,66 @@ function readAccountCapacity(value: unknown, key: string): number { return capacity; } +function readTempUnschedulablePolicy(value: unknown, key: string, fallback: CodexTempUnschedulablePolicy): CodexTempUnschedulablePolicy { + if (value === undefined || value === null) return cloneTempUnschedulablePolicy(fallback); + if (!isRecord(value)) throw new Error(`${codexPoolConfigPath}.${key} must be a YAML object`); + const enabled = readBooleanConfig(value.enabled, `${key}.enabled`, fallback.enabled); + const rules = value.rules === undefined || value.rules === null + ? cloneTempUnschedulablePolicy(fallback).rules + : readTempUnschedulableRules(value.rules, `${key}.rules`); + if (enabled && rules.length === 0) throw new Error(`${codexPoolConfigPath}.${key}.rules must not be empty when enabled=true`); + return { enabled, rules }; +} + +function readTempUnschedulableRules(value: unknown, key: string): CodexTempUnschedulableRule[] { + if (!Array.isArray(value)) throw new Error(`${codexPoolConfigPath}.${key} must be a YAML array`); + return value.map((entry, index) => { + if (!isRecord(entry)) throw new Error(`${codexPoolConfigPath}.${key}[${index}] must be an object`); + const statusCode = numberValue(entry.statusCode ?? entry.errorCode); + if (statusCode === null || !Number.isInteger(statusCode) || statusCode < 100 || statusCode > 599) { + throw new Error(`${codexPoolConfigPath}.${key}[${index}].statusCode must be an HTTP status code from 100 to 599`); + } + const durationMinutes = numberValue(entry.durationMinutes); + if (durationMinutes === null || !Number.isInteger(durationMinutes) || durationMinutes < 1 || durationMinutes > 1440) { + throw new Error(`${codexPoolConfigPath}.${key}[${index}].durationMinutes must be an integer from 1 to 1440`); + } + const keywords = readTempUnschedulableKeywords(entry.keywords, `${key}[${index}].keywords`); + const description = stringValue(entry.description); + if (description !== null && description.length > 240) throw new Error(`${codexPoolConfigPath}.${key}[${index}].description must be at most 240 characters`); + return { statusCode, keywords, durationMinutes, description }; + }); +} + +function readTempUnschedulableKeywords(value: unknown, key: string): string[] { + if (!Array.isArray(value)) throw new Error(`${codexPoolConfigPath}.${key} must be a YAML array`); + const seen = new Set(); + const keywords: string[] = []; + for (const item of value) { + const keyword = stringValue(item); + if (keyword === null) throw new Error(`${codexPoolConfigPath}.${key} entries must be non-empty strings`); + if (keyword.length > 120) throw new Error(`${codexPoolConfigPath}.${key} entries must be at most 120 characters`); + if (/[\r\n]/u.test(keyword)) throw new Error(`${codexPoolConfigPath}.${key} entries must not contain newlines`); + const normalized = keyword.toLowerCase(); + if (seen.has(normalized)) continue; + seen.add(normalized); + keywords.push(keyword); + } + if (keywords.length === 0) throw new Error(`${codexPoolConfigPath}.${key} must not be empty`); + return keywords; +} + +function cloneTempUnschedulablePolicy(policy: CodexTempUnschedulablePolicy): CodexTempUnschedulablePolicy { + return { + enabled: policy.enabled, + rules: policy.rules.map((rule) => ({ + statusCode: rule.statusCode, + keywords: [...rule.keywords], + durationMinutes: rule.durationMinutes, + description: rule.description, + })), + }; +} + function readBooleanConfig(value: unknown, key: string, fallback: boolean): boolean { if (value === undefined || value === null) return fallback; const parsed = booleanValue(value); @@ -1726,6 +1859,32 @@ def list_accounts(token): data = ensure_success(curl_api("GET", path, bearer=token), "list accounts") return extract_items(data) +def temp_unschedulable_credentials(profile): + policy = profile.get("tempUnschedulable") or {} + enabled = policy.get("enabled") is True + rules = [] + if enabled: + for rule in policy.get("rules") or []: + status_code = rule.get("statusCode") if isinstance(rule, dict) else None + duration_minutes = rule.get("durationMinutes") if isinstance(rule, dict) else None + keywords = rule.get("keywords") if isinstance(rule, dict) else None + if not isinstance(status_code, int) or not isinstance(duration_minutes, int) or not isinstance(keywords, list): + continue + clean_keywords = [str(item) for item in keywords if isinstance(item, str) and item.strip()] + if not clean_keywords: + continue + description = rule.get("description") if isinstance(rule.get("description"), str) else "" + rules.append({ + "error_code": status_code, + "keywords": clean_keywords, + "duration_minutes": duration_minutes, + "description": description, + }) + return { + "enabled": enabled and len(rules) > 0, + "rules": rules, + } + def account_payload(profile, group_id): extra = { "openai_responses_mode": "force_responses", @@ -1743,6 +1902,9 @@ def account_payload(profile, group_id): upstream_user_agent = profile.get("upstreamUserAgent") if upstream_user_agent: credentials["user_agent"] = upstream_user_agent + temp_unschedulable = temp_unschedulable_credentials(profile) + credentials["temp_unschedulable_enabled"] = temp_unschedulable["enabled"] + credentials["temp_unschedulable_rules"] = temp_unschedulable["rules"] 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']}.", @@ -1788,6 +1950,8 @@ def ensure_accounts(token, profiles, group_id): "priority": int(profile.get("priority", 1) or 1), "capacity": int(profile.get("capacity", 5) or 5), "runtimeConcurrency": data.get("concurrency") if isinstance(data, dict) else None, + "tempUnschedulableConfigured": bool(payload["credentials"].get("temp_unschedulable_enabled")), + "tempUnschedulableRuleCount": len(payload["credentials"].get("temp_unschedulable_rules") or []), "upstreamUserAgentConfigured": bool(profile.get("upstreamUserAgent")), "valuesPrinted": False, })