diff --git a/.agents/skills/unidesk-sub2api/SKILL.md b/.agents/skills/unidesk-sub2api/SKILL.md index f80755e0..fb684c48 100644 --- a/.agents/skills/unidesk-sub2api/SKILL.md +++ b/.agents/skills/unidesk-sub2api/SKILL.md @@ -60,7 +60,8 @@ bun scripts/cli.ts platform-infra sub2api codex-pool validate - `pool.defaultTempUnschedulable`: 默认账号级临时下线规则;用于在上游返回容量、限流、overload 或认证状态异常时,让 Sub2API 冷却该账号并切换到同组其他账号。 - `profiles.entries`: 从 master `~/.codex/` 选择上游 profile 并映射到 Sub2API account。 - `profiles.entries[].capacity`: 可选 per-account concurrency override;不写则使用 `pool.defaultAccountCapacity`。具体数值只以 `config/platform-infra/sub2api-codex-pool.yaml` 为准,skill 和长期参考只描述规则,不重复写当前值。 -- 除非用户明确要求修改配置,不要仅凭推断改账号 membership、priority、capacity、WebSocket mode 或其他调度策略;先保留 YAML,完成 provenance/runtime evidence 溯源,并把结论写回相关 issue 或 runbook 后再提出变更。 +- `profiles.entries[].loadFactor`: 可选 per-account Sub2API `load_factor` override;不写则使用 `pool.defaultAccountLoadFactor`。具体数值只以 `config/platform-infra/sub2api-codex-pool.yaml` 为准,修改后必须 `codex-pool sync --confirm` 和 `codex-pool validate`。 +- 除非用户明确要求修改配置,不要仅凭推断改账号 membership、priority、capacity、loadFactor、WebSocket mode 或其他调度策略;先保留 YAML,完成 provenance/runtime evidence 溯源,并把结论写回相关 issue 或 runbook 后再提出变更。 - `profiles.entries[].tempUnschedulable`: 可选 per-account 临时下线规则覆盖;字段语义以 `docs/reference/platform-infra.md` 为权威。 - `profiles.entries[].openaiResponsesWebSocketsV2Mode`: 需要 Responses WebSocket v2 的上游才设置,值为 `off`、`ctx_pool` 或 `passthrough`。 - `profiles.entries[].upstreamUserAgent`: 少数要求 Codex CLI User-Agent 的上游才设置,不能含换行。 @@ -77,7 +78,7 @@ Codex 启动时反复出现 WebSocket reconnect、HTTPS fallback、`websocket cl 1. 在 master `~/.codex/` 准备 profile 文件,例如 `config.toml.` 和 `auth.json.`。 2. 在 `config/platform-infra/sub2api-codex-pool.yaml` 添加 `profiles.entries` 项,指定 `profile`、`accountName`、`configFile`、`authFile`。 -3. 如需要,给该项加 `priority`、`capacity`、`tempUnschedulable`、`openaiResponsesWebSocketsV2Mode` 或 `upstreamUserAgent`;capacity 的具体数值只写在 YAML。 +3. 如需要,给该项加 `priority`、`capacity`、`loadFactor`、`tempUnschedulable`、`openaiResponsesWebSocketsV2Mode` 或 `upstreamUserAgent`;capacity/loadFactor 的具体数值只写在 YAML。 4. 跑 `codex-pool plan`,确认 profile 可读、`base_url` 和 API key 来源有效,且 stdout 未泄露完整 key。 5. 跑 `codex-pool sync --confirm`。 6. 跑 `codex-pool validate`。 diff --git a/config/platform-infra/sub2api-codex-pool.yaml b/config/platform-infra/sub2api-codex-pool.yaml index 5c16f42b..e4e64774 100644 --- a/config/platform-infra/sub2api-codex-pool.yaml +++ b/config/platform-infra/sub2api-codex-pool.yaml @@ -6,6 +6,7 @@ pool: minOwnerBalanceUsd: 1000 minOwnerConcurrency: 50 defaultAccountCapacity: 5 + defaultAccountLoadFactor: 1 defaultTempUnschedulable: enabled: true rules: @@ -53,6 +54,7 @@ profiles: authFile: auth.json.HY openaiResponsesWebSocketsV2Mode: passthrough capacity: 10 + loadFactor: 10 priority: 1 - profile: gptclub accountName: unidesk-codex-gptclub diff --git a/docs/reference/platform-infra.md b/docs/reference/platform-infra.md index 907de29f..ec7e98e2 100644 --- a/docs/reference/platform-infra.md +++ b/docs/reference/platform-infra.md @@ -29,7 +29,8 @@ - `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[].capacity` optionally overrides `pool.defaultAccountCapacity` for one account. Capacity is a YAML-controlled routing input; concrete current values belong only in `config/platform-infra/sub2api-codex-pool.yaml` and runtime validation output, not in long-term reference prose. Code constants, Secrets, ad-hoc runtime patches, or stale tests must not override YAML source of truth. -- Do not change account membership, priority, capacity, WebSocket mode, or other routing policy from inference alone. Unless the user explicitly asks for a configuration change, first preserve the current YAML, collect provenance and runtime evidence, and write the finding to the relevant issue or runbook before proposing a change. +- `profiles.entries[].loadFactor` optionally overrides `pool.defaultAccountLoadFactor` for one account and is rendered to Sub2API `load_factor`. Treat it as routing policy: values belong in YAML and `codex-pool validate` output, not code constants, Secrets, or ad-hoc runtime patches. +- Do not change account membership, priority, capacity, load factor, WebSocket mode, or other routing policy from inference alone. Unless the user explicitly asks for a configuration change, first preserve the current YAML, collect provenance and runtime evidence, and write the finding to the relevant issue or runbook before proposing a change. - `profiles.entries[].tempUnschedulable` may override the pool default for one account. The CLI renders it into Sub2API credentials as `temp_unschedulable_enabled` and `temp_unschedulable_rules`; rules match HTTP status plus response-body keywords and place only that account into a temporary unschedulable cooldown. - `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. @@ -38,9 +39,9 @@ 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`, WS handshake 5xx, or before `response.completed`. -When Codex startup repeatedly reports WebSocket reconnects or HTTPS fallback, preserve membership, priority, capacity, and other routing policy until runtime logs identify the failing account and transport. If bounded Sub2API logs show repeated `openai.websocket_proxy_failed` or upstream WS handshake 5xx for one account, remove only that account from the WSv2 capability set in YAML, run `codex-pool sync --confirm`, and prove the result with Codex smoke plus `codex-pool validate`. +When Codex startup repeatedly reports WebSocket reconnects or HTTPS fallback, preserve membership, priority, capacity, load factor, and other routing policy until runtime logs identify the failing account and transport. If bounded Sub2API logs show repeated `openai.websocket_proxy_failed` or upstream WS handshake 5xx for one account, remove only that account from the WSv2 capability set in YAML, run `codex-pool sync --confirm`, and prove the result with Codex smoke plus `codex-pool validate`. -Do not encode current availability assumptions in long-term reference prose. If an account needs a higher concurrency than `pool.defaultAccountCapacity`, make that a deliberate YAML override and verify it with `codex-pool validate`; the reference document should describe the rule, not repeat the current numeric value. +Do not encode current availability assumptions in long-term reference prose. If an account needs a higher concurrency or load factor than the pool default, make that a deliberate YAML override and verify it with `codex-pool validate`; the reference document should describe the rule, not repeat the current numeric value. Do not enable Sub2API `pool_mode` for UniDesk-managed Codex accounts. `pool_mode` retries the same selected account path, while UniDesk's desired failover behavior is to mark the failing account temporarily unschedulable and let Sub2API choose another account from the group. `codex-pool validate` reports each managed account's temporary-unschedulable runtime alignment and should be used after `codex-pool sync --confirm`. Generic 502 bodies such as `Bad Gateway` and Codex-facing `Upstream request failed` must stay in the YAML cooldown policy so an intermittently bad account is cooled down instead of repeatedly adding latency at the next compact or Responses request. The Codex pool default error cooldown is severity-tiered: temporary signals can start at ten minutes, gateway/service/overload failures should cool down longer, and credential, permission, quota, or account-state failures should use the longest cooldown. Exact current values belong in YAML and runtime validation output. diff --git a/scripts/platform-infra-sub2api-codex-routing-contract-test.ts b/scripts/platform-infra-sub2api-codex-routing-contract-test.ts index 056887ec..4b0cc32a 100644 --- a/scripts/platform-infra-sub2api-codex-routing-contract-test.ts +++ b/scripts/platform-infra-sub2api-codex-routing-contract-test.ts @@ -9,27 +9,31 @@ const configPath = rootPath("config", "platform-infra", "sub2api-codex-pool.yaml const parsed = Bun.YAML.parse(readFileSync(configPath, "utf8")) as { pool?: { defaultAccountCapacity?: number; + defaultAccountLoadFactor?: number; minOwnerConcurrency?: number; defaultTempUnschedulable?: { enabled?: boolean; rules?: Array<{ statusCode?: number; keywords?: string[]; durationMinutes?: number }>; }; }; - profiles?: { entries?: Array<{ profile?: string; accountName?: string; capacity?: number; openaiResponsesWebSocketsV2Mode?: string | null }> }; + profiles?: { entries?: Array<{ profile?: string; accountName?: string; capacity?: number; loadFactor?: number; openaiResponsesWebSocketsV2Mode?: string | null }> }; localCodex?: { responsesSmokeModel?: string }; }; const entries = parsed.profiles?.entries ?? []; const rules = parsed.pool?.defaultTempUnschedulable?.rules ?? []; const defaultCapacity = parsed.pool?.defaultAccountCapacity ?? 0; +const defaultLoadFactor = parsed.pool?.defaultAccountLoadFactor ?? 0; const desiredCapacity = entries.reduce((total, entry) => total + (entry.capacity ?? defaultCapacity), 0); const allowedWebSocketModes = new Set(["off", "ctx_pool", "passthrough"]); assertCondition(entries.length > 0, "Codex pool must declare YAML-managed profile entries", parsed.profiles); assertCondition(Number.isInteger(defaultCapacity) && defaultCapacity > 0, "defaultAccountCapacity must be a positive integer", parsed.pool); +assertCondition(Number.isInteger(defaultLoadFactor) && defaultLoadFactor > 0, "defaultAccountLoadFactor must be a positive integer", parsed.pool); assertCondition(entries.every((entry) => typeof entry.profile === "string" && entry.profile.length > 0), "profile entries must declare profile names", entries); assertCondition(entries.every((entry) => typeof entry.accountName === "string" && entry.accountName.length > 0), "profile entries must declare account names", entries); assertCondition(entries.every((entry) => entry.capacity === undefined || (Number.isInteger(entry.capacity) && entry.capacity > 0)), "profile capacity overrides must be positive integers when declared", entries); +assertCondition(entries.every((entry) => entry.loadFactor === undefined || (Number.isInteger(entry.loadFactor) && entry.loadFactor > 0)), "profile load factor overrides must be positive integers when declared", entries); assertCondition( entries.every((entry) => entry.openaiResponsesWebSocketsV2Mode === undefined || entry.openaiResponsesWebSocketsV2Mode === null || allowedWebSocketModes.has(entry.openaiResponsesWebSocketsV2Mode)), "profile WebSocket mode overrides must use supported values when declared", @@ -49,6 +53,7 @@ console.log(JSON.stringify({ checks: [ "routing config is schema-valid without profile-specific test gates", "pool owner concurrency covers the YAML account capacity set", + "profile load factor overrides are YAML-controlled positive integers", "optional WebSocket mode overrides use supported values", "temporary unschedulable rules are structurally valid when enabled", "Responses smoke model is YAML-declared", diff --git a/scripts/src/platform-infra-sub2api-codex.ts b/scripts/src/platform-infra-sub2api-codex.ts index 85bfd3a8..42fdb253 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; + loadFactor: number; tempUnschedulable: CodexTempUnschedulablePolicy; authOpenAIKeyShape: string; ok: boolean; @@ -76,6 +77,7 @@ interface CodexPoolConfig { minOwnerBalanceUsd: number; minOwnerConcurrency: number; defaultAccountCapacity: number; + defaultAccountLoadFactor: number; defaultTempUnschedulable: CodexTempUnschedulablePolicy; profiles: CodexPoolProfileConfig[]; publicExposure: CodexPoolPublicExposureConfig; @@ -93,6 +95,7 @@ interface CodexPoolProfileConfig { upstreamUserAgent: string | null; priority: number; capacity: number | null; + loadFactor: number | null; tempUnschedulable: CodexTempUnschedulablePolicy; } @@ -266,6 +269,7 @@ async function codexPoolSync(config: UniDeskConfig, options: SyncOptions): Promi minOwnerBalanceUsd: pool.minOwnerBalanceUsd, minOwnerConcurrency: pool.minOwnerConcurrency, defaultAccountCapacity: pool.defaultAccountCapacity, + defaultAccountLoadFactor: pool.defaultAccountLoadFactor, }, profiles: profiles.map((profile) => ({ profile: profile.profile, @@ -283,6 +287,7 @@ async function codexPoolSync(config: UniDeskConfig, options: SyncOptions): Promi upstreamUserAgent: profile.upstreamUserAgent, priority: profile.priority, capacity: profile.capacity, + loadFactor: profile.loadFactor, tempUnschedulable: profile.tempUnschedulable, tempUnschedulableCredentials: renderSub2ApiTempUnschedulableCredentials(profile.tempUnschedulable), })), @@ -475,6 +480,7 @@ function collectCodexProfiles(): CodexProfile[] { upstreamUserAgent: entry.upstreamUserAgent, priority: entry.priority, capacity: entry.capacity ?? pool.defaultAccountCapacity, + loadFactor: entry.loadFactor ?? pool.defaultAccountLoadFactor, tempUnschedulable: entry.tempUnschedulable, authOpenAIKeyShape: existsSync(authPath) ? "unknown" : "missing", ok: false, @@ -542,6 +548,7 @@ function discoverCodexProfileConfigs(codexDir: string, defaultTempUnschedulable upstreamUserAgent: null, priority: 1, capacity: null, + loadFactor: null, tempUnschedulable: defaultTempUnschedulable, }; }); @@ -573,6 +580,7 @@ function readCodexPoolConfig(): CodexPoolConfig { minOwnerBalanceUsd: numberValue(pool.minOwnerBalanceUsd) ?? defaults.minOwnerBalanceUsd, minOwnerConcurrency: readAccountCapacity(pool.minOwnerConcurrency, "pool.minOwnerConcurrency"), defaultAccountCapacity: readAccountCapacity(pool.defaultAccountCapacity, "pool.defaultAccountCapacity"), + defaultAccountLoadFactor: readAccountLoadFactor(pool.defaultAccountLoadFactor, "pool.defaultAccountLoadFactor"), defaultTempUnschedulable, profiles: readProfileConfig(parsed.profiles, defaults.profiles, defaultTempUnschedulable), publicExposure: readPublicExposureConfig(parsed.publicExposure, defaults.publicExposure), @@ -601,6 +609,7 @@ function defaultCodexPoolConfig(): CodexPoolConfig { // Only used for the no-YAML fallback path; real pool configs must declare pool.minOwnerConcurrency. minOwnerConcurrency: 1, defaultAccountCapacity: 5, + defaultAccountLoadFactor: 1, defaultTempUnschedulable: defaultCodexTempUnschedulablePolicy(), profiles: [], publicExposure: { @@ -713,6 +722,7 @@ function readProfileConfig(value: unknown, defaults: CodexPoolProfileConfig[], d 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 loadFactor = entry.loadFactor === undefined || entry.loadFactor === null ? null : readAccountLoadFactor(entry.loadFactor, `profiles.entries[${index}].loadFactor`); const tempUnschedulable = readTempUnschedulablePolicy(entry.tempUnschedulable, `profiles.entries[${index}].tempUnschedulable`, defaultTempUnschedulable); return { profile, @@ -725,6 +735,7 @@ function readProfileConfig(value: unknown, defaults: CodexPoolProfileConfig[], d upstreamUserAgent, priority, capacity, + loadFactor, tempUnschedulable, }; }); @@ -764,6 +775,14 @@ function readAccountCapacity(value: unknown, key: string): number { return capacity; } +function readAccountLoadFactor(value: unknown, key: string): number { + const loadFactor = numberValue(value); + if (loadFactor === null || !Number.isInteger(loadFactor) || loadFactor < 1 || loadFactor > 1000) { + throw new Error(`${codexPoolConfigPath}.${key} must be an integer from 1 to 1000`); + } + return loadFactor; +} + 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`); @@ -961,6 +980,7 @@ function redactProfile(profile: CodexProfile): Record { upstreamUserAgent: profile.upstreamUserAgent, priority: profile.priority, capacity: profile.capacity, + loadFactor: profile.loadFactor, tempUnschedulable: tempUnschedulableSummary(profile.tempUnschedulable), apiKeyPresent: profile.apiKey !== null && profile.apiKey.length > 0, apiKeyBytes: profile.apiKey === null ? 0 : Buffer.byteLength(profile.apiKey, "utf8"), @@ -1006,6 +1026,7 @@ function poolTarget(pool = readCodexPoolConfig()): Record { apiKeySecret: `${namespace}/${pool.apiKeySecretName}.${pool.apiKeySecretKey}`, minOwnerConcurrency: pool.minOwnerConcurrency, defaultAccountCapacity: pool.defaultAccountCapacity, + defaultAccountLoadFactor: pool.defaultAccountLoadFactor, valuesPrinted: false, }; } @@ -1668,6 +1689,19 @@ function desiredAccountCapacityMap(pool: CodexPoolConfig): Record { + const codexDir = join(homedir(), ".codex"); + const seenAccountNames = new Set(); + const configs = pool.profiles.length > 0 ? pool.profiles : discoverCodexProfileConfigs(codexDir, pool.defaultTempUnschedulable); + const loadFactors: Record = {}; + for (const entry of configs) { + const accountName = entry.accountName ?? uniqueAccountName(entry.profile, seenAccountNames); + seenAccountNames.add(accountName); + loadFactors[accountName] = entry.loadFactor ?? pool.defaultAccountLoadFactor; + } + return loadFactors; +} + function desiredAccountWebSocketsV2ModeMap(pool: CodexPoolConfig): Record { const codexDir = join(homedir(), ".codex"); const seenAccountNames = new Set(); @@ -1719,8 +1753,10 @@ POOL_API_KEY_SECRET_KEY = "${pool.apiKeySecretKey}" MIN_OWNER_BALANCE_USD = ${JSON.stringify(pool.minOwnerBalanceUsd)} MIN_OWNER_CONCURRENCY = ${JSON.stringify(pool.minOwnerConcurrency)} POOL_DEFAULT_ACCOUNT_CAPACITY = ${JSON.stringify(pool.defaultAccountCapacity)} +POOL_DEFAULT_ACCOUNT_LOAD_FACTOR = ${JSON.stringify(pool.defaultAccountLoadFactor)} RESPONSES_SMOKE_MODEL = ${JSON.stringify(pool.localCodex.responsesSmokeModel)} EXPECTED_ACCOUNT_CAPACITIES = ${JSON.stringify(desiredAccountCapacityMap(pool))} +EXPECTED_ACCOUNT_LOAD_FACTORS = ${JSON.stringify(desiredAccountLoadFactorMap(pool))} EXPECTED_ACCOUNT_WS_MODES = json.loads(${JSON.stringify(JSON.stringify(desiredAccountWebSocketsV2ModeMap(pool)))}) EXPECTED_ACCOUNT_TEMP_UNSCHEDULABLE = json.loads(${JSON.stringify(JSON.stringify(desiredAccountTempUnschedulableMap(pool)))}) MODE = "${mode}" @@ -1954,7 +1990,7 @@ def account_payload(profile, group_id): "concurrency": int(profile.get("capacity", 5) or 5), "priority": int(profile.get("priority", 1) or 1), "rate_multiplier": 1, - "load_factor": 1, + "load_factor": int(profile.get("loadFactor", POOL_DEFAULT_ACCOUNT_LOAD_FACTOR) or POOL_DEFAULT_ACCOUNT_LOAD_FACTOR), "group_ids": [group_id], "confirm_mixed_channel_risk": True, } @@ -1988,7 +2024,9 @@ def ensure_accounts(token, profiles, group_id): "openaiResponsesWebSocketsV2Mode": profile.get("openaiResponsesWebSocketsV2Mode"), "priority": int(profile.get("priority", 1) or 1), "capacity": int(profile.get("capacity", 5) or 5), + "loadFactor": int(profile.get("loadFactor", POOL_DEFAULT_ACCOUNT_LOAD_FACTOR) or POOL_DEFAULT_ACCOUNT_LOAD_FACTOR), "runtimeConcurrency": data.get("concurrency") if isinstance(data, dict) else None, + "runtimeLoadFactor": data.get("load_factor") 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")), @@ -2443,6 +2481,56 @@ def account_capacity_status(token): "valuesPrinted": False, } +def account_load_factor_status(token): + accounts = list_accounts(token) + by_name = {item.get("name"): item for item in accounts if isinstance(item.get("name"), str)} + items = [] + missing = [] + mismatched = [] + for name in sorted(EXPECTED_ACCOUNT_LOAD_FACTORS): + expected = int(EXPECTED_ACCOUNT_LOAD_FACTORS[name]) + account = by_name.get(name) + if account is None: + missing.append(name) + items.append({ + "accountName": name, + "accountId": None, + "expectedLoadFactor": expected, + "runtimeLoadFactor": None, + "ok": False, + }) + continue + runtime_raw = account.get("load_factor") + if runtime_raw is None: + detail = get_account_detail(token, account) + runtime_raw = detail.get("load_factor") if isinstance(detail, dict) else None + try: + runtime = int(runtime_raw) + except Exception: + runtime = runtime_raw + ok = runtime == expected + if not ok: + mismatched.append(name) + items.append({ + "accountName": name, + "accountId": account.get("id"), + "expectedLoadFactor": expected, + "runtimeLoadFactor": runtime, + "priority": account.get("priority"), + "status": account.get("status"), + "schedulable": account.get("schedulable"), + "ok": ok, + }) + return { + "ok": len(missing) == 0 and len(mismatched) == 0, + "defaultAccountLoadFactor": POOL_DEFAULT_ACCOUNT_LOAD_FACTOR, + "desired": len(EXPECTED_ACCOUNT_LOAD_FACTORS), + "missing": missing, + "mismatched": mismatched, + "items": items, + "valuesPrinted": False, + } + def account_ws_v2_status(token): accounts = list_accounts(token) by_name = {item.get("name"): item for item in accounts if isinstance(item.get("name"), str)} @@ -2527,6 +2615,7 @@ def run_sync(): raise RuntimeError("pool group id missing after ensure") account_results, pruned_account_results = ensure_accounts(token, profiles, group_id) capacity_status = account_capacity_status(token) + load_factor_status = account_load_factor_status(token) ws_v2_status = account_ws_v2_status(token) temp_unschedulable_status = account_temp_unschedulable_status(token) api_key, secret_action, secret_apply_stdout = ensure_api_key_secret(group_id) @@ -2536,7 +2625,7 @@ def run_sync(): gateway = validate_gateway(api_key) responses_smoke = validate_gateway_responses(api_key) return { - "ok": gateway["ok"] is True and responses_smoke["ok"] is True and owner_concurrency["ok"] is True and capacity_status["ok"] is True and ws_v2_status["ok"] is True and temp_unschedulable_status["ok"] is True, + "ok": gateway["ok"] is True and responses_smoke["ok"] is True and owner_concurrency["ok"] is True and capacity_status["ok"] is True and load_factor_status["ok"] is True and ws_v2_status["ok"] is True and temp_unschedulable_status["ok"] is True, "degraded": bool(responses_smoke.get("degraded")), "mode": "sync", "namespace": NAMESPACE, @@ -2554,6 +2643,7 @@ def run_sync(): "valuesPrinted": False, }, "capacity": capacity_status, + "loadFactor": load_factor_status, "webSocketsV2": ws_v2_status, "tempUnschedulable": temp_unschedulable_status, "apiKey": { @@ -2585,12 +2675,13 @@ def run_validate(): owner_balance = ensure_pool_owner_balance(token, key_item["user_id"]) owner_concurrency = ensure_pool_owner_concurrency(token, key_item["user_id"]) capacity_status = account_capacity_status(token) + load_factor_status = account_load_factor_status(token) ws_v2_status = account_ws_v2_status(token) temp_unschedulable_status = account_temp_unschedulable_status(token) gateway = validate_gateway(api_key) responses_smoke = validate_gateway_responses(api_key) return { - "ok": gateway["ok"] is True and responses_smoke["ok"] is True and (owner_concurrency is None or owner_concurrency["ok"] is True) and capacity_status["ok"] is True and ws_v2_status["ok"] is True and temp_unschedulable_status["ok"] is True, + "ok": gateway["ok"] is True and responses_smoke["ok"] is True and (owner_concurrency is None or owner_concurrency["ok"] is True) and capacity_status["ok"] is True and load_factor_status["ok"] is True and ws_v2_status["ok"] is True and temp_unschedulable_status["ok"] is True, "degraded": bool(responses_smoke.get("degraded")), "mode": "validate", "namespace": NAMESPACE, @@ -2607,6 +2698,7 @@ def run_validate(): "ownerBalance": owner_balance, "ownerConcurrency": owner_concurrency, "capacity": capacity_status, + "loadFactor": load_factor_status, "webSocketsV2": ws_v2_status, "tempUnschedulable": temp_unschedulable_status, "validation": {"gatewayModels": gateway, "gatewayResponses": responses_smoke},