Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
370 changes: 370 additions & 0 deletions crates/tinyinference/src/providers/anthropic.rs
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,370 @@
//! Anthropic Messages API provider with explicit prompt-cache breakpoints.
//!
//! Unlike OpenAI-compatible APIs, Anthropic enables prompt caching by attaching
//! `{"type":"ephemeral"}` as `cache_control` to a system/content block. This
//! adapter turns TinyInference's cacheable prompt segments into that wire shape
//! and maps the provider's cache usage counters back into [`Usage`].

use async_trait::async_trait;
use serde_json::{Value, json};
use std::time::Duration;

use crate::message::{AssistantMessage, ContentBlock, Message};
use crate::model::{ChatModel, ModelProfile, ModelRequest, ModelResponse};
use crate::usage::Usage;
use crate::{Error, Result};

const DEFAULT_BASE_URL: &str = "https://api.anthropic.com/v1";
const DEFAULT_MODEL: &str = "claude-sonnet-4-6";
const ANTHROPIC_VERSION: &str = "2023-06-01";

/// A chat model backed by Anthropic's native Messages API.
pub struct AnthropicModel {
client: reqwest::Client,
api_key: String,
base_url: String,
model: String,
profile: ModelProfile,
}

impl std::fmt::Debug for AnthropicModel {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter
.debug_struct("AnthropicModel")
.field("client", &self.client)
.field("api_key", &"[redacted]")
.field("base_url", &self.base_url)
.field("model", &self.model)
.field("profile", &self.profile)
.finish()
}
}

impl AnthropicModel {
/// Creates an Anthropic model using the default Messages API endpoint.
pub fn new(api_key: impl Into<String>) -> Self {
Self::with_base_url(api_key, DEFAULT_BASE_URL)
}

/// Creates an Anthropic model targeting a Messages-API-compatible endpoint.
pub fn with_base_url(api_key: impl Into<String>, base_url: impl Into<String>) -> Self {
let model = DEFAULT_MODEL.to_string();
Comment thread
senamakel marked this conversation as resolved.
Self {
client: reqwest::Client::new(),
api_key: api_key.into(),
base_url: base_url.into().trim_end_matches('/').to_string(),
profile: ModelProfile {
provider: Some("anthropic".to_string()),
model: Some(model.clone()),
..ModelProfile::default()
},
model,
}
}

/// Overrides the default model id used when a request does not specify one.
pub fn with_model(mut self, model: impl Into<String>) -> Self {
self.model = model.into();
self.profile.model = Some(self.model.clone());
self
}

/// Reads `ANTHROPIC_API_KEY`, plus optional `ANTHROPIC_BASE_URL` and
/// `ANTHROPIC_MODEL`, from the environment.
///
/// # Errors
///
/// Returns [`Error::Model`] when `ANTHROPIC_API_KEY` is not set.
pub fn from_env() -> Result<Self> {
Comment thread
senamakel marked this conversation as resolved.
let key = std::env::var("ANTHROPIC_API_KEY")
.map_err(|_| Error::Model("ANTHROPIC_API_KEY is not set".to_string()))?;
let base_url =
std::env::var("ANTHROPIC_BASE_URL").unwrap_or_else(|_| DEFAULT_BASE_URL.into());
let model = std::env::var("ANTHROPIC_MODEL").unwrap_or_else(|_| DEFAULT_MODEL.into());
Ok(Self::with_base_url(key, base_url).with_model(model))
}

fn endpoint(&self) -> String {
if self.base_url.ends_with("/messages") {
self.base_url.clone()
} else {
format!("{}/messages", self.base_url)
}
}
}

/// Builds the native Messages API body. Kept pure so cache-control placement is
/// exhaustively testable without a network server.
pub(crate) fn request_body(request: &ModelRequest, default_model: &str) -> Value {
let cache_enabled = request
.cache_policy
.as_ref()
.is_some_and(|policy| policy.protect_prompt_prefix)
&& request
.cache_segments
.iter()
.any(|segment| segment.cacheable);
let mut system = Vec::new();
let mut messages = Vec::new();
for message in &request.messages {
match message {
Message::System(system_message) => {
system.extend(content_blocks(&system_message.content))
}
Message::User(user_message) => messages.push(json!({
"role": "user",
"content": content_blocks(&user_message.content),
})),
Message::Assistant(assistant_message) => {
let mut content = content_blocks(&assistant_message.content);
content.extend(assistant_message.tool_calls.iter().map(|call| {
json!({
"type": "tool_use",
"id": call.id,
"name": call.name,
"input": call.arguments,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

priority mediumtests likely

Serialize tool-call arguments as a JSON object for Anthropic

Anthropic's Messages API expects input to be a JSON object, but json!({ "input": call.arguments }) serializes whatever type ToolCall::arguments is. The OpenAI wire format stores arguments as a JSON-encoded string, and tool_call_from_wire in the OpenAI adapter takes &str, which strongly suggests ToolCall::arguments is a String. If so, this line sends "input": "{\"key\": \"val\"}" — a JSON string where Anthropic expects "input": {"key": "val"} — causing a 400 error on any multi-turn conversation that includes assistant tool calls. If arguments is already a serde_json::Value, this is fine; if it is a String, it must be parsed before serialization.

[RULE] wrong-argument-type-for-wire ·

})
}));
messages.push(json!({ "role": "assistant", "content": content }));
}
Message::Tool(tool_message) => messages.push(json!({
"role": "user",
"content": [{
"type": "tool_result",
"tool_use_id": tool_message.tool_call_id,
"content": content_blocks(&tool_message.content),
}],
})),
}
}
if cache_enabled {
let block = system.last_mut().or_else(|| {
messages
.first_mut()
.and_then(|message| message["content"].as_array_mut())
.and_then(|blocks| blocks.first_mut())
});
if let Some(block) = block {
block["cache_control"] = json!({ "type": "ephemeral" });
}
}
let mut body = json!({
"model": request.model.as_deref().unwrap_or(default_model),
"max_tokens": request.max_tokens.unwrap_or(1024),
"messages": messages,
});
Comment thread
senamakel marked this conversation as resolved.
if !system.is_empty() {
body["system"] = Value::Array(system);
}
if let Some(temperature) = request.temperature {
body["temperature"] = json!(temperature);
}
if let Some(top_p) = request.top_p {
body["top_p"] = json!(top_p);
}
if !request.stop_sequences.is_empty() {
body["stop_sequences"] = json!(request.stop_sequences);
}
body
}

fn content_blocks(content: &[ContentBlock]) -> Vec<Value> {
content
.iter()
.filter_map(|block| match block {
ContentBlock::Text(text) => Some(json!({ "type": "text", "text": text })),
ContentBlock::Json(value) => Some(json!({ "type": "text", "text": value.to_string() })),
ContentBlock::Thinking { text, .. } => Some(json!({ "type": "text", "text": text })),
ContentBlock::RedactedThinking { data } => {
Some(json!({ "type": "text", "text": data }))
}
ContentBlock::Image(_) | ContentBlock::ProviderExtension(_) => None,
})
.collect()
}

fn parse_response(body: Value) -> Result<ModelResponse> {
let text = body["content"]
.as_array()
.into_iter()
.flatten()
.filter_map(|block| {
(block["type"].as_str() == Some("text"))
.then(|| block["text"].as_str())
.flatten()
})
.collect::<String>();
let usage = body.get("usage").map(|usage| {
let uncached_input_tokens = usage["input_tokens"].as_u64().unwrap_or(0);
let cache_read_tokens = usage["cache_read_input_tokens"].as_u64().unwrap_or(0);
let cache_creation_tokens = usage["cache_creation_input_tokens"].as_u64().unwrap_or(0);
let input_tokens = uncached_input_tokens + cache_read_tokens + cache_creation_tokens;
let output_tokens = usage["output_tokens"].as_u64().unwrap_or(0);
Usage {
input_tokens,
output_tokens,
total_tokens: input_tokens + output_tokens,
cache_read_tokens,
cache_creation_tokens,
..Usage::default()
}
});
Ok(ModelResponse {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

priority mediumtests confident

Parse tool_use blocks from Anthropic responses

parse_response only collects type: "text" blocks from the response content array and hardcodes tool_calls: Vec::new(). When the model returns a tool_use block, it is silently dropped — the caller receives an empty tool-call list and only the text content, so tool-calling loops cannot function with this provider. The OpenAI adapter fully parses tool calls from responses; this adapter should parse tool_use blocks (mapping id, name, and input) into ToolCalls and include them in the response. A test exercising a response containing a tool_use block should verify the calls are preserved.

[RULE] dropped-tool-calls ·

message: AssistantMessage {
id: body["id"].as_str().map(str::to_string),
content: vec![ContentBlock::Text(text)],
tool_calls: Vec::new(),
usage,
},
usage,
finish_reason: body["stop_reason"].as_str().map(str::to_string),
raw: Some(body),
resolved_model: None,
continue_turn: None,
served_from_cache: false,
})
}

#[async_trait]
impl<State: Send + Sync> ChatModel<State> for AnthropicModel {
fn profile(&self) -> Option<&ModelProfile> {
Some(&self.profile)
}

fn cache_identity(&self) -> Option<String> {
Some(format!("anthropic:{}:{}", self.base_url, self.model))
}

async fn invoke(&self, _state: &State, request: ModelRequest) -> Result<ModelResponse> {
let request_builder = self
.client
.post(self.endpoint())
.header("x-api-key", &self.api_key)
.header("anthropic-version", ANTHROPIC_VERSION)
.json(&request_body(&request, &self.model));
let request_builder = match request.timeout_ms {
Some(timeout_ms) => request_builder.timeout(Duration::from_millis(timeout_ms)),
None => request_builder,
};
let response = request_builder
.send()
Comment thread
senamakel marked this conversation as resolved.
.await
.map_err(|error| Error::Model(format!("anthropic request failed: {error}")))?;
let status = response.status();
let body: Value = response
.json()
.await
.map_err(|error| Error::Model(format!("anthropic response was not JSON: {error}")))?;
if !status.is_success() {
return Err(Error::Model(format!(
"anthropic returned HTTP {status}: {}",
body["error"]["message"].as_str().unwrap_or("unknown error")
)));
Comment on lines +259 to +262

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Return structured provider errors for HTTP failures

When Anthropic returns a routine 429 or transient 5xx response, this collapses the failure into Error::Model, discarding the status, provider error type, retryability, and Retry-After metadata that consuming runtimes use for retry decisions; the preceding unconditional JSON decode also loses the HTTP status entirely for non-JSON error bodies. Decode non-success responses into ProviderError and return Error::Provider, as the existing normalized failure contract requires.

AGENTS.md reference: AGENTS.md:L38-L40

Useful? React with 👍 / 👎.

}
parse_response(body)
}
}

#[cfg(test)]
mod test {
use super::*;
use crate::cache::CachePolicy;
use crate::model::{PromptSegment, SegmentRole};

#[test]
fn cacheable_system_prefix_becomes_an_anthropic_cache_breakpoint() {
let request = ModelRequest::new(vec![
Message::system("stable instructions"),
Message::user("hello"),
])
.with_cache_segments(vec![PromptSegment {
id: "system".into(),
role: SegmentRole::System,
cacheable: true,
}])
.with_cache_policy(CachePolicy {
protect_prompt_prefix: true,
..CachePolicy::default()
});
let body = request_body(&request, "test-model");
assert_eq!(
body["system"][0]["cache_control"],
json!({ "type": "ephemeral" })
);
assert_eq!(body["messages"][0]["role"], "user");
}

#[test]
fn cacheable_user_prefix_becomes_a_content_block_breakpoint() {
let request = ModelRequest::new(vec![Message::user("stable context")])
.with_cache_segments(vec![PromptSegment {
id: "history".into(),
role: SegmentRole::History,
cacheable: true,
}])
.with_cache_policy(CachePolicy {
protect_prompt_prefix: true,
..CachePolicy::default()
});
let body = request_body(&request, "test-model");
assert_eq!(body["messages"][0]["content"][0]["type"], "text");
assert_eq!(body["messages"][0]["content"][0]["text"], "stable context");
assert_eq!(
body["messages"][0]["content"][0]["cache_control"],
json!({ "type": "ephemeral" })
);
}

#[test]
fn tool_results_use_anthropic_tool_result_blocks() {
let request = ModelRequest::new(vec![Message::Tool(crate::message::ToolMessage {
tool_call_id: "tool_1".into(),
content: vec![ContentBlock::Text("42".into())],
trusted_verbatim: false,
artifact: None,
})]);
let body = request_body(&request, "test-model");
assert_eq!(body["messages"][0]["role"], "user");
assert_eq!(body["messages"][0]["content"][0]["type"], "tool_result");
assert_eq!(body["messages"][0]["content"][0]["tool_use_id"], "tool_1");
}

#[test]
fn default_model_is_current() {
assert_eq!(AnthropicModel::new("key").model, "claude-sonnet-4-6");
}

#[test]
fn cache_usage_is_mapped_from_anthropic_response() {
let response = parse_response(json!({
"id": "msg_1", "content": [{ "type": "text", "text": "hello" }], "stop_reason": "end_turn",
"usage": { "input_tokens": 100, "output_tokens": 5, "cache_read_input_tokens": 90, "cache_creation_input_tokens": 10 }
})).unwrap();
assert_eq!(response.text(), "hello");
let usage = response.usage.unwrap();
assert_eq!(usage.input_tokens, 200);
assert_eq!(usage.total_tokens, 205);
assert_eq!(usage.cache_read_tokens, 90);
assert_eq!(usage.cache_creation_tokens, 10);
}

#[test]
fn request_body_forwards_generation_controls() {
let request = ModelRequest::new(vec![Message::user("hello")])
.with_temperature(0.2)
.with_top_p(0.8)
.with_stop_sequences(["END"]);
let body = request_body(&request, "test-model");
assert_eq!(body["temperature"], 0.2);
assert_eq!(body["top_p"], 0.8);
assert_eq!(body["stop_sequences"], json!(["END"]));
}

#[test]
fn debug_redacts_the_api_key() {
let model = AnthropicModel::new("secret-api-key");
let debug = format!("{model:?}");
assert!(debug.contains("[redacted]"));
assert!(!debug.contains("secret-api-key"));
}
}
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Add copy buttons to all
 blocks
(function() {
function addCopyButtons() {
document.querySelectorAll('pre code').forEach(function(codeBlock) {
if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;
codeBlock.parentElement.setAttribute('data-copy-added', 'true');
var btn = document.createElement('button');
btn.textContent = 'Copy';
btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';
btn.onmouseover = function() { this.style.opacity = '1'; };
btn.onmouseout = function() { this.style.opacity = '0.7'; };
btn.onclick = function() {
navigator.clipboard.writeText(codeBlock.textContent).then(function() {
btn.textContent = 'Copied!';
setTimeout(function() { btn.textContent = 'Copy'; }, 1500);
});
};
codeBlock.parentElement.style.position = 'relative';
codeBlock.parentElement.appendChild(btn);
});
}
addCopyButtons();
// Re-run on dynamic content
var observer = new MutationObserver(addCopyButtons);
observer.observe(document.body, { childList: true, subtree: true });
})();
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
370 changes: 370 additions & 0 deletions crates/tinyinference/src/providers/anthropic.rs
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,370 @@
//! Anthropic Messages API provider with explicit prompt-cache breakpoints.
//!
//! Unlike OpenAI-compatible APIs, Anthropic enables prompt caching by attaching
//! `{"type":"ephemeral"}` as `cache_control` to a system/content block. This
//! adapter turns TinyInference's cacheable prompt segments into that wire shape
//! and maps the provider's cache usage counters back into [`Usage`].

use async_trait::async_trait;
use serde_json::{Value, json};
use std::time::Duration;

use crate::message::{AssistantMessage, ContentBlock, Message};
use crate::model::{ChatModel, ModelProfile, ModelRequest, ModelResponse};
use crate::usage::Usage;
use crate::{Error, Result};

const DEFAULT_BASE_URL: &str = "https://api.anthropic.com/v1";
const DEFAULT_MODEL: &str = "claude-sonnet-4-6";
const ANTHROPIC_VERSION: &str = "2023-06-01";

/// A chat model backed by Anthropic's native Messages API.
pub struct AnthropicModel {
client: reqwest::Client,
api_key: String,
base_url: String,
model: String,
profile: ModelProfile,
}

impl std::fmt::Debug for AnthropicModel {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter
.debug_struct("AnthropicModel")
.field("client", &self.client)
.field("api_key", &"[redacted]")
.field("base_url", &self.base_url)
.field("model", &self.model)
.field("profile", &self.profile)
.finish()
}
}

impl AnthropicModel {
/// Creates an Anthropic model using the default Messages API endpoint.
pub fn new(api_key: impl Into<String>) -> Self {
Self::with_base_url(api_key, DEFAULT_BASE_URL)
}

/// Creates an Anthropic model targeting a Messages-API-compatible endpoint.
pub fn with_base_url(api_key: impl Into<String>, base_url: impl Into<String>) -> Self {
let model = DEFAULT_MODEL.to_string();
Comment thread
senamakel marked this conversation as resolved.
Self {
client: reqwest::Client::new(),
api_key: api_key.into(),
base_url: base_url.into().trim_end_matches('/').to_string(),
profile: ModelProfile {
provider: Some("anthropic".to_string()),
model: Some(model.clone()),
..ModelProfile::default()
},
model,
}
}

/// Overrides the default model id used when a request does not specify one.
pub fn with_model(mut self, model: impl Into<String>) -> Self {
self.model = model.into();
self.profile.model = Some(self.model.clone());
self
}

/// Reads `ANTHROPIC_API_KEY`, plus optional `ANTHROPIC_BASE_URL` and
/// `ANTHROPIC_MODEL`, from the environment.
///
/// # Errors
///
/// Returns [`Error::Model`] when `ANTHROPIC_API_KEY` is not set.
pub fn from_env() -> Result<Self> {
Comment thread
senamakel marked this conversation as resolved.
let key = std::env::var("ANTHROPIC_API_KEY")
.map_err(|_| Error::Model("ANTHROPIC_API_KEY is not set".to_string()))?;
let base_url =
std::env::var("ANTHROPIC_BASE_URL").unwrap_or_else(|_| DEFAULT_BASE_URL.into());
let model = std::env::var("ANTHROPIC_MODEL").unwrap_or_else(|_| DEFAULT_MODEL.into());
Ok(Self::with_base_url(key, base_url).with_model(model))
}

fn endpoint(&self) -> String {
if self.base_url.ends_with("/messages") {
self.base_url.clone()
} else {
format!("{}/messages", self.base_url)
}
}
}

/// Builds the native Messages API body. Kept pure so cache-control placement is
/// exhaustively testable without a network server.
pub(crate) fn request_body(request: &ModelRequest, default_model: &str) -> Value {
let cache_enabled = request
.cache_policy
.as_ref()
.is_some_and(|policy| policy.protect_prompt_prefix)
&& request
.cache_segments
.iter()
.any(|segment| segment.cacheable);
let mut system = Vec::new();
let mut messages = Vec::new();
for message in &request.messages {
match message {
Message::System(system_message) => {
system.extend(content_blocks(&system_message.content))
}
Message::User(user_message) => messages.push(json!({
"role": "user",
"content": content_blocks(&user_message.content),
})),
Message::Assistant(assistant_message) => {
let mut content = content_blocks(&assistant_message.content);
content.extend(assistant_message.tool_calls.iter().map(|call| {
json!({
"type": "tool_use",
"id": call.id,
"name": call.name,
"input": call.arguments,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

priority mediumtests likely

Serialize tool-call arguments as a JSON object for Anthropic

Anthropic's Messages API expects input to be a JSON object, but json!({ "input": call.arguments }) serializes whatever type ToolCall::arguments is. The OpenAI wire format stores arguments as a JSON-encoded string, and tool_call_from_wire in the OpenAI adapter takes &str, which strongly suggests ToolCall::arguments is a String. If so, this line sends "input": "{\"key\": \"val\"}" — a JSON string where Anthropic expects "input": {"key": "val"} — causing a 400 error on any multi-turn conversation that includes assistant tool calls. If arguments is already a serde_json::Value, this is fine; if it is a String, it must be parsed before serialization.

[RULE] wrong-argument-type-for-wire ·

})
}));
messages.push(json!({ "role": "assistant", "content": content }));
}
Message::Tool(tool_message) => messages.push(json!({
"role": "user",
"content": [{
"type": "tool_result",
"tool_use_id": tool_message.tool_call_id,
"content": content_blocks(&tool_message.content),
}],
})),
}
}
if cache_enabled {
let block = system.last_mut().or_else(|| {
messages
.first_mut()
.and_then(|message| message["content"].as_array_mut())
.and_then(|blocks| blocks.first_mut())
});
if let Some(block) = block {
block["cache_control"] = json!({ "type": "ephemeral" });
}
}
let mut body = json!({
"model": request.model.as_deref().unwrap_or(default_model),
"max_tokens": request.max_tokens.unwrap_or(1024),
"messages": messages,
});
Comment thread
senamakel marked this conversation as resolved.
if !system.is_empty() {
body["system"] = Value::Array(system);
}
if let Some(temperature) = request.temperature {
body["temperature"] = json!(temperature);
}
if let Some(top_p) = request.top_p {
body["top_p"] = json!(top_p);
}
if !request.stop_sequences.is_empty() {
body["stop_sequences"] = json!(request.stop_sequences);
}
body
}

fn content_blocks(content: &[ContentBlock]) -> Vec<Value> {
content
.iter()
.filter_map(|block| match block {
ContentBlock::Text(text) => Some(json!({ "type": "text", "text": text })),
ContentBlock::Json(value) => Some(json!({ "type": "text", "text": value.to_string() })),
ContentBlock::Thinking { text, .. } => Some(json!({ "type": "text", "text": text })),
ContentBlock::RedactedThinking { data } => {
Some(json!({ "type": "text", "text": data }))
}
ContentBlock::Image(_) | ContentBlock::ProviderExtension(_) => None,
})
.collect()
}

fn parse_response(body: Value) -> Result<ModelResponse> {
let text = body["content"]
.as_array()
.into_iter()
.flatten()
.filter_map(|block| {
(block["type"].as_str() == Some("text"))
.then(|| block["text"].as_str())
.flatten()
})
.collect::<String>();
let usage = body.get("usage").map(|usage| {
let uncached_input_tokens = usage["input_tokens"].as_u64().unwrap_or(0);
let cache_read_tokens = usage["cache_read_input_tokens"].as_u64().unwrap_or(0);
let cache_creation_tokens = usage["cache_creation_input_tokens"].as_u64().unwrap_or(0);
let input_tokens = uncached_input_tokens + cache_read_tokens + cache_creation_tokens;
let output_tokens = usage["output_tokens"].as_u64().unwrap_or(0);
Usage {
input_tokens,
output_tokens,
total_tokens: input_tokens + output_tokens,
cache_read_tokens,
cache_creation_tokens,
..Usage::default()
}
});
Ok(ModelResponse {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

priority mediumtests confident

Parse tool_use blocks from Anthropic responses

parse_response only collects type: "text" blocks from the response content array and hardcodes tool_calls: Vec::new(). When the model returns a tool_use block, it is silently dropped — the caller receives an empty tool-call list and only the text content, so tool-calling loops cannot function with this provider. The OpenAI adapter fully parses tool calls from responses; this adapter should parse tool_use blocks (mapping id, name, and input) into ToolCalls and include them in the response. A test exercising a response containing a tool_use block should verify the calls are preserved.

[RULE] dropped-tool-calls ·

message: AssistantMessage {
id: body["id"].as_str().map(str::to_string),
content: vec![ContentBlock::Text(text)],
tool_calls: Vec::new(),
usage,
},
usage,
finish_reason: body["stop_reason"].as_str().map(str::to_string),
raw: Some(body),
resolved_model: None,
continue_turn: None,
served_from_cache: false,
})
}

#[async_trait]
impl<State: Send + Sync> ChatModel<State> for AnthropicModel {
fn profile(&self) -> Option<&ModelProfile> {
Some(&self.profile)
}

fn cache_identity(&self) -> Option<String> {
Some(format!("anthropic:{}:{}", self.base_url, self.model))
}

async fn invoke(&self, _state: &State, request: ModelRequest) -> Result<ModelResponse> {
let request_builder = self
.client
.post(self.endpoint())
.header("x-api-key", &self.api_key)
.header("anthropic-version", ANTHROPIC_VERSION)
.json(&request_body(&request, &self.model));
let request_builder = match request.timeout_ms {
Some(timeout_ms) => request_builder.timeout(Duration::from_millis(timeout_ms)),
None => request_builder,
};
let response = request_builder
.send()
Comment thread
senamakel marked this conversation as resolved.
.await
.map_err(|error| Error::Model(format!("anthropic request failed: {error}")))?;
let status = response.status();
let body: Value = response
.json()
.await
.map_err(|error| Error::Model(format!("anthropic response was not JSON: {error}")))?;
if !status.is_success() {
return Err(Error::Model(format!(
"anthropic returned HTTP {status}: {}",
body["error"]["message"].as_str().unwrap_or("unknown error")
)));
Comment on lines +259 to +262

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Return structured provider errors for HTTP failures

When Anthropic returns a routine 429 or transient 5xx response, this collapses the failure into Error::Model, discarding the status, provider error type, retryability, and Retry-After metadata that consuming runtimes use for retry decisions; the preceding unconditional JSON decode also loses the HTTP status entirely for non-JSON error bodies. Decode non-success responses into ProviderError and return Error::Provider, as the existing normalized failure contract requires.

AGENTS.md reference: AGENTS.md:L38-L40

Useful? React with 👍 / 👎.

}
parse_response(body)
}
}

#[cfg(test)]
mod test {
use super::*;
use crate::cache::CachePolicy;
use crate::model::{PromptSegment, SegmentRole};

#[test]
fn cacheable_system_prefix_becomes_an_anthropic_cache_breakpoint() {
let request = ModelRequest::new(vec![
Message::system("stable instructions"),
Message::user("hello"),
])
.with_cache_segments(vec![PromptSegment {
id: "system".into(),
role: SegmentRole::System,
cacheable: true,
}])
.with_cache_policy(CachePolicy {
protect_prompt_prefix: true,
..CachePolicy::default()
});
let body = request_body(&request, "test-model");
assert_eq!(
body["system"][0]["cache_control"],
json!({ "type": "ephemeral" })
);
assert_eq!(body["messages"][0]["role"], "user");
}

#[test]
fn cacheable_user_prefix_becomes_a_content_block_breakpoint() {
let request = ModelRequest::new(vec![Message::user("stable context")])
.with_cache_segments(vec![PromptSegment {
id: "history".into(),
role: SegmentRole::History,
cacheable: true,
}])
.with_cache_policy(CachePolicy {
protect_prompt_prefix: true,
..CachePolicy::default()
});
let body = request_body(&request, "test-model");
assert_eq!(body["messages"][0]["content"][0]["type"], "text");
assert_eq!(body["messages"][0]["content"][0]["text"], "stable context");
assert_eq!(
body["messages"][0]["content"][0]["cache_control"],
json!({ "type": "ephemeral" })
);
}

#[test]
fn tool_results_use_anthropic_tool_result_blocks() {
let request = ModelRequest::new(vec![Message::Tool(crate::message::ToolMessage {
tool_call_id: "tool_1".into(),
content: vec![ContentBlock::Text("42".into())],
trusted_verbatim: false,
artifact: None,
})]);
let body = request_body(&request, "test-model");
assert_eq!(body["messages"][0]["role"], "user");
assert_eq!(body["messages"][0]["content"][0]["type"], "tool_result");
assert_eq!(body["messages"][0]["content"][0]["tool_use_id"], "tool_1");
}

#[test]
fn default_model_is_current() {
assert_eq!(AnthropicModel::new("key").model, "claude-sonnet-4-6");
}

#[test]
fn cache_usage_is_mapped_from_anthropic_response() {
let response = parse_response(json!({
"id": "msg_1", "content": [{ "type": "text", "text": "hello" }], "stop_reason": "end_turn",
"usage": { "input_tokens": 100, "output_tokens": 5, "cache_read_input_tokens": 90, "cache_creation_input_tokens": 10 }
})).unwrap();
assert_eq!(response.text(), "hello");
let usage = response.usage.unwrap();
assert_eq!(usage.input_tokens, 200);
assert_eq!(usage.total_tokens, 205);
assert_eq!(usage.cache_read_tokens, 90);
assert_eq!(usage.cache_creation_tokens, 10);
}

#[test]
fn request_body_forwards_generation_controls() {
let request = ModelRequest::new(vec![Message::user("hello")])
.with_temperature(0.2)
.with_top_p(0.8)
.with_stop_sequences(["END"]);
let body = request_body(&request, "test-model");
assert_eq!(body["temperature"], 0.2);
assert_eq!(body["top_p"], 0.8);
assert_eq!(body["stop_sequences"], json!(["END"]));
}

#[test]
fn debug_redacts_the_api_key() {
let model = AnthropicModel::new("secret-api-key");
let debug = format!("{model:?}");
assert!(debug.contains("[redacted]"));
assert!(!debug.contains("secret-api-key"));
}
}
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Force GitHub README to respect dark mode (function() { var style = document.createElement('style'); style.textContent = ' .markdown-body { color-scheme: dark light; } .markdown-body pre { background: #161b22 !important; } .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; } .markdown-body table th, .markdown-body table td { border-color: #30363d !important; } .markdown-body img { background: #0d1117; } .markdown-body blockquote { border-left-color: #8b949e; } .markdown-body hr { border-color: #30363d; } '; document.head.appendChild(style); })(); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
370 changes: 370 additions & 0 deletions crates/tinyinference/src/providers/anthropic.rs
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,370 @@
//! Anthropic Messages API provider with explicit prompt-cache breakpoints.
//!
//! Unlike OpenAI-compatible APIs, Anthropic enables prompt caching by attaching
//! `{"type":"ephemeral"}` as `cache_control` to a system/content block. This
//! adapter turns TinyInference's cacheable prompt segments into that wire shape
//! and maps the provider's cache usage counters back into [`Usage`].

use async_trait::async_trait;
use serde_json::{Value, json};
use std::time::Duration;

use crate::message::{AssistantMessage, ContentBlock, Message};
use crate::model::{ChatModel, ModelProfile, ModelRequest, ModelResponse};
use crate::usage::Usage;
use crate::{Error, Result};

const DEFAULT_BASE_URL: &str = "https://api.anthropic.com/v1";
const DEFAULT_MODEL: &str = "claude-sonnet-4-6";
const ANTHROPIC_VERSION: &str = "2023-06-01";

/// A chat model backed by Anthropic's native Messages API.
pub struct AnthropicModel {
client: reqwest::Client,
api_key: String,
base_url: String,
model: String,
profile: ModelProfile,
}

impl std::fmt::Debug for AnthropicModel {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter
.debug_struct("AnthropicModel")
.field("client", &self.client)
.field("api_key", &"[redacted]")
.field("base_url", &self.base_url)
.field("model", &self.model)
.field("profile", &self.profile)
.finish()
}
}

impl AnthropicModel {
/// Creates an Anthropic model using the default Messages API endpoint.
pub fn new(api_key: impl Into<String>) -> Self {
Self::with_base_url(api_key, DEFAULT_BASE_URL)
}

/// Creates an Anthropic model targeting a Messages-API-compatible endpoint.
pub fn with_base_url(api_key: impl Into<String>, base_url: impl Into<String>) -> Self {
let model = DEFAULT_MODEL.to_string();
Comment thread
senamakel marked this conversation as resolved.
Self {
client: reqwest::Client::new(),
api_key: api_key.into(),
base_url: base_url.into().trim_end_matches('/').to_string(),
profile: ModelProfile {
provider: Some("anthropic".to_string()),
model: Some(model.clone()),
..ModelProfile::default()
},
model,
}
}

/// Overrides the default model id used when a request does not specify one.
pub fn with_model(mut self, model: impl Into<String>) -> Self {
self.model = model.into();
self.profile.model = Some(self.model.clone());
self
}

/// Reads `ANTHROPIC_API_KEY`, plus optional `ANTHROPIC_BASE_URL` and
/// `ANTHROPIC_MODEL`, from the environment.
///
/// # Errors
///
/// Returns [`Error::Model`] when `ANTHROPIC_API_KEY` is not set.
pub fn from_env() -> Result<Self> {
Comment thread
senamakel marked this conversation as resolved.
let key = std::env::var("ANTHROPIC_API_KEY")
.map_err(|_| Error::Model("ANTHROPIC_API_KEY is not set".to_string()))?;
let base_url =
std::env::var("ANTHROPIC_BASE_URL").unwrap_or_else(|_| DEFAULT_BASE_URL.into());
let model = std::env::var("ANTHROPIC_MODEL").unwrap_or_else(|_| DEFAULT_MODEL.into());
Ok(Self::with_base_url(key, base_url).with_model(model))
}

fn endpoint(&self) -> String {
if self.base_url.ends_with("/messages") {
self.base_url.clone()
} else {
format!("{}/messages", self.base_url)
}
}
}

/// Builds the native Messages API body. Kept pure so cache-control placement is
/// exhaustively testable without a network server.
pub(crate) fn request_body(request: &ModelRequest, default_model: &str) -> Value {
let cache_enabled = request
.cache_policy
.as_ref()
.is_some_and(|policy| policy.protect_prompt_prefix)
&& request
.cache_segments
.iter()
.any(|segment| segment.cacheable);
let mut system = Vec::new();
let mut messages = Vec::new();
for message in &request.messages {
match message {
Message::System(system_message) => {
system.extend(content_blocks(&system_message.content))
}
Message::User(user_message) => messages.push(json!({
"role": "user",
"content": content_blocks(&user_message.content),
})),
Message::Assistant(assistant_message) => {
let mut content = content_blocks(&assistant_message.content);
content.extend(assistant_message.tool_calls.iter().map(|call| {
json!({
"type": "tool_use",
"id": call.id,
"name": call.name,
"input": call.arguments,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

priority mediumtests likely

Serialize tool-call arguments as a JSON object for Anthropic

Anthropic's Messages API expects input to be a JSON object, but json!({ "input": call.arguments }) serializes whatever type ToolCall::arguments is. The OpenAI wire format stores arguments as a JSON-encoded string, and tool_call_from_wire in the OpenAI adapter takes &str, which strongly suggests ToolCall::arguments is a String. If so, this line sends "input": "{\"key\": \"val\"}" — a JSON string where Anthropic expects "input": {"key": "val"} — causing a 400 error on any multi-turn conversation that includes assistant tool calls. If arguments is already a serde_json::Value, this is fine; if it is a String, it must be parsed before serialization.

[RULE] wrong-argument-type-for-wire ·

})
}));
messages.push(json!({ "role": "assistant", "content": content }));
}
Message::Tool(tool_message) => messages.push(json!({
"role": "user",
"content": [{
"type": "tool_result",
"tool_use_id": tool_message.tool_call_id,
"content": content_blocks(&tool_message.content),
}],
})),
}
}
if cache_enabled {
let block = system.last_mut().or_else(|| {
messages
.first_mut()
.and_then(|message| message["content"].as_array_mut())
.and_then(|blocks| blocks.first_mut())
});
if let Some(block) = block {
block["cache_control"] = json!({ "type": "ephemeral" });
}
}
let mut body = json!({
"model": request.model.as_deref().unwrap_or(default_model),
"max_tokens": request.max_tokens.unwrap_or(1024),
"messages": messages,
});
Comment thread
senamakel marked this conversation as resolved.
if !system.is_empty() {
body["system"] = Value::Array(system);
}
if let Some(temperature) = request.temperature {
body["temperature"] = json!(temperature);
}
if let Some(top_p) = request.top_p {
body["top_p"] = json!(top_p);
}
if !request.stop_sequences.is_empty() {
body["stop_sequences"] = json!(request.stop_sequences);
}
body
}

fn content_blocks(content: &[ContentBlock]) -> Vec<Value> {
content
.iter()
.filter_map(|block| match block {
ContentBlock::Text(text) => Some(json!({ "type": "text", "text": text })),
ContentBlock::Json(value) => Some(json!({ "type": "text", "text": value.to_string() })),
ContentBlock::Thinking { text, .. } => Some(json!({ "type": "text", "text": text })),
ContentBlock::RedactedThinking { data } => {
Some(json!({ "type": "text", "text": data }))
}
ContentBlock::Image(_) | ContentBlock::ProviderExtension(_) => None,
})
.collect()
}

fn parse_response(body: Value) -> Result<ModelResponse> {
let text = body["content"]
.as_array()
.into_iter()
.flatten()
.filter_map(|block| {
(block["type"].as_str() == Some("text"))
.then(|| block["text"].as_str())
.flatten()
})
.collect::<String>();
let usage = body.get("usage").map(|usage| {
let uncached_input_tokens = usage["input_tokens"].as_u64().unwrap_or(0);
let cache_read_tokens = usage["cache_read_input_tokens"].as_u64().unwrap_or(0);
let cache_creation_tokens = usage["cache_creation_input_tokens"].as_u64().unwrap_or(0);
let input_tokens = uncached_input_tokens + cache_read_tokens + cache_creation_tokens;
let output_tokens = usage["output_tokens"].as_u64().unwrap_or(0);
Usage {
input_tokens,
output_tokens,
total_tokens: input_tokens + output_tokens,
cache_read_tokens,
cache_creation_tokens,
..Usage::default()
}
});
Ok(ModelResponse {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

priority mediumtests confident

Parse tool_use blocks from Anthropic responses

parse_response only collects type: "text" blocks from the response content array and hardcodes tool_calls: Vec::new(). When the model returns a tool_use block, it is silently dropped — the caller receives an empty tool-call list and only the text content, so tool-calling loops cannot function with this provider. The OpenAI adapter fully parses tool calls from responses; this adapter should parse tool_use blocks (mapping id, name, and input) into ToolCalls and include them in the response. A test exercising a response containing a tool_use block should verify the calls are preserved.

[RULE] dropped-tool-calls ·

message: AssistantMessage {
id: body["id"].as_str().map(str::to_string),
content: vec![ContentBlock::Text(text)],
tool_calls: Vec::new(),
usage,
},
usage,
finish_reason: body["stop_reason"].as_str().map(str::to_string),
raw: Some(body),
resolved_model: None,
continue_turn: None,
served_from_cache: false,
})
}

#[async_trait]
impl<State: Send + Sync> ChatModel<State> for AnthropicModel {
fn profile(&self) -> Option<&ModelProfile> {
Some(&self.profile)
}

fn cache_identity(&self) -> Option<String> {
Some(format!("anthropic:{}:{}", self.base_url, self.model))
}

async fn invoke(&self, _state: &State, request: ModelRequest) -> Result<ModelResponse> {
let request_builder = self
.client
.post(self.endpoint())
.header("x-api-key", &self.api_key)
.header("anthropic-version", ANTHROPIC_VERSION)
.json(&request_body(&request, &self.model));
let request_builder = match request.timeout_ms {
Some(timeout_ms) => request_builder.timeout(Duration::from_millis(timeout_ms)),
None => request_builder,
};
let response = request_builder
.send()
Comment thread
senamakel marked this conversation as resolved.
.await
.map_err(|error| Error::Model(format!("anthropic request failed: {error}")))?;
let status = response.status();
let body: Value = response
.json()
.await
.map_err(|error| Error::Model(format!("anthropic response was not JSON: {error}")))?;
if !status.is_success() {
return Err(Error::Model(format!(
"anthropic returned HTTP {status}: {}",
body["error"]["message"].as_str().unwrap_or("unknown error")
)));
Comment on lines +259 to +262

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Return structured provider errors for HTTP failures

When Anthropic returns a routine 429 or transient 5xx response, this collapses the failure into Error::Model, discarding the status, provider error type, retryability, and Retry-After metadata that consuming runtimes use for retry decisions; the preceding unconditional JSON decode also loses the HTTP status entirely for non-JSON error bodies. Decode non-success responses into ProviderError and return Error::Provider, as the existing normalized failure contract requires.

AGENTS.md reference: AGENTS.md:L38-L40

Useful? React with 👍 / 👎.

}
parse_response(body)
}
}

#[cfg(test)]
mod test {
use super::*;
use crate::cache::CachePolicy;
use crate::model::{PromptSegment, SegmentRole};

#[test]
fn cacheable_system_prefix_becomes_an_anthropic_cache_breakpoint() {
let request = ModelRequest::new(vec![
Message::system("stable instructions"),
Message::user("hello"),
])
.with_cache_segments(vec![PromptSegment {
id: "system".into(),
role: SegmentRole::System,
cacheable: true,
}])
.with_cache_policy(CachePolicy {
protect_prompt_prefix: true,
..CachePolicy::default()
});
let body = request_body(&request, "test-model");
assert_eq!(
body["system"][0]["cache_control"],
json!({ "type": "ephemeral" })
);
assert_eq!(body["messages"][0]["role"], "user");
}

#[test]
fn cacheable_user_prefix_becomes_a_content_block_breakpoint() {
let request = ModelRequest::new(vec![Message::user("stable context")])
.with_cache_segments(vec![PromptSegment {
id: "history".into(),
role: SegmentRole::History,
cacheable: true,
}])
.with_cache_policy(CachePolicy {
protect_prompt_prefix: true,
..CachePolicy::default()
});
let body = request_body(&request, "test-model");
assert_eq!(body["messages"][0]["content"][0]["type"], "text");
assert_eq!(body["messages"][0]["content"][0]["text"], "stable context");
assert_eq!(
body["messages"][0]["content"][0]["cache_control"],
json!({ "type": "ephemeral" })
);
}

#[test]
fn tool_results_use_anthropic_tool_result_blocks() {
let request = ModelRequest::new(vec![Message::Tool(crate::message::ToolMessage {
tool_call_id: "tool_1".into(),
content: vec![ContentBlock::Text("42".into())],
trusted_verbatim: false,
artifact: None,
})]);
let body = request_body(&request, "test-model");
assert_eq!(body["messages"][0]["role"], "user");
assert_eq!(body["messages"][0]["content"][0]["type"], "tool_result");
assert_eq!(body["messages"][0]["content"][0]["tool_use_id"], "tool_1");
}

#[test]
fn default_model_is_current() {
assert_eq!(AnthropicModel::new("key").model, "claude-sonnet-4-6");
}

#[test]
fn cache_usage_is_mapped_from_anthropic_response() {
let response = parse_response(json!({
"id": "msg_1", "content": [{ "type": "text", "text": "hello" }], "stop_reason": "end_turn",
"usage": { "input_tokens": 100, "output_tokens": 5, "cache_read_input_tokens": 90, "cache_creation_input_tokens": 10 }
})).unwrap();
assert_eq!(response.text(), "hello");
let usage = response.usage.unwrap();
assert_eq!(usage.input_tokens, 200);
assert_eq!(usage.total_tokens, 205);
assert_eq!(usage.cache_read_tokens, 90);
assert_eq!(usage.cache_creation_tokens, 10);
}

#[test]
fn request_body_forwards_generation_controls() {
let request = ModelRequest::new(vec![Message::user("hello")])
.with_temperature(0.2)
.with_top_p(0.8)
.with_stop_sequences(["END"]);
let body = request_body(&request, "test-model");
assert_eq!(body["temperature"], 0.2);
assert_eq!(body["top_p"], 0.8);
assert_eq!(body["stop_sequences"], json!(["END"]));
}

#[test]
fn debug_redacts_the_api_key() {
let model = AnthropicModel::new("secret-api-key");
let debug = format!("{model:?}");
assert!(debug.contains("[redacted]"));
assert!(!debug.contains("secret-api-key"));
}
}
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Highlight search terms from Google/DuckDuckGo/Bing referrer (function() { var ref = document.referrer; var terms = []; if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) { var url = new URL(ref); var q = url.searchParams.get('q') || url.searchParams.get('p'); if (q) { terms = q.split(/\s+/).filter(function(t) { return t.length > 2; }); } } if (terms.length === 0) return; var style = document.createElement('style'); style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }'; document.head.appendChild(style); function highlight(node) { if (node.nodeType === 3) { // text node var text = node.textContent; var found = false; terms.forEach(function(term) { var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\]\\]/g, '\\') + ')', 'gi'); if (regex.test(text)) { found = true; var frag = document.createDocumentFragment(); var parts = text.split(regex); parts.forEach(function(part, i) { if (i % 2 === 0) { frag.appendChild(document.createTextNode(part)); } else { var span = document.createElement('span'); span.className = 'userscript-highlight'; span.textContent = part; frag.appendChild(span); } }); node.parentNode.replaceChild(frag, node); } }); } else if (node.nodeType === 1 && node.childNodes) { // element var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT']; if (!skipTags.includes(node.tagName)) { Array.from(node.childNodes).forEach(highlight); } } } highlight(document.body); // Re-highlight on dynamic content var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1 || node.nodeType === 3) highlight(node); }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
370 changes: 370 additions & 0 deletions crates/tinyinference/src/providers/anthropic.rs
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,370 @@
//! Anthropic Messages API provider with explicit prompt-cache breakpoints.
//!
//! Unlike OpenAI-compatible APIs, Anthropic enables prompt caching by attaching
//! `{"type":"ephemeral"}` as `cache_control` to a system/content block. This
//! adapter turns TinyInference's cacheable prompt segments into that wire shape
//! and maps the provider's cache usage counters back into [`Usage`].

use async_trait::async_trait;
use serde_json::{Value, json};
use std::time::Duration;

use crate::message::{AssistantMessage, ContentBlock, Message};
use crate::model::{ChatModel, ModelProfile, ModelRequest, ModelResponse};
use crate::usage::Usage;
use crate::{Error, Result};

const DEFAULT_BASE_URL: &str = "https://api.anthropic.com/v1";
const DEFAULT_MODEL: &str = "claude-sonnet-4-6";
const ANTHROPIC_VERSION: &str = "2023-06-01";

/// A chat model backed by Anthropic's native Messages API.
pub struct AnthropicModel {
client: reqwest::Client,
api_key: String,
base_url: String,
model: String,
profile: ModelProfile,
}

impl std::fmt::Debug for AnthropicModel {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter
.debug_struct("AnthropicModel")
.field("client", &self.client)
.field("api_key", &"[redacted]")
.field("base_url", &self.base_url)
.field("model", &self.model)
.field("profile", &self.profile)
.finish()
}
}

impl AnthropicModel {
/// Creates an Anthropic model using the default Messages API endpoint.
pub fn new(api_key: impl Into<String>) -> Self {
Self::with_base_url(api_key, DEFAULT_BASE_URL)
}

/// Creates an Anthropic model targeting a Messages-API-compatible endpoint.
pub fn with_base_url(api_key: impl Into<String>, base_url: impl Into<String>) -> Self {
let model = DEFAULT_MODEL.to_string();
Comment thread
senamakel marked this conversation as resolved.
Self {
client: reqwest::Client::new(),
api_key: api_key.into(),
base_url: base_url.into().trim_end_matches('/').to_string(),
profile: ModelProfile {
provider: Some("anthropic".to_string()),
model: Some(model.clone()),
..ModelProfile::default()
},
model,
}
}

/// Overrides the default model id used when a request does not specify one.
pub fn with_model(mut self, model: impl Into<String>) -> Self {
self.model = model.into();
self.profile.model = Some(self.model.clone());
self
}

/// Reads `ANTHROPIC_API_KEY`, plus optional `ANTHROPIC_BASE_URL` and
/// `ANTHROPIC_MODEL`, from the environment.
///
/// # Errors
///
/// Returns [`Error::Model`] when `ANTHROPIC_API_KEY` is not set.
pub fn from_env() -> Result<Self> {
Comment thread
senamakel marked this conversation as resolved.
let key = std::env::var("ANTHROPIC_API_KEY")
.map_err(|_| Error::Model("ANTHROPIC_API_KEY is not set".to_string()))?;
let base_url =
std::env::var("ANTHROPIC_BASE_URL").unwrap_or_else(|_| DEFAULT_BASE_URL.into());
let model = std::env::var("ANTHROPIC_MODEL").unwrap_or_else(|_| DEFAULT_MODEL.into());
Ok(Self::with_base_url(key, base_url).with_model(model))
}

fn endpoint(&self) -> String {
if self.base_url.ends_with("/messages") {
self.base_url.clone()
} else {
format!("{}/messages", self.base_url)
}
}
}

/// Builds the native Messages API body. Kept pure so cache-control placement is
/// exhaustively testable without a network server.
pub(crate) fn request_body(request: &ModelRequest, default_model: &str) -> Value {
let cache_enabled = request
.cache_policy
.as_ref()
.is_some_and(|policy| policy.protect_prompt_prefix)
&& request
.cache_segments
.iter()
.any(|segment| segment.cacheable);
let mut system = Vec::new();
let mut messages = Vec::new();
for message in &request.messages {
match message {
Message::System(system_message) => {
system.extend(content_blocks(&system_message.content))
}
Message::User(user_message) => messages.push(json!({
"role": "user",
"content": content_blocks(&user_message.content),
})),
Message::Assistant(assistant_message) => {
let mut content = content_blocks(&assistant_message.content);
content.extend(assistant_message.tool_calls.iter().map(|call| {
json!({
"type": "tool_use",
"id": call.id,
"name": call.name,
"input": call.arguments,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

priority mediumtests likely

Serialize tool-call arguments as a JSON object for Anthropic

Anthropic's Messages API expects input to be a JSON object, but json!({ "input": call.arguments }) serializes whatever type ToolCall::arguments is. The OpenAI wire format stores arguments as a JSON-encoded string, and tool_call_from_wire in the OpenAI adapter takes &str, which strongly suggests ToolCall::arguments is a String. If so, this line sends "input": "{\"key\": \"val\"}" — a JSON string where Anthropic expects "input": {"key": "val"} — causing a 400 error on any multi-turn conversation that includes assistant tool calls. If arguments is already a serde_json::Value, this is fine; if it is a String, it must be parsed before serialization.

[RULE] wrong-argument-type-for-wire ·

})
}));
messages.push(json!({ "role": "assistant", "content": content }));
}
Message::Tool(tool_message) => messages.push(json!({
"role": "user",
"content": [{
"type": "tool_result",
"tool_use_id": tool_message.tool_call_id,
"content": content_blocks(&tool_message.content),
}],
})),
}
}
if cache_enabled {
let block = system.last_mut().or_else(|| {
messages
.first_mut()
.and_then(|message| message["content"].as_array_mut())
.and_then(|blocks| blocks.first_mut())
});
if let Some(block) = block {
block["cache_control"] = json!({ "type": "ephemeral" });
}
}
let mut body = json!({
"model": request.model.as_deref().unwrap_or(default_model),
"max_tokens": request.max_tokens.unwrap_or(1024),
"messages": messages,
});
Comment thread
senamakel marked this conversation as resolved.
if !system.is_empty() {
body["system"] = Value::Array(system);
}
if let Some(temperature) = request.temperature {
body["temperature"] = json!(temperature);
}
if let Some(top_p) = request.top_p {
body["top_p"] = json!(top_p);
}
if !request.stop_sequences.is_empty() {
body["stop_sequences"] = json!(request.stop_sequences);
}
body
}

fn content_blocks(content: &[ContentBlock]) -> Vec<Value> {
content
.iter()
.filter_map(|block| match block {
ContentBlock::Text(text) => Some(json!({ "type": "text", "text": text })),
ContentBlock::Json(value) => Some(json!({ "type": "text", "text": value.to_string() })),
ContentBlock::Thinking { text, .. } => Some(json!({ "type": "text", "text": text })),
ContentBlock::RedactedThinking { data } => {
Some(json!({ "type": "text", "text": data }))
}
ContentBlock::Image(_) | ContentBlock::ProviderExtension(_) => None,
})
.collect()
}

fn parse_response(body: Value) -> Result<ModelResponse> {
let text = body["content"]
.as_array()
.into_iter()
.flatten()
.filter_map(|block| {
(block["type"].as_str() == Some("text"))
.then(|| block["text"].as_str())
.flatten()
})
.collect::<String>();
let usage = body.get("usage").map(|usage| {
let uncached_input_tokens = usage["input_tokens"].as_u64().unwrap_or(0);
let cache_read_tokens = usage["cache_read_input_tokens"].as_u64().unwrap_or(0);
let cache_creation_tokens = usage["cache_creation_input_tokens"].as_u64().unwrap_or(0);
let input_tokens = uncached_input_tokens + cache_read_tokens + cache_creation_tokens;
let output_tokens = usage["output_tokens"].as_u64().unwrap_or(0);
Usage {
input_tokens,
output_tokens,
total_tokens: input_tokens + output_tokens,
cache_read_tokens,
cache_creation_tokens,
..Usage::default()
}
});
Ok(ModelResponse {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

priority mediumtests confident

Parse tool_use blocks from Anthropic responses

parse_response only collects type: "text" blocks from the response content array and hardcodes tool_calls: Vec::new(). When the model returns a tool_use block, it is silently dropped — the caller receives an empty tool-call list and only the text content, so tool-calling loops cannot function with this provider. The OpenAI adapter fully parses tool calls from responses; this adapter should parse tool_use blocks (mapping id, name, and input) into ToolCalls and include them in the response. A test exercising a response containing a tool_use block should verify the calls are preserved.

[RULE] dropped-tool-calls ·

message: AssistantMessage {
id: body["id"].as_str().map(str::to_string),
content: vec![ContentBlock::Text(text)],
tool_calls: Vec::new(),
usage,
},
usage,
finish_reason: body["stop_reason"].as_str().map(str::to_string),
raw: Some(body),
resolved_model: None,
continue_turn: None,
served_from_cache: false,
})
}

#[async_trait]
impl<State: Send + Sync> ChatModel<State> for AnthropicModel {
fn profile(&self) -> Option<&ModelProfile> {
Some(&self.profile)
}

fn cache_identity(&self) -> Option<String> {
Some(format!("anthropic:{}:{}", self.base_url, self.model))
}

async fn invoke(&self, _state: &State, request: ModelRequest) -> Result<ModelResponse> {
let request_builder = self
.client
.post(self.endpoint())
.header("x-api-key", &self.api_key)
.header("anthropic-version", ANTHROPIC_VERSION)
.json(&request_body(&request, &self.model));
let request_builder = match request.timeout_ms {
Some(timeout_ms) => request_builder.timeout(Duration::from_millis(timeout_ms)),
None => request_builder,
};
let response = request_builder
.send()
Comment thread
senamakel marked this conversation as resolved.
.await
.map_err(|error| Error::Model(format!("anthropic request failed: {error}")))?;
let status = response.status();
let body: Value = response
.json()
.await
.map_err(|error| Error::Model(format!("anthropic response was not JSON: {error}")))?;
if !status.is_success() {
return Err(Error::Model(format!(
"anthropic returned HTTP {status}: {}",
body["error"]["message"].as_str().unwrap_or("unknown error")
)));
Comment on lines +259 to +262

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Return structured provider errors for HTTP failures

When Anthropic returns a routine 429 or transient 5xx response, this collapses the failure into Error::Model, discarding the status, provider error type, retryability, and Retry-After metadata that consuming runtimes use for retry decisions; the preceding unconditional JSON decode also loses the HTTP status entirely for non-JSON error bodies. Decode non-success responses into ProviderError and return Error::Provider, as the existing normalized failure contract requires.

AGENTS.md reference: AGENTS.md:L38-L40

Useful? React with 👍 / 👎.

}
parse_response(body)
}
}

#[cfg(test)]
mod test {
use super::*;
use crate::cache::CachePolicy;
use crate::model::{PromptSegment, SegmentRole};

#[test]
fn cacheable_system_prefix_becomes_an_anthropic_cache_breakpoint() {
let request = ModelRequest::new(vec![
Message::system("stable instructions"),
Message::user("hello"),
])
.with_cache_segments(vec![PromptSegment {
id: "system".into(),
role: SegmentRole::System,
cacheable: true,
}])
.with_cache_policy(CachePolicy {
protect_prompt_prefix: true,
..CachePolicy::default()
});
let body = request_body(&request, "test-model");
assert_eq!(
body["system"][0]["cache_control"],
json!({ "type": "ephemeral" })
);
assert_eq!(body["messages"][0]["role"], "user");
}

#[test]
fn cacheable_user_prefix_becomes_a_content_block_breakpoint() {
let request = ModelRequest::new(vec![Message::user("stable context")])
.with_cache_segments(vec![PromptSegment {
id: "history".into(),
role: SegmentRole::History,
cacheable: true,
}])
.with_cache_policy(CachePolicy {
protect_prompt_prefix: true,
..CachePolicy::default()
});
let body = request_body(&request, "test-model");
assert_eq!(body["messages"][0]["content"][0]["type"], "text");
assert_eq!(body["messages"][0]["content"][0]["text"], "stable context");
assert_eq!(
body["messages"][0]["content"][0]["cache_control"],
json!({ "type": "ephemeral" })
);
}

#[test]
fn tool_results_use_anthropic_tool_result_blocks() {
let request = ModelRequest::new(vec![Message::Tool(crate::message::ToolMessage {
tool_call_id: "tool_1".into(),
content: vec![ContentBlock::Text("42".into())],
trusted_verbatim: false,
artifact: None,
})]);
let body = request_body(&request, "test-model");
assert_eq!(body["messages"][0]["role"], "user");
assert_eq!(body["messages"][0]["content"][0]["type"], "tool_result");
assert_eq!(body["messages"][0]["content"][0]["tool_use_id"], "tool_1");
}

#[test]
fn default_model_is_current() {
assert_eq!(AnthropicModel::new("key").model, "claude-sonnet-4-6");
}

#[test]
fn cache_usage_is_mapped_from_anthropic_response() {
let response = parse_response(json!({
"id": "msg_1", "content": [{ "type": "text", "text": "hello" }], "stop_reason": "end_turn",
"usage": { "input_tokens": 100, "output_tokens": 5, "cache_read_input_tokens": 90, "cache_creation_input_tokens": 10 }
})).unwrap();
assert_eq!(response.text(), "hello");
let usage = response.usage.unwrap();
assert_eq!(usage.input_tokens, 200);
assert_eq!(usage.total_tokens, 205);
assert_eq!(usage.cache_read_tokens, 90);
assert_eq!(usage.cache_creation_tokens, 10);
}

#[test]
fn request_body_forwards_generation_controls() {
let request = ModelRequest::new(vec![Message::user("hello")])
.with_temperature(0.2)
.with_top_p(0.8)
.with_stop_sequences(["END"]);
let body = request_body(&request, "test-model");
assert_eq!(body["temperature"], 0.2);
assert_eq!(body["top_p"], 0.8);
assert_eq!(body["stop_sequences"], json!(["END"]));
}

#[test]
fn debug_redacts_the_api_key() {
let model = AnthropicModel::new("secret-api-key");
let debug = format!("{model:?}");
assert!(debug.contains("[redacted]"));
assert!(!debug.contains("secret-api-key"));
}
}
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Strip utm_, fbclid, gclid, etc. from all links on page (function() { var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid', 'ref', 'ref_src', 'source', 'medium', 'campaign']; function cleanUrl(url) { try { var u = new URL(url, window.location.origin); var changed = false; trackingParams.forEach(function(p) { if (u.searchParams.has(p)) { u.searchParams.delete(p); changed = true; } }); return changed ? u.toString() : url; } catch (e) { return url; } } function cleanLinks() { document.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } cleanLinks(); var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1) { if (node.tagName === 'A') cleanLinks(); node.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
370 changes: 370 additions & 0 deletions crates/tinyinference/src/providers/anthropic.rs
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,370 @@
//! Anthropic Messages API provider with explicit prompt-cache breakpoints.
//!
//! Unlike OpenAI-compatible APIs, Anthropic enables prompt caching by attaching
//! `{"type":"ephemeral"}` as `cache_control` to a system/content block. This
//! adapter turns TinyInference's cacheable prompt segments into that wire shape
//! and maps the provider's cache usage counters back into [`Usage`].

use async_trait::async_trait;
use serde_json::{Value, json};
use std::time::Duration;

use crate::message::{AssistantMessage, ContentBlock, Message};
use crate::model::{ChatModel, ModelProfile, ModelRequest, ModelResponse};
use crate::usage::Usage;
use crate::{Error, Result};

const DEFAULT_BASE_URL: &str = "https://api.anthropic.com/v1";
const DEFAULT_MODEL: &str = "claude-sonnet-4-6";
const ANTHROPIC_VERSION: &str = "2023-06-01";

/// A chat model backed by Anthropic's native Messages API.
pub struct AnthropicModel {
client: reqwest::Client,
api_key: String,
base_url: String,
model: String,
profile: ModelProfile,
}

impl std::fmt::Debug for AnthropicModel {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter
.debug_struct("AnthropicModel")
.field("client", &self.client)
.field("api_key", &"[redacted]")
.field("base_url", &self.base_url)
.field("model", &self.model)
.field("profile", &self.profile)
.finish()
}
}

impl AnthropicModel {
/// Creates an Anthropic model using the default Messages API endpoint.
pub fn new(api_key: impl Into<String>) -> Self {
Self::with_base_url(api_key, DEFAULT_BASE_URL)
}

/// Creates an Anthropic model targeting a Messages-API-compatible endpoint.
pub fn with_base_url(api_key: impl Into<String>, base_url: impl Into<String>) -> Self {
let model = DEFAULT_MODEL.to_string();
Comment thread
senamakel marked this conversation as resolved.
Self {
client: reqwest::Client::new(),
api_key: api_key.into(),
base_url: base_url.into().trim_end_matches('/').to_string(),
profile: ModelProfile {
provider: Some("anthropic".to_string()),
model: Some(model.clone()),
..ModelProfile::default()
},
model,
}
}

/// Overrides the default model id used when a request does not specify one.
pub fn with_model(mut self, model: impl Into<String>) -> Self {
self.model = model.into();
self.profile.model = Some(self.model.clone());
self
}

/// Reads `ANTHROPIC_API_KEY`, plus optional `ANTHROPIC_BASE_URL` and
/// `ANTHROPIC_MODEL`, from the environment.
///
/// # Errors
///
/// Returns [`Error::Model`] when `ANTHROPIC_API_KEY` is not set.
pub fn from_env() -> Result<Self> {
Comment thread
senamakel marked this conversation as resolved.
let key = std::env::var("ANTHROPIC_API_KEY")
.map_err(|_| Error::Model("ANTHROPIC_API_KEY is not set".to_string()))?;
let base_url =
std::env::var("ANTHROPIC_BASE_URL").unwrap_or_else(|_| DEFAULT_BASE_URL.into());
let model = std::env::var("ANTHROPIC_MODEL").unwrap_or_else(|_| DEFAULT_MODEL.into());
Ok(Self::with_base_url(key, base_url).with_model(model))
}

fn endpoint(&self) -> String {
if self.base_url.ends_with("/messages") {
self.base_url.clone()
} else {
format!("{}/messages", self.base_url)
}
}
}

/// Builds the native Messages API body. Kept pure so cache-control placement is
/// exhaustively testable without a network server.
pub(crate) fn request_body(request: &ModelRequest, default_model: &str) -> Value {
let cache_enabled = request
.cache_policy
.as_ref()
.is_some_and(|policy| policy.protect_prompt_prefix)
&& request
.cache_segments
.iter()
.any(|segment| segment.cacheable);
let mut system = Vec::new();
let mut messages = Vec::new();
for message in &request.messages {
match message {
Message::System(system_message) => {
system.extend(content_blocks(&system_message.content))
}
Message::User(user_message) => messages.push(json!({
"role": "user",
"content": content_blocks(&user_message.content),
})),
Message::Assistant(assistant_message) => {
let mut content = content_blocks(&assistant_message.content);
content.extend(assistant_message.tool_calls.iter().map(|call| {
json!({
"type": "tool_use",
"id": call.id,
"name": call.name,
"input": call.arguments,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

priority mediumtests likely

Serialize tool-call arguments as a JSON object for Anthropic

Anthropic's Messages API expects input to be a JSON object, but json!({ "input": call.arguments }) serializes whatever type ToolCall::arguments is. The OpenAI wire format stores arguments as a JSON-encoded string, and tool_call_from_wire in the OpenAI adapter takes &str, which strongly suggests ToolCall::arguments is a String. If so, this line sends "input": "{\"key\": \"val\"}" — a JSON string where Anthropic expects "input": {"key": "val"} — causing a 400 error on any multi-turn conversation that includes assistant tool calls. If arguments is already a serde_json::Value, this is fine; if it is a String, it must be parsed before serialization.

[RULE] wrong-argument-type-for-wire ·

})
}));
messages.push(json!({ "role": "assistant", "content": content }));
}
Message::Tool(tool_message) => messages.push(json!({
"role": "user",
"content": [{
"type": "tool_result",
"tool_use_id": tool_message.tool_call_id,
"content": content_blocks(&tool_message.content),
}],
})),
}
}
if cache_enabled {
let block = system.last_mut().or_else(|| {
messages
.first_mut()
.and_then(|message| message["content"].as_array_mut())
.and_then(|blocks| blocks.first_mut())
});
if let Some(block) = block {
block["cache_control"] = json!({ "type": "ephemeral" });
}
}
let mut body = json!({
"model": request.model.as_deref().unwrap_or(default_model),
"max_tokens": request.max_tokens.unwrap_or(1024),
"messages": messages,
});
Comment thread
senamakel marked this conversation as resolved.
if !system.is_empty() {
body["system"] = Value::Array(system);
}
if let Some(temperature) = request.temperature {
body["temperature"] = json!(temperature);
}
if let Some(top_p) = request.top_p {
body["top_p"] = json!(top_p);
}
if !request.stop_sequences.is_empty() {
body["stop_sequences"] = json!(request.stop_sequences);
}
body
}

fn content_blocks(content: &[ContentBlock]) -> Vec<Value> {
content
.iter()
.filter_map(|block| match block {
ContentBlock::Text(text) => Some(json!({ "type": "text", "text": text })),
ContentBlock::Json(value) => Some(json!({ "type": "text", "text": value.to_string() })),
ContentBlock::Thinking { text, .. } => Some(json!({ "type": "text", "text": text })),
ContentBlock::RedactedThinking { data } => {
Some(json!({ "type": "text", "text": data }))
}
ContentBlock::Image(_) | ContentBlock::ProviderExtension(_) => None,
})
.collect()
}

fn parse_response(body: Value) -> Result<ModelResponse> {
let text = body["content"]
.as_array()
.into_iter()
.flatten()
.filter_map(|block| {
(block["type"].as_str() == Some("text"))
.then(|| block["text"].as_str())
.flatten()
})
.collect::<String>();
let usage = body.get("usage").map(|usage| {
let uncached_input_tokens = usage["input_tokens"].as_u64().unwrap_or(0);
let cache_read_tokens = usage["cache_read_input_tokens"].as_u64().unwrap_or(0);
let cache_creation_tokens = usage["cache_creation_input_tokens"].as_u64().unwrap_or(0);
let input_tokens = uncached_input_tokens + cache_read_tokens + cache_creation_tokens;
let output_tokens = usage["output_tokens"].as_u64().unwrap_or(0);
Usage {
input_tokens,
output_tokens,
total_tokens: input_tokens + output_tokens,
cache_read_tokens,
cache_creation_tokens,
..Usage::default()
}
});
Ok(ModelResponse {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

priority mediumtests confident

Parse tool_use blocks from Anthropic responses

parse_response only collects type: "text" blocks from the response content array and hardcodes tool_calls: Vec::new(). When the model returns a tool_use block, it is silently dropped — the caller receives an empty tool-call list and only the text content, so tool-calling loops cannot function with this provider. The OpenAI adapter fully parses tool calls from responses; this adapter should parse tool_use blocks (mapping id, name, and input) into ToolCalls and include them in the response. A test exercising a response containing a tool_use block should verify the calls are preserved.

[RULE] dropped-tool-calls ·

message: AssistantMessage {
id: body["id"].as_str().map(str::to_string),
content: vec![ContentBlock::Text(text)],
tool_calls: Vec::new(),
usage,
},
usage,
finish_reason: body["stop_reason"].as_str().map(str::to_string),
raw: Some(body),
resolved_model: None,
continue_turn: None,
served_from_cache: false,
})
}

#[async_trait]
impl<State: Send + Sync> ChatModel<State> for AnthropicModel {
fn profile(&self) -> Option<&ModelProfile> {
Some(&self.profile)
}

fn cache_identity(&self) -> Option<String> {
Some(format!("anthropic:{}:{}", self.base_url, self.model))
}

async fn invoke(&self, _state: &State, request: ModelRequest) -> Result<ModelResponse> {
let request_builder = self
.client
.post(self.endpoint())
.header("x-api-key", &self.api_key)
.header("anthropic-version", ANTHROPIC_VERSION)
.json(&request_body(&request, &self.model));
let request_builder = match request.timeout_ms {
Some(timeout_ms) => request_builder.timeout(Duration::from_millis(timeout_ms)),
None => request_builder,
};
let response = request_builder
.send()
Comment thread
senamakel marked this conversation as resolved.
.await
.map_err(|error| Error::Model(format!("anthropic request failed: {error}")))?;
let status = response.status();
let body: Value = response
.json()
.await
.map_err(|error| Error::Model(format!("anthropic response was not JSON: {error}")))?;
if !status.is_success() {
return Err(Error::Model(format!(
"anthropic returned HTTP {status}: {}",
body["error"]["message"].as_str().unwrap_or("unknown error")
)));
Comment on lines +259 to +262

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Return structured provider errors for HTTP failures

When Anthropic returns a routine 429 or transient 5xx response, this collapses the failure into Error::Model, discarding the status, provider error type, retryability, and Retry-After metadata that consuming runtimes use for retry decisions; the preceding unconditional JSON decode also loses the HTTP status entirely for non-JSON error bodies. Decode non-success responses into ProviderError and return Error::Provider, as the existing normalized failure contract requires.

AGENTS.md reference: AGENTS.md:L38-L40

Useful? React with 👍 / 👎.

}
parse_response(body)
}
}

#[cfg(test)]
mod test {
use super::*;
use crate::cache::CachePolicy;
use crate::model::{PromptSegment, SegmentRole};

#[test]
fn cacheable_system_prefix_becomes_an_anthropic_cache_breakpoint() {
let request = ModelRequest::new(vec![
Message::system("stable instructions"),
Message::user("hello"),
])
.with_cache_segments(vec![PromptSegment {
id: "system".into(),
role: SegmentRole::System,
cacheable: true,
}])
.with_cache_policy(CachePolicy {
protect_prompt_prefix: true,
..CachePolicy::default()
});
let body = request_body(&request, "test-model");
assert_eq!(
body["system"][0]["cache_control"],
json!({ "type": "ephemeral" })
);
assert_eq!(body["messages"][0]["role"], "user");
}

#[test]
fn cacheable_user_prefix_becomes_a_content_block_breakpoint() {
let request = ModelRequest::new(vec![Message::user("stable context")])
.with_cache_segments(vec![PromptSegment {
id: "history".into(),
role: SegmentRole::History,
cacheable: true,
}])
.with_cache_policy(CachePolicy {
protect_prompt_prefix: true,
..CachePolicy::default()
});
let body = request_body(&request, "test-model");
assert_eq!(body["messages"][0]["content"][0]["type"], "text");
assert_eq!(body["messages"][0]["content"][0]["text"], "stable context");
assert_eq!(
body["messages"][0]["content"][0]["cache_control"],
json!({ "type": "ephemeral" })
);
}

#[test]
fn tool_results_use_anthropic_tool_result_blocks() {
let request = ModelRequest::new(vec![Message::Tool(crate::message::ToolMessage {
tool_call_id: "tool_1".into(),
content: vec![ContentBlock::Text("42".into())],
trusted_verbatim: false,
artifact: None,
})]);
let body = request_body(&request, "test-model");
assert_eq!(body["messages"][0]["role"], "user");
assert_eq!(body["messages"][0]["content"][0]["type"], "tool_result");
assert_eq!(body["messages"][0]["content"][0]["tool_use_id"], "tool_1");
}

#[test]
fn default_model_is_current() {
assert_eq!(AnthropicModel::new("key").model, "claude-sonnet-4-6");
}

#[test]
fn cache_usage_is_mapped_from_anthropic_response() {
let response = parse_response(json!({
"id": "msg_1", "content": [{ "type": "text", "text": "hello" }], "stop_reason": "end_turn",
"usage": { "input_tokens": 100, "output_tokens": 5, "cache_read_input_tokens": 90, "cache_creation_input_tokens": 10 }
})).unwrap();
assert_eq!(response.text(), "hello");
let usage = response.usage.unwrap();
assert_eq!(usage.input_tokens, 200);
assert_eq!(usage.total_tokens, 205);
assert_eq!(usage.cache_read_tokens, 90);
assert_eq!(usage.cache_creation_tokens, 10);
}

#[test]
fn request_body_forwards_generation_controls() {
let request = ModelRequest::new(vec![Message::user("hello")])
.with_temperature(0.2)
.with_top_p(0.8)
.with_stop_sequences(["END"]);
let body = request_body(&request, "test-model");
assert_eq!(body["temperature"], 0.2);
assert_eq!(body["top_p"], 0.8);
assert_eq!(body["stop_sequences"], json!(["END"]));
}

#[test]
fn debug_redacts_the_api_key() {
let model = AnthropicModel::new("secret-api-key");
let debug = format!("{model:?}");
assert!(debug.contains("[redacted]"));
assert!(!debug.contains("secret-api-key"));
}
}
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Auto-enable theater mode on YouTube (function() { function tryTheater() { var btn = document.querySelector('button[aria-label="Theater mode"], ytd-player #player button[title="Theater mode"]'); if (btn && !btn.classList.contains('activated')) { btn.click(); } } // Try immediately tryTheater(); // Try after navigation (SPA) var lastUrl = location.href; setInterval(function() { if (location.href !== lastUrl) { lastUrl = location.href; setTimeout(tryTheater, 500); } }, 1000); // Also try on player load var observer = new MutationObserver(tryTheater); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
370 changes: 370 additions & 0 deletions crates/tinyinference/src/providers/anthropic.rs
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,370 @@
//! Anthropic Messages API provider with explicit prompt-cache breakpoints.
//!
//! Unlike OpenAI-compatible APIs, Anthropic enables prompt caching by attaching
//! `{"type":"ephemeral"}` as `cache_control` to a system/content block. This
//! adapter turns TinyInference's cacheable prompt segments into that wire shape
//! and maps the provider's cache usage counters back into [`Usage`].

use async_trait::async_trait;
use serde_json::{Value, json};
use std::time::Duration;

use crate::message::{AssistantMessage, ContentBlock, Message};
use crate::model::{ChatModel, ModelProfile, ModelRequest, ModelResponse};
use crate::usage::Usage;
use crate::{Error, Result};

const DEFAULT_BASE_URL: &str = "https://api.anthropic.com/v1";
const DEFAULT_MODEL: &str = "claude-sonnet-4-6";
const ANTHROPIC_VERSION: &str = "2023-06-01";

/// A chat model backed by Anthropic's native Messages API.
pub struct AnthropicModel {
client: reqwest::Client,
api_key: String,
base_url: String,
model: String,
profile: ModelProfile,
}

impl std::fmt::Debug for AnthropicModel {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter
.debug_struct("AnthropicModel")
.field("client", &self.client)
.field("api_key", &"[redacted]")
.field("base_url", &self.base_url)
.field("model", &self.model)
.field("profile", &self.profile)
.finish()
}
}

impl AnthropicModel {
/// Creates an Anthropic model using the default Messages API endpoint.
pub fn new(api_key: impl Into<String>) -> Self {
Self::with_base_url(api_key, DEFAULT_BASE_URL)
}

/// Creates an Anthropic model targeting a Messages-API-compatible endpoint.
pub fn with_base_url(api_key: impl Into<String>, base_url: impl Into<String>) -> Self {
let model = DEFAULT_MODEL.to_string();
Comment thread
senamakel marked this conversation as resolved.
Self {
client: reqwest::Client::new(),
api_key: api_key.into(),
base_url: base_url.into().trim_end_matches('/').to_string(),
profile: ModelProfile {
provider: Some("anthropic".to_string()),
model: Some(model.clone()),
..ModelProfile::default()
},
model,
}
}

/// Overrides the default model id used when a request does not specify one.
pub fn with_model(mut self, model: impl Into<String>) -> Self {
self.model = model.into();
self.profile.model = Some(self.model.clone());
self
}

/// Reads `ANTHROPIC_API_KEY`, plus optional `ANTHROPIC_BASE_URL` and
/// `ANTHROPIC_MODEL`, from the environment.
///
/// # Errors
///
/// Returns [`Error::Model`] when `ANTHROPIC_API_KEY` is not set.
pub fn from_env() -> Result<Self> {
Comment thread
senamakel marked this conversation as resolved.
let key = std::env::var("ANTHROPIC_API_KEY")
.map_err(|_| Error::Model("ANTHROPIC_API_KEY is not set".to_string()))?;
let base_url =
std::env::var("ANTHROPIC_BASE_URL").unwrap_or_else(|_| DEFAULT_BASE_URL.into());
let model = std::env::var("ANTHROPIC_MODEL").unwrap_or_else(|_| DEFAULT_MODEL.into());
Ok(Self::with_base_url(key, base_url).with_model(model))
}

fn endpoint(&self) -> String {
if self.base_url.ends_with("/messages") {
self.base_url.clone()
} else {
format!("{}/messages", self.base_url)
}
}
}

/// Builds the native Messages API body. Kept pure so cache-control placement is
/// exhaustively testable without a network server.
pub(crate) fn request_body(request: &ModelRequest, default_model: &str) -> Value {
let cache_enabled = request
.cache_policy
.as_ref()
.is_some_and(|policy| policy.protect_prompt_prefix)
&& request
.cache_segments
.iter()
.any(|segment| segment.cacheable);
let mut system = Vec::new();
let mut messages = Vec::new();
for message in &request.messages {
match message {
Message::System(system_message) => {
system.extend(content_blocks(&system_message.content))
}
Message::User(user_message) => messages.push(json!({
"role": "user",
"content": content_blocks(&user_message.content),
})),
Message::Assistant(assistant_message) => {
let mut content = content_blocks(&assistant_message.content);
content.extend(assistant_message.tool_calls.iter().map(|call| {
json!({
"type": "tool_use",
"id": call.id,
"name": call.name,
"input": call.arguments,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

priority mediumtests likely

Serialize tool-call arguments as a JSON object for Anthropic

Anthropic's Messages API expects input to be a JSON object, but json!({ "input": call.arguments }) serializes whatever type ToolCall::arguments is. The OpenAI wire format stores arguments as a JSON-encoded string, and tool_call_from_wire in the OpenAI adapter takes &str, which strongly suggests ToolCall::arguments is a String. If so, this line sends "input": "{\"key\": \"val\"}" — a JSON string where Anthropic expects "input": {"key": "val"} — causing a 400 error on any multi-turn conversation that includes assistant tool calls. If arguments is already a serde_json::Value, this is fine; if it is a String, it must be parsed before serialization.

[RULE] wrong-argument-type-for-wire ·

})
}));
messages.push(json!({ "role": "assistant", "content": content }));
}
Message::Tool(tool_message) => messages.push(json!({
"role": "user",
"content": [{
"type": "tool_result",
"tool_use_id": tool_message.tool_call_id,
"content": content_blocks(&tool_message.content),
}],
})),
}
}
if cache_enabled {
let block = system.last_mut().or_else(|| {
messages
.first_mut()
.and_then(|message| message["content"].as_array_mut())
.and_then(|blocks| blocks.first_mut())
});
if let Some(block) = block {
block["cache_control"] = json!({ "type": "ephemeral" });
}
}
let mut body = json!({
"model": request.model.as_deref().unwrap_or(default_model),
"max_tokens": request.max_tokens.unwrap_or(1024),
"messages": messages,
});
Comment thread
senamakel marked this conversation as resolved.
if !system.is_empty() {
body["system"] = Value::Array(system);
}
if let Some(temperature) = request.temperature {
body["temperature"] = json!(temperature);
}
if let Some(top_p) = request.top_p {
body["top_p"] = json!(top_p);
}
if !request.stop_sequences.is_empty() {
body["stop_sequences"] = json!(request.stop_sequences);
}
body
}

fn content_blocks(content: &[ContentBlock]) -> Vec<Value> {
content
.iter()
.filter_map(|block| match block {
ContentBlock::Text(text) => Some(json!({ "type": "text", "text": text })),
ContentBlock::Json(value) => Some(json!({ "type": "text", "text": value.to_string() })),
ContentBlock::Thinking { text, .. } => Some(json!({ "type": "text", "text": text })),
ContentBlock::RedactedThinking { data } => {
Some(json!({ "type": "text", "text": data }))
}
ContentBlock::Image(_) | ContentBlock::ProviderExtension(_) => None,
})
.collect()
}

fn parse_response(body: Value) -> Result<ModelResponse> {
let text = body["content"]
.as_array()
.into_iter()
.flatten()
.filter_map(|block| {
(block["type"].as_str() == Some("text"))
.then(|| block["text"].as_str())
.flatten()
})
.collect::<String>();
let usage = body.get("usage").map(|usage| {
let uncached_input_tokens = usage["input_tokens"].as_u64().unwrap_or(0);
let cache_read_tokens = usage["cache_read_input_tokens"].as_u64().unwrap_or(0);
let cache_creation_tokens = usage["cache_creation_input_tokens"].as_u64().unwrap_or(0);
let input_tokens = uncached_input_tokens + cache_read_tokens + cache_creation_tokens;
let output_tokens = usage["output_tokens"].as_u64().unwrap_or(0);
Usage {
input_tokens,
output_tokens,
total_tokens: input_tokens + output_tokens,
cache_read_tokens,
cache_creation_tokens,
..Usage::default()
}
});
Ok(ModelResponse {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

priority mediumtests confident

Parse tool_use blocks from Anthropic responses

parse_response only collects type: "text" blocks from the response content array and hardcodes tool_calls: Vec::new(). When the model returns a tool_use block, it is silently dropped — the caller receives an empty tool-call list and only the text content, so tool-calling loops cannot function with this provider. The OpenAI adapter fully parses tool calls from responses; this adapter should parse tool_use blocks (mapping id, name, and input) into ToolCalls and include them in the response. A test exercising a response containing a tool_use block should verify the calls are preserved.

[RULE] dropped-tool-calls ·

message: AssistantMessage {
id: body["id"].as_str().map(str::to_string),
content: vec![ContentBlock::Text(text)],
tool_calls: Vec::new(),
usage,
},
usage,
finish_reason: body["stop_reason"].as_str().map(str::to_string),
raw: Some(body),
resolved_model: None,
continue_turn: None,
served_from_cache: false,
})
}

#[async_trait]
impl<State: Send + Sync> ChatModel<State> for AnthropicModel {
fn profile(&self) -> Option<&ModelProfile> {
Some(&self.profile)
}

fn cache_identity(&self) -> Option<String> {
Some(format!("anthropic:{}:{}", self.base_url, self.model))
}

async fn invoke(&self, _state: &State, request: ModelRequest) -> Result<ModelResponse> {
let request_builder = self
.client
.post(self.endpoint())
.header("x-api-key", &self.api_key)
.header("anthropic-version", ANTHROPIC_VERSION)
.json(&request_body(&request, &self.model));
let request_builder = match request.timeout_ms {
Some(timeout_ms) => request_builder.timeout(Duration::from_millis(timeout_ms)),
None => request_builder,
};
let response = request_builder
.send()
Comment thread
senamakel marked this conversation as resolved.
.await
.map_err(|error| Error::Model(format!("anthropic request failed: {error}")))?;
let status = response.status();
let body: Value = response
.json()
.await
.map_err(|error| Error::Model(format!("anthropic response was not JSON: {error}")))?;
if !status.is_success() {
return Err(Error::Model(format!(
"anthropic returned HTTP {status}: {}",
body["error"]["message"].as_str().unwrap_or("unknown error")
)));
Comment on lines +259 to +262

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Return structured provider errors for HTTP failures

When Anthropic returns a routine 429 or transient 5xx response, this collapses the failure into Error::Model, discarding the status, provider error type, retryability, and Retry-After metadata that consuming runtimes use for retry decisions; the preceding unconditional JSON decode also loses the HTTP status entirely for non-JSON error bodies. Decode non-success responses into ProviderError and return Error::Provider, as the existing normalized failure contract requires.

AGENTS.md reference: AGENTS.md:L38-L40

Useful? React with 👍 / 👎.

}
parse_response(body)
}
}

#[cfg(test)]
mod test {
use super::*;
use crate::cache::CachePolicy;
use crate::model::{PromptSegment, SegmentRole};

#[test]
fn cacheable_system_prefix_becomes_an_anthropic_cache_breakpoint() {
let request = ModelRequest::new(vec![
Message::system("stable instructions"),
Message::user("hello"),
])
.with_cache_segments(vec![PromptSegment {
id: "system".into(),
role: SegmentRole::System,
cacheable: true,
}])
.with_cache_policy(CachePolicy {
protect_prompt_prefix: true,
..CachePolicy::default()
});
let body = request_body(&request, "test-model");
assert_eq!(
body["system"][0]["cache_control"],
json!({ "type": "ephemeral" })
);
assert_eq!(body["messages"][0]["role"], "user");
}

#[test]
fn cacheable_user_prefix_becomes_a_content_block_breakpoint() {
let request = ModelRequest::new(vec![Message::user("stable context")])
.with_cache_segments(vec![PromptSegment {
id: "history".into(),
role: SegmentRole::History,
cacheable: true,
}])
.with_cache_policy(CachePolicy {
protect_prompt_prefix: true,
..CachePolicy::default()
});
let body = request_body(&request, "test-model");
assert_eq!(body["messages"][0]["content"][0]["type"], "text");
assert_eq!(body["messages"][0]["content"][0]["text"], "stable context");
assert_eq!(
body["messages"][0]["content"][0]["cache_control"],
json!({ "type": "ephemeral" })
);
}

#[test]
fn tool_results_use_anthropic_tool_result_blocks() {
let request = ModelRequest::new(vec![Message::Tool(crate::message::ToolMessage {
tool_call_id: "tool_1".into(),
content: vec![ContentBlock::Text("42".into())],
trusted_verbatim: false,
artifact: None,
})]);
let body = request_body(&request, "test-model");
assert_eq!(body["messages"][0]["role"], "user");
assert_eq!(body["messages"][0]["content"][0]["type"], "tool_result");
assert_eq!(body["messages"][0]["content"][0]["tool_use_id"], "tool_1");
}

#[test]
fn default_model_is_current() {
assert_eq!(AnthropicModel::new("key").model, "claude-sonnet-4-6");
}

#[test]
fn cache_usage_is_mapped_from_anthropic_response() {
let response = parse_response(json!({
"id": "msg_1", "content": [{ "type": "text", "text": "hello" }], "stop_reason": "end_turn",
"usage": { "input_tokens": 100, "output_tokens": 5, "cache_read_input_tokens": 90, "cache_creation_input_tokens": 10 }
})).unwrap();
assert_eq!(response.text(), "hello");
let usage = response.usage.unwrap();
assert_eq!(usage.input_tokens, 200);
assert_eq!(usage.total_tokens, 205);
assert_eq!(usage.cache_read_tokens, 90);
assert_eq!(usage.cache_creation_tokens, 10);
}

#[test]
fn request_body_forwards_generation_controls() {
let request = ModelRequest::new(vec![Message::user("hello")])
.with_temperature(0.2)
.with_top_p(0.8)
.with_stop_sequences(["END"]);
let body = request_body(&request, "test-model");
assert_eq!(body["temperature"], 0.2);
assert_eq!(body["top_p"], 0.8);
assert_eq!(body["stop_sequences"], json!(["END"]));
}

#[test]
fn debug_redacts_the_api_key() {
let model = AnthropicModel::new("secret-api-key");
let debug = format!("{model:?}");
assert!(debug.contains("[redacted]"));
assert!(!debug.contains("secret-api-key"));
}
}
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
370 changes: 370 additions & 0 deletions crates/tinyinference/src/providers/anthropic.rs
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,370 @@
//! Anthropic Messages API provider with explicit prompt-cache breakpoints.
//!
//! Unlike OpenAI-compatible APIs, Anthropic enables prompt caching by attaching
//! `{"type":"ephemeral"}` as `cache_control` to a system/content block. This
//! adapter turns TinyInference's cacheable prompt segments into that wire shape
//! and maps the provider's cache usage counters back into [`Usage`].

use async_trait::async_trait;
use serde_json::{Value, json};
use std::time::Duration;

use crate::message::{AssistantMessage, ContentBlock, Message};
use crate::model::{ChatModel, ModelProfile, ModelRequest, ModelResponse};
use crate::usage::Usage;
use crate::{Error, Result};

const DEFAULT_BASE_URL: &str = "https://api.anthropic.com/v1";
const DEFAULT_MODEL: &str = "claude-sonnet-4-6";
const ANTHROPIC_VERSION: &str = "2023-06-01";

/// A chat model backed by Anthropic's native Messages API.
pub struct AnthropicModel {
client: reqwest::Client,
api_key: String,
base_url: String,
model: String,
profile: ModelProfile,
}

impl std::fmt::Debug for AnthropicModel {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter
.debug_struct("AnthropicModel")
.field("client", &self.client)
.field("api_key", &"[redacted]")
.field("base_url", &self.base_url)
.field("model", &self.model)
.field("profile", &self.profile)
.finish()
}
}

impl AnthropicModel {
/// Creates an Anthropic model using the default Messages API endpoint.
pub fn new(api_key: impl Into<String>) -> Self {
Self::with_base_url(api_key, DEFAULT_BASE_URL)
}

/// Creates an Anthropic model targeting a Messages-API-compatible endpoint.
pub fn with_base_url(api_key: impl Into<String>, base_url: impl Into<String>) -> Self {
let model = DEFAULT_MODEL.to_string();
Comment thread
senamakel marked this conversation as resolved.
Self {
client: reqwest::Client::new(),
api_key: api_key.into(),
base_url: base_url.into().trim_end_matches('/').to_string(),
profile: ModelProfile {
provider: Some("anthropic".to_string()),
model: Some(model.clone()),
..ModelProfile::default()
},
model,
}
}

/// Overrides the default model id used when a request does not specify one.
pub fn with_model(mut self, model: impl Into<String>) -> Self {
self.model = model.into();
self.profile.model = Some(self.model.clone());
self
}

/// Reads `ANTHROPIC_API_KEY`, plus optional `ANTHROPIC_BASE_URL` and
/// `ANTHROPIC_MODEL`, from the environment.
///
/// # Errors
///
/// Returns [`Error::Model`] when `ANTHROPIC_API_KEY` is not set.
pub fn from_env() -> Result<Self> {
Comment thread
senamakel marked this conversation as resolved.
let key = std::env::var("ANTHROPIC_API_KEY")
.map_err(|_| Error::Model("ANTHROPIC_API_KEY is not set".to_string()))?;
let base_url =
std::env::var("ANTHROPIC_BASE_URL").unwrap_or_else(|_| DEFAULT_BASE_URL.into());
let model = std::env::var("ANTHROPIC_MODEL").unwrap_or_else(|_| DEFAULT_MODEL.into());
Ok(Self::with_base_url(key, base_url).with_model(model))
}

fn endpoint(&self) -> String {
if self.base_url.ends_with("/messages") {
self.base_url.clone()
} else {
format!("{}/messages", self.base_url)
}
}
}

/// Builds the native Messages API body. Kept pure so cache-control placement is
/// exhaustively testable without a network server.
pub(crate) fn request_body(request: &ModelRequest, default_model: &str) -> Value {
let cache_enabled = request
.cache_policy
.as_ref()
.is_some_and(|policy| policy.protect_prompt_prefix)
&& request
.cache_segments
.iter()
.any(|segment| segment.cacheable);
let mut system = Vec::new();
let mut messages = Vec::new();
for message in &request.messages {
match message {
Message::System(system_message) => {
system.extend(content_blocks(&system_message.content))
}
Message::User(user_message) => messages.push(json!({
"role": "user",
"content": content_blocks(&user_message.content),
})),
Message::Assistant(assistant_message) => {
let mut content = content_blocks(&assistant_message.content);
content.extend(assistant_message.tool_calls.iter().map(|call| {
json!({
"type": "tool_use",
"id": call.id,
"name": call.name,
"input": call.arguments,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

priority mediumtests likely

Serialize tool-call arguments as a JSON object for Anthropic

Anthropic's Messages API expects input to be a JSON object, but json!({ "input": call.arguments }) serializes whatever type ToolCall::arguments is. The OpenAI wire format stores arguments as a JSON-encoded string, and tool_call_from_wire in the OpenAI adapter takes &str, which strongly suggests ToolCall::arguments is a String. If so, this line sends "input": "{\"key\": \"val\"}" — a JSON string where Anthropic expects "input": {"key": "val"} — causing a 400 error on any multi-turn conversation that includes assistant tool calls. If arguments is already a serde_json::Value, this is fine; if it is a String, it must be parsed before serialization.

[RULE] wrong-argument-type-for-wire ·

})
}));
messages.push(json!({ "role": "assistant", "content": content }));
}
Message::Tool(tool_message) => messages.push(json!({
"role": "user",
"content": [{
"type": "tool_result",
"tool_use_id": tool_message.tool_call_id,
"content": content_blocks(&tool_message.content),
}],
})),
}
}
if cache_enabled {
let block = system.last_mut().or_else(|| {
messages
.first_mut()
.and_then(|message| message["content"].as_array_mut())
.and_then(|blocks| blocks.first_mut())
});
if let Some(block) = block {
block["cache_control"] = json!({ "type": "ephemeral" });
}
}
let mut body = json!({
"model": request.model.as_deref().unwrap_or(default_model),
"max_tokens": request.max_tokens.unwrap_or(1024),
"messages": messages,
});
Comment thread
senamakel marked this conversation as resolved.
if !system.is_empty() {
body["system"] = Value::Array(system);
}
if let Some(temperature) = request.temperature {
body["temperature"] = json!(temperature);
}
if let Some(top_p) = request.top_p {
body["top_p"] = json!(top_p);
}
if !request.stop_sequences.is_empty() {
body["stop_sequences"] = json!(request.stop_sequences);
}
body
}

fn content_blocks(content: &[ContentBlock]) -> Vec<Value> {
content
.iter()
.filter_map(|block| match block {
ContentBlock::Text(text) => Some(json!({ "type": "text", "text": text })),
ContentBlock::Json(value) => Some(json!({ "type": "text", "text": value.to_string() })),
ContentBlock::Thinking { text, .. } => Some(json!({ "type": "text", "text": text })),
ContentBlock::RedactedThinking { data } => {
Some(json!({ "type": "text", "text": data }))
}
ContentBlock::Image(_) | ContentBlock::ProviderExtension(_) => None,
})
.collect()
}

fn parse_response(body: Value) -> Result<ModelResponse> {
let text = body["content"]
.as_array()
.into_iter()
.flatten()
.filter_map(|block| {
(block["type"].as_str() == Some("text"))
.then(|| block["text"].as_str())
.flatten()
})
.collect::<String>();
let usage = body.get("usage").map(|usage| {
let uncached_input_tokens = usage["input_tokens"].as_u64().unwrap_or(0);
let cache_read_tokens = usage["cache_read_input_tokens"].as_u64().unwrap_or(0);
let cache_creation_tokens = usage["cache_creation_input_tokens"].as_u64().unwrap_or(0);
let input_tokens = uncached_input_tokens + cache_read_tokens + cache_creation_tokens;
let output_tokens = usage["output_tokens"].as_u64().unwrap_or(0);
Usage {
input_tokens,
output_tokens,
total_tokens: input_tokens + output_tokens,
cache_read_tokens,
cache_creation_tokens,
..Usage::default()
}
});
Ok(ModelResponse {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

priority mediumtests confident

Parse tool_use blocks from Anthropic responses

parse_response only collects type: "text" blocks from the response content array and hardcodes tool_calls: Vec::new(). When the model returns a tool_use block, it is silently dropped — the caller receives an empty tool-call list and only the text content, so tool-calling loops cannot function with this provider. The OpenAI adapter fully parses tool calls from responses; this adapter should parse tool_use blocks (mapping id, name, and input) into ToolCalls and include them in the response. A test exercising a response containing a tool_use block should verify the calls are preserved.

[RULE] dropped-tool-calls ·

message: AssistantMessage {
id: body["id"].as_str().map(str::to_string),
content: vec![ContentBlock::Text(text)],
tool_calls: Vec::new(),
usage,
},
usage,
finish_reason: body["stop_reason"].as_str().map(str::to_string),
raw: Some(body),
resolved_model: None,
continue_turn: None,
served_from_cache: false,
})
}

#[async_trait]
impl<State: Send + Sync> ChatModel<State> for AnthropicModel {
fn profile(&self) -> Option<&ModelProfile> {
Some(&self.profile)
}

fn cache_identity(&self) -> Option<String> {
Some(format!("anthropic:{}:{}", self.base_url, self.model))
}

async fn invoke(&self, _state: &State, request: ModelRequest) -> Result<ModelResponse> {
let request_builder = self
.client
.post(self.endpoint())
.header("x-api-key", &self.api_key)
.header("anthropic-version", ANTHROPIC_VERSION)
.json(&request_body(&request, &self.model));
let request_builder = match request.timeout_ms {
Some(timeout_ms) => request_builder.timeout(Duration::from_millis(timeout_ms)),
None => request_builder,
};
let response = request_builder
.send()
Comment thread
senamakel marked this conversation as resolved.
.await
.map_err(|error| Error::Model(format!("anthropic request failed: {error}")))?;
let status = response.status();
let body: Value = response
.json()
.await
.map_err(|error| Error::Model(format!("anthropic response was not JSON: {error}")))?;
if !status.is_success() {
return Err(Error::Model(format!(
"anthropic returned HTTP {status}: {}",
body["error"]["message"].as_str().unwrap_or("unknown error")
)));
Comment on lines +259 to +262

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Return structured provider errors for HTTP failures

When Anthropic returns a routine 429 or transient 5xx response, this collapses the failure into Error::Model, discarding the status, provider error type, retryability, and Retry-After metadata that consuming runtimes use for retry decisions; the preceding unconditional JSON decode also loses the HTTP status entirely for non-JSON error bodies. Decode non-success responses into ProviderError and return Error::Provider, as the existing normalized failure contract requires.

AGENTS.md reference: AGENTS.md:L38-L40

Useful? React with 👍 / 👎.

}
parse_response(body)
}
}

#[cfg(test)]
mod test {
use super::*;
use crate::cache::CachePolicy;
use crate::model::{PromptSegment, SegmentRole};

#[test]
fn cacheable_system_prefix_becomes_an_anthropic_cache_breakpoint() {
let request = ModelRequest::new(vec![
Message::system("stable instructions"),
Message::user("hello"),
])
.with_cache_segments(vec![PromptSegment {
id: "system".into(),
role: SegmentRole::System,
cacheable: true,
}])
.with_cache_policy(CachePolicy {
protect_prompt_prefix: true,
..CachePolicy::default()
});
let body = request_body(&request, "test-model");
assert_eq!(
body["system"][0]["cache_control"],
json!({ "type": "ephemeral" })
);
assert_eq!(body["messages"][0]["role"], "user");
}

#[test]
fn cacheable_user_prefix_becomes_a_content_block_breakpoint() {
let request = ModelRequest::new(vec![Message::user("stable context")])
.with_cache_segments(vec![PromptSegment {
id: "history".into(),
role: SegmentRole::History,
cacheable: true,
}])
.with_cache_policy(CachePolicy {
protect_prompt_prefix: true,
..CachePolicy::default()
});
let body = request_body(&request, "test-model");
assert_eq!(body["messages"][0]["content"][0]["type"], "text");
assert_eq!(body["messages"][0]["content"][0]["text"], "stable context");
assert_eq!(
body["messages"][0]["content"][0]["cache_control"],
json!({ "type": "ephemeral" })
);
}

#[test]
fn tool_results_use_anthropic_tool_result_blocks() {
let request = ModelRequest::new(vec![Message::Tool(crate::message::ToolMessage {
tool_call_id: "tool_1".into(),
content: vec![ContentBlock::Text("42".into())],
trusted_verbatim: false,
artifact: None,
})]);
let body = request_body(&request, "test-model");
assert_eq!(body["messages"][0]["role"], "user");
assert_eq!(body["messages"][0]["content"][0]["type"], "tool_result");
assert_eq!(body["messages"][0]["content"][0]["tool_use_id"], "tool_1");
}

#[test]
fn default_model_is_current() {
assert_eq!(AnthropicModel::new("key").model, "claude-sonnet-4-6");
}

#[test]
fn cache_usage_is_mapped_from_anthropic_response() {
let response = parse_response(json!({
"id": "msg_1", "content": [{ "type": "text", "text": "hello" }], "stop_reason": "end_turn",
"usage": { "input_tokens": 100, "output_tokens": 5, "cache_read_input_tokens": 90, "cache_creation_input_tokens": 10 }
})).unwrap();
assert_eq!(response.text(), "hello");
let usage = response.usage.unwrap();
assert_eq!(usage.input_tokens, 200);
assert_eq!(usage.total_tokens, 205);
assert_eq!(usage.cache_read_tokens, 90);
assert_eq!(usage.cache_creation_tokens, 10);
}

#[test]
fn request_body_forwards_generation_controls() {
let request = ModelRequest::new(vec![Message::user("hello")])
.with_temperature(0.2)
.with_top_p(0.8)
.with_stop_sequences(["END"]);
let body = request_body(&request, "test-model");
assert_eq!(body["temperature"], 0.2);
assert_eq!(body["top_p"], 0.8);
assert_eq!(body["stop_sequences"], json!(["END"]));
}

#[test]
fn debug_redacts_the_api_key() {
let model = AnthropicModel::new("secret-api-key");
let debug = format!("{model:?}");
assert!(debug.contains("[redacted]"));
assert!(!debug.contains("secret-api-key"));
}
}
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Universal Dark Mode - works on any site (function() { var enabled = true; function applyDarkMode() { if (!enabled) return; // Create style element if it doesn't exist var style = document.getElementById('universal-dark-mode-style'); if (!style) { style = document.createElement('style'); style.id = 'universal-dark-mode-style'; document.head.appendChild(style); } // Dark mode CSS - inverts colors but preserves images/video style.textContent = ' /* Invert everything except media */ html { filter: invert(1) hue-rotate(180deg) !important; background: #1a1a2e !important; } /* Restore images, videos, iframes, canvas */ img, video, iframe, canvas, svg, picture, [style*="background-image"] { filter: invert(1) hue-rotate(180deg) !important; } /* Preserve specific elements that should not be inverted */ .no-dark-mode, .no-dark-mode *, [data-theme="light"], [data-theme="light"], .ace_editor, .ace_editor *, .CodeMirror, .CodeMirror *, .monaco-editor, .monaco-editor *, .markdown-body pre, .markdown-body pre *, .highlight, .highlight *, pre code, pre code * { filter: none !important; } /* Fix common UI elements */ .modal, .popup, .dropdown-menu, .tooltip, .popover { filter: invert(1) hue-rotate(180deg) !important; background: #2d2d44 !important; border-color: #444 !important; } /* Scrollbars */ ::-webkit-scrollbar { background: #1a1a2e !important; } ::-webkit-scrollbar-thumb { background: #444 !important; } ::-webkit-scrollbar-thumb:hover { background: #555 !important; } /* Selection */ ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; } ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; } '; } function removeDarkMode() { var style = document.getElementById('universal-dark-mode-style'); if (style) style.remove(); } // Toggle with Alt+Shift+D document.addEventListener('keydown', function(e) { if (e.altKey && e.shiftKey && e.key === 'D') { e.preventDefault(); enabled = !enabled; if (enabled) { applyDarkMode(); console.log('[Universal Dark Mode] Enabled'); } else { removeDarkMode(); console.log('[Universal Dark Mode] Disabled'); } } }); // Apply on load applyDarkMode(); // Re-apply on dynamic content var observer = new MutationObserver(function(mutations) { if (enabled && !document.getElementById('universal-dark-mode-style')) { applyDarkMode(); } }); observer.observe(document.head, { childList: true }); console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle'); })(); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
370 changes: 370 additions & 0 deletions crates/tinyinference/src/providers/anthropic.rs
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,370 @@
//! Anthropic Messages API provider with explicit prompt-cache breakpoints.
//!
//! Unlike OpenAI-compatible APIs, Anthropic enables prompt caching by attaching
//! `{"type":"ephemeral"}` as `cache_control` to a system/content block. This
//! adapter turns TinyInference's cacheable prompt segments into that wire shape
//! and maps the provider's cache usage counters back into [`Usage`].

use async_trait::async_trait;
use serde_json::{Value, json};
use std::time::Duration;

use crate::message::{AssistantMessage, ContentBlock, Message};
use crate::model::{ChatModel, ModelProfile, ModelRequest, ModelResponse};
use crate::usage::Usage;
use crate::{Error, Result};

const DEFAULT_BASE_URL: &str = "https://api.anthropic.com/v1";
const DEFAULT_MODEL: &str = "claude-sonnet-4-6";
const ANTHROPIC_VERSION: &str = "2023-06-01";

/// A chat model backed by Anthropic's native Messages API.
pub struct AnthropicModel {
client: reqwest::Client,
api_key: String,
base_url: String,
model: String,
profile: ModelProfile,
}

impl std::fmt::Debug for AnthropicModel {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter
.debug_struct("AnthropicModel")
.field("client", &self.client)
.field("api_key", &"[redacted]")
.field("base_url", &self.base_url)
.field("model", &self.model)
.field("profile", &self.profile)
.finish()
}
}

impl AnthropicModel {
/// Creates an Anthropic model using the default Messages API endpoint.
pub fn new(api_key: impl Into<String>) -> Self {
Self::with_base_url(api_key, DEFAULT_BASE_URL)
}

/// Creates an Anthropic model targeting a Messages-API-compatible endpoint.
pub fn with_base_url(api_key: impl Into<String>, base_url: impl Into<String>) -> Self {
let model = DEFAULT_MODEL.to_string();
Comment thread
senamakel marked this conversation as resolved.
Self {
client: reqwest::Client::new(),
api_key: api_key.into(),
base_url: base_url.into().trim_end_matches('/').to_string(),
profile: ModelProfile {
provider: Some("anthropic".to_string()),
model: Some(model.clone()),
..ModelProfile::default()
},
model,
}
}

/// Overrides the default model id used when a request does not specify one.
pub fn with_model(mut self, model: impl Into<String>) -> Self {
self.model = model.into();
self.profile.model = Some(self.model.clone());
self
}

/// Reads `ANTHROPIC_API_KEY`, plus optional `ANTHROPIC_BASE_URL` and
/// `ANTHROPIC_MODEL`, from the environment.
///
/// # Errors
///
/// Returns [`Error::Model`] when `ANTHROPIC_API_KEY` is not set.
pub fn from_env() -> Result<Self> {
Comment thread
senamakel marked this conversation as resolved.
let key = std::env::var("ANTHROPIC_API_KEY")
.map_err(|_| Error::Model("ANTHROPIC_API_KEY is not set".to_string()))?;
let base_url =
std::env::var("ANTHROPIC_BASE_URL").unwrap_or_else(|_| DEFAULT_BASE_URL.into());
let model = std::env::var("ANTHROPIC_MODEL").unwrap_or_else(|_| DEFAULT_MODEL.into());
Ok(Self::with_base_url(key, base_url).with_model(model))
}

fn endpoint(&self) -> String {
if self.base_url.ends_with("/messages") {
self.base_url.clone()
} else {
format!("{}/messages", self.base_url)
}
}
}

/// Builds the native Messages API body. Kept pure so cache-control placement is
/// exhaustively testable without a network server.
pub(crate) fn request_body(request: &ModelRequest, default_model: &str) -> Value {
let cache_enabled = request
.cache_policy
.as_ref()
.is_some_and(|policy| policy.protect_prompt_prefix)
&& request
.cache_segments
.iter()
.any(|segment| segment.cacheable);
let mut system = Vec::new();
let mut messages = Vec::new();
for message in &request.messages {
match message {
Message::System(system_message) => {
system.extend(content_blocks(&system_message.content))
}
Message::User(user_message) => messages.push(json!({
"role": "user",
"content": content_blocks(&user_message.content),
})),
Message::Assistant(assistant_message) => {
let mut content = content_blocks(&assistant_message.content);
content.extend(assistant_message.tool_calls.iter().map(|call| {
json!({
"type": "tool_use",
"id": call.id,
"name": call.name,
"input": call.arguments,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

priority mediumtests likely

Serialize tool-call arguments as a JSON object for Anthropic

Anthropic's Messages API expects input to be a JSON object, but json!({ "input": call.arguments }) serializes whatever type ToolCall::arguments is. The OpenAI wire format stores arguments as a JSON-encoded string, and tool_call_from_wire in the OpenAI adapter takes &str, which strongly suggests ToolCall::arguments is a String. If so, this line sends "input": "{\"key\": \"val\"}" — a JSON string where Anthropic expects "input": {"key": "val"} — causing a 400 error on any multi-turn conversation that includes assistant tool calls. If arguments is already a serde_json::Value, this is fine; if it is a String, it must be parsed before serialization.

[RULE] wrong-argument-type-for-wire ·

})
}));
messages.push(json!({ "role": "assistant", "content": content }));
}
Message::Tool(tool_message) => messages.push(json!({
"role": "user",
"content": [{
"type": "tool_result",
"tool_use_id": tool_message.tool_call_id,
"content": content_blocks(&tool_message.content),
}],
})),
}
}
if cache_enabled {
let block = system.last_mut().or_else(|| {
messages
.first_mut()
.and_then(|message| message["content"].as_array_mut())
.and_then(|blocks| blocks.first_mut())
});
if let Some(block) = block {
block["cache_control"] = json!({ "type": "ephemeral" });
}
}
let mut body = json!({
"model": request.model.as_deref().unwrap_or(default_model),
"max_tokens": request.max_tokens.unwrap_or(1024),
"messages": messages,
});
Comment thread
senamakel marked this conversation as resolved.
if !system.is_empty() {
body["system"] = Value::Array(system);
}
if let Some(temperature) = request.temperature {
body["temperature"] = json!(temperature);
}
if let Some(top_p) = request.top_p {
body["top_p"] = json!(top_p);
}
if !request.stop_sequences.is_empty() {
body["stop_sequences"] = json!(request.stop_sequences);
}
body
}

fn content_blocks(content: &[ContentBlock]) -> Vec<Value> {
content
.iter()
.filter_map(|block| match block {
ContentBlock::Text(text) => Some(json!({ "type": "text", "text": text })),
ContentBlock::Json(value) => Some(json!({ "type": "text", "text": value.to_string() })),
ContentBlock::Thinking { text, .. } => Some(json!({ "type": "text", "text": text })),
ContentBlock::RedactedThinking { data } => {
Some(json!({ "type": "text", "text": data }))
}
ContentBlock::Image(_) | ContentBlock::ProviderExtension(_) => None,
})
.collect()
}

fn parse_response(body: Value) -> Result<ModelResponse> {
let text = body["content"]
.as_array()
.into_iter()
.flatten()
.filter_map(|block| {
(block["type"].as_str() == Some("text"))
.then(|| block["text"].as_str())
.flatten()
})
.collect::<String>();
let usage = body.get("usage").map(|usage| {
let uncached_input_tokens = usage["input_tokens"].as_u64().unwrap_or(0);
let cache_read_tokens = usage["cache_read_input_tokens"].as_u64().unwrap_or(0);
let cache_creation_tokens = usage["cache_creation_input_tokens"].as_u64().unwrap_or(0);
let input_tokens = uncached_input_tokens + cache_read_tokens + cache_creation_tokens;
let output_tokens = usage["output_tokens"].as_u64().unwrap_or(0);
Usage {
input_tokens,
output_tokens,
total_tokens: input_tokens + output_tokens,
cache_read_tokens,
cache_creation_tokens,
..Usage::default()
}
});
Ok(ModelResponse {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

priority mediumtests confident

Parse tool_use blocks from Anthropic responses

parse_response only collects type: "text" blocks from the response content array and hardcodes tool_calls: Vec::new(). When the model returns a tool_use block, it is silently dropped — the caller receives an empty tool-call list and only the text content, so tool-calling loops cannot function with this provider. The OpenAI adapter fully parses tool calls from responses; this adapter should parse tool_use blocks (mapping id, name, and input) into ToolCalls and include them in the response. A test exercising a response containing a tool_use block should verify the calls are preserved.

[RULE] dropped-tool-calls ·

message: AssistantMessage {
id: body["id"].as_str().map(str::to_string),
content: vec![ContentBlock::Text(text)],
tool_calls: Vec::new(),
usage,
},
usage,
finish_reason: body["stop_reason"].as_str().map(str::to_string),
raw: Some(body),
resolved_model: None,
continue_turn: None,
served_from_cache: false,
})
}

#[async_trait]
impl<State: Send + Sync> ChatModel<State> for AnthropicModel {
fn profile(&self) -> Option<&ModelProfile> {
Some(&self.profile)
}

fn cache_identity(&self) -> Option<String> {
Some(format!("anthropic:{}:{}", self.base_url, self.model))
}

async fn invoke(&self, _state: &State, request: ModelRequest) -> Result<ModelResponse> {
let request_builder = self
.client
.post(self.endpoint())
.header("x-api-key", &self.api_key)
.header("anthropic-version", ANTHROPIC_VERSION)
.json(&request_body(&request, &self.model));
let request_builder = match request.timeout_ms {
Some(timeout_ms) => request_builder.timeout(Duration::from_millis(timeout_ms)),
None => request_builder,
};
let response = request_builder
.send()
Comment thread
senamakel marked this conversation as resolved.
.await
.map_err(|error| Error::Model(format!("anthropic request failed: {error}")))?;
let status = response.status();
let body: Value = response
.json()
.await
.map_err(|error| Error::Model(format!("anthropic response was not JSON: {error}")))?;
if !status.is_success() {
return Err(Error::Model(format!(
"anthropic returned HTTP {status}: {}",
body["error"]["message"].as_str().unwrap_or("unknown error")
)));
Comment on lines +259 to +262

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Return structured provider errors for HTTP failures

When Anthropic returns a routine 429 or transient 5xx response, this collapses the failure into Error::Model, discarding the status, provider error type, retryability, and Retry-After metadata that consuming runtimes use for retry decisions; the preceding unconditional JSON decode also loses the HTTP status entirely for non-JSON error bodies. Decode non-success responses into ProviderError and return Error::Provider, as the existing normalized failure contract requires.

AGENTS.md reference: AGENTS.md:L38-L40

Useful? React with 👍 / 👎.

}
parse_response(body)
}
}

#[cfg(test)]
mod test {
use super::*;
use crate::cache::CachePolicy;
use crate::model::{PromptSegment, SegmentRole};

#[test]
fn cacheable_system_prefix_becomes_an_anthropic_cache_breakpoint() {
let request = ModelRequest::new(vec![
Message::system("stable instructions"),
Message::user("hello"),
])
.with_cache_segments(vec![PromptSegment {
id: "system".into(),
role: SegmentRole::System,
cacheable: true,
}])
.with_cache_policy(CachePolicy {
protect_prompt_prefix: true,
..CachePolicy::default()
});
let body = request_body(&request, "test-model");
assert_eq!(
body["system"][0]["cache_control"],
json!({ "type": "ephemeral" })
);
assert_eq!(body["messages"][0]["role"], "user");
}

#[test]
fn cacheable_user_prefix_becomes_a_content_block_breakpoint() {
let request = ModelRequest::new(vec![Message::user("stable context")])
.with_cache_segments(vec![PromptSegment {
id: "history".into(),
role: SegmentRole::History,
cacheable: true,
}])
.with_cache_policy(CachePolicy {
protect_prompt_prefix: true,
..CachePolicy::default()
});
let body = request_body(&request, "test-model");
assert_eq!(body["messages"][0]["content"][0]["type"], "text");
assert_eq!(body["messages"][0]["content"][0]["text"], "stable context");
assert_eq!(
body["messages"][0]["content"][0]["cache_control"],
json!({ "type": "ephemeral" })
);
}

#[test]
fn tool_results_use_anthropic_tool_result_blocks() {
let request = ModelRequest::new(vec![Message::Tool(crate::message::ToolMessage {
tool_call_id: "tool_1".into(),
content: vec![ContentBlock::Text("42".into())],
trusted_verbatim: false,
artifact: None,
})]);
let body = request_body(&request, "test-model");
assert_eq!(body["messages"][0]["role"], "user");
assert_eq!(body["messages"][0]["content"][0]["type"], "tool_result");
assert_eq!(body["messages"][0]["content"][0]["tool_use_id"], "tool_1");
}

#[test]
fn default_model_is_current() {
assert_eq!(AnthropicModel::new("key").model, "claude-sonnet-4-6");
}

#[test]
fn cache_usage_is_mapped_from_anthropic_response() {
let response = parse_response(json!({
"id": "msg_1", "content": [{ "type": "text", "text": "hello" }], "stop_reason": "end_turn",
"usage": { "input_tokens": 100, "output_tokens": 5, "cache_read_input_tokens": 90, "cache_creation_input_tokens": 10 }
})).unwrap();
assert_eq!(response.text(), "hello");
let usage = response.usage.unwrap();
assert_eq!(usage.input_tokens, 200);
assert_eq!(usage.total_tokens, 205);
assert_eq!(usage.cache_read_tokens, 90);
assert_eq!(usage.cache_creation_tokens, 10);
}

#[test]
fn request_body_forwards_generation_controls() {
let request = ModelRequest::new(vec![Message::user("hello")])
.with_temperature(0.2)
.with_top_p(0.8)
.with_stop_sequences(["END"]);
let body = request_body(&request, "test-model");
assert_eq!(body["temperature"], 0.2);
assert_eq!(body["top_p"], 0.8);
assert_eq!(body["stop_sequences"], json!(["END"]));
}

#[test]
fn debug_redacts_the_api_key() {
let model = AnthropicModel::new("secret-api-key");
let debug = format!("{model:?}");
assert!(debug.contains("[redacted]"));
assert!(!debug.contains("secret-api-key"));
}
}
Loading
Loading