1use crate::client::env::{self, EnvError};
14use crate::completion::{CompletionRequest, ProviderCapabilities};
15use crate::error::EncodeError;
16use crate::operation::Completion;
17use crate::providers::internal::named_dialect;
18use crate::wire::{Capabilities, Descriptor, Encoded, Framing, Mode, Secret, Wire};
19use serde::{Deserialize, Serialize};
20
21use super::completion::{
22 CacheTtl, default_max_tokens_for_model, document_source, image_source,
23 rejects_forced_tool_choice,
24};
25use super::streaming::MessagesDecoder;
26
27#[derive(Debug, Clone, Copy, PartialEq, Eq)]
31pub struct Dialect {
32 pub name: &'static str,
34 pub base_url: &'static str,
36 pub api_key_env: &'static str,
38 pub base_url_env: Option<&'static str>,
41 pub request_id_header: Option<&'static str>,
43 pub quirks: Quirks,
45}
46
47#[derive(Debug, Clone, Copy, PartialEq, Eq)]
49#[non_exhaustive]
50pub struct Quirks {
51 pub max_tokens: MaxTokens,
55 pub strict_tool_schemas: bool,
58 pub unsigned_thinking: bool,
62 pub tool_input_streaming: ToolInputStreaming,
65}
66
67#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
73#[serde(rename_all = "snake_case")]
74pub enum ToolInputStreaming {
75 #[default]
77 Eager,
78 BetaHeader,
81 Off,
83}
84
85impl Quirks {
86 pub const fn anthropic() -> Self {
88 Self {
89 max_tokens: MaxTokens::ByModel,
90 strict_tool_schemas: true,
91 unsigned_thinking: false,
92 tool_input_streaming: ToolInputStreaming::Eager,
93 }
94 }
95
96 pub const fn gateway() -> Self {
100 Self {
101 max_tokens: MaxTokens::Fixed(4096),
102 strict_tool_schemas: false,
103 unsigned_thinking: false,
104 tool_input_streaming: ToolInputStreaming::Eager,
105 }
106 }
107}
108
109#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
111pub enum MaxTokens {
112 ByModel,
114 Fixed(u64),
116}
117
118pub(crate) const MESSAGES_API: &str = "anthropic.messages";
120
121pub const ANTHROPIC: Dialect = Dialect {
123 name: super::PROVIDER_NAME,
124 base_url: "https://api.anthropic.com",
125 api_key_env: "ANTHROPIC_API_KEY",
126 base_url_env: Some("ANTHROPIC_BASE_URL"),
127 request_id_header: Some("request-id"),
128 quirks: Quirks::anthropic(),
129};
130
131const ALL: &[&Dialect] = &[&ANTHROPIC, &ZAI, &MINIMAX, &MOONSHOT, &XIAOMIMIMO];
133
134pub fn all() -> impl Iterator<Item = &'static Dialect> {
136 ALL.iter().copied()
137}
138
139impl Dialect {
140 pub fn by_name(name: &str) -> Option<Self> {
142 all().copied().find(|dialect| dialect.name == name)
143 }
144}
145
146impl Serialize for Dialect {
147 fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
148 let registered = Self::by_name(self.name).as_ref() == Some(self);
149 named_dialect::serialize(serializer, "Anthropic", self.name, registered)
150 }
151}
152
153impl<'de> Deserialize<'de> for Dialect {
154 fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
155 named_dialect::deserialize(deserializer, "Anthropic", Self::by_name)
156 }
157}
158
159pub const fn compatible(
162 name: &'static str,
163 base_url: &'static str,
164 api_key_env: &'static str,
165 base_url_env: Option<&'static str>,
166) -> Dialect {
167 Dialect {
168 name,
169 base_url,
170 api_key_env,
171 base_url_env,
172 request_id_header: Some("request-id"),
173 quirks: Quirks::gateway(),
174 }
175}
176
177impl Dialect {
178 pub fn default_max_tokens(&self, model: &str) -> Option<u64> {
180 match self.quirks.max_tokens {
181 MaxTokens::ByModel => default_max_tokens_for_model(model),
182 MaxTokens::Fixed(tokens) => Some(tokens),
183 }
184 }
185}
186
187pub const ZAI: Dialect = compatible(
189 crate::providers::zai::PROVIDER_NAME,
190 "https://api.z.ai/api/anthropic",
191 "ZAI_API_KEY",
192 Some("ZAI_ANTHROPIC_API_BASE"),
193);
194
195pub const MINIMAX: Dialect = compatible(
197 crate::providers::minimax::PROVIDER_NAME,
198 "https://api.minimax.io/anthropic",
199 "MINIMAX_API_KEY",
200 Some("MINIMAX_ANTHROPIC_API_BASE"),
201);
202
203pub const MOONSHOT: Dialect = Dialect {
206 quirks: Quirks {
207 unsigned_thinking: true,
208 ..Quirks::gateway()
209 },
210 ..compatible(
211 crate::providers::moonshot::PROVIDER_NAME,
212 "https://api.moonshot.ai/anthropic",
213 "MOONSHOT_API_KEY",
214 Some("MOONSHOT_ANTHROPIC_API_BASE"),
215 )
216};
217
218pub const XIAOMIMIMO: Dialect = compatible(
220 crate::providers::xiaomimimo::PROVIDER_NAME,
221 "https://api.xiaomimimo.com/anthropic",
222 "XIAOMI_MIMO_API_KEY",
223 Some("XIAOMI_MIMO_ANTHROPIC_API_BASE"),
224);
225
226#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
230#[serde(deny_unknown_fields)]
231pub struct AnthropicConfig {
232 pub api_key: Secret,
234 pub base_url: String,
236 pub version: String,
238 pub betas: Vec<String>,
240 pub dialect: Dialect,
242}
243
244impl AnthropicConfig {
245 pub fn new(api_key: impl Into<Secret>) -> Self {
247 Self::with_key(&ANTHROPIC, api_key)
248 }
249
250 pub fn with_key(dialect: &Dialect, api_key: impl Into<Secret>) -> Self {
254 Self {
255 api_key: api_key.into(),
256 base_url: normalize_base_url(dialect.base_url),
257 version: super::completion::ANTHROPIC_VERSION_LATEST.to_owned(),
258 betas: Vec::new(),
259 dialect: *dialect,
260 }
261 }
262
263 pub fn from_env() -> Result<Self, EnvError> {
265 Self::from_env_with(&ANTHROPIC)
266 }
267
268 pub fn from_env_with(dialect: &Dialect) -> Result<Self, EnvError> {
270 let mut provider = Self::with_key(dialect, env::required(dialect.api_key_env)?);
271 if let Some(name) = dialect.base_url_env
272 && let Some(base_url) = env::optional(name)?
273 {
274 provider.base_url = normalize_base_url(&base_url);
275 }
276 Ok(provider)
277 }
278
279 pub fn with_version(mut self, version: impl Into<String>) -> Self {
281 self.version = version.into();
282 self
283 }
284
285 pub fn with_beta(mut self, beta: impl Into<String>) -> Self {
287 self.betas.push(beta.into());
288 self
289 }
290
291 pub fn with_base_url(mut self, base_url: impl AsRef<str>) -> Self {
293 self.base_url = normalize_base_url(base_url.as_ref());
294 self
295 }
296
297 pub(crate) fn completion(&self, model: impl Into<String>) -> Messages {
299 let model = model.into();
300 Messages {
301 default_max_tokens: self.dialect.default_max_tokens(&model),
302 provider: self.clone(),
303 model,
304 prompt_caching: false,
305 static_prefix_cache_ttl: None,
306 strict_tools: false,
307 tool_input_streaming: self.dialect.quirks.tool_input_streaming,
308 }
309 }
310
311 pub(super) fn headers(&self, builder: http::request::Builder) -> http::request::Builder {
313 self.headers_with(builder, &[])
314 }
315
316 pub(super) fn headers_with(
318 &self,
319 builder: http::request::Builder,
320 extra: &[&str],
321 ) -> http::request::Builder {
322 let builder = builder
323 .header("x-api-key", self.api_key.expose())
324 .header("anthropic-version", &self.version);
325 let mut betas: Vec<&str> = self.betas.iter().map(String::as_str).collect();
326 for extra in extra {
327 if !betas.contains(extra) {
328 betas.push(extra);
329 }
330 }
331 if betas.is_empty() {
332 builder
333 } else {
334 builder.header("anthropic-beta", betas.join(","))
335 }
336 }
337}
338
339pub fn normalize_base_url(base_url: &str) -> String {
342 let trimmed = base_url.trim_end_matches('/');
343 for suffix in ["/v1/messages", "/messages", "/v1"] {
344 if let Some(stripped) = trimmed.strip_suffix(suffix) {
345 return stripped.to_owned();
346 }
347 }
348 trimmed.to_owned()
349}
350
351#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
353pub struct Messages {
354 pub provider: AnthropicConfig,
356 pub model: String,
358 pub default_max_tokens: Option<u64>,
362 pub prompt_caching: bool,
368 pub static_prefix_cache_ttl: Option<CacheTtl>,
371 pub strict_tools: bool,
373 #[serde(default)]
376 pub tool_input_streaming: ToolInputStreaming,
377}
378
379impl Messages {
380 pub fn with_default_max_tokens(mut self, tokens: u64) -> Self {
382 self.default_max_tokens = Some(tokens);
383 self
384 }
385
386 pub fn with_prompt_caching(mut self) -> Self {
391 self.prompt_caching = true;
392 self
393 }
394
395 pub fn with_static_prefix_cache_ttl(mut self, ttl: CacheTtl) -> Self {
418 self.static_prefix_cache_ttl = Some(ttl);
419 self
420 }
421
422 pub fn with_strict_tools(mut self) -> Self {
437 self.strict_tools = true;
438 self
439 }
440
441 pub fn with_tool_input_streaming(mut self, streaming: ToolInputStreaming) -> Self {
457 self.tool_input_streaming = streaming;
458 self
459 }
460}
461
462impl Wire for Messages {
463 type Op = Completion;
464 type Payload = crate::wire::Encoded;
465 type Frame = crate::wire::WireFrame;
466 type Decoder<'id> = MessagesDecoder;
467 type Reassembler = super::streaming::document::Message;
468
469 fn describe(&self) -> Descriptor<'_> {
471 Descriptor::new(self.provider.dialect.name)
472 .model(self.model.as_str())
473 .capabilities(Capabilities::completion(
474 ProviderCapabilities::default()
475 .with_native_output_tool_composition(true)
476 .with_forced_tool_choice_rejected(rejects_forced_tool_choice(&self.model)),
477 ))
478 .replay(self)
479 }
480
481 fn encode(&self, request: CompletionRequest, mode: Mode) -> Result<Encoded, EncodeError> {
482 let model = request.model.clone().unwrap_or_else(|| self.model.clone());
483 let fine_grained = !request.tools.is_empty()
484 && self.tool_input_streaming == ToolInputStreaming::BetaHeader;
485 let body = super::completion::body(self, &request, mode)?;
486 let betas: Vec<&str> = [
487 super::completion::drops_unbound_thinking(self, &model, body.get("thinking"))
488 .then_some(super::completion::THINKING_BINDING_BETA),
489 fine_grained.then_some(super::completion::FINE_GRAINED_TOOL_STREAMING_BETA),
490 ]
491 .into_iter()
492 .flatten()
493 .collect();
494 crate::providers::internal::trace_json(
495 crate::providers::internal::LogTarget::Completions,
496 "Anthropic completion request",
497 &body,
498 );
499 let request = self
500 .provider
501 .headers_with(
502 http::Request::post(format!("{}/v1/messages", self.provider.base_url)),
503 &betas,
504 )
505 .header(http::header::CONTENT_TYPE, "application/json")
506 .body(body.into_body())?;
507 Ok(Encoded::new(
508 request,
509 match mode {
510 Mode::Unary => Framing::Whole,
511 Mode::Streaming => Framing::Sse,
512 },
513 )
514 .with_request_id_header(self.provider.dialect.request_id_header)
515 .with_projection(MessagesDecoder::project))
516 }
517
518 fn decoder<'id>(&self) -> Self::Decoder<'id> {
519 MessagesDecoder::new(self.provider.dialect.quirks.unsigned_thinking)
520 }
521}
522
523impl crate::completion::ReplayTarget for Messages {
524 fn map_options(
526 &self,
527 request: &CompletionRequest,
528 fields: crate::completion::options::OptionFields<'_>,
529 ) -> crate::completion::options::OptionMap {
530 super::options::map_options(self, request, fields)
531 }
532
533 fn api(&self) -> crate::message::Api {
534 crate::message::Api::from_static(MESSAGES_API)
535 }
536
537 fn provider(&self) -> &str {
538 self.provider.dialect.name
539 }
540
541 fn model(&self) -> &str {
542 &self.model
543 }
544
545 fn accepts(&self, model: &str) -> crate::completion::Accepts {
551 let rule: Option<fn(&str) -> bool> = match self.provider.dialect.name {
552 name if name == ANTHROPIC.name => None,
553 name if name == ZAI.name => Some(crate::providers::zai::reads_images),
554 name if name == MOONSHOT.name => Some(crate::providers::moonshot::reads_images),
555 name if name == MINIMAX.name => Some(crate::providers::minimax::reads_images),
556 name if name == XIAOMIMIMO.name => Some(crate::providers::xiaomimimo::reads_images),
557 _ => Some(|_| true),
558 };
559 let images = rule.is_none_or(|rule| {
560 crate::catalog::reads_images_or(self.provider.dialect.name, model, rule)
561 });
562 crate::completion::Accepts {
563 user_images: images,
564 assistant_images: false,
565 tool_result_images: images,
566 tools: true,
567 }
568 }
569
570 fn encodes(&self, _model: &str, media: crate::completion::Media<'_>) -> bool {
575 use crate::completion::{Media, Place};
576 match media {
577 Media::Image(image, Place::User | Place::ToolResult) => image_source(image).is_some(),
578 Media::Document(document) => document_source(document).is_some(),
579 Media::Image(_, Place::Assistant) | Media::Audio(_) | Media::Video(_) => false,
580 }
581 }
582
583 fn normalize_tool_call_id(
586 &self,
587 id: &str,
588 _model: &str,
589 _source: Option<&crate::message::Origin>,
590 ) -> String {
591 crate::providers::internal::wire_ids::legal_call_id(id, 64)
592 }
593
594 fn identity(&self, item: &serde_json::Value) -> serde_json::Map<String, serde_json::Value> {
597 item.get("caller")
598 .filter(|_| item.get("type").and_then(serde_json::Value::as_str) == Some("tool_use"))
599 .map(|caller| serde_json::Map::from_iter([("caller".to_owned(), caller.clone())]))
600 .unwrap_or_default()
601 }
602
603 fn later_system(&self, model: &str) -> crate::completion::LaterSystem {
606 if super::completion::takes_mid_conversation_system(model) {
607 crate::completion::LaterSystem::InPlace
608 } else {
609 crate::completion::LaterSystem::Leading
610 }
611 }
612
613 fn binds_context(&self, model: &str) -> bool {
618 self.provider.dialect.name == ANTHROPIC.name && super::completion::binds_context(model)
619 }
620
621 fn drops_unbound_items(&self, request: &CompletionRequest) -> bool {
626 let model = request.model.as_deref().unwrap_or(&self.model);
627 let thinking = crate::completion::options::param(self, request, "thinking");
628 super::completion::drops_unbound_thinking(self, model, thinking.as_ref())
629 }
630
631 fn sends_alone(&self, block: &crate::message::AssistantContent) -> bool {
633 let ids = crate::providers::internal::wire_ids::WireIds::default();
634 super::completion::assistant_part(block, self, &ids).is_some()
635 }
636
637 fn call_id_slot(&self) -> Option<&'static str> {
638 Some("/id")
639 }
640
641 fn hosted_pair(
644 &self,
645 item: &serde_json::Value,
646 ) -> Option<(crate::completion::Pairing, String)> {
647 use crate::completion::Pairing;
648 let kind = item.get("type")?.as_str()?;
649 let (side, key) = if kind.ends_with("_tool_use") {
650 (Pairing::Use, "id")
651 } else if kind.ends_with("_tool_result") {
652 (Pairing::Result, "tool_use_id")
653 } else {
654 return None;
655 };
656 Some((side, item.get(key)?.as_str()?.to_owned()))
657 }
658}
659
660#[cfg(test)]
661mod tests;