Skip to main content

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}