summaryrefslogtreecommitdiffhomepage
path: root/packages/api/src
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-05-22 17:07:31 +0900
committerAdam Malczewski <[email protected]>2026-05-22 17:07:31 +0900
commit288b21cec98421fda57028a0c8c9d835cfbb14b0 (patch)
tree9ce3ec38acdcf7be96e28f1e0d5deccf6b46c917 /packages/api/src
parent45a4890031192f4e7409443f98e824dad17ba175 (diff)
downloaddispatch-288b21cec98421fda57028a0c8c9d835cfbb14b0.tar.gz
dispatch-288b21cec98421fda57028a0c8c9d835cfbb14b0.zip
feat: add/remove keys from UI, backend URL setting, user service, Docker fix
- Add POST /models/add-key and POST /models/remove-key API endpoints - Add 'Add New Key' modal (page-level) with provider selection - Add remove button per key in Model Status view - Add configurable backend URL setting in Settings panel with localStorage persistence - Convert systemd service from system to user service (systemctl --user) - Fix Docker entrypoint to chown all nested node_modules dirs - Update dispatch.toml credential paths to use -pro/-max naming - Make API port configurable via PORT env var (default 3000, prod 18390)
Diffstat (limited to 'packages/api/src')
-rw-r--r--packages/api/src/routes/models.ts124
1 files changed, 124 insertions, 0 deletions
diff --git a/packages/api/src/routes/models.ts b/packages/api/src/routes/models.ts
index 1daf37e..d6b82c2 100644
--- a/packages/api/src/routes/models.ts
+++ b/packages/api/src/routes/models.ts
@@ -1,3 +1,5 @@
+import { readFileSync, writeFileSync } from "node:fs";
+import { homedir } from "node:os";
import type { ModelRegistry } from "@dispatch/core";
import {
ANTHROPIC_MODELS_FALLBACK,
@@ -396,6 +398,128 @@ modelsRoutes.get("/credentials-status", (c) => {
return c.json({ credentials: status });
});
+// ─── Add key to dispatch.toml ─────────────────────────────────
+
+const VALID_PROVIDERS = ["anthropic", "opencode-go", "github-copilot"] as const;
+type SupportedProvider = (typeof VALID_PROVIDERS)[number];
+
+const PROVIDER_BASE_URLS: Record<SupportedProvider, string> = {
+ anthropic: "https://api.anthropic.com/v1",
+ "opencode-go": "https://opencode.ai/zen/go/v1",
+ "github-copilot": "https://api.githubcopilot.com",
+};
+
+modelsRoutes.post("/add-key", async (c) => {
+ const body = await c.req.json<{ id?: unknown; provider?: unknown }>();
+
+ // Validate id
+ if (typeof body.id !== "string" || !body.id.trim() || !/^[a-zA-Z0-9_-]+$/.test(body.id.trim())) {
+ return c.json({ error: "id must contain only letters, numbers, dashes, and underscores" }, 400);
+ }
+ const id = body.id.trim();
+
+ // Validate provider
+ if (!VALID_PROVIDERS.includes(body.provider as SupportedProvider)) {
+ return c.json(
+ { error: `provider must be one of: ${VALID_PROVIDERS.join(", ")}` },
+ 400,
+ );
+ }
+ const provider = body.provider as SupportedProvider;
+ const base_url = PROVIDER_BASE_URLS[provider];
+
+ // Read current dispatch.toml
+ const tomlPath = `${process.cwd()}/dispatch.toml`;
+ let tomlContent: string;
+ try {
+ tomlContent = readFileSync(tomlPath, "utf-8");
+ } catch (err) {
+ return c.json({ error: `failed to read dispatch.toml: ${String(err)}` }, 500);
+ }
+
+ // Check for duplicate key id
+ const idPattern = new RegExp(`^\\s*id\\s*=\\s*["']?${id}["']?\\s*$`, "m");
+ if (idPattern.test(tomlContent)) {
+ return c.json({ error: `key with id "${id}" already exists` }, 409);
+ }
+
+ // Build the new [[keys]] block
+ let newBlock = `\n[[keys]]\nid = "${id}"\nprovider = "${provider}"\nbase_url = "${base_url}"`;
+ if (provider === "anthropic") {
+ const credPath = `${homedir()}/.claude/.credentials-${id}.json`;
+ newBlock += `\ncredentials_file = "${credPath}"`;
+ }
+ newBlock += "\n";
+
+ // Insert before the # ─── Permissions section if it exists, otherwise at end
+ const permissionsMarker = /\n# [─\-]+ Permissions/;
+ let newContent: string;
+ const permMatch = permissionsMarker.exec(tomlContent);
+ if (permMatch) {
+ const insertAt = permMatch.index;
+ newContent = tomlContent.slice(0, insertAt) + newBlock + tomlContent.slice(insertAt);
+ } else {
+ newContent = tomlContent + newBlock;
+ }
+
+ try {
+ writeFileSync(tomlPath, newContent, "utf-8");
+ } catch (err) {
+ return c.json({ error: `failed to write dispatch.toml: ${String(err)}` }, 500);
+ }
+
+ const key: { id: string; provider: string; base_url: string; credentials_file?: string } = {
+ id,
+ provider,
+ base_url,
+ };
+ if (provider === "anthropic") {
+ key.credentials_file = `${homedir()}/.claude/.credentials-${id}.json`;
+ }
+
+ return c.json({ success: true, key });
+});
+
+// ─── Remove key from dispatch.toml ────────────────────────────
+
+modelsRoutes.post("/remove-key", async (c) => {
+ const body = await c.req.json<{ id?: unknown }>();
+
+ if (typeof body.id !== "string" || !body.id.trim()) {
+ return c.json({ error: "id is required" }, 400);
+ }
+ const id = body.id.trim();
+
+ const tomlPath = `${process.cwd()}/dispatch.toml`;
+ let tomlContent: string;
+ try {
+ tomlContent = readFileSync(tomlPath, "utf-8");
+ } catch (err) {
+ return c.json({ error: `failed to read dispatch.toml: ${String(err)}` }, 500);
+ }
+
+ // Match the [[keys]] block containing this id and remove it.
+ // A block starts with [[keys]] and ends at the next [[...]] header, # ─── section marker, or EOF.
+ const blockPattern = new RegExp(
+ `\\n?\\[\\[keys\\]\\]\\n(?:[^\\[#]|#(?! [─\\-]))*?id\\s*=\\s*"${id.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}"[^\\[#]*(?:\\n(?=\\[|# [─\\-])|$)`,
+ "s",
+ );
+ const match = blockPattern.exec(tomlContent);
+ if (!match) {
+ return c.json({ error: `key "${id}" not found in dispatch.toml` }, 404);
+ }
+
+ const newContent = tomlContent.slice(0, match.index) + tomlContent.slice(match.index + match[0].length);
+
+ try {
+ writeFileSync(tomlPath, newContent, "utf-8");
+ } catch (err) {
+ return c.json({ error: `failed to write dispatch.toml: ${String(err)}` }, 500);
+ }
+
+ return c.json({ success: true });
+});
+
// ─── Shared wake function ─────────────────────────────────────
async function wakeAllClaudeAccounts(): Promise<