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