From 235678d571e23e713e06418b41281d0bdcba1720 Mon Sep 17 00:00:00 2001 From: Alon Gubkin Date: Fri, 7 Aug 2026 18:57:52 +0300 Subject: [PATCH 1/3] fix: make the starter release cross-platform --- Cargo.lock | 1 + crates/alien-build/Cargo.toml | 1 + .../alien-build/src/toolchain/native_addon.rs | 237 +++++++++++++++++- examples/remote-worker-ts/alien.ts | 1 + examples/remote-worker-ts/package.json | 6 +- .../remote-worker-ts/tests/worker.test.ts | 21 +- packages/bindings/PACKAGE_LAYOUT.md | 4 + 7 files changed, 259 insertions(+), 12 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 6c716692e..ba46dde53 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -293,6 +293,7 @@ dependencies = [ "alien-error", "alien-preflights", "async-trait", + "base64 0.22.1", "dirs 5.0.1", "dockdash", "dotenvy", diff --git a/crates/alien-build/Cargo.toml b/crates/alien-build/Cargo.toml index 28e29e43e..a95a915d6 100644 --- a/crates/alien-build/Cargo.toml +++ b/crates/alien-build/Cargo.toml @@ -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" diff --git a/crates/alien-build/src/toolchain/native_addon.rs b/crates/alien-build/src/toolchain/native_addon.rs index 5bb47599f..17b8f955a 100644 --- a/crates/alien-build/src/toolchain/native_addon.rs +++ b/crates/alien-build/src/toolchain/native_addon.rs @@ -22,14 +22,34 @@ use crate::error::{ErrorData, Result}; use alien_core::BinaryTarget; use alien_error::{AlienError, Context, IntoAlienError}; +use base64::{engine::general_purpose::STANDARD as BASE64_STANDARD, Engine as _}; use fs2::FileExt; +use serde::Deserialize; +use sha2::{Digest, Sha512}; use std::fs::{File, OpenOptions}; +use std::io::Read; use std::path::{Path, PathBuf}; use std::sync::Arc; use tokio::fs; use tokio::process::Command; use tracing::info; +#[derive(Deserialize)] +struct PackageManifest { + version: String, +} + +#[derive(Deserialize)] +struct NpmVersionMetadata { + dist: NpmDist, +} + +#[derive(Deserialize)] +struct NpmDist { + tarball: String, + integrity: String, +} + /// The native-asset shape a package embeds into a compiled binary. enum AddonKind { /// A napi-rs addon: `..node`, sourced from a per-triple @@ -106,6 +126,211 @@ fn napi_triple(target: BinaryTarget) -> Option<&'static str> { } } +/// Download one target-native asset from its published npm prebuild package. +/// +/// Package managers intentionally skip optional dependencies whose `os`/`cpu` +/// selectors do not match the host. Alien cross-builds for Linux from macOS, +/// so the target prebuild is often absent even though the JavaScript wrapper +/// is installed correctly. Fetch only the requested target package, verify its +/// npm integrity, and cache the single asset outside the application. +async fn fetch_published_asset( + addon_dist: &Path, + spec: &NativeAddonSpec, + triple: &str, + asset_name: &str, + resource_name: &str, +) -> Result> { + let package_root = addon_dist.parent().ok_or_else(|| { + AlienError::new(ErrorData::ImageBuildFailed { + resource_name: resource_name.to_string(), + reason: format!( + "Resolved {} directory '{}' has no package root", + spec.package, + addon_dist.display() + ), + build_output: None, + }) + })?; + let manifest_path = package_root.join("package.json"); + // Unit-test fixtures and linked development packages may expose only the + // resolved native entry. Without a published wrapper version there is no + // safe target package to download; preserve the normal local-source path. + if !manifest_path.is_file() { + return Ok(None); + } + let manifest: PackageManifest = + serde_json::from_slice(&fs::read(&manifest_path).await.into_alien_error().context( + ErrorData::FileOperationFailed { + operation: "read".to_string(), + file_path: manifest_path.display().to_string(), + reason: format!("Failed to read the installed {} version", spec.package), + }, + )?) + .into_alien_error() + .context(ErrorData::FileOperationFailed { + operation: "parse".to_string(), + file_path: manifest_path.display().to_string(), + reason: format!("Failed to parse the installed {} manifest", spec.package), + })?; + + let target_package = format!("{}-{triple}", spec.package); + let cache_root = dirs::cache_dir() + .unwrap_or_else(std::env::temp_dir) + .join("alien") + .join("native-assets") + .join(spec.scoped_name) + .join(&manifest.version) + .join(triple); + let cached = cache_root.join(asset_name); + if cached.is_file() { + return Ok(Some(cached)); + } + + let encoded_package = target_package.replace('@', "%40").replace('/', "%2F"); + let metadata_url = format!( + "https://registry.npmjs.org/{encoded_package}/{}", + manifest.version + ); + info!( + "Downloading {}@{} for cross-build target {}", + target_package, manifest.version, triple + ); + let response = reqwest::get(&metadata_url) + .await + .into_alien_error() + .context(ErrorData::HttpRequestFailed { + message: format!("Failed to query npm metadata for {target_package}"), + url: Some(metadata_url.clone()), + })?; + if response.status() == reqwest::StatusCode::NOT_FOUND { + return Ok(None); + } + let metadata: NpmVersionMetadata = response + .error_for_status() + .into_alien_error() + .context(ErrorData::HttpRequestFailed { + message: format!("npm rejected metadata request for {target_package}"), + url: Some(metadata_url), + })? + .json() + .await + .into_alien_error() + .context(ErrorData::HttpRequestFailed { + message: format!("Failed to parse npm metadata for {target_package}"), + url: None, + })?; + let archive_bytes = reqwest::get(&metadata.dist.tarball) + .await + .into_alien_error() + .context(ErrorData::HttpRequestFailed { + message: format!("Failed to download {target_package}"), + url: Some(metadata.dist.tarball.clone()), + })? + .error_for_status() + .into_alien_error() + .context(ErrorData::HttpRequestFailed { + message: format!("npm rejected download for {target_package}"), + url: Some(metadata.dist.tarball.clone()), + })? + .bytes() + .await + .into_alien_error() + .context(ErrorData::HttpRequestFailed { + message: format!("Failed to read {target_package} archive"), + url: Some(metadata.dist.tarball), + })?; + + let expected_integrity = metadata + .dist + .integrity + .strip_prefix("sha512-") + .ok_or_else(|| { + AlienError::new(ErrorData::ImageBuildFailed { + resource_name: resource_name.to_string(), + reason: format!( + "npm metadata for {target_package} did not provide SHA-512 integrity" + ), + build_output: None, + }) + })?; + let expected_digest = BASE64_STANDARD + .decode(expected_integrity) + .map_err(|error| { + AlienError::new(ErrorData::ImageBuildFailed { + resource_name: resource_name.to_string(), + reason: format!("Invalid npm integrity for {target_package}: {error}"), + build_output: None, + }) + })?; + let actual_digest = Sha512::digest(&archive_bytes); + if actual_digest.as_slice() != expected_digest { + return Err(AlienError::new(ErrorData::ImageBuildFailed { + resource_name: resource_name.to_string(), + reason: format!("Integrity verification failed for {target_package}"), + build_output: None, + })); + } + + let expected_path = PathBuf::from("package").join(asset_name); + let archive_for_extract = archive_bytes.to_vec(); + let extracted = tokio::task::spawn_blocking(move || -> std::io::Result>> { + let decoder = flate2::read::GzDecoder::new(archive_for_extract.as_slice()); + let mut archive = tar::Archive::new(decoder); + for entry in archive.entries()? { + let mut entry = entry?; + if entry.path()?.as_ref() == expected_path { + let mut bytes = Vec::new(); + entry.read_to_end(&mut bytes)?; + return Ok(Some(bytes)); + } + } + Ok(None) + }) + .await + .into_alien_error() + .context(ErrorData::ImageBuildFailed { + resource_name: resource_name.to_string(), + reason: format!("Failed to inspect {target_package} archive"), + build_output: None, + })? + .into_alien_error() + .context(ErrorData::ImageBuildFailed { + resource_name: resource_name.to_string(), + reason: format!("Failed to extract {target_package} archive"), + build_output: None, + })?; + let Some(asset_bytes) = extracted else { + return Ok(None); + }; + + fs::create_dir_all(&cache_root) + .await + .into_alien_error() + .context(ErrorData::FileOperationFailed { + operation: "create directory".to_string(), + file_path: cache_root.display().to_string(), + reason: "Failed to create the native asset cache".to_string(), + })?; + let temporary = cache_root.join(format!("{asset_name}.staging-{}", std::process::id())); + fs::write(&temporary, asset_bytes) + .await + .into_alien_error() + .context(ErrorData::FileOperationFailed { + operation: "write".to_string(), + file_path: temporary.display().to_string(), + reason: format!("Failed to cache {target_package}"), + })?; + fs::rename(&temporary, &cached) + .await + .into_alien_error() + .context(ErrorData::FileOperationFailed { + operation: "rename".to_string(), + file_path: cached.display().to_string(), + reason: format!("Failed to publish cached {target_package}"), + })?; + Ok(Some(cached)) +} + /// Locate the native addon binary for `triple`, trying (in order): /// /// 1. The per-platform prebuild package (`@alienplatform/bindings-`, @@ -193,7 +418,8 @@ async fn find_napi_source( // 3. In-repo, host-triple build: source-build the dev addon with napi. let host_triple = napi_triple(BinaryTarget::current_os()); let Some(crate_dir) = workspace_addon_crate else { - return Ok(None); + return fetch_published_asset(addon_dist, spec, triple, addon_file_name, resource_name) + .await; }; if host_triple != Some(triple) { checked.push(format!( @@ -369,7 +595,14 @@ async fn find_binary_source( checked.push(format!( "(no crates/{cargo_package} above the app or resolved package)" )); - return Ok(None); + return match fetch_published_asset(addon_dist, spec, triple, bin_name, resource_name).await + { + Ok(asset) => Ok(asset), + Err(error) => { + checked.push(format!("(published prebuild download failed: {error})")); + Ok(None) + } + }; }; // The dev/target binary is host-native, so only usable when building for the diff --git a/examples/remote-worker-ts/alien.ts b/examples/remote-worker-ts/alien.ts index 1dae2c9f5..4fcae636a 100644 --- a/examples/remote-worker-ts/alien.ts +++ b/examples/remote-worker-ts/alien.ts @@ -10,6 +10,7 @@ const worker = new alien.Worker("worker") .build() export default new alien.Stack("remote-worker") + .platforms(["aws", "gcp", "azure"]) .add(files, "frozen") .add(worker, "live") .permissions({ diff --git a/examples/remote-worker-ts/package.json b/examples/remote-worker-ts/package.json index b3652395e..37ea94302 100644 --- a/examples/remote-worker-ts/package.json +++ b/examples/remote-worker-ts/package.json @@ -9,13 +9,13 @@ "test": "vitest run" }, "dependencies": { - "@alienplatform/core": "^1.0.1", - "@alienplatform/sdk": "^1.0.0", + "@alienplatform/core": "^3.3.11", + "@alienplatform/sdk": "^3.3.11", "hono": "^4.0.0", "zod": "4.3.2" }, "devDependencies": { - "@alienplatform/testing": "^0.1.0", + "@alienplatform/testing": "^3.3.11", "@types/node": "^24.0.15", "typescript": "~5.8.3", "vitest": "^3.1.4" diff --git a/examples/remote-worker-ts/tests/worker.test.ts b/examples/remote-worker-ts/tests/worker.test.ts index 8def22a93..1aff050ef 100644 --- a/examples/remote-worker-ts/tests/worker.test.ts +++ b/examples/remote-worker-ts/tests/worker.test.ts @@ -1,5 +1,10 @@ import { type Deployment, deploy } from "@alienplatform/testing" import { afterAll, beforeAll, describe, expect, it } from "vitest" +import { z } from "zod" + +const healthResponseSchema = z.object({ status: z.literal("ok") }) +const toolsResponseSchema = z.array(z.object({ name: z.string(), description: z.string() })) +const readFileResponseSchema = z.object({ content: z.string() }) describe("remote-worker-ts", () => { let deployment: Deployment @@ -16,15 +21,15 @@ describe("remote-worker-ts", () => { const response = await fetch(`${deployment.url}/health`) expect(response.ok).toBe(true) - const data = await response.json() + const data = healthResponseSchema.parse(await response.json()) expect(data.status).toBe("ok") }) it("should list available tools", async () => { - const tools = await deployment.invokeCommand("list-tools", {}) + const tools = toolsResponseSchema.parse(await deployment.invokeCommand("list-tools", {})) expect(Array.isArray(tools)).toBe(true) - const names = tools.map((t: { name: string }) => t.name) + const names = tools.map(tool => tool.name) expect(names).toContain("read-file") expect(names).toContain("write-file") }) @@ -35,10 +40,12 @@ describe("remote-worker-ts", () => { params: { path: "hello.txt", content: "Hello!" }, }) - const result = await deployment.invokeCommand("execute-tool", { - tool: "read-file", - params: { path: "hello.txt" }, - }) + const result = readFileResponseSchema.parse( + await deployment.invokeCommand("execute-tool", { + tool: "read-file", + params: { path: "hello.txt" }, + }), + ) expect(result.content).toBe("Hello!") }) diff --git a/packages/bindings/PACKAGE_LAYOUT.md b/packages/bindings/PACKAGE_LAYOUT.md index 425a54c9f..6352265ff 100644 --- a/packages/bindings/PACKAGE_LAYOUT.md +++ b/packages/bindings/PACKAGE_LAYOUT.md @@ -108,6 +108,10 @@ Only two entry points. Every condition carries `types`. No deep imports. workspace source manifest (`packages/bindings/package.json`) carries no `optionalDependencies` — adding the per-platform packages there would pin unpublished versions and break `pnpm install --frozen-lockfile` before release. + Package managers install only the host-matching optional dependency. When + `alien build` cross-builds for another target, it downloads that target's + matching prebuild at the wrapper's exact version, verifies the npm SHA-512 + integrity, and caches the single native asset under the Alien user cache. - `description` and `keywords`. - Support note: Bun ≥ 1.0.23 and Node ≥ 18 (Node-API / napi-rs addon). - `dependencies`: `@alienplatform/core` (errors) only. From ef40e3b17876c45f1d3cbb7659f88d90ab6ef882 Mon Sep 17 00:00:00 2001 From: Alon Gubkin Date: Sun, 9 Aug 2026 01:28:17 +0300 Subject: [PATCH 2/3] feat: complete customer onboarding CLI --- crates/alien-cli/src/commands/onboard.rs | 112 +++++++++++++++++++++-- crates/alien-cli/src/commands/release.rs | 10 +- 2 files changed, 111 insertions(+), 11 deletions(-) diff --git a/crates/alien-cli/src/commands/onboard.rs b/crates/alien-cli/src/commands/onboard.rs index 9315e9051..4ebfea6f3 100644 --- a/crates/alien-cli/src/commands/onboard.rs +++ b/crates/alien-cli/src/commands/onboard.rs @@ -5,6 +5,7 @@ use crate::ui::{accent, command, contextual_heading, dim_label, success_line, Fi use alien_core::{Platform, Stack, StackInputDefinition, StackInputKind, StackInputProvider}; use alien_error::{AlienError, Context, IntoAlienError}; use clap::{Parser, ValueEnum}; +use sha2::{Digest, Sha256}; use std::collections::HashMap; use std::str::FromStr; @@ -18,7 +19,7 @@ pub struct OnboardArgs { #[arg(value_name = "NAME")] pub name: Option, - /// Stable customer identifier used by your application. Defaults to NAME. + /// Stable customer identifier used by your application. Defaults to a URL-safe form of NAME. #[arg(long)] pub external_id: Option, @@ -134,7 +135,11 @@ async fn onboard_platform(args: OnboardArgs, ctx: ExecutionMode, name: String) - .as_deref() .map(validate_public_subdomain) .transpose()?; - let external_id = args.external_id.as_deref().unwrap_or(&name).to_string(); + let deployment_group_name = customer_environment_name(&name); + let external_id = args + .external_id + .clone() + .unwrap_or_else(|| deployment_group_name.clone()); if !args.json { let platforms_label = selected_platforms @@ -178,6 +183,7 @@ async fn onboard_platform(args: OnboardArgs, ctx: ExecutionMode, name: String) - setup_environment_variables, &selected_platforms, public_subdomain.as_deref(), + &name, )?, description: None, expires_at: None, @@ -190,7 +196,7 @@ async fn onboard_platform(args: OnboardArgs, ctx: ExecutionMode, name: String) - input_values: Some(stack_input_values), max_deployments: std::num::NonZeroU64::new(args.max_deployments) .unwrap_or(std::num::NonZeroU64::new(100).unwrap()), - name: name.clone().try_into().map_err(|e| { + name: deployment_group_name.try_into().map_err(|e| { AlienError::new(ErrorData::ValidationError { field: "name".to_string(), message: format!("{}", e), @@ -344,7 +350,8 @@ fn validate_setup_items( if requested.is_empty() { return Err(AlienError::new(ErrorData::ValidationError { field: "setup-items".to_string(), - message: "Select at least one of application, models, or keys.".to_string(), + message: "Select at least one of application, models, keys, storage, or registry." + .to_string(), })); } for item in requested { @@ -366,6 +373,7 @@ fn platform_onboard_deployment_setup_config( environment_variables: Vec, platforms: &[Platform], public_subdomain: Option<&str>, + customer_name: &str, ) -> Result { use alien_platform_api::types; @@ -399,8 +407,14 @@ fn platform_onboard_deployment_setup_config( .expect("selected onboarding platforms are validated before setup config creation") }; + let mut metadata = serde_json::Map::new(); + metadata.insert( + "customerName".to_string(), + serde_json::Value::String(customer_name.to_string()), + ); + Ok(types::DeploymentSetupConfig { - metadata: types::DeploymentSetupMetadata(serde_json::Map::new()), + metadata: types::DeploymentSetupMetadata(metadata), public_subdomain, policy: types::DeploymentSetupPolicy { allow_release_pinning: None, @@ -1008,11 +1022,12 @@ async fn onboard_standalone(args: OnboardArgs, ctx: ExecutionMode, name: String) Some(steps) }; + let deployment_group_name = customer_environment_name(&name); let response = mgr .client .create_deployment_group() .body(CreateDeploymentGroupRequest { - name: name.clone(), + name: deployment_group_name, max_deployments: Some(args.max_deployments as i64), }) .send() @@ -1085,6 +1100,46 @@ async fn onboard_standalone(args: OnboardArgs, ctx: ExecutionMode, name: String) Ok(()) } +/// Turn a customer-facing name into the stable internal Deployment Group name. +/// +/// The CLI intentionally accepts friendly names such as `Acme Corp`. Platform +/// Deployment Group names are URL-safe identifiers, so exposing that storage +/// constraint in the command's primary argument would make onboarding needlessly +/// awkward. The explicit `--external-id` remains untouched. +fn customer_environment_name(display_name: &str) -> String { + let mut name = display_name + .trim() + .to_ascii_lowercase() + .chars() + .fold(String::new(), |mut value, character| { + if character.is_ascii_alphanumeric() { + value.push(character); + } else if !value.ends_with('-') { + value.push('-'); + } + value + }) + .trim_matches('-') + .chars() + .take(100) + .collect::() + .trim_end_matches('-') + .to_string(); + + if name.is_empty() { + let digest = Sha256::digest(display_name.as_bytes()); + name = format!("customer-{}", hex::encode(&digest[..6])); + } else if name.len() == 1 { + name.push_str("-customer"); + } else if name.starts_with("dg-") || name.starts_with("dg_") { + name = format!("customer-{name}"); + name.truncate(100); + name = name.trim_end_matches('-').to_string(); + } + + name +} + #[cfg(all(test, feature = "platform"))] mod tests { use super::*; @@ -1151,12 +1206,49 @@ mod tests { let default = OnboardArgs::try_parse_from(["onboard", "customer"]).unwrap(); assert_eq!(default.setup_items, vec![OnboardSetupItem::Application]); - let composed = - OnboardArgs::try_parse_from(["onboard", "customer", "--setup-items", "models,keys"]) - .unwrap(); + let composed = OnboardArgs::try_parse_from([ + "onboard", + "customer", + "--setup-items", + "models,keys,storage,registry", + ]) + .unwrap(); assert_eq!( composed.setup_items, - vec![OnboardSetupItem::Models, OnboardSetupItem::Keys] + vec![ + OnboardSetupItem::Models, + OnboardSetupItem::Keys, + OnboardSetupItem::Storage, + OnboardSetupItem::Registry, + ] + ); + } + + #[test] + fn customer_names_become_safe_internal_environment_names() { + assert_eq!(customer_environment_name("Acme Corp"), "acme-corp"); + assert_eq!(customer_environment_name(" A "), "a-customer"); + assert_eq!(customer_environment_name("dg-admin"), "customer-dg-admin"); + assert_eq!( + customer_environment_name("客户"), + customer_environment_name("客户") + ); + assert!(customer_environment_name("客户").starts_with("customer-")); + } + + #[test] + fn setup_portal_keeps_the_customer_facing_name() { + let config = platform_onboard_deployment_setup_config( + Vec::new(), + &[Platform::Aws], + None, + "Acme Corp", + ) + .expect("setup config should be valid"); + + assert_eq!( + config.metadata.0.get("customerName"), + Some(&serde_json::Value::String("Acme Corp".to_string())) ); } diff --git a/crates/alien-cli/src/commands/release.rs b/crates/alien-cli/src/commands/release.rs index 85c756ce8..b6dfb100a 100644 --- a/crates/alien-cli/src/commands/release.rs +++ b/crates/alien-cli/src/commands/release.rs @@ -847,7 +847,7 @@ async fn declare_platform_release( ctx: &ExecutionMode, project_id: &str, workspace: Option<&str>, - _version: &str, + version: &str, git_metadata: Option, channel: &str, ) -> Result { @@ -860,8 +860,16 @@ async fn declare_platform_release( // No `.stack(...)` — a stackless release is a version identity only. let channel = parse_release_channel_name(channel)?; + let version = alien_platform_api::types::CreateReleaseRequestVersion::try_from(version) + .map_err(|error| { + AlienError::new(ErrorData::ValidationError { + field: "version".to_string(), + message: format!("Invalid release version: {error}"), + }) + })?; let body = alien_platform_api::types::CreateReleaseRequest::builder() .project(project_id.to_string()) + .version(version) .channel(channel) .git_metadata(git_metadata); From 27bd97310de42626a6ad8997b369aaf7f0fe1236 Mon Sep 17 00:00:00 2001 From: Alon Gubkin Date: Fri, 14 Aug 2026 12:50:15 -0600 Subject: [PATCH 3/3] feat: add OpenAI customer model routing --- crates/alien-ai-gateway/src/creds.rs | 35 ++++++ crates/alien-ai-gateway/src/lib.rs | 8 +- crates/alien-ai-gateway/src/router/mod.rs | 110 +++++++++++++++++-- crates/alien-ai-gateway/tests/integration.rs | 64 ++++++++++- 4 files changed, 201 insertions(+), 16 deletions(-) diff --git a/crates/alien-ai-gateway/src/creds.rs b/crates/alien-ai-gateway/src/creds.rs index fda0a10c3..71f15831e 100644 --- a/crates/alien-ai-gateway/src/creds.rs +++ b/crates/alien-ai-gateway/src/creds.rs @@ -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 { @@ -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) -> Result { + 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 { diff --git a/crates/alien-ai-gateway/src/lib.rs b/crates/alien-ai-gateway/src/lib.rs index dae9f85d3..8def0e15b 100644 --- a/crates/alien-ai-gateway/src/lib.rs +++ b/crates/alien-ai-gateway/src/lib.rs @@ -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}; diff --git a/crates/alien-ai-gateway/src/router/mod.rs b/crates/alien-ai-gateway/src/router/mod.rs index 798a5a308..cc1bea603 100644 --- a/crates/alien-ai-gateway/src/router/mod.rs +++ b/crates/alien-ai-gateway/src/router/mod.rs @@ -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; @@ -79,6 +79,7 @@ where pub enum GatewayTarget { Cloud(Platform), DirectAnthropic, + DirectOpenAi, } pub struct GatewayRoute { @@ -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, + api_key: impl Into, +) -> Result { + 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, client: reqwest::Client, @@ -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 { @@ -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)) @@ -543,6 +590,25 @@ async fn list_models( }) }) .collect(), + GatewayTarget::DirectOpenAi => { + let mut models = allowed + .into_iter() + .flatten() + .flat_map(|models| models.iter()) + .collect::>(); + 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()) } @@ -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 { + 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 { AlienError::new(ErrorData::BindingConfigInvalid { @@ -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(), })) } }; diff --git a/crates/alien-ai-gateway/tests/integration.rs b/crates/alien-ai-gateway/tests/integration.rs index f12515f46..a9505cb1d 100644 --- a/crates/alien-ai-gateway/tests/integration.rs +++ b/crates/alien-ai-gateway/tests/integration.rs @@ -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; @@ -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()); +}