diff --git a/.github/workflows/ci-fast.yml b/.github/workflows/ci-fast.yml index 1bb579b9e2..4139849d47 100644 --- a/.github/workflows/ci-fast.yml +++ b/.github/workflows/ci-fast.yml @@ -235,6 +235,8 @@ jobs: # AI gateway suites that hit real model endpoints: they need .env.test creds. 'and not binary_id(alien-ai-gateway::live_bedrock)' + 'and not binary_id(alien-ai-gateway::live_databricks)' + 'and not binary_id(alien-ai-gateway::live_direct_providers)' 'and not binary_id(alien-ai-gateway::live_foundry_claude)' 'and not binary_id(alien-ai-gateway::live_vertex_claude)' ) diff --git a/.github/workflows/cloud-tests.yml b/.github/workflows/cloud-tests.yml index 2b5191fe56..cf0666a6f3 100644 --- a/.github/workflows/cloud-tests.yml +++ b/.github/workflows/cloud-tests.yml @@ -202,6 +202,11 @@ jobs: env: TF_TOKEN_app_terraform_io: ${{ secrets.TF_API_TOKEN }} AXIOM_TOKEN: ${{ secrets.AXIOM_CI_API_KEY }} + OPENAI_TEST_API_KEY: ${{ secrets.OPENAI_TEST_API_KEY }} + ANTHROPIC_TEST_API_KEY: ${{ secrets.ANTHROPIC_TEST_API_KEY }} + DATABRICKS_TEST_WORKSPACE_URL: ${{ secrets.DATABRICKS_TEST_WORKSPACE_URL }} + DATABRICKS_TEST_CLIENT_ID: ${{ secrets.DATABRICKS_TEST_CLIENT_ID }} + DATABRICKS_TEST_CLIENT_SECRET: ${{ secrets.DATABRICKS_TEST_CLIENT_SECRET }} - name: Mask all .env.test values run: | diff --git a/.github/workflows/e2e-cloud.yml b/.github/workflows/e2e-cloud.yml index 2e12d5459f..37d9d2f462 100644 --- a/.github/workflows/e2e-cloud.yml +++ b/.github/workflows/e2e-cloud.yml @@ -1136,6 +1136,11 @@ jobs: TF_TOKEN_app_terraform_io: ${{ secrets.TF_API_TOKEN }} AXIOM_TOKEN: ${{ secrets.AXIOM_CI_API_KEY }} NGROK_AUTHTOKEN: ${{ secrets.NGROK_AUTHTOKEN }} + OPENAI_TEST_API_KEY: ${{ secrets.OPENAI_TEST_API_KEY }} + ANTHROPIC_TEST_API_KEY: ${{ secrets.ANTHROPIC_TEST_API_KEY }} + DATABRICKS_TEST_WORKSPACE_URL: ${{ secrets.DATABRICKS_TEST_WORKSPACE_URL }} + DATABRICKS_TEST_CLIENT_ID: ${{ secrets.DATABRICKS_TEST_CLIENT_ID }} + DATABRICKS_TEST_CLIENT_SECRET: ${{ secrets.DATABRICKS_TEST_CLIENT_SECRET }} - name: Mask all .env.test values run: | diff --git a/crates/alien-ai-gateway/src/protocol.rs b/crates/alien-ai-gateway/src/protocol.rs index 1f58938762..acd9519cb8 100644 --- a/crates/alien-ai-gateway/src/protocol.rs +++ b/crates/alien-ai-gateway/src/protocol.rs @@ -570,9 +570,32 @@ fn responses_request_to_chat(payload: Value) -> Result { *tool = json!({ "type": "function", "function": function }); } } + if let Some(choice) = obj.remove("tool_choice") { + obj.insert( + "tool_choice".to_string(), + responses_tool_choice_to_chat(choice)?, + ); + } Ok(Value::Object(obj)) } +fn responses_tool_choice_to_chat(choice: Value) -> Result { + if choice.is_string() { + return Ok(choice); + } + let obj = object(choice, "Responses tool_choice")?; + match obj.get("type").and_then(Value::as_str) { + Some("function") => Ok(json!({ + "type": "function", + "function": { "name": obj.get("name").cloned().unwrap_or_else(|| json!("")) } + })), + Some(kind) => Err(invalid(format!( + "Responses tool choice `{kind}` is not portable" + ))), + None => Err(invalid("Responses tool_choice has no `type`")), + } +} + fn responses_input_item_to_chat(item: Value) -> Result { let obj = object(item, "Responses input item")?; match obj.get("type").and_then(Value::as_str).unwrap_or("message") { @@ -913,20 +936,27 @@ fn chat_content_to_anthropic(content: Value) -> Result> { match content { Value::Null => Ok(Vec::new()), Value::String(text) => Ok(vec![json!({ "type": "text", "text": text })]), - Value::Array(parts) => parts.into_iter().map(|part| { - let obj = object(part, "Chat Completions content part")?; - match obj.get("type").and_then(Value::as_str) { - Some("text") => Ok(json!({ "type": "text", "text": obj.get("text").cloned().unwrap_or_else(|| json!("")) })), + Value::Array(parts) => { + let mut converted = Vec::new(); + for part in parts { + let obj = object(part, "Chat Completions content part")?; + match obj.get("type").and_then(Value::as_str) { + Some("text") => converted.push(json!({ "type": "text", "text": obj.get("text").cloned().unwrap_or_else(|| json!("")) })), Some("image_url") => { let url = obj.get("image_url").and_then(|v| v.get("url")).and_then(Value::as_str).ok_or_else(|| invalid("image_url has no URL"))?; let (metadata, data) = url.split_once(",").ok_or_else(|| invalid("Messages translation supports only base64 data image URLs"))?; let media_type = metadata.strip_prefix("data:").and_then(|v| v.strip_suffix(";base64")).ok_or_else(|| invalid("image URL is not base64 data"))?; - Ok(json!({ "type": "image", "source": { "type": "base64", "media_type": media_type, "data": data } })) + converted.push(json!({ "type": "image", "source": { "type": "base64", "media_type": media_type, "data": data } })); + } + // Anthropic thinking blocks require a provider signature. An + // unsigned Chat reasoning summary cannot be forwarded as one. + Some("reasoning") => {} + Some(kind) => return Err(invalid(format!("Chat content type `{kind}` is not portable"))), + None => return Err(invalid("Chat content part has no `type`")), } - Some(kind) => Err(invalid(format!("Chat content type `{kind}` is not portable"))), - None => Err(invalid("Chat content part has no `type`")), } - }).collect(), + Ok(converted) + } _ => Err(invalid("Chat message content must be a string or array")), } } @@ -1007,9 +1037,26 @@ fn chat_request_to_responses(payload: Value) -> Result { }); } } + if let Some(choice) = obj.remove("tool_choice") { + obj.insert( + "tool_choice".to_string(), + chat_tool_choice_to_responses(choice)?, + ); + } Ok(Value::Object(obj)) } +fn chat_tool_choice_to_responses(choice: Value) -> Result { + if choice.is_string() { + return Ok(choice); + } + let name = choice + .pointer("/function/name") + .cloned() + .ok_or_else(|| invalid("Chat tool choice has no function name"))?; + Ok(json!({ "type": "function", "name": name })) +} + fn chat_messages_to_responses(messages: Value) -> Result { let messages = messages .as_array() @@ -1056,15 +1103,21 @@ fn chat_content_to_responses(content: Value, role: &str) -> Result { match content { Value::Null => Ok(Value::Array(Vec::new())), Value::String(text) => Ok(json!([{ "type": format!("{prefix}_text"), "text": text }])), - Value::Array(parts) => Ok(Value::Array(parts.into_iter().map(|part| { - let obj = object(part, "Chat content part")?; - match obj.get("type").and_then(Value::as_str) { - Some("text") => Ok(json!({ "type": format!("{prefix}_text"), "text": obj.get("text").cloned().unwrap_or_else(|| json!("")) })), - Some("image_url") if prefix == "input" => Ok(json!({ "type": "input_image", "image_url": obj.get("image_url").and_then(|v| v.get("url")).cloned().unwrap_or_else(|| json!("")) })), - Some(kind) => Err(invalid(format!("Chat content type `{kind}` is not portable to Responses"))), - None => Err(invalid("Chat content part has no type")), + Value::Array(parts) => { + let mut converted = Vec::new(); + for part in parts { + let obj = object(part, "Chat content part")?; + match obj.get("type").and_then(Value::as_str) { + Some("text") => converted.push(json!({ "type": format!("{prefix}_text"), "text": obj.get("text").cloned().unwrap_or_else(|| json!("")) })), + Some("image_url") if prefix == "input" => converted.push(json!({ "type": "input_image", "image_url": obj.get("image_url").and_then(|v| v.get("url")).cloned().unwrap_or_else(|| json!("")) })), + // Provider reasoning becomes a top-level Responses output item. + Some("reasoning") if prefix == "output" => {} + Some(kind) => return Err(invalid(format!("Chat content type `{kind}` is not portable to Responses"))), + None => return Err(invalid("Chat content part has no type")), + } } - }).collect::>>()?)), + Ok(Value::Array(converted)) + } _ => Err(invalid("Chat message content must be a string or array")), } } @@ -1217,9 +1270,24 @@ fn chat_response_to_responses(payload: Value, requested_model: &str) -> Result>(); + output.push( json!({ "type": "message", "id": "msg_translated", "status": "completed", "role": "assistant", "content": content }), - ]; + ); if let Some(calls) = message.get("tool_calls").and_then(Value::as_array) { output.extend(calls.iter().map(|call| json!({ "type": "function_call", "id": call.get("id").cloned().unwrap_or_else(|| json!("call")), "call_id": call.get("id").cloned().unwrap_or_else(|| json!("call")), "name": call.pointer("/function/name").cloned().unwrap_or_else(|| json!("")), "arguments": call.pointer("/function/arguments").cloned().unwrap_or_else(|| json!("{}")), "status": "completed" }))); } @@ -1297,6 +1365,100 @@ mod tests { assert_eq!(translated["max_completion_tokens"], 42); } + #[test] + fn named_tool_choice_translates_between_responses_and_chat() { + let chat = translate_request( + json!({ + "model": "model", + "input": "use the tool", + "tool_choice": {"type": "function", "name": "get_code"} + }), + WireProtocol::Responses, + WireProtocol::ChatCompletions, + ) + .unwrap(); + assert_eq!( + chat["tool_choice"], + json!({"type": "function", "function": {"name": "get_code"}}) + ); + + let responses = translate_request( + json!({ + "model": "model", + "messages": [], + "tool_choice": {"type": "function", "function": {"name": "get_code"}} + }), + WireProtocol::ChatCompletions, + WireProtocol::Responses, + ) + .unwrap(); + assert_eq!( + responses["tool_choice"], + json!({"type": "function", "name": "get_code"}) + ); + } + + #[test] + fn chat_reasoning_and_text_become_responses_output_items() { + let translated = translate_response( + json!({ + "id": "chatcmpl_1", + "created": 1, + "choices": [{ + "message": { + "role": "assistant", + "content": [ + {"type": "reasoning", "summary": [{"type": "summary_text", "text": "brief rationale"}]}, + {"type": "text", "text": "ALIEN_OK"} + ] + }, + "finish_reason": "stop" + }], + "usage": {"prompt_tokens": 2, "completion_tokens": 3} + }), + WireProtocol::ChatCompletions, + WireProtocol::Responses, + "gpt-oss-120b", + ) + .unwrap(); + + assert_eq!(translated["output"][0]["type"], "reasoning"); + assert_eq!( + translated["output"][0]["summary"][0]["text"], + "brief rationale" + ); + assert_eq!(translated["output"][1]["content"][0]["text"], "ALIEN_OK"); + } + + #[test] + fn unsigned_chat_reasoning_is_omitted_from_messages_but_text_is_preserved() { + let translated = translate_response( + json!({ + "id": "chatcmpl_1", + "choices": [{ + "message": { + "role": "assistant", + "content": [ + {"type": "reasoning", "summary": [{"type": "summary_text", "text": "brief rationale"}]}, + {"type": "text", "text": "ALIEN_OK"} + ] + }, + "finish_reason": "stop" + }], + "usage": {"prompt_tokens": 2, "completion_tokens": 3} + }), + WireProtocol::ChatCompletions, + WireProtocol::Messages, + "gpt-oss-120b", + ) + .unwrap(); + + assert_eq!( + translated["content"], + json!([{"type": "text", "text": "ALIEN_OK"}]) + ); + } + #[test] fn optional_openai_output_limits_become_required_messages_limits() { for (source, request) in [ diff --git a/crates/alien-ai-gateway/src/router/mod.rs b/crates/alien-ai-gateway/src/router/mod.rs index cc3aa8188b..3952a7eb55 100644 --- a/crates/alien-ai-gateway/src/router/mod.rs +++ b/crates/alien-ai-gateway/src/router/mod.rs @@ -641,6 +641,17 @@ fn translate_for_provider( translate_request(payload, client_protocol, provider_api) } +/// Databricks' serving-endpoint and MLflow Chat APIs reject OpenAI's newer +/// `max_completion_tokens` field and still require `max_tokens`. +fn normalize_databricks_chat_payload(payload: &mut Value) { + let Some(obj) = payload.as_object_mut() else { + return; + }; + if let Some(limit) = obj.remove("max_completion_tokens") { + obj.entry("max_tokens".to_string()).or_insert(limit); + } +} + fn usage_client_api(client_api: ClientApi) -> AiUsageClientApi { match client_api { ClientApi::OpenAiChatCompletions => AiUsageClientApi::OpenAiChatCompletions, @@ -892,8 +903,8 @@ async fn proxy( binding: binding.clone(), }) })?; - let provider_model = direct.upstream_id; - let provider_protocol = provider_protocol(direct.client_apis, client_api); + let provider_model = direct.upstream.model_id(); + let provider_protocol = WireProtocol::from(direct.upstream.client_api()); let descriptor = usage_context( &binding, AiUsageProvider::Databricks, @@ -908,30 +919,36 @@ async fn proxy( let client_protocol = WireProtocol::from(client_api); let response = async { payload = translate_for_provider(payload, client_api, provider_protocol)?; - let response = match provider_protocol { - WireProtocol::ChatCompletions => { + if provider_protocol == WireProtocol::ChatCompletions { + normalize_databricks_chat_payload(&mut payload); + } + let response = match direct.upstream { + ai_catalog::DirectDatabricksUpstream::ServingEndpointChat { endpoint } => { + debug_assert_eq!(endpoint, provider_model); let path = format!("/serving-endpoints/{provider_model}/invocations"); proxy_direct_openai(&state.client, route, payload, provider_model, &path) .await } - WireProtocol::Messages => { - proxy_direct_anthropic_at( + ai_catalog::DirectDatabricksUpstream::ModelServiceChat { service } => { + debug_assert_eq!(service, provider_model); + proxy_direct_openai( &state.client, route, payload, provider_model, - &headers, - "/ai-gateway/anthropic/v1/messages", + "/ai-gateway/mlflow/v1/chat/completions", ) .await } - WireProtocol::Responses => { - proxy_direct_openai( + ai_catalog::DirectDatabricksUpstream::ModelServiceMessages { service } => { + debug_assert_eq!(service, provider_model); + proxy_direct_anthropic_at( &state.client, route, payload, provider_model, - "/ai-gateway/openai/v1/responses", + &headers, + "/ai-gateway/anthropic/v1/messages", ) .await } @@ -1681,17 +1698,28 @@ mod tests { } #[tokio::test] - async fn databricks_responses_rewrites_the_public_model_id() { + async fn databricks_responses_use_the_models_canonical_serving_endpoint_route() { let server = MockServer::start_async().await; let upstream = server .mock_async(|when, then| { when.method(POST) - .path("/ai-gateway/openai/v1/responses") + .path("/serving-endpoints/databricks-gpt-5-6-sol/invocations") .header("authorization", "Bearer temporary-oauth-token") - .body_contains("databricks-gpt-5-6-sol"); + .body_contains("databricks-gpt-5-6-sol") + .body_contains("messages") + .body_contains("max_tokens"); then.status(200) .header("content-type", "application/json") - .json_body(json!({"id": "response", "output": []})); + .json_body(json!({ + "id": "completion", + "model": "databricks-gpt-5-6-sol", + "choices": [{ + "index": 0, + "message": {"role": "assistant", "content": "pong"}, + "finish_reason": "stop" + }], + "usage": {"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2} + })); }) .await; let url = serve(build_router_with_availability( @@ -1705,12 +1733,19 @@ mod tests { let response = reqwest::Client::new() .post(format!("{url}/llm/v1/responses")) - .json(&json!({"model": "gpt-5.6-sol", "input": "ping"})) + .json(&json!({ + "model": "gpt-5.6-sol", + "input": "ping", + "max_output_tokens": 32 + })) .send() .await .expect("proxy response"); assert_eq!(response.status(), 200); + let body: Value = response.json().await.expect("Responses body"); + assert_eq!(body["model"], "gpt-5.6-sol"); + assert_eq!(body["output"][0]["content"][0]["text"], "pong"); upstream.assert_async().await; } @@ -1720,9 +1755,9 @@ mod tests { let upstream = server .mock_async(|when, then| { when.method(POST) - .path("/serving-endpoints/databricks-gemma-3-12b/invocations") + .path("/ai-gateway/mlflow/v1/chat/completions") .header("authorization", "Bearer temporary-oauth-token") - .body_contains("databricks-gemma-3-12b"); + .body_contains("system.ai.gemma-3-12b"); then.status(200) .header("content-type", "application/json") .json_body(json!({"id": "completion", "choices": []})); @@ -1757,7 +1792,7 @@ mod tests { .path("/ai-gateway/anthropic/v1/messages") .header("authorization", "Bearer temporary-oauth-token") .header("anthropic-version", "2023-06-01") - .body_contains("databricks-claude-sonnet-5"); + .body_contains("system.ai.claude-sonnet-5"); then.status(200) .header("content-type", "application/json") .json_body(json!({"id": "message", "content": []})); diff --git a/crates/alien-ai-gateway/tests/live_bedrock.rs b/crates/alien-ai-gateway/tests/live_bedrock.rs index 67a81cfa65..f6c771f1c1 100644 --- a/crates/alien-ai-gateway/tests/live_bedrock.rs +++ b/crates/alien-ai-gateway/tests/live_bedrock.rs @@ -1,5 +1,5 @@ -//! Live verification against real AWS Bedrock. Reads the shared alien-test-target -//! credentials from the workspace-root `.env.test`, so it runs in the credentialed +//! Live verification against real AWS Bedrock. Reads test credentials from the +//! repository-root `.env.test`, so it runs in the credentialed //! e2e job (and locally whenever that file is present). //! //! This is the end-to-end proof that the Rust SigV4 signer produces a signature @@ -13,20 +13,20 @@ use alien_ai_gateway::{build_router, AmbientCred, AwsSigV4Cred, GatewayRoute, Ga use alien_core::Platform; use serde_json::{json, Value}; -/// Load the shared alien-test-target credentials from the workspace-root +/// Load test credentials from the repository-root /// `.env.test` and expose the AWS target account under the SDK default-chain /// variable names, so the SigV4 signer resolves them exactly as a deployed /// workload's ambient identity would. fn load_test_env() { let root = workspace_root::get_workspace_root(); - dotenvy::from_path(root.join(".env.test")).expect("load .env.test from the workspace root"); + dotenvy::from_path(root.join(".env.test")).expect("load .env.test from the repository root"); for (from, to) in [ ("AWS_TARGET_ACCESS_KEY_ID", "AWS_ACCESS_KEY_ID"), ("AWS_TARGET_SECRET_ACCESS_KEY", "AWS_SECRET_ACCESS_KEY"), ] { // Fail loud rather than fall through to whatever AWS creds are already // ambient (e.g. a developer's own profile): this test must sign as the - // alien-test-target account, so a missing var is a setup error. + // configured test account, so a missing var is a setup error. let value = std::env::var(from).unwrap_or_else(|_| panic!("{from} must be set in .env.test")); std::env::set_var(to, value); @@ -45,6 +45,7 @@ async fn serve(router: axum::Router) -> String { } #[tokio::test] +#[ignore = "hits real Bedrock GPT-OSS across public APIs; reads credentials from .env.test"] async fn live_bedrock_openai_chat() { load_test_env(); let cred = AmbientCred::Aws( @@ -98,6 +99,49 @@ async fn live_bedrock_openai_chat() { "expected a 'pong' completion, got: {content:?}" ); } + + for (scenario, path, request) in [ + ( + "Responses", + "responses", + json!({ + "model": "gpt-oss-20b", + "input": "Reply with exactly pong", + "max_output_tokens": 1024, + "reasoning": {"effort": "low"} + }), + ), + ( + "Messages", + "messages", + json!({ + "model": "gpt-oss-20b", + "messages": [{"role": "user", "content": "Reply with exactly pong"}], + "max_tokens": 1024 + }), + ), + ] { + let response = reqwest::Client::new() + .post(format!("{base}/llm/v1/{path}")) + .header("anthropic-version", "2023-06-01") + .json(&request) + .send() + .await + .expect("request to Bedrock through Alien"); + let status = response.status(); + let text = response.text().await.expect("gateway response body"); + eprintln!("live Bedrock {scenario} status={status} body={text}"); + assert!( + status.is_success() || status == reqwest::StatusCode::TOO_MANY_REQUESTS, + "Bedrock {scenario} must authenticate and route; got {status}: {text}" + ); + if status.is_success() { + assert!( + text.to_ascii_lowercase().contains("pong"), + "Bedrock {scenario} returned no pong: {text}" + ); + } + } } #[tokio::test] diff --git a/crates/alien-ai-gateway/tests/live_databricks.rs b/crates/alien-ai-gateway/tests/live_databricks.rs new file mode 100644 index 0000000000..20e04904c3 --- /dev/null +++ b/crates/alien-ai-gateway/tests/live_databricks.rs @@ -0,0 +1,213 @@ +//! Live Databricks verification through Alien's three public APIs. +//! +//! Run with: +//! cargo test -p alien-ai-gateway --test live_databricks -- --ignored --nocapture + +use std::net::Ipv4Addr; +use std::time::Duration; + +use alien_ai_gateway::{build_router, route_from_direct_databricks}; +use serde_json::{json, Value}; + +fn load_test_env() { + let root = workspace_root::get_workspace_root(); + dotenvy::from_path(root.join(".env.test")).expect("load .env.test from the repository root"); +} + +async fn access_token(client: &reqwest::Client, workspace: &str) -> String { + let client_id = + std::env::var("DATABRICKS_TEST_CLIENT_ID").expect("DATABRICKS_TEST_CLIENT_ID must be set"); + let client_secret = std::env::var("DATABRICKS_TEST_CLIENT_SECRET") + .expect("DATABRICKS_TEST_CLIENT_SECRET must be set"); + let response = client + .post(format!("{workspace}/oidc/v1/token")) + .basic_auth(client_id, Some(client_secret)) + .form(&[("grant_type", "client_credentials"), ("scope", "all-apis")]) + .send() + .await + .expect("Databricks OAuth request"); + let status = response.status(); + let body: Value = response.json().await.expect("Databricks OAuth JSON"); + assert!( + status.is_success(), + "Databricks OAuth failed: {status}: {body}" + ); + body["access_token"] + .as_str() + .expect("Databricks access_token") + .to_string() +} + +async fn gateway() -> String { + load_test_env(); + let workspace = std::env::var("DATABRICKS_TEST_WORKSPACE_URL") + .expect("DATABRICKS_TEST_WORKSPACE_URL must be set") + .trim_end_matches('/') + .to_string(); + let client = reqwest::Client::new(); + let token = access_token(&client, &workspace).await; + let route = route_from_direct_databricks("llm", &workspace, token).expect("Databricks route"); + let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0)) + .await + .expect("bind test gateway"); + let url = format!("http://{}", listener.local_addr().unwrap()); + tokio::spawn(async move { + axum::serve(listener, build_router(vec![route])) + .await + .unwrap(); + }); + url +} + +async fn json_response(response: reqwest::Response, scenario: &str) -> Value { + let status = response.status(); + let text = response.text().await.expect("read gateway response"); + eprintln!("{scenario}: status={status} body={text}"); + assert!(status.is_success(), "{scenario} failed: {status}: {text}"); + serde_json::from_str(&text).expect("gateway response must be JSON") +} + +fn assert_alien_ok(value: &Value, scenario: &str) { + assert!( + value.to_string().to_ascii_uppercase().contains("ALIEN_OK"), + "{scenario} did not contain ALIEN_OK: {value}" + ); +} + +async fn exercise_all_public_apis(base: &str, model: &str) { + let client = reqwest::Client::new(); + let chat = json_response( + client + .post(format!("{base}/llm/v1/chat/completions")) + .json(&json!({ + "model": model, + "messages": [{"role": "user", "content": "Reply with exactly ALIEN_OK"}], + "max_tokens": 128 + })) + .send() + .await + .expect("send Chat request"), + &format!("Databricks {model} Chat"), + ) + .await; + assert_alien_ok(&chat, &format!("Databricks {model} Chat")); + tokio::time::sleep(Duration::from_secs(1)).await; + + let responses = json_response( + client + .post(format!("{base}/llm/v1/responses")) + .json(&json!({ + "model": model, + "input": "Reply with exactly ALIEN_OK", + "max_output_tokens": 128 + })) + .send() + .await + .expect("send Responses request"), + &format!("Databricks {model} Responses"), + ) + .await; + assert_alien_ok(&responses, &format!("Databricks {model} Responses")); + tokio::time::sleep(Duration::from_secs(1)).await; + + let messages = json_response( + client + .post(format!("{base}/llm/v1/messages")) + .header("anthropic-version", "2023-06-01") + .json(&json!({ + "model": model, + "messages": [{"role": "user", "content": "Reply with exactly ALIEN_OK"}], + "max_tokens": 128 + })) + .send() + .await + .expect("send Messages request"), + &format!("Databricks {model} Messages"), + ) + .await; + assert_alien_ok(&messages, &format!("Databricks {model} Messages")); + tokio::time::sleep(Duration::from_secs(1)).await; +} + +#[tokio::test] +#[ignore = "hits real Databricks serving endpoints using OAuth from .env.test"] +async fn live_databricks_routes_across_public_apis_tools_and_streaming() { + let base = gateway().await; + exercise_all_public_apis(&base, "gpt-oss-120b").await; + + let client = reqwest::Client::new(); + let tool = json_response( + client + .post(format!("{base}/llm/v1/responses")) + .json(&json!({ + "model": "gpt-oss-120b", + "input": "Use get_code for Paris.", + "tools": [{ + "type": "function", + "name": "get_code", + "description": "Get a fixed city code", + "parameters": { + "type": "object", + "properties": {"city": {"type": "string"}}, + "required": ["city"], + "additionalProperties": false + } + }], + "tool_choice": {"type": "function", "name": "get_code"}, + "max_output_tokens": 128 + })) + .send() + .await + .expect("send Responses tool request"), + "Databricks GPT-OSS Responses tool call", + ) + .await; + assert!( + tool.to_string().contains("get_code"), + "Databricks GPT-OSS returned no get_code call: {tool}" + ); + tokio::time::sleep(Duration::from_secs(1)).await; + + let stream = client + .post(format!("{base}/llm/v1/messages")) + .header("anthropic-version", "2023-06-01") + .json(&json!({ + "model": "gpt-oss-120b", + "messages": [{"role": "user", "content": "Reply with exactly ALIEN_OK"}], + "max_tokens": 128, + "stream": true + })) + .send() + .await + .expect("send translated stream"); + let status = stream.status(); + let body = stream.text().await.expect("read translated stream"); + eprintln!("Databricks GPT-OSS Messages stream: status={status} body={body}"); + assert!(status.is_success(), "translated stream failed: {body}"); + assert!( + body.contains("message_start"), + "missing Messages start: {body}" + ); + assert!( + body.contains("message_stop"), + "missing Messages stop: {body}" + ); + let content = body + .lines() + .filter_map(|line| line.strip_prefix("data: ")) + .filter_map(|data| serde_json::from_str::(data).ok()) + .filter_map(|event| { + event + .pointer("/delta/text") + .and_then(Value::as_str) + .map(str::to_string) + }) + .collect::(); + assert!( + content.to_ascii_uppercase().contains("ALIEN_OK"), + "translated stream did not produce ALIEN_OK; content={content:?}, stream={body}" + ); + tokio::time::sleep(Duration::from_secs(1)).await; + + exercise_all_public_apis(&base, "gemma-3-12b").await; +} diff --git a/crates/alien-ai-gateway/tests/live_direct_providers.rs b/crates/alien-ai-gateway/tests/live_direct_providers.rs new file mode 100644 index 0000000000..b5be710a41 --- /dev/null +++ b/crates/alien-ai-gateway/tests/live_direct_providers.rs @@ -0,0 +1,227 @@ +//! Live public-API verification for the direct OpenAI and Anthropic routes. +//! +//! Every request enters through Alien's loopback HTTP router. The suite verifies +//! provider authentication, model rewriting, request/response translation, tools, +//! multi-turn state, and streaming against the real providers. +//! +//! Run with: +//! cargo test -p alien-ai-gateway --test live_direct_providers -- --ignored --nocapture + +use std::net::Ipv4Addr; + +use alien_ai_gateway::{ + build_router, route_from_direct_anthropic, route_from_direct_openai, GatewayRoute, +}; +use serde_json::{json, Value}; + +const OPENAI_MODEL: &str = "gpt-4.1-mini"; +const ANTHROPIC_MODEL: &str = "claude-haiku-4.5"; + +fn load_test_env() { + let root = workspace_root::get_workspace_root(); + dotenvy::from_path(root.join(".env.test")).expect("load .env.test from the repository root"); +} + +async fn serve(route: GatewayRoute) -> String { + let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0)) + .await + .expect("bind test gateway"); + let url = format!("http://{}", listener.local_addr().unwrap()); + tokio::spawn(async move { + axum::serve(listener, build_router(vec![route])) + .await + .unwrap(); + }); + url +} + +async fn json_response(response: reqwest::Response, scenario: &str) -> Value { + let status = response.status(); + let text = response.text().await.expect("read gateway response"); + eprintln!("{scenario}: status={status} body={text}"); + assert!(status.is_success(), "{scenario} failed: {status}: {text}"); + serde_json::from_str(&text).expect("gateway response must be JSON") +} + +fn assert_alien_ok(value: &Value, scenario: &str) { + assert!( + value.to_string().to_ascii_uppercase().contains("ALIEN_OK"), + "{scenario} did not contain ALIEN_OK: {value}" + ); +} + +async fn exercise_public_text_apis(base: &str, model: &str, provider: &str) { + let client = reqwest::Client::new(); + let chat = json_response( + client + .post(format!("{base}/llm/v1/chat/completions")) + .json(&json!({ + "model": model, + "messages": [ + {"role": "system", "content": "Always follow the requested output exactly."}, + {"role": "user", "content": "Reply with exactly ALIEN_OK"} + ], + "max_tokens": 32 + })) + .send() + .await + .expect("send Chat request"), + &format!("{provider} Chat"), + ) + .await; + assert_alien_ok(&chat, &format!("{provider} Chat")); + + let responses = json_response( + client + .post(format!("{base}/llm/v1/responses")) + .json(&json!({ + "model": model, + "instructions": "Always follow the requested output exactly.", + "input": "Reply with exactly ALIEN_OK", + "max_output_tokens": 32 + })) + .send() + .await + .expect("send Responses request"), + &format!("{provider} Responses"), + ) + .await; + assert_alien_ok(&responses, &format!("{provider} Responses")); + + let messages = json_response( + client + .post(format!("{base}/llm/v1/messages")) + .header("anthropic-version", "2023-06-01") + .json(&json!({ + "model": model, + "system": "Always follow the requested output exactly.", + "messages": [{"role": "user", "content": "Reply with exactly ALIEN_OK"}], + "max_tokens": 32 + })) + .send() + .await + .expect("send Messages request"), + &format!("{provider} Messages"), + ) + .await; + assert_alien_ok(&messages, &format!("{provider} Messages")); +} + +async fn exercise_chat_tool_and_continuation(base: &str, model: &str, provider: &str) { + let client = reqwest::Client::new(); + let first = json_response( + client + .post(format!("{base}/llm/v1/chat/completions")) + .json(&json!({ + "model": model, + "messages": [{"role": "user", "content": "Use get_code for Paris."}], + "tools": [{ + "type": "function", + "function": { + "name": "get_code", + "description": "Get a fixed city code", + "parameters": { + "type": "object", + "properties": {"city": {"type": "string"}}, + "required": ["city"], + "additionalProperties": false + } + } + }], + "tool_choice": {"type": "function", "function": {"name": "get_code"}}, + "max_tokens": 128 + })) + .send() + .await + .expect("send tool request"), + &format!("{provider} tool call"), + ) + .await; + let call = &first["choices"][0]["message"]["tool_calls"][0]; + assert_eq!(call["function"]["name"], "get_code"); + let call_id = call["id"].as_str().expect("tool call id"); + + let second = json_response( + client + .post(format!("{base}/llm/v1/chat/completions")) + .json(&json!({ + "model": model, + "messages": [ + {"role": "user", "content": "Use get_code for Paris."}, + {"role": "assistant", "content": null, "tool_calls": [call]}, + {"role": "tool", "tool_call_id": call_id, "content": "PAR-75"} + ], + "max_tokens": 64 + })) + .send() + .await + .expect("send tool continuation"), + &format!("{provider} tool continuation"), + ) + .await; + assert!( + second.to_string().contains("PAR-75"), + "{provider} did not use the tool result: {second}" + ); +} + +async fn exercise_chat_stream(base: &str, model: &str, provider: &str) { + let response = reqwest::Client::new() + .post(format!("{base}/llm/v1/chat/completions")) + .json(&json!({ + "model": model, + "messages": [{"role": "user", "content": "Reply with exactly ALIEN_OK"}], + "stream": true, + "max_tokens": 32 + })) + .send() + .await + .expect("send streaming request"); + let status = response.status(); + let body = response.text().await.expect("read stream"); + eprintln!("{provider} streaming: status={status} body={body}"); + assert!(status.is_success(), "{provider} stream failed: {body}"); + assert!( + body.contains("data:"), + "{provider} returned no SSE data: {body}" + ); + let content = body + .lines() + .filter_map(|line| line.strip_prefix("data: ")) + .filter(|data| *data != "[DONE]") + .filter_map(|data| serde_json::from_str::(data).ok()) + .filter_map(|event| { + event["choices"][0]["delta"]["content"] + .as_str() + .map(str::to_string) + }) + .collect::(); + assert!( + content.to_ascii_uppercase().contains("ALIEN_OK"), + "{provider} stream did not produce ALIEN_OK; content={content:?}, stream={body}" + ); +} + +#[tokio::test] +#[ignore = "hits real OpenAI using OPENAI_TEST_API_KEY from .env.test"] +async fn live_openai_across_public_apis_and_scenarios() { + load_test_env(); + let key = std::env::var("OPENAI_TEST_API_KEY").expect("OPENAI_TEST_API_KEY must be set"); + let base = serve(route_from_direct_openai("llm", key).expect("OpenAI route")).await; + + exercise_public_text_apis(&base, OPENAI_MODEL, "OpenAI").await; + exercise_chat_tool_and_continuation(&base, OPENAI_MODEL, "OpenAI").await; + exercise_chat_stream(&base, OPENAI_MODEL, "OpenAI").await; +} + +#[tokio::test] +#[ignore = "hits real Anthropic using ANTHROPIC_TEST_API_KEY from .env.test"] +async fn live_anthropic_across_public_apis_and_scenarios() { + load_test_env(); + let key = std::env::var("ANTHROPIC_TEST_API_KEY").expect("ANTHROPIC_TEST_API_KEY must be set"); + let base = serve(route_from_direct_anthropic("llm", key).expect("Anthropic route")).await; + + exercise_public_text_apis(&base, ANTHROPIC_MODEL, "Anthropic").await; + exercise_chat_tool_and_continuation(&base, ANTHROPIC_MODEL, "Anthropic").await; + exercise_chat_stream(&base, ANTHROPIC_MODEL, "Anthropic").await; +} diff --git a/crates/alien-ai-gateway/tests/live_foundry_claude.rs b/crates/alien-ai-gateway/tests/live_foundry_claude.rs index 8c0140d63b..ae6dd8b741 100644 --- a/crates/alien-ai-gateway/tests/live_foundry_claude.rs +++ b/crates/alien-ai-gateway/tests/live_foundry_claude.rs @@ -1,7 +1,6 @@ //! Live verification of Claude on Azure Foundry. Ignored by default because it //! makes a real inference call and needs a Foundry endpoint plus a short-lived -//! Entra token in the environment. Verified green against the alien-test-target -//! resource alien-ai-foundry-e2e1 (RG alien-ai-e2e). +//! Entra token in the environment. //! //! Run it with: //! export AZURE_AI_ENDPOINT="https://.cognitiveservices.azure.com/" @@ -13,7 +12,7 @@ //! Foundry accepts that host with an `ai.azure.com`-audience token on the first //! request, with no host derivation or audience swap needed. -use std::net::Ipv4Addr; +use std::{net::Ipv4Addr, time::Duration}; use alien_ai_gateway::{build_router, AmbientCred, BearerTokenCred, GatewayRoute, GatewayTarget}; use alien_core::Platform; @@ -30,6 +29,30 @@ async fn serve(router: axum::Router) -> String { url } +async fn post_with_quota_backoff( + client: &reqwest::Client, + url: &str, + body: &Value, +) -> reqwest::Response { + for attempt in 1..=4 { + let response = client + .post(url) + .json(body) + .send() + .await + .expect("request to Foundry through Alien"); + if response.status() != reqwest::StatusCode::TOO_MANY_REQUESTS || attempt == 4 { + return response; + } + eprintln!( + "Foundry quota window active; retrying attempt {}", + attempt + 1 + ); + tokio::time::sleep(Duration::from_secs(31)).await; + } + unreachable!("bounded retry loop always returns") +} + fn foundry_route() -> GatewayRoute { let endpoint = std::env::var("AZURE_AI_ENDPOINT").expect("AZURE_AI_ENDPOINT must name the account"); @@ -48,20 +71,18 @@ fn foundry_route() -> GatewayRoute { } #[tokio::test] -#[ignore = "hits real Foundry Claude; verified green against alien-ai-foundry-e2e1. Needs a minted (short-lived) AZURE_ACCESS_TOKEN, so it can't run in static-cred CI; see the module docstring to run it"] -async fn live_foundry_claude_messages() { +#[ignore = "hits real Foundry Claude across public APIs; needs a short-lived AZURE_ACCESS_TOKEN; see the module docstring"] +async fn live_foundry_claude_across_public_apis_and_streaming() { let base = serve(build_router(vec![foundry_route()])).await; + let client = reqwest::Client::new(); - let resp = reqwest::Client::new() - .post(format!("{base}/llm/v1/messages")) - .json(&json!({ - "model": "claude-haiku-4.5", - "max_tokens": 64, - "messages": [{ "role": "user", "content": "Reply with exactly: pong" }] - })) - .send() - .await - .expect("request to the gateway"); + let messages_url = format!("{base}/llm/v1/messages"); + let messages_body = json!({ + "model": "claude-haiku-4.5", + "max_tokens": 64, + "messages": [{ "role": "user", "content": "Reply with exactly: pong" }] + }); + let resp = post_with_quota_backoff(&client, &messages_url, &messages_body).await; let status = resp.status(); let text = resp.text().await.expect("gateway response body"); @@ -77,26 +98,50 @@ async fn live_foundry_claude_messages() { content.to_lowercase().contains("pong"), "expected a 'pong' reply, got: {content:?}" ); -} -#[tokio::test] -#[ignore = "hits real Foundry Claude streaming; verified green against alien-ai-foundry-e2e1. Needs a minted (short-lived) AZURE_ACCESS_TOKEN, so it can't run in static-cred CI; see the module docstring to run it"] -async fn live_foundry_claude_streaming() { + tokio::time::sleep(Duration::from_secs(1)).await; + for (scenario, path, body) in [ + ( + "Chat", + "chat/completions", + json!({ + "model": "claude-haiku-4.5", + "max_tokens": 64, + "messages": [{"role": "user", "content": "Reply with exactly pong"}] + }), + ), + ( + "Responses", + "responses", + json!({ + "model": "claude-haiku-4.5", + "max_output_tokens": 64, + "input": "Reply with exactly pong" + }), + ), + ] { + let url = format!("{base}/llm/v1/{path}"); + let response = post_with_quota_backoff(&client, &url, &body).await; + let status = response.status(); + let text = response.text().await.expect("gateway response body"); + eprintln!("Foundry {scenario}: status={status} body={text}"); + assert!( + status.is_success(), + "Foundry {scenario} failed: {status}: {text}" + ); + assert!(text.to_ascii_lowercase().contains("pong")); + tokio::time::sleep(Duration::from_secs(1)).await; + } + // Standard Anthropic streaming on the body's `stream` flag; the reply must be // native Anthropic SSE passed through untouched. - let base = serve(build_router(vec![foundry_route()])).await; - - let resp = reqwest::Client::new() - .post(format!("{base}/llm/v1/messages")) - .json(&json!({ - "model": "claude-haiku-4.5", - "max_tokens": 64, - "stream": true, - "messages": [{ "role": "user", "content": "Reply with exactly: pong" }] - })) - .send() - .await - .expect("request to the gateway"); + let stream_body = json!({ + "model": "claude-haiku-4.5", + "max_tokens": 64, + "stream": true, + "messages": [{ "role": "user", "content": "Reply with exactly: pong" }] + }); + let resp = post_with_quota_backoff(&client, &messages_url, &stream_body).await; let status = resp.status(); assert!( diff --git a/crates/alien-ai-gateway/tests/live_vertex_claude.rs b/crates/alien-ai-gateway/tests/live_vertex_claude.rs index f8a7b433aa..20fa30b17d 100644 --- a/crates/alien-ai-gateway/tests/live_vertex_claude.rs +++ b/crates/alien-ai-gateway/tests/live_vertex_claude.rs @@ -44,6 +44,77 @@ fn vertex_route() -> GatewayRoute { } } +fn vertex_gemini_route() -> GatewayRoute { + let mut route = vertex_route(); + route.region = + Some(std::env::var("GCP_LOCATION").expect("GCP_LOCATION must name the Vertex location")); + route +} + +async fn assert_json_contains_pong(response: reqwest::Response, scenario: &str) { + let status = response.status(); + let text = response.text().await.expect("gateway response body"); + eprintln!("{scenario}: status={status} body={text}"); + assert!(status.is_success(), "{scenario} failed: {status}: {text}"); + assert!( + text.to_ascii_lowercase().contains("pong"), + "{scenario} returned no pong: {text}" + ); +} + +#[tokio::test] +#[ignore = "hits real Vertex Gemini; needs GCP_PROJECT, GCP_LOCATION, and GCP_ACCESS_TOKEN"] +async fn live_vertex_gemini_across_public_apis() { + let base = serve(build_router(vec![vertex_gemini_route()])).await; + let client = reqwest::Client::new(); + + assert_json_contains_pong( + client + .post(format!("{base}/llm/v1/chat/completions")) + .json(&json!({ + "model": "gemini-2.5-flash", + "messages": [{"role": "user", "content": "Reply with exactly pong"}], + "max_tokens": 32 + })) + .send() + .await + .expect("Vertex Chat request"), + "Vertex Gemini Chat", + ) + .await; + + assert_json_contains_pong( + client + .post(format!("{base}/llm/v1/responses")) + .json(&json!({ + "model": "gemini-2.5-flash", + "input": "Reply with exactly pong", + "max_output_tokens": 32 + })) + .send() + .await + .expect("Vertex Responses request"), + "Vertex Gemini Responses", + ) + .await; + + assert_json_contains_pong( + client + .post(format!("{base}/llm/v1/messages")) + .header("anthropic-version", "2023-06-01") + .json(&json!({ + "model": "gemini-2.5-flash", + "messages": [{"role": "user", "content": "Reply with exactly pong"}], + "max_tokens": 32 + })) + .send() + .await + .expect("Vertex Messages request"), + "Vertex Gemini Messages", + ) + .await; +} + #[tokio::test] #[ignore = "hits real Vertex Claude; needs GCP_PROJECT + GCP_ACCESS_TOKEN and a Model Garden grant"] async fn live_vertex_claude_messages() { diff --git a/crates/alien-core/src/ai_catalog.rs b/crates/alien-core/src/ai_catalog.rs index f2c0cc13e3..bb5ce3f65b 100644 --- a/crates/alien-core/src/ai_catalog.rs +++ b/crates/alien-core/src/ai_catalog.rs @@ -95,12 +95,41 @@ pub struct DirectOpenAiModel { pub client_apis: &'static [ClientApi], } -/// One Databricks-hosted model service qualified through Unity AI Gateway. +/// The one Databricks resource and protocol used for a direct model. +/// +/// A caller may use any public gateway protocol. This route describes only the +/// provider-facing request after protocol translation, so the incoming API can +/// never accidentally select a retired Databricks path. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DirectDatabricksUpstream { + ServingEndpointChat { endpoint: &'static str }, + ModelServiceChat { service: &'static str }, + ModelServiceMessages { service: &'static str }, +} + +impl DirectDatabricksUpstream { + pub const fn model_id(self) -> &'static str { + match self { + Self::ServingEndpointChat { endpoint } => endpoint, + Self::ModelServiceChat { service } | Self::ModelServiceMessages { service } => service, + } + } + + pub const fn client_api(self) -> ClientApi { + match self { + Self::ServingEndpointChat { .. } | Self::ModelServiceChat { .. } => { + ClientApi::OpenAiChatCompletions + } + Self::ModelServiceMessages { .. } => ClientApi::AnthropicMessages, + } + } +} + +/// One Databricks-hosted text model with an explicitly qualified upstream route. #[derive(Debug, Clone, Copy)] pub struct DirectDatabricksModel { pub public_id: &'static str, - pub upstream_id: &'static str, - pub client_apis: &'static [ClientApi], + pub upstream: DirectDatabricksUpstream, } impl DirectAnthropicModel { @@ -882,143 +911,123 @@ static DIRECT_OPENAI_MODELS: &[DirectOpenAiModel] = &[ static DIRECT_DATABRICKS_MODELS: &[DirectDatabricksModel] = &[ DirectDatabricksModel { public_id: "gpt-5.6-sol", - upstream_id: "databricks-gpt-5-6-sol", - client_apis: &[ClientApi::OpenAiChatCompletions, ClientApi::OpenAiResponses], + upstream: DirectDatabricksUpstream::ServingEndpointChat { + endpoint: "databricks-gpt-5-6-sol", + }, }, DirectDatabricksModel { public_id: "gpt-5.6-terra", - upstream_id: "databricks-gpt-5-6-terra", - client_apis: &[ClientApi::OpenAiChatCompletions, ClientApi::OpenAiResponses], + upstream: DirectDatabricksUpstream::ServingEndpointChat { + endpoint: "databricks-gpt-5-6-terra", + }, }, DirectDatabricksModel { public_id: "gpt-5.6-luna", - upstream_id: "databricks-gpt-5-6-luna", - client_apis: &[ClientApi::OpenAiChatCompletions, ClientApi::OpenAiResponses], + upstream: DirectDatabricksUpstream::ServingEndpointChat { + endpoint: "databricks-gpt-5-6-luna", + }, }, DirectDatabricksModel { public_id: "gpt-5.5", - upstream_id: "databricks-gpt-5-5", - client_apis: &[ClientApi::OpenAiResponses], + upstream: DirectDatabricksUpstream::ServingEndpointChat { + endpoint: "databricks-gpt-5-5", + }, }, DirectDatabricksModel { public_id: "gpt-5.4", - upstream_id: "databricks-gpt-5-4", - client_apis: &[ClientApi::OpenAiChatCompletions, ClientApi::OpenAiResponses], + upstream: DirectDatabricksUpstream::ServingEndpointChat { + endpoint: "databricks-gpt-5-4", + }, }, DirectDatabricksModel { public_id: "claude-haiku-4.5", - upstream_id: "databricks-claude-haiku-4-5", - client_apis: &[ - ClientApi::OpenAiChatCompletions, - ClientApi::OpenAiResponses, - ClientApi::AnthropicMessages, - ], + upstream: DirectDatabricksUpstream::ModelServiceMessages { + service: "system.ai.claude-haiku-4-5", + }, }, DirectDatabricksModel { public_id: "claude-opus-5", - upstream_id: "system.ai.claude-opus-5", - client_apis: &[ - ClientApi::OpenAiChatCompletions, - ClientApi::OpenAiResponses, - ClientApi::AnthropicMessages, - ], + upstream: DirectDatabricksUpstream::ModelServiceMessages { + service: "system.ai.claude-opus-5", + }, }, DirectDatabricksModel { public_id: "claude-sonnet-5", - upstream_id: "databricks-claude-sonnet-5", - client_apis: &[ - ClientApi::OpenAiChatCompletions, - ClientApi::OpenAiResponses, - ClientApi::AnthropicMessages, - ], + upstream: DirectDatabricksUpstream::ModelServiceMessages { + service: "system.ai.claude-sonnet-5", + }, }, DirectDatabricksModel { public_id: "claude-sonnet-4.6", - upstream_id: "databricks-claude-sonnet-4-6", - client_apis: &[ - ClientApi::OpenAiChatCompletions, - ClientApi::OpenAiResponses, - ClientApi::AnthropicMessages, - ], + upstream: DirectDatabricksUpstream::ModelServiceMessages { + service: "system.ai.claude-sonnet-4-6", + }, }, DirectDatabricksModel { public_id: "claude-sonnet-4.5", - upstream_id: "databricks-claude-sonnet-4-5", - client_apis: &[ - ClientApi::OpenAiChatCompletions, - ClientApi::OpenAiResponses, - ClientApi::AnthropicMessages, - ], + upstream: DirectDatabricksUpstream::ModelServiceMessages { + service: "system.ai.claude-sonnet-4-5", + }, }, DirectDatabricksModel { public_id: "claude-fable-5", - upstream_id: "databricks-claude-fable-5", - client_apis: &[ - ClientApi::OpenAiChatCompletions, - ClientApi::OpenAiResponses, - ClientApi::AnthropicMessages, - ], + upstream: DirectDatabricksUpstream::ModelServiceMessages { + service: "system.ai.claude-fable-5", + }, }, DirectDatabricksModel { public_id: "claude-opus-4.8", - upstream_id: "databricks-claude-opus-4-8", - client_apis: &[ - ClientApi::OpenAiChatCompletions, - ClientApi::OpenAiResponses, - ClientApi::AnthropicMessages, - ], + upstream: DirectDatabricksUpstream::ModelServiceMessages { + service: "system.ai.claude-opus-4-8", + }, }, DirectDatabricksModel { public_id: "claude-opus-4.7", - upstream_id: "databricks-claude-opus-4-7", - client_apis: &[ - ClientApi::OpenAiChatCompletions, - ClientApi::OpenAiResponses, - ClientApi::AnthropicMessages, - ], + upstream: DirectDatabricksUpstream::ModelServiceMessages { + service: "system.ai.claude-opus-4-7", + }, }, DirectDatabricksModel { public_id: "claude-opus-4.6", - upstream_id: "databricks-claude-opus-4-6", - client_apis: &[ - ClientApi::OpenAiChatCompletions, - ClientApi::OpenAiResponses, - ClientApi::AnthropicMessages, - ], + upstream: DirectDatabricksUpstream::ModelServiceMessages { + service: "system.ai.claude-opus-4-6", + }, }, DirectDatabricksModel { public_id: "claude-opus-4.5", - upstream_id: "databricks-claude-opus-4-5", - client_apis: &[ - ClientApi::OpenAiChatCompletions, - ClientApi::OpenAiResponses, - ClientApi::AnthropicMessages, - ], + upstream: DirectDatabricksUpstream::ModelServiceMessages { + service: "system.ai.claude-opus-4-5", + }, }, DirectDatabricksModel { public_id: "gemini-3.5-flash", - upstream_id: "databricks-gemini-3-5-flash", - client_apis: &[ClientApi::OpenAiChatCompletions, ClientApi::OpenAiResponses], + upstream: DirectDatabricksUpstream::ModelServiceChat { + service: "system.ai.gemini-3-5-flash", + }, }, DirectDatabricksModel { public_id: "gemini-3.1-flash-lite", - upstream_id: "databricks-gemini-3-1-flash-lite", - client_apis: &[ClientApi::OpenAiChatCompletions, ClientApi::OpenAiResponses], + upstream: DirectDatabricksUpstream::ModelServiceChat { + service: "system.ai.gemini-3-1-flash-lite", + }, }, DirectDatabricksModel { public_id: "gpt-oss-120b", - upstream_id: "databricks-gpt-oss-120b", - client_apis: &[ClientApi::OpenAiChatCompletions, ClientApi::OpenAiResponses], + upstream: DirectDatabricksUpstream::ServingEndpointChat { + endpoint: "databricks-gpt-oss-120b", + }, }, DirectDatabricksModel { public_id: "gpt-oss-20b", - upstream_id: "databricks-gpt-oss-20b", - client_apis: &[ClientApi::OpenAiChatCompletions, ClientApi::OpenAiResponses], + upstream: DirectDatabricksUpstream::ServingEndpointChat { + endpoint: "databricks-gpt-oss-20b", + }, }, DirectDatabricksModel { public_id: "gemma-3-12b", - upstream_id: "databricks-gemma-3-12b", - client_apis: &[ClientApi::OpenAiChatCompletions, ClientApi::OpenAiResponses], + upstream: DirectDatabricksUpstream::ModelServiceChat { + service: "system.ai.gemma-3-12b", + }, }, ]; @@ -1294,16 +1303,15 @@ mod tests { let mut public_ids = std::collections::HashSet::new(); for model in DIRECT_DATABRICKS_MODELS { assert!(public_ids.insert(model.public_id)); + let upstream_id = model.upstream.model_id(); assert!( - model.upstream_id.starts_with("databricks-") - || model.upstream_id.starts_with("system.ai.") + upstream_id.starts_with("databricks-") || upstream_id.starts_with("system.ai.") ); - assert!(!model.client_apis.is_empty()); assert_eq!( resolve_direct_databricks(model.public_id) .expect("direct Databricks model must resolve") - .upstream_id, - model.upstream_id + .upstream, + model.upstream ); } assert!(resolve_direct_databricks("bge-large-en").is_none()); @@ -1311,14 +1319,17 @@ mod tests { assert_eq!( resolve_direct_databricks("claude-opus-5") .expect("Claude Opus 5 must resolve") - .upstream_id, + .upstream + .model_id(), "system.ai.claude-opus-5" ); assert_eq!( - resolve_direct_databricks("gpt-5.5") - .expect("GPT-5.5 must resolve") - .client_apis, - &[ClientApi::OpenAiResponses] + resolve_direct_databricks("gpt-oss-120b") + .expect("GPT-OSS 120B must resolve") + .upstream, + DirectDatabricksUpstream::ServingEndpointChat { + endpoint: "databricks-gpt-oss-120b" + } ); } diff --git a/scripts/gen-env-test.sh b/scripts/gen-env-test.sh index c9d13bc560..5f6f66c80b 100755 --- a/scripts/gen-env-test.sh +++ b/scripts/gen-env-test.sh @@ -6,6 +6,11 @@ # Environment variables required: # AXIOM_TOKEN - Axiom API token # NGROK_AUTHTOKEN - Ngrok auth token (for push-mode E2E tests) +# OPENAI_TEST_API_KEY - OpenAI credential for ignored live gateway tests +# ANTHROPIC_TEST_API_KEY - Anthropic credential for ignored live gateway tests +# DATABRICKS_TEST_WORKSPACE_URL - Databricks workspace base URL +# DATABRICKS_TEST_CLIENT_ID - Databricks OAuth service-principal client ID +# DATABRICKS_TEST_CLIENT_SECRET - Databricks OAuth service-principal secret # Environment variables optional: # AXIOM_OTLP_ENDPOINT - (default: https://api.axiom.co/v1/logs) # AXIOM_DATASET - (default: dev) @@ -330,6 +335,13 @@ NGROK_AUTHTOKEN='${NGROK_AUTHTOKEN:-}' AXIOM_OTLP_ENDPOINT='${AXIOM_OTLP_ENDPOINT:-https://api.axiom.co/v1/logs}' AXIOM_TOKEN='${AXIOM_TOKEN:?AXIOM_TOKEN environment variable must be set}' AXIOM_DATASET='${AXIOM_DATASET:-dev}' + +# Live AI provider tests +OPENAI_TEST_API_KEY='${OPENAI_TEST_API_KEY:-}' +ANTHROPIC_TEST_API_KEY='${ANTHROPIC_TEST_API_KEY:-}' +DATABRICKS_TEST_WORKSPACE_URL='${DATABRICKS_TEST_WORKSPACE_URL:-}' +DATABRICKS_TEST_CLIENT_ID='${DATABRICKS_TEST_CLIENT_ID:-}' +DATABRICKS_TEST_CLIENT_SECRET='${DATABRICKS_TEST_CLIENT_SECRET:-}' EOF {