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::Rerank
108        | Operation::CreateSpeech
109        | Operation::CreateTranscription
110        | Operation::CreateTranslation
111        | Operation::CreateConversation
112        | Operation::ConnectRealtime => Err(TransformError::unsupported_pair(source, target)),
113        _ => {
114            unreachable!("new non-exhaustive protocol variant requires a lockstep transform update")
115        }
116    }
117}
118
119fn resolve_content_generation(
120    source: OperationKey,
121    target: OperationKey,
122) -> Result<TransformPair, TransformError> {
123    let OperationKind::ContentGeneration(source_kind) = source.kind() else {
124        return Err(TransformError::unsupported_pair(source, target));
125    };
126    let OperationKind::ContentGeneration(target_kind) = target.kind() else {
127        return Err(TransformError::unsupported_pair(source, target));
128    };
129
130    use ContentGenerationKind as Kind;
131    use TransformPair as Pair;
132
133    match (source_kind, target_kind) {
134        (Kind::OpenAiResponses, Kind::OpenAiChatCompletions) => {
135            Ok(Pair::OpenAiResponsesToOpenAiChat)
136        }
137        (Kind::OpenAiChatCompletions, Kind::OpenAiResponses) => {
138            Ok(Pair::OpenAiChatToOpenAiResponses)
139        }
140        (Kind::OpenAiResponses, Kind::OpenAiResponsesWebSocket) => {
141            Ok(Pair::OpenAiResponsesToOpenAiResponsesWebSocket)
142        }
143        (Kind::OpenAiResponsesWebSocket, Kind::OpenAiResponses) => {
144            Ok(Pair::OpenAiResponsesWebSocketToOpenAiResponses)
145        }
146        (Kind::OpenAiChatCompletions, Kind::OpenAiResponsesWebSocket) => {
147            Ok(Pair::OpenAiChatToOpenAiResponsesWebSocket)
148        }
149        (Kind::OpenAiResponsesWebSocket, Kind::OpenAiChatCompletions) => {
150            Ok(Pair::OpenAiResponsesWebSocketToOpenAiChat)
151        }
152        (Kind::ClaudeMessages, Kind::OpenAiResponsesWebSocket) => {
153            Ok(Pair::ClaudeMessagesToOpenAiResponsesWebSocket)
154        }
155        (Kind::OpenAiResponsesWebSocket, Kind::ClaudeMessages) => {
156            Ok(Pair::OpenAiResponsesWebSocketToClaudeMessages)
157        }
158        (Kind::GeminiGenerateContent, Kind::OpenAiResponsesWebSocket) => {
159            Ok(Pair::GeminiGenerateContentToOpenAiResponsesWebSocket)
160        }
161        (Kind::OpenAiResponsesWebSocket, Kind::GeminiGenerateContent) => {
162            Ok(Pair::OpenAiResponsesWebSocketToGeminiGenerateContent)
163        }
164        (Kind::OpenAiResponses, Kind::ClaudeMessages) => Ok(Pair::OpenAiResponsesToClaudeMessages),
165        (Kind::ClaudeMessages, Kind::OpenAiResponses) => Ok(Pair::ClaudeMessagesToOpenAiResponses),
166        (Kind::OpenAiResponses, Kind::GeminiGenerateContent) => {
167            Ok(Pair::OpenAiResponsesToGeminiGenerateContent)
168        }
169        (Kind::GeminiGenerateContent, Kind::OpenAiResponses) => {
170            Ok(Pair::GeminiGenerateContentToOpenAiResponses)
171        }
172        (Kind::OpenAiChatCompletions, Kind::ClaudeMessages) => Ok(Pair::OpenAiChatToClaudeMessages),
173        (Kind::ClaudeMessages, Kind::OpenAiChatCompletions) => Ok(Pair::ClaudeMessagesToOpenAiChat),
174        (Kind::OpenAiChatCompletions, Kind::GeminiGenerateContent) => {
175            Ok(Pair::OpenAiChatToGeminiGenerateContent)
176        }
177        (Kind::GeminiGenerateContent, Kind::OpenAiChatCompletions) => {
178            Ok(Pair::GeminiGenerateContentToOpenAiChat)
179        }
180        (Kind::ClaudeMessages, Kind::GeminiGenerateContent) => {
181            Ok(Pair::ClaudeMessagesToGeminiGenerateContent)
182        }
183        (Kind::GeminiGenerateContent, Kind::ClaudeMessages) => {
184            Ok(Pair::GeminiGenerateContentToClaudeMessages)
185        }
186        _ => Err(TransformError::unsupported_pair(source, target)),
187    }
188}
189
190fn resolve_provider_pair(
191    source: OperationKey,
192    target: OperationKey,
193    pair_fn: fn(Provider, Provider) -> Option<TransformPair>,
194) -> Result<TransformPair, TransformError> {
195    let OperationKind::Provider(source_provider) = source.kind() else {
196        return Err(TransformError::unsupported_pair(source, target));
197    };
198    let OperationKind::Provider(target_provider) = target.kind() else {
199        return Err(TransformError::unsupported_pair(source, target));
200    };
201
202    pair_fn(source_provider, target_provider)
203        .ok_or_else(|| TransformError::unsupported_pair(source, target))
204}
205
206fn count_tokens_pair(source: Provider, target: Provider) -> Option<TransformPair> {
207    provider_matrix(
208        source,
209        target,
210        ProviderMatrix {
211            openai_to_claude: TransformPair::OpenAiToClaudeCountTokens,
212            claude_to_openai: TransformPair::ClaudeToOpenAiCountTokens,
213            openai_to_gemini: TransformPair::OpenAiToGeminiCountTokens,
214            gemini_to_openai: TransformPair::GeminiToOpenAiCountTokens,
215            claude_to_gemini: TransformPair::ClaudeToGeminiCountTokens,
216            gemini_to_claude: TransformPair::GeminiToClaudeCountTokens,
217        },
218    )
219}
220
221fn models_pair(source: Provider, target: Provider) -> Option<TransformPair> {
222    provider_matrix(
223        source,
224        target,
225        ProviderMatrix {
226            openai_to_claude: TransformPair::OpenAiToClaudeModels,
227            claude_to_openai: TransformPair::ClaudeToOpenAiModels,
228            openai_to_gemini: TransformPair::OpenAiToGeminiModels,
229            gemini_to_openai: TransformPair::GeminiToOpenAiModels,
230            claude_to_gemini: TransformPair::ClaudeToGeminiModels,
231            gemini_to_claude: TransformPair::GeminiToClaudeModels,
232        },
233    )
234}
235
236fn embeddings_pair(source: Provider, target: Provider) -> Option<TransformPair> {
237    match (source, target) {
238        (Provider::OpenAi, Provider::Gemini) => Some(TransformPair::OpenAiToGeminiEmbeddings),
239        (Provider::Gemini, Provider::OpenAi) => Some(TransformPair::GeminiToOpenAiEmbeddings),
240        _ => None,
241    }
242}
243
244/// Resolve cross-operation image-generation routes. OpenAI has dedicated
245/// create/edit-image endpoints; Gemini services them through generate-content
246/// (image response modality), so it has no image operation of its own.
247fn resolve_image_generation(source: OperationKey, target: OperationKey) -> Option<TransformPair> {
248    use ContentGenerationKind as Kind;
249    use OperationKind as OK;
250
251    // OpenAI create/edit-image -> OpenAI Responses: codex-like channels have no
252    // images endpoint and generate/edit via the Responses `image_generation`
253    // tool. Each pair serves both directions (request: images->responses;
254    // response: responses->images), so resolve it for the reverse ordering too.
255    if let (OK::Provider(Provider::OpenAi), OK::ContentGeneration(Kind::OpenAiResponses)) =
256        (source.kind(), target.kind())
257        && target.operation().is_content_generation()
258    {
259        return match source.operation() {
260            Operation::CreateImage => Some(TransformPair::OpenAiCreateImageToOpenAiResponses),
261            Operation::EditImage => Some(TransformPair::OpenAiEditImageToOpenAiResponses),
262            _ => None,
263        };
264    }
265    if let (OK::ContentGeneration(Kind::OpenAiResponses), OK::Provider(Provider::OpenAi)) =
266        (source.kind(), target.kind())
267        && source.operation().is_content_generation()
268    {
269        return match target.operation() {
270            Operation::CreateImage => Some(TransformPair::OpenAiCreateImageToOpenAiResponses),
271            Operation::EditImage => Some(TransformPair::OpenAiEditImageToOpenAiResponses),
272            _ => None,
273        };
274    }
275
276    // OpenAI create/edit image -> Gemini generate-content.
277    if let (OK::Provider(Provider::OpenAi), OK::ContentGeneration(Kind::GeminiGenerateContent)) =
278        (source.kind(), target.kind())
279        && target.operation().is_content_generation()
280    {
281        return match source.operation() {
282            Operation::CreateImage => Some(TransformPair::OpenAiCreateImageToGemini),
283            Operation::EditImage => Some(TransformPair::OpenAiEditImageToGemini),
284            _ => None,
285        };
286    }
287
288    // Gemini generate-content -> OpenAI create/edit image.
289    if let (OK::ContentGeneration(Kind::GeminiGenerateContent), OK::Provider(Provider::OpenAi)) =
290        (source.kind(), target.kind())
291        && source.operation().is_content_generation()
292    {
293        return match target.operation() {
294            Operation::CreateImage => Some(TransformPair::GeminiToOpenAiCreateImage),
295            Operation::EditImage => Some(TransformPair::GeminiToOpenAiEditImage),
296            _ => None,
297        };
298    }
299
300    None
301}
302
303/// Resolve cross-operation compaction routes. Compaction is the only sanctioned
304/// cross-operation conversion: a generate-content payload folded into an OpenAI
305/// compact payload, or an OpenAI compact payload serviced by a generate-content
306/// upstream.
307fn resolve_compaction(source: OperationKey, target: OperationKey) -> Option<TransformPair> {
308    use ContentGenerationKind as Kind;
309    use OperationKind as OK;
310
311    // content-generation -> OpenAI compact.
312    if let (OK::ContentGeneration(kind), Operation::CompactContent, OK::Provider(Provider::OpenAi)) =
313        (source.kind(), target.operation(), target.kind())
314        && source.operation().is_content_generation()
315    {
316        return match kind {
317            Kind::OpenAiResponses => Some(TransformPair::OpenAiResponsesToOpenAiCompact),
318            Kind::OpenAiResponsesWebSocket => None,
319            Kind::GeminiGenerateContent => Some(TransformPair::GeminiToOpenAiCompact),
320            Kind::OpenAiChatCompletions => Some(TransformPair::OpenAiChatToOpenAiCompact),
321            Kind::ClaudeMessages => Some(TransformPair::ClaudeToOpenAiCompact),
322            _ => unreachable!(
323                "new non-exhaustive protocol variant requires a lockstep transform update"
324            ),
325        };
326    }
327
328    // OpenAI compact -> content-generation.
329    if let (OK::Provider(Provider::OpenAi), Operation::CompactContent) =
330        (source.kind(), source.operation())
331        && let OK::ContentGeneration(kind) = target.kind()
332        && target.operation().is_content_generation()
333    {
334        return match kind {
335            Kind::GeminiGenerateContent => Some(TransformPair::OpenAiCompactToGemini),
336            Kind::OpenAiChatCompletions => Some(TransformPair::OpenAiCompactToOpenAiChat),
337            Kind::ClaudeMessages => Some(TransformPair::OpenAiToClaudeCompact),
338            Kind::OpenAiResponses => Some(TransformPair::OpenAiCompactToOpenAiResponses),
339            Kind::OpenAiResponsesWebSocket => None,
340            _ => unreachable!(
341                "new non-exhaustive protocol variant requires a lockstep transform update"
342            ),
343        };
344    }
345
346    None
347}
348
349#[derive(Debug, Clone, Copy)]
350struct ProviderMatrix {
351    openai_to_claude: TransformPair,
352    claude_to_openai: TransformPair,
353    openai_to_gemini: TransformPair,
354    gemini_to_openai: TransformPair,
355    claude_to_gemini: TransformPair,
356    gemini_to_claude: TransformPair,
357}
358
359fn provider_matrix(
360    source: Provider,
361    target: Provider,
362    matrix: ProviderMatrix,
363) -> Option<TransformPair> {
364    match (source, target) {
365        (Provider::OpenAi, Provider::Claude) => Some(matrix.openai_to_claude),
366        (Provider::Claude, Provider::OpenAi) => Some(matrix.claude_to_openai),
367        (Provider::OpenAi, Provider::Gemini) => Some(matrix.openai_to_gemini),
368        (Provider::Gemini, Provider::OpenAi) => Some(matrix.gemini_to_openai),
369        (Provider::Claude, Provider::Gemini) => Some(matrix.claude_to_gemini),
370        (Provider::Gemini, Provider::Claude) => Some(matrix.gemini_to_claude),
371        _ => None,
372    }
373}
374
375#[cfg(test)]
376mod tests {
377    use super::*;
378
379    #[test]
380    fn edit_image_resolves_to_responses_image_tool_pair() {
381        let source = OperationKey::provider(Operation::EditImage, Provider::OpenAi);
382        let target = OperationKey::content_generation(
383            Operation::StreamGenerateContent,
384            ContentGenerationKind::OpenAiResponses,
385        );
386
387        assert_eq!(
388            resolve(source, target).unwrap(),
389            TransformPair::OpenAiEditImageToOpenAiResponses
390        );
391        assert_eq!(
392            resolve(target, source).unwrap(),
393            TransformPair::OpenAiEditImageToOpenAiResponses
394        );
395    }
396}