diff --git a/.env.example b/.env.example
index ea69ab76b..c9f059ea1 100644
--- a/.env.example
+++ b/.env.example
@@ -1,6 +1,7 @@
ANTHROPIC_API_KEY=
BRAVE_SEARCH_API_KEY=
DAYTONA_API_KEY=
+DEEPSEEK_API_KEY=
FIREWORKS_API_KEY=
GEMINI_API_KEY=
INCEPTION_API_KEY=
diff --git a/apps/fabro-web/public/images/providers/deepseek.svg b/apps/fabro-web/public/images/providers/deepseek.svg
new file mode 100644
index 000000000..5d6efa991
--- /dev/null
+++ b/apps/fabro-web/public/images/providers/deepseek.svg
@@ -0,0 +1,3 @@
+
diff --git a/docs/public/administration/server-configuration.mdx b/docs/public/administration/server-configuration.mdx
index c806406f7..9f107d046 100644
--- a/docs/public/administration/server-configuration.mdx
+++ b/docs/public/administration/server-configuration.mdx
@@ -388,6 +388,7 @@ fabro secret set GEMINI_API_KEY AI...
| `MINIMAX_API_KEY` | Minimax |
| `INCEPTION_API_KEY` | Inception (Mercury) |
| `POOLSIDE_API_KEY` | Poolside (Laguna) |
+| `DEEPSEEK_API_KEY` | DeepSeek |
| `OPENROUTER_API_KEY` | OpenRouter (when enabled) |
| `MODAL_TOKEN_ID` and `MODAL_TOKEN_SECRET` | Modal (when enabled) |
| `FIREWORKS_API_KEY` | Fireworks AI (when enabled) |
diff --git a/docs/public/core-concepts/models.mdx b/docs/public/core-concepts/models.mdx
index a4befa37f..84f00bb1e 100644
--- a/docs/public/core-concepts/models.mdx
+++ b/docs/public/core-concepts/models.mdx
@@ -59,13 +59,15 @@ Fabro performs this selection once when creating a run and persists the chosen p
| `gemini-3.1-flash-lite` | gemini | `gemini-flash-lite`, `gemini-3.1-flash-lite-preview` | 1M | $0.25 / $1.50 | 200 tok/s |
| `kimi-k2.5` | kimi | | 262K | $0.60 / $3.00 | 50 tok/s |
| `kimi-k3` | kimi | `kimi` | 1M | $3.00 / $15.00 | n/a |
+| `deepseek-v4-flash` | deepseek | `deepseek`, `deepseek-v4`, `deepseek-flash` | 1,048,576 | $0.14 / $0.28 | n/a |
+| `deepseek-v4-pro` | deepseek | | 1,048,576 | $0.435 / $0.87 | n/a |
| `laguna-s-2.1` | poolside | `laguna`, `laguna-s` | 1M | $0.10 / $0.20 | n/a |
| `laguna-xs-2.1` | poolside | `laguna-xs` | 262K | $0.10 / $0.20 | n/a |
| `glm-5.2` | zai | `glm`, `glm5`, `glm52`, `glm5.2` | 1M | $1.40 / $4.40 | n/a |
| `minimax-m2.5` | minimax | `minimax` | 197K | $0.30 / $1.20 | 45 tok/s |
| `mercury-2` | inception | `mercury` | 131K | $0.25 / $0.75 | 1000 tok/s |
-Each provider requires its own API key. Server-backed workflows read provider credentials from the server vault (for example `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, `GEMINI_API_KEY`, or `POOLSIDE_API_KEY` set with `fabro secret set` or `fabro provider login`). Standalone SDK/CLI flows can opt into env-backed credential sources explicitly. See the [Quick Start](/getting-started/quick-start) for setup.
+Each provider requires its own API key. Server-backed workflows read provider credentials from the server vault (for example `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, `GEMINI_API_KEY`, `DEEPSEEK_API_KEY`, or `POOLSIDE_API_KEY` set with `fabro secret set` or `fabro provider login`). Standalone SDK/CLI flows can opt into env-backed credential sources explicitly. See the [Quick Start](/getting-started/quick-start) for setup.
Claude Fable 5 is available as an explicit model but is not the default Anthropic model. If Fable refuses a request, Fabro reports the refusal as a content-filter LLM error and applies the configured `run.model.fallbacks` chain when one is present.
diff --git a/docs/public/docs.json b/docs/public/docs.json
index e00f0d711..faf0d0f84 100644
--- a/docs/public/docs.json
+++ b/docs/public/docs.json
@@ -96,6 +96,7 @@
"integrations/daytona",
"integrations/litellm",
"integrations/bedrock",
+ "integrations/deepseek",
"integrations/poolside",
"integrations/openrouter",
"integrations/modal",
diff --git a/docs/public/integrations/deepseek.mdx b/docs/public/integrations/deepseek.mdx
new file mode 100644
index 000000000..23fcdb361
--- /dev/null
+++ b/docs/public/integrations/deepseek.mdx
@@ -0,0 +1,146 @@
+---
+title: "DeepSeek"
+description: "Run DeepSeek V4 Flash and Pro through DeepSeek's direct API or supported gateways"
+---
+
+[DeepSeek](https://www.deepseek.com/) provides an OpenAI-compatible API for DeepSeek V4. Fabro includes direct access to V4 Flash and V4 Pro. The same Fabro model IDs also work through the optional Fireworks AI and OpenRouter providers.
+
+## Prerequisites
+
+- A [DeepSeek Platform](https://platform.deepseek.com/) account
+- A DeepSeek API key from [platform.deepseek.com/api_keys](https://platform.deepseek.com/api_keys)
+- A running Fabro server
+
+## Configure direct access
+
+The direct `deepseek` provider is enabled in the built-in catalog. Store its key in the target Fabro server vault:
+
+```bash
+fabro provider login --provider deepseek
+
+# For a non-default remote server:
+fabro provider login --server https://your-fabro.example --provider deepseek
+
+# Or set the vault token directly:
+fabro secret set DEEPSEEK_API_KEY
+fabro secret --server https://your-fabro.example set DEEPSEEK_API_KEY
+```
+
+Standalone SDK usage outside a Fabro server can use an env-backed credential source explicitly:
+
+```bash
+export DEEPSEEK_API_KEY=
+```
+
+Fabro sends bearer-authenticated Chat Completions requests to `https://api.deepseek.com`.
+
+## Included models
+
+| Fabro model ID | DeepSeek API model ID | Context | Max output | Role |
+|---|---|---:|---:|---|
+| `deepseek-v4-flash` | `deepseek-v4-flash` | 1,048,576 | 384,000 | Provider default, small default, and connectivity probe; aliases `deepseek`, `deepseek-v4`, `deepseek-flash` |
+| `deepseek-v4-pro` | `deepseek-v4-pro` | 1,048,576 | 384,000 | Higher-capability V4 model |
+
+Both models support text input, tool calling, native reasoning, streaming, JSON output, and automatic prompt caching. They do not support image input. Thinking mode is enabled by default.
+
+## Agent profile
+
+DeepSeek V4 uses Fabro's `openai` agent profile on every route. This profile is the closest match for DeepSeek's general coding behavior: it supplies project `AGENTS.md` instructions, standard JSON function tools, and a JSON-compatible file editor on Chat Completions routes. The setting is model-specific, so it remains the same through direct DeepSeek, Fireworks AI, and OpenRouter.
+
+Fabro does not use the `gpt56` profile for DeepSeek. That profile has a smaller Codex-specific tool set for GPT-5.6 models.
+
+## Use DeepSeek models
+
+```bash
+fabro model list --provider deepseek
+fabro model test --provider deepseek --model deepseek-v4-flash --deep
+fabro run workflow.fabro --provider deepseek --model deepseek
+```
+
+In workflow stylesheets:
+
+```dot title="workflow.fabro"
+digraph Example {
+ graph [
+ model_stylesheet="
+ * { model: deepseek-v4-flash; }
+ .difficult { model: deepseek-v4-pro; }
+ "
+ ]
+
+ start [shape=Mdiamond, label="Start"]
+ work [label="Implement", prompt="Implement and verify the requested change."]
+ exit [shape=Msquare, label="Exit"]
+
+ start -> work -> exit
+}
+```
+
+## Thinking and reasoning effort
+
+DeepSeek enables thinking by default at `high` effort. Fabro advertises only effort values that produce a distinct model behavior on each route:
+
+| Route | V4 Flash | V4 Pro |
+|---|---|---|
+| Direct DeepSeek | `low`, `high`, `max` | `high`, `max` |
+| Fireworks AI | `high`, `max` | `high`, `max` |
+| OpenRouter | `low`, `high`, `max` | `high`, `xhigh` |
+
+DeepSeek currently maps a V4 Pro request for `low` to `high`; its documentation says this mapping will change in early August 2026. Fireworks maps `low` and `medium` to `high`, and maps `xhigh` to `max`. OpenRouter names the Pro maximum tier `xhigh`.
+
+Fabro omits `temperature` and `top_p` for these models because DeepSeek ignores sampling parameters while thinking is enabled.
+
+To disable thinking on the direct provider, omit typed `reasoning_effort` and pass DeepSeek's native toggle through provider options:
+
+```json
+{
+ "provider_options": {
+ "deepseek": {
+ "thinking": { "type": "disabled" }
+ }
+ }
+}
+```
+
+DeepSeek requires `reasoning_content` from an assistant tool call to appear in every later request in that tool-use turn. Fabro captures this content and replays it with the assistant message. This keeps multi-step tool calls valid and prevents DeepSeek's HTTP 400 response for missing reasoning history.
+
+## Prompt caching and pricing
+
+DeepSeek applies prefix caching automatically. Fabro reads DeepSeek's `prompt_cache_hit_tokens` usage field and reports cache-read tokens separately from uncached input tokens.
+
+The built-in catalog uses DeepSeek's published prices per million tokens:
+
+| Model | Uncached input | Cache hit | Output |
+|---|---:|---:|---:|
+| `deepseek-v4-flash` | $0.14 | $0.0028 | $0.28 |
+| `deepseek-v4-pro` | $0.435 | $0.003625 | $0.87 |
+
+DeepSeek does not return an in-band dollar cost. Fabro calculates an estimated cost from these catalog rates and the reported token buckets.
+
+## Use a gateway
+
+The `deepseek-v4-flash`, `deepseek-v4-pro`, `deepseek`, `deepseek-v4`, and `deepseek-flash` selectors are portable across direct DeepSeek, Fireworks AI, and OpenRouter routes. Use `--provider` or a provider-qualified model selector when you need a specific route.
+
+See the [Fireworks AI integration](/integrations/fireworks) and [OpenRouter integration](/integrations/openrouter) for gateway setup and provider-specific pricing.
+
+## Troubleshooting
+
+**"No API key configured"** — Store `DEEPSEEK_API_KEY` on the target server with `fabro provider login --provider deepseek`. The server runtime resolves the key from its vault, not from process env.
+
+**Unknown model** — Use `deepseek-v4-flash` or `deepseek-v4-pro`. The retired `deepseek-chat` and `deepseek-reasoner` API IDs are not in the Fabro catalog.
+
+**A tool continuation returns HTTP 400** — Keep the assistant thinking content in conversation history. Fabro does this automatically when it replays tool-call turns.
+
+## Further reading
+
+
+
+ Official authentication, endpoints, and API reference.
+
+
+ Official limits, features, and token prices.
+
+
+ Official thinking toggles, effort mappings, and tool-call replay rules.
+
+
diff --git a/docs/public/integrations/fireworks.mdx b/docs/public/integrations/fireworks.mdx
index 7c3331de6..26e873705 100644
--- a/docs/public/integrations/fireworks.mdx
+++ b/docs/public/integrations/fireworks.mdx
@@ -50,7 +50,7 @@ The built-in catalog gives Fireworks offerings the same human-facing model slugs
| --- | --- |
| `kimi-k2.7-code` | `accounts/fireworks/models/kimi-k2p7-code`; provider default |
| `kimi-k2.6` | `accounts/fireworks/models/kimi-k2p6` |
-| `deepseek-v4-pro`, `deepseek-v4-flash` | `accounts/fireworks/models/deepseek-v4-...` |
+| `deepseek-v4-pro`, `deepseek-v4-flash` (`deepseek`, `deepseek-v4`, `deepseek-flash`) | `accounts/fireworks/models/deepseek-v4-...` |
| `glm-5.2` | `accounts/fireworks/models/glm-5p2` |
| `minimax-m2.7` | `accounts/fireworks/models/minimax-m2p7` |
| `qwen3.7-plus` | `accounts/fireworks/models/qwen3p7-plus` |
diff --git a/docs/public/integrations/openrouter.mdx b/docs/public/integrations/openrouter.mdx
index c3bf7ff2c..d82dbc4e7 100644
--- a/docs/public/integrations/openrouter.mdx
+++ b/docs/public/integrations/openrouter.mdx
@@ -53,7 +53,7 @@ The built-in catalog gives OpenRouter offerings the same human-facing model slug
| `claude-haiku-4-5` | `anthropic/claude-haiku-4.5`; provider small default |
| `gpt-5.6-sol`, `gpt-5.6-terra`, `gpt-5.6-luna`, `gpt-5.4`, `gpt-5.5` | Matching `openai/...` API IDs |
| `gemini-3.1-pro-preview`, `gemini-3.5-flash` | `google/...` API IDs |
-| `deepseek-v4-pro` (`deepseek`, `deepseek-v4`), `deepseek-v4-flash` (`deepseek-flash`) | `deepseek/...` API IDs |
+| `deepseek-v4-pro`, `deepseek-v4-flash` (`deepseek`, `deepseek-v4`, `deepseek-flash`) | `deepseek/...` API IDs; Flash uses the `deepseek-v4-flash-0731` release |
| `kimi-k3`, `kimi-k2.6`, `qwen3-coder`, `qwen3.6-flash` | Vendor-prefixed API IDs |
| `laguna-s-2.1`, `laguna-xs-2.1` | `poolside/...`; native reasoning and tool use |
| `glm-5.2` (`glm`, `glm5`, `glm52`, `glm5.2`), `glm-4.6` | `z-ai/...` API IDs |
diff --git a/docs/public/reference/sdk.mdx b/docs/public/reference/sdk.mdx
index feeea0c0a..ad4eedb0b 100644
--- a/docs/public/reference/sdk.mdx
+++ b/docs/public/reference/sdk.mdx
@@ -371,6 +371,7 @@ For env-backed usage, `EnvCredentialSource` checks for API key environment varia
| `MINIMAX_API_KEY` | Minimax |
| `INCEPTION_API_KEY` | Inception |
| `POOLSIDE_API_KEY` | Poolside |
+| `DEEPSEEK_API_KEY` | DeepSeek |
| `OPENROUTER_API_KEY` | OpenRouter, when enabled in settings |
The first provider registered becomes the default. Provider base URLs come from the model catalog. For vault-backed usage inside Fabro, use `fabro_auth::VaultCredentialSource` instead.
diff --git a/lib/apps/fabro-cli/src/commands/install.rs b/lib/apps/fabro-cli/src/commands/install.rs
index 3486b35f5..adfb2d97c 100644
--- a/lib/apps/fabro-cli/src/commands/install.rs
+++ b/lib/apps/fabro-cli/src/commands/install.rs
@@ -3521,6 +3521,7 @@ root = "{}"
assert!(ids.contains(&ProviderId::new("inception")));
assert!(ids.contains(&ProviderId::new("venice")));
assert!(ids.contains(&ProviderId::new("poolside")));
+ assert!(ids.contains(&ProviderId::new("deepseek")));
assert!(!ids.contains(&ProviderId::new("fireworks")));
assert!(!ids.contains(&ProviderId::new("ollama")));
assert!(!ids.contains(&ProviderId::new("litellm")));
diff --git a/lib/components/fabro-llm/src/adapter_registry.rs b/lib/components/fabro-llm/src/adapter_registry.rs
index 26ff6d70b..bb1b893ce 100644
--- a/lib/components/fabro-llm/src/adapter_registry.rs
+++ b/lib/components/fabro-llm/src/adapter_registry.rs
@@ -303,6 +303,8 @@ mod tests {
("claude-sonnet-4-5", "claude-sonnet-4-5", T::Anthropic, C::AnthropicMessages, B::Anthropic, P::Anthropic),
("claude-sonnet-4-6", "claude-sonnet-4-6", T::Anthropic, C::AnthropicMessages, B::Anthropic, P::Anthropic),
("claude-sonnet-5", "claude-sonnet-5", T::Anthropic, C::AnthropicMessages, B::Anthropic, P::Claude5),
+ ("deepseek-v4-flash", "deepseek-v4-flash", T::OpenAiCompatible, C::OpenAiCompatible, B::OpenAi, P::OpenAi),
+ ("deepseek-v4-pro", "deepseek-v4-pro", T::OpenAiCompatible, C::OpenAiCompatible, B::OpenAi, P::OpenAi),
("gemini-3-flash-preview", "gemini-3-flash-preview", T::Gemini, C::GeminiGenerate, B::Gemini, P::Gemini),
("gemini-3.1-flash-lite", "gemini-3.1-flash-lite", T::Gemini, C::GeminiGenerate, B::Gemini, P::Gemini),
("gemini-3.1-pro-preview", "gemini-3.1-pro-preview", T::Gemini, C::GeminiGenerate, B::Gemini, P::Gemini),
diff --git a/lib/components/fabro-llm/src/codec/openai_compatible/translate.rs b/lib/components/fabro-llm/src/codec/openai_compatible/translate.rs
index dbef4831f..b32fac567 100644
--- a/lib/components/fabro-llm/src/codec/openai_compatible/translate.rs
+++ b/lib/components/fabro-llm/src/codec/openai_compatible/translate.rs
@@ -237,7 +237,9 @@ pub(super) fn translate_response_format(format: &ResponseFormat) -> serde_json::
#[cfg(test)]
mod tests {
use super::*;
- use crate::types::{AudioData, ContentPart, DocumentData, Message, Role, ToolCall};
+ use crate::types::{
+ AudioData, ContentPart, DocumentData, Message, Role, ThinkingData, ToolCall,
+ };
#[test]
fn translate_assistant_message_with_tool_calls_only() {
@@ -291,6 +293,37 @@ mod tests {
assert_eq!(tool_calls[0].function.name, "get_weather");
}
+ #[test]
+ fn translate_assistant_tool_call_replays_reasoning_content() {
+ let msg = Message {
+ role: Role::Assistant,
+ content: vec![
+ ContentPart::Thinking(ThinkingData {
+ text: "I need the weather tool.".to_string(),
+ signature: None,
+ redacted: false,
+ }),
+ ContentPart::ToolCall(ToolCall::new(
+ "call_2",
+ "get_weather",
+ serde_json::json!({"city": "NYC"}),
+ )),
+ ],
+ name: None,
+ tool_call_id: None,
+ };
+
+ let translated = translate_messages(&[msg]);
+
+ assert_eq!(
+ translated[0].reasoning_content.as_deref(),
+ Some("I need the weather tool.")
+ );
+ assert_eq!(translated[0].tool_calls.as_ref().unwrap().len(), 1);
+ let json = serde_json::to_value(&translated[0]).unwrap();
+ assert_eq!(json["reasoning_content"], "I need the weather tool.");
+ }
+
#[test]
fn translate_assistant_message_with_raw_arguments() {
let mut tc = ToolCall::new("call_3", "search", serde_json::json!({"q": "rust"}));
diff --git a/lib/components/fabro-llm/src/codec/openai_compatible/wire.rs b/lib/components/fabro-llm/src/codec/openai_compatible/wire.rs
index 3e1de949d..1b7ff3b33 100644
--- a/lib/components/fabro-llm/src/codec/openai_compatible/wire.rs
+++ b/lib/components/fabro-llm/src/codec/openai_compatible/wire.rs
@@ -44,7 +44,7 @@ pub(super) struct ChatMessage {
#[serde(skip_serializing_if = "Option::is_none")]
pub content: Option,
/// Reasoning/thinking content echoed back for providers that require it
- /// (Kimi).
+ /// during tool-call continuations (including Kimi and DeepSeek).
#[serde(skip_serializing_if = "Option::is_none")]
pub reasoning_content: Option,
#[serde(skip_serializing_if = "Option::is_none")]
@@ -300,6 +300,10 @@ pub(super) struct ApiUsage {
pub cost: Option,
#[serde(default)]
pub prompt_tokens_details: Option,
+ /// DeepSeek-specific top-level count of prompt tokens served from its
+ /// automatic context cache.
+ #[serde(default)]
+ pub prompt_cache_hit_tokens: Option,
#[serde(default)]
pub completion_tokens_details: Option,
}
@@ -329,6 +333,7 @@ impl ApiUsage {
.prompt_tokens_details
.as_ref()
.and_then(|d| d.cached_tokens)
+ .or(self.prompt_cache_hit_tokens)
.unwrap_or(0);
let cache_write_detail = self
.prompt_tokens_details
@@ -538,6 +543,23 @@ mod tests {
});
}
+ #[test]
+ fn token_counts_accept_deepseek_cache_hit_field() {
+ let usage: ApiUsage = serde_json::from_value(serde_json::json!({
+ "prompt_tokens": 53,
+ "completion_tokens": 11,
+ "prompt_cache_hit_tokens": 41
+ }))
+ .unwrap();
+
+ assert_eq!(usage.token_counts(), TokenCounts {
+ input_tokens: 12,
+ output_tokens: 11,
+ cache_read_tokens: 41,
+ ..TokenCounts::default()
+ });
+ }
+
#[test]
fn reasoning_accepts_provider_and_openrouter_spellings() {
let provider_response: ApiResponse = serde_json::from_value(serde_json::json!({
diff --git a/lib/components/fabro-llm/tests/integration.rs b/lib/components/fabro-llm/tests/integration.rs
index 3c80ef796..b9790b5ad 100644
--- a/lib/components/fabro-llm/tests/integration.rs
+++ b/lib/components/fabro-llm/tests/integration.rs
@@ -373,7 +373,7 @@ async fn openrouter_complete() {
std::env::var(EnvVars::OPENROUTER_API_KEY).expect("OPENROUTER_API_KEY must be set");
let adapter = OpenAiCompatibleAdapter::new(api_key, "https://openrouter.ai/api/v1")
.with_name("openrouter");
- let request = make_request("deepseek/deepseek-v4-flash");
+ let request = make_request("deepseek/deepseek-v4-flash-0731");
let response = adapter.complete(&request).await.unwrap();
assert!(
@@ -599,6 +599,39 @@ async fn fireworks_complete() {
assert_eq!(response.provider, "fireworks");
}
+#[fabro_macros::e2e_test(live("DEEPSEEK_API_KEY"))]
+async fn deepseek_complete() {
+ let api_key = std::env::var(EnvVars::DEEPSEEK_API_KEY).expect("DEEPSEEK_API_KEY must be set");
+ let adapter =
+ OpenAiCompatibleAdapter::new(api_key, "https://api.deepseek.com").with_name("deepseek");
+ let request = Request {
+ // Thinking mode is enabled by default and shares this budget with the
+ // visible answer.
+ max_tokens: Some(1024),
+ ..make_request("deepseek-v4-flash")
+ };
+ let response = adapter.complete(&request).await.unwrap();
+
+ assert!(
+ !response.text().is_empty(),
+ "response text should not be empty"
+ );
+ assert!(response.usage.input_tokens > 0);
+ assert!(response.usage.output_tokens > 0 || response.usage.reasoning_tokens > 0);
+ assert_eq!(response.provider, "deepseek");
+}
+
+#[fabro_macros::e2e_test(live("DEEPSEEK_API_KEY"))]
+async fn deepseek_v4_flash_deep_tool_round_trip() {
+ let api_key = std::env::var(EnvVars::DEEPSEEK_API_KEY).expect("DEEPSEEK_API_KEY must be set");
+ let provider = ProviderId::new("deepseek");
+ let catalog = enabled_provider_catalog(&provider, None);
+ let credential = ApiCredential::from_api_key(provider.clone(), api_key, &catalog)
+ .expect("DeepSeek credential should resolve from the catalog");
+
+ assert_deep_tool_round_trip(&catalog, &provider, "deepseek-v4-flash", credential).await;
+}
+
#[fabro_macros::e2e_test(live("FIREWORKS_API_KEY"))]
async fn fireworks_kimi_k2_7_code_deep_tool_round_trip() {
let api_key = std::env::var(EnvVars::FIREWORKS_API_KEY).expect("FIREWORKS_API_KEY must be set");
diff --git a/lib/components/fabro-llm/tests/it/wire/openai_compatible.rs b/lib/components/fabro-llm/tests/it/wire/openai_compatible.rs
index 1294e2e8a..1074cc222 100644
--- a/lib/components/fabro-llm/tests/it/wire/openai_compatible.rs
+++ b/lib/components/fabro-llm/tests/it/wire/openai_compatible.rs
@@ -185,8 +185,8 @@ async fn encode_tool_round_trip() {
fabro_test::fabro_json_snapshot!(capture.body);
}
-/// Assistant thinking parts echo back as `reasoning_content` (Kimi-motivated,
-/// applies to every compat assistant message).
+/// Assistant thinking parts echo back as `reasoning_content` (required by
+/// Kimi and DeepSeek during tool-call continuations).
#[tokio::test]
async fn encode_thinking_round_trip_as_reasoning_content() {
let capture = encode_capture(&corpus_thinking_round_trip(MODEL)).await;
diff --git a/lib/foundation/fabro-model/src/catalog.rs b/lib/foundation/fabro-model/src/catalog.rs
index 21aa04888..82878ab1b 100644
--- a/lib/foundation/fabro-model/src/catalog.rs
+++ b/lib/foundation/fabro-model/src/catalog.rs
@@ -3028,6 +3028,148 @@ enabled = true
);
}
+ #[test]
+ fn builtin_deepseek_provider_routes_v4_models() {
+ let deepseek = ProviderId::new("deepseek");
+ let catalog = Catalog::builtin();
+ let provider = catalog
+ .provider(&deepseek)
+ .expect("DeepSeek provider should be active");
+
+ assert_eq!(provider.adapter, AdapterKind::OpenAiCompatible);
+ assert_eq!(provider.codec, CodecKind::OpenAiCompatible);
+ assert_eq!(provider.billing_policy, BillingPolicy::OpenAi);
+ assert_eq!(
+ provider.base_url.as_deref(),
+ Some("https://api.deepseek.com")
+ );
+ assert_eq!(provider.priority, 75);
+ assert_eq!(provider.auth.as_ref().unwrap().credentials, vec![
+ CredentialRef::Env("DEEPSEEK_API_KEY".to_string()),
+ CredentialRef::Vault("DEEPSEEK_API_KEY".to_string()),
+ ]);
+ assert_eq!(
+ catalog
+ .default_for_provider(&deepseek)
+ .map(|model| model.id.as_str()),
+ Some("deepseek-v4-flash")
+ );
+ assert_eq!(
+ catalog
+ .small_default_for_provider(&deepseek)
+ .map(|model| model.id.as_str()),
+ Some("deepseek-v4-flash")
+ );
+ assert_eq!(
+ catalog
+ .probe_for_provider(&deepseek)
+ .map(|model| model.id.as_str()),
+ Some("deepseek-v4-flash")
+ );
+
+ let expected = [
+ ("deepseek-v4-flash", 0.14, 0.28, 0.0028),
+ ("deepseek-v4-pro", 0.435, 0.87, 0.003_625),
+ ];
+ for (id, input, output, cache_read) in expected {
+ let model = catalog
+ .get_on_provider(&deepseek, id)
+ .unwrap_or_else(|| panic!("DeepSeek model '{id}' should be present"));
+ assert_eq!(model.family, "deepseek-v4", "{id}");
+ assert_eq!(model.limits.context_window, 1_048_576, "{id}");
+ assert_eq!(model.limits.max_output, Some(384_000), "{id}");
+ assert!(model.features.tools, "{id}");
+ assert!(!model.features.vision, "{id}");
+ assert!(model.features.reasoning, "{id}");
+ assert!(model.features.prompt_cache, "{id}");
+ assert!(!model.features.sampling_params, "{id}");
+ assert_eq!(model.costs.input_cost_per_mtok, Some(input), "{id}");
+ assert_eq!(model.costs.output_cost_per_mtok, Some(output), "{id}");
+ assert_eq!(
+ model.costs.cache_input_cost_per_mtok,
+ Some(cache_read),
+ "{id}"
+ );
+
+ let settings = catalog
+ .model_settings_on_provider(&deepseek, id)
+ .unwrap_or_else(|| panic!("DeepSeek settings for '{id}' should be present"));
+ assert_eq!(settings.api_id, id, "{id}");
+ assert!(settings.reasoning_by_default, "{id}");
+ }
+ }
+
+ #[test]
+ fn builtin_deepseek_reasoning_controls_match_provider_dialects() {
+ let catalog = Catalog::from_builtin_with_overrides(&minimal_settings(
+ r"
+[providers.fireworks]
+enabled = true
+
+[providers.openrouter]
+enabled = true
+",
+ ))
+ .expect("DeepSeek gateway providers should build when enabled");
+
+ let expected = [
+ (ProviderId::new("deepseek"), "deepseek-v4-flash", vec![
+ ReasoningEffort::Low,
+ ReasoningEffort::High,
+ ReasoningEffort::Max,
+ ]),
+ (ProviderId::new("deepseek"), "deepseek-v4-pro", vec![
+ ReasoningEffort::High,
+ ReasoningEffort::Max,
+ ]),
+ (ProviderId::new("fireworks"), "deepseek-v4-flash", vec![
+ ReasoningEffort::High,
+ ReasoningEffort::Max,
+ ]),
+ (ProviderId::new("fireworks"), "deepseek-v4-pro", vec![
+ ReasoningEffort::High,
+ ReasoningEffort::Max,
+ ]),
+ (ProviderId::new("openrouter"), "deepseek-v4-flash", vec![
+ ReasoningEffort::Low,
+ ReasoningEffort::High,
+ ReasoningEffort::Max,
+ ]),
+ (ProviderId::new("openrouter"), "deepseek-v4-pro", vec![
+ ReasoningEffort::High,
+ ReasoningEffort::XHigh,
+ ]),
+ ];
+
+ for (provider, id, efforts) in expected {
+ let model = catalog
+ .get_on_provider(&provider, id)
+ .unwrap_or_else(|| panic!("{provider}/{id} should be present"));
+ assert!(model.features.reasoning, "{provider}/{id}");
+ assert_eq!(
+ model.features.reasoning_effort,
+ ReasoningEffortFeature::Levels,
+ "{provider}/{id}"
+ );
+ assert_eq!(model.controls.reasoning_effort, efforts, "{provider}/{id}");
+ assert!(!model.features.sampling_params, "{provider}/{id}");
+
+ let settings = catalog
+ .model_settings_on_provider(&provider, id)
+ .unwrap_or_else(|| panic!("{provider}/{id} settings should be present"));
+ assert_eq!(
+ settings.agent_profile,
+ AgentProfileKind::OpenAi,
+ "{provider}/{id}"
+ );
+ assert!(settings.reasoning_by_default, "{provider}/{id}");
+ assert_eq!(
+ settings.controls.reasoning_effort, efforts,
+ "{provider}/{id}"
+ );
+ }
+ }
+
#[test]
fn builtin_openrouter_provider_is_opt_in() {
let openrouter = ProviderId::new("openrouter");
@@ -3071,6 +3213,27 @@ enabled = true
.billing_policy,
BillingPolicy::OpenAi
);
+ let deepseek = catalog
+ .get_on_provider(&openrouter, "deepseek-v4-flash")
+ .expect("DeepSeek V4 Flash should be present on OpenRouter");
+ assert_eq!(deepseek.limits.max_output, Some(384_000));
+ assert!(deepseek.features.prompt_cache);
+ assert_eq!(deepseek.costs.input_cost_per_mtok, Some(0.14));
+ assert_eq!(deepseek.costs.output_cost_per_mtok, Some(0.28));
+ assert_eq!(deepseek.costs.cache_input_cost_per_mtok, Some(0.0028));
+ assert_eq!(
+ catalog.settings_for(deepseek).unwrap().api_id,
+ "deepseek/deepseek-v4-flash-0731"
+ );
+ let deepseek_pro = catalog
+ .get_on_provider(&openrouter, "deepseek-v4-pro")
+ .expect("DeepSeek V4 Pro should be present on OpenRouter");
+ assert_eq!(deepseek_pro.limits.max_output, Some(384_000));
+ assert!(deepseek_pro.features.prompt_cache);
+ assert_eq!(
+ deepseek_pro.costs.cache_input_cost_per_mtok,
+ Some(0.003_625)
+ );
assert_eq!(
catalog
.default_for_provider(&openrouter)
@@ -3316,8 +3479,8 @@ enabled = true
for (selector, canonical_id) in [
("deepseek-v4-pro", "deepseek-v4-pro"),
- ("deepseek-v4", "deepseek-v4-pro"),
- ("deepseek", "deepseek-v4-pro"),
+ ("deepseek-v4", "deepseek-v4-flash"),
+ ("deepseek", "deepseek-v4-flash"),
("deepseek-v4-flash", "deepseek-v4-flash"),
("deepseek-flash", "deepseek-v4-flash"),
] {
@@ -3877,9 +4040,9 @@ enabled = true
"accounts/fireworks/models/deepseek-v4-flash",
"deepseek-v4",
1_048_576,
- 16_384,
- false,
+ 384_000,
false,
+ true,
0.14,
0.28,
0.028,
@@ -3999,7 +4162,7 @@ enabled = true
}
#[test]
- fn builtin_fireworks_shared_slugs_are_portable_with_openrouter() {
+ fn builtin_deepseek_shared_slugs_are_portable_across_providers() {
let catalog = Catalog::from_builtin_with_overrides(&minimal_settings(
r"
[providers.fireworks]
@@ -4026,6 +4189,43 @@ enabled = true
assert_eq!(model.provider, provider, "{provider}/{id}");
}
}
+
+ for provider in [
+ ProviderId::new("deepseek"),
+ ProviderId::new("fireworks"),
+ ProviderId::new("openrouter"),
+ ] {
+ for id in ["deepseek-v4-pro", "deepseek-v4-flash"] {
+ let model = catalog
+ .get_on_provider(&provider, id)
+ .unwrap_or_else(|| panic!("'{id}' should resolve on provider '{provider}'"));
+ assert_eq!(model.id, id, "{provider}/{id}");
+ assert_eq!(model.provider, provider, "{provider}/{id}");
+ assert_eq!(model.limits.context_window, 1_048_576, "{provider}/{id}");
+ }
+ for alias in ["deepseek", "deepseek-v4", "deepseek-flash"] {
+ let model = catalog
+ .resolve_on_provider(&provider, alias)
+ .unwrap_or_else(|error| {
+ panic!("'{alias}' should resolve on provider '{provider}': {error}")
+ });
+ assert_eq!(model.id, "deepseek-v4-flash", "{provider}/{alias}");
+ assert_eq!(model.provider, provider, "{provider}/{alias}");
+ }
+ }
+
+ let selected = catalog
+ .select(
+ "deepseek",
+ None,
+ &HashSet::from([
+ ProviderId::new("deepseek"),
+ ProviderId::new("fireworks"),
+ ProviderId::new("openrouter"),
+ ]),
+ )
+ .expect("direct DeepSeek should win portable DeepSeek selection");
+ assert_eq!(selected.provider, ProviderId::new("deepseek"));
}
#[test]
diff --git a/lib/foundation/fabro-model/src/catalog/providers/deepseek.toml b/lib/foundation/fabro-model/src/catalog/providers/deepseek.toml
new file mode 100644
index 000000000..e97981178
--- /dev/null
+++ b/lib/foundation/fabro-model/src/catalog/providers/deepseek.toml
@@ -0,0 +1,74 @@
+[providers.deepseek]
+display_name = "DeepSeek"
+adapter = "openai_compatible"
+api_key_url = "https://platform.deepseek.com/api_keys"
+base_url = "https://api.deepseek.com"
+priority = 75
+
+[providers.deepseek.auth]
+credentials = ["env:DEEPSEEK_API_KEY", "vault:DEEPSEEK_API_KEY"]
+
+# DeepSeek V4 uses thinking mode by default. The API accepts sampling
+# parameters in that mode but ignores them, so Fabro omits those parameters.
+# Prompt caching is automatic and usage reports prompt_cache_hit_tokens.
+# Prices are from api-docs.deepseek.com/quick_start/pricing, verified
+# 2026-07-31.
+
+[providers.deepseek.models."deepseek-v4-flash"]
+display_name = "DeepSeek V4 Flash"
+family = "deepseek-v4"
+aliases = ["deepseek-v4", "deepseek", "deepseek-flash"]
+# DeepSeek uses a general coding prompt and standard JSON function tools.
+agent_profile = "openai"
+default = true
+small_default = true
+probe = true
+
+[providers.deepseek.models."deepseek-v4-flash".limits]
+context_window = 1048576
+max_output = 384000
+
+[providers.deepseek.models."deepseek-v4-flash".features]
+tools = true
+vision = false
+reasoning = true
+reasoning_effort = "levels"
+reasoning_by_default = true
+prompt_cache = true
+sampling_params = false
+
+[providers.deepseek.models."deepseek-v4-flash".controls]
+reasoning_effort = ["low", "high", "max"]
+
+[providers.deepseek.models."deepseek-v4-flash".costs]
+input_cost_per_mtok = 0.14
+output_cost_per_mtok = 0.28
+cache_input_cost_per_mtok = 0.0028
+
+[providers.deepseek.models."deepseek-v4-pro"]
+display_name = "DeepSeek V4 Pro"
+family = "deepseek-v4"
+agent_profile = "openai"
+
+[providers.deepseek.models."deepseek-v4-pro".limits]
+context_window = 1048576
+max_output = 384000
+
+[providers.deepseek.models."deepseek-v4-pro".features]
+tools = true
+vision = false
+reasoning = true
+reasoning_effort = "levels"
+reasoning_by_default = true
+prompt_cache = true
+sampling_params = false
+
+[providers.deepseek.models."deepseek-v4-pro".controls]
+# V4 Pro currently maps low to high. Keep only its distinct effort levels;
+# DeepSeek says it plans to change Pro's mapping in early August 2026.
+reasoning_effort = ["high", "max"]
+
+[providers.deepseek.models."deepseek-v4-pro".costs]
+input_cost_per_mtok = 0.435
+output_cost_per_mtok = 0.87
+cache_input_cost_per_mtok = 0.003625
diff --git a/lib/foundation/fabro-model/src/catalog/providers/fireworks.toml b/lib/foundation/fabro-model/src/catalog/providers/fireworks.toml
index 38b2a5231..553fd58ed 100644
--- a/lib/foundation/fabro-model/src/catalog/providers/fireworks.toml
+++ b/lib/foundation/fabro-model/src/catalog/providers/fireworks.toml
@@ -25,7 +25,7 @@ credentials = ["env:FIREWORKS_API_KEY", "vault:FIREWORKS_API_KEY"]
# Prompt caching is automatic prefix caching (no cache_control breakpoints);
# serverless responses report prompt_tokens_details.cached_tokens in the
# usage body. Costs below are from docs.fireworks.ai/serverless/pricing
-# (standard tier), verified 2026-07-24.
+# (standard tier), verified 2026-07-31.
[providers.fireworks.models."kimi-k2.7-code"]
api_id = "accounts/fireworks/models/kimi-k2p7-code"
@@ -72,6 +72,7 @@ cache_input_cost_per_mtok = 0.16
api_id = "accounts/fireworks/models/deepseek-v4-pro"
display_name = "DeepSeek V4 Pro"
family = "deepseek-v4"
+agent_profile = "openai"
[providers.fireworks.models."deepseek-v4-pro".limits]
context_window = 1048576
@@ -81,7 +82,14 @@ max_output = 16384
tools = true
vision = false
reasoning = true
+reasoning_effort = "levels"
+reasoning_by_default = true
prompt_cache = true
+sampling_params = false
+
+[providers.fireworks.models."deepseek-v4-pro".controls]
+# Fireworks promotes low/medium to high and xhigh to max for DeepSeek V4.
+reasoning_effort = ["high", "max"]
[providers.fireworks.models."deepseek-v4-pro".costs]
input_cost_per_mtok = 1.74
@@ -92,16 +100,24 @@ cache_input_cost_per_mtok = 0.145
api_id = "accounts/fireworks/models/deepseek-v4-flash"
display_name = "DeepSeek V4 Flash"
family = "deepseek-v4"
+aliases = ["deepseek-v4", "deepseek", "deepseek-flash"]
+agent_profile = "openai"
[providers.fireworks.models."deepseek-v4-flash".limits]
context_window = 1048576
-max_output = 16384
+max_output = 384000
[providers.fireworks.models."deepseek-v4-flash".features]
tools = true
vision = false
-reasoning = false
+reasoning = true
+reasoning_effort = "levels"
+reasoning_by_default = true
prompt_cache = true
+sampling_params = false
+
+[providers.fireworks.models."deepseek-v4-flash".controls]
+reasoning_effort = ["high", "max"]
[providers.fireworks.models."deepseek-v4-flash".costs]
input_cost_per_mtok = 0.14
diff --git a/lib/foundation/fabro-model/src/catalog/providers/openrouter.toml b/lib/foundation/fabro-model/src/catalog/providers/openrouter.toml
index f57361b0f..d4ea987bb 100644
--- a/lib/foundation/fabro-model/src/catalog/providers/openrouter.toml
+++ b/lib/foundation/fabro-model/src/catalog/providers/openrouter.toml
@@ -416,39 +416,57 @@ output_cost_per_mtok = 1.20
api_id = "deepseek/deepseek-v4-pro"
display_name = "DeepSeek V4 Pro"
family = "deepseek-v4"
-aliases = ["deepseek-v4", "deepseek"]
+agent_profile = "openai"
[providers.openrouter.models."deepseek-v4-pro".limits]
-context_window = 1050000
-max_output = 16384
+context_window = 1048576
+max_output = 384000
[providers.openrouter.models."deepseek-v4-pro".features]
tools = true
vision = false
reasoning = true
+reasoning_effort = "levels"
+reasoning_by_default = true
+prompt_cache = true
+sampling_params = false
+
+[providers.openrouter.models."deepseek-v4-pro".controls]
+# OpenRouter names DeepSeek's max tier xhigh on this route.
+reasoning_effort = ["high", "xhigh"]
[providers.openrouter.models."deepseek-v4-pro".costs]
input_cost_per_mtok = 0.435
output_cost_per_mtok = 0.87
+cache_input_cost_per_mtok = 0.003625
[providers.openrouter.models."deepseek-v4-flash"]
-api_id = "deepseek/deepseek-v4-flash"
+api_id = "deepseek/deepseek-v4-flash-0731"
display_name = "DeepSeek V4 Flash"
family = "deepseek-v4"
-aliases = ["deepseek-flash"]
+aliases = ["deepseek-v4", "deepseek", "deepseek-flash"]
+agent_profile = "openai"
[providers.openrouter.models."deepseek-v4-flash".limits]
-context_window = 1050000
-max_output = 16384
+context_window = 1048576
+max_output = 384000
[providers.openrouter.models."deepseek-v4-flash".features]
tools = true
vision = false
-reasoning = false
+reasoning = true
+reasoning_effort = "levels"
+reasoning_by_default = true
+prompt_cache = true
+sampling_params = false
+
+[providers.openrouter.models."deepseek-v4-flash".controls]
+reasoning_effort = ["low", "high", "max"]
[providers.openrouter.models."deepseek-v4-flash".costs]
-input_cost_per_mtok = 0.10
-output_cost_per_mtok = 0.20
+input_cost_per_mtok = 0.14
+output_cost_per_mtok = 0.28
+cache_input_cost_per_mtok = 0.0028
[providers.openrouter.models."kimi-k2.6"]
api_id = "moonshotai/kimi-k2.6"
diff --git a/lib/foundation/fabro-static/src/env_vars.rs b/lib/foundation/fabro-static/src/env_vars.rs
index 849ec6842..fc5d2312a 100644
--- a/lib/foundation/fabro-static/src/env_vars.rs
+++ b/lib/foundation/fabro-static/src/env_vars.rs
@@ -48,6 +48,7 @@ impl EnvVars {
pub const BEDROCK_API_KEY: &'static str = "BEDROCK_API_KEY";
pub const BRAVE_SEARCH_API_KEY: &'static str = "BRAVE_SEARCH_API_KEY";
pub const CHATGPT_ACCOUNT_ID: &'static str = "CHATGPT_ACCOUNT_ID";
+ pub const DEEPSEEK_API_KEY: &'static str = "DEEPSEEK_API_KEY";
pub const FIREWORKS_API_KEY: &'static str = "FIREWORKS_API_KEY";
pub const GEMINI_API_KEY: &'static str = "GEMINI_API_KEY";
pub const GEMINI_BASE_URL: &'static str = "GEMINI_BASE_URL";
@@ -198,6 +199,7 @@ mod tests {
EnvVars::BEDROCK_API_KEY,
EnvVars::BRAVE_SEARCH_API_KEY,
EnvVars::CHATGPT_ACCOUNT_ID,
+ EnvVars::DEEPSEEK_API_KEY,
EnvVars::FIREWORKS_API_KEY,
EnvVars::GEMINI_API_KEY,
EnvVars::GEMINI_BASE_URL,
diff --git a/lib/foundation/fabro-static/src/secret_registry.rs b/lib/foundation/fabro-static/src/secret_registry.rs
index 03dec8e12..3bfbf92be 100644
--- a/lib/foundation/fabro-static/src/secret_registry.rs
+++ b/lib/foundation/fabro-static/src/secret_registry.rs
@@ -19,6 +19,7 @@ const OPTIONAL_VAULT_SECRETS: &[&str] = &[
EnvVars::AWS_BEARER_TOKEN_BEDROCK,
EnvVars::BEDROCK_API_KEY,
EnvVars::BRAVE_SEARCH_API_KEY,
+ EnvVars::DEEPSEEK_API_KEY,
EnvVars::FABRO_SLACK_APP_TOKEN,
EnvVars::FABRO_SLACK_BOT_TOKEN,
EnvVars::FIREWORKS_API_KEY,
@@ -92,6 +93,7 @@ mod tests {
EnvVars::ANTHROPIC_API_KEY,
EnvVars::AWS_BEARER_TOKEN_BEDROCK,
EnvVars::BEDROCK_API_KEY,
+ EnvVars::DEEPSEEK_API_KEY,
EnvVars::FIREWORKS_API_KEY,
EnvVars::GEMINI_API_KEY,
EnvVars::INCEPTION_API_KEY,