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