diff --git a/crates/cli/src/configuration/mod.rs b/crates/cli/src/configuration/mod.rs index cddd5bc96..47239d0bc 100644 --- a/crates/cli/src/configuration/mod.rs +++ b/crates/cli/src/configuration/mod.rs @@ -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, @@ -65,7 +65,9 @@ struct FileGatewayConfig { #[derive(Debug, Clone, Default, Deserialize)] struct FileUpstreamConfig { openai_base_url: Option, + openai_auth_header: Option, anthropic_base_url: Option, + anthropic_auth_header: Option, } #[derive(Debug, Clone, Default, Deserialize)] @@ -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, @@ -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 { @@ -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(()) @@ -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) { +fn apply_file_upstream_config( + gateway: &mut GatewayConfig, + upstream: Option, +) -> 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)] @@ -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 = @@ -1475,6 +1525,16 @@ fn apply_env_config(config: &mut GatewayConfig) -> Result<(), CliError> { Ok(()) } +fn validate_auth_header(name: &str, value: String) -> Result { + 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) +} + fn parse_env_body_limit(name: &str, raw: &str) -> Result { let value = raw.parse::().map_err(|error| { CliError::Config(format!("{name} must be a positive byte count: {error}")) @@ -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() { diff --git a/crates/cli/src/configuration/types.rs b/crates/cli/src/configuration/types.rs index 38a55d8e3..0f2c06a8c 100644 --- a/crates/cli/src/configuration/types.rs +++ b/crates/cli/src/configuration/types.rs @@ -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, pub(crate) anthropic_base_url: String, + pub(crate) anthropic_auth_header: Option, pub(crate) metadata: Option, pub(crate) plugin_config: Option, pub(crate) max_hook_payload_bytes: usize, @@ -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, diff --git a/crates/cli/src/gateway/mod.rs b/crates/cli/src/gateway/mod.rs index bdc00d965..c1f2f25dd 100644 --- a/crates/cli/src/gateway/mod.rs +++ b/crates/cli/src/gateway/mod.rs @@ -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(); @@ -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(); @@ -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(); @@ -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()); @@ -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 } @@ -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(), ) } @@ -911,6 +919,7 @@ fn inject_provider_auth_with_env( route: ProviderRoute, inbound: &HeaderMap, allow_environment_provider_auth: bool, + configured_auth_header: Option<&str>, env_lookup: F, ) -> reqwest::RequestBuilder where @@ -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 @@ -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(); @@ -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() @@ -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 { @@ -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(); diff --git a/crates/cli/src/gateway/request.rs b/crates/cli/src/gateway/request.rs index 8ca10f24c..ed9c3f24a 100644 --- a/crates/cli/src/gateway/request.rs +++ b/crates/cli/src/gateway/request.rs @@ -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 diff --git a/crates/cli/src/gateway/routes.rs b/crates/cli/src/gateway/routes.rs index b109ac386..b97774556 100644 --- a/crates/cli/src/gateway/routes.rs +++ b/crates/cli/src/gateway/routes.rs @@ -14,22 +14,35 @@ pub(super) enum ProviderRoute { AnthropicCountTokens, } -#[derive(Clone, Copy)] +#[derive(Clone)] pub(super) struct ProviderForwarding { pub(super) source_route: ProviderRoute, pub(super) authorization: crate::provider_auth::ProviderRequestAuthorization, + openai_auth_header: Option, + anthropic_auth_header: Option, } impl ProviderForwarding { pub(super) fn new( source_route: ProviderRoute, authorization: crate::provider_auth::ProviderRequestAuthorization, + config: &crate::configuration::GatewayConfig, ) -> Self { Self { source_route, authorization, + openai_auth_header: config.openai_auth_header.clone(), + anthropic_auth_header: config.anthropic_auth_header.clone(), } } + + pub(super) fn configured_auth_header(&self, route: ProviderRoute) -> Option<&str> { + configured_auth_header( + route, + self.openai_auth_header.as_deref(), + self.anthropic_auth_header.as_deref(), + ) + } } impl ProviderRoute { @@ -106,6 +119,17 @@ impl ProviderRoute { self.upstream_url_with_base(base, path_and_query) } + pub(super) fn configured_auth_header( + self, + config: &crate::configuration::GatewayConfig, + ) -> Option<&str> { + configured_auth_header( + self, + config.openai_auth_header.as_deref(), + config.anthropic_auth_header.as_deref(), + ) + } + // Like `upstream_url` but with an explicit base URL. This keeps OpenAI `/v1` normalization in // one place for configured public, enterprise, or local proxy bases. pub(super) fn upstream_url_with_base(self, base: &str, path_and_query: &str) -> String { @@ -133,6 +157,21 @@ impl ProviderRoute { } } +fn configured_auth_header<'a>( + route: ProviderRoute, + openai_auth_header: Option<&'a str>, + anthropic_auth_header: Option<&'a str>, +) -> Option<&'a str> { + match route { + ProviderRoute::OpenAiResponses + | ProviderRoute::OpenAiChatCompletions + | ProviderRoute::OpenAiModels => openai_auth_header, + ProviderRoute::AnthropicMessages | ProviderRoute::AnthropicCountTokens => { + anthropic_auth_header + } + } +} + pub(super) fn normalize_openai_path_for_base(base: &str, path_and_query: &str) -> String { match (base.ends_with("/v1"), path_and_query.starts_with("/v1/")) { (true, true) => path_and_query @@ -152,12 +191,17 @@ pub(super) fn gateway_upstream_url_override( headers: &HeaderMap, path_and_query: &str, allow_environment_provider_auth: bool, + config: &crate::configuration::GatewayConfig, ) -> Option { gateway_upstream_url_override_with_openai_key_state( route, headers, path_and_query, - allow_environment_provider_auth && env_var_is_nonempty("OPENAI_API_KEY"), + has_openai_replacement_auth( + route, + allow_environment_provider_auth, + route.configured_auth_header(config), + ), ) } @@ -182,11 +226,16 @@ pub(super) fn strip_replaceable_agent_auth_headers( headers: &HeaderMap, route: ProviderRoute, allow_environment_provider_auth: bool, + configured_auth_header: Option<&str>, ) -> HeaderMap { strip_replaceable_agent_auth_headers_with_openai_key_state( headers, route, - allow_environment_provider_auth && env_var_is_nonempty("OPENAI_API_KEY"), + has_openai_replacement_auth( + route, + allow_environment_provider_auth, + configured_auth_header, + ), ) } @@ -205,6 +254,21 @@ pub(super) fn env_var_is_nonempty(name: &str) -> bool { .is_some() } +fn has_openai_replacement_auth( + route: ProviderRoute, + allow_environment_provider_auth: bool, + configured_auth_header: Option<&str>, +) -> bool { + allow_environment_provider_auth + && matches!( + route, + ProviderRoute::OpenAiResponses + | ProviderRoute::OpenAiChatCompletions + | ProviderRoute::OpenAiModels + ) + && (configured_auth_header.is_some() || env_var_is_nonempty("OPENAI_API_KEY")) +} + // Delegates provider-specific session fallbacks to `alignment` so request construction stays // generic and each coding-agent quirk has one documented adapter. pub(super) fn gateway_session_id( diff --git a/crates/cli/src/mcp_environment.rs b/crates/cli/src/mcp_environment.rs index 8446d7cae..1625aa5ba 100644 --- a/crates/cli/src/mcp_environment.rs +++ b/crates/cli/src/mcp_environment.rs @@ -38,10 +38,12 @@ const BASE_MCP_ENV_VARS: &[&str] = &[ "HTTPS_PROXY", "HTTP_PROXY", "LOCALAPPDATA", + "NEMO_RELAY_ANTHROPIC_AUTH_HEADER", "NEMO_RELAY_ANTHROPIC_BASE_URL", "NEMO_RELAY_GATEWAY_URL", "NEMO_RELAY_MAX_HOOK_PAYLOAD_BYTES", "NEMO_RELAY_MAX_PASSTHROUGH_BODY_BYTES", + "NEMO_RELAY_OPENAI_AUTH_HEADER", "NEMO_RELAY_OPENAI_BASE_URL", "NEMO_RELAY_PLUGIN_IDLE_TIMEOUT_SECS", "NEMO_RELAY_PYTHON", diff --git a/crates/cli/tests/coverage/shared/config_tests.rs b/crates/cli/tests/coverage/shared/config_tests.rs index 0deb33198..f74db743a 100644 --- a/crates/cli/tests/coverage/shared/config_tests.rs +++ b/crates/cli/tests/coverage/shared/config_tests.rs @@ -37,6 +37,10 @@ struct PluginConfigDiscoveryScope { previous_xdg_config_home: Option, previous_config_scope: Option, previous_openai_api_key: Option, + previous_openai_base_url: Option, + previous_openai_auth_header: Option, + previous_anthropic_base_url: Option, + previous_anthropic_auth_header: Option, previous_bootstrap_fingerprint: Option, previous_plugin_idle_timeout: Option, } @@ -51,12 +55,20 @@ impl PluginConfigDiscoveryScope { let previous_xdg_config_home = std::env::var_os("XDG_CONFIG_HOME"); let previous_config_scope = std::env::var_os("NEMO_RELAY_CONFIG_SCOPE"); let previous_openai_api_key = std::env::var_os("OPENAI_API_KEY"); + let previous_openai_base_url = std::env::var_os("NEMO_RELAY_OPENAI_BASE_URL"); + let previous_openai_auth_header = std::env::var_os("NEMO_RELAY_OPENAI_AUTH_HEADER"); + let previous_anthropic_base_url = std::env::var_os("NEMO_RELAY_ANTHROPIC_BASE_URL"); + let previous_anthropic_auth_header = std::env::var_os("NEMO_RELAY_ANTHROPIC_AUTH_HEADER"); let previous_bootstrap_fingerprint = std::env::var_os(BOOTSTRAP_FINGERPRINT_ENV); let previous_plugin_idle_timeout = std::env::var_os(PLUGIN_IDLE_TIMEOUT_ENV); unsafe { std::env::set_var("XDG_CONFIG_HOME", xdg_config_home); std::env::remove_var("NEMO_RELAY_CONFIG_SCOPE"); std::env::remove_var("OPENAI_API_KEY"); + std::env::remove_var("NEMO_RELAY_OPENAI_BASE_URL"); + std::env::remove_var("NEMO_RELAY_OPENAI_AUTH_HEADER"); + std::env::remove_var("NEMO_RELAY_ANTHROPIC_BASE_URL"); + std::env::remove_var("NEMO_RELAY_ANTHROPIC_AUTH_HEADER"); std::env::remove_var(BOOTSTRAP_FINGERPRINT_ENV); std::env::remove_var(PLUGIN_IDLE_TIMEOUT_ENV); } @@ -68,6 +80,10 @@ impl PluginConfigDiscoveryScope { previous_xdg_config_home, previous_config_scope, previous_openai_api_key, + previous_openai_base_url, + previous_openai_auth_header, + previous_anthropic_base_url, + previous_anthropic_auth_header, previous_bootstrap_fingerprint, previous_plugin_idle_timeout, } @@ -86,6 +102,22 @@ impl PluginConfigDiscoveryScope { std::env::set_var(BOOTSTRAP_FINGERPRINT_ENV, fingerprint); } } + + fn set_auth_headers(&self, openai: &str, anthropic: &str) { + // SAFETY: This scope holds the process-wide environment mutex. + unsafe { + std::env::set_var("NEMO_RELAY_OPENAI_AUTH_HEADER", openai); + std::env::set_var("NEMO_RELAY_ANTHROPIC_AUTH_HEADER", anthropic); + } + } + + fn set_base_urls(&self, openai: &str, anthropic: &str) { + // SAFETY: This scope holds the process-wide environment mutex. + unsafe { + std::env::set_var("NEMO_RELAY_OPENAI_BASE_URL", openai); + std::env::set_var("NEMO_RELAY_ANTHROPIC_BASE_URL", anthropic); + } + } } impl Drop for PluginConfigDiscoveryScope { @@ -104,6 +136,22 @@ impl Drop for PluginConfigDiscoveryScope { Some(value) => std::env::set_var("OPENAI_API_KEY", value), None => std::env::remove_var("OPENAI_API_KEY"), } + match self.previous_openai_base_url.take() { + Some(value) => std::env::set_var("NEMO_RELAY_OPENAI_BASE_URL", value), + None => std::env::remove_var("NEMO_RELAY_OPENAI_BASE_URL"), + } + match self.previous_openai_auth_header.take() { + Some(value) => std::env::set_var("NEMO_RELAY_OPENAI_AUTH_HEADER", value), + None => std::env::remove_var("NEMO_RELAY_OPENAI_AUTH_HEADER"), + } + match self.previous_anthropic_base_url.take() { + Some(value) => std::env::set_var("NEMO_RELAY_ANTHROPIC_BASE_URL", value), + None => std::env::remove_var("NEMO_RELAY_ANTHROPIC_BASE_URL"), + } + match self.previous_anthropic_auth_header.take() { + Some(value) => std::env::set_var("NEMO_RELAY_ANTHROPIC_AUTH_HEADER", value), + None => std::env::remove_var("NEMO_RELAY_ANTHROPIC_AUTH_HEADER"), + } match self.previous_bootstrap_fingerprint.take() { Some(value) => std::env::set_var(BOOTSTRAP_FINGERPRINT_ENV, value), None => std::env::remove_var(BOOTSTRAP_FINGERPRINT_ENV), @@ -120,8 +168,9 @@ fn config() -> GatewayConfig { GatewayConfig { bind: "127.0.0.1:0".parse().unwrap(), openai_base_url: "http://openai".into(), - + openai_auth_header: None, anthropic_base_url: "http://anthropic".into(), + anthropic_auth_header: None, metadata: None, plugin_config: None, max_hook_payload_bytes: crate::configuration::DEFAULT_MAX_HOOK_PAYLOAD_BYTES, @@ -129,6 +178,14 @@ fn config() -> GatewayConfig { } } +#[test] +fn provider_auth_headers_default_to_unset() { + let config = GatewayConfig::default(); + + assert!(config.openai_auth_header.is_none()); + assert!(config.anthropic_auth_header.is_none()); +} + #[test] fn effective_plugin_toml_sources_reports_empty_and_sorted_contributors() { let temp = tempfile::tempdir().unwrap(); @@ -407,7 +464,9 @@ fn explicit_toml_config_maps_supported_sections() { r#" [upstream] openai_base_url = "http://openai" +openai_auth_header = "Bearer openai-file" anthropic_base_url = "http://anthropic" +anthropic_auth_header = "Basic anthropic-file" [gateway] max_hook_payload_bytes = 12345 @@ -440,7 +499,15 @@ command = "hermes --yolo chat" assert_eq!(resolved.gateway.bind.to_string(), "127.0.0.1:0"); assert_eq!(resolved.gateway.openai_base_url, "http://openai"); + assert_eq!( + resolved.gateway.openai_auth_header.as_deref(), + Some("Bearer openai-file") + ); assert_eq!(resolved.gateway.anthropic_base_url, "http://anthropic"); + assert_eq!( + resolved.gateway.anthropic_auth_header.as_deref(), + Some("Basic anthropic-file") + ); assert_eq!(resolved.gateway.max_hook_payload_bytes, 12345); assert_eq!(resolved.gateway.max_passthrough_body_bytes, 67890); assert_eq!(resolved.gateway.metadata, None); @@ -455,6 +522,183 @@ command = "hermes --yolo chat" ); } +#[test] +fn provider_auth_environment_overrides_file_values() { + let temp = tempfile::tempdir().unwrap(); + let xdg = temp.path().join("xdg"); + std::fs::create_dir_all(&xdg).unwrap(); + let scope = PluginConfigDiscoveryScope::enter(temp.path(), &xdg); + let path = temp.path().join("config.toml"); + std::fs::write( + &path, + r#" +[upstream] +openai_auth_header = "Bearer openai-file" +anthropic_auth_header = "Basic anthropic-file" +"#, + ) + .unwrap(); + scope.set_auth_headers(" Bearer openai-env ", " Basic anthropic-env "); + + let resolved = resolve_server_config(&GatewayOverrides { + config: Some(path), + ..GatewayOverrides::default() + }) + .unwrap(); + + assert_eq!( + resolved.gateway.openai_auth_header.as_deref(), + Some("Bearer openai-env") + ); + assert_eq!( + resolved.gateway.anthropic_auth_header.as_deref(), + Some("Basic anthropic-env") + ); +} + +#[test] +fn endpoint_overrides_clear_inherited_provider_auth_headers() { + let temp = tempfile::tempdir().unwrap(); + let project = temp.path().join("project"); + let nested = project.join("nested"); + let xdg = temp.path().join("xdg"); + std::fs::create_dir_all(project.join(".nemo-relay")).unwrap(); + std::fs::create_dir_all(&nested).unwrap(); + std::fs::create_dir_all(xdg.join("nemo-relay")).unwrap(); + std::fs::write( + project.join(".nemo-relay/config.toml"), + r#" +[upstream] +openai_base_url = "http://project-openai" +openai_auth_header = "Bearer project-openai" +anthropic_base_url = "http://project-anthropic" +anthropic_auth_header = "Basic project-anthropic" +"#, + ) + .unwrap(); + std::fs::write( + xdg.join("nemo-relay/config.toml"), + r#" +[upstream] +openai_base_url = "http://user-openai" +anthropic_base_url = "http://user-anthropic" +"#, + ) + .unwrap(); + let _scope = PluginConfigDiscoveryScope::enter(&nested, &xdg); + + let resolved = resolve_server_config(&GatewayOverrides::default()).unwrap(); + + assert_eq!(resolved.gateway.openai_base_url, "http://user-openai"); + assert!(resolved.gateway.openai_auth_header.is_none()); + assert_eq!(resolved.gateway.anthropic_base_url, "http://user-anthropic"); + assert!(resolved.gateway.anthropic_auth_header.is_none()); +} + +#[test] +fn endpoint_environment_overrides_clear_file_provider_auth_headers() { + let temp = tempfile::tempdir().unwrap(); + let xdg = temp.path().join("xdg"); + std::fs::create_dir_all(&xdg).unwrap(); + let scope = PluginConfigDiscoveryScope::enter(temp.path(), &xdg); + let path = temp.path().join("config.toml"); + std::fs::write( + &path, + r#" +[upstream] +openai_auth_header = "Bearer file-openai" +anthropic_auth_header = "Basic file-anthropic" +"#, + ) + .unwrap(); + scope.set_base_urls("http://environment-openai", "http://environment-anthropic"); + + let resolved = resolve_server_config(&GatewayOverrides { + config: Some(path), + ..GatewayOverrides::default() + }) + .unwrap(); + + assert_eq!( + resolved.gateway.openai_base_url, + "http://environment-openai" + ); + assert!(resolved.gateway.openai_auth_header.is_none()); + assert_eq!( + resolved.gateway.anthropic_base_url, + "http://environment-anthropic" + ); + assert!(resolved.gateway.anthropic_auth_header.is_none()); +} + +#[test] +fn invalid_provider_auth_header_errors_do_not_expose_secret_values() { + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("config.toml"); + std::fs::write( + &path, + "[upstream]\nopenai_auth_header = \"\"\"\\\nBearer private\nsecret\"\"\"\n", + ) + .unwrap(); + + let error = resolve_server_config(&GatewayOverrides { + config: Some(path), + ..GatewayOverrides::default() + }) + .unwrap_err() + .to_string(); + + assert!(error.contains("upstream.openai_auth_header"), "{error}"); + assert!(error.contains("valid HTTP header value"), "{error}"); + assert!(!error.contains("private"), "{error}"); + assert!(!error.contains("secret"), "{error}"); +} + +#[test] +fn invalid_anthropic_provider_auth_header_errors_do_not_expose_secret_values() { + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("config.toml"); + std::fs::write( + &path, + "[upstream]\nanthropic_auth_header = \"\"\"\\\nBasic private\nsecret\"\"\"\n", + ) + .unwrap(); + + let error = resolve_server_config(&GatewayOverrides { + config: Some(path), + ..GatewayOverrides::default() + }) + .unwrap_err() + .to_string(); + + assert!(error.contains("upstream.anthropic_auth_header"), "{error}"); + assert!(error.contains("valid HTTP header value"), "{error}"); + assert!(!error.contains("private"), "{error}"); + assert!(!error.contains("secret"), "{error}"); +} + +#[test] +fn invalid_provider_auth_environment_errors_do_not_expose_secret_values() { + let temp = tempfile::tempdir().unwrap(); + let xdg = temp.path().join("xdg"); + std::fs::create_dir_all(&xdg).unwrap(); + let scope = PluginConfigDiscoveryScope::enter(temp.path(), &xdg); + let path = temp.path().join("config.toml"); + std::fs::write(&path, "").unwrap(); + scope.set_auth_headers("Bearer private\nsecret", "Basic valid"); + + let error = resolve_server_config(&GatewayOverrides { + config: Some(path), + ..GatewayOverrides::default() + }) + .unwrap_err() + .to_string(); + + assert!(error.contains("NEMO_RELAY_OPENAI_AUTH_HEADER"), "{error}"); + assert!(!error.contains("private"), "{error}"); + assert!(!error.contains("secret"), "{error}"); +} + #[test] fn explicit_config_must_exist() { let temp = tempfile::tempdir().unwrap(); @@ -1906,6 +2150,49 @@ fn persistent_server_resolution_excludes_project_config_and_fingerprints_credent assert_ne!(first.bootstrap_fingerprint, second.bootstrap_fingerprint); } +#[test] +fn persistent_fingerprint_tracks_provider_auth_headers() { + let temp = tempfile::tempdir().unwrap(); + let project = temp.path().join("project"); + let xdg = temp.path().join("xdg"); + std::fs::create_dir_all(&project).unwrap(); + let user_config_dir = xdg.join("nemo-relay"); + std::fs::create_dir_all(&user_config_dir).unwrap(); + let _scope = PluginConfigDiscoveryScope::enter(&project, &xdg); + let config_path = user_config_dir.join("config.toml"); + std::fs::write( + &config_path, + "[upstream]\nopenai_auth_header = \"Bearer one\"\nanthropic_auth_header = \"Basic one\"\n", + ) + .unwrap(); + + let first = resolve_persistent_server_config(&GatewayOverrides::default()) + .unwrap() + .bootstrap_fingerprint + .unwrap(); + std::fs::write( + &config_path, + "[upstream]\nopenai_auth_header = \"Bearer two\"\nanthropic_auth_header = \"Basic one\"\n", + ) + .unwrap(); + let openai_changed = resolve_persistent_server_config(&GatewayOverrides::default()) + .unwrap() + .bootstrap_fingerprint + .unwrap(); + std::fs::write( + &config_path, + "[upstream]\nopenai_auth_header = \"Bearer two\"\nanthropic_auth_header = \"Basic two\"\n", + ) + .unwrap(); + let anthropic_changed = resolve_persistent_server_config(&GatewayOverrides::default()) + .unwrap() + .bootstrap_fingerprint + .unwrap(); + + assert_ne!(first, openai_changed); + assert_ne!(openai_changed, anthropic_changed); +} + #[test] fn managed_bootstrap_canonicalizes_unset_and_zero_padded_default_idle_timeout() { let temp = tempfile::tempdir().unwrap(); diff --git a/crates/cli/tests/coverage/shared/gateway_tests.rs b/crates/cli/tests/coverage/shared/gateway_tests.rs index 9d566ccbf..c5ccd467e 100644 --- a/crates/cli/tests/coverage/shared/gateway_tests.rs +++ b/crates/cli/tests/coverage/shared/gateway_tests.rs @@ -19,6 +19,13 @@ fn test_http_client() -> Client { Client::new() } +fn environment_authorization() -> crate::provider_auth::ProviderRequestAuthorization { + crate::provider_auth::ProviderRequestAuthorization { + source_credential: crate::provider_auth::SourceCredentialDisposition::Absent, + allow_environment_provider_auth: true, + } +} + #[test] fn removes_hop_by_hop_headers() { let headers = HeaderMap::new(); @@ -123,9 +130,13 @@ async fn prepared_gateway_request_decodes_zstd_for_observability() { .body(Body::from(compressed.clone())) .unwrap(); - let prepared = prepare_gateway_request(&GatewayConfig::default(), request, true) - .await - .unwrap(); + let prepared = prepare_gateway_request( + &GatewayConfig::default(), + request, + environment_authorization(), + ) + .await + .unwrap(); assert_eq!(prepared.body_bytes.as_ref(), compressed); assert_eq!( @@ -154,9 +165,13 @@ async fn prepared_gateway_request_decodes_chained_zstd_for_observability() { .body(Body::from(compressed_twice.clone())) .unwrap(); - let prepared = prepare_gateway_request(&GatewayConfig::default(), request, true) - .await - .unwrap(); + let prepared = prepare_gateway_request( + &GatewayConfig::default(), + request, + environment_authorization(), + ) + .await + .unwrap(); assert_eq!(prepared.body_bytes.as_ref(), compressed_twice); assert_eq!( @@ -197,7 +212,7 @@ async fn request_observability_decode_is_bounded_and_encoding_aware() { .header(header::CONTENT_ENCODING, "zstd") .body(Body::from(compressed)) .unwrap(); - let prepared = prepare_gateway_request(&config, request, true) + let prepared = prepare_gateway_request(&config, request, environment_authorization()) .await .unwrap(); assert!(prepared.request_json.is_null()); @@ -208,7 +223,7 @@ async fn request_observability_decode_is_bounded_and_encoding_aware() { .header(header::CONTENT_ENCODING, "gzip") .body(Body::from(r#"{"model":"opaque"}"#)) .unwrap(); - let prepared = prepare_gateway_request(&config, request, true) + let prepared = prepare_gateway_request(&config, request, environment_authorization()) .await .unwrap(); assert!(prepared.request_json.is_null()); @@ -219,7 +234,7 @@ async fn request_observability_decode_is_bounded_and_encoding_aware() { .header(header::CONTENT_ENCODING, "identity") .body(Body::from(r#"{"model":"gpt-test"}"#)) .unwrap(); - let prepared = prepare_gateway_request(&config, request, true) + let prepared = prepare_gateway_request(&config, request, environment_authorization()) .await .unwrap(); assert_eq!( @@ -238,9 +253,13 @@ async fn malformed_encoded_request_remains_a_raw_passthrough() { .header(header::CONTENT_ENCODING, "zstd") .body(Body::from("not-a-zstd-frame")) .unwrap(); - let prepared = prepare_gateway_request(&GatewayConfig::default(), request, true) - .await - .unwrap(); + let prepared = prepare_gateway_request( + &GatewayConfig::default(), + request, + environment_authorization(), + ) + .await + .unwrap(); let managed = build_llm_gateway_start(&prepared).request; let (body, headers) = @@ -350,8 +369,9 @@ fn provider_routes_preserve_path_query_and_choose_upstream() { let config = GatewayConfig { bind: "127.0.0.1:0".parse().unwrap(), openai_base_url: "http://openai/v1/".into(), - + openai_auth_header: None, anthropic_base_url: "http://anthropic/".into(), + anthropic_auth_header: None, metadata: None, plugin_config: None, max_hook_payload_bytes: crate::configuration::DEFAULT_MAX_HOOK_PAYLOAD_BYTES, @@ -381,7 +401,9 @@ fn openai_upstream_url_accepts_origin_or_v1_base() { let mut config = GatewayConfig { bind: "127.0.0.1:0".parse().unwrap(), openai_base_url: "http://openai".into(), + openai_auth_header: None, anthropic_base_url: "http://anthropic".into(), + anthropic_auth_header: None, metadata: None, plugin_config: None, max_hook_payload_bytes: crate::configuration::DEFAULT_MAX_HOOK_PAYLOAD_BYTES, @@ -666,6 +688,7 @@ fn cross_protocol_target_uses_only_binding_owned_authentication() { effective.target_route, &effective.headers, false, + None, |_: &str| Some("ambient-key-must-not-win".into()), ) .build() @@ -1322,10 +1345,16 @@ fn injects_openai_bearer_when_inbound_has_no_auth() { _ => None, }; let builder = http.get("http://upstream/v1/responses"); - let built = - inject_provider_auth_with_env(builder, ProviderRoute::OpenAiResponses, &inbound, true, env) - .build() - .unwrap(); + let built = inject_provider_auth_with_env( + builder, + ProviderRoute::OpenAiResponses, + &inbound, + true, + None, + env, + ) + .build() + .unwrap(); assert_eq!( built.headers().get("authorization").unwrap(), "Bearer sk-test-123" @@ -1346,6 +1375,7 @@ fn injects_anthropic_x_api_key_for_anthropic_routes() { ProviderRoute::AnthropicMessages, &inbound, true, + None, env, ) .build() @@ -1357,6 +1387,94 @@ fn injects_anthropic_x_api_key_for_anthropic_routes() { assert!(built.headers().get("authorization").is_none()); } +#[test] +fn configured_auth_headers_are_provider_specific_and_precede_environment_keys() { + let config = GatewayConfig { + openai_auth_header: Some("Basic openai-custom".into()), + anthropic_auth_header: Some("Bearer anthropic-custom".into()), + ..GatewayConfig::default() + }; + let forwarding = ProviderForwarding::new( + ProviderRoute::OpenAiResponses, + crate::provider_auth::ProviderRequestAuthorization { + source_credential: crate::provider_auth::SourceCredentialDisposition::Absent, + allow_environment_provider_auth: true, + }, + &config, + ); + + for (route, expected) in [ + (ProviderRoute::OpenAiResponses, "Basic openai-custom"), + (ProviderRoute::OpenAiChatCompletions, "Basic openai-custom"), + (ProviderRoute::OpenAiModels, "Basic openai-custom"), + (ProviderRoute::AnthropicMessages, "Bearer anthropic-custom"), + ( + ProviderRoute::AnthropicCountTokens, + "Bearer anthropic-custom", + ), + ] { + let built = inject_provider_auth_with_env( + test_http_client().post("http://upstream"), + route, + &HeaderMap::new(), + true, + forwarding.configured_auth_header(route), + |_| Some("standard-env-key".into()), + ) + .build() + .unwrap(); + + assert_eq!(built.headers().get("authorization").unwrap(), expected); + assert!(built.headers().get("x-api-key").is_none()); + } +} + +#[test] +fn configured_openai_auth_replaces_chatgpt_jwt() { + let config = GatewayConfig { + openai_auth_header: Some("Basic configured-openai".into()), + ..GatewayConfig::default() + }; + let mut inbound = HeaderMap::new(); + inbound.insert( + "authorization", + HeaderValue::from_static("Bearer eyJhbGciOiJIUzI1NiJ9.deadbeef.signature"), + ); + let configured_auth_header = ProviderRoute::OpenAiResponses.configured_auth_header(&config); + let sanitized = strip_replaceable_agent_auth_headers( + &inbound, + ProviderRoute::OpenAiResponses, + true, + configured_auth_header, + ); + assert!(sanitized.get("authorization").is_none()); + assert_eq!( + gateway_upstream_url_override( + ProviderRoute::OpenAiResponses, + &inbound, + "/responses", + true, + &config, + ), + None + ); + + let request = inject_provider_auth_with_env( + test_http_client().post("http://upstream"), + ProviderRoute::OpenAiResponses, + &sanitized, + true, + configured_auth_header, + |_| None, + ) + .build() + .unwrap(); + assert_eq!( + request.headers().get("authorization").unwrap(), + "Basic configured-openai" + ); +} + #[test] fn skips_injection_when_inbound_already_has_authorization() { // If the agent (e.g., a future codex version, or anyone using the gateway directly) sends @@ -1369,10 +1487,16 @@ fn skips_injection_when_inbound_already_has_authorization() { ); let env = |_: &str| Some("sk-test-from-env".into()); let builder = http.post("http://upstream/v1/responses"); - let built = - inject_provider_auth_with_env(builder, ProviderRoute::OpenAiResponses, &inbound, true, env) - .build() - .unwrap(); + let built = inject_provider_auth_with_env( + builder, + ProviderRoute::OpenAiResponses, + &inbound, + true, + None, + env, + ) + .build() + .unwrap(); // The builder doesn't carry inbound headers itself (forward_upstream_request adds them in a // separate loop), so the only header on `built` would be the env-injected one. Since the // inbound had auth, we expect no injection at all. @@ -1385,10 +1509,16 @@ fn skips_injection_when_env_var_unset() { let inbound = HeaderMap::new(); let env = |_: &str| None; let builder = http.post("http://upstream/v1/responses"); - let built = - inject_provider_auth_with_env(builder, ProviderRoute::OpenAiResponses, &inbound, true, env) - .build() - .unwrap(); + let built = inject_provider_auth_with_env( + builder, + ProviderRoute::OpenAiResponses, + &inbound, + true, + None, + env, + ) + .build() + .unwrap(); assert!(built.headers().get("authorization").is_none()); } @@ -1403,6 +1533,7 @@ fn managed_sidecar_never_injects_forwarded_provider_credentials() { ProviderRoute::OpenAiResponses, &inbound, false, + None, env, ) .build() @@ -1526,8 +1657,9 @@ async fn passthrough_rejects_unsupported_provider_path_directly() { let config = GatewayConfig { bind: "127.0.0.1:0".parse().unwrap(), openai_base_url: "http://openai".into(), - + openai_auth_header: None, anthropic_base_url: "http://anthropic".into(), + anthropic_auth_header: None, metadata: None, plugin_config: None, max_hook_payload_bytes: crate::configuration::DEFAULT_MAX_HOOK_PAYLOAD_BYTES, @@ -1563,8 +1695,9 @@ async fn models_rejects_non_get_requests_directly() { let config = GatewayConfig { bind: "127.0.0.1:0".parse().unwrap(), openai_base_url: "http://openai".into(), - + openai_auth_header: None, anthropic_base_url: "http://anthropic".into(), + anthropic_auth_header: None, metadata: None, plugin_config: None, max_hook_payload_bytes: crate::configuration::DEFAULT_MAX_HOOK_PAYLOAD_BYTES, diff --git a/crates/cli/tests/coverage/shared/server_tests.rs b/crates/cli/tests/coverage/shared/server_tests.rs index d51117ee2..92742338a 100644 --- a/crates/cli/tests/coverage/shared/server_tests.rs +++ b/crates/cli/tests/coverage/shared/server_tests.rs @@ -161,8 +161,9 @@ fn test_config() -> GatewayConfig { GatewayConfig { bind: "127.0.0.1:0".parse().unwrap(), openai_base_url: "http://127.0.0.1".into(), - + openai_auth_header: None, anthropic_base_url: "http://127.0.0.1".into(), + anthropic_auth_header: None, metadata: None, plugin_config: None, max_hook_payload_bytes: crate::configuration::DEFAULT_MAX_HOOK_PAYLOAD_BYTES, diff --git a/crates/cli/tests/coverage/shared/session_tests.rs b/crates/cli/tests/coverage/shared/session_tests.rs index 35f2d14d0..f847bdd9b 100644 --- a/crates/cli/tests/coverage/shared/session_tests.rs +++ b/crates/cli/tests/coverage/shared/session_tests.rs @@ -911,8 +911,9 @@ async fn nests_agent_subagent_and_tool_lifecycle() { let config = GatewayConfig { bind: "127.0.0.1:0".parse().unwrap(), openai_base_url: "http://127.0.0.1".into(), - + openai_auth_header: None, anthropic_base_url: "http://127.0.0.1".into(), + anthropic_auth_header: None, metadata: None, plugin_config: None, max_hook_payload_bytes: crate::configuration::DEFAULT_MAX_HOOK_PAYLOAD_BYTES, @@ -2163,8 +2164,9 @@ async fn writes_atif_on_session_end_from_plugin_config() { let config = GatewayConfig { bind: "127.0.0.1:0".parse().unwrap(), openai_base_url: "http://127.0.0.1".into(), - + openai_auth_header: None, anthropic_base_url: "http://127.0.0.1".into(), + anthropic_auth_header: None, metadata: None, plugin_config: None, max_hook_payload_bytes: crate::configuration::DEFAULT_MAX_HOOK_PAYLOAD_BYTES, @@ -2434,8 +2436,9 @@ async fn duplicate_agent_end_does_not_overwrite_atif_with_empty_session() { let config = GatewayConfig { bind: "127.0.0.1:0".parse().unwrap(), openai_base_url: "http://127.0.0.1".into(), - + openai_auth_header: None, anthropic_base_url: "http://127.0.0.1".into(), + anthropic_auth_header: None, metadata: None, plugin_config: None, max_hook_payload_bytes: crate::configuration::DEFAULT_MAX_HOOK_PAYLOAD_BYTES, @@ -2514,8 +2517,9 @@ async fn writes_hermes_api_hook_usage_to_atif_metrics() { let config = GatewayConfig { bind: "127.0.0.1:0".parse().unwrap(), openai_base_url: "http://127.0.0.1".into(), - + openai_auth_header: None, anthropic_base_url: "http://127.0.0.1".into(), + anthropic_auth_header: None, metadata: None, plugin_config: None, max_hook_payload_bytes: crate::configuration::DEFAULT_MAX_HOOK_PAYLOAD_BYTES, @@ -2597,8 +2601,9 @@ async fn writes_hermes_api_hook_reported_cost_to_atif_metrics() { let config = GatewayConfig { bind: "127.0.0.1:0".parse().unwrap(), openai_base_url: "http://127.0.0.1".into(), - + openai_auth_header: None, anthropic_base_url: "http://127.0.0.1".into(), + anthropic_auth_header: None, metadata: None, plugin_config: None, max_hook_payload_bytes: crate::configuration::DEFAULT_MAX_HOOK_PAYLOAD_BYTES, @@ -3903,8 +3908,9 @@ async fn handles_out_of_order_subagent_and_tool_end_events() { let config = GatewayConfig { bind: "127.0.0.1:0".parse().unwrap(), openai_base_url: "http://127.0.0.1".into(), - + openai_auth_header: None, anthropic_base_url: "http://127.0.0.1".into(), + anthropic_auth_header: None, metadata: None, plugin_config: None, max_hook_payload_bytes: crate::configuration::DEFAULT_MAX_HOOK_PAYLOAD_BYTES, @@ -3980,8 +3986,9 @@ async fn out_of_order_started_subagent_end_does_not_leak_scope() { let config = GatewayConfig { bind: "127.0.0.1:0".parse().unwrap(), openai_base_url: "http://127.0.0.1".into(), - + openai_auth_header: None, anthropic_base_url: "http://127.0.0.1".into(), + anthropic_auth_header: None, metadata: None, plugin_config: None, max_hook_payload_bytes: crate::configuration::DEFAULT_MAX_HOOK_PAYLOAD_BYTES, @@ -4053,8 +4060,9 @@ async fn agent_end_closes_nested_active_subagents_lifo() { let config = GatewayConfig { bind: "127.0.0.1:0".parse().unwrap(), openai_base_url: "http://127.0.0.1".into(), - + openai_auth_header: None, anthropic_base_url: "http://127.0.0.1".into(), + anthropic_auth_header: None, metadata: None, plugin_config: None, max_hook_payload_bytes: crate::configuration::DEFAULT_MAX_HOOK_PAYLOAD_BYTES, @@ -4110,8 +4118,9 @@ async fn llm_lifecycle_starts_implicit_gateway_session() { let config = GatewayConfig { bind: "127.0.0.1:0".parse().unwrap(), openai_base_url: "http://127.0.0.1".into(), - + openai_auth_header: None, anthropic_base_url: "http://127.0.0.1".into(), + anthropic_auth_header: None, metadata: None, plugin_config: None, max_hook_payload_bytes: crate::configuration::DEFAULT_MAX_HOOK_PAYLOAD_BYTES, @@ -4594,8 +4603,9 @@ async fn llm_lifecycle_uses_single_active_hook_session_when_header_is_missing() let config = GatewayConfig { bind: "127.0.0.1:0".parse().unwrap(), openai_base_url: "http://127.0.0.1".into(), - + openai_auth_header: None, anthropic_base_url: "http://127.0.0.1".into(), + anthropic_auth_header: None, metadata: None, plugin_config: None, max_hook_payload_bytes: crate::configuration::DEFAULT_MAX_HOOK_PAYLOAD_BYTES, @@ -4722,8 +4732,9 @@ async fn single_pending_llm_hint_claims_next_gateway_llm() { let config = GatewayConfig { bind: "127.0.0.1:0".parse().unwrap(), openai_base_url: "http://127.0.0.1".into(), - + openai_auth_header: None, anthropic_base_url: "http://127.0.0.1".into(), + anthropic_auth_header: None, metadata: None, plugin_config: None, max_hook_payload_bytes: crate::configuration::DEFAULT_MAX_HOOK_PAYLOAD_BYTES, @@ -4820,8 +4831,9 @@ async fn multiple_llm_hints_resolve_by_generation_id() { let config = GatewayConfig { bind: "127.0.0.1:0".parse().unwrap(), openai_base_url: "http://127.0.0.1".into(), - + openai_auth_header: None, anthropic_base_url: "http://127.0.0.1".into(), + anthropic_auth_header: None, metadata: None, plugin_config: None, max_hook_payload_bytes: crate::configuration::DEFAULT_MAX_HOOK_PAYLOAD_BYTES, @@ -4936,8 +4948,9 @@ async fn ambiguous_llm_hints_fall_back_to_agent_scope() { let config = GatewayConfig { bind: "127.0.0.1:0".parse().unwrap(), openai_base_url: "http://127.0.0.1".into(), - + openai_auth_header: None, anthropic_base_url: "http://127.0.0.1".into(), + anthropic_auth_header: None, metadata: None, plugin_config: None, max_hook_payload_bytes: crate::configuration::DEFAULT_MAX_HOOK_PAYLOAD_BYTES, @@ -5030,8 +5043,9 @@ async fn no_active_hint_reuses_last_llm_owner() { let config = GatewayConfig { bind: "127.0.0.1:0".parse().unwrap(), openai_base_url: "http://127.0.0.1".into(), - + openai_auth_header: None, anthropic_base_url: "http://127.0.0.1".into(), + anthropic_auth_header: None, metadata: None, plugin_config: None, max_hook_payload_bytes: crate::configuration::DEFAULT_MAX_HOOK_PAYLOAD_BYTES, @@ -6860,8 +6874,9 @@ fn session_test_config() -> GatewayConfig { GatewayConfig { bind: "127.0.0.1:0".parse().unwrap(), openai_base_url: "http://127.0.0.1".into(), - + openai_auth_header: None, anthropic_base_url: "http://127.0.0.1".into(), + anthropic_auth_header: None, metadata: None, plugin_config: None, max_hook_payload_bytes: crate::configuration::DEFAULT_MAX_HOOK_PAYLOAD_BYTES, @@ -6875,8 +6890,9 @@ async fn turn_ended_is_noop_without_active_turn_scope() { let config = GatewayConfig { bind: "127.0.0.1:0".parse().unwrap(), openai_base_url: "http://127.0.0.1".into(), - + openai_auth_header: None, anthropic_base_url: "http://127.0.0.1".into(), + anthropic_auth_header: None, metadata: None, plugin_config: None, max_hook_payload_bytes: crate::configuration::DEFAULT_MAX_HOOK_PAYLOAD_BYTES, diff --git a/integrations/coding-agents/codex/.mcp.json b/integrations/coding-agents/codex/.mcp.json index 01d6245cc..c45737935 100644 --- a/integrations/coding-agents/codex/.mcp.json +++ b/integrations/coding-agents/codex/.mcp.json @@ -36,10 +36,12 @@ "HTTPS_PROXY", "HTTP_PROXY", "LOCALAPPDATA", + "NEMO_RELAY_ANTHROPIC_AUTH_HEADER", "NEMO_RELAY_ANTHROPIC_BASE_URL", "NEMO_RELAY_GATEWAY_URL", "NEMO_RELAY_MAX_HOOK_PAYLOAD_BYTES", "NEMO_RELAY_MAX_PASSTHROUGH_BODY_BYTES", + "NEMO_RELAY_OPENAI_AUTH_HEADER", "NEMO_RELAY_OPENAI_BASE_URL", "NEMO_RELAY_PLUGIN_IDLE_TIMEOUT_SECS", "NEMO_RELAY_PYTHON",