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