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