diff --git a/src-tauri/src/llm/mod.rs b/src-tauri/src/llm/mod.rs index ecda5f0..c0862a6 100644 --- a/src-tauri/src/llm/mod.rs +++ b/src-tauri/src/llm/mod.rs @@ -61,7 +61,12 @@ const RESPONSE_FORMAT_INSTRUCTIONS: &str = "Respond in Markdown with exactly thr are bullet lists (each line starting with \"- \"); write \"- None\" if a section has nothing \ to report. Do not add any other top-level sections."; -fn build_messages(prompt: &Prompt, user_system: Option<&str>) -> Vec { +/// Splits the assembled prompt into its `(system, user)` halves. Shared by +/// every provider: OpenAI-shaped chat APIs (Ollama, OpenAI-compat) fold both +/// into a `messages` array via `build_messages` below; Anthropic's Messages +/// API (ADR-0011) takes `system` as its own top-level field instead, so it +/// calls this directly. +fn build_system_and_user(prompt: &Prompt, user_system: Option<&str>) -> (String, String) { let mut user = String::new(); if !prompt.metadata.is_empty() { user.push_str(&prompt.metadata); @@ -84,6 +89,11 @@ fn build_messages(prompt: &Prompt, user_system: Option<&str>) -> Vec) -> Vec { + let (system, user) = build_system_and_user(prompt, user_system); vec![ serde_json::json!({ "role": "system", "content": system }), serde_json::json!({ "role": "user", "content": user }), @@ -175,10 +185,20 @@ const TAG_INSTRUCTIONS: &str = "You generate short topical tags for a meeting tr explanation, no quotes. Each tag: lowercase, 1-3 words, hyphenated instead of spaces (e.g. \ \"budget-review\" not \"budget review\")."; +/// `(system, user)` halves for the tag-suggestion prompt — see +/// `build_system_and_user` above for why Anthropic needs these split out. +fn tag_system_and_user(transcript: &str) -> (String, String) { + ( + TAG_INSTRUCTIONS.to_string(), + format!("Transcript:\n{transcript}"), + ) +} + fn build_tag_messages(transcript: &str) -> Vec { + let (system, user) = tag_system_and_user(transcript); vec![ - serde_json::json!({ "role": "system", "content": TAG_INSTRUCTIONS }), - serde_json::json!({ "role": "user", "content": format!("Transcript:\n{transcript}") }), + serde_json::json!({ "role": "system", "content": system }), + serde_json::json!({ "role": "user", "content": user }), ] } @@ -443,6 +463,51 @@ impl LlmProvider for OllamaProvider { } } +/// Hosted-provider API keys (Anthropic, and a hosted OpenAI-compatible +/// gateway once `credential_ref` is set on `OpenAiCompatProvider`) live in +/// the OS credential store, keyed by `credential_ref` — never in +/// settings.json/wa.db/logs (ADR-0011, FR-SEC-1). Mirrors +/// `sync::credentials`, with its own service name so LLM keys and sync +/// secrets don't share a keyring namespace. +#[cfg(feature = "sync")] +pub mod credentials { + use super::LlmError; + + const SERVICE: &str = "WhispAssist-llm"; + + fn entry(credential_ref: &str) -> Result { + keyring::Entry::new(SERVICE, credential_ref) + .map_err(|e| LlmError::Unreachable(format!("credential store: {e}"))) + } + + pub fn set(credential_ref: &str, secret: &str) -> Result<(), LlmError> { + entry(credential_ref)? + .set_password(secret) + .map_err(|e| LlmError::Unreachable(format!("credential store: {e}"))) + } + + pub fn get(credential_ref: &str) -> Result { + entry(credential_ref)? + .get_password() + .map_err(|e| LlmError::Unreachable(format!("credential store: {e}"))) + } + + pub fn delete(credential_ref: &str) -> Result<(), LlmError> { + // Missing entry is fine — deletion is best-effort cleanup. + match entry(credential_ref)?.delete_credential() { + Ok(()) | Err(keyring::Error::NoEntry) => Ok(()), + Err(e) => Err(LlmError::Unreachable(format!("credential store: {e}"))), + } + } +} + +/// Fixed credential-store key for the Anthropic API key (ADR-0011). Only one +/// hosted Anthropic configuration is supported at a time (single `llm_*` +/// settings fields, like every other provider here), so a deterministic ref — +/// rather than a per-target uuid like sync's — is enough; nothing to +/// disambiguate. +pub const ANTHROPIC_CREDENTIAL_REF: &str = "wa-llm-anthropic"; + // ---- OpenAI-compatible endpoint (Phase 5's "custom" option; Phase 10a reuses ---- // this for hosted providers by setting `credential_ref`, ADR-0011.) @@ -491,10 +556,16 @@ impl OpenAiCompatProvider { } /// Reads the API key from the OS credential store (never settings/DB — - /// FR-SEC-1). `None` for local, unauthenticated custom endpoints. + /// FR-SEC-1). `None` for local, unauthenticated custom endpoints (no + /// `credential_ref` at all) or if the store lookup fails. + #[cfg(feature = "sync")] + fn api_key(&self) -> Option { + self.credential_ref + .as_deref() + .and_then(|r| credentials::get(r).ok()) + } + #[cfg(not(feature = "sync"))] fn api_key(&self) -> Option { - // T10a.2: `keyring` lookup by `credential_ref` lands with hosted - // providers; a local custom endpoint has no `credential_ref` at all. None } @@ -727,34 +798,250 @@ pub async fn pull_model( .await } -/// Anthropic Messages API (`/v1/messages`) — native shape, NOT OpenAI-compatible. +/// Anthropic Messages API version header (ADR-0011) — Anthropic's REST API is +/// versioned by header, not URL path. +const ANTHROPIC_VERSION: &str = "2023-06-01"; + +/// `max_tokens` is required by the Messages API and WA has no per-meeting +/// tuning surface for it yet; a fixed generous cap is enough for a summary or +/// a short tag list (T10a.1 — revisit if a real need for a larger cap shows up). +const ANTHROPIC_MAX_TOKENS: u32 = 4096; + +/// Anthropic Messages API (`/v1/messages`) — native shape, NOT OpenAI- +/// compatible (ADR-0011): `system` is a top-level field rather than a +/// `messages[0]` entry, auth is `x-api-key` (not `Authorization: Bearer`), +/// and it needs the `anthropic-version` header. Always hosted — `is_local()` +/// is unconditionally `false` (unlike `OpenAiCompatProvider`, there is no +/// unauthenticated/local mode for this API). pub struct AnthropicProvider { pub model: String, pub credential_ref: String, + /// `https://api.anthropic.com` in production; overridable so tests can + /// point this at a local mock server. + pub endpoint: String, +} + +#[derive(Deserialize)] +struct AnthropicModelsResponse { + #[serde(default)] + data: Vec, +} + +#[derive(Deserialize)] +struct AnthropicModel { + id: String, +} + +/// One line of a Messages API SSE stream, e.g. +/// `data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"Hi"}}`. +/// Only `content_block_delta` events carry text; every other event type +/// (`message_start`, `content_block_start/stop`, `message_delta`, +/// `message_stop`, `ping`) is ignored except to detect the stream's end. +#[derive(Deserialize)] +struct AnthropicStreamEvent { + #[serde(rename = "type")] + kind: String, + #[serde(default)] + delta: Option, +} + +#[derive(Deserialize, Default)] +struct AnthropicDelta { + #[serde(default)] + text: Option, +} + +/// Non-streaming Messages API reply (used for `suggest_tags`, whose short +/// reply doesn't need token-by-token streaming — same call shape Ollama uses). +#[derive(Deserialize)] +struct AnthropicMessageResponse { + #[serde(default)] + content: Vec, +} + +#[derive(Deserialize)] +struct AnthropicContentBlock { + #[serde(default)] + text: Option, +} + +impl AnthropicProvider { + fn base(&self) -> &str { + self.endpoint.trim_end_matches('/') + } + + /// Reads the API key from the OS credential store (never settings/DB — + /// FR-SEC-1, ADR-0011). Unlike `OpenAiCompatProvider` this always fails + /// closed (`Err`, not a silent `None`) — Anthropic has no unauthenticated + /// mode, so a missing key means the request simply cannot be made. + #[cfg(feature = "sync")] + fn api_key(&self) -> Result { + credentials::get(&self.credential_ref) + } + #[cfg(not(feature = "sync"))] + fn api_key(&self) -> Result { + Err(LlmError::Unreachable( + "credential store unavailable (built without the \"sync\" feature)".to_string(), + )) + } + + fn auth(&self, req: reqwest::RequestBuilder, key: &str) -> reqwest::RequestBuilder { + req.header("x-api-key", key) + .header("anthropic-version", ANTHROPIC_VERSION) + } + + /// `status()`'s actual HTTP call, taking an already-resolved key — split + /// out so tests can exercise the request/response shape against a mock + /// server with a fixed key, without needing that key to actually exist + /// in the real OS credential store (the store lookup itself is a thin, + /// already-trusted `keyring` wrapper — see `credentials` above — not + /// worth re-proving with an HTTP mock). + async fn status_with_key(&self, key: &str) -> LlmStatus { + let req = self.auth( + reqwest::Client::new().get(format!("{}/v1/models", self.base())), + key, + ); + let models = match req.send().await { + Ok(resp) if resp.status().is_success() => resp + .json::() + .await + .map(|r| r.data.into_iter().map(|m| m.id).collect()) + .unwrap_or_default(), + _ => { + return LlmStatus { + provider: "anthropic".to_string(), + reachable: false, + is_local: false, + models: Vec::new(), + } + } + }; + LlmStatus { + provider: "anthropic".to_string(), + reachable: true, + is_local: false, + models, + } + } + + /// `summarize()`'s actual HTTP call — see `status_with_key`. + async fn summarize_with_key( + &self, + key: &str, + prompt: Prompt, + out: TokenSink, + ) -> Result { + let (system, user) = build_system_and_user(&prompt, None); + let body = serde_json::json!({ + "model": self.model, + "max_tokens": ANTHROPIC_MAX_TOKENS, + "system": system, + "messages": [{ "role": "user", "content": user }], + "stream": true, + }); + let req = self.auth( + reqwest::Client::new() + .post(format!("{}/v1/messages", self.base())) + .json(&body), + key, + ); + let resp = req + .send() + .await + .map_err(|e| LlmError::Unreachable(e.to_string()))?; + if !resp.status().is_success() { + return Err(LlmError::Request(format!("HTTP {}", resp.status()))); + } + + let mut full_text = String::new(); + stream_lines(resp, |line| { + let Some(payload) = line.strip_prefix("data:") else { + return true; // "event: ..." lines and blanks — not JSON, skip + }; + let payload = payload.trim(); + let Ok(evt) = serde_json::from_str::(payload) else { + return true; // ignore an unparseable line, keep reading + }; + if evt.kind == "content_block_delta" { + if let Some(text) = evt.delta.and_then(|d| d.text) { + if !text.is_empty() { + full_text.push_str(&text); + let _ = out.send(text); + } + } + } + evt.kind != "message_stop" + }) + .await?; + + Ok(parse_summary(&full_text)) + } + + /// `suggest_tags()`'s actual HTTP call — see `status_with_key`. + async fn suggest_tags_with_key( + &self, + key: &str, + transcript: &str, + ) -> Result, LlmError> { + let (system, user) = tag_system_and_user(transcript); + let body = serde_json::json!({ + "model": self.model, + "max_tokens": ANTHROPIC_MAX_TOKENS, + "system": system, + "messages": [{ "role": "user", "content": user }], + "stream": false, + }); + let req = self.auth( + reqwest::Client::new() + .post(format!("{}/v1/messages", self.base())) + .json(&body), + key, + ); + let resp = req + .send() + .await + .map_err(|e| LlmError::Unreachable(e.to_string()))?; + if !resp.status().is_success() { + return Err(LlmError::Request(format!("HTTP {}", resp.status()))); + } + let parsed: AnthropicMessageResponse = resp + .json() + .await + .map_err(|e| LlmError::Request(e.to_string()))?; + let text: String = parsed + .content + .into_iter() + .filter_map(|b| b.text) + .collect::>() + .join(""); + Ok(parse_tags(&text)) + } } #[async_trait] impl LlmProvider for AnthropicProvider { async fn status(&self) -> LlmStatus { - // Not built yet (Phase 10a) and not reachable today: `set_llm_provider` - // rejects "anthropic" before settings could ever select this provider. - LlmStatus { - provider: "anthropic".to_string(), - reachable: false, - is_local: false, - models: Vec::new(), - } + let Ok(key) = self.api_key() else { + return LlmStatus { + provider: "anthropic".to_string(), + reachable: false, + is_local: false, + models: Vec::new(), + }; + }; + self.status_with_key(&key).await } - async fn summarize(&self, _prompt: Prompt, _out: TokenSink) -> Result { - Err(LlmError::Request( - "Anthropic provider isn't built yet (Phase 10a)".to_string(), - )) + + async fn summarize(&self, prompt: Prompt, out: TokenSink) -> Result { + let key = self.api_key()?; + self.summarize_with_key(&key, prompt, out).await } - async fn suggest_tags(&self, _transcript: &str) -> Result, LlmError> { - Err(LlmError::Request( - "Anthropic provider isn't built yet (Phase 10a)".to_string(), - )) + + async fn suggest_tags(&self, transcript: &str) -> Result, LlmError> { + let key = self.api_key()?; + self.suggest_tags_with_key(&key, transcript).await } + fn is_local(&self) -> bool { false } @@ -764,6 +1051,294 @@ impl LlmProvider for AnthropicProvider { mod tests { use super::*; + /// Minimal single-request HTTP mock: binds loopback on an OS-chosen port, + /// accepts one connection on a background thread, hands the raw request + /// bytes back over a channel, and writes `response` (a full raw + /// `"HTTP/1.1 ..."` response) to the socket. Mirrors the blocking-loopback + /// pattern `sync::oauth::LoopbackRedirect` already uses for a similar + /// reason (no HTTP mocking crate in the dependency tree) — good enough + /// for "one provider call, one reply" test shapes, which is all + /// `summarize`/`suggest_tags`/`status` ever do. + struct MockServer { + port: u16, + captured: std::sync::mpsc::Receiver, + } + + impl MockServer { + fn start(response: &'static str) -> Self { + let listener = + std::net::TcpListener::bind("127.0.0.1:0").expect("bind a loopback test port"); + let port = listener.local_addr().expect("local_addr").port(); + let (tx, rx) = std::sync::mpsc::channel(); + std::thread::spawn(move || { + use std::io::{Read, Write}; + if let Ok((mut stream, _)) = listener.accept() { + // A POST's headers and JSON body aren't guaranteed to + // arrive in a single `read()` (separate writev calls can + // land as separate TCP segments even on loopback), so + // accumulate reads until the client goes quiet rather + // than trusting one read to have the whole request. + let _ = stream.set_read_timeout(Some(std::time::Duration::from_millis(300))); + let mut request = Vec::new(); + let mut buf = [0u8; 4096]; + loop { + match stream.read(&mut buf) { + Ok(0) | Err(_) => break, // EOF or read-timeout: client is done sending + Ok(n) => request.extend_from_slice(&buf[..n]), + } + } + let _ = tx.send(String::from_utf8_lossy(&request).to_string()); + let _ = stream.write_all(response.as_bytes()); + let _ = stream.flush(); + } + }); + Self { port, captured: rx } + } + + fn base_url(&self) -> String { + format!("http://127.0.0.1:{}", self.port) + } + + /// The raw request the mock received (headers + body), for asserting + /// shape (path, auth header, JSON body fields). + fn request(&self) -> String { + self.captured + .recv_timeout(std::time::Duration::from_secs(5)) + .unwrap_or_default() + } + } + + fn http_ok(content_type: &str, body: &str) -> String { + format!( + "HTTP/1.1 200 OK\r\nContent-Type: {content_type}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", + body.len() + ) + } + + fn drain_tokens(rx: &std::sync::mpsc::Receiver) -> String { + let mut out = String::new(); + while let Ok(tok) = rx.try_recv() { + out.push_str(&tok); + } + out + } + + // ---- AnthropicProvider (M3.1/M3.2, ADR-0011) ---- + + fn anthropic_provider(endpoint: String) -> AnthropicProvider { + AnthropicProvider { + model: "claude-3-5-sonnet-latest".to_string(), + credential_ref: "test-anthropic-key".to_string(), + endpoint, + } + } + + #[tokio::test] + async fn anthropic_summarize_streams_tokens_and_hits_v1_messages_with_auth_headers() { + let sse = "event: message_start\n\ + data: {\"type\":\"message_start\"}\n\n\ + event: content_block_delta\n\ + data: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"## Summary\\n\"}}\n\n\ + event: content_block_delta\n\ + data: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"Shipped M3.\\n\\n## Decisions\\n- None\\n\\n## Action Items\\n- None\\n\"}}\n\n\ + event: message_stop\n\ + data: {\"type\":\"message_stop\"}\n\n"; + let response = format!( + "HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", + sse.len(), + sse + ); + let response: &'static str = Box::leak(response.into_boxed_str()); + let server = MockServer::start(response); + let provider = anthropic_provider(server.base_url()); + + let (tx, rx) = std::sync::mpsc::channel::(); + let prompt = Prompt { + transcript: "Alice: let's ship it.".to_string(), + metadata: "Meeting: M3 review".to_string(), + template: None, + }; + let summary = provider + .summarize_with_key("test-anthropic-key", prompt, tx) + .await + .expect("summarize should succeed against the mock"); + + let request = server.request(); + assert!(request.starts_with("POST /v1/messages"), "{request}"); + assert!( + request.contains("x-api-key: test-anthropic-key"), + "{request}" + ); + assert!( + request.contains(&format!("anthropic-version: {ANTHROPIC_VERSION}")), + "{request}" + ); + assert!( + request.contains("\"model\":\"claude-3-5-sonnet-latest\""), + "{request}" + ); + assert!(request.contains("\"stream\":true"), "{request}"); + // Anthropic's own shape: system is a top-level field, not messages[0]. + assert!(request.contains("\"system\":"), "{request}"); + assert!(!request.contains("\"role\":\"system\""), "{request}"); + + assert_eq!(summary.summary_md, "Shipped M3."); + assert_eq!( + drain_tokens(&rx), + "## Summary\nShipped M3.\n\n## Decisions\n- None\n\n## Action Items\n- None\n" + ); + } + + #[tokio::test] + async fn anthropic_suggest_tags_parses_the_non_streaming_reply() { + let body = r#"{"id":"msg_1","type":"message","role":"assistant","content":[{"type":"text","text":"golang, webrtc, dtls"}],"model":"claude-3-5-sonnet-latest","stop_reason":"end_turn"}"#; + let response = http_ok("application/json", body); + let response: &'static str = Box::leak(response.into_boxed_str()); + let server = MockServer::start(response); + let provider = anthropic_provider(server.base_url()); + + let tags = provider + .suggest_tags_with_key( + "test-anthropic-key", + "Alice: let's talk webrtc and dtls in golang.", + ) + .await + .expect("suggest_tags should succeed against the mock"); + + let request = server.request(); + assert!(request.starts_with("POST /v1/messages"), "{request}"); + assert!(request.contains("\"stream\":false"), "{request}"); + assert_eq!(tags, vec!["golang", "webrtc", "dtls"]); + } + + #[tokio::test] + async fn anthropic_status_reports_reachable_and_models_from_v1_models() { + let body = r#"{"data":[{"type":"model","id":"claude-3-5-sonnet-latest","display_name":"Claude 3.5 Sonnet"}],"has_more":false}"#; + let response = http_ok("application/json", body); + let response: &'static str = Box::leak(response.into_boxed_str()); + let server = MockServer::start(response); + let provider = anthropic_provider(server.base_url()); + + let status = provider.status_with_key("test-anthropic-key").await; + let request = server.request(); + assert!(request.starts_with("GET /v1/models"), "{request}"); + assert!( + request.contains("x-api-key: test-anthropic-key"), + "{request}" + ); + assert!(status.reachable); + assert!(!status.is_local); + assert_eq!(status.provider, "anthropic"); + assert_eq!(status.models, vec!["claude-3-5-sonnet-latest"]); + } + + #[test] + fn anthropic_provider_is_never_local() { + // Unlike OpenAiCompatProvider (which is local until a credential_ref + // is set), Anthropic has no unauthenticated/local mode at all. + let provider = anthropic_provider("http://127.0.0.1:1".to_string()); + assert!(!provider.is_local()); + } + + #[tokio::test] + async fn anthropic_status_fails_closed_when_no_credential_is_stored() { + // Exercises the real trait-level status() (not status_with_key), i.e. + // the actual credential-store lookup path — a fresh/unused + // credential_ref should never be present, so this proves the "no key + // => unreachable, no request attempted" behavior without needing to + // seed the real OS credential store from a test. + let provider = AnthropicProvider { + model: "claude-3-5-sonnet-latest".to_string(), + credential_ref: "wa-llm-anthropic-test-missing-credential-do-not-create".to_string(), + endpoint: "http://127.0.0.1:1".to_string(), // would refuse the connection if ever hit + }; + let status = provider.status().await; + assert!(!status.reachable); + assert!(!status.is_local); + assert!(status.models.is_empty()); + } + + // ---- OpenAiCompatProvider (already-scaffolded reference; adding the ---- + // mock-server coverage M3's acceptance criteria calls for, which didn't + // exist yet — see openai_compat_provider_is_not_local_once_a_credential_is_set + // above for the pre-existing pure-logic test.) + + #[tokio::test] + async fn openai_compat_summarize_streams_sse_tokens_from_v1_chat_completions() { + let sse = "data: {\"choices\":[{\"delta\":{\"content\":\"## Summary\\n\"}}]}\n\n\ + data: {\"choices\":[{\"delta\":{\"content\":\"All good.\\n\\n## Decisions\\n- None\\n\\n## Action Items\\n- None\\n\"}}]}\n\n\ + data: [DONE]\n\n"; + let response = format!( + "HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", + sse.len(), + sse + ); + let response: &'static str = Box::leak(response.into_boxed_str()); + let server = MockServer::start(response); + let provider = OpenAiCompatProvider { + endpoint: server.base_url(), + model: "gpt-4o-mini".to_string(), + credential_ref: None, + }; + + let (tx, rx) = std::sync::mpsc::channel::(); + let prompt = Prompt { + transcript: "Alice: let's ship it.".to_string(), + metadata: "Meeting: M3 review".to_string(), + template: None, + }; + let summary = provider + .summarize(prompt, tx) + .await + .expect("summarize should succeed against the mock"); + + let request = server.request(); + assert!( + request.starts_with("POST /v1/chat/completions"), + "{request}" + ); + assert!(request.contains("\"model\":\"gpt-4o-mini\""), "{request}"); + assert!(request.contains("\"role\":\"system\""), "{request}"); // OpenAI shape: system IS a message + assert_eq!(summary.summary_md, "All good."); + assert_eq!( + drain_tokens(&rx), + "## Summary\nAll good.\n\n## Decisions\n- None\n\n## Action Items\n- None\n" + ); + } + + #[tokio::test] + async fn openai_compat_provider_with_unset_credential_sends_no_auth_header() { + let sse = "data: [DONE]\n\n"; + let response = format!( + "HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", + sse.len(), + sse + ); + let response: &'static str = Box::leak(response.into_boxed_str()); + let server = MockServer::start(response); + // `credential_ref: None` is the local/unauthenticated custom-endpoint + // case (ADR-0007) — no lookup happens at all, so this asserts the + // request truly carries no Authorization header, unlike the + // `Some(ref)` hosted case above. + let provider = OpenAiCompatProvider { + endpoint: server.base_url(), + model: "gpt-4o-mini".to_string(), + credential_ref: None, + }; + assert!(provider.is_local()); + + let _ = provider.suggest_tags("hello").await; + let request = server.request(); + assert!( + request.starts_with("POST /v1/chat/completions"), + "{request}" + ); + assert!( + !request.to_lowercase().contains("authorization:"), + "{request}" + ); + } + #[test] fn parse_summary_splits_the_three_requested_sections() { let text = "## Summary\nWe discussed the roadmap.\nIt went well.\n\n\