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