Skip to main content

gproxy_transform/transform/dispatch/
mod.rs

1//! Bytes-level dispatch from a resolved [`TransformPair`] to its typed pair
2//! functions. [`content`] holds the 12 content-generation pairs (M2);
3//! [`other`] holds count_tokens/models/embeddings/images/compact (M2.5).
4//! Streaming is wired for content pairs only.
5
6mod content;
7mod other;
8
9use serde::Serialize;
10use serde::de::DeserializeOwned;
11
12use super::{TransformContext, TransformError, TransformPair};
13
14/// Whether the bytes dispatch has arms for this pair.
15pub fn is_wired(pair: TransformPair) -> bool {
16    content::is_content(pair) || other::is_wired(pair)
17}
18
19/// Convert a request body (inbound wire JSON → upstream wire JSON).
20pub fn request_bytes(
21    pair: TransformPair,
22    ctx: &TransformContext,
23    body: &[u8],
24) -> Result<Vec<u8>, TransformError> {
25    if content::is_content(pair) {
26        content::request_bytes(pair, ctx, body)
27    } else {
28        other::request_bytes(pair, ctx, body)
29    }
30}
31
32/// Convert a response body (upstream wire JSON → inbound wire JSON). The pair
33/// here is the REVERSE pair (`resolve(upstream_key, inbound_key)`).
34pub fn response_bytes(
35    pair: TransformPair,
36    ctx: &TransformContext,
37    body: &[u8],
38) -> Result<Vec<u8>, TransformError> {
39    if content::is_content(pair) {
40        content::response_bytes(pair, ctx, body)
41    } else {
42        other::response_bytes(pair, ctx, body)
43    }
44}
45
46/// One converted stream event: pre-encoded inbound frame payload, or the
47/// typed Responses event when the inbound side runs the aggregation state
48/// machine.
49pub enum StreamEventOut {
50    Encoded { event: Option<String>, data: String },
51    Responses(Box<crate::protocol::openai::ResponseStreamEvent>),
52}
53
54/// Convert one decoded stream event (upstream wire JSON text → inbound event).
55/// Same reverse-pair convention as [`response_bytes`]. Only content-generation
56/// pairs stream; the other groups are buffered.
57pub fn stream_event(
58    pair: TransformPair,
59    ctx: &TransformContext,
60    data: &str,
61) -> Result<StreamEventOut, TransformError> {
62    if content::is_content(pair) {
63        content::stream_event(pair, ctx, data)
64    } else {
65        Err(not_wired(pair))
66    }
67}
68
69fn run<S, T>(
70    f: impl Fn(S, &TransformContext) -> Result<T, TransformError>,
71    ctx: &TransformContext,
72    body: &[u8],
73) -> Result<Vec<u8>, TransformError>
74where
75    S: DeserializeOwned,
76    T: Serialize,
77{
78    let input: S = serde_json::from_slice(body).map_err(|e| TransformError::InvalidInput {
79        reason: format!("decode source body: {e}"),
80    })?;
81    let out = f(input, ctx)?;
82    serde_json::to_vec(&out).map_err(|e| TransformError::Serialization {
83        reason: e.to_string(),
84    })
85}
86
87/// [`run`] for infallible pair functions (plain return, no `Result`).
88fn run_ok<S, T>(
89    f: impl Fn(S, &TransformContext) -> T,
90    ctx: &TransformContext,
91    body: &[u8],
92) -> Result<Vec<u8>, TransformError>
93where
94    S: DeserializeOwned,
95    T: Serialize,
96{
97    run(
98        |input, ctx| Ok::<_, TransformError>(f(input, ctx)),
99        ctx,
100        body,
101    )
102}
103
104fn not_wired(pair: TransformPair) -> TransformError {
105    TransformError::InvalidInput {
106        reason: format!("bytes dispatch not wired for {pair:?}"),
107    }
108}
109
110#[cfg(test)]
111mod tests {
112    use serde_json::Value;
113
114    use super::*;
115    use crate::protocol::{ContentGenerationKind, Operation, OperationKey, Provider};
116
117    #[test]
118    fn claude_to_openai_chat_request_roundtrip() {
119        let source = OperationKey::content_generation(
120            Operation::GenerateContent,
121            ContentGenerationKind::ClaudeMessages,
122        );
123        let target = OperationKey::content_generation(
124            Operation::GenerateContent,
125            ContentGenerationKind::OpenAiChatCompletions,
126        );
127        let ctx = TransformContext::new(source, target);
128        let body = br#"{"model":"m","max_tokens":16,"messages":[{"role":"user","content":"hi"}]}"#;
129        let out = request_bytes(TransformPair::ClaudeMessagesToOpenAiChat, &ctx, body).unwrap();
130        let v: Value = serde_json::from_slice(&out).unwrap();
131        assert_eq!(v["messages"][0]["role"], "user");
132        assert!(v.get("max_tokens").is_some() || v.get("max_completion_tokens").is_some());
133    }
134
135    #[test]
136    fn openai_responses_to_websocket_request_roundtrip() {
137        let source = OperationKey::content_generation(
138            Operation::GenerateContent,
139            ContentGenerationKind::OpenAiResponses,
140        );
141        let target = OperationKey::content_generation(
142            Operation::GenerateContent,
143            ContentGenerationKind::OpenAiResponsesWebSocket,
144        );
145        let pair = crate::transform::resolve(source, target).unwrap();
146        assert!(is_wired(pair));
147
148        let ctx = TransformContext::new(source, target);
149        let body = br#"{"model":"m","input":"hi","stream":true}"#;
150        let out = request_bytes(pair, &ctx, body).unwrap();
151        let v: Value = serde_json::from_slice(&out).unwrap();
152
153        assert_eq!(v["type"], "response.create");
154        assert_eq!(v["model"], "m");
155        assert_eq!(v["stream"], true);
156    }
157
158    #[test]
159    fn claude_to_openai_responses_websocket_request_roundtrip() {
160        let source = OperationKey::content_generation(
161            Operation::GenerateContent,
162            ContentGenerationKind::ClaudeMessages,
163        );
164        let target = OperationKey::content_generation(
165            Operation::GenerateContent,
166            ContentGenerationKind::OpenAiResponsesWebSocket,
167        );
168        let pair = crate::transform::resolve(source, target).unwrap();
169        assert!(is_wired(pair));
170
171        let ctx = TransformContext::new(source, target);
172        let body = br#"{"model":"m","max_tokens":16,"messages":[{"role":"user","content":"hi"}]}"#;
173        let out = request_bytes(pair, &ctx, body).unwrap();
174        let v: Value = serde_json::from_slice(&out).unwrap();
175
176        assert_eq!(v["type"], "response.create");
177        assert_eq!(v["model"], "m");
178        assert!(v.get("input").is_some());
179    }
180
181    #[test]
182    fn claude_to_openai_count_tokens_request_roundtrip() {
183        let source = OperationKey::provider(Operation::CountTokens, Provider::Claude);
184        let target = OperationKey::provider(Operation::CountTokens, Provider::OpenAi);
185        let ctx = TransformContext::new(source, target);
186        let body = br#"{"model":"m","messages":[{"role":"user","content":"hi"}]}"#;
187        let out = request_bytes(TransformPair::ClaudeToOpenAiCountTokens, &ctx, body).unwrap();
188        let v: Value = serde_json::from_slice(&out).unwrap();
189        assert_eq!(v["model"], "m");
190        assert!(v.get("input").is_some());
191    }
192
193    #[test]
194    fn compact_to_responses_is_resolved_and_wired() {
195        let source = OperationKey::provider(Operation::CompactContent, Provider::OpenAi);
196        let target = OperationKey::content_generation(
197            Operation::GenerateContent,
198            ContentGenerationKind::OpenAiResponses,
199        );
200        let pair = crate::transform::resolve(source, target).unwrap();
201        assert_eq!(pair, TransformPair::OpenAiCompactToOpenAiResponses);
202        assert!(is_wired(pair));
203
204        let ctx = TransformContext::new(source, target);
205        let body = br#"{"model":"m","input":"summarize this"}"#;
206        let out = request_bytes(pair, &ctx, body).unwrap();
207        let value: Value = serde_json::from_slice(&out).unwrap();
208        assert_eq!(value["model"], "m");
209        assert!(value.get("input").is_some());
210    }
211
212    #[test]
213    fn openai_to_claude_models_list_response_roundtrip() {
214        let source = OperationKey::provider(Operation::ListModels, Provider::OpenAi);
215        let target = OperationKey::provider(Operation::ListModels, Provider::Claude);
216        let ctx = TransformContext::new(source, target);
217        let body = br#"{"object":"list","data":[{"id":"gpt-x","created":1,"object":"model","owned_by":"openai"}]}"#;
218        let out = response_bytes(TransformPair::OpenAiToClaudeModels, &ctx, body).unwrap();
219        let v: Value = serde_json::from_slice(&out).unwrap();
220        assert_eq!(v["data"][0]["id"], "gpt-x");
221    }
222}