fix: harden sub2api codex ws routing

This commit is contained in:
Codex
2026-06-09 08:17:32 +00:00
parent 7d157d7a94
commit b55320c185
6 changed files with 252 additions and 5 deletions
+1
View File
@@ -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` namespacemaster server 只是控制端和消费者,不部署 Sub2API/PostgreSQL/Redis。
- Secret、`~/.codex/config.toml*``~/.codex/auth.json*` 是运行时输入或本地状态,不提交。
- 输出只能包含 Secret 路径、长度、preview/fingerprint;禁止打印完整 API key、admin password、JWT secret、TOTP key。
+37 -1
View File
@@ -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
+2 -1
View File
@@ -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:
@@ -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",
],
}));
+3
View File
@@ -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));
+167 -3
View File
@@ -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<string>();
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,
})