Add local model backends: per-spawn dropdown pointing claude at alternative endpoints

Operators can register Anthropic-API-compatible endpoints (a local Qwen/Llama
behind LiteLLM or claude-code-router, an LLM gateway) on the dashboard's
Claude -> Models tab and pick one from a Model dropdown when spawning an agent.
The agent still launches as the same claude binary with the same hooks, skills,
connectors, plugins, and gates — only its ANTHROPIC_BASE_URL / ANTHROPIC_MODEL /
ANTHROPIC_AUTH_TOKEN env differs — and it stays pinned to its backend across
resumes. No selection keeps the worker's Claude subscription untouched.

- claude_models table (+ agents.model_id pin), migration 0012
- control.models resolves a row into the launch env (API keys Fernet-encrypted
  at rest, decrypted only in the control container; placeholder key when none is
  stored so the subscription OAuth token never reaches a local endpoint)
- /claude/models CRUD (admin-gated writes, key never returned), spawn route +
  worker + CLI (--model) pass the selection through, fail-fast on missing or
  disabled backends
- dashboard: Models tab, spawn-form dropdown, model badge in the agents table
- docs/local-models.md: why bare OpenAI-compatible servers break tool calling
  with Qwen-Coder, and working vLLM/LiteLLM/llama.cpp stacks

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DzDofD7gP63WpeLG8vEdZu
This commit is contained in:
Claude
2026-07-29 18:36:16 +00:00
parent c4e6ae0faa
commit 2d5c0e34d7
47 changed files with 1219 additions and 60 deletions
+32 -1
View File
@@ -25,6 +25,7 @@ const emptySpawn = {
worktree: "",
subdir: "",
task: "",
model_id: "",
};
export function AgentsSection() {
@@ -35,6 +36,22 @@ export function AgentsSection() {
() => s.projects.map((p) => ({ value: p.id, label: p.id })),
[s.projects],
);
/* The seamless switch: Claude subscription by default, plus every enabled backend
* registered on the Claude page's Models tab. Same binary, hooks, and skills either
* way — only the ANTHROPIC_* env of the launched process differs. */
const modelOpts = useMemo(
() => [
{ value: "", label: "Claude (subscription)" },
...s.claudeModels
.filter((m) => m.enabled)
.map((m) => ({ value: String(m.id), label: `${m.name} (${m.model})` })),
],
[s.claudeModels],
);
const modelName = useMemo(() => {
const byId = new Map(s.claudeModels.map((m) => [m.id, m.name]));
return (id: number | null | undefined) => (id == null ? null : byId.get(id) ?? `#${id}`);
}, [s.claudeModels]);
const agents = useMemo(
() => s.agents.filter((a) => a.project_id === s.selectedProjectId),
[s.agents, s.selectedProjectId],
@@ -87,6 +104,12 @@ export function AgentsSection() {
) : (
<Input label="Subdir" value={form.subdir} onChange={(v) => setForm({ ...form, subdir: v })} placeholder="api" />
)}
<Select
label="Model"
value={form.model_id}
onChange={(v) => setForm({ ...form, model_id: v })}
options={modelOpts}
/>
</div>
<div className="mt14">
<Textarea
@@ -113,6 +136,7 @@ export function AgentsSection() {
<tr>
<th>Name</th>
<th>Role</th>
<th>Model</th>
<th>Status</th>
<th>Working dir</th>
<th>Created</th>
@@ -125,6 +149,13 @@ export function AgentsSection() {
<tr>
<td className="mono">{a.name}</td>
<td>{a.role ? <Badge tone="info">{a.role}</Badge> : "—"}</td>
<td>
{a.model_id != null ? (
<Badge tone="warning">{modelName(a.model_id)}</Badge>
) : (
<Badge tone="neutral">claude</Badge>
)}
</td>
<td>
<StatusBadge status={a.status} />
</td>
@@ -146,7 +177,7 @@ export function AgentsSection() {
</tr>
{(a.status === "working" || a.status === "crashed") && a.last_output?.trim() && (
<tr>
<td colSpan={6} style={{ paddingTop: 0 }}>
<td colSpan={7} style={{ paddingTop: 0 }}>
<div className="faint" style={{ fontSize: "var(--text-xs)", marginBottom: 4 }}>
{a.status === "crashed" ? "last output before crash" : "live output"}
{a.output_at ? ` · ${timeAgo(a.output_at)}` : ""}
+166 -5
View File
@@ -8,9 +8,9 @@
import { useState } from "react";
import { useDashboard } from "@/components/store";
import type { ConnectorBody, PluginBody, SkillBody } from "@/components/store";
import type { ConnectorBody, ModelBody, PluginBody, SkillBody } from "@/components/store";
import { Badge, Button, Card, Input, Select, Tabs, Textarea, Toggle } from "@/components/ui";
import type { ClaudeConnector, ClaudePlugin, ClaudeSkill } from "@/lib/api";
import type { ClaudeConnector, ClaudeModel, ClaudePlugin, ClaudeSkill } from "@/lib/api";
import { ClaudeLoginPanel } from "@/components/sections/LoginSection";
/* KEY=VALUE-per-line <-> map helpers for connector env/headers. */
@@ -499,6 +499,165 @@ function PluginsPanel() {
);
}
/* ---- Model backends ---------------------------------------------------------------- */
const emptyModel = {
name: "",
base_url: "",
model: "",
small_fast_model: "",
api_key: "",
env: "",
enabled: true,
};
function ModelsPanel() {
const s = useDashboard();
const [form, setForm] = useState(emptyModel);
const [editingId, setEditingId] = useState<number | null>(null);
const reset = () => {
setForm(emptyModel);
setEditingId(null);
};
const save = async () => {
const body: ModelBody = {
name: form.name,
base_url: form.base_url.trim(),
model: form.model.trim(),
small_fast_model: form.small_fast_model.trim() || null,
api_key: form.api_key.trim() || null,
env: parseKeyValues(form.env),
enabled: form.enabled,
};
const ok =
editingId != null
? await s.updateClaudeModel(editingId, body)
: await s.createClaudeModel(body);
if (ok) reset();
};
const edit = (m: ClaudeModel) => {
setForm({
name: m.name,
base_url: m.base_url,
model: m.model,
small_fast_model: m.small_fast_model ?? "",
api_key: "", // write-only; blank = keep the stored key
env: formatKeyValues(m.env),
enabled: m.enabled,
});
setEditingId(m.id);
};
return (
<>
<div className="faint" style={{ fontSize: "var(--text-sm)", marginBottom: 14 }}>
Alternative model backends the spawn dropdown offers next to the Claude
subscription the same <span className="mono">claude</span> binary pointed at a
different endpoint via <span className="mono">ANTHROPIC_BASE_URL</span>, so
skills, connectors, hooks, and gates apply unchanged. The endpoint must speak the{" "}
<b>Anthropic Messages API including tool use</b> a bare OpenAI-compatible
server (Ollama, llama.cpp, LM Studio) breaks tool calling; front it with LiteLLM
or claude-code-router and enable the backend&apos;s native tool parser. See{" "}
<span className="mono">docs/local-models.md</span> for working Qwen-Coder stacks.
</div>
<Card>
<div className="card-head" style={{ marginBottom: 14 }}>
<span className="card-title" style={{ fontSize: "var(--text-md)", color: "var(--text-heading)" }}>
{editingId != null ? `Edit model · ${form.name}` : "Add a model backend"}
</span>
</div>
<div className="form-grid">
<Input
label="Name (what the spawn dropdown shows)"
value={form.name}
onChange={(v) => setForm({ ...form, name: v })}
placeholder="qwen3-coder"
/>
<Input
label="Base URL (Anthropic-compatible /v1/messages endpoint)"
value={form.base_url}
onChange={(v) => setForm({ ...form, base_url: v })}
placeholder="http://llm.lan:4000"
/>
<Input
label="Model id (as the endpoint serves it)"
value={form.model}
onChange={(v) => setForm({ ...form, model: v })}
placeholder="qwen3-coder-30b"
/>
<Input
label="Small/fast model id (optional — defaults to the main model)"
value={form.small_fast_model}
onChange={(v) => setForm({ ...form, small_fast_model: v })}
placeholder="qwen3-1.7b"
/>
<Input
label={editingId != null ? "API key (blank = keep stored key)" : "API key (optional)"}
value={form.api_key}
onChange={(v) => setForm({ ...form, api_key: v })}
placeholder="sk-… (encrypted at rest, never shown again)"
/>
<Textarea
label="Extra env overrides (KEY=VALUE per line, optional)"
value={form.env}
onChange={(v) => setForm({ ...form, env: v })}
rows={3}
placeholder={"API_TIMEOUT_MS=600000\nCLAUDE_CODE_MAX_OUTPUT_TOKENS=8192"}
/>
</div>
<div className="hstack mt14">
<Button
variant="primary"
disabled={!form.name.trim() || !form.base_url.trim() || !form.model.trim()}
onClick={save}
>
{editingId != null ? "Save changes" : "Add model"}
</Button>
{editingId != null && (
<Button variant="ghost" onClick={reset}>
Cancel
</Button>
)}
</div>
</Card>
{s.claudeModels.length === 0 && (
<div className="empty">No model backends yet agents run on the Claude subscription.</div>
)}
{s.claudeModels.map((m) => (
<Card key={m.id}>
<div className="card-head">
<span className="mono" style={{ fontWeight: "var(--fw-bold)", fontSize: "var(--text-lg)", color: "var(--text-heading)" }}>
{m.name}
</span>
<div className="hstack">
{m.has_api_key && <Badge tone="info">key stored</Badge>}
<Badge tone={m.enabled ? "success" : "neutral"}>
{m.enabled ? "enabled" : "disabled"}
</Badge>
<Toggle on={m.enabled} onClick={() => s.updateClaudeModel(m.id, { enabled: !m.enabled })} />
<Button size="sm" variant="secondary" onClick={() => edit(m)}>
Edit
</Button>
<Button size="sm" variant="danger" onClick={() => s.deleteClaudeModel(m.id)}>
Remove
</Button>
</div>
</div>
<div className="mono faint" style={{ fontSize: "var(--text-xs)", marginTop: 8 }}>
{m.model}
{m.small_fast_model ? ` (fast: ${m.small_fast_model})` : ""} @ {m.base_url}
</div>
</Card>
))}
</>
);
}
/* ---- Permissions ------------------------------------------------------------------- */
const MODE_OPTS = [
@@ -594,6 +753,7 @@ function PermissionsPanel() {
const TABS = [
{ value: "account", label: "Account" },
{ value: "models", label: "Models" },
{ value: "skills", label: "Skills" },
{ value: "connectors", label: "Connectors" },
{ value: "plugins", label: "Plugins" },
@@ -608,9 +768,9 @@ export function ClaudeSection() {
<div className="section-head">
<div className="section-title">Claude</div>
<div className="section-desc">
Manage the Claude Code install agents run on: the account login, plus skills,
MCP connectors, plugins, and permissions. Changes apply to the next launch of
every agent.
Manage the Claude Code install agents run on: the account login, alternative
model backends, plus skills, MCP connectors, plugins, and permissions. Changes
apply to the next launch of every agent.
</div>
</div>
<div className="section-body">
@@ -618,6 +778,7 @@ export function ClaudeSection() {
<Tabs tabs={TABS} value={tab} onChange={setTab} />
</div>
{tab === "account" && <ClaudeLoginPanel />}
{tab === "models" && <ModelsPanel />}
{tab === "skills" && <SkillsPanel />}
{tab === "connectors" && <ConnectorsPanel />}
{tab === "plugins" && <PluginsPanel />}
+75 -3
View File
@@ -22,6 +22,7 @@ import {
type Approval,
type Checkmark,
type ClaudeConnector,
type ClaudeModel,
type ClaudePermissions,
type ClaudePlugin,
type ClaudeSkill,
@@ -146,6 +147,12 @@ interface StoreValue {
updateClaudePlugin: (id: number, b: Partial<PluginBody>) => Promise<boolean>;
deleteClaudePlugin: (id: number) => Promise<void>;
saveClaudePermissions: (b: PermissionsBody) => Promise<boolean>;
/* Model backends (Claude page Models tab + the spawn form's model dropdown). */
claudeModels: ClaudeModel[];
createClaudeModel: (b: ModelBody) => Promise<boolean>;
updateClaudeModel: (id: number, b: Partial<ModelBody>) => Promise<boolean>;
deleteClaudeModel: (id: number) => Promise<void>;
}
export interface SkillBody {
@@ -177,6 +184,18 @@ export interface PermissionsBody {
ask: string[];
}
export interface ModelBody {
name: string;
base_url: string;
model: string;
small_fast_model: string | null;
/* Write-only: encrypted at rest server-side, never echoed back. Null = no change. */
api_key: string | null;
clear_api_key?: boolean;
env: Record<string, string>;
enabled: boolean;
}
export interface SpawnBody {
name: string;
role: string;
@@ -184,6 +203,8 @@ export interface SpawnBody {
worktree: string;
subdir: string;
task: string;
/* Model backend id as a select value; "" = the Claude subscription. */
model_id: string;
}
export interface ProjectBody {
id: string;
@@ -291,6 +312,7 @@ export function DashboardProvider({
const [claudeConnectors, setClaudeConnectors] = useState<ClaudeConnector[]>([]);
const [claudePlugins, setClaudePlugins] = useState<ClaudePlugin[]>([]);
const [claudePermissions, setClaudePermissions] = useState<ClaudePermissions | null>(null);
const [claudeModels, setClaudeModels] = useState<ClaudeModel[]>([]);
// Keep polling loop reading fresh values without re-subscribing every render.
const sectionRef = useRef(section);
@@ -429,6 +451,16 @@ export function DashboardProvider({
}
}, []);
/* Models load separately from the rest of the Claude page: the Agents section's spawn
* dropdown needs them too, without dragging skills/connectors along. */
const loadClaudeModels = useCallback(async () => {
try {
setClaudeModels(await clientRef.current.api<ClaudeModel[]>("/claude/models"));
} catch (e) {
swallow(e);
}
}, []);
const loadClaude = useCallback(async () => {
try {
const [skills, connectors, plugins, permissions] = await Promise.all([
@@ -441,10 +473,11 @@ export function DashboardProvider({
setClaudeConnectors(connectors);
setClaudePlugins(plugins);
setClaudePermissions(permissions);
await loadClaudeModels();
} catch (e) {
swallow(e);
}
}, []);
}, [loadClaudeModels]);
/* One refresh cycle for whatever section is active (plus always-cheap projects/agents
* so the nav counts and inbox stay live). */
@@ -463,13 +496,14 @@ export function DashboardProvider({
const s = sectionRef.current;
const run = selectedRunRef.current;
if (run) await loadRun(run.projectId, run.name);
if (s === "agents") await loadClaudeModels(); // the spawn form's model dropdown
if (s === "approvals") await loadApprovals(selectedProjectRef.current);
if (s === "servers") await loadHosts();
if (s === "activity") await loadCommands();
if (s === "schedules") await loadSchedules();
if (s === "shared") await loadShared();
if (s === "claude") await loadClaude();
}, [loadAgents, loadRun, loadApprovals, loadHosts, loadCommands, loadSchedules, loadShared, loadClaude]);
}, [loadAgents, loadRun, loadApprovals, loadHosts, loadCommands, loadSchedules, loadShared, loadClaude, loadClaudeModels]);
// Initial load + polling loop. The first tick populates projects *and* agents (and the
// active section) up front, so the Runs inbox is filled without waiting a poll interval.
@@ -493,6 +527,7 @@ export function DashboardProvider({
(s: Section) => {
setSectionRaw(s);
setCmd({ text: "", error: false, busy: false });
if (s === "agents") void loadClaudeModels();
if (s === "approvals") void loadApprovals(selectedProjectRef.current);
if (s === "servers") void loadHosts();
if (s === "activity") void loadCommands();
@@ -500,7 +535,7 @@ export function DashboardProvider({
if (s === "shared") void loadShared();
if (s === "claude") void loadClaude();
},
[loadApprovals, loadHosts, loadCommands, loadSchedules, loadShared, loadClaude],
[loadApprovals, loadHosts, loadCommands, loadSchedules, loadShared, loadClaude, loadClaudeModels],
);
const selectProject = useCallback(
@@ -584,6 +619,7 @@ export function DashboardProvider({
};
if (f.placement === "worktree" && f.worktree.trim()) body.worktree = f.worktree.trim();
if (f.placement === "subdir" && f.subdir.trim()) body.subdir = f.subdir.trim();
if (f.model_id) body.model_id = Number(f.model_id);
const p = encodeURIComponent(selectedProjectRef.current);
const final = await enqueueAndTrack(`/projects/${p}/agents/spawn`, body, `spawn ${body.name}`);
await loadAgents(projects);
@@ -1190,6 +1226,38 @@ export function DashboardProvider({
[claudeWrite],
);
const createClaudeModel = useCallback(
(b: ModelBody) =>
claudeWrite(
() =>
clientRef.current.api("/claude/models", {
method: "POST",
body: { ...b, name: b.name.trim() },
}),
`model '${b.name.trim()}' saved — pick it from the spawn dropdown`,
),
[claudeWrite],
);
const updateClaudeModel = useCallback(
(id: number, b: Partial<ModelBody>) =>
claudeWrite(
() => clientRef.current.api(`/claude/models/${id}`, { method: "PATCH", body: b }),
"model updated — applies to the next agent launch",
),
[claudeWrite],
);
const deleteClaudeModel = useCallback(
async (id: number) => {
await claudeWrite(
() => clientRef.current.api(`/claude/models/${id}`, { method: "DELETE" }),
"model removed — agents pinned to it can no longer resume",
);
},
[claudeWrite],
);
const setSharedKey = useCallback(
async (key: string, value: string) => {
try {
@@ -1269,6 +1337,10 @@ export function DashboardProvider({
updateClaudePlugin,
deleteClaudePlugin,
saveClaudePermissions,
claudeModels,
createClaudeModel,
updateClaudeModel,
deleteClaudeModel,
};
return <Ctx.Provider value={value}>{children}</Ctx.Provider>;