mod content;
mod other;
use serde::Serialize;
use serde::de::DeserializeOwned;
use super::{TransformContext, TransformError, TransformOutput, TransformPair};
pub fn is_wired(pair: TransformPair) -> bool {
content::is_content(pair) || other::is_wired(pair)
}
pub fn request_bytes(
pair: TransformPair,
ctx: &TransformContext,
body: &[u8],
) -> Result<Vec<u8>, TransformError> {
validate_pair(pair, ctx)?;
ctx.scope(|| {
if content::is_content(pair) {
content::request_bytes(pair, ctx, body)
} else {
other::request_bytes(pair, ctx, body)
}
})
}
pub fn request_bytes_detailed(
pair: TransformPair,
ctx: &TransformContext,
body: &[u8],
) -> Result<TransformOutput<Vec<u8>>, TransformError> {
let scoped = ctx.isolated();
let value = request_bytes(pair, &scoped, body)?;
Ok(TransformOutput::new(value, scoped.take_diagnostics()))
}
pub fn response_bytes(
pair: TransformPair,
ctx: &TransformContext,
body: &[u8],
) -> Result<Vec<u8>, TransformError> {
validate_pair(pair, ctx)?;
ctx.scope(|| {
if content::is_content(pair) {
content::response_bytes(pair, ctx, body)
} else {
other::response_bytes(pair, ctx, body)
}
})
}
pub fn response_bytes_detailed(
pair: TransformPair,
ctx: &TransformContext,
body: &[u8],
) -> Result<TransformOutput<Vec<u8>>, TransformError> {
let scoped = ctx.isolated();
let value = response_bytes(pair, &scoped, body)?;
Ok(TransformOutput::new(value, scoped.take_diagnostics()))
}
pub enum StreamEventOut {
Encoded { event: Option<String>, data: String },
Responses(Box<crate::protocol::openai::ResponseStreamEvent>),
}
pub(crate) struct StreamEventBatch {
pub events: Vec<StreamEventOut>,
pub terminal: bool,
}
pub struct StreamConverter {
inner: content::ContentStreamConverter,
}
impl StreamConverter {
pub fn new(pair: TransformPair, ctx: TransformContext) -> Result<Self, TransformError> {
validate_pair(pair, &ctx)?;
Ok(Self {
inner: content::ContentStreamConverter::new(pair, ctx)?,
})
}
pub fn push(&mut self, data: &str) -> Result<Vec<StreamEventOut>, TransformError> {
Ok(self.push_detailed(data)?.value)
}
pub fn push_detailed(
&mut self,
data: &str,
) -> Result<TransformOutput<Vec<StreamEventOut>>, TransformError> {
let output = self.push_detailed_with_status(data)?;
Ok(TransformOutput::new(
output.value.events,
output.diagnostics,
))
}
pub(crate) fn push_detailed_with_status(
&mut self,
data: &str,
) -> Result<TransformOutput<StreamEventBatch>, TransformError> {
let output = self.inner.push(data)?;
Ok(TransformOutput::new(
StreamEventBatch {
events: output.events,
terminal: output.terminal,
},
self.inner.take_diagnostics(),
))
}
pub fn finish(&mut self) -> Result<Vec<StreamEventOut>, TransformError> {
Ok(self.finish_detailed()?.value)
}
pub fn finish_detailed(
&mut self,
) -> Result<TransformOutput<Vec<StreamEventOut>>, TransformError> {
let value = self.inner.finish()?;
Ok(TransformOutput::new(value, self.inner.take_diagnostics()))
}
}
pub fn stream_event(
pair: TransformPair,
ctx: &TransformContext,
data: &str,
) -> Result<Vec<StreamEventOut>, TransformError> {
if content::is_content(pair) {
let mut converter = StreamConverter::new(pair, ctx.clone())?;
converter.push(data)
} else {
Err(not_wired(pair))
}
}
fn validate_pair(pair: TransformPair, ctx: &TransformContext) -> Result<(), TransformError> {
let resolved = super::resolve(ctx.source, ctx.target)?;
if resolved == pair {
Ok(())
} else {
Err(TransformError::InvalidInput {
reason: format!(
"transform pair {pair:?} does not match context {:?} -> {:?} (resolved {resolved:?})",
ctx.source, ctx.target
),
})
}
}
fn run<S, T>(
f: impl Fn(S, &TransformContext) -> Result<T, TransformError>,
ctx: &TransformContext,
body: &[u8],
) -> Result<Vec<u8>, TransformError>
where
S: DeserializeOwned,
T: Serialize,
{
let input: S = serde_json::from_slice(body).map_err(|e| TransformError::InvalidInput {
reason: format!("decode source body: {e}"),
})?;
let out = f(input, ctx)?;
serde_json::to_vec(&out).map_err(|e| TransformError::Serialization {
reason: e.to_string(),
})
}
fn run_ok<S, T>(
f: impl Fn(S, &TransformContext) -> T,
ctx: &TransformContext,
body: &[u8],
) -> Result<Vec<u8>, TransformError>
where
S: DeserializeOwned,
T: Serialize,
{
run(
|input, ctx| Ok::<_, TransformError>(f(input, ctx)),
ctx,
body,
)
}
fn not_wired(pair: TransformPair) -> TransformError {
TransformError::InvalidInput {
reason: format!("bytes dispatch not wired for {pair:?}"),
}
}
#[cfg(test)]
mod tests {
use serde_json::Value;
use super::*;
use crate::protocol::{ContentGenerationKind, Operation, OperationKey, Provider};
use crate::transform::TransformDiagnosticKind;
#[test]
fn claude_to_openai_chat_request_roundtrip() {
let source = OperationKey::content_generation(
Operation::GenerateContent,
ContentGenerationKind::ClaudeMessages,
);
let target = OperationKey::content_generation(
Operation::GenerateContent,
ContentGenerationKind::OpenAiChatCompletions,
);
let ctx = TransformContext::new(source, target);
let body = br#"{"model":"m","max_tokens":16,"messages":[{"role":"user","content":"hi"}]}"#;
let out = request_bytes(TransformPair::ClaudeMessagesToOpenAiChat, &ctx, body).unwrap();
let v: Value = serde_json::from_slice(&out).unwrap();
assert_eq!(v["messages"][0]["role"], "user");
assert!(v.get("max_tokens").is_some() || v.get("max_completion_tokens").is_some());
}
#[test]
fn detailed_request_returns_structured_semantic_loss() {
let source = OperationKey::content_generation(
Operation::GenerateContent,
ContentGenerationKind::OpenAiChatCompletions,
);
let target = OperationKey::content_generation(
Operation::GenerateContent,
ContentGenerationKind::ClaudeMessages,
);
let pair = crate::transform::resolve(source, target).unwrap();
let ctx = TransformContext::new(source, target);
let body = br#"{
"model":"m",
"messages":[{"role":"user","content":[{
"type":"text",
"text":"",
"prompt_cache_breakpoint":{"mode":"explicit"}
}]}]
}"#;
let output = request_bytes_detailed(pair, &ctx, body).unwrap();
assert_eq!(output.diagnostics.len(), 1);
assert_eq!(
output.diagnostics[0].kind,
TransformDiagnosticKind::LossyField
);
assert_eq!(
output.diagnostics[0].field,
"messages[].content[].text.prompt_cache_breakpoint"
);
assert!(ctx.diagnostics().is_empty(), "detailed calls are isolated");
}
#[test]
fn openai_responses_to_websocket_request_roundtrip() {
let source = OperationKey::content_generation(
Operation::GenerateContent,
ContentGenerationKind::OpenAiResponses,
);
let target = OperationKey::content_generation(
Operation::GenerateContent,
ContentGenerationKind::OpenAiResponsesWebSocket,
);
let pair = crate::transform::resolve(source, target).unwrap();
assert!(is_wired(pair));
let ctx = TransformContext::new(source, target);
let body = br#"{"model":"m","input":"hi","stream":true}"#;
let out = request_bytes(pair, &ctx, body).unwrap();
let v: Value = serde_json::from_slice(&out).unwrap();
assert_eq!(v["type"], "response.create");
assert_eq!(v["model"], "m");
assert_eq!(v["stream"], true);
}
#[test]
fn claude_to_openai_responses_websocket_request_roundtrip() {
let source = OperationKey::content_generation(
Operation::GenerateContent,
ContentGenerationKind::ClaudeMessages,
);
let target = OperationKey::content_generation(
Operation::GenerateContent,
ContentGenerationKind::OpenAiResponsesWebSocket,
);
let pair = crate::transform::resolve(source, target).unwrap();
assert!(is_wired(pair));
let ctx = TransformContext::new(source, target);
let body = br#"{"model":"m","max_tokens":16,"messages":[{"role":"user","content":"hi"}]}"#;
let out = request_bytes(pair, &ctx, body).unwrap();
let v: Value = serde_json::from_slice(&out).unwrap();
assert_eq!(v["type"], "response.create");
assert_eq!(v["model"], "m");
assert!(v.get("input").is_some());
}
#[test]
fn claude_to_openai_count_tokens_request_roundtrip() {
let source = OperationKey::provider(Operation::CountTokens, Provider::Claude);
let target = OperationKey::provider(Operation::CountTokens, Provider::OpenAi);
let ctx = TransformContext::new(source, target);
let body = br#"{"model":"m","messages":[{"role":"user","content":"hi"}]}"#;
let out = request_bytes(TransformPair::ClaudeToOpenAiCountTokens, &ctx, body).unwrap();
let v: Value = serde_json::from_slice(&out).unwrap();
assert_eq!(v["model"], "m");
assert!(v.get("input").is_some());
}
#[test]
fn compact_to_responses_is_resolved_and_wired() {
let source = OperationKey::provider(Operation::CompactContent, Provider::OpenAi);
let target = OperationKey::content_generation(
Operation::GenerateContent,
ContentGenerationKind::OpenAiResponses,
);
let pair = crate::transform::resolve(source, target).unwrap();
assert_eq!(pair, TransformPair::OpenAiCompactToOpenAiResponses);
assert!(is_wired(pair));
let ctx = TransformContext::new(source, target);
let body = br#"{"model":"m","input":"summarize this"}"#;
let out = request_bytes(pair, &ctx, body).unwrap();
let value: Value = serde_json::from_slice(&out).unwrap();
assert_eq!(value["model"], "m");
assert!(value.get("input").is_some());
}
#[test]
fn openai_to_claude_models_list_response_roundtrip() {
let source = OperationKey::provider(Operation::ListModels, Provider::OpenAi);
let target = OperationKey::provider(Operation::ListModels, Provider::Claude);
let ctx = TransformContext::new(source, target);
let body = br#"{"object":"list","data":[{"id":"gpt-x","created":1,"object":"model","owned_by":"openai"}]}"#;
let out = response_bytes(TransformPair::OpenAiToClaudeModels, &ctx, body).unwrap();
let v: Value = serde_json::from_slice(&out).unwrap();
assert_eq!(v["data"][0]["id"], "gpt-x");
}
}