Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
97 changes: 90 additions & 7 deletions crates/cli/src/configuration/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ use std::path::{Path, PathBuf};
use std::thread;
use std::time::{Duration, Instant};

use axum::http::HeaderMap;
use axum::http::{HeaderMap, HeaderValue};
use nemo_relay::logging::LoggingConfig;
use nemo_relay::plugin::dynamic::{
DYNAMIC_PLUGIN_MANIFEST_FILENAME, DynamicPluginManifest, DynamicPluginManifestLoad,
Expand Down Expand Up @@ -65,7 +65,9 @@ struct FileGatewayConfig {
#[derive(Debug, Clone, Default, Deserialize)]
struct FileUpstreamConfig {
openai_base_url: Option<String>,
openai_auth_header: Option<String>,
anthropic_base_url: Option<String>,
anthropic_auth_header: Option<String>,
}

#[derive(Debug, Clone, Default, Deserialize)]
Expand Down Expand Up @@ -236,7 +238,9 @@ fn persistent_bootstrap_fingerprint(
"bootstrap_protocol": crate::bootstrap::BOOTSTRAP_PROTOCOL_VERSION,
"relay_version": env!("CARGO_PKG_VERSION"),
"openai_base_url": gateway.openai_base_url,
"openai_auth_header": gateway.openai_auth_header,
"anthropic_base_url": gateway.anthropic_base_url,
"anthropic_auth_header": gateway.anthropic_auth_header,
"metadata": gateway.metadata,
"plugin_config": gateway.plugin_config,
"max_hook_payload_bytes": gateway.max_hook_payload_bytes,
Expand Down Expand Up @@ -982,7 +986,7 @@ fn load_shared_config_scoped(
path.display()
)));
}
merge_toml(&mut merged, parsed);
merge_gateway_config_toml(&mut merged, parsed);
}
let plugin_toml = load_plugin_toml_config_scoped(explicit, plugin_config_path, user_only)?;
let mut resolved = ResolvedConfig {
Expand Down Expand Up @@ -1156,7 +1160,7 @@ fn apply_file_config(resolved: &mut ResolvedConfig, value: toml::Value) -> Resul
CliError::Config(format!("invalid gateway configuration shape: {error}"))
})?;
apply_file_gateway_config(&mut resolved.gateway, config.gateway)?;
apply_file_upstream_config(&mut resolved.gateway, config.upstream);
apply_file_upstream_config(&mut resolved.gateway, config.upstream)?;
apply_file_agents_config(&mut resolved.agents, config.agents);
logging::apply_file_logging_config(&mut resolved.logging, config.logging)?;
Ok(())
Expand All @@ -1182,16 +1186,42 @@ fn apply_file_gateway_config(

// Applies upstream LLM provider URLs. These are the bases for OpenAI- and Anthropic-shaped
// gateway routes; transparent `run` mode can still override them per invocation.
fn apply_file_upstream_config(gateway: &mut GatewayConfig, upstream: Option<FileUpstreamConfig>) {
fn apply_file_upstream_config(
gateway: &mut GatewayConfig,
upstream: Option<FileUpstreamConfig>,
) -> Result<(), CliError> {
let Some(upstream) = upstream else {
return;
return Ok(());
};
if let Some(value) = upstream.openai_base_url {
let FileUpstreamConfig {
openai_base_url,
openai_auth_header,
anthropic_base_url,
anthropic_auth_header,
} = upstream;
if let Some(value) = openai_base_url {
gateway.openai_base_url = value;
if openai_auth_header.is_none() {
gateway.openai_auth_header = None;
}
}
if let Some(value) = openai_auth_header {
gateway.openai_auth_header =
Some(validate_auth_header("upstream.openai_auth_header", value)?);
}
if let Some(value) = upstream.anthropic_base_url {
if let Some(value) = anthropic_base_url {
gateway.anthropic_base_url = value;
if anthropic_auth_header.is_none() {
gateway.anthropic_auth_header = None;
}
}
if let Some(value) = anthropic_auth_header {
gateway.anthropic_auth_header = Some(validate_auth_header(
"upstream.anthropic_auth_header",
value,
)?);
}
Ok(())
}

#[derive(Debug, Clone)]
Expand Down Expand Up @@ -1458,11 +1488,31 @@ fn apply_env_config(config: &mut GatewayConfig) -> Result<(), CliError> {
{
config.bind = value;
}
let openai_auth_header = std::env::var("NEMO_RELAY_OPENAI_AUTH_HEADER").ok();
if let Ok(value) = std::env::var("NEMO_RELAY_OPENAI_BASE_URL") {
config.openai_base_url = value;
if openai_auth_header.is_none() {
config.openai_auth_header = None;
}
}
if let Some(value) = openai_auth_header {
config.openai_auth_header = Some(validate_auth_header(
"NEMO_RELAY_OPENAI_AUTH_HEADER",
value,
)?);
}
let anthropic_auth_header = std::env::var("NEMO_RELAY_ANTHROPIC_AUTH_HEADER").ok();
if let Ok(value) = std::env::var("NEMO_RELAY_ANTHROPIC_BASE_URL") {
config.anthropic_base_url = value;
if anthropic_auth_header.is_none() {
config.anthropic_auth_header = None;
}
}
if let Some(value) = anthropic_auth_header {
config.anthropic_auth_header = Some(validate_auth_header(
"NEMO_RELAY_ANTHROPIC_AUTH_HEADER",
value,
)?);
}
if let Ok(value) = std::env::var("NEMO_RELAY_MAX_HOOK_PAYLOAD_BYTES") {
config.max_hook_payload_bytes =
Expand All @@ -1475,6 +1525,16 @@ fn apply_env_config(config: &mut GatewayConfig) -> Result<(), CliError> {
Ok(())
}

fn validate_auth_header(name: &str, value: String) -> Result<String, CliError> {
let value = value.trim().to_string();
if value.is_empty() {
return Err(CliError::Config(format!("{name} must not be empty")));
}
HeaderValue::from_str(&value)
.map_err(|_| CliError::Config(format!("{name} must be a valid HTTP header value")))?;
Ok(value)
Comment thread
willkill07 marked this conversation as resolved.
}

fn parse_env_body_limit(name: &str, raw: &str) -> Result<usize, CliError> {
let value = raw.parse::<usize>().map_err(|error| {
CliError::Config(format!("{name} must be a positive byte count: {error}"))
Expand Down Expand Up @@ -1507,6 +1567,29 @@ fn merge_toml(left: &mut toml::Value, right: toml::Value) {
}
}

// Upstream credentials are bound to their configured endpoint. A higher-priority layer that
// changes an endpoint without supplying a replacement credential must not inherit the credential
// for the old endpoint.
fn merge_gateway_config_toml(left: &mut toml::Value, right: toml::Value) {
if let (Some(existing), Some(override_upstream)) = (
left.get_mut("upstream").and_then(toml::Value::as_table_mut),
right.get("upstream").and_then(toml::Value::as_table),
) {
for (base_url, auth_header) in [
("openai_base_url", "openai_auth_header"),
("anthropic_base_url", "anthropic_auth_header"),
] {
let endpoint_changed = override_upstream
.get(base_url)
.is_some_and(|value| existing.get(base_url) != Some(value));
if endpoint_changed && !override_upstream.contains_key(auth_header) {
existing.remove(auth_header);
}
}
}
merge_toml(left, right);
}

fn legacy_observability_sections(value: &toml::Value) -> Vec<&'static str> {
let mut sections = Vec::new();
if value.get("exporters").is_some() {
Expand Down
4 changes: 4 additions & 0 deletions crates/cli/src/configuration/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,9 @@ use super::{
pub(crate) struct GatewayConfig {
pub(crate) bind: SocketAddr,
pub(crate) openai_base_url: String,
pub(crate) openai_auth_header: Option<String>,
pub(crate) anthropic_base_url: String,
pub(crate) anthropic_auth_header: Option<String>,
pub(crate) metadata: Option<Value>,
pub(crate) plugin_config: Option<Value>,
pub(crate) max_hook_payload_bytes: usize,
Expand Down Expand Up @@ -112,7 +114,9 @@ impl Default for GatewayConfig {
.parse()
.expect("valid default bind address"),
openai_base_url: "https://api.openai.com/v1".into(),
openai_auth_header: None,
anthropic_base_url: "https://api.anthropic.com".into(),
anthropic_auth_header: None,
metadata: None,
plugin_config: None,
max_hook_payload_bytes: DEFAULT_MAX_HOOK_PAYLOAD_BYTES,
Expand Down
24 changes: 20 additions & 4 deletions crates/cli/src/gateway/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,7 @@ async fn run_unmanaged_gateway(
&prepared.body_bytes,
&prepared.headers,
None,
ProviderForwarding::new(prepared.provider, prepared.authorization),
ProviderForwarding::new(prepared.provider, prepared.authorization, &state.config),
)
.await?;
let status = response.status();
Expand Down Expand Up @@ -254,9 +254,11 @@ fn build_buffered_func(
let url = prepared.upstream_url.clone();
let body_bytes = prepared.body_bytes.clone();
let headers = prepared.headers.clone();
let forwarding = ProviderForwarding::new(prepared.provider, prepared.authorization);
let forwarding =
ProviderForwarding::new(prepared.provider, prepared.authorization, &state.config);
Arc::new(move |request| {
let http = http.clone();
let forwarding = forwarding.clone();
let method = method.clone();
let url = url.clone();
let body_bytes = body_bytes.clone();
Expand Down Expand Up @@ -436,9 +438,11 @@ fn build_streaming_func(
let url = prepared.upstream_url.clone();
let body_bytes = prepared.body_bytes.clone();
let headers = prepared.headers.clone();
let forwarding = ProviderForwarding::new(prepared.provider, prepared.authorization);
let forwarding =
ProviderForwarding::new(prepared.provider, prepared.authorization, &state.config);
Arc::new(move |request| {
let http = http.clone();
let forwarding = forwarding.clone();
let method = method.clone();
let url = url.clone();
let body_bytes = body_bytes.clone();
Expand Down Expand Up @@ -711,6 +715,7 @@ async fn forward_upstream_request(
url,
forwarding.source_route,
);
let configured_auth_header = forwarding.configured_auth_header(effective.target_route);
let mut upstream = http
.request(method.clone(), &effective.url)
.body(effective.body_bytes.clone());
Expand All @@ -727,6 +732,7 @@ async fn forward_upstream_request(
effective.credential_policy,
TargetCredentialPolicy::SourceOrEnvironment
) && forwarding.authorization.allow_environment_provider_auth,
configured_auth_header,
);
upstream.send().await
}
Expand Down Expand Up @@ -894,12 +900,14 @@ fn inject_provider_auth(
route: ProviderRoute,
inbound: &HeaderMap,
allow_environment_provider_auth: bool,
configured_auth_header: Option<&str>,
) -> reqwest::RequestBuilder {
inject_provider_auth_with_env(
builder,
route,
inbound,
allow_environment_provider_auth,
configured_auth_header,
|key| std::env::var(key).ok(),
)
}
Expand All @@ -911,6 +919,7 @@ fn inject_provider_auth_with_env<F>(
route: ProviderRoute,
inbound: &HeaderMap,
allow_environment_provider_auth: bool,
configured_auth_header: Option<&str>,
env_lookup: F,
) -> reqwest::RequestBuilder
where
Expand All @@ -926,6 +935,9 @@ where
if already_authed {
return builder;
}
if let Some(value) = configured_auth_header {
return builder.header(http::header::AUTHORIZATION, value);
}
let (env_var, header_name) = match route {
ProviderRoute::OpenAiResponses
| ProviderRoute::OpenAiChatCompletions
Expand Down Expand Up @@ -966,7 +978,7 @@ async fn passthrough_streaming(
&prepared.body_bytes,
&prepared.headers,
None,
ProviderForwarding::new(prepared.provider, prepared.authorization),
ProviderForwarding::new(prepared.provider, prepared.authorization, &state.config),
)
.await?;
let status = response.status();
Expand Down Expand Up @@ -1105,6 +1117,7 @@ pub(crate) async fn models(
);
}
let provider = ProviderRoute::OpenAiModels;
let configured_auth_header = provider.configured_auth_header(&state.config);
let path_and_query = parts
.uri
.path_and_query()
Expand All @@ -1118,12 +1131,14 @@ pub(crate) async fn models(
&parts.headers,
path_and_query,
allow_environment_provider_auth,
&state.config,
)
.unwrap_or_else(|| provider.upstream_url(&state.config, path_and_query));
let sanitized = strip_replaceable_agent_auth_headers(
&parts.headers,
provider,
allow_environment_provider_auth,
configured_auth_header,
);
let mut upstream = state.http.get(upstream_url);
for (name, value) in &sanitized {
Expand All @@ -1136,6 +1151,7 @@ pub(crate) async fn models(
provider,
&sanitized,
allow_environment_provider_auth,
configured_auth_header,
);
let upstream_response = upstream.send().await?;
let status = upstream_response.status();
Expand Down
2 changes: 2 additions & 0 deletions crates/cli/src/gateway/request.rs
Original file line number Diff line number Diff line change
Expand Up @@ -65,12 +65,14 @@ pub(super) async fn prepare_gateway_request(
&parts.headers,
path_and_query,
authorization.allow_environment_provider_auth,
config,
)
.unwrap_or_else(|| provider.upstream_url(config, path_and_query));
parts.headers = super::routes::strip_replaceable_agent_auth_headers(
&parts.headers,
provider,
authorization.allow_environment_provider_auth,
provider.configured_auth_header(config),
);
authorization.source_credential = authorization
.source_credential
Expand Down
Loading
Loading