feat(llm): AnthropicProvider real implementation (T10.1/M3.1)
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.
This commit is contained in:
1 parent
3038b9d05d
commit
c3e8bae27f
1 file changed
+598
-23
+598
-23
@@ -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<serde_json::Value> {
|
||||
/// 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<serde_json:
|
||||
}
|
||||
system.push_str(RESPONSE_FORMAT_INSTRUCTIONS);
|
||||
|
||||
(system, user)
|
||||
}
|
||||
|
||||
fn build_messages(prompt: &Prompt, user_system: Option<&str>) -> Vec<serde_json::Value> {
|
||||
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<serde_json::Value> {
|
||||
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, LlmError> {
|
||||
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<String, LlmError> {
|
||||
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<String> {
|
||||
self.credential_ref
|
||||
.as_deref()
|
||||
.and_then(|r| credentials::get(r).ok())
|
||||
}
|
||||
#[cfg(not(feature = "sync"))]
|
||||
fn api_key(&self) -> Option<String> {
|
||||
// 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<AnthropicModel>,
|
||||
}
|
||||
|
||||
#[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<AnthropicDelta>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Default)]
|
||||
struct AnthropicDelta {
|
||||
#[serde(default)]
|
||||
text: Option<String>,
|
||||
}
|
||||
|
||||
/// 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<AnthropicContentBlock>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct AnthropicContentBlock {
|
||||
#[serde(default)]
|
||||
text: Option<String>,
|
||||
}
|
||||
|
||||
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<String, LlmError> {
|
||||
credentials::get(&self.credential_ref)
|
||||
}
|
||||
#[cfg(not(feature = "sync"))]
|
||||
fn api_key(&self) -> Result<String, LlmError> {
|
||||
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::<AnthropicModelsResponse>()
|
||||
.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<Summary, LlmError> {
|
||||
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::<AnthropicStreamEvent>(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<Vec<String>, 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::<Vec<_>>()
|
||||
.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<Summary, LlmError> {
|
||||
Err(LlmError::Request(
|
||||
"Anthropic provider isn't built yet (Phase 10a)".to_string(),
|
||||
))
|
||||
|
||||
async fn summarize(&self, prompt: Prompt, out: TokenSink) -> Result<Summary, LlmError> {
|
||||
let key = self.api_key()?;
|
||||
self.summarize_with_key(&key, prompt, out).await
|
||||
}
|
||||
async fn suggest_tags(&self, _transcript: &str) -> Result<Vec<String>, LlmError> {
|
||||
Err(LlmError::Request(
|
||||
"Anthropic provider isn't built yet (Phase 10a)".to_string(),
|
||||
))
|
||||
|
||||
async fn suggest_tags(&self, transcript: &str) -> Result<Vec<String>, 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<String>,
|
||||
}
|
||||
|
||||
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>) -> 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::<String>();
|
||||
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::<String>();
|
||||
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\
|
||||
|
||||
Reference in new issue
Block a user