From 28c75f50759e9b73b9a6105061f865cc7db50505 Mon Sep 17 00:00:00 2001 From: Edwin Date: Mon, 3 Aug 2026 07:39:09 -0700 Subject: [PATCH] Make every direct-API-key provider a built-in route target MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Exporting DEEPSEEK_API_KEY made DeepSeek a route target with nothing to declare (spec 0179). Exporting OPENAI_API_KEY did not do the same for OpenAI: it made smith work immediately and silently did nothing for the route pickers, so "I set the key" and "I can route to it" came apart with no signal explaining why — the exact asymmetry 0179 was written to remove. Generalize the built-in to every direct-API-key provider: anthropic, openai, gemini, meta, grok, and deepseek. The mechanism was already right — route_profiles() synthesizes an ordinary profile and nothing downstream knows built-ins exist — so the DeepSeek-shaped `if` becomes a BUILTIN_TARGETS table and the machinery is untouched. Two things were genuinely missing: - Meta had no dialect. provider_dialect() had no `meta` arm, so Meta was the one built-in-eligible provider that would have been listed as permanently unusable. Meta serves Muse Spark over the OpenAI Responses API — smith's own Meta client posts to /v1/responses and decodes the standard response.* events, which is what the router's existing Responses translator emits. - `grok:` had no rows in the shared model catalog, so a direct Grok route would have offered its default model and nothing else. The key a built-in reports as api_key_env is the one that actually resolved, so a later blocker names the variable the user really set rather than the first alias in the list. This is the retrofit 0179 anticipated and asked to be done deliberately per provider: pickers that were empty on machines with an API key will now list entries. Each provider clears both bars the spec sets — one public endpoint, and a translator that exists — and a test now asserts that for every row, so an entry cannot be added that is listed but unselectable. azure-openai stays declaration-only: its base URL is per-resource. The config test env guard now clears every built-in key env rather than just DeepSeek's. Otherwise a developer's exported ANTHROPIC_API_KEY adds a route target the fixture never asked for, and the count assertions pass or fail depending on whose machine ran them. --- crates/daemon/src/config.rs | 351 ++++++++++++++++++-- crates/daemon/src/router.rs | 57 +++- crates/protocol/src/slash.rs | 4 + docs/model-routing.md | 40 ++- docs/smith.md | 16 +- specs/0179-builtin-api-key-route-targets.md | 28 +- 6 files changed, 438 insertions(+), 58 deletions(-) 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