refactor: require yaml codex pool config
This commit is contained in:
@@ -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<string, unknown>, 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<string, unknown> {
|
||||
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;
|
||||
|
||||
Reference in New Issue
Block a user