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