Skip to main content

systemprompt_wire/
upstream.rs

1//! How one upstream is addressed: the pair of wire protocol and hosting.
2//!
3//! The codecs beside this module render a canonical request into a wire body
4//! and read the reply back; they are the same wherever the model is hosted.
5//! What changes with hosting is the envelope around that body — the URL path,
6//! the header an API key travels in, the headers the platform requires, and
7//! the few body fields it moves out of the headers or the URL. Claude on
8//! Vertex AI is the case that forced this into one place: the Messages body is
9//! identical, but it is posted to `:rawPredict`/`:streamRawPredict` on the
10//! model's own path, carries `anthropic_version` in the body rather than a
11//! header, and names no `model` because the URL already does.
12//!
13//! [`UpstreamDialect`] is pure: the gateway and the in-process AI service
14//! both ask it, so neither can learn a platform the other does not know.
15//!
16//! Copyright (c) systemprompt.io — Business Source License 1.1.
17//! See <https://systemprompt.io> for licensing details.
18
19// JSON: protocol boundary — the envelope edits apply to a wire body that is
20// dynamic JSON by definition.
21use serde_json::{Map, Value};
22
23use std::collections::BTreeSet;
24
25use super::anthropic::{AnthropicBeta, BetaPolicy};
26use super::{anthropic, gemini};
27use crate::{Hosting, WireProtocol};
28
29// Why: Vertex AI's partner-model contract for Claude pins the Messages API
30// version in the body under this value; the `anthropic-version` header is
31// not read there.
32pub const VERTEX_ANTHROPIC_VERSION: &str = "vertex-2023-10-16";
33
34#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
35pub struct UpstreamDialect {
36    pub wire: WireProtocol,
37    pub hosting: Hosting,
38}
39
40impl UpstreamDialect {
41    #[must_use]
42    pub const fn new(wire: WireProtocol, hosting: Hosting) -> Self {
43        Self { wire, hosting }
44    }
45
46    #[must_use]
47    pub fn of(wire: WireProtocol, endpoint: &str) -> Self {
48        Self::new(wire, Hosting::of(endpoint))
49    }
50
51    #[must_use]
52    pub fn path(self, upstream_model: &str, stream: bool) -> String {
53        match (self.wire, self.hosting) {
54            (WireProtocol::Anthropic, Hosting::FirstParty) => "/messages".to_owned(),
55            (WireProtocol::Anthropic, Hosting::Vertex) => {
56                let verb = if stream {
57                    "streamRawPredict"
58                } else {
59                    "rawPredict"
60                };
61                format!("/models/{upstream_model}:{verb}")
62            },
63            (WireProtocol::Gemini, _) => gemini::upstream_path(upstream_model, stream),
64            (WireProtocol::OpenAiChat, _) => "/chat/completions".to_owned(),
65            (WireProtocol::OpenAiResponses, _) => "/responses".to_owned(),
66        }
67    }
68
69    #[must_use]
70    pub fn url(self, endpoint: &str, upstream_model: &str, stream: bool) -> String {
71        format!(
72            "{}{}",
73            endpoint.trim_end_matches('/'),
74            self.path(upstream_model, stream)
75        )
76    }
77
78    #[must_use]
79    pub const fn api_key_header(self) -> Option<&'static str> {
80        match self.wire {
81            WireProtocol::Anthropic => Some("x-api-key"),
82            WireProtocol::Gemini => Some(gemini::API_KEY_HEADER),
83            WireProtocol::OpenAiChat | WireProtocol::OpenAiResponses => None,
84        }
85    }
86
87    #[must_use]
88    pub fn required_headers(self) -> Vec<(&'static str, &'static str)> {
89        match (self.wire, self.hosting) {
90            (WireProtocol::Anthropic, Hosting::FirstParty) => {
91                vec![("anthropic-version", anthropic::ANTHROPIC_VERSION)]
92            },
93            _ => Vec::new(),
94        }
95    }
96
97    // Why: Vertex AI rejects an `anthropic-beta` flag it does not serve, and a
98    // client forwards the flags it would send Anthropic's own API. With no
99    // provider list, Vertex is therefore sent none. The 1M context window is
100    // not one of them: Vertex serves Opus 4.6+ and Sonnet 4.6+ at 1,000,000
101    // input tokens natively.
102    #[must_use]
103    pub fn beta_policy(self, accepted: Option<&BTreeSet<AnthropicBeta>>) -> BetaPolicy {
104        match (accepted, self.hosting) {
105            (Some(accepted), _) => BetaPolicy::Only(accepted.clone()),
106            (None, Hosting::FirstParty) => BetaPolicy::ForwardAll,
107            (None, Hosting::Vertex) => BetaPolicy::Only(BTreeSet::new()),
108        }
109    }
110
111    #[must_use]
112    pub const fn drops_forwarded_header(self, name: &str) -> bool {
113        matches!(
114            (self.wire, self.hosting),
115            (WireProtocol::Anthropic, Hosting::Vertex)
116        ) && name.eq_ignore_ascii_case("anthropic-version")
117    }
118
119    // JSON: provider request body — upstream wire format finished per dialect.
120    pub fn finish_body(self, body: &mut Map<String, Value>) {
121        if (self.wire, self.hosting) == (WireProtocol::Anthropic, Hosting::Vertex) {
122            body.remove("model");
123            body.insert(
124                "anthropic_version".to_owned(),
125                Value::String(VERTEX_ANTHROPIC_VERSION.to_owned()),
126            );
127            if let Some(Value::Array(tools)) = body.get_mut("tools") {
128                tools.retain(|tool| !is_tool_absent_on_vertex(tool));
129                for tool in tools.iter_mut() {
130                    downgrade_vertex_web_search(tool);
131                }
132                if tools.is_empty() {
133                    body.remove("tools");
134                }
135            }
136        }
137    }
138
139    // JSON: provider request body — upstream wire format finished per dialect.
140    pub fn finish_value(self, body: &mut Value) {
141        if let Some(obj) = body.as_object_mut() {
142            self.finish_body(obj);
143        }
144    }
145}
146
147// Why: Vertex AI serves Claude's web search only as the basic
148// `web_search_20250305` tool; the dated successors and the fields only they
149// accept are refused with a 400, so a newer definition is rewritten to the
150// one Vertex runs.
151// JSON: provider request body — upstream wire format finished per dialect.
152pub fn downgrade_vertex_web_search(tool: &mut Value) {
153    const BASIC_WEB_SEARCH: &str = "web_search_20250305";
154    const NEWER_ONLY_FIELDS: [&str; 2] = ["allowed_callers", "response_inclusion"];
155    let is_newer_web_search = server_tool_type(tool)
156        .is_some_and(|kind| kind.starts_with("web_search_") && kind != BASIC_WEB_SEARCH);
157    if !is_newer_web_search {
158        return;
159    }
160    let Some(obj) = tool.as_object_mut() else {
161        return;
162    };
163    obj.insert(
164        "type".to_owned(),
165        Value::String(BASIC_WEB_SEARCH.to_owned()),
166    );
167    for field in NEWER_ONLY_FIELDS {
168        obj.remove(field);
169    }
170}
171
172// Why: Google Cloud does not run web fetch, code execution or the advisor
173// tool for Claude, and Vertex refuses the whole request when one is declared,
174// so the definition is removed; an installation that wants them routes those
175// requests to a provider that serves them. Client tools carry no server
176// `type` and are never touched.
177#[must_use]
178// JSON: provider request body — a tool entry in the upstream wire format.
179pub fn is_tool_absent_on_vertex(tool: &Value) -> bool {
180    const ABSENT_ON_VERTEX: [&str; 3] = ["web_fetch_", "code_execution_", "advisor_"];
181    server_tool_type(tool).is_some_and(|kind| {
182        ABSENT_ON_VERTEX
183            .iter()
184            .any(|prefix| kind.starts_with(prefix))
185    })
186}
187
188// JSON: provider request body — a tool entry in the upstream wire format.
189fn server_tool_type(tool: &Value) -> Option<&str> {
190    tool.get("type")
191        .and_then(Value::as_str)
192        .filter(|kind| *kind != "custom")
193}