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
8 changes: 5 additions & 3 deletions .github/workflows/cross-transformation-coverage.yml
Original file line number Diff line number Diff line change
Expand Up @@ -42,11 +42,13 @@ jobs:
sudo apt-get update
sudo apt-get install -y protobuf-compiler

- name: Verify no unexpected failures
run: |
cargo test -p coverage-report --test cross_provider_test -- --nocapture

- name: Generate coverage report
run: |
cargo run -p coverage-report > coverage_report.md
# This job is informational only - it always succeeds
# Click into the job summary to see the actual coverage report

- name: Post coverage to job summary
run: |
Expand All @@ -59,4 +61,4 @@ jobs:
with:
name: transformation-coverage-report
path: coverage_report.md
retention-days: 30
retention-days: 30
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ test-typescript-integration: typescript ## Run TypeScript integration tests

test-python: ## Run Python tests
@echo "Running Python tests..."
cd bindings/python && uv run maturin develop --features python
cd bindings/python && uv run pytest tests/ -v

clean: ## Clean build artifacts
Expand Down
95 changes: 93 additions & 2 deletions crates/braintrust-llm-router/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -84,8 +84,8 @@ pub enum Error {
#[error("invalid request: {0}")]
InvalidRequest(String),

#[error("lingua conversion failed: {0}")]
Lingua(String),
#[error("{0}")]
Lingua(#[from] lingua::TransformError),

#[error("authentication error: {0}")]
Auth(String),
Expand All @@ -107,6 +107,32 @@ impl Error {
_ => None,
}
}

/// Returns true if this is a client-side error (400 Bad Request).
///
/// Client errors indicate problems with the user's request that they
/// should fix, such as unknown models, unsupported formats, or invalid payloads.
pub fn is_client_error(&self) -> bool {
matches!(
self,
Error::UnknownModel(_) | Error::NoProvider(_) | Error::InvalidRequest(_)
) || matches!(self, Error::Lingua(e) if e.is_client_error())
}

/// Returns true if this is an authentication error (401 Unauthorized).
///
/// Auth errors indicate missing or invalid authentication credentials.
pub fn is_auth_error(&self) -> bool {
matches!(self, Error::NoAuth(_) | Error::Auth(_))
}

/// Returns true if this is an upstream provider error with HTTP details.
///
/// Upstream errors should be passed through to the client with the original
/// status code, headers, and body from the provider.
pub fn is_upstream_error(&self) -> bool {
matches!(self, Error::Provider { http: Some(_), .. })
}
}

#[cfg(test)]
Expand All @@ -128,4 +154,69 @@ mod tests {
assert_eq!(body, "not found");
assert_eq!(returned_headers, vec![("x-test".into(), "value".into())]);
}

#[test]
fn transform_error_classification() {
use lingua::TransformError;

// Client errors
assert!(TransformError::UnableToDetectFormat.is_client_error());
assert!(TransformError::ValidationFailed {
target: ProviderFormat::OpenAI,
reason: "test".into()
}
.is_client_error());
assert!(TransformError::DeserializationFailed("invalid json".into()).is_client_error());
assert!(TransformError::UnsupportedTargetFormat(ProviderFormat::OpenAI).is_client_error());
assert!(TransformError::UnsupportedSourceFormat(ProviderFormat::OpenAI).is_client_error());

// Conversion errors are client errors (user sent unsupported content)
assert!(TransformError::FromUniversalFailed("test".into()).is_client_error());
assert!(TransformError::ToUniversalFailed("test".into()).is_client_error());

// Server errors (internal issues)
assert!(!TransformError::SerializationFailed("test".into()).is_client_error());
assert!(!TransformError::StreamingNotImplemented("test".into()).is_client_error());
}

#[test]
fn router_error_classification() {
// Client errors
assert!(Error::UnknownModel("gpt-5".into()).is_client_error());
assert!(Error::NoProvider(ProviderFormat::OpenAI).is_client_error());
assert!(Error::InvalidRequest("bad".into()).is_client_error());
assert!(Error::Lingua(lingua::TransformError::UnableToDetectFormat).is_client_error());

// Auth errors
assert!(Error::NoAuth("openai".into()).is_auth_error());
assert!(Error::Auth("invalid".into()).is_auth_error());

// Not client errors
assert!(!Error::Timeout.is_client_error());
assert!(
!Error::Lingua(lingua::TransformError::SerializationFailed("test".into()))
.is_client_error()
);

// Upstream errors
let upstream_err = Error::Provider {
provider: "openai".into(),
source: anyhow::anyhow!("test"),
retry_after: None,
http: Some(UpstreamHttpError {
status: 404,
headers: vec![],
body: "not found".into(),
}),
};
assert!(upstream_err.is_upstream_error());

let non_upstream_err = Error::Provider {
provider: "openai".into(),
source: anyhow::anyhow!("test"),
retry_after: None,
http: None,
};
assert!(!non_upstream_err.is_upstream_error());
}
}
7 changes: 3 additions & 4 deletions crates/braintrust-llm-router/src/router.rs
Original file line number Diff line number Diff line change
Expand Up @@ -154,7 +154,7 @@ impl Router {
Ok(TransformResult::PassThrough(bytes)) => bytes,
Ok(TransformResult::Transformed { bytes, .. }) => bytes,
Err(TransformError::UnsupportedTargetFormat(_)) => body.clone(),
Err(e) => return Err(Error::Lingua(e.to_string())),
Err(e) => return Err(e.into()),
};

let response_bytes = self
Expand All @@ -168,8 +168,7 @@ impl Router {
)
.await?;

let result = lingua::transform_response(response_bytes.clone(), output_format)
.map_err(|e| Error::Lingua(e.to_string()))?;
let result = lingua::transform_response(response_bytes.clone(), output_format)?;

let response = match result {
TransformResult::PassThrough(bytes) => bytes,
Expand Down Expand Up @@ -211,7 +210,7 @@ impl Router {
Ok(TransformResult::PassThrough(bytes)) => bytes,
Ok(TransformResult::Transformed { bytes, .. }) => bytes,
Err(TransformError::UnsupportedTargetFormat(_)) => body.clone(),
Err(e) => return Err(Error::Lingua(e.to_string())),
Err(e) => return Err(e.into()),
};

let raw_stream = provider
Expand Down
2 changes: 1 addition & 1 deletion crates/braintrust-llm-router/src/streaming.rs
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ pub fn transform_stream(raw: RawResponseStream, output_format: ProviderFormat) -
// Pass through unrecognized formats
Some(Ok(bytes))
}
Err(e) => Some(Err(Error::Lingua(e.to_string()))),
Err(e) => Some(Err(Error::Lingua(e))),
}
}
Err(e) => Some(Err(e)),
Expand Down
5 changes: 5 additions & 0 deletions crates/coverage-report/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,10 @@ edition.workspace = true
publish = false
description = "Cross-provider transformation coverage report generator for Lingua"

[lib]
name = "coverage_report"
path = "src/lib.rs"

[[bin]]
name = "coverage-report"
path = "src/main.rs"
Expand All @@ -14,3 +18,4 @@ lingua = { path = "../lingua", features = ["openai", "anthropic", "google", "bed
serde.workspace = true
big_serde_json.workspace = true
bytes.workspace = true
regex = "1"
Loading