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
106 changes: 63 additions & 43 deletions apis/src/anthropic/to_openai/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -114,9 +114,9 @@ impl HttpFilter for AnthropicToOpenaiFilter {
.as_ref()
.map_or(500, |response| response.status.as_u16());
ctx.set_metadata(RESPONSE_STATUS_KEY, status.to_string());
if let Some(request_id) = request_id {
ctx.set_metadata(RESPONSE_REQUEST_ID_KEY, request_id);
}
}
if let Some(request_id) = request_id {
ctx.set_metadata(RESPONSE_REQUEST_ID_KEY, request_id);
}

ctx.set_response_body_mode(BodyMode::StreamBuffer {
Expand Down Expand Up @@ -184,9 +184,11 @@ impl HttpFilter for AnthropicToOpenaiFilter {
let request_model = ctx
.filter_metadata
.get("anthropic_to_openai.model")
.cloned()
.unwrap_or_default();
transform_non_streaming_body(ctx, body, &request_model);
.map_or("", String::as_str);
let request_id = ctx.get_metadata(RESPONSE_REQUEST_ID_KEY);
if let Some(finish_reason) = transform_non_streaming_body(body, request_model, request_id) {
ctx.set_metadata("openai.finish_reason", finish_reason);
}
}

Ok(FilterAction::Continue)
Expand Down Expand Up @@ -333,32 +335,33 @@ fn transform_error_body(body: &mut Option<Bytes>, status: http::StatusCode, requ
}

/// Apply non-streaming JSON transformation to the response body.
fn transform_non_streaming_body(ctx: &mut HttpFilterContext<'_>, body: &mut Option<Bytes>, request_model: &str) {
let bytes = match body.as_ref() {
Some(b) => b.as_ref(),
None => return,
};

if bytes.is_empty() {
return;
}

match response::transform_response(bytes, request_model) {
fn transform_non_streaming_body(
body: &mut Option<Bytes>,
request_model: &str,
request_id: Option<&str>,
) -> Option<String> {
match response::transform_response(body.as_deref().unwrap_or_default(), request_model) {
Ok(result) => {
debug!(
original_len = bytes.len(),
original_len = body.as_ref().map_or(0, Bytes::len),
transformed_len = result.body.len(),
original_finish_reason = result.original_finish_reason.as_str(),
"transformed Chat Completions-compatible response to Anthropic"
);
ctx.set_metadata("openai.finish_reason", result.original_finish_reason);
*body = Some(Bytes::from(result.body));
Some(result.original_finish_reason)
},
Err(msg) => {
warn!(
error = msg.as_str(),
"failed to transform Chat Completions-compatible response"
);
*body = Some(Bytes::from(wire::error_body(
"api_error",
"upstream response could not be transformed",
request_id,
)));
None
},
}
}
Expand Down Expand Up @@ -819,56 +822,73 @@ mod tests {
// --- transform_non_streaming_body ---

#[test]
fn transform_non_streaming_body_none_is_noop() {
let request = make_request(Method::POST, "/v1/messages");
let mut ctx = make_filter_context(&request);
fn transform_non_streaming_body_missing_body_returns_api_error() {
let mut body: Option<Bytes> = None;

transform_non_streaming_body(&mut ctx, &mut body, "gpt-4");
let finish_reason = transform_non_streaming_body(&mut body, "gpt-4", None);
let parsed: serde_json::Value = serde_json::from_slice(body.as_deref().unwrap()).unwrap();

assert!(body.is_none());
assert!(finish_reason.is_none());
assert_eq!(parsed["type"], "error");
assert_eq!(parsed["error"]["type"], "api_error");
assert!(parsed["request_id"].is_null());
}

#[test]
fn transform_non_streaming_body_empty_bytes_is_noop() {
let request = make_request(Method::POST, "/v1/messages");
let mut ctx = make_filter_context(&request);
fn transform_non_streaming_body_empty_bytes_returns_api_error() {
let mut body = Some(Bytes::new());

transform_non_streaming_body(&mut ctx, &mut body, "gpt-4");
let finish_reason = transform_non_streaming_body(&mut body, "gpt-4", None);
let parsed: serde_json::Value = serde_json::from_slice(body.as_deref().unwrap()).unwrap();

assert_eq!(body.as_ref().unwrap().len(), 0, "empty bytes should not be transformed");
assert!(finish_reason.is_none());
assert_eq!(parsed["type"], "error");
assert_eq!(parsed["error"]["type"], "api_error");
}

#[test]
fn transform_non_streaming_body_success() {
let request = make_request(Method::POST, "/v1/messages");
let mut ctx = make_filter_context(&request);
let response_json = br#"{"id":"chatcmpl-1","model":"gpt-4","choices":[{"message":{"role":"assistant","content":"Hello!"},"finish_reason":"stop"}],"usage":{"prompt_tokens":10,"completion_tokens":5}}"#;
let mut body = Some(Bytes::from(response_json.to_vec()));

transform_non_streaming_body(&mut ctx, &mut body, "gpt-4");
let finish_reason = transform_non_streaming_body(&mut body, "gpt-4", None);

assert!(body.is_some());
let parsed: serde_json::Value = serde_json::from_slice(body.unwrap().as_ref()).unwrap();
assert_eq!(parsed["type"], "message");
assert_eq!(parsed["content"][0]["text"], "Hello!");
assert_eq!(
ctx.filter_metadata.get("openai.finish_reason").unwrap(),
"stop",
"finish_reason should be stored in metadata"
);
assert_eq!(finish_reason.as_deref(), Some("stop"));
}

#[test]
fn transform_non_streaming_body_invalid_json_preserves_body() {
#[tokio::test]
async fn malformed_non_streaming_success_returns_anthropic_api_error() {
let yaml: serde_yaml::Value = serde_yaml::from_str("{}").unwrap();
let filter = AnthropicToOpenaiFilter::from_config(&yaml).unwrap();
let request = make_request(Method::POST, "/v1/messages");
let mut ctx = make_filter_context(&request);
let original = Bytes::from_static(b"not json");
let mut body = Some(original.clone());
ctx.set_metadata("anthropic_to_openai.streaming", "false");
ctx.set_metadata("anthropic_to_openai.model", "gpt-4");
let mut response = make_response();
response
.headers
.insert("x-request-id", "req_malformed".parse().unwrap());
ctx.response_header = Some(&mut response);

let action = filter.on_response(&mut ctx).await.unwrap();

assert!(matches!(action, FilterAction::Continue));
assert!(matches!(ctx.response_body_mode, BodyMode::StreamBuffer { .. }));
ctx.response_header = None;

transform_non_streaming_body(&mut ctx, &mut body, "gpt-4");
let mut body = Some(Bytes::from_static(b"not json"));
let action = filter.on_response_body(&mut ctx, &mut body, true).unwrap();
let parsed: serde_json::Value = serde_json::from_slice(body.as_deref().unwrap()).unwrap();

assert_eq!(body, Some(original), "body should not be modified on error");
assert!(matches!(action, FilterAction::Continue));
assert_eq!(parsed["type"], "error");
assert_eq!(parsed["error"]["type"], "api_error");
assert_eq!(parsed["error"]["message"], "upstream response could not be transformed");
assert_eq!(parsed["request_id"], "req_malformed");
assert!(!ctx.filter_metadata.contains_key("openai.finish_reason"));
}
}
3 changes: 2 additions & 1 deletion tests/integration/fixtures/inference/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,13 +19,14 @@ than editing the table.
<!-- BEGIN GENERATED INFERENCE COVERAGE -->
<!-- Generated by `cargo xtask sync-inference-readme`; do not edit. -->

The manifest declares **13 features** across **5 scopes**, linked to **11 scenarios**.
The manifest declares **14 features** across **5 scopes**, linked to **12 scenarios**.

| Scope | Feature | Status | Scenarios | Provider coverage |
| --- | --- | --- | --- | --- |
| `messages_to_chat_completions` | `messages.request.minimal` | `live_covered` | `messages/basic-nonstream`<br>`messages/basic-stream` | `openai`: `covered`<br>`vllm`: `live_covered` |
| `messages_to_chat_completions` | `messages.response.text` | `live_covered` | `messages/basic-nonstream`<br>`messages/basic-stream` | `openai`: `covered`<br>`vllm`: `live_covered` |
| `messages_to_chat_completions` | `messages.error.upstream` | `synthetic_only` | `messages/upstream-error` | `synthetic`: `synthetic_only` |
| `messages_to_chat_completions` | `messages.error.malformed_success` | `synthetic_only` | `messages/malformed-success` | `synthetic`: `synthetic_only` |
| `messages_native_passthrough` | `messages.native.request` | `live_covered` | `messages/native-basic-nonstream`<br>`messages/native-basic-stream`<br>`messages/native-tool-use` | `anthropic`: `live_covered` |
| `messages_native_passthrough` | `messages.native.response.text` | `live_covered` | `messages/native-basic-nonstream`<br>`messages/native-basic-stream` | `anthropic`: `live_covered` |
| `messages_native_passthrough` | `messages.native.tool_use` | `live_covered` | `messages/native-tool-use` | `anthropic`: `live_covered` |
Expand Down
9 changes: 9 additions & 0 deletions tests/integration/fixtures/inference/coverage.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,15 @@ features:
providers:
synthetic:
status: synthetic_only
- id: messages.error.malformed_success
scopes:
- messages_to_chat_completions
status: synthetic_only
scenarios:
- messages/malformed-success
providers:
synthetic:
status: synthetic_only
- id: messages.native.request
scopes:
- messages_native_passthrough
Expand Down

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

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

28 changes: 28 additions & 0 deletions tests/integration/tests/suite/examples/anthropic_messages.rs
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,34 @@ fn anthropic_to_openai_transforms_response_body() {
);
}

#[test]
fn anthropic_to_openai_replaces_malformed_success_body() {
let backend = Backend::fixed("not json")
.header("content-type", "application/json")
.header("x-request-id", "req_malformed")
.start_with_shutdown();
let proxy_port = free_port();
let config = load_example_config(
"anthropic/messages-to-openai.yaml",
proxy_port,
HashMap::from([("127.0.0.1:8000", backend.port())]),
);
let proxy = start_proxy(&config);
let request_body = serde_json::json!({
"model": "claude-opus-4-8",
"max_tokens": 64,
"messages": [{"role": "user", "content": "Hello"}],
});

let raw = http_send(proxy.addr(), &json_post("/v1/messages", &request_body.to_string()));
let parsed: serde_json::Value = serde_json::from_str(&parse_body(&raw)).expect("fallback response should be JSON");

assert_eq!(parsed["type"], "error");
assert_eq!(parsed["error"]["type"], "api_error");
assert_eq!(parsed["error"]["message"], "upstream response could not be transformed");
assert_eq!(parsed["request_id"], "req_malformed");
}

fn run_anthropic_to_openai_error(status: u16, response_body: &str, stream: bool) -> (u16, serde_json::Value) {
let backend = Backend::status(status, response_body)
.header("content-type", "application/json")
Expand Down
Loading
Loading