From c3e8bae27f4eac9f5f990851c3ccb2021edb94f2 Mon Sep 17 00:00:00 2001 From: iamdoubz <> Date: Tue, 7 Jul 2026 07:47:03 -0500 Subject: [PATCH 01/12] feat(llm): AnthropicProvider real implementation (T10.1/M3.1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements AnthropicProvider::status/summarize/suggest_tags against the real Anthropic Messages API (POST /v1/messages, x-api-key + anthropic- version headers, SSE streaming for summarize, non-streaming for the short tag reply), replacing the stub that returned "isn't built yet". - Adds llm::credentials (mirrors sync::credentials) so hosted API keys live in the OS credential store, never settings/DB/logs (ADR-0011). - Fixes OpenAiCompatProvider::api_key() to actually read credential_ref from the store instead of the hardcoded None left by the T10a.2 stub. - Splits build_messages/build_tag_messages into system/user halves (build_system_and_user/tag_system_and_user) since Anthropic's system prompt is a top-level field, not a messages[0] entry like OpenAI's shape. - is_local() is unconditionally false for AnthropicProvider (no unauthenticated mode, unlike OpenAiCompatProvider). - Adds a loopback raw-socket mock HTTP server (mirrors sync::oauth's LoopbackRedirect pattern — no HTTP-mock crate in the dependency tree) and tests proving request/response shape for both AnthropicProvider and OpenAiCompatProvider against mocked endpoints. --- src-tauri/src/llm/mod.rs | 621 +++++++++++++++++++++++++++++++++++++-- 1 file changed, 598 insertions(+), 23 deletions(-) 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\ From e7925fc3adbb7549ba5f1cd5299677973542f546 Mon Sep 17 00:00:00 2001 From: iamdoubz <> Date: Tue, 7 Jul 2026 07:47:57 -0500 Subject: [PATCH 02/12] feat(llm): add hosted_ai_acknowledged settings field (T10.3/M3.3) One-time "data leaves your device" acknowledgment flag for hosted (non-local) AI providers, persisted like consent_acknowledged so the banner doesn't nag on every use. serde(default) so it's false for any settings.json written before this field existed. --- src-tauri/src/models.rs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src-tauri/src/models.rs b/src-tauri/src/models.rs index 7ba4a31..4b2a417 100644 --- a/src-tauri/src/models.rs +++ b/src-tauri/src/models.rs @@ -202,6 +202,13 @@ pub struct Settings { // Recording retention (ADR-0009). Default OFF. pub default_record: bool, pub consent_acknowledged: bool, + /// One-time "data leaves your device" acknowledgment for hosted + /// (non-local) AI providers — Anthropic, or a hosted OpenAI-compatible + /// gateway (ADR-0011, T10.3/M3.3). Shown once before first hosted use; + /// this flag is what makes it not nag every time. Independent of + /// `consent_acknowledged` (that one's specifically about recording law). + #[serde(default)] + pub hosted_ai_acknowledged: bool, // Sync master switch (ADR-0010). Default OFF. Target rows live in the DB; secrets in OS keychain. pub sync_enabled: bool, // Storage retention policy (FR-STORE-2). None = no cap on that dimension. From d73af49742a9fbea3ed4ca797dc1bd7bc4efce35 Mon Sep 17 00:00:00 2001 From: iamdoubz <> Date: Tue, 7 Jul 2026 07:50:15 -0500 Subject: [PATCH 03/12] feat(llm): set_llm_provider stores Anthropic key in credential store, extends egress allowlist (T10.2/M3.2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit set_llm_provider now accepts provider "anthropic": the apiKey argument is written straight to crate::llm::credentials (OS credential store) and never assigned into Settings, so it structurally cannot reach settings.json/wa.db (FR-SEC-1). apply_llm_provider_args pulls the settings-mutation logic into a small pure function specifically so that guarantee is unit-testable without touching a real keyring. llm_provider_from_settings gets an "anthropic" arm; api.anthropic.com joins the settings-derived egress allowlist through the exact same generic is_local()-based path privacy_self_check_json already uses for every other provider — no anthropic-specific allowlist code, and the host only appears once the provider is actually selected (never unconditionally). Also fixes pre-existing cargo-fmt drift on one unrelated line this file's formatter pass touched (CONTENT_RANGE header call). --- src-tauri/src/commands.rs | 159 +++++++++++++++++++++++++++++++++----- 1 file changed, 139 insertions(+), 20 deletions(-) diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 0b6cede..bfbda16 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -57,6 +57,7 @@ fn default_settings() -> Settings { low_overhead: false, default_record: false, consent_acknowledged: false, + hosted_ai_acknowledged: false, sync_enabled: false, retention_max_age_days: None, retention_max_size_gb: None, @@ -1735,7 +1736,10 @@ pub(crate) fn serve_recording( if let Some((start, end)) = parse_byte_range(range, total) { return base() .status(StatusCode::PARTIAL_CONTENT) - .header(header::CONTENT_RANGE, format!("bytes {start}-{end}/{total}")) + .header( + header::CONTENT_RANGE, + format!("bytes {start}-{end}/{total}"), + ) .header(header::CONTENT_LENGTH, (end - start + 1).to_string()) .body(plain[start..=end].to_vec()) .unwrap_or_else(|_| fail(StatusCode::INTERNAL_SERVER_ERROR)); @@ -1964,6 +1968,18 @@ fn llm_provider_from_settings(settings: &Settings) -> Option Some(Box::new(crate::llm::AnthropicProvider { + model: settings.llm_model.clone(), + credential_ref: crate::llm::ANTHROPIC_CREDENTIAL_REF.to_string(), + endpoint: settings.llm_endpoint.clone(), + })), _ => None, // "off" } } @@ -1986,34 +2002,79 @@ pub async fn llm_status() -> WaResult { } #[derive(Deserialize)] +#[serde(rename_all = "camelCase")] pub struct SetLlmProviderArgs { - pub provider: String, // "ollama" | "custom" | "off" (Phase 10a adds "anthropic" | "openai") + pub provider: String, // "ollama" | "custom" | "anthropic" | "off" ("openai" not yet wired — M3 scope is Anthropic) pub endpoint: Option, pub model: Option, + /// Hosted providers only (Anthropic, M3.2). Consumed here to write the + /// OS credential store and then dropped — never assigned into `Settings`, + /// so it structurally cannot reach settings.json/wa.db (ADR-0011, + /// FR-SEC-1). Optional on every call so re-saving the model/endpoint + /// doesn't force re-entering a key that's already stored. + pub api_key: Option, } -/// Select/configure the LLM provider (T5.2, FR-LLM-1). Hosted providers -/// (`apiKey`, stored only in the OS credential store — never settings/DB) -/// land in Phase 10a. -#[tauri::command] -pub async fn set_llm_provider(args: SetLlmProviderArgs) -> WaResult<()> { - if !matches!(args.provider.as_str(), "ollama" | "custom" | "off") { - return Err(WaError::new( - "llm", - format!( - "provider '{}' is not available yet — hosted providers land in Phase 10a", - args.provider - ), - )); - } - let mut settings = load_settings(); - settings.llm_provider = args.provider; - if let Some(endpoint) = args.endpoint { +/// Pure settings mutation for `set_llm_provider` — everything except the +/// credential-store write, so it's unit-testable without touching a real +/// keyring and so the "the API key never reaches `Settings`" guarantee is +/// checkable structurally (this function's signature has no key parameter +/// at all; see `set_llm_provider_never_writes_the_api_key_into_settings`). +/// Anthropic's endpoint is fixed, not user-configurable (ADR-0011) — any +/// `endpoint` argument is ignored for that provider so a stale endpoint left +/// over from a previous "custom"/"ollama" selection can't leak through. +fn apply_llm_provider_args( + settings: &mut Settings, + provider: &str, + endpoint: Option, + model: Option, +) { + settings.llm_provider = provider.to_string(); + if provider == "anthropic" { + settings.llm_endpoint = "https://api.anthropic.com".to_string(); + } else if let Some(endpoint) = endpoint { settings.llm_endpoint = endpoint; } - if let Some(model) = args.model { + if let Some(model) = model { settings.llm_model = model; } +} + +/// Select/configure the LLM provider (T5.2/T10.2, FR-LLM-1, ADR-0011). +/// Hosted providers (`apiKey`) are stored only in the OS credential store — +/// never settings/DB (FR-SEC-1); selecting "anthropic" here is itself what +/// puts `api.anthropic.com` on the settings-derived egress allowlist (see +/// `privacy_self_check_json`), so it must never happen implicitly. +#[tauri::command] +pub async fn set_llm_provider(args: SetLlmProviderArgs) -> WaResult<()> { + if !matches!( + args.provider.as_str(), + "ollama" | "custom" | "anthropic" | "off" + ) { + return Err(WaError::new( + "llm", + format!("provider '{}' is not available yet", args.provider), + )); + } + if args.provider == "anthropic" { + match args.api_key.as_deref().map(str::trim) { + Some(key) if !key.is_empty() => { + crate::llm::credentials::set(crate::llm::ANTHROPIC_CREDENTIAL_REF, key) + .map_err(|e| WaError::new("llm", e.to_string()))?; + } + // No new key supplied — fine only if one was already stored from + // a previous call (e.g. the user is just changing the model). + _ if crate::llm::credentials::get(crate::llm::ANTHROPIC_CREDENTIAL_REF).is_err() => { + return Err(WaError::new( + "llm", + "an Anthropic API key is required".to_string(), + )); + } + _ => {} + } + } + let mut settings = load_settings(); + apply_llm_provider_args(&mut settings, &args.provider, args.endpoint, args.model); save_settings(&settings) } @@ -3465,6 +3526,64 @@ mod tests { ); } + #[test] + fn privacy_self_check_anthropic_host_joins_the_allowlist_only_once_selected() { + // Off by default (ADR-0011) — merely *knowing about* Anthropic (the + // provider code exists) must not put its host on the allowlist. + let result = privacy_self_check_json(&default_settings(), Vec::new()); + assert_eq!(result["allowlistedHosts"], serde_json::json!([])); + + // Only once the user has actually selected it (mirrors what + // set_llm_provider/apply_llm_provider_args writes) does the same + // generic llm_provider_from_settings + is_local() path used for every + // other provider pick it up — no anthropic-specific allowlist code. + let mut settings = default_settings(); + settings.llm_provider = "anthropic".to_string(); + settings.llm_endpoint = "https://api.anthropic.com".to_string(); + let result = privacy_self_check_json(&settings, Vec::new()); + assert_eq!(result["llmIsLocal"], false); + assert_eq!( + result["allowlistedHosts"], + serde_json::json!(["api.anthropic.com"]) + ); + } + + #[test] + fn set_llm_provider_never_writes_the_api_key_into_settings() { + // apply_llm_provider_args has no key parameter at all — this proves + // structurally (not just by inspection) that a supplied apiKey can + // never end up in the Settings struct that gets serialized to + // settings.json (FR-SEC-1, ADR-0011). The real key only ever reaches + // crate::llm::credentials::set, called separately in + // set_llm_provider before this function runs. + let mut settings = default_settings(); + apply_llm_provider_args( + &mut settings, + "anthropic", + Some("https://attacker-controlled.example/ignored".to_string()), + Some("claude-3-5-sonnet-latest".to_string()), + ); + assert_eq!(settings.llm_provider, "anthropic"); + // The fixed hosted endpoint wins over any caller-supplied one. + assert_eq!(settings.llm_endpoint, "https://api.anthropic.com"); + assert_eq!(settings.llm_model, "claude-3-5-sonnet-latest"); + + let json = serde_json::to_string(&settings).expect("settings must serialize"); + assert!(!json.to_lowercase().contains("api_key")); + assert!(!json.to_lowercase().contains("apikey")); + assert!(!json.contains("sk-ant-")); + } + + #[test] + fn apply_llm_provider_args_keeps_the_existing_endpoint_for_non_anthropic_providers() { + let mut settings = default_settings(); + settings.llm_endpoint = "http://192.168.0.42:11434".to_string(); + apply_llm_provider_args(&mut settings, "ollama", None, Some("llama3.1".to_string())); + assert_eq!(settings.llm_provider, "ollama"); + assert_eq!(settings.llm_endpoint, "http://192.168.0.42:11434"); + assert_eq!(settings.llm_model, "llama3.1"); + } + #[test] fn privacy_self_check_only_enabled_sync_targets_join_the_allowlist() { let mut settings = default_settings(); From 11b175dafa9127757d7802a8cc809e4f024bb9af Mon Sep 17 00:00:00 2001 From: iamdoubz <> Date: Tue, 7 Jul 2026 07:52:11 -0500 Subject: [PATCH 04/12] feat(llm): add hosted_ai_acknowledged to AppSettings (T10.3/M3.3) Frontend counterpart of the new Settings field: the one-time "data leaves your device" acknowledgment for hosted (non-local) AI providers. --- src/lib/api.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/lib/api.ts b/src/lib/api.ts index bd734b6..e7c234d 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -57,7 +57,7 @@ export interface AudioDeviceInfo { } export interface LlmStatus { - provider: string; // ollama|custom|off (Phase 10a adds anthropic|openai) + provider: string; // ollama|custom|anthropic|off (ADR-0011; "openai" not yet wired) reachable: boolean; isLocal: boolean; models: string[]; @@ -287,6 +287,10 @@ export interface AppSettings { low_overhead: boolean; default_record: boolean; consent_acknowledged: boolean; + /** One-time "data leaves your device" ack for hosted (non-local) AI + * providers — Anthropic today (ADR-0011, T10.3). Independent of + * consent_acknowledged (that one's about recording law). */ + hosted_ai_acknowledged: boolean; sync_enabled: boolean; mcp_enabled: boolean; retention_max_age_days: number | null; From 3cdb0abb2de8e59d2b323ee98757a0aa432d07a9 Mon Sep 17 00:00:00 2001 From: iamdoubz <> Date: Tue, 7 Jul 2026 07:52:18 -0500 Subject: [PATCH 05/12] feat(llm): wire apiKey through setLlmProvider, add acknowledgeHostedAi (T10.2/T10.3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit settings.svelte.ts's setLlmProvider now accepts an optional apiKey (forwarded straight to the set_llm_provider command, which is the only place it gets stored — the OS credential store) and DEFAULT_SETTINGS gains hosted_ai_acknowledged. acknowledgeHostedAi() persists the one-time hosted-AI banner acknowledgment the same way acknowledgeConsent() does for recording consent. --- src/lib/stores/settings.svelte.ts | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/src/lib/stores/settings.svelte.ts b/src/lib/stores/settings.svelte.ts index bd3b0e9..0cb1840 100644 --- a/src/lib/stores/settings.svelte.ts +++ b/src/lib/stores/settings.svelte.ts @@ -28,6 +28,7 @@ const DEFAULT_SETTINGS: AppSettings = { low_overhead: false, default_record: false, // recording OFF by default (ADR-0009) consent_acknowledged: false, + hosted_ai_acknowledged: false, // hosted-AI "leaves your device" notice (ADR-0011) sync_enabled: false, // sync OFF by default (ADR-0010) mcp_enabled: false, // MCP server OFF by default (ADR-0011) retention_max_age_days: null, // no cap by default (FR-STORE-2) @@ -183,8 +184,16 @@ class SettingsStore { } } - /** Persist the LLM provider/endpoint/model and refresh status (T5.2). */ - async setLlmProvider(config: { provider: string; endpoint?: string; model?: string }) { + /** Persist the LLM provider/endpoint/model (+ hosted apiKey, ADR-0011) and + * refresh status (T5.2/T10.2). The key is only ever sent to the backend + * command (which stores it in the OS credential store) — never held here + * beyond this call, and never merged into `this.settings`. */ + async setLlmProvider(config: { + provider: string; + endpoint?: string; + model?: string; + apiKey?: string; + }) { this.llmSaving = true; // Optimistic local update so the form reflects the change immediately. this.settings = { @@ -202,6 +211,13 @@ class SettingsStore { } } + /** Persist the one-time hosted-AI "leaves your device" acknowledgment + * (ADR-0011, T10.3) — same generic patch() every other boolean setting + * here uses (see setDefaultRecord below). */ + acknowledgeHostedAi() { + return this.patch({ hosted_ai_acknowledged: true }); + } + async setPreferredBackend(backend: AppSettings["preferred_backend"]) { await this.patch({ preferred_backend: backend }); await this.loadHardware(); From 754f0f0b6a23e51ff673dc1635c44b1ed89b0921 Mon Sep 17 00:00:00 2001 From: iamdoubz <> Date: Tue, 7 Jul 2026 07:52:25 -0500 Subject: [PATCH 06/12] feat(ui): HostedAiBanner one-time hosted-AI notice component (T10.3/M3.3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New component mirroring ConsentNotice.svelte's card/overlay pattern: shown before first use of a hosted (non-local) provider, explains that the transcript leaves the device to a third party, and offers accept/cancel. Uses the existing design tokens (--accent, --warning, --bg-elevated, --radius-lg/sm, --shadow-lg) so it matches the recording-consent notice visually. Not yet wired into any view — next commits add it to Settings and SummaryPanel. --- src/lib/components/HostedAiBanner.svelte | 94 ++++++++++++++++++++++++ 1 file changed, 94 insertions(+) create mode 100644 src/lib/components/HostedAiBanner.svelte diff --git a/src/lib/components/HostedAiBanner.svelte b/src/lib/components/HostedAiBanner.svelte new file mode 100644 index 0000000..381cc65 --- /dev/null +++ b/src/lib/components/HostedAiBanner.svelte @@ -0,0 +1,94 @@ + + + + + From 0a597f86f0b1e7715a5734382e7b0908ab904204 Mon Sep 17 00:00:00 2001 From: iamdoubz <> Date: Tue, 7 Jul 2026 07:54:21 -0500 Subject: [PATCH 07/12] feat(ui): Anthropic provider + hosted-AI banner in Settings AI section (T10.1/T10.3/M3.3) Adds "Anthropic (Claude)" to the provider select, a write-only masked API key field (show/hide toggle) shown in place of the endpoint field for that provider (Anthropic's endpoint is fixed server-side), and a persistent "leaves this device" note. Save is gated behind HostedAiBanner the first time the user selects a hosted provider (Anthropic, or "custom" once its endpoint resolves off-network) and hasn't acknowledged hosted_ai_acknowledged yet; accepting persists the ack and completes the save in one step. --- src/lib/views/Settings.svelte | 143 ++++++++++++++++++++++++++++++---- 1 file changed, 130 insertions(+), 13 deletions(-) diff --git a/src/lib/views/Settings.svelte b/src/lib/views/Settings.svelte index a559ea3..c5b952d 100644 --- a/src/lib/views/Settings.svelte +++ b/src/lib/views/Settings.svelte @@ -6,6 +6,7 @@ import { settings } from "../stores/settings.svelte"; import { calendar } from "../stores/calendar.svelte"; import ConsentNotice from "../components/ConsentNotice.svelte"; + import HostedAiBanner from "../components/HostedAiBanner.svelte"; import { open } from "@tauri-apps/plugin-dialog"; import { api, errorMessage, events } from "../api"; import type { BackendId, SyncKind, SyncTargetConfig, SyncTargetInfo } from "../api"; @@ -25,6 +26,9 @@ ChevronRight, RotateCcw, Info, + Eye, + EyeOff, + Globe, } from "@lucide/svelte"; import { OLLAMA_OPTIONS, @@ -44,22 +48,57 @@ const SOURCE_URL = "https://git.dou.bet/iamdoubz/WhispAssist"; let appInfo = $state<{ version: string; commit: string } | null>(null); - // ---- AI summary provider (T5.2, FR-LLM-1) ---- + // ---- AI summary provider (T5.2/T10.1/T10.2, FR-LLM-1, ADR-0011) ---- let llmProvider = $state(settings.settings.llm_provider); let llmEndpoint = $state(settings.settings.llm_endpoint); let llmModel = $state(settings.settings.llm_model); + // Hosted (Anthropic) API key — never read back from the backend (it's + // never returned by llm_status/get_settings, FR-SEC-1); this is a + // write-only field that's blank unless the user is actively (re)typing + // one, and is dropped from memory the moment saveLlm() sends it. + let llmApiKey = $state(""); + let showApiKey = $state(false); $effect(() => { // Re-sync the form when settings (re)load or are saved elsewhere. llmProvider = settings.settings.llm_provider; llmEndpoint = settings.settings.llm_endpoint; llmModel = settings.settings.llm_model; }); + + const HOSTED_PROVIDER_LABELS: Record = { anthropic: "Anthropic (Claude)" }; + + /** True when the *currently selected* provider is hosted (leaves the + * device): Anthropic always is; "custom" is hosted only once its endpoint + * resolves off-network — mirrors the live warning already shown below. */ + function isHostedProviderSelection(): boolean { + if (llmProvider === "anthropic") return true; + if (llmProvider === "custom" && llmEndpoint) return !endpointIsLocalOrLan(llmEndpoint); + return false; + } + + // ---- Hosted-AI "leaves your device" one-time banner (T10.3/M3.3) ---- + let showHostedBanner = $state(false); + function onSaveLlmClick() { + if (isHostedProviderSelection() && !settings.settings.hosted_ai_acknowledged) { + showHostedBanner = true; + return; + } + saveLlm(); + } + async function acceptHostedBannerAndSave() { + await settings.acknowledgeHostedAi(); + showHostedBanner = false; + await saveLlm(); + } + async function saveLlm() { await settings.setLlmProvider({ provider: llmProvider, - endpoint: llmEndpoint || undefined, + endpoint: llmProvider === "anthropic" ? undefined : llmEndpoint || undefined, model: llmModel || undefined, + apiKey: llmApiKey.trim() || undefined, }); + llmApiKey = ""; // never linger in memory once sent } /** Client-side mirror of the backend's loopback+private-LAN check, for a live * "leaves your network" hint while typing. */ @@ -986,8 +1025,10 @@

AI summary provider

- Summaries run on a local LLM. Point this at Ollama on this PC or another machine on your - LAN (e.g. 192.168.0.x) — both count as local, so nothing leaves your network. + Summaries run on a local LLM by default. Point this at Ollama on this PC or another + machine on your LAN (e.g. 192.168.0.x) — both count as local, so nothing + leaves your network. Hosted providers (Anthropic) are optional, off by default, and send + the transcript to a third party once you turn one on.

{#if llmProvider !== "off"}
- - + {#if llmProvider !== "anthropic"} + + + {:else} + + + {/if}
- {#if llmEndpoint && !endpointIsLocalOrLan(llmEndpoint)} + {#if llmProvider === "anthropic"} + + {:else if llmEndpoint && !endpointIsLocalOrLan(llmEndpoint)} {/if}
-
+ {#if showHostedBanner} + (showHostedBanner = false)} + /> + {/if} {#if settings.llmStatus} {@const s = settings.llmStatus}
@@ -1518,6 +1603,38 @@ margin-top: 0.15rem; color: var(--warning); } + /* Informational (not a warning-severity) variant for "this is hosted, by + your own choice" notices — Anthropic's fixed endpoint (ADR-0011). */ + .banner.hosted { + background: color-mix(in srgb, var(--accent) 12%, var(--bg)); + } + .banner.hosted :global(svg) { + color: var(--accent); + } + .key-input { + display: flex; + align-items: center; + gap: 0.3rem; + } + .key-input input { + flex: 1; + min-width: 0; + } + .icon-toggle { + display: flex; + align-items: center; + justify-content: center; + background: none; + border: 1px solid var(--border); + border-radius: 5px; + padding: 0.35rem; + color: var(--muted); + cursor: pointer; + } + .icon-toggle:hover { + background: var(--bg-hover); + color: var(--fg); + } ul.targets, ul.models, ul.events { From b986c570f791d9a40ef3b35f79e95949ee0816be Mon Sep 17 00:00:00 2001 From: iamdoubz <> Date: Tue, 7 Jul 2026 07:56:09 -0500 Subject: [PATCH 08/12] fix(llm): reset stale hosted endpoint when quick-switching away from Anthropic apply_llm_provider_args now resets llm_endpoint to Ollama's local default when switching to a non-Anthropic provider without an explicit endpoint and the stored endpoint is still Anthropic's fixed hosted URL. Prevents a future per-use provider switch (SummaryPanel) that only sends `provider` from silently leaving llm_endpoint pointed at a third party for a provider that has no business talking to it. --- src-tauri/src/commands.rs | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index bfbda16..63168fb 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -2023,6 +2023,12 @@ pub struct SetLlmProviderArgs { /// Anthropic's endpoint is fixed, not user-configurable (ADR-0011) — any /// `endpoint` argument is ignored for that provider so a stale endpoint left /// over from a previous "custom"/"ollama" selection can't leak through. +/// Symmetrically, switching *away* from Anthropic without supplying a new +/// endpoint resets to Ollama's own local default rather than silently +/// keeping its fixed hosted URL around — otherwise a quick per-use provider +/// switch (SummaryPanel, M3.3) that only sends `provider` could leave +/// `llm_endpoint` pointed at a third party for a provider that has no +/// business talking to it. fn apply_llm_provider_args( settings: &mut Settings, provider: &str, @@ -2034,6 +2040,8 @@ fn apply_llm_provider_args( settings.llm_endpoint = "https://api.anthropic.com".to_string(); } else if let Some(endpoint) = endpoint { settings.llm_endpoint = endpoint; + } else if settings.llm_endpoint == "https://api.anthropic.com" { + settings.llm_endpoint = "http://localhost:11434".to_string(); } if let Some(model) = model { settings.llm_model = model; @@ -3584,6 +3592,21 @@ mod tests { assert_eq!(settings.llm_model, "llama3.1"); } + #[test] + fn apply_llm_provider_args_resets_endpoint_when_switching_away_from_anthropics_fixed_url() { + // A per-use provider quick-switch (SummaryPanel, M3.3) only sends + // `provider`, relying on whatever endpoint/model were last saved — + // if that was Anthropic's fixed URL, switching to ollama must not + // silently keep pointing at a third party. + let mut settings = default_settings(); + settings.llm_provider = "anthropic".to_string(); + settings.llm_endpoint = "https://api.anthropic.com".to_string(); + + apply_llm_provider_args(&mut settings, "ollama", None, None); + assert_eq!(settings.llm_provider, "ollama"); + assert_eq!(settings.llm_endpoint, "http://localhost:11434"); + } + #[test] fn privacy_self_check_only_enabled_sync_targets_join_the_allowlist() { let mut settings = default_settings(); From a211f88ad4ab7ec0f175ef2fa5055d4365022dfb Mon Sep 17 00:00:00 2001 From: iamdoubz <> Date: Tue, 7 Jul 2026 07:59:57 -0500 Subject: [PATCH 09/12] feat(ui): active-provider indicator + per-use quick switch in SummaryPanel (T10.3/M3.3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a provider row above Summary (dropdown mirroring Settings' AI options + a local/hosted badge sourced from llm_status) so it's always visible which provider a summary/tag generation will actually use. Generate/Regenerate and the quick switch itself route hosted selections (Anthropic, or "custom" once its endpoint resolves off-network) through the same HostedAiBanner one-time acknowledgment gate as Settings — the generate-click gate is the one that actually matters, since selecting a provider alone sends nothing; it's kept even though the quick-switch already pre-empts Anthropic specifically. Also drops a stale svelte-ignore comment (pre-existing, unrelated to this change — eslint-plugin-svelte no longer flags that element) that was failing `eslint .` for this file. --- src/lib/views/SummaryPanel.svelte | 156 ++++++++++++++++++++++++++++-- 1 file changed, 150 insertions(+), 6 deletions(-) diff --git a/src/lib/views/SummaryPanel.svelte b/src/lib/views/SummaryPanel.svelte index 9d71653..daeed28 100644 --- a/src/lib/views/SummaryPanel.svelte +++ b/src/lib/views/SummaryPanel.svelte @@ -13,6 +13,7 @@ } from "../api"; import { renderMarkdown } from "../markdown"; import TagChip from "../components/TagChip.svelte"; + import HostedAiBanner from "../components/HostedAiBanner.svelte"; import { Tags, Sparkles, @@ -22,6 +23,8 @@ Mic2, Bell, UploadCloud, + Cpu, + Globe, } from "@lucide/svelte"; onMount(() => calendar.load()); @@ -48,19 +51,93 @@ // ---- Summary + action items (T5.4/T5.5/T5.6, FR-LLM-2/3/4) ---- let llmStatus = $state(null); - onMount(async () => { + async function refreshLlmStatus() { try { llmStatus = await api.llmStatus(); } catch { llmStatus = null; } - }); + } + onMount(refreshLlmStatus); function generateSummary() { const m = meetings.selected; if (m) meetings.generateSummary(m.id); } + // ---- Active provider indicator + per-use quick switch (T10.3/M3.3) ---- + // Same four providers Settings' AI section offers; switching here reuses + // the exact same set_llm_provider command (and hence the same "anthropic's + // endpoint is fixed" / "switching away resets a stale hosted endpoint" + // guarantees — see apply_llm_provider_args in commands.rs). + const PROVIDER_LABELS: Record = { + off: "Off", + ollama: "Ollama", + custom: "Custom", + anthropic: "Anthropic (Claude)", + }; + function isHostedStatus(status: LlmStatus | null): boolean { + return !!status && status.provider !== "off" && !status.isLocal; + } + let switchingProvider = $state(false); + + // ---- Hosted-AI "leaves your device" one-time gate (T10.3/M3.3) — shared + // by the quick switch (switching TO Anthropic) and Generate/Regenerate + // (the actual point a transcript would leave the device, so this is the + // gate that matters even if the quick switch's own check is skipped, e.g. + // "custom" resolving to a hosted endpoint that was configured elsewhere). + let showHostedBanner = $state(false); + let hostedBannerLabel = $state("this hosted provider"); + let pendingHostedAction = $state<(() => void) | null>(null); + function requireHostedAck(providerLabel: string, action: () => void) { + if (!settings.settings.hosted_ai_acknowledged) { + hostedBannerLabel = providerLabel; + pendingHostedAction = action; + showHostedBanner = true; + return; + } + action(); + } + async function acceptHostedBanner() { + await settings.acknowledgeHostedAi(); + showHostedBanner = false; + const action = pendingHostedAction; + pendingHostedAction = null; + action?.(); + } + function cancelHostedBanner() { + showHostedBanner = false; + pendingHostedAction = null; + } + + async function switchProvider(provider: string) { + switchingProvider = true; + try { + await settings.setLlmProvider({ provider }); + await refreshLlmStatus(); + } finally { + switchingProvider = false; + } + } + function onProviderChange(e: Event) { + const next = (e.target as HTMLSelectElement).value; + if (next === "anthropic") { + // Deterministically hosted — worth confirming before even switching to + // it, not just before the next generate. + requireHostedAck(PROVIDER_LABELS[next], () => void switchProvider(next)); + } else { + void switchProvider(next); + } + } + function onGenerateClick() { + const label = llmStatus ? (PROVIDER_LABELS[llmStatus.provider] ?? llmStatus.provider) : ""; + if (isHostedStatus(llmStatus)) { + requireHostedAck(label, generateSummary); + } else { + generateSummary(); + } + } + // Editable copy so toggling a checkbox doesn't persist until "Save" is // pressed; recomputes whenever a different meeting (or a freshly generated // summary) comes in. bind:checked mutates each item in place, which a plain @@ -256,7 +333,8 @@ {#if meetings.selected?.recorded}

{#if audioSrc} - +