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: 2 additions & 2 deletions .github/workflows/ci-fast.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
56 changes: 56 additions & 0 deletions crates/alien-ai-gateway/src/protocol.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -868,6 +872,9 @@ fn chat_request_to_messages(payload: Value) -> Result<Value> {
}
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 }));
Expand Down Expand Up @@ -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(
Expand Down
25 changes: 20 additions & 5 deletions crates/alien-ai-gateway/tests/integration.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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"}]}"#);
Expand All @@ -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");
Expand All @@ -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());
}
Expand Down
Loading