zcode-api / src /config /loader.ts
bolikoto's picture
Deploy minimal Docker Space
67d18ac verified
Raw History Blame Contribute Delete
17 kB
/**
* YAML config loader with env-var overrides and validation.
* @see .omo/plans/zcode-proxy.md Task 2
*/
import { readFileSync, existsSync } from "node:fs";
import { parse } from "yaml";
import type { ClientIdentityConfig, ProxyConfig, ProviderEndpoints, ProxyIdentity, ResponsesConfig, McpConfig, AsyncConfig, EndpointRoutingConfig, ClientSigningConfig, ClaimConfig } from "./types.js";
/** Environment variable keys that override YAML values. */
const ENV = {
PORT: "ZCODE_PROXY_PORT",
PROXY_API_KEY: "ZCODE_PROXY_API_KEY",
PROVIDER: "ZCODE_PROVIDER",
APP_VERSION: "ZCODE_APP_VERSION",
SOURCE_TITLE: "ZCODE_SOURCE_TITLE",
REFERER_ORIGIN: "ZCODE_REFERER_ORIGIN",
ASYNC_ENABLED: "ZCODE_ASYNC_ENABLED",
ASYNC_ORIGIN: "ZCODE_ASYNC_ORIGIN",
ASYNC_MAX_RETRIES: "ZCODE_ASYNC_MAX_RETRIES",
ASYNC_MAX_WAIT_MS: "ZCODE_ASYNC_MAX_WAIT_MS",
CLAIM_ENABLED: "ZCODE_CLAIM_ENABLED",
CLAIM_AUTO: "ZCODE_CLAIM_AUTO",
CLAIM_ORIGIN: "ZCODE_CLAIM_ORIGIN",
CLAIM_POLL_INTERVAL_MS: "ZCODE_CLAIM_POLL_INTERVAL_MS",
ENDPOINT_ROUTING_ENABLED: "ZCODE_ENDPOINT_ROUTING",
CLIENT_SIGNING_ENABLED: "ZCODE_CLIENT_SIGNING",
} as const;
const DEFAULTS = {
PORT: 8080,
HOST: "0.0.0.0",
PROVIDER: "zai" as const,
PLAN: "coding-plan" as const,
DEFAULT_MODEL: "glm-4.6",
LOG_LEVEL: "info" as const,
ZAI_ANTHROPIC_BASE: "https://api.z.ai/api/anthropic",
ZAI_OPENAI_BASE: "https://api.z.ai/api/coding/paas/v4",
BIGMODEL_ANTHROPIC_BASE: "https://open.bigmodel.cn/api/anthropic",
BIGMODEL_OPENAI_BASE: "https://open.bigmodel.cn/api/coding/paas/v4",
APP_VERSION: "3.11.2",
SOURCE_TITLE: "cli",
REFERER_ORIGIN: "https://zcode.z.ai",
CLIENT_IDENTITY_MODE: "observe" as const,
CLIENT_IDENTITY_TTL_SECONDS: 900,
CLIENT_IDENTITY_MAX_SESSIONS: 1024,
RESPONSES_ENABLED: true,
RESPONSES_STORE_MAX_ENTRIES: 1000,
RESPONSES_STORE_TTL_MS: 24 * 60 * 60 * 1000,
MCP_ENABLED: true,
MCP_WEB_SEARCH: true,
MCP_WEB_READER: false,
MCP_ZREAD: false,
ASYNC_ENABLED: false,
ASYNC_ORIGIN: "https://zcode.z.ai",
ASYNC_POLL_INTERVAL_MS: 5000,
ASYNC_KEEPALIVE_INTERVAL_MS: 3000,
ASYNC_MAX_WAIT_MS: 0,
ASYNC_MAX_RETRIES: 3,
ASYNC_SETTLE_TIMEOUT_MS: 8000,
ASYNC_CONTROL_TIMEOUT_MS: 15000,
ASYNC_DEFAULT_MODEL: "",
CLAIM_ENABLED: true,
CLAIM_AUTO: true,
CLAIM_ORIGIN: "https://zcode.z.ai",
CLAIM_POLL_INTERVAL_MS: 300000,
CLAIM_COOLDOWN_MS: 600000,
CLAIM_PLAN_ID: "",
ENDPOINT_ROUTING_ENABLED: true,
ENDPOINT_ROUTING_ORIGIN: "https://zcode.z.ai",
CLIENT_SIGNING_ENABLED: true,
CLIENT_SIGNING_ORIGIN: "https://zcode.z.ai",
};
/** Printable-ASCII gate copied from the ZCode bundle's `rYn` helper. */
const ASCII_PRINTABLE = /^[\x20-\x7e]+$/;
/**
* Load and validate proxy configuration from a YAML file, applying env overrides.
* @throws Error if file not found or required fields are invalid.
*/
export function loadConfig(path: string): ProxyConfig {
if (!existsSync(path)) {
throw new Error(`Config file not found: ${path}`);
}
const raw = readFileSync(path, "utf-8");
const parsed = parse(raw) ?? {};
// --- server ---
const port = resolvePort(process.env[ENV.PORT] ?? parsed?.server?.port);
const host = typeof parsed?.server?.host === "string" ? parsed.server.host : DEFAULTS.HOST;
// --- auth ---
const proxyApiKey = process.env[ENV.PROXY_API_KEY] ?? parsed?.auth?.proxyApiKey;
const oauthCredentialsPath = parsed?.auth?.oauthCredentialsPath;
// --- provider ---
const provider = resolveProvider(process.env[ENV.PROVIDER] ?? parsed?.provider);
const plan = resolvePlan(parsed?.plan);
// --- providers ---
const zai: ProviderEndpoints = {
anthropicBase: parsed?.providers?.zai?.anthropicBase ?? DEFAULTS.ZAI_ANTHROPIC_BASE,
openaiBase: parsed?.providers?.zai?.openaiBase ?? DEFAULTS.ZAI_OPENAI_BASE,
};
const bigmodel: ProviderEndpoints = {
anthropicBase: parsed?.providers?.bigmodel?.anthropicBase ?? DEFAULTS.BIGMODEL_ANTHROPIC_BASE,
openaiBase: parsed?.providers?.bigmodel?.openaiBase ?? DEFAULTS.BIGMODEL_OPENAI_BASE,
};
// --- models ---
const defaultModel = typeof parsed?.defaultModel === "string" ? parsed.defaultModel : DEFAULTS.DEFAULT_MODEL;
const models = Array.isArray(parsed?.models) ? parsed.models : [defaultModel];
// --- logging ---
const logLevel = resolveLogLevel(parsed?.logging?.level);
// --- identity ---
const identity = resolveIdentity({
appVersionEnv: process.env[ENV.APP_VERSION],
appVersionYaml: parsed?.identity?.appVersion,
sourceTitleEnv: process.env[ENV.SOURCE_TITLE],
sourceTitleYaml: parsed?.identity?.sourceTitle,
refererEnv: process.env[ENV.REFERER_ORIGIN],
refererYaml: parsed?.identity?.refererOrigin,
deviceMidYaml: parsed?.identity?.deviceMid,
});
const clientIdentity = resolveClientIdentity(parsed?.clientIdentity);
const responses = resolveResponsesConfig(parsed?.responses);
const mcp = resolveMcpConfig(parsed?.mcp);
const asyncCfg = resolveAsyncConfig(parsed?.async);
const claimCfg = resolveClaimConfig(parsed?.claim);
const endpointRouting = resolveEndpointRoutingConfig(parsed?.endpointRouting);
const clientSigning = resolveClientSigningConfig(parsed?.clientSigning);
const config: ProxyConfig = {
server: { port, host },
auth: { proxyApiKey, oauthCredentialsPath },
provider,
plan,
providers: { zai, bigmodel },
defaultModel,
models,
identity,
clientIdentity,
responses,
endpointRouting,
clientSigning,
mcp,
async: asyncCfg,
claim: claimCfg,
logging: { level: logLevel },
};
validate(config);
return config;
}
function resolveClientIdentity(raw: unknown): ClientIdentityConfig {
const obj = raw && typeof raw === "object" ? raw as Record<string, unknown> : {};
const mode = resolveClientIdentityMode(obj.mode);
const ttlSeconds = resolvePositiveInt(obj.ttlSeconds, DEFAULTS.CLIENT_IDENTITY_TTL_SECONDS, "clientIdentity.ttlSeconds");
const maxSessions = resolvePositiveInt(obj.maxSessions, DEFAULTS.CLIENT_IDENTITY_MAX_SESSIONS, "clientIdentity.maxSessions");
return { mode, ttlSeconds, maxSessions };
}
function resolveClientIdentityMode(raw: unknown): ClientIdentityConfig["mode"] {
if (raw === undefined || raw === null) return DEFAULTS.CLIENT_IDENTITY_MODE;
if (raw === "off" || raw === "observe" || raw === "enforce") return raw;
throw new Error(`Invalid clientIdentity.mode "${String(raw)}": must be "off", "observe", or "enforce"`);
}
function resolveResponsesConfig(raw: unknown): ResponsesConfig {
const obj = raw && typeof raw === "object" ? raw as Record<string, unknown> : {};
const storeRaw = obj.store && typeof obj.store === "object" ? obj.store as Record<string, unknown> : {};
return {
enabled: resolveBool(obj.enabled, DEFAULTS.RESPONSES_ENABLED),
storeMaxEntries: resolvePositiveInt(storeRaw.maxEntries, DEFAULTS.RESPONSES_STORE_MAX_ENTRIES, "responses.store.maxEntries"),
storeTtlMs: resolvePositiveInt(storeRaw.ttlMs, DEFAULTS.RESPONSES_STORE_TTL_MS, "responses.store.ttlMs"),
};
}
function resolveMcpConfig(raw: unknown): McpConfig {
const obj = raw && typeof raw === "object" ? raw as Record<string, unknown> : {};
return {
enabled: resolveBool(obj.enabled, DEFAULTS.MCP_ENABLED),
webSearch: resolveBool(obj.webSearch ?? obj.web_search, DEFAULTS.MCP_WEB_SEARCH),
webReader: resolveBool(obj.webReader ?? obj.web_reader, DEFAULTS.MCP_WEB_READER),
zread: resolveBool(obj.zread, DEFAULTS.MCP_ZREAD),
};
}
function resolveAsyncConfig(raw: unknown): AsyncConfig {
const obj = raw && typeof raw === "object" ? raw as Record<string, unknown> : {};
const enabledEnv = process.env[ENV.ASYNC_ENABLED];
const originEnv = process.env[ENV.ASYNC_ORIGIN];
const maxRetriesEnv = process.env[ENV.ASYNC_MAX_RETRIES];
const maxWaitMsEnv = process.env[ENV.ASYNC_MAX_WAIT_MS];
const origin = (originEnv ?? (typeof obj.origin === "string" ? obj.origin : DEFAULTS.ASYNC_ORIGIN)).trim() || DEFAULTS.ASYNC_ORIGIN;
validateOrigin(origin, "async.origin");
return {
enabled: enabledEnv !== undefined ? resolveBool(enabledEnv, DEFAULTS.ASYNC_ENABLED) : resolveBool(obj.enabled, DEFAULTS.ASYNC_ENABLED),
origin,
pollIntervalMs: resolvePositiveInt(obj.pollIntervalMs ?? obj.poll_interval_ms, DEFAULTS.ASYNC_POLL_INTERVAL_MS, "async.pollIntervalMs"),
keepAliveIntervalMs: resolvePositiveInt(obj.keepAliveIntervalMs ?? obj.keepalive_interval_ms, DEFAULTS.ASYNC_KEEPALIVE_INTERVAL_MS, "async.keepAliveIntervalMs"),
maxWaitMs: resolveNonNegativeInt(maxWaitMsEnv ?? obj.maxWaitMs ?? obj.max_wait_ms, DEFAULTS.ASYNC_MAX_WAIT_MS, "async.maxWaitMs"),
maxRetries: resolveNonNegativeInt(maxRetriesEnv ?? obj.maxRetries ?? obj.max_retries, DEFAULTS.ASYNC_MAX_RETRIES, "async.maxRetries"),
settleTimeoutMs: resolvePositiveInt(obj.settleTimeoutMs ?? obj.settle_timeout_ms, DEFAULTS.ASYNC_SETTLE_TIMEOUT_MS, "async.settleTimeoutMs"),
controlTimeoutMs: resolvePositiveInt(obj.controlTimeoutMs ?? obj.control_timeout_ms, DEFAULTS.ASYNC_CONTROL_TIMEOUT_MS, "async.controlTimeoutMs"),
defaultModel: typeof obj.defaultModel === "string" ? obj.defaultModel : DEFAULTS.ASYNC_DEFAULT_MODEL,
};
}
function validateOrigin(origin: string, name: string): void {
let parsed: URL;
try {
parsed = new URL(origin);
} catch {
throw new Error(`${name} "${origin}" is not a valid URL`);
}
// Scheme allowlist: only http/https. Other schemes (ftp:, file:, etc.) rejected.
if (parsed.protocol !== "https:" && parsed.protocol !== "http:") {
throw new Error(`${name} must use http: or https: scheme (got ${parsed.protocol})`);
}
// Cleartext HTTP only for loopback (dev/mock mode). Real off-peak backend requires
// HTTPS — cleartext would leak the JWT + coding-plan API key to any network observer.
const hostname = parsed.hostname.replace(/^\[|\]$/g, ""); // strip IPv6 brackets
const isLoopback = hostname === "127.0.0.1" || hostname === "localhost" || hostname === "::1";
if (parsed.protocol === "http:" && !isLoopback) {
throw new Error(`${name} http:// is only allowed for loopback hosts (got ${hostname}). Use https:// for remote origins.`);
}
if (parsed.username || parsed.password) {
throw new Error(`${name} must not contain userinfo`);
}
if (parsed.hash) {
throw new Error(`${name} must not contain a fragment`);
}
if (parsed.pathname !== "/" && parsed.pathname !== "") {
throw new Error(`${name} must not contain a path (got "${parsed.pathname}"); clients append their own paths`);
}
if (parsed.search) {
throw new Error(`${name} must not contain a query string`);
}
}
function resolveEndpointRoutingConfig(raw: unknown): EndpointRoutingConfig {
const obj = raw && typeof raw === "object" ? raw as Record<string, unknown> : {};
const enabledEnv = process.env[ENV.ENDPOINT_ROUTING_ENABLED];
const origin = (typeof obj.origin === "string" ? obj.origin : DEFAULTS.ENDPOINT_ROUTING_ORIGIN).trim()
|| DEFAULTS.ENDPOINT_ROUTING_ORIGIN;
validateOrigin(origin, "endpointRouting.origin");
return {
enabled: enabledEnv !== undefined ? resolveBool(enabledEnv, DEFAULTS.ENDPOINT_ROUTING_ENABLED) : resolveBool(obj.enabled, DEFAULTS.ENDPOINT_ROUTING_ENABLED),
origin,
};
}
function resolveClientSigningConfig(raw: unknown): ClientSigningConfig {
const obj = raw && typeof raw === "object" ? raw as Record<string, unknown> : {};
const enabledEnv = process.env[ENV.CLIENT_SIGNING_ENABLED];
const origin = (typeof obj.origin === "string" ? obj.origin : DEFAULTS.CLIENT_SIGNING_ORIGIN).trim()
|| DEFAULTS.CLIENT_SIGNING_ORIGIN;
validateOrigin(origin, "clientSigning.origin");
return {
enabled: enabledEnv !== undefined ? resolveBool(enabledEnv, DEFAULTS.CLIENT_SIGNING_ENABLED) : resolveBool(obj.enabled, DEFAULTS.CLIENT_SIGNING_ENABLED),
origin,
};
}
function resolveBool(raw: unknown, fallback: boolean): boolean {
if (typeof raw === "boolean") return raw;
if (typeof raw === "string") return raw === "true" || raw === "1";
return fallback;
}
function resolvePositiveInt(raw: unknown, fallback: number, name: string): number {
if (raw === undefined || raw === null) return fallback;
const n = typeof raw === "number" ? raw : parseInt(String(raw), 10);
if (!Number.isInteger(n) || n < 1) {
throw new Error(`${name} must be a positive integer`);
}
return n;
}
function resolveNonNegativeInt(raw: unknown, fallback: number, name: string): number {
if (raw === undefined || raw === null) return fallback;
const n = typeof raw === "number" ? raw : parseInt(String(raw), 10);
if (!Number.isInteger(n) || n < 0) {
throw new Error(`${name} must be a non-negative integer`);
}
return n;
}
/** Resolve port from raw value (YAML or env), defaulting to 8080. */
function resolvePort(raw: unknown): number {
if (raw === undefined || raw === null) return DEFAULTS.PORT;
const n = typeof raw === "number" ? raw : parseInt(String(raw), 10);
if (!Number.isFinite(n)) {
throw new Error("server.port must be a valid number");
}
return n;
}
/** Resolve and validate provider string. */
function resolveProvider(raw: unknown): "zai" | "bigmodel" {
const v = typeof raw === "string" ? raw : DEFAULTS.PROVIDER;
if (v !== "zai" && v !== "bigmodel") {
throw new Error(`Invalid provider "${v}": must be "zai" or "bigmodel"`);
}
return v;
}
/**
* Resolve and validate the plan tier. Mirrors `resolveProvider`'s hard
* validation style: an unrecognized value (e.g. `start_plan`/`startplan`
* typos) THROWS instead of silently falling back to coding-plan — a silent
* fallback sent users to the wrong upstream (401/403, no captcha/quota flow)
* with nothing pointing at the config typo.
*/
function resolvePlan(raw: unknown): "coding-plan" | "start-plan" {
if (raw === undefined || raw === null) return DEFAULTS.PLAN;
if (raw === "coding-plan" || raw === "start-plan") return raw;
throw new Error(`Invalid plan "${String(raw)}": must be "coding-plan" or "start-plan"`);
}
/** Resolve log level with fallback. */
function resolveLogLevel(raw: unknown): "debug" | "info" | "warn" | "error" {
const levels = ["debug", "info", "warn", "error"] as const;
if (typeof raw === "string" && (levels as readonly string[]).includes(raw)) {
return raw as "debug" | "info" | "warn" | "error";
}
return DEFAULTS.LOG_LEVEL;
}
interface IdentityInputs {
appVersionEnv?: string;
appVersionYaml?: string;
sourceTitleEnv?: string;
sourceTitleYaml?: string;
refererEnv?: string;
refererYaml?: string;
deviceMidYaml?: string;
}
/** Resolve identity fields (env > YAML > default). Non-ASCII `appVersion` silently falls back to the default. */
function resolveIdentity(inp: IdentityInputs): ProxyIdentity {
const rawVersion = (inp.appVersionEnv ?? inp.appVersionYaml ?? DEFAULTS.APP_VERSION).trim();
const appVersion = ASCII_PRINTABLE.test(rawVersion) ? rawVersion : DEFAULTS.APP_VERSION;
const sourceTitle = (inp.sourceTitleEnv ?? inp.sourceTitleYaml ?? DEFAULTS.SOURCE_TITLE).trim()
|| DEFAULTS.SOURCE_TITLE;
const refererOrigin = (inp.refererEnv ?? inp.refererYaml ?? DEFAULTS.REFERER_ORIGIN).trim()
|| DEFAULTS.REFERER_ORIGIN;
const deviceMid = typeof inp.deviceMidYaml === "string" ? inp.deviceMidYaml.trim() : "";
return { appVersion, sourceTitle, refererOrigin, ...(deviceMid ? { deviceMid } : {}) };
}
/** Cross-field validation after all fields are resolved. */
function resolveClaimConfig(raw: unknown): ClaimConfig {
const obj = raw && typeof raw === "object" ? raw as Record<string, unknown> : {};
const enabledEnv = process.env[ENV.CLAIM_ENABLED];
const autoEnv = process.env[ENV.CLAIM_AUTO];
const originEnv = process.env[ENV.CLAIM_ORIGIN];
const pollIntervalEnv = process.env[ENV.CLAIM_POLL_INTERVAL_MS];
const origin = (originEnv ?? (typeof obj.origin === "string" ? obj.origin : DEFAULTS.CLAIM_ORIGIN)).trim() || DEFAULTS.CLAIM_ORIGIN;
validateOrigin(origin, "claim.origin");
return {
enabled: enabledEnv !== undefined ? resolveBool(enabledEnv, DEFAULTS.CLAIM_ENABLED) : resolveBool(obj.enabled, DEFAULTS.CLAIM_ENABLED),
auto: autoEnv !== undefined ? resolveBool(autoEnv, DEFAULTS.CLAIM_AUTO) : resolveBool(obj.auto, DEFAULTS.CLAIM_AUTO),
origin,
pollIntervalMs: resolvePositiveInt(pollIntervalEnv ?? obj.pollIntervalMs ?? obj.poll_interval_ms, DEFAULTS.CLAIM_POLL_INTERVAL_MS, "claim.pollIntervalMs"),
cooldownMs: resolvePositiveInt(obj.cooldownMs ?? obj.cooldown_ms, DEFAULTS.CLAIM_COOLDOWN_MS, "claim.cooldownMs"),
planId: typeof obj.planId === "string" ? obj.planId.trim() : DEFAULTS.CLAIM_PLAN_ID,
};
}
function validate(config: ProxyConfig): void {
if (config.server.port < 1 || config.server.port > 65535) {
throw new Error(`server.port ${config.server.port} is out of range (1-65535)`);
}
if (!config.models.includes(config.defaultModel)) {
// defaultModel not in the models list — add it automatically
config.models.push(config.defaultModel);
}
}