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 examples/live_chutes_embedding.rs
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,7 @@ async fn run() -> Result<(), String> {
headers: HashMap::new(),
path: Some("/v1/embeddings".to_string()),
target_route_id: None,
client_authorization: Default::default(),
};
let prepared = backend
.prepare(request)
Expand Down
18 changes: 18 additions & 0 deletions src/aci/upstream/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -43,12 +43,30 @@ use openai::request_model_id;
pub const DEFAULT_UPSTREAM_CONNECT_TIMEOUT_SECONDS: u64 = 10;
pub const DEFAULT_UPSTREAM_READ_TIMEOUT_SECONDS: u64 = 600;

/// Per-request upstream credential carried through the forwarding path
/// for BYOK auth-passthrough. Holds the caller's token in memory only and
/// redacts on `Debug` so a raw key can never land in logs or receipts.
#[derive(Clone, Default)]
pub struct ClientAuthorization(pub Option<String>);

impl std::fmt::Debug for ClientAuthorization {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self.0 {
Some(_) => f.write_str("ClientAuthorization(<redacted>)"),
None => f.write_str("ClientAuthorization(None)"),
}
}
}

#[derive(Debug, Clone, Default)]
pub struct UpstreamRequest {
pub body: Vec<u8>,
pub headers: HashMap<String, String>,
pub path: Option<String>,
pub target_route_id: Option<String>,
/// Caller credential for BYOK auth-passthrough upstreams. Ignored by
/// backends that are not configured for passthrough.
pub client_authorization: ClientAuthorization,
}

#[derive(Debug, Clone)]
Expand Down
23 changes: 22 additions & 1 deletion src/aci/upstream/openai.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,11 @@ pub struct OpenAICompatibleBackend {
base_url: String,
path: String,
bearer_token: Option<String>,
/// When true, the caller's per-request credential
/// ([`UpstreamRequest::client_authorization`]) is forwarded as the
/// upstream `Authorization` and the static `bearer_token` is never used
/// (BYOK). Set from `UpstreamConfig::auth_passthrough`.
auth_passthrough: bool,
client: reqwest::Client,
connect_timeout_seconds: u64,
read_timeout_seconds: u64,
Expand Down Expand Up @@ -61,6 +66,7 @@ impl OpenAICompatibleBackend {
base_url: base,
path: "/v1/chat/completions".to_string(),
bearer_token: None,
auth_passthrough: false,
client,
connect_timeout_seconds,
read_timeout_seconds,
Expand All @@ -85,6 +91,13 @@ impl OpenAICompatibleBackend {
self.bearer_token = Some(token.into());
self
}

/// Enable BYOK auth-passthrough: forward the caller's per-request
/// credential to the upstream instead of a statically-configured token.
pub fn with_auth_passthrough(mut self, enabled: bool) -> Self {
self.auth_passthrough = enabled;
self
}
}

pub(super) fn request_model_id(body: &[u8]) -> Option<String> {
Expand Down Expand Up @@ -325,7 +338,15 @@ impl OpenAICompatibleBackend {
for (k, v) in req.headers.iter() {
builder = builder.header(k, v);
}
if let Some(t) = &self.bearer_token {
if self.auth_passthrough {
// BYOK: forward only the caller's credential; never fall back to
// a static token for a passthrough upstream. Absent credential =>
// no auth header (the upstream rejects, which is the correct,
// visible failure).
if let Some(t) = &req.client_authorization.0 {
builder = builder.header("authorization", format!("Bearer {t}"));
}
} else if let Some(t) = &self.bearer_token {
builder = builder.header("authorization", format!("Bearer {t}"));
}
builder
Expand Down
7 changes: 6 additions & 1 deletion src/aggregator/service/forward.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@ use crate::aci::receipt::{
ReceiptBuilder, ReceiptError, TransparencyEventKind, UpstreamVerifiedEvent, VerificationResult,
};
use crate::aci::types::Receipt;
use crate::aci::upstream::{PreparedUpstreamRequest, UpstreamError, UpstreamRequest};
use crate::aci::upstream::{
ClientAuthorization, PreparedUpstreamRequest, UpstreamError, UpstreamRequest,
};
use crate::aggregator::metrics::{RequestMode, StreamErrorKind};
use crate::aggregator::session::{
AttestedSession, EvidenceRef, SessionClaims, WorkloadIdentityRef,
Expand Down Expand Up @@ -40,6 +42,7 @@ impl AciService {
upstream_verification_event,
requester: None,
e2ee: None,
client_authorization: ClientAuthorization(None),
})
.await
}
Expand All @@ -66,6 +69,7 @@ impl AciService {
body: backend_input_body,
path: Some(endpoint_path.to_string()),
target_route_id: target_route_id.clone(),
client_authorization: req.client_authorization.clone(),
..Default::default()
})?;
let forwarded_body = prepared.request.body.clone();
Expand Down Expand Up @@ -209,6 +213,7 @@ impl AciService {
body: backend_input_body,
path: Some(endpoint_path.to_string()),
target_route_id: target_route_id.clone(),
client_authorization: req.client_authorization.clone(),
..Default::default()
})?;
let forwarded_body = prepared.request.body.clone();
Expand Down
1 change: 1 addition & 0 deletions src/aggregator/service/middleware.rs
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,7 @@ impl AciService {
body: candidate.body.clone(),
path: Some(endpoint_path.to_string()),
target_route_id: Some(route_id.clone()),
client_authorization: req.client_authorization.clone(),
..Default::default()
}) {
Ok(prepared) => prepared,
Expand Down
6 changes: 6 additions & 0 deletions src/aggregator/service/wire.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ use futures_util::Stream;
use super::{ReceiptOwner, ServiceError};
use crate::aci::receipt::{ReceiptBuilder, UpstreamVerifiedEvent};
use crate::aci::types::Receipt;
use crate::aci::upstream::ClientAuthorization;
use crate::aggregator::metrics::RequestMode;

pub struct E2eeRequestParts<'a> {
Expand Down Expand Up @@ -228,6 +229,11 @@ pub struct ChatCompletionRequest<'a> {
/// receipt that any caller can retrieve.
pub requester: Option<ReceiptOwner>,
pub e2ee: Option<E2eeRequestContext>,
/// Caller credential to forward to a BYOK auth-passthrough upstream.
/// Empty for non-passthrough upstreams (the static token is used).
/// Typed as [`ClientAuthorization`] so the raw secret redacts on `Debug`
/// end-to-end, not just at the final hop.
pub client_authorization: ClientAuthorization,
}

#[derive(Debug, Clone, Default)]
Expand Down
20 changes: 19 additions & 1 deletion src/aggregator/upstream_config/builders.rs
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,23 @@ fn provider_is_tee(provider: UpstreamProvider) -> bool {
}
}

/// Whether a provider's backend honours BYOK `auth_passthrough`. Only the
/// providers served by `OpenAICompatibleBackend` (wired with
/// `with_auth_passthrough` below) forward the caller credential. Chutes uses a
/// dedicated E2EE transport that ignores it, so enabling passthrough there
/// would be a silent no-op — rejected at validation instead. Keep this in sync
/// with the backend match arms in `build_provider_backend`.
pub(super) fn provider_supports_auth_passthrough(provider: UpstreamProvider) -> bool {
match provider {
UpstreamProvider::Chutes => false,
UpstreamProvider::OpenAiCompatible
| UpstreamProvider::AciDcap
| UpstreamProvider::Tinfoil
| UpstreamProvider::NearAi
| UpstreamProvider::PhalaDirect => true,
}
}

fn build_provider_backend(
cfg: &UpstreamConfig,
options: &UpstreamRuntimeOptions,
Expand Down Expand Up @@ -118,7 +135,8 @@ fn build_provider_backend(
read_timeout_seconds,
)
.map_err(|e| UpstreamConfigError::InvalidConfig(e.to_string()))?
.with_name(cfg.name.clone());
.with_name(cfg.name.clone())
.with_auth_passthrough(cfg.auth_passthrough);
if let Some(token) = &cfg.bearer_token {
backend = backend.with_bearer_token(token.clone());
}
Expand Down
7 changes: 7 additions & 0 deletions src/aggregator/upstream_config/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,11 @@ pub struct UpstreamConfig {
pub models: BTreeMap<String, String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub bearer_token: Option<String>,
/// BYOK: forward each caller's own `Authorization` to the upstream
/// instead of a static `bearer_token`. Mutually exclusive with
/// `bearer_token` (rejected at config validation).
#[serde(default)]
pub auth_passthrough: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub accepted_workload_ids: Option<Vec<String>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
Expand Down Expand Up @@ -87,6 +92,7 @@ pub struct PublicUpstreamConfig {
pub path: Option<String>,
pub models: BTreeMap<String, String>,
pub bearer_token_configured: bool,
pub auth_passthrough: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub accepted_workload_ids: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
Expand Down Expand Up @@ -126,6 +132,7 @@ impl UpstreamConfig {
path: self.path.clone(),
models: self.models.clone(),
bearer_token_configured: self.bearer_token.is_some(),
auth_passthrough: self.auth_passthrough,
accepted_workload_ids: self.accepted_workload_ids.clone(),
accepted_image_digests: self.accepted_image_digests.clone(),
accepted_dstack_kms_root_public_keys: self.accepted_dstack_kms_root_public_keys.clone(),
Expand Down
78 changes: 78 additions & 0 deletions src/aggregator/upstream_config/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ fn test_upstream_config(
path: None,
models: BTreeMap::from([(public_model.to_string(), upstream_model.to_string())]),
bearer_token: None,
auth_passthrough: false,
accepted_workload_ids: None,
accepted_image_digests: None,
accepted_dstack_kms_root_public_keys: None,
Expand Down Expand Up @@ -89,6 +90,82 @@ fn parse_config_allows_same_public_model_on_distinct_route_ids() {
assert_eq!(config.len(), 2);
}

#[test]
fn byok_auth_passthrough_parses_and_defaults_off() {
let config = parse_config_text(
r#"
[
{
"name": "near-ai",
"provider": "near-ai",
"base_url": "https://cloud-api.near.ai",
"models": {"openai/gpt-oss-120b": "near-model"},
"auth_passthrough": true
},
{
"name": "plain",
"provider": "openai-compatible",
"base_url": "https://plain.example",
"models": {"m": "u"}
}
]
"#,
)
.expect("auth_passthrough upstream is valid without a static token");

assert!(config[0].auth_passthrough);
assert!(!config[1].auth_passthrough, "defaults off when omitted");
}

#[test]
fn byok_auth_passthrough_rejects_static_bearer_token() {
let err = parse_config_text(
r#"
[
{
"name": "near-ai",
"provider": "near-ai",
"base_url": "https://cloud-api.near.ai",
"models": {"openai/gpt-oss-120b": "near-model"},
"auth_passthrough": true,
"bearer_token": "should-not-be-here"
}
]
"#,
)
.expect_err("auth_passthrough + bearer_token must be rejected");

assert!(
matches!(err, UpstreamConfigError::InvalidConfig(msg) if msg.contains("auth_passthrough")),
"error should name the offending field",
);
}

#[test]
fn byok_auth_passthrough_rejects_providers_that_do_not_support_it() {
// Chutes uses a dedicated E2EE transport that ignores auth_passthrough,
// so enabling it there is a silent no-op and must be rejected.
let err = parse_config_text(
r#"
[
{
"name": "chutes-provider",
"provider": "chutes",
"base_url": "https://chutes.example",
"models": {"m": "u"},
"auth_passthrough": true
}
]
"#,
)
.expect_err("auth_passthrough on an unsupported provider must be rejected");

assert!(
matches!(err, UpstreamConfigError::InvalidConfig(msg) if msg.contains("does not")),
"error should explain the provider does not support passthrough",
);
}

#[test]
fn global_aci_dcap_does_not_require_policy_for_plain_openai_compatible_upstreams() {
let config = vec![
Expand Down Expand Up @@ -166,6 +243,7 @@ async fn prewarm_verification_deduplicates_upstream_models() {
("public-c".to_string(), "upstream-c".to_string()),
]),
bearer_token: None,
auth_passthrough: false,
accepted_workload_ids: None,
accepted_image_digests: None,
accepted_dstack_kms_root_public_keys: None,
Expand Down
16 changes: 16 additions & 0 deletions src/aggregator/upstream_config/validation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,22 @@ pub(super) fn validate_config(config: &[UpstreamConfig]) -> Result<(), UpstreamC
upstream.name
)));
}
if upstream.auth_passthrough && upstream.bearer_token.is_some() {
return Err(UpstreamConfigError::InvalidConfig(format!(
"upstream {:?} sets both auth_passthrough and bearer_token; \
a passthrough upstream must not configure a static token",
upstream.name
)));
}
if upstream.auth_passthrough
&& !super::builders::provider_supports_auth_passthrough(upstream.provider)
{
return Err(UpstreamConfigError::InvalidConfig(format!(
"upstream {:?} sets auth_passthrough but provider {:?} does not \
support it (only OpenAI-compatible-forwarded providers do)",
upstream.name, upstream.provider
)));
}
for (field, value) in [
("connect_timeout_seconds", upstream.connect_timeout_seconds),
("read_timeout_seconds", upstream.read_timeout_seconds),
Expand Down
13 changes: 12 additions & 1 deletion src/http/app/backend.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ use futures_util::StreamExt;
use rand::RngCore;
use serde_json::Value;

use crate::aci::upstream::UpstreamError;
use crate::aci::upstream::{ClientAuthorization, UpstreamError};
use crate::aggregator::service::{
AciService, ChatCompletionRequest, E2eeRequestContext, E2eeResponseInfo, GatewayRequestContext,
MiddlewareForwardResult, ReceiptOwner, ServiceError, StreamingForwardResult,
Expand All @@ -34,6 +34,10 @@ pub(super) struct BackendForwardInput {
pub(super) upstream_required: bool,
pub(super) requester: Option<ReceiptOwner>,
pub(super) e2ee: Option<E2eeRequestContext>,
/// Caller credential for BYOK auth-passthrough upstreams. Forwarded to the
/// backend in memory only; the [`ClientAuthorization`] newtype redacts on
/// `Debug` so the raw secret cannot be logged or persisted.
pub(super) client_authorization: ClientAuthorization,
pub(super) stream: bool,
}

Expand All @@ -52,6 +56,7 @@ pub(super) async fn forward_to_backend(
upstream_verification_event: None,
requester: input.requester,
e2ee: input.e2ee,
client_authorization: input.client_authorization,
})
.await;
return match result {
Expand Down Expand Up @@ -104,6 +109,7 @@ pub(super) async fn forward_to_backend(
upstream_verification_event: None,
requester: input.requester,
e2ee: input.e2ee,
client_authorization: input.client_authorization,
})
.await;
match result {
Expand Down Expand Up @@ -181,6 +187,11 @@ pub(super) async fn internal_forward(
upstream_verification_event: None,
requester: stored.requester,
e2ee: stored.e2ee,
// Middleware mode deliberately does not carry the raw caller
// credential (the request store keeps only a hashed requester),
// so BYOK passthrough is unsupported on this path by design
// (and rejected at startup when middleware is enabled).
client_authorization: ClientAuthorization(None),
},
candidates,
stream,
Expand Down
Loading