diff --git a/crates/adapter-smith/src/agent.rs b/crates/adapter-smith/src/agent.rs index 06394557..ae64f388 100644 --- a/crates/adapter-smith/src/agent.rs +++ b/crates/adapter-smith/src/agent.rs @@ -69,6 +69,9 @@ const APPROVAL_HISTORY_LIMIT: usize = 20; /// Purely a function of observed progress — model- and provider-agnostic. const NONPRODUCTIVE_STREAK_LIMIT: usize = 4; const GROK_BASE_URL: &str = "https://api.x.ai/v1"; +/// DeepSeek's OpenAI-compatible surface. Served by the same +/// `provider::openai` client as Grok — the wire format is chat completions. +const DEEPSEEK_BASE_URL: &str = "https://api.deepseek.com/v1"; fn record_approval_history( history: &mut VecDeque, @@ -1577,6 +1580,7 @@ impl ResolvedModel { provider::routing::Provider::Meta => "meta", provider::routing::Provider::Ollama => "ollama", provider::routing::Provider::Grok => "grok", + provider::routing::Provider::DeepSeek => "deepseek", provider::routing::Provider::GrokOauth => "grok-oauth", provider::routing::Provider::CodexOauth => "codex-oauth", provider::routing::Provider::ClaudeOauth => "claude-oauth", @@ -1611,7 +1615,8 @@ impl ResolvedModel { /// 4. OPENAI_API_KEY set → `gpt-5`. /// 5. GEMINI_API_KEY (or GOOGLE_API_KEY) set → `gemini-2.5-pro`. /// 6. META_API_KEY (or MODEL_API_KEY) set → `muse-spark-1.1`. -/// 7. none of the above → an error (spec 0069). Earlier versions fell +/// 7. DEEPSEEK_API_KEY set → `deepseek-v4-pro`. +/// 8. none of the above → an error (spec 0069). Earlier versions fell /// through to `ollama:llama3.1` here unconditionally, so a zero-config /// machine with no Ollama server running got a session that looked /// healthy and then died mid-turn with a raw transport error instead @@ -1637,7 +1642,7 @@ pub fn resolve_model(params: &SessionStartParams) -> Result { resolve_model_from_spec(&spec_str) } -/// The auto-detect ladder's direct-API-key rungs (steps 3-5 above), used +/// The auto-detect ladder's direct-API-key rungs (steps 3-7 above), used /// when neither `--model` nor `CONSTRUCT_SMITH_MODEL` is set. Returns an /// error rather than silently picking a provider that isn't configured. fn default_auto_detect_spec() -> Result { @@ -1653,10 +1658,16 @@ fn default_auto_detect_spec() -> Result { if std::env::var("META_API_KEY").is_ok() || std::env::var("MODEL_API_KEY").is_ok() { return Ok("meta:muse-spark-1.1".to_string()); } + // Last rung: a machine whose only credential is DeepSeek's still gets a + // working session instead of the curated error. Ordered after the others + // so no machine that already resolved changes provider (spec 0071). + if std::env::var("DEEPSEEK_API_KEY").is_ok() { + return Ok("deepseek:deepseek-v4-pro".to_string()); + } anyhow::bail!( "no auto-detected smith credential (ANTHROPIC_API_KEY, OPENAI_API_KEY, or \ - GEMINI_API_KEY/GOOGLE_API_KEY, or META_API_KEY/MODEL_API_KEY) and no \ - CONSTRUCT_SMITH_MODEL pin set" + GEMINI_API_KEY/GOOGLE_API_KEY, or META_API_KEY/MODEL_API_KEY, or \ + DEEPSEEK_API_KEY) and no CONSTRUCT_SMITH_MODEL pin set" ) } @@ -1687,6 +1698,10 @@ pub fn resolve_model_from_spec(spec_str: &str) -> Result { Some(GROK_BASE_URL.to_string()), grok_api_key()?, )?), + provider::routing::Provider::DeepSeek => Box::new(provider::openai::OpenAi::with_config( + Some(DEEPSEEK_BASE_URL.to_string()), + deepseek_api_key()?, + )?), provider::routing::Provider::GrokOauth => Box::new(provider::openai::OpenAi::with_config( Some(GROK_BASE_URL.to_string()), grok_oauth_token()?, @@ -1745,6 +1760,7 @@ fn build_profile_model( "meta" => provider::routing::Provider::Meta, "ollama" => provider::routing::Provider::Ollama, "grok" => provider::routing::Provider::Grok, + "deepseek" => provider::routing::Provider::DeepSeek, "codex-oauth" | "claude-oauth" | "claude-code-oauth" | "grok-oauth" | "kimi-oauth" => anyhow::bail!( "profile `{name}`: provider `{}` is OAuth-backed and has no \ configurable endpoint — use the `{}:` model prefix directly", @@ -1753,7 +1769,7 @@ fn build_profile_model( ), other => anyhow::bail!( "profile `{name}`: unknown provider `{other}` \ - (expected openai | anthropic | gemini | meta | ollama | grok)" + (expected openai | anthropic | gemini | meta | ollama | grok | deepseek)" ), }; @@ -1793,6 +1809,10 @@ fn build_profile_model( base_url.or_else(|| Some(GROK_BASE_URL.to_string())), profile_api_key(profile, name, &["GROK_API_KEY", "XAI_API_KEY"])?, )?), + provider::routing::Provider::DeepSeek => Box::new(provider::openai::OpenAi::with_config( + base_url.or_else(|| Some(DEEPSEEK_BASE_URL.to_string())), + profile_api_key(profile, name, &["DEEPSEEK_API_KEY"])?, + )?), // codex-oauth / claude-oauth / grok-oauth rejected above. _ => unreachable!("oauth providers rejected above"), }; @@ -1839,6 +1859,11 @@ fn grok_api_key() -> Result { .map_err(|_| anyhow::anyhow!("grok provider requires GROK_API_KEY or XAI_API_KEY")) } +fn deepseek_api_key() -> Result { + std::env::var("DEEPSEEK_API_KEY") + .map_err(|_| anyhow::anyhow!("deepseek provider requires DEEPSEEK_API_KEY")) +} + fn grok_auth_path() -> Result { if let Ok(home) = std::env::var("GROK_HOME") { if !home.trim().is_empty() { @@ -2298,6 +2323,7 @@ mod tests { "GOOGLE_API_KEY", "META_API_KEY", "MODEL_API_KEY", + "DEEPSEEK_API_KEY", ]; let saved: Vec> = vars.iter().map(|v| env::var(v).ok()).collect(); for v in vars { @@ -2317,7 +2343,7 @@ mod tests { } #[test] - fn default_auto_detect_spec_precedence_anthropic_openai_gemini_then_meta() { + fn default_auto_detect_spec_precedence_anthropic_openai_gemini_meta_then_deepseek() { let _lock = MODEL_ENV_LOCK.lock().unwrap(); let vars = [ "ANTHROPIC_API_KEY", @@ -2326,12 +2352,17 @@ mod tests { "GOOGLE_API_KEY", "META_API_KEY", "MODEL_API_KEY", + "DEEPSEEK_API_KEY", ]; let saved: Vec> = vars.iter().map(|v| env::var(v).ok()).collect(); for v in vars { env::remove_var(v); } + // DeepSeek is the last rung: it resolves when it is the only key, and + // yields to every other direct-API credential. + env::set_var("DEEPSEEK_API_KEY", "x"); + let deepseek_only = default_auto_detect_spec().expect("deepseek"); env::set_var("MODEL_API_KEY", "x"); let meta_only = default_auto_detect_spec().expect("meta"); env::set_var("GEMINI_API_KEY", "x"); @@ -2347,6 +2378,7 @@ mod tests { None => env::remove_var(v), } } + assert_eq!(deepseek_only, "deepseek:deepseek-v4-pro"); assert_eq!(meta_only, "meta:muse-spark-1.1"); assert_eq!(gemini_over_meta, "gemini:gemini-2.5-pro"); assert_eq!(openai_over_gemini, "openai:gpt-5"); diff --git a/crates/adapter-smith/src/context.rs b/crates/adapter-smith/src/context.rs index 26e3b8d7..386afb6c 100644 --- a/crates/adapter-smith/src/context.rs +++ b/crates/adapter-smith/src/context.rs @@ -39,6 +39,11 @@ pub fn context_window_tokens(provider: &str, model: &str) -> usize { // cloud endpoint (order-of-magnitude in the same class as recent // OpenAI models); this is a safe conservative starting value. ("grok", _) => 100_000, + // DeepSeek's V4 line (pro and flash) both advertise a 1M-token + // context window. Without an entry here the `_` arm would cap the + // session at 8K and compact almost immediately on a model that can + // hold the whole conversation. + ("deepseek", _) => 1_000_000, // ChatGPT-subscription Codex backend. Same gpt-5* family, // same advertised context window as the platform API — the // billing pipe is what differs, not the model. Starting @@ -381,6 +386,22 @@ mod tests { } } + /// A provider with no entry falls to the 8K default, which would compact + /// a 1M-context model almost immediately. Regression guard for the + /// DeepSeek arm specifically, since the fallthrough is silent. + #[test] + fn deepseek_gets_its_real_context_window_not_the_default() { + assert_eq!(context_window_tokens("deepseek", "deepseek-v4-pro"), 1_000_000); + assert_eq!( + context_window_tokens("deepseek", "deepseek-v4-flash"), + 1_000_000 + ); + assert!( + context_window_tokens("deepseek", "some-future-model") > 8_000, + "an unrecognized DeepSeek model must not fall to the generic default" + ); + } + #[test] fn no_prune_under_budget() { let mut ms = vec![user("hi"), asst("hello")]; diff --git a/crates/adapter-smith/src/lib.rs b/crates/adapter-smith/src/lib.rs index 1a85796c..9e353485 100644 --- a/crates/adapter-smith/src/lib.rs +++ b/crates/adapter-smith/src/lib.rs @@ -146,6 +146,8 @@ fn model_startup_error_message(params: &SessionStartParams, error: &str) -> Stri msg.push_str( "\n\nAction: set `GEMINI_API_KEY` or `GOOGLE_API_KEY`, or switch smith to another model.", ); + } else if lower.contains("deepseek_api_key") { + msg.push_str("\n\nAction: set `DEEPSEEK_API_KEY` or switch smith to another model."); } else if lower.contains("meta_api_key") || lower.contains("model_api_key") { msg.push_str( "\n\nAction: set `META_API_KEY` or `MODEL_API_KEY`, or switch smith to another model.", @@ -162,7 +164,7 @@ fn model_startup_error_message(params: &SessionStartParams, error: &str) -> Stri msg.push_str( "\n\nsmith needs one of: `CONSTRUCT_SMITH_MODEL`, `ANTHROPIC_API_KEY`, \ `OPENAI_API_KEY`, `GEMINI_API_KEY`, `META_API_KEY`/`MODEL_API_KEY`, `GROK_API_KEY`/`XAI_API_KEY`, \ - a valid Grok OAuth login, or a local Ollama. Run `/configure` in the construct TUI \ + `DEEPSEEK_API_KEY`, a valid Grok OAuth login, or a local Ollama. Run `/configure` in the construct TUI \ (or `M-x configure`) to check status and pick one.", ); msg diff --git a/crates/adapter-smith/src/provider/routing.rs b/crates/adapter-smith/src/provider/routing.rs index 7190f016..02d5a487 100644 --- a/crates/adapter-smith/src/provider/routing.rs +++ b/crates/adapter-smith/src/provider/routing.rs @@ -1,11 +1,13 @@ //! Translate a model spec string into a (provider, bare model name). //! //! Explicit prefixes (`openai:`, `anthropic:`, `gemini:`, `meta:`, `ollama:`, -//! `grok:`, `grok-oauth:`, `codex-oauth:`, `claude-oauth:`, `claude-code-oauth:`) always win. +//! `grok:`, `deepseek:`, `grok-oauth:`, `codex-oauth:`, `claude-oauth:`, +//! `claude-code-oauth:`) always win. //! Otherwise we sniff the bare name: //! - starts with `gpt-` or `o[1-5]` → OpenAI //! - starts with `claude-` → Anthropic //! - starts with `gemini-` → Gemini +//! - starts with `deepseek` → DeepSeek //! - anything else → Ollama (local fallback) //! //! Returning an enum keeps the dispatch table small and testable. @@ -31,6 +33,9 @@ pub enum Provider { Ollama, /// xAI Grok API surface. Grok, + /// DeepSeek platform API surface. OpenAI-compatible chat completions at + /// `api.deepseek.com`, billed against a DeepSeek API key. + DeepSeek, /// OAuth-backed Grok access path. GrokOauth, /// OAuth-backed Codex backend; reads `~/.codex/auth.json`, bills @@ -91,6 +96,12 @@ pub fn parse_model_spec(s: &str) -> Result { model: rest.to_string(), }); } + if let Some(rest) = s.strip_prefix("deepseek:") { + return Ok(ModelSpec { + provider: Provider::DeepSeek, + model: rest.to_string(), + }); + } if let Some(rest) = s.strip_prefix("grok-oauth:") { return Ok(ModelSpec { provider: Provider::GrokOauth, @@ -129,6 +140,7 @@ pub fn parse_model_spec(s: &str) -> Result { | "meta" | "ollama" | "grok" + | "deepseek" | "grok-oauth" | "codex-oauth" | "claude-oauth" @@ -138,7 +150,7 @@ pub fn parse_model_spec(s: &str) -> Result { { return Err(format!( "unknown provider prefix `{prefix}:` (expected one of \ - openai:, anthropic:, gemini:, meta:, ollama:, grok:, grok-oauth:, codex-oauth:, claude-oauth:, kimi-oauth:)" + openai:, anthropic:, gemini:, meta:, ollama:, grok:, deepseek:, grok-oauth:, codex-oauth:, claude-oauth:, kimi-oauth:)" )); } } @@ -150,6 +162,8 @@ pub fn parse_model_spec(s: &str) -> Result { Provider::Gemini } else if s.starts_with("grok") { Provider::Grok + } else if s.starts_with("deepseek") { + Provider::DeepSeek } else { Provider::Ollama }; @@ -310,6 +324,35 @@ mod tests { assert_eq!(s.model, "grok-2-1212"); } + #[test] + fn deepseek_prefix_is_recognized() { + let s = parse("deepseek:deepseek-v4-pro"); + assert_eq!(s.provider, Provider::DeepSeek); + assert_eq!(s.model, "deepseek-v4-pro"); + } + + /// Every model DeepSeek serves is named `deepseek-*`, so the bare name is + /// unambiguous — unlike `claude-*`/`gpt-*`, no other vendor claims it. + #[test] + fn bare_deepseek_model_routes_to_deepseek() { + assert_eq!(parse("deepseek-v4-pro").provider, Provider::DeepSeek); + assert_eq!(parse("deepseek-v4-flash").provider, Provider::DeepSeek); + // The retired `deepseek-chat` / `deepseek-reasoner` ids are still + // accepted by the API (it maps them onto a current model), so they + // must keep reaching DeepSeek rather than falling through to Ollama. + assert_eq!(parse("deepseek-chat").provider, Provider::DeepSeek); + assert_eq!(parse("deepseek-reasoner").provider, Provider::DeepSeek); + } + + /// A DeepSeek model served by some other endpoint (an OpenAI-compatible + /// reseller, a local copy) is still reachable — the bare-name sniff is a + /// default, and an explicit prefix or `@profile` overrides it. + #[test] + fn explicit_prefix_beats_deepseek_sniff() { + assert_eq!(parse("ollama:deepseek-v4-flash").provider, Provider::Ollama); + assert_eq!(parse("openai:deepseek-v4-pro").provider, Provider::OpenAI); + } + #[test] fn claude_oauth_prefixes_are_recognized() { let s = parse("claude-oauth:sonnet"); diff --git a/crates/adapter-smith/src/title_mode.rs b/crates/adapter-smith/src/title_mode.rs index ac42307a..55f723a2 100644 --- a/crates/adapter-smith/src/title_mode.rs +++ b/crates/adapter-smith/src/title_mode.rs @@ -55,6 +55,10 @@ pub(crate) fn pick_default_spec_str() -> Result { if std::env::var("META_API_KEY").is_ok() || std::env::var("MODEL_API_KEY").is_ok() { return Ok("meta:muse-spark-1.1".to_string()); } + if std::env::var("DEEPSEEK_API_KEY").is_ok() { + // Flash is the cheap tier — titles never need the pro model. + return Ok("deepseek:deepseek-v4-flash".to_string()); + } Err(anyhow!( "no auto-detected smith credential and no CONSTRUCT_SMITH_MODEL pin set; skipping auto-title" )) @@ -73,6 +77,11 @@ pub(crate) fn provider_for(p: Provider) -> Result> { .or_else(|_| std::env::var("XAI_API_KEY")) .map_err(|_| anyhow!("grok requires GROK_API_KEY or XAI_API_KEY"))?, )?), + Provider::DeepSeek => Box::new(provider::openai::OpenAi::with_config( + Some("https://api.deepseek.com/v1".to_string()), + std::env::var("DEEPSEEK_API_KEY") + .map_err(|_| anyhow!("deepseek requires DEEPSEEK_API_KEY"))?, + )?), // Title generation always uses one of the key providers above; the // user never picks OAuth providers for title-gen since the // selection comes from `pick_default_spec_str` which only diff --git a/crates/cli/src/app/configure.rs b/crates/cli/src/app/configure.rs index 677a1f92..591ecc8c 100644 --- a/crates/cli/src/app/configure.rs +++ b/crates/cli/src/app/configure.rs @@ -111,6 +111,9 @@ pub fn smith_method_guidance(id: &str) -> &'static str { "export GROK_API_KEY (or XAI_API_KEY) in the shell that starts the daemon, then \ restart the daemon" } + "deepseek_api_key" => { + "export DEEPSEEK_API_KEY in the shell that starts the daemon, then restart the daemon" + } "claude_subscription" => { "run `claude` and log in with your Claude subscription first (creates \ ~/.claude/.credentials.json), as the user the daemon runs as, then restart the daemon" diff --git a/crates/daemon/src/availability.rs b/crates/daemon/src/availability.rs index 0b7143c2..feb367d6 100644 --- a/crates/daemon/src/availability.rs +++ b/crates/daemon/src/availability.rs @@ -182,6 +182,9 @@ pub async fn probe_smith(cache: &std::sync::Mutex) -> Availab if env_present("GROK_API_KEY") || env_present("XAI_API_KEY") { return Availability::ready("ready (Grok API key)"); } + if env_present("DEEPSEEK_API_KEY") { + return Availability::ready("ready (DeepSeek API key)"); + } if claude_oauth_credentials_present(cache).await { return Availability::ready("ready (Claude subscription)"); } @@ -386,6 +389,13 @@ pub async fn smith_auth_methods( "grok-2-latest", &["GROK_API_KEY", "XAI_API_KEY"], ); + let deepseek_key = env_key_method( + "deepseek_api_key", + "DeepSeek API key", + "deepseek", + "deepseek-v4-pro", + &["DEEPSEEK_API_KEY"], + ); let claude_sub_present = claude_oauth_credentials_present(cache).await; let claude_sub = SmithAuthMethod { id: "claude_subscription", @@ -460,8 +470,11 @@ pub async fn smith_auth_methods( // is ready" while a session started without a pin still errors with // "no auto-detected smith credential" — the exact promise/behavior // mismatch this dialog exists to prevent. - let auto_available = - anthropic.available || openai.available || gemini.available || meta.available; + let auto_available = anthropic.available + || openai.available + || gemini.available + || meta.available + || deepseek_key.available; let auto = SmithAuthMethod { id: "auto", label: "Auto-detect", @@ -469,14 +482,16 @@ pub async fn smith_auth_methods( default_model: "", available: auto_available, detail: if auto_available { - "auto-detects the first set API key: Anthropic → OpenAI → Gemini → Meta".to_string() + "auto-detects the first set API key: Anthropic → OpenAI → Gemini → Meta → DeepSeek" + .to_string() } else { "no auto-detected API key set (subscriptions and Ollama must be picked explicitly)" .to_string() }, }; vec![ - anthropic, openai, gemini, meta, grok_key, claude_sub, codex_sub, grok_sub, kimi_sub, + anthropic, openai, gemini, meta, grok_key, deepseek_key, claude_sub, codex_sub, grok_sub, + kimi_sub, ollama, auto, ] } @@ -726,11 +741,17 @@ mod tests { // `CODEX_HOME` could still interleave with this one's read. Take the // crate-wide guard every env-mutating test uses. let _lock = crate::router::oauth::test_env_guard(); + // Every direct-API-key var the auto rung counts. A var missing from + // this list makes the test pass or fail on the developer's own + // exported keys rather than on the fixture. let key_vars = [ "ANTHROPIC_API_KEY", "OPENAI_API_KEY", "GEMINI_API_KEY", "GOOGLE_API_KEY", + "META_API_KEY", + "MODEL_API_KEY", + "DEEPSEEK_API_KEY", ]; let saved_keys: Vec> = key_vars.iter().map(|v| std::env::var(v).ok()).collect(); diff --git a/crates/daemon/src/config.rs b/crates/daemon/src/config.rs index 3989e0d8..948daa6b 100644 --- a/crates/daemon/src/config.rs +++ b/crates/daemon/src/config.rs @@ -220,13 +220,24 @@ enabled = true # selectable from a `claude` session (the first two via translation); # `meta` / `ollama` are shown with the reason they are not. -# OpenAI-compatible example (DeepSeek): +# 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. # [smith.models.deepseek] -# provider = "openai" +# provider = "deepseek" # base_url = "https://api.deepseek.com/v1" # api_key_env = "DEEPSEEK_API_KEY" # name of the env var holding the key (preferred) # # api_key = "sk-..." # inline key (discouraged; use api_key_env) -# model = "deepseek-chat" # default model; overridable with @deepseek: +# model = "deepseek-v4-pro" # default model; overridable with @deepseek: + +# DeepSeek over its Anthropic-compatible surface instead of the OpenAI one. +# Useful for a `claude` session, which then needs no dialect translation: +# [smith.models.deepseek-anthropic] +# provider = "anthropic" +# base_url = "https://api.deepseek.com/anthropic" +# api_key_env = "DEEPSEEK_API_KEY" +# model = "deepseek-v4-pro" # OpenAI-compatible example (Groq): # [smith.models.groq] @@ -593,6 +604,16 @@ impl Default for RouterConfig { } } +/// DeepSeek's OpenAI-compatible endpoint, and the env var holding its key. +/// Named constants because the built-in route target below and the profile +/// defaults above must agree on both. +pub const DEEPSEEK_BASE_URL: &str = "https://api.deepseek.com/v1"; +pub const DEEPSEEK_API_KEY_ENV: &str = "DEEPSEEK_API_KEY"; +/// Route name the built-in DeepSeek target claims. A user-declared +/// `[smith.models.deepseek]` profile takes this name back (see +/// [`SmithConfig::route_profiles`]). +pub const DEEPSEEK_ROUTE_NAME: &str = "deepseek"; + /// `[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 @@ -603,6 +624,41 @@ pub struct SmithConfig { pub models: BTreeMap, } +impl SmithConfig { + /// The profiles the router offers as route targets: everything the user + /// declared, plus any built-in endpoint whose credential is present in + /// the daemon's environment (spec 0179). + /// + /// 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. + 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) { + profiles.insert( + DEEPSEEK_ROUTE_NAME.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()), + 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()), + }, + ); + } + profiles + } +} + +fn env_var_present(name: &str) -> bool { + std::env::var(name) + .map(|v| !v.trim().is_empty()) + .unwrap_or(false) +} + /// One `[smith.models.]` entry (spec 0030). #[derive(Debug, Clone, Deserialize)] pub struct ModelProfile { @@ -631,6 +687,7 @@ impl ModelProfile { "openai" | "openai-responses" => "https://api.openai.com/v1", "anthropic" => "https://api.anthropic.com/v1", "grok" => "https://api.x.ai/v1", + "deepseek" => DEEPSEEK_BASE_URL, "gemini" | "google" => "https://generativelanguage.googleapis.com/v1beta", "meta" => "https://api.meta.ai/v1", "ollama" => "http://localhost:11434", @@ -649,6 +706,7 @@ impl ModelProfile { "gemini" | "google" => &["GEMINI_API_KEY", "GOOGLE_API_KEY"], "meta" => &["META_API_KEY", "MODEL_API_KEY"], "grok" => &["GROK_API_KEY", "XAI_API_KEY"], + "deepseek" => &[DEEPSEEK_API_KEY_ENV], _ => &[], } } @@ -1279,4 +1337,105 @@ mod tests { Some("/status --verbose") ); } + + /// Run `f` with `DEEPSEEK_API_KEY` set to `value` (or unset for `None`), + /// 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 { + 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 out = f(); + match saved { + Some(v) => std::env::set_var(DEEPSEEK_API_KEY_ENV, v), + None => std::env::remove_var(DEEPSEEK_API_KEY_ENV), + } + out + } + + /// 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] + fn deepseek_profile_defaults_to_its_public_endpoint_and_key() { + let profile: ModelProfile = toml::from_str(r#"provider = "deepseek""#).expect("parse"); + assert_eq!( + profile.resolved_base_url().as_deref(), + Some(DEEPSEEK_BASE_URL) + ); + assert_eq!(profile.default_key_envs(), &[DEEPSEEK_API_KEY_ENV]); + } + + /// The key alone makes DeepSeek a route target — no config block (spec 0179). + #[test] + fn deepseek_is_a_builtin_route_target_when_its_key_is_set() { + let cfg: Config = toml::from_str("").expect("parse"); + let profiles = with_deepseek_key(Some("sk-test"), || cfg.smith.route_profiles()); + let deepseek = profiles.get(DEEPSEEK_ROUTE_NAME).expect("built-in target"); + assert_eq!(deepseek.provider, "deepseek"); + assert_eq!(deepseek.resolved_base_url().as_deref(), Some(DEEPSEEK_BASE_URL)); + assert_eq!(deepseek.model.as_deref(), Some("deepseek-v4-pro")); + assert_eq!( + crate::router::provider_dialect(&deepseek.provider), + Some(crate::router::Dialect::OpenAiChat), + "a built-in target that no dialect can serve would be listed but unusable" + ); + } + + /// No key, no target: the picker must not advertise an endpoint that + /// would fail on first use. + #[test] + fn deepseek_is_absent_without_its_key() { + let cfg: Config = toml::from_str("").expect("parse"); + let profiles = with_deepseek_key(None, || cfg.smith.route_profiles()); + assert!(!profiles.contains_key(DEEPSEEK_ROUTE_NAME)); + // An empty/whitespace value is not a credential either. + let profiles = with_deepseek_key(Some(" "), || cfg.smith.route_profiles()); + assert!(!profiles.contains_key(DEEPSEEK_ROUTE_NAME)); + } + + /// User config always wins: a declared `deepseek` profile is never + /// overwritten by the built-in, even with the key set. + #[test] + fn declared_deepseek_profile_beats_the_builtin() { + let toml = r#" + [smith.models.deepseek] + provider = "openai" + base_url = "https://deepseek.internal/v1" + api_key_env = "WORK_DEEPSEEK_KEY" + model = "deepseek-v4-flash" + "#; + let cfg: Config = toml::from_str(toml).expect("parse"); + let profiles = with_deepseek_key(Some("sk-test"), || cfg.smith.route_profiles()); + let deepseek = profiles.get(DEEPSEEK_ROUTE_NAME).expect("declared profile"); + assert_eq!( + deepseek.resolved_base_url().as_deref(), + Some("https://deepseek.internal/v1") + ); + assert_eq!(deepseek.api_key_env.as_deref(), Some("WORK_DEEPSEEK_KEY")); + assert_eq!(deepseek.model.as_deref(), Some("deepseek-v4-flash")); + } + + /// Declared profiles survive the built-in merge untouched. + #[test] + fn route_profiles_keeps_unrelated_declared_profiles() { + let toml = r#" + [smith.models.groq] + provider = "openai" + base_url = "https://api.groq.com/openai/v1" + model = "llama-3.3-70b-versatile" + "#; + let cfg: Config = toml::from_str(toml).expect("parse"); + let profiles = with_deepseek_key(Some("sk-test"), || cfg.smith.route_profiles()); + assert!(profiles.contains_key("groq")); + assert!(profiles.contains_key(DEEPSEEK_ROUTE_NAME)); + let profiles = with_deepseek_key(None, || cfg.smith.route_profiles()); + assert_eq!(profiles.len(), 1, "only the declared profile remains"); + } } diff --git a/crates/daemon/src/router.rs b/crates/daemon/src/router.rs index 12de0f51..628bc92e 100644 --- a/crates/daemon/src/router.rs +++ b/crates/daemon/src/router.rs @@ -90,9 +90,9 @@ pub fn provider_dialect(provider: &str) -> Option { match provider.to_ascii_lowercase().as_str() { "anthropic" => Some(Dialect::AnthropicMessages), "gemini" | "google" => Some(Dialect::GoogleGemini), - // Grok is served by smith's OpenAI client and speaks the same wire - // format. - "openai" | "grok" => Some(Dialect::OpenAiChat), + // Grok and DeepSeek are served by smith's OpenAI client and speak the + // same wire format. + "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" => { @@ -112,16 +112,39 @@ pub enum EffortSupport { Grok, /// Kimi K3's always-on thinking plus `output_config.effort`. Kimi, + /// DeepSeek's `reasoning_effort` enum. Its accepted values include + /// `medium` and `xhigh`, but only `low` / `high` / `max` were observed to + /// grade the work monotonically, so those are the levels offered. + DeepSeek, } -pub fn profile_effort_support(provider: &str) -> EffortSupport { +/// How a declared or built-in profile consumes a requested effort. +/// +/// Model-aware, not provider-aware: a vendor may grade effort on one model +/// and floor every level to the same value on another, and advertising a +/// scale the model does not honor is worse than advertising none. +pub fn profile_effort_support(provider: &str, model: &str) -> EffortSupport { match provider.to_ascii_lowercase().as_str() { "anthropic" => EffortSupport::Thinking, "openai" | "openai-responses" | "azure" | "azure-openai" => EffortSupport::Verbatim, + "deepseek" => deepseek_effort_support(model), _ => EffortSupport::Unsupported, } } +/// DeepSeek grades effort on the flash tier only. The pro tier accepts every +/// level and floors them all to its own default, so measured reasoning length +/// is flat and non-monotonic across `low` / `high` / `max` — it gets no scale +/// rather than a picker column that changes nothing. +fn deepseek_effort_support(model: &str) -> EffortSupport { + let m = model.to_ascii_lowercase(); + if m.contains("flash") { + EffortSupport::DeepSeek + } else { + EffortSupport::Unsupported + } +} + /// Default and selectable levels for a target's effort support (spec 0160). /// /// A single-element list is a provider-default stub — the native catalog @@ -133,6 +156,8 @@ pub fn effort_level_set(support: EffortSupport) -> (&'static str, &'static [&'st EffortSupport::Thinking => ("minimal", &["minimal", "low", "medium", "high"]), EffortSupport::Grok => ("high", &["low", "medium", "high"]), EffortSupport::Kimi => ("high", &["low", "high", "xhigh"]), + // DeepSeek's own default effort is `high`. + EffortSupport::DeepSeek => ("high", &["low", "high", "max"]), EffortSupport::Unsupported => ("medium", &["medium"]), } } @@ -1099,19 +1124,17 @@ impl Router { "route \"{name}\": azure-openai base_url contains an unresolved placeholder" )); } + // Resolve the model once: the endpoint, the armed model, and the + // effort scale must all describe the same model, since effort support + // varies per model within a provider. + let resolved_model = model + .map(str::to_string) + .or_else(|| profile.model.clone()) + .unwrap_or_default(); Ok(ArmedRoute { name: name.to_string(), - endpoint: translate::target_url( - &base_url, - target_dialect, - model.or(profile.model.as_deref()).unwrap_or_default(), - true, - ), + endpoint: translate::target_url(&base_url, target_dialect, &resolved_model, true), base_url, - model: model - .map(str::to_string) - .or_else(|| profile.model.clone()) - .unwrap_or_default(), api_key: profile.resolve_api_key().map_err(|e| anyhow!(e))?, auth: match profile.provider.to_ascii_lowercase().as_str() { "anthropic" => TargetAuth::ApiKeyHeader, @@ -1124,7 +1147,8 @@ impl Router { drop_params: &[], target_dialect, client_dialect: routing.dialect, - effort: profile_effort_support(&profile.provider), + effort: profile_effort_support(&profile.provider, &resolved_model), + model: resolved_model, pin_effort: None, client: reqwest::Client::new(), }) @@ -1246,8 +1270,9 @@ impl Router { .iter() .map(|(name, profile)| { let models = self.profile_model_list(profile); - let support = profile_effort_support(&profile.provider); - let efforts = efforts_for_models(models.iter().cloned(), |_| support); + let efforts = efforts_for_models(models.iter().cloned(), |m| { + profile_effort_support(&profile.provider, m) + }); RouteOption { name: name.clone(), dialect: provider_dialect(&profile.provider) @@ -1994,6 +2019,150 @@ mod tests { ); } + /// The built-in DeepSeek target (spec 0179) reaches the router as an + /// ordinary profile, so it must be selectable from an Anthropic harness + /// and carry every model the shared catalog lists for the provider — + /// not just the one the built-in names as its default. + #[tokio::test] + async fn deepseek_target_is_selectable_and_offers_the_catalog_models() { + let dir = tempfile::tempdir().unwrap(); + std::env::set_var("CONSTRUCT_TEST_DEEPSEEK_KEY", "sk-deepseek"); + let r = started_with( + &dir, + cfg_with(true), + profiles(vec![( + "deepseek", + ModelProfile { + provider: "deepseek".to_string(), + base_url: None, + api_key_env: Some("CONSTRUCT_TEST_DEEPSEEK_KEY".to_string()), + api_key: None, + model: Some("deepseek-v4-pro".to_string()), + }, + )]), + ) + .await; + r.attach_session("s1", "claude", None).unwrap(); + + let listed = r.list_routes("claude", true, None, false); + let deepseek = route_named(&listed, "deepseek"); + assert_eq!(deepseek.unavailable_reason, None); + assert_eq!(deepseek.dialect, "openai-chat"); + assert_eq!(deepseek.base_url, "https://api.deepseek.com/v1"); + assert!( + deepseek.models.iter().any(|m| m == "deepseek-v4-flash"), + "the shared catalog's models must reach the picker: {:?}", + deepseek.models + ); + + let armed = r + .set_route("s1", "claude", Some("deepseek"), None, None, None) + .unwrap() + .unwrap(); + assert_eq!(armed.model, "deepseek-v4-pro"); + let ctx = r.sessions.read().unwrap()["s1"].clone(); + assert!( + ctx.armed_route().unwrap().translates(), + "a chat-completions target from an anthropic harness must translate" + ); + } + + /// Effort support is per model, not per provider. Measured against the + /// live API: on flash, `low` / `high` / `max` produce cleanly separated + /// reasoning lengths; on pro every level floors to the same default, so + /// pro advertises no scale rather than a control that does nothing. + #[test] + fn deepseek_effort_scale_is_flash_only() { + assert_eq!( + profile_effort_support("deepseek", "deepseek-v4-flash"), + EffortSupport::DeepSeek + ); + assert_eq!( + effort_level_set(EffortSupport::DeepSeek), + ("high", &["low", "high", "max"][..]), + "DeepSeek's own default effort is high" + ); + assert_eq!( + profile_effort_support("deepseek", "deepseek-v4-pro"), + EffortSupport::Unsupported + ); + assert!( + effort_levels_for_picker(profile_effort_support("deepseek", "deepseek-v4-pro")) + .is_empty(), + "pro must not render a picker column it cannot honor" + ); + // Other providers keep provider-wide behavior regardless of model. + assert_eq!( + profile_effort_support("openai", "gpt-5"), + EffortSupport::Verbatim + ); + } + + /// `Unsupported` strips the effort before it reaches the wire, so pro + /// never receives a level the picker did not offer. `DeepSeek` must not + /// be caught by that arm — flash's levels have to survive to the emitter. + #[test] + fn deepseek_flash_effort_is_not_stripped_as_unsupported() { + assert_ne!(EffortSupport::DeepSeek, EffortSupport::Unsupported); + } + + /// The per-model split has to reach the picker, not just the helper. + #[tokio::test] + async fn deepseek_route_offers_effort_on_flash_but_not_pro() { + let dir = tempfile::tempdir().unwrap(); + std::env::set_var("CONSTRUCT_TEST_DEEPSEEK_EFFORT_KEY", "sk-deepseek"); + let r = started_with( + &dir, + cfg_with(true), + profiles(vec![( + "deepseek", + ModelProfile { + provider: "deepseek".to_string(), + base_url: None, + api_key_env: Some("CONSTRUCT_TEST_DEEPSEEK_EFFORT_KEY".to_string()), + api_key: None, + model: Some("deepseek-v4-pro".to_string()), + }, + )]), + ) + .await; + r.attach_session("s1", "claude", None).unwrap(); + + let listed = r.list_routes("claude", true, None, false); + let deepseek = route_named(&listed, "deepseek"); + assert_eq!( + deepseek.efforts.get("deepseek-v4-flash").map(Vec::as_slice), + Some(&["low".to_string(), "high".to_string(), "max".to_string()][..]), + "flash grades effort: {:?}", + deepseek.efforts + ); + assert!( + !deepseek.efforts.contains_key("deepseek-v4-pro"), + "pro floors every level, so it offers none: {:?}", + deepseek.efforts + ); + + // An armed route carries the effort scale of the model it resolved. + let armed = r + .set_route( + "s1", + "claude", + Some("deepseek"), + Some("deepseek-v4-flash"), + None, + None, + ) + .unwrap() + .unwrap(); + assert_eq!(armed.model, "deepseek-v4-flash"); + let ctx = r.sessions.read().unwrap()["s1"].clone(); + assert_eq!( + ctx.armed_route().unwrap().effort, + EffortSupport::DeepSeek, + "arming flash must carry flash's scale, not the provider default" + ); + } + /// A profile that sets no model cannot be a route: there would be /// nothing to substitute. #[tokio::test] diff --git a/crates/daemon/src/router/catalog.rs b/crates/daemon/src/router/catalog.rs index 0543be48..5b7cee85 100644 --- a/crates/daemon/src/router/catalog.rs +++ b/crates/daemon/src/router/catalog.rs @@ -69,11 +69,12 @@ impl Router { continue; } for model in self.profile_model_list(profile) { + let effort = profile_effort_support(&profile.provider, &model); out.push(PublishedModel { id: published_model_id_for_harness(harness, route, &model), route: route.clone(), model, - effort: profile_effort_support(&profile.provider), + effort, }); } } @@ -374,6 +375,11 @@ pub fn build_codex_catalog( {"effort": "high", "description": "High Kimi thinking effort"}, {"effort": "xhigh", "description": "Maximum Kimi thinking effort"} ]), + EffortSupport::DeepSeek => json!([ + {"effort": "low", "description": "Brief reasoning, fastest"}, + {"effort": "high", "description": "DeepSeek default reasoning depth"}, + {"effort": "max", "description": "Longest reasoning, slowest"} + ]), EffortSupport::Unsupported => json!([{ "effort": "medium", "description": "Provider-default reasoning through Construct" diff --git a/crates/daemon/src/session.rs b/crates/daemon/src/session.rs index 91051083..74dd3910 100644 --- a/crates/daemon/src/session.rs +++ b/crates/daemon/src/session.rs @@ -1693,7 +1693,7 @@ impl SessionManager { storage.data_dir().to_path_buf(), runtime_dir.clone(), &config.router, - config.smith.models.clone(), + config.smith.route_profiles(), ); let summaries = storage.list_summaries()?; let mut sessions = HashMap::new(); diff --git a/crates/protocol/src/slash.rs b/crates/protocol/src/slash.rs index a7703ddb..11489f72 100644 --- a/crates/protocol/src/slash.rs +++ b/crates/protocol/src/slash.rs @@ -414,6 +414,9 @@ pub const MODEL_COMPLETIONS: &[&str] = &[ "gemini:gemini-2.5-flash", // Meta Model API path. "meta:muse-spark-1.1", + // DeepSeek platform API path. + "deepseek:deepseek-v4-pro", + "deepseek:deepseek-v4-flash", // Local Ollama examples. "ollama:llama3.1", "ollama:qwen3-coder", diff --git a/docs/model-routing.md b/docs/model-routing.md index 05e9f999..6f7f4edf 100644 --- a/docs/model-routing.md +++ b/docs/model-routing.md @@ -27,6 +27,17 @@ A target is somewhere the router can send a model request: Grok, Kimi) are offered automatically — nothing to declare. The router 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. + + DeepSeek's reasoning effort is offered per model: `deepseek-v4-flash` + exposes `low` / `high` / `max` (default `high`), and `deepseek-v4-pro` + exposes none, because it floors every level to the same default. Effort + levels are advertised only where they were measured to change the work + (spec 0160), so the picker's third column is absent rather than inert. - **Declared endpoints** are the `[smith.models.*]` profiles in `config.toml` — declare an endpoint once and it is reachable from both smith and a routed session: @@ -47,8 +58,8 @@ with the reason they can't be selected. 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 -with no logins and no profiles has nothing to offer, so pickers stay -native-only until a login or profile exists. +with no logins, no built-in provider keys, and no profiles has nothing to +offer, so pickers stay native-only until one of those exists. ### Summary of route targets @@ -58,7 +69,8 @@ native-only until a login or profile 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) | -| `[smith.models.]` | Declared Endpoint | Configured (`openai`, `anthropic`, `responses`, `gemini`, `azure`) | Configured `base_url` | Declared in `config.toml` (`api_key_env` / `api_key`) | +| `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`) | *Note: Antigravity OAuth logins are not offered as route targets because their backend uses a Gemini-shaped protocol with no proxy translator.* @@ -221,7 +233,8 @@ grok-oauth = "grok-4.5" ## Design records -Specs [0113](../specs/0113-model-routing-is-proxy-transported.md), +Specs [0179](../specs/0179-builtin-api-key-route-targets.md), +[0113](../specs/0113-model-routing-is-proxy-transported.md), [0114](../specs/0114-session-route-is-durable-session-state.md), [0115](../specs/0115-routing-injection-is-probe-verified.md), [0157](../specs/0157-native-model-catalog-routing.md), and diff --git a/docs/smith.md b/docs/smith.md index 21b641f9..090ff7f7 100644 --- a/docs/smith.md +++ b/docs/smith.md @@ -16,6 +16,7 @@ export ANTHROPIC_API_KEY=sk-ant-... # or export GEMINI_API_KEY=... # (or GOOGLE_API_KEY) # or export META_API_KEY=... # (or MODEL_API_KEY) # or export GROK_API_KEY=... # (or XAI_API_KEY) +# or export DEEPSEEK_API_KEY=... # or codex login, then use --model codex-oauth:gpt-5.4-mini # or claude login, then use --model claude-oauth:sonnet # or grok login, then use --model grok-oauth:grok-4.3 @@ -37,15 +38,17 @@ The spec is one of: - `meta:` — e.g. `meta:muse-spark-1.1` using `META_API_KEY` or `MODEL_API_KEY` - `grok:` — e.g. `grok:grok-4.3` using `GROK_API_KEY` or `XAI_API_KEY` +- `deepseek:` — e.g. `deepseek:deepseek-v4-pro` using `DEEPSEEK_API_KEY` - `grok-oauth:` — e.g. `grok-oauth:grok-4.3` using the Grok CLI auth file - `kimi-oauth:` — e.g. `kimi-oauth:k3` using the Kimi Code CLI login - `ollama:` — e.g. `ollama:llama3.1` - `codex-oauth:` — e.g. `codex-oauth:gpt-5.4-mini` - `@` — a named endpoint profile (see [Model profiles](#model-profiles)), - e.g. `@deepseek` or `@deepseek:deepseek-reasoner` to override its model + e.g. `@work-gateway` or `@work-gateway:deepseek-v4-flash` to override its model Bare names auto-detect: `gpt-*` / `o[1-5]*` → OpenAI, `claude-*` → -Anthropic, `gemini-*` → Gemini, `grok*` → Grok, anything else → Ollama. +Anthropic, `gemini-*` → Gemini, `grok*` → Grok, `deepseek*` → DeepSeek, +anything else → Ollama. Use the explicit `meta:` prefix for Muse Spark; a bare `muse-spark-1.1` continues to mean an Ollama model. When in doubt, use the explicit prefix. @@ -80,7 +83,8 @@ If you don't pass a model and `CONSTRUCT_SMITH_MODEL` isn't set, smith picks: `ANTHROPIC_API_KEY` → `claude-opus-4-8`, else `OPENAI_API_KEY` → `gpt-5`, else `GEMINI_API_KEY` (or `GOOGLE_API_KEY`) → `gemini-2.5-pro`, else `META_API_KEY` (or `MODEL_API_KEY`) → -`muse-spark-1.1`, else **smith fails to start** with an error explaining +`muse-spark-1.1`, else `DEEPSEEK_API_KEY` → `deepseek-v4-pro`, +else **smith fails to start** with an error explaining what's missing. The initial Status event records the chosen `provider:model` so you can verify. @@ -104,20 +108,24 @@ OpenAI plus two OpenAI-compatible vendors — declare named profiles in Each `[smith.models.]` entry sets: - `provider` — wire protocol to speak: `openai`, `anthropic`, `gemini`, - `meta`, `grok`, or `ollama`. (OAuth providers can't be profiled — use their - prefixes directly.) + `meta`, `grok`, `deepseek`, or `ollama`. (OAuth providers can't be profiled + — use their prefixes directly.) - `base_url` — endpoint URL (defaults to the protocol's public endpoint). - `api_key_env` — name of the env var holding the key (preferred). Or `api_key = "..."` inline (discouraged). If neither is set, the protocol's 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. + ```toml -[smith.models.deepseek] -provider = "openai" -base_url = "https://api.deepseek.com/v1" -api_key_env = "DEEPSEEK_API_KEY" -model = "deepseek-chat" +[smith.models.work-gateway] +provider = "deepseek" +base_url = "https://deepseek.internal/v1" +api_key_env = "WORK_DEEPSEEK_KEY" +model = "deepseek-v4-pro" [smith.models.groq-llama] provider = "openai" @@ -137,9 +145,10 @@ model = "muse-spark-1.1" ``` ```text -construct new --model @deepseek --prompt "..." smith # start on a profile +construct new --model @work-gateway --prompt "..." smith # start on a profile /model openai:gpt-5 # first-party OpenAI -/model @deepseek # DeepSeek +/model deepseek:deepseek-v4-pro # DeepSeek's public endpoint +/model @work-gateway # DeepSeek via a private gateway /model @groq-llama:llama-3.1-8b-instant # Groq, one-off model override /model # shows current + lists @profiles ``` @@ -234,6 +243,8 @@ 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). - `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/0030-smith-model-profiles-are-named-endpoints.md b/specs/0030-smith-model-profiles-are-named-endpoints.md index 5d85731d..80b304c2 100644 --- a/specs/0030-smith-model-profiles-are-named-endpoints.md +++ b/specs/0030-smith-model-profiles-are-named-endpoints.md @@ -58,11 +58,11 @@ a profile is used only when explicitly named. `config.toml`: ```toml -[smith.models.deepseek] -provider = "openai" -base_url = "https://api.deepseek.com/v1" -api_key_env = "DEEPSEEK_API_KEY" -model = "deepseek-chat" +[smith.models.deepseek-internal] +provider = "deepseek" +base_url = "https://deepseek.internal/v1" +api_key_env = "WORK_DEEPSEEK_KEY" +model = "deepseek-v4-pro" [smith.models.groq-llama] provider = "openai" @@ -72,5 +72,12 @@ model = "llama-3.3-70b-versatile" ``` In one session: `/model openai:gpt-5` reaches first-party OpenAI, then -`/model @deepseek` reaches DeepSeek, then `/model @groq-llama:llama-3.1-8b-instant` -reaches Groq with a one-off model override — no restart, no env changes. +`/model @deepseek-internal` reaches the private gateway, then +`/model @groq-llama:llama-3.1-8b-instant` reaches Groq with a one-off model +override — no restart, no env changes. + +A profile is not the only way to reach a vendor. A provider with its own +prefix (`deepseek:`, `grok:`, …) is reachable directly at its public endpoint; +profiles exist for the endpoints Construct cannot know — private gateways, +resellers, a second account. See +[[0179-builtin-api-key-route-targets]] for the routing-side equivalent. diff --git a/specs/0071-smith-no-implicit-fallback.md b/specs/0071-smith-no-implicit-fallback.md index 97729f00..4cd25c22 100644 --- a/specs/0071-smith-no-implicit-fallback.md +++ b/specs/0071-smith-no-implicit-fallback.md @@ -7,7 +7,7 @@ Scope: What smith's model auto-detect ladder does when it finds no usable direct ## Decision -When smith is started with no explicit model (no `--model`, no `CONSTRUCT_SMITH_MODEL`) and none of `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, or `GEMINI_API_KEY`/`GOOGLE_API_KEY` is set, smith fails to start with a curated error rather than silently defaulting to a local Ollama server. The same applies to the separate, lighter auto-title ladder (`construct-adapter-smith --title-mode`): it now returns an error instead of guessing Ollama, and the daemon's existing best-effort caller already treats that as "leave the title unset" — no behavior change needed on the caller side. +When smith is started with no explicit model (no `--model`, no `CONSTRUCT_SMITH_MODEL`) and none of the direct-API-key variables the ladder checks is set, smith fails to start with a curated error rather than silently defaulting to a local Ollama server. The ladder's rungs are direct API keys only, tried in a fixed order; a new rung is appended (never inserted) so adding one cannot change which provider an already-working machine resolves to. The same applies to the separate, lighter auto-title ladder (`construct-adapter-smith --title-mode`): it now returns an error instead of guessing Ollama, and the daemon's existing best-effort caller already treats that as "leave the title unset" — no behavior change needed on the caller side. OAuth-subscription providers (`claude-oauth:`, `codex-oauth:`, `grok-oauth:`, `kimi-oauth:`) and Ollama remain fully supported — they just require an explicit `:` spec, via `--model`, `CONSTRUCT_SMITH_MODEL`, an `@profile`, or picking that method in the `/configure` dialog's smith-auth tab (which pins `CONSTRUCT_SMITH_MODEL` for you — see [[0070-smith-model-pin-persistence]]). None of those paths are guessed automatically when no model is specified at all. @@ -22,7 +22,7 @@ The previous ladder's last rung silently built an `ollama:llama3.1` spec wheneve ## Consequences - A session-start failure from this path is not itself a Playbook/session-list-degrading event beyond the ordinary `Errored` state a startup error already produces — no new failure-handling machinery was needed, only a different failure trigger and a better message. -- Any future rung added to the ladder must be an explicit, deliberate choice about what "auto-detect" should guess — silently falling through to a network-dependent default is exactly the failure mode this decision closes off. +- Any future rung added to the ladder must be an explicit, deliberate choice about what "auto-detect" should guess — silently falling through to a network-dependent default is exactly the failure mode this decision closes off. A rung qualifies only if its credential is a direct API key that can be checked with a cheap, side-effect-free presence test, and it goes at the end of the ladder so existing machines keep resolving to the same provider. - Existing explicit Ollama users (`--model ollama:`, `CONSTRUCT_SMITH_MODEL=ollama:`, an `@profile` pointing at `provider = "ollama"`) are unaffected — this only changes what happens when no model is specified at all. - The orchestrator exception is scoped to the main conversational turn only — the ambient fleet monitor's periodic background tick does not itself re-attempt resolution or fire the curated error; it silently produces no finding until either the main turn's lazy re-resolve succeeds or the session restarts. This is a deliberate scope boundary, not an oversight: the monitor is a background convenience, and re-resolving on every tick would add complexity for a path with no user waiting on it. - A future session kind or client surface that is similarly slash-capable and prompt-optional (i.e. useful without ever needing a model call) should follow the same exception rather than inventing a new one — the distinguishing property is "can this surface do useful work with zero model calls," not "is this specifically the orchestrator." diff --git a/specs/0160-reasoning-effort-is-routable-request-state.md b/specs/0160-reasoning-effort-is-routable-request-state.md index 99ae6d75..1805c846 100644 --- a/specs/0160-reasoning-effort-is-routable-request-state.md +++ b/specs/0160-reasoning-effort-is-routable-request-state.md @@ -15,6 +15,14 @@ verbatim or map it onto a semantically similar native control. For every unsupported target the catalog advertises a single provider-default level, so the picker never offers a choice the route cannot honor. +Effort support is a property of the **target and model together**, not of +the provider alone. A vendor may grade effort on one model and floor every +level to the same value on another; each model advertises only what it was +observed to honor. "Verified" means measured against the live API, not +inferred from vendor documentation — where the two disagree, the measurement +governs, and levels whose effect cannot be distinguished from run-to-run +variance are not offered. + The router's canonical request form carries the effort value so it survives the rebuild/translation path, and the byte-forwarding path preserves it implicitly. Anthropic targets map selected levels onto extended-thinking @@ -29,6 +37,13 @@ defaults to `high`. Kimi K3 accepts `low`, `high`, and `max` through `max` as its native `xhigh` level. Kimi models that publish no selectable effort scale do not advertise one. +API-key targets may expose one on the same terms. DeepSeek accepts a seven +value effort enum, but only `low`, `high`, and `max` were measured to grade +the work monotonically, and only on its flash tier — its pro tier floors +every level to one default. So flash offers those three with `high` as its +default, pro offers none, and the levels the enum accepts but that showed no +separable effect are left out. + ## Reason Encoding effort into model ids would multiply picker entries, complicate the @@ -42,15 +57,20 @@ silently ignores would misrepresent what the user selected. ## Consequences - The published-id codec stays `(route, model)`; effort never enters it. -- Catalog generation must know, per route, whether the target accepts the - effort knob verbatim, maps it onto a native control, or does not support - it, and must default to the single-level advertisement for unsupported - targets. +- Catalog generation must know, per route *and model*, whether the target + accepts the effort knob verbatim, maps it onto a native control, or does + not support it, and must default to the single-level advertisement for + unsupported targets. Resolving a route must derive its effort scale from + the model it actually resolved, or an armed route can carry a scale + belonging to a sibling model. - The canonical request form preserves effort end-to-end for accepting targets; adding a new dialect or target requires deciding whether it carries the knob verbatim, maps it, or drops it. -- The advertised level set is a conservative intersection until routes carry - per-model capability metadata. +- The advertised level set stays conservative: a level the API accepts is + not thereby offered. Adding one requires evidence it changes the work, + which means a level set can shrink when a vendor changes a model's + behavior, and a new model of an existing provider starts with no scale + until measured. - Anthropic `low`, `medium`, and `high` map to 4,096, 12,288, and 24,576 thinking tokens. The router raises `max_tokens` above that budget and omits incompatible sampling controls. Forced-tool turns leave thinking @@ -60,6 +80,10 @@ silently ignores would misrepresent what the user selected. - Kimi K3 requests carry `thinking: {type: enabled}` and map Codex `low/high/xhigh` onto Kimi `low/high/max`. K3 is always-thinking, so its picker does not offer an off position. +- DeepSeek's scale is forwarded as `reasoning_effort`, with `high` as the + catalog default. Its enum also accepts an off position, which the picker + does not currently expose because Construct's effort scales are graded + rather than on/off; a future off-position concept could adopt it. ## Non-Goals diff --git a/specs/0179-builtin-api-key-route-targets.md b/specs/0179-builtin-api-key-route-targets.md new file mode 100644 index 00000000..b3e5a535 --- /dev/null +++ b/specs/0179-builtin-api-key-route-targets.md @@ -0,0 +1,85 @@ +# 0179-builtin-api-key-route-targets + +Status: accepted +Date: 2026-08-02 +Area: architecture +Scope: When a direct-API-key provider may be offered as a route target without the user declaring an endpoint for it. + +## Decision + +A provider with a single well-known public endpoint may ship as a **built-in +route target**: when its API-key environment variable is present in the +daemon's environment, it appears in every route picker and native model +catalog with no declaration in `config.toml`. + +A built-in is a *default*, never an override: + +- A user-declared profile carrying the same route name replaces the built-in + entirely — its base URL, credential, and default model all win. +- The built-in is synthesized as an ordinary profile, so it is subject to the + same dialect resolution, credential check, model list, and blocker reporting + as a declared one. There is no second target type. +- Absent the credential, the target does not exist. It is never listed as + present-but-blocked, because there is nothing the user declared that a + blocker would be explaining. + +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. + +## Reason + +Route targets were previously either a subscription login (auto-discovered) or +a `[smith.models.*]` profile (hand-written). That left an inconsistent middle: +a provider reachable with one env var and no other choices still cost the user +a five-line config block before any harness could route to it — configuration +that carried no information, since every field was the vendor's only value. + +The asymmetry was also user-visible in the wrong direction. Setting a key made +the provider work in smith immediately, but silently did nothing for the +routing pickers, so "I set the key" and "I can route to it" came apart with no +signal explaining why. + +## Consequences + +- Adding a built-in means asserting the endpoint is stable and singular. A + provider whose base URL depends on region, tenant, or deployment must stay + declaration-only; a built-in pointing at the wrong host fails on first use + with no config for the user to inspect and correct. +- Route names become a shared namespace between built-ins and user config. + The collision rule (declared wins) must hold in both directions: a built-in + added later must never shadow an existing user profile, and removing a user + profile may make a built-in reappear. +- A built-in's presence depends on the *daemon's* environment, not the user's + shell. A key exported after the daemon started does not create the target + until the daemon restarts — the same rule that already governs every + API-key surface. +- 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. + +## Non-Goals + +- Not a plugin or discovery mechanism: built-ins are compiled in, not + contributed at runtime. +- Not a way to smuggle in defaults for providers that need real configuration + — if a reasonable person would need to look up what to put in a field, that + provider is not a built-in. +- Does not change smith's own model resolution, which reaches these providers + through explicit prefixes and its own credential ladder. + +## Examples + +- A machine exports only `DEEPSEEK_API_KEY`. A new Claude Code session's + `/model` lists DeepSeek's models as Construct gateway entries, and the + redirect menu offers DeepSeek — with an empty `config.toml`. +- The same machine adds a `deepseek` profile pointing at an internal + OpenAI-compatible gateway. The picker now shows that endpoint; the public + one is gone, because the declaration replaced the built-in rather than + adding a second entry. +- The key is unset and the daemon restarted. DeepSeek disappears from the + pickers rather than appearing with "no API key" next to it.