Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 38 additions & 6 deletions crates/adapter-smith/src/agent.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<ApprovalHistoryEntry>,
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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
Expand All @@ -1637,7 +1642,7 @@ pub fn resolve_model(params: &SessionStartParams) -> Result<ResolvedModel> {
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<String> {
Expand All @@ -1653,10 +1658,16 @@ fn default_auto_detect_spec() -> Result<String> {
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"
)
}

Expand Down Expand Up @@ -1687,6 +1698,10 @@ pub fn resolve_model_from_spec(spec_str: &str) -> Result<ResolvedModel> {
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()?,
Expand Down Expand Up @@ -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",
Expand All @@ -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)"
),
};

Expand Down Expand Up @@ -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"),
};
Expand Down Expand Up @@ -1839,6 +1859,11 @@ fn grok_api_key() -> Result<String> {
.map_err(|_| anyhow::anyhow!("grok provider requires GROK_API_KEY or XAI_API_KEY"))
}

fn deepseek_api_key() -> Result<String> {
std::env::var("DEEPSEEK_API_KEY")
.map_err(|_| anyhow::anyhow!("deepseek provider requires DEEPSEEK_API_KEY"))
}

fn grok_auth_path() -> Result<PathBuf> {
if let Ok(home) = std::env::var("GROK_HOME") {
if !home.trim().is_empty() {
Expand Down Expand Up @@ -2298,6 +2323,7 @@ mod tests {
"GOOGLE_API_KEY",
"META_API_KEY",
"MODEL_API_KEY",
"DEEPSEEK_API_KEY",
];
let saved: Vec<Option<String>> = vars.iter().map(|v| env::var(v).ok()).collect();
for v in vars {
Expand All @@ -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",
Expand All @@ -2326,12 +2352,17 @@ mod tests {
"GOOGLE_API_KEY",
"META_API_KEY",
"MODEL_API_KEY",
"DEEPSEEK_API_KEY",
];
let saved: Vec<Option<String>> = 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");
Expand All @@ -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");
Expand Down
21 changes: 21 additions & 0 deletions crates/adapter-smith/src/context.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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")];
Expand Down
4 changes: 3 additions & 1 deletion crates/adapter-smith/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand All @@ -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
Expand Down
47 changes: 45 additions & 2 deletions crates/adapter-smith/src/provider/routing.rs
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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
Expand Down Expand Up @@ -91,6 +96,12 @@ pub fn parse_model_spec(s: &str) -> Result<ModelSpec, String> {
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,
Expand Down Expand Up @@ -129,6 +140,7 @@ pub fn parse_model_spec(s: &str) -> Result<ModelSpec, String> {
| "meta"
| "ollama"
| "grok"
| "deepseek"
| "grok-oauth"
| "codex-oauth"
| "claude-oauth"
Expand All @@ -138,7 +150,7 @@ pub fn parse_model_spec(s: &str) -> Result<ModelSpec, String> {
{
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:)"
));
}
}
Expand All @@ -150,6 +162,8 @@ pub fn parse_model_spec(s: &str) -> Result<ModelSpec, String> {
Provider::Gemini
} else if s.starts_with("grok") {
Provider::Grok
} else if s.starts_with("deepseek") {
Provider::DeepSeek
} else {
Provider::Ollama
};
Expand Down Expand Up @@ -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");
Expand Down
9 changes: 9 additions & 0 deletions crates/adapter-smith/src/title_mode.rs
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,10 @@ pub(crate) fn pick_default_spec_str() -> Result<String> {
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"
))
Expand All @@ -73,6 +77,11 @@ pub(crate) fn provider_for(p: Provider) -> Result<Box<dyn LlmProvider>> {
.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
Expand Down
3 changes: 3 additions & 0 deletions crates/cli/src/app/configure.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
Loading
Loading