Skip to main content

gproxy_transform/transform/
registry.rs

1use crate::protocol::{ContentGenerationKind, Operation, OperationKey, OperationKind, Provider};
2
3use super::TransformError;
4
5/// Stable identifier for a supported transform implementation.
6#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
7pub enum TransformPair {
8    OpenAiResponsesToOpenAiChat,
9    OpenAiChatToOpenAiResponses,
10    OpenAiResponsesToOpenAiResponsesWebSocket,
11    OpenAiResponsesWebSocketToOpenAiResponses,
12    OpenAiChatToOpenAiResponsesWebSocket,
13    OpenAiResponsesWebSocketToOpenAiChat,
14    ClaudeMessagesToOpenAiResponsesWebSocket,
15    OpenAiResponsesWebSocketToClaudeMessages,
16    GeminiGenerateContentToOpenAiResponsesWebSocket,
17    OpenAiResponsesWebSocketToGeminiGenerateContent,
18    OpenAiResponsesToClaudeMessages,
19    ClaudeMessagesToOpenAiResponses,
20    OpenAiResponsesToGeminiGenerateContent,
21    GeminiGenerateContentToOpenAiResponses,
22    OpenAiChatToClaudeMessages,
23    ClaudeMessagesToOpenAiChat,
24    OpenAiChatToGeminiGenerateContent,
25    GeminiGenerateContentToOpenAiChat,
26    ClaudeMessagesToGeminiGenerateContent,
27    GeminiGenerateContentToClaudeMessages,
28    OpenAiToClaudeCountTokens,
29    ClaudeToOpenAiCountTokens,
30    OpenAiToGeminiCountTokens,
31    GeminiToOpenAiCountTokens,
32    ClaudeToGeminiCountTokens,
33    GeminiToClaudeCountTokens,
34    OpenAiToClaudeModels,
35    ClaudeToOpenAiModels,
36    OpenAiToGeminiModels,
37    GeminiToOpenAiModels,
38    ClaudeToGeminiModels,
39    GeminiToClaudeModels,
40    OpenAiToGeminiEmbeddings,
41    GeminiToOpenAiEmbeddings,
42    OpenAiCreateImageToGemini,
43    GeminiToOpenAiCreateImage,
44    OpenAiCreateImageToOpenAiResponses,
45    OpenAiEditImageToOpenAiResponses,
46    OpenAiEditImageToGemini,
47    GeminiToOpenAiEditImage,
48    OpenAiToClaudeCompact,
49    ClaudeToOpenAiCompact,
50    OpenAiCompactToOpenAiResponses,
51    OpenAiResponsesToOpenAiCompact,
52    OpenAiCompactToGemini,
53    GeminiToOpenAiCompact,
54    OpenAiCompactToOpenAiChat,
55    OpenAiChatToOpenAiCompact,
56}
57
58/// Resolve operation keys to a concrete pair module.
59///
60/// Same-kind passthrough is intentionally not represented here; routing should
61/// bypass the transform layer when source and target wire kinds match.
62pub fn resolve(
63    source: OperationKey,
64    target: OperationKey,
65) -> Result<TransformPair, TransformError> {
66    if !source.is_consistent() {
67        return Err(TransformError::InvalidInput {
68            reason: "source operation and kind are inconsistent".to_owned(),
69        });
70    }
71    if !target.is_consistent() {
72        return Err(TransformError::InvalidInput {
73            reason: "target operation and kind are inconsistent".to_owned(),
74        });
75    }
76    if source == target {
77        return Err(TransformError::unsupported_pair(source, target));
78    }
79    // Compaction and image generation are the sanctioned cross-operation routes:
80    // OpenAI has dedicated compact/image endpoints, while the other providers
81    // service them through generate-content.
82    if let Some(pair) = resolve_compaction(source, target) {
83        return Ok(pair);
84    }
85    if let Some(pair) = resolve_image_generation(source, target) {
86        return Ok(pair);
87    }
88    if source.operation() != target.operation() {
89        return Err(TransformError::unsupported_pair(source, target));
90    }
91
92    match source.operation() {
93        Operation::GenerateContent | Operation::StreamGenerateContent => {
94            resolve_content_generation(source, target)
95        }
96        Operation::CountTokens => resolve_provider_pair(source, target, count_tokens_pair),
97        Operation::ListModels | Operation::GetModel => {
98            resolve_provider_pair(source, target, models_pair)
99        }
100        Operation::CreateEmbedding => resolve_provider_pair(source, target, embeddings_pair),
101        // Compaction and image generation are never same-operation routes: OpenAI
102        // is the only provider with dedicated compact/image endpoints, and every
103        // such conversion is handled cross-operation above.
104        Operation::CompactContent
105        | Operation::CreateImage
106        | Operation::EditImage
107        | Operation::CreateConversation
108        | Operation::ConnectRealtime => Err(TransformError::unsupported_pair(source, target)),
109        _ => {
110            unreachable!("new non-exhaustive protocol variant requires a lockstep transform update")
111        }
112    }
113}
114
115fn resolve_content_generation(
116    source: OperationKey,
117    target: OperationKey,
118) -> Result<TransformPair, TransformError> {
119    let OperationKind::ContentGeneration(source_kind) = source.kind() else {
120        return Err(TransformError::unsupported_pair(source, target));
121    };
122    let OperationKind::ContentGeneration(target_kind) = target.kind() else {
123        return Err(TransformError::unsupported_pair(source, target));
124    };
125
126    use ContentGenerationKind as Kind;
127    use TransformPair as Pair;
128
129    match (source_kind, target_kind) {
130        (Kind::OpenAiResponses, Kind::OpenAiChatCompletions) => {
131            Ok(Pair::OpenAiResponsesToOpenAiChat)
132        }
133        (Kind::OpenAiChatCompletions, Kind::OpenAiResponses) => {
134            Ok(Pair::OpenAiChatToOpenAiResponses)
135        }
136        (Kind::OpenAiResponses, Kind::OpenAiResponsesWebSocket) => {
137            Ok(Pair::OpenAiResponsesToOpenAiResponsesWebSocket)
138        }
139        (Kind::OpenAiResponsesWebSocket, Kind::OpenAiResponses) => {
140            Ok(Pair::OpenAiResponsesWebSocketToOpenAiResponses)
141        }
142        (Kind::OpenAiChatCompletions, Kind::OpenAiResponsesWebSocket) => {
143            Ok(Pair::OpenAiChatToOpenAiResponsesWebSocket)
144        }
145        (Kind::OpenAiResponsesWebSocket, Kind::OpenAiChatCompletions) => {
146            Ok(Pair::OpenAiResponsesWebSocketToOpenAiChat)
147        }
148        (Kind::ClaudeMessages, Kind::OpenAiResponsesWebSocket) => {
149            Ok(Pair::ClaudeMessagesToOpenAiResponsesWebSocket)
150        }
151        (Kind::OpenAiResponsesWebSocket, Kind::ClaudeMessages) => {
152            Ok(Pair::OpenAiResponsesWebSocketToClaudeMessages)
153        }
154        (Kind::GeminiGenerateContent, Kind::OpenAiResponsesWebSocket) => {
155            Ok(Pair::GeminiGenerateContentToOpenAiResponsesWebSocket)
156        }
157        (Kind::OpenAiResponsesWebSocket, Kind::GeminiGenerateContent) => {
158            Ok(Pair::OpenAiResponsesWebSocketToGeminiGenerateContent)
159        }
160        (Kind::OpenAiResponses, Kind::ClaudeMessages) => Ok(Pair::OpenAiResponsesToClaudeMessages),
161        (Kind::ClaudeMessages, Kind::OpenAiResponses) => Ok(Pair::ClaudeMessagesToOpenAiResponses),
162        (Kind::OpenAiResponses, Kind::GeminiGenerateContent) => {
163            Ok(Pair::OpenAiResponsesToGeminiGenerateContent)
164        }
165        (Kind::GeminiGenerateContent, Kind::OpenAiResponses) => {
166            Ok(Pair::GeminiGenerateContentToOpenAiResponses)
167        }
168        (Kind::OpenAiChatCompletions, Kind::ClaudeMessages) => Ok(Pair::OpenAiChatToClaudeMessages),
169        (Kind::ClaudeMessages, Kind::OpenAiChatCompletions) => Ok(Pair::ClaudeMessagesToOpenAiChat),
170        (Kind::OpenAiChatCompletions, Kind::GeminiGenerateContent) => {
171            Ok(Pair::OpenAiChatToGeminiGenerateContent)
172        }
173        (Kind::GeminiGenerateContent, Kind::OpenAiChatCompletions) => {
174            Ok(Pair::GeminiGenerateContentToOpenAiChat)
175        }
176        (Kind::ClaudeMessages, Kind::GeminiGenerateContent) => {
177            Ok(Pair::ClaudeMessagesToGeminiGenerateContent)
178        }
179        (Kind::GeminiGenerateContent, Kind::ClaudeMessages) => {
180            Ok(Pair::GeminiGenerateContentToClaudeMessages)
181        }
182        _ => Err(TransformError::unsupported_pair(source, target)),
183    }
184}
185
186fn resolve_provider_pair(
187    source: OperationKey,
188    target: OperationKey,
189    pair_fn: fn(Provider, Provider) -> Option<TransformPair>,
190) -> Result<TransformPair, TransformError> {
191    let OperationKind::Provider(source_provider) = source.kind() else {
192        return Err(TransformError::unsupported_pair(source, target));
193    };
194    let OperationKind::Provider(target_provider) = target.kind() else {
195        return Err(TransformError::unsupported_pair(source, target));
196    };
197
198    pair_fn(source_provider, target_provider)
199        .ok_or_else(|| TransformError::unsupported_pair(source, target))
200}
201
202fn count_tokens_pair(source: Provider, target: Provider) -> Option<TransformPair> {
203    provider_matrix(
204        source,
205        target,
206        ProviderMatrix {
207            openai_to_claude: TransformPair::OpenAiToClaudeCountTokens,
208            claude_to_openai: TransformPair::ClaudeToOpenAiCountTokens,
209            openai_to_gemini: TransformPair::OpenAiToGeminiCountTokens,
210            gemini_to_openai: TransformPair::GeminiToOpenAiCountTokens,
211            claude_to_gemini: TransformPair::ClaudeToGeminiCountTokens,
212            gemini_to_claude: TransformPair::GeminiToClaudeCountTokens,
213        },
214    )
215}
216
217fn models_pair(source: Provider, target: Provider) -> Option<TransformPair> {
218    provider_matrix(
219        source,
220        target,
221        ProviderMatrix {
222            openai_to_claude: TransformPair::OpenAiToClaudeModels,
223            claude_to_openai: TransformPair::ClaudeToOpenAiModels,
224            openai_to_gemini: TransformPair::OpenAiToGeminiModels,
225            gemini_to_openai: TransformPair::GeminiToOpenAiModels,
226            claude_to_gemini: TransformPair::ClaudeToGeminiModels,
227            gemini_to_claude: TransformPair::GeminiToClaudeModels,
228        },
229    )
230}
231
232fn embeddings_pair(source: Provider, target: Provider) -> Option<TransformPair> {
233    match (source, target) {
234        (Provider::OpenAi, Provider::Gemini) => Some(TransformPair::OpenAiToGeminiEmbeddings),
235        (Provider::Gemini, Provider::OpenAi) => Some(TransformPair::GeminiToOpenAiEmbeddings),
236        _ => None,
237    }
238}
239
240/// Resolve cross-operation image-generation routes. OpenAI has dedicated
241/// create/edit-image endpoints; Gemini services them through generate-content
242/// (image response modality), so it has no image operation of its own.
243fn resolve_image_generation(source: OperationKey, target: OperationKey) -> Option<TransformPair> {
244    use ContentGenerationKind as Kind;
245    use OperationKind as OK;
246
247    // OpenAI create/edit-image -> OpenAI Responses: codex-like channels have no
248    // images endpoint and generate/edit via the Responses `image_generation`
249    // tool. Each pair serves both directions (request: images->responses;
250    // response: responses->images), so resolve it for the reverse ordering too.
251    if let (OK::Provider(Provider::OpenAi), OK::ContentGeneration(Kind::OpenAiResponses)) =
252        (source.kind(), target.kind())
253        && target.operation().is_content_generation()
254    {
255        return match source.operation() {
256            Operation::CreateImage => Some(TransformPair::OpenAiCreateImageToOpenAiResponses),
257            Operation::EditImage => Some(TransformPair::OpenAiEditImageToOpenAiResponses),
258            _ => None,
259        };
260    }
261    if let (OK::ContentGeneration(Kind::OpenAiResponses), OK::Provider(Provider::OpenAi)) =
262        (source.kind(), target.kind())
263        && source.operation().is_content_generation()
264    {
265        return match target.operation() {
266            Operation::CreateImage => Some(TransformPair::OpenAiCreateImageToOpenAiResponses),
267            Operation::EditImage => Some(TransformPair::OpenAiEditImageToOpenAiResponses),
268            _ => None,
269        };
270    }
271
272    // OpenAI create/edit image -> Gemini generate-content.
273    if let (OK::Provider(Provider::OpenAi), OK::ContentGeneration(Kind::GeminiGenerateContent)) =
274        (source.kind(), target.kind())
275        && target.operation().is_content_generation()
276    {
277        return match source.operation() {
278            Operation::CreateImage => Some(TransformPair::OpenAiCreateImageToGemini),
279            Operation::EditImage => Some(TransformPair::OpenAiEditImageToGemini),
280            _ => None,
281        };
282    }
283
284    // Gemini generate-content -> OpenAI create/edit image.
285    if let (OK::ContentGeneration(Kind::GeminiGenerateContent), OK::Provider(Provider::OpenAi)) =
286        (source.kind(), target.kind())
287        && source.operation().is_content_generation()
288    {
289        return match target.operation() {
290            Operation::CreateImage => Some(TransformPair::GeminiToOpenAiCreateImage),
291            Operation::EditImage => Some(TransformPair::GeminiToOpenAiEditImage),
292            _ => None,
293        };
294    }
295
296    None
297}
298
299/// Resolve cross-operation compaction routes. Compaction is the only sanctioned
300/// cross-operation conversion: a generate-content payload folded into an OpenAI
301/// compact payload, or an OpenAI compact payload serviced by a generate-content
302/// upstream.
303fn resolve_compaction(source: OperationKey, target: OperationKey) -> Option<TransformPair> {
304    use ContentGenerationKind as Kind;
305    use OperationKind as OK;
306
307    // content-generation -> OpenAI compact.
308    if let (OK::ContentGeneration(kind), Operation::CompactContent, OK::Provider(Provider::OpenAi)) =
309        (source.kind(), target.operation(), target.kind())
310        && source.operation().is_content_generation()
311    {
312        return match kind {
313            Kind::OpenAiResponses => Some(TransformPair::OpenAiResponsesToOpenAiCompact),
314            Kind::OpenAiResponsesWebSocket => None,
315            Kind::GeminiGenerateContent => Some(TransformPair::GeminiToOpenAiCompact),
316            Kind::OpenAiChatCompletions => Some(TransformPair::OpenAiChatToOpenAiCompact),
317            Kind::ClaudeMessages => Some(TransformPair::ClaudeToOpenAiCompact),
318            _ => unreachable!(
319                "new non-exhaustive protocol variant requires a lockstep transform update"
320            ),
321        };
322    }
323
324    // OpenAI compact -> content-generation.
325    if let (OK::Provider(Provider::OpenAi), Operation::CompactContent) =
326        (source.kind(), source.operation())
327        && let OK::ContentGeneration(kind) = target.kind()
328        && target.operation().is_content_generation()
329    {
330        return match kind {
331            Kind::GeminiGenerateContent => Some(TransformPair::OpenAiCompactToGemini),
332            Kind::OpenAiChatCompletions => Some(TransformPair::OpenAiCompactToOpenAiChat),
333            Kind::ClaudeMessages => Some(TransformPair::OpenAiToClaudeCompact),
334            Kind::OpenAiResponses => Some(TransformPair::OpenAiCompactToOpenAiResponses),
335            Kind::OpenAiResponsesWebSocket => None,
336            _ => unreachable!(
337                "new non-exhaustive protocol variant requires a lockstep transform update"
338            ),
339        };
340    }
341
342    None
343}
344
345#[derive(Debug, Clone, Copy)]
346struct ProviderMatrix {
347    openai_to_claude: TransformPair,
348    claude_to_openai: TransformPair,
349    openai_to_gemini: TransformPair,
350    gemini_to_openai: TransformPair,
351    claude_to_gemini: TransformPair,
352    gemini_to_claude: TransformPair,
353}
354
355fn provider_matrix(
356    source: Provider,
357    target: Provider,
358    matrix: ProviderMatrix,
359) -> Option<TransformPair> {
360    match (source, target) {
361        (Provider::OpenAi, Provider::Claude) => Some(matrix.openai_to_claude),
362        (Provider::Claude, Provider::OpenAi) => Some(matrix.claude_to_openai),
363        (Provider::OpenAi, Provider::Gemini) => Some(matrix.openai_to_gemini),
364        (Provider::Gemini, Provider::OpenAi) => Some(matrix.gemini_to_openai),
365        (Provider::Claude, Provider::Gemini) => Some(matrix.claude_to_gemini),
366        (Provider::Gemini, Provider::Claude) => Some(matrix.gemini_to_claude),
367        _ => None,
368    }
369}
370
371#[cfg(test)]
372mod tests {
373    use super::*;
374
375    #[test]
376    fn edit_image_resolves_to_responses_image_tool_pair() {
377        let source = OperationKey::provider(Operation::EditImage, Provider::OpenAi);
378        let target = OperationKey::content_generation(
379            Operation::StreamGenerateContent,
380            ContentGenerationKind::OpenAiResponses,
381        );
382
383        assert_eq!(
384            resolve(source, target).unwrap(),
385            TransformPair::OpenAiEditImageToOpenAiResponses
386        );
387        assert_eq!(
388            resolve(target, source).unwrap(),
389            TransformPair::OpenAiEditImageToOpenAiResponses
390        );
391    }
392}