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
+26
View File
@@ -1,5 +1,6 @@
use serde::{Deserialize, Serialize};
use super::gateway_settings::GatewaySettings;
use super::project::{ClaudeCodeSettings, EnvVar};
fn default_true() -> bool {
@@ -53,6 +54,23 @@ pub struct GlobalOllamaSettings {
pub base_url: Option<String>,
#[serde(default)]
pub default_model_id: Option<String>,
/// Global fallback for the `haiku` alias override. Blank means "use the
/// resolved model id", which is what makes background Claude Code calls
/// work against a server that only serves one model.
#[serde(default)]
pub default_haiku_model_id: Option<String>,
}
/// Global defaults for the llama.cpp (`llama-server`) backend.
/// Mirrors [`GlobalOllamaSettings`]; used when the per-project field is blank.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct GlobalLlamaCppSettings {
#[serde(default)]
pub base_url: Option<String>,
#[serde(default)]
pub default_model_id: Option<String>,
#[serde(default)]
pub default_haiku_model_id: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
@@ -61,6 +79,8 @@ pub struct GlobalOpenAiCompatibleSettings {
pub base_url: Option<String>,
#[serde(default)]
pub default_model_id: Option<String>,
#[serde(default)]
pub default_haiku_model_id: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -82,6 +102,8 @@ pub struct AppSettings {
#[serde(default)]
pub global_ollama: GlobalOllamaSettings,
#[serde(default)]
pub global_llamacpp: GlobalLlamaCppSettings,
#[serde(default)]
pub global_openai_compatible: GlobalOpenAiCompatibleSettings,
#[serde(default = "default_global_instructions")]
pub global_claude_instructions: Option<String>,
@@ -102,6 +124,8 @@ pub struct AppSettings {
#[serde(default)]
pub stt: SttSettings,
#[serde(default)]
pub gateway: GatewaySettings,
#[serde(default)]
pub global_claude_code_settings: Option<ClaudeCodeSettings>,
}
@@ -180,6 +204,7 @@ impl Default for AppSettings {
custom_image_name: None,
global_aws: GlobalAwsSettings::default(),
global_ollama: GlobalOllamaSettings::default(),
global_llamacpp: GlobalLlamaCppSettings::default(),
global_openai_compatible: GlobalOpenAiCompatibleSettings::default(),
global_claude_instructions: default_global_instructions(),
global_custom_env_vars: Vec::new(),
@@ -190,6 +215,7 @@ impl Default for AppSettings {
dismissed_image_digest: None,
web_terminal: WebTerminalSettings::default(),
stt: SttSettings::default(),
gateway: GatewaySettings::default(),
global_claude_code_settings: None,
}
}
@@ -0,0 +1,99 @@
//! Settings and status for the **model gateway** — a LiteLLM proxy container
//! Triple-C runs as a sibling of the project containers.
//!
//! Claude Code speaks only the Anthropic Messages API (`POST
//! ${ANTHROPIC_BASE_URL}/v1/messages`). OpenAI has no such route, so an OpenAI
//! key cannot drive Claude Code directly. The gateway exposes `/v1/messages`
//! in Anthropic format and translates each call to the configured provider,
//! which is what turns "OpenAI Compatible" from *bring your own proxy* into
//! something Triple-C manages itself.
//!
//! Nothing secret lives in this module. The provider API key and the gateway's
//! own master key are held in the OS keychain (see `storage::secure`); what is
//! persisted to `settings.json` is only the non-secret shape of the config.
use serde::{Deserialize, Serialize};
/// LiteLLM's own default port, and the one the existing "OpenAI Compatible"
/// placeholder text already suggests.
pub fn default_gateway_port() -> u16 {
4000
}
fn default_gateway_provider() -> String {
"openai".to_string()
}
/// One entry of LiteLLM's `model_list`.
///
/// `name` is the friendly handle a project puts in its model field — it is what
/// Claude Code sends as the `model` of a `/v1/messages` request. `model_id` is
/// the provider-side id. The gateway config composes them as
/// `<provider>/<model_id>`, which is why the shape stays generic across
/// providers instead of hard-coding OpenAI.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
pub struct GatewayModel {
/// Friendly name projects use (e.g. `gpt-5.1`).
pub name: String,
/// Provider-side model id (e.g. `gpt-5.1`).
pub model_id: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct GatewaySettings {
/// Auto-start the gateway container with the app.
#[serde(default)]
pub enabled: bool,
/// Host port the gateway is published on.
#[serde(default = "default_gateway_port")]
pub port: u16,
/// LiteLLM provider prefix — `openai`, `azure`, `gemini`, `groq`, …
#[serde(default = "default_gateway_provider")]
pub provider: String,
/// Optional provider base URL override (Azure endpoints, proxies, …).
#[serde(default)]
pub api_base: Option<String>,
/// Models the gateway should serve.
#[serde(default)]
pub models: Vec<GatewayModel>,
}
impl Default for GatewaySettings {
fn default() -> Self {
Self {
enabled: false,
port: default_gateway_port(),
provider: default_gateway_provider(),
api_base: None,
models: Vec::new(),
}
}
}
impl GatewaySettings {
/// Models with both fields filled in. Half-typed rows in the UI must not
/// reach the generated YAML.
pub fn valid_models(&self) -> Vec<&GatewayModel> {
self.models
.iter()
.filter(|m| !m.name.trim().is_empty() && !m.model_id.trim().is_empty())
.collect()
}
}
/// What the settings UI needs to know about the gateway. Deliberately carries
/// **no** secret: `has_api_key` is a boolean, not the key.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct GatewayStatus {
pub container_exists: bool,
pub running: bool,
pub port: u16,
pub image_exists: bool,
/// Number of fully-specified models in the current settings.
pub model_count: usize,
/// Whether a provider API key is present in the keychain.
pub has_api_key: bool,
/// The value a project should use for its base URL. See
/// `docker::gateway::gateway_base_url`.
pub base_url: String,
}
+2
View File
@@ -1,9 +1,11 @@
pub mod project;
pub mod container_config;
pub mod app_settings;
pub mod gateway_settings;
pub mod update_info;
pub use project::*;
pub use container_config::*;
pub use app_settings::*;
pub use gateway_settings::*;
pub use update_info::*;
+78 -7
View File
@@ -123,6 +123,8 @@ pub struct Project {
pub backend: Backend,
pub bedrock_config: Option<BedrockConfig>,
pub ollama_config: Option<OllamaConfig>,
#[serde(default, alias = "llama_cpp_config")]
pub llamacpp_config: Option<LlamaCppConfig>,
#[serde(alias = "litellm_config")]
pub openai_compatible_config: Option<OpenAiCompatibleConfig>,
pub allow_docker_access: bool,
@@ -137,6 +139,12 @@ pub struct Project {
/// because toggling it changes nothing about the container itself.
#[serde(default)]
pub auth_bridge_enabled: bool,
/// Opt in to the browser-view pane, which watches and takes over the
/// browser Claude drives with Playwright inside the container. Purely
/// host-side like `auth_bridge_enabled`, so it likewise has no
/// container-recreation label.
#[serde(default)]
pub browser_view_enabled: bool,
/// Use the shared, long-lived Claude Code OAuth token (from
/// `claude setup-token`, held in the OS keychain) for this project instead
/// of requiring its own `claude login`. Only consulted when `backend` is
@@ -191,8 +199,10 @@ pub enum ProjectStatus {
/// - `Anthropic`: Direct Anthropic API (user runs `claude login` inside the container)
/// - `Bedrock`: AWS Bedrock with per-project AWS credentials
/// - `Ollama`: Local or remote Ollama server
/// - `OpenAiCompatible`: Any OpenAI API-compatible endpoint (e.g., LiteLLM, vLLM, etc.)
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
/// - `LlamaCpp`: A local or remote `llama-server` (llama.cpp)
/// - `OpenAiCompatible`: Any endpoint that speaks the Anthropic Messages API
/// (e.g. LiteLLM). See [`Backend::uses_custom_endpoint`].
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum Backend {
/// Backward compat: old projects stored as "login" or "api_key" map to Anthropic.
@@ -200,6 +210,10 @@ pub enum Backend {
Anthropic,
Bedrock,
Ollama,
/// Serialises as `llama_cpp`; the aliases accept the spellings a
/// hand-edited `projects.json` is likely to contain.
#[serde(alias = "llamacpp", alias = "llama-cpp", alias = "llama.cpp")]
LlamaCpp,
#[serde(alias = "lite_llm", alias = "litellm")]
OpenAiCompatible,
}
@@ -210,6 +224,28 @@ impl Default for Backend {
}
}
impl Backend {
/// Whether this backend points Claude Code at a non-Anthropic HTTP endpoint
/// via `ANTHROPIC_BASE_URL`.
///
/// Those endpoints serve whatever model *they* were started with, so
/// Claude Code's built-in `opus`/`sonnet`/`haiku`/`fable` aliases resolve to
/// Anthropic model ids the server has never heard of. Every backend for
/// which this returns `true` therefore gets the
/// `ANTHROPIC_DEFAULT_*_MODEL` alias vars pinned to the configured model —
/// see `docker::container::compute_model_aliases`.
///
/// Bedrock is deliberately excluded: it talks to AWS, which does host the
/// real Anthropic model ids, so Claude Code's own defaults are correct
/// there. Anthropic is excluded for the same reason.
pub fn uses_custom_endpoint(&self) -> bool {
matches!(
self,
Backend::Ollama | Backend::LlamaCpp | Backend::OpenAiCompatible
)
}
}
/// How Bedrock authenticates with AWS.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "snake_case")]
@@ -248,27 +284,60 @@ pub struct BedrockConfig {
}
/// Ollama configuration for a project.
/// Ollama exposes an Anthropic-compatible API endpoint.
/// Ollama natively implements the Anthropic Messages API at `/v1/messages`.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OllamaConfig {
/// The base URL of the Ollama server (e.g., "http://host.docker.internal:11434" or "http://192.168.1.100:11434")
pub base_url: String,
/// Optional model override (e.g., "qwen3.5:27b")
pub model_id: Option<String>,
/// Optional override for the model the `haiku` alias resolves to.
/// Blank falls back to `model_id`. See [`Backend::uses_custom_endpoint`].
#[serde(default)]
pub haiku_model_id: Option<String>,
}
/// llama.cpp (`llama-server`) configuration for a project.
///
/// `llama-server` natively implements the Anthropic Messages API at
/// `POST /v1/messages` (plus `/v1/messages/count_tokens`), so Claude Code can
/// talk to it directly through `ANTHROPIC_BASE_URL` — exactly like Ollama, with
/// no translation shim.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LlamaCppConfig {
/// The base URL of the llama-server instance. `llama-server`'s default
/// listen port is 8080 (`--port PORT | port to listen (default: 8080)`).
pub base_url: String,
/// Optional model override. `llama-server` serves whatever model it was
/// started with, so this is mostly the id Claude Code should *say* it is
/// using — but it is also what the model aliases are pinned to.
pub model_id: Option<String>,
/// Optional override for the model the `haiku` alias resolves to.
/// Blank falls back to `model_id`.
#[serde(default)]
pub haiku_model_id: Option<String>,
}
/// OpenAI Compatible endpoint configuration for a project.
/// Routes Anthropic API calls through any OpenAI API-compatible endpoint
/// (e.g., LiteLLM, vLLM, or other compatible gateways).
///
/// Despite the name (kept for backward compatibility with existing
/// `projects.json` data), the endpoint must implement the **Anthropic Messages
/// API** — Claude Code only ever speaks `POST /v1/messages`. Gateways such as
/// LiteLLM expose an Anthropic-shaped route and work; a bare
/// `/v1/chat/completions` server does not.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OpenAiCompatibleConfig {
/// The base URL of the OpenAI-compatible endpoint (e.g., "http://host.docker.internal:4000" or "https://api.example.com")
/// The base URL of the endpoint (e.g., "http://host.docker.internal:4000" or "https://api.example.com")
pub base_url: String,
/// API key for the OpenAI-compatible endpoint
/// API key for the endpoint
#[serde(skip_serializing, default)]
pub api_key: Option<String>,
/// Optional model override
pub model_id: Option<String>,
/// Optional override for the model the `haiku` alias resolves to.
/// Blank falls back to `model_id`.
#[serde(default)]
pub haiku_model_id: Option<String>,
}
impl Project {
@@ -283,11 +352,13 @@ impl Project {
backend: Backend::default(),
bedrock_config: None,
ollama_config: None,
llamacpp_config: None,
openai_compatible_config: None,
allow_docker_access: false,
sandbox_mode_enabled: false,
mission_control_enabled: false,
auth_bridge_enabled: false,
browser_view_enabled: false,
use_shared_auth_token: default_use_shared_auth_token(),
full_permissions: false,
permission_mode: None,