refactor(rust/messages): split Anthropic config into its own provider file and type the request/response contract

Co-Authored-By: Ishaan Jaffer <155045088+ishaan-berri@users.noreply.github.com>
This commit is contained in:
Devin AI 2026-07-17 03:13:05 +00:00
parent 95feb89aea
commit 403dd14580
9 changed files with 397 additions and 133 deletions

View file

@ -35,12 +35,13 @@ pub(super) async fn execute_messages_provider_call(
});
}
let response_json: Value = serde_json::from_str(&text).map_err(|err| {
let response = serde_json::from_str(&text).map_err(|err| {
CoreError::InvalidResponse(format!("invalid messages response JSON: {err}"))
})?;
Ok(request
let transformed = request
.config
.transform_response(&request.model, response_json)?
.into_json())
.transform_response(&request.model, response)?;
serde_json::to_value(transformed).map_err(|err| {
CoreError::InvalidResponse(format!("failed to serialize messages response: {err}"))
})
}

View file

@ -51,7 +51,15 @@ pub(super) fn prepare_messages_call(
}
let url = config.complete_url(request.api_base, &model, &env_lookup)?;
let body = config.transform_request(request.body)?.body;
let typed_request = serde_json::from_value(request.body).map_err(|err| {
CoreError::InvalidRequest(format!("invalid Anthropic messages request: {err}"))
})?;
let transformed = config.transform_request(typed_request)?;
let body = serde_json::to_value(transformed).map_err(|err| {
CoreError::InvalidRequest(format!(
"failed to serialize Anthropic messages request: {err}"
))
})?;
Ok(ProviderMessagesRequest {
model,

View file

@ -1,8 +1,6 @@
use serde_json::Value;
use crate::error::CoreResult;
use crate::error::{json_type_name, CoreError, CoreResult};
use super::types::{MessagesRequestData, MessagesResponseData};
use super::types::{AnthropicMessagesRequest, AnthropicMessagesResponse};
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum MessagesAuthStrategy {
@ -44,29 +42,18 @@ pub trait AnthropicMessagesProviderConfig: Sync {
]
}
fn transform_request(&self, body: Value) -> CoreResult<MessagesRequestData> {
if !body.is_object() {
return Err(CoreError::InvalidType {
expected: "object",
actual: json_type_name(&body),
});
}
Ok(MessagesRequestData { body })
fn transform_request(
&self,
request: AnthropicMessagesRequest,
) -> CoreResult<AnthropicMessagesRequest> {
Ok(request)
}
fn transform_response(
&self,
_model: &str,
response_json: Value,
) -> CoreResult<MessagesResponseData> {
if !response_json.is_object() {
return Err(CoreError::InvalidType {
expected: "object",
actual: json_type_name(&response_json),
});
}
Ok(MessagesResponseData {
body: response_json,
})
response: AnthropicMessagesResponse,
) -> CoreResult<AnthropicMessagesResponse> {
Ok(response)
}
}

View file

@ -1,18 +1,110 @@
use serde::{Deserialize, Serialize};
use serde_json::Value;
use serde_json::{Map, Value};
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct MessagesRequestData {
pub body: Value,
#[serde(untagged)]
pub enum SystemPrompt {
Text(String),
Blocks(Vec<ContentBlock>),
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct MessagesResponseData {
pub body: Value,
#[serde(untagged)]
pub enum MessageContent {
Text(String),
Blocks(Vec<ContentBlock>),
}
impl MessagesResponseData {
pub fn into_json(self) -> Value {
self.body
}
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
pub struct ContentBlock {
#[serde(skip_serializing_if = "Option::is_none")]
pub cache_control: Option<CacheControl>,
#[serde(flatten)]
pub extra: Map<String, Value>,
}
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
pub struct CacheControl {
#[serde(rename = "type", skip_serializing_if = "Option::is_none")]
pub cache_type: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub ttl: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub scope: Option<String>,
#[serde(flatten)]
pub extra: Map<String, Value>,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct AnthropicMessage {
pub role: String,
pub content: MessageContent,
#[serde(flatten)]
pub extra: Map<String, Value>,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct AnthropicMessagesRequest {
pub model: String,
pub messages: Vec<AnthropicMessage>,
#[serde(skip_serializing_if = "Option::is_none")]
pub max_tokens: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub system: Option<SystemPrompt>,
#[serde(skip_serializing_if = "Option::is_none")]
pub metadata: Option<Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub stop_sequences: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub stream: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub temperature: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub top_p: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub top_k: Option<i64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tools: Option<Vec<Value>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tool_choice: Option<Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub thinking: Option<Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub service_tier: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub container: Option<Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub mcp_servers: Option<Vec<Value>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub context_management: Option<Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub output_format: Option<Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub output_config: Option<Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub speed: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub inference_geo: Option<String>,
#[serde(flatten)]
pub extra: Map<String, Value>,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct AnthropicMessagesResponse {
pub id: String,
#[serde(rename = "type")]
pub message_type: String,
pub role: String,
pub model: String,
pub content: Vec<Value>,
// Anthropic always includes stop_reason / stop_sequence, null until the turn
// ends; serialize them even when None so callers see the same shape as Python.
pub stop_reason: Option<String>,
pub stop_sequence: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub usage: Option<Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub container: Option<Value>,
#[serde(flatten)]
pub extra: Map<String, Value>,
}

View file

@ -0,0 +1 @@
pub mod transformation;

View file

@ -0,0 +1,142 @@
use crate::error::{CoreError, CoreResult};
use crate::messages::transformation::{AnthropicMessagesProviderConfig, MessagesAuthStrategy};
const ANTHROPIC_API_KEY_ENV: &str = "ANTHROPIC_API_KEY";
const ANTHROPIC_API_BASE_ENV: &str = "ANTHROPIC_API_BASE";
const DEFAULT_ANTHROPIC_API_BASE: &str = "https://api.anthropic.com";
const MESSAGES_PATH_SUFFIX: &str = "/v1/messages";
pub struct AnthropicMessagesConfig;
pub const ANTHROPIC_MESSAGES_CONFIG: AnthropicMessagesConfig = AnthropicMessagesConfig;
pub fn non_empty(value: Option<&str>) -> Option<&str> {
value.map(str::trim).filter(|value| !value.is_empty())
}
pub fn resolve_anthropic_api_key(
api_key: Option<&str>,
env_lookup: &dyn Fn(&str) -> Option<String>,
) -> CoreResult<String> {
non_empty(api_key)
.map(str::to_string)
.or_else(|| env_lookup(ANTHROPIC_API_KEY_ENV).filter(|value| !value.trim().is_empty()))
.ok_or_else(|| {
CoreError::Auth(
"Missing Anthropic API Key - Set `api_key` or the ANTHROPIC_API_KEY \
environment variable"
.to_string(),
)
})
}
pub fn complete_anthropic_url(
api_base: Option<&str>,
env_lookup: &dyn Fn(&str) -> Option<String>,
) -> String {
let api_base = non_empty(api_base)
.map(str::to_string)
.or_else(|| env_lookup(ANTHROPIC_API_BASE_ENV).filter(|value| !value.trim().is_empty()))
.unwrap_or_else(|| DEFAULT_ANTHROPIC_API_BASE.to_string());
let api_base = api_base.trim_end_matches('/');
if api_base.ends_with(MESSAGES_PATH_SUFFIX) {
return api_base.to_string();
}
format!("{api_base}{MESSAGES_PATH_SUFFIX}")
}
impl AnthropicMessagesProviderConfig for AnthropicMessagesConfig {
fn complete_url(
&self,
api_base: Option<&str>,
_model: &str,
env_lookup: &dyn Fn(&str) -> Option<String>,
) -> CoreResult<String> {
Ok(complete_anthropic_url(api_base, env_lookup))
}
fn resolve_api_key(
&self,
api_key: Option<&str>,
env_lookup: &dyn Fn(&str) -> Option<String>,
) -> CoreResult<String> {
resolve_anthropic_api_key(api_key, env_lookup)
}
fn auth_strategy(&self) -> MessagesAuthStrategy {
MessagesAuthStrategy::Header("x-api-key")
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn url_defaults_to_public_anthropic_endpoint() {
assert_eq!(
complete_anthropic_url(None, &|_| None),
"https://api.anthropic.com/v1/messages"
);
}
#[test]
fn url_appends_messages_suffix_to_custom_base() {
assert_eq!(
complete_anthropic_url(Some("https://proxy.internal"), &|_| None),
"https://proxy.internal/v1/messages"
);
}
#[test]
fn url_leaves_complete_messages_endpoint_untouched() {
assert_eq!(
complete_anthropic_url(Some("https://proxy.internal/v1/messages"), &|_| None),
"https://proxy.internal/v1/messages"
);
}
#[test]
fn url_falls_back_to_env_base() {
let with_env = |key: &str| {
(key == ANTHROPIC_API_BASE_ENV).then(|| "https://env.anthropic".to_string())
};
assert_eq!(
complete_anthropic_url(Some(" "), &with_env),
"https://env.anthropic/v1/messages"
);
}
#[test]
fn api_key_prefers_param_then_env_then_errors() {
assert_eq!(
resolve_anthropic_api_key(Some("sk-param"), &|_| None).unwrap(),
"sk-param"
);
let with_env = |key: &str| (key == ANTHROPIC_API_KEY_ENV).then(|| "sk-env".to_string());
assert_eq!(
resolve_anthropic_api_key(Some(" "), &with_env).unwrap(),
"sk-env"
);
assert!(matches!(
resolve_anthropic_api_key(None, &|_| None).expect_err("missing key"),
CoreError::Auth(_)
));
}
#[test]
fn auth_strategy_and_default_headers_match_anthropic() {
assert_eq!(
ANTHROPIC_MESSAGES_CONFIG.auth_strategy().header_name(),
"x-api-key"
);
assert_eq!(
ANTHROPIC_MESSAGES_CONFIG.default_headers(),
&[
("anthropic-version", "2023-06-01"),
("content-type", "application/json"),
]
);
}
}

View file

@ -0,0 +1 @@
pub mod messages;

View file

@ -1,20 +1,26 @@
use serde_json::{Map, Value};
use crate::error::{json_type_name, CoreError, CoreResult};
use crate::error::{CoreError, CoreResult};
use crate::messages::transformation::{AnthropicMessagesProviderConfig, MessagesAuthStrategy};
use crate::messages::types::MessagesRequestData;
use crate::messages::types::{
AnthropicMessage, AnthropicMessagesRequest, AnthropicMessagesResponse, ContentBlock,
MessageContent, SystemPrompt,
};
use crate::providers::anthropic::messages::transformation::{
non_empty, AnthropicMessagesConfig, ANTHROPIC_MESSAGES_CONFIG,
};
const AZURE_API_KEY_ENV: &str = "AZURE_API_KEY";
const AZURE_API_BASE_ENV: &str = "AZURE_API_BASE";
const ANTHROPIC_PATH_SEGMENT: &str = "/anthropic";
const MESSAGES_PATH_SUFFIX: &str = "/v1/messages";
pub struct AzureAnthropicMessagesConfig;
pub struct AzureAnthropicMessagesConfig {
anthropic: AnthropicMessagesConfig,
}
pub const AZURE_ANTHROPIC_MESSAGES_CONFIG: AzureAnthropicMessagesConfig =
AzureAnthropicMessagesConfig;
fn non_empty(value: Option<&str>) -> Option<&str> {
value.map(str::trim).filter(|value| !value.is_empty())
}
AzureAnthropicMessagesConfig {
anthropic: ANTHROPIC_MESSAGES_CONFIG,
};
pub fn resolve_azure_api_key(
api_key: Option<&str>,
@ -48,42 +54,32 @@ pub fn complete_azure_anthropic_url(
let api_base = api_base.trim_end_matches('/');
if api_base.ends_with("/v1/messages") || api_base.ends_with("/anthropic/v1/messages") {
if api_base.ends_with(MESSAGES_PATH_SUFFIX) {
return Ok(api_base.to_string());
}
let with_anthropic = match api_base.split_once("/anthropic") {
Some((prefix, _)) => format!("{prefix}/anthropic"),
None => format!("{api_base}/anthropic"),
let with_anthropic = match api_base.split_once(ANTHROPIC_PATH_SEGMENT) {
Some((prefix, _)) => format!("{prefix}{ANTHROPIC_PATH_SEGMENT}"),
None => format!("{api_base}{ANTHROPIC_PATH_SEGMENT}"),
};
Ok(format!("{with_anthropic}/v1/messages"))
Ok(format!("{with_anthropic}{MESSAGES_PATH_SUFFIX}"))
}
fn remove_scope_from_content_blocks(content: &mut [Value]) {
for item in content.iter_mut() {
if let Some(cache_control) = item
.as_object_mut()
.and_then(|block| block.get_mut("cache_control"))
.and_then(Value::as_object_mut)
{
cache_control.remove("scope");
}
fn strip_scope_from_block(block: &mut ContentBlock) {
if let Some(cache_control) = block.cache_control.as_mut() {
cache_control.scope = None;
}
}
fn remove_scope_from_cache_control(body: &mut Map<String, Value>) {
if let Some(Value::Array(system)) = body.get_mut("system") {
remove_scope_from_content_blocks(system);
fn strip_scope_from_system(system: &mut SystemPrompt) {
if let SystemPrompt::Blocks(blocks) = system {
blocks.iter_mut().for_each(strip_scope_from_block);
}
if let Some(Value::Array(messages)) = body.get_mut("messages") {
for message in messages.iter_mut() {
if let Some(Value::Array(content)) = message
.as_object_mut()
.and_then(|message| message.get_mut("content"))
{
remove_scope_from_content_blocks(content);
}
}
}
fn strip_scope_from_message(message: &mut AnthropicMessage) {
if let MessageContent::Blocks(blocks) = &mut message.content {
blocks.iter_mut().for_each(strip_scope_from_block);
}
}
@ -106,23 +102,33 @@ impl AnthropicMessagesProviderConfig for AzureAnthropicMessagesConfig {
}
fn auth_strategy(&self) -> MessagesAuthStrategy {
MessagesAuthStrategy::Header("x-api-key")
self.anthropic.auth_strategy()
}
fn transform_request(&self, body: Value) -> CoreResult<MessagesRequestData> {
let mut body = match body {
Value::Object(body) => body,
other => {
return Err(CoreError::InvalidType {
expected: "object",
actual: json_type_name(&other),
})
}
};
remove_scope_from_cache_control(&mut body);
Ok(MessagesRequestData {
body: Value::Object(body),
})
fn default_headers(&self) -> &'static [(&'static str, &'static str)] {
self.anthropic.default_headers()
}
fn transform_request(
&self,
mut request: AnthropicMessagesRequest,
) -> CoreResult<AnthropicMessagesRequest> {
if let Some(system) = request.system.as_mut() {
strip_scope_from_system(system);
}
request
.messages
.iter_mut()
.for_each(strip_scope_from_message);
self.anthropic.transform_request(request)
}
fn transform_response(
&self,
model: &str,
response: AnthropicMessagesResponse,
) -> CoreResult<AnthropicMessagesResponse> {
self.anthropic.transform_response(model, response)
}
}
@ -131,6 +137,14 @@ mod tests {
use super::*;
use serde_json::json;
fn request_from(value: serde_json::Value) -> AnthropicMessagesRequest {
serde_json::from_value(value).expect("valid request")
}
fn to_value(request: AnthropicMessagesRequest) -> serde_json::Value {
serde_json::to_value(request).expect("serializable request")
}
#[test]
fn url_appends_anthropic_and_messages_suffix() {
let url =
@ -234,7 +248,7 @@ mod tests {
#[test]
fn transform_request_strips_scope_from_system_and_messages() {
let body = json!({
let request = request_from(json!({
"model": "claude-sonnet-4-5",
"max_tokens": 1024,
"system": [
@ -257,12 +271,13 @@ mod tests {
]
}
]
});
}));
let transformed = AZURE_ANTHROPIC_MESSAGES_CONFIG
.transform_request(body)
.expect("request transforms")
.body;
let transformed = to_value(
AZURE_ANTHROPIC_MESSAGES_CONFIG
.transform_request(request)
.expect("request transforms"),
);
assert_eq!(
transformed["system"][0]["cache_control"],
@ -280,66 +295,82 @@ mod tests {
#[test]
fn transform_request_is_idempotent_and_preserves_string_system() {
let body = json!({
let request = request_from(json!({
"model": "claude-sonnet-4-5",
"max_tokens": 16,
"system": "plain string system",
"messages": [{"role": "user", "content": "hi"}]
});
}));
let once = AZURE_ANTHROPIC_MESSAGES_CONFIG
.transform_request(body)
.expect("request transforms")
.body;
.transform_request(request)
.expect("request transforms");
let twice = AZURE_ANTHROPIC_MESSAGES_CONFIG
.transform_request(once.clone())
.expect("request transforms")
.body;
.expect("request transforms");
assert_eq!(once, twice);
assert_eq!(once["system"], json!("plain string system"));
assert_eq!(to_value(once)["system"], json!("plain string system"));
}
#[test]
fn transform_request_preserves_all_supported_params() {
let body = json!({
"model": "claude-sonnet-4-5",
"max_tokens": 256,
"messages": [{"role": "user", "content": "hi"}],
"system": "be terse",
"metadata": {"user_id": "u1"},
"stop_sequences": ["STOP"],
"stream": false,
"temperature": 0.4,
"top_p": 0.9,
"top_k": 40,
"tools": [{"name": "get_weather", "input_schema": {"type": "object"}}],
"tool_choice": {"type": "auto"},
"thinking": {"type": "enabled", "budget_tokens": 1024},
"service_tier": "auto",
"container": {"id": "c1"},
"mcp_servers": [{"type": "url", "url": "https://mcp.example", "name": "x"}],
"context_management": {"edits": []},
"output_format": {"type": "json_schema"},
"output_config": {"effort": "high"},
"speed": "fast",
"inference_geo": "us",
"litellm_metadata": {"trace": "abc"}
});
let transformed = to_value(
AZURE_ANTHROPIC_MESSAGES_CONFIG
.transform_request(request_from(body.clone()))
.expect("request transforms"),
);
assert_eq!(transformed, body);
}
#[test]
fn transform_request_rejects_non_object_body() {
let err = AZURE_ANTHROPIC_MESSAGES_CONFIG
.transform_request(json!("bad"))
let err = serde_json::from_value::<AnthropicMessagesRequest>(json!("bad"))
.expect_err("non-object body should error");
assert_eq!(
err,
CoreError::InvalidType {
expected: "object",
actual: "string",
}
);
assert!(err.is_data());
}
#[test]
fn transform_response_passes_through_object() {
let response = json!({
fn transform_response_passes_through() {
let response: AnthropicMessagesResponse = serde_json::from_value(json!({
"id": "msg_1",
"type": "message",
"role": "assistant",
"content": [{"type": "text", "text": "hello"}],
"model": "claude-sonnet-4-5",
"stop_reason": "end_turn",
"stop_sequence": null,
"usage": {"input_tokens": 1, "output_tokens": 2}
});
}))
.expect("valid response");
let transformed = AZURE_ANTHROPIC_MESSAGES_CONFIG
.transform_response("claude-sonnet-4-5", response.clone())
.expect("response transforms")
.into_json();
assert_eq!(transformed, response);
}
#[test]
fn transform_response_rejects_non_object() {
let err = AZURE_ANTHROPIC_MESSAGES_CONFIG
.transform_response("claude-sonnet-4-5", json!([1, 2, 3]))
.expect_err("array response should error");
assert_eq!(
err,
CoreError::InvalidType {
expected: "object",
actual: "array",
}
);
.transform_response("claude-sonnet-4-5", response)
.expect("response transforms");
let value = serde_json::to_value(transformed).expect("serializable");
assert_eq!(value["stop_reason"], json!("end_turn"));
assert_eq!(value["stop_sequence"], json!(null));
assert_eq!(value["content"][0]["text"], json!("hello"));
}
}

View file

@ -1,3 +1,4 @@
pub mod anthropic;
pub mod azure_ai;
pub mod mistral;
pub mod openai;