diff --git a/CHANGELOG.md b/CHANGELOG.md index 1ab91ec..49329e1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- DeepSeek provider (`https://api.deepseek.com`). + ### Changed - `/local add` auto-detects models via `/models` and provides a searchable picker for model selection. @@ -16,6 +20,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed - Local model overlay background now opaque — no longer shows underlying content. +- Picker search (models, profiles, local models) now matches characters as an ordered subsequence. +- Popup v2 clears before rendering a message such that background text doesn't overlap with popup's message. ## 0.1.4 - 2026-10-4 diff --git a/crates/agent/src/context.rs b/crates/agent/src/context.rs index 66769be..93c2745 100644 --- a/crates/agent/src/context.rs +++ b/crates/agent/src/context.rs @@ -147,21 +147,18 @@ impl AgentMessage { Self::Assistant(message) => { let text = message.text(); let calls: Vec = message.tool_calls().cloned().collect(); - // Reasoning history is never re-sent: it is verbose and the - // model does not need its own chain-of-thought back as input. - // The transcript on disk still preserves it. if calls.is_empty() { Message::assistant_with_reasoning( (!text.is_empty()).then_some(text), - None, - Vec::new(), + message.reasoning.clone(), + message.reasoning_details.clone(), ) } else { Message::assistant_with_tool_calls_and_reasoning( (!text.is_empty()).then_some(text), calls, - None, - Vec::new(), + message.reasoning.clone(), + message.reasoning_details.clone(), ) } } @@ -242,25 +239,4 @@ mod tests { ); assert!(message.tool_calls.is_none()); } - - #[test] - fn plain_assistant_message_strips_reasoning() { - let assistant = LlmResponse { - content: vec![ContentBlock::Text("hello".into())], - stop_reason: StopReason::Stop, - usage: None, - model: None, - reasoning: Some("thought process".into()), - reasoning_details: vec![ - serde_json::json!({"type": "reasoning.text", "text": "thought process"}), - ], - }; - - let message = AgentMessage::Assistant(assistant).to_llm(); - - assert_eq!(message.content.as_deref(), Some("hello")); - assert_eq!(message.reasoning, None); - assert!(message.reasoning_details.is_none()); - assert!(message.tool_calls.is_none()); - } } diff --git a/crates/alan/src/main.rs b/crates/alan/src/main.rs index ac5e4c0..59be857 100644 --- a/crates/alan/src/main.rs +++ b/crates/alan/src/main.rs @@ -22,8 +22,8 @@ use agent::{Agent, SessionManager, ToolPermissionManager, default_tools}; use alan_tui::Runtime; use llm::ReasoningEffort; use providers::{ - FileCredentialStore, LocalProvider, ModelOptions, OpenRouterProvider, Provider, - ProviderRegistry, ZaiProvider, bind_model, + DeepSeekProvider, FileCredentialStore, LocalProvider, ModelOptions, OpenRouterProvider, + Provider, ProviderRegistry, ZaiProvider, bind_model, }; use std::path::PathBuf; use std::sync::Arc; @@ -154,6 +154,7 @@ fn build_providers( // until there really exists some evidence that this is needed, we can add back // Arc::new(GoogleProvider::from_store(Arc::clone(credential_store)).build()?), Arc::new(OpenRouterProvider::from_store(Arc::clone(credential_store)).build()?), + Arc::new(DeepSeekProvider::from_store(Arc::clone(credential_store)).build()?), Arc::clone(local_provider) as Arc, ]) } diff --git a/crates/alan/src/views/components/popup_v2.rs b/crates/alan/src/views/components/popup_v2.rs index bfb889d..89cc655 100644 --- a/crates/alan/src/views/components/popup_v2.rs +++ b/crates/alan/src/views/components/popup_v2.rs @@ -100,6 +100,7 @@ impl PopupListv2 { } if let Some(message) = &self.message { + frame.render_widget(ratatui::widgets::Clear, area); frame.render_widget( Paragraph::new(message.clone()) .style(Style::default().bg(theme::EDITOR_BG)) diff --git a/crates/alan/src/views/components/search_list.rs b/crates/alan/src/views/components/search_list.rs index e82c4a4..0bbe648 100644 --- a/crates/alan/src/views/components/search_list.rs +++ b/crates/alan/src/views/components/search_list.rs @@ -27,6 +27,24 @@ pub struct SearchListOverlay { selected: usize, } +fn is_subsequence(query: &str, item: &str) -> bool { + let query = query.to_lowercase(); + if query.is_empty() { + return true; + } + let mut want = query.chars(); + let mut next = want.next(); + for c in item.to_lowercase().chars() { + if Some(c) == next { + next = want.next(); + if next.is_none() { + return true; + } + } + } + next.is_none() +} + impl SearchListOverlay { pub fn new(title: impl Into, items: Vec) -> Self { let filtered = (0..items.len()).collect(); @@ -50,12 +68,11 @@ impl SearchListOverlay { } fn refresh(&mut self) { - let query = self.query.to_lowercase(); self.filtered = self .items .iter() .enumerate() - .filter(|(_, item)| query.is_empty() || item.to_lowercase().contains(&query)) + .filter(|(_, item)| is_subsequence(&self.query, item)) .map(|(index, _)| index) .collect(); self.selected = self.selected.min(self.filtered.len().saturating_sub(1)); @@ -112,6 +129,14 @@ impl Component for SearchListOverlay { cx.close_overlay(); } } + KeyCode::Backspace if key.modifiers.contains(KeyModifiers::SUPER) => { + self.query.clear(); + self.refresh(); + } + KeyCode::Char('u') if key.modifiers.contains(KeyModifiers::CONTROL) => { + self.query.clear(); + self.refresh(); + } KeyCode::Backspace => { self.query.pop(); self.refresh(); @@ -164,7 +189,7 @@ impl SearchListOverlay { .title_alignment(Alignment::Center) .title_bottom( Line::from(if width >= 60 { - " ↑↓ / C-n C-p move Enter select Esc close " + " ↑↓ / C-n C-p move Enter select C-⌫ clear Esc close " } else if width >= 38 { " ↑↓ move · Enter select · Esc close " } else { diff --git a/crates/llm/src/apis/chat_completions/codec.rs b/crates/llm/src/apis/chat_completions/codec.rs index 7065e36..d9690a4 100644 --- a/crates/llm/src/apis/chat_completions/codec.rs +++ b/crates/llm/src/apis/chat_completions/codec.rs @@ -88,11 +88,6 @@ pub struct WireMessage { pub tool_calls: Option>, #[serde(skip_serializing_if = "Option::is_none")] pub tool_call_id: Option, - #[serde(skip_serializing_if = "Option::is_none")] - /// Always `None`: reasoning history is never re-sent on the wire - /// (OpenAI-compatible APIs reject or mis-handle round-tripped - /// `reasoning_details`). - pub reasoning_details: Option>, } #[derive(Serialize)] @@ -367,7 +362,6 @@ fn wire_message(message: &Message) -> WireMessage { .collect() }), tool_call_id: message.tool_call_id.clone(), - reasoning_details: None, } } @@ -450,7 +444,7 @@ mod tests { } #[test] - fn serializes_reasoning_effort_and_strips_reasoning_details() { + fn serializes_reasoning_effort_and_omits_reasoning_history() { let messages = [Message::assistant_with_tool_calls_and_reasoning( None, vec![crate::ToolCall { @@ -469,7 +463,11 @@ mod tests { serde_json::from_str(&codec().request(&request).unwrap()).unwrap(); assert_eq!(json["reasoning"]["effort"], "high"); assert!( - json["messages"][0].get("reasoning_details").is_none(), + json["messages"][0] + .as_object() + .unwrap() + .keys() + .all(|key| key != "reasoning_details" && key != "reasoning_content"), "reasoning history must not be re-sent" ); } diff --git a/crates/providers/src/deepseek.rs b/crates/providers/src/deepseek.rs new file mode 100644 index 0000000..5acaed1 --- /dev/null +++ b/crates/providers/src/deepseek.rs @@ -0,0 +1,427 @@ +use crate::{ + ApiId, ApiKeyAuth, AuthError, AuthResolver, CredentialAuth, ModelInfo, Provider, ProviderError, + ProviderId, ServerToolInfo, +}; +use async_trait::async_trait; +use llm::apis::chat_completions::{BaseRequest, BaseResponse, decode_stream_response}; +use llm::{ChatCompletionsApi, CodecChunk, HttpClient, LlmApi, LlmApiCodec, LlmError, LlmRequest}; +use reqwest::StatusCode; +use serde::Deserialize; +use std::{collections::HashMap, sync::Arc}; + +const BASE_URL: &str = "https://api.deepseek.com"; + +/// DeepSeek's thinking mode: the model emits a chain of thought before the +/// final answer and, when the request carries tools, requires that reasoning +/// to be sent back on subsequent turns. +struct DeepSeekCodec; + +/// DeepSeek's reasoning-effort mapping from the requested level. +fn wire_reasoning_effort(effort: llm::ReasoningEffort) -> Option<&'static str> { + match effort { + llm::ReasoningEffort::None => None, + llm::ReasoningEffort::Minimal | llm::ReasoningEffort::Low => Some("low"), + llm::ReasoningEffort::Medium | llm::ReasoningEffort::High | llm::ReasoningEffort::XHigh => { + Some("high") + } + llm::ReasoningEffort::Max => Some("max"), + } +} + +impl LlmApiCodec for DeepSeekCodec { + fn request(&self, request: &LlmRequest<'_>) -> Result { + let base = BaseRequest::new(request); + let mut json = serde_json::to_value(&base).map_err(LlmError::Serialization)?; + + // The canonical body uses the OpenAI `reasoning` object and cache + // fields DeepSeek does not support; replace them with DeepSeek's + // thinking toggle and top-level effort. + let effort = wire_reasoning_effort(request.reasoning_effort); + let object = json + .as_object_mut() + .ok_or_else(|| LlmError::InvalidResponse("request body is not a JSON object".into()))?; + object.remove("reasoning"); + object.remove("prompt_cache_key"); + object.remove("cache_control"); + object.insert( + "thinking".into(), + serde_json::json!({"type": if effort.is_some() { "enabled" } else { "disabled" }}), + ); + if let Some(effort) = effort { + object.insert("reasoning_effort".into(), serde_json::json!(effort)); + } + + // Assistant messages may carry reasoning from earlier turns; DeepSeek + // requires it back when tools are present. + if !base.tools.is_empty() { + for (wire, message) in object + .get_mut("messages") + .and_then(serde_json::Value::as_array_mut) + .ok_or_else(|| LlmError::InvalidResponse("messages must be an array".into()))? + .iter_mut() + .zip(request.messages) + { + if wire["role"] != "assistant" { + continue; + } + if let Some(reasoning) = &message.reasoning + && !reasoning.is_empty() + { + wire["reasoning_content"] = serde_json::Value::String(reasoning.clone()); + } + } + } + + serde_json::to_string(&json).map_err(LlmError::Serialization) + } + + fn response(&self, data: &str) -> Result { + let chunk: DeepSeekStreamChunk = + serde_json::from_str(data).map_err(LlmError::Serialization)?; + // DeepSeek reports cache hits as flat fields next to the standard + // usage object; the canonical `WireUsage` ignores unknown fields. + let cached_tokens = chunk + .usage + .as_ref() + .and_then(|usage| usage.get("prompt_cache_hit_tokens")) + .and_then(serde_json::Value::as_u64); + + let usage = chunk + .usage + .map(Deserialize::deserialize) + .transpose() + .map_err(LlmError::Serialization)?; + + let mut decoded = decode_stream_response(&BaseResponse { + model: chunk.model, + choices: chunk.choices, + usage, + })?; + + if let (Some(cached), Some(usage)) = (cached_tokens, &mut decoded.usage) { + usage.cached_tokens = Some(cached); + } + Ok(decoded) + } +} + +#[derive(Deserialize)] +struct DeepSeekStreamChunk { + model: Option, + choices: Vec, + usage: Option, +} + +pub struct DeepSeekProvider { + id: ProviderId, + models: std::sync::RwLock>, + apis: HashMap>, + auth: Arc, + fetch_lock: tokio::sync::Mutex<()>, +} + +impl DeepSeekProvider { + pub fn builder(api_key: impl Into) -> DeepSeekBuilder { + DeepSeekBuilder { + api_key: api_key.into(), + models: Vec::new(), + api: None, + auth: None, + } + } + + pub fn from_store(store: Arc) -> DeepSeekBuilder { + DeepSeekBuilder::from_auth(Arc::new(CredentialAuth::new( + ProviderId::new("deepseek"), + store, + Some("DEEPSEEK_API_KEY"), + ))) + } +} + +async fn validate_api_key(key: &str) -> Result<(), AuthError> { + let response = reqwest::Client::new() + .get(format!("{BASE_URL}/models")) + .bearer_auth(key) + .send() + .await + .map_err(|error| AuthError::Validation(format!("request failed: {error}")))?; + + if response.status() == StatusCode::UNAUTHORIZED { + return Err(AuthError::Validation("API key was rejected".into())); + } + if !response.status().is_success() { + return Err(AuthError::Validation(format!( + "DeepSeek returned HTTP {}", + response.status() + ))); + } + Ok(()) +} + +#[async_trait] +impl Provider for DeepSeekProvider { + fn id(&self) -> ProviderId { + self.id.clone() + } + + fn models(&self) -> Vec { + self.models + .read() + .unwrap_or_else(|e| e.into_inner()) + .clone() + } + + fn server_tools(&self) -> &[ServerToolInfo] { + &[] + } + + fn auth_methods(&self) -> Vec { + vec![crate::auth::AuthMethod::ApiKey] + } + + fn apis(&self) -> &HashMap> { + &self.apis + } + + fn auth(&self) -> Arc { + self.auth.clone() + } + + async fn validate_auth( + &self, + auth_result: &crate::auth::AuthResult, + ) -> Result<(), crate::auth::AuthError> { + match auth_result { + crate::auth::AuthResult::ApiKey(key) => validate_api_key(key).await, + } + } + + async fn fetch_models(&self) -> Result<(), ProviderError> { + let _guard = self.fetch_lock.lock().await; + let credential = self + .auth + .resolve() + .await + .map_err(|error| ProviderError::Fetch(format!("authentication failed: {error}")))?; + let mut request = reqwest::Client::new().get(format!("{BASE_URL}/models")); + if let llm::Credential::ApiKey(key) = credential { + request = request.bearer_auth(key); + } + let response = request + .send() + .await + .map_err(|error| ProviderError::Fetch(format!("request failed: {error}")))?; + let status = response.status(); + if !status.is_success() { + return Err(ProviderError::Fetch(format!( + "DeepSeek returned HTTP {status}" + ))); + } + let payload = response + .text() + .await + .map_err(|error| ProviderError::Fetch(format!("request failed: {error}")))?; + let models = parse_catalog(&self.id, &payload)?; + *self.models.write().unwrap_or_else(|e| e.into_inner()) = models; + Ok(()) + } +} + +pub struct DeepSeekBuilder { + api_key: String, + models: Vec, + api: Option>, + auth: Option>, +} + +impl DeepSeekBuilder { + fn from_auth(auth: Arc) -> Self { + Self { + api_key: String::new(), + models: Vec::new(), + api: None, + auth: Some(auth), + } + } + + pub fn with_models(mut self, models: impl IntoIterator) -> Self { + self.models.extend(models); + self + } + + pub fn with_api(mut self, api: Arc) -> Self { + self.api = Some(api); + self + } + + pub fn with_auth(mut self, auth: Arc) -> Self { + self.auth = Some(auth); + self + } + + pub fn build(self) -> Result { + let api = self.api.unwrap_or_else(|| { + Arc::new( + ChatCompletionsApi::new(BASE_URL, Arc::new(HttpClient::new())) + .with_codec(Arc::new(DeepSeekCodec)), + ) + }); + let auth = self + .auth + .unwrap_or_else(|| Arc::new(ApiKeyAuth::new(self.api_key))); + Ok(DeepSeekProvider { + id: ProviderId::new("deepseek"), + models: std::sync::RwLock::new(self.models), + apis: HashMap::from([(ApiId::ChatCompletions, api)]), + auth, + fetch_lock: tokio::sync::Mutex::new(()), + }) + } +} + +fn parse_catalog(provider: &ProviderId, payload: &str) -> Result, ProviderError> { + let catalog: CatalogResponse = + serde_json::from_str(payload).map_err(|error| ProviderError::Fetch(error.to_string()))?; + let mut models = Vec::new(); + for entry in catalog.data { + let name = entry.name.unwrap_or_else(|| entry.id.clone()); + models.push(ModelInfo { + provider: provider.clone(), + id: entry.id, + name, + pricing: None, + context_length: entry.context_window, + }); + } + Ok(models) +} + +#[derive(Deserialize)] +struct CatalogResponse { + data: Vec, +} + +#[derive(Deserialize)] +struct CatalogModel { + id: String, + name: Option, + context_window: Option, +} + +#[cfg(test)] +mod codec_tests { + use super::*; + use llm::{Extensions, Message, ReasoningEffort, RequestOptions, ToolDefinition, ToolSpec}; + + fn wire_request( + effort: ReasoningEffort, + messages: Vec, + tools: &[ToolSpec], + ) -> serde_json::Value { + let options = RequestOptions::default(); + let request = LlmRequest { + model_id: "deepseek-flash", + messages: &messages, + tools, + options: &options, + credential: None, + reasoning_effort: effort, + extensions: Extensions::default(), + }; + serde_json::from_str(&DeepSeekCodec.request(&request).unwrap()).unwrap() + } + + #[test] + fn maps_thinking_and_effort_per_level() { + for (effort, thinking, wire_effort) in [ + (ReasoningEffort::None, "disabled", None), + (ReasoningEffort::Low, "enabled", Some("low")), + (ReasoningEffort::Medium, "enabled", Some("high")), + (ReasoningEffort::Max, "enabled", Some("max")), + ] { + let json = wire_request(effort, vec![Message::user("hi")], &[]); + assert_eq!(json["thinking"]["type"], thinking); + match wire_effort { + Some(level) => assert_eq!(json["reasoning_effort"], level), + None => assert!(json.get("reasoning_effort").is_none()), + } + assert!(json.get("reasoning").is_none(), "canonical field removed"); + assert!( + json.get("prompt_cache_key").is_none(), + "DeepSeek has no prompt_cache_key" + ); + } + } + + #[test] + fn sends_reasoning_content_back_only_with_tools() { + let messages = vec![Message::assistant_with_reasoning( + Some("answer".into()), + Some("chain of thought".into()), + Vec::new(), + )]; + let with_tools = wire_request( + ReasoningEffort::High, + messages.clone(), + &[ToolSpec::Function(ToolDefinition { + name: "bash".into(), + description: "run".into(), + parameters: serde_json::json!({"type": "object"}), + })], + ); + assert_eq!( + with_tools["messages"][0]["reasoning_content"], + "chain of thought" + ); + + let without_tools = wire_request(ReasoningEffort::High, messages, &[]); + assert!( + without_tools["messages"][0] + .get("reasoning_content") + .is_none() + ); + } + + #[test] + fn fills_cached_tokens_from_cache_hit_field() { + let chunk = DeepSeekCodec + .response( + r#"{"choices":[],"usage":{"prompt_tokens":20,"completion_tokens":5, + "prompt_cache_hit_tokens":14,"prompt_cache_miss_tokens":6}}"#, + ) + .unwrap(); + let usage = chunk.usage.unwrap(); + assert_eq!(usage.input_tokens, 20); + assert_eq!(usage.cached_tokens, Some(14)); + } +} + +#[cfg(test)] +mod catalog_tests { + use super::*; + + const SAMPLE: &str = r#"{"object": "list", "data": [{ + "id": "deepseek-flash", "object": "model", "owned_by": "deepseek", + "name": "DeepSeek-V4.1-Flash", + "context_window": 1048576, "max_output_tokens": 393216, + "input_modalities": ["text", "image"], "output_modalities": ["text"], + "effort": {"supported_levels": ["low", "high", "max"], "default_level": "high"} + }]}"#; + + #[test] + fn parses_sample_catalog_entry() { + let models = parse_catalog(&ProviderId::new("deepseek"), SAMPLE).unwrap(); + assert_eq!(models.len(), 1); + assert_eq!(models[0].id, "deepseek-flash"); + assert_eq!(models[0].name, "DeepSeek-V4.1-Flash"); + assert_eq!(models[0].context_length, Some(1048576)); + assert!(models[0].pricing.is_none()); + assert_eq!(models[0].provider, ProviderId::new("deepseek")); + } + + #[test] + fn rejects_invalid_payload() { + assert!(parse_catalog(&ProviderId::new("deepseek"), "not json").is_err()); + } +} diff --git a/crates/providers/src/lib.rs b/crates/providers/src/lib.rs index 1db6286..4e4cfb5 100644 --- a/crates/providers/src/lib.rs +++ b/crates/providers/src/lib.rs @@ -1,6 +1,7 @@ mod auth; mod catalog; mod credentials; +mod deepseek; mod google; mod local; mod model; @@ -9,6 +10,8 @@ mod provider; mod store; mod zai; +pub use deepseek::{DeepSeekBuilder, DeepSeekProvider}; + pub use auth::{ ApiKeyAuth, AuthError, AuthMethod, AuthResolver, AuthResult, CredentialAuth, NoAuth, };