chore: sync post-task workspace updates
This commit is contained in:
@@ -97,6 +97,13 @@ interface CodexPoolPublicExposureConfig {
|
||||
configPath: string;
|
||||
containerName: string;
|
||||
};
|
||||
masterCaddy: {
|
||||
enabled: boolean;
|
||||
domain: string;
|
||||
configPath: string;
|
||||
serviceName: string;
|
||||
upstreamBaseUrl: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface CodexPoolLocalCodexConfig {
|
||||
@@ -327,15 +334,17 @@ async function codexPoolExpose(config: UniDeskConfig, options: ConfirmOptions):
|
||||
};
|
||||
}
|
||||
const masterResult = await applyMasterFrpsAllowPort(pool);
|
||||
const caddyResult = await applyMasterCaddySite(pool);
|
||||
const remoteResult = await capture(config, g14K3sRoute, ["script"], exposeScript(pool));
|
||||
const parsed = parseJsonOutput(remoteResult.stdout);
|
||||
const publicProbe = await probePublicModels(pool, "without-api-key");
|
||||
const ok = masterResult.ok && remoteResult.exitCode === 0 && boolField(parsed, "ok", false) && publicProbe.httpStatus === 401;
|
||||
const publicProbe = await probePublicModels(pool, "without-api-key", undefined, "public");
|
||||
const ok = masterResult.ok && caddyResult.ok && remoteResult.exitCode === 0 && boolField(parsed, "ok", false) && publicProbe.httpStatus === 401;
|
||||
if (options.raw) {
|
||||
return {
|
||||
ok,
|
||||
action: "platform-infra-sub2api-codex-pool-expose",
|
||||
masterFrps: masterResult,
|
||||
masterCaddy: caddyResult,
|
||||
remote: compactCapture(remoteResult, { full: true }),
|
||||
parsed,
|
||||
publicProbe,
|
||||
@@ -347,6 +356,7 @@ async function codexPoolExpose(config: UniDeskConfig, options: ConfirmOptions):
|
||||
mode: "confirmed",
|
||||
publicExposure: publicExposureSummary(pool),
|
||||
masterFrps: masterResult,
|
||||
masterCaddy: caddyResult,
|
||||
remote: parsed ?? compactCapture(remoteResult, { full: options.full || remoteResult.exitCode !== 0 }),
|
||||
publicProbe,
|
||||
next: {
|
||||
@@ -577,6 +587,13 @@ function defaultCodexPoolConfig(): CodexPoolConfig {
|
||||
configPath: "/opt/hwlab-frp/frps.dev.toml",
|
||||
containerName: "hwlab-frps-dev",
|
||||
},
|
||||
masterCaddy: {
|
||||
enabled: true,
|
||||
domain: "sub2api.74-48-78-17.nip.io",
|
||||
configPath: "/etc/caddy/Caddyfile",
|
||||
serviceName: "caddy",
|
||||
upstreamBaseUrl: "http://127.0.0.1:21880",
|
||||
},
|
||||
},
|
||||
localCodex: {
|
||||
backupSuffix: "pre-sub2api",
|
||||
@@ -671,6 +688,7 @@ function readBooleanConfig(value: unknown, key: string, fallback: boolean): bool
|
||||
function readPublicExposureConfig(value: unknown, defaults: CodexPoolPublicExposureConfig): CodexPoolPublicExposureConfig {
|
||||
if (!isRecord(value)) return defaults;
|
||||
const masterFrpsValue = isRecord(value.masterFrps) ? value.masterFrps : {};
|
||||
const masterCaddyValue = isRecord(value.masterCaddy) ? value.masterCaddy : {};
|
||||
const config: CodexPoolPublicExposureConfig = {
|
||||
enabled: booleanValue(value.enabled) ?? defaults.enabled,
|
||||
proxyName: stringValue(value.proxyName) ?? defaults.proxyName,
|
||||
@@ -688,6 +706,13 @@ function readPublicExposureConfig(value: unknown, defaults: CodexPoolPublicExpos
|
||||
configPath: stringValue(masterFrpsValue.configPath) ?? defaults.masterFrps.configPath,
|
||||
containerName: stringValue(masterFrpsValue.containerName) ?? defaults.masterFrps.containerName,
|
||||
},
|
||||
masterCaddy: {
|
||||
enabled: booleanValue(masterCaddyValue.enabled) ?? defaults.masterCaddy.enabled,
|
||||
domain: stringValue(masterCaddyValue.domain) ?? defaults.masterCaddy.domain,
|
||||
configPath: stringValue(masterCaddyValue.configPath) ?? defaults.masterCaddy.configPath,
|
||||
serviceName: stringValue(masterCaddyValue.serviceName) ?? defaults.masterCaddy.serviceName,
|
||||
upstreamBaseUrl: normalizeBaseUrl(stringValue(masterCaddyValue.upstreamBaseUrl)) ?? defaults.masterCaddy.upstreamBaseUrl,
|
||||
},
|
||||
};
|
||||
validateKubernetesName(config.configMapName, "publicExposure.configMapName", true);
|
||||
validateKubernetesName(config.deploymentName, "publicExposure.deploymentName", true);
|
||||
@@ -700,6 +725,11 @@ function readPublicExposureConfig(value: unknown, defaults: CodexPoolPublicExpos
|
||||
if (!/^[A-Za-z0-9._:-]+$/u.test(config.localIP)) throw new Error(`${codexPoolConfigPath}.publicExposure.localIP has an unsupported format`);
|
||||
if (!config.masterFrps.configPath.startsWith("/")) throw new Error(`${codexPoolConfigPath}.publicExposure.masterFrps.configPath must be absolute`);
|
||||
validateProxyName(config.masterFrps.containerName, "publicExposure.masterFrps.containerName");
|
||||
validatePublicHostname(config.masterCaddy.domain, "publicExposure.masterCaddy.domain");
|
||||
if (!config.masterCaddy.configPath.startsWith("/")) throw new Error(`${codexPoolConfigPath}.publicExposure.masterCaddy.configPath must be absolute`);
|
||||
validateProxyName(config.masterCaddy.serviceName, "publicExposure.masterCaddy.serviceName");
|
||||
const upstream = new URL(config.masterCaddy.upstreamBaseUrl);
|
||||
if (upstream.protocol !== "http:") throw new Error(`${codexPoolConfigPath}.publicExposure.masterCaddy.upstreamBaseUrl must use http:// for the local upstream`);
|
||||
return config;
|
||||
}
|
||||
|
||||
@@ -728,6 +758,12 @@ function validateProxyName(value: string, key: string): void {
|
||||
if (!/^[A-Za-z0-9._-]+$/u.test(value)) throw new Error(`${codexPoolConfigPath}.${key} has an unsupported format`);
|
||||
}
|
||||
|
||||
function validatePublicHostname(value: string, key: string): void {
|
||||
if (value.length > 253 || !/^[A-Za-z0-9.-]+$/u.test(value) || value.startsWith(".") || value.endsWith(".")) {
|
||||
throw new Error(`${codexPoolConfigPath}.${key} has an unsupported hostname format`);
|
||||
}
|
||||
}
|
||||
|
||||
function validatePort(value: number, key: string): void {
|
||||
if (!Number.isInteger(value) || value < 1 || value > 65535) throw new Error(`${codexPoolConfigPath}.${key} must be an integer port`);
|
||||
}
|
||||
@@ -813,6 +849,13 @@ function publicExposureSummary(pool: CodexPoolConfig): Record<string, unknown> {
|
||||
masterConfigPath: pool.publicExposure.masterFrps.configPath,
|
||||
masterContainerName: pool.publicExposure.masterFrps.containerName,
|
||||
},
|
||||
caddy: {
|
||||
enabled: pool.publicExposure.masterCaddy.enabled,
|
||||
domain: pool.publicExposure.masterCaddy.domain,
|
||||
configPath: pool.publicExposure.masterCaddy.configPath,
|
||||
serviceName: pool.publicExposure.masterCaddy.serviceName,
|
||||
upstreamBaseUrl: pool.publicExposure.masterCaddy.upstreamBaseUrl,
|
||||
},
|
||||
upstream: {
|
||||
localIP: pool.publicExposure.localIP,
|
||||
localPort: pool.publicExposure.localPort,
|
||||
@@ -869,6 +912,97 @@ async function applyMasterFrpsAllowPort(pool: CodexPoolConfig): Promise<Record<s
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
async function applyMasterCaddySite(pool: CodexPoolConfig): Promise<Record<string, unknown>> {
|
||||
const caddy = pool.publicExposure.masterCaddy;
|
||||
if (!caddy.enabled) return { ok: true, action: "disabled-by-yaml", valuesPrinted: false };
|
||||
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 existing = caddySiteBlock(before, caddy.domain);
|
||||
const alreadyConfigured = existing === desiredBlock;
|
||||
let backupPath: string | null = null;
|
||||
let action = "kept-existing";
|
||||
if (!alreadyConfigured) {
|
||||
const stamp = new Date().toISOString().replace(/[-:]/gu, "").replace(/\..*$/u, "Z");
|
||||
backupPath = `${path}.bak-sub2api-https-${stamp}`;
|
||||
copyFileSync(path, backupPath);
|
||||
const next = existing === null
|
||||
? `${before.replace(/\s*$/u, "")}\n\n${desiredBlock}\n`
|
||||
: before.replace(existing, desiredBlock);
|
||||
writeFileSync(path, next, "utf8");
|
||||
chmodSync(path, statSync(backupPath).mode & 0o777);
|
||||
action = existing === null ? "added-site" : "updated-site";
|
||||
}
|
||||
const validate = Bun.spawnSync(["caddy", "validate", "--config", path, "--adapter", "caddyfile"]);
|
||||
let reload: ReturnType<typeof Bun.spawnSync> | null = null;
|
||||
if (validate.exitCode === 0 && !alreadyConfigured) {
|
||||
reload = Bun.spawnSync(["systemctl", "reload", caddy.serviceName]);
|
||||
}
|
||||
const active = Bun.spawnSync(["systemctl", "is-active", caddy.serviceName]);
|
||||
const ok = validate.exitCode === 0 && (reload === null || reload.exitCode === 0) && active.exitCode === 0 && String(active.stdout).trim() === "active";
|
||||
return {
|
||||
ok,
|
||||
action,
|
||||
path,
|
||||
backupPath,
|
||||
domain: caddy.domain,
|
||||
upstreamBaseUrl: caddy.upstreamBaseUrl,
|
||||
serviceName: caddy.serviceName,
|
||||
validate: {
|
||||
exitCode: validate.exitCode,
|
||||
stdoutTail: Buffer.from(validate.stdout).toString("utf8").slice(-1000),
|
||||
stderrTail: Buffer.from(validate.stderr).toString("utf8").slice(-2000),
|
||||
},
|
||||
reload: reload === null ? null : {
|
||||
exitCode: reload.exitCode,
|
||||
stdoutTail: Buffer.from(reload.stdout).toString("utf8").slice(-1000),
|
||||
stderrTail: Buffer.from(reload.stderr).toString("utf8").slice(-1000),
|
||||
},
|
||||
active: {
|
||||
exitCode: active.exitCode,
|
||||
stdoutTail: Buffer.from(active.stdout).toString("utf8").slice(-1000),
|
||||
stderrTail: Buffer.from(active.stderr).toString("utf8").slice(-1000),
|
||||
},
|
||||
valuesPrinted: false,
|
||||
};
|
||||
}
|
||||
|
||||
function renderCaddySiteBlock(domain: string, upstreamBaseUrl: string): string {
|
||||
const upstream = new URL(upstreamBaseUrl);
|
||||
const upstreamHost = `${upstream.hostname}${upstream.port ? `:${upstream.port}` : ""}`;
|
||||
return `${domain} {
|
||||
encode zstd gzip
|
||||
reverse_proxy ${upstreamHost} {
|
||||
header_up Host {host}
|
||||
header_up X-Real-IP {remote_host}
|
||||
transport http {
|
||||
dial_timeout 5s
|
||||
response_header_timeout 30s
|
||||
}
|
||||
}
|
||||
}`;
|
||||
}
|
||||
|
||||
function caddySiteBlock(text: string, domain: string): string | null {
|
||||
const startMatch = new RegExp(`(^|\\n)${escapeRegExp(domain)}\\s*\\{`, "u").exec(text);
|
||||
if (startMatch === null) return null;
|
||||
const start = startMatch.index + (startMatch[1] === "\n" ? 1 : 0);
|
||||
const open = text.indexOf("{", start);
|
||||
if (open < 0) return null;
|
||||
let depth = 0;
|
||||
for (let index = open; index < text.length; index += 1) {
|
||||
const ch = text[index];
|
||||
if (ch === "{") depth += 1;
|
||||
if (ch === "}") {
|
||||
depth -= 1;
|
||||
if (depth === 0) return text.slice(start, index + 1);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function frpsAllowPortExists(toml: string, port: number): boolean {
|
||||
const sections = toml.split(/(?=\[\[allowPorts\]\])/u);
|
||||
return sections.some((section) => {
|
||||
@@ -1227,8 +1361,9 @@ async function validatePublicGatewayWithKey(pool: CodexPoolConfig, apiKey: strin
|
||||
};
|
||||
}
|
||||
|
||||
async function probePublicModels(pool: CodexPoolConfig, mode: "with-api-key" | "without-api-key", apiKey?: string): Promise<Record<string, unknown>> {
|
||||
const url = `${pool.publicExposure.masterBaseUrl.replace(/\/+$/u, "")}/v1/models`;
|
||||
async function probePublicModels(pool: CodexPoolConfig, mode: "with-api-key" | "without-api-key", apiKey?: string, base: "master" | "public" = "master"): Promise<Record<string, unknown>> {
|
||||
const baseUrl = base === "public" ? pool.publicExposure.publicBaseUrl : pool.publicExposure.masterBaseUrl;
|
||||
const url = `${baseUrl.replace(/\/+$/u, "")}/v1/models`;
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), 8000);
|
||||
try {
|
||||
@@ -1249,7 +1384,7 @@ async function probePublicModels(pool: CodexPoolConfig, mode: "with-api-key" | "
|
||||
ok: response.ok,
|
||||
mode,
|
||||
method: "GET /v1/models",
|
||||
baseUrl: pool.publicExposure.masterBaseUrl,
|
||||
baseUrl,
|
||||
httpStatus: response.status,
|
||||
modelCount,
|
||||
bodyPreview: response.ok ? "" : body.slice(0, 500),
|
||||
@@ -1260,7 +1395,7 @@ async function probePublicModels(pool: CodexPoolConfig, mode: "with-api-key" | "
|
||||
ok: false,
|
||||
mode,
|
||||
method: "GET /v1/models",
|
||||
baseUrl: pool.publicExposure.masterBaseUrl,
|
||||
baseUrl,
|
||||
httpStatus: 0,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
valuesPrinted: false,
|
||||
@@ -1357,6 +1492,19 @@ function desiredAccountCapacityMap(pool: CodexPoolConfig): Record<string, number
|
||||
return capacities;
|
||||
}
|
||||
|
||||
function desiredAccountWebSocketsV2ModeMap(pool: CodexPoolConfig): Record<string, OpenAIResponsesWebSocketsV2Mode | null> {
|
||||
const codexDir = join(homedir(), ".codex");
|
||||
const seenAccountNames = new Set<string>();
|
||||
const configs = pool.profiles.length > 0 ? pool.profiles : discoverCodexProfileConfigs(codexDir);
|
||||
const modes: Record<string, OpenAIResponsesWebSocketsV2Mode | null> = {};
|
||||
for (const entry of configs) {
|
||||
const accountName = entry.accountName ?? uniqueAccountName(entry.profile, seenAccountNames);
|
||||
seenAccountNames.add(accountName);
|
||||
modes[accountName] = entry.openaiResponsesWebSocketsV2Mode;
|
||||
}
|
||||
return modes;
|
||||
}
|
||||
|
||||
function remotePythonScript(mode: "sync" | "validate", encodedPayload: string, pool: CodexPoolConfig): string {
|
||||
return `
|
||||
set -u
|
||||
@@ -1382,6 +1530,7 @@ POOL_API_KEY_SECRET_KEY = "${pool.apiKeySecretKey}"
|
||||
MIN_OWNER_BALANCE_USD = ${JSON.stringify(pool.minOwnerBalanceUsd)}
|
||||
POOL_DEFAULT_ACCOUNT_CAPACITY = ${JSON.stringify(pool.defaultAccountCapacity)}
|
||||
EXPECTED_ACCOUNT_CAPACITIES = ${JSON.stringify(desiredAccountCapacityMap(pool))}
|
||||
EXPECTED_ACCOUNT_WS_MODES = json.loads(${JSON.stringify(JSON.stringify(desiredAccountWebSocketsV2ModeMap(pool)))})
|
||||
MODE = "${mode}"
|
||||
PAYLOAD_B64 = "${encodedPayload}"
|
||||
|
||||
@@ -1835,6 +1984,73 @@ def account_capacity_status(token):
|
||||
"valuesPrinted": False,
|
||||
}
|
||||
|
||||
def account_ws_v2_status(token):
|
||||
accounts = list_accounts(token)
|
||||
by_name = {item.get("name"): item for item in accounts if isinstance(item.get("name"), str)}
|
||||
items = []
|
||||
missing = []
|
||||
mismatched = []
|
||||
enabled_names = []
|
||||
unschedulable_enabled = []
|
||||
schedulable_enabled = []
|
||||
for name in sorted(EXPECTED_ACCOUNT_WS_MODES):
|
||||
expected_mode = EXPECTED_ACCOUNT_WS_MODES[name]
|
||||
expected_enabled = expected_mode not in (None, "", "off")
|
||||
account = by_name.get(name)
|
||||
if account is None:
|
||||
missing.append(name)
|
||||
items.append({
|
||||
"accountName": name,
|
||||
"accountId": None,
|
||||
"expectedMode": expected_mode,
|
||||
"expectedEnabled": expected_enabled,
|
||||
"runtimeMode": None,
|
||||
"runtimeEnabled": None,
|
||||
"ok": False,
|
||||
})
|
||||
continue
|
||||
extra = account.get("extra") if isinstance(account.get("extra"), dict) else {}
|
||||
runtime_mode = extra.get("openai_apikey_responses_websockets_v2_mode")
|
||||
runtime_enabled = extra.get("openai_apikey_responses_websockets_v2_enabled")
|
||||
schedulable = account.get("schedulable")
|
||||
if expected_mode == "off":
|
||||
ok = runtime_mode == "off" and runtime_enabled is False
|
||||
elif expected_mode is None:
|
||||
ok = runtime_mode in (None, "", "off") and runtime_enabled in (None, False)
|
||||
else:
|
||||
ok = runtime_mode == expected_mode and runtime_enabled is True
|
||||
if not ok:
|
||||
mismatched.append(name)
|
||||
if expected_enabled:
|
||||
enabled_names.append(name)
|
||||
if schedulable is True:
|
||||
schedulable_enabled.append(name)
|
||||
else:
|
||||
unschedulable_enabled.append(name)
|
||||
items.append({
|
||||
"accountName": name,
|
||||
"accountId": account.get("id"),
|
||||
"expectedMode": expected_mode,
|
||||
"expectedEnabled": expected_enabled,
|
||||
"runtimeMode": runtime_mode,
|
||||
"runtimeEnabled": runtime_enabled,
|
||||
"status": account.get("status"),
|
||||
"schedulable": schedulable,
|
||||
"ok": ok and ((not expected_enabled) or schedulable is True),
|
||||
})
|
||||
availability_ok = len(enabled_names) == 0 or len(schedulable_enabled) > 0
|
||||
return {
|
||||
"ok": len(missing) == 0 and len(mismatched) == 0 and availability_ok,
|
||||
"desired": len(EXPECTED_ACCOUNT_WS_MODES),
|
||||
"enabled": enabled_names,
|
||||
"schedulableEnabled": schedulable_enabled,
|
||||
"unschedulableEnabled": unschedulable_enabled,
|
||||
"missing": missing,
|
||||
"mismatched": mismatched,
|
||||
"items": items,
|
||||
"valuesPrinted": False,
|
||||
}
|
||||
|
||||
def api_key_preview(api_key):
|
||||
if len(api_key) <= 14:
|
||||
return "***"
|
||||
@@ -1852,12 +2068,13 @@ def run_sync():
|
||||
raise RuntimeError("pool group id missing after ensure")
|
||||
account_results, pruned_account_results = ensure_accounts(token, profiles, group_id)
|
||||
capacity_status = account_capacity_status(token)
|
||||
ws_v2_status = account_ws_v2_status(token)
|
||||
api_key, secret_action, secret_apply_stdout = ensure_api_key_secret(group_id)
|
||||
api_key_result = ensure_sub2api_api_key(token, api_key, group_id)
|
||||
owner_balance = ensure_pool_owner_balance(token, api_key_result["userId"])
|
||||
gateway = validate_gateway(api_key)
|
||||
return {
|
||||
"ok": gateway["ok"] is True and capacity_status["ok"] is True,
|
||||
"ok": gateway["ok"] is True and capacity_status["ok"] is True and ws_v2_status["ok"] is True,
|
||||
"mode": "sync",
|
||||
"namespace": NAMESPACE,
|
||||
"serviceDns": SERVICE_DNS,
|
||||
@@ -1874,6 +2091,7 @@ def run_sync():
|
||||
"valuesPrinted": False,
|
||||
},
|
||||
"capacity": capacity_status,
|
||||
"webSocketsV2": ws_v2_status,
|
||||
"apiKey": {
|
||||
"name": POOL_API_KEY_NAME,
|
||||
"secret": f"{NAMESPACE}/{POOL_API_KEY_SECRET_NAME}.{POOL_API_KEY_SECRET_KEY}",
|
||||
@@ -1900,9 +2118,10 @@ def run_validate():
|
||||
if key_item is not None and key_item.get("user_id") is not None:
|
||||
owner_balance = ensure_pool_owner_balance(token, key_item["user_id"])
|
||||
capacity_status = account_capacity_status(token)
|
||||
ws_v2_status = account_ws_v2_status(token)
|
||||
gateway = validate_gateway(api_key)
|
||||
return {
|
||||
"ok": gateway["ok"] is True and capacity_status["ok"] is True,
|
||||
"ok": gateway["ok"] is True and capacity_status["ok"] is True and ws_v2_status["ok"] is True,
|
||||
"mode": "validate",
|
||||
"namespace": NAMESPACE,
|
||||
"serviceDns": SERVICE_DNS,
|
||||
@@ -1917,6 +2136,7 @@ def run_validate():
|
||||
},
|
||||
"ownerBalance": owner_balance,
|
||||
"capacity": capacity_status,
|
||||
"webSocketsV2": ws_v2_status,
|
||||
"validation": {"gatewayModels": gateway},
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user