fix: extend Sub2API compact proxy timeout
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { renderCodexLocalConsumerToml } from "./src/platform-infra-sub2api-codex";
|
||||
import { renderCaddySiteBlock, renderCodexLocalConsumerToml } from "./src/platform-infra-sub2api-codex";
|
||||
|
||||
function assertCondition(condition: unknown, message: string, detail: unknown = {}): void {
|
||||
if (!condition) throw new Error(`${message}: ${JSON.stringify(detail)}`);
|
||||
@@ -58,11 +58,19 @@ const disabled = renderCodexLocalConsumerToml(existing, {
|
||||
assertCondition(disabled.includes("supports_websockets = false"), "disabled localCodex policy must render provider WebSocket transport off", disabled);
|
||||
assertCondition(disabled.includes("responses_websockets_v2 = false"), "disabled localCodex policy must render Responses WebSocket v2 off", disabled);
|
||||
|
||||
const caddyBlock = renderCaddySiteBlock("sub2api.example.test", "http://127.0.0.1:21880", 180);
|
||||
|
||||
assertCondition(caddyBlock.includes("sub2api.example.test {"), "Caddy site block must use the configured domain", caddyBlock);
|
||||
assertCondition(caddyBlock.includes("reverse_proxy 127.0.0.1:21880"), "Caddy site block must use the configured local upstream", caddyBlock);
|
||||
assertCondition(caddyBlock.includes("response_header_timeout 180s"), "Caddy response header timeout must allow long Codex compact requests", caddyBlock);
|
||||
assertCondition(!caddyBlock.includes("response_header_timeout 30s"), "Caddy site block must not retain the old 30s compact timeout", caddyBlock);
|
||||
|
||||
console.log(JSON.stringify({
|
||||
ok: true,
|
||||
checks: [
|
||||
"existing Codex TOML is upgraded to the Sub2API WSv2 consumer settings",
|
||||
"fresh Codex TOML creates provider and feature sections with WSv2 enabled",
|
||||
"disabled localCodex WebSocket policy renders both consumer flags off",
|
||||
"Caddy site block uses the YAML-controlled long response-header timeout",
|
||||
],
|
||||
}));
|
||||
|
||||
@@ -18,6 +18,7 @@ const parsed = Bun.YAML.parse(readFileSync(configPath, "utf8")) as {
|
||||
};
|
||||
};
|
||||
profiles?: { entries?: Array<{ profile?: string; accountName?: string; capacity?: number; loadFactor?: number; openaiResponsesWebSocketsV2Mode?: string | null }> };
|
||||
publicExposure?: { masterCaddy?: { responseHeaderTimeoutSeconds?: number } };
|
||||
localCodex?: { supportsWebSockets?: boolean; responsesWebSocketsV2?: boolean; responsesSmokeModel?: string };
|
||||
};
|
||||
|
||||
@@ -39,6 +40,11 @@ assertCondition(entries.every((entry) => typeof entry.profile === "string" && en
|
||||
assertCondition(entries.every((entry) => typeof entry.accountName === "string" && entry.accountName.length > 0), "profile entries must declare account names", entries);
|
||||
assertCondition(entries.every((entry) => entry.capacity === undefined || (Number.isInteger(entry.capacity) && entry.capacity > 0)), "profile capacity overrides must be positive integers when declared", entries);
|
||||
assertCondition(entries.every((entry) => entry.loadFactor === undefined || (Number.isInteger(entry.loadFactor) && entry.loadFactor > 0)), "profile load factor overrides must be positive integers when declared", entries);
|
||||
assertCondition(
|
||||
Number.isInteger(parsed.publicExposure?.masterCaddy?.responseHeaderTimeoutSeconds) && (parsed.publicExposure?.masterCaddy?.responseHeaderTimeoutSeconds ?? 0) >= 180,
|
||||
"Sub2API public Caddy response-header timeout must allow long Codex compact requests",
|
||||
parsed.publicExposure?.masterCaddy,
|
||||
);
|
||||
assertCondition(
|
||||
entries.every((entry) => entry.openaiResponsesWebSocketsV2Mode === undefined || entry.openaiResponsesWebSocketsV2Mode === null || allowedWebSocketModes.has(entry.openaiResponsesWebSocketsV2Mode)),
|
||||
"profile WebSocket mode overrides must use supported values when declared",
|
||||
@@ -59,8 +65,8 @@ if (parsed.pool?.defaultTempUnschedulable?.enabled === true) {
|
||||
const gateway502Rule = rules.find((rule) => rule.statusCode === 502);
|
||||
const gateway502Keywords = new Set((gateway502Rule?.keywords ?? []).map((keyword) => keyword.toLowerCase()));
|
||||
assertCondition(gateway502Keywords.has("recovered upstream error"), "502 temporary-unschedulable rule must catch recovered upstream error wrappers", gateway502Rule);
|
||||
for (const keyword of ["unknown error", "upstream request failed", "context canceled"]) {
|
||||
assertCondition(gateway502Keywords.has(keyword), "502 temporary-unschedulable rule must catch compact gateway wrappers", { keyword, gateway502Rule });
|
||||
for (const keyword of ["unknown error", "upstream request failed", "context deadline exceeded", "context canceled"]) {
|
||||
assertCondition(gateway502Keywords.has(keyword), "502 temporary-unschedulable rule must catch compact gateway timeout wrappers", { keyword, gateway502Rule });
|
||||
}
|
||||
const largeContext413Rule = rules.find((rule) => rule.statusCode === 413);
|
||||
const largeContext413Keywords = new Set((largeContext413Rule?.keywords ?? []).map((keyword) => keyword.toLowerCase()));
|
||||
@@ -74,7 +80,7 @@ if (parsed.pool?.defaultTempUnschedulable?.enabled === true) {
|
||||
}
|
||||
const cloudflare524Rule = rules.find((rule) => rule.statusCode === 524);
|
||||
const cloudflare524Keywords = new Set((cloudflare524Rule?.keywords ?? []).map((keyword) => keyword.toLowerCase()));
|
||||
for (const keyword of ["timeout", "a timeout occurred", "cloudflare", "unknown error", "upstream request failed", "context canceled"]) {
|
||||
for (const keyword of ["timeout", "a timeout occurred", "cloudflare", "upstream request failed", "unknown error", "context canceled", "recovered upstream error"]) {
|
||||
assertCondition(cloudflare524Keywords.has(keyword), "524 temporary-unschedulable rule must catch Cloudflare timeout wrappers", { keyword, cloudflare524Rule });
|
||||
}
|
||||
const accountState403Rule = rules.find((rule) => rule.statusCode === 403);
|
||||
@@ -99,6 +105,7 @@ console.log(JSON.stringify({
|
||||
"routing config is schema-valid without profile-specific test gates",
|
||||
"pool owner concurrency covers the YAML account capacity set",
|
||||
"profile load factor overrides are YAML-controlled positive integers",
|
||||
"public Caddy response-header timeout is long enough for Codex compact",
|
||||
"optional WebSocket mode overrides use supported values",
|
||||
"local Codex WebSocket transport is consistent with YAML-declared WSv2-capable accounts",
|
||||
"temporary unschedulable rules are structurally valid when enabled",
|
||||
|
||||
@@ -44,13 +44,13 @@ for (const keyword of ["model_not_found", "no available channel for model"]) {
|
||||
for (const keyword of ["openai_error", "context length", "maximum context"]) {
|
||||
assertCondition(largeContext413Rule?.keywords?.includes(keyword), "413 rendered rule must catch large-context upstream failures", { keyword, largeContext413Rule });
|
||||
}
|
||||
for (const keyword of ["unknown error", "upstream request failed", "context canceled"]) {
|
||||
assertCondition(gateway502Rule?.keywords?.includes(keyword), "502 rendered rule must catch compact gateway wrappers", { keyword, gateway502Rule });
|
||||
for (const keyword of ["unknown error", "upstream request failed", "context deadline exceeded", "context canceled"]) {
|
||||
assertCondition(gateway502Rule?.keywords?.includes(keyword), "502 rendered rule must catch compact gateway timeout wrappers", { keyword, gateway502Rule });
|
||||
}
|
||||
for (const keyword of ["gateway timeout", "unknown error", "context deadline exceeded"]) {
|
||||
assertCondition(gatewayTimeout504Rule?.keywords?.includes(keyword), "504 rendered rule must preserve gateway-timeout cooldown keyword", { keyword, gatewayTimeout504Rule });
|
||||
}
|
||||
for (const keyword of ["timeout", "a timeout occurred", "cloudflare", "unknown error", "upstream request failed", "context canceled"]) {
|
||||
for (const keyword of ["timeout", "a timeout occurred", "cloudflare", "upstream request failed", "unknown error", "context canceled", "recovered upstream error"]) {
|
||||
assertCondition(cloudflare524Rule?.keywords?.includes(keyword), "524 rendered rule must catch Cloudflare timeout wrappers", { keyword, cloudflare524Rule });
|
||||
}
|
||||
|
||||
|
||||
@@ -124,6 +124,7 @@ interface CodexPoolPublicExposureConfig {
|
||||
configPath: string;
|
||||
serviceName: string;
|
||||
upstreamBaseUrl: string;
|
||||
responseHeaderTimeoutSeconds: number;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -651,6 +652,7 @@ function defaultCodexPoolConfig(): CodexPoolConfig {
|
||||
configPath: "/etc/caddy/Caddyfile",
|
||||
serviceName: "caddy",
|
||||
upstreamBaseUrl: "http://127.0.0.1:21880",
|
||||
responseHeaderTimeoutSeconds: 180,
|
||||
},
|
||||
},
|
||||
localCodex: {
|
||||
@@ -822,6 +824,15 @@ function readAccountLoadFactor(value: unknown, key: string): number {
|
||||
return loadFactor;
|
||||
}
|
||||
|
||||
function readCaddyTimeoutSeconds(value: unknown, key: string, fallback: number): number {
|
||||
if (value === undefined || value === null) return fallback;
|
||||
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;
|
||||
}
|
||||
|
||||
function readTempUnschedulablePolicy(value: unknown, key: string, fallback: CodexTempUnschedulablePolicy): CodexTempUnschedulablePolicy {
|
||||
if (value === undefined || value === null) return cloneTempUnschedulablePolicy(fallback);
|
||||
if (!isRecord(value)) throw new Error(`${codexPoolConfigPath}.${key} must be a YAML object`);
|
||||
@@ -916,6 +927,11 @@ function readPublicExposureConfig(value: unknown, defaults: CodexPoolPublicExpos
|
||||
configPath: stringValue(masterCaddyValue.configPath) ?? defaults.masterCaddy.configPath,
|
||||
serviceName: stringValue(masterCaddyValue.serviceName) ?? defaults.masterCaddy.serviceName,
|
||||
upstreamBaseUrl: normalizeBaseUrl(stringValue(masterCaddyValue.upstreamBaseUrl)) ?? defaults.masterCaddy.upstreamBaseUrl,
|
||||
responseHeaderTimeoutSeconds: readCaddyTimeoutSeconds(
|
||||
masterCaddyValue.responseHeaderTimeoutSeconds,
|
||||
"publicExposure.masterCaddy.responseHeaderTimeoutSeconds",
|
||||
defaults.masterCaddy.responseHeaderTimeoutSeconds,
|
||||
),
|
||||
},
|
||||
};
|
||||
validateKubernetesName(config.configMapName, "publicExposure.configMapName", true);
|
||||
@@ -1092,6 +1108,7 @@ function publicExposureSummary(pool: CodexPoolConfig): Record<string, unknown> {
|
||||
configPath: pool.publicExposure.masterCaddy.configPath,
|
||||
serviceName: pool.publicExposure.masterCaddy.serviceName,
|
||||
upstreamBaseUrl: pool.publicExposure.masterCaddy.upstreamBaseUrl,
|
||||
responseHeaderTimeoutSeconds: pool.publicExposure.masterCaddy.responseHeaderTimeoutSeconds,
|
||||
},
|
||||
upstream: {
|
||||
localIP: pool.publicExposure.localIP,
|
||||
@@ -1156,7 +1173,7 @@ async function applyMasterCaddySite(pool: CodexPoolConfig): Promise<Record<strin
|
||||
const path = caddy.configPath;
|
||||
if (!existsSync(path)) return { ok: false, error: "master-caddy-config-missing", path, valuesPrinted: false };
|
||||
const before = readFileSync(path, "utf8");
|
||||
const desiredBlock = renderCaddySiteBlock(caddy.domain, caddy.upstreamBaseUrl);
|
||||
const desiredBlock = renderCaddySiteBlock(caddy.domain, caddy.upstreamBaseUrl, caddy.responseHeaderTimeoutSeconds);
|
||||
const existing = caddySiteBlock(before, caddy.domain);
|
||||
const alreadyConfigured = existing === desiredBlock;
|
||||
let backupPath: string | null = null;
|
||||
@@ -1187,6 +1204,7 @@ async function applyMasterCaddySite(pool: CodexPoolConfig): Promise<Record<strin
|
||||
domain: caddy.domain,
|
||||
upstreamBaseUrl: caddy.upstreamBaseUrl,
|
||||
serviceName: caddy.serviceName,
|
||||
responseHeaderTimeoutSeconds: caddy.responseHeaderTimeoutSeconds,
|
||||
validate: {
|
||||
exitCode: validate.exitCode,
|
||||
stdoutTail: Buffer.from(validate.stdout).toString("utf8").slice(-1000),
|
||||
@@ -1206,7 +1224,7 @@ async function applyMasterCaddySite(pool: CodexPoolConfig): Promise<Record<strin
|
||||
};
|
||||
}
|
||||
|
||||
function renderCaddySiteBlock(domain: string, upstreamBaseUrl: string): string {
|
||||
export function renderCaddySiteBlock(domain: string, upstreamBaseUrl: string, responseHeaderTimeoutSeconds = 180): string {
|
||||
const upstream = new URL(upstreamBaseUrl);
|
||||
const upstreamHost = `${upstream.hostname}${upstream.port ? `:${upstream.port}` : ""}`;
|
||||
return `${domain} {
|
||||
@@ -1216,7 +1234,7 @@ function renderCaddySiteBlock(domain: string, upstreamBaseUrl: string): string {
|
||||
header_up X-Real-IP {remote_host}
|
||||
transport http {
|
||||
dial_timeout 5s
|
||||
response_header_timeout 30s
|
||||
response_header_timeout ${responseHeaderTimeoutSeconds}s
|
||||
}
|
||||
}
|
||||
}`;
|
||||
|
||||
Reference in New Issue
Block a user