The copyable text opened with a "# hint" line — but default interactive zsh has interactivecomments off, so the line was parsed as a command, and the "!" inside «(перезапишет!)» fired history expansion: pasting the OpenCode snippet died with "zsh: event not found: )" before doing anything. Hints now live in the UI above the snippet; the clipboard gets commands only. Verified by piping the actual rendered snippet through an interactive zsh: the config file lands intact.
444 lines
18 KiB
JavaScript
Vendored
444 lines
18 KiB
JavaScript
Vendored
// API keys — list, create (with copy-once), revoke.
|
|
import { h, mount, modal, toast, confirmDialog, moneyEl, num } from "../ui.js";
|
|
import { t } from "../i18n.js";
|
|
import { api } from "../api.js";
|
|
|
|
export async function renderKeys() {
|
|
const root = h("div");
|
|
let usageRange = "7d";
|
|
|
|
function openCreate(reload) {
|
|
const accountIn = h("input", { placeholder: "team-a" });
|
|
const rateIn = h("input", { type: "number", value: 600 });
|
|
const budgetIn = h("input", { type: "number", step: "1", min: "0", placeholder: "0" });
|
|
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.budget")), budgetIn, h("div", { class: "hint" }, t("keys.form.budgetHint"))),
|
|
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,
|
|
monthly_budget_usd: parseFloat(budgetIn.value) > 0 ? parseFloat(budgetIn.value) : null,
|
|
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";
|
|
let key = lastCreatedKey || (openMode ? "sk-anything" : "<" + t("keys.snip.keyPh") + ">");
|
|
// Every scripty snippet exists in two OS flavors: bash heredocs for
|
|
// Linux/macOS and PowerShell here-strings for Windows. SDK snippets
|
|
// (python/js) and GUI instructions are OS-independent.
|
|
let os = /win/i.test(navigator.platform || "") ? "ps" : "sh";
|
|
// Harness auto-setup scripts first (the gateway speaks OpenAI at /v1/* and
|
|
// Anthropic at /v1/messages, so Claude Code connects natively), SDKs after.
|
|
const buildSnips = (osKind) => osKind === "ps" ? {
|
|
"Claude Code": `$env:ANTHROPIC_BASE_URL = "${origin}"
|
|
$env:ANTHROPIC_AUTH_TOKEN = "${key}"
|
|
$env:ANTHROPIC_MODEL = "cortiq-auto"
|
|
|
|
claude`,
|
|
Codex: `New-Item -ItemType Directory -Force "$HOME\\.codex" | Out-Null
|
|
Add-Content "$HOME\\.codex\\config.toml" @'
|
|
model = "cortiq-auto"
|
|
model_provider = "cortiq"
|
|
|
|
[model_providers.cortiq]
|
|
name = "Cortiq Gateway"
|
|
base_url = "${base}"
|
|
env_key = "CORTIQ_API_KEY"
|
|
wire_api = "chat"
|
|
'@
|
|
$env:CORTIQ_API_KEY = "${key}"
|
|
|
|
codex`,
|
|
OpenCode: `New-Item -ItemType Directory -Force "$HOME\\.config\\opencode" | Out-Null
|
|
Set-Content "$HOME\\.config\\opencode\\opencode.json" @'
|
|
{
|
|
"$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)" } }
|
|
}
|
|
}
|
|
}
|
|
'@
|
|
|
|
opencode`,
|
|
Continue: `New-Item -ItemType Directory -Force "$HOME\\.continue" | Out-Null
|
|
Add-Content "$HOME\\.continue\\config.yaml" @'
|
|
models:
|
|
- name: Cortiq Auto
|
|
provider: openai
|
|
model: cortiq-auto
|
|
apiBase: ${base}
|
|
apiKey: ${key}
|
|
'@`,
|
|
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.exe ${base}/chat/completions \`
|
|
-H "Authorization: Bearer ${key}" \`
|
|
-H "Content-Type: application/json" \`
|
|
-d '{"model":"cortiq-auto","messages":[{"role":"user","content":"Привет!"}]}'`,
|
|
} : {
|
|
"Claude Code": `export ANTHROPIC_BASE_URL="${origin}"
|
|
export ANTHROPIC_AUTH_TOKEN="${key}"
|
|
export ANTHROPIC_MODEL="cortiq-auto"
|
|
|
|
claude`,
|
|
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: `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: `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 shSnips, psSnips, snips;
|
|
function rebuildSnips() {
|
|
shSnips = buildSnips("sh");
|
|
psSnips = buildSnips("ps");
|
|
// python/js/Cline одинаковы — дополняем ps-набор из sh
|
|
for (const k of Object.keys(shSnips)) if (!psSnips[k]) psSnips[k] = shSnips[k];
|
|
snips = os === "ps" ? psSnips : shSnips;
|
|
}
|
|
rebuildSnips();
|
|
let cur = "Claude Code";
|
|
// per-harness hint lives OUTSIDE the copyable text: a leading "# comment"
|
|
// in a pasted snippet is not a comment in default interactive zsh, and a
|
|
// "!" inside it triggers history expansion ("event not found")
|
|
const HINTS = {
|
|
"Claude Code": () => t("keys.h.claude"),
|
|
Codex: () => t("keys.h.codex"),
|
|
OpenCode: () => t("keys.h.opencode"),
|
|
Continue: () => t("keys.h.continue"),
|
|
};
|
|
const hintHost = h("div", { class: "muted small", style: "margin-bottom:6px" });
|
|
const paintHint = () => { hintHost.textContent = HINTS[cur] ? HINTS[cur]() : ""; };
|
|
const pre = h("pre", { class: "conv-log snippet" }, snips[cur]);
|
|
paintHint();
|
|
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];
|
|
paintHint();
|
|
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") —
|
|
// and a one-line INSTALL command (per OS) when it's missing.
|
|
const INSTALL = {
|
|
"Claude Code": { sh: "curl -fsSL https://claude.ai/install.sh | bash", ps: "irm https://claude.ai/install.ps1 | iex" },
|
|
Codex: { sh: "npm install -g @openai/codex", ps: "npm install -g @openai/codex" },
|
|
OpenCode: { sh: "curl -fsSL https://opencode.ai/install | bash", ps: "npm install -g opencode-ai" },
|
|
Continue: { sh: "code --install-extension Continue.continue", ps: "code --install-extension Continue.continue" },
|
|
Cline: { sh: "code --install-extension saoudrizwan.claude-dev", ps: "code --install-extension saoudrizwan.claude-dev" },
|
|
};
|
|
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 {
|
|
const cmd = (INSTALL[cur] || {})[os];
|
|
const copyInstall = h("button", { class: "btn ghost sm", onClick: async () => {
|
|
await navigator.clipboard.writeText(cmd);
|
|
toast(t("common.copied"), "good");
|
|
} }, "⧉");
|
|
mount(statusHost,
|
|
h("span", { class: "muted" }, "○ " + t("keys.h.st.notFound")),
|
|
cmd ? h("div", { class: "flex", style: "gap:6px; align-items:center; margin-top:4px" },
|
|
h("span", { class: "muted" }, t("keys.h.install")),
|
|
h("code", { class: "mono small cmd-chip" }, cmd),
|
|
copyInstall) : null);
|
|
}
|
|
}
|
|
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(() => {});
|
|
|
|
// OS switch: bash for Linux/macOS, PowerShell for Windows (auto-detected)
|
|
const osSeg = h(
|
|
"div",
|
|
{ class: "seg" },
|
|
h("button", { class: os === "sh" ? "active" : "" }, "🐧🍎 Linux / macOS"),
|
|
h("button", { class: os === "ps" ? "active" : "" }, "🪟 Windows")
|
|
);
|
|
osSeg.querySelectorAll("button").forEach((b, i) =>
|
|
b.addEventListener("click", () => {
|
|
os = i === 0 ? "sh" : "ps";
|
|
rebuildSnips();
|
|
osSeg.querySelectorAll("button").forEach((x, j) => x.classList.toggle("active", j === (os === "sh" ? 0 : 1)));
|
|
pre.textContent = snips[cur];
|
|
paintStatus();
|
|
}));
|
|
|
|
// paste-your-key: substitutes the key into every snippet immediately
|
|
const keyIn = h("input", { class: "mono", placeholder: t("keys.snip.keyInputPh"), style: "max-width:230px; font-size:12px", value: lastCreatedKey || "" });
|
|
const applyKey = () => {
|
|
key = keyIn.value.trim() || (openMode ? "sk-anything" : "<" + t("keys.snip.keyPh") + ">");
|
|
rebuildSnips();
|
|
pre.textContent = snips[cur];
|
|
};
|
|
const applyBtn = h("button", { class: "btn ghost sm", onClick: applyKey }, "⤵ " + t("keys.snip.apply"));
|
|
keyIn.addEventListener("keydown", (e) => { if (e.key === "Enter") applyKey(); });
|
|
|
|
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" }), keyIn, applyBtn, osSeg, copy),
|
|
hintHost,
|
|
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 || [];
|
|
// per-account usage: window breakdown + cumulative-forever totals — the
|
|
// "who spends what" view for departments/developers sharing one gateway
|
|
let usage = {};
|
|
let alltime = {};
|
|
try {
|
|
const st = await api.stats(usageRange, "account");
|
|
for (const b of st.breakdown || []) usage[b.key] = b;
|
|
alltime = st.alltime_by_account || {};
|
|
} catch { /* stats disabled — columns show dashes */ }
|
|
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("div", { class: "seg", style: "margin-right:10px" },
|
|
...["24h", "7d", "30d"].map((r) =>
|
|
h("button", { class: r === usageRange ? "active" : "", onClick: () => { usageRange = r; reload(); } }, r))),
|
|
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("keys.col.requests") + " (" + usageRange + ")"),
|
|
h("th", { class: "right-align" }, t("keys.col.tokens") + " (" + usageRange + ")"),
|
|
h("th", { class: "right-align" }, "$ (" + usageRange + ")"),
|
|
h("th", { class: "right-align" }, t("keys.col.costAll")),
|
|
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 mono" }, String((usage[k.account] || {}).requests ?? "—")),
|
|
h("td", { class: "right-align mono small" }, usage[k.account] ? num(usage[k.account].prompt_tokens) + " / " + num(usage[k.account].completion_tokens) : "—"),
|
|
h("td", { class: "right-align" }, usage[k.account] ? moneyEl(usage[k.account].cost_usd) : "—"),
|
|
h("td", { class: "right-align" },
|
|
alltime[k.account] ? moneyEl(alltime[k.account].cost_usd) : "—",
|
|
k.monthly_budget_usd
|
|
? h("div", { class: "small " + (((usage[k.account] || {}).cost_usd || 0) >= k.monthly_budget_usd ? "err" : "muted") },
|
|
t("keys.budgetOf", { b: "$" + k.monthly_budget_usd }))
|
|
: null),
|
|
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;
|
|
}
|