diff --git a/config/platform-infra/sub2api-codex-pool.yaml b/config/platform-infra/sub2api-codex-pool.yaml index 133ae361..336eb6f9 100644 --- a/config/platform-infra/sub2api-codex-pool.yaml +++ b/config/platform-infra/sub2api-codex-pool.yaml @@ -10,9 +10,11 @@ metadata: pool: groupName: unidesk-codex-pool + groupDescription: UniDesk-managed Codex API-key pool for YAML-selected Sub2API clients. apiKeyName: unidesk-codex-pool-api-key apiKeySecretName: sub2api-codex-pool-api-key apiKeySecretKey: API_KEY + adminEmailDefault: admin@sub2api.platform-infra.local minOwnerBalanceUsd: 1000 defaultAccountPriority: 10 defaultAccountCapacity: 10 diff --git a/scripts/src/platform-infra-sub2api-codex-sentinel.ts b/scripts/src/platform-infra-sub2api-codex-sentinel.ts index 5c2ab510..299ac76c 100644 --- a/scripts/src/platform-infra-sub2api-codex-sentinel.ts +++ b/scripts/src/platform-infra-sub2api-codex-sentinel.ts @@ -92,76 +92,13 @@ export interface CodexPoolSentinelManifestOptions { serviceName: string; serviceDns: string; appSecretName: string; + adminEmailDefault: string; proxy?: { httpProxy: string; noProxy: string; } | null; } -export function defaultCodexPoolSentinelConfig(): CodexPoolSentinelConfig { - return { - monitor: { - enabled: true, - }, - actions: { - enabled: false, - }, - schedule: "*/1 * * * *", - image: "python:3.12-alpine", - serviceAccountName: "sub2api-account-sentinel", - configMapName: "sub2api-account-sentinel-config", - credentialsSecretName: "sub2api-account-sentinel-profiles", - stateConfigMapName: "sub2api-account-sentinel-state", - cronJobName: "sub2api-account-sentinel", - roleName: "sub2api-account-sentinel", - roleBindingName: "sub2api-account-sentinel", - model: "gpt-5.5", - endpoint: "responses", - marker: { - prefix: "UDSG_OK", - exact: true, - }, - probe: { - timeoutSeconds: 30, - maxOutputTokens: 16, - transportRetryMinutes: 5, - userAgent: "Go-http-client/1.1", - }, - gatewayFailureMonitor: { - enabled: false, - lookbackSeconds: 900, - tailLines: 4000, - initialTtlMinutes: 5, - maxTtlMinutes: 30, - backoffMultiplier: 2, - paths: ["/responses", "/v1/responses", "/responses/compact", "/v1/responses/compact"], - }, - sdk: { - openaiPythonVersion: "2.41.1", - }, - cadence: { - successInitialIntervalMinutes: 1, - successMaxIntervalMinutes: 20, - trustedSuccessMaxIntervalMinutes: 20, - untrustedSuccessMaxIntervalMinutes: 2, - successBackoffMultiplier: 2, - jitterPercent: 10, - }, - freeze: { - initialTtlMinutes: 1, - maxTtlMinutes: 10, - backoffMultiplier: 2, - jitterPercent: 10, - }, - pricing: { - usdPer1MInputTokens: 1.25, - usdPer1MOutputTokens: 10, - }, - historyLimit: 200, - protectedManualAccounts: [], - }; -} - export function codexPoolSentinelRuntimeImage(config: CodexPoolSentinelConfig): CodexPoolSentinelImageTarget { const baseTag = config.image .replace(/[^A-Za-z0-9_.-]+/gu, "-") @@ -177,28 +114,25 @@ export function codexPoolSentinelRuntimeImage(config: CodexPoolSentinelConfig): }; } -export function readCodexPoolSentinelConfig(value: unknown, defaults: CodexPoolSentinelConfig, sourcePath: string): CodexPoolSentinelConfig { - if (!isRecord(value)) return defaults; - const monitor = isRecord(value.monitor) ? value.monitor : {}; - const actions = isRecord(value.actions) ? value.actions : {}; - const marker = isRecord(value.marker) ? value.marker : {}; - const probe = isRecord(value.probe) ? value.probe : {}; - const gatewayFailureMonitor = isRecord(value.gatewayFailureMonitor) ? value.gatewayFailureMonitor : {}; - const sdk = isRecord(value.sdk) ? value.sdk : {}; - const cadence = isRecord(value.cadence) ? value.cadence : {}; - const freeze = isRecord(value.freeze) ? value.freeze : {}; - const pricing = isRecord(value.pricing) ? value.pricing : {}; - const legacySuccessMax = readInt(valueAt(cadence, "successMaxIntervalMinutes"), `${sourcePath}.sentinel.cadence.successMaxIntervalMinutes`, defaults.cadence.successMaxIntervalMinutes, 1, 1440); - const trustedSuccessMax = readInt(valueAt(cadence, "trustedSuccessMaxIntervalMinutes"), `${sourcePath}.sentinel.cadence.trustedSuccessMaxIntervalMinutes`, legacySuccessMax, 1, 1440); - const untrustedSuccessMax = readInt(valueAt(cadence, "untrustedSuccessMaxIntervalMinutes"), `${sourcePath}.sentinel.cadence.untrustedSuccessMaxIntervalMinutes`, legacySuccessMax, 1, 1440); +export function readCodexPoolSentinelConfig(value: unknown, sourcePath: string): CodexPoolSentinelConfig { + if (!isRecord(value)) throw new Error(`${sourcePath}.sentinel must be a YAML object`); + const monitor = readRequiredRecord(valueAt(value, "monitor"), `${sourcePath}.sentinel.monitor`); + const actions = readRequiredRecord(valueAt(value, "actions"), `${sourcePath}.sentinel.actions`); + const marker = readRequiredRecord(valueAt(value, "marker"), `${sourcePath}.sentinel.marker`); + const probe = readRequiredRecord(valueAt(value, "probe"), `${sourcePath}.sentinel.probe`); + const gatewayFailureMonitor = readRequiredRecord(valueAt(value, "gatewayFailureMonitor"), `${sourcePath}.sentinel.gatewayFailureMonitor`); + const sdk = readRequiredRecord(valueAt(value, "sdk"), `${sourcePath}.sentinel.sdk`); + const cadence = readRequiredRecord(valueAt(value, "cadence"), `${sourcePath}.sentinel.cadence`); + const freeze = readRequiredRecord(valueAt(value, "freeze"), `${sourcePath}.sentinel.freeze`); + const pricing = readRequiredRecord(valueAt(value, "pricing"), `${sourcePath}.sentinel.pricing`); const config: CodexPoolSentinelConfig = { monitor: { - enabled: readBoolean(valueAt(monitor, "enabled"), `${sourcePath}.sentinel.monitor.enabled`, defaults.monitor.enabled), + enabled: readRequiredBoolean(valueAt(monitor, "enabled"), `${sourcePath}.sentinel.monitor.enabled`), }, actions: { - enabled: readBoolean(valueAt(actions, "enabled"), `${sourcePath}.sentinel.actions.enabled`, defaults.actions.enabled), + enabled: readRequiredBoolean(valueAt(actions, "enabled"), `${sourcePath}.sentinel.actions.enabled`), }, - schedule: readString(valueAt(value, "schedule"), `${sourcePath}.sentinel.schedule`, defaults.schedule), + schedule: readRequiredString(valueAt(value, "schedule"), `${sourcePath}.sentinel.schedule`), image: readRequiredImage(valueAt(value, "image"), `${sourcePath}.sentinel.image`), serviceAccountName: readRequiredDnsName(valueAt(value, "serviceAccountName"), `${sourcePath}.sentinel.serviceAccountName`), configMapName: readRequiredDnsName(valueAt(value, "configMapName"), `${sourcePath}.sentinel.configMapName`), @@ -210,46 +144,46 @@ export function readCodexPoolSentinelConfig(value: unknown, defaults: CodexPoolS model: readRequiredModelName(valueAt(value, "model"), `${sourcePath}.sentinel.model`), endpoint: readRequiredEndpoint(valueAt(value, "endpoint"), `${sourcePath}.sentinel.endpoint`), marker: { - prefix: readMarkerPrefix(valueAt(marker, "prefix"), `${sourcePath}.sentinel.marker.prefix`, defaults.marker.prefix), - exact: readBoolean(valueAt(marker, "exact"), `${sourcePath}.sentinel.marker.exact`, defaults.marker.exact), + prefix: readRequiredMarkerPrefix(valueAt(marker, "prefix"), `${sourcePath}.sentinel.marker.prefix`), + exact: readRequiredBoolean(valueAt(marker, "exact"), `${sourcePath}.sentinel.marker.exact`), }, probe: { - timeoutSeconds: readInt(valueAt(probe, "timeoutSeconds"), `${sourcePath}.sentinel.probe.timeoutSeconds`, defaults.probe.timeoutSeconds, 3, 300), - maxOutputTokens: readInt(valueAt(probe, "maxOutputTokens"), `${sourcePath}.sentinel.probe.maxOutputTokens`, defaults.probe.maxOutputTokens, 1, 128), - transportRetryMinutes: readInt(valueAt(probe, "transportRetryMinutes"), `${sourcePath}.sentinel.probe.transportRetryMinutes`, defaults.probe.transportRetryMinutes, 1, 120), - userAgent: readUserAgent(valueAt(probe, "userAgent"), `${sourcePath}.sentinel.probe.userAgent`, defaults.probe.userAgent), + timeoutSeconds: readRequiredInt(valueAt(probe, "timeoutSeconds"), `${sourcePath}.sentinel.probe.timeoutSeconds`, 3, 300), + maxOutputTokens: readRequiredInt(valueAt(probe, "maxOutputTokens"), `${sourcePath}.sentinel.probe.maxOutputTokens`, 1, 128), + transportRetryMinutes: readRequiredInt(valueAt(probe, "transportRetryMinutes"), `${sourcePath}.sentinel.probe.transportRetryMinutes`, 1, 120), + userAgent: readRequiredUserAgent(valueAt(probe, "userAgent"), `${sourcePath}.sentinel.probe.userAgent`), }, gatewayFailureMonitor: { - enabled: readBoolean(valueAt(gatewayFailureMonitor, "enabled"), `${sourcePath}.sentinel.gatewayFailureMonitor.enabled`, defaults.gatewayFailureMonitor.enabled), - lookbackSeconds: readInt(valueAt(gatewayFailureMonitor, "lookbackSeconds"), `${sourcePath}.sentinel.gatewayFailureMonitor.lookbackSeconds`, defaults.gatewayFailureMonitor.lookbackSeconds, 60, 7200), - tailLines: readInt(valueAt(gatewayFailureMonitor, "tailLines"), `${sourcePath}.sentinel.gatewayFailureMonitor.tailLines`, defaults.gatewayFailureMonitor.tailLines, 100, 50000), - initialTtlMinutes: readInt(valueAt(gatewayFailureMonitor, "initialTtlMinutes"), `${sourcePath}.sentinel.gatewayFailureMonitor.initialTtlMinutes`, defaults.gatewayFailureMonitor.initialTtlMinutes, 1, 1440), - maxTtlMinutes: readInt(valueAt(gatewayFailureMonitor, "maxTtlMinutes"), `${sourcePath}.sentinel.gatewayFailureMonitor.maxTtlMinutes`, defaults.gatewayFailureMonitor.maxTtlMinutes, 1, 1440), - backoffMultiplier: readInt(valueAt(gatewayFailureMonitor, "backoffMultiplier"), `${sourcePath}.sentinel.gatewayFailureMonitor.backoffMultiplier`, defaults.gatewayFailureMonitor.backoffMultiplier, 1, 10), - paths: readPathList(valueAt(gatewayFailureMonitor, "paths"), `${sourcePath}.sentinel.gatewayFailureMonitor.paths`, defaults.gatewayFailureMonitor.paths), + enabled: readRequiredBoolean(valueAt(gatewayFailureMonitor, "enabled"), `${sourcePath}.sentinel.gatewayFailureMonitor.enabled`), + lookbackSeconds: readRequiredInt(valueAt(gatewayFailureMonitor, "lookbackSeconds"), `${sourcePath}.sentinel.gatewayFailureMonitor.lookbackSeconds`, 60, 7200), + tailLines: readRequiredInt(valueAt(gatewayFailureMonitor, "tailLines"), `${sourcePath}.sentinel.gatewayFailureMonitor.tailLines`, 100, 50000), + initialTtlMinutes: readRequiredInt(valueAt(gatewayFailureMonitor, "initialTtlMinutes"), `${sourcePath}.sentinel.gatewayFailureMonitor.initialTtlMinutes`, 1, 1440), + maxTtlMinutes: readRequiredInt(valueAt(gatewayFailureMonitor, "maxTtlMinutes"), `${sourcePath}.sentinel.gatewayFailureMonitor.maxTtlMinutes`, 1, 1440), + backoffMultiplier: readRequiredInt(valueAt(gatewayFailureMonitor, "backoffMultiplier"), `${sourcePath}.sentinel.gatewayFailureMonitor.backoffMultiplier`, 1, 10), + paths: readRequiredPathList(valueAt(gatewayFailureMonitor, "paths"), `${sourcePath}.sentinel.gatewayFailureMonitor.paths`), }, sdk: { openaiPythonVersion: readRequiredOpenAiPythonVersion(valueAt(sdk, "openaiPythonVersion"), `${sourcePath}.sentinel.sdk.openaiPythonVersion`), }, cadence: { - successInitialIntervalMinutes: readInt(valueAt(cadence, "successInitialIntervalMinutes"), `${sourcePath}.sentinel.cadence.successInitialIntervalMinutes`, defaults.cadence.successInitialIntervalMinutes, 1, 1440), - successMaxIntervalMinutes: legacySuccessMax, - trustedSuccessMaxIntervalMinutes: trustedSuccessMax, - untrustedSuccessMaxIntervalMinutes: untrustedSuccessMax, - successBackoffMultiplier: readInt(valueAt(cadence, "successBackoffMultiplier"), `${sourcePath}.sentinel.cadence.successBackoffMultiplier`, defaults.cadence.successBackoffMultiplier, 1, 10), - jitterPercent: readInt(valueAt(cadence, "jitterPercent"), `${sourcePath}.sentinel.cadence.jitterPercent`, defaults.cadence.jitterPercent, 0, 50), + successInitialIntervalMinutes: readRequiredInt(valueAt(cadence, "successInitialIntervalMinutes"), `${sourcePath}.sentinel.cadence.successInitialIntervalMinutes`, 1, 1440), + successMaxIntervalMinutes: readRequiredInt(valueAt(cadence, "successMaxIntervalMinutes"), `${sourcePath}.sentinel.cadence.successMaxIntervalMinutes`, 1, 1440), + trustedSuccessMaxIntervalMinutes: readRequiredInt(valueAt(cadence, "trustedSuccessMaxIntervalMinutes"), `${sourcePath}.sentinel.cadence.trustedSuccessMaxIntervalMinutes`, 1, 1440), + untrustedSuccessMaxIntervalMinutes: readRequiredInt(valueAt(cadence, "untrustedSuccessMaxIntervalMinutes"), `${sourcePath}.sentinel.cadence.untrustedSuccessMaxIntervalMinutes`, 1, 1440), + successBackoffMultiplier: readRequiredInt(valueAt(cadence, "successBackoffMultiplier"), `${sourcePath}.sentinel.cadence.successBackoffMultiplier`, 1, 10), + jitterPercent: readRequiredInt(valueAt(cadence, "jitterPercent"), `${sourcePath}.sentinel.cadence.jitterPercent`, 0, 50), }, freeze: { - initialTtlMinutes: readInt(valueAt(freeze, "initialTtlMinutes"), `${sourcePath}.sentinel.freeze.initialTtlMinutes`, defaults.freeze.initialTtlMinutes, 1, 1440), - maxTtlMinutes: readInt(valueAt(freeze, "maxTtlMinutes"), `${sourcePath}.sentinel.freeze.maxTtlMinutes`, defaults.freeze.maxTtlMinutes, 1, 1440), - backoffMultiplier: readInt(valueAt(freeze, "backoffMultiplier"), `${sourcePath}.sentinel.freeze.backoffMultiplier`, defaults.freeze.backoffMultiplier, 1, 10), - jitterPercent: readInt(valueAt(freeze, "jitterPercent"), `${sourcePath}.sentinel.freeze.jitterPercent`, defaults.freeze.jitterPercent, 0, 50), + initialTtlMinutes: readRequiredInt(valueAt(freeze, "initialTtlMinutes"), `${sourcePath}.sentinel.freeze.initialTtlMinutes`, 1, 1440), + maxTtlMinutes: readRequiredInt(valueAt(freeze, "maxTtlMinutes"), `${sourcePath}.sentinel.freeze.maxTtlMinutes`, 1, 1440), + backoffMultiplier: readRequiredInt(valueAt(freeze, "backoffMultiplier"), `${sourcePath}.sentinel.freeze.backoffMultiplier`, 1, 10), + jitterPercent: readRequiredInt(valueAt(freeze, "jitterPercent"), `${sourcePath}.sentinel.freeze.jitterPercent`, 0, 50), }, pricing: { - usdPer1MInputTokens: readNumber(valueAt(pricing, "usdPer1MInputTokens"), `${sourcePath}.sentinel.pricing.usdPer1MInputTokens`, defaults.pricing.usdPer1MInputTokens, 0, 100000), - usdPer1MOutputTokens: readNumber(valueAt(pricing, "usdPer1MOutputTokens"), `${sourcePath}.sentinel.pricing.usdPer1MOutputTokens`, defaults.pricing.usdPer1MOutputTokens, 0, 100000), + usdPer1MInputTokens: readRequiredNumber(valueAt(pricing, "usdPer1MInputTokens"), `${sourcePath}.sentinel.pricing.usdPer1MInputTokens`, 0, 100000), + usdPer1MOutputTokens: readRequiredNumber(valueAt(pricing, "usdPer1MOutputTokens"), `${sourcePath}.sentinel.pricing.usdPer1MOutputTokens`, 0, 100000), }, - historyLimit: readInt(valueAt(value, "historyLimit"), `${sourcePath}.sentinel.historyLimit`, defaults.historyLimit, 1, 2000), + historyLimit: readRequiredInt(valueAt(value, "historyLimit"), `${sourcePath}.sentinel.historyLimit`, 1, 2000), }; if (config.actions.enabled && !config.monitor.enabled) { throw new Error(`${sourcePath}.sentinel.actions.enabled requires sentinel.monitor.enabled=true`); @@ -321,7 +255,7 @@ export function renderCodexPoolSentinelManifest( actions: config.actions, service: { baseUrl: `http://${options.serviceDns}`, - adminEmailDefault: "admin@sub2api.platform-infra.local", + adminEmailDefault: options.adminEmailDefault, }, model: config.model, endpoint: config.endpoint, @@ -2098,27 +2032,19 @@ function valueAt(value: Record, key: string): unknown { return Object.prototype.hasOwnProperty.call(value, key) ? value[key] : undefined; } -function readBoolean(value: unknown, key: string, fallback: boolean): boolean { - if (value === undefined || value === null) return fallback; +function readRequiredRecord(value: unknown, key: string): Record { + if (!isRecord(value)) throw new Error(`${key} must be a YAML object`); + return value; +} + +function readRequiredBoolean(value: unknown, key: string): boolean { + if (value === undefined || value === null) throw new Error(`${key} is required`); if (typeof value === "boolean") return value; if (typeof value === "string" && value.trim() === "true") return true; if (typeof value === "string" && value.trim() === "false") return false; throw new Error(`${key} must be a boolean`); } -function readString(value: unknown, key: string, fallback: string): string { - if (value === undefined || value === null) return fallback; - if (typeof value !== "string" || value.trim().length === 0) throw new Error(`${key} must be a non-empty string`); - if (/[\r\n]/u.test(value)) throw new Error(`${key} must not contain newlines`); - return value.trim(); -} - -function readDnsName(value: unknown, key: string, fallback: string): string { - const text = readString(value, key, fallback); - if (!/^[a-z0-9]([-a-z0-9]*[a-z0-9])?$/u.test(text)) throw new Error(`${key} must be a Kubernetes DNS label`); - return text; -} - function readRequiredDnsName(value: unknown, key: string): string { const text = readRequiredString(value, key); if (!/^[a-z0-9]([-a-z0-9]*[a-z0-9])?$/u.test(text)) throw new Error(`${key} must be a Kubernetes DNS label`); @@ -2156,21 +2082,21 @@ function readRequiredString(value: unknown, key: string): string { return value.trim(); } -function readMarkerPrefix(value: unknown, key: string, fallback: string): string { - const text = readString(value, key, fallback); +function readRequiredMarkerPrefix(value: unknown, key: string): string { + const text = readRequiredString(value, key); if (!/^[A-Za-z0-9_-]{2,32}$/u.test(text)) throw new Error(`${key} must be 2-32 chars of letters, digits, _ or -`); return text; } -function readUserAgent(value: unknown, key: string, fallback: string): string { - const text = readString(value, key, fallback); +function readRequiredUserAgent(value: unknown, key: string): string { + const text = readRequiredString(value, key); if (/[\r\n]/u.test(text)) throw new Error(`${key} must not contain newlines`); if (Buffer.byteLength(text, "utf8") > 200) throw new Error(`${key} must be at most 200 bytes`); return text; } -function readPathList(value: unknown, key: string, fallback: string[]): string[] { - if (value === undefined || value === null) return fallback; +function readRequiredPathList(value: unknown, key: string): string[] { + if (value === undefined || value === null) throw new Error(`${key} is required`); if (!Array.isArray(value) || value.length === 0) throw new Error(`${key} must be a non-empty string array`); const paths = value.map((item, index) => { if (typeof item !== "string" || item.trim().length === 0) throw new Error(`${key}[${index}] must be a non-empty string`); @@ -2181,15 +2107,15 @@ function readPathList(value: unknown, key: string, fallback: string[]): string[] return [...new Set(paths)]; } -function readInt(value: unknown, key: string, fallback: number, min: number, max: number): number { - if (value === undefined || value === null) return fallback; +function readRequiredInt(value: unknown, key: string, min: number, max: number): number { + if (value === undefined || value === null) throw new Error(`${key} is required`); const parsed = typeof value === "number" ? value : typeof value === "string" && value.trim() ? Number(value) : Number.NaN; if (!Number.isInteger(parsed) || parsed < min || parsed > max) throw new Error(`${key} must be an integer from ${min} to ${max}`); return parsed; } -function readNumber(value: unknown, key: string, fallback: number, min: number, max: number): number { - if (value === undefined || value === null) return fallback; +function readRequiredNumber(value: unknown, key: string, min: number, max: number): number { + if (value === undefined || value === null) throw new Error(`${key} is required`); const parsed = typeof value === "number" ? value : typeof value === "string" && value.trim() ? Number(value) : Number.NaN; if (!Number.isFinite(parsed) || parsed < min || parsed > max) throw new Error(`${key} must be a number from ${min} to ${max}`); return parsed; diff --git a/scripts/src/platform-infra-sub2api-codex.ts b/scripts/src/platform-infra-sub2api-codex.ts index a247f7b7..fc94571e 100644 --- a/scripts/src/platform-infra-sub2api-codex.ts +++ b/scripts/src/platform-infra-sub2api-codex.ts @@ -1,5 +1,5 @@ import { createHash } from "node:crypto"; -import { chmodSync, copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs"; +import { chmodSync, copyFileSync, existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs"; import { homedir } from "node:os"; import { join } from "node:path"; import type { UniDeskConfig } from "./config"; @@ -9,7 +9,6 @@ import { applyLocalCaddyManagedSite } from "./pk01-caddy"; import { codexPoolSentinelSummary, codexPoolSentinelRuntimeImage, - defaultCodexPoolSentinelConfig, readCodexPoolSentinelConfig, renderCodexPoolSentinelManifest, type CodexPoolSentinelConfig, @@ -23,12 +22,6 @@ const fieldManager = "unidesk-platform-infra"; const sub2apiConfigPath = rootPath("config", "platform-infra", "sub2api.yaml"); const codexPoolConfigPath = rootPath("config", "platform-infra", "sub2api-codex-pool.yaml"); const sentinelImageDockerfilePath = rootPath("src", "components", "platform-infra", "sub2api", "sentinel.Dockerfile"); -const defaultPoolGroupName = "unidesk-codex-pool"; -const defaultPoolApiKeyName = "unidesk-codex-pool-api-key"; -const defaultPoolApiKeySecretName = "sub2api-codex-pool-api-key"; -const defaultPoolApiKeySecretKey = "API_KEY"; -const defaultMinOwnerBalanceUsd = 1000; -const defaultAccountPriority = 10; const remoteJobDir = "/tmp/unidesk-platform-infra-sub2api-codex-pool"; const remoteJobTimeoutMs = 15 * 60_000; const remoteJobPollMs = 5_000; @@ -136,9 +129,11 @@ interface CodexPoolConfig { relatedIssues: number[]; }; groupName: string; + groupDescription: string; apiKeyName: string; apiKeySecretName: string; apiKeySecretKey: string; + adminEmailDefault: string; minOwnerBalanceUsd: number; minOwnerConcurrency: number; minOwnerConcurrencySource: "auto" | "yaml"; @@ -760,6 +755,7 @@ async function codexPoolSync(config: UniDeskConfig, options: SyncOptions): Promi serviceName: runtimeTarget.serviceName, serviceDns: runtimeTarget.serviceDns, appSecretName: runtimeTarget.appSecretName, + adminEmailDefault: pool.adminEmailDefault, proxy: runtimeTarget.egressProxy?.applyToSentinel ? { httpProxy: runtimeTarget.egressProxy.httpProxy, noProxy: runtimeTarget.egressProxy.noProxy, @@ -769,9 +765,11 @@ async function codexPoolSync(config: UniDeskConfig, options: SyncOptions): Promi }, pool: { groupName: pool.groupName, + groupDescription: pool.groupDescription, apiKeyName: pool.apiKeyName, apiKeySecretName: pool.apiKeySecretName, apiKeySecretKey: pool.apiKeySecretKey, + adminEmailDefault: pool.adminEmailDefault, minOwnerBalanceUsd: pool.minOwnerBalanceUsd, minOwnerConcurrency: pool.minOwnerConcurrency, defaultAccountPriority: pool.defaultAccountPriority, @@ -1171,10 +1169,7 @@ function collectCodexProfiles(): CodexProfile[] { const pool = readCodexPoolConfig(); if (!existsSync(codexDir)) return []; const seenAccountNames = new Set(); - const configs = pool.profiles.length > 0 - ? pool.profiles - : discoverCodexProfileConfigs(codexDir, pool.defaultTempUnschedulable, pool.defaultSentinelProtect, pool.defaultAccountPriority); - return configs.map((entry) => { + return pool.profiles.map((entry) => { const resolved = resolveProfileFiles(codexDir, entry); const profile = entry.profile; const accountName = entry.accountName ?? uniqueAccountName(profile, seenAccountNames); @@ -1247,41 +1242,6 @@ function collectCodexProfiles(): CodexProfile[] { }); } -function discoverCodexProfileConfigs( - codexDir: string, - defaultTempUnschedulable = defaultCodexTempUnschedulablePolicy(), - defaultSentinelProtect = defaultCodexSentinelProtectPolicy(), - defaultPriority = defaultAccountPriority, -): CodexPoolProfileConfig[] { - return readdirSync(codexDir) - .filter((file) => file === "config.toml" || file.startsWith("config.toml.")) - .sort((a, b) => { - if (a === "config.toml") return -1; - if (b === "config.toml") return 1; - return a.localeCompare(b); - }) - .map((configFile) => { - const suffix = configFile === "config.toml" ? "" : configFile.slice("config.toml.".length); - const profile = suffix === "" ? "default" : suffix; - return { - profile, - accountName: null, - configFile, - authFile: suffix === "" ? "auth.json" : `auth.json.${suffix}`, - fallbackConfigFile: null, - fallbackAuthFile: null, - openaiResponsesWebSocketsV2Mode: null, - upstreamUserAgent: null, - trustUpstream: false, - sentinelProtect: defaultSentinelProtect, - priority: defaultPriority, - capacity: null, - loadFactor: null, - tempUnschedulable: defaultTempUnschedulable, - }; - }); -} - function resolveProfileFiles(codexDir: string, profile: CodexPoolProfileConfig): { configFile: string; authFile: string } { const configFile = existsSync(join(codexDir, profile.configFile)) || profile.fallbackConfigFile === null ? profile.configFile @@ -1293,7 +1253,6 @@ function resolveProfileFiles(codexDir: string, profile: CodexPoolProfileConfig): } function readCodexPoolConfig(): CodexPoolConfig { - const defaults = defaultCodexPoolConfig(); if (!existsSync(codexPoolConfigPath)) throw new Error(`${codexPoolConfigPath} is required`); const parsed = Bun.YAML.parse(readFileSync(codexPoolConfigPath, "utf8")) as unknown; if (!isRecord(parsed)) throw new Error(`${codexPoolConfigPath} must contain a YAML object`); @@ -1306,19 +1265,18 @@ function readCodexPoolConfig(): CodexPoolConfig { if (!isRecord(pool)) throw new Error(`${codexPoolConfigPath}.pool must be a YAML object`); if (!isRecord(parsed.profiles)) throw new Error(`${codexPoolConfigPath}.profiles must be a YAML object`); rejectSchedulableYamlField(pool, "pool"); - const defaultTempUnschedulable = readTempUnschedulablePolicy(pool.defaultTempUnschedulable, "pool.defaultTempUnschedulable", defaults.defaultTempUnschedulable); + const defaultTempUnschedulable = readTempUnschedulablePolicy(pool.defaultTempUnschedulable, "pool.defaultTempUnschedulable"); const defaultAccountPriorityValue = readAccountPriority(pool.defaultAccountPriority, "pool.defaultAccountPriority"); const defaultAccountCapacityValue = readAccountCapacity(pool.defaultAccountCapacity, "pool.defaultAccountCapacity"); const defaultAccountLoadFactorValue = readAccountLoadFactor(pool.defaultAccountLoadFactor, "pool.defaultAccountLoadFactor"); - const defaultSentinelProtect = readSentinelProtectPolicy(pool.defaultSentinelProtect, "pool.defaultSentinelProtect", defaults.defaultSentinelProtect); + const defaultSentinelProtect = readSentinelProtectPolicy(pool.defaultSentinelProtect, "pool.defaultSentinelProtect"); const profiles = readProfileConfig( parsed.profiles, - defaults.profiles, defaultTempUnschedulable, defaultSentinelProtect, defaultAccountPriorityValue, ); - const manualAccounts = readManualAccountsConfig(parsed.manualAccounts, defaults.manualAccounts); + const manualAccounts = readManualAccountsConfig(parsed.manualAccounts); assertProtectedManualAccountsNotManaged(profiles, manualAccounts); const declaredAccountCapacity = desiredProfileCapacityTotal(profiles, defaultAccountCapacityValue); const minOwnerConcurrencySource = pool.minOwnerConcurrency === undefined || pool.minOwnerConcurrency === null ? "auto" : "yaml"; @@ -1333,11 +1291,13 @@ function readCodexPoolConfig(): CodexPoolConfig { owner: requiredStringConfigField(metadata, "owner", "metadata"), relatedIssues: integerArrayConfigField(metadata, "relatedIssues", "metadata"), }, - groupName: stringValue(pool.groupName) ?? defaults.groupName, - apiKeyName: stringValue(pool.apiKeyName) ?? defaults.apiKeyName, - apiKeySecretName: stringValue(pool.apiKeySecretName) ?? defaults.apiKeySecretName, - apiKeySecretKey: stringValue(pool.apiKeySecretKey) ?? defaults.apiKeySecretKey, - minOwnerBalanceUsd: numberValue(pool.minOwnerBalanceUsd) ?? defaults.minOwnerBalanceUsd, + groupName: requiredStringConfigField(pool, "groupName", "pool"), + groupDescription: readRequiredDescription(pool.groupDescription, "pool.groupDescription", 300), + apiKeyName: requiredStringConfigField(pool, "apiKeyName", "pool"), + apiKeySecretName: requiredStringConfigField(pool, "apiKeySecretName", "pool"), + apiKeySecretKey: requiredStringConfigField(pool, "apiKeySecretKey", "pool"), + adminEmailDefault: readRequiredEmail(pool.adminEmailDefault, "pool.adminEmailDefault"), + minOwnerBalanceUsd: readRequiredPositiveNumber(pool.minOwnerBalanceUsd, "pool.minOwnerBalanceUsd"), minOwnerConcurrency, minOwnerConcurrencySource, defaultAccountPriority: defaultAccountPriorityValue, @@ -1347,10 +1307,10 @@ function readCodexPoolConfig(): CodexPoolConfig { defaultSentinelProtect, profiles, manualAccounts, - publicExposure: readPublicExposureConfig(parsed.publicExposure, defaults.publicExposure), - localCodex: readLocalCodexConfig(parsed.localCodex, defaults.localCodex), + publicExposure: readPublicExposureConfig(parsed.publicExposure), + localCodex: readLocalCodexConfig(parsed.localCodex), sentinel: { - ...readCodexPoolSentinelConfig(parsed.sentinel, defaults.sentinel, codexPoolConfigPath), + ...readCodexPoolSentinelConfig(parsed.sentinel, codexPoolConfigPath), protectedManualAccounts: manualAccounts.protected.map((account) => account.accountName), }, }; @@ -1366,96 +1326,13 @@ function readCodexPoolConfig(): CodexPoolConfig { return config; } -function defaultCodexPoolConfig(): CodexPoolConfig { - const runtimeTarget = codexPoolRuntimeTarget(); - return { - version: 1, - kind: "platform-infra-sub2api-codex-pool", - metadata: { - id: "sub2api-codex-pool", - owner: "unidesk", - relatedIssues: [], - }, - groupName: defaultPoolGroupName, - apiKeyName: defaultPoolApiKeyName, - apiKeySecretName: defaultPoolApiKeySecretName, - apiKeySecretKey: defaultPoolApiKeySecretKey, - minOwnerBalanceUsd: defaultMinOwnerBalanceUsd, - minOwnerConcurrency: 1, - minOwnerConcurrencySource: "auto", - defaultAccountPriority, - defaultAccountCapacity: 5, - defaultAccountLoadFactor: 1, - defaultTempUnschedulable: defaultCodexTempUnschedulablePolicy(), - defaultSentinelProtect: defaultCodexSentinelProtectPolicy(), - profiles: [], - manualAccounts: { - protected: [], - }, - publicExposure: { - enabled: false, - proxyName: "platform-infra-sub2api", - configMapName: "sub2api-frpc-config", - deploymentName: "sub2api-frpc", - frpcImage: "fatedier/frpc:v0.68.1", - serverAddr: "74.48.78.17", - serverPort: 7000, - remotePort: 21880, - localIP: runtimeTarget.serviceDns.split(":")[0], - localPort: 8080, - publicBaseUrl: "http://74.48.78.17:21880", - masterBaseUrl: "http://127.0.0.1:21880", - masterFrps: { - configPath: "/opt/hwlab-frp/frps.dev.toml", - containerName: "hwlab-frps-dev", - }, - masterCaddy: { - enabled: true, - domain: "sub2api.74-48-78-17.nip.io", - configPath: "/etc/caddy/Caddyfile", - serviceName: "caddy", - upstreamBaseUrl: "http://127.0.0.1:21880", - responseHeaderTimeoutSeconds: 180, - edgeRetry: { - enabled: false, - tryDurationSeconds: 0, - tryIntervalMilliseconds: 250, - retryMatch: { - methods: [], - paths: [], - }, - }, - }, - }, - localCodex: { - backupSuffix: "pre-sub2api", - providerName: "OpenAI", - wireApi: "responses", - modelContextWindow: 272000, - modelAutoCompactTokenLimit: 240000, - supportsWebSockets: true, - responsesWebSocketsV2: true, - responsesSmokeModel: "gpt-5.5", - }, - sentinel: defaultCodexPoolSentinelConfig(), - }; -} - -export function defaultCodexTempUnschedulablePolicy(): CodexTempUnschedulablePolicy { - return { - enabled: false, - rules: [], - }; -} - function readProfileConfig( value: unknown, - defaults: CodexPoolProfileConfig[], defaultTempUnschedulable: CodexTempUnschedulablePolicy, defaultSentinelProtect: CodexSentinelProtectPolicy, defaultPriority: number, ): CodexPoolProfileConfig[] { - if (!isRecord(value)) return defaults; + if (!isRecord(value)) throw new Error(`${codexPoolConfigPath}.profiles must be a YAML object`); const entries = value.entries; if (!Array.isArray(entries)) throw new Error(`${codexPoolConfigPath}.profiles.entries must be a YAML array`); return entries.map((entry, index) => { @@ -1506,11 +1383,9 @@ function desiredProfileCapacityTotal(profiles: CodexPoolProfileConfig[], default return profiles.reduce((total, profile) => total + (profile.capacity ?? defaultCapacity), 0); } -function readManualAccountsConfig(value: unknown, defaults: CodexPoolManualAccountsConfig): CodexPoolManualAccountsConfig { - if (value === undefined || value === null) return defaults; +function readManualAccountsConfig(value: unknown): CodexPoolManualAccountsConfig { if (!isRecord(value)) throw new Error(`${codexPoolConfigPath}.manualAccounts must be a YAML object`); const protectedRaw = value.protected; - if (protectedRaw === undefined || protectedRaw === null) return { protected: [] }; if (!Array.isArray(protectedRaw)) throw new Error(`${codexPoolConfigPath}.manualAccounts.protected must be a YAML array`); const seen = new Set(); const protectedAccounts = protectedRaw.map((entry, index): CodexPoolManualAccountProtection => { @@ -1536,7 +1411,8 @@ function readManualAccountProxyBinding(value: unknown, key: string): CodexPoolMa if (value === undefined || value === null) return null; if (!isRecord(value)) throw new Error(`${codexPoolConfigPath}.${key} must be a YAML object`); const enabled = value.enabled === undefined ? true : value.enabled === true; - const source = stringValue(value.source) ?? "target-egress-proxy"; + const source = stringValue(value.source); + if (source === null) throw new Error(`${codexPoolConfigPath}.${key}.source is required`); if (source !== "target-egress-proxy") throw new Error(`${codexPoolConfigPath}.${key}.source must be target-egress-proxy`); const proxyName = stringValue(value.proxyName); if (proxyName === null || proxyName.trim().length === 0) throw new Error(`${codexPoolConfigPath}.${key}.proxyName is required`); @@ -1552,7 +1428,8 @@ function readManualAccountGroupBinding(value: unknown, key: string): CodexPoolMa if (value === undefined || value === null) return null; if (!isRecord(value)) throw new Error(`${codexPoolConfigPath}.${key} must be a YAML object`); const enabled = value.enabled === undefined ? true : value.enabled === true; - const source = stringValue(value.source) ?? "pool-group"; + const source = stringValue(value.source); + if (source === null) throw new Error(`${codexPoolConfigPath}.${key}.source is required`); if (source !== "pool-group") throw new Error(`${codexPoolConfigPath}.${key}.source must be pool-group`); return { enabled, @@ -1618,36 +1495,42 @@ function readTrustUpstream(value: unknown, key: string): boolean { return parsed; } -export function defaultCodexSentinelProtectPolicy(): CodexSentinelProtectPolicy { - return { - enabled: false, - consecutiveFailures: 1, - initialRetryDelaySeconds: 2, - maxRetryDelaySeconds: 60, - backoffMultiplier: 2, - }; -} - -function readSentinelProtectPolicy(value: unknown, key: string, fallback = defaultCodexSentinelProtectPolicy()): CodexSentinelProtectPolicy { - if (value === undefined || value === null) return fallback; +function readSentinelProtectPolicy(value: unknown, key: string, fallback?: CodexSentinelProtectPolicy): CodexSentinelProtectPolicy { + if (value === undefined || value === null) { + if (fallback !== undefined) return fallback; + throw new Error(`${codexPoolConfigPath}.${key} is required`); + } if (!isRecord(value)) throw new Error(`${codexPoolConfigPath}.${key} must be a YAML object`); - const enabled = readBooleanConfig(value.enabled, `${key}.enabled`, fallback.enabled); - if (!enabled) return { ...fallback, enabled: false }; + const enabled = readBooleanConfig(value.enabled, `${key}.enabled`, fallback?.enabled); const required = ["consecutiveFailures", "initialRetryDelaySeconds", "maxRetryDelaySeconds", "backoffMultiplier"]; for (const field of required) { - if (value[field] === undefined || value[field] === null) { + if (fallback === undefined && (value[field] === undefined || value[field] === null)) { + throw new Error(`${codexPoolConfigPath}.${key}.${field} is required`); + } + if (enabled && value[field] === undefined && fallback === undefined) { throw new Error(`${codexPoolConfigPath}.${key}.${field} is required when enabled=true`); } } - const consecutiveFailures = readBoundedInteger(value.consecutiveFailures, `${key}.consecutiveFailures`, 1, 20); - const initialRetryDelaySeconds = readBoundedInteger(value.initialRetryDelaySeconds, `${key}.initialRetryDelaySeconds`, 1, 3600); - const maxRetryDelaySeconds = readBoundedInteger(value.maxRetryDelaySeconds, `${key}.maxRetryDelaySeconds`, 1, 3600); - const backoffMultiplier = readBoundedInteger(value.backoffMultiplier, `${key}.backoffMultiplier`, 1, 10); + const consecutiveFailures = value.consecutiveFailures === undefined || value.consecutiveFailures === null + ? fallback?.consecutiveFailures + : readBoundedInteger(value.consecutiveFailures, `${key}.consecutiveFailures`, 1, 20); + const initialRetryDelaySeconds = value.initialRetryDelaySeconds === undefined || value.initialRetryDelaySeconds === null + ? fallback?.initialRetryDelaySeconds + : readBoundedInteger(value.initialRetryDelaySeconds, `${key}.initialRetryDelaySeconds`, 1, 3600); + const maxRetryDelaySeconds = value.maxRetryDelaySeconds === undefined || value.maxRetryDelaySeconds === null + ? fallback?.maxRetryDelaySeconds + : readBoundedInteger(value.maxRetryDelaySeconds, `${key}.maxRetryDelaySeconds`, 1, 3600); + const backoffMultiplier = value.backoffMultiplier === undefined || value.backoffMultiplier === null + ? fallback?.backoffMultiplier + : readBoundedInteger(value.backoffMultiplier, `${key}.backoffMultiplier`, 1, 10); + if (consecutiveFailures === undefined || initialRetryDelaySeconds === undefined || maxRetryDelaySeconds === undefined || backoffMultiplier === undefined) { + throw new Error(`${codexPoolConfigPath}.${key} is missing required sentinel protection fields`); + } if (maxRetryDelaySeconds < initialRetryDelaySeconds) { throw new Error(`${codexPoolConfigPath}.${key}.maxRetryDelaySeconds must be >= initialRetryDelaySeconds`); } return { - enabled: true, + enabled, consecutiveFailures, initialRetryDelaySeconds, maxRetryDelaySeconds, @@ -1663,8 +1546,11 @@ function readBoundedInteger(value: unknown, key: string, min: number, max: numbe return parsed; } -function readAccountPriority(value: unknown, key: string, fallback = defaultAccountPriority): number { - if (value === undefined || value === null) return fallback; +function readAccountPriority(value: unknown, key: string, fallback?: number): number { + if (value === undefined || value === null) { + if (fallback !== undefined) return fallback; + throw new Error(`${codexPoolConfigPath}.${key} is required`); + } const priority = numberValue(value); if (priority === null || !Number.isInteger(priority) || priority < 0 || priority > 1000) { throw new Error(`${codexPoolConfigPath}.${key} must be an integer from 0 to 1000`); @@ -1696,8 +1582,8 @@ function readAccountLoadFactor(value: unknown, key: string): number { return loadFactor; } -function readCaddyTimeoutSeconds(value: unknown, key: string, fallback: number): number { - if (value === undefined || value === null) return fallback; +function readCaddyTimeoutSeconds(value: unknown, key: string): number { + if (value === undefined || value === null) throw new Error(`${codexPoolConfigPath}.${key} is required`); const seconds = numberValue(value); if (seconds === null || !Number.isInteger(seconds) || seconds < 30 || seconds > 900) { throw new Error(`${codexPoolConfigPath}.${key} must be an integer from 30 to 900`); @@ -1705,16 +1591,14 @@ function readCaddyTimeoutSeconds(value: unknown, key: string, fallback: number): return seconds; } -function readCaddyEdgeRetryConfig(value: unknown, fallback: CodexPoolCaddyEdgeRetryConfig, key: string): CodexPoolCaddyEdgeRetryConfig { - if (value === undefined || value === null) return cloneCaddyEdgeRetryConfig(fallback); +function readCaddyEdgeRetryConfig(value: unknown, key: string): CodexPoolCaddyEdgeRetryConfig { if (!isRecord(value)) throw new Error(`${codexPoolConfigPath}.${key} must be a YAML object`); - const enabled = readBooleanConfig(value.enabled, `${key}.enabled`, fallback.enabled); - if (!enabled) return { ...cloneCaddyEdgeRetryConfig(fallback), enabled: false }; + const enabled = readBooleanConfig(value.enabled, `${key}.enabled`); const tryDurationSeconds = readPositiveInteger(value.tryDurationSeconds, `${key}.tryDurationSeconds`); const tryIntervalMilliseconds = readPositiveInteger(value.tryIntervalMilliseconds, `${key}.tryIntervalMilliseconds`); const retryMatchValue = isRecord(value.retryMatch) ? value.retryMatch : null; - if (retryMatchValue === null) throw new Error(`${codexPoolConfigPath}.${key}.retryMatch must be a YAML object when enabled=true`); + if (retryMatchValue === null) throw new Error(`${codexPoolConfigPath}.${key}.retryMatch must be a YAML object`); const retryMatch = { methods: readCaddyRetryMethods(retryMatchValue.methods, `${key}.retryMatch.methods`), paths: readCaddyRetryPaths(retryMatchValue.paths, `${key}.retryMatch.paths`), @@ -1733,8 +1617,8 @@ function readPositiveInteger(value: unknown, key: string): number { return parsed; } -function readPositiveIntegerConfig(value: unknown, key: string, fallback: number): number { - if (value === undefined || value === null) return fallback; +function readPositiveIntegerConfig(value: unknown, key: string): number { + if (value === undefined || value === null) throw new Error(`${codexPoolConfigPath}.${key} is required`); return readPositiveInteger(value, key); } @@ -1770,13 +1654,20 @@ function readCaddyRetryPaths(value: unknown, key: string): string[] { return paths; } -function readTempUnschedulablePolicy(value: unknown, key: string, fallback: CodexTempUnschedulablePolicy): CodexTempUnschedulablePolicy { - if (value === undefined || value === null) return cloneTempUnschedulablePolicy(fallback); +function readTempUnschedulablePolicy(value: unknown, key: string, fallback?: CodexTempUnschedulablePolicy): CodexTempUnschedulablePolicy { + if (value === undefined || value === null) { + if (fallback !== undefined) return cloneTempUnschedulablePolicy(fallback); + throw new Error(`${codexPoolConfigPath}.${key} is required`); + } 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`); + const enabled = readBooleanConfig(value.enabled, `${key}.enabled`, fallback?.enabled); + let rules: CodexTempUnschedulableRule[]; + if (value.rules === undefined || value.rules === null) { + if (fallback === undefined) throw new Error(`${codexPoolConfigPath}.${key}.rules is required`); + rules = cloneTempUnschedulablePolicy(fallback).rules; + } else { + 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 }; } @@ -1830,60 +1721,49 @@ function cloneTempUnschedulablePolicy(policy: CodexTempUnschedulablePolicy): Cod }; } -function cloneCaddyEdgeRetryConfig(config: CodexPoolCaddyEdgeRetryConfig): CodexPoolCaddyEdgeRetryConfig { - return { - enabled: config.enabled, - tryDurationSeconds: config.tryDurationSeconds, - tryIntervalMilliseconds: config.tryIntervalMilliseconds, - retryMatch: { - methods: [...config.retryMatch.methods], - paths: [...config.retryMatch.paths], - }, - }; -} - -function readBooleanConfig(value: unknown, key: string, fallback: boolean): boolean { - if (value === undefined || value === null) return fallback; +function readBooleanConfig(value: unknown, key: string, fallback?: boolean): boolean { + if (value === undefined || value === null) { + if (fallback !== undefined) return fallback; + throw new Error(`${codexPoolConfigPath}.${key} is required`); + } const parsed = booleanValue(value); if (parsed === null) throw new Error(`${codexPoolConfigPath}.${key} must be a boolean`); return parsed; } -function readPublicExposureConfig(value: unknown, defaults: CodexPoolPublicExposureConfig): CodexPoolPublicExposureConfig { - if (!isRecord(value)) return defaults; - const masterFrpsValue = isRecord(value.masterFrps) ? value.masterFrps : {}; - const masterCaddyValue = isRecord(value.masterCaddy) ? value.masterCaddy : {}; +function readPublicExposureConfig(value: unknown): CodexPoolPublicExposureConfig { + if (!isRecord(value)) throw new Error(`${codexPoolConfigPath}.publicExposure must be a YAML object`); + const masterFrpsValue = requiredRecordConfigField(value, "masterFrps", "publicExposure"); + const masterCaddyValue = requiredRecordConfigField(value, "masterCaddy", "publicExposure"); const config: CodexPoolPublicExposureConfig = { - enabled: booleanValue(value.enabled) ?? defaults.enabled, - proxyName: stringValue(value.proxyName) ?? defaults.proxyName, - configMapName: stringValue(value.configMapName) ?? defaults.configMapName, - deploymentName: stringValue(value.deploymentName) ?? defaults.deploymentName, - frpcImage: stringValue(value.frpcImage) ?? defaults.frpcImage, - serverAddr: stringValue(value.serverAddr) ?? defaults.serverAddr, - serverPort: numberValue(value.serverPort) ?? defaults.serverPort, - remotePort: numberValue(value.remotePort) ?? defaults.remotePort, - localIP: stringValue(value.localIP) ?? defaults.localIP, - localPort: numberValue(value.localPort) ?? defaults.localPort, - publicBaseUrl: normalizeBaseUrl(stringValue(value.publicBaseUrl)) ?? defaults.publicBaseUrl, - masterBaseUrl: normalizeBaseUrl(stringValue(value.masterBaseUrl)) ?? defaults.masterBaseUrl, + enabled: readBooleanConfig(value.enabled, "publicExposure.enabled"), + proxyName: requiredStringConfigField(value, "proxyName", "publicExposure"), + configMapName: requiredStringConfigField(value, "configMapName", "publicExposure"), + deploymentName: requiredStringConfigField(value, "deploymentName", "publicExposure"), + frpcImage: requiredStringConfigField(value, "frpcImage", "publicExposure"), + serverAddr: requiredStringConfigField(value, "serverAddr", "publicExposure"), + serverPort: readRequiredPort(value.serverPort, "publicExposure.serverPort"), + remotePort: readRequiredPort(value.remotePort, "publicExposure.remotePort"), + localIP: requiredStringConfigField(value, "localIP", "publicExposure"), + localPort: readRequiredPort(value.localPort, "publicExposure.localPort"), + publicBaseUrl: readRequiredBaseUrl(value.publicBaseUrl, "publicExposure.publicBaseUrl"), + masterBaseUrl: readRequiredBaseUrl(value.masterBaseUrl, "publicExposure.masterBaseUrl"), masterFrps: { - configPath: stringValue(masterFrpsValue.configPath) ?? defaults.masterFrps.configPath, - containerName: stringValue(masterFrpsValue.containerName) ?? defaults.masterFrps.containerName, + configPath: requiredStringConfigField(masterFrpsValue, "configPath", "publicExposure.masterFrps"), + containerName: requiredStringConfigField(masterFrpsValue, "containerName", "publicExposure.masterFrps"), }, masterCaddy: { - enabled: booleanValue(masterCaddyValue.enabled) ?? defaults.masterCaddy.enabled, - domain: stringValue(masterCaddyValue.domain) ?? defaults.masterCaddy.domain, - configPath: stringValue(masterCaddyValue.configPath) ?? defaults.masterCaddy.configPath, - serviceName: stringValue(masterCaddyValue.serviceName) ?? defaults.masterCaddy.serviceName, - upstreamBaseUrl: normalizeBaseUrl(stringValue(masterCaddyValue.upstreamBaseUrl)) ?? defaults.masterCaddy.upstreamBaseUrl, + enabled: readBooleanConfig(masterCaddyValue.enabled, "publicExposure.masterCaddy.enabled"), + domain: requiredStringConfigField(masterCaddyValue, "domain", "publicExposure.masterCaddy"), + configPath: requiredStringConfigField(masterCaddyValue, "configPath", "publicExposure.masterCaddy"), + serviceName: requiredStringConfigField(masterCaddyValue, "serviceName", "publicExposure.masterCaddy"), + upstreamBaseUrl: readRequiredBaseUrl(masterCaddyValue.upstreamBaseUrl, "publicExposure.masterCaddy.upstreamBaseUrl"), responseHeaderTimeoutSeconds: readCaddyTimeoutSeconds( masterCaddyValue.responseHeaderTimeoutSeconds, "publicExposure.masterCaddy.responseHeaderTimeoutSeconds", - defaults.masterCaddy.responseHeaderTimeoutSeconds, ), edgeRetry: readCaddyEdgeRetryConfig( masterCaddyValue.edgeRetry, - defaults.masterCaddy.edgeRetry, "publicExposure.masterCaddy.edgeRetry", ), }, @@ -1907,17 +1787,17 @@ function readPublicExposureConfig(value: unknown, defaults: CodexPoolPublicExpos return config; } -function readLocalCodexConfig(value: unknown, defaults: CodexPoolLocalCodexConfig): CodexPoolLocalCodexConfig { - if (!isRecord(value)) return defaults; +function readLocalCodexConfig(value: unknown): CodexPoolLocalCodexConfig { + if (!isRecord(value)) throw new Error(`${codexPoolConfigPath}.localCodex must be a YAML object`); const config = { - backupSuffix: stringValue(value.backupSuffix) ?? defaults.backupSuffix, - providerName: stringValue(value.providerName) ?? defaults.providerName, - wireApi: stringValue(value.wireApi) ?? defaults.wireApi, - modelContextWindow: readPositiveIntegerConfig(value.modelContextWindow, "localCodex.modelContextWindow", defaults.modelContextWindow), - modelAutoCompactTokenLimit: readPositiveIntegerConfig(value.modelAutoCompactTokenLimit, "localCodex.modelAutoCompactTokenLimit", defaults.modelAutoCompactTokenLimit), - supportsWebSockets: readBooleanConfig(value.supportsWebSockets, "localCodex.supportsWebSockets", defaults.supportsWebSockets), - responsesWebSocketsV2: readBooleanConfig(value.responsesWebSocketsV2, "localCodex.responsesWebSocketsV2", defaults.responsesWebSocketsV2), - responsesSmokeModel: stringValue(value.responsesSmokeModel) ?? defaults.responsesSmokeModel, + backupSuffix: requiredStringConfigField(value, "backupSuffix", "localCodex"), + providerName: requiredStringConfigField(value, "providerName", "localCodex"), + wireApi: requiredStringConfigField(value, "wireApi", "localCodex"), + modelContextWindow: readPositiveIntegerConfig(value.modelContextWindow, "localCodex.modelContextWindow"), + modelAutoCompactTokenLimit: readPositiveIntegerConfig(value.modelAutoCompactTokenLimit, "localCodex.modelAutoCompactTokenLimit"), + supportsWebSockets: readBooleanConfig(value.supportsWebSockets, "localCodex.supportsWebSockets"), + responsesWebSocketsV2: readBooleanConfig(value.responsesWebSocketsV2, "localCodex.responsesWebSocketsV2"), + responsesSmokeModel: requiredStringConfigField(value, "responsesSmokeModel", "localCodex"), }; if (!/^[A-Za-z0-9._-]+$/u.test(config.backupSuffix)) throw new Error(`${codexPoolConfigPath}.localCodex.backupSuffix has an unsupported format`); validateProxyName(config.providerName, "localCodex.providerName"); @@ -2054,9 +1934,11 @@ function codexPoolConfigSummary(pool: CodexPoolConfig): Record kind: pool.kind, metadata: pool.metadata, groupName: pool.groupName, + groupDescription: pool.groupDescription, apiKeyName: pool.apiKeyName, apiKeySecretName: pool.apiKeySecretName, apiKeySecretKey: pool.apiKeySecretKey, + adminEmailDefault: pool.adminEmailDefault, minOwnerBalanceUsd: pool.minOwnerBalanceUsd, minOwnerConcurrency: pool.minOwnerConcurrency, minOwnerConcurrencySource: pool.minOwnerConcurrencySource, @@ -3676,6 +3558,41 @@ function requiredStringConfigField(obj: Record, key: string, pa return value; } +function readRequiredDescription(value: unknown, key: string, maxChars: number): string { + const text = stringValue(value); + if (text === null) throw new Error(`${codexPoolConfigPath}.${key} must be a non-empty string`); + if (text.length > maxChars) throw new Error(`${codexPoolConfigPath}.${key} must be at most ${maxChars} characters`); + if (/[\r\n]/u.test(text)) throw new Error(`${codexPoolConfigPath}.${key} must not contain newlines`); + return text; +} + +function readRequiredEmail(value: unknown, key: string): string { + const text = stringValue(value); + if (text === null) throw new Error(`${codexPoolConfigPath}.${key} must be a non-empty string`); + if (!/^[^@\s]+@[^@\s]+[.][^@\s]+$/u.test(text)) throw new Error(`${codexPoolConfigPath}.${key} must be an email address`); + return text; +} + +function readRequiredBaseUrl(value: unknown, key: string): string { + const baseUrl = normalizeBaseUrl(stringValue(value)); + if (baseUrl === null) throw new Error(`${codexPoolConfigPath}.${key} must be a valid http(s) URL`); + return baseUrl; +} + +function readRequiredPort(value: unknown, key: string): number { + const port = numberValue(value); + if (port === null || !Number.isInteger(port) || port < 1 || port > 65535) { + throw new Error(`${codexPoolConfigPath}.${key} must be an integer port`); + } + return port; +} + +function readRequiredPositiveNumber(value: unknown, key: string): number { + const number = numberValue(value); + if (number === null || number <= 0) throw new Error(`${codexPoolConfigPath}.${key} must be > 0`); + return number; +} + function requiredRecordConfigField(obj: Record, key: string, path: string): Record { const value = obj[key]; const prefix = path.length > 0 ? `${path}.` : ""; @@ -4151,11 +4068,8 @@ function desiredAccountCapacityTotal(pool: CodexPoolConfig): number { 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, pool.defaultSentinelProtect, pool.defaultAccountPriority); const capacities: Record = {}; - for (const entry of configs) { + for (const entry of pool.profiles) { const accountName = entry.accountName ?? uniqueAccountName(entry.profile, seenAccountNames); seenAccountNames.add(accountName); capacities[accountName] = entry.capacity ?? pool.defaultAccountCapacity; @@ -4166,11 +4080,8 @@ 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, pool.defaultSentinelProtect, pool.defaultAccountPriority); const loadFactors: Record = {}; - for (const entry of configs) { + for (const entry of pool.profiles) { const accountName = entry.accountName ?? uniqueAccountName(entry.profile, seenAccountNames); seenAccountNames.add(accountName); loadFactors[accountName] = entry.loadFactor ?? pool.defaultAccountLoadFactor; @@ -4181,11 +4092,8 @@ function desiredAccountLoadFactorMap(pool: CodexPoolConfig): Record { const codexDir = join(homedir(), ".codex"); const seenAccountNames = new Set(); - const configs = pool.profiles.length > 0 - ? pool.profiles - : discoverCodexProfileConfigs(codexDir, pool.defaultTempUnschedulable, pool.defaultSentinelProtect, pool.defaultAccountPriority); const modes: Record = {}; - for (const entry of configs) { + for (const entry of pool.profiles) { const accountName = entry.accountName ?? uniqueAccountName(entry.profile, seenAccountNames); seenAccountNames.add(accountName); modes[accountName] = entry.openaiResponsesWebSocketsV2Mode; @@ -4196,11 +4104,8 @@ function desiredAccountWebSocketsV2ModeMap(pool: CodexPoolConfig): Record { const codexDir = join(homedir(), ".codex"); const seenAccountNames = new Set(); - const configs = pool.profiles.length > 0 - ? pool.profiles - : discoverCodexProfileConfigs(codexDir, pool.defaultTempUnschedulable, pool.defaultSentinelProtect, pool.defaultAccountPriority); const policies: Record = {}; - for (const entry of configs) { + for (const entry of pool.profiles) { const accountName = entry.accountName ?? uniqueAccountName(entry.profile, seenAccountNames); seenAccountNames.add(accountName); policies[accountName] = renderSub2ApiTempUnschedulableCredentials(entry.tempUnschedulable); @@ -4230,9 +4135,11 @@ SERVICE_DNS = ${JSON.stringify(target.serviceDns)} FIELD_MANAGER = "${fieldManager}" APP_SECRET_NAME = ${JSON.stringify(target.appSecretName)} POOL_GROUP_NAME = "${pool.groupName}" +POOL_GROUP_DESCRIPTION = ${JSON.stringify(pool.groupDescription)} POOL_API_KEY_NAME = "${pool.apiKeyName}" POOL_API_KEY_SECRET_NAME = "${pool.apiKeySecretName}" POOL_API_KEY_SECRET_KEY = "${pool.apiKeySecretKey}" +POOL_ADMIN_EMAIL_DEFAULT = ${JSON.stringify(pool.adminEmailDefault)} MIN_OWNER_BALANCE_USD = ${JSON.stringify(pool.minOwnerBalanceUsd)} MIN_OWNER_CONCURRENCY = ${JSON.stringify(pool.minOwnerConcurrency)} MIN_OWNER_CONCURRENCY_SOURCE = ${JSON.stringify(pool.minOwnerConcurrencySource)} @@ -4507,7 +4414,7 @@ def find_access_token(data): return None def login(): - admin_email = get_config_value("sub2api-config", "ADMIN_EMAIL") or "admin@sub2api.platform-infra.local" + admin_email = get_config_value("sub2api-config", "ADMIN_EMAIL") or POOL_ADMIN_EMAIL_DEFAULT admin_password = decode_secret_value(APP_SECRET_NAME, "ADMIN_PASSWORD") if not admin_password: raise RuntimeError("ADMIN_PASSWORD missing from sub2api-secrets") @@ -4523,7 +4430,7 @@ def login(): def group_payload(): return { "name": POOL_GROUP_NAME, - "description": "UniDesk-managed Codex API-key pool for G14 k3s internal Sub2API clients.", + "description": POOL_GROUP_DESCRIPTION, "platform": "openai", "rate_multiplier": 1, "is_exclusive": False,