Skip to content
Merged
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
4 changes: 1 addition & 3 deletions bindings/python/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ build-backend = "maturin"
name = "braintrust-lingua"
version = "0.1.0"
description = "Lingua - Universal message format for LLM APIs"
requires-python = ">=3.8"
requires-python = ">=3.10"
license = { text = "MIT OR Apache-2.0" }
keywords = ["llm", "ai", "openai", "anthropic", "api", "lingua"]
classifiers = [
Expand All @@ -15,8 +15,6 @@ classifiers = [
"License :: OSI Approved :: MIT License",
"License :: OSI Approved :: Apache Software License",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.8",
"Programming Language :: Python :: 3.9",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
Expand Down
34 changes: 19 additions & 15 deletions bindings/python/src/lingua/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
Note: Python uses exceptions while TypeScript uses Zod-style result objects.
"""

from typing import Any, Dict
from typing import Any

# Import the native conversion functions
from lingua._lingua import (
Expand Down Expand Up @@ -65,7 +65,9 @@ def chat_completions_messages_to_lingua(messages: list) -> list:
try:
return _chat_completions_messages_to_lingua(messages)
except Exception as e:
raise ConversionError(f"Failed to convert chat completions messages to Lingua: {e}") from e
raise ConversionError(
f"Failed to convert chat completions messages to Lingua: {e}"
) from e


def lingua_to_chat_completions_messages(messages: list) -> list:
Expand All @@ -84,7 +86,9 @@ def lingua_to_chat_completions_messages(messages: list) -> list:
try:
return _lingua_to_chat_completions_messages(messages)
except Exception as e:
raise ConversionError(f"Failed to convert Lingua to chat completions messages: {e}") from e
raise ConversionError(
f"Failed to convert Lingua to chat completions messages: {e}"
) from e


# ============================================================================
Expand All @@ -107,7 +111,9 @@ def responses_messages_to_lingua(messages: list) -> list:
try:
return _responses_messages_to_lingua(messages)
except Exception as e:
raise ConversionError(f"Failed to convert responses messages to Lingua: {e}") from e
raise ConversionError(
f"Failed to convert responses messages to Lingua: {e}"
) from e


def lingua_to_responses_messages(messages: list) -> list:
Expand All @@ -126,7 +132,9 @@ def lingua_to_responses_messages(messages: list) -> list:
try:
return _lingua_to_responses_messages(messages)
except Exception as e:
raise ConversionError(f"Failed to convert Lingua to responses messages: {e}") from e
raise ConversionError(
f"Failed to convert Lingua to responses messages: {e}"
) from e


# ============================================================================
Expand All @@ -149,7 +157,9 @@ def anthropic_messages_to_lingua(messages: list) -> list:
try:
return _anthropic_messages_to_lingua(messages)
except Exception as e:
raise ConversionError(f"Failed to convert Anthropic messages to Lingua: {e}") from e
raise ConversionError(
f"Failed to convert Anthropic messages to Lingua: {e}"
) from e


def lingua_to_anthropic_messages(messages: list) -> list:
Expand All @@ -168,7 +178,9 @@ def lingua_to_anthropic_messages(messages: list) -> list:
try:
return _lingua_to_anthropic_messages(messages)
except Exception as e:
raise ConversionError(f"Failed to convert Lingua to Anthropic messages: {e}") from e
raise ConversionError(
f"Failed to convert Lingua to Anthropic messages: {e}"
) from e


# ============================================================================
Expand Down Expand Up @@ -400,36 +412,28 @@ def validate_anthropic_response(json_str: str) -> Any:
__all__ = [
# Error handling
"ConversionError",

# Chat Completions API conversions
"chat_completions_messages_to_lingua",
"lingua_to_chat_completions_messages",

# Responses API conversions
"responses_messages_to_lingua",
"lingua_to_responses_messages",

# Anthropic conversions
"anthropic_messages_to_lingua",
"lingua_to_anthropic_messages",

# Processing functions
"deduplicate_messages",
"import_messages_from_spans",
"import_and_deduplicate_messages",

# Chat Completions validation
"validate_chat_completions_request",
"validate_chat_completions_response",

# Responses API validation
"validate_responses_request",
"validate_responses_response",

# OpenAI validation (deprecated - use Chat Completions or Responses instead)
"validate_openai_request",
"validate_openai_response",

# Anthropic validation
"validate_anthropic_request",
"validate_anthropic_response",
Expand Down
63 changes: 49 additions & 14 deletions bindings/python/src/lingua/__init__.pyi
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"""Type stubs for Lingua Python bindings"""

from typing import Any, Dict
from typing import Any


# ============================================================================
# Error types
Expand All @@ -10,48 +11,82 @@ class ConversionError(Exception):
"""Error during format conversion"""
...


# ============================================================================
# Chat Completions API conversions
# ============================================================================

def chat_completions_messages_to_lingua(messages: list) -> list: ...
def lingua_to_chat_completions_messages(messages: list) -> list: ...
def chat_completions_messages_to_lingua(messages: list) -> list:
...


def lingua_to_chat_completions_messages(messages: list) -> list:
...


# ============================================================================
# Responses API conversions
# ============================================================================

def responses_messages_to_lingua(messages: list) -> list: ...
def lingua_to_responses_messages(messages: list) -> list: ...
def responses_messages_to_lingua(messages: list) -> list:
...


def lingua_to_responses_messages(messages: list) -> list:
...


# ============================================================================
# Anthropic conversions
# ============================================================================

def anthropic_messages_to_lingua(messages: list) -> list: ...
def lingua_to_anthropic_messages(messages: list) -> list: ...
def anthropic_messages_to_lingua(messages: list) -> list:
...


def lingua_to_anthropic_messages(messages: list) -> list:
...


# ============================================================================
# Processing functions
# ============================================================================

def deduplicate_messages(messages: list) -> list: ...
def import_messages_from_spans(spans: list) -> list: ...
def import_and_deduplicate_messages(spans: list) -> list: ...
def deduplicate_messages(messages: list) -> list:
...


def import_messages_from_spans(spans: list) -> list:
...


def import_and_deduplicate_messages(spans: list) -> list:
...


# ============================================================================
# OpenAI validation
# ============================================================================

def validate_openai_request(json_str: str) -> Any: ...
def validate_openai_response(json_str: str) -> Any: ...
def validate_openai_request(json_str: str) -> Any:
...


def validate_openai_response(json_str: str) -> Any:
...


# ============================================================================
# Anthropic validation
# ============================================================================

def validate_anthropic_request(json_str: str) -> Any: ...
def validate_anthropic_response(json_str: str) -> Any: ...
def validate_anthropic_request(json_str: str) -> Any:
...


def validate_anthropic_response(json_str: str) -> Any:
...


# ============================================================================
# Exports
Expand Down
45 changes: 29 additions & 16 deletions bindings/python/tests/test_roundtrip.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
import json
import os
from pathlib import Path
from typing import Any, Dict, List, Optional
from typing import Any

import pytest

Expand All @@ -31,9 +31,9 @@ def __init__(
name: str,
provider: str,
turn: str,
request: Optional[Dict] = None,
response: Optional[Dict] = None,
streaming_response: Optional[List] = None,
request: dict | None = None,
response: dict | None = None,
streaming_response: list | None = None,
):
self.name = name
self.provider = provider
Expand All @@ -43,12 +43,17 @@ def __init__(
self.streaming_response = streaming_response


def load_test_snapshots(test_case_name: str) -> List[Snapshot]:
def load_test_snapshots(test_case_name: str) -> list[Snapshot]:
"""Load all snapshots for a given test case"""
snapshots: List[Snapshot] = []
snapshots: list[Snapshot] = []

# Snapshots are in the payloads directory (3 levels up from tests/)
snapshots_dir = Path(__file__).parent.parent.parent.parent / "payloads" / "snapshots" / test_case_name
snapshots_dir = (
Path(__file__).parent.parent.parent.parent
/ "payloads"
/ "snapshots"
/ test_case_name
)

if not snapshots_dir.exists():
return snapshots
Expand Down Expand Up @@ -95,8 +100,7 @@ def load_test_snapshots(test_case_name: str) -> List[Snapshot]:
# Try newline-delimited JSON
lines = [
json.loads(line)
for line in content.split("\n")
if line.strip()
for line in content.split("\n") if line.strip()
]
snapshot_data["streaming_response"] = lines

Expand All @@ -119,7 +123,9 @@ def normalize_for_comparison(obj: Any) -> Any:

if isinstance(obj, list):
# Remove None from arrays and recursively normalize
normalized = [normalize_for_comparison(item) for item in obj if item is not None]
normalized = [
normalize_for_comparison(item) for item in obj if item is not None
]
# Return None for empty arrays to remove them
return normalized if normalized else None

Expand All @@ -138,7 +144,7 @@ def normalize_for_comparison(obj: Any) -> Any:
return obj


def perform_openai_roundtrip(openai_message: Dict) -> Dict[str, Any]:
def perform_openai_roundtrip(openai_message: dict) -> dict[str, Any]:
"""
Perform roundtrip conversion: Chat Completions -> Lingua -> Chat Completions

Expand All @@ -154,10 +160,14 @@ def perform_openai_roundtrip(openai_message: Dict) -> Dict[str, Any]:
lingua_msg = chat_completions_messages_to_lingua([openai_message])[0]
roundtripped = lingua_to_chat_completions_messages([lingua_msg])[0]

return {"original": openai_message, "lingua": lingua_msg, "roundtripped": roundtripped}
return {
"original": openai_message,
"lingua": lingua_msg,
"roundtripped": roundtripped,
}


def perform_anthropic_roundtrip(anthropic_message: Dict) -> Dict[str, Any]:
def perform_anthropic_roundtrip(anthropic_message: dict) -> dict[str, Any]:
"""
Perform roundtrip conversion: Anthropic -> Lingua -> Anthropic

Expand Down Expand Up @@ -217,7 +227,10 @@ def test_openai_roundtrips(self, test_cases):
snapshots = load_test_snapshots(test_case)

for snapshot in snapshots:
if snapshot.provider != "openai-chat-completions" or not snapshot.request:
if (
snapshot.provider != "openai-chat-completions"
or not snapshot.request
):
continue

messages = snapshot.request.get("messages", [])
Expand Down Expand Up @@ -305,8 +318,8 @@ def test_coverage(self, test_cases):

for test_case in test_cases:
snapshots = load_test_snapshots(test_case)
providers = list(set(s.provider for s in snapshots))
turns = list(set(s.turn for s in snapshots))
providers = list({s.provider for s in snapshots})
turns = list({s.turn for s in snapshots})

coverage[test_case] = {"providers": providers, "turns": turns}

Expand Down