diff --git a/apps/aether-gateway/src/handlers/admin/provider/query/models/mod.rs b/apps/aether-gateway/src/handlers/admin/provider/query/models/mod.rs index f7d463e6e..e88bc5356 100644 --- a/apps/aether-gateway/src/handlers/admin/provider/query/models/mod.rs +++ b/apps/aether-gateway/src/handlers/admin/provider/query/models/mod.rs @@ -399,7 +399,8 @@ async fn provider_query_read_cached_models( let cache_key = format!("upstream_models:{provider_id}:{key_id}"); let raw = state.runtime_state().kv_get(&cache_key).await.ok()??; let parsed = serde_json::from_str::>(&raw).ok()?; - Some(aggregate_models_for_cache(&parsed)) + let models = aggregate_models_for_cache(&parsed); + (!models.is_empty()).then_some(models) } async fn provider_query_read_provider_cached_models( @@ -409,7 +410,8 @@ async fn provider_query_read_provider_cached_models( let cache_key = format!("{ANTIGRAVITY_PROVIDER_CACHE_KEY_PREFIX}{provider_id}"); let raw = state.runtime_state().kv_get(&cache_key).await.ok()??; let parsed = serde_json::from_str::>(&raw).ok()?; - Some(aggregate_models_for_cache(&parsed)) + let models = aggregate_models_for_cache(&parsed); + (!models.is_empty()).then_some(models) } async fn provider_query_write_provider_cached_models( @@ -417,7 +419,11 @@ async fn provider_query_write_provider_cached_models( provider_id: &str, models: &[Value], ) { - let Ok(serialized) = serde_json::to_string(&aggregate_models_for_cache(models)) else { + let models = aggregate_models_for_cache(models); + if models.is_empty() { + return; + } + let Ok(serialized) = serde_json::to_string(&models) else { return; }; let cache_key = format!("{ANTIGRAVITY_PROVIDER_CACHE_KEY_PREFIX}{provider_id}"); @@ -577,7 +583,7 @@ async fn provider_query_fetch_models_for_key( }; all_errors.extend(outcome.errors); - let unique_models = aggregate_models_for_cache(&outcome.cached_models); + let unique_models = outcome.legacy_models; if outcome.has_success && !unique_models.is_empty() { ::write_upstream_models_cache( state.app(), diff --git a/apps/aether-gateway/src/model_fetch/runtime.rs b/apps/aether-gateway/src/model_fetch/runtime.rs index b68c836c2..fc032d24f 100644 --- a/apps/aether-gateway/src/model_fetch/runtime.rs +++ b/apps/aether-gateway/src/model_fetch/runtime.rs @@ -378,7 +378,7 @@ async fn fetch_and_persist_key_models( ) .await?; state - .write_upstream_models_cache(&target.provider.id, &target.key.id, &result.cached_models) + .write_upstream_models_cache(&target.provider.id, &target.key.id, &result.legacy_models) .await; sync_provider_model_whitelist_associations(state, &target.provider.id, &filtered_models) .await diff --git a/apps/aether-gateway/src/state/integrations.rs b/apps/aether-gateway/src/state/integrations.rs index 36ce42648..9c3397c6b 100644 --- a/apps/aether-gateway/src/state/integrations.rs +++ b/apps/aether-gateway/src/state/integrations.rs @@ -469,8 +469,11 @@ impl ModelFetchRuntimeState for AppState { key_id: &str, cached_models: &[Value], ) { - let Ok(serialized) = serde_json::to_string(&aggregate_models_for_cache(cached_models)) - else { + let models = aggregate_models_for_cache(cached_models); + if models.is_empty() { + return; + } + let Ok(serialized) = serde_json::to_string(&models) else { return; }; let cache_key = format!("upstream_models:{provider_id}:{key_id}"); diff --git a/apps/aether-gateway/src/tests/control/admin/provider_query.rs b/apps/aether-gateway/src/tests/control/admin/provider_query.rs index 0f82c669b..61ca5bf2c 100644 --- a/apps/aether-gateway/src/tests/control/admin/provider_query.rs +++ b/apps/aether-gateway/src/tests/control/admin/provider_query.rs @@ -537,6 +537,159 @@ async fn gateway_handles_admin_provider_query_models_with_openai_responses_endpo execution_runtime_handle.abort(); } +#[test] +fn gateway_recovers_codex_slug_only_models_from_an_empty_legacy_cache() { + run_provider_query_test( + "gateway_recovers_codex_slug_only_models_from_an_empty_legacy_cache", + gateway_recovers_codex_slug_only_models_from_an_empty_legacy_cache_impl, + ); +} + +async fn gateway_recovers_codex_slug_only_models_from_an_empty_legacy_cache_impl() { + let execution_runtime_hits = Arc::new(Mutex::new(0usize)); + let execution_runtime_hits_clone = Arc::clone(&execution_runtime_hits); + let execution_runtime = Router::new().route( + "/v1/execute/sync", + any(move |Json(plan): Json| { + let execution_runtime_hits_inner = Arc::clone(&execution_runtime_hits_clone); + async move { + *execution_runtime_hits_inner + .lock() + .expect("mutex should lock") += 1; + assert_eq!( + plan.url, + "https://chatgpt.com/backend-api/codex/models?client_version=0.144.1" + ); + assert_eq!(plan.provider_api_format, "openai:responses"); + Json(json!({ + "request_id": "req-provider-query-codex-slug-only", + "status_code": 200, + "headers": { + "content-type": "application/json" + }, + "body": { + "json_body": { + "models": [{ + "slug": "gpt-future-dynamic", + "display_name": "Future Dynamic", + "description": "A model unknown to this Aether build", + "model_messages": { + "instructions_template": "Follow the dynamic instructions." + }, + "api_format": "opaque-upstream-protocol", + "future_capability": { + "opaque": true, + "schema_version": 7 + } + }] + } + } + })) + } + }), + ); + + let (execution_runtime_url, execution_runtime_handle) = start_server(execution_runtime).await; + let mut provider = sample_provider("provider-codex-dynamic", "Codex Dynamic", 10); + provider.provider_type = "codex".to_string(); + let provider_catalog_repository = Arc::new(InMemoryProviderCatalogReadRepository::seed( + vec![provider], + vec![sample_endpoint( + "endpoint-codex-dynamic", + "provider-codex-dynamic", + "openai:responses", + "https://chatgpt.com/backend-api/codex", + )], + vec![sample_key( + "key-codex-dynamic", + "provider-codex-dynamic", + "openai:responses", + "codex-dynamic-token", + )], + )); + + let state = build_state_with_execution_runtime_override(execution_runtime_url) + .with_data_state_for_tests(GatewayDataState::with_provider_transport_reader_for_tests( + provider_catalog_repository, + DEVELOPMENT_ENCRYPTION_KEY.to_string(), + )); + state + .runtime_state() + .kv_set( + "upstream_models:provider-codex-dynamic:key-codex-dynamic", + "[]".to_string(), + None, + ) + .await + .expect("empty legacy cache should seed"); + let cache_state = state.clone(); + let gateway = build_router_with_state(state); + let (gateway_url, gateway_handle) = start_server(gateway).await; + + for (request_index, expected_from_cache) in [(0usize, false), (1usize, true)] { + let response = reqwest::Client::new() + .post(format!("{gateway_url}/api/admin/provider-query/models")) + .header(crate::constants::GATEWAY_HEADER, "rust-phase3b") + .header(TRUSTED_ADMIN_USER_ID_HEADER, "admin-user-123") + .header(TRUSTED_ADMIN_USER_ROLE_HEADER, "admin") + .header(TRUSTED_ADMIN_SESSION_ID_HEADER, "session-123") + .json(&json!({ + "provider_id": "provider-codex-dynamic", + "api_key_id": "key-codex-dynamic" + })) + .send() + .await + .expect("request should succeed"); + + assert_eq!(response.status(), StatusCode::OK); + let payload: serde_json::Value = response.json().await.expect("json body should parse"); + assert_eq!(payload["success"], json!(true)); + assert_eq!(payload["data"]["error"], serde_json::Value::Null); + assert_eq!( + payload["data"]["from_cache"], + json!(expected_from_cache), + "request {request_index} cache status" + ); + assert_eq!( + payload["data"]["models"][0]["id"], + json!("gpt-future-dynamic") + ); + assert_eq!( + payload["data"]["models"][0]["api_formats"], + json!(["openai:responses"]) + ); + assert_eq!( + payload["data"]["models"][0]["api_format"], + json!("opaque-upstream-protocol") + ); + assert_eq!( + payload["data"]["models"][0]["model_messages"]["instructions_template"], + json!("Follow the dynamic instructions.") + ); + assert_eq!( + payload["data"]["models"][0]["future_capability"], + json!({"opaque": true, "schema_version": 7}) + ); + assert_eq!( + *execution_runtime_hits.lock().expect("mutex should lock"), + 1, + "request {request_index} must not cause another upstream fetch" + ); + if request_index == 0 { + ::write_upstream_models_cache( + &cache_state, + "provider-codex-dynamic", + "key-codex-dynamic", + &[], + ) + .await; + } + } + + gateway_handle.abort(); + execution_runtime_handle.abort(); +} + #[test] fn gateway_handles_admin_provider_query_models_falls_back_to_codex_preset_when_token_invalidated() { run_provider_query_test( diff --git a/crates/aether-model-fetch/src/lib.rs b/crates/aether-model-fetch/src/lib.rs index e86e8fa63..bb3432805 100644 --- a/crates/aether-model-fetch/src/lib.rs +++ b/crates/aether-model-fetch/src/lib.rs @@ -16,9 +16,9 @@ pub use logic::{ endpoint_supports_rust_models_fetch, extract_error_message, json_string_list, merge_upstream_metadata, model_catalog_upstream_metadata, parse_models_response, parse_models_response_page, parse_windsurf_model_configs_response, preset_models_for_provider, - provider_type_uses_preset_models, select_models_fetch_endpoint, - selected_models_fetch_endpoints, upstream_metadata_namespace_updates, ModelFetchRunSummary, - ModelsFetchPage, ModelsFetchSuccess, + project_codex_models_for_legacy_cache, provider_type_uses_preset_models, + select_models_fetch_endpoint, selected_models_fetch_endpoints, + upstream_metadata_namespace_updates, ModelFetchRunSummary, ModelsFetchPage, ModelsFetchSuccess, }; pub use strategy::{ fetch_models_from_transports, fetch_models_from_transports_for_client_version, diff --git a/crates/aether-model-fetch/src/logic.rs b/crates/aether-model-fetch/src/logic.rs index b353f97cc..b4c330257 100644 --- a/crates/aether-model-fetch/src/logic.rs +++ b/crates/aether-model-fetch/src/logic.rs @@ -303,7 +303,7 @@ fn codex_model_identities(model: &Value) -> Result, String> { pub(crate) fn codex_model_identity(model: &Value) -> Option<&str> { let object = model.as_object()?; - ["id", "slug"].iter().find_map(|field| { + ["slug", "id"].iter().find_map(|field| { object .get(*field) .and_then(Value::as_str) @@ -312,6 +312,67 @@ pub(crate) fn codex_model_identity(model: &Value) -> Option<&str> { }) } +/// Projects opaque Codex cards into the legacy model-cache shape used by permission sync. +/// +/// The source cards remain untouched. Only formats from transports that actually returned the +/// card are admitted into `api_formats`; an upstream `api_format` field is protocol data and is +/// preserved as-is rather than interpreted as an Aether endpoint format. +pub fn project_codex_models_for_legacy_cache<'a>( + successful_transports: impl IntoIterator, +) -> Vec { + let mut projected = BTreeMap::>::new(); + + for (endpoint_api_format, models) in successful_transports { + let api_format = normalize_api_format(endpoint_api_format); + if api_format.is_empty() { + continue; + } + + for model in models { + let Some(model_id) = codex_model_identity(model).map(ToOwned::to_owned) else { + continue; + }; + let Some(source) = model.as_object() else { + continue; + }; + + let entry = projected.entry(model_id.clone()).or_insert_with(|| { + let mut card = source.clone(); + card.insert("id".to_string(), Value::String(model_id)); + // `api_formats` is Aether's routing projection. Never inherit a similarly named + // opaque upstream field when constructing this legacy view. + card.insert("api_formats".to_string(), Value::Array(Vec::new())); + card + }); + let mut formats = entry + .get("api_formats") + .and_then(Value::as_array) + .map(|items| { + items + .iter() + .filter_map(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned) + .collect::>() + }) + .unwrap_or_default(); + formats.insert(api_format.clone()); + entry.insert( + "api_formats".to_string(), + Value::Array( + sorted_api_formats(formats) + .into_iter() + .map(Value::String) + .collect(), + ), + ); + } + } + + projected.into_values().map(Value::Object).collect() +} + fn valid_codex_model_identity(value: &str) -> bool { !value.is_empty() && value.len() <= 256 @@ -715,9 +776,15 @@ pub fn aggregate_models_for_cache(models: &[Value]) -> Vec { continue; }; + let has_api_formats_array = object + .get("api_formats") + .and_then(Value::as_array) + .is_some(); let entry = aggregated.entry(model_id.to_string()).or_insert_with(|| { let mut cloned = object.clone(); - cloned.remove("api_format"); + if !has_api_formats_array { + cloned.remove("api_format"); + } cloned }); @@ -734,12 +801,16 @@ pub fn aggregate_models_for_cache(models: &[Value]) -> Vec { .collect::>() }) .unwrap_or_default(); - let legacy_api_format = object - .get("api_format") - .and_then(Value::as_str) - .map(str::trim) - .filter(|value| !value.is_empty()) - .map(ToOwned::to_owned); + let legacy_api_format = (!has_api_formats_array) + .then(|| { + object + .get("api_format") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned) + }) + .flatten(); let existing_formats = entry .get("api_formats") .and_then(Value::as_array) @@ -767,7 +838,13 @@ pub fn aggregate_models_for_cache(models: &[Value]) -> Vec { entry.insert("api_formats".to_string(), Value::Array(merged_formats)); for (key, value) in object { - if key == "api_format" || entry.contains_key(key) { + if key == "api_format" { + if has_api_formats_array && !entry.contains_key(key) { + entry.insert(key.clone(), value.clone()); + } + continue; + } + if entry.contains_key(key) { continue; } entry.insert(key.clone(), value.clone()); @@ -1082,7 +1159,8 @@ mod tests { aggregate_models_for_cache, apply_model_filters, build_gemini_models_url, build_models_fetch_url, build_models_fetch_url_for_client_version, merge_upstream_metadata, parse_codex_models_response_page, parse_models_response, parse_models_response_page, - preset_models_for_provider, selected_models_fetch_endpoints, + preset_models_for_provider, project_codex_models_for_legacy_cache, + selected_models_fetch_endpoints, }; fn sample_endpoint( @@ -1200,6 +1278,27 @@ mod tests { assert!(aggregated[0].get("api_format").is_none()); } + #[test] + fn aggregate_models_for_cache_preserves_opaque_api_format_on_projected_cards() { + let card = json!({ + "slug": "gpt-slug-only-future", + "api_format": "opaque-upstream-protocol", + "model_messages": {"instructions_template": "Future instructions"}, + "future_capability": {"opaque": true} + }); + let cards = vec![card]; + let projected = + project_codex_models_for_legacy_cache([("openai:responses", cards.as_slice())]); + + let aggregated = aggregate_models_for_cache(&projected); + + assert_eq!(aggregated.len(), 1); + assert_eq!(aggregated[0]["id"], "gpt-slug-only-future"); + assert_eq!(aggregated[0]["api_format"], "opaque-upstream-protocol"); + assert_eq!(aggregated[0]["api_formats"], json!(["openai:responses"])); + assert_eq!(aggregated[0]["future_capability"]["opaque"], true); + } + #[test] fn build_gemini_models_url_preserves_base_query() { let url = @@ -1466,6 +1565,38 @@ mod tests { assert_eq!(parsed.cached_models, vec![card]); } + #[test] + fn codex_legacy_projector_adds_internal_identity_and_only_successful_endpoint_formats() { + let card = json!({ + "id": "opaque-upstream-id", + "slug": "gpt-slug-only-future", + "api_format": "opaque-upstream-protocol", + "api_formats": ["opaque-upstream-format-list"], + "model_messages": {"instructions_template": "Future instructions"}, + "future_capability": {"opaque": true} + }); + let cards = vec![card.clone()]; + + let projected = project_codex_models_for_legacy_cache([ + ("openai:responses", cards.as_slice()), + ("openai:chat", cards.as_slice()), + ]); + + assert_eq!(cards, vec![card]); + assert_eq!(projected.len(), 1); + assert_eq!(projected[0]["id"], "gpt-slug-only-future"); + assert_eq!( + projected[0]["api_formats"], + json!(["openai:chat", "openai:responses"]) + ); + assert_eq!(projected[0]["api_format"], "opaque-upstream-protocol"); + assert_eq!( + projected[0]["model_messages"]["instructions_template"], + "Future instructions" + ); + assert_eq!(projected[0]["future_capability"]["opaque"], true); + } + #[test] fn strict_codex_parser_rejects_empty_models_array() { let error = parse_codex_models_response_page(&json!({"models": []})) diff --git a/crates/aether-model-fetch/src/strategy.rs b/crates/aether-model-fetch/src/strategy.rs index bfcb582c3..64f8ab1b8 100644 --- a/crates/aether-model-fetch/src/strategy.rs +++ b/crates/aether-model-fetch/src/strategy.rs @@ -23,6 +23,7 @@ use crate::logic::{ aggregate_models_for_cache, codex_model_identity, extract_error_message, merge_codex_models_preserving_cards, parse_codex_models_response_page, parse_models_response_page, parse_windsurf_model_configs_response, preset_models_for_provider, + project_codex_models_for_legacy_cache, }; use crate::transport::{ build_antigravity_fetch_available_models_plan, build_antigravity_load_code_assist_plan, @@ -46,7 +47,10 @@ const GOOGLE_CLOUD_PLATFORM_SCOPE: &str = "https://www.googleapis.com/auth/cloud #[derive(Debug, Clone, PartialEq)] pub struct ModelsFetchOutcome { pub fetched_model_ids: Vec, + /// Provider response cards. Versioned Codex catalogs remain byte-for-byte opaque here. pub cached_models: Vec, + /// Provider cards projected into Aether's legacy admin/runtime-cache shape. + pub legacy_models: Vec, pub errors: Vec, pub has_success: bool, pub upstream_metadata: Option, @@ -253,15 +257,21 @@ async fn fetch_standard_models( codex_client_version: Option<&str>, ) -> Result { let mut all_models = Vec::new(); + let mut successful_codex_catalogs = Vec::<(String, Vec)>::new(); let mut errors = Vec::new(); let mut has_success = false; let mut etag = ConsistentValue::default(); let mut upstream_status = ConsistentValue::default(); + let is_codex = provider_type.trim().eq_ignore_ascii_case("codex"); for transport in transports { match fetch_standard_models_for_transport(runtime, transport, codex_client_version).await { Ok(outcome) => { - all_models.extend(outcome.cached_models); + all_models.extend(outcome.cached_models.iter().cloned()); + if is_codex && outcome.has_success { + successful_codex_catalogs + .push((transport.endpoint.api_format.clone(), outcome.cached_models)); + } has_success |= outcome.has_success; if outcome.has_success { etag.observe(outcome.etag); @@ -275,7 +285,6 @@ async fn fetch_standard_models( } } - let is_codex = provider_type.trim().eq_ignore_ascii_case("codex"); let merged_models = if is_codex { merge_codex_models_preserving_cards(&all_models)? } else { @@ -287,6 +296,11 @@ async fn fetch_standard_models( let mut outcome = build_success_outcome(merged_models, upstream_metadata, has_success); if let Some(model_ids) = codex_model_ids { outcome.fetched_model_ids = model_ids; + outcome.legacy_models = project_codex_models_for_legacy_cache( + successful_codex_catalogs + .iter() + .map(|(api_format, models)| (api_format.as_str(), models.as_slice())), + ); } Ok(outcome .with_errors(errors) @@ -328,7 +342,11 @@ async fn fetch_standard_models_for_transport( let body_json = execution_result_json_body(&result).map_err(|err| (err, Some(result.status_code)))?; let parsed = if is_codex { - parse_codex_models_response_page(&body_json) + parse_codex_models_response_for_request( + &transport.endpoint.api_format, + &body_json, + codex_client_version, + ) } else { parse_models_response_page(&transport.endpoint.api_format, &body_json) } @@ -372,6 +390,19 @@ async fn fetch_standard_models_for_transport( .with_upstream_status(upstream_status.finish())) } +fn parse_codex_models_response_for_request( + endpoint_api_format: &str, + body: &Value, + codex_client_version: Option<&str>, +) -> Result { + if codex_client_version.is_none() + && (body.is_array() || body.get("data").and_then(Value::as_array).is_some()) + { + return parse_models_response_page(endpoint_api_format, body); + } + parse_codex_models_response_page(body) +} + async fn fetch_antigravity_models( runtime: &(impl ModelFetchTransportRuntime + ?Sized), transport: &GatewayProviderTransportSnapshot, @@ -425,6 +456,7 @@ async fn fetch_antigravity_models( Ok(ModelsFetchOutcome { fetched_model_ids: Vec::new(), cached_models: Vec::new(), + legacy_models: Vec::new(), errors, has_success: false, upstream_metadata: None, @@ -607,6 +639,7 @@ async fn fetch_vertex_api_key_models( return Ok(ModelsFetchOutcome { fetched_model_ids: Vec::new(), cached_models: Vec::new(), + legacy_models: Vec::new(), errors: vec!["vertex_ai(api_key): missing api key".to_string()], has_success: false, upstream_metadata: None, @@ -666,6 +699,7 @@ async fn fetch_vertex_api_key_models( Ok(ModelsFetchOutcome { fetched_model_ids: Vec::new(), cached_models: Vec::new(), + legacy_models: Vec::new(), errors, has_success, upstream_metadata: None, @@ -683,6 +717,7 @@ async fn fetch_vertex_service_account_models( return Ok(ModelsFetchOutcome { fetched_model_ids: Vec::new(), cached_models: Vec::new(), + legacy_models: Vec::new(), errors: vec!["vertex_ai(service_account): missing auth_config".to_string()], has_success: false, upstream_metadata: None, @@ -753,6 +788,7 @@ async fn fetch_vertex_service_account_models( Ok(ModelsFetchOutcome { fetched_model_ids: Vec::new(), cached_models: Vec::new(), + legacy_models: Vec::new(), errors, has_success, upstream_metadata: None, @@ -1367,9 +1403,11 @@ fn build_success_outcome( upstream_metadata: Option, has_success: bool, ) -> ModelsFetchOutcome { + let legacy_models = cached_models.clone(); ModelsFetchOutcome { fetched_model_ids: collect_model_ids(&cached_models), cached_models, + legacy_models, errors: Vec::new(), has_success, upstream_metadata, @@ -1580,7 +1618,8 @@ mod tests { use super::{ build_vertex_google_list_url, build_vertex_service_account_list_url, - select_model_fetch_strategy, ModelFetchStrategy, ModelFetchStrategyKind, + parse_codex_models_response_for_request, select_model_fetch_strategy, ModelFetchStrategy, + ModelFetchStrategyKind, }; use crate::transport::ModelFetchTransportRuntime; use crate::{fetch_models_from_transports, fetch_models_from_transports_for_client_version}; @@ -1929,6 +1968,30 @@ mod tests { assert_eq!(strategy.kind(), ModelFetchStrategyKind::StandardTransport); } + #[test] + fn unversioned_codex_parser_accepts_top_level_openai_compatible_array() { + let parsed = parse_codex_models_response_for_request( + "openai:responses", + &json!([{"id": "gpt-array-compatible"}]), + None, + ) + .expect("unversioned admin fetch should retain the top-level array fallback"); + + assert_eq!(parsed.fetched_model_ids, vec!["gpt-array-compatible"]); + assert_eq!( + parsed.cached_models[0]["api_formats"], + json!(["openai:responses"]) + ); + + let error = parse_codex_models_response_for_request( + "openai:responses", + &json!([{"id": "gpt-array-compatible"}]), + Some("0.145.2"), + ) + .expect_err("versioned catalogs must use the opaque models-array schema"); + assert!(error.contains("missing models array")); + } + #[test] fn strategy_selection_uses_preset_catalog_for_claude_code() { let mut transport = sample_custom_aiplatform_transport(); @@ -2044,6 +2107,7 @@ mod tests { vec!["responses-only", "shared-model"] ); assert_eq!(outcome.cached_models.len(), 2); + assert_eq!(outcome.legacy_models, outcome.cached_models); assert_eq!(outcome.errors.len(), 1); assert!(outcome.errors[0].contains("connection reset")); let shared_model = outcome @@ -2209,6 +2273,16 @@ mod tests { "opaque-future-field" ); assert!(outcome.cached_models[0].get("api_formats").is_none()); + assert_eq!(outcome.legacy_models.len(), 1); + assert_eq!(outcome.legacy_models[0]["id"], "gpt-5.6-future"); + assert_eq!( + outcome.legacy_models[0]["api_formats"], + json!(["openai:responses"]) + ); + assert_eq!( + outcome.legacy_models[0]["api_format"], + "opaque-future-field" + ); let card = &outcome .upstream_metadata .as_ref() @@ -2243,6 +2317,130 @@ mod tests { assert_eq!(outcome.fetched_model_ids, vec!["gpt-slug-only-future"]); assert_eq!(outcome.cached_models, vec![card]); assert!(outcome.cached_models[0].get("id").is_none()); + assert_eq!(outcome.legacy_models[0]["id"], "gpt-slug-only-future"); + assert_eq!( + outcome.legacy_models[0]["api_formats"], + json!(["openai:responses"]) + ); + assert_eq!( + outcome.legacy_models[0]["future_capability"]["opaque"], + true + ); + } + + #[tokio::test] + async fn codex_legacy_projection_merges_only_formats_from_successful_transports() { + let executed_urls = Arc::new(Mutex::new(Vec::new())); + let card = json!({ + "slug": "gpt-multi-format-future", + "api_format": "opaque-upstream-protocol", + "future_capability": {"opaque": true} + }); + let runtime = RoutingTestRuntime { + executed_urls, + routes: vec![ + ( + "chat.example.com/backend-api/codex/models".to_string(), + Ok((200, json!({"models": [card.clone()]}))), + ), + ( + "responses.example.com/backend-api/codex/models".to_string(), + Ok((200, json!({"models": [card.clone()]}))), + ), + ( + "compact.example.com/backend-api/codex/models".to_string(), + Err("compact endpoint unavailable".to_string()), + ), + ], + }; + let mut chat = sample_codex_transport_for_base( + "endpoint-chat", + "https://chat.example.com/backend-api/codex", + ); + chat.endpoint.api_format = "openai:chat".to_string(); + let responses = sample_codex_transport_for_base( + "endpoint-responses", + "https://responses.example.com/backend-api/codex", + ); + let mut compact = sample_codex_transport_for_base( + "endpoint-compact", + "https://compact.example.com/backend-api/codex", + ); + compact.endpoint.api_format = "openai:responses:compact".to_string(); + + let outcome = fetch_models_from_transports_for_client_version( + &runtime, + &[chat, responses, compact], + Some("0.145.2"), + ) + .await + .expect("successful endpoint catalogs should survive a sibling failure"); + + assert_eq!(outcome.cached_models, vec![card]); + assert_eq!(outcome.legacy_models.len(), 1); + assert_eq!( + outcome.legacy_models[0]["api_formats"], + json!(["openai:chat", "openai:responses"]) + ); + assert_eq!( + outcome.legacy_models[0]["api_format"], + "opaque-upstream-protocol" + ); + assert_eq!(outcome.errors.len(), 1); + assert!(outcome.errors[0].contains("compact endpoint unavailable")); + } + + #[tokio::test] + async fn unversioned_codex_admin_fetch_keeps_openai_compatible_data_fallback() { + let executed_urls = Arc::new(Mutex::new(Vec::new())); + let runtime = TestRuntime { + executed_urls, + response_body: json!({ + "data": [{ + "id": "gpt-legacy-compatible", + "future_capability": {"preserved": true} + }] + }), + status_code: 200, + response_headers: BTreeMap::new(), + }; + + let outcome = fetch_models_from_transports(&runtime, &[sample_codex_transport()]) + .await + .expect("unversioned admin fetch should retain the generic parser fallback"); + + assert!(outcome.has_success); + assert_eq!(outcome.fetched_model_ids, vec!["gpt-legacy-compatible"]); + assert_eq!(outcome.cached_models[0]["id"], "gpt-legacy-compatible"); + assert_eq!( + outcome.legacy_models[0]["api_formats"], + json!(["openai:responses"]) + ); + } + + #[tokio::test] + async fn versioned_codex_catalog_does_not_accept_openai_compatible_data_fallback() { + let executed_urls = Arc::new(Mutex::new(Vec::new())); + let runtime = TestRuntime { + executed_urls, + response_body: json!({"data": [{"id": "gpt-not-an-opaque-card"}]}), + status_code: 200, + response_headers: BTreeMap::new(), + }; + + let outcome = fetch_models_from_transports_for_client_version( + &runtime, + &[sample_codex_transport()], + Some("0.145.2"), + ) + .await + .expect("transport failures are returned as observable outcomes"); + + assert!(!outcome.has_success); + assert!(outcome.cached_models.is_empty()); + assert!(outcome.legacy_models.is_empty()); + assert_eq!(outcome.errors.len(), 1); + assert!(outcome.errors[0].contains("missing models array")); } #[tokio::test] diff --git a/crates/aether-model-fetch/src/transport.rs b/crates/aether-model-fetch/src/transport.rs index e49a5f343..7527ac750 100644 --- a/crates/aether-model-fetch/src/transport.rs +++ b/crates/aether-model-fetch/src/transport.rs @@ -973,8 +973,9 @@ mod tests { let mut transport = sample_transport("codex", "openai:chat", "oauth"); transport.endpoint.base_url = "https://chatgpt.com/backend-api/codex".to_string(); transport.endpoint.header_rules = Some(json!([ - {"op": "set", "name": "chatgpt-account-id", "value": "spoofed-account"}, - {"op": "set", "name": "x-openai-fedramp", "value": "false"} + {"action": "set", "key": "authorization", "value": "Bearer spoofed-token"}, + {"action": "set", "key": "chatgpt-account-id", "value": "spoofed-account"}, + {"action": "set", "key": "x-openai-fedramp", "value": "false"} ])); transport.key.decrypted_auth_config = Some(r#"{"account_id":"account-1","chatgpt_account_is_fedramp":true}"#.to_string()); @@ -1028,8 +1029,8 @@ mod tests { let mut transport = sample_transport("codex", "openai:responses", "oauth"); transport.endpoint.base_url = "https://chatgpt.com/backend-api/codex".to_string(); transport.endpoint.header_rules = Some(json!([ - {"op": "set", "name": "user-agent", "value": "codex_cli_rs/0.1.0"}, - {"op": "remove", "name": "originator"} + {"action": "set", "key": "user-agent", "value": "custom-codex-client/0.1.0"}, + {"action": "drop", "key": "originator"} ])); transport.key.decrypted_auth_config = Some(r#"{"account_id":"account-1","chatgpt_account_is_fedramp":true}"#.to_string()); @@ -1054,10 +1055,6 @@ mod tests { plan.headers.get("originator").map(String::as_str), Some("codex_cli_rs") ); - assert_eq!( - plan.headers.get("originator").map(String::as_str), - Some("codex_cli_rs") - ); assert_eq!( plan.headers.get("chatgpt-account-id").map(String::as_str), Some("account-1")