From 4bada15b6649b610b0a0bcfe32647147a00a8211 Mon Sep 17 00:00:00 2001 From: Joshua Wootonn Date: Wed, 6 May 2026 13:12:30 -0500 Subject: [PATCH 1/2] step 1: add responses web search proof coverage --- .../src/providers/openai/tool_parsing.rs | 36 +++++++++++++++++++ crates/lingua/src/universal/tools.rs | 23 ++++++++++++ 2 files changed, 59 insertions(+) diff --git a/crates/lingua/src/providers/openai/tool_parsing.rs b/crates/lingua/src/providers/openai/tool_parsing.rs index 3d1670f4..f055a62a 100644 --- a/crates/lingua/src/providers/openai/tool_parsing.rs +++ b/crates/lingua/src/providers/openai/tool_parsing.rs @@ -166,6 +166,42 @@ mod tests { assert!(tools[0].is_custom()); } + #[test] + fn test_parse_responses_web_search_tools() { + use crate::universal::tools::{BuiltinToolProvider, UniversalToolType}; + + let responses = json!([ + {"type": "web_search"}, + {"type": "web_search_preview"}, + ]); + + let tools = parse_openai_responses_tools_array(&responses); + assert_eq!(tools.len(), 2); + + for (tool, expected_type) in tools.iter().zip(["web_search", "web_search_preview"]) { + assert_eq!(tool.name, expected_type); + assert!(tool.is_builtin()); + assert_eq!( + tool.builtin_provider(), + Some(BuiltinToolProvider::Responses) + ); + match &tool.tool_type { + UniversalToolType::Builtin { + builtin_type, + config, + .. + } => { + assert_eq!(builtin_type, expected_type); + assert_eq!( + config.as_ref().and_then(|value| value.get("type")), + Some(&json!(expected_type)) + ); + } + other => panic!("expected Responses builtin tool, got {other:?}"), + } + } + } + #[test] fn test_parse_chat_tools_is_schema_scoped() { let responses_like = json!([{ diff --git a/crates/lingua/src/universal/tools.rs b/crates/lingua/src/universal/tools.rs index 2a9120c5..388b2ae0 100644 --- a/crates/lingua/src/universal/tools.rs +++ b/crates/lingua/src/universal/tools.rs @@ -817,6 +817,29 @@ mod tests { assert_eq!(value, config); } + #[test] + fn test_universal_tool_to_responses_web_search_passthrough() { + let config = json!({ + "type": "web_search", + "user_location": { + "type": "approximate", + "city": "San Francisco", + "region": "California", + "country": "US", + "timezone": "America/Los_Angeles" + } + }); + let tool = UniversalTool::builtin( + "web_search", + BuiltinToolProvider::Responses, + "web_search", + Some(config.clone()), + ); + + let value = tool.to_responses_value().unwrap(); + assert_eq!(value, config); + } + #[test] fn test_universal_tool_roundtrip_anthropic() { let original = json!({ From ee328aed5ebf6b350d58cd079f737f23c7b38516 Mon Sep 17 00:00:00 2001 From: Joshua Wootonn Date: Wed, 6 May 2026 14:16:15 -0500 Subject: [PATCH 2/2] Expose request transforms in TypeScript bindings --- bindings/typescript/src/converters.ts | 74 +++++++++++++++++++ bindings/typescript/src/wasm.ts | 2 + .../typescript/tests/node-exports.test.ts | 23 ++++++ 3 files changed, 99 insertions(+) diff --git a/bindings/typescript/src/converters.ts b/bindings/typescript/src/converters.ts index b9086c93..84f458f7 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,39 @@ type GoogleWasmExports = { lingua_to_google_contents: (value: unknown) => unknown; }; +export type TransformRequestResult = { + passThrough?: boolean; + transformed?: boolean; + sourceFormat?: ProviderFormat; + data: unknown; +}; + +function isProviderFormat(value: unknown): value is ProviderFormat { + return ( + typeof value === "string" && + [ + "openai", + "anthropic", + "google", + "mistral", + "converse", + "responses", + "unknown", + ].includes(value) + ); +} + +function isTransformRequestResult( + value: unknown +): value is TransformRequestResult { + if (typeof value !== "object" || value === null) { + return false; + } + + const sourceFormat = Reflect.get(value, "sourceFormat"); + return sourceFormat === undefined || isProviderFormat(sourceFormat); +} + // ============================================================================ // Error handling // ============================================================================ @@ -48,6 +82,46 @@ export class ConversionError extends Error { } } +export function transformRequest( + input: unknown, + targetFormat: ProviderFormat, + model?: string +): TransformRequestResult { + const transformRequestFn = Reflect.get( + getWasm(), + "transform_request" + ); + if (typeof transformRequestFn !== "function") { + throw new ConversionError( + "Lingua WASM transform_request export is unavailable", + targetFormat, + "from_lingua" + ); + } + + const inputString = typeof input === "string" ? input : JSON.stringify(input); + try { + const result = convertMapsToObjects( + Reflect.apply(transformRequestFn, undefined, [ + inputString, + targetFormat, + model, + ]) + ); + if (!isTransformRequestResult(result)) { + throw new Error("Lingua WASM returned an invalid transform result"); + } + return result; + } catch (error: unknown) { + throw new ConversionError( + `Failed to transform request to ${targetFormat}`, + targetFormat, + "from_lingua", + error + ); + } +} + // ============================================================================ // Generic converter factory // ============================================================================ diff --git a/bindings/typescript/src/wasm.ts b/bindings/typescript/src/wasm.ts index b44cb09a..c0fed88a 100644 --- a/bindings/typescript/src/wasm.ts +++ b/bindings/typescript/src/wasm.ts @@ -19,6 +19,7 @@ export { linguaToAnthropicMessages, // Processing functions + transformRequest, deduplicateMessages, importMessagesFromSpans, importAndDeduplicateMessages, @@ -50,5 +51,6 @@ export type { StreamSessionChunk, TransformStreamChunkResult, TransformStreamSessionHandle, + TransformRequestResult, ValidationResult, } from "./converters"; diff --git a/bindings/typescript/tests/node-exports.test.ts b/bindings/typescript/tests/node-exports.test.ts index ae63879f..2b1a2bba 100644 --- a/bindings/typescript/tests/node-exports.test.ts +++ b/bindings/typescript/tests/node-exports.test.ts @@ -20,6 +20,7 @@ describe("Node.js exports", () => { expect(typeof exports.chatCompletionsMessagesToLingua).toBe("function"); expect(typeof exports.linguaToChatCompletionsMessages).toBe("function"); + expect(typeof exports.transformRequest).toBe("function"); expect(typeof exports.anthropicMessagesToLingua).toBe("function"); expect(typeof exports.linguaToAnthropicMessages).toBe("function"); }); @@ -57,6 +58,28 @@ describe("Node.js exports", () => { expect(result[0].role).toBe("user"); }); + test("should transform full requests through WASM", async () => { + const { transformRequest } = await import("../src/index"); + + const result = transformRequest( + { + model: "gpt-5-mini", + messages: [{ role: "user", content: "Hello" }], + stream: true, + max_tokens: 16, + }, + "responses", + "gpt-5-mini", + ); + + expect(result.data).toEqual({ + model: "gpt-5-mini", + input: [{ role: "user", content: "Hello" }], + max_output_tokens: 16, + stream: true, + }); + }); + test("should import messages from prompt wrapper with tool calls", async () => { const { importMessagesFromSpans } = await import("../src/index");