feat: extend G14 runtime automation
This commit is contained in:
@@ -0,0 +1,848 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { existsSync, readFileSync, readdirSync } from "node:fs";
|
||||
import { homedir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import type { UniDeskConfig } from "./config";
|
||||
import { runSshCommandCapture, type SshCaptureResult } from "./ssh";
|
||||
|
||||
const g14K3sRoute = "G14:k3s";
|
||||
const namespace = "platform-infra";
|
||||
const serviceName = "sub2api";
|
||||
const serviceDns = `${serviceName}.${namespace}.svc.cluster.local:8080`;
|
||||
const fieldManager = "unidesk-platform-infra";
|
||||
const appSecretName = "sub2api-secrets";
|
||||
const poolGroupName = "unidesk-codex-pool";
|
||||
const poolApiKeyName = "unidesk-codex-pool-api-key";
|
||||
const poolApiKeySecretName = "sub2api-codex-pool-api-key";
|
||||
const poolApiKeySecretKey = "API_KEY";
|
||||
|
||||
interface DisclosureOptions {
|
||||
full: boolean;
|
||||
raw: boolean;
|
||||
}
|
||||
|
||||
interface SyncOptions extends DisclosureOptions {
|
||||
confirm: boolean;
|
||||
}
|
||||
|
||||
interface CodexProfile {
|
||||
profile: string;
|
||||
accountName: string;
|
||||
configFile: string;
|
||||
authFile: string;
|
||||
provider: string;
|
||||
baseUrl: string;
|
||||
wireApi: string | null;
|
||||
model: string | null;
|
||||
envKey: string | null;
|
||||
apiKey: string | null;
|
||||
apiKeySource: "auth-json" | "env" | null;
|
||||
authOpenAIKeyShape: string;
|
||||
ok: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
export function codexPoolHelp(): unknown {
|
||||
return {
|
||||
command: "platform-infra sub2api codex-pool plan|sync|validate",
|
||||
output: "json",
|
||||
usage: [
|
||||
"bun scripts/cli.ts platform-infra sub2api codex-pool plan",
|
||||
"bun scripts/cli.ts platform-infra sub2api codex-pool sync --confirm",
|
||||
"bun scripts/cli.ts platform-infra sub2api codex-pool validate [--full|--raw]",
|
||||
],
|
||||
description: "Import ~/.codex/config.toml plus config.toml.* API-key profiles into one Sub2API OpenAI pool and expose one internal API_KEY stored in a k3s Secret.",
|
||||
target: {
|
||||
route: g14K3sRoute,
|
||||
namespace,
|
||||
serviceDns,
|
||||
poolGroupName,
|
||||
poolApiKeySecretName,
|
||||
poolApiKeySecretKey,
|
||||
secretValuesPrinted: false,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function runCodexPoolCommand(config: UniDeskConfig, args: string[]): Promise<Record<string, unknown>> {
|
||||
const [action = "plan"] = args;
|
||||
if (action === "plan") return codexPoolPlan();
|
||||
if (action === "sync") return await codexPoolSync(config, parseSyncOptions(args.slice(1)));
|
||||
if (action === "validate") return await codexPoolValidate(config, parseDisclosureOptions(args.slice(1)));
|
||||
return {
|
||||
ok: false,
|
||||
error: "unsupported-platform-infra-sub2api-codex-pool-command",
|
||||
args,
|
||||
help: codexPoolHelp(),
|
||||
};
|
||||
}
|
||||
|
||||
function parseSyncOptions(args: string[]): SyncOptions {
|
||||
validateOptions(args, new Set(["--confirm", "--full", "--raw"]));
|
||||
const disclosure = parseDisclosureOptions(args.filter((arg) => arg !== "--confirm"));
|
||||
return { ...disclosure, confirm: args.includes("--confirm") };
|
||||
}
|
||||
|
||||
function parseDisclosureOptions(args: string[]): DisclosureOptions {
|
||||
validateOptions(args, new Set(["--full", "--raw"]));
|
||||
const raw = args.includes("--raw");
|
||||
return { full: raw || args.includes("--full"), raw };
|
||||
}
|
||||
|
||||
function validateOptions(args: string[], booleanOptions: Set<string>): void {
|
||||
for (const arg of args) {
|
||||
if (booleanOptions.has(arg)) continue;
|
||||
throw new Error(`unsupported option: ${arg}`);
|
||||
}
|
||||
}
|
||||
|
||||
function codexPoolPlan(): Record<string, unknown> {
|
||||
const profiles = collectCodexProfiles();
|
||||
const ok = profiles.length > 0 && profiles.every((profile) => profile.ok);
|
||||
return {
|
||||
ok,
|
||||
action: "platform-infra-sub2api-codex-pool-plan",
|
||||
source: {
|
||||
directory: join(homedir(), ".codex"),
|
||||
configPattern: "config.toml and config.toml.*",
|
||||
authPattern: "auth.json and auth.json.*",
|
||||
valuesPrinted: false,
|
||||
},
|
||||
target: poolTarget(),
|
||||
profiles: profiles.map(redactProfile),
|
||||
decision: {
|
||||
accountType: "openai/apikey",
|
||||
grouping: `All discovered Codex profiles are bound to one Sub2API group named ${poolGroupName}.`,
|
||||
unifiedApiKey: `The client-facing API_KEY is controlled by k3s Secret ${namespace}/${poolApiKeySecretName}.${poolApiKeySecretKey}.`,
|
||||
idempotency: "sync reuses the group, account names, and k3s Secret when they already exist; credentials are updated from the current local Codex files.",
|
||||
configPolicy: "UniDesk-owned durable configuration remains YAML-first; local ~/.codex files and runtime Secrets are not committed.",
|
||||
},
|
||||
next: ok
|
||||
? { sync: "bun scripts/cli.ts platform-infra sub2api codex-pool sync --confirm" }
|
||||
: { fix: "Ensure every discovered config.toml profile has a base_url and either auth.json OPENAI_API_KEY or the configured env_key present in this shell." },
|
||||
};
|
||||
}
|
||||
|
||||
async function codexPoolSync(config: UniDeskConfig, options: SyncOptions): Promise<Record<string, unknown>> {
|
||||
const profiles = collectCodexProfiles();
|
||||
const planOk = profiles.length > 0 && profiles.every((profile) => profile.ok);
|
||||
if (!options.confirm || !planOk) {
|
||||
return {
|
||||
...codexPoolPlan(),
|
||||
ok: !options.confirm ? planOk : false,
|
||||
mode: options.confirm ? "blocked-invalid-local-profile" : "dry-run",
|
||||
next: options.confirm
|
||||
? { fix: "Repair invalid local Codex profiles, then rerun sync --confirm." }
|
||||
: { confirm: "bun scripts/cli.ts platform-infra sub2api codex-pool sync --confirm" },
|
||||
};
|
||||
}
|
||||
|
||||
const payload = {
|
||||
pool: {
|
||||
groupName: poolGroupName,
|
||||
apiKeyName: poolApiKeyName,
|
||||
apiKeySecretName: poolApiKeySecretName,
|
||||
apiKeySecretKey: poolApiKeySecretKey,
|
||||
},
|
||||
profiles: profiles.map((profile) => ({
|
||||
profile: profile.profile,
|
||||
accountName: profile.accountName,
|
||||
configFile: profile.configFile,
|
||||
authFile: profile.authFile,
|
||||
provider: profile.provider,
|
||||
baseUrl: profile.baseUrl,
|
||||
wireApi: profile.wireApi,
|
||||
model: profile.model,
|
||||
apiKey: profile.apiKey,
|
||||
apiKeySource: profile.apiKeySource,
|
||||
apiKeyFingerprint: fingerprint(profile.apiKey ?? ""),
|
||||
})),
|
||||
};
|
||||
const result = await capture(config, g14K3sRoute, ["script"], syncScript(payload));
|
||||
const parsed = parseJsonOutput(result.stdout);
|
||||
if (options.raw) {
|
||||
return {
|
||||
ok: result.exitCode === 0 && boolField(parsed, "ok", false),
|
||||
action: "platform-infra-sub2api-codex-pool-sync",
|
||||
remote: compactCapture(result, { full: true }),
|
||||
parsed,
|
||||
};
|
||||
}
|
||||
return {
|
||||
ok: result.exitCode === 0 && boolField(parsed, "ok", false),
|
||||
action: "platform-infra-sub2api-codex-pool-sync",
|
||||
local: {
|
||||
profiles: profiles.map(redactProfile),
|
||||
valuesPrinted: false,
|
||||
},
|
||||
remote: parsed ?? compactCapture(result, { full: options.full || result.exitCode !== 0 }),
|
||||
next: {
|
||||
validate: "bun scripts/cli.ts platform-infra sub2api codex-pool validate",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function codexPoolValidate(config: UniDeskConfig, options: DisclosureOptions): Promise<Record<string, unknown>> {
|
||||
const result = await capture(config, g14K3sRoute, ["script"], validateScript());
|
||||
const parsed = parseJsonOutput(result.stdout);
|
||||
if (options.raw) {
|
||||
return {
|
||||
ok: result.exitCode === 0 && boolField(parsed, "ok", false),
|
||||
action: "platform-infra-sub2api-codex-pool-validate",
|
||||
remote: compactCapture(result, { full: true }),
|
||||
parsed,
|
||||
};
|
||||
}
|
||||
return {
|
||||
ok: result.exitCode === 0 && boolField(parsed, "ok", false),
|
||||
action: "platform-infra-sub2api-codex-pool-validate",
|
||||
summary: parsed,
|
||||
remote: compactCapture(result, { full: options.full || result.exitCode !== 0 }),
|
||||
};
|
||||
}
|
||||
|
||||
function collectCodexProfiles(): CodexProfile[] {
|
||||
const codexDir = join(homedir(), ".codex");
|
||||
if (!existsSync(codexDir)) return [];
|
||||
const configFiles = readdirSync(codexDir)
|
||||
.filter((file) => file === "config.toml" || file.startsWith("config.toml."))
|
||||
.sort((a, b) => {
|
||||
if (a === "config.toml") return -1;
|
||||
if (b === "config.toml") return 1;
|
||||
return a.localeCompare(b);
|
||||
});
|
||||
const seenAccountNames = new Set<string>();
|
||||
return configFiles.map((configFile) => {
|
||||
const suffix = configFile === "config.toml" ? "" : configFile.slice("config.toml.".length);
|
||||
const profile = suffix === "" ? "default" : suffix;
|
||||
const accountName = uniqueAccountName(profile, seenAccountNames);
|
||||
const authFile = suffix === "" ? "auth.json" : `auth.json.${suffix}`;
|
||||
const configPath = join(codexDir, configFile);
|
||||
const authPath = join(codexDir, authFile);
|
||||
const base: CodexProfile = {
|
||||
profile,
|
||||
accountName,
|
||||
configFile,
|
||||
authFile,
|
||||
provider: "",
|
||||
baseUrl: "",
|
||||
wireApi: null,
|
||||
model: null,
|
||||
envKey: null,
|
||||
apiKey: null,
|
||||
apiKeySource: null,
|
||||
authOpenAIKeyShape: existsSync(authPath) ? "unknown" : "missing",
|
||||
ok: false,
|
||||
error: null,
|
||||
};
|
||||
|
||||
try {
|
||||
const parsed = Bun.TOML.parse(readFileSync(configPath, "utf8")) as unknown;
|
||||
if (!isRecord(parsed)) throw new Error("config is not a TOML object");
|
||||
const providers = parsed.model_providers;
|
||||
if (!isRecord(providers)) throw new Error("model_providers is missing");
|
||||
const provider = stringValue(parsed.model_provider) ?? Object.keys(providers)[0] ?? "";
|
||||
if (provider === "") throw new Error("model_provider is missing");
|
||||
const providerConfig = providers[provider];
|
||||
if (!isRecord(providerConfig)) throw new Error(`model provider ${provider} is missing`);
|
||||
const baseUrl = normalizeBaseUrl(stringValue(providerConfig.base_url));
|
||||
if (baseUrl === null) throw new Error(`model provider ${provider} base_url is missing or invalid`);
|
||||
base.provider = provider;
|
||||
base.baseUrl = baseUrl;
|
||||
base.envKey = stringValue(providerConfig.env_key);
|
||||
base.wireApi = stringValue(providerConfig.wire_api);
|
||||
base.model = stringValue(parsed.model);
|
||||
|
||||
const auth = readAuthAPIKey(authPath);
|
||||
base.authOpenAIKeyShape = auth.shape;
|
||||
if (auth.apiKey !== null) {
|
||||
base.apiKey = auth.apiKey;
|
||||
base.apiKeySource = "auth-json";
|
||||
} else if (base.envKey !== null && typeof process.env[base.envKey] === "string" && process.env[base.envKey]!.length > 0) {
|
||||
base.apiKey = process.env[base.envKey]!;
|
||||
base.apiKeySource = "env";
|
||||
}
|
||||
if (base.apiKey === null || base.apiKey.length === 0) {
|
||||
throw new Error(base.envKey === null ? "auth OPENAI_API_KEY is missing or empty" : `auth OPENAI_API_KEY is missing and env ${base.envKey} is not present`);
|
||||
}
|
||||
base.ok = true;
|
||||
return base;
|
||||
} catch (error) {
|
||||
base.error = error instanceof Error ? error.message : String(error);
|
||||
return base;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function readAuthAPIKey(authPath: string): { apiKey: string | null; shape: string } {
|
||||
if (!existsSync(authPath)) return { apiKey: null, shape: "missing" };
|
||||
const parsed = JSON.parse(readFileSync(authPath, "utf8")) as unknown;
|
||||
if (!isRecord(parsed)) return { apiKey: null, shape: "non-object" };
|
||||
const value = parsed.OPENAI_API_KEY;
|
||||
const shape = value === null ? "null" : Array.isArray(value) ? "array" : typeof value;
|
||||
if (typeof value === "string" && value.length > 0) return { apiKey: value, shape };
|
||||
return { apiKey: null, shape };
|
||||
}
|
||||
|
||||
function uniqueAccountName(profile: string, seen: Set<string>): string {
|
||||
const normalized = profile
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9._-]+/gu, "-")
|
||||
.replace(/^-+|-+$/gu, "") || "default";
|
||||
let candidate = `unidesk-codex-${normalized}`;
|
||||
let counter = 2;
|
||||
while (seen.has(candidate)) {
|
||||
candidate = `unidesk-codex-${normalized}-${counter}`;
|
||||
counter += 1;
|
||||
}
|
||||
seen.add(candidate);
|
||||
return candidate;
|
||||
}
|
||||
|
||||
function redactProfile(profile: CodexProfile): Record<string, unknown> {
|
||||
return {
|
||||
profile: profile.profile,
|
||||
accountName: profile.accountName,
|
||||
configFile: profile.configFile,
|
||||
authFile: profile.authFile,
|
||||
provider: profile.provider || null,
|
||||
baseUrl: profile.baseUrl || null,
|
||||
wireApi: profile.wireApi,
|
||||
model: profile.model,
|
||||
envKey: profile.envKey,
|
||||
apiKeySource: profile.apiKeySource,
|
||||
apiKeyPresent: profile.apiKey !== null && profile.apiKey.length > 0,
|
||||
apiKeyBytes: profile.apiKey === null ? 0 : Buffer.byteLength(profile.apiKey, "utf8"),
|
||||
apiKeyFingerprint: profile.apiKey === null ? null : fingerprint(profile.apiKey),
|
||||
authOpenAIKeyShape: profile.authOpenAIKeyShape,
|
||||
ok: profile.ok,
|
||||
error: profile.error,
|
||||
valuesPrinted: false,
|
||||
};
|
||||
}
|
||||
|
||||
function poolTarget(): Record<string, unknown> {
|
||||
return {
|
||||
route: g14K3sRoute,
|
||||
namespace,
|
||||
service: serviceName,
|
||||
serviceDns,
|
||||
groupName: poolGroupName,
|
||||
apiKeyName: poolApiKeyName,
|
||||
apiKeySecret: `${namespace}/${poolApiKeySecretName}.${poolApiKeySecretKey}`,
|
||||
valuesPrinted: false,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeBaseUrl(value: string | null): string | null {
|
||||
if (value === null) return null;
|
||||
const trimmed = value.trim().replace(/\/+$/u, "");
|
||||
if (trimmed.length === 0) return null;
|
||||
try {
|
||||
const parsed = new URL(trimmed);
|
||||
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return null;
|
||||
return parsed.toString().replace(/\/+$/u, "");
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function stringValue(value: unknown): string | null {
|
||||
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function fingerprint(value: string): string {
|
||||
return createHash("sha256").update(value).digest("hex").slice(0, 12);
|
||||
}
|
||||
|
||||
function syncScript(payload: unknown): string {
|
||||
const encoded = Buffer.from(JSON.stringify(payload), "utf8").toString("base64");
|
||||
return remotePythonScript("sync", encoded);
|
||||
}
|
||||
|
||||
function validateScript(): string {
|
||||
return remotePythonScript("validate", "");
|
||||
}
|
||||
|
||||
function remotePythonScript(mode: "sync" | "validate", encodedPayload: string): string {
|
||||
return `
|
||||
set -u
|
||||
python3 - <<'PY'
|
||||
import base64
|
||||
import json
|
||||
import secrets
|
||||
import string
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from urllib.parse import quote
|
||||
|
||||
NAMESPACE = "${namespace}"
|
||||
SERVICE_NAME = "${serviceName}"
|
||||
SERVICE_DNS = "${serviceDns}"
|
||||
FIELD_MANAGER = "${fieldManager}"
|
||||
APP_SECRET_NAME = "${appSecretName}"
|
||||
POOL_GROUP_NAME = "${poolGroupName}"
|
||||
POOL_API_KEY_NAME = "${poolApiKeyName}"
|
||||
POOL_API_KEY_SECRET_NAME = "${poolApiKeySecretName}"
|
||||
POOL_API_KEY_SECRET_KEY = "${poolApiKeySecretKey}"
|
||||
MODE = "${mode}"
|
||||
PAYLOAD_B64 = "${encodedPayload}"
|
||||
|
||||
def run(cmd, input_bytes=None):
|
||||
return subprocess.run(cmd, input=input_bytes, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
||||
|
||||
def text(data, limit=4000):
|
||||
if isinstance(data, bytes):
|
||||
data = data.decode("utf-8", errors="replace")
|
||||
return data[-limit:]
|
||||
|
||||
def kubectl(args, input_obj=None):
|
||||
if isinstance(input_obj, str):
|
||||
input_bytes = input_obj.encode("utf-8")
|
||||
else:
|
||||
input_bytes = input_obj
|
||||
return run(["kubectl", *args], input_bytes)
|
||||
|
||||
def require_kubectl(args, input_obj=None, label="kubectl"):
|
||||
proc = kubectl(args, input_obj)
|
||||
if proc.returncode != 0:
|
||||
raise RuntimeError(f"{label} failed: {text(proc.stderr, 1000)}")
|
||||
return proc.stdout
|
||||
|
||||
def kube_json(args, label):
|
||||
raw = require_kubectl([*args, "-o", "json"], label=label)
|
||||
return json.loads(raw.decode("utf-8"))
|
||||
|
||||
def decode_secret_value(name, key):
|
||||
data = kube_json(["-n", NAMESPACE, "get", "secret", name], f"secret/{name}").get("data") or {}
|
||||
if key not in data:
|
||||
return None
|
||||
return base64.b64decode(data[key]).decode("utf-8")
|
||||
|
||||
def get_config_value(name, key):
|
||||
data = kube_json(["-n", NAMESPACE, "get", "configmap", name], f"configmap/{name}").get("data") or {}
|
||||
value = data.get(key)
|
||||
return value if isinstance(value, str) and value else None
|
||||
|
||||
def select_app_pod():
|
||||
pods = kube_json(["-n", NAMESPACE, "get", "pods", "-l", "app.kubernetes.io/name=sub2api"], "sub2api pods").get("items") or []
|
||||
for pod in pods:
|
||||
status = pod.get("status") or {}
|
||||
if status.get("phase") != "Running":
|
||||
continue
|
||||
statuses = status.get("containerStatuses") or []
|
||||
if statuses and all(item.get("ready") is True for item in statuses):
|
||||
return pod["metadata"]["name"]
|
||||
if pods:
|
||||
return pods[0]["metadata"]["name"]
|
||||
raise RuntimeError("sub2api app pod not found")
|
||||
|
||||
APP_POD = select_app_pod()
|
||||
|
||||
def parse_curl_output(proc):
|
||||
stdout = proc.stdout.decode("utf-8", errors="replace")
|
||||
marker = "\\n__HTTP_CODE__:"
|
||||
pos = stdout.rfind(marker)
|
||||
if pos < 0:
|
||||
return {
|
||||
"ok": False,
|
||||
"httpStatus": 0,
|
||||
"json": None,
|
||||
"body": stdout,
|
||||
"stderr": text(proc.stderr, 1000),
|
||||
"transportExitCode": proc.returncode,
|
||||
}
|
||||
body = stdout[:pos]
|
||||
status_text = stdout[pos + len(marker):].strip()
|
||||
try:
|
||||
http_status = int(status_text[-3:])
|
||||
except ValueError:
|
||||
http_status = 0
|
||||
try:
|
||||
parsed = json.loads(body) if body.strip() else None
|
||||
except json.JSONDecodeError:
|
||||
parsed = None
|
||||
return {
|
||||
"ok": proc.returncode == 0 and 200 <= http_status < 300,
|
||||
"httpStatus": http_status,
|
||||
"json": parsed,
|
||||
"body": body,
|
||||
"stderr": text(proc.stderr, 1000),
|
||||
"transportExitCode": proc.returncode,
|
||||
}
|
||||
|
||||
def curl_api(method, path, bearer=None, payload=None):
|
||||
body = b"" if payload is None else json.dumps(payload, separators=(",", ":")).encode("utf-8")
|
||||
script = r'''
|
||||
set -eu
|
||||
method="$1"
|
||||
url="$2"
|
||||
token="\${3:-}"
|
||||
tmp="$(mktemp)"
|
||||
trap 'rm -f "$tmp"' EXIT
|
||||
cat > "$tmp"
|
||||
if [ -n "$token" ]; then
|
||||
if [ "$method" = "GET" ] && [ ! -s "$tmp" ]; then
|
||||
curl -sS -w '\\n__HTTP_CODE__:%{http_code}' -X "$method" -H "Authorization: Bearer $token" "$url"
|
||||
else
|
||||
curl -sS -w '\\n__HTTP_CODE__:%{http_code}' -X "$method" -H 'Content-Type: application/json' -H "Authorization: Bearer $token" --data-binary @"$tmp" "$url"
|
||||
fi
|
||||
else
|
||||
if [ "$method" = "GET" ] && [ ! -s "$tmp" ]; then
|
||||
curl -sS -w '\\n__HTTP_CODE__:%{http_code}' -X "$method" "$url"
|
||||
else
|
||||
curl -sS -w '\\n__HTTP_CODE__:%{http_code}' -X "$method" -H 'Content-Type: application/json' --data-binary @"$tmp" "$url"
|
||||
fi
|
||||
fi
|
||||
'''
|
||||
proc = run([
|
||||
"kubectl", "-n", NAMESPACE, "exec", "-i", APP_POD,
|
||||
"--", "sh", "-c", script, "sh", method, f"http://127.0.0.1:8080{path}", bearer or "",
|
||||
], body)
|
||||
return parse_curl_output(proc)
|
||||
|
||||
def envelope_data(parsed):
|
||||
if isinstance(parsed, dict) and "data" in parsed:
|
||||
return parsed.get("data")
|
||||
return parsed
|
||||
|
||||
def ensure_success(resp, label):
|
||||
parsed = resp.get("json")
|
||||
code = parsed.get("code") if isinstance(parsed, dict) else None
|
||||
if not resp.get("ok") or (code is not None and code != 0):
|
||||
message = parsed.get("message") if isinstance(parsed, dict) else text(resp.get("body", ""), 500)
|
||||
raise RuntimeError(f"{label} failed: http={resp.get('httpStatus')} message={message}")
|
||||
return envelope_data(parsed)
|
||||
|
||||
def extract_items(data):
|
||||
if isinstance(data, list):
|
||||
return data
|
||||
if isinstance(data, dict):
|
||||
if isinstance(data.get("items"), list):
|
||||
return data["items"]
|
||||
for key in ("groups", "accounts", "api_keys", "keys"):
|
||||
if isinstance(data.get(key), list):
|
||||
return data[key]
|
||||
return []
|
||||
|
||||
def find_access_token(data):
|
||||
if isinstance(data, dict):
|
||||
for key in ("access_token", "token"):
|
||||
if isinstance(data.get(key), str) and data[key]:
|
||||
return data[key]
|
||||
for value in data.values():
|
||||
token = find_access_token(value)
|
||||
if token:
|
||||
return token
|
||||
return None
|
||||
|
||||
def login():
|
||||
admin_email = get_config_value("sub2api-config", "ADMIN_EMAIL") or "admin@sub2api.platform-infra.local"
|
||||
admin_password = decode_secret_value(APP_SECRET_NAME, "ADMIN_PASSWORD")
|
||||
if not admin_password:
|
||||
raise RuntimeError("ADMIN_PASSWORD missing from sub2api-secrets")
|
||||
data = ensure_success(curl_api("POST", "/api/v1/auth/login", payload={"email": admin_email, "password": admin_password}), "admin login")
|
||||
token = find_access_token(data)
|
||||
if not token:
|
||||
raise RuntimeError("admin login response did not contain access_token")
|
||||
return admin_email, token
|
||||
|
||||
def group_payload():
|
||||
return {
|
||||
"name": POOL_GROUP_NAME,
|
||||
"description": "UniDesk-managed Codex API-key pool for G14 k3s internal Sub2API clients.",
|
||||
"platform": "openai",
|
||||
"rate_multiplier": 1,
|
||||
"is_exclusive": False,
|
||||
"subscription_type": "standard",
|
||||
"allow_messages_dispatch": True,
|
||||
"require_oauth_only": False,
|
||||
"require_privacy_set": False,
|
||||
"rpm_limit": 0,
|
||||
}
|
||||
|
||||
def ensure_group(token):
|
||||
existing_data = ensure_success(curl_api("GET", "/api/v1/admin/groups/all?platform=openai", bearer=token), "list groups")
|
||||
existing = next((item for item in extract_items(existing_data) if item.get("name") == POOL_GROUP_NAME), None)
|
||||
payload = group_payload()
|
||||
if existing is None:
|
||||
created = ensure_success(curl_api("POST", "/api/v1/admin/groups", bearer=token, payload=payload), "create group")
|
||||
return created, "created"
|
||||
group_id = existing.get("id")
|
||||
if group_id is None:
|
||||
raise RuntimeError("existing group has no id")
|
||||
payload["status"] = "active"
|
||||
updated = ensure_success(curl_api("PUT", f"/api/v1/admin/groups/{group_id}", bearer=token, payload=payload), "update group")
|
||||
return updated if isinstance(updated, dict) else existing, "updated"
|
||||
|
||||
def list_accounts(token):
|
||||
path = "/api/v1/admin/accounts?page=1&page_size=200&platform=openai&type=apikey&search=" + quote("unidesk-codex-")
|
||||
data = ensure_success(curl_api("GET", path, bearer=token), "list accounts")
|
||||
return extract_items(data)
|
||||
|
||||
def account_payload(profile, group_id):
|
||||
return {
|
||||
"name": profile["accountName"],
|
||||
"notes": f"UniDesk-managed Codex profile {profile['profile']} from {profile['configFile']} and {profile['authFile']}; secret source={profile['apiKeySource']}; fingerprint={profile['apiKeyFingerprint']}.",
|
||||
"platform": "openai",
|
||||
"type": "apikey",
|
||||
"credentials": {
|
||||
"api_key": profile["apiKey"],
|
||||
"base_url": profile["baseUrl"],
|
||||
},
|
||||
"extra": {
|
||||
"openai_responses_mode": "force_responses",
|
||||
"unidesk_codex_profile": profile["profile"],
|
||||
"unidesk_managed": True,
|
||||
},
|
||||
"concurrency": 1,
|
||||
"priority": 1,
|
||||
"rate_multiplier": 1,
|
||||
"load_factor": 1,
|
||||
"group_ids": [group_id],
|
||||
"confirm_mixed_channel_risk": True,
|
||||
}
|
||||
|
||||
def ensure_accounts(token, profiles, group_id):
|
||||
existing = {item.get("name"): item for item in list_accounts(token)}
|
||||
results = []
|
||||
for profile in profiles:
|
||||
payload = account_payload(profile, group_id)
|
||||
current = existing.get(profile["accountName"])
|
||||
if current and current.get("id") is not None:
|
||||
account_id = current["id"]
|
||||
update_payload = dict(payload)
|
||||
update_payload.pop("platform", None)
|
||||
update_payload["status"] = "active"
|
||||
data = ensure_success(curl_api("PUT", f"/api/v1/admin/accounts/{account_id}", bearer=token, payload=update_payload), f"update account {profile['accountName']}")
|
||||
action = "updated"
|
||||
else:
|
||||
data = ensure_success(curl_api("POST", "/api/v1/admin/accounts", bearer=token, payload=payload), f"create account {profile['accountName']}")
|
||||
action = "created"
|
||||
results.append({
|
||||
"profile": profile["profile"],
|
||||
"accountName": profile["accountName"],
|
||||
"accountId": data.get("id") if isinstance(data, dict) else None,
|
||||
"action": action,
|
||||
"baseUrl": profile["baseUrl"],
|
||||
"apiKeySource": profile["apiKeySource"],
|
||||
"apiKeyFingerprint": profile["apiKeyFingerprint"],
|
||||
"valuesPrinted": False,
|
||||
})
|
||||
return results
|
||||
|
||||
def generate_api_key():
|
||||
alphabet = string.ascii_letters + string.digits
|
||||
return "sk-unidesk-codex-" + "".join(secrets.choice(alphabet) for _ in range(48))
|
||||
|
||||
def ensure_api_key_secret(group_id):
|
||||
existing = None
|
||||
try:
|
||||
existing = decode_secret_value(POOL_API_KEY_SECRET_NAME, POOL_API_KEY_SECRET_KEY)
|
||||
except Exception:
|
||||
existing = None
|
||||
api_key = existing if existing else generate_api_key()
|
||||
secret_action = "kept-existing" if existing else "created"
|
||||
manifest = {
|
||||
"apiVersion": "v1",
|
||||
"kind": "Secret",
|
||||
"metadata": {
|
||||
"name": POOL_API_KEY_SECRET_NAME,
|
||||
"namespace": NAMESPACE,
|
||||
"labels": {
|
||||
"app.kubernetes.io/name": "sub2api",
|
||||
"app.kubernetes.io/part-of": "platform-infra",
|
||||
"app.kubernetes.io/managed-by": "unidesk",
|
||||
"unidesk.ai/secret-purpose": "sub2api-codex-pool-api-key",
|
||||
},
|
||||
},
|
||||
"type": "Opaque",
|
||||
"stringData": {
|
||||
POOL_API_KEY_SECRET_KEY: api_key,
|
||||
"GROUP_ID": str(group_id),
|
||||
"GROUP_NAME": POOL_GROUP_NAME,
|
||||
"SERVICE_DNS": SERVICE_DNS,
|
||||
},
|
||||
}
|
||||
proc = kubectl(["apply", "--server-side", "--force-conflicts", f"--field-manager={FIELD_MANAGER}", "-f", "-"], json.dumps(manifest))
|
||||
if proc.returncode != 0:
|
||||
raise RuntimeError(f"apply API key secret failed: {text(proc.stderr, 1000)}")
|
||||
return api_key, secret_action, text(proc.stdout, 1000)
|
||||
|
||||
def list_user_keys(token):
|
||||
data = ensure_success(curl_api("GET", "/api/v1/keys?page=1&page_size=200", bearer=token), "list user keys")
|
||||
return extract_items(data)
|
||||
|
||||
def ensure_sub2api_api_key(token, api_key, group_id):
|
||||
keys = list_user_keys(token)
|
||||
existing = next((item for item in keys if item.get("key") == api_key), None)
|
||||
action = "kept-existing"
|
||||
if existing is None:
|
||||
existing = next((item for item in keys if item.get("name") == POOL_API_KEY_NAME), None)
|
||||
if existing is None or existing.get("key") != api_key:
|
||||
payload = {
|
||||
"name": POOL_API_KEY_NAME,
|
||||
"group_id": group_id,
|
||||
"custom_key": api_key,
|
||||
"quota": 0,
|
||||
"rate_limit_5h": 0,
|
||||
"rate_limit_1d": 0,
|
||||
"rate_limit_7d": 0,
|
||||
}
|
||||
created = ensure_success(curl_api("POST", "/api/v1/keys", bearer=token, payload=payload), "create pool API key")
|
||||
existing = created if isinstance(created, dict) else existing
|
||||
action = "created"
|
||||
elif existing.get("id") is not None and existing.get("group_id") != group_id:
|
||||
updated = ensure_success(curl_api("PUT", f"/api/v1/keys/{existing['id']}", bearer=token, payload={"name": POOL_API_KEY_NAME, "group_id": group_id}), "update pool API key group")
|
||||
existing = updated if isinstance(updated, dict) else existing
|
||||
action = "updated-group"
|
||||
return {
|
||||
"action": action,
|
||||
"id": existing.get("id") if isinstance(existing, dict) else None,
|
||||
"name": existing.get("name") if isinstance(existing, dict) else POOL_API_KEY_NAME,
|
||||
"groupId": existing.get("group_id") if isinstance(existing, dict) else group_id,
|
||||
}
|
||||
|
||||
def validate_gateway(api_key):
|
||||
resp = curl_api("GET", "/v1/models", bearer=api_key)
|
||||
parsed = resp.get("json")
|
||||
model_count = None
|
||||
if isinstance(parsed, dict) and isinstance(parsed.get("data"), list):
|
||||
model_count = len(parsed["data"])
|
||||
return {
|
||||
"ok": resp.get("ok"),
|
||||
"httpStatus": resp.get("httpStatus"),
|
||||
"transportExitCode": resp.get("transportExitCode"),
|
||||
"modelCount": model_count,
|
||||
"bodyPreview": text(resp.get("body", ""), 500) if not resp.get("ok") else "",
|
||||
"stderr": resp.get("stderr", ""),
|
||||
"method": "GET /v1/models",
|
||||
"serviceDns": SERVICE_DNS,
|
||||
"valuesPrinted": False,
|
||||
}
|
||||
|
||||
def api_key_preview(api_key):
|
||||
if len(api_key) <= 14:
|
||||
return "***"
|
||||
return api_key[:10] + "..." + api_key[-4:]
|
||||
|
||||
def run_sync():
|
||||
payload = json.loads(base64.b64decode(PAYLOAD_B64).decode("utf-8"))
|
||||
profiles = payload.get("profiles") or []
|
||||
if not profiles:
|
||||
raise RuntimeError("sync payload has no profiles")
|
||||
admin_email, token = login()
|
||||
group, group_action = ensure_group(token)
|
||||
group_id = group.get("id") if isinstance(group, dict) else None
|
||||
if group_id is None:
|
||||
raise RuntimeError("pool group id missing after ensure")
|
||||
account_results = ensure_accounts(token, profiles, group_id)
|
||||
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)
|
||||
gateway = validate_gateway(api_key)
|
||||
return {
|
||||
"ok": gateway["ok"] is True,
|
||||
"mode": "sync",
|
||||
"namespace": NAMESPACE,
|
||||
"serviceDns": SERVICE_DNS,
|
||||
"appPod": APP_POD,
|
||||
"admin": {"email": admin_email, "tokenPrinted": False},
|
||||
"pool": {"name": POOL_GROUP_NAME, "id": group_id, "action": group_action, "platform": group.get("platform") if isinstance(group, dict) else "openai"},
|
||||
"accounts": {
|
||||
"desired": len(profiles),
|
||||
"created": sum(1 for item in account_results if item["action"] == "created"),
|
||||
"updated": sum(1 for item in account_results if item["action"] == "updated"),
|
||||
"items": account_results,
|
||||
"valuesPrinted": False,
|
||||
},
|
||||
"apiKey": {
|
||||
"name": POOL_API_KEY_NAME,
|
||||
"secret": f"{NAMESPACE}/{POOL_API_KEY_SECRET_NAME}.{POOL_API_KEY_SECRET_KEY}",
|
||||
"secretAction": secret_action,
|
||||
"secretApply": secret_apply_stdout,
|
||||
"sub2apiAction": api_key_result["action"],
|
||||
"sub2apiId": api_key_result["id"],
|
||||
"groupId": api_key_result["groupId"],
|
||||
"keyPreview": api_key_preview(api_key),
|
||||
"valuesPrinted": False,
|
||||
},
|
||||
"validation": {"gatewayModels": gateway},
|
||||
}
|
||||
|
||||
def run_validate():
|
||||
api_key = decode_secret_value(POOL_API_KEY_SECRET_NAME, POOL_API_KEY_SECRET_KEY)
|
||||
if not api_key:
|
||||
raise RuntimeError(f"{POOL_API_KEY_SECRET_NAME}.{POOL_API_KEY_SECRET_KEY} missing")
|
||||
gateway = validate_gateway(api_key)
|
||||
return {
|
||||
"ok": gateway["ok"] is True,
|
||||
"mode": "validate",
|
||||
"namespace": NAMESPACE,
|
||||
"serviceDns": SERVICE_DNS,
|
||||
"appPod": APP_POD,
|
||||
"apiKey": {
|
||||
"secret": f"{NAMESPACE}/{POOL_API_KEY_SECRET_NAME}.{POOL_API_KEY_SECRET_KEY}",
|
||||
"keyPreview": api_key_preview(api_key),
|
||||
"valuesPrinted": False,
|
||||
},
|
||||
"validation": {"gatewayModels": gateway},
|
||||
}
|
||||
|
||||
try:
|
||||
result = run_sync() if MODE == "sync" else run_validate()
|
||||
except Exception as exc:
|
||||
result = {
|
||||
"ok": False,
|
||||
"mode": MODE,
|
||||
"namespace": NAMESPACE,
|
||||
"serviceDns": SERVICE_DNS,
|
||||
"appPod": globals().get("APP_POD"),
|
||||
"error": str(exc),
|
||||
"valuesPrinted": False,
|
||||
}
|
||||
|
||||
print(json.dumps(result, ensure_ascii=False, indent=2))
|
||||
sys.exit(0 if result.get("ok") else 1)
|
||||
PY
|
||||
`;
|
||||
}
|
||||
|
||||
async function capture(config: UniDeskConfig, target: string, args: string[], input?: string): Promise<SshCaptureResult> {
|
||||
return await runSshCommandCapture(config, target, args, input);
|
||||
}
|
||||
|
||||
function parseJsonOutput(stdout: string): Record<string, unknown> | null {
|
||||
const trimmed = stdout.trim();
|
||||
if (trimmed.length === 0) return null;
|
||||
const start = trimmed.indexOf("{");
|
||||
const end = trimmed.lastIndexOf("}");
|
||||
if (start === -1 || end === -1 || end <= start) return null;
|
||||
try {
|
||||
const parsed = JSON.parse(trimmed.slice(start, end + 1)) as unknown;
|
||||
return typeof parsed === "object" && parsed !== null && !Array.isArray(parsed) ? parsed as Record<string, unknown> : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function boolField(value: Record<string, unknown> | null, key: string, defaultValue: boolean): boolean {
|
||||
if (value === null) return defaultValue;
|
||||
const field = value[key];
|
||||
return typeof field === "boolean" ? field : defaultValue;
|
||||
}
|
||||
|
||||
function compactCapture(result: SshCaptureResult, options: { full?: boolean } = {}): Record<string, unknown> {
|
||||
const full = options.full ?? false;
|
||||
return {
|
||||
exitCode: result.exitCode,
|
||||
stdoutBytes: Buffer.byteLength(result.stdout, "utf8"),
|
||||
stderrBytes: Buffer.byteLength(result.stderr, "utf8"),
|
||||
stdoutTail: full || result.exitCode !== 0 ? result.stdout.slice(-8000) : "",
|
||||
stderrTail: full || result.exitCode !== 0 ? result.stderr.slice(-4000) : "",
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user