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