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