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(null); const [healthy, setHealthy] = useState(null); const [loading, setLoading] = useState(false); const [pulling, setPulling] = useState(false); const [building, setBuilding] = useState(false); const [log, setLog] = useState(null); const [error, setError] = useState(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(null); const [copied, setCopied] = useState(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]); /** * Persist a gateway settings change, then re-read the container status. * * `update_settings` reconciles the container itself — it stops the gateway * when `enabled` goes false and recreates it on a port change — so the status * we are holding is stale the moment the save returns. */ const patch = async (changes: Partial) => { if (!appSettings) return; await saveSettings({ ...appSettings, gateway: { ...gateway, ...changes } }); await refreshStatus(); }; 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) => setModels(gateway.models.map((m, i) => (i === index ? { ...m, ...changes } : m))); const run = async (fn: () => Promise) => { 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, ) => { setBusy(true); setLog(null); setError(null); const unlisten = await listen(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"; // Rendered in whichever branch is live — only one of them ever mounts. const errorLine = error ? (

{error}

) : null; return (

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{" "} /v1/messages and translates each call to your provider. Point a project's OpenAI Compatible backend at it.

patch({ enabled: value })} /> } /> {/* Turning the gateway off hides its configuration, but a container that already exists must stay reachable — otherwise a leftover container keeps its port bound with no UI left to stop it. */} {!gateway.enabled && status?.container_exists && (

The gateway container is still present. Stop it here if it is still running; it will not be started again while the gateway is off.

{errorLine}
)} {gateway.enabled && ( <> {/* ── Container ─────────────────────────────────────────────── */}
{status?.image_exists && ( )}
{log && (
                {log}
              
)} {errorLine} {/* ── Provider ──────────────────────────────────────────────── */} {(id) => ( setProvider(e.target.value)} onBlur={() => patch({ provider: provider.trim() || "openai" })} placeholder="openai" className={inputClass} /> )} {(id) => (
setApiKeyDraft(e.target.value)} placeholder={status?.has_api_key ? "•••••••• (stored)" : "sk-…"} className={monoInputClass} /> {status?.has_api_key && ( )}
)}
{(id) => ( setApiBase(e.target.value)} onBlur={() => patch({ api_base: apiBase.trim() || null })} placeholder="https://api.openai.com/v1" className={inputClass} /> )} {(id) => ( setPort(e.target.value)} onBlur={savePort} className={inputClass} /> )} {/* ── Models ────────────────────────────────────────────────── */}
Models

Each row becomes one model the gateway serves. Name is what a project puts in its model field; Model id is the provider's own id. The gateway sends them as{" "} {provider || "openai"}/<model id>.

{gateway.models.map((model, index) => (
updateModel(index, { name: e.target.value })} placeholder="gpt-5.1" className={monoInputClass} /> updateModel(index, { model_id: e.target.value })} placeholder="gpt-5.1" className={monoInputClass} />
))}
{/* ── What a project should use ─────────────────────────────── */}
Project settings for this gateway

Set a project's backend to OpenAI Compatible and use these values. The base URL below is the one your Docker engine actually needs —{" "} host.docker.internal on Docker Desktop, the bridge gateway address on native Linux, where that name is not injected into containers.

{(id) => (
)}
{(id) => (
{authToken ? ( ) : ( )}
)}
)}
{confirmRotate && ( setConfirmRotate(false)} footer={
} >

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.

)}
); }