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
1 change: 1 addition & 0 deletions crates/cli/src/agents/codex/alignment.rs
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,7 @@ fn is_openai_route(route: GatewayRouteKind) -> bool {
route,
GatewayRouteKind::OpenAiResponses
| GatewayRouteKind::OpenAiChatCompletions
| GatewayRouteKind::OpenAiImagesGenerations
Comment thread
coderabbitai[bot] marked this conversation as resolved.
| GatewayRouteKind::OpenAiModels
)
}
Expand Down
23 changes: 15 additions & 8 deletions crates/cli/src/agents/codex/host.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)?;
Expand All @@ -805,16 +806,16 @@ 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
&& let Err(error) = refresh_codex_config_backup(
path,
&raw,
&doc,
gateway_url,
&gateway_url,
has_managed_proof,
&challenge,
)
Expand All @@ -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);
Expand Down Expand Up @@ -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(());
}
Expand All @@ -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(
Expand Down Expand Up @@ -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)
Expand All @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion crates/cli/src/agents/codex/launch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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),
)
Expand Down
9 changes: 9 additions & 0 deletions crates/cli/src/agents/codex/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -37,3 +37,12 @@ pub(super) const DESCRIPTOR: AgentDescriptor = AgentDescriptor {
pub(super) fn parse_version(raw: &str) -> Option<Version> {
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")
}
}
6 changes: 5 additions & 1 deletion crates/cli/src/agents/shared/alignment.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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",
Expand Down Expand Up @@ -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,
Expand Down
16 changes: 16 additions & 0 deletions crates/cli/src/gateway/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<AppState>,
mut request: Request<Body>,
) -> Result<Response<Body>, 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`].
Expand Down Expand Up @@ -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")
Expand All @@ -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,
};
Expand Down
24 changes: 17 additions & 7 deletions crates/cli/src/gateway/routes.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ use super::*;
pub(super) enum ProviderRoute {
OpenAiResponses,
OpenAiChatCompletions,
OpenAiImagesGenerations,
OpenAiModels,
AnthropicMessages,
AnthropicCountTokens,
Expand Down Expand Up @@ -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),
Expand All @@ -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)
}
Expand All @@ -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,
}
}

Expand All @@ -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()
}
Expand All @@ -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}")
Expand All @@ -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,
Expand All @@ -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
Expand Down Expand Up @@ -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"))
Expand Down
1 change: 1 addition & 0 deletions crates/cli/src/server/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
25 changes: 24 additions & 1 deletion crates/cli/tests/coverage/agents/launcher_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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\"")
Comment thread
willkill07 marked this conversation as resolved.
// 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.
Expand Down Expand Up @@ -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\"")
}));
}

Comment thread
willkill07 marked this conversation as resolved.
#[test]
fn prepares_codex_with_hooks_when_auth_missing() {
let _guard = current_dir_lock().lock().unwrap();
Expand Down
18 changes: 11 additions & 7 deletions crates/cli/tests/coverage/agents/plugin_host_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down Expand Up @@ -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());
Expand Down Expand Up @@ -1642,15 +1646,15 @@ 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();
assert!(uninstalled.contains("model_provider = \"openai\""));
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"));
}

Expand Down Expand Up @@ -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\""));
Expand Down Expand Up @@ -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));
Expand Down Expand Up @@ -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));
Expand Down Expand Up @@ -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());
}
Expand Down
Loading
Loading