codex-proxy / src /config.ts
icebear
feat: add sticky rotation strategy (#107) (#110)
d0eeb87 unverified
raw
history blame
6.61 kB
import { readFileSync } from "fs";
import { resolve } from "path";
import yaml from "js-yaml";
import { z } from "zod";
import { loadStaticModels } from "./models/model-store.js";
import { triggerImmediateRefresh } from "./models/model-fetcher.js";
import { getConfigDir } from "./paths.js";
export const ROTATION_STRATEGIES = ["least_used", "round_robin", "sticky"] as const;
const ConfigSchema = z.object({
api: z.object({
base_url: z.string().default("https://chatgpt.com/backend-api"),
timeout_seconds: z.number().min(1).default(60),
}),
client: z.object({
originator: z.string().default("Codex Desktop"),
app_version: z.string().default("260202.0859"),
build_number: z.string().default("517"),
platform: z.string().default("darwin"),
arch: z.string().default("arm64"),
chromium_version: z.string().default("136"),
}),
model: z.object({
default: z.string().default("gpt-5.2-codex"),
default_reasoning_effort: z.string().default("medium"),
default_service_tier: z.string().nullable().default(null),
inject_desktop_context: z.boolean().default(false),
suppress_desktop_directives: z.boolean().default(true),
}),
auth: z.object({
jwt_token: z.string().nullable().default(null),
chatgpt_oauth: z.boolean().default(true),
refresh_margin_seconds: z.number().min(0).default(300),
rotation_strategy: z.enum(ROTATION_STRATEGIES).default("least_used"),
rate_limit_backoff_seconds: z.number().min(1).default(60),
oauth_client_id: z.string().default("app_EMoamEEZ73f0CkXaXp7hrann"),
oauth_auth_endpoint: z.string().default("https://auth.openai.com/oauth/authorize"),
oauth_token_endpoint: z.string().default("https://auth.openai.com/oauth/token"),
}),
server: z.object({
host: z.string().default("0.0.0.0"),
port: z.number().min(1).max(65535).default(8080),
proxy_api_key: z.string().nullable().default(null),
}),
session: z.object({
ttl_minutes: z.number().min(1).default(60),
cleanup_interval_minutes: z.number().min(1).default(5),
}),
tls: z.object({
curl_binary: z.string().default("auto"),
impersonate_profile: z.string().default("chrome136"),
proxy_url: z.string().nullable().default(null),
transport: z.enum(["auto", "curl-cli", "libcurl-ffi"]).default("auto"),
force_http11: z.boolean().default(false),
}).default({}),
quota: z.object({
refresh_interval_minutes: z.number().min(1).default(5),
warning_thresholds: z.object({
primary: z.array(z.number().min(1).max(100)).default([80, 90]),
secondary: z.array(z.number().min(1).max(100)).default([80, 90]),
}).default({}),
skip_exhausted: z.boolean().default(true),
}).default({}),
});
export type AppConfig = z.infer<typeof ConfigSchema>;
const FingerprintSchema = z.object({
user_agent_template: z.string(),
auth_domains: z.array(z.string()),
auth_domain_exclusions: z.array(z.string()),
header_order: z.array(z.string()),
default_headers: z.record(z.string()).optional().default({}),
});
export type FingerprintConfig = z.infer<typeof FingerprintSchema>;
function loadYaml(filePath: string): unknown {
const content = readFileSync(filePath, "utf-8");
return yaml.load(content);
}
function applyEnvOverrides(raw: Record<string, unknown>): Record<string, unknown> {
const jwtEnv = process.env.CODEX_JWT_TOKEN?.trim();
if (jwtEnv && jwtEnv.startsWith("eyJ")) {
(raw.auth as Record<string, unknown>).jwt_token = jwtEnv;
} else if (jwtEnv) {
console.warn("[Config] CODEX_JWT_TOKEN ignored: not a valid JWT (must start with 'eyJ')");
}
if (process.env.CODEX_PLATFORM) {
(raw.client as Record<string, unknown>).platform = process.env.CODEX_PLATFORM;
}
if (process.env.CODEX_ARCH) {
(raw.client as Record<string, unknown>).arch = process.env.CODEX_ARCH;
}
if (process.env.PORT) {
const parsed = parseInt(process.env.PORT, 10);
if (!isNaN(parsed)) {
(raw.server as Record<string, unknown>).port = parsed;
}
}
const proxyEnv = process.env.HTTPS_PROXY || process.env.https_proxy;
if (proxyEnv) {
if (!raw.tls) raw.tls = {};
(raw.tls as Record<string, unknown>).proxy_url = proxyEnv;
}
return raw;
}
let _config: AppConfig | null = null;
let _fingerprint: FingerprintConfig | null = null;
export function loadConfig(configDir?: string): AppConfig {
if (_config) return _config;
const dir = configDir ?? getConfigDir();
const raw = loadYaml(resolve(dir, "default.yaml")) as Record<string, unknown>;
applyEnvOverrides(raw);
_config = ConfigSchema.parse(raw);
return _config;
}
export function loadFingerprint(configDir?: string): FingerprintConfig {
if (_fingerprint) return _fingerprint;
const dir = configDir ?? getConfigDir();
const raw = loadYaml(resolve(dir, "fingerprint.yaml"));
_fingerprint = FingerprintSchema.parse(raw);
return _fingerprint;
}
export function getConfig(): AppConfig {
if (!_config) throw new Error("Config not loaded. Call loadConfig() first.");
return _config;
}
export function getFingerprint(): FingerprintConfig {
if (!_fingerprint) throw new Error("Fingerprint not loaded. Call loadFingerprint() first.");
return _fingerprint;
}
export function mutateClientConfig(patch: Partial<AppConfig["client"]>): void {
if (!_config) throw new Error("Config not loaded");
Object.assign(_config.client, patch);
}
/** Reload config from disk (hot-reload after full-update).
* P1-5: Load to temp first, then swap atomically to avoid null window. */
export function reloadConfig(configDir?: string): AppConfig {
const dir = configDir ?? getConfigDir();
const raw = loadYaml(resolve(dir, "default.yaml")) as Record<string, unknown>;
applyEnvOverrides(raw);
const fresh = ConfigSchema.parse(raw);
_config = fresh;
return _config;
}
/** Reload fingerprint from disk (hot-reload after full-update).
* P1-5: Load to temp first, then swap atomically. */
export function reloadFingerprint(configDir?: string): FingerprintConfig {
const dir = configDir ?? getConfigDir();
const raw = loadYaml(resolve(dir, "fingerprint.yaml"));
const fresh = FingerprintSchema.parse(raw);
_fingerprint = fresh;
return _fingerprint;
}
/** Reload both config and fingerprint from disk, plus static models. */
export function reloadAllConfigs(configDir?: string): void {
reloadConfig(configDir);
reloadFingerprint(configDir);
loadStaticModels(configDir);
console.log("[Config] Hot-reloaded config, fingerprint, and models from disk");
// Re-merge backend models so hot-reload doesn't wipe them for ~1h
triggerImmediateRefresh();
}