Add llama.cpp backend, model gateway, URL relay and browser view

Four features, plus a latent bug fix.

llama.cpp backend. Claude Code only ever speaks the Anthropic Messages
API — confirmed empirically by pointing it at a logging server, which
received POST /v1/messages?beta=true. llama-server implements that
natively (verified in its README, alongside --port default 8080), so
this is a plain base-URL backend with no translation shim, the same
shape as Ollama. Its --api-key defaults to none, so the auth token is a
placeholder Claude Code requires and llama-server ignores.

Model alias fix. ANTHROPIC_DEFAULT_HAIKU_MODEL is documented as "also
used for background functionality", and Triple-C set none of the alias
vars. So on every custom-endpoint backend, Claude Code resolved `haiku`
to an Anthropic model id and sent it to a local server that does not
have it — background features failed silently. All four
ANTHROPIC_DEFAULT_{OPUS,SONNET,HAIKU,FABLE}_MODEL vars are now pinned to
the backend's configured model, with an optional Haiku override, and
blanked for Anthropic and Bedrock so those keep Claude Code's defaults.
The deprecated ANTHROPIC_SMALL_FAST_MODEL is never emitted. Existing
Ollama and OpenAI-Compatible containers are recreated once so the new
env reaches them; the snapshot is preserved.

Model gateway. Optional LiteLLM sibling container, off by default,
mirroring stt.rs — this is what makes real OpenAI usable, since
api.openai.com has no /v1/messages. Pinned to v1.96.0 by tag and digest:
the 1.82.7/1.82.8 malware was PyPI-only and never affected the official
images, which is precisely why this builds FROM the image rather than
pip-installing, but 1.84.0 is still the floor for proxy CVEs (API-key
SQLi, Host-header auth bypass, MCP auth bypass). Binds 0.0.0.0 because
project containers consume it, and therefore always sets a master_key —
LiteLLM without one accepts any key. The provider key lives in the OS
keychain and is uploaded into a volume, never an image layer or label.

URL relay. A container-side xdg-open/BROWSER shim opens URLs in the
host's browser. Uses an OSC sequence to /dev/tty rather than a printed
sentinel, because the shim usually runs as a grandchild of a process
capturing its children's output. Degrades to printing the URL when no
terminal is attached, so scheduled tasks do not hang. Only http/https,
with control characters rejected before new URL() — which strips
newlines, so java\nscript: would otherwise parse as javascript:. Nothing
auto-opens; the user confirms. The web terminal shows a tap-to-open
banner instead, since that browser may be a phone across a tunnel.

Browser view. A Project Home tab that watches and takes over the browser
Claude drives with Playwright, using Playwright's own dashboard. Zero
image cost — Playwright stays user-installed. It does not reuse the auth
bridge's PortForward, which binds an unauthenticated port: correct for a
throwaway OAuth listener, wrong for mouse and keyboard control of a
browser in a passwordless-sudo container. Instead a token-gated loopback
proxy checks Host, then token or a forbidden-header origin signal,
before a byte reaches the container. Host ports are confined to
47820..=47827 so CSP frame-src can enumerate them rather than widening
to a wildcard, with a test asserting the two agree.

188 frontend tests, 107 Rust tests, both builds clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-09 16:55:28 -07:00
co-authored by Claude Opus 5
parent 7d00390e1f
commit cc5f691677
46 changed files with 6194 additions and 61 deletions
@@ -0,0 +1,178 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
import BrowserTab from "./BrowserTab";
import type { BrowserViewStatus, Project } from "../../../lib/types";
const getBrowserViewStatus = vi.fn<() => Promise<BrowserViewStatus>>();
const setBrowserViewEnabled = vi.fn<() => Promise<BrowserViewStatus>>();
const pushToast = vi.fn();
vi.mock("../../../lib/tauri-commands", () => ({
getBrowserViewStatus: () => getBrowserViewStatus(),
setBrowserViewEnabled: () => setBrowserViewEnabled(),
}));
vi.mock("@tauri-apps/api/event", () => ({
listen: vi.fn(async () => () => {}),
}));
vi.mock("../../../store/appState", () => ({
useAppState: (selector: (s: unknown) => unknown) => selector({ pushToast }),
}));
const OFF: BrowserViewStatus = {
enabled: false,
state: "off",
url: null,
host_port: null,
container_port: null,
started_at: null,
detection: null,
message: null,
};
const project: Project = {
id: "p1",
name: "api-server",
paths: [{ host_path: "/home/user/api", mount_name: "api" }],
container_id: "c1",
status: "running",
backend: "anthropic",
bedrock_config: null,
ollama_config: null,
openai_compatible_config: null,
allow_docker_access: false,
sandbox_mode_enabled: true,
mission_control_enabled: false,
auth_bridge_enabled: false,
use_shared_auth_token: true,
full_permissions: false,
permission_mode: "bypass",
ssh_key_path: null,
git_token: null,
git_user_name: null,
git_user_email: null,
custom_env_vars: [],
port_mappings: [],
claude_instructions: null,
claude_code_settings: null,
renamed_session_names: {},
created_at: "2026-01-01T00:00:00Z",
updated_at: "2026-01-01T00:00:00Z",
} as unknown as Project;
beforeEach(() => {
vi.clearAllMocks();
getBrowserViewStatus.mockResolvedValue(OFF);
});
describe("BrowserTab", () => {
it("does not offer to start anything while the container is stopped", async () => {
render(<BrowserTab project={{ ...project, status: "stopped" }} active />);
expect(await screen.findByText(/container isnt running/i)).toBeInTheDocument();
expect(screen.queryByRole("button", { name: /start browser view/i })).toBeNull();
expect(getBrowserViewStatus).not.toHaveBeenCalled();
});
it("starts off, and never starts a view without being asked", async () => {
render(<BrowserTab project={project} active />);
await waitFor(() => expect(getBrowserViewStatus).toHaveBeenCalled());
expect(screen.getByText("Off")).toBeInTheDocument();
expect(screen.queryByTitle(/browser view for/i)).toBeNull();
expect(setBrowserViewEnabled).not.toHaveBeenCalled();
});
it("shows the live pane, pointed at loopback with a token, once started", async () => {
setBrowserViewEnabled.mockResolvedValue({
...OFF,
enabled: true,
state: "running",
url: "http://127.0.0.1:47820/index.html?ws=abc&token=SEKRIT",
host_port: 47820,
container_port: 39321,
started_at: "2026-08-09T10:00:00Z",
});
render(<BrowserTab project={project} active />);
await waitFor(() => expect(getBrowserViewStatus).toHaveBeenCalled());
await act(async () => {
fireEvent.click(screen.getByRole("button", { name: /start browser view/i }));
});
const frame = await screen.findByTitle("Playwright browser view for api-server");
expect(frame).toHaveAttribute(
"src",
"http://127.0.0.1:47820/index.html?ws=abc&token=SEKRIT",
);
expect(screen.getByText("Live")).toBeInTheDocument();
expect(screen.getByText(/127\.0\.0\.1:47820 → container :39321/)).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Stop" })).toBeInTheDocument();
});
it("explains precisely what is missing instead of spinning", async () => {
getBrowserViewStatus.mockResolvedValue({
...OFF,
enabled: true,
state: "unavailable",
message:
"Playwright isn't installed in this container. Install it with `npm i -D playwright`.",
detection: {
node_version: "22.11.0",
playwright_version: null,
playwright_path: null,
has_bind: false,
cli_version: null,
cli_entry: null,
searched: ["/workspace", "/usr/lib/node_modules"],
},
});
render(<BrowserTab project={project} active />);
expect(await screen.findByText(/npm i -D playwright/)).toBeInTheDocument();
expect(screen.getByText("Unavailable")).toBeInTheDocument();
// The probe's findings are shown, so the user can see why.
expect(screen.getByText("22.11.0")).toBeInTheDocument();
expect(screen.getByText("not in this build")).toBeInTheDocument();
expect(screen.getByText(/usr\/lib\/node_modules/)).toBeInTheDocument();
expect(screen.queryByTitle(/browser view for/i)).toBeNull();
});
it("surfaces a start failure rather than leaving the pane blank", async () => {
setBrowserViewEnabled.mockRejectedValue("container went away");
render(<BrowserTab project={project} active />);
await waitFor(() => expect(getBrowserViewStatus).toHaveBeenCalled());
await act(async () => {
fireEvent.click(screen.getByRole("button", { name: /start browser view/i }));
});
expect(await screen.findByText(/didnt start/i)).toBeInTheDocument();
expect(screen.getByText(/container went away/)).toBeInTheDocument();
expect(pushToast).toHaveBeenCalledWith(
expect.objectContaining({ kind: "error" }),
);
});
it("stops the view when asked", async () => {
getBrowserViewStatus.mockResolvedValue({
...OFF,
enabled: true,
state: "running",
url: "http://127.0.0.1:47821/?token=T",
host_port: 47821,
container_port: 39321,
});
setBrowserViewEnabled.mockResolvedValue(OFF);
render(<BrowserTab project={project} active />);
const stop = await screen.findByRole("button", { name: "Stop" });
await act(async () => {
fireEvent.click(stop);
});
await waitFor(() => expect(setBrowserViewEnabled).toHaveBeenCalled());
expect(await screen.findByText("Off")).toBeInTheDocument();
expect(screen.queryByTitle(/browser view for/i)).toBeNull();
});
});
@@ -0,0 +1,261 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { listen } from "@tauri-apps/api/event";
import type {
BrowserViewChangedEvent,
BrowserViewStatus,
Project,
} from "../../../lib/types";
import {
getBrowserViewStatus,
setBrowserViewEnabled,
} from "../../../lib/tauri-commands";
import { useAppState } from "../../../store/appState";
import Button from "../../ui/Button";
import StatusIndicator from "../../ui/StatusIndicator";
interface Props {
project: Project;
active: boolean;
}
const OFF: BrowserViewStatus = {
enabled: false,
state: "off",
url: null,
host_port: null,
container_port: null,
started_at: null,
detection: null,
message: null,
};
/**
* Watch — and take over — the browser Claude is driving with Playwright inside
* the container.
*
* The pane is an iframe onto Playwright's own live dashboard, which runs in the
* container and is reached through a token-gated listener on the host's
* loopback. Nothing starts until the user asks: this is remote control of a
* browser in a privileged sandbox, so it is off by default and opted into per
* project, exactly like the auth bridge.
*/
export default function BrowserTab({ project, active }: Props) {
const [status, setStatus] = useState<BrowserViewStatus>(OFF);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
/** Bumped to force the iframe to reload without changing its src. */
const [reloadKey, setReloadKey] = useState(0);
const pushToast = useAppState((s) => s.pushToast);
const running = project.status === "running";
// The backend is the source of truth: it emits whenever a view starts or is
// torn down (container stopped, project removed, viewer died).
const projectId = project.id;
const mounted = useRef(true);
useEffect(() => {
mounted.current = true;
return () => {
mounted.current = false;
};
}, []);
useEffect(() => {
let dispose: (() => void) | undefined;
listen<BrowserViewChangedEvent>("browser-view-changed", (event) => {
if (event.payload.project_id === projectId && mounted.current) {
setStatus(event.payload.status);
}
}).then((un) => {
if (mounted.current) dispose = un;
else un();
});
return () => dispose?.();
}, [projectId]);
useEffect(() => {
if (!active || !running) return;
getBrowserViewStatus(projectId)
.then((s) => mounted.current && setStatus(s))
.catch(() => {});
}, [active, projectId, running]);
const toggle = useCallback(
async (next: boolean) => {
setBusy(true);
setError(null);
try {
const result = await setBrowserViewEnabled(projectId, next);
if (mounted.current) setStatus(result);
} catch (e) {
const detail = String(e);
if (mounted.current) setError(detail);
pushToast({
kind: "error",
message: next ? "Could not start the browser view" : "Could not stop the browser view",
detail,
});
} finally {
if (mounted.current) setBusy(false);
}
},
[projectId, pushToast],
);
// A stopped container can't be hosting a browser, so say that plainly rather
// than offering a control that would only fail.
if (!running) {
return (
<Explainer title="The container isnt running.">
Start the container, have Claude drive a browser with Playwright, then come
back here to watch it.
</Explainer>
);
}
const live = status.state === "running" && status.url;
return (
<div className="flex flex-col h-full min-h-0">
<div className="flex items-center gap-2 px-4 py-2 border-b border-[var(--border-color)] flex-shrink-0 flex-wrap">
<StatusIndicator
tone={
busy
? "busy"
: status.state === "running"
? "running"
: status.state === "unavailable"
? "error"
: "off"
}
label={
busy
? "Starting"
: status.state === "running"
? "Live"
: status.state === "unavailable"
? "Unavailable"
: "Off"
}
/>
{live && (
<span className="text-xs text-[var(--text-secondary)] font-mono truncate">
127.0.0.1:{status.host_port} container :{status.container_port}
</span>
)}
<div className="flex-1" />
{live && (
<Button size="md" onClick={() => setReloadKey((k) => k + 1)}>
Reload
</Button>
)}
<Button
size="md"
variant={live ? "secondary" : "primary"}
disabled={busy}
onClick={() => toggle(!status.enabled || status.state !== "running")}
>
{busy ? "Working…" : live ? "Stop" : "Start browser view"}
</Button>
</div>
{live ? (
<iframe
key={reloadKey}
// Loopback only, and the URL carries the one-time session token the
// host-side gate checks before anything reaches the container.
src={status.url ?? undefined}
title={`Playwright browser view for ${project.name}`}
className="flex-1 min-h-0 w-full border-0 bg-[var(--bg-primary)]"
/>
) : (
<div className="flex-1 min-h-0 overflow-y-auto">
{status.state === "unavailable" ? (
<Unavailable status={status} />
) : error ? (
<Explainer title="The browser view didnt start." tone="error">
<span className="font-mono text-xs break-words">{error}</span>
</Explainer>
) : (
<Explainer title="Nothing is being watched yet.">
Start the view to run Playwrights live dashboard inside this container
and mirror it here. Youll see any browser a script has published with{" "}
<Code>await browser.bind(&apos;claude&apos;)</Code> and{" "}
<Code>@playwright/mcp</Code> publishes automatically, so nothing extra is
needed if Claude is using that.
</Explainer>
)}
</div>
)}
</div>
);
}
/** The container can't serve a view — say exactly what is missing. */
function Unavailable({ status }: { status: BrowserViewStatus }) {
const d = status.detection;
return (
<div className="p-4 max-w-[46rem] space-y-3">
<h2 className="text-[13px] font-semibold text-[var(--text-primary)]">
This container cant serve a browser view yet
</h2>
<p className="text-[13px] text-[var(--text-secondary)] leading-relaxed">
{status.message}
</p>
{d && (
<dl className="text-xs grid grid-cols-[auto_1fr] gap-x-3 gap-y-1 pt-2 border-t border-[var(--border-color)]">
<Detail label="Node.js" value={d.node_version} />
<Detail label="Playwright" value={d.playwright_version} />
<Detail label="browser.bind()" value={d.has_bind ? "available" : "not in this build"} />
<Detail label="@playwright/cli" value={d.cli_version} />
{d.searched.length > 0 && (
<Detail label="Searched" value={d.searched.join(", ")} />
)}
</dl>
)}
</div>
);
}
function Detail({ label, value }: { label: string; value: string | null }) {
return (
<>
<dt className="text-[var(--text-secondary)]">{label}</dt>
<dd className="font-mono text-[var(--text-primary)] break-all">
{value ?? "not found"}
</dd>
</>
);
}
function Explainer({
title,
tone = "normal",
children,
}: {
title: string;
tone?: "normal" | "error";
children: React.ReactNode;
}) {
return (
<div className="p-4 max-w-[46rem]">
<h2
className={`text-[13px] font-semibold ${
tone === "error" ? "text-[var(--error)]" : "text-[var(--text-primary)]"
}`}
>
{title}
</h2>
<p className="mt-1 text-[13px] text-[var(--text-secondary)] leading-relaxed">
{children}
</p>
</div>
);
}
function Code({ children }: { children: React.ReactNode }) {
return (
<code className="font-mono text-xs px-1 py-0.5 rounded-[var(--radius-control)] bg-[var(--bg-tertiary)] text-[var(--text-primary)]">
{children}
</code>
);
}
@@ -21,6 +21,7 @@ const BACKEND_LABEL: Record<Project["backend"], string> = {
anthropic: "Anthropic",
bedrock: "AWS Bedrock",
ollama: "Ollama",
llama_cpp: "llama.cpp",
open_ai_compatible: "OpenAI Compatible",
};
@@ -14,6 +14,7 @@ import SessionsTab from "./SessionsTab";
import AutomationTab from "./AutomationTab";
import ConfigTab from "./ConfigTab";
import FilesTab from "./FilesTab";
import BrowserTab from "./BrowserTab";
import { formatUptime } from "./format";
const TABS = [
@@ -22,6 +23,7 @@ const TABS = [
{ id: "automation", label: "Automation" },
{ id: "config", label: "Config" },
{ id: "files", label: "Files" },
{ id: "browser", label: "Browser" },
] as const;
export type ProjectHomeTabId = (typeof TABS)[number]["id"];
@@ -206,6 +208,9 @@ export default function ProjectHome({ projectId, active }: Props) {
<ConfigTab project={project} save={save} saveState={saveState} />
)}
{tab === "files" && <FilesTab project={project} />}
{tab === "browser" && (
<BrowserTab project={project} active={active && tab === "browser"} />
)}
</div>
{confirmReset && (
@@ -1,6 +1,10 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import ModelSection from "./ModelSection";
import ModelSection, {
DEFAULT_LLAMACPP_CONFIG,
DEFAULT_OLLAMA_CONFIG,
} from "./ModelSection";
import { CUSTOM_ENDPOINT_BACKENDS } from "../../../../lib/types";
import type { Backend, Project } from "../../../../lib/types";
const baseProject: Project = {
@@ -12,6 +16,7 @@ const baseProject: Project = {
backend: "anthropic",
bedrock_config: null,
ollama_config: null,
llamacpp_config: null,
openai_compatible_config: null,
allow_docker_access: false,
sandbox_mode_enabled: true,
@@ -55,7 +60,7 @@ describe("ModelSection — shared auth token toggle", () => {
expect(screen.getByRole("switch", { name: TOGGLE })).toBeInTheDocument();
});
it.each<Backend>(["bedrock", "ollama", "open_ai_compatible"])(
it.each<Backend>(["bedrock", "ollama", "llama_cpp", "open_ai_compatible"])(
"is hidden for the %s backend",
(backend) => {
renderSection({ backend });
@@ -93,3 +98,118 @@ describe("ModelSection — shared auth token toggle", () => {
expect(screen.getByRole("switch", { name: TOGGLE })).toBeDisabled();
});
});
describe("ModelSection — llama.cpp backend", () => {
beforeEach(() => vi.clearAllMocks());
it("is offered as a backend choice", () => {
renderSection();
expect(
screen.getByRole("option", { name: "llama.cpp" }),
).toBeInTheDocument();
});
it("seeds llama-server's default port when the backend is first chosen", () => {
renderSection();
fireEvent.change(screen.getByLabelText("Backend"), {
target: { value: "llama_cpp" },
});
expect(save).toHaveBeenCalledWith({
backend: "llama_cpp",
llamacpp_config: DEFAULT_LLAMACPP_CONFIG,
});
expect(DEFAULT_LLAMACPP_CONFIG.base_url).toContain(":8080");
});
it("does not clobber an existing config when re-selected", () => {
renderSection({
backend: "ollama",
llamacpp_config: {
base_url: "http://gpu-box:9090",
model_id: "mine",
haiku_model_id: null,
},
});
fireEvent.change(screen.getByLabelText("Backend"), {
target: { value: "llama_cpp" },
});
expect(save).toHaveBeenCalledWith({ backend: "llama_cpp" });
});
it("saves the base URL and model on blur", () => {
renderSection({ backend: "llama_cpp" });
const url = screen.getByLabelText("Base URL");
fireEvent.change(url, { target: { value: "http://gpu-box:8080" } });
fireEvent.blur(url);
expect(save).toHaveBeenCalledWith({
llamacpp_config: { ...DEFAULT_LLAMACPP_CONFIG, base_url: "http://gpu-box:8080" },
});
const model = screen.getByLabelText("Model");
fireEvent.change(model, { target: { value: "qwen3.5-coder-30b" } });
fireEvent.blur(model);
expect(save).toHaveBeenCalledWith({
llamacpp_config: { ...DEFAULT_LLAMACPP_CONFIG, model_id: "qwen3.5-coder-30b" },
});
});
});
describe("ModelSection — background (haiku) model override", () => {
beforeEach(() => vi.clearAllMocks());
it("covers exactly the backends that point at a custom endpoint", () => {
expect([...CUSTOM_ENDPOINT_BACKENDS]).toEqual([
"ollama",
"llama_cpp",
"open_ai_compatible",
]);
});
it.each([...CUSTOM_ENDPOINT_BACKENDS])("is offered for the %s backend", (backend) => {
renderSection({ backend });
const field = screen.getByLabelText("Background model");
expect(field).toBeInTheDocument();
// Blank is the documented default — it reuses the main model.
expect(field).toHaveValue("");
expect(field).toHaveAttribute("placeholder", "(same as the model above)");
});
it.each<Backend>(["anthropic", "bedrock"])(
"is not offered for the %s backend, which keeps Claude Code's defaults",
(backend) => {
renderSection({ backend });
expect(screen.queryByLabelText("Background model")).not.toBeInTheDocument();
},
);
it("saves a trimmed override, and clears it back to null when blanked", () => {
renderSection({ backend: "ollama" });
const field = screen.getByLabelText("Background model");
fireEvent.change(field, { target: { value: " qwen3.5:3b " } });
fireEvent.blur(field);
expect(save).toHaveBeenCalledWith({
ollama_config: { ...DEFAULT_OLLAMA_CONFIG, haiku_model_id: "qwen3.5:3b" },
});
fireEvent.change(field, { target: { value: " " } });
fireEvent.blur(field);
expect(save).toHaveBeenCalledWith({
ollama_config: { ...DEFAULT_OLLAMA_CONFIG, haiku_model_id: null },
});
});
it("shows an existing override and explains what it is for", () => {
renderSection({
backend: "llama_cpp",
llamacpp_config: {
base_url: "http://host.docker.internal:8080",
model_id: "big",
haiku_model_id: "small",
},
});
expect(screen.getByLabelText("Background model")).toHaveValue("small");
expect(screen.getByText(/background work/i)).toBeInTheDocument();
});
});
@@ -3,6 +3,7 @@ import type {
Backend,
BedrockAuthMethod,
BedrockConfig,
LlamaCppConfig,
OllamaConfig,
OpenAiCompatibleConfig,
Project,
@@ -31,14 +32,28 @@ export const DEFAULT_BEDROCK_CONFIG: BedrockConfig = {
export const DEFAULT_OLLAMA_CONFIG: OllamaConfig = {
base_url: "http://host.docker.internal:11434",
model_id: null,
haiku_model_id: null,
};
/** `llama-server` listens on port 8080 unless `--port` says otherwise. */
export const DEFAULT_LLAMACPP_CONFIG: LlamaCppConfig = {
base_url: "http://host.docker.internal:8080",
model_id: null,
haiku_model_id: null,
};
export const DEFAULT_OPENAI_COMPATIBLE_CONFIG: OpenAiCompatibleConfig = {
base_url: "http://host.docker.internal:4000",
api_key: null,
model_id: null,
haiku_model_id: null,
};
/** Shown under the optional per-backend Haiku override. Kept in one place so
* all three custom-endpoint backends explain it identically. */
const HAIKU_HINT =
"Optional. Claude Code resolves the `haiku` alias to this, and uses it for background work such as conversation titles. Leave blank to reuse the model above — that is what stops background calls failing against a server that only serves one model.";
interface Props {
project: Project;
save: (patch: Partial<Project>) => Promise<boolean>;
@@ -64,6 +79,19 @@ export default function ModelSection({ project, save, disabled }: Props) {
const [ollamaModelId, setOllamaModelId] = useState(
project.ollama_config?.model_id ?? "",
);
const [ollamaHaikuModelId, setOllamaHaikuModelId] = useState(
project.ollama_config?.haiku_model_id ?? "",
);
const [llamaCppBaseUrl, setLlamaCppBaseUrl] = useState(
project.llamacpp_config?.base_url ?? DEFAULT_LLAMACPP_CONFIG.base_url,
);
const [llamaCppModelId, setLlamaCppModelId] = useState(
project.llamacpp_config?.model_id ?? "",
);
const [llamaCppHaikuModelId, setLlamaCppHaikuModelId] = useState(
project.llamacpp_config?.haiku_model_id ?? "",
);
const [oaiBaseUrl, setOaiBaseUrl] = useState(
project.openai_compatible_config?.base_url ??
@@ -75,6 +103,9 @@ export default function ModelSection({ project, save, disabled }: Props) {
const [oaiModelId, setOaiModelId] = useState(
project.openai_compatible_config?.model_id ?? "",
);
const [oaiHaikuModelId, setOaiHaikuModelId] = useState(
project.openai_compatible_config?.haiku_model_id ?? "",
);
useEffect(() => {
const bc = project.bedrock_config ?? DEFAULT_BEDROCK_CONFIG;
@@ -88,12 +119,19 @@ export default function ModelSection({ project, save, disabled }: Props) {
setServiceTier(bc.service_tier ?? "");
setOllamaBaseUrl(project.ollama_config?.base_url ?? DEFAULT_OLLAMA_CONFIG.base_url);
setOllamaModelId(project.ollama_config?.model_id ?? "");
setOllamaHaikuModelId(project.ollama_config?.haiku_model_id ?? "");
setLlamaCppBaseUrl(
project.llamacpp_config?.base_url ?? DEFAULT_LLAMACPP_CONFIG.base_url,
);
setLlamaCppModelId(project.llamacpp_config?.model_id ?? "");
setLlamaCppHaikuModelId(project.llamacpp_config?.haiku_model_id ?? "");
setOaiBaseUrl(
project.openai_compatible_config?.base_url ??
DEFAULT_OPENAI_COMPATIBLE_CONFIG.base_url,
);
setOaiApiKey(project.openai_compatible_config?.api_key ?? "");
setOaiModelId(project.openai_compatible_config?.model_id ?? "");
setOaiHaikuModelId(project.openai_compatible_config?.haiku_model_id ?? "");
}, [project]);
const saveBedrock = (patch: Partial<BedrockConfig>) =>
@@ -104,6 +142,14 @@ export default function ModelSection({ project, save, disabled }: Props) {
ollama_config: { ...(project.ollama_config ?? DEFAULT_OLLAMA_CONFIG), ...patch },
});
const saveLlamaCpp = (patch: Partial<LlamaCppConfig>) =>
save({
llamacpp_config: {
...(project.llamacpp_config ?? DEFAULT_LLAMACPP_CONFIG),
...patch,
},
});
const saveOpenAi = (patch: Partial<OpenAiCompatibleConfig>) =>
save({
openai_compatible_config: {
@@ -122,6 +168,8 @@ export default function ModelSection({ project, save, disabled }: Props) {
patch.bedrock_config = DEFAULT_BEDROCK_CONFIG;
if (mode === "ollama" && !project.ollama_config)
patch.ollama_config = DEFAULT_OLLAMA_CONFIG;
if (mode === "llama_cpp" && !project.llamacpp_config)
patch.llamacpp_config = DEFAULT_LLAMACPP_CONFIG;
if (mode === "open_ai_compatible" && !project.openai_compatible_config)
patch.openai_compatible_config = DEFAULT_OPENAI_COMPATIBLE_CONFIG;
save(patch);
@@ -131,7 +179,7 @@ export default function ModelSection({ project, save, disabled }: Props) {
<ConfigGroup title="Model" description="Which provider serves this project's Claude.">
<Field
label="Backend"
hint="Anthropic connects directly via OAuth (run `claude login` in a terminal). Bedrock routes through AWS. Ollama and OpenAI Compatible point at any compatible endpoint."
hint="Anthropic connects directly via OAuth (run `claude login` in a terminal). Bedrock routes through AWS. Ollama, llama.cpp and OpenAI Compatible point at any endpoint that implements the Anthropic Messages API."
>
{(id) => (
<select
@@ -144,6 +192,7 @@ export default function ModelSection({ project, save, disabled }: Props) {
<option value="anthropic">Anthropic</option>
<option value="bedrock">Bedrock</option>
<option value="ollama">Ollama</option>
<option value="llama_cpp">llama.cpp</option>
<option value="open_ai_compatible">OpenAI Compatible</option>
</select>
)}
@@ -365,6 +414,73 @@ export default function ModelSection({ project, save, disabled }: Props) {
/>
)}
</Field>
<Field label="Background model" hint={HAIKU_HINT}>
{(id) => (
<input
id={id}
value={ollamaHaikuModelId}
onChange={(e) => setOllamaHaikuModelId(e.target.value)}
onBlur={() =>
saveOllama({ haiku_model_id: ollamaHaikuModelId.trim() || null })
}
placeholder="(same as the model above)"
disabled={disabled}
className={monoInputClass}
/>
)}
</Field>
</div>
)}
{project.backend === "llama_cpp" && (
<div className="space-y-4 pt-2 border-t border-[var(--border-color)]">
<Field
label="Base URL"
hint="Your llama-server. It listens on port 8080 by default; use host.docker.internal to reach the host machine."
>
{(id) => (
<input
id={id}
value={llamaCppBaseUrl}
onChange={(e) => setLlamaCppBaseUrl(e.target.value)}
onBlur={() => saveLlamaCpp({ base_url: llamaCppBaseUrl })}
placeholder="http://host.docker.internal:8080"
disabled={disabled}
className={monoInputClass}
/>
)}
</Field>
<Field
label="Model"
hint="The model llama-server was started with. llama-server serves one model, so this is mainly what Claude Code reports — but it is also what the model aliases are pinned to."
>
{(id) => (
<input
id={id}
value={llamaCppModelId}
onChange={(e) => setLlamaCppModelId(e.target.value)}
onBlur={() => saveLlamaCpp({ model_id: llamaCppModelId || null })}
placeholder="qwen3.5-coder-30b"
disabled={disabled}
className={monoInputClass}
/>
)}
</Field>
<Field label="Background model" hint={HAIKU_HINT}>
{(id) => (
<input
id={id}
value={llamaCppHaikuModelId}
onChange={(e) => setLlamaCppHaikuModelId(e.target.value)}
onBlur={() =>
saveLlamaCpp({ haiku_model_id: llamaCppHaikuModelId.trim() || null })
}
placeholder="(same as the model above)"
disabled={disabled}
className={monoInputClass}
/>
)}
</Field>
</div>
)}
@@ -372,7 +488,7 @@ export default function ModelSection({ project, save, disabled }: Props) {
<div className="space-y-4 pt-2 border-t border-[var(--border-color)]">
<Field
label="Base URL"
hint="Any OpenAI API-compatible endpoint — LiteLLM, OpenRouter, vLLM, and so on."
hint="A gateway that implements the Anthropic Messages API (POST /v1/messages) — LiteLLM, for example. An endpoint that only speaks OpenAI /v1/chat/completions will not work."
>
{(id) => (
<input
@@ -413,6 +529,21 @@ export default function ModelSection({ project, save, disabled }: Props) {
/>
)}
</Field>
<Field label="Background model" hint={HAIKU_HINT}>
{(id) => (
<input
id={id}
value={oaiHaikuModelId}
onChange={(e) => setOaiHaikuModelId(e.target.value)}
onBlur={() =>
saveOpenAi({ haiku_model_id: oaiHaikuModelId.trim() || null })
}
placeholder="(same as the model above)"
disabled={disabled}
className={monoInputClass}
/>
)}
</Field>
</div>
)}
</ConfigGroup>
@@ -0,0 +1,482 @@
import { useState, useEffect, useCallback } from "react";
import { listen } from "@tauri-apps/api/event";
import { useSettings } from "../../hooks/useSettings";
import {
getGatewayStatus,
startGateway,
stopGateway,
checkGatewayHealth,
pullGatewayImage,
buildGatewayImage,
setGatewayApiKey,
clearGatewayApiKey,
getGatewayAuthToken,
regenerateGatewayAuthToken,
} from "../../lib/tauri-commands";
import type { GatewayModel, GatewaySettings as GatewaySettingsType, GatewayStatus } from "../../lib/types";
import Button from "../ui/Button";
import Field, { SwitchRow, inputClass, monoInputClass } from "../ui/Field";
import Modal from "../ui/Modal";
import StatusIndicator, { type StatusTone } from "../ui/StatusIndicator";
import Toggle from "../ui/Toggle";
const DEFAULT_GATEWAY: GatewaySettingsType = {
enabled: false,
port: 4000,
provider: "openai",
api_base: null,
models: [],
};
/**
* Settings for the model gateway — the LiteLLM container Triple-C runs so that
* Claude Code, which only speaks the Anthropic Messages API, can be driven by
* an OpenAI key.
*
* The provider API key is write-only from here: it goes to the OS keychain and
* there is no command that reads it back, so the UI can only ever report
* whether one is stored.
*/
export default function GatewaySettings() {
const { appSettings, saveSettings } = useSettings();
const gateway = appSettings?.gateway ?? DEFAULT_GATEWAY;
const [status, setStatus] = useState<GatewayStatus | null>(null);
const [healthy, setHealthy] = useState<boolean | null>(null);
const [loading, setLoading] = useState(false);
const [pulling, setPulling] = useState(false);
const [building, setBuilding] = useState(false);
const [log, setLog] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [provider, setProvider] = useState(gateway.provider);
const [port, setPort] = useState(String(gateway.port));
const [apiBase, setApiBase] = useState(gateway.api_base ?? "");
const [apiKeyDraft, setApiKeyDraft] = useState("");
const [savingKey, setSavingKey] = useState(false);
const [authToken, setAuthToken] = useState<string | null>(null);
const [copied, setCopied] = useState<string | null>(null);
const [confirmRotate, setConfirmRotate] = useState(false);
useEffect(() => {
setProvider(gateway.provider);
setPort(String(gateway.port));
setApiBase(gateway.api_base ?? "");
}, [gateway.provider, gateway.port, gateway.api_base]);
const refreshStatus = useCallback(async () => {
try {
const next = await getGatewayStatus();
setStatus(next);
setHealthy(next.running ? await checkGatewayHealth() : null);
} catch (e) {
console.error("Gateway status failed:", e);
}
}, []);
useEffect(() => {
refreshStatus();
}, [refreshStatus]);
const patch = async (changes: Partial<GatewaySettingsType>) => {
if (!appSettings) return;
await saveSettings({ ...appSettings, gateway: { ...gateway, ...changes } });
};
const savePort = async () => {
const parsed = parseInt(port, 10);
if (isNaN(parsed) || parsed < 1 || parsed > 65535) {
setPort(String(gateway.port));
return;
}
await patch({ port: parsed });
};
const setModels = (models: GatewayModel[]) => patch({ models });
const updateModel = (index: number, changes: Partial<GatewayModel>) =>
setModels(gateway.models.map((m, i) => (i === index ? { ...m, ...changes } : m)));
const run = async (fn: () => Promise<unknown>) => {
setLoading(true);
setError(null);
try {
await fn();
await refreshStatus();
} catch (e) {
setError(String(e));
} finally {
setLoading(false);
}
};
const withProgress = async (
event: string,
setBusy: (busy: boolean) => void,
fn: () => Promise<void>,
) => {
setBusy(true);
setLog(null);
setError(null);
const unlisten = await listen<string>(event, (e) => setLog(e.payload));
try {
await fn();
await refreshStatus();
} catch (e) {
setError(String(e));
} finally {
setBusy(false);
unlisten();
}
};
const handleSaveKey = async () => {
if (!apiKeyDraft.trim()) return;
setSavingKey(true);
setError(null);
try {
await setGatewayApiKey(apiKeyDraft);
setApiKeyDraft("");
await refreshStatus();
} catch (e) {
setError(String(e));
} finally {
setSavingKey(false);
}
};
const revealToken = async () => {
try {
setAuthToken(await getGatewayAuthToken());
} catch (e) {
setError(String(e));
}
};
const rotateToken = async () => {
setConfirmRotate(false);
try {
setAuthToken(await regenerateGatewayAuthToken());
await refreshStatus();
} catch (e) {
setError(String(e));
}
};
const copy = async (label: string, value: string) => {
await navigator.clipboard.writeText(value);
setCopied(label);
setTimeout(() => setCopied(null), 2000);
};
const tone: StatusTone = !status?.image_exists
? "off"
: status.running
? healthy === false
? "busy"
: "running"
: status.container_exists
? "stopped"
: "off";
const statusLabel = !status?.image_exists
? "No image"
: status.running
? healthy === false
? "Starting…"
: `Running on port ${status.port}`
: status.container_exists
? "Stopped"
: "Image ready";
return (
<div>
<label className="block text-sm font-medium mb-1">Model Gateway</label>
<p className="text-xs text-[var(--text-secondary)] mb-3">
Runs a pinned LiteLLM proxy in a container. Claude Code only speaks the Anthropic
Messages API, so an OpenAI key cannot drive it directly the gateway serves{" "}
<code className="font-mono">/v1/messages</code> and translates each call to your
provider. Point a project's <strong>OpenAI Compatible</strong> backend at it.
</p>
<div className="space-y-4">
<SwitchRow
label="Model gateway"
hint="Start the gateway container with Triple-C."
control={
<Toggle
label="Model gateway"
checked={gateway.enabled}
onChange={(value) => patch({ enabled: value })}
/>
}
/>
{gateway.enabled && (
<>
{/* ── Container ─────────────────────────────────────────────── */}
<div className="flex items-center gap-3 flex-wrap">
<StatusIndicator tone={tone} label={statusLabel} className="text-xs" />
{status?.image_exists && (
<Button
variant={status.running ? "danger" : "primary"}
disabled={loading}
onClick={() => run(status.running ? stopGateway : startGateway)}
>
{loading ? "Working…" : status.running ? "Stop" : "Start"}
</Button>
)}
<Button
disabled={pulling || building}
onClick={() => withProgress("gateway-pull-progress", setPulling, pullGatewayImage)}
>
{pulling ? "Pulling…" : "Pull Image"}
</Button>
<Button
disabled={pulling || building}
onClick={() => withProgress("gateway-build-progress", setBuilding, buildGatewayImage)}
>
{building ? "Building…" : "Build Locally"}
</Button>
</div>
{log && (
<pre className="text-[10px] text-[var(--text-secondary)] bg-[var(--bg-primary)] border border-[var(--border-color)] rounded-[var(--radius-control)] px-2 py-1 max-h-20 overflow-y-auto whitespace-pre-wrap">
{log}
</pre>
)}
{error && (
<p className="text-xs text-[var(--error)]" role="alert">
{error}
</p>
)}
{/* ── Provider ──────────────────────────────────────────────── */}
<Field
label="Provider"
hint="LiteLLM provider prefix. OpenAI is the common case; anything LiteLLM supports works (azure, gemini, groq, …)."
>
{(id) => (
<input
id={id}
type="text"
value={provider}
onChange={(e) => setProvider(e.target.value)}
onBlur={() => patch({ provider: provider.trim() || "openai" })}
placeholder="openai"
className={inputClass}
/>
)}
</Field>
<Field
label="Provider API key"
hint={
status?.has_api_key
? "A key is stored in your OS keychain. Enter a new one to replace it — it is never shown again."
: "Stored in your OS keychain, written only into the gateway container's config. Never shown again once saved."
}
>
{(id) => (
<div className="flex items-center gap-2">
<input
id={id}
type="password"
autoComplete="off"
value={apiKeyDraft}
onChange={(e) => setApiKeyDraft(e.target.value)}
placeholder={status?.has_api_key ? " (stored)" : "sk-"}
className={monoInputClass}
/>
<Button
variant="primary"
disabled={savingKey || !apiKeyDraft.trim()}
onClick={handleSaveKey}
>
{savingKey ? "Saving" : "Save"}
</Button>
{status?.has_api_key && (
<Button variant="danger" onClick={() => run(clearGatewayApiKey)}>
Clear
</Button>
)}
</div>
)}
</Field>
<Field
label="Provider base URL (optional)"
hint="Override the provider's endpoint — Azure deployments, self-hosted OpenAI-compatible servers, and so on. Leave blank for the provider default."
>
{(id) => (
<input
id={id}
type="text"
value={apiBase}
onChange={(e) => setApiBase(e.target.value)}
onBlur={() => patch({ api_base: apiBase.trim() || null })}
placeholder="https://api.openai.com/v1"
className={inputClass}
/>
)}
</Field>
<Field
label="Host port"
hint="Port the gateway is published on. Changing it recreates the container."
>
{(id) => (
<input
id={id}
type="number"
min={1}
max={65535}
value={port}
onChange={(e) => setPort(e.target.value)}
onBlur={savePort}
className={inputClass}
/>
)}
</Field>
{/* ── Models ────────────────────────────────────────────────── */}
<div>
<div className="text-[13px] font-medium text-[var(--text-primary)]">Models</div>
<p className="mt-0.5 mb-2 text-xs text-[var(--text-secondary)] leading-snug">
Each row becomes one model the gateway serves. <strong>Name</strong> is what a
project puts in its model field; <strong>Model id</strong> is the provider's own
id. The gateway sends them as{" "}
<code className="font-mono">{provider || "openai"}/&lt;model id&gt;</code>.
</p>
<div className="space-y-2">
{gateway.models.map((model, index) => (
<div key={index} className="flex items-center gap-2">
<input
type="text"
aria-label={`Model ${index + 1} name`}
value={model.name}
onChange={(e) => updateModel(index, { name: e.target.value })}
placeholder="gpt-5.1"
className={monoInputClass}
/>
<input
type="text"
aria-label={`Model ${index + 1} provider id`}
value={model.model_id}
onChange={(e) => updateModel(index, { model_id: e.target.value })}
placeholder="gpt-5.1"
className={monoInputClass}
/>
<Button
variant="ghost"
aria-label={`Remove model ${index + 1}`}
onClick={() => setModels(gateway.models.filter((_, i) => i !== index))}
>
Remove
</Button>
</div>
))}
<Button
onClick={() => setModels([...gateway.models, { name: "", model_id: "" }])}
>
Add model
</Button>
</div>
</div>
{/* ── What a project should use ─────────────────────────────── */}
<div className="border border-[var(--border-color)] rounded-[var(--radius-panel)] bg-[var(--bg-secondary)] px-3 py-3 space-y-3">
<div>
<div className="text-[13px] font-medium text-[var(--text-primary)]">
Project settings for this gateway
</div>
<p className="mt-0.5 text-xs text-[var(--text-secondary)] leading-snug">
Set a project's backend to <strong>OpenAI Compatible</strong> and use these
values. On native Linux Docker, where{" "}
<code className="font-mono">host.docker.internal</code> is not injected into
containers, use <code className="font-mono">http://172.17.0.1:{gateway.port}</code>{" "}
instead.
</p>
</div>
<Field label="Base URL">
{(id) => (
<div className="flex items-center gap-2">
<input
id={id}
readOnly
value={status?.base_url ?? `http://host.docker.internal:${gateway.port}`}
className={monoInputClass}
/>
<Button
onClick={() =>
copy(
"url",
status?.base_url ?? `http://host.docker.internal:${gateway.port}`,
)
}
>
{copied === "url" ? "Copied" : "Copy"}
</Button>
</div>
)}
</Field>
<Field
label="Auth token"
hint="The gateway requires this on every request, which is what stops the published port being an open proxy onto your provider account."
>
{(id) => (
<div className="flex items-center gap-2">
<input
id={id}
readOnly
type={authToken ? "text" : "password"}
value={authToken ?? "••••••••••••"}
className={monoInputClass}
/>
{authToken ? (
<Button onClick={() => copy("token", authToken)}>
{copied === "token" ? "Copied" : "Copy"}
</Button>
) : (
<Button onClick={revealToken}>Reveal</Button>
)}
<Button variant="danger" onClick={() => setConfirmRotate(true)}>
Regenerate
</Button>
</div>
)}
</Field>
</div>
</>
)}
</div>
{confirmRotate && (
<Modal
title="Regenerate gateway auth token?"
onClose={() => setConfirmRotate(false)}
footer={
<div className="flex justify-end gap-2">
<Button size="md" onClick={() => setConfirmRotate(false)}>
Cancel
</Button>
<Button size="md" variant="danger" onClick={rotateToken}>
Regenerate
</Button>
</div>
}
>
<p className="text-[13px] text-[var(--text-secondary)]">
Every project still using the current token will stop reaching the gateway until you
paste the new one into its model config. The gateway is recreated on its next start.
</p>
</Modal>
)}
</div>
);
}
@@ -0,0 +1,68 @@
import { useSettings } from "../../hooks/useSettings";
import Tooltip from "../ui/Tooltip";
type Field = "base_url" | "default_model_id" | "default_haiku_model_id";
export default function LlamaCppSettings() {
const { appSettings, saveSettings } = useSettings();
const globalLlamaCpp = appSettings?.global_llamacpp ?? {
base_url: null,
default_model_id: null,
default_haiku_model_id: null,
};
const handleChange = async (field: Field, value: string) => {
if (!appSettings) return;
await saveSettings({
...appSettings,
global_llamacpp: { ...globalLlamaCpp, [field]: value || null },
});
};
return (
<div>
<label className="block text-sm font-medium mb-2">llama.cpp Configuration</label>
<div className="space-y-3 text-sm">
<p className="text-xs text-[var(--text-secondary)]">
Global defaults for a local or remote <code>llama-server</code>, which serves
the Anthropic Messages API directly. Used when a per-project field is blank.
Changes here require a container rebuild to take effect.
</p>
<div>
<span className="text-[var(--text-secondary)] text-xs block mb-1">Default Base URL<Tooltip text="URL of your llama-server. Used when a per-project llama.cpp base URL is blank. llama-server listens on port 8080 by default." /></span>
<input
type="text"
value={globalLlamaCpp.base_url ?? ""}
onChange={(e) => handleChange("base_url", e.target.value)}
placeholder="http://host.docker.internal:8080"
className="w-full px-2 py-1.5 text-xs bg-[var(--bg-primary)] border border-[var(--border-color)] rounded focus:border-[var(--accent)]"
/>
</div>
<div>
<span className="text-[var(--text-secondary)] text-xs block mb-1">Default Model<Tooltip text="Default model identifier. Used when a per-project llama.cpp model is blank." /></span>
<input
type="text"
value={globalLlamaCpp.default_model_id ?? ""}
onChange={(e) => handleChange("default_model_id", e.target.value)}
placeholder="qwen3.5-coder-30b"
className="w-full px-2 py-1.5 text-xs bg-[var(--bg-primary)] border border-[var(--border-color)] rounded focus:border-[var(--accent)]"
/>
</div>
<div>
<span className="text-[var(--text-secondary)] text-xs block mb-1">Default Background Model<span className="text-[var(--text-disabled)]"> (optional)</span><Tooltip text="What the `haiku` alias resolves to, which is also what Claude Code uses for background work such as titles and summaries. Leave blank to reuse the model above — only set this if you serve a second, smaller model." /></span>
<input
type="text"
value={globalLlamaCpp.default_haiku_model_id ?? ""}
onChange={(e) => handleChange("default_haiku_model_id", e.target.value)}
placeholder="(same as the model above)"
className="w-full px-2 py-1.5 text-xs bg-[var(--bg-primary)] border border-[var(--border-color)] rounded focus:border-[var(--accent)]"
/>
</div>
</div>
</div>
);
}
+16 -1
View File
@@ -7,9 +7,13 @@ export default function OllamaSettings() {
const globalOllama = appSettings?.global_ollama ?? {
base_url: null,
default_model_id: null,
default_haiku_model_id: null,
};
const handleChange = async (field: "base_url" | "default_model_id", value: string) => {
const handleChange = async (
field: "base_url" | "default_model_id" | "default_haiku_model_id",
value: string,
) => {
if (!appSettings) return;
await saveSettings({
...appSettings,
@@ -47,6 +51,17 @@ export default function OllamaSettings() {
className="w-full px-2 py-1.5 text-xs bg-[var(--bg-primary)] border border-[var(--border-color)] rounded focus:border-[var(--accent)]"
/>
</div>
<div>
<span className="text-[var(--text-secondary)] text-xs block mb-1">Default Background Model<span className="text-[var(--text-disabled)]"> (optional)</span><Tooltip text="What the `haiku` alias resolves to, which is also what Claude Code uses for background work such as titles and summaries. Leave blank to reuse the model above — only set this if you have pulled a second, smaller model." /></span>
<input
type="text"
value={globalOllama.default_haiku_model_id ?? ""}
onChange={(e) => handleChange("default_haiku_model_id", e.target.value)}
placeholder="(same as the model above)"
className="w-full px-2 py-1.5 text-xs bg-[var(--bg-primary)] border border-[var(--border-color)] rounded focus:border-[var(--accent)]"
/>
</div>
</div>
</div>
);
@@ -7,9 +7,13 @@ export default function OpenAiCompatibleSettings() {
const globalOai = appSettings?.global_openai_compatible ?? {
base_url: null,
default_model_id: null,
default_haiku_model_id: null,
};
const handleChange = async (field: "base_url" | "default_model_id", value: string) => {
const handleChange = async (
field: "base_url" | "default_model_id" | "default_haiku_model_id",
value: string,
) => {
if (!appSettings) return;
await saveSettings({
...appSettings,
@@ -22,8 +26,9 @@ export default function OpenAiCompatibleSettings() {
<label className="block text-sm font-medium mb-2">OpenAI Compatible Configuration</label>
<div className="space-y-3 text-sm">
<p className="text-xs text-[var(--text-secondary)]">
Global defaults for any OpenAI-compatible endpoint (LiteLLM, OpenRouter, vLLM, etc.).
Used when a per-project field is blank. Changes require a container rebuild.
Global defaults for a gateway that implements the Anthropic Messages API
(<code>POST /v1/messages</code>) LiteLLM, for example. Used when a per-project
field is blank. Changes require a container rebuild.
</p>
<div>
@@ -47,6 +52,17 @@ export default function OpenAiCompatibleSettings() {
className="w-full px-2 py-1.5 text-xs bg-[var(--bg-primary)] border border-[var(--border-color)] rounded focus:border-[var(--accent)]"
/>
</div>
<div>
<span className="text-[var(--text-secondary)] text-xs block mb-1">Default Background Model<span className="text-[var(--text-disabled)]"> (optional)</span><Tooltip text="What the `haiku` alias resolves to, which is also what Claude Code uses for background work such as titles and summaries. Leave blank to reuse the model above — only set this if your gateway also serves a smaller model." /></span>
<input
type="text"
value={globalOai.default_haiku_model_id ?? ""}
onChange={(e) => handleChange("default_haiku_model_id", e.target.value)}
placeholder="(same as the model above)"
className="w-full px-2 py-1.5 text-xs bg-[var(--bg-primary)] border border-[var(--border-color)] rounded focus:border-[var(--accent)]"
/>
</div>
</div>
</div>
);
@@ -2,7 +2,9 @@ import { useState, useEffect } from "react";
import DockerSettings from "./DockerSettings";
import AwsSettings from "./AwsSettings";
import OllamaSettings from "./OllamaSettings";
import LlamaCppSettings from "./LlamaCppSettings";
import OpenAiCompatibleSettings from "./OpenAiCompatibleSettings";
import GatewaySettings from "./GatewaySettings";
import { useSettings } from "../../hooks/useSettings";
import { useUpdates } from "../../hooks/useUpdates";
import ClaudeInstructionsModal from "../projects/ClaudeInstructionsModal";
@@ -159,7 +161,11 @@ export default function SettingsPanel() {
<div className="pt-3 border-t border-[var(--border-color)]" />
<OllamaSettings />
<div className="pt-3 border-t border-[var(--border-color)]" />
<LlamaCppSettings />
<div className="pt-3 border-t border-[var(--border-color)]" />
<OpenAiCompatibleSettings />
<div className="pt-3 border-t border-[var(--border-color)]" />
<GatewaySettings />
</AccordionSection>
<AccordionSection id="container" title="Container" defaultOpen={false}>
+52 -12
View File
@@ -10,6 +10,11 @@ import { useAppState } from "../../store/appState";
import { awsSsoRefresh, uploadHostFileToTerminal } from "../../lib/tauri-commands";
import { getCurrentWebview } from "@tauri-apps/api/webview";
import { UrlDetector } from "../../lib/urlDetector";
import {
RelayRateLimiter,
URL_RELAY_OSC,
parseUrlRelayOsc,
} from "../../lib/urlRelay";
import UrlToast from "./UrlToast";
import { trimSelection } from "./trimSelection";
import TerminalContextMenu from "./TerminalContextMenu";
@@ -37,7 +42,13 @@ export default function TerminalView({ sessionId, active }: Props) {
(s) => s.sessions.find((sess) => sess.id === sessionId)?.projectId
);
const [detectedUrl, setDetectedUrl] = useState<string | null>(null);
// One toast slot, two producers: the heuristic long-URL detector and the
// container's explicit "open this in the host browser" relay (OSC 7777).
// Sharing the slot keeps them from stacking on top of each other.
const [urlPrompt, setUrlPrompt] = useState<{ url: string; label: string } | null>(
null,
);
const relayLimiterRef = useRef(new RelayRateLimiter());
const [imagePasteMsg, setImagePasteMsg] = useState<string | null>(null);
const [isAtBottom, setIsAtBottom] = useState(true);
const [isAutoFollow, setIsAutoFollow] = useState(true);
@@ -212,6 +223,31 @@ export default function TerminalView({ sessionId, active }: Props) {
return true;
});
// URL relay (OSC 7777) — a CLI inside the container asked for a URL to be
// opened in a browser. The container has none; `triple-c-open` (installed
// as xdg-open / $BROWSER / sensible-browser / ...) forwards the request
// here instead.
//
// The container is untrusted, so this never opens anything by itself:
// parseUrlRelayOsc enforces the http/https allowlist and the payload is
// rate-limited, then the user gets the same confirmation toast the
// long-URL detector uses. One click is a small price for not handing a
// sandboxed agent a "make the host's logged-in browser fetch this"
// primitive.
const relayDisposable = term.parser.registerOscHandler(URL_RELAY_OSC, (data) => {
const url = parseUrlRelayOsc(data);
if (!url) {
console.warn("URL relay: rejected request from container");
return true; // consumed either way — never let it reach the screen
}
if (!relayLimiterRef.current.allow(url)) {
console.warn("URL relay: rate-limited", url);
return true;
}
setUrlPrompt({ url, label: "Container asked to open a URL" });
return true;
});
// Handle user input -> backend
const inputDisposable = term.onData((data) => {
sendInput(sessionId, data);
@@ -295,7 +331,9 @@ export default function TerminalView({ sessionId, active }: Props) {
// Handle backend output -> terminal
let aborted = false;
const detector = new UrlDetector((url) => setDetectedUrl(url));
const detector = new UrlDetector((url) =>
setUrlPrompt({ url, label: "Long URL detected" }),
);
detectorRef.current = detector;
const SSO_MARKER = "###TRIPLE_C_SSO_REFRESH###";
@@ -369,6 +407,7 @@ export default function TerminalView({ sessionId, active }: Props) {
ssoTriggeredRef.current = false;
ssoBufferRef.current = "";
osc52Disposable.dispose();
relayDisposable.dispose();
inputDisposable.dispose();
scrollDisposable.dispose();
selectionDisposable.dispose();
@@ -425,10 +464,10 @@ export default function TerminalView({ sessionId, active }: Props) {
// Auto-dismiss toast after 30 seconds
useEffect(() => {
if (!detectedUrl) return;
const timer = setTimeout(() => setDetectedUrl(null), 30_000);
if (!urlPrompt) return;
const timer = setTimeout(() => setUrlPrompt(null), 30_000);
return () => clearTimeout(timer);
}, [detectedUrl]);
}, [urlPrompt]);
// Auto-dismiss image paste message after 3 seconds
useEffect(() => {
@@ -438,13 +477,13 @@ export default function TerminalView({ sessionId, active }: Props) {
}, [imagePasteMsg]);
const handleOpenUrl = useCallback(() => {
if (detectedUrl) {
openUrl(detectedUrl).catch((e) =>
if (urlPrompt) {
openUrl(urlPrompt.url).catch((e) =>
console.error("Failed to open URL:", e),
);
setDetectedUrl(null);
setUrlPrompt(null);
}
}, [detectedUrl]);
}, [urlPrompt]);
const handleScrollToBottom = useCallback(() => {
const term = termRef.current;
@@ -516,11 +555,12 @@ export default function TerminalView({ sessionId, active }: Props) {
ref={terminalContainerRef}
className={`w-full h-full relative ${active ? "" : "hidden"}`}
>
{detectedUrl && (
{urlPrompt && (
<UrlToast
url={detectedUrl}
url={urlPrompt.url}
label={urlPrompt.label}
onOpen={handleOpenUrl}
onDismiss={() => setDetectedUrl(null)}
onDismiss={() => setUrlPrompt(null)}
/>
)}
{imagePasteMsg && (
+9 -2
View File
@@ -1,10 +1,17 @@
interface Props {
url: string;
/** Heading above the URL. Says why the toast appeared. */
label?: string;
onOpen: () => void;
onDismiss: () => void;
}
export default function UrlToast({ url, onOpen, onDismiss }: Props) {
export default function UrlToast({
url,
label = "Long URL detected",
onOpen,
onDismiss,
}: Props) {
return (
<div
className="animate-slide-down"
@@ -33,7 +40,7 @@ export default function UrlToast({ url, onOpen, onDismiss }: Props) {
marginBottom: 2,
}}
>
Long URL detected
{label}
</div>
<div
style={{