Skip to content

Commit cef2c90

Browse files
fix(deepseek): use provider-native transport (#198)
1 parent a569435 commit cef2c90

18 files changed

Lines changed: 424 additions & 28 deletions

‎.repo-seed/manifest.json‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,7 @@
7777
},
7878
{
7979
"path": "docs/architecture.md",
80-
"sha256": "135e9e3812edafbc1af5c849660ea1416aab0bbb51aeedd1cebea72d0d6aca19",
80+
"sha256": "8aff1a006b838161fb55274c8f192719f37a93fd04c2752b68a4cc26ec123c9d",
8181
"category": "docs",
8282
"capability": "baseline"
8383
},
@@ -258,7 +258,7 @@
258258
},
259259
{
260260
"path": "docs/postmortems/README.md",
261-
"sha256": "d5ddcfd1ad66efd11f1d4ad4d183c2d6c72100b48ed83a982d2addda2d391de9",
261+
"sha256": "f6b4879a58d15e90f9c899073554673d616be160795cfa2004c0c2145e859a87",
262262
"category": "docs",
263263
"capability": "postmortems"
264264
},

‎Cargo.lock‎

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎crates/accelerator/src/provider.rs‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ pub const PROVIDERS: &[Provider] = &[
4242
},
4343
Provider {
4444
key: "deepseek",
45-
protocol: Protocol::OpenAI,
45+
protocol: Protocol::DeepSeek,
4646
endpoint: "https://api.deepseek.com",
4747
env_var: "DEEPSEEK_API_KEY",
4848
default_model: "deepseek-v4-flash",

‎crates/accelerator/tests/provider.rs‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
//! Tests for the provider table and `resolve_model`.
22
33
use accelerator::provider::{self, ResolveError};
4+
use machine::Protocol;
45
use std::sync::Mutex;
56

67
/// Tests in this file all mutate process-wide environment variables. Cargo
@@ -87,6 +88,7 @@ fn resolves_provider_only_to_default_model() {
8788
let model = provider::resolve_model(Some("deepseek")).expect("resolve");
8889
assert_eq!(model.name, "deepseek/deepseek-v4-flash");
8990
assert_eq!(model.endpoint.as_deref(), Some("https://api.deepseek.com"));
91+
assert_eq!(model.protocol, Protocol::DeepSeek);
9092
}
9193

9294
#[test]

‎crates/cli/Cargo.toml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "cli"
3-
version = "0.2.23"
3+
version = "0.2.24"
44
edition = "2024"
55
publish = false
66

‎crates/cli/src/rcm/compile.rs‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -356,8 +356,8 @@ fn build_models(defs: &[ast::ModelDef]) -> Result<Vec<Model>, String> {
356356
thinking: def.thinking,
357357
..Default::default()
358358
};
359-
if def.thinking_configured && protocol == Protocol::OpenAI {
360-
model.set_openai_thinking_mode(def.thinking);
359+
if def.thinking_configured && matches!(protocol, Protocol::OpenAI | Protocol::DeepSeek) {
360+
model.set_thinking_mode(def.thinking);
361361
}
362362
if let Some(timeout) = def.timeout {
363363
model.timeout = timeout;
@@ -373,6 +373,7 @@ fn build_models(defs: &[ast::ModelDef]) -> Result<Vec<Model>, String> {
373373
fn parse_protocol(name: &str) -> Result<Protocol, String> {
374374
match name {
375375
"openai" => Ok(Protocol::OpenAI),
376+
"deepseek" => Ok(Protocol::DeepSeek),
376377
"anthropic" => Ok(Protocol::Anthropic),
377378
"gemini" => Ok(Protocol::Gemini),
378379
_ => Err(format!("unknown protocol: {}", name)),

‎crates/cli/tests/compile.rs‎

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -149,7 +149,7 @@ fn compile_selects_resource_pools_without_initial_activation() {
149149
}
150150

151151
#[test]
152-
fn compile_preserves_explicit_openai_thinking_modes() {
152+
fn compile_preserves_explicit_thinking_modes_for_supported_protocols() {
153153
let source = r#"
154154
name = "thinking modes"
155155
model enabled {
@@ -172,8 +172,15 @@ fn compile_preserves_explicit_openai_thinking_modes() {
172172
limit = { context = "1000", output = "100" }
173173
modalities = { input = ["text"], output = ["text"] }
174174
}
175+
model deepseek {
176+
protocol = "deepseek"
177+
credentials = { key = "REDACTED" }
178+
limit = { context = "1000", output = "100" }
179+
modalities = { input = ["text"], output = ["text"] }
180+
thinking = "true"
181+
}
175182
accelerator {
176-
models = ["enabled", "disabled", "implicit"]
183+
models = ["enabled", "disabled", "implicit", "deepseek"]
177184
}
178185
"#;
179186

@@ -183,6 +190,8 @@ fn compile_preserves_explicit_openai_thinking_modes() {
183190
assert_eq!(models["enabled"].openai_thinking_mode(), Some(true));
184191
assert_eq!(models["disabled"].openai_thinking_mode(), Some(false));
185192
assert_eq!(models["implicit"].openai_thinking_mode(), None);
193+
assert_eq!(models["deepseek"].protocol, machine::Protocol::DeepSeek);
194+
assert_eq!(models["deepseek"].thinking_mode(), Some(true));
186195
}
187196

188197
#[test]

‎crates/machine/src/completion.rs‎

Lines changed: 42 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -172,6 +172,26 @@ pub async fn complete_with_diagnostics(
172172
.completion_model(wire_name);
173173
send(&endpoint, model, request).await
174174
}
175+
Protocol::DeepSeek => {
176+
// DeepSeek documents assistant `content` as string-or-null and
177+
// requires the complete prior reasoning/tool-call response to be
178+
// replayed. Its native Rig adapter owns that exact conversion.
179+
// Sources:
180+
// https://api-docs.deepseek.com/api/create-chat-completion/
181+
// https://api-docs.deepseek.com/guides/thinking_mode/
182+
let mut builder = rig::providers::deepseek::Client::builder()
183+
.api_key(api_key)
184+
.http_client(openai_http_client());
185+
if let Some(endpoint) = endpoint_url {
186+
builder = builder.base_url(endpoint);
187+
}
188+
builder = apply_headers(builder, &model.headers);
189+
let endpoint = builder
190+
.build()
191+
.expect("failed to build deepseek client")
192+
.completion_model(wire_name);
193+
send(&endpoint, model, request).await
194+
}
175195
Protocol::Anthropic => {
176196
let mut builder = rig::providers::anthropic::Client::builder().api_key(api_key);
177197
if let Some(endpoint) = endpoint_url {
@@ -684,16 +704,33 @@ pub fn build_request(
684704
)
685705
})?;
686706

687-
let additional_params = if model.protocol == Protocol::OpenAI {
688-
model.openai_thinking_mode().map(|enabled| {
707+
let additional_params = match model.protocol {
708+
Protocol::OpenAI => model.thinking_mode().map(|enabled| {
689709
serde_json::json!({
690710
"thinking": {
691711
"type": if enabled { "enabled" } else { "disabled" },
692712
}
693713
})
694-
})
695-
} else {
696-
None
714+
}),
715+
Protocol::DeepSeek => {
716+
let mut params = serde_json::Map::new();
717+
if let Some(enabled) = model.thinking_mode() {
718+
params.insert(
719+
"thinking".into(),
720+
serde_json::json!({
721+
"type": if enabled { "enabled" } else { "disabled" },
722+
}),
723+
);
724+
}
725+
// Rig 0.36's native DeepSeek adapter does not project the generic
726+
// CompletionRequest max_tokens field, so preserve the declared
727+
// model limit through its flattened provider parameters.
728+
if let Some(limit) = &model.limit {
729+
params.insert("max_tokens".into(), limit.output.into());
730+
}
731+
(!params.is_empty()).then_some(serde_json::Value::Object(params))
732+
}
733+
Protocol::Anthropic | Protocol::Gemini => None,
697734
};
698735

699736
Ok(CompletionRequest {

‎crates/machine/src/model.rs‎

Lines changed: 37 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ use serde_json::Value;
33
use std::collections::HashMap;
44

55
pub const DEFAULT_MODEL_TIMEOUT_SECS: u64 = 1_800;
6-
const OPENAI_THINKING_MODE_EXTRA: &str = "openai_thinking_mode";
6+
const THINKING_MODE_EXTRA: &str = "openai_thinking_mode";
77

88
/// LLM configuration.
99
///
@@ -62,41 +62,66 @@ impl Default for Model {
6262
}
6363

6464
impl Model {
65+
/// Configure an explicit provider thinking mode.
66+
///
67+
/// The stored key retains its historical spelling for serialized-model
68+
/// compatibility. Request construction applies it only to protocols that
69+
/// support the `thinking.type` extension.
70+
pub fn set_thinking_mode(&mut self, enabled: bool) {
71+
self.extra
72+
.insert(THINKING_MODE_EXTRA.to_string(), Value::Bool(enabled));
73+
}
74+
75+
/// Return an explicitly configured provider thinking mode.
76+
pub fn thinking_mode(&self) -> Option<bool> {
77+
self.extra.get(THINKING_MODE_EXTRA).and_then(Value::as_bool)
78+
}
79+
6580
/// Configure the OpenAI-compatible `thinking.type` request parameter.
6681
///
6782
/// This is opt-in so generic OpenAI-compatible providers that do not
6883
/// recognize the extension keep receiving the historical request shape.
6984
pub fn set_openai_thinking_mode(&mut self, enabled: bool) {
70-
self.extra
71-
.insert(OPENAI_THINKING_MODE_EXTRA.to_string(), Value::Bool(enabled));
85+
self.set_thinking_mode(enabled);
7286
}
7387

7488
/// Return an explicitly configured OpenAI-compatible thinking mode.
7589
pub fn openai_thinking_mode(&self) -> Option<bool> {
76-
self.extra
77-
.get(OPENAI_THINKING_MODE_EXTRA)
78-
.and_then(Value::as_bool)
90+
self.thinking_mode()
7991
}
8092
}
8193

8294
#[cfg(test)]
8395
mod tests {
84-
use super::Model;
96+
use super::{Model, Protocol};
8597

8698
#[test]
8799
fn model_default_timeout_allows_thirty_minute_requests() {
88100
assert_eq!(Model::default().timeout, 1_800);
89101
}
102+
103+
#[test]
104+
fn deepseek_protocol_round_trips_through_serde() {
105+
let serialized = serde_json::to_string(&Protocol::DeepSeek).expect("serializes");
106+
assert_eq!(serialized, "\"deepseek\"");
107+
assert_eq!(
108+
serde_json::from_str::<Protocol>(&serialized).expect("deserializes"),
109+
Protocol::DeepSeek
110+
);
111+
}
90112
}
91113

92114
/// Wire protocol.
93115
///
94-
/// Three protocols are supported. Most providers are OpenAI-compatible;
95-
/// use the `OpenAI` variant with a custom `endpoint`.
116+
/// Four protocols are supported. Most providers are OpenAI-compatible;
117+
/// use the `OpenAI` variant with a custom `endpoint`. DeepSeek has an
118+
/// explicit variant because its assistant-message schema differs at the
119+
/// thinking/tool-history boundary.
96120
///
97121
/// | Protocol | Examples |
98122
/// |----------|---------|
99-
/// | `OpenAI` | OpenAI, DeepSeek, Groq, Mistral, xAI, Ollama, OpenRouter ... |
123+
/// | `OpenAI` | OpenAI, Groq, Mistral, xAI, Ollama, OpenRouter ... |
124+
/// | `DeepSeek` | DeepSeek Chat Completions API |
100125
/// | `Anthropic` | Anthropic Claude |
101126
/// | `Gemini` | Google Gemini |
102127
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
@@ -105,6 +130,8 @@ pub enum Protocol {
105130
/// OpenAI Chat Completions API.
106131
#[default]
107132
OpenAI,
133+
/// DeepSeek Chat Completions API.
134+
DeepSeek,
108135
/// Anthropic Messages API.
109136
Anthropic,
110137
/// Google Gemini API.

0 commit comments

Comments
 (0)