Skip to main content

gproxy_protocol/protocol/
operation.rs

1//! Shared operation taxonomy and endpoint metadata.
2
3use serde::{Deserialize, Serialize};
4
5/// Upstream protocol family.
6///
7/// Provider-specific wire modules (`openai`, `claude`, `gemini`) should reuse
8/// this enum when declaring endpoint metadata or routing rules.
9#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
10#[serde(rename_all = "snake_case")]
11#[non_exhaustive]
12pub enum Provider {
13    OpenAi,
14    Claude,
15    Gemini,
16}
17
18/// Coarse operation family, used to organize protocol support by capability.
19#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
20#[serde(rename_all = "snake_case")]
21#[non_exhaustive]
22pub enum OperationGroup {
23    Models,
24    CountTokens,
25    GenerateContent,
26    Images,
27    Embeddings,
28    Compact,
29    Conversation,
30    Realtime,
31}
32
33/// Provider-neutral operation name.
34///
35/// Variants are capability-oriented. A provider module should model only the
36/// variants that the provider actually exposes; unsupported operations are not
37/// represented by synthetic request/response types.
38#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
39#[serde(rename_all = "snake_case")]
40#[non_exhaustive]
41pub enum Operation {
42    ListModels,
43    GetModel,
44    CountTokens,
45    GenerateContent,
46    StreamGenerateContent,
47    CreateImage,
48    EditImage,
49    CreateEmbedding,
50    CompactContent,
51    CreateConversation,
52    ConnectRealtime,
53}
54
55impl Operation {
56    /// Return the operation group for this operation.
57    pub const fn group(self) -> OperationGroup {
58        match self {
59            Self::ListModels | Self::GetModel => OperationGroup::Models,
60            Self::CountTokens => OperationGroup::CountTokens,
61            Self::GenerateContent | Self::StreamGenerateContent => OperationGroup::GenerateContent,
62            Self::CreateImage | Self::EditImage => OperationGroup::Images,
63            Self::CreateEmbedding => OperationGroup::Embeddings,
64            Self::CompactContent => OperationGroup::Compact,
65            Self::CreateConversation => OperationGroup::Conversation,
66            Self::ConnectRealtime => OperationGroup::Realtime,
67        }
68    }
69
70    /// Whether requests of this operation carry a JSON body.
71    pub const fn has_request_body(self) -> bool {
72        !matches!(
73            self,
74            Self::ListModels | Self::GetModel | Self::ConnectRealtime
75        )
76    }
77}
78
79/// Wire-format kind used together with [`Operation`].
80///
81/// Content generation needs a four-way kind because OpenAI has two distinct
82/// native formats for the same capability. Non-content operations only need the
83/// three provider families.
84#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
85#[serde(untagged)]
86#[non_exhaustive]
87pub enum OperationKind {
88    ContentGeneration(ContentGenerationKind),
89    Provider(Provider),
90}
91
92impl OperationKind {
93    pub const fn provider(self) -> Provider {
94        match self {
95            Self::ContentGeneration(kind) => kind.provider(),
96            Self::Provider(provider) => provider,
97        }
98    }
99
100    pub const fn is_content_generation(self) -> bool {
101        matches!(self, Self::ContentGeneration(_))
102    }
103}
104
105/// Content-generation wire formats.
106#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
107#[serde(rename_all = "snake_case")]
108#[non_exhaustive]
109pub enum ContentGenerationKind {
110    OpenAiResponses,
111    #[serde(rename = "open_ai_responses_websocket")]
112    OpenAiResponsesWebSocket,
113    OpenAiChatCompletions,
114    ClaudeMessages,
115    GeminiGenerateContent,
116}
117
118impl ContentGenerationKind {
119    pub const fn provider(self) -> Provider {
120        match self {
121            Self::OpenAiResponses
122            | Self::OpenAiResponsesWebSocket
123            | Self::OpenAiChatCompletions => Provider::OpenAi,
124            Self::ClaudeMessages => Provider::Claude,
125            Self::GeminiGenerateContent => Provider::Gemini,
126        }
127    }
128}
129
130/// Capability plus wire-format kind.
131#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize)]
132#[non_exhaustive]
133pub struct OperationKey {
134    operation: Operation,
135    kind: OperationKind,
136}
137
138impl OperationKey {
139    pub fn content_generation(operation: Operation, kind: ContentGenerationKind) -> Self {
140        assert!(
141            operation.is_content_generation(),
142            "content-generation kind used with non-content operation"
143        );
144        Self {
145            operation,
146            kind: OperationKind::ContentGeneration(kind),
147        }
148    }
149
150    pub fn provider(operation: Operation, provider: Provider) -> Self {
151        assert!(
152            !operation.is_content_generation(),
153            "provider kind used with content-generation operation"
154        );
155        Self {
156            operation,
157            kind: OperationKind::Provider(provider),
158        }
159    }
160
161    pub const fn group(self) -> OperationGroup {
162        self.operation.group()
163    }
164
165    /// Return the capability operation protected by this key's invariant.
166    pub const fn operation(self) -> Operation {
167        self.operation
168    }
169
170    /// Return the provider wire-format kind protected by this key's invariant.
171    pub const fn kind(self) -> OperationKind {
172        self.kind
173    }
174
175    pub const fn provider_family(self) -> Provider {
176        self.kind.provider()
177    }
178
179    pub const fn is_consistent(self) -> bool {
180        self.operation.is_content_generation() == self.kind.is_content_generation()
181    }
182
183    pub const fn try_new(
184        operation: Operation,
185        kind: OperationKind,
186    ) -> Result<Self, OperationKeyError> {
187        let key = Self { operation, kind };
188        if key.is_consistent() {
189            Ok(key)
190        } else {
191            Err(OperationKeyError { operation, kind })
192        }
193    }
194
195    #[cfg(test)]
196    pub(crate) const fn new_unchecked(operation: Operation, kind: OperationKind) -> Self {
197        Self { operation, kind }
198    }
199}
200
201#[derive(Debug, Clone, Copy, PartialEq, Eq, gproxy_protocol_macros::WireBuilder)]
202#[non_exhaustive]
203pub struct OperationKeyError {
204    pub operation: Operation,
205    pub kind: OperationKind,
206}
207
208impl std::fmt::Display for OperationKeyError {
209    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
210        write!(
211            formatter,
212            "operation {:?} is inconsistent with kind {:?}",
213            self.operation, self.kind
214        )
215    }
216}
217
218impl std::error::Error for OperationKeyError {}
219
220impl<'de> Deserialize<'de> for OperationKey {
221    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
222    where
223        D: serde::Deserializer<'de>,
224    {
225        #[derive(Deserialize)]
226        struct WireOperationKey {
227            operation: Operation,
228            kind: OperationKind,
229        }
230
231        let wire = WireOperationKey::deserialize(deserializer)?;
232        Self::try_new(wire.operation, wire.kind).map_err(serde::de::Error::custom)
233    }
234}
235
236impl Operation {
237    pub const fn is_content_generation(self) -> bool {
238        matches!(self, Self::GenerateContent | Self::StreamGenerateContent)
239    }
240}
241
242/// HTTP method for an upstream endpoint.
243#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
244#[serde(rename_all = "UPPERCASE")]
245#[non_exhaustive]
246pub enum HttpMethod {
247    Get,
248    Post,
249    Put,
250    Patch,
251    Delete,
252}
253
254impl From<HttpMethod> for http::Method {
255    fn from(m: HttpMethod) -> Self {
256        match m {
257            HttpMethod::Get => http::Method::GET,
258            HttpMethod::Post => http::Method::POST,
259            HttpMethod::Put => http::Method::PUT,
260            HttpMethod::Patch => http::Method::PATCH,
261            HttpMethod::Delete => http::Method::DELETE,
262        }
263    }
264}
265
266/// Provider endpoint metadata used by routing and protocol modules.
267#[derive(
268    Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, gproxy_protocol_macros::WireBuilder,
269)]
270#[non_exhaustive]
271pub struct Endpoint {
272    pub operation_key: OperationKey,
273    pub method: HttpMethod,
274    /// Provider-relative path template, e.g. `/v1/chat/completions`.
275    pub path: String,
276}
277
278impl Endpoint {
279    pub fn new(operation_key: OperationKey, method: HttpMethod, path: impl Into<String>) -> Self {
280        Self {
281            operation_key,
282            method,
283            path: path.into(),
284        }
285    }
286
287    pub fn content_generation(
288        operation: Operation,
289        kind: ContentGenerationKind,
290        method: HttpMethod,
291        path: impl Into<String>,
292    ) -> Self {
293        Self::new(
294            OperationKey::content_generation(operation, kind),
295            method,
296            path,
297        )
298    }
299
300    pub fn provider(
301        operation: Operation,
302        provider: Provider,
303        method: HttpMethod,
304        path: impl Into<String>,
305    ) -> Self {
306        Self::new(OperationKey::provider(operation, provider), method, path)
307    }
308
309    pub const fn provider_family(&self) -> Provider {
310        self.operation_key.provider_family()
311    }
312
313    pub const fn group(&self) -> OperationGroup {
314        self.operation_key.group()
315    }
316}
317
318#[cfg(test)]
319mod tests {
320    use super::*;
321
322    #[test]
323    fn deserialization_rejects_inconsistent_operation_key() {
324        let value = serde_json::json!({
325            "operation": "generate_content",
326            "kind": "open_ai"
327        });
328        assert!(serde_json::from_value::<OperationKey>(value).is_err());
329    }
330
331    #[test]
332    fn try_new_checks_the_invariant() {
333        assert!(
334            OperationKey::try_new(
335                Operation::GenerateContent,
336                OperationKind::Provider(Provider::OpenAi),
337            )
338            .is_err()
339        );
340    }
341}