systemprompt_wire/hosting.rs
1//! Where a provider's models are hosted, as opposed to which wire they speak.
2//!
3//! A [`WireProtocol`](super::WireProtocol) names the request/response shape;
4//! [`Hosting`] names the platform in front of it. The two are independent:
5//! Claude speaks the Anthropic wire both on `api.anthropic.com` and on Google
6//! Vertex AI, and the two differ in URL shape, auth and a handful of body
7//! fields while sharing the codec. Hosting is derived from the endpoint host,
8//! never declared, so a catalog entry cannot claim one platform while pointing
9//! at another. [`Hosting::of`] is the only place that decision is made.
10//!
11//! Copyright (c) systemprompt.io — Business Source License 1.1.
12//! See <https://systemprompt.io> for licensing details.
13
14const VERTEX_HOST: &str = "aiplatform.googleapis.com";
15
16#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
17pub enum Hosting {
18 FirstParty,
19 Vertex,
20}
21
22impl Hosting {
23 #[must_use]
24 pub fn of(endpoint: &str) -> Self {
25 let on_vertex = url::Url::parse(endpoint)
26 .ok()
27 .and_then(|url| url.host_str().map(is_vertex_host))
28 .unwrap_or(false);
29 if on_vertex {
30 Self::Vertex
31 } else {
32 Self::FirstParty
33 }
34 }
35
36 #[must_use]
37 pub const fn as_str(self) -> &'static str {
38 match self {
39 Self::FirstParty => "first_party",
40 Self::Vertex => "vertex",
41 }
42 }
43}
44
45impl std::fmt::Display for Hosting {
46 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
47 f.write_str(self.as_str())
48 }
49}
50
51// Why: Vertex serves from the global host and from regional hosts named
52// `<region>-aiplatform.googleapis.com`; both are the same platform.
53#[must_use]
54pub fn is_vertex_host(host: &str) -> bool {
55 let host = host.to_ascii_lowercase();
56 host == VERTEX_HOST || host.ends_with(&format!("-{VERTEX_HOST}"))
57}
58
59pub const PROJECT_PLACEHOLDER: &str = "{project}";
60
61// Why: the placeholder names live here, beside the registry that validates
62// endpoints, so that the credential layer that fills them and the validator
63// that polices them can never disagree about their spelling. `{region}` has
64// no filler today — no shipped credential type carries a region — and an
65// endpoint using it is refused until one does, which is the intended shape:
66// a coordinate is served by the credential or not at all.
67pub const REGION_PLACEHOLDER: &str = "{region}";
68
69// Why: a Google Cloud project id is a tenant identifier, and Vertex reports it
70// verbatim in every IAM error it returns, which the gateway relays to the
71// caller. A catalog that names one literally therefore ships that id to every
72// installation of the image and every client that trips a 403. The id lives in
73// exactly one place, the service-account key, and the endpoint says
74// `{project}` instead.
75#[must_use]
76pub fn names_a_project_literally(endpoint: &str) -> bool {
77 let Ok(url) = url::Url::parse(endpoint) else {
78 return false;
79 };
80 if !url.host_str().is_some_and(is_vertex_host) {
81 return false;
82 }
83 let mut segments = url.path_segments().into_iter().flatten();
84 while let Some(segment) = segments.next() {
85 if segment == "projects" {
86 return segments
87 .next()
88 .is_some_and(|id| id != "%7Bproject%7D" && id != PROJECT_PLACEHOLDER);
89 }
90 }
91 false
92}