Skip to main content

open_agent/types/
protocol.rs

1//! Which HTTP API an endpoint speaks.
2//!
3//! The SDK started as an OpenAI-compatible client and the shape of that API was baked into
4//! the request path, the auth header, the request body and the streaming vocabulary. Several
5//! providers that are otherwise ordinary OpenAI-compatible vendors publish their
6//! subscription tiers only behind an Anthropic-shaped `/messages` endpoint, so "which
7//! protocol" became a per-endpoint fact rather than a property of the SDK.
8//!
9//! This is deliberately not a provider enum. It names the wire format, so a new vendor
10//! speaking an existing protocol needs no change here.
11
12/// The wire protocol an endpoint exposes.
13///
14/// Selects the request path, the authentication header, the request body shape and the
15/// streaming event vocabulary. Defaults to [`ApiProtocol::OpenAiChat`], which is what every
16/// endpoint the SDK supported before 0.9.0 speaks.
17///
18/// # Examples
19///
20/// ```rust
21/// use open_agent::ApiProtocol;
22///
23/// assert_eq!(ApiProtocol::default(), ApiProtocol::OpenAiChat);
24/// assert_eq!(ApiProtocol::Anthropic.path(), "/messages");
25/// ```
26#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
27#[non_exhaustive]
28pub enum ApiProtocol {
29    /// OpenAI chat completions: `POST {base_url}/chat/completions`, bearer auth.
30    #[default]
31    OpenAiChat,
32
33    /// Anthropic messages: `POST {base_url}/messages`, `x-api-key` auth.
34    Anthropic,
35}
36
37impl ApiProtocol {
38    /// The path appended to `base_url` to reach the completion endpoint.
39    ///
40    /// Includes the leading slash, because `base_url` is documented as carrying no trailing
41    /// one.
42    pub fn path(&self) -> &'static str {
43        match self {
44            Self::OpenAiChat => "/chat/completions",
45            Self::Anthropic => "/messages",
46        }
47    }
48
49    /// The lowercase name used in configuration files and diagnostics.
50    pub fn as_str(&self) -> &'static str {
51        match self {
52            Self::OpenAiChat => "openai",
53            Self::Anthropic => "anthropic",
54        }
55    }
56
57    /// Parses a protocol name, ASCII-case-insensitively.
58    ///
59    /// Returns `None` for anything unrecognised, so a caller reading a config file can name
60    /// the offending value in its own error rather than silently falling back to a default
61    /// the user did not ask for.
62    ///
63    /// `openai` is accepted alongside `openai-chat` and `openai_chat`: configuration files
64    /// in the wild write it every way, and rejecting the short form would be a trap with no
65    /// upside.
66    ///
67    /// # Examples
68    ///
69    /// ```rust
70    /// use open_agent::ApiProtocol;
71    ///
72    /// assert_eq!(ApiProtocol::from_wire("Anthropic"), Some(ApiProtocol::Anthropic));
73    /// assert_eq!(ApiProtocol::from_wire("openai_chat"), Some(ApiProtocol::OpenAiChat));
74    /// assert_eq!(ApiProtocol::from_wire("cohere"), None);
75    /// ```
76    pub fn from_wire(raw: &str) -> Option<Self> {
77        match raw.to_ascii_lowercase().as_str() {
78            "openai" | "openai-chat" | "openai_chat" => Some(Self::OpenAiChat),
79            "anthropic" => Some(Self::Anthropic),
80            _ => None,
81        }
82    }
83}
84
85impl std::fmt::Display for ApiProtocol {
86    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
87        f.write_str(self.as_str())
88    }
89}
90
91#[cfg(test)]
92mod tests {
93    use super::*;
94
95    #[test]
96    fn default_is_openai_chat() {
97        assert_eq!(ApiProtocol::default(), ApiProtocol::OpenAiChat);
98    }
99
100    #[test]
101    fn each_protocol_has_its_own_path() {
102        assert_eq!(ApiProtocol::OpenAiChat.path(), "/chat/completions");
103        assert_eq!(ApiProtocol::Anthropic.path(), "/messages");
104    }
105
106    #[test]
107    fn each_protocol_has_its_own_name() {
108        assert_eq!(ApiProtocol::OpenAiChat.as_str(), "openai");
109        assert_eq!(ApiProtocol::Anthropic.as_str(), "anthropic");
110    }
111
112    #[test]
113    fn from_wire_is_case_insensitive() {
114        assert_eq!(
115            ApiProtocol::from_wire("ANTHROPIC"),
116            Some(ApiProtocol::Anthropic)
117        );
118        assert_eq!(
119            ApiProtocol::from_wire("OpenAI"),
120            Some(ApiProtocol::OpenAiChat)
121        );
122    }
123
124    #[test]
125    fn from_wire_accepts_every_spelling_of_openai() {
126        for spelling in ["openai", "openai-chat", "openai_chat"] {
127            assert_eq!(
128                ApiProtocol::from_wire(spelling),
129                Some(ApiProtocol::OpenAiChat),
130                "{spelling} should parse"
131            );
132        }
133    }
134
135    #[test]
136    fn from_wire_rejects_an_unknown_protocol() {
137        assert_eq!(ApiProtocol::from_wire("cohere"), None);
138        assert_eq!(ApiProtocol::from_wire(""), None);
139    }
140
141    #[test]
142    fn display_matches_as_str() {
143        assert_eq!(ApiProtocol::Anthropic.to_string(), "anthropic");
144        assert_eq!(ApiProtocol::OpenAiChat.to_string(), "openai");
145    }
146
147    #[test]
148    fn from_wire_round_trips_as_str() {
149        for protocol in [ApiProtocol::OpenAiChat, ApiProtocol::Anthropic] {
150            assert_eq!(ApiProtocol::from_wire(protocol.as_str()), Some(protocol));
151        }
152    }
153}