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        }
128    }
129
130    // JSON: provider request body — upstream wire format finished per dialect.
131    pub fn finish_value(self, body: &mut Value) {
132        if let Some(obj) = body.as_object_mut() {
133            self.finish_body(obj);
134        }
135    }
136}