diff --git a/crates/daemon/src/config.rs b/crates/daemon/src/config.rs index e2d00124..6e98b03d 100644 --- a/crates/daemon/src/config.rs +++ b/crates/daemon/src/config.rs @@ -216,14 +216,28 @@ enabled = true # # These profiles are ALSO the route targets used by [router] below: with # routing enabled, each one appears in the TUI's route picker for any -# route-capable session. `openai` / `grok` / `anthropic` profiles are -# selectable from a `claude` session (the first two via translation); -# `meta` / `ollama` are shown with the reason they are not. - -# DeepSeek is built in: export DEEPSEEK_API_KEY and it is already a route -# target and a `deepseek:` prefix for smith — no profile needed. -# Declare one only to override the endpoint, key, or default model; a profile -# named `deepseek` replaces the built-in entirely. +# route-capable session. `anthropic` / `openai` / `grok` / `meta` / `gemini` +# profiles are all selectable from a `claude` session (every one but +# `anthropic` via translation); `ollama`'s native API is shown with the +# reason it is not. +# +# Every direct-API-key provider below is BUILT IN: export its key and it is +# already a route target, with nothing to declare here (spec 0179). +# +# ANTHROPIC_API_KEY -> anthropic (claude-opus-4-8) +# OPENAI_API_KEY -> openai (gpt-5) +# GEMINI_API_KEY / GOOGLE_API_KEY-> gemini (gemini-2.5-pro) +# META_API_KEY / MODEL_API_KEY -> meta (muse-spark-1.1) +# GROK_API_KEY / XAI_API_KEY -> grok (grok-4.5) +# DEEPSEEK_API_KEY -> deepseek (deepseek-v4-pro) +# +# The key must be in the DAEMON's environment (or [daemon.env] below), and a +# restart picks it up. Declare a profile with one of those names only to +# override the endpoint, key, or default model — a declared profile replaces +# the built-in entirely rather than adding a second entry. + +# The built-in DeepSeek target, written out. Identical to what DEEPSEEK_API_KEY +# gives you for free; shown here as the shape to copy when overriding one. # [smith.models.deepseek] # provider = "deepseek" # base_url = "https://api.deepseek.com/v1" @@ -289,8 +303,8 @@ enabled = true # applies only where that variable is not already set to a non-empty value, so # whatever you export for a given run always wins. # -# It covers the credentials the daemon itself resolves — built-in route targets -# (e.g. DeepSeek), `[smith.models.*]` profiles that name no key, and what +# It covers the credentials the daemon itself resolves — the built-in route +# targets listed above, `[smith.models.*]` profiles that name no key, and what # /configure and `construct doctor` report — and is passed down as the base # environment of every process the daemon spawns: session adapters, title # generation, suggestions. @@ -405,13 +419,14 @@ enabled = true # gateway is loopback-only and capability-scoped to the session; Construct # leaves an existing ANTHROPIC_BASE_URL untouched. # -# Route targets are the [smith.models.*] profiles below — declare an -# endpoint once and it is reachable from both smith and a routed session. +# Route targets are the built-in API-key providers listed above plus the +# [smith.models.*] profiles — declare an endpoint once and it is reachable +# from both smith and a routed session. # When the target's dialect differs from the harness's, the router # translates through a canonical form. Anthropic Messages, OpenAI Chat -# Completions, OpenAI Responses (including Azure), and Google Gemini are -# supported. Providers with no translator — meta and Ollama's native API — -# are listed in the picker with that reason and cannot be selected. An +# Completions, OpenAI Responses (including Azure and Meta), and Google Gemini +# are supported. Providers with no translator — Ollama's native API — are +# listed in the picker with that reason and cannot be selected. An # OpenAI-compatible server (including Ollama's own /v1 # endpoint) can be reached by declaring it as `provider = "openai"` with # its base_url. @@ -648,6 +663,82 @@ pub const DEEPSEEK_API_KEY_ENV: &str = "DEEPSEEK_API_KEY"; /// [`SmithConfig::route_profiles`]). pub const DEEPSEEK_ROUTE_NAME: &str = "deepseek"; +/// One built-in API-key route target (spec 0179): a provider with a single +/// well-known public endpoint, which becomes a route target the moment its +/// key is present in the daemon's environment — no config block required. +pub struct BuiltinTarget { + /// Route name the built-in claims. A user-declared `[smith.models.*]` + /// profile of the same name replaces the built-in entirely. + pub route: &'static str, + /// Wire provider, which the router maps to a dialect. A provider with no + /// translator must never be listed here: it would appear in the picker + /// only to report that it cannot be selected, which is exactly what a + /// built-in is meant to avoid. + pub provider: &'static str, + /// Public endpoint. Asserting one here is asserting the vendor has + /// exactly one — anything region-, tenant-, or deployment-specific stays + /// declaration-only. + pub base_url: &'static str, + /// Env vars carrying the credential, in the order they are consulted. + /// The target exists when any of them resolves. + pub key_envs: &'static [&'static str], + /// Model the target sends when none is chosen. The picker's remaining + /// models come from the shared catalog via `models_for_provider`. + pub default_model: &'static str, +} + +/// Every built-in API-key route target (spec 0179). +/// +/// Each entry is a deliberate per-provider decision, not a sweep: the +/// endpoint must be the vendor's only one, and the router must have a +/// translator for the wire provider. The default models mirror smith's own +/// auto-detect ladder, so a route's default is the same model an unpinned +/// smith session would pick from the same key. +pub const BUILTIN_TARGETS: &[BuiltinTarget] = &[ + BuiltinTarget { + route: "anthropic", + provider: "anthropic", + base_url: "https://api.anthropic.com/v1", + key_envs: &["ANTHROPIC_API_KEY"], + default_model: "claude-opus-4-8", + }, + BuiltinTarget { + route: "openai", + provider: "openai", + base_url: "https://api.openai.com/v1", + key_envs: &["OPENAI_API_KEY"], + default_model: "gpt-5", + }, + BuiltinTarget { + route: "gemini", + provider: "gemini", + base_url: "https://generativelanguage.googleapis.com/v1beta", + key_envs: &["GEMINI_API_KEY", "GOOGLE_API_KEY"], + default_model: "gemini-2.5-pro", + }, + BuiltinTarget { + route: "meta", + provider: "meta", + base_url: "https://api.meta.ai/v1", + key_envs: &["META_API_KEY", "MODEL_API_KEY"], + default_model: "muse-spark-1.1", + }, + BuiltinTarget { + route: "grok", + provider: "grok", + base_url: "https://api.x.ai/v1", + key_envs: &["GROK_API_KEY", "XAI_API_KEY"], + default_model: "grok-4.5", + }, + BuiltinTarget { + route: DEEPSEEK_ROUTE_NAME, + provider: DEEPSEEK_ROUTE_NAME, + base_url: DEEPSEEK_BASE_URL, + key_envs: &[DEEPSEEK_API_KEY_ENV], + default_model: "deepseek-v4-pro", + }, +]; + /// `[smith]` — only the `models` table is read by the daemon. Smith parses /// this same section itself for its own `/model @` switching; the /// daemon reads it so the router can offer the same endpoints as route @@ -666,20 +757,29 @@ impl SmithConfig { /// A built-in exists so a provider with one well-known endpoint costs the /// user an env var rather than a config block. It is *only* a default: a /// declared profile of the same name always wins, so pinning a different - /// base URL, key, or model for `deepseek` still works. + /// base URL, key, or model for any of them still works. pub fn route_profiles(&self) -> BTreeMap { let mut profiles = self.models.clone(); - if !profiles.contains_key(DEEPSEEK_ROUTE_NAME) && env_var_present(DEEPSEEK_API_KEY_ENV) { + for target in BUILTIN_TARGETS { + if profiles.contains_key(target.route) { + continue; + } + // The first var that actually resolves becomes the profile's + // `api_key_env`, so the picker's blocker names the variable the + // credential really came from. + let Some(key_env) = target.key_envs.iter().find(|v| env_var_present(v)) else { + continue; + }; profiles.insert( - DEEPSEEK_ROUTE_NAME.to_string(), + target.route.to_string(), ModelProfile { - provider: DEEPSEEK_ROUTE_NAME.to_string(), - base_url: Some(DEEPSEEK_BASE_URL.to_string()), - api_key_env: Some(DEEPSEEK_API_KEY_ENV.to_string()), + provider: target.provider.to_string(), + base_url: Some(target.base_url.to_string()), + api_key_env: Some((*key_env).to_string()), api_key: None, // The picker's remaining models come from the shared // catalog via `models_for_provider`; this is the default. - model: Some("deepseek-v4-pro".to_string()), + model: Some(target.default_model.to_string()), }, ); } @@ -1376,28 +1476,51 @@ mod tests { ); } - /// Run `f` with `DEEPSEEK_API_KEY` set to `value` (or unset for `None`), - /// restoring whatever the environment had before. + /// Run `f` with *every* built-in key env cleared except the pairs in + /// `set`, restoring whatever the environment had before. /// - /// Takes the crate-wide env guard, not a lock private to this module: the - /// variable is process-global, and `smith_auth_methods` in `availability` - /// reads it too. A private lock would let this test's `set_var` window - /// overlap that test's read. - fn with_deepseek_key(value: Option<&str>, f: impl FnOnce() -> T) -> T { + /// Clearing all of them is what makes these tests describe the fixture + /// rather than the developer's shell: now that every direct-API-key + /// provider is a built-in, an exported `ANTHROPIC_API_KEY` would add a + /// route target no test asked for, and the counts below would pass or + /// fail depending on whose machine ran them. + /// + /// Takes the crate-wide env guard, not a lock private to this module: + /// these variables are process-global, and `smith_auth_methods` in + /// `availability` reads them too. A private lock would let this test's + /// `set_var` window overlap that test's read. + fn with_builtin_keys(set: &[(&str, &str)], f: impl FnOnce() -> T) -> T { let _lock = crate::router::oauth::test_env_guard(); - let saved = std::env::var(DEEPSEEK_API_KEY_ENV).ok(); - match value { - Some(v) => std::env::set_var(DEEPSEEK_API_KEY_ENV, v), - None => std::env::remove_var(DEEPSEEK_API_KEY_ENV), + let all: Vec<&str> = BUILTIN_TARGETS + .iter() + .flat_map(|t| t.key_envs.iter().copied()) + .collect(); + let saved: Vec<(&str, Option)> = + all.iter().map(|v| (*v, std::env::var(v).ok())).collect(); + for v in &all { + std::env::remove_var(v); + } + for (k, v) in set { + std::env::set_var(k, v); } let out = f(); - match saved { - Some(v) => std::env::set_var(DEEPSEEK_API_KEY_ENV, v), - None => std::env::remove_var(DEEPSEEK_API_KEY_ENV), + for (v, prior) in saved { + match prior { + Some(prior) => std::env::set_var(v, prior), + None => std::env::remove_var(v), + } } out } + /// The common case: only DeepSeek's key is set. + fn with_deepseek_key(value: Option<&str>, f: impl FnOnce() -> T) -> T { + match value { + Some(v) => with_builtin_keys(&[(DEEPSEEK_API_KEY_ENV, v)], f), + None => with_builtin_keys(&[], f), + } + } + /// A `deepseek` provider resolves to the same endpoint and credential /// whether it reaches the router as a built-in or as a declared profile. #[test] @@ -1426,6 +1549,162 @@ mod tests { ); } + /// Every built-in must be usable the moment it is listed. A route whose + /// provider no dialect can serve, or whose endpoint disagrees with what + /// the same profile would resolve on its own, is a target the picker + /// offers and then refuses — the failure mode spec 0179 exists to + /// prevent. This is the guard for adding a new entry to the table. + #[test] + fn every_builtin_target_is_routable_and_self_consistent() { + for target in BUILTIN_TARGETS { + assert!( + crate::router::provider_dialect(target.provider).is_some(), + "built-in {:?} has no translator for provider {:?}: it would be \ + listed but never selectable", + target.route, + target.provider + ); + assert!( + !target.key_envs.is_empty(), + "built-in {:?} has no credential to detect", + target.route + ); + // A built-in resolves to the same place whether it reaches the + // router synthesized here or declared by hand as `provider = + // ""` with no base_url. + let bare = ModelProfile { + provider: target.provider.to_string(), + base_url: None, + api_key_env: None, + api_key: None, + model: None, + }; + assert_eq!( + bare.resolved_base_url().as_deref(), + Some(target.base_url), + "built-in {:?} points somewhere its own provider default does not", + target.route + ); + assert_eq!( + bare.default_key_envs(), + target.key_envs, + "built-in {:?} detects a different key than the profile would resolve", + target.route + ); + assert!( + !target.default_model.trim().is_empty(), + "built-in {:?} sets no model, so it could never be armed", + target.route + ); + } + } + + /// Route names are a shared namespace, so two built-ins claiming one name + /// would make which endpoint you get depend on table order. + #[test] + fn builtin_route_names_are_unique() { + let mut seen = std::collections::BTreeSet::new(); + for target in BUILTIN_TARGETS { + assert!( + seen.insert(target.route), + "duplicate built-in route name {:?}", + target.route + ); + } + } + + /// The generalization (spec 0179's retrofit clause): a key alone makes + /// each of these a route target, with no config block anywhere. + #[test] + fn every_direct_api_key_provider_is_a_builtin_target() { + let cfg: Config = toml::from_str("").expect("parse"); + for target in BUILTIN_TARGETS { + // Every assertion runs inside the closure: the guard restores the + // real environment on the way out, and `resolve_api_key` reads it + // live rather than off the profile. + with_builtin_keys(&[(target.key_envs[0], "sk-test")], || { + let profiles = cfg.smith.route_profiles(); + let built = profiles + .get(target.route) + .unwrap_or_else(|| panic!("{:?} should be a built-in target", target.route)); + assert_eq!(built.provider, target.provider); + assert_eq!(built.resolved_base_url().as_deref(), Some(target.base_url)); + assert_eq!(built.model.as_deref(), Some(target.default_model)); + assert_eq!(built.resolve_api_key().as_deref(), Ok("sk-test")); + assert_eq!( + profiles.len(), + 1, + "only {:?}'s key was set, so it must be the only target", + target.route + ); + }); + } + } + + /// A provider with two accepted key vars is reachable through either — + /// the second is not a decorative alias. + #[test] + fn an_alternate_key_env_also_creates_the_target() { + let cfg: Config = toml::from_str("").expect("parse"); + for target in BUILTIN_TARGETS.iter().filter(|t| t.key_envs.len() > 1) { + for key_env in target.key_envs { + with_builtin_keys(&[(key_env, "sk-alt")], || { + let profiles = cfg.smith.route_profiles(); + let built = profiles.get(target.route).unwrap_or_else(|| { + panic!("{:?} should be reachable via {key_env}", target.route) + }); + // The profile names the var the credential really came + // from, so a blocker later names the one the user set. + assert_eq!(built.api_key_env.as_deref(), Some(*key_env)); + assert_eq!(built.resolve_api_key().as_deref(), Ok("sk-alt")); + }); + } + } + } + + /// Built-ins are independent: one key does not conjure the others, and + /// several keys produce several targets. + #[test] + fn builtin_targets_appear_only_for_the_keys_that_are_set() { + let cfg: Config = toml::from_str("").expect("parse"); + let profiles = with_builtin_keys( + &[("ANTHROPIC_API_KEY", "sk-ant"), ("GROK_API_KEY", "xai-key")], + || cfg.smith.route_profiles(), + ); + assert!(profiles.contains_key("anthropic")); + assert!(profiles.contains_key("grok")); + assert!(!profiles.contains_key("openai")); + assert!(!profiles.contains_key("gemini")); + assert!(!profiles.contains_key(DEEPSEEK_ROUTE_NAME)); + assert_eq!(profiles.len(), 2); + } + + /// The collision rule holds for every built-in, not just DeepSeek: a + /// declared profile of the same name replaces it entirely, so pointing + /// `openai` at an internal gateway keeps working after this change. + #[test] + fn a_declared_profile_beats_every_builtin() { + let toml = r#" + [smith.models.openai] + provider = "openai" + base_url = "https://gateway.internal/v1" + api_key_env = "WORK_OPENAI_KEY" + model = "gpt-5-mini" + "#; + let cfg: Config = toml::from_str(toml).expect("parse"); + let profiles = with_builtin_keys(&[("OPENAI_API_KEY", "sk-public")], || { + cfg.smith.route_profiles() + }); + let openai = profiles.get("openai").expect("declared profile"); + assert_eq!( + openai.resolved_base_url().as_deref(), + Some("https://gateway.internal/v1") + ); + assert_eq!(openai.api_key_env.as_deref(), Some("WORK_OPENAI_KEY")); + assert_eq!(openai.model.as_deref(), Some("gpt-5-mini")); + assert_eq!(profiles.len(), 1, "the built-in must not be added alongside"); + } + /// No key, no target: the picker must not advertise an endpoint that /// would fail on first use. #[test] diff --git a/crates/daemon/src/router.rs b/crates/daemon/src/router.rs index a6ca5258..456b209a 100644 --- a/crates/daemon/src/router.rs +++ b/crates/daemon/src/router.rs @@ -119,7 +119,10 @@ pub fn provider_dialect(provider: &str) -> Option { "openai" | "grok" | "deepseek" => Some(Dialect::OpenAiChat), // Azure's current v1 API uses Responses on the wire; its adapter // difference is the `api-key` header, not a separate JSON dialect. - "openai-responses" | "azure" | "azure-openai" => { + // Meta serves Muse Spark over the same Responses surface — smith's + // own Meta client posts to `/v1/responses` and decodes the standard + // `response.*` event vocabulary, which is what this translator emits. + "openai-responses" | "azure" | "azure-openai" | "meta" => { Some(Dialect::OpenAiResponses) } _ => None, @@ -2148,25 +2151,69 @@ mod tests { } /// A provider with no translator is offered but not selectable, with - /// the reason attached rather than hidden (spec 0115). + /// the reason attached rather than hidden (spec 0115). Ollama's native + /// API is the example: its `/api/chat` shape is nobody else's, and the + /// way to route to it is to declare its OpenAI-compatible `/v1` endpoint + /// as `provider = "openai"` instead. #[tokio::test] async fn untranslatable_providers_are_listed_unavailable() { let dir = tempfile::tempdir().unwrap(); let r = started_with( &dir, cfg_with(true), - profiles(vec![("meta-model", profile("meta", Some("X")))]), + profiles(vec![("local", profile("ollama", None))]), ) .await; r.attach_session("s1", "claude", None).unwrap(); let listed = r.list_routes("claude", true, None, false); assert!(listed.unavailable_reason.is_none()); - let reason = route_named(&listed, "meta-model") + let reason = route_named(&listed, "local") .unavailable_reason .as_deref() .unwrap(); assert!(reason.contains("no translator"), "{reason}"); - assert!(r.set_route("s1", "claude", Some("meta-model"), None, None, None).is_err()); + assert!(r.set_route("s1", "claude", Some("local"), None, None, None).is_err()); + } + + /// Meta's Model API is the Responses wire format, not a dialect of its + /// own — smith's own Meta client posts to `/v1/responses` and reads the + /// standard `response.*` events. Before this was mapped, Meta was the + /// one built-in-eligible provider that would have been listed as + /// permanently unusable. + #[tokio::test] + async fn meta_profiles_speak_responses_and_are_selectable() { + let dir = tempfile::tempdir().unwrap(); + let r = started_with( + &dir, + cfg_with(true), + profiles(vec![( + "meta", + ModelProfile { + provider: "meta".to_string(), + base_url: None, + api_key_env: None, + api_key: Some("meta-key".to_string()), + model: Some("muse-spark-1.1".to_string()), + }, + )]), + ) + .await; + r.attach_session("s1", "claude", None).unwrap(); + + let listed = r.list_routes("claude", true, None, false); + let meta = route_named(&listed, "meta"); + assert_eq!(meta.unavailable_reason, None); + assert_eq!(meta.dialect, "openai-responses"); + assert_eq!(meta.base_url, "https://api.meta.ai/v1"); + + r.set_route("s1", "claude", Some("meta"), None, None, None) + .unwrap(); + let armed = r.sessions.read().unwrap()["s1"].armed_route().unwrap(); + assert_eq!(armed.target_dialect, Dialect::OpenAiResponses); + // Meta takes a bearer, not the Anthropic key header, even though the + // harness on this side of the route is an Anthropic one. + assert_eq!(armed.auth, TargetAuth::Bearer); + assert_eq!(armed.endpoint, "https://api.meta.ai/v1/responses"); } #[tokio::test] diff --git a/crates/protocol/src/slash.rs b/crates/protocol/src/slash.rs index 11489f72..758aa9f6 100644 --- a/crates/protocol/src/slash.rs +++ b/crates/protocol/src/slash.rs @@ -420,6 +420,10 @@ pub const MODEL_COMPLETIONS: &[&str] = &[ // Local Ollama examples. "ollama:llama3.1", "ollama:qwen3-coder", + // xAI platform API path. Same models as the OAuth path below, billed + // against an API key instead of a Grok subscription. + "grok:grok-4.5", + "grok:grok-4.3", // Grok / xAI OAuth path. "grok-oauth:grok-4.5", "grok-oauth:grok-4.3", diff --git a/docs/model-routing.md b/docs/model-routing.md index 6f7f4edf..ee0713a4 100644 --- a/docs/model-routing.md +++ b/docs/model-routing.md @@ -28,10 +28,27 @@ A target is somewhere the router can send a model request: reads those credentials from the owning CLI's store and never refreshes them; an expired login is reported with the command to renew it. - **Built-in API-key providers** are offered as soon as their key is in the - daemon's environment, with nothing to declare. `DEEPSEEK_API_KEY` alone - makes DeepSeek a route target (spec 0179). Declaring a profile under the - same name replaces the built-in, so a private gateway or second account - still overrides it. + daemon's environment, with nothing to declare (spec 0179). Every + direct-API-key provider Construct speaks to is built in: + + | Key | Route | Default model | + | :--- | :--- | :--- | + | `ANTHROPIC_API_KEY` | `anthropic` | `claude-opus-4-8` | + | `OPENAI_API_KEY` | `openai` | `gpt-5` | + | `GEMINI_API_KEY` / `GOOGLE_API_KEY` | `gemini` | `gemini-2.5-pro` | + | `META_API_KEY` / `MODEL_API_KEY` | `meta` | `muse-spark-1.1` | + | `GROK_API_KEY` / `XAI_API_KEY` | `grok` | `grok-4.5` | + | `DEEPSEEK_API_KEY` | `deepseek` | `deepseek-v4-pro` | + + The default model is only the default — the picker offers the rest of that + provider's catalog too. Declaring a profile under the same name replaces + the built-in, so a private gateway or second account still overrides it. + The key has to be in the *daemon's* environment (or `[daemon.env]`), so a + key exported after the daemon started needs a restart to take effect. + + Providers with both an API key and a subscription login (Claude, Codex, + Grok) show both, side by side — they are different billing paths, not + duplicates. DeepSeek's reasoning effort is offered per model: `deepseek-v4-flash` exposes `low` / `high` / `max` (default `high`), and `deepseek-v4-pro` @@ -52,9 +69,11 @@ A target is somewhere the router can send a model request: When the target speaks a different wire dialect than the harness, the router translates through a canonical form: Anthropic Messages, OpenAI -Chat Completions, OpenAI Responses (including Azure), and Google Gemini -are supported. Targets with no translator are still listed in the picker, -with the reason they can't be selected. +Chat Completions, OpenAI Responses (including Azure and Meta), and Google +Gemini are supported. Targets with no translator — Ollama's native API — are +still listed in the picker, with the reason they can't be selected. Ollama's +own OpenAI-compatible `/v1` endpoint is routable: declare it as +`provider = "openai"`. A target appears only when it is actually usable — a credential the router can read, or a configured endpoint with its key present. A fresh machine @@ -69,8 +88,13 @@ offer, so pickers stay native-only until one of those exists. | `codex-oauth` | Subscription Login | OpenAI Responses | `https://chatgpt.com/backend-api/codex/responses` | Auto-discovered from Codex CLI store (read-only token) | | `grok-oauth` | Subscription Login | OpenAI Chat Completions | `https://api.x.ai/v1/chat/completions` | Auto-discovered from Grok CLI store (read-only token) | | `kimi-oauth` | Subscription Login | Anthropic Messages | `https://api.kimi.com/coding/v1/messages` | Auto-discovered from Kimi CLI store (read-only token) | +| `anthropic` | Built-in API Key | Anthropic Messages | `https://api.anthropic.com/v1` | `ANTHROPIC_API_KEY` in the daemon's environment | +| `openai` | Built-in API Key | OpenAI Chat Completions | `https://api.openai.com/v1` | `OPENAI_API_KEY` in the daemon's environment | +| `gemini` | Built-in API Key | Google Gemini | `https://generativelanguage.googleapis.com/v1beta` | `GEMINI_API_KEY` / `GOOGLE_API_KEY` in the daemon's environment | +| `meta` | Built-in API Key | OpenAI Responses | `https://api.meta.ai/v1` | `META_API_KEY` / `MODEL_API_KEY` in the daemon's environment | +| `grok` | Built-in API Key | OpenAI Chat Completions | `https://api.x.ai/v1` | `GROK_API_KEY` / `XAI_API_KEY` in the daemon's environment | | `deepseek` | Built-in API Key | OpenAI Chat Completions | `https://api.deepseek.com/v1` | `DEEPSEEK_API_KEY` in the daemon's environment | -| `[smith.models.]` | Declared Endpoint | Configured (`openai`, `anthropic`, `responses`, `gemini`, `azure`, `deepseek`) | Configured `base_url` | Declared in `config.toml` (`api_key_env` / `api_key`) | +| `[smith.models.]` | Declared Endpoint | Configured (`openai`, `anthropic`, `responses`, `gemini`, `azure`, `meta`, `grok`, `deepseek`) | Configured `base_url` | Declared in `config.toml` (`api_key_env` / `api_key`) | *Note: Antigravity OAuth logins are not offered as route targets because their backend uses a Gemini-shaped protocol with no proxy translator.* diff --git a/docs/smith.md b/docs/smith.md index 090ff7f7..3a9a4d2e 100644 --- a/docs/smith.md +++ b/docs/smith.md @@ -116,9 +116,11 @@ Each `[smith.models.]` entry sets: standard key env var is used (`OPENAI_API_KEY`, etc.). - `model` — default model name; override per call with `@:`. -DeepSeek needs no profile — `DEEPSEEK_API_KEY` plus the `deepseek:` prefix -already reaches its public endpoint. Declare one only for an endpoint -Construct can't know: a private gateway, a reseller, a second account. +None of the direct-API-key providers needs a profile — the key plus the +`:` prefix already reaches its public endpoint, and the same key +makes it a route target for other harnesses (spec 0179). Declare one only +for an endpoint Construct can't know: a private gateway, a reseller, a +second account. ```toml [smith.models.work-gateway] @@ -243,8 +245,12 @@ notice in the status bar that opens `/configure`. accepted). - `GROK_API_KEY` / `XAI_API_KEY` — xAI Grok API credentials (either is accepted). -- `DEEPSEEK_API_KEY` — DeepSeek platform credentials. Also makes DeepSeek a - route target for other harnesses with no further config (spec 0179). +- `DEEPSEEK_API_KEY` — DeepSeek platform credentials. + + Each of the keys above also makes its provider a **route target** for other + harnesses with no further config, when it is set in the daemon's + environment (spec 0179) — see + [Model routing](model-routing.md#route-targets). - `GROK_HOME` — override the base directory used by `grok-oauth:` token lookup; Smith reads `$GROK_HOME/.grok/auth.json` instead of `~/.grok/auth.json`. - `KIMI_CODE_HOME` — override the base directory used by `kimi-oauth:` diff --git a/specs/0179-builtin-api-key-route-targets.md b/specs/0179-builtin-api-key-route-targets.md index b3e5a535..8d716e56 100644 --- a/specs/0179-builtin-api-key-route-targets.md +++ b/specs/0179-builtin-api-key-route-targets.md @@ -23,9 +23,24 @@ A built-in is a *default*, never an override: present-but-blocked, because there is nothing the user declared that a blocker would be explaining. +Every direct-API-key provider Construct speaks to qualifies, and all of them +are built in: Anthropic, OpenAI, Gemini, Meta, Grok, and DeepSeek. A provider +is admitted only when both hold: + +- its public endpoint is the vendor's *only* one, and +- the router has a translator for its wire dialect, so the target is + selectable and not merely listed. + +The second is a hard gate, not a nicety. A built-in that cannot be routed to +is strictly worse than no built-in: it occupies a route name and spends the +picker's space to say "unavailable". + This does not extend to OAuth/subscription targets, which are discovered from a local CLI's credential store and already appear automatically, nor to providers whose endpoint genuinely varies per user — those must be declared. +A provider reachable through both an API key and a subscription login has two +targets, and that is correct: they are different billing paths, and the user +picks which one to spend. ## Reason @@ -57,10 +72,15 @@ signal explaining why. - Because a built-in is materialized as a profile, none of the router's downstream machinery (dialect translation, published-model ids, effort levels, picker blockers) needs to know built-ins exist. -- Retrofitting built-ins onto providers that today require a profile is - allowed but is a behavior change for existing machines: pickers that were - empty would start listing entries. Such a change should be made deliberately - per provider, not as a sweep. +- Retrofitting built-ins onto providers that previously required a profile is + a behavior change for existing machines: pickers that were empty start + listing entries. Each provider is admitted deliberately against the two + criteria above, never because it resembles one already admitted. +- Because the credential check is the only thing gating a built-in, exporting + a key now has one meaning everywhere: smith can use it *and* every routable + harness can be pointed at it. A key that works in one place and silently + not the other is the failure this decision exists to remove, so a new + direct-API-key provider added to smith should arrive with its built-in. ## Non-Goals