746 lines
41 KiB
TypeScript
746 lines
41 KiB
TypeScript
// SPEC: PJ2026-01060307 控制面模块化 draft-2026-06-25-p0. config module for scripts/src/platform-infra-sub2api-codex.ts.
|
|
|
|
// Moved mechanically from scripts/src/platform-infra-sub2api-codex.ts:1317-2034 for #903.
|
|
|
|
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";
|
|
import { rootPath } from "../config";
|
|
import type { RenderedCliResult } from "../output";
|
|
import { applyPk01CaddyBlock, prepareFrpcSecret, renderFrpcManifest, type PublicServiceExposure, type PublicServiceTarget } from "../platform-infra-public-service";
|
|
import { shortSha256Fingerprint } from "../platform-infra-ops-library";
|
|
import {
|
|
codexPoolSentinelSummary,
|
|
codexPoolSentinelRuntimeImage,
|
|
readCodexPoolSentinelConfig,
|
|
renderCodexPoolSentinelManifest,
|
|
type CodexPoolSentinelConfig,
|
|
type CodexPoolSentinelProfileSecret,
|
|
} from "../platform-infra-sub2api-codex-sentinel";
|
|
import { parseEnvFile, readTextFile, redactRepoPath, requiredEnvValue } from "../secrets";
|
|
import { runSshCommandCapture, type SshCaptureResult } from "../ssh";
|
|
|
|
import type { CodexPoolCaddyEdgeRetryConfig, CodexPoolConfig, CodexPoolLocalCodexConfig, CodexPoolManualAccountGroupBinding, CodexPoolManualAccountProtection, CodexPoolManualAccountProxyBinding, CodexPoolManualAccountsConfig, CodexPoolManualBindingKind, CodexPoolManualBindingProvider, CodexPoolManualBindingSource, CodexPoolManualBindingSourcesConfig, CodexPoolProfileConfig, CodexPoolPublicExposureConfig, CodexProfile, CodexSentinelProtectPolicy, CodexTempUnschedulablePolicy, CodexTempUnschedulableRule, OpenAIResponsesWebSocketsV2Mode } from "./types";
|
|
import { booleanValue, integerArrayConfigField, integerConfigField, isRecord, normalizeBaseUrl, numberValue, readRequiredBaseUrl, readRequiredDescription, readRequiredEmail, readRequiredPort, readRequiredPositiveNumber, requiredRecordConfigField, requiredStringConfigField, stringValue, validateKubernetesName } from "./config-utils";
|
|
import { readAuthAPIKey, uniqueAccountName } from "./redaction";
|
|
import { codexPoolConfigPath } from "./types";
|
|
|
|
export function collectCodexProfiles(): CodexProfile[] {
|
|
const codexDir = join(homedir(), ".codex");
|
|
const pool = readCodexPoolConfig();
|
|
if (!existsSync(codexDir)) return [];
|
|
const seenAccountNames = new Set<string>();
|
|
return pool.profiles.map((entry) => {
|
|
const resolved = resolveProfileFiles(codexDir, entry);
|
|
const profile = entry.profile;
|
|
const accountName = entry.accountName ?? uniqueAccountName(profile, seenAccountNames);
|
|
seenAccountNames.add(accountName);
|
|
const configFile = resolved.configFile;
|
|
const authFile = resolved.authFile;
|
|
const configPath = join(codexDir, configFile);
|
|
const authPath = join(codexDir, authFile);
|
|
const base: CodexProfile = {
|
|
profile,
|
|
accountName,
|
|
configFile,
|
|
authFile,
|
|
provider: "",
|
|
baseUrl: "",
|
|
wireApi: null,
|
|
model: null,
|
|
envKey: null,
|
|
apiKey: null,
|
|
apiKeySource: null,
|
|
openaiResponsesWebSocketsV2Mode: entry.openaiResponsesWebSocketsV2Mode,
|
|
upstreamUserAgent: entry.upstreamUserAgent,
|
|
trustUpstream: entry.trustUpstream,
|
|
sentinelProtect: entry.sentinelProtect,
|
|
priority: entry.priority,
|
|
capacity: entry.capacity ?? pool.defaultAccountCapacity,
|
|
loadFactor: entry.loadFactor ?? pool.defaultAccountLoadFactor,
|
|
tempUnschedulable: entry.tempUnschedulable,
|
|
authOpenAIKeyShape: existsSync(authPath) ? "unknown" : "missing",
|
|
ok: false,
|
|
error: null,
|
|
};
|
|
|
|
try {
|
|
if (!existsSync(configPath)) throw new Error(`config file ${configFile} is missing`);
|
|
const parsed = Bun.TOML.parse(readFileSync(configPath, "utf8")) as unknown;
|
|
if (!isRecord(parsed)) throw new Error("config is not a TOML object");
|
|
const providers = parsed.model_providers;
|
|
if (!isRecord(providers)) throw new Error("model_providers is missing");
|
|
const provider = stringValue(parsed.model_provider) ?? Object.keys(providers)[0] ?? "";
|
|
if (provider === "") throw new Error("model_provider is missing");
|
|
const providerConfig = providers[provider];
|
|
if (!isRecord(providerConfig)) throw new Error(`model provider ${provider} is missing`);
|
|
const baseUrl = normalizeBaseUrl(stringValue(providerConfig.base_url));
|
|
if (baseUrl === null) throw new Error(`model provider ${provider} base_url is missing or invalid`);
|
|
base.provider = provider;
|
|
base.baseUrl = baseUrl;
|
|
base.envKey = stringValue(providerConfig.env_key);
|
|
base.wireApi = stringValue(providerConfig.wire_api);
|
|
base.model = stringValue(parsed.model);
|
|
|
|
const auth = readAuthAPIKey(authPath);
|
|
base.authOpenAIKeyShape = auth.shape;
|
|
if (auth.apiKey !== null) {
|
|
base.apiKey = auth.apiKey;
|
|
base.apiKeySource = "auth-json";
|
|
} else if (base.envKey !== null && typeof process.env[base.envKey] === "string" && process.env[base.envKey]!.length > 0) {
|
|
base.apiKey = process.env[base.envKey]!;
|
|
base.apiKeySource = "env";
|
|
}
|
|
if (base.apiKey === null || base.apiKey.length === 0) {
|
|
throw new Error(base.envKey === null ? "auth OPENAI_API_KEY is missing or empty" : `auth OPENAI_API_KEY is missing and env ${base.envKey} is not present`);
|
|
}
|
|
base.ok = true;
|
|
return base;
|
|
} catch (error) {
|
|
base.error = error instanceof Error ? error.message : String(error);
|
|
return base;
|
|
}
|
|
});
|
|
}
|
|
|
|
export function resolveProfileFiles(codexDir: string, profile: CodexPoolProfileConfig): { configFile: string; authFile: string } {
|
|
const configFile = existsSync(join(codexDir, profile.configFile)) || profile.fallbackConfigFile === null
|
|
? profile.configFile
|
|
: profile.fallbackConfigFile;
|
|
const authFile = existsSync(join(codexDir, profile.authFile)) || profile.fallbackAuthFile === null
|
|
? profile.authFile
|
|
: profile.fallbackAuthFile;
|
|
return { configFile, authFile };
|
|
}
|
|
|
|
export function readCodexPoolConfig(): CodexPoolConfig {
|
|
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`);
|
|
const version = integerConfigField(parsed, "version", "");
|
|
if (version !== 1) throw new Error(`${codexPoolConfigPath}.version must be 1`);
|
|
const kind = requiredStringConfigField(parsed, "kind", "");
|
|
if (kind !== "platform-infra-sub2api-codex-pool") throw new Error(`${codexPoolConfigPath}.kind must be platform-infra-sub2api-codex-pool`);
|
|
const metadata = requiredRecordConfigField(parsed, "metadata", "");
|
|
const pool = parsed.pool;
|
|
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");
|
|
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");
|
|
const profiles = readProfileConfig(
|
|
parsed.profiles,
|
|
defaultTempUnschedulable,
|
|
defaultSentinelProtect,
|
|
defaultAccountPriorityValue,
|
|
);
|
|
const manualAccounts = readManualAccountsConfig(parsed.manualAccounts);
|
|
assertProtectedManualAccountsNotManaged(profiles, manualAccounts);
|
|
const declaredAccountCapacity = desiredProfileCapacityTotal(profiles, defaultAccountCapacityValue);
|
|
const minOwnerConcurrencySource = pool.minOwnerConcurrency === undefined || pool.minOwnerConcurrency === null ? "auto" : "yaml";
|
|
const minOwnerConcurrency = minOwnerConcurrencySource === "auto"
|
|
? Math.max(1, declaredAccountCapacity)
|
|
: readOwnerConcurrency(pool.minOwnerConcurrency, "pool.minOwnerConcurrency");
|
|
const config: CodexPoolConfig = {
|
|
version,
|
|
kind,
|
|
metadata: {
|
|
id: requiredStringConfigField(metadata, "id", "metadata"),
|
|
owner: requiredStringConfigField(metadata, "owner", "metadata"),
|
|
relatedIssues: integerArrayConfigField(metadata, "relatedIssues", "metadata"),
|
|
},
|
|
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,
|
|
defaultAccountCapacity: defaultAccountCapacityValue,
|
|
defaultAccountLoadFactor: defaultAccountLoadFactorValue,
|
|
defaultTempUnschedulable,
|
|
defaultSentinelProtect,
|
|
profiles,
|
|
manualAccounts,
|
|
publicExposure: readPublicExposureConfig(parsed.publicExposure),
|
|
localCodex: readLocalCodexConfig(parsed.localCodex),
|
|
sentinel: {
|
|
...readCodexPoolSentinelConfig(parsed.sentinel, codexPoolConfigPath),
|
|
protectedManualAccounts: manualAccounts.protected.map((account) => account.accountName),
|
|
},
|
|
};
|
|
validateKubernetesName(config.groupName, "pool.groupName", false);
|
|
validateKubernetesName(config.apiKeySecretName, "pool.apiKeySecretName", true);
|
|
if (!/^[A-Za-z_][A-Za-z0-9_]*$/u.test(config.apiKeySecretKey)) {
|
|
throw new Error(`${codexPoolConfigPath}.pool.apiKeySecretKey must be a valid secret key name`);
|
|
}
|
|
if (config.minOwnerBalanceUsd <= 0) throw new Error(`${codexPoolConfigPath}.pool.minOwnerBalanceUsd must be > 0`);
|
|
if (declaredAccountCapacity > 0 && config.minOwnerConcurrency < declaredAccountCapacity) {
|
|
throw new Error(`${codexPoolConfigPath}.pool.minOwnerConcurrency must be >= the sum of declared account capacities (${declaredAccountCapacity})`);
|
|
}
|
|
return config;
|
|
}
|
|
|
|
export function readProfileConfig(
|
|
value: unknown,
|
|
defaultTempUnschedulable: CodexTempUnschedulablePolicy,
|
|
defaultSentinelProtect: CodexSentinelProtectPolicy,
|
|
defaultPriority: number,
|
|
): CodexPoolProfileConfig[] {
|
|
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) => {
|
|
if (!isRecord(entry)) throw new Error(`${codexPoolConfigPath}.profiles.entries[${index}] must be an object`);
|
|
rejectSchedulableYamlField(entry, `profiles.entries[${index}]`);
|
|
const profile = stringValue(entry.profile);
|
|
const configFile = stringValue(entry.configFile);
|
|
const authFile = stringValue(entry.authFile);
|
|
if (profile === null || configFile === null || authFile === null) {
|
|
throw new Error(`${codexPoolConfigPath}.profiles.entries[${index}] requires profile, configFile, and authFile`);
|
|
}
|
|
const accountName = stringValue(entry.accountName);
|
|
if (accountName !== null) validateKubernetesName(accountName, `profiles.entries[${index}].accountName`, false);
|
|
validateCodexFileName(configFile, `profiles.entries[${index}].configFile`);
|
|
validateCodexFileName(authFile, `profiles.entries[${index}].authFile`);
|
|
const fallbackConfigFile = stringValue(entry.fallbackConfigFile);
|
|
const fallbackAuthFile = stringValue(entry.fallbackAuthFile);
|
|
if (fallbackConfigFile !== null) validateCodexFileName(fallbackConfigFile, `profiles.entries[${index}].fallbackConfigFile`);
|
|
if (fallbackAuthFile !== null) validateCodexFileName(fallbackAuthFile, `profiles.entries[${index}].fallbackAuthFile`);
|
|
const openaiResponsesWebSocketsV2Mode = readOpenAIResponsesWebSocketsV2Mode(entry.openaiResponsesWebSocketsV2Mode, `profiles.entries[${index}].openaiResponsesWebSocketsV2Mode`);
|
|
const upstreamUserAgent = readUpstreamUserAgent(entry.upstreamUserAgent, `profiles.entries[${index}].upstreamUserAgent`);
|
|
const trustUpstream = readTrustUpstream(entry.trustUpstream, `profiles.entries[${index}].trustUpstream`);
|
|
const sentinelProtect = readSentinelProtectPolicy(entry.sentinelProtect, `profiles.entries[${index}].sentinelProtect`, defaultSentinelProtect);
|
|
const priority = readAccountPriority(entry.priority, `profiles.entries[${index}].priority`, defaultPriority);
|
|
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,
|
|
accountName,
|
|
configFile,
|
|
authFile,
|
|
fallbackConfigFile,
|
|
fallbackAuthFile,
|
|
openaiResponsesWebSocketsV2Mode,
|
|
upstreamUserAgent,
|
|
trustUpstream,
|
|
sentinelProtect,
|
|
priority,
|
|
capacity,
|
|
loadFactor,
|
|
tempUnschedulable,
|
|
};
|
|
});
|
|
}
|
|
|
|
export function desiredProfileCapacityTotal(profiles: CodexPoolProfileConfig[], defaultCapacity: number): number {
|
|
return profiles.reduce((total, profile) => total + (profile.capacity ?? defaultCapacity), 0);
|
|
}
|
|
|
|
export function readManualAccountsConfig(value: unknown): CodexPoolManualAccountsConfig {
|
|
if (!isRecord(value)) throw new Error(`${codexPoolConfigPath}.manualAccounts must be a YAML object`);
|
|
const bindingSources = readManualBindingSources(value.bindingSources, "manualAccounts.bindingSources");
|
|
const protectedRaw = value.protected;
|
|
if (!Array.isArray(protectedRaw)) throw new Error(`${codexPoolConfigPath}.manualAccounts.protected must be a YAML array`);
|
|
const seen = new Set<string>();
|
|
const protectedAccounts = protectedRaw.map((entry, index): CodexPoolManualAccountProtection => {
|
|
const key = `manualAccounts.protected[${index}]`;
|
|
const accountName = typeof entry === "string"
|
|
? readManualAccountName(entry, key)
|
|
: isRecord(entry)
|
|
? readManualAccountName(entry.accountName, `${key}.accountName`)
|
|
: null;
|
|
if (accountName === null) throw new Error(`${codexPoolConfigPath}.${key} must be an account name string or object with accountName`);
|
|
const normalized = accountName.toLowerCase();
|
|
if (seen.has(normalized)) throw new Error(`${codexPoolConfigPath}.${key}.accountName is duplicated in manualAccounts.protected`);
|
|
seen.add(normalized);
|
|
const reason = isRecord(entry) ? readManualAccountReason(entry.reason, `${key}.reason`) : null;
|
|
const proxyBinding = isRecord(entry) ? readManualAccountProxyBinding(entry.proxyBinding, `${key}.proxyBinding`, bindingSources) : null;
|
|
const groupBinding = isRecord(entry) ? readManualAccountGroupBinding(entry.groupBinding, `${key}.groupBinding`, bindingSources) : null;
|
|
return { accountName, reason, proxyBinding, groupBinding };
|
|
});
|
|
return { bindingSources, protected: protectedAccounts };
|
|
}
|
|
|
|
export function readManualBindingSources(value: unknown, key: string): CodexPoolManualBindingSourcesConfig {
|
|
if (!isRecord(value)) throw new Error(`${codexPoolConfigPath}.${key} must be a YAML object`);
|
|
const items: CodexPoolManualBindingSource[] = [];
|
|
const byId: Record<string, CodexPoolManualBindingSource> = {};
|
|
for (const [id, rawSource] of Object.entries(value)) {
|
|
const path = `${key}.${id}`;
|
|
validateManualBindingSourceId(id, path);
|
|
if (!isRecord(rawSource)) throw new Error(`${codexPoolConfigPath}.${path} must be a YAML object`);
|
|
const source: CodexPoolManualBindingSource = {
|
|
id,
|
|
enabled: readBooleanConfig(rawSource.enabled, `${path}.enabled`),
|
|
kind: readManualBindingKind(rawSource.kind, `${path}.kind`),
|
|
provider: readManualBindingProvider(rawSource.provider, `${path}.provider`),
|
|
description: readManualAccountReason(rawSource.description, `${path}.description`),
|
|
};
|
|
if (source.kind === "proxy" && source.provider !== "target-egress-proxy") {
|
|
throw new Error(`${codexPoolConfigPath}.${path}.provider must be target-egress-proxy for kind=proxy`);
|
|
}
|
|
if (source.kind === "group" && source.provider !== "pool-group") {
|
|
throw new Error(`${codexPoolConfigPath}.${path}.provider must be pool-group for kind=group`);
|
|
}
|
|
byId[id] = source;
|
|
items.push(source);
|
|
}
|
|
if (items.length === 0) throw new Error(`${codexPoolConfigPath}.${key} must declare at least one source`);
|
|
return { items, byId };
|
|
}
|
|
|
|
export function readManualAccountProxyBinding(value: unknown, key: string, bindingSources: CodexPoolManualBindingSourcesConfig): CodexPoolManualAccountProxyBinding | null {
|
|
if (value === undefined || value === null) return null;
|
|
if (!isRecord(value)) throw new Error(`${codexPoolConfigPath}.${key} must be a YAML object`);
|
|
const enabled = readBooleanConfig(value.enabled, `${key}.enabled`);
|
|
const source = readManualBindingSourceRef(value.source, `${key}.source`, bindingSources, "proxy", enabled);
|
|
const proxyName = stringValue(value.proxyName);
|
|
if (proxyName === null || proxyName.trim().length === 0) throw new Error(`${codexPoolConfigPath}.${key}.proxyName is required`);
|
|
validateProxyName(proxyName, `${key}.proxyName`);
|
|
return {
|
|
enabled,
|
|
source,
|
|
proxyName,
|
|
};
|
|
}
|
|
|
|
export function readManualAccountGroupBinding(value: unknown, key: string, bindingSources: CodexPoolManualBindingSourcesConfig): CodexPoolManualAccountGroupBinding | null {
|
|
if (value === undefined || value === null) return null;
|
|
if (!isRecord(value)) throw new Error(`${codexPoolConfigPath}.${key} must be a YAML object`);
|
|
const enabled = readBooleanConfig(value.enabled, `${key}.enabled`);
|
|
const source = readManualBindingSourceRef(value.source, `${key}.source`, bindingSources, "group", enabled);
|
|
return {
|
|
enabled,
|
|
source,
|
|
};
|
|
}
|
|
|
|
export function readManualBindingSourceRef(value: unknown, key: string, bindingSources: CodexPoolManualBindingSourcesConfig, expectedKind: CodexPoolManualBindingKind, bindingEnabled: boolean): string {
|
|
const sourceId = stringValue(value);
|
|
if (sourceId === null) throw new Error(`${codexPoolConfigPath}.${key} is required`);
|
|
validateManualBindingSourceId(sourceId, key);
|
|
const source = bindingSources.byId[sourceId];
|
|
if (source === undefined) throw new Error(`${codexPoolConfigPath}.${key} references unknown manualAccounts.bindingSources id ${sourceId}`);
|
|
if (source.kind !== expectedKind) throw new Error(`${codexPoolConfigPath}.${key} references ${sourceId} with kind=${source.kind}; expected ${expectedKind}`);
|
|
if (bindingEnabled && !source.enabled) throw new Error(`${codexPoolConfigPath}.${key} references disabled manualAccounts.bindingSources.${sourceId}`);
|
|
return sourceId;
|
|
}
|
|
|
|
export function readManualBindingKind(value: unknown, key: string): CodexPoolManualBindingKind {
|
|
const text = stringValue(value);
|
|
if (text !== "proxy" && text !== "group") throw new Error(`${codexPoolConfigPath}.${key} must be proxy or group`);
|
|
return text;
|
|
}
|
|
|
|
export function readManualBindingProvider(value: unknown, key: string): CodexPoolManualBindingProvider {
|
|
const text = stringValue(value);
|
|
if (text !== "target-egress-proxy" && text !== "pool-group") throw new Error(`${codexPoolConfigPath}.${key} must be target-egress-proxy or pool-group`);
|
|
return text;
|
|
}
|
|
|
|
export function validateManualBindingSourceId(value: string, key: string): void {
|
|
if (!/^[A-Za-z0-9]([A-Za-z0-9._-]*[A-Za-z0-9])?$/u.test(value)) throw new Error(`${codexPoolConfigPath}.${key} has an unsupported source id format`);
|
|
}
|
|
|
|
export function readManualAccountName(value: unknown, key: string): string | null {
|
|
const text = stringValue(value)?.trim() ?? null;
|
|
if (text === null || text.length === 0) return null;
|
|
if (text.length > 256) throw new Error(`${codexPoolConfigPath}.${key} must be at most 256 characters`);
|
|
if (/[\r\n]/u.test(text)) throw new Error(`${codexPoolConfigPath}.${key} must not contain newlines`);
|
|
if (!/^[^<>"'`\\]+$/u.test(text)) throw new Error(`${codexPoolConfigPath}.${key} contains unsupported characters`);
|
|
return text;
|
|
}
|
|
|
|
export function readManualAccountReason(value: unknown, key: string): string | null {
|
|
if (value === undefined || value === null) return null;
|
|
const text = stringValue(value)?.trim() ?? null;
|
|
if (text === null || text.length === 0) return null;
|
|
if (text.length > 240) throw new Error(`${codexPoolConfigPath}.${key} must be at most 240 characters`);
|
|
if (/[\r\n]/u.test(text)) throw new Error(`${codexPoolConfigPath}.${key} must not contain newlines`);
|
|
return text;
|
|
}
|
|
|
|
export function assertProtectedManualAccountsNotManaged(profiles: CodexPoolProfileConfig[], manualAccounts: CodexPoolManualAccountsConfig): void {
|
|
const protectedNames = new Set(manualAccounts.protected.map((account) => account.accountName.toLowerCase()));
|
|
for (const [index, profile] of profiles.entries()) {
|
|
const accountName = profile.accountName;
|
|
if (accountName !== null && protectedNames.has(accountName.toLowerCase())) {
|
|
throw new Error(`${codexPoolConfigPath}.profiles.entries[${index}].accountName is listed in manualAccounts.protected; protected manual accounts must not be YAML-managed`);
|
|
}
|
|
}
|
|
}
|
|
|
|
export function rejectSchedulableYamlField(value: Record<string, unknown>, key: string): void {
|
|
if (Object.prototype.hasOwnProperty.call(value, "schedulable")) {
|
|
throw new Error(`${codexPoolConfigPath}.${key}.schedulable is process control, not durable YAML config; use codex-pool sync actions instead`);
|
|
}
|
|
}
|
|
|
|
export function readOpenAIResponsesWebSocketsV2Mode(value: unknown, key: string): OpenAIResponsesWebSocketsV2Mode | null {
|
|
if (value === undefined || value === null) return null;
|
|
const text = stringValue(value);
|
|
if (text === null) throw new Error(`${codexPoolConfigPath}.${key} must be a string`);
|
|
if (text === "off" || text === "ctx_pool" || text === "passthrough") return text;
|
|
throw new Error(`${codexPoolConfigPath}.${key} must be one of off, ctx_pool, passthrough`);
|
|
}
|
|
|
|
export function readUpstreamUserAgent(value: unknown, key: string): string | null {
|
|
if (value === undefined || value === null) return null;
|
|
const text = stringValue(value);
|
|
if (text === null) throw new Error(`${codexPoolConfigPath}.${key} must be a string`);
|
|
if (text.length > 512) throw new Error(`${codexPoolConfigPath}.${key} must be at most 512 characters`);
|
|
if (/[\r\n]/u.test(text)) throw new Error(`${codexPoolConfigPath}.${key} must not contain newlines`);
|
|
return text;
|
|
}
|
|
|
|
export function readTrustUpstream(value: unknown, key: string): boolean {
|
|
if (value === undefined || value === null) return false;
|
|
const parsed = booleanValue(value);
|
|
if (parsed === null) throw new Error(`${codexPoolConfigPath}.${key} must be a boolean`);
|
|
return parsed;
|
|
}
|
|
|
|
export 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);
|
|
const required = ["consecutiveFailures", "initialRetryDelaySeconds", "maxRetryDelaySeconds", "backoffMultiplier"];
|
|
for (const field of required) {
|
|
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 = 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,
|
|
consecutiveFailures,
|
|
initialRetryDelaySeconds,
|
|
maxRetryDelaySeconds,
|
|
backoffMultiplier,
|
|
};
|
|
}
|
|
|
|
export function readBoundedInteger(value: unknown, key: string, min: number, max: number): number {
|
|
const parsed = numberValue(value);
|
|
if (parsed === null || !Number.isInteger(parsed) || parsed < min || parsed > max) {
|
|
throw new Error(`${codexPoolConfigPath}.${key} must be an integer from ${min} to ${max}`);
|
|
}
|
|
return parsed;
|
|
}
|
|
|
|
export 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`);
|
|
}
|
|
return priority;
|
|
}
|
|
|
|
export function readAccountCapacity(value: unknown, key: string): number {
|
|
const capacity = numberValue(value);
|
|
if (capacity === null || !Number.isInteger(capacity) || capacity < 1 || capacity > 1000) {
|
|
throw new Error(`${codexPoolConfigPath}.${key} must be an integer from 1 to 1000`);
|
|
}
|
|
return capacity;
|
|
}
|
|
|
|
export function readOwnerConcurrency(value: unknown, key: string): number {
|
|
const concurrency = numberValue(value);
|
|
if (concurrency === null || !Number.isInteger(concurrency) || concurrency < 1 || concurrency > 100000) {
|
|
throw new Error(`${codexPoolConfigPath}.${key} must be an integer from 1 to 100000`);
|
|
}
|
|
return concurrency;
|
|
}
|
|
|
|
export 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;
|
|
}
|
|
|
|
export 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`);
|
|
}
|
|
return seconds;
|
|
}
|
|
|
|
export 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`);
|
|
|
|
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`);
|
|
const retryMatch = {
|
|
methods: readCaddyRetryMethods(retryMatchValue.methods, `${key}.retryMatch.methods`),
|
|
paths: readCaddyRetryPaths(retryMatchValue.paths, `${key}.retryMatch.paths`),
|
|
};
|
|
if (retryMatch.methods.length === 0 && retryMatch.paths.length === 0) {
|
|
throw new Error(`${codexPoolConfigPath}.${key}.retryMatch must include at least one method or path matcher when enabled=true`);
|
|
}
|
|
return { enabled, tryDurationSeconds, tryIntervalMilliseconds, retryMatch };
|
|
}
|
|
|
|
export function readPositiveInteger(value: unknown, key: string): number {
|
|
const parsed = numberValue(value);
|
|
if (parsed === null || !Number.isInteger(parsed) || parsed < 1) {
|
|
throw new Error(`${codexPoolConfigPath}.${key} must be a positive integer`);
|
|
}
|
|
return parsed;
|
|
}
|
|
|
|
export function readPositiveIntegerConfig(value: unknown, key: string): number {
|
|
if (value === undefined || value === null) throw new Error(`${codexPoolConfigPath}.${key} is required`);
|
|
return readPositiveInteger(value, key);
|
|
}
|
|
|
|
export function readCaddyRetryMethods(value: unknown, key: string): string[] {
|
|
if (value === undefined || value === null) return [];
|
|
if (!Array.isArray(value)) throw new Error(`${codexPoolConfigPath}.${key} must be a YAML array`);
|
|
const seen = new Set<string>();
|
|
const methods: string[] = [];
|
|
for (const item of value) {
|
|
const method = stringValue(item)?.toUpperCase() ?? null;
|
|
if (method === null || !/^[A-Z][A-Z0-9_-]*$/u.test(method)) throw new Error(`${codexPoolConfigPath}.${key} entries must be HTTP method tokens`);
|
|
if (seen.has(method)) continue;
|
|
seen.add(method);
|
|
methods.push(method);
|
|
}
|
|
return methods;
|
|
}
|
|
|
|
export function readCaddyRetryPaths(value: unknown, key: string): string[] {
|
|
if (value === undefined || value === null) return [];
|
|
if (!Array.isArray(value)) throw new Error(`${codexPoolConfigPath}.${key} must be a YAML array`);
|
|
const seen = new Set<string>();
|
|
const paths: string[] = [];
|
|
for (const item of value) {
|
|
const path = stringValue(item);
|
|
if (path === null || !path.startsWith("/") || /[\r\n]/u.test(path)) {
|
|
throw new Error(`${codexPoolConfigPath}.${key} entries must be Caddy path matchers starting with / and without newlines`);
|
|
}
|
|
if (seen.has(path)) continue;
|
|
seen.add(path);
|
|
paths.push(path);
|
|
}
|
|
return paths;
|
|
}
|
|
|
|
export 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);
|
|
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 };
|
|
}
|
|
|
|
export 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 };
|
|
});
|
|
}
|
|
|
|
export 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;
|
|
}
|
|
|
|
export 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,
|
|
})),
|
|
};
|
|
}
|
|
|
|
export 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;
|
|
}
|
|
|
|
export 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: 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: requiredStringConfigField(masterFrpsValue, "configPath", "publicExposure.masterFrps"),
|
|
containerName: requiredStringConfigField(masterFrpsValue, "containerName", "publicExposure.masterFrps"),
|
|
},
|
|
masterCaddy: {
|
|
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",
|
|
),
|
|
edgeRetry: readCaddyEdgeRetryConfig(
|
|
masterCaddyValue.edgeRetry,
|
|
"publicExposure.masterCaddy.edgeRetry",
|
|
),
|
|
},
|
|
};
|
|
validateKubernetesName(config.configMapName, "publicExposure.configMapName", true);
|
|
validateKubernetesName(config.deploymentName, "publicExposure.deploymentName", true);
|
|
validateProxyName(config.proxyName, "publicExposure.proxyName");
|
|
validatePort(config.serverPort, "publicExposure.serverPort");
|
|
validatePort(config.remotePort, "publicExposure.remotePort");
|
|
validatePort(config.localPort, "publicExposure.localPort");
|
|
if (!/^[A-Za-z0-9._:/-]+$/u.test(config.frpcImage)) throw new Error(`${codexPoolConfigPath}.publicExposure.frpcImage has an unsupported format`);
|
|
if (!/^[A-Za-z0-9._:-]+$/u.test(config.serverAddr)) throw new Error(`${codexPoolConfigPath}.publicExposure.serverAddr has an unsupported format`);
|
|
if (!/^[A-Za-z0-9._:-]+$/u.test(config.localIP)) throw new Error(`${codexPoolConfigPath}.publicExposure.localIP has an unsupported format`);
|
|
if (!config.masterFrps.configPath.startsWith("/")) throw new Error(`${codexPoolConfigPath}.publicExposure.masterFrps.configPath must be absolute`);
|
|
validateProxyName(config.masterFrps.containerName, "publicExposure.masterFrps.containerName");
|
|
validatePublicHostname(config.masterCaddy.domain, "publicExposure.masterCaddy.domain");
|
|
if (!config.masterCaddy.configPath.startsWith("/")) throw new Error(`${codexPoolConfigPath}.publicExposure.masterCaddy.configPath must be absolute`);
|
|
validateProxyName(config.masterCaddy.serviceName, "publicExposure.masterCaddy.serviceName");
|
|
const upstream = new URL(config.masterCaddy.upstreamBaseUrl);
|
|
if (upstream.protocol !== "http:") throw new Error(`${codexPoolConfigPath}.publicExposure.masterCaddy.upstreamBaseUrl must use http:// for the local upstream`);
|
|
return config;
|
|
}
|
|
|
|
export function readLocalCodexConfig(value: unknown): CodexPoolLocalCodexConfig {
|
|
if (!isRecord(value)) throw new Error(`${codexPoolConfigPath}.localCodex must be a YAML object`);
|
|
const config = {
|
|
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");
|
|
validateProxyName(config.wireApi, "localCodex.wireApi");
|
|
validateModelName(config.responsesSmokeModel, "localCodex.responsesSmokeModel");
|
|
return config;
|
|
}
|
|
|
|
export function validateCodexFileName(value: string, key: string): void {
|
|
if (!/^(config\.toml|config\.toml\.[A-Za-z0-9._-]+|auth\.json|auth\.json\.[A-Za-z0-9._-]+)$/u.test(value)) {
|
|
throw new Error(`${codexPoolConfigPath}.${key} has an unsupported file name`);
|
|
}
|
|
}
|
|
|
|
export function validateProxyName(value: string, key: string): void {
|
|
if (!/^[A-Za-z0-9._-]+$/u.test(value)) throw new Error(`${codexPoolConfigPath}.${key} has an unsupported format`);
|
|
}
|
|
|
|
export function validateModelName(value: string, key: string): void {
|
|
if (!/^[A-Za-z0-9._:-]+$/u.test(value)) throw new Error(`${codexPoolConfigPath}.${key} has an unsupported model name`);
|
|
}
|
|
|
|
export function validatePublicHostname(value: string, key: string): void {
|
|
if (value.length > 253 || !/^[A-Za-z0-9.-]+$/u.test(value) || value.startsWith(".") || value.endsWith(".")) {
|
|
throw new Error(`${codexPoolConfigPath}.${key} has an unsupported hostname format`);
|
|
}
|
|
}
|
|
|
|
export function validatePort(value: number, key: string): void {
|
|
if (!Number.isInteger(value) || value < 1 || value > 65535) throw new Error(`${codexPoolConfigPath}.${key} must be an integer port`);
|
|
}
|