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
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.

35 changes: 35 additions & 0 deletions crates/alien-ai-gateway/src/creds.rs
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,9 @@ pub enum AmbientCred {
/// A direct Anthropic workspace key. Kept separate from bearer credentials so
/// it can only be emitted as `x-api-key`, never as an Authorization header.
AnthropicApiKey(AnthropicApiKeyCred),
/// A direct OpenAI project key. Kept separate from cloud bearer tokens so
/// static provider credentials cannot be resolved through metadata paths.
OpenAiApiKey(OpenAiApiKeyCred),
}

impl AmbientCred {
Expand All @@ -142,10 +145,42 @@ impl AmbientCred {
AmbientCred::Aws(c) => c.sign(req, aws_sigv4_service).await,
AmbientCred::Bearer(c) => c.attach(req).await,
AmbientCred::AnthropicApiKey(c) => c.attach(req),
AmbientCred::OpenAiApiKey(c) => c.attach(req),
}
}
}

/// A standard OpenAI API key. This type deliberately has no `Debug`
/// implementation so accidental structured logging cannot print the key.
pub struct OpenAiApiKeyCred {
key: String,
}

impl OpenAiApiKeyCred {
pub fn new(key: impl Into<String>) -> Result<Self> {
let key = key.into();
if key.is_empty() || key.bytes().any(|byte| byte.is_ascii_whitespace()) {
return Err(AlienError::new(ErrorData::BindingConfigInvalid {
binding: "openai".to_string(),
message: "a valid OpenAI API key is required".to_string(),
}));
}
Ok(Self { key })
}

fn attach(&self, req: &mut reqwest::Request) -> Result<()> {
let value = HeaderValue::from_str(&format!("Bearer {}", self.key))
.into_alien_error()
.context(ErrorData::BindingConfigInvalid {
binding: "openai".to_string(),
message: "the OpenAI API key is not a valid HTTP header".to_string(),
})?;
req.headers_mut()
.insert(HeaderName::from_static("authorization"), value);
Ok(())
}
}

/// A standard Anthropic API key. This type deliberately has no `Debug`
/// implementation so accidental structured logging cannot print the key.
pub struct AnthropicApiKeyCred {
Expand Down
8 changes: 5 additions & 3 deletions crates/alien-ai-gateway/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,13 @@ mod creds;
mod error;
mod router;
pub use config::{bindings_from_env, bindings_from_env_map, route_from_remote_ai_lease};
pub use creds::{AmbientCred, AnthropicApiKeyCred, AwsSigV4Cred, BearerTokenCred};
pub use creds::{
AmbientCred, AnthropicApiKeyCred, AwsSigV4Cred, BearerTokenCred, OpenAiApiKeyCred,
};
pub use error::{ErrorData, Result};
pub use router::{
build_router, build_router_with_availability, route_from_direct_anthropic, AvailableModels,
GatewayRoute, GatewayTarget,
build_router, build_router_with_availability, route_from_direct_anthropic,
route_from_direct_openai, AvailableModels, GatewayRoute, GatewayTarget,
};

use std::net::{Ipv4Addr, SocketAddr};
Expand Down
110 changes: 99 additions & 11 deletions crates/alien-ai-gateway/src/router/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ use axum::{
};
use serde_json::{json, Value};

use crate::creds::{AmbientCred, AnthropicApiKeyCred};
use crate::creds::{AmbientCred, AnthropicApiKeyCred, OpenAiApiKeyCred};
use crate::error::{ErrorData, Result};

mod bedrock;
Expand Down Expand Up @@ -79,6 +79,7 @@ where
pub enum GatewayTarget {
Cloud(Platform),
DirectAnthropic,
DirectOpenAi,
}

pub struct GatewayRoute {
Expand Down Expand Up @@ -116,6 +117,24 @@ pub fn route_from_direct_anthropic(
})
}

/// Build the fixed-host OpenAI static-key route. Keeping this separate from a
/// generic bearer route prevents a stored provider key from being forwarded to
/// a caller-controlled host.
pub fn route_from_direct_openai(
name: impl Into<String>,
api_key: impl Into<String>,
) -> Result<GatewayRoute> {
Ok(GatewayRoute {
name: name.into(),
target: GatewayTarget::DirectOpenAi,
region: None,
project: None,
azure_endpoint: None,
cred: AmbientCred::OpenAiApiKey(OpenAiApiKeyCred::new(api_key)?),
upstream_base_override: None,
})
}

struct AppState {
routes: HashMap<String, GatewayRoute>,
client: reqwest::Client,
Expand Down Expand Up @@ -338,18 +357,41 @@ async fn proxy(

// Cloud-scoped resolution: Claude ids appear once per cloud, so a first-match
// resolve would always land on another cloud's entry and fail the cloud filter.
if route.target == GatewayTarget::DirectAnthropic {
ensure_model_available(&state, &binding, &model)?;
if client_api != ClientApi::AnthropicMessages {
return Err(AlienError::new(ErrorData::InvalidRequest {
message: format!("direct Anthropic supports only /{binding}/v1/messages"),
}));
match route.target {
GatewayTarget::DirectAnthropic => {
ensure_model_available(&state, &binding, &model)?;
if client_api != ClientApi::AnthropicMessages {
return Err(AlienError::new(ErrorData::InvalidRequest {
message: format!("direct Anthropic supports only /{binding}/v1/messages"),
}));
}
return proxy_direct_anthropic(&state.client, route, payload, &model, &headers).await;
}
GatewayTarget::DirectOpenAi => {
ensure_model_available(&state, &binding, &model)?;
if client_api != ClientApi::OpenAiChatCompletions {
return Err(AlienError::new(ErrorData::InvalidRequest {
message: format!(
"direct OpenAI chat completions use /{binding}/v1/chat/completions"
),
}));
}
return proxy_direct_openai(
&state.client,
route,
payload,
&model,
"/v1/chat/completions",
)
.await;
}
return proxy_direct_anthropic(&state.client, route, payload, &model, &headers).await;
GatewayTarget::Cloud(_) => {}
}
let cloud = match route.target {
GatewayTarget::Cloud(cloud) => cloud,
GatewayTarget::DirectAnthropic => unreachable!("handled above"),
GatewayTarget::DirectAnthropic | GatewayTarget::DirectOpenAi => {
unreachable!("handled above")
}
};
let cm = ai_catalog::resolve_for(&model, cloud).ok_or_else(|| {
AlienError::new(ErrorData::ModelNotAvailable {
Expand Down Expand Up @@ -446,6 +488,11 @@ async fn proxy_responses(
binding,
}))
}
GatewayTarget::DirectOpenAi => {
ensure_model_available(&state, &binding, &model)?;
return proxy_direct_openai(&state.client, route, payload, &model, "/v1/responses")
.await;
}
};
let catalog_model = ai_catalog::resolve_for(&model, cloud)
.filter(|model| model.client_apis.contains(&ClientApi::OpenAiResponses))
Expand Down Expand Up @@ -543,6 +590,25 @@ async fn list_models(
})
})
.collect(),
GatewayTarget::DirectOpenAi => {
let mut models = allowed
.into_iter()
.flatten()
.flat_map(|models| models.iter())
.collect::<Vec<_>>();
models.sort();
models
.into_iter()
.map(|model| {
json!({
"id": model,
"object": "model",
"provider": "openai",
"displayName": model,
})
})
.collect()
}
};
Ok(Json(json!({ "object": "list", "data": data })).into_response())
}
Expand Down Expand Up @@ -598,6 +664,28 @@ async fn proxy_direct_anthropic(
forward_response(upstream).await
}

async fn proxy_direct_openai(
client: &reqwest::Client,
route: &GatewayRoute,
mut payload: Value,
model: &str,
path: &str,
) -> Result<Response> {
payload["model"] = Value::String(model.to_string());
let body = serde_json::to_vec(&payload)
.into_alien_error()
.context(ErrorData::Other {
message: "could not serialize the OpenAI request".to_string(),
})?;
let base = route
.upstream_base_override
.as_deref()
.unwrap_or("https://api.openai.com");
let url = format!("{}{}", base.trim_end_matches('/'), path);
let upstream = sign_and_execute(client, &route.cred, &url, "", body, &[]).await?;
forward_response(upstream).await
}

/// The error for a binding missing a field a handler needs.
pub(crate) fn missing_field(route: &GatewayRoute, field: &str) -> AlienError<ErrorData> {
AlienError::new(ErrorData::BindingConfigInvalid {
Expand All @@ -613,9 +701,9 @@ pub(crate) fn upstream_target(
) -> Result<(String, &'static str)> {
let cloud = match route.target {
GatewayTarget::Cloud(cloud) => cloud,
GatewayTarget::DirectAnthropic => {
GatewayTarget::DirectAnthropic | GatewayTarget::DirectOpenAi => {
return Err(AlienError::new(ErrorData::Other {
message: "direct Anthropic does not use a cloud upstream target".to_string(),
message: "direct providers do not use a cloud upstream target".to_string(),
}))
}
};
Expand Down
64 changes: 62 additions & 2 deletions crates/alien-ai-gateway/tests/integration.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,8 @@
use std::net::Ipv4Addr;

use alien_ai_gateway::{
build_router, route_from_direct_anthropic, AmbientCred, AwsSigV4Cred, BearerTokenCred,
GatewayRoute, GatewayTarget,
build_router, route_from_direct_anthropic, route_from_direct_openai, AmbientCred, AwsSigV4Cred,
BearerTokenCred, GatewayRoute, GatewayTarget,
};
use alien_core::Platform;
use aws_credential_types::provider::SharedCredentialsProvider;
Expand Down Expand Up @@ -303,3 +303,63 @@ async fn direct_anthropic_is_fixed_to_messages_and_injects_only_its_api_key() {

assert!(route_from_direct_anthropic("direct", "sk-ant-admin-test").is_err());
}

#[tokio::test]
async fn direct_openai_is_fixed_to_openai_endpoints_and_injects_bearer_auth() {
let upstream = MockServer::start_async().await;
let chat = upstream
.mock_async(|when, then| {
when.method(POST)
.path("/v1/chat/completions")
.header("authorization", "Bearer sk-proj-test-secret")
.body_contains("gpt-5");
then.status(200)
.header("content-type", "application/json")
.body(r#"{"id":"chat_direct","choices":[]}"#);
})
.await;
let responses = upstream
.mock_async(|when, then| {
when.method(POST)
.path("/v1/responses")
.header("authorization", "Bearer sk-proj-test-secret")
.body_contains("gpt-5");
then.status(200)
.header("content-type", "application/json")
.body(r#"{"id":"resp_direct","output":[]}"#);
})
.await;

let mut route =
route_from_direct_openai("direct", "sk-proj-test-secret").expect("valid API key");
route.upstream_base_override = Some(upstream.base_url());
let base = serve(build_router(vec![route])).await;
let client = reqwest::Client::new();

let chat_response = client
.post(format!("{base}/direct/v1/chat/completions"))
.json(&json!({"model": "gpt-5", "messages": []}))
.send()
.await
.expect("chat request");
assert_eq!(chat_response.status(), 200);

let responses_response = client
.post(format!("{base}/direct/v1/responses"))
.json(&json!({"model": "gpt-5", "input": "hello"}))
.send()
.await
.expect("responses request");
assert_eq!(responses_response.status(), 200);

let wrong_protocol = client
.post(format!("{base}/direct/v1/messages"))
.json(&json!({"model": "gpt-5", "messages": []}))
.send()
.await
.expect("wrong protocol response");
assert_eq!(wrong_protocol.status(), 400);
chat.assert_async().await;
responses.assert_async().await;
assert!(route_from_direct_openai("direct", "contains whitespace").is_err());
}
1 change: 1 addition & 0 deletions crates/alien-build/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ tracing = { workspace = true }
serde_json = { workspace = true }
glob = { workspace = true }
async-trait = "0.1"
base64 = { workspace = true }
object_store = { workspace = true }
sha2 = { workspace = true }
tar = "0.4"
Expand Down
Loading
Loading