Skip to main content

systemprompt_wire/anthropic/
beta.rs

1//! `anthropic-beta`: one opt-in feature flag, the comma-separated header that
2//! carries several, and the policy that decides which of them an upstream is
3//! sent.
4//!
5//! A client forwards the betas it would send to Anthropic's own API. Another
6//! host serves a subset and rejects the rest, so a request is re-rendered
7//! through [`BetaPolicy`] before it leaves the gateway rather than relayed
8//! verbatim.
9//!
10//! Some betas gate a top-level body field as well as the header
11//! ([`BETA_GATED_FIELDS`]): `context-management-*` admits `context_management`.
12//! An upstream that is sent the field without the flag refuses the whole
13//! request ("`context_management`: Extra inputs are not permitted"), so a flag
14//! the gateway drops takes its field with it ([`strip_fields_gated_by`]). A
15//! field a client sends with no flag at all is left alone: that is the
16//! client's own contract with the upstream, not a seam the gateway opened.
17//!
18//! Copyright (c) systemprompt.io — Business Source License 1.1.
19//! See <https://systemprompt.io> for licensing details.
20
21use std::collections::BTreeSet;
22use std::fmt;
23
24use serde::{Deserialize, Serialize};
25// JSON: protocol boundary — the gated fields live in a dynamic wire body.
26use serde_json::{Map, Value};
27
28pub const ANTHROPIC_BETA_HEADER: &str = "anthropic-beta";
29
30#[derive(
31    Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, schemars::JsonSchema,
32)]
33#[serde(transparent)]
34pub struct AnthropicBeta(String);
35
36impl AnthropicBeta {
37    #[must_use]
38    pub fn new(flag: impl Into<String>) -> Self {
39        Self(flag.into())
40    }
41
42    #[must_use]
43    pub fn as_str(&self) -> &str {
44        &self.0
45    }
46}
47
48impl fmt::Display for AnthropicBeta {
49    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
50        f.write_str(&self.0)
51    }
52}
53
54/// The betas one request carries, in the order the client sent them, each once.
55#[derive(Debug, Clone, Default, PartialEq, Eq)]
56pub struct BetaHeader(Vec<AnthropicBeta>);
57
58impl BetaHeader {
59    #[must_use]
60    pub fn parse(value: &str) -> Self {
61        let mut betas: Vec<AnthropicBeta> = Vec::new();
62        for flag in value.split(',').map(str::trim).filter(|f| !f.is_empty()) {
63            if !betas.iter().any(|b| b.as_str() == flag) {
64                betas.push(AnthropicBeta::new(flag));
65            }
66        }
67        Self(betas)
68    }
69
70    #[must_use]
71    pub fn admitted_by(self, policy: &BetaPolicy) -> Self {
72        Self(self.0.into_iter().filter(|b| policy.admits(b)).collect())
73    }
74
75    #[must_use]
76    pub fn refused_by(self, policy: &BetaPolicy) -> Self {
77        Self(self.0.into_iter().filter(|b| !policy.admits(b)).collect())
78    }
79
80    pub fn extend(&mut self, other: Self) {
81        for beta in other.0 {
82            if !self.0.iter().any(|b| b == &beta) {
83                self.0.push(beta);
84            }
85        }
86    }
87
88    #[must_use]
89    pub fn contains(&self, flag: &str) -> bool {
90        self.0.iter().any(|b| b.as_str() == flag)
91    }
92
93    #[must_use]
94    pub fn opens(&self, gate: &BetaGatedField) -> bool {
95        self.0
96            .iter()
97            .any(|b| b.as_str().starts_with(gate.beta_prefix))
98    }
99
100    #[must_use]
101    pub const fn is_empty(&self) -> bool {
102        self.0.is_empty()
103    }
104
105    #[must_use]
106    pub fn betas(&self) -> &[AnthropicBeta] {
107        &self.0
108    }
109
110    #[must_use]
111    pub fn render(&self) -> Option<String> {
112        (!self.0.is_empty()).then(|| {
113            self.0
114                .iter()
115                .map(AnthropicBeta::as_str)
116                .collect::<Vec<_>>()
117                .join(",")
118        })
119    }
120}
121
122/// Which forwarded betas an upstream is sent.
123#[derive(Debug, Clone, PartialEq, Eq)]
124pub enum BetaPolicy {
125    ForwardAll,
126    Only(BTreeSet<AnthropicBeta>),
127}
128
129impl BetaPolicy {
130    #[must_use]
131    pub fn admits(&self, beta: &AnthropicBeta) -> bool {
132        match self {
133            Self::ForwardAll => true,
134            Self::Only(accepted) => accepted.contains(beta),
135        }
136    }
137}
138
139/// A top-level Messages body field that exists only under an `anthropic-beta`
140/// flag. `beta_prefix` is the flag without its date suffix, so every version
141/// of the beta opens the same field.
142#[derive(Debug, Clone, Copy, PartialEq, Eq)]
143pub struct BetaGatedField {
144    pub field: &'static str,
145    pub beta_prefix: &'static str,
146}
147
148pub const BETA_GATED_FIELDS: &[BetaGatedField] = &[
149    BetaGatedField {
150        field: "context_management",
151        beta_prefix: "context-management-",
152    },
153    BetaGatedField {
154        field: "mcp_servers",
155        beta_prefix: "mcp-client-",
156    },
157    BetaGatedField {
158        field: "container",
159        beta_prefix: "code-execution-",
160    },
161];
162
163// JSON: Anthropic Messages request body; beta-gated fields are stripped by key.
164pub fn strip_fields_gated_by(
165    body: &mut Map<String, Value>,
166    dropped: &BetaHeader,
167) -> Vec<&'static str> {
168    BETA_GATED_FIELDS
169        .iter()
170        .filter(|gate| dropped.opens(gate) && body.remove(gate.field).is_some())
171        .map(|gate| gate.field)
172        .collect()
173}