From e8046872a0376659f02a566d4cf15ec007fbc54d Mon Sep 17 00:00:00 2001 From: Alex Z Date: Thu, 21 May 2026 15:53:31 -0700 Subject: [PATCH 1/9] accept universal format --- bindings/python/src/lingua/__init__.pyi | 2 +- .../src/generated/ProviderFormat.ts | 2 +- .../src/catalog/resolver.rs | 1 + .../src/providers/mod.rs | 1 + crates/braintrust-llm-router/tests/router.rs | 77 +++++++++++++++++++ crates/lingua/src/capabilities/format.rs | 4 + crates/lingua/src/processing/adapters.rs | 49 +++++++++++- crates/lingua/src/processing/transform.rs | 52 +++++++++++++ crates/lingua/src/universal/request.rs | 16 ++-- crates/lingua/src/universal/response.rs | 63 +++++++++------ 10 files changed, 233 insertions(+), 34 deletions(-) diff --git a/bindings/python/src/lingua/__init__.pyi b/bindings/python/src/lingua/__init__.pyi index 175f0d52..b04cb38c 100644 --- a/bindings/python/src/lingua/__init__.pyi +++ b/bindings/python/src/lingua/__init__.pyi @@ -8,7 +8,7 @@ from typing_extensions import TypedDict # Provider format # ============================================================================ -ProviderFormat = Literal["openai", "anthropic", "google", "mistral", "converse", "responses", "unknown"] +ProviderFormat = Literal["openai", "anthropic", "google", "mistral", "converse", "responses", "universal", "unknown"] # ============================================================================ diff --git a/bindings/typescript/src/generated/ProviderFormat.ts b/bindings/typescript/src/generated/ProviderFormat.ts index 79d0809e..5dc9ea18 100644 --- a/bindings/typescript/src/generated/ProviderFormat.ts +++ b/bindings/typescript/src/generated/ProviderFormat.ts @@ -9,4 +9,4 @@ * 2. Update detection heuristics in `processing/detect.rs` * 3. Add conversion logic in `providers//convert.rs` if needed */ -export type ProviderFormat = "openai" | "anthropic" | "google" | "mistral" | "converse" | "responses" | "unknown"; +export type ProviderFormat = "openai" | "anthropic" | "google" | "mistral" | "converse" | "responses" | "universal" | "unknown"; diff --git a/crates/braintrust-llm-router/src/catalog/resolver.rs b/crates/braintrust-llm-router/src/catalog/resolver.rs index 519eaafe..432ec794 100644 --- a/crates/braintrust-llm-router/src/catalog/resolver.rs +++ b/crates/braintrust-llm-router/src/catalog/resolver.rs @@ -87,6 +87,7 @@ fn format_identifier(format: ProviderFormat) -> String { ProviderFormat::Mistral => "mistral", ProviderFormat::Converse => "bedrock", ProviderFormat::Responses => "openai", // Responses API uses OpenAI provider + ProviderFormat::Universal => "universal", ProviderFormat::Unknown => "unknown", } .to_string() diff --git a/crates/braintrust-llm-router/src/providers/mod.rs b/crates/braintrust-llm-router/src/providers/mod.rs index f9f84872..5f9d21eb 100644 --- a/crates/braintrust-llm-router/src/providers/mod.rs +++ b/crates/braintrust-llm-router/src/providers/mod.rs @@ -202,6 +202,7 @@ pub(crate) fn enable_streaming_payload(payload: Bytes, format: ProviderFormat) - | ProviderFormat::Converse | ProviderFormat::BedrockAnthropic | ProviderFormat::VertexAnthropic + | ProviderFormat::Universal | ProviderFormat::Unknown => return payload, } diff --git a/crates/braintrust-llm-router/tests/router.rs b/crates/braintrust-llm-router/tests/router.rs index d8ad9878..c852d558 100644 --- a/crates/braintrust-llm-router/tests/router.rs +++ b/crates/braintrust-llm-router/tests/router.rs @@ -152,6 +152,83 @@ async fn router_routes_to_stub_provider() { assert!(response.get("choices").is_some()); } +#[tokio::test] +async fn router_accepts_universal_request_input() { + let mut catalog = ModelCatalog::empty(); + catalog.insert( + "stub-model".into(), + ModelSpec { + model: "stub-model".into(), + format: ProviderFormat::ChatCompletions, + flavor: ModelFlavor::Chat, + display_name: None, + parent: None, + input_cost_per_mil_tokens: None, + output_cost_per_mil_tokens: None, + input_cache_read_cost_per_mil_tokens: None, + multimodal: None, + reasoning: None, + max_input_tokens: None, + max_output_tokens: None, + supports_streaming: true, + extra: Default::default(), + available_providers: Default::default(), + }, + ); + let catalog = Arc::new(catalog); + + let router = RouterBuilder::new() + .with_catalog(Arc::clone(&catalog)) + .with_retry_policy(RetryPolicy::default()) + .add_provider( + "stub", + StubProvider, + AuthConfig::ApiKey { + key: "test".into(), + header: Some("authorization".into()), + prefix: Some("Bearer".into()), + }, + vec![ProviderFormat::ChatCompletions], + ) + .build() + .expect("router builds"); + + let body = to_body(json!({ + "model": "stub-model", + "messages": [{"role": "user", "content": "Ping"}], + "params": { + "temperature": 0.2, + "top_p": null, + "top_k": null, + "seed": null, + "presence_penalty": null, + "frequency_penalty": null, + "token_budget": null, + "stop": null, + "logprobs": null, + "top_logprobs": null, + "tools": null, + "tool_choice": null, + "parallel_tool_calls": null, + "response_format": null, + "reasoning": null, + "metadata": null, + "store": null, + "conversation_reference": null, + "service_tier": null, + "stream": null + } + })); + + let (_request, metadata) = router + .create_request(body, "stub-model", ProviderFormat::ChatCompletions) + .await + .expect("create request"); + + assert_eq!(metadata.detected_input_format, ProviderFormat::Universal); + assert_eq!(metadata.provider_format, ProviderFormat::ChatCompletions); +} + #[tokio::test] async fn router_resolves_provider_alias_in_metadata() { let mut catalog = ModelCatalog::empty(); diff --git a/crates/lingua/src/capabilities/format.rs b/crates/lingua/src/capabilities/format.rs index aeddf783..7a07fd98 100644 --- a/crates/lingua/src/capabilities/format.rs +++ b/crates/lingua/src/capabilities/format.rs @@ -31,6 +31,8 @@ pub enum ProviderFormat { Converse, /// OpenAI Responses API format (for reasoning models like o1-pro, o3) Responses, + /// Lingua universal request format + Universal, /// Internal-only format for Bedrock Anthropic invoke envelope handling. #[serde(skip_serializing, skip_deserializing)] #[ts(skip)] @@ -61,6 +63,7 @@ impl std::fmt::Display for ProviderFormat { ProviderFormat::Mistral => "mistral", ProviderFormat::Converse => "converse", ProviderFormat::Responses => "responses", + ProviderFormat::Universal => "universal", ProviderFormat::BedrockAnthropic => "bedrock_anthropic", ProviderFormat::VertexAnthropic => "vertex_anthropic", ProviderFormat::Unknown => "unknown", @@ -80,6 +83,7 @@ impl std::str::FromStr for ProviderFormat { "mistral" => Ok(ProviderFormat::Mistral), "converse" | "bedrock" => Ok(ProviderFormat::Converse), "responses" => Ok(ProviderFormat::Responses), + "universal" => Ok(ProviderFormat::Universal), _ => Err(()), } } diff --git a/crates/lingua/src/processing/adapters.rs b/crates/lingua/src/processing/adapters.rs index a3fcddb1..aa295fed 100644 --- a/crates/lingua/src/processing/adapters.rs +++ b/crates/lingua/src/processing/adapters.rs @@ -16,7 +16,7 @@ use std::sync::LazyLock; use crate::capabilities::ProviderFormat; use crate::processing::transform::TransformError; -use crate::serde_json::{Map, Number, Value}; +use crate::serde_json::{self, Map, Number, Value}; use crate::universal::{UniversalRequest, UniversalResponse, UniversalStreamChunk}; /// Trait for provider-specific request and response handling. @@ -128,6 +128,51 @@ pub trait ProviderAdapter: Send + Sync { } } +struct UniversalAdapter; + +impl ProviderAdapter for UniversalAdapter { + fn format(&self) -> ProviderFormat { + ProviderFormat::Universal + } + + fn directory_name(&self) -> &'static str { + "universal" + } + + fn display_name(&self) -> &'static str { + "Universal" + } + + fn detect_request(&self, payload: &Value) -> bool { + serde_json::from_value::(payload.clone()).is_ok() + } + + fn request_to_universal(&self, payload: Value) -> Result { + serde_json::from_value(payload) + .map_err(|e| TransformError::ToUniversalFailed(e.to_string())) + } + + fn request_from_universal(&self, req: &UniversalRequest) -> Result { + serde_json::to_value(req).map_err(|e| TransformError::FromUniversalFailed(e.to_string())) + } + + fn detect_response(&self, _payload: &Value) -> bool { + false + } + + fn response_to_universal(&self, _payload: Value) -> Result { + Err(TransformError::UnsupportedSourceFormat( + ProviderFormat::Universal, + )) + } + + fn response_from_universal(&self, _resp: &UniversalResponse) -> Result { + Err(TransformError::UnsupportedTargetFormat( + ProviderFormat::Universal, + )) + } +} + // ============================================================================ // Helper functions for adapter implementations // ============================================================================ @@ -195,6 +240,8 @@ static ADAPTERS: LazyLock>> = LazyLock::new(|| { let mut list: Vec> = Vec::new(); // Note: Order matters for detection - more specific formats first + list.push(Box::new(UniversalAdapter)); + #[cfg(feature = "openai")] list.push(Box::new(crate::providers::openai::ResponsesAdapter)); diff --git a/crates/lingua/src/processing/transform.rs b/crates/lingua/src/processing/transform.rs index 6eaf0141..c57f96ad 100644 --- a/crates/lingua/src/processing/transform.rs +++ b/crates/lingua/src/processing/transform.rs @@ -761,6 +761,58 @@ mod tests { assert_eq!(output.as_ptr(), input_ptr); } + #[test] + #[cfg(feature = "openai")] + fn test_transform_request_universal_to_openai() { + let payload = json!({ + "model": "gpt-4", + "messages": [{"role": "user", "content": "Hello"}], + "params": { + "temperature": 0.2, + "top_p": null, + "top_k": null, + "seed": null, + "presence_penalty": null, + "frequency_penalty": null, + "token_budget": null, + "stop": null, + "logprobs": null, + "top_logprobs": null, + "tools": null, + "tool_choice": null, + "parallel_tool_calls": null, + "response_format": null, + "reasoning": null, + "metadata": null, + "store": null, + "conversation_reference": null, + "service_tier": null, + "stream": null + } + }); + let input = to_bytes(&payload); + + let result = transform_request(input, ProviderFormat::ChatCompletions, None).unwrap(); + + match result { + TransformResult::Transformed { + bytes, + source_format, + actual_target_format, + } => { + assert_eq!(source_format, ProviderFormat::Universal); + assert_eq!(actual_target_format, ProviderFormat::ChatCompletions); + let payload: Value = serde_json::from_slice(&bytes).unwrap(); + assert_eq!( + payload.get("temperature").and_then(Value::as_f64), + Some(0.2) + ); + assert!(payload.get("params").is_none()); + } + TransformResult::PassThrough(_) => panic!("universal input should transform"), + } + } + #[test] #[cfg(feature = "openai")] fn test_transform_request_passthrough_repairs_lone_surrogate() { diff --git a/crates/lingua/src/universal/request.rs b/crates/lingua/src/universal/request.rs index d525dd9a..e628c9ee 100644 --- a/crates/lingua/src/universal/request.rs +++ b/crates/lingua/src/universal/request.rs @@ -46,7 +46,8 @@ use crate::universal::tools::UniversalTool; /// Universal request envelope for LLM API calls. /// /// This type captures the common structure across all provider request formats. -#[derive(Debug, Clone, Serialize, TS)] +#[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[serde(deny_unknown_fields)] #[ts(export)] pub struct UniversalRequest { /// Model identifier (may be None for providers that use endpoint-based model selection) @@ -82,7 +83,8 @@ pub enum TokenBudget { /// /// Uses canonical names - adapters handle mapping to provider-specific names. /// Provider-specific fields without canonical mappings are stored in `extras`. -#[derive(Debug, Clone, Default, Serialize, TS)] +#[derive(Debug, Clone, Default, Serialize, Deserialize, TS)] +#[serde(deny_unknown_fields)] #[ts(export)] pub struct UniversalParams { // === Sampling parameters === @@ -486,7 +488,7 @@ impl AsRef for SummaryMode { /// - OpenAI Chat: `"auto"` | `"none"` | `"required"` | `{ type: "function", function: { name } }` /// - OpenAI Responses: `"auto"` | `{ type: "function", name }` /// - Anthropic: `{ type: "auto" | "any" | "none" | "tool", name?, disable_parallel_tool_use? }` -#[derive(Debug, Clone, Default, Serialize, TS)] +#[derive(Debug, Clone, Default, Serialize, Deserialize, TS)] #[ts(export)] pub struct ToolChoiceConfig { /// Selection mode - the semantic intent of the tool choice @@ -497,7 +499,7 @@ pub struct ToolChoiceConfig { } /// Tool selection mode (portable across providers). -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, TS)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, TS)] #[ts(export)] pub enum ToolChoiceMode { /// Provider decides whether to use tools @@ -572,7 +574,7 @@ impl AsRef for ToolChoiceMode { /// - OpenAI Responses: nested under `text.format` /// - Google: `response_mime_type` + `response_schema` /// - Anthropic: `{ type: "json_schema", schema, name?, strict?, description? }` -#[derive(Debug, Clone, Default, Serialize, TS)] +#[derive(Debug, Clone, Default, Serialize, Deserialize, TS)] #[ts(export)] pub struct ResponseFormatConfig { /// Output format type @@ -583,7 +585,7 @@ pub struct ResponseFormatConfig { } /// Response format type (portable across providers). -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, TS)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, TS)] #[ts(export)] pub enum ResponseFormatType { /// Plain text output (default) @@ -634,7 +636,7 @@ impl AsRef for ResponseFormatType { } /// JSON schema configuration for structured output. -#[derive(Debug, Clone, Serialize, TS)] +#[derive(Debug, Clone, Serialize, Deserialize, TS)] #[ts(export)] pub struct JsonSchemaConfig { /// Schema name (required by OpenAI) diff --git a/crates/lingua/src/universal/response.rs b/crates/lingua/src/universal/response.rs index 0d20525d..eaceb1d7 100644 --- a/crates/lingua/src/universal/response.rs +++ b/crates/lingua/src/universal/response.rs @@ -186,13 +186,19 @@ impl FinishReason { (Self::Stop, ProviderFormat::Responses) => "completed", ( Self::Stop, - ProviderFormat::ChatCompletions | ProviderFormat::Mistral | ProviderFormat::Unknown, + ProviderFormat::ChatCompletions + | ProviderFormat::Mistral + | ProviderFormat::Universal + | ProviderFormat::Unknown, ) => "stop", // Length variants ( Self::Length, - ProviderFormat::ChatCompletions | ProviderFormat::Mistral | ProviderFormat::Unknown, + ProviderFormat::ChatCompletions + | ProviderFormat::Mistral + | ProviderFormat::Universal + | ProviderFormat::Unknown, ) => "length", (Self::Length, ProviderFormat::Responses) => "incomplete", (Self::Length, ProviderFormat::Google) => "MAX_TOKENS", @@ -216,7 +222,10 @@ impl FinishReason { (Self::ToolCalls, ProviderFormat::Responses) => "completed", // Tool calls also complete ( Self::ToolCalls, - ProviderFormat::ChatCompletions | ProviderFormat::Mistral | ProviderFormat::Unknown, + ProviderFormat::ChatCompletions + | ProviderFormat::Mistral + | ProviderFormat::Universal + | ProviderFormat::Unknown, ) => "tool_calls", // ContentFilter variants @@ -230,6 +239,7 @@ impl FinishReason { | ProviderFormat::BedrockAnthropic | ProviderFormat::VertexAnthropic | ProviderFormat::Mistral + | ProviderFormat::Universal | ProviderFormat::Unknown, ) => "content_filter", @@ -263,9 +273,10 @@ impl UniversalResponse { ProviderFormat::Anthropic => "msg_", ProviderFormat::BedrockAnthropic => "msg_bdrk_", ProviderFormat::VertexAnthropic => "msg_vrtx_", - ProviderFormat::ChatCompletions | ProviderFormat::Mistral | ProviderFormat::Unknown => { - "chatcmpl-" - } + ProviderFormat::ChatCompletions + | ProviderFormat::Mistral + | ProviderFormat::Universal + | ProviderFormat::Unknown => "chatcmpl-", ProviderFormat::Responses => "resp_", ProviderFormat::Google => "resp_", ProviderFormat::Converse => "msg_", @@ -298,23 +309,24 @@ impl UniversalUsage { pub fn from_provider_value(usage: &Value, provider: ProviderFormat) -> Self { match provider { // OpenAI, Mistral, and Unknown use OpenAI format - ProviderFormat::ChatCompletions | ProviderFormat::Mistral | ProviderFormat::Unknown => { - Self { - prompt_tokens: usage.get("prompt_tokens").and_then(Value::as_i64), - completion_tokens: usage.get("completion_tokens").and_then(Value::as_i64), - prompt_cached_tokens: usage - .get("prompt_tokens_details") - .and_then(|d| d.get("cached_tokens")) - .and_then(Value::as_i64), - prompt_cache_creation_tokens: None, // OpenAI doesn't report cache creation tokens - // Treat 0 as None: 0 reasoning tokens means "no reasoning" = semantically None - completion_reasoning_tokens: usage - .get("completion_tokens_details") - .and_then(|d| d.get("reasoning_tokens")) - .and_then(Value::as_i64) - .filter(|&v| v > 0), - } - } + ProviderFormat::ChatCompletions + | ProviderFormat::Mistral + | ProviderFormat::Universal + | ProviderFormat::Unknown => Self { + prompt_tokens: usage.get("prompt_tokens").and_then(Value::as_i64), + completion_tokens: usage.get("completion_tokens").and_then(Value::as_i64), + prompt_cached_tokens: usage + .get("prompt_tokens_details") + .and_then(|d| d.get("cached_tokens")) + .and_then(Value::as_i64), + prompt_cache_creation_tokens: None, // OpenAI doesn't report cache creation tokens + // Treat 0 as None: 0 reasoning tokens means "no reasoning" = semantically None + completion_reasoning_tokens: usage + .get("completion_tokens_details") + .and_then(|d| d.get("reasoning_tokens")) + .and_then(Value::as_i64) + .filter(|&v| v > 0), + }, ProviderFormat::Responses => Self { prompt_tokens: usage.get("input_tokens").and_then(Value::as_i64), completion_tokens: usage.get("output_tokens").and_then(Value::as_i64), @@ -372,7 +384,10 @@ impl UniversalUsage { match provider { // OpenAI, Mistral, and Unknown use OpenAI format - ProviderFormat::ChatCompletions | ProviderFormat::Mistral | ProviderFormat::Unknown => { + ProviderFormat::ChatCompletions + | ProviderFormat::Mistral + | ProviderFormat::Universal + | ProviderFormat::Unknown => { let mut map = serde_json::Map::new(); map.insert("prompt_tokens".into(), serde_json::json!(prompt)); map.insert("completion_tokens".into(), serde_json::json!(completion)); From 47198f94eb78fd7c50f5a9a350492129f87ae979 Mon Sep 17 00:00:00 2001 From: Alex Z Date: Thu, 21 May 2026 16:01:24 -0700 Subject: [PATCH 2/9] CI --- crates/lingua/src/universal/response.rs | 44 ++++++++++++------------- 1 file changed, 22 insertions(+), 22 deletions(-) diff --git a/crates/lingua/src/universal/response.rs b/crates/lingua/src/universal/response.rs index eaceb1d7..07e91687 100644 --- a/crates/lingua/src/universal/response.rs +++ b/crates/lingua/src/universal/response.rs @@ -308,25 +308,27 @@ impl UniversalUsage { /// - Mistral: uses OpenAI format pub fn from_provider_value(usage: &Value, provider: ProviderFormat) -> Self { match provider { + ProviderFormat::Universal => { + Self::from_provider_value(usage, ProviderFormat::ChatCompletions) + } // OpenAI, Mistral, and Unknown use OpenAI format - ProviderFormat::ChatCompletions - | ProviderFormat::Mistral - | ProviderFormat::Universal - | ProviderFormat::Unknown => Self { - prompt_tokens: usage.get("prompt_tokens").and_then(Value::as_i64), - completion_tokens: usage.get("completion_tokens").and_then(Value::as_i64), - prompt_cached_tokens: usage - .get("prompt_tokens_details") - .and_then(|d| d.get("cached_tokens")) - .and_then(Value::as_i64), - prompt_cache_creation_tokens: None, // OpenAI doesn't report cache creation tokens - // Treat 0 as None: 0 reasoning tokens means "no reasoning" = semantically None - completion_reasoning_tokens: usage - .get("completion_tokens_details") - .and_then(|d| d.get("reasoning_tokens")) - .and_then(Value::as_i64) - .filter(|&v| v > 0), - }, + ProviderFormat::ChatCompletions | ProviderFormat::Mistral | ProviderFormat::Unknown => { + Self { + prompt_tokens: usage.get("prompt_tokens").and_then(Value::as_i64), + completion_tokens: usage.get("completion_tokens").and_then(Value::as_i64), + prompt_cached_tokens: usage + .get("prompt_tokens_details") + .and_then(|d| d.get("cached_tokens")) + .and_then(Value::as_i64), + prompt_cache_creation_tokens: None, // OpenAI doesn't report cache creation tokens + // Treat 0 as None: 0 reasoning tokens means "no reasoning" = semantically None + completion_reasoning_tokens: usage + .get("completion_tokens_details") + .and_then(|d| d.get("reasoning_tokens")) + .and_then(Value::as_i64) + .filter(|&v| v > 0), + } + } ProviderFormat::Responses => Self { prompt_tokens: usage.get("input_tokens").and_then(Value::as_i64), completion_tokens: usage.get("output_tokens").and_then(Value::as_i64), @@ -383,11 +385,9 @@ impl UniversalUsage { let completion = self.completion_tokens.unwrap_or(0); match provider { + ProviderFormat::Universal => self.to_provider_value(ProviderFormat::ChatCompletions), // OpenAI, Mistral, and Unknown use OpenAI format - ProviderFormat::ChatCompletions - | ProviderFormat::Mistral - | ProviderFormat::Universal - | ProviderFormat::Unknown => { + ProviderFormat::ChatCompletions | ProviderFormat::Mistral | ProviderFormat::Unknown => { let mut map = serde_json::Map::new(); map.insert("prompt_tokens".into(), serde_json::json!(prompt)); map.insert("completion_tokens".into(), serde_json::json!(completion)); From a0e218f442b5c4c91b6e511997554d7a0d989485 Mon Sep 17 00:00:00 2001 From: Alex Z Date: Fri, 22 May 2026 15:15:57 -0700 Subject: [PATCH 3/9] changes --- bindings/typescript/src/converters.ts | 66 +++++++++++++++++++ bindings/typescript/src/wasm.ts | 4 ++ .../typescript/tests/browser-exports.test.ts | 51 ++++++++++++++ .../typescript/tests/node-exports.test.ts | 47 +++++++++++++ crates/lingua/src/processing/adapters.rs | 6 +- crates/lingua/src/processing/transform.rs | 37 +++++++++++ 6 files changed, 207 insertions(+), 4 deletions(-) diff --git a/bindings/typescript/src/converters.ts b/bindings/typescript/src/converters.ts index b9086c93..4f3fbc9c 100644 --- a/bindings/typescript/src/converters.ts +++ b/bindings/typescript/src/converters.ts @@ -10,6 +10,7 @@ import { getWasm } from "./wasm-runtime"; import type { Message } from "./generated/Message"; +import type { ProviderFormat } from "./generated/ProviderFormat"; import type { ChatCompletionRequestMessage } from "./generated/openai/ChatCompletionRequestMessage"; import type { InputItem } from "./generated/openai/InputItem"; import type { InputMessage } from "./generated/anthropic/InputMessage"; @@ -27,6 +28,19 @@ type GoogleWasmExports = { lingua_to_google_contents: (value: unknown) => unknown; }; +export type TransformRequestResult = + | { + passThrough: true; + data: TData; + } + | { + transformed: true; + data: TData; + sourceFormat: ProviderFormat; + }; + +export type TransformResponseResult = TransformRequestResult; + // ============================================================================ // Error handling // ============================================================================ @@ -409,6 +423,58 @@ export function importAndDeduplicateMessages( } } +/** + * Transform a request payload to a target provider format. + * + * @param json - Source request JSON string. + * @param targetFormat - Target provider format, including "universal". + * @param model - Optional model override applied during transformation. + * @returns Transform result containing either pass-through or transformed data. + * @throws {ConversionError} If transformation fails. + */ +export function transformRequest( + json: string, + targetFormat: ProviderFormat, + model?: string | null +): TransformRequestResult { + try { + const result = getWasm().transform_request(json, targetFormat, model); + return convertMapsToObjects(result) as TransformRequestResult; + } catch (error: unknown) { + throw new ConversionError( + "Failed to transform request", + targetFormat, + undefined, + error + ); + } +} + +/** + * Transform a response payload to a target provider format. + * + * @param json - Source response JSON string. + * @param targetFormat - Target provider format, including "universal". + * @returns Transform result containing either pass-through or transformed data. + * @throws {ConversionError} If transformation fails. + */ +export function transformResponse( + json: string, + targetFormat: ProviderFormat +): TransformResponseResult { + try { + const result = getWasm().transform_response(json, targetFormat); + return convertMapsToObjects(result) as TransformResponseResult; + } catch (error: unknown) { + throw new ConversionError( + "Failed to transform response", + targetFormat, + undefined, + error + ); + } +} + // ============================================================================ // Validation functions (Zod-style API) // ============================================================================ diff --git a/bindings/typescript/src/wasm.ts b/bindings/typescript/src/wasm.ts index b44cb09a..8f4c6657 100644 --- a/bindings/typescript/src/wasm.ts +++ b/bindings/typescript/src/wasm.ts @@ -22,6 +22,8 @@ export { deduplicateMessages, importMessagesFromSpans, importAndDeduplicateMessages, + transformRequest, + transformResponse, // Chat Completions validation validateChatCompletionsRequest, @@ -50,5 +52,7 @@ export type { StreamSessionChunk, TransformStreamChunkResult, TransformStreamSessionHandle, + TransformRequestResult, + TransformResponseResult, ValidationResult, } from "./converters"; diff --git a/bindings/typescript/tests/browser-exports.test.ts b/bindings/typescript/tests/browser-exports.test.ts index 4b5e283f..f2aabb38 100644 --- a/bindings/typescript/tests/browser-exports.test.ts +++ b/bindings/typescript/tests/browser-exports.test.ts @@ -45,6 +45,8 @@ describe("Browser exports", () => { expect(typeof exports.linguaToChatCompletionsMessages).toBe("function"); expect(typeof exports.anthropicMessagesToLingua).toBe("function"); expect(typeof exports.linguaToAnthropicMessages).toBe("function"); + expect(typeof exports.transformRequest).toBe("function"); + expect(typeof exports.transformResponse).toBe("function"); }); test("should export validation functions", async () => { @@ -85,6 +87,55 @@ describe("Browser exports", () => { expect(result[0].role).toBe("user"); }); + test("should transform chat completions request to universal after init()", async () => { + const { init, transformRequest } = await import("../src/index.browser"); + + const wasmBuffer = readFileSync(wasmPath); + await init(wasmBuffer); + + const result = transformRequest( + JSON.stringify({ + model: "gpt-5-mini", + messages: [{ role: "user", content: "Hello" }], + }), + "universal", + ); + + expect(result.data).toMatchObject({ + model: "gpt-5-mini", + messages: [{ role: "user", content: "Hello" }], + }); + }); + + test("should transform chat completions response to universal after init()", async () => { + const { init, transformResponse } = await import("../src/index.browser"); + + const wasmBuffer = readFileSync(wasmPath); + await init(wasmBuffer); + + const result = transformResponse( + JSON.stringify({ + id: "chatcmpl-123", + object: "chat.completion", + model: "gpt-5-mini", + choices: [ + { + index: 0, + message: { role: "assistant", content: "Hello!" }, + finish_reason: "stop", + }, + ], + }), + "universal", + ); + + expect(result.data).toMatchObject({ + model: "gpt-5-mini", + messages: [{ role: "assistant", content: "Hello!" }], + finish_reason: "Stop", + }); + }); + test("init() can be called multiple times safely", async () => { const { init, chatCompletionsMessagesToLingua } = await import( "../src/index.browser" diff --git a/bindings/typescript/tests/node-exports.test.ts b/bindings/typescript/tests/node-exports.test.ts index ae63879f..4f1481ce 100644 --- a/bindings/typescript/tests/node-exports.test.ts +++ b/bindings/typescript/tests/node-exports.test.ts @@ -22,6 +22,8 @@ describe("Node.js exports", () => { expect(typeof exports.linguaToChatCompletionsMessages).toBe("function"); expect(typeof exports.anthropicMessagesToLingua).toBe("function"); expect(typeof exports.linguaToAnthropicMessages).toBe("function"); + expect(typeof exports.transformRequest).toBe("function"); + expect(typeof exports.transformResponse).toBe("function"); }); test("should export validation functions", async () => { @@ -57,6 +59,51 @@ describe("Node.js exports", () => { expect(result[0].role).toBe("user"); }); + test("should transform chat completions request to universal request", async () => { + const { transformRequest } = await import("../src/index"); + + const result = transformRequest( + JSON.stringify({ + model: "gpt-5-mini", + messages: [{ role: "user", content: "Hello" }], + temperature: 0.2, + }), + "universal", + ); + + expect(result.data).toMatchObject({ + model: "gpt-5-mini", + messages: [{ role: "user", content: "Hello" }], + params: { temperature: 0.2 }, + }); + }); + + test("should transform chat completions response to universal response", async () => { + const { transformResponse } = await import("../src/index"); + + const result = transformResponse( + JSON.stringify({ + id: "chatcmpl-123", + object: "chat.completion", + model: "gpt-5-mini", + choices: [ + { + index: 0, + message: { role: "assistant", content: "Hello!" }, + finish_reason: "stop", + }, + ], + }), + "universal", + ); + + expect(result.data).toMatchObject({ + model: "gpt-5-mini", + messages: [{ role: "assistant", content: "Hello!" }], + finish_reason: "Stop", + }); + }); + test("should import messages from prompt wrapper with tool calls", async () => { const { importMessagesFromSpans } = await import("../src/index"); diff --git a/crates/lingua/src/processing/adapters.rs b/crates/lingua/src/processing/adapters.rs index aa295fed..667d9814 100644 --- a/crates/lingua/src/processing/adapters.rs +++ b/crates/lingua/src/processing/adapters.rs @@ -166,10 +166,8 @@ impl ProviderAdapter for UniversalAdapter { )) } - fn response_from_universal(&self, _resp: &UniversalResponse) -> Result { - Err(TransformError::UnsupportedTargetFormat( - ProviderFormat::Universal, - )) + fn response_from_universal(&self, resp: &UniversalResponse) -> Result { + serde_json::to_value(resp).map_err(|e| TransformError::FromUniversalFailed(e.to_string())) } } diff --git a/crates/lingua/src/processing/transform.rs b/crates/lingua/src/processing/transform.rs index c57f96ad..6db3b300 100644 --- a/crates/lingua/src/processing/transform.rs +++ b/crates/lingua/src/processing/transform.rs @@ -1170,6 +1170,43 @@ mod tests { assert_eq!(result.into_bytes().as_ptr(), input_ptr); } + #[test] + #[cfg(feature = "openai")] + fn test_transform_response_to_universal() { + let payload = json!({ + "id": "chatcmpl-123", + "object": "chat.completion", + "model": "gpt-5-mini", + "choices": [{ + "index": 0, + "message": {"role": "assistant", "content": "Hello!"}, + "finish_reason": "stop" + }] + }); + let input = to_bytes(&payload); + + let result = transform_response(input, ProviderFormat::Universal).unwrap(); + + match result { + TransformResult::Transformed { + bytes, + source_format, + actual_target_format, + } => { + assert_eq!(source_format, ProviderFormat::ChatCompletions); + assert_eq!(actual_target_format, ProviderFormat::Universal); + let payload: Value = serde_json::from_slice(&bytes).unwrap(); + assert_eq!( + payload.get("model").and_then(Value::as_str), + Some("gpt-5-mini") + ); + assert!(payload.get("messages").is_some()); + assert!(payload.get("usage").is_some()); + } + TransformResult::PassThrough(_) => panic!("provider response should transform"), + } + } + #[test] #[cfg(feature = "openai")] fn test_reasoning_model_forces_translation() { From 36cc60cff81ae2bede788ec25c3f9154f7974454 Mon Sep 17 00:00:00 2001 From: Alex Z Date: Tue, 26 May 2026 17:07:42 -0700 Subject: [PATCH 4/9] fix some openai stuff --- .../src/providers/openai/capabilities.rs | 104 ++++++++++++++++-- .../src/providers/openai/responses_adapter.rs | 40 +++++++ 2 files changed, 135 insertions(+), 9 deletions(-) diff --git a/crates/lingua/src/providers/openai/capabilities.rs b/crates/lingua/src/providers/openai/capabilities.rs index 627b1469..6689390c 100644 --- a/crates/lingua/src/providers/openai/capabilities.rs +++ b/crates/lingua/src/providers/openai/capabilities.rs @@ -11,6 +11,8 @@ pub enum ModelTransform { StripTemperature, /// Strip top_p parameter (reasoning models don't support it) StripTopP, + /// Strip top_logprobs parameter (reasoning models don't support it) + StripTopLogprobs, /// Convert max_tokens to max_completion_tokens ForceMaxCompletionTokens, /// Convert max_completion_tokens to max_tokens @@ -26,19 +28,39 @@ use ModelTransform::*; const MODEL_TRANSFORM_RULES: &[(&str, &[ModelTransform])] = &[ ( "o1", - &[StripTemperature, StripTopP, ForceMaxCompletionTokens], + &[ + StripTemperature, + StripTopP, + StripTopLogprobs, + ForceMaxCompletionTokens, + ], ), ( "o3", - &[StripTemperature, StripTopP, ForceMaxCompletionTokens], + &[ + StripTemperature, + StripTopP, + StripTopLogprobs, + ForceMaxCompletionTokens, + ], ), ( "o4", - &[StripTemperature, StripTopP, ForceMaxCompletionTokens], + &[ + StripTemperature, + StripTopP, + StripTopLogprobs, + ForceMaxCompletionTokens, + ], ), ( "gpt-5", - &[StripTemperature, StripTopP, ForceMaxCompletionTokens], + &[ + StripTemperature, + StripTopP, + StripTopLogprobs, + ForceMaxCompletionTokens, + ], ), // TODO: would be nice if we could apply these rules by provider instead of model name, and // apply these to all Mistral models @@ -202,6 +224,9 @@ pub fn apply_model_transforms(model: &str, obj: &mut Map) { StripTopP => { obj.remove("top_p"); } + StripTopLogprobs => { + obj.remove("top_logprobs"); + } ForceMaxCompletionTokens => { // (Responses API) max_output_tokens is valid. if obj.contains_key("max_output_tokens") { @@ -236,23 +261,48 @@ mod tests { let cases = [ ( "o1", - &[StripTemperature, StripTopP, ForceMaxCompletionTokens][..], + &[ + StripTemperature, + StripTopP, + StripTopLogprobs, + ForceMaxCompletionTokens, + ][..], ), ( "o1-mini", - &[StripTemperature, StripTopP, ForceMaxCompletionTokens][..], + &[ + StripTemperature, + StripTopP, + StripTopLogprobs, + ForceMaxCompletionTokens, + ][..], ), ( "o3", - &[StripTemperature, StripTopP, ForceMaxCompletionTokens][..], + &[ + StripTemperature, + StripTopP, + StripTopLogprobs, + ForceMaxCompletionTokens, + ][..], ), ( "o4-preview", - &[StripTemperature, StripTopP, ForceMaxCompletionTokens][..], + &[ + StripTemperature, + StripTopP, + StripTopLogprobs, + ForceMaxCompletionTokens, + ][..], ), ( "gpt-5-mini", - &[StripTemperature, StripTopP, ForceMaxCompletionTokens][..], + &[ + StripTemperature, + StripTopP, + StripTopLogprobs, + ForceMaxCompletionTokens, + ][..], ), ("gpt-4", &[][..]), ("gpt-4o", &[][..]), @@ -383,6 +433,42 @@ mod tests { } } + #[test] + fn test_strip_top_logprobs() { + let reasoning_models = ["o1", "o1-mini", "o3", "gpt-5-mini"]; + let non_reasoning_models = ["gpt-4", "gpt-4o", "claude-3"]; + + for model in reasoning_models { + let mut obj: Map = serde_json::from_value(json!({ + "model": model, + "messages": [{"role": "user", "content": "Hello"}], + "top_logprobs": 0 + })) + .unwrap(); + apply_model_transforms(model, &mut obj); + assert!( + !obj.contains_key("top_logprobs"), + "{} should strip top_logprobs", + model + ); + } + + for model in non_reasoning_models { + let mut obj: Map = serde_json::from_value(json!({ + "model": model, + "messages": [{"role": "user", "content": "Hello"}], + "top_logprobs": 0 + })) + .unwrap(); + apply_model_transforms(model, &mut obj); + assert!( + obj.contains_key("top_logprobs"), + "{} should preserve top_logprobs", + model + ); + } + } + #[test] fn test_force_max_completion_tokens() { // Reasoning models: max_tokens → max_completion_tokens diff --git a/crates/lingua/src/providers/openai/responses_adapter.rs b/crates/lingua/src/providers/openai/responses_adapter.rs index cb66de15..e4d9d9ce 100644 --- a/crates/lingua/src/providers/openai/responses_adapter.rs +++ b/crates/lingua/src/providers/openai/responses_adapter.rs @@ -1536,6 +1536,46 @@ mod tests { assert_eq!(typed.top_p, Some(0.9)); } + #[test] + fn test_responses_omits_top_logprobs_for_reasoning_models() { + let req = UniversalRequest { + model: Some("gpt-5-mini".to_string()), + messages: vec![Message::User { + content: UserContent::String("Hello".to_string()), + }], + params: UniversalParams { + top_logprobs: Some(0), + ..Default::default() + }, + }; + + let adapter = ResponsesAdapter; + let typed: OpenAIResponsesParams = + serde_json::from_value(adapter.request_from_universal(&req).unwrap()).unwrap(); + + assert_eq!(typed.top_logprobs, None); + } + + #[test] + fn test_responses_preserves_top_logprobs_for_non_reasoning_models() { + let req = UniversalRequest { + model: Some("gpt-4.1".to_string()), + messages: vec![Message::User { + content: UserContent::String("Hello".to_string()), + }], + params: UniversalParams { + top_logprobs: Some(2), + ..Default::default() + }, + }; + + let adapter = ResponsesAdapter; + let typed: OpenAIResponsesParams = + serde_json::from_value(adapter.request_from_universal(&req).unwrap()).unwrap(); + + assert_eq!(typed.top_logprobs, Some(2)); + } + #[test] fn test_responses_clamps_reasoning_effort_for_target_model() { let req = UniversalRequest { From a3da14ee0b5e754e9ec7d7d9489c4710a269d90e Mon Sep 17 00:00:00 2001 From: Alex Z Date: Wed, 27 May 2026 14:43:10 -0700 Subject: [PATCH 5/9] fix comments --- .../typescript/src/generated/SummaryMode.ts | 2 +- crates/lingua/src/processing/transform.rs | 57 +++++++++++++++++++ crates/lingua/src/universal/request.rs | 3 +- 3 files changed, 60 insertions(+), 2 deletions(-) diff --git a/bindings/typescript/src/generated/SummaryMode.ts b/bindings/typescript/src/generated/SummaryMode.ts index b01b29a8..5a21ef85 100644 --- a/bindings/typescript/src/generated/SummaryMode.ts +++ b/bindings/typescript/src/generated/SummaryMode.ts @@ -3,4 +3,4 @@ /** * Summary mode for reasoning output. */ -export type SummaryMode = "None" | "Auto" | "Detailed"; +export type SummaryMode = "none" | "auto" | "detailed"; diff --git a/crates/lingua/src/processing/transform.rs b/crates/lingua/src/processing/transform.rs index 6db3b300..addcb127 100644 --- a/crates/lingua/src/processing/transform.rs +++ b/crates/lingua/src/processing/transform.rs @@ -813,6 +813,63 @@ mod tests { } } + #[test] + fn test_transform_request_universal_accepts_lowercase_summary() { + let payload = json!({ + "model": "gpt-5-mini", + "messages": [{"role": "user", "content": "Hello"}], + "params": { + "reasoning": { + "summary": "detailed" + } + } + }); + let input = to_bytes(&payload); + + let result = transform_request(input, ProviderFormat::Universal, None).unwrap(); + + assert!(result.is_passthrough()); + } + + #[test] + #[cfg(feature = "openai")] + fn test_transform_request_openai_to_universal_preserves_provider_extras() { + let payload = json!({ + "model": "gpt-4", + "messages": [{"role": "user", "content": "Hello"}], + "user": "user_123", + "custom_field": {"nested": true} + }); + let input = to_bytes(&payload); + + let result = transform_request(input, ProviderFormat::Universal, None).unwrap(); + let universal_bytes = result.into_bytes(); + let universal: crate::universal::UniversalRequest = + crate::serde_json::from_slice(&universal_bytes).unwrap(); + let openai_extras = universal + .params + .extras + .get(&ProviderFormat::ChatCompletions) + .unwrap(); + + assert_eq!(openai_extras.get("user"), Some(&json!("user_123"))); + assert_eq!( + openai_extras.get("custom_field"), + Some(&json!({"nested": true})) + ); + + let roundtrip = + transform_request(universal_bytes, ProviderFormat::ChatCompletions, None).unwrap(); + let roundtrip_payload: Value = + crate::serde_json::from_slice(&roundtrip.into_bytes()).unwrap(); + + assert_eq!( + roundtrip_payload.get("custom_field"), + Some(&json!({"nested": true})) + ); + assert_eq!(roundtrip_payload.get("user"), Some(&json!("user_123"))); + } + #[test] #[cfg(feature = "openai")] fn test_transform_request_passthrough_repairs_lone_surrogate() { diff --git a/crates/lingua/src/universal/request.rs b/crates/lingua/src/universal/request.rs index e628c9ee..e3df81b0 100644 --- a/crates/lingua/src/universal/request.rs +++ b/crates/lingua/src/universal/request.rs @@ -204,7 +204,7 @@ pub struct UniversalParams { /// /// Keyed by source `ProviderFormat` - only restored when converting back to /// the same provider (no cross-provider contamination). - #[serde(skip)] + #[serde(default, skip_serializing_if = "HashMap::is_empty")] #[ts(skip)] pub extras: HashMap>, } @@ -427,6 +427,7 @@ pub enum ReasoningCanonical { /// Summary mode for reasoning output. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, TS)] +#[serde(rename_all = "lowercase")] #[ts(export)] pub enum SummaryMode { /// No summary included in response. From 85fd793ca5f9ae6e44227b68497378dfbf62e925 Mon Sep 17 00:00:00 2001 From: Alex Z Date: Wed, 27 May 2026 14:58:14 -0700 Subject: [PATCH 6/9] make ci pass --- .../src/requests_expected_differences.json | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/crates/coverage-report/src/requests_expected_differences.json b/crates/coverage-report/src/requests_expected_differences.json index b87474d3..9fc44da3 100644 --- a/crates/coverage-report/src/requests_expected_differences.json +++ b/crates/coverage-report/src/requests_expected_differences.json @@ -5,6 +5,7 @@ "target": "*", "fields": [ { "pattern": "params.service_tier", "reason": "OpenAI-specific billing tier not universal across providers" }, + { "pattern": "params.extras", "reason": "Provider-specific extras are scoped to the originating format and are not cross-provider semantics" }, { "pattern": "messages[*].id", "reason": "Message/response IDs are provider-specific (OpenAI uses response-level IDs, Anthropic uses message-level IDs, Bedrock has none)" }, { "pattern": "params.reasoning.canonical", "reason": "Metadata indicating source format (effort vs budget_tokens) - changes when converting between providers with different canonical representations" } ] @@ -201,6 +202,15 @@ "skip": true, "reason": "Anthropic assistant messages don't support image content" }, + { + "testCase": "outputFormatJsonSchemaParam", + "source": "Anthropic", + "target": "Anthropic", + "fields": [ + { "pattern": "params.extras.anthropic.output_format", "reason": "Legacy Anthropic output_format is normalized to output_config.format" }, + { "pattern": "params.extras.anthropic.output_config", "reason": "Legacy Anthropic output_format is normalized to output_config.format" } + ] + }, { "testCase": "documentContentParam", "source": "*", From 9ec087a54ff4c35e724886b5943ea5dbc589adb6 Mon Sep 17 00:00:00 2001 From: Alex Z Date: Mon, 1 Jun 2026 15:29:27 -0700 Subject: [PATCH 7/9] reorder transforms --- crates/lingua/src/processing/transform.rs | 89 +++++++++++++++++++++-- 1 file changed, 82 insertions(+), 7 deletions(-) diff --git a/crates/lingua/src/processing/transform.rs b/crates/lingua/src/processing/transform.rs index 125005b8..bdae2706 100644 --- a/crates/lingua/src/processing/transform.rs +++ b/crates/lingua/src/processing/transform.rs @@ -22,9 +22,9 @@ use crate::providers::openai::model_needs_transforms; use crate::serde_json; use crate::serde_json::Value; use crate::universal::{ - AssistantContent, AssistantContentPart, Message, UniversalReasoningDelta, UniversalResponse, - UniversalStreamChoice, UniversalStreamChunk, UniversalStreamDelta, UniversalToolCallDelta, - UniversalToolFunctionDelta, + AssistantContent, AssistantContentPart, Message, UniversalReasoningDelta, UniversalRequest, + UniversalResponse, UniversalStreamChoice, UniversalStreamChunk, UniversalStreamDelta, + UniversalToolCallDelta, UniversalToolFunctionDelta, }; use serde::de::DeserializeOwned; use thiserror::Error; @@ -269,6 +269,22 @@ fn chat_completions_needs_responses_upgrade(payload: &Value) -> bool { has_reasoning_effort && has_tools } +#[cfg(feature = "openai")] +fn universal_request_needs_responses_upgrade(req: &UniversalRequest) -> bool { + let has_reasoning = req + .params + .reasoning + .as_ref() + .is_some_and(|reasoning| !reasoning.is_effectively_disabled()); + let has_tools = req + .params + .tools + .as_ref() + .is_some_and(|tools| !tools.is_empty()); + + has_reasoning && has_tools +} + pub fn transform_request( input: Bytes, target_format: ProviderFormat, @@ -298,16 +314,25 @@ pub fn transform_request( return Ok(TransformResult::PassThrough(request_bytes)); } - let source_format = source_adapter.format(); - let target_adapter = adapter_for_format(target_format) - .ok_or(TransformError::UnsupportedTargetFormat(target_format))?; - let mut universal = source_adapter.request_to_universal(payload)?; if let Some(model) = model { universal.model = Some(model.to_string()); } + #[cfg(feature = "openai")] + let target_format = if target_format == ProviderFormat::ChatCompletions + && universal_request_needs_responses_upgrade(&universal) + { + ProviderFormat::Responses + } else { + target_format + }; + + let source_format = source_adapter.format(); + let target_adapter = adapter_for_format(target_format) + .ok_or(TransformError::UnsupportedTargetFormat(target_format))?; + // Apply target provider defaults (e.g., Anthropic's required max_tokens) target_adapter.apply_defaults(&mut universal); @@ -1669,6 +1694,56 @@ mod tests { } } + #[test] + #[cfg(feature = "openai")] + fn test_transform_request_upgrades_universal_target_to_responses_for_reasoning_plus_tools() { + let payload = json!({ + "model": "gpt-5.4-mini", + "messages": [{"role": "user", "content": "Tokyo weather?"}], + "params": { + "reasoning": { + "enabled": true, + "effort": "medium" + }, + "tools": [{ + "name": "get_weather", + "description": "Get weather", + "parameters": { + "type": "object", + "properties": {"location": {"type": "string"}}, + "required": ["location"] + }, + "kind": "function" + }] + } + }); + let input = to_bytes(&payload); + + let result = transform_request(input, ProviderFormat::ChatCompletions, None).unwrap(); + + match result { + TransformResult::Transformed { + source_format, + actual_target_format, + bytes, + } => { + assert_eq!(source_format, ProviderFormat::Universal); + assert_eq!( + actual_target_format, + ProviderFormat::Responses, + "Universal requests should also upgrade when reasoning + tools are present" + ); + let output: Value = crate::serde_json::from_slice(&bytes).unwrap(); + assert!(output.get("input").is_some()); + assert!(output.get("tools").is_some()); + assert!(output.get("reasoning").is_some()); + } + TransformResult::PassThrough(_) => { + panic!("Expected transformation, got passthrough"); + } + } + } + #[test] #[cfg(feature = "openai")] fn test_transform_request_does_not_upgrade_without_tools() { From bd7b8cfa28d871c434aab45a1968e194678f4de9 Mon Sep 17 00:00:00 2001 From: Alex Z Date: Mon, 1 Jun 2026 15:57:53 -0700 Subject: [PATCH 8/9] fiiixes --- .../src/generated/ResponseFormatType.ts | 2 +- .../src/generated/ToolChoiceMode.ts | 2 +- crates/lingua/src/processing/adapters.rs | 11 ++- crates/lingua/src/processing/transform.rs | 76 +++++++++++++++++++ crates/lingua/src/universal/request.rs | 31 ++++++++ crates/lingua/src/universal/response.rs | 8 +- 6 files changed, 120 insertions(+), 10 deletions(-) diff --git a/bindings/typescript/src/generated/ResponseFormatType.ts b/bindings/typescript/src/generated/ResponseFormatType.ts index 4244af09..67aac0f5 100644 --- a/bindings/typescript/src/generated/ResponseFormatType.ts +++ b/bindings/typescript/src/generated/ResponseFormatType.ts @@ -3,4 +3,4 @@ /** * Response format type (portable across providers). */ -export type ResponseFormatType = "Text" | "JsonObject" | "JsonSchema"; +export type ResponseFormatType = "text" | "json_object" | "json_schema"; diff --git a/bindings/typescript/src/generated/ToolChoiceMode.ts b/bindings/typescript/src/generated/ToolChoiceMode.ts index 1e808e83..385258e0 100644 --- a/bindings/typescript/src/generated/ToolChoiceMode.ts +++ b/bindings/typescript/src/generated/ToolChoiceMode.ts @@ -3,4 +3,4 @@ /** * Tool selection mode (portable across providers). */ -export type ToolChoiceMode = "Auto" | "None" | "Required" | "Tool"; +export type ToolChoiceMode = "auto" | "none" | "required" | "tool"; diff --git a/crates/lingua/src/processing/adapters.rs b/crates/lingua/src/processing/adapters.rs index 667d9814..6729b08e 100644 --- a/crates/lingua/src/processing/adapters.rs +++ b/crates/lingua/src/processing/adapters.rs @@ -156,14 +156,13 @@ impl ProviderAdapter for UniversalAdapter { serde_json::to_value(req).map_err(|e| TransformError::FromUniversalFailed(e.to_string())) } - fn detect_response(&self, _payload: &Value) -> bool { - false + fn detect_response(&self, payload: &Value) -> bool { + serde_json::from_value::(payload.clone()).is_ok() } - fn response_to_universal(&self, _payload: Value) -> Result { - Err(TransformError::UnsupportedSourceFormat( - ProviderFormat::Universal, - )) + fn response_to_universal(&self, payload: Value) -> Result { + serde_json::from_value(payload) + .map_err(|e| TransformError::ToUniversalFailed(e.to_string())) } fn response_from_universal(&self, resp: &UniversalResponse) -> Result { diff --git a/crates/lingua/src/processing/transform.rs b/crates/lingua/src/processing/transform.rs index bdae2706..cda8aed1 100644 --- a/crates/lingua/src/processing/transform.rs +++ b/crates/lingua/src/processing/transform.rs @@ -322,6 +322,7 @@ pub fn transform_request( #[cfg(feature = "openai")] let target_format = if target_format == ProviderFormat::ChatCompletions + && source_adapter.format() == ProviderFormat::Universal && universal_request_needs_responses_upgrade(&universal) { ProviderFormat::Responses @@ -876,6 +877,37 @@ mod tests { assert!(result.is_passthrough()); } + #[test] + fn test_transform_request_universal_accepts_canonical_tool_choice_and_response_format() { + let payload = json!({ + "model": "gpt-5-mini", + "messages": [{"role": "user", "content": "Hello"}], + "params": { + "tool_choice": { + "mode": "required" + }, + "response_format": { + "format_type": "json_schema", + "json_schema": { + "name": "answer", + "schema": { + "type": "object", + "properties": { + "answer": { "type": "string" } + }, + "required": ["answer"] + } + } + } + } + }); + let input = to_bytes(&payload); + + let result = transform_request(input, ProviderFormat::Universal, None).unwrap(); + + assert!(result.is_passthrough()); + } + #[test] #[cfg(feature = "openai")] fn test_transform_request_openai_to_universal_preserves_provider_extras() { @@ -1329,6 +1361,50 @@ mod tests { } } + #[test] + #[cfg(feature = "openai")] + fn test_transform_response_universal_roundtrips_to_provider() { + let payload = json!({ + "id": "chatcmpl-123", + "object": "chat.completion", + "model": "gpt-5-mini", + "choices": [{ + "index": 0, + "message": {"role": "assistant", "content": "Hello!"}, + "finish_reason": "stop" + }], + "usage": { + "prompt_tokens": 3, + "completion_tokens": 2, + "total_tokens": 5 + } + }); + let input = to_bytes(&payload); + + let universal = transform_response(input, ProviderFormat::Universal) + .unwrap() + .into_bytes(); + let provider = transform_response(universal, ProviderFormat::ChatCompletions).unwrap(); + + match provider { + TransformResult::Transformed { + source_format, + actual_target_format, + bytes, + } => { + assert_eq!(source_format, ProviderFormat::Universal); + assert_eq!(actual_target_format, ProviderFormat::ChatCompletions); + let output: Value = crate::serde_json::from_slice(&bytes).unwrap(); + assert_eq!( + output.get("object").and_then(Value::as_str), + Some("chat.completion") + ); + assert!(output.get("choices").is_some()); + } + TransformResult::PassThrough(_) => panic!("universal response should transform"), + } + } + #[test] #[cfg(feature = "openai")] fn test_reasoning_model_forces_translation() { diff --git a/crates/lingua/src/universal/request.rs b/crates/lingua/src/universal/request.rs index e3df81b0..554379ad 100644 --- a/crates/lingua/src/universal/request.rs +++ b/crates/lingua/src/universal/request.rs @@ -501,6 +501,7 @@ pub struct ToolChoiceConfig { /// Tool selection mode (portable across providers). #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, TS)] +#[serde(rename_all = "lowercase")] #[ts(export)] pub enum ToolChoiceMode { /// Provider decides whether to use tools @@ -587,6 +588,7 @@ pub struct ResponseFormatConfig { /// Response format type (portable across providers). #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, TS)] +#[serde(rename_all = "snake_case")] #[ts(export)] pub enum ResponseFormatType { /// Plain text output (default) @@ -772,4 +774,33 @@ mod tests { ); assert_eq!(tool_choice.disable_parallel_tool_use, Some(true)); } + + #[test] + fn test_tool_choice_mode_deserializes_canonical_values() { + let cases = [ + ("auto", ToolChoiceMode::Auto), + ("none", ToolChoiceMode::None), + ("required", ToolChoiceMode::Required), + ("tool", ToolChoiceMode::Tool), + ]; + + for (input, expected) in cases { + let actual: ToolChoiceMode = crate::serde_json::from_value(json!(input)).unwrap(); + assert_eq!(actual, expected); + } + } + + #[test] + fn test_response_format_type_deserializes_canonical_values() { + let cases = [ + ("text", ResponseFormatType::Text), + ("json_object", ResponseFormatType::JsonObject), + ("json_schema", ResponseFormatType::JsonSchema), + ]; + + for (input, expected) in cases { + let actual: ResponseFormatType = crate::serde_json::from_value(json!(input)).unwrap(); + assert_eq!(actual, expected); + } + } } diff --git a/crates/lingua/src/universal/response.rs b/crates/lingua/src/universal/response.rs index 07e91687..eff9324b 100644 --- a/crates/lingua/src/universal/response.rs +++ b/crates/lingua/src/universal/response.rs @@ -14,7 +14,7 @@ use serde::{Deserialize, Serialize}; /// Universal response envelope for LLM API responses. /// /// This type captures the common structure across all provider response formats. -#[derive(Debug, Clone, Serialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] pub struct UniversalResponse { /// Original response ID from the provider (e.g. "msg_abc123"), and the /// format it came from. Both are skipped during serialization — IDs are @@ -60,18 +60,22 @@ pub struct UniversalUsage { /// Reason why the model stopped generating. /// /// Normalized across provider-specific values. -#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub enum FinishReason { /// Normal completion (OpenAI: "stop", Anthropic: "end_turn", Google: "STOP") + #[serde(alias = "stop")] Stop, /// Hit token limit (OpenAI: "length", Anthropic: "max_tokens") + #[serde(alias = "length")] Length, /// Model wants to call tools (OpenAI: "tool_calls", Anthropic: "tool_use") + #[serde(alias = "tool_calls")] ToolCalls, /// Content was filtered + #[serde(alias = "content_filter")] ContentFilter, /// Provider-specific reason not in the canonical set From 219af6796737608595a6249ea6e289552aefa5d2 Mon Sep 17 00:00:00 2001 From: Alex Z Date: Mon, 1 Jun 2026 16:14:08 -0700 Subject: [PATCH 9/9] more changes --- bindings/typescript/src/converters.ts | 1 + .../typescript/tests/node-exports.test.ts | 37 +++++++++++ crates/lingua/src/universal/response.rs | 63 ++++++++++++++++--- crates/lingua/src/wasm.rs | 54 ++++++++-------- 4 files changed, 119 insertions(+), 36 deletions(-) diff --git a/bindings/typescript/src/converters.ts b/bindings/typescript/src/converters.ts index 4f3fbc9c..741035da 100644 --- a/bindings/typescript/src/converters.ts +++ b/bindings/typescript/src/converters.ts @@ -37,6 +37,7 @@ export type TransformRequestResult = transformed: true; data: TData; sourceFormat: ProviderFormat; + actualTargetFormat: ProviderFormat; }; export type TransformResponseResult = TransformRequestResult; diff --git a/bindings/typescript/tests/node-exports.test.ts b/bindings/typescript/tests/node-exports.test.ts index 4f1481ce..43f875be 100644 --- a/bindings/typescript/tests/node-exports.test.ts +++ b/bindings/typescript/tests/node-exports.test.ts @@ -78,6 +78,43 @@ describe("Node.js exports", () => { }); }); + test("should report actual target format when universal request upgrades to responses", async () => { + const { transformRequest } = await import("../src/index"); + + const result = transformRequest( + JSON.stringify({ + model: "gpt-5.4-mini", + messages: [{ role: "user", content: "Tokyo weather" }], + params: { + reasoning: { + enabled: true, + effort: "medium", + }, + tools: [ + { + name: "get_weather", + description: "Get weather", + parameters: { + type: "object", + properties: { location: { type: "string" } }, + required: ["location"], + }, + kind: "function", + }, + ], + }, + }), + "openai", + ); + + expect(result).toMatchObject({ + transformed: true, + sourceFormat: "universal", + actualTargetFormat: "responses", + }); + expect(result.data).toHaveProperty("input"); + }); + test("should transform chat completions response to universal response", async () => { const { transformResponse } = await import("../src/index"); diff --git a/crates/lingua/src/universal/response.rs b/crates/lingua/src/universal/response.rs index eff9324b..34af376e 100644 --- a/crates/lingua/src/universal/response.rs +++ b/crates/lingua/src/universal/response.rs @@ -259,7 +259,7 @@ impl UniversalResponse { /// If the stored ID originated from the same format, it is returned as-is so /// that round-trips preserve the original value. Otherwise we attempt to /// generate a vaguely reasonable-looking placeholder (e.g. - /// `"msg_transformed"`, `"chatcmpl-transformed"`). + /// `"msg_transformed"`, `"chatcmpl-transformed"`, `"universal_transformed"`). /// Extract the `id` field from a provider response payload using typed /// deserialization, avoiding direct `Value::get` access. pub fn extract_id_from_payload(payload: &Value) -> Option { @@ -277,10 +277,9 @@ impl UniversalResponse { ProviderFormat::Anthropic => "msg_", ProviderFormat::BedrockAnthropic => "msg_bdrk_", ProviderFormat::VertexAnthropic => "msg_vrtx_", - ProviderFormat::ChatCompletions - | ProviderFormat::Mistral - | ProviderFormat::Universal - | ProviderFormat::Unknown => "chatcmpl-", + ProviderFormat::ChatCompletions | ProviderFormat::Mistral => "chatcmpl-", + ProviderFormat::Universal => "universal_", + ProviderFormat::Unknown => "resp_", ProviderFormat::Responses => "resp_", ProviderFormat::Google => "resp_", ProviderFormat::Converse => "msg_", @@ -289,10 +288,17 @@ impl UniversalResponse { if self.id_format == Some(format) { return id.to_string(); } - let unique_part = ["msg_bdrk_", "msg_vrtx_", "resp_", "chatcmpl-", "msg_"] - .iter() - .find_map(|p| id.strip_prefix(p)) - .unwrap_or(id); + let unique_part = [ + "msg_bdrk_", + "msg_vrtx_", + "universal_", + "resp_", + "chatcmpl-", + "msg_", + ] + .iter() + .find_map(|p| id.strip_prefix(p)) + .unwrap_or(id); if !unique_part.is_empty() && unique_part != PLACEHOLDER_ID { return format!("{}{}", prefix, unique_part); } @@ -506,3 +512,42 @@ impl UniversalUsage { } } } + +#[cfg(test)] +mod tests { + use super::*; + + fn response_with_id(id: Option<&str>, id_format: Option) -> UniversalResponse { + UniversalResponse { + id: id.map(ToString::to_string), + id_format, + model: None, + messages: Vec::new(), + usage: None, + finish_reason: None, + } + } + + #[test] + fn id_for_uses_neutral_prefixes_for_universal_and_unknown() { + let response = response_with_id(None, None); + + assert_eq!( + response.id_for(ProviderFormat::Universal), + "universal_transformed" + ); + assert_eq!(response.id_for(ProviderFormat::Unknown), "resp_transformed"); + } + + #[test] + fn id_for_rewrites_universal_prefix_for_provider_targets() { + let response = + response_with_id(Some("universal_existing"), Some(ProviderFormat::Universal)); + + assert_eq!( + response.id_for(ProviderFormat::ChatCompletions), + "chatcmpl-existing" + ); + assert_eq!(response.id_for(ProviderFormat::Responses), "resp_existing"); + } +} diff --git a/crates/lingua/src/wasm.rs b/crates/lingua/src/wasm.rs index 4d9ab80e..ddc46c8b 100644 --- a/crates/lingua/src/wasm.rs +++ b/crates/lingua/src/wasm.rs @@ -55,6 +55,7 @@ fn transform_result_to_js( pass_through: bool, bytes: bytes::Bytes, source_format: Option, + actual_target_format: Option, ) -> Result { let data_str = String::from_utf8_lossy(&bytes); let data = @@ -68,6 +69,9 @@ fn transform_result_to_js( if let Some(sf) = source_format { js_sys::Reflect::set(&obj, &"sourceFormat".into(), &sf.to_string().into())?; } + if let Some(tf) = actual_target_format { + js_sys::Reflect::set(&obj, &"actualTargetFormat".into(), &tf.to_string().into())?; + } } js_sys::Reflect::set(&obj, &"data".into(), &data)?; Ok(obj.into()) @@ -347,7 +351,7 @@ pub fn validate_google_response(json: &str) -> Result { /// /// Returns an object with either: /// - `{ passThrough: true, data: ... }` if payload is already valid for target -/// - `{ transformed: true, data: ..., sourceFormat: "..." }` if transformed +/// - `{ transformed: true, data: ..., sourceFormat: "...", actualTargetFormat: "..." }` if transformed #[wasm_bindgen] pub fn transform_request( input: &str, @@ -368,30 +372,21 @@ pub fn transform_request( // Use JS native JSON.parse to avoid serde_wasm_bindgen serialization issues // (Map objects, $serde_json::private::Number from arbitrary_precision) - let (pass_through, bytes, source_format) = match result { - TransformResult::PassThrough(bytes) => (true, bytes, None), + let (pass_through, bytes, source_format, actual_target_format) = match result { + TransformResult::PassThrough(bytes) => (true, bytes, None, None), TransformResult::Transformed { bytes, source_format, - .. - } => (false, bytes, Some(source_format)), + actual_target_format, + } => ( + false, + bytes, + Some(source_format), + Some(actual_target_format), + ), }; - let data_str = String::from_utf8_lossy(&bytes); - let data = - js_sys::JSON::parse(&data_str).map_err(|_| JsValue::from_str("Failed to parse JSON"))?; - - let obj = js_sys::Object::new(); - if pass_through { - js_sys::Reflect::set(&obj, &"passThrough".into(), &JsValue::TRUE)?; - } else { - js_sys::Reflect::set(&obj, &"transformed".into(), &JsValue::TRUE)?; - if let Some(sf) = source_format { - js_sys::Reflect::set(&obj, &"sourceFormat".into(), &sf.to_string().into())?; - } - } - js_sys::Reflect::set(&obj, &"data".into(), &data)?; - Ok(obj.into()) + transform_result_to_js(pass_through, bytes, source_format, actual_target_format) } /// Transform a response payload from one format to another. @@ -401,7 +396,7 @@ pub fn transform_request( /// /// Returns an object with either: /// - `{ passThrough: true, data: ... }` if payload is already valid for target -/// - `{ transformed: true, data: ..., sourceFormat: "..." }` if transformed +/// - `{ transformed: true, data: ..., sourceFormat: "...", actualTargetFormat: "..." }` if transformed #[wasm_bindgen] pub fn transform_response(input: &str, target_format: &str) -> Result { use crate::capabilities::ProviderFormat; @@ -417,16 +412,21 @@ pub fn transform_response(input: &str, target_format: &str) -> Result (true, bytes, None), + let (pass_through, bytes, source_format, actual_target_format) = match result { + TransformResult::PassThrough(bytes) => (true, bytes, None, None), TransformResult::Transformed { bytes, source_format, - .. - } => (false, bytes, Some(source_format)), + actual_target_format, + } => ( + false, + bytes, + Some(source_format), + Some(actual_target_format), + ), }; - transform_result_to_js(pass_through, bytes, source_format) + transform_result_to_js(pass_through, bytes, source_format, actual_target_format) } /// Transform a streaming chunk payload from one format to another. @@ -459,7 +459,7 @@ pub fn transform_stream_chunk(input: &str, target_format: &str) -> Result (false, bytes, Some(source_format)), }; - transform_result_to_js(pass_through, bytes, source_format) + transform_result_to_js(pass_through, bytes, source_format, None) } #[wasm_bindgen]