1use crate::catalog::ModelFacts;
14use crate::client::env::{self, EnvError};
15use crate::completion::options::FinalBody;
16use crate::completion::{CompletionRequest, ProviderCapabilities};
17use crate::error::EncodeError;
18use crate::operation::Completion;
19use crate::providers::internal::named_dialect;
20use crate::wire::{Body, Capabilities, Descriptor, Encoded, Framing, Mode, Secret, Wire};
21use serde::{Deserialize, Serialize};
22
23use super::completion::{
24 CacheTtl, default_max_tokens_for_model, document_source, image_source,
25 rejects_forced_tool_choice,
26};
27use super::streaming::MessagesDecoder;
28
29#[derive(Debug, Clone, Copy, PartialEq, Eq)]
33pub struct Dialect {
34 pub name: &'static str,
36 pub base_url: &'static str,
38 pub api_key_env: &'static str,
40 pub base_url_env: Option<&'static str>,
43 pub request_id_header: Option<&'static str>,
45 pub quirks: Quirks,
47}
48
49#[derive(Debug, Clone, Copy, PartialEq, Eq)]
52#[non_exhaustive]
53pub struct Quirks {
54 pub max_tokens: MaxTokens,
58 pub strict_tool_schemas: bool,
61 pub unsigned_thinking: bool,
65 pub tool_input_streaming: ToolInputStreaming,
67 pub auth: Auth,
69 pub session_header: Option<&'static str>,
72 pub supports_output_format: bool,
77 pub catalog_priced: bool,
80 pub cache_ttl_pricing: bool,
84}
85
86#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
88pub enum Auth {
89 #[default]
91 ApiKey,
92 OptionalBearer,
96}
97
98#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
104#[serde(rename_all = "snake_case")]
105pub enum ToolInputStreaming {
106 #[default]
108 Eager,
109 BetaHeader,
112 Off,
114}
115
116impl Quirks {
117 pub const fn anthropic() -> Self {
119 Self {
120 max_tokens: MaxTokens::ByModel,
121 strict_tool_schemas: true,
122 unsigned_thinking: false,
123 tool_input_streaming: ToolInputStreaming::Eager,
124 auth: Auth::ApiKey,
125 session_header: None,
126 supports_output_format: true,
127 catalog_priced: true,
128 cache_ttl_pricing: true,
129 }
130 }
131
132 pub const fn gateway() -> Self {
136 Self {
137 max_tokens: MaxTokens::Fixed(4096),
138 strict_tool_schemas: false,
139 unsigned_thinking: false,
140 tool_input_streaming: ToolInputStreaming::Eager,
141 auth: Auth::ApiKey,
142 session_header: None,
143 supports_output_format: true,
144 catalog_priced: true,
145 cache_ttl_pricing: false,
146 }
147 }
148}
149
150#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
152pub enum MaxTokens {
153 ByModel,
155 Fixed(u64),
157}
158
159pub(crate) const MESSAGES_API: &str = "anthropic.messages";
161
162pub const ANTHROPIC: Dialect = Dialect {
164 name: super::PROVIDER_NAME,
165 base_url: "https://api.anthropic.com",
166 api_key_env: "ANTHROPIC_API_KEY",
167 base_url_env: Some("ANTHROPIC_BASE_URL"),
168 request_id_header: Some("request-id"),
169 quirks: Quirks::anthropic(),
170};
171
172const ALL: &[&Dialect] = &[
174 &ANTHROPIC,
175 &ZAI,
176 &MINIMAX,
177 &MOONSHOT,
178 &XIAOMIMIMO,
179 &OLLAMA,
180 &OPENCODE,
181 &OPENCODE_GO,
182];
183
184pub fn all() -> impl Iterator<Item = &'static Dialect> {
186 ALL.iter().copied()
187}
188
189impl Dialect {
190 pub fn by_name(name: &str) -> Option<Self> {
192 all().copied().find(|dialect| dialect.name == name)
193 }
194}
195
196impl Serialize for Dialect {
197 fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
198 let registered = Self::by_name(self.name).as_ref() == Some(self);
199 named_dialect::serialize(serializer, "Anthropic", self.name, registered)
200 }
201}
202
203impl<'de> Deserialize<'de> for Dialect {
204 fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
205 named_dialect::deserialize(deserializer, "Anthropic", Self::by_name)
206 }
207}
208
209pub const fn compatible(
212 name: &'static str,
213 base_url: &'static str,
214 api_key_env: &'static str,
215 base_url_env: Option<&'static str>,
216) -> Dialect {
217 Dialect {
218 name,
219 base_url,
220 api_key_env,
221 base_url_env,
222 request_id_header: Some("request-id"),
223 quirks: Quirks::gateway(),
224 }
225}
226
227impl Dialect {
228 pub fn default_max_tokens(&self, facts: &ModelFacts, model: &str) -> Option<u64> {
231 match self.quirks.max_tokens {
232 MaxTokens::ByModel => default_max_tokens_for_model(facts, model),
233 MaxTokens::Fixed(tokens) => Some(tokens),
234 }
235 }
236}
237
238pub const ZAI: Dialect = compatible(
240 crate::providers::zai::PROVIDER_NAME,
241 "https://api.z.ai/api/anthropic",
242 "ZAI_API_KEY",
243 Some("ZAI_ANTHROPIC_API_BASE"),
244);
245
246pub const MINIMAX: Dialect = compatible(
248 crate::providers::minimax::PROVIDER_NAME,
249 "https://api.minimax.io/anthropic",
250 "MINIMAX_API_KEY",
251 Some("MINIMAX_ANTHROPIC_API_BASE"),
252);
253
254pub const MOONSHOT: Dialect = Dialect {
257 quirks: Quirks {
258 unsigned_thinking: true,
259 ..Quirks::gateway()
260 },
261 ..compatible(
262 crate::providers::moonshot::PROVIDER_NAME,
263 "https://api.moonshot.ai/anthropic",
264 "MOONSHOT_API_KEY",
265 Some("MOONSHOT_ANTHROPIC_API_BASE"),
266 )
267};
268
269pub const XIAOMIMIMO: Dialect = compatible(
271 crate::providers::xiaomimimo::PROVIDER_NAME,
272 "https://api.xiaomimimo.com/anthropic",
273 "XIAOMI_MIMO_API_KEY",
274 Some("XIAOMI_MIMO_ANTHROPIC_API_BASE"),
275);
276
277pub const OLLAMA: Dialect = Dialect {
283 quirks: Quirks {
284 auth: Auth::OptionalBearer,
285 unsigned_thinking: true,
286 supports_output_format: false,
288 catalog_priced: false,
290 ..Quirks::gateway()
291 },
292 ..compatible(
293 crate::providers::ollama::PROVIDER_NAME,
294 "http://localhost:11434",
295 "OLLAMA_API_KEY",
296 Some("OLLAMA_API_BASE_URL"),
297 )
298};
299
300pub const OPENCODE: Dialect = compatible(
303 crate::providers::opencode::ZEN,
304 "https://opencode.ai/inference/anthropic",
305 "OPENCODE_API_KEY",
306 Some("OPENCODE_ANTHROPIC_BASE_URL"),
307);
308
309pub const OPENCODE_GO: Dialect = Dialect {
312 quirks: Quirks {
313 session_header: Some(crate::providers::opencode::SESSION_HEADER),
314 ..Quirks::gateway()
315 },
316 ..compatible(
317 crate::providers::opencode::GO,
318 "https://opencode.ai/inference/go/anthropic",
319 "OPENCODE_API_KEY",
320 Some("OPENCODE_GO_ANTHROPIC_BASE_URL"),
321 )
322};
323
324#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
328#[serde(deny_unknown_fields)]
329pub struct AnthropicConfig {
330 pub api_key: Secret,
332 pub base_url: String,
334 pub version: String,
336 pub betas: Vec<String>,
338 pub dialect: Dialect,
340 #[serde(default, skip_serializing_if = "Addressing::is_body")]
342 pub addressing: Addressing,
343}
344
345#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
347#[serde(rename_all = "snake_case")]
348pub enum Addressing {
349 #[default]
352 Body,
353 Url,
357}
358
359impl Addressing {
360 fn is_body(&self) -> bool {
362 *self == Self::Body
363 }
364}
365
366impl AnthropicConfig {
367 pub fn new(api_key: impl Into<Secret>) -> Self {
369 Self::with_key(&ANTHROPIC, api_key)
370 }
371
372 pub fn with_key(dialect: &Dialect, api_key: impl Into<Secret>) -> Self {
376 Self {
377 api_key: api_key.into(),
378 base_url: normalize_base_url(dialect.base_url),
379 version: super::completion::ANTHROPIC_VERSION_LATEST.to_owned(),
380 betas: Vec::new(),
381 dialect: *dialect,
382 addressing: Addressing::Body,
383 }
384 }
385
386 pub fn from_env() -> Result<Self, EnvError> {
388 Self::from_env_with(&ANTHROPIC)
389 }
390
391 pub fn from_env_with(dialect: &Dialect) -> Result<Self, EnvError> {
395 let api_key = match dialect.quirks.auth {
396 Auth::ApiKey => env::required(dialect.api_key_env)?,
397 Auth::OptionalBearer => env::optional(dialect.api_key_env)?.unwrap_or_default(),
398 };
399 Self::from_env_with_credential(dialect, api_key)
400 }
401
402 pub(crate) fn from_env_with_credential(
405 dialect: &Dialect,
406 api_key: impl Into<Secret>,
407 ) -> Result<Self, EnvError> {
408 let mut provider = Self::with_key(dialect, api_key);
409 if let Some(name) = dialect.base_url_env
410 && let Some(base_url) = env::optional(name)?
411 {
412 provider.base_url = normalize_base_url(&base_url);
413 }
414 Ok(provider)
415 }
416
417 pub fn with_version(mut self, version: impl Into<String>) -> Self {
419 self.version = version.into();
420 self
421 }
422
423 pub fn with_beta(mut self, beta: impl Into<String>) -> Self {
425 self.betas.push(beta.into());
426 self
427 }
428
429 pub fn with_base_url(mut self, base_url: impl AsRef<str>) -> Self {
431 self.base_url = normalize_base_url(base_url.as_ref());
432 self
433 }
434
435 pub fn completion(&self, model: impl Into<String>) -> Messages {
437 let model = model.into();
438 Messages {
439 default_max_tokens: self
440 .dialect
441 .default_max_tokens(ModelFacts::builtin(), &model),
442 provider: self.clone(),
443 model,
444 prompt_caching: false,
445 static_prefix_cache_ttl: None,
446 strict_tools: false,
447 tool_input_streaming: self.dialect.quirks.tool_input_streaming,
448 facts: ModelFacts::default(),
449 }
450 }
451
452 pub(super) fn headers(&self, builder: http::request::Builder) -> http::request::Builder {
454 self.headers_with(builder, &self.betas)
455 }
456
457 fn headers_with(
459 &self,
460 builder: http::request::Builder,
461 betas: &[String],
462 ) -> http::request::Builder {
463 let builder = match self.dialect.quirks.auth {
464 Auth::ApiKey => builder.header("x-api-key", self.api_key.expose()),
465 Auth::OptionalBearer if self.api_key.is_empty() => builder,
466 Auth::OptionalBearer => builder.header(
467 http::header::AUTHORIZATION,
468 format!("Bearer {}", self.api_key.expose()),
469 ),
470 };
471 let builder = builder.header("anthropic-version", &self.version);
472 if betas.is_empty() {
473 builder
474 } else {
475 builder.header("anthropic-beta", betas.join(","))
476 }
477 }
478
479 fn betas_with(&self, extra: &[&str]) -> Vec<String> {
482 let mut betas = self.betas.clone();
483 for extra in extra {
484 if !betas.iter().any(|beta| beta == extra) {
485 betas.push((*extra).to_owned());
486 }
487 }
488 betas
489 }
490}
491
492pub fn normalize_base_url(base_url: &str) -> String {
495 let trimmed = base_url.trim_end_matches('/');
496 for suffix in ["/v1/messages", "/messages", "/v1"] {
497 if let Some(stripped) = trimmed.strip_suffix(suffix) {
498 return stripped.to_owned();
499 }
500 }
501 trimmed.to_owned()
502}
503
504#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
506pub struct Messages {
507 pub provider: AnthropicConfig,
509 pub model: String,
511 pub default_max_tokens: Option<u64>,
515 pub prompt_caching: bool,
521 pub static_prefix_cache_ttl: Option<CacheTtl>,
524 pub strict_tools: bool,
526 #[serde(default)]
529 pub tool_input_streaming: ToolInputStreaming,
530 #[serde(skip)]
532 pub facts: ModelFacts,
533}
534
535impl Messages {
536 pub fn with_facts(mut self, facts: ModelFacts) -> Self {
541 self.default_max_tokens = self
542 .provider
543 .dialect
544 .default_max_tokens(&facts, &self.model);
545 self.facts = facts;
546 self
547 }
548
549 pub fn with_default_max_tokens(mut self, tokens: u64) -> Self {
551 self.default_max_tokens = Some(tokens);
552 self
553 }
554
555 pub fn with_prompt_caching(mut self) -> Self {
560 self.prompt_caching = true;
561 self
562 }
563
564 pub fn with_static_prefix_cache_ttl(mut self, ttl: CacheTtl) -> Self {
587 self.static_prefix_cache_ttl = Some(ttl);
588 self
589 }
590
591 pub fn with_strict_tools(mut self) -> Self {
606 self.strict_tools = true;
607 self
608 }
609
610 pub fn with_tool_input_streaming(mut self, streaming: ToolInputStreaming) -> Self {
626 self.tool_input_streaming = streaming;
627 self
628 }
629}
630
631#[derive(Debug, Clone, PartialEq)]
634#[non_exhaustive]
635pub struct Prepared {
636 pub model: String,
638 pub body: FinalBody,
640 pub betas: Vec<String>,
643}
644
645impl Messages {
646 pub fn encode_with(
657 &self,
658 request: CompletionRequest,
659 mode: Mode,
660 envelope: impl FnOnce(Prepared) -> Result<http::Request<Body>, EncodeError>,
661 ) -> Result<Encoded, EncodeError> {
662 let model = request.model.clone().unwrap_or_else(|| self.model.clone());
663 let fine_grained = !request.tools.is_empty()
664 && self.tool_input_streaming == ToolInputStreaming::BetaHeader;
665 let body = super::completion::body(self, &request, mode)?;
666 let betas: Vec<&str> = [
667 body.get("thinking")
668 .and_then(|thinking| thinking.get("block_binding"))
669 .is_some_and(serde_json::Value::is_object)
670 .then_some(super::completion::THINKING_BINDING_BETA),
671 fine_grained.then_some(super::completion::FINE_GRAINED_TOOL_STREAMING_BETA),
672 ]
673 .into_iter()
674 .flatten()
675 .collect();
676 crate::providers::internal::trace_json(
677 crate::providers::internal::LogTarget::Completions,
678 "Anthropic completion request",
679 &body,
680 );
681 let request = envelope(Prepared {
682 model,
683 body,
684 betas: self.provider.betas_with(&betas),
685 })?;
686 Ok(Encoded::new(
687 request,
688 match mode {
689 Mode::Unary => Framing::Whole,
690 Mode::Streaming => Framing::Sse,
691 },
692 )
693 .with_request_id_header(self.provider.dialect.request_id_header)
694 .with_projection(MessagesDecoder::project))
695 }
696}
697
698impl Wire for Messages {
699 type Op = Completion;
700 type Payload = crate::wire::Encoded;
701 type Frame = crate::wire::WireFrame;
702 type Event = super::streaming::MessagesEvent;
703 type Decoder<'id> = MessagesDecoder;
704 type Reassembler = super::streaming::document::Message;
705
706 fn describe(&self) -> Descriptor<'_> {
710 Descriptor::new(self.provider.dialect.name)
711 .model(self.model.as_str())
712 .facts(&self.facts)
713 .capabilities(Capabilities::completion(
714 ProviderCapabilities::default()
715 .with_native_output_tool_composition(
716 self.provider.dialect.quirks.supports_output_format,
717 )
718 .with_forced_tool_choice_rejected(rejects_forced_tool_choice(
719 &self.facts,
720 &self.model,
721 )),
722 ))
723 .replay(self)
724 }
725
726 fn encode(&self, request: CompletionRequest, mode: Mode) -> Result<Encoded, EncodeError> {
727 let session = self.provider.dialect.quirks.session_header.map(|name| {
728 let id = crate::providers::internal::request_session_id(&request);
729 (name, id)
730 });
731 self.encode_with(request, mode, |prepared| {
732 let mut builder = self
733 .provider
734 .headers_with(
735 http::Request::post(format!("{}/v1/messages", self.provider.base_url)),
736 &prepared.betas,
737 )
738 .header(http::header::CONTENT_TYPE, "application/json");
739 if let Some((name, id)) = session {
740 builder = builder.header(name, id);
741 }
742 Ok(builder.body(prepared.body.into_body())?)
743 })
744 }
745
746 fn decoder<'id>(&self) -> Self::Decoder<'id> {
747 MessagesDecoder::new(self.provider.dialect.quirks)
748 }
749}
750
751impl crate::completion::ReplayTarget for Messages {
752 fn catalog_priced(&self) -> bool {
755 self.provider.dialect.quirks.catalog_priced
756 }
757
758 fn map_options(
760 &self,
761 request: &CompletionRequest,
762 fields: crate::completion::options::OptionFields<'_>,
763 ) -> crate::completion::options::OptionMap {
764 super::options::map_options(self, request, fields)
765 }
766
767 fn facts(&self) -> Option<&ModelFacts> {
768 Some(&self.facts)
769 }
770
771 fn api(&self) -> crate::message::Api {
772 crate::message::Api::from_static(MESSAGES_API)
773 }
774
775 fn provider(&self) -> &str {
776 self.provider.dialect.name
777 }
778
779 fn model(&self) -> &str {
780 &self.model
781 }
782
783 fn accepts(&self, model: &str) -> crate::completion::Accepts {
789 let rule: Option<fn(&str) -> bool> = match self.provider.dialect.name {
790 name if name == ANTHROPIC.name => None,
791 name if name == ZAI.name => Some(crate::providers::zai::reads_images),
792 name if name == MOONSHOT.name => Some(crate::providers::moonshot::reads_images),
793 name if name == MINIMAX.name => Some(crate::providers::minimax::reads_images),
794 name if name == XIAOMIMIMO.name => Some(crate::providers::xiaomimimo::reads_images),
795 _ => Some(|_| true),
796 };
797 let images = rule.is_none_or(|rule| {
798 self.facts
799 .reads_images_or(self.provider.dialect.name, model, rule)
800 });
801 crate::completion::Accepts {
802 user_images: images,
803 assistant_images: false,
804 tool_result_images: images,
805 tools: true,
806 foreign_reasoning: false,
808 }
809 }
810
811 fn encodes(&self, _model: &str, media: crate::completion::Media<'_>) -> bool {
816 use crate::completion::{Media, Place};
817 match media {
818 Media::Image(image, Place::User | Place::ToolResult) => image_source(image).is_some(),
819 Media::Document(document) => document_source(document).is_some(),
820 Media::Image(_, Place::Assistant) | Media::Audio(_) | Media::Video(_) => false,
821 }
822 }
823
824 fn normalize_tool_call_id(
826 &self,
827 id: &str,
828 _model: &str,
829 _source: Option<&crate::message::Origin>,
830 ) -> String {
831 crate::providers::internal::wire_ids::legal_call_id(id, 64)
832 }
833
834 fn identity(&self, item: &serde_json::Value) -> serde_json::Map<String, serde_json::Value> {
837 item.get("caller")
838 .filter(|_| item.get("type").and_then(serde_json::Value::as_str) == Some("tool_use"))
839 .map(|caller| serde_json::Map::from_iter([("caller".to_owned(), caller.clone())]))
840 .unwrap_or_default()
841 }
842
843 fn later_system(&self, model: &str) -> crate::completion::LaterSystem {
846 if super::completion::takes_mid_conversation_system(&self.facts, model) {
847 crate::completion::LaterSystem::InPlace
848 } else {
849 crate::completion::LaterSystem::Leading
850 }
851 }
852
853 fn binds_context(&self, model: &str) -> bool {
858 self.provider.dialect.name == ANTHROPIC.name
859 && super::completion::binds_context(&self.facts, model)
860 }
861
862 fn drops_unbound_items(&self, request: &CompletionRequest) -> bool {
867 let model = request.model.as_deref().unwrap_or(&self.model);
868 let thinking = crate::completion::options::param(self, request, "thinking");
869 super::completion::drops_unbound_thinking(self, model, thinking.as_ref())
870 }
871
872 fn sends_alone(&self, block: &crate::message::AssistantContent) -> bool {
874 let ids = crate::providers::internal::wire_ids::WireIds::default();
875 super::completion::assistant_part(block, self, &ids).is_some()
876 }
877
878 fn call_id_slot(&self) -> Option<&'static str> {
879 Some("/id")
880 }
881
882 fn blob_slots(&self) -> &'static [&'static str] {
883 &["/signature", "/data"]
884 }
885
886 fn hosted_pair(
889 &self,
890 item: &serde_json::Value,
891 ) -> Option<(crate::completion::Pairing, String)> {
892 use crate::completion::Pairing;
893 let kind = item.get("type")?.as_str()?;
894 let (side, key) = if kind.ends_with("_tool_use") {
895 (Pairing::Use, "id")
896 } else if kind.ends_with("_tool_result") {
897 (Pairing::Result, "tool_use_id")
898 } else {
899 return None;
900 };
901 Some((side, item.get(key)?.as_str()?.to_owned()))
902 }
903}
904
905#[cfg(test)]
906mod tests;