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:
iamdoubz committed 2026-07-07 07:47:03 -05:00
1 parent 3038b9d05d
commit c3e8bae27f
1 file changed
+598 -23
+598 -23
View File
@@ -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\