Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion bindings/python/src/lingua/__init__.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -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"]


# ============================================================================
Expand Down
66 changes: 66 additions & 0 deletions bindings/typescript/src/converters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -27,6 +28,19 @@ type GoogleWasmExports = {
lingua_to_google_contents: (value: unknown) => unknown;
};

export type TransformRequestResult<TData = unknown> =
| {
passThrough: true;
data: TData;
}
| {
transformed: true;
data: TData;
sourceFormat: ProviderFormat;
};
Comment on lines +37 to +40
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Return actual target format from transformRequest

The new TypeScript transformRequest API returns only sourceFormat, so callers cannot tell when the Rust transform upgraded the request to a different wire format (for example Chat Completions → Responses for reasoning+tools requests). In that case, SDK consumers will typically keep using the originally requested endpoint and can send a Responses-shaped payload to /v1/chat/completions, causing request failures despite a “successful” transform result.

Useful? React with 👍 / 👎.


export type TransformResponseResult<TData = unknown> = TransformRequestResult<TData>;

// ============================================================================
// Error handling
// ============================================================================
Expand Down Expand Up @@ -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<TData = unknown>(
json: string,
targetFormat: ProviderFormat,
model?: string | null
): TransformRequestResult<TData> {
try {
const result = getWasm().transform_request(json, targetFormat, model);
return convertMapsToObjects(result) as TransformRequestResult<TData>;
} 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<TData = unknown>(
json: string,
targetFormat: ProviderFormat
): TransformResponseResult<TData> {
try {
const result = getWasm().transform_response(json, targetFormat);
return convertMapsToObjects(result) as TransformResponseResult<TData>;
} catch (error: unknown) {
throw new ConversionError(
"Failed to transform response",
targetFormat,
undefined,
error
);
}
}

// ============================================================================
// Validation functions (Zod-style API)
// ============================================================================
Expand Down
2 changes: 1 addition & 1 deletion bindings/typescript/src/generated/ProviderFormat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,4 +9,4 @@
* 2. Update detection heuristics in `processing/detect.rs`
* 3. Add conversion logic in `providers/<name>/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";
2 changes: 1 addition & 1 deletion bindings/typescript/src/generated/SummaryMode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,4 @@
/**
* Summary mode for reasoning output.
*/
export type SummaryMode = "None" | "Auto" | "Detailed";
export type SummaryMode = "none" | "auto" | "detailed";
4 changes: 4 additions & 0 deletions bindings/typescript/src/wasm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ export {
deduplicateMessages,
importMessagesFromSpans,
importAndDeduplicateMessages,
transformRequest,
transformResponse,

// Chat Completions validation
validateChatCompletionsRequest,
Expand Down Expand Up @@ -50,5 +52,7 @@ export type {
StreamSessionChunk,
TransformStreamChunkResult,
TransformStreamSessionHandle,
TransformRequestResult,
TransformResponseResult,
ValidationResult,
} from "./converters";
51 changes: 51 additions & 0 deletions bindings/typescript/tests/browser-exports.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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"
Expand Down
47 changes: 47 additions & 0 deletions bindings/typescript/tests/node-exports.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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");

Expand Down
1 change: 1 addition & 0 deletions crates/braintrust-llm-router/src/catalog/resolver.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i wonder what this looks like in practice. i'd like to see more testing with transforms or full on gateway PR before we merge.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SG I won't merge until I get it running end to end with a UI form.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Do not default universal models to a non-existent provider alias

Mapping ProviderFormat::Universal to the alias string "universal" makes resolver fallback route selection produce an alias that is not registered by default, so catalog entries with format: universal and no explicit available_providers will fail later with NoProvider(...) instead of resolving to an executable provider path. This effectively makes universal-format models unroutable unless callers add a custom provider alias named universal.

Useful? React with 👍 / 👎.

ProviderFormat::Unknown => "unknown",
}
.to_string()
Expand Down
1 change: 1 addition & 0 deletions crates/braintrust-llm-router/src/providers/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}

Expand Down
77 changes: 77 additions & 0 deletions crates/braintrust-llm-router/tests/router.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
10 changes: 10 additions & 0 deletions crates/coverage-report/src/requests_expected_differences.json
Original file line number Diff line number Diff line change
Expand Up @@ -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" }
]
Expand Down Expand Up @@ -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": "*",
Expand Down
4 changes: 4 additions & 0 deletions crates/lingua/src/capabilities/format.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)]
Expand Down Expand Up @@ -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",
Expand All @@ -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),
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Do not accept universal as a public format string yet

ProviderFormat::from_str now accepts "universal", but the newly added adapter still returns UnsupportedTargetFormat(Universal) for response conversion, so requests that select universal as output format fail at runtime instead of being rejected at parse/validation time. Until universal response transforms are implemented, exposing this parse path makes the API advertise a format it cannot serve end-to-end.

Useful? React with 👍 / 👎.

_ => Err(()),
}
}
Expand Down
Loading
Loading