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}