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:
@@ -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,
|
||||
}
|
||||
@@ -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::*;
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user