Skip to main content

rig_core/providers/anthropic/
extension.rs

1//! Typed request options and reply extras for Anthropic's own Messages API.
2//! [`AnthropicExt`] keys them by [`PROVIDER_NAME`](super::PROVIDER_NAME); the
3//! Messages wire reads the entry only when its dialect is Anthropic, so a
4//! gateway speaking the same format never receives them.
5//!
6//! A field marked "+ beta" needs its `anthropic-beta` flag set on the
7//! provider with [`AnthropicConfig::with_beta`](super::AnthropicConfig::with_beta);
8//! without it the API rejects the request.
9//!
10//! ```
11//! use rig_core::completion::CompletionRequest;
12//! use rig_core::providers::anthropic::extension::{AnthropicOptions, InferenceGeo};
13//!
14//! let options = AnthropicOptions::default()
15//!     .metadata_user_id("user-7")
16//!     .inference_geo(InferenceGeo::Us);
17//! let request = CompletionRequest::new("hi").provider_option(options);
18//! # let _ = request;
19//! ```
20
21use serde::ser::SerializeMap;
22use serde::{Deserialize, Serialize, Serializer};
23use serde_json::Value;
24
25use super::completion::{CLAUDE_OPUS_4_8, CLAUDE_OPUS_5, CLAUDE_OPUS_5_5};
26use super::wire::MESSAGES_API;
27use crate::catalog::Sampling;
28use crate::completion::{
29    CompletionRequest, ExtensionOptions, ProviderExtension, ReplayTarget, ReplyExtras,
30};
31use crate::message::Api;
32
33/// The `anthropic-beta` flag fast mode needs.
34pub const FAST_MODE_BETA: &str = "fast-mode-2026-02-01";
35/// The `anthropic-beta` flag task budgets need.
36pub const TASK_BUDGETS_BETA: &str = "task-budgets-2026-03-13";
37/// The `anthropic-beta` flag [`Fallbacks::Default`] needs.
38pub const SERVER_SIDE_FALLBACK_BETA: &str = "server-side-fallback-2026-07-01";
39/// The `anthropic-beta` flag [`Fallbacks::Models`] needs.
40pub const SERVER_SIDE_FALLBACK_MODELS_BETA: &str = "server-side-fallback-2026-06-01";
41/// The `anthropic-beta` flag context editing needs.
42pub const CONTEXT_MANAGEMENT_BETA: &str = "context-management-2025-06-27";
43/// The `anthropic-beta` flag the MCP connector needs.
44pub const MCP_CLIENT_BETA: &str = "mcp-client-2025-11-20";
45/// The `anthropic-beta` flag cache diagnostics need.
46pub const CACHE_DIAGNOSIS_BETA: &str = "cache-diagnosis-2026-04-07";
47
48/// The models fast mode runs on.
49const FAST_MODELS: &[&str] = &[CLAUDE_OPUS_5_5, CLAUDE_OPUS_5, CLAUDE_OPUS_4_8];
50
51/// Anthropic's typed options and extras.
52#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
53pub struct AnthropicExt;
54
55impl ProviderExtension for AnthropicExt {
56    const PROVIDER: &'static str = super::PROVIDER_NAME;
57    type Options = AnthropicOptions;
58    type Extras = AnthropicExtras;
59}
60
61/// Anthropic's request options. The Messages API is its one route, so every
62/// field sits in the shared section.
63#[non_exhaustive]
64#[derive(Clone, Debug, Default, PartialEq, Serialize)]
65pub struct AnthropicOptions {
66    /// The fields every route sends.
67    #[serde(rename = "*")]
68    pub shared: AnthropicShared,
69}
70
71/// The fields of [`AnthropicOptions`]. Each unset field is left out of the
72/// body.
73#[non_exhaustive]
74#[derive(Clone, Debug, Default, PartialEq)]
75pub struct AnthropicShared {
76    /// `top_k`: sample from the `k` most likely tokens. Refused on models
77    /// that fix their sampling.
78    pub top_k: Option<u32>,
79    /// `metadata.user_id`: an opaque id of the end user, at most 512
80    /// characters.
81    pub metadata_user_id: Option<String>,
82    /// `inference_geo`: where inference runs.
83    pub inference_geo: Option<InferenceGeo>,
84    /// `speed`. [`Speed::Fast`] + beta [`FAST_MODE_BETA`], on Claude Opus
85    /// 5.5, Opus 5 and Opus 4.8 only; refused on other listed models.
86    pub speed: Option<Speed>,
87    /// `output_config.task_budget` + beta [`TASK_BUDGETS_BETA`].
88    pub task_budget: Option<TaskBudget>,
89    /// `fallbacks`: the models a refused request is re-run on, + beta
90    /// [`SERVER_SIDE_FALLBACK_BETA`] or [`SERVER_SIDE_FALLBACK_MODELS_BETA`].
91    pub fallbacks: Option<Fallbacks>,
92    /// `container`: the code-execution container to reuse, or the skills to
93    /// load into it. It replaces the container the history names.
94    pub container: Option<ContainerParam>,
95    /// `context_management` + beta [`CONTEXT_MANAGEMENT_BETA`].
96    pub context_management: Option<ContextManagement>,
97    /// `mcp_servers` + beta [`MCP_CLIENT_BETA`]. Each server also needs an
98    /// `mcp_toolset` tool naming it.
99    pub mcp_servers: Vec<McpServer>,
100    /// `diagnostics.previous_message_id` + beta [`CACHE_DIAGNOSIS_BETA`]:
101    /// `Some(None)` sends `null` on a conversation's first request,
102    /// `Some(Some(id))` the previous reply's id.
103    pub diagnostics_previous_message_id: Option<Option<String>>,
104}
105
106impl Serialize for AnthropicShared {
107    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
108        let Self {
109            top_k,
110            metadata_user_id,
111            inference_geo,
112            speed,
113            task_budget,
114            fallbacks,
115            container,
116            context_management,
117            mcp_servers,
118            diagnostics_previous_message_id,
119        } = self;
120        let mut map = serializer.serialize_map(None)?;
121        if let Some(top_k) = top_k {
122            map.serialize_entry("top_k", top_k)?;
123        }
124        if let Some(user_id) = metadata_user_id {
125            map.serialize_entry("metadata", &Nested("user_id", user_id))?;
126        }
127        if let Some(geo) = inference_geo {
128            map.serialize_entry("inference_geo", geo)?;
129        }
130        if let Some(speed) = speed {
131            map.serialize_entry("speed", speed)?;
132        }
133        if let Some(budget) = task_budget {
134            map.serialize_entry("output_config", &Nested("task_budget", budget))?;
135        }
136        if let Some(fallbacks) = fallbacks {
137            map.serialize_entry("fallbacks", fallbacks)?;
138        }
139        if let Some(container) = container {
140            map.serialize_entry("container", container)?;
141        }
142        if let Some(context_management) = context_management {
143            map.serialize_entry("context_management", context_management)?;
144        }
145        if !mcp_servers.is_empty() {
146            map.serialize_entry("mcp_servers", mcp_servers)?;
147        }
148        if let Some(previous) = diagnostics_previous_message_id {
149            map.serialize_entry("diagnostics", &Nested("previous_message_id", previous))?;
150        }
151        map.end()
152    }
153}
154
155/// A one-key object: `{key: value}`.
156pub(crate) struct Nested<'a, T>(pub(crate) &'static str, pub(crate) &'a T);
157
158impl<T: Serialize> Serialize for Nested<'_, T> {
159    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
160        let mut map = serializer.serialize_map(Some(1))?;
161        map.serialize_entry(self.0, self.1)?;
162        map.end()
163    }
164}
165
166impl AnthropicOptions {
167    /// Set [`AnthropicShared::top_k`].
168    pub fn top_k(mut self, top_k: u32) -> Self {
169        self.shared.top_k = Some(top_k);
170        self
171    }
172
173    /// Set [`AnthropicShared::metadata_user_id`].
174    pub fn metadata_user_id(mut self, user_id: impl Into<String>) -> Self {
175        self.shared.metadata_user_id = Some(user_id.into());
176        self
177    }
178
179    /// Set [`AnthropicShared::inference_geo`].
180    pub fn inference_geo(mut self, geo: InferenceGeo) -> Self {
181        self.shared.inference_geo = Some(geo);
182        self
183    }
184
185    /// Set [`AnthropicShared::speed`].
186    pub fn speed(mut self, speed: Speed) -> Self {
187        self.shared.speed = Some(speed);
188        self
189    }
190
191    /// Set [`AnthropicShared::task_budget`].
192    pub fn task_budget(mut self, budget: TaskBudget) -> Self {
193        self.shared.task_budget = Some(budget);
194        self
195    }
196
197    /// Set [`AnthropicShared::fallbacks`].
198    pub fn fallbacks(mut self, fallbacks: Fallbacks) -> Self {
199        self.shared.fallbacks = Some(fallbacks);
200        self
201    }
202
203    /// Set [`AnthropicShared::container`].
204    pub fn container(mut self, container: impl Into<ContainerParam>) -> Self {
205        self.shared.container = Some(container.into());
206        self
207    }
208
209    /// Set [`AnthropicShared::context_management`].
210    pub fn context_management(mut self, context_management: ContextManagement) -> Self {
211        self.shared.context_management = Some(context_management);
212        self
213    }
214
215    /// Add one server to [`AnthropicShared::mcp_servers`].
216    pub fn mcp_server(mut self, server: McpServer) -> Self {
217        self.shared.mcp_servers.push(server);
218        self
219    }
220
221    /// Set [`AnthropicShared::diagnostics_previous_message_id`].
222    pub fn diagnostics_previous_message_id(mut self, previous: Option<String>) -> Self {
223        self.shared.diagnostics_previous_message_id = Some(previous);
224        self
225    }
226}
227
228impl ExtensionOptions for AnthropicOptions {
229    type Ext = AnthropicExt;
230
231    /// `top_k` on a model that fixes its sampling, and fast mode on a
232    /// listed model that does not offer it. A model the catalog does not
233    /// list is sent everything.
234    fn unsupported(
235        &self,
236        target: &dyn ReplayTarget,
237        request: &CompletionRequest,
238    ) -> Vec<(&'static str, String)> {
239        let model = request
240            .model
241            .as_deref()
242            .filter(|model| !model.is_empty())
243            .unwrap_or_else(|| target.model());
244        let facts = target
245            .facts()
246            .unwrap_or_else(|| crate::catalog::ModelFacts::builtin());
247        let Some(spec) = facts.for_model(target.provider(), model) else {
248            return Vec::new();
249        };
250        let mut refused = Vec::new();
251        if self.shared.top_k.is_some() && spec.sampling == Some(Sampling::Never) {
252            refused.push(("top_k", "this model does not take `top_k`".to_owned()));
253        }
254        if self.shared.speed == Some(Speed::Fast) && !FAST_MODELS.contains(&spec.id.as_str()) {
255            refused.push((
256                "speed",
257                "fast mode runs on Claude Opus 5.5, Opus 5 and Opus 4.8 only".to_owned(),
258            ));
259        }
260        refused
261    }
262}
263
264/// Where inference runs.
265#[non_exhaustive]
266#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
267#[serde(rename_all = "lowercase")]
268pub enum InferenceGeo {
269    /// `"us"`: in the United States only.
270    Us,
271    /// `"global"`: anywhere.
272    Global,
273}
274
275/// The output speed.
276#[non_exhaustive]
277#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
278#[serde(rename_all = "lowercase")]
279pub enum Speed {
280    /// `"standard"`.
281    Standard,
282    /// `"fast"`: fast mode, at its own price.
283    Fast,
284}
285
286/// A token budget the model paces an agentic loop by, at least 20000
287/// tokens.
288#[non_exhaustive]
289#[derive(Clone, Copy, Debug, PartialEq, Eq)]
290pub struct TaskBudget {
291    /// The whole budget.
292    pub total: u32,
293    /// What is left of it, when history was rewritten and the server can no
294    /// longer count it. Unset, the server counts it.
295    pub remaining: Option<u32>,
296}
297
298impl TaskBudget {
299    /// A budget of `total` tokens the server counts down.
300    pub fn new(total: u32) -> Self {
301        Self {
302            total,
303            remaining: None,
304        }
305    }
306
307    /// `self` with `remaining` tokens left.
308    pub fn remaining(mut self, remaining: u32) -> Self {
309        self.remaining = Some(remaining);
310        self
311    }
312}
313
314impl Serialize for TaskBudget {
315    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
316        let Self { total, remaining } = self;
317        let mut map = serializer.serialize_map(None)?;
318        map.serialize_entry("type", "tokens")?;
319        map.serialize_entry("total", total)?;
320        if let Some(remaining) = remaining {
321            map.serialize_entry("remaining", remaining)?;
322        }
323        map.end()
324    }
325}
326
327/// The models a refused request is re-run on.
328#[non_exhaustive]
329#[derive(Clone, Debug, PartialEq, Eq)]
330pub enum Fallbacks {
331    /// `"default"`: Anthropic picks by refusal category. Beta
332    /// [`SERVER_SIDE_FALLBACK_BETA`].
333    Default,
334    /// `[{"model": ..}, ..]`: these models, in order. Beta
335    /// [`SERVER_SIDE_FALLBACK_MODELS_BETA`].
336    Models(Vec<String>),
337}
338
339impl Serialize for Fallbacks {
340    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
341        match self {
342            Self::Default => serializer.serialize_str("default"),
343            Self::Models(models) => {
344                serializer.collect_seq(models.iter().map(|model| Nested("model", model)))
345            }
346        }
347    }
348}
349
350/// The container a request runs code in.
351#[non_exhaustive]
352#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
353#[serde(untagged)]
354pub enum ContainerParam {
355    /// An existing container's id.
356    Id(String),
357    /// A container with skills loaded: `id` reuses one, unset starts one.
358    Skills {
359        /// The container to reuse.
360        #[serde(skip_serializing_if = "Option::is_none")]
361        id: Option<String>,
362        /// The skills to load.
363        skills: Vec<Skill>,
364    },
365}
366
367impl From<String> for ContainerParam {
368    fn from(id: String) -> Self {
369        Self::Id(id)
370    }
371}
372
373impl From<&str> for ContainerParam {
374    fn from(id: &str) -> Self {
375        Self::Id(id.to_owned())
376    }
377}
378
379/// A skill loaded into a container.
380#[non_exhaustive]
381#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
382pub struct Skill {
383    /// Who publishes it.
384    #[serde(rename = "type")]
385    pub kind: SkillKind,
386    /// Its id: a prebuilt skill's name (`"xlsx"`) or a custom skill's id.
387    pub skill_id: String,
388    /// Its version, such as `"latest"`. Unset, the latest.
389    #[serde(skip_serializing_if = "Option::is_none")]
390    pub version: Option<String>,
391}
392
393impl Skill {
394    /// The skill `skill_id` from `kind`, at its latest version.
395    pub fn new(kind: SkillKind, skill_id: impl Into<String>) -> Self {
396        Self {
397            kind,
398            skill_id: skill_id.into(),
399            version: None,
400        }
401    }
402
403    /// `self` at `version`.
404    pub fn version(mut self, version: impl Into<String>) -> Self {
405        self.version = Some(version.into());
406        self
407    }
408}
409
410/// Who publishes a skill.
411#[non_exhaustive]
412#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
413#[serde(rename_all = "lowercase")]
414pub enum SkillKind {
415    /// `"anthropic"`: a prebuilt skill.
416    Anthropic,
417    /// `"custom"`: one the organization uploaded.
418    Custom,
419}
420
421/// Context editing: the edits the server applies before the model reads the
422/// conversation.
423#[non_exhaustive]
424#[derive(Clone, Debug, Default, PartialEq, Serialize)]
425pub struct ContextManagement {
426    /// Each edit as the API spells it, such as
427    /// `{"type": "clear_tool_uses_20250919"}`. Edits are versioned server
428    /// types, so they are written as JSON.
429    pub edits: Vec<Value>,
430}
431
432impl ContextManagement {
433    /// The edits `edits`.
434    pub fn new(edits: Vec<Value>) -> Self {
435        Self { edits }
436    }
437}
438
439/// A remote MCP server the API connects to.
440#[non_exhaustive]
441#[derive(Clone, Debug, PartialEq, Eq)]
442pub struct McpServer {
443    /// The server's URL.
444    pub url: String,
445    /// The name an `mcp_toolset` tool refers to it by.
446    pub name: String,
447    /// An OAuth token the API sends to it.
448    pub authorization_token: Option<String>,
449}
450
451impl McpServer {
452    /// The server `name` at `url`, with no token.
453    pub fn new(url: impl Into<String>, name: impl Into<String>) -> Self {
454        Self {
455            url: url.into(),
456            name: name.into(),
457            authorization_token: None,
458        }
459    }
460
461    /// `self` with `token` sent to the server.
462    pub fn authorization_token(mut self, token: impl Into<String>) -> Self {
463        self.authorization_token = Some(token.into());
464        self
465    }
466}
467
468impl Serialize for McpServer {
469    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
470        let Self {
471            url,
472            name,
473            authorization_token,
474        } = self;
475        let mut map = serializer.serialize_map(None)?;
476        map.serialize_entry("type", "url")?;
477        map.serialize_entry("url", url)?;
478        map.serialize_entry("name", name)?;
479        if let Some(token) = authorization_token {
480            map.serialize_entry("authorization_token", token)?;
481        }
482        map.end()
483    }
484}
485
486/// The fields of an Anthropic Messages reply rig does not normalize. Each
487/// is `None` when the reply leaves it out.
488#[non_exhaustive]
489#[derive(Clone, Debug, Default, PartialEq)]
490pub struct AnthropicExtras {
491    /// `stop_reason`, verbatim.
492    pub stop_reason: Option<String>,
493    /// `stop_sequence`: the stop sequence the turn ended on.
494    pub stop_sequence: Option<String>,
495    /// `stop_details`: why a refusal was declined.
496    pub stop_details: Option<StopDetails>,
497    /// `usage.cache_creation`: the cache writes per lifetime.
498    pub cache_creation: Option<CacheCreation>,
499    /// `usage.service_tier`: `standard`, `priority` or `batch`.
500    pub service_tier: Option<String>,
501    /// `usage.inference_geo`: where inference ran.
502    pub inference_geo: Option<String>,
503    /// `usage.speed`: the speed the reply ran at.
504    pub speed: Option<String>,
505    /// `usage.server_tool_use`: the server tools' request counts.
506    pub server_tool_use: Option<ServerToolUse>,
507    /// `container`: the code-execution container the reply ran in.
508    pub container: Option<Container>,
509    /// The model a leading `fallback` block switched to, when the request
510    /// was refused and re-run on another model.
511    pub fallback_model: Option<String>,
512}
513
514/// Why a reply was refused.
515#[non_exhaustive]
516#[derive(Clone, Debug, Default, PartialEq, Eq, Deserialize)]
517pub struct StopDetails {
518    /// `type`: `"refusal"`.
519    #[serde(rename = "type", default)]
520    pub kind: String,
521    /// The policy category, such as `"cyber"`.
522    #[serde(default)]
523    pub category: Option<String>,
524    /// A readable explanation.
525    #[serde(default)]
526    pub explanation: Option<String>,
527}
528
529/// The input tokens written to the cache, per cache lifetime.
530#[non_exhaustive]
531#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Deserialize)]
532pub struct CacheCreation {
533    /// Written with a five-minute lifetime.
534    #[serde(default)]
535    pub ephemeral_5m_input_tokens: u64,
536    /// Written with a one-hour lifetime.
537    #[serde(default)]
538    pub ephemeral_1h_input_tokens: u64,
539}
540
541/// How many requests the server tools made.
542#[non_exhaustive]
543#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Deserialize)]
544pub struct ServerToolUse {
545    /// Web searches.
546    #[serde(default)]
547    pub web_search_requests: u64,
548    /// Web fetches.
549    #[serde(default)]
550    pub web_fetch_requests: u64,
551}
552
553/// A code-execution container.
554#[non_exhaustive]
555#[derive(Clone, Debug, Default, PartialEq, Eq, Deserialize)]
556pub struct Container {
557    /// Its id, which a later request reuses.
558    #[serde(default)]
559    pub id: String,
560    /// When it expires, as an RFC 3339 time.
561    #[serde(default)]
562    pub expires_at: String,
563}
564
565/// The reply fields [`AnthropicExtras`] reads.
566#[derive(Deserialize)]
567struct Reply {
568    #[serde(default)]
569    stop_reason: Option<String>,
570    #[serde(default)]
571    stop_sequence: Option<String>,
572    #[serde(default)]
573    stop_details: Option<StopDetails>,
574    #[serde(default)]
575    usage: Option<ReplyUsage>,
576    #[serde(default)]
577    container: Option<Container>,
578    /// Read for its shape only; the first block is read from the reply.
579    #[serde(default, rename = "content")]
580    _content: Vec<serde::de::IgnoredAny>,
581}
582
583#[derive(Default, Deserialize)]
584struct ReplyUsage {
585    #[serde(default)]
586    cache_creation: Option<CacheCreation>,
587    #[serde(default)]
588    service_tier: Option<String>,
589    #[serde(default)]
590    inference_geo: Option<String>,
591    #[serde(default)]
592    speed: Option<String>,
593    #[serde(default)]
594    server_tool_use: Option<ServerToolUse>,
595}
596
597impl ReplyExtras for AnthropicExtras {
598    fn from_reply(api: &Api, raw: &Value) -> Result<Self, serde_json::Error> {
599        if api.as_str() != MESSAGES_API {
600            return Err(serde::de::Error::custom(format!(
601                "Anthropic extras read a Messages reply, not one from `{api}`"
602            )));
603        }
604        let Reply {
605            stop_reason,
606            stop_sequence,
607            stop_details,
608            usage,
609            container,
610            _content,
611        } = Reply::deserialize(raw)?;
612        let usage = usage.unwrap_or_default();
613        let fallback_model = raw
614            .pointer("/content/0")
615            .filter(|block| block.get("type").and_then(Value::as_str) == Some("fallback"))
616            .and_then(|block| block.pointer("/to/model"))
617            .and_then(Value::as_str)
618            .map(str::to_owned);
619        Ok(Self {
620            stop_reason,
621            stop_sequence,
622            stop_details,
623            cache_creation: usage.cache_creation,
624            service_tier: usage.service_tier,
625            inference_geo: usage.inference_geo,
626            speed: usage.speed,
627            server_tool_use: usage.server_tool_use,
628            container,
629            fallback_model,
630        })
631    }
632}
633
634/// The reply fields every Messages-format dialect returns, which the
635/// dialects' extras read on this route.
636#[derive(Deserialize)]
637pub(crate) struct MessagesStop {
638    #[serde(default)]
639    pub(crate) stop_reason: Option<String>,
640    #[serde(default)]
641    pub(crate) stop_sequence: Option<String>,
642}
643
644impl MessagesStop {
645    /// The stop fields of `raw`, a reply on `api`, for `provider`'s extras.
646    ///
647    /// # Errors
648    ///
649    /// When `api` is not the Messages API or `raw` is not an object.
650    pub(crate) fn read(provider: &str, api: &Api, raw: &Value) -> Result<Self, serde_json::Error> {
651        if api.as_str() != MESSAGES_API {
652            return Err(serde::de::Error::custom(format!(
653                "{provider} extras read a Messages reply, not one from `{api}`"
654            )));
655        }
656        Self::deserialize(raw)
657    }
658}
659
660#[cfg(test)]
661mod tests;