diff --git a/.github/workflows/ci-fast.yml b/.github/workflows/ci-fast.yml index ef6012f5e..1bb579b9e 100644 --- a/.github/workflows/ci-fast.yml +++ b/.github/workflows/ci-fast.yml @@ -112,8 +112,8 @@ jobs: # reports "runner lost communication" with no logs). Full-width # parallelism (16 rustc jobs, then 16 concurrent test binaries — many # of which spawn servers and embedded databases) is what spikes it. - CARGO_BUILD_JOBS: "12" - NEXTEST_TEST_THREADS: "12" + CARGO_BUILD_JOBS: "8" + NEXTEST_TEST_THREADS: "8" steps: - uses: actions/checkout@v7 diff --git a/crates/alien-ai-gateway/src/protocol.rs b/crates/alien-ai-gateway/src/protocol.rs index dd94c7194..1f5893876 100644 --- a/crates/alien-ai-gateway/src/protocol.rs +++ b/crates/alien-ai-gateway/src/protocol.rs @@ -12,6 +12,10 @@ use serde_json::{json, Map, Value}; use crate::error::{ErrorData, Result}; +// OpenAI leaves its output ceiling optional, while Anthropic requires one. +// Use a bounded portable default rather than making valid OpenAI requests fail. +const DEFAULT_MAX_OUTPUT_TOKENS: u64 = 4_096; + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum WireProtocol { ChatCompletions, @@ -868,6 +872,9 @@ fn chat_request_to_messages(payload: Value) -> Result { } rename(&mut obj, "max_completion_tokens", "max_tokens"); rename(&mut obj, "max_tokens", "max_tokens"); + if obj.get("max_tokens").is_none_or(Value::is_null) { + obj.insert("max_tokens".to_string(), json!(DEFAULT_MAX_OUTPUT_TOKENS)); + } rename(&mut obj, "stop", "stop_sequences"); if let Some(user) = obj.remove("user") { obj.insert("metadata".to_string(), json!({ "user_id": user })); @@ -1290,6 +1297,55 @@ mod tests { assert_eq!(translated["max_completion_tokens"], 42); } + #[test] + fn optional_openai_output_limits_become_required_messages_limits() { + for (source, request) in [ + ( + WireProtocol::ChatCompletions, + json!({ "model": "model", "messages": [{ "role": "user", "content": "hello" }] }), + ), + ( + WireProtocol::ChatCompletions, + json!({ "model": "model", "max_completion_tokens": null, "messages": [{ "role": "user", "content": "hello" }] }), + ), + ( + WireProtocol::Responses, + json!({ "model": "model", "input": "hello" }), + ), + ( + WireProtocol::Responses, + json!({ "model": "model", "max_output_tokens": null, "input": "hello" }), + ), + ] { + let translated = translate_request(request, source, WireProtocol::Messages).unwrap(); + + assert_eq!(translated["max_tokens"], DEFAULT_MAX_OUTPUT_TOKENS); + } + } + + #[test] + fn explicit_openai_output_limits_are_preserved_for_messages() { + for (source, request) in [ + ( + WireProtocol::ChatCompletions, + json!({ "model": "model", "max_completion_tokens": 123, "messages": [] }), + ), + ( + WireProtocol::Responses, + json!({ "model": "model", "max_output_tokens": 456, "input": "hello" }), + ), + ] { + let expected = match source { + WireProtocol::ChatCompletions => 123, + WireProtocol::Responses => 456, + WireProtocol::Messages => unreachable!(), + }; + let translated = translate_request(request, source, WireProtocol::Messages).unwrap(); + + assert_eq!(translated["max_tokens"], expected); + } + } + #[test] fn stateful_responses_input_is_rejected_instead_of_losing_context() { let error = translate_request( diff --git a/crates/alien-ai-gateway/tests/integration.rs b/crates/alien-ai-gateway/tests/integration.rs index 2c47f54ae..c532df267 100644 --- a/crates/alien-ai-gateway/tests/integration.rs +++ b/crates/alien-ai-gateway/tests/integration.rs @@ -263,13 +263,26 @@ async fn body_past_the_cap_never_reaches_the_upstream() { #[tokio::test] async fn direct_anthropic_translates_chat_and_injects_only_its_api_key() { let upstream = MockServer::start_async().await; - let messages = upstream + let native_messages = upstream .mock_async(|when, then| { when.method(POST) .path("/v1/messages") .header("x-api-key", "sk-ant-api03-test-secret") .header("anthropic-version", "2023-06-01") - .body_contains("claude-sonnet-4-6"); + .body_contains("\"max_tokens\":1"); + then.status(200) + .header("content-type", "application/json") + .body(r#"{"id":"msg_direct","content":[{"type":"text","text":"pong"}]}"#); + }) + .await; + let translated_messages = upstream + .mock_async(|when, then| { + when.method(POST) + .path("/v1/messages") + .header("x-api-key", "sk-ant-api03-test-secret") + .header("anthropic-version", "2023-06-01") + .body_contains("\"max_tokens\":4096") + .body_contains("\"text\":\"hi\""); then.status(200) .header("content-type", "application/json") .body(r#"{"id":"msg_direct","content":[{"type":"text","text":"pong"}]}"#); @@ -294,11 +307,13 @@ async fn direct_anthropic_translates_chat_and_injects_only_its_api_key() { .expect("direct request"); assert_eq!(response.status(), 200); assert!(response.text().await.unwrap().contains("msg_direct")); - messages.assert_async().await; + native_messages.assert_async().await; let translated_protocol = client .post(format!("{base}/direct/v1/chat/completions")) - .json(&json!({"model": "claude-sonnet-4.6", "messages": []})) + .json( + &json!({"model": "claude-sonnet-4.6", "messages": [{"role": "user", "content": "hi"}]}), + ) .send() .await .expect("translated protocol response"); @@ -310,7 +325,7 @@ async fn direct_anthropic_translates_chat_and_injects_only_its_api_key() { .unwrap()["object"], "chat.completion" ); - assert_eq!(messages.hits_async().await, 2); + translated_messages.assert_async().await; assert!(route_from_direct_anthropic("direct", "sk-ant-admin-test").is_err()); }