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