lakehouse/auditor 1 blocking issue: todo!() macro call in tests/real-world/scrum_master_pipeline.ts
Modern OpenAI clients (pi-ai, openai SDK 6.x, langchain-js, the official
agents) send `messages[].content` as an array of content parts:
`[{type:"text", text:"..."}, {type:"image_url", ...}]`. Our gateway
typed `content` as plain `String` and 422'd those calls.
Fix: `Message.content` is now `serde_json::Value` so requests
deserialize regardless of shape. `Message::text()` flattens
content-parts arrays (concat'd `text` fields, non-text parts skipped)
for places that need a plain string — Ollama prompt assembly, char
counts, the assistant's own response synthesis. `Message::new_text()`
constructs string-content messages without writing the wrapper at
each call site. Forwarders (openrouter) clone content through
verbatim so providers see exactly what the client sent.
Verified end-to-end: Pi CLI (`pi --print --provider openrouter`)
landed a clean 1902-token request through `/v1/chat/completions`,
routed to OpenRouter as `openai/gpt-oss-120b:free`, response in
1.62s, Langfuse trace `v1.chat:openrouter` recorded with provider
tag. Same path that any tool using the official openai SDK takes.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
223 lines
7.0 KiB
Rust
223 lines
7.0 KiB
Rust
//! Claude (Anthropic) adapter.
|
|
//!
|
|
//! POST `https://api.anthropic.com/v1/messages`. Auth via `x-api-key`
|
|
//! header (not bearer) + required `anthropic-version` header. Payload
|
|
//! is NOT OpenAI-compatible — response text lives at
|
|
//! `content[0].text`. Phase 40 deliverable. System prompts travel in
|
|
//! a top-level `system` field, separate from the `messages` array.
|
|
|
|
use std::time::Duration;
|
|
use serde::{Deserialize, Serialize};
|
|
|
|
use super::{ChatRequest, ChatResponse, Choice, Message, UsageBlock};
|
|
|
|
const CLAUDE_BASE_URL: &str = "https://api.anthropic.com/v1";
|
|
const CLAUDE_API_VERSION: &str = "2023-06-01";
|
|
const CLAUDE_TIMEOUT_SECS: u64 = 180;
|
|
|
|
pub fn resolve_claude_key() -> Option<String> {
|
|
if let Ok(k) = std::env::var("ANTHROPIC_API_KEY") {
|
|
if !k.trim().is_empty() { return Some(k.trim().to_string()); }
|
|
}
|
|
for path in ["/home/profit/.env", "/root/.env"] {
|
|
if let Ok(raw) = std::fs::read_to_string(path) {
|
|
for line in raw.lines() {
|
|
if let Some(rest) = line.strip_prefix("ANTHROPIC_API_KEY=") {
|
|
let k = rest.trim().trim_matches('"').trim_matches('\'');
|
|
if !k.is_empty() { return Some(k.to_string()); }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
None
|
|
}
|
|
|
|
pub async fn chat(
|
|
key: &str,
|
|
req: &ChatRequest,
|
|
) -> Result<ChatResponse, String> {
|
|
// Strip the "claude/" prefix if the caller used the namespaced form.
|
|
let model = req.model.strip_prefix("claude/").unwrap_or(&req.model).to_string();
|
|
|
|
// Anthropic carries system prompts outside the messages array.
|
|
// Concatenate any system-role messages into a single system string;
|
|
// keep user + assistant messages in `messages`.
|
|
let mut system_parts: Vec<String> = Vec::new();
|
|
let mut msgs: Vec<AnMessage> = Vec::new();
|
|
for m in &req.messages {
|
|
if m.role == "system" {
|
|
system_parts.push(m.text());
|
|
} else {
|
|
// Anthropic expects strictly "user" or "assistant"; anything
|
|
// else we normalize to "user".
|
|
let role = if m.role == "assistant" { "assistant" } else { "user" };
|
|
msgs.push(AnMessage { role: role.to_string(), content: m.text() });
|
|
}
|
|
}
|
|
let system = if system_parts.is_empty() {
|
|
None
|
|
} else {
|
|
Some(system_parts.join("\n\n"))
|
|
};
|
|
|
|
let body = AnChatBody {
|
|
model: model.clone(),
|
|
messages: msgs,
|
|
max_tokens: req.max_tokens.unwrap_or(800),
|
|
temperature: req.temperature.unwrap_or(0.3),
|
|
system,
|
|
};
|
|
|
|
let client = reqwest::Client::builder()
|
|
.timeout(Duration::from_secs(CLAUDE_TIMEOUT_SECS))
|
|
.build()
|
|
.map_err(|e| format!("build client: {e}"))?;
|
|
|
|
let t0 = std::time::Instant::now();
|
|
let resp = client
|
|
.post(format!("{}/messages", CLAUDE_BASE_URL))
|
|
.header("x-api-key", key)
|
|
.header("anthropic-version", CLAUDE_API_VERSION)
|
|
.json(&body)
|
|
.send()
|
|
.await
|
|
.map_err(|e| format!("api.anthropic.com unreachable: {e}"))?;
|
|
|
|
let status = resp.status();
|
|
if !status.is_success() {
|
|
let body = resp.text().await.unwrap_or_else(|_| "?".into());
|
|
return Err(format!("claude {}: {}", status, body));
|
|
}
|
|
|
|
let parsed: AnChatResponse = resp.json().await
|
|
.map_err(|e| format!("invalid claude response: {e}"))?;
|
|
|
|
let latency_ms = t0.elapsed().as_millis();
|
|
let text = parsed.content.into_iter()
|
|
.find(|b| b.block_type == "text")
|
|
.map(|b| b.text)
|
|
.unwrap_or_default();
|
|
|
|
let prompt_tokens = parsed.usage.as_ref().map(|u| u.input_tokens).unwrap_or_else(|| {
|
|
let chars: usize = req.messages.iter().map(|m| m.text().chars().count()).sum();
|
|
((chars + 3) / 4) as u32
|
|
});
|
|
let completion_tokens = parsed.usage.as_ref().map(|u| u.output_tokens).unwrap_or_else(|| {
|
|
((text.chars().count() + 3) / 4) as u32
|
|
});
|
|
|
|
tracing::info!(
|
|
target: "v1.chat",
|
|
provider = "claude",
|
|
model = %model,
|
|
prompt_tokens,
|
|
completion_tokens,
|
|
latency_ms = latency_ms as u64,
|
|
"claude chat completed",
|
|
);
|
|
|
|
Ok(ChatResponse {
|
|
id: format!("chatcmpl-{}", chrono::Utc::now().timestamp_nanos_opt().unwrap_or(0)),
|
|
object: "chat.completion",
|
|
created: chrono::Utc::now().timestamp(),
|
|
model,
|
|
choices: vec![Choice {
|
|
index: 0,
|
|
message: Message::new_text("assistant", text),
|
|
finish_reason: parsed.stop_reason.unwrap_or_else(|| "stop".into()),
|
|
}],
|
|
usage: UsageBlock {
|
|
prompt_tokens,
|
|
completion_tokens,
|
|
total_tokens: prompt_tokens + completion_tokens,
|
|
},
|
|
})
|
|
}
|
|
|
|
// -- Anthropic Messages API wire shapes --
|
|
|
|
#[derive(Serialize)]
|
|
struct AnChatBody {
|
|
model: String,
|
|
messages: Vec<AnMessage>,
|
|
max_tokens: u32,
|
|
temperature: f64,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
system: Option<String>,
|
|
}
|
|
|
|
#[derive(Serialize)]
|
|
struct AnMessage { role: String, content: String }
|
|
|
|
#[derive(Deserialize)]
|
|
struct AnChatResponse {
|
|
content: Vec<AnContentBlock>,
|
|
#[serde(default, rename = "stop_reason")]
|
|
stop_reason: Option<String>,
|
|
#[serde(default)]
|
|
usage: Option<AnUsage>,
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
struct AnContentBlock {
|
|
#[serde(rename = "type")]
|
|
block_type: String,
|
|
#[serde(default)]
|
|
text: String,
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
struct AnUsage { input_tokens: u32, output_tokens: u32 }
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn resolve_claude_key_does_not_panic() {
|
|
let _ = resolve_claude_key();
|
|
}
|
|
|
|
#[test]
|
|
fn chat_body_serializes_with_separate_system() {
|
|
let body = AnChatBody {
|
|
model: "claude-3-5-sonnet-latest".into(),
|
|
messages: vec![
|
|
AnMessage { role: "user".into(), content: "hi".into() },
|
|
],
|
|
max_tokens: 800,
|
|
temperature: 0.3,
|
|
system: Some("You are helpful.".into()),
|
|
};
|
|
let json = serde_json::to_string(&body).unwrap();
|
|
assert!(json.contains("\"system\":\"You are helpful.\""));
|
|
assert!(json.contains("\"messages\""));
|
|
assert!(json.contains("\"max_tokens\":800"));
|
|
}
|
|
|
|
#[test]
|
|
fn body_omits_system_when_none() {
|
|
let body = AnChatBody {
|
|
model: "claude-3-5-sonnet-latest".into(),
|
|
messages: vec![AnMessage { role: "user".into(), content: "hi".into() }],
|
|
max_tokens: 800,
|
|
temperature: 0.3,
|
|
system: None,
|
|
};
|
|
let json = serde_json::to_string(&body).unwrap();
|
|
assert!(!json.contains("\"system\""), "system field should be skipped when None: {json}");
|
|
}
|
|
|
|
#[test]
|
|
fn model_prefix_strip_preserves_bare_names() {
|
|
let cases = [
|
|
("claude/claude-3-5-sonnet-latest", "claude-3-5-sonnet-latest"),
|
|
("claude-3-5-sonnet-latest", "claude-3-5-sonnet-latest"),
|
|
];
|
|
for (input, expected) in cases {
|
|
let out = input.strip_prefix("claude/").unwrap_or(input);
|
|
assert_eq!(out, expected);
|
|
}
|
|
}
|
|
}
|