Skip to main content

gproxy_protocol/
operation.rs

1//! Operation identity: what a request is asking for, and in whose dialect.
2//!
3//! An operation is declared once here and nowhere else. Everything downstream
4//! — transform pairs, channel support tables, routing rules — keys on
5//! [`OperationKey`], so adding a variant produces a compile-error checklist
6//! rather than a runtime fallthrough.
7//!
8//! Paths are deliberately absent. Which URL serves an operation is an HTTP
9//! convention owned by the ingress layer; an SDK caller names the operation
10//! directly and never sees a path.
11//!
12//! Persistence strings live on the variants rather than in a hand-written
13//! match. `snake_case` conversion is exact for every operation; the OpenAI
14//! dialects need an explicit spelling because the derive would otherwise split
15//! them into `open_ai`. A pinning test guards the whole set, so renaming a
16//! variant cannot silently change what is already in a database.
17
18/// What the caller wants done.
19///
20/// The operation and [`Dialect`] together identify the wire shape.
21#[derive(
22    Debug,
23    Clone,
24    Copy,
25    PartialEq,
26    Eq,
27    Hash,
28    PartialOrd,
29    Ord,
30    serde::Serialize,
31    serde::Deserialize,
32    strum::IntoStaticStr,
33    strum::EnumString,
34    strum::EnumIter,
35)]
36#[serde(rename_all = "snake_case")]
37#[strum(serialize_all = "snake_case")]
38#[cfg_attr(not(feature = "exhaustive"), non_exhaustive)]
39pub enum Operation {
40    // models
41    ListModels,
42    GetModel,
43    // counting
44    CountTokens,
45    // generation
46    GenerateContent,
47    StreamGenerateContent,
48    // moderation
49    /// Standard OpenAI moderation JSON, distinct from Codex Guardian's SSE.
50    CreateModeration,
51    GuardianReview,
52    GuardianClassify,
53    // context management
54    CompactContent,
55    SummarizeMemory,
56    CreateConversation,
57    // embeddings
58    CreateEmbedding,
59    BatchCreateEmbedding,
60    // retrieval
61    Rerank,
62    // alpha search for codex (maybe include by other providers later)
63    WebSearch,
64    // images
65    CreateImage,
66    EditImage,
67    // audio
68    CreateSpeech,
69    CreateTranscription,
70    CreateTranslation,
71    // files
72    CreateFile,
73    ListFiles,
74    RetrieveFile,
75    RetrieveFileContent,
76    DeleteFile,
77    // video — the async-job core. Keyed with the OpenAI family dialect; the
78    // request body is OpenAI's plus documented extensions, because OpenAI's own
79    // five fields cap what the models can be asked for (three fixed durations,
80    // four fixed sizes, one reference asset, no seed). Sora-only operations —
81    // remix, edit, extend, characters — are deliberately absent: no other
82    // vendor offers them. Add them back only when a second vendor does.
83    CreateVideo,
84    RetrieveVideo,
85    ListVideos,
86    DeleteVideo,
87    /// Download video bytes, e.g. OpenAI's GET /videos/{video_id}/content.
88    /// Some vendors instead expose a download URL in a job response; their
89    /// channel implementation resolves that URL as part of this operation.
90    DownloadVideoContent,
91    // realtime
92    /// SDP handshake creating a WebRTC realtime call.
93    CreateRealtimeCall,
94    /// Duplex websocket session. Never transformed — only same-protocol
95    /// passthrough, because a client that can keep talking mid-response has no
96    /// equivalent in a half-duplex dialect.
97    ConnectRealtime,
98}
99
100impl Operation {
101    /// Whether this operation performs inference or a metered tool action.
102    /// Catalog, token-counting, resource management and signaling-only calls
103    /// belong in request logs, not request-level usage statistics.
104    pub const fn produces_usage(self) -> bool {
105        match self {
106            Self::GenerateContent
107            | Self::StreamGenerateContent
108            | Self::CreateModeration
109            | Self::GuardianReview
110            | Self::GuardianClassify
111            | Self::CompactContent
112            | Self::SummarizeMemory
113            | Self::CreateEmbedding
114            | Self::BatchCreateEmbedding
115            | Self::Rerank
116            | Self::WebSearch
117            | Self::CreateImage
118            | Self::EditImage
119            | Self::CreateSpeech
120            | Self::CreateTranscription
121            | Self::CreateTranslation
122            | Self::CreateVideo
123            | Self::ConnectRealtime => true,
124            Self::ListModels
125            | Self::GetModel
126            | Self::CountTokens
127            | Self::CreateConversation
128            | Self::CreateFile
129            | Self::ListFiles
130            | Self::RetrieveFile
131            | Self::RetrieveFileContent
132            | Self::DeleteFile
133            | Self::RetrieveVideo
134            | Self::ListVideos
135            | Self::DeleteVideo
136            | Self::DownloadVideoContent
137            | Self::CreateRealtimeCall => false,
138        }
139    }
140
141    /// Shared by persistence and historical usage queries.
142    pub fn usage_operations() -> impl Iterator<Item = Self> {
143        <Self as strum::IntoEnumIterator>::iter().filter(|op| op.produces_usage())
144    }
145
146    /// Stable persistence id.
147    pub fn id(self) -> &'static str {
148        self.into()
149    }
150
151    pub fn from_id(value: &str) -> Option<Self> {
152        value.parse().ok()
153    }
154}
155
156/// Vendor grouping of dialects. Derived from a [`Dialect`], never stored
157/// alongside one.
158///
159/// Names a dialect family, not a configured backend — v2 called this
160/// `Provider` and the two kept getting confused. Which vendor actually *serves*
161/// a dialect is the channel's business: Anthropic and Google both expose
162/// OpenAI-compatible endpoints, and that shows up as channel support, not here.
163#[derive(
164    Debug,
165    Clone,
166    Copy,
167    PartialEq,
168    Eq,
169    Hash,
170    PartialOrd,
171    Ord,
172    serde::Serialize,
173    serde::Deserialize,
174    strum::IntoStaticStr,
175    strum::EnumString,
176    strum::EnumIter,
177)]
178#[serde(rename_all = "snake_case")]
179#[strum(serialize_all = "snake_case")]
180#[cfg_attr(not(feature = "exhaustive"), non_exhaustive)]
181pub enum WireFamily {
182    #[serde(rename = "openai")]
183    #[strum(serialize = "openai")]
184    OpenAi,
185    Claude,
186    Gemini,
187}
188
189impl WireFamily {
190    pub fn id(self) -> &'static str {
191        self.into()
192    }
193
194    pub fn from_id(value: &str) -> Option<Self> {
195        value.parse().ok()
196    }
197}
198
199/// The wire shape an operation is expressed in.
200///
201/// The three vendor dialects cover all operations. For content generation,
202/// they mean OpenAI Responses, Claude Messages and Gemini GenerateContent.
203/// OpenAI additionally has Chat Completions and a Responses websocket envelope.
204/// [`Dialect::family`] derives the vendor grouping from this single axis.
205///
206/// There is no AWS variant: Bedrock Converse has no ingress path, so no client
207/// speaks it to us. It is an upstream shape the `aws_bedrock` channel produces,
208/// and its wire types live with that channel.
209#[derive(
210    Debug,
211    Clone,
212    Copy,
213    PartialEq,
214    Eq,
215    Hash,
216    PartialOrd,
217    Ord,
218    serde::Serialize,
219    serde::Deserialize,
220    strum::IntoStaticStr,
221    strum::EnumString,
222    strum::EnumIter,
223)]
224#[serde(rename_all = "snake_case")]
225#[strum(serialize_all = "snake_case")]
226#[cfg_attr(not(feature = "exhaustive"), non_exhaustive)]
227pub enum Dialect {
228    /// OpenAI's shape for the operation; Responses for content generation.
229    #[serde(rename = "openai")]
230    #[strum(serialize = "openai")]
231    OpenAi,
232    /// Claude's shape for the operation; Messages for content generation.
233    Claude,
234    /// Gemini's shape for the operation; GenerateContent for content generation.
235    Gemini,
236    /// OpenAI Chat Completions, for conversation operations only.
237    #[serde(rename = "openai_chat")]
238    #[strum(serialize = "openai_chat")]
239    OpenAiChat,
240    /// Responses over a websocket, for conversation operations only. The
241    /// envelope layer unwraps onto [`Dialect::OpenAi`]'s Responses pairs and
242    /// wraps the result back; this variant owns no transform pairs of its own.
243    #[serde(rename = "openai_responses_websocket")]
244    #[strum(serialize = "openai_responses_websocket")]
245    OpenAiResponsesWebSocket,
246}
247
248impl Dialect {
249    pub fn id(self) -> &'static str {
250        self.into()
251    }
252
253    pub fn from_id(value: &str) -> Option<Self> {
254        value.parse().ok()
255    }
256
257    /// The vendor family this shape belongs to.
258    pub const fn family(self) -> WireFamily {
259        match self {
260            Self::OpenAi | Self::OpenAiChat | Self::OpenAiResponsesWebSocket => WireFamily::OpenAi,
261            Self::Claude => WireFamily::Claude,
262            Self::Gemini => WireFamily::Gemini,
263        }
264    }
265
266    /// The dialect that owns the transform pairs for this one. Only the
267    /// websocket envelope variant differs from itself.
268    pub const fn pair_dialect(self) -> Self {
269        match self {
270            Self::OpenAiResponsesWebSocket => Self::OpenAi,
271            other => other,
272        }
273    }
274}
275
276/// What transform pairs, channel support tables and routing rules key on.
277///
278/// Construct directly from an operation and dialect. Supported combinations
279/// are defined by transform and channel support tables, not by this type.
280#[derive(
281    Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, serde::Serialize, serde::Deserialize,
282)]
283#[serde(deny_unknown_fields)]
284pub struct OperationKey {
285    pub operation: Operation,
286    pub dialect: Dialect,
287}
288
289impl OperationKey {
290    pub const fn family(self) -> WireFamily {
291        self.dialect.family()
292    }
293
294    /// The key whose transform pairs serve this one. Differs from `self` only
295    /// for the websocket envelope variant.
296    pub const fn pair_key(self) -> Self {
297        Self {
298            operation: self.operation,
299            dialect: self.dialect.pair_dialect(),
300        }
301    }
302}
303
304#[cfg(test)]
305mod tests {
306    use super::*;
307    use strum::IntoEnumIterator;
308
309    /// Pins every persistence id. The derive generates these from the variant
310    /// names, so without this test renaming a variant would silently rewrite
311    /// what is already stored in a database. Order and count are pinned too,
312    /// which is what makes adding an operation a deliberate act.
313    #[test]
314    fn only_inference_and_metered_tool_operations_produce_usage() {
315        for op in [
316            Operation::ListModels,
317            Operation::GetModel,
318            Operation::CountTokens,
319            Operation::CreateConversation,
320            Operation::CreateFile,
321            Operation::ListFiles,
322            Operation::RetrieveFile,
323            Operation::RetrieveFileContent,
324            Operation::DeleteFile,
325            Operation::RetrieveVideo,
326            Operation::ListVideos,
327            Operation::DeleteVideo,
328            Operation::DownloadVideoContent,
329            Operation::CreateRealtimeCall,
330        ] {
331            assert!(!op.produces_usage(), "{}", op.id());
332        }
333        assert!(Operation::GenerateContent.produces_usage());
334        assert!(Operation::CreateEmbedding.produces_usage());
335        assert!(Operation::ConnectRealtime.produces_usage());
336    }
337
338    #[test]
339    fn operation_ids_are_pinned() {
340        let ids: Vec<&'static str> = Operation::iter().map(Operation::id).collect();
341        assert_eq!(
342            ids,
343            [
344                "list_models",
345                "get_model",
346                "count_tokens",
347                "generate_content",
348                "stream_generate_content",
349                "create_moderation",
350                "guardian_review",
351                "guardian_classify",
352                "compact_content",
353                "summarize_memory",
354                "create_conversation",
355                "create_embedding",
356                "batch_create_embedding",
357                "rerank",
358                "web_search",
359                "create_image",
360                "edit_image",
361                "create_speech",
362                "create_transcription",
363                "create_translation",
364                "create_file",
365                "list_files",
366                "retrieve_file",
367                "retrieve_file_content",
368                "delete_file",
369                "create_video",
370                "retrieve_video",
371                "list_videos",
372                "delete_video",
373                "download_video_content",
374                "create_realtime_call",
375                "connect_realtime",
376            ]
377        );
378    }
379
380    /// Pins the five canonical dialect ids used by serde and strum.
381    #[test]
382    fn dialect_ids_are_pinned() {
383        let ids: Vec<&'static str> = Dialect::iter().map(Dialect::id).collect();
384        assert_eq!(
385            ids,
386            [
387                "openai",
388                "claude",
389                "gemini",
390                "openai_chat",
391                "openai_responses_websocket",
392            ]
393        );
394
395        let families: Vec<&'static str> = WireFamily::iter().map(WireFamily::id).collect();
396        assert_eq!(families, ["openai", "claude", "gemini"]);
397    }
398
399    #[test]
400    fn every_id_round_trips() {
401        for operation in Operation::iter() {
402            assert_eq!(Operation::from_id(operation.id()), Some(operation));
403        }
404        for dialect in Dialect::iter() {
405            assert_eq!(Dialect::from_id(dialect.id()), Some(dialect));
406        }
407        for family in WireFamily::iter() {
408            assert_eq!(WireFamily::from_id(family.id()), Some(family));
409        }
410    }
411
412    /// serde and strum derive their strings separately; nothing forces them to
413    /// agree, so check that they do.
414    #[test]
415    fn serde_agrees_with_strum() {
416        for operation in Operation::iter() {
417            let json = serde_json::to_string(&operation).expect("serializes");
418            assert_eq!(json, format!("\"{}\"", operation.id()));
419            let back: Operation = serde_json::from_str(&json).expect("deserializes");
420            assert_eq!(back, operation);
421        }
422        for dialect in Dialect::iter() {
423            let json = serde_json::to_string(&dialect).expect("serializes");
424            assert_eq!(json, format!("\"{}\"", dialect.id()));
425            let back: Dialect = serde_json::from_str(&json).expect("deserializes");
426            assert_eq!(back, dialect);
427        }
428        for family in WireFamily::iter() {
429            let json = serde_json::to_string(&family).expect("serializes");
430            assert_eq!(json, format!("\"{}\"", family.id()));
431        }
432    }
433
434    #[test]
435    fn unknown_ids_are_rejected() {
436        assert_eq!(Operation::from_id("not_an_operation"), None);
437        assert_eq!(Dialect::from_id("open_ai"), None);
438        assert_eq!(WireFamily::from_id("open_ai"), None);
439    }
440
441    /// Every dialect belongs to exactly one family, and the family shapes are
442    /// the identity of their own family.
443    #[test]
444    fn families_are_derivable() {
445        assert_eq!(Dialect::OpenAiChat.family(), WireFamily::OpenAi);
446        assert_eq!(
447            Dialect::OpenAiResponsesWebSocket.family(),
448            WireFamily::OpenAi
449        );
450        assert_eq!(Dialect::OpenAi.family(), WireFamily::OpenAi);
451        assert_eq!(Dialect::Claude.family(), WireFamily::Claude);
452        assert_eq!(Dialect::Gemini.family(), WireFamily::Gemini);
453    }
454
455    #[test]
456    fn only_the_websocket_envelope_borrows_another_dialects_pairs() {
457        let ws = OperationKey {
458            operation: Operation::StreamGenerateContent,
459            dialect: Dialect::OpenAiResponsesWebSocket,
460        };
461        assert_eq!(
462            ws.pair_key(),
463            OperationKey {
464                operation: Operation::StreamGenerateContent,
465                dialect: Dialect::OpenAi,
466            }
467        );
468
469        for dialect in Dialect::iter().filter(|d| *d != Dialect::OpenAiResponsesWebSocket) {
470            assert_eq!(dialect.pair_dialect(), dialect);
471        }
472    }
473}