diff --git a/crates/cli/src/agents/codex/alignment.rs b/crates/cli/src/agents/codex/alignment.rs index ecdc84003..888ade8d0 100644 --- a/crates/cli/src/agents/codex/alignment.rs +++ b/crates/cli/src/agents/codex/alignment.rs @@ -132,6 +132,7 @@ fn is_openai_route(route: GatewayRouteKind) -> bool { route, GatewayRouteKind::OpenAiResponses | GatewayRouteKind::OpenAiChatCompletions + | GatewayRouteKind::OpenAiImagesGenerations | GatewayRouteKind::OpenAiModels ) } diff --git a/crates/cli/src/agents/codex/host.rs b/crates/cli/src/agents/codex/host.rs index 93906a6e9..d1794d5d0 100644 --- a/crates/cli/src/agents/codex/host.rs +++ b/crates/cli/src/agents/codex/host.rs @@ -796,6 +796,7 @@ pub(crate) fn prepare_codex_config(path: &Path) -> Result<(), String> { } pub(crate) fn install_codex_config(path: &Path, gateway_url: &str) -> Result<(), String> { + let gateway_url = super::versioned_gateway_url(gateway_url); let challenge = BootstrapChallengeKey::load().map_err(|error| error.to_string())?; let client_token = challenge.client_token(); let raw = read_optional_text(path)?; @@ -805,8 +806,8 @@ pub(crate) fn install_codex_config(path: &Path, gateway_url: &str) -> Result<(), let backup_snapshot = snapshot_optional_file(&backup_path(path))?; let has_managed_proof = codex_provider_client_token(&doc).is_some_and(|token| challenge.verify_client_token(token)); - let provider_extensions = codex_provider_user_extensions(&doc, gateway_url); - let unmodified_managed_install = codex_config_doc_has_managed_install(&doc, gateway_url) + let provider_extensions = codex_provider_user_extensions(&doc, &gateway_url); + let unmodified_managed_install = codex_config_doc_has_managed_install(&doc, &gateway_url) && has_managed_proof && codex_provider_has_only_generated_fields(&doc); if !unmodified_managed_install @@ -814,7 +815,7 @@ pub(crate) fn install_codex_config(path: &Path, gateway_url: &str) -> Result<(), path, &raw, &doc, - gateway_url, + &gateway_url, has_managed_proof, &challenge, ) @@ -833,7 +834,7 @@ pub(crate) fn install_codex_config(path: &Path, gateway_url: &str) -> Result<(), let providers = ensure_table(&mut doc, "model_providers"); let mut provider = Table::new(); provider["name"] = value("NeMo Relay"); - provider["base_url"] = value(gateway_url); + provider["base_url"] = value(&gateway_url); provider["wire_api"] = value("responses"); provider["requires_openai_auth"] = value(true); provider["supports_websockets"] = value(false); @@ -1210,6 +1211,7 @@ pub(crate) fn uninstall_codex_config( gateway_url: &str, preserve_hooks: bool, ) -> Result<(), String> { + let gateway_url = super::versioned_gateway_url(gateway_url); if !path.exists() { return Ok(()); } @@ -1220,13 +1222,13 @@ pub(crate) fn uninstall_codex_config( .map_err(|error| format!("invalid TOML in {}: {error}", path.display()))?; let challenge = BootstrapChallengeKey::load_existing().map_err(|error| error.to_string())?; let backup_doc = read_codex_backup_doc(path)? - .map(|backup| sanitize_codex_backup_doc(backup, gateway_url, challenge.as_ref())); + .map(|backup| sanitize_codex_backup_doc(backup, &gateway_url, challenge.as_ref())); let restore_dangling_symlink = backup_doc .as_ref() .is_some_and(codex_backup_marks_original_config_absent); let original_symlink_target = backup_doc.as_ref().and_then(codex_backup_symlink_target); - let preserved_provider = codex_extended_provider_without_proof(&doc, gateway_url); - let provider_is_managed = codex_provider_item_is_managed(&doc, gateway_url); + let preserved_provider = codex_extended_provider_without_proof(&doc, &gateway_url); + let provider_is_managed = codex_provider_item_is_managed(&doc, &gateway_url); match backup_doc.as_ref() { Some(backup_doc) => { restore_codex_config_from_backup( @@ -1597,6 +1599,9 @@ pub(crate) fn codex_provider_table_is_managed_for_gateway( provider: &Table, gateway_url: &str, ) -> bool { + let gateway_url = gateway_url.trim_end_matches('/'); + let versioned_gateway_url = super::versioned_gateway_url(gateway_url); + let legacy_gateway_url = gateway_url.strip_suffix("/v1").unwrap_or(gateway_url); provider .get("name") .and_then(Item::as_value) @@ -1606,7 +1611,9 @@ pub(crate) fn codex_provider_table_is_managed_for_gateway( .get("base_url") .and_then(Item::as_value) .and_then(|value| value.as_str()) - == Some(gateway_url) + .is_some_and(|base_url| { + base_url == versioned_gateway_url.as_str() || base_url == legacy_gateway_url + }) && provider .get("wire_api") .and_then(Item::as_value) diff --git a/crates/cli/src/agents/codex/launch.rs b/crates/cli/src/agents/codex/launch.rs index 41cfeafe6..58676f8bc 100644 --- a/crates/cli/src/agents/codex/launch.rs +++ b/crates/cli/src/agents/codex/launch.rs @@ -199,7 +199,7 @@ fn canonical_json(value: Value) -> Value { fn gateway_provider_config(gateway_url: &str) -> String { format!( "model_providers.nemo-relay-openai={{name=\"NeMo Relay OpenAI\",base_url={},wire_api=\"responses\",requires_openai_auth=true,supports_websockets=false,env_http_headers={{{}={}}}}}", - toml_string(gateway_url), + toml_string(&super::versioned_gateway_url(gateway_url)), toml_string(crate::provider_auth::TRANSPARENT_PROXY_CREDENTIAL_HEADER), toml_string(crate::provider_auth::TRANSPARENT_PROXY_CREDENTIAL_ENV), ) diff --git a/crates/cli/src/agents/codex/mod.rs b/crates/cli/src/agents/codex/mod.rs index 542515982..8076b10ce 100644 --- a/crates/cli/src/agents/codex/mod.rs +++ b/crates/cli/src/agents/codex/mod.rs @@ -37,3 +37,12 @@ pub(super) const DESCRIPTOR: AgentDescriptor = AgentDescriptor { pub(super) fn parse_version(raw: &str) -> Option { Version::parse(raw.strip_prefix("codex-cli ")?).ok() } + +pub(super) fn versioned_gateway_url(gateway_url: &str) -> String { + let gateway_url = gateway_url.trim_end_matches('/'); + if gateway_url.ends_with("/v1") { + gateway_url.to_string() + } else { + format!("{gateway_url}/v1") + } +} diff --git a/crates/cli/src/agents/shared/alignment.rs b/crates/cli/src/agents/shared/alignment.rs index 94e03af54..40e49322f 100644 --- a/crates/cli/src/agents/shared/alignment.rs +++ b/crates/cli/src/agents/shared/alignment.rs @@ -47,15 +47,17 @@ impl SubagentSessionContext { pub(crate) enum GatewayRouteKind { OpenAiResponses, OpenAiChatCompletions, + OpenAiImagesGenerations, OpenAiModels, AnthropicMessages, AnthropicCountTokens, } impl GatewayRouteKind { - pub(crate) const ALL: [Self; 5] = [ + pub(crate) const ALL: [Self; 6] = [ Self::OpenAiResponses, Self::OpenAiChatCompletions, + Self::OpenAiImagesGenerations, Self::OpenAiModels, Self::AnthropicMessages, Self::AnthropicCountTokens, @@ -65,6 +67,7 @@ impl GatewayRouteKind { match self { Self::OpenAiResponses => "openai.responses", Self::OpenAiChatCompletions => "openai.chat_completions", + Self::OpenAiImagesGenerations => "openai.images.generations", Self::OpenAiModels => "openai.models", Self::AnthropicMessages => "anthropic.messages", Self::AnthropicCountTokens => "anthropic.count_tokens", @@ -386,6 +389,7 @@ fn provider_request_extractor(route: GatewayRouteKind) -> &'static dyn ProviderR match route { GatewayRouteKind::OpenAiResponses => &OPENAI_RESPONSES_REQUEST_EXTRACTOR, GatewayRouteKind::OpenAiChatCompletions => &OPENAI_CHAT_COMPLETIONS_REQUEST_EXTRACTOR, + GatewayRouteKind::OpenAiImagesGenerations => &OPENAI_MODELS_REQUEST_EXTRACTOR, GatewayRouteKind::OpenAiModels => &OPENAI_MODELS_REQUEST_EXTRACTOR, GatewayRouteKind::AnthropicMessages => &ANTHROPIC_MESSAGES_REQUEST_EXTRACTOR, GatewayRouteKind::AnthropicCountTokens => &ANTHROPIC_COUNT_TOKENS_REQUEST_EXTRACTOR, diff --git a/crates/cli/src/gateway/mod.rs b/crates/cli/src/gateway/mod.rs index 474d05baa..606c97663 100644 --- a/crates/cli/src/gateway/mod.rs +++ b/crates/cli/src/gateway/mod.rs @@ -82,6 +82,20 @@ pub(crate) async fn passthrough( run_managed_gateway(state, prepared, prep).await } +/// Transparently proxies OpenAI image-generation requests without emitting LLM events. +/// +/// Relay has no image-generation codec, so preserving the upstream response exactly is safer than +/// forcing this distinct API shape through the managed text-generation pipeline. +pub(crate) async fn images_generations( + State(state): State, + mut request: Request, +) -> Result, CliError> { + state.touch(); + let authorization = state.authorize_provider_request(request.headers_mut())?; + let prepared = prepare_gateway_request(&state.config, request, authorization).await?; + run_unmanaged_gateway(state, prepared).await +} + /// Exact failure material from one ordinary upstream attempt. /// /// Retry-aware routing attempts use [`FlowError::Upstream`]. @@ -1069,6 +1083,7 @@ where let (env_var, header_name) = match route { ProviderRoute::OpenAiResponses | ProviderRoute::OpenAiChatCompletions + | ProviderRoute::OpenAiImagesGenerations | ProviderRoute::OpenAiModels => ("OPENAI_API_KEY", http::header::AUTHORIZATION.as_str()), ProviderRoute::AnthropicMessages | ProviderRoute::AnthropicCountTokens => { ("ANTHROPIC_API_KEY", "x-api-key") @@ -1086,6 +1101,7 @@ where let header_value = match route { ProviderRoute::OpenAiResponses | ProviderRoute::OpenAiChatCompletions + | ProviderRoute::OpenAiImagesGenerations | ProviderRoute::OpenAiModels => format!("Bearer {value}"), ProviderRoute::AnthropicMessages | ProviderRoute::AnthropicCountTokens => value, }; diff --git a/crates/cli/src/gateway/routes.rs b/crates/cli/src/gateway/routes.rs index b97774556..572feb349 100644 --- a/crates/cli/src/gateway/routes.rs +++ b/crates/cli/src/gateway/routes.rs @@ -9,6 +9,7 @@ use super::*; pub(super) enum ProviderRoute { OpenAiResponses, OpenAiChatCompletions, + OpenAiImagesGenerations, OpenAiModels, AnthropicMessages, AnthropicCountTokens, @@ -54,6 +55,7 @@ impl ProviderRoute { "/v1/responses" => Some(Self::OpenAiResponses), "/chat/completions" => Some(Self::OpenAiChatCompletions), "/v1/chat/completions" => Some(Self::OpenAiChatCompletions), + "/v1/images/generations" => Some(Self::OpenAiImagesGenerations), "/models" => Some(Self::OpenAiModels), "/v1/models" => Some(Self::OpenAiModels), "/v1/messages" => Some(Self::AnthropicMessages), @@ -71,6 +73,9 @@ impl ProviderRoute { "openai_responses" | "openai.responses" | "/v1/responses" => { Some(Self::OpenAiResponses) } + "openai_images_generations" + | "openai.images.generations" + | "/v1/images/generations" => Some(Self::OpenAiImagesGenerations), "openai_models" | "openai.models" | "/models" | "/v1/models" => { Some(Self::OpenAiModels) } @@ -89,7 +94,7 @@ impl ProviderRoute { Self::OpenAiResponses => Some(ProviderSurface::OpenAIResponses), Self::OpenAiChatCompletions => Some(ProviderSurface::OpenAIChat), Self::AnthropicMessages => Some(ProviderSurface::AnthropicMessages), - Self::AnthropicCountTokens | Self::OpenAiModels => None, + Self::AnthropicCountTokens | Self::OpenAiImagesGenerations | Self::OpenAiModels => None, } } @@ -109,9 +114,10 @@ impl ProviderRoute { path_and_query: &str, ) -> String { let base = match self { - Self::OpenAiResponses | Self::OpenAiChatCompletions | Self::OpenAiModels => { - config.openai_base_url.as_str() - } + Self::OpenAiResponses + | Self::OpenAiChatCompletions + | Self::OpenAiImagesGenerations + | Self::OpenAiModels => config.openai_base_url.as_str(), Self::AnthropicMessages | Self::AnthropicCountTokens => { config.anthropic_base_url.as_str() } @@ -135,9 +141,10 @@ impl ProviderRoute { pub(super) fn upstream_url_with_base(self, base: &str, path_and_query: &str) -> String { let base = base.trim_end_matches('/'); let path_and_query = match self { - Self::OpenAiResponses | Self::OpenAiChatCompletions | Self::OpenAiModels => { - normalize_openai_path_for_base(base, path_and_query) - } + Self::OpenAiResponses + | Self::OpenAiChatCompletions + | Self::OpenAiImagesGenerations + | Self::OpenAiModels => normalize_openai_path_for_base(base, path_and_query), _ => path_and_query.to_string(), }; format!("{base}{path_and_query}") @@ -150,6 +157,7 @@ impl ProviderRoute { match self { Self::OpenAiResponses => GatewayRouteKind::OpenAiResponses, Self::OpenAiChatCompletions => GatewayRouteKind::OpenAiChatCompletions, + Self::OpenAiImagesGenerations => GatewayRouteKind::OpenAiImagesGenerations, Self::OpenAiModels => GatewayRouteKind::OpenAiModels, Self::AnthropicMessages => GatewayRouteKind::AnthropicMessages, Self::AnthropicCountTokens => GatewayRouteKind::AnthropicCountTokens, @@ -165,6 +173,7 @@ fn configured_auth_header<'a>( match route { ProviderRoute::OpenAiResponses | ProviderRoute::OpenAiChatCompletions + | ProviderRoute::OpenAiImagesGenerations | ProviderRoute::OpenAiModels => openai_auth_header, ProviderRoute::AnthropicMessages | ProviderRoute::AnthropicCountTokens => { anthropic_auth_header @@ -264,6 +273,7 @@ fn has_openai_replacement_auth( route, ProviderRoute::OpenAiResponses | ProviderRoute::OpenAiChatCompletions + | ProviderRoute::OpenAiImagesGenerations | ProviderRoute::OpenAiModels ) && (configured_auth_header.is_some() || env_var_is_nonempty("OPENAI_API_KEY")) diff --git a/crates/cli/src/server/mod.rs b/crates/cli/src/server/mod.rs index d1b146e9c..3cda96c1d 100644 --- a/crates/cli/src/server/mod.rs +++ b/crates/cli/src/server/mod.rs @@ -584,6 +584,7 @@ fn router_with_state(state: AppState) -> Router { .route("/models", get(gateway::models)) .route("/v1/responses", post(gateway::passthrough)) .route("/v1/chat/completions", post(gateway::passthrough)) + .route("/v1/images/generations", post(gateway::images_generations)) .route("/v1/messages", post(gateway::passthrough)) .route("/v1/messages/count_tokens", post(gateway::passthrough)) .route("/v1/models", get(gateway::models)) diff --git a/crates/cli/tests/coverage/agents/launcher_tests.rs b/crates/cli/tests/coverage/agents/launcher_tests.rs index d747cb8ca..3b00de60d 100644 --- a/crates/cli/tests/coverage/agents/launcher_tests.rs +++ b/crates/cli/tests/coverage/agents/launcher_tests.rs @@ -263,7 +263,7 @@ fn prepares_codex_config_overrides() { .argv .iter() .any(|arg| arg.contains("model_providers.nemo-relay-openai") - && arg.contains("base_url=\"http://127.0.0.1:1234\"") + && arg.contains("base_url=\"http://127.0.0.1:1234/v1\"") // Codex sends its own credentials (ChatGPT-Plus OAuth or OPENAI_API_KEY). // When OPENAI_API_KEY is in the environment the gateway substitutes it; // otherwise codex's own auth is forwarded as-is. @@ -343,6 +343,29 @@ fn prepares_codex_config_overrides() { prepared.restore().unwrap(); } +#[test] +fn prepares_codex_config_overrides_with_versioned_trailing_slash_gateway_url() { + let _guard = current_dir_lock().lock().unwrap(); + let resolved = ResolvedConfig { + gateway: GatewayConfig::default(), + agents: AgentConfigs::default(), + ..ResolvedConfig::default() + }; + let prepared = PreparedAgentLaunch::new( + CodingAgent::Codex, + vec!["codex".into()], + "http://127.0.0.1:1234/", + &resolved, + false, + ) + .unwrap(); + + assert!(prepared.argv.iter().any(|arg| { + arg.contains("model_providers.nemo-relay-openai") + && arg.contains("base_url=\"http://127.0.0.1:1234/v1\"") + })); +} + #[test] fn prepares_codex_with_hooks_when_auth_missing() { let _guard = current_dir_lock().lock().unwrap(); diff --git a/crates/cli/tests/coverage/agents/plugin_host_tests.rs b/crates/cli/tests/coverage/agents/plugin_host_tests.rs index aaaf1be5c..de331e658 100644 --- a/crates/cli/tests/coverage/agents/plugin_host_tests.rs +++ b/crates/cli/tests/coverage/agents/plugin_host_tests.rs @@ -1235,6 +1235,10 @@ tool_namespace = "agents" installed["features"]["multi_agent_v2"]["tool_namespace"].as_str(), Some("agents") ); + assert_eq!( + installed["model_providers"]["nemo-relay-openai"]["base_url"].as_str(), + Some("http://127.0.0.1:47632/v1") + ); uninstall_codex_config(&path, DEFAULT_URL, false).unwrap(); @@ -1603,7 +1607,7 @@ fn codex_backup_migration_preserves_user_provider_extensions() { let uninstalled = fs::read_to_string(&path).unwrap(); assert!(uninstalled.contains("user_option = \"keep\"")); assert!(!uninstalled.contains("model_provider = \"nemo-relay-openai\"")); - assert!(uninstalled.contains(&format!("base_url = \"{DEFAULT_URL}\""))); + assert!(uninstalled.contains(&format!("base_url = \"{DEFAULT_URL}/v1\""))); assert!(!uninstalled.contains(BOOTSTRAP_CLIENT_TOKEN_HEADER)); assert!(!uninstalled.contains("hooks = true")); assert!(!backup_path(&path).exists()); @@ -1642,7 +1646,7 @@ fn codex_reinstall_round_trips_user_provider_fields_and_headers() { assert!(backup.contains("user_option = \"keep\"")); assert!(backup.contains("x-user-header = \"keep-header\"")); assert!(!backup.contains(BOOTSTRAP_CLIENT_TOKEN_HEADER)); - assert!(backup.contains(&format!("base_url = \"{DEFAULT_URL}\""))); + assert!(backup.contains(&format!("base_url = \"{DEFAULT_URL}/v1\""))); uninstall_codex_config(&path, DEFAULT_URL, false).unwrap(); let uninstalled = fs::read_to_string(&path).unwrap(); @@ -1650,7 +1654,7 @@ fn codex_reinstall_round_trips_user_provider_fields_and_headers() { assert!(uninstalled.contains("user_option = \"keep\"")); assert!(uninstalled.contains("x-user-header = \"keep-header\"")); assert!(!uninstalled.contains(BOOTSTRAP_CLIENT_TOKEN_HEADER)); - assert!(uninstalled.contains(&format!("base_url = \"{DEFAULT_URL}\""))); + assert!(uninstalled.contains(&format!("base_url = \"{DEFAULT_URL}/v1\""))); assert!(!uninstalled.contains("hooks = true")); } @@ -1679,7 +1683,7 @@ fn codex_direct_uninstall_preserves_a_complete_extended_provider_inactively() { uninstall_codex_config(&path, DEFAULT_URL, false).unwrap(); let uninstalled = fs::read_to_string(&path).unwrap(); assert!(uninstalled.contains("model_provider = \"openai\"")); - assert!(uninstalled.contains(&format!("base_url = \"{DEFAULT_URL}\""))); + assert!(uninstalled.contains(&format!("base_url = \"{DEFAULT_URL}/v1\""))); assert!(uninstalled.contains("name = \"NeMo Relay\"")); assert!(uninstalled.contains("user_option = \"keep\"")); assert!(uninstalled.contains("x-user-header = \"keep-header\"")); @@ -1718,7 +1722,7 @@ fn codex_uninstall_sanitizes_an_extended_contaminated_backup_without_the_key() { let uninstalled = fs::read_to_string(&path).unwrap(); assert!(uninstalled.contains("custom = \"keep\"")); assert!(!uninstalled.contains("model_provider = \"nemo-relay-openai\"")); - assert!(uninstalled.contains(&format!("base_url = \"{DEFAULT_URL}\""))); + assert!(uninstalled.contains(&format!("base_url = \"{DEFAULT_URL}/v1\""))); assert!(uninstalled.contains("user_option = \"keep\"")); assert!(uninstalled.contains("x-user-header = \"keep-header\"")); assert!(!uninstalled.contains(BOOTSTRAP_CLIENT_TOKEN_HEADER)); @@ -1756,7 +1760,7 @@ fn codex_uninstall_sanitizes_an_extended_contaminated_backup_after_key_rotation( let uninstalled = fs::read_to_string(&path).unwrap(); assert!(uninstalled.contains("custom = \"keep\"")); assert!(!uninstalled.contains("model_provider = \"nemo-relay-openai\"")); - assert!(uninstalled.contains(&format!("base_url = \"{DEFAULT_URL}\""))); + assert!(uninstalled.contains(&format!("base_url = \"{DEFAULT_URL}/v1\""))); assert!(uninstalled.contains("user_option = \"keep\"")); assert!(uninstalled.contains("x-user-header = \"keep-header\"")); assert!(!uninstalled.contains(BOOTSTRAP_CLIENT_TOKEN_HEADER)); @@ -2669,7 +2673,7 @@ fn codex_uninstall_removes_proof_from_a_user_modified_provider() { uninstall_codex_config(&path, DEFAULT_URL, false).unwrap(); let updated = fs::read_to_string(&path).unwrap(); - assert!(updated.contains("base_url = \"http://127.0.0.1:49999\"")); + assert!(updated.contains("base_url = \"http://127.0.0.1:49999/v1\"")); assert!(!updated.contains(BOOTSTRAP_CLIENT_TOKEN_HEADER)); assert!(!backup_path(&path).exists()); } diff --git a/crates/cli/tests/coverage/shared/gateway_tests.rs b/crates/cli/tests/coverage/shared/gateway_tests.rs index 86e672ad0..3c3d8aa0b 100644 --- a/crates/cli/tests/coverage/shared/gateway_tests.rs +++ b/crates/cli/tests/coverage/shared/gateway_tests.rs @@ -295,6 +295,14 @@ fn selects_provider_routes() { ProviderRoute::from_path("/models"), Some(ProviderRoute::OpenAiModels) ); + assert_eq!( + ProviderRoute::from_path("/v1/images/generations"), + Some(ProviderRoute::OpenAiImagesGenerations) + ); + assert_eq!( + ProviderRoute::OpenAiImagesGenerations.name(), + "openai.images.generations" + ); assert_eq!(ProviderRoute::OpenAiModels.name(), "openai.models"); assert_eq!( ProviderRoute::AnthropicMessages.name(), @@ -316,6 +324,10 @@ fn selects_provider_routes() { ProviderRoute::OpenAiModels.alignment_route(), GatewayRouteKind::OpenAiModels ); + assert_eq!( + ProviderRoute::OpenAiImagesGenerations.alignment_route(), + GatewayRouteKind::OpenAiImagesGenerations + ); assert_eq!( ProviderRoute::AnthropicMessages.alignment_route(), GatewayRouteKind::AnthropicMessages @@ -351,6 +363,7 @@ fn generation_routes_have_request_codecs_and_passthrough_routes_do_not() { for route in [ ProviderRoute::AnthropicCountTokens, + ProviderRoute::OpenAiImagesGenerations, ProviderRoute::OpenAiModels, ] { let codecs = codecs_for_route(route); @@ -413,6 +426,17 @@ fn generation_route_codecs_reject_stream_mode_changes() { #[test] fn dispatch_override_routes_cover_models_and_count_tokens() { + for alias in [ + "openai_images_generations", + "openai.images.generations", + "/v1/images/generations", + ] { + assert_eq!( + ProviderRoute::from_dispatch_override(alias), + Some(ProviderRoute::OpenAiImagesGenerations), + "alias {alias}" + ); + } for alias in ["openai_models", "openai.models", "/models", "/v1/models"] { assert_eq!( ProviderRoute::from_dispatch_override(alias), @@ -438,6 +462,7 @@ fn provider_route_names_round_trip_through_alignment_routes() { for route in [ ProviderRoute::OpenAiResponses, ProviderRoute::OpenAiChatCompletions, + ProviderRoute::OpenAiImagesGenerations, ProviderRoute::OpenAiModels, ProviderRoute::AnthropicMessages, ProviderRoute::AnthropicCountTokens, @@ -475,6 +500,11 @@ fn provider_routes_preserve_path_query_and_choose_upstream() { ProviderRoute::OpenAiModels.upstream_url(&config, "/models"), "http://openai/v1/models" ); + assert_eq!( + ProviderRoute::OpenAiImagesGenerations + .upstream_url(&config, "/v1/images/generations?output_format=png"), + "http://openai/v1/images/generations?output_format=png" + ); assert_eq!( ProviderRoute::AnthropicMessages.upstream_url(&config, "/v1/messages"), "http://anthropic/v1/messages" @@ -1678,6 +1708,10 @@ fn configured_auth_headers_are_provider_specific_and_precede_environment_keys() for (route, expected) in [ (ProviderRoute::OpenAiResponses, "Basic openai-custom"), (ProviderRoute::OpenAiChatCompletions, "Basic openai-custom"), + ( + ProviderRoute::OpenAiImagesGenerations, + "Basic openai-custom", + ), (ProviderRoute::OpenAiModels, "Basic openai-custom"), (ProviderRoute::AnthropicMessages, "Bearer anthropic-custom"), ( diff --git a/crates/cli/tests/coverage/shared/server_tests.rs b/crates/cli/tests/coverage/shared/server_tests.rs index f7593509a..67ca609a8 100644 --- a/crates/cli/tests/coverage/shared/server_tests.rs +++ b/crates/cli/tests/coverage/shared/server_tests.rs @@ -2283,6 +2283,42 @@ async fn gateway_forwards_openai_json_without_rewriting_payload() { assert_eq!(body["connection"], Value::Null); } +#[tokio::test] +async fn gateway_transparently_forwards_openai_image_generations() { + let upstream = spawn_upstream(false).await; + let mut config = test_config(); + config.openai_base_url = upstream.url(); + let response = router(config) + .oneshot( + Request::builder() + .method("POST") + .uri("/v1/images/generations?output_format=png") + .header("content-type", "application/json") + .header("authorization", "Bearer image-test") + .body(Body::from( + json!({ + "model": "gpt-image-1", + "prompt": "a tiny relay robot" + }) + .to_string(), + )) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::OK); + let bytes = response.into_body().collect().await.unwrap().to_bytes(); + let body: Value = serde_json::from_slice(&bytes).unwrap(); + assert_eq!(body["model"], json!("gpt-image-1")); + assert_eq!(body["prompt"], json!("a tiny relay robot")); + assert_eq!( + body["path"], + json!("/v1/images/generations?output_format=png") + ); + assert_eq!(body["authorization"], json!("Bearer image-test")); +} + #[tokio::test] async fn transparent_gateway_requires_and_consumes_its_invocation_token() { let upstream = spawn_upstream(false).await; @@ -3298,11 +3334,13 @@ async fn wait_for_gateway(url: &str) { } async fn spawn_upstream(streaming: bool) -> TestServer { - async fn chat(headers: HeaderMap, body: Bytes) -> impl IntoResponse { + async fn chat(uri: axum::http::Uri, headers: HeaderMap, body: Bytes) -> impl IntoResponse { let payload: Value = serde_json::from_slice(&body).unwrap(); Json(json!({ + "path": uri.path_and_query().map(|value| value.as_str()), "model": payload["model"], "input": payload["input"], + "prompt": payload["prompt"], "authorization": headers .get(header::AUTHORIZATION) .and_then(|value| value.to_str().ok()), @@ -3341,6 +3379,7 @@ async fn spawn_upstream(streaming: bool) -> TestServer { } else { Router::new() .route("/v1/chat/completions", post(chat)) + .route("/v1/images/generations", post(chat)) .route("/v1/responses", post(chat)) }; let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); diff --git a/docs/nemo-relay-cli/codex.mdx b/docs/nemo-relay-cli/codex.mdx index 887a6e03a..d36d2e363 100644 --- a/docs/nemo-relay-cli/codex.mdx +++ b/docs/nemo-relay-cli/codex.mdx @@ -139,7 +139,7 @@ nemo-relay install codex The installer creates a local marketplace, installs `nemo-relay-plugin@nemo-relay-local`, enables Codex hooks, and configures the -`nemo-relay-openai` provider alias at `http://127.0.0.1:47632`. It then uses the +`nemo-relay-openai` provider alias at `http://127.0.0.1:47632/v1`. It then uses the existing `nemo-relay` binary on `PATH`; it does not install a plugin-local Relay binary. @@ -311,7 +311,7 @@ model_provider = "nemo-relay-openai" [model_providers.nemo-relay-openai] name = "NeMo Relay OpenAI" -base_url = "http://127.0.0.1:4040" +base_url = "http://127.0.0.1:4040/v1" wire_api = "responses" requires_openai_auth = true supports_websockets = false diff --git a/docs/nemo-relay-cli/plugin-installation.mdx b/docs/nemo-relay-cli/plugin-installation.mdx index 5170e68cf..562ce5661 100644 --- a/docs/nemo-relay-cli/plugin-installation.mdx +++ b/docs/nemo-relay-cli/plugin-installation.mdx @@ -101,7 +101,7 @@ it adds the provider route. The plugin also declares the native For Codex, `nemo-relay install codex` registers the local marketplace, installs `nemo-relay-plugin@nemo-relay-local`, enables Codex hooks, and configures the -`nemo-relay-openai` provider alias at `http://127.0.0.1:47632`. The plugin's +`nemo-relay-openai` provider alias at `http://127.0.0.1:47632/v1`. The plugin's `hooks/hooks.json` is the sole persistent Relay hook source. The installer uses the Codex app-server API to select hooks by plugin ID, exact canonical command, and event definition. It never trusts unrelated user, project, or plugin hooks. diff --git a/docs/reference/migration-guides.mdx b/docs/reference/migration-guides.mdx index eb4d153b9..039169a98 100644 --- a/docs/reference/migration-guides.mdx +++ b/docs/reference/migration-guides.mdx @@ -13,6 +13,18 @@ intervening release in sequence. ## Upgrade to NeMo Relay 0.8 +### Refresh the Installed Codex Provider Configuration + +If you use the persistent Codex integration, cycle its installation after +upgrading to refresh the `nemo-relay-openai` provider configuration. This fixes +a routing bug that can prevent Codex image-generation requests from reaching +Relay: + +```bash +nemo-relay uninstall codex +nemo-relay install codex +``` + ### Update Exhaustive Cache Configuration Literals Rust code that constructs `ResponseCacheConfig` with an exhaustive struct