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, claude_spec};
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 Some(spec) = claude_spec(model) else {
245            return Vec::new();
246        };
247        let mut refused = Vec::new();
248        if self.shared.top_k.is_some() && spec.sampling == Some(Sampling::Never) {
249            refused.push(("top_k", "this model does not take `top_k`".to_owned()));
250        }
251        if self.shared.speed == Some(Speed::Fast) && !FAST_MODELS.contains(&spec.id.as_str()) {
252            refused.push((
253                "speed",
254                "fast mode runs on Claude Opus 5.5, Opus 5 and Opus 4.8 only".to_owned(),
255            ));
256        }
257        refused
258    }
259}
260
261/// Where inference runs.
262#[non_exhaustive]
263#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
264#[serde(rename_all = "lowercase")]
265pub enum InferenceGeo {
266    /// `"us"`: in the United States only.
267    Us,
268    /// `"global"`: anywhere.
269    Global,
270}
271
272/// The output speed.
273#[non_exhaustive]
274#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
275#[serde(rename_all = "lowercase")]
276pub enum Speed {
277    /// `"standard"`.
278    Standard,
279    /// `"fast"`: fast mode, at its own price.
280    Fast,
281}
282
283/// A token budget the model paces an agentic loop by, at least 20000
284/// tokens.
285#[non_exhaustive]
286#[derive(Clone, Copy, Debug, PartialEq, Eq)]
287pub struct TaskBudget {
288    /// The whole budget.
289    pub total: u32,
290    /// What is left of it, when history was rewritten and the server can no
291    /// longer count it. Unset, the server counts it.
292    pub remaining: Option<u32>,
293}
294
295impl TaskBudget {
296    /// A budget of `total` tokens the server counts down.
297    pub fn new(total: u32) -> Self {
298        Self {
299            total,
300            remaining: None,
301        }
302    }
303
304    /// `self` with `remaining` tokens left.
305    pub fn remaining(mut self, remaining: u32) -> Self {
306        self.remaining = Some(remaining);
307        self
308    }
309}
310
311impl Serialize for TaskBudget {
312    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
313        let Self { total, remaining } = self;
314        let mut map = serializer.serialize_map(None)?;
315        map.serialize_entry("type", "tokens")?;
316        map.serialize_entry("total", total)?;
317        if let Some(remaining) = remaining {
318            map.serialize_entry("remaining", remaining)?;
319        }
320        map.end()
321    }
322}
323
324/// The models a refused request is re-run on.
325#[non_exhaustive]
326#[derive(Clone, Debug, PartialEq, Eq)]
327pub enum Fallbacks {
328    /// `"default"`: Anthropic picks by refusal category. Beta
329    /// [`SERVER_SIDE_FALLBACK_BETA`].
330    Default,
331    /// `[{"model": ..}, ..]`: these models, in order. Beta
332    /// [`SERVER_SIDE_FALLBACK_MODELS_BETA`].
333    Models(Vec<String>),
334}
335
336impl Serialize for Fallbacks {
337    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
338        match self {
339            Self::Default => serializer.serialize_str("default"),
340            Self::Models(models) => {
341                serializer.collect_seq(models.iter().map(|model| Nested("model", model)))
342            }
343        }
344    }
345}
346
347/// The container a request runs code in.
348#[non_exhaustive]
349#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
350#[serde(untagged)]
351pub enum ContainerParam {
352    /// An existing container's id.
353    Id(String),
354    /// A container with skills loaded: `id` reuses one, unset starts one.
355    Skills {
356        /// The container to reuse.
357        #[serde(skip_serializing_if = "Option::is_none")]
358        id: Option<String>,
359        /// The skills to load.
360        skills: Vec<Skill>,
361    },
362}
363
364impl From<String> for ContainerParam {
365    fn from(id: String) -> Self {
366        Self::Id(id)
367    }
368}
369
370impl From<&str> for ContainerParam {
371    fn from(id: &str) -> Self {
372        Self::Id(id.to_owned())
373    }
374}
375
376/// A skill loaded into a container.
377#[non_exhaustive]
378#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
379pub struct Skill {
380    /// Who publishes it.
381    #[serde(rename = "type")]
382    pub kind: SkillKind,
383    /// Its id: a prebuilt skill's name (`"xlsx"`) or a custom skill's id.
384    pub skill_id: String,
385    /// Its version, such as `"latest"`. Unset, the latest.
386    #[serde(skip_serializing_if = "Option::is_none")]
387    pub version: Option<String>,
388}
389
390impl Skill {
391    /// The skill `skill_id` from `kind`, at its latest version.
392    pub fn new(kind: SkillKind, skill_id: impl Into<String>) -> Self {
393        Self {
394            kind,
395            skill_id: skill_id.into(),
396            version: None,
397        }
398    }
399
400    /// `self` at `version`.
401    pub fn version(mut self, version: impl Into<String>) -> Self {
402        self.version = Some(version.into());
403        self
404    }
405}
406
407/// Who publishes a skill.
408#[non_exhaustive]
409#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
410#[serde(rename_all = "lowercase")]
411pub enum SkillKind {
412    /// `"anthropic"`: a prebuilt skill.
413    Anthropic,
414    /// `"custom"`: one the organization uploaded.
415    Custom,
416}
417
418/// Context editing: the edits the server applies before the model reads the
419/// conversation.
420#[non_exhaustive]
421#[derive(Clone, Debug, Default, PartialEq, Serialize)]
422pub struct ContextManagement {
423    /// Each edit as the API spells it, such as
424    /// `{"type": "clear_tool_uses_20250919"}`. Edits are versioned server
425    /// types, so they are written as JSON.
426    pub edits: Vec<Value>,
427}
428
429impl ContextManagement {
430    /// The edits `edits`.
431    pub fn new(edits: Vec<Value>) -> Self {
432        Self { edits }
433    }
434}
435
436/// A remote MCP server the API connects to.
437#[non_exhaustive]
438#[derive(Clone, Debug, PartialEq, Eq)]
439pub struct McpServer {
440    /// The server's URL.
441    pub url: String,
442    /// The name an `mcp_toolset` tool refers to it by.
443    pub name: String,
444    /// An OAuth token the API sends to it.
445    pub authorization_token: Option<String>,
446}
447
448impl McpServer {
449    /// The server `name` at `url`, with no token.
450    pub fn new(url: impl Into<String>, name: impl Into<String>) -> Self {
451        Self {
452            url: url.into(),
453            name: name.into(),
454            authorization_token: None,
455        }
456    }
457
458    /// `self` with `token` sent to the server.
459    pub fn authorization_token(mut self, token: impl Into<String>) -> Self {
460        self.authorization_token = Some(token.into());
461        self
462    }
463}
464
465impl Serialize for McpServer {
466    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
467        let Self {
468            url,
469            name,
470            authorization_token,
471        } = self;
472        let mut map = serializer.serialize_map(None)?;
473        map.serialize_entry("type", "url")?;
474        map.serialize_entry("url", url)?;
475        map.serialize_entry("name", name)?;
476        if let Some(token) = authorization_token {
477            map.serialize_entry("authorization_token", token)?;
478        }
479        map.end()
480    }
481}
482
483/// The fields of an Anthropic Messages reply rig does not normalize. Each
484/// is `None` when the reply leaves it out.
485#[non_exhaustive]
486#[derive(Clone, Debug, Default, PartialEq)]
487pub struct AnthropicExtras {
488    /// `stop_reason`, verbatim.
489    pub stop_reason: Option<String>,
490    /// `stop_sequence`: the stop sequence the turn ended on.
491    pub stop_sequence: Option<String>,
492    /// `stop_details`: why a refusal was declined.
493    pub stop_details: Option<StopDetails>,
494    /// `usage.cache_creation`: the cache writes per lifetime.
495    pub cache_creation: Option<CacheCreation>,
496    /// `usage.service_tier`: `standard`, `priority` or `batch`.
497    pub service_tier: Option<String>,
498    /// `usage.inference_geo`: where inference ran.
499    pub inference_geo: Option<String>,
500    /// `usage.speed`: the speed the reply ran at.
501    pub speed: Option<String>,
502    /// `usage.server_tool_use`: the server tools' request counts.
503    pub server_tool_use: Option<ServerToolUse>,
504    /// `container`: the code-execution container the reply ran in.
505    pub container: Option<Container>,
506    /// The model a leading `fallback` block switched to, when the request
507    /// was refused and re-run on another model.
508    pub fallback_model: Option<String>,
509}
510
511/// Why a reply was refused.
512#[non_exhaustive]
513#[derive(Clone, Debug, Default, PartialEq, Eq, Deserialize)]
514pub struct StopDetails {
515    /// `type`: `"refusal"`.
516    #[serde(rename = "type", default)]
517    pub kind: String,
518    /// The policy category, such as `"cyber"`.
519    #[serde(default)]
520    pub category: Option<String>,
521    /// A readable explanation.
522    #[serde(default)]
523    pub explanation: Option<String>,
524}
525
526/// The input tokens written to the cache, per cache lifetime.
527#[non_exhaustive]
528#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Deserialize)]
529pub struct CacheCreation {
530    /// Written with a five-minute lifetime.
531    #[serde(default)]
532    pub ephemeral_5m_input_tokens: u64,
533    /// Written with a one-hour lifetime.
534    #[serde(default)]
535    pub ephemeral_1h_input_tokens: u64,
536}
537
538/// How many requests the server tools made.
539#[non_exhaustive]
540#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Deserialize)]
541pub struct ServerToolUse {
542    /// Web searches.
543    #[serde(default)]
544    pub web_search_requests: u64,
545    /// Web fetches.
546    #[serde(default)]
547    pub web_fetch_requests: u64,
548}
549
550/// A code-execution container.
551#[non_exhaustive]
552#[derive(Clone, Debug, Default, PartialEq, Eq, Deserialize)]
553pub struct Container {
554    /// Its id, which a later request reuses.
555    #[serde(default)]
556    pub id: String,
557    /// When it expires, as an RFC 3339 time.
558    #[serde(default)]
559    pub expires_at: String,
560}
561
562/// The reply fields [`AnthropicExtras`] reads.
563#[derive(Deserialize)]
564struct Reply {
565    #[serde(default)]
566    stop_reason: Option<String>,
567    #[serde(default)]
568    stop_sequence: Option<String>,
569    #[serde(default)]
570    stop_details: Option<StopDetails>,
571    #[serde(default)]
572    usage: Option<ReplyUsage>,
573    #[serde(default)]
574    container: Option<Container>,
575    #[serde(default)]
576    content: Vec<Value>,
577}
578
579#[derive(Default, Deserialize)]
580struct ReplyUsage {
581    #[serde(default)]
582    cache_creation: Option<CacheCreation>,
583    #[serde(default)]
584    service_tier: Option<String>,
585    #[serde(default)]
586    inference_geo: Option<String>,
587    #[serde(default)]
588    speed: Option<String>,
589    #[serde(default)]
590    server_tool_use: Option<ServerToolUse>,
591}
592
593impl ReplyExtras for AnthropicExtras {
594    fn from_reply(api: &Api, raw: &Value) -> Result<Self, serde_json::Error> {
595        if api.as_str() != MESSAGES_API {
596            return Err(serde::de::Error::custom(format!(
597                "Anthropic extras read a Messages reply, not one from `{api}`"
598            )));
599        }
600        let Reply {
601            stop_reason,
602            stop_sequence,
603            stop_details,
604            usage,
605            container,
606            content,
607        } = Reply::deserialize(raw)?;
608        let usage = usage.unwrap_or_default();
609        let fallback_model = content
610            .first()
611            .filter(|block| block.get("type").and_then(Value::as_str) == Some("fallback"))
612            .and_then(|block| block.pointer("/to/model"))
613            .and_then(Value::as_str)
614            .map(str::to_owned);
615        Ok(Self {
616            stop_reason,
617            stop_sequence,
618            stop_details,
619            cache_creation: usage.cache_creation,
620            service_tier: usage.service_tier,
621            inference_geo: usage.inference_geo,
622            speed: usage.speed,
623            server_tool_use: usage.server_tool_use,
624            container,
625            fallback_model,
626        })
627    }
628}
629
630/// The reply fields every Messages-format dialect returns, which the
631/// dialects' extras read on this route.
632#[derive(Deserialize)]
633pub(crate) struct MessagesStop {
634    #[serde(default)]
635    pub(crate) stop_reason: Option<String>,
636    #[serde(default)]
637    pub(crate) stop_sequence: Option<String>,
638}
639
640impl MessagesStop {
641    /// The stop fields of `raw`, a reply on `api`, for `provider`'s extras.
642    ///
643    /// # Errors
644    ///
645    /// When `api` is not the Messages API or `raw` is not an object.
646    pub(crate) fn read(provider: &str, api: &Api, raw: &Value) -> Result<Self, serde_json::Error> {
647        if api.as_str() != MESSAGES_API {
648            return Err(serde::de::Error::custom(format!(
649                "{provider} extras read a Messages reply, not one from `{api}`"
650            )));
651        }
652        Self::deserialize(raw)
653    }
654}
655
656#[cfg(test)]
657mod tests;