// API keys — list, create (with copy-once), revoke. import { h, mount, modal, toast, confirmDialog } from "../ui.js"; import { t } from "../i18n.js"; import { api } from "../api.js"; export async function renderKeys() { const root = h("div"); function openCreate(reload) { const accountIn = h("input", { placeholder: "team-a" }); const rateIn = h("input", { type: "number", value: 600 }); const customIn = h("input", { placeholder: "sk-gw-…" }); const body = h( "div", {}, h("label", { class: "field" }, h("span", {}, t("keys.form.account")), accountIn), h("label", { class: "field" }, h("span", {}, t("keys.form.rate")), rateIn), h("label", { class: "field" }, h("span", {}, t("keys.form.custom")), customIn, h("div", { class: "hint" }, t("keys.form.customHint"))) ); modal(t("keys.form.title"), body, async () => { try { const r = await api.createKey({ key: customIn.value.trim(), account: accountIn.value.trim() || "default", rate_per_min: parseInt(rateIn.value) || 0, allow_models: [], }); reload(); showCreated(r.key); } catch (e) { toast(String(e.message || e), "bad"); return false; } }, t("common.add")); } function showCreated(key) { const input = h("input", { value: key, readonly: true, class: "mono" }); const copyBtn = h("button", { class: "btn", onClick: () => { input.select(); navigator.clipboard?.writeText(key); toast(t("common.copied"), "good"); } }, t("common.copy")); modal(t("common.ok"), h("div", {}, h("p", {}, t("keys.created")), h("div", { class: "flex" }, input, copyBtn)), null); lastCreatedKey = key; reload(); } let lastCreatedKey = null; // "Connect your app" — copy-paste snippets with this gateway's real URL. // Keys are stored hashed-masked in the list, so the key itself is a // placeholder unless one was just created on this page. function snippetsCard(openMode) { const origin = location.origin; const base = origin + "/v1"; const key = lastCreatedKey || (openMode ? "sk-anything" : "<" + t("keys.snip.keyPh") + ">"); // Harness auto-setup scripts first (the gateway speaks OpenAI at /v1/* and // Anthropic at /v1/messages, so Claude Code connects natively), SDKs after. const snips = { "Claude Code": `# ${t("keys.h.claude")} export ANTHROPIC_BASE_URL="${origin}" export ANTHROPIC_AUTH_TOKEN="${key}" export ANTHROPIC_MODEL="cortiq-auto" claude`, Codex: `# ${t("keys.h.codex")} mkdir -p ~/.codex && cat >> ~/.codex/config.toml <<'EOF' model = "cortiq-auto" model_provider = "cortiq" [model_providers.cortiq] name = "Cortiq Gateway" base_url = "${base}" env_key = "CORTIQ_API_KEY" wire_api = "chat" EOF export CORTIQ_API_KEY="${key}" codex`, OpenCode: `# ${t("keys.h.opencode")} mkdir -p ~/.config/opencode && cat > ~/.config/opencode/opencode.json <<'EOF' { "$schema": "https://opencode.ai/config.json", "provider": { "cortiq": { "npm": "@ai-sdk/openai-compatible", "name": "Cortiq Gateway", "options": { "baseURL": "${base}", "apiKey": "${key}" }, "models": { "cortiq-auto": { "name": "Cortiq Auto (smart routing)" } } } } } EOF opencode`, Continue: `# ${t("keys.h.continue")} mkdir -p ~/.continue && cat >> ~/.continue/config.yaml <<'EOF' models: - name: Cortiq Auto provider: openai model: cortiq-auto apiBase: ${base} apiKey: ${key} EOF`, Cline: `${t("keys.h.cline1")} API Provider: OpenAI Compatible Base URL: ${base} API Key: ${key} Model ID: cortiq-auto ${t("keys.h.cline2")}`, curl: `curl ${base}/chat/completions \\ -H "Authorization: Bearer ${key}" \\ -H "Content-Type: application/json" \\ -d '{"model":"cortiq-auto","messages":[{"role":"user","content":"Привет!"}]}'`, python: `from openai import OpenAI client = OpenAI(base_url="${base}", api_key="${key}") resp = client.chat.completions.create( model="cortiq-auto", # авто-роутинг; или точное имя модели messages=[{"role": "user", "content": "Привет!"}], ) print(resp.choices[0].message.content)`, javascript: `import OpenAI from "openai"; const client = new OpenAI({ baseURL: "${base}", apiKey: "${key}" }); const resp = await client.chat.completions.create({ model: "cortiq-auto", messages: [{ role: "user", content: "Привет!" }], }); console.log(resp.choices[0].message.content);`, }; let cur = "Claude Code"; const pre = h("pre", { class: "conv-log snippet" }, snips[cur]); const seg = h( "div", { class: "seg" }, ...Object.keys(snips).map((k, i) => h("button", { class: (i === 0 ? "active" : "") + (["curl", "python", "javascript"].includes(k) ? " sdk" : ""), "data-snip": k, onClick: (e) => { cur = k; pre.textContent = snips[k]; protoWarn.style.display = k === "Claude Code" && protoWarn.childNodes.length ? "" : "none"; paintStatus(); seg.querySelectorAll("button").forEach((b) => b.classList.remove("active")); e.currentTarget.classList.add("active"); } }, k) ) ); // Claude Code needs the anthropic_messages protocol — offer a one-click // enable when it is off, right where the user will hit the problem. const protoWarn = h("div", { style: "display:none; margin-top:8px" }); api.getProtocols().then((prot) => { if (prot.anthropic_messages) return; const fixBtn = h("button", { class: "btn sm primary" }, t("keys.h.enableProto")); fixBtn.addEventListener("click", async () => { try { await api.putProtocols({ ...prot, anthropic_messages: true }); protoWarn.style.display = "none"; toast(t("common.saved"), "ok"); } catch (e) { toast(String(e.message || e), "err"); } }); mount(protoWarn, h("div", { class: "callout warn" }, h("span", {}, "⚠ " + t("keys.h.protoOff")), " ", fixBtn)); if (cur === "Claude Code") protoWarn.style.display = ""; }).catch(() => {}); // Detected harnesses: ✓ on the tab when installed on the gateway machine, // status line under the snippet ("installed" / "already configured"). let detected = {}; const statusHost = h("div", { class: "small", style: "margin-top:6px" }); function paintStatus() { const d = detected[cur]; if (!d) { statusHost.textContent = ""; return; } statusHost.innerHTML = ""; if (d.configured) mount(statusHost, h("span", { class: "ok" }, "✓ " + t("keys.h.st.configured"))); else if (d.installed) mount(statusHost, h("span", { class: "ok" }, "✓ " + t("keys.h.st.installed"))); else mount(statusHost, h("span", { class: "muted" }, "○ " + t("keys.h.st.notFound"))); } api.harnesses().then((d) => { detected = d || {}; seg.querySelectorAll("button").forEach((b) => { const st = detected[b.dataset.snip]; if (st && st.installed) b.append(" ✓"); }); paintStatus(); }).catch(() => {}); const copy = h("button", { class: "btn ghost sm" }, "⧉ " + t("common.copy")); copy.addEventListener("click", async () => { await navigator.clipboard.writeText(pre.textContent); toast(t("common.copied"), "ok"); }); return h( "div", { class: "card" }, h("div", { class: "card-title" }, "🔌 " + t("keys.snip.title")), h("div", { class: "muted small", style: "margin-bottom:8px" }, lastCreatedKey ? t("keys.snip.withKey") : openMode ? t("keys.snip.openMode") : t("keys.snip.hint")), h("div", { class: "flex", style: "gap:8px; margin-bottom:8px; flex-wrap:wrap" }, seg, h("div", { class: "grow" }), copy), pre, statusHost, protoWarn, h("div", { class: "muted small", style: "margin-top:6px" }, t("keys.h.note")) ); } async function reload() { const data = await api.listKeys(); const keys = data.keys || []; mount( root, h( "div", { class: "page-head" }, h( "div", { class: "flex" }, h("div", { class: "grow" }, h("h2", {}, t("keys.title")), h("p", {}, t("keys.subtitle"))), h("button", { class: "btn primary", onClick: () => openCreate(reload) }, "+ " + t("keys.add")) ) ), data.open_mode ? h("div", { class: "callout info" }, t("keys.openMode")) : null, keys.length === 0 ? h("div", { class: "card" }, h("div", { class: "empty" }, t("keys.empty"))) : h( "div", { class: "card" }, h( "div", { class: "table-wrap" }, h( "table", {}, h( "thead", {}, h("tr", {}, h("th", {}, t("keys.col.key")), h("th", {}, t("keys.col.account")), h("th", {}, t("keys.col.rate")), h("th", {}, t("keys.col.models")), h("th", { class: "right-align" }, t("common.actions"))) ), h( "tbody", {}, ...keys.map((k) => h( "tr", {}, h("td", { class: "mono" }, k.key_masked), h("td", {}, k.account), h("td", { class: "mono" }, k.rate_per_min || "—"), h("td", {}, (k.allow_models && k.allow_models.length) ? k.allow_models.join(", ") : h("span", { class: "muted" }, t("keys.allowAll"))), h( "td", { class: "right-align" }, h( "button", { class: "btn sm danger", onClick: async () => { if (!(await confirmDialog(t("keys.revokeConfirm")))) return; try { await api.deleteKey(k.key); toast(t("toast.deleted"), "good"); reload(); } catch (e) { toast(String(e.message || e), "bad"); } }, }, t("keys.revoke") ) ) ) ) ) ) ) ), snippetsCard(!!data.open_mode) ); } await reload(); return root; }