import { existsSync } from "node:fs"; import type { AdapterConfig, FallbackConsumptionConfig, HeaderFieldMapping, LoadResult, QuotaStatusConfig, } from "./types.js"; import { readJsonFile, writeJsonFileAtomic } from "./storage.js"; export const DEFAULT_REFRESH_INTERVAL_MS = 60_000; export const DEFAULT_WARNING_THRESHOLD = 25; export const DEFAULT_CRITICAL_THRESHOLD = 10; export const DEFAULT_CONFIG: Required< Pick< QuotaStatusConfig, "version" | "refreshIntervalMs" | "warningThreshold" | "criticalThreshold" > > & { adapters: AdapterConfig[]; } = { version: 1, refreshIntervalMs: DEFAULT_REFRESH_INTERVAL_MS, warningThreshold: DEFAULT_WARNING_THRESHOLD, criticalThreshold: DEFAULT_CRITICAL_THRESHOLD, adapters: [ { name: "anthropic", type: "anthropic", provider: "anthropic", models: ["claude-*"], }, { name: "openai", type: "generic", provider: "openai*", models: ["gpt-*", "o*", "chatgpt-*"], dimensions: [ { dimension: "requests", limit: "x-ratelimit-limit-requests", remaining: "x-ratelimit-remaining-requests", reset: "x-ratelimit-reset-requests", retryAfter: "retry-after", }, { dimension: "tokens", limit: "x-ratelimit-limit-tokens", remaining: "x-ratelimit-remaining-tokens", reset: "x-ratelimit-reset-tokens", retryAfter: "retry-after", }, ], }, ], }; export const CONFIG_TEMPLATE: QuotaStatusConfig = { version: 1, refreshIntervalMs: DEFAULT_REFRESH_INTERVAL_MS, warningThreshold: DEFAULT_WARNING_THRESHOLD, criticalThreshold: DEFAULT_CRITICAL_THRESHOLD, adapters: [ { name: "anthropic", type: "anthropic", enabled: true, provider: "anthropic", models: ["claude-*"], fallback: { enabled: false, dimension: "messages", limit: 45, windowSeconds: 5 * 60 * 60, consume: { unit: "turns", amount: 1 }, }, }, { name: "openai", type: "generic", enabled: true, provider: "openai*", models: ["gpt-*", "o*", "chatgpt-*"], dimensions: [ { dimension: "requests", limit: "x-ratelimit-limit-requests", remaining: "x-ratelimit-remaining-requests", reset: "x-ratelimit-reset-requests", retryAfter: "retry-after", }, { dimension: "tokens", limit: "x-ratelimit-limit-tokens", remaining: "x-ratelimit-remaining-tokens", reset: "x-ratelimit-reset-tokens", retryAfter: "retry-after", }, ], }, ], }; export function normalizeConfig(config: unknown): QuotaStatusConfig { return normalizeConfigResult(config).value; } interface NormalizedConfigResult { value: QuotaStatusConfig; error?: string; } interface NormalizedAdapterResult { adapter?: AdapterConfig; error?: string; } function normalizeConfigResult(config: unknown): NormalizedConfigResult { if (!isRecord(config)) { return { value: { ...DEFAULT_CONFIG, adapters: [...DEFAULT_CONFIG.adapters] }, error: "Invalid config: expected a JSON object.", }; } if (config.adapters !== undefined && !Array.isArray(config.adapters)) { return { value: normalizeConfigWithoutAdapters(config, DEFAULT_CONFIG.adapters), error: "Invalid config: adapters must be an array.", }; } const adapters = Array.isArray(config.adapters) && config.adapters.length > 0 ? config.adapters : DEFAULT_CONFIG.adapters; const normalizedAdapters: AdapterConfig[] = []; const errors: string[] = []; for (const [index, adapter] of adapters.entries()) { const result = normalizeAdapter(adapter); if (result.error) { errors.push(`adapters[${index}]${result.error}`); continue; } const normalizedAdapter = result.adapter; if (normalizedAdapter && normalizedAdapter.enabled !== false) normalizedAdapters.push(normalizedAdapter); } return { value: normalizeConfigWithoutAdapters(config, normalizedAdapters), error: errors.length > 0 ? `Invalid config: ${errors.join("; ")}; invalid adapter entries were ignored.` : undefined, }; } function normalizeConfigWithoutAdapters( config: Record, adapters: AdapterConfig[], ): QuotaStatusConfig { return { version: 1, refreshIntervalMs: positiveNumber( config.refreshIntervalMs, DEFAULT_REFRESH_INTERVAL_MS, ), warningThreshold: positiveNumber( config.warningThreshold, DEFAULT_WARNING_THRESHOLD, ), criticalThreshold: positiveNumber( config.criticalThreshold, DEFAULT_CRITICAL_THRESHOLD, ), adapters, }; } function normalizeAdapter(value: unknown): NormalizedAdapterResult { if (!isRecord(value)) return { error: " must be an object" }; const type = adapterType(value.type); if (!type) { return { error: '.type must be "generic" or "anthropic" when provided', }; } const name = adapterName(value.name, type); if (!name) { return { error: ".name must be a non-empty string when provided" }; } const provider = adapterProvider(value.provider); if (!provider) { return { error: ".provider must be a non-empty string when provided" }; } const models = adapterModels(value.models); if (!models) { return { error: ".models must be an array of non-empty strings when provided", }; } return { adapter: { name, type, enabled: typeof value.enabled === "boolean" ? value.enabled : undefined, provider, models: models.length > 0 ? models : ["*"], ...(normalizeHeaderMappings(value.headers, false)), ...(normalizeHeaderMappings(value.dimensions, true)), ...(normalizeFallback(value.fallback)), }, }; } function adapterType(value: unknown): AdapterConfig["type"] | undefined { if (value === undefined) return "generic"; return value === "generic" || value === "anthropic" ? value : undefined; } function adapterName(value: unknown, fallback: string): string | undefined { return value === undefined ? fallback : nonEmptyString(value); } function adapterProvider(value: unknown): string | undefined { if (value === undefined) return "*"; return nonEmptyString(value); } function adapterModels(value: unknown): string[] | undefined { if (value === undefined) return ["*"]; if (!Array.isArray(value) || !value.every(nonEmptyString)) return undefined; return value; } function normalizeHeaderMappings( value: unknown, isDimensions: boolean, ): Pick { const candidates = isDimensions ? Array.isArray(value) ? value : [] : asArray(value); const mappings = candidates.map(normalizeHeaderMapping) .filter((mapping): mapping is HeaderFieldMapping => Boolean(mapping)); if (mappings.length === 0) return {}; return isDimensions ? { dimensions: mappings } : { headers: mappings }; } function normalizeHeaderMapping( value: unknown, ): HeaderFieldMapping | undefined { if (!isRecord(value)) return undefined; const dimension = nonEmptyString(value.dimension); const limit = headerNames(value.limit); const remaining = headerNames(value.remaining); const reset = headerNames(value.reset); const retryAfter = headerNames(value.retryAfter); const hyphenatedRetryAfter = headerNames(value["retry-after"]); const mapping = { ...(dimension ? { dimension } : {}), ...(limit ? { limit } : {}), ...(remaining ? { remaining } : {}), ...(reset ? { reset } : {}), ...(retryAfter ? { retryAfter } : {}), ...(hyphenatedRetryAfter ? { "retry-after": hyphenatedRetryAfter } : {}), }; return Object.keys(mapping).length > 0 ? mapping : undefined; } function normalizeFallback( value: unknown, ): Pick { if (!isRecord(value)) return {}; const limit = finiteNumber(value.limit); const windowSeconds = finiteNumber(value.windowSeconds); if (limit === undefined || windowSeconds === undefined) return {}; const consume = isRecord(value.consume) ? normalizeFallbackConsumption(value.consume) : undefined; const dimension = nonEmptyString(value.dimension); const resetAt = typeof value.resetAt === "string" ? value.resetAt : finiteNumber(value.resetAt); return { fallback: { enabled: typeof value.enabled === "boolean" ? value.enabled : undefined, ...(dimension ? { dimension } : {}), limit, windowSeconds, ...(resetAt !== undefined ? { resetAt } : {}), ...(consume ? { consume } : {}), }, }; } function normalizeFallbackConsumption( value: Record, ): FallbackConsumptionConfig | undefined { const unit = consumptionUnit(value.unit); const amount = finiteNumber(value.amount); if (unit === undefined && amount === undefined) return undefined; return { ...(unit !== undefined ? { unit } : {}), ...(amount !== undefined ? { amount } : {}), }; } function consumptionUnit( value: unknown, ): FallbackConsumptionConfig["unit"] | undefined { return value === "turns" || value === "tokens" || value === "costUnits" ? value : undefined; } function isRecord(value: unknown): value is Record { return Boolean(value && typeof value === "object" && !Array.isArray(value)); } function nonEmptyString(value: unknown): string | undefined { return typeof value === "string" && value.length > 0 ? value : undefined; } function stringArray(value: unknown): string[] { return Array.isArray(value) ? value.filter((item): item is string => nonEmptyString(item) !== undefined) : []; } function asArray(value: unknown): unknown[] { return Array.isArray(value) ? value : value === undefined ? [] : [value]; } function headerNames(value: unknown): string | string[] | undefined { const name = nonEmptyString(value); if (name) return name; const names = stringArray(value); return names.length > 0 ? names : undefined; } function finiteNumber(value: unknown): number | undefined { return typeof value === "number" && Number.isFinite(value) ? value : undefined; } function positiveNumber(value: unknown, fallback: number): number { return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : fallback; } export async function loadConfig( configFile: string, ): Promise> { const result = await readJsonFile(configFile, DEFAULT_CONFIG); const normalized = normalizeConfigResult(result.value); return { path: configFile, value: normalized.value, error: result.error ?? normalized.error, }; } export async function ensureConfigTemplate( configFile: string, ): Promise> { if (existsSync(configFile)) { const existing = await readJsonFile(configFile, undefined); const normalized = normalizeConfigResult(existing.value); return { path: configFile, value: normalized.value, error: existing.error ?? normalized.error, created: false, }; } await writeJsonFileAtomic(configFile, CONFIG_TEMPLATE, { exclusive: true }); return { path: configFile, value: normalizeConfig(CONFIG_TEMPLATE), created: true, }; }