mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-08 12:10:19 +08:00
fix(codex): serve versioned dynamic model catalogs
This commit is contained in:
Generated
+2
@@ -325,6 +325,7 @@ dependencies = [
|
||||
"axum",
|
||||
"base64 0.22.1",
|
||||
"bcrypt",
|
||||
"brotli",
|
||||
"bytes",
|
||||
"chrono",
|
||||
"chrono-tz",
|
||||
@@ -346,6 +347,7 @@ dependencies = [
|
||||
"reqwest",
|
||||
"rsa",
|
||||
"rustls 0.23.37",
|
||||
"semver",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha1",
|
||||
|
||||
@@ -106,6 +106,7 @@ async-trait = "0.1"
|
||||
axum = "0.8"
|
||||
base64 = "0.22"
|
||||
bcrypt = "0.16"
|
||||
brotli = "8"
|
||||
bytes = "1"
|
||||
cbc = "0.1"
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
|
||||
@@ -52,6 +52,7 @@ async-trait.workspace = true
|
||||
axum = { version = "0.8", features = ["ws"] }
|
||||
base64.workspace = true
|
||||
bcrypt.workspace = true
|
||||
brotli.workspace = true
|
||||
bytes.workspace = true
|
||||
chrono.workspace = true
|
||||
chrono-tz.workspace = true
|
||||
@@ -75,6 +76,7 @@ rsa = "0.9.10"
|
||||
rustls.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
semver.workspace = true
|
||||
sha1 = "0.10"
|
||||
sha2 = { workspace = true, features = ["oid"] }
|
||||
socket2.workspace = true
|
||||
|
||||
@@ -339,7 +339,7 @@ fn apply_codex_oauth_fingerprint_convergence_to_decision(
|
||||
else {
|
||||
return;
|
||||
};
|
||||
crate::provider_transport::apply_codex_oauth_fingerprint_convergence(
|
||||
crate::ai_serving::transport::apply_codex_oauth_fingerprint_convergence(
|
||||
transport,
|
||||
provider_api_format,
|
||||
input.original_client_session_id.as_deref(),
|
||||
|
||||
@@ -82,10 +82,10 @@ pub(crate) use aether_ai_formats::api::{
|
||||
parse_codex_auth_identity, parse_direct_request_body, parse_model_directive,
|
||||
parse_model_directive_with_suffixes, parse_openai_stop_sequences,
|
||||
parse_openai_tool_result_content, prepare_local_success_response_parts,
|
||||
prepare_local_success_response_parts_owned, project_codex_openai_image_api_request_body,
|
||||
project_openai_image_api_request_body, provider_adaptation_allows_sync_finalize_envelope,
|
||||
provider_adaptation_anchor_api_format, provider_adaptation_descriptor_for_envelope,
|
||||
provider_adaptation_descriptor_for_provider_type,
|
||||
prepare_local_success_response_parts_owned, project_codex_catalog_model_card,
|
||||
project_codex_openai_image_api_request_body, project_openai_image_api_request_body,
|
||||
provider_adaptation_allows_sync_finalize_envelope, provider_adaptation_anchor_api_format,
|
||||
provider_adaptation_descriptor_for_envelope, provider_adaptation_descriptor_for_provider_type,
|
||||
provider_adaptation_requires_eventstream_accept,
|
||||
provider_adaptation_should_unwrap_stream_envelope,
|
||||
provider_private_response_allows_sync_finalize, record_converted_response_history,
|
||||
@@ -175,6 +175,7 @@ pub(crate) use aether_ai_formats::{
|
||||
is_rerank_api_format, openai_responses_request_operation,
|
||||
openai_responses_synthetic_reasoning_item_id,
|
||||
strip_incompatible_openai_responses_reasoning_items, ApiOperation, ClientSurface,
|
||||
CODEX_CLIENT_VERSION,
|
||||
};
|
||||
|
||||
pub(crate) fn plan_kind_matches_api_operation(
|
||||
|
||||
@@ -59,8 +59,9 @@ pub(crate) mod windsurf {
|
||||
}
|
||||
|
||||
pub(crate) use aether_provider_transport::{
|
||||
append_transport_diagnostics_to_value, apply_local_auth_config_header_overrides,
|
||||
apply_local_body_rules, apply_local_body_rules_with_request_headers, apply_local_header_rules,
|
||||
append_transport_diagnostics_to_value, apply_codex_oauth_fingerprint_convergence,
|
||||
apply_local_auth_config_header_overrides, apply_local_body_rules,
|
||||
apply_local_body_rules_with_request_headers, apply_local_header_rules,
|
||||
apply_local_header_rules_with_request_headers, apply_standard_provider_request_body_rules,
|
||||
apply_standard_provider_request_body_rules_with_request_headers,
|
||||
apply_transport_request_body_semantics, body_rules_are_locally_supported,
|
||||
|
||||
@@ -85,29 +85,17 @@ async fn read_last_backup_slot(app: &AppState) -> Result<Option<String>, Gateway
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::backup::schedule::{BackupSchedule, BackupScheduleUnit};
|
||||
use crate::task_runtime::{task_definition, TASK_KEY_SYSTEM_S3_BACKUP};
|
||||
|
||||
#[test]
|
||||
fn backup_worker_skips_already_recorded_slot() {
|
||||
let schedule = BackupSchedule {
|
||||
unit: BackupScheduleUnit::Days,
|
||||
interval: 1,
|
||||
minute: 0,
|
||||
hour: 3,
|
||||
weekday: 1,
|
||||
month_day: 1,
|
||||
};
|
||||
let now = chrono::DateTime::parse_from_rfc3339("2026-05-24T03:00:30+08:00")
|
||||
.unwrap()
|
||||
.with_timezone(&chrono::Utc);
|
||||
let slot = schedule.due_slot(now).expect("slot should be due");
|
||||
let slot = "days:2026-05-23T19:00:00Z";
|
||||
|
||||
assert!(super::should_start_scheduled_backup(
|
||||
Some("days:2026-05-22T19:00:00Z"),
|
||||
&slot
|
||||
slot
|
||||
));
|
||||
assert!(!super::should_start_scheduled_backup(Some(&slot), &slot));
|
||||
assert!(!super::should_start_scheduled_backup(Some(slot), slot));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
-10
@@ -1274,7 +1274,6 @@ mod tests {
|
||||
let first_cache = Arc::clone(&cache);
|
||||
let first_key = key.clone();
|
||||
let first_calls = Arc::clone(&calls);
|
||||
let first_started = Instant::now();
|
||||
let first = tokio::spawn(async move {
|
||||
first_cache
|
||||
.get_or_load_once_stale_while_refreshing::<(), _, _>(
|
||||
@@ -1292,7 +1291,6 @@ mod tests {
|
||||
});
|
||||
|
||||
let follower_cache = Arc::clone(&cache);
|
||||
let follower_started = Instant::now();
|
||||
let follower_calls = Arc::clone(&calls);
|
||||
let follower = tokio::spawn(async move {
|
||||
follower_cache
|
||||
@@ -1310,15 +1308,7 @@ mod tests {
|
||||
});
|
||||
|
||||
assert_eq!(first.await.unwrap().unwrap(), Some(1));
|
||||
assert!(
|
||||
first_started.elapsed() < Duration::from_millis(80),
|
||||
"stale value should not wait for request-path refresh"
|
||||
);
|
||||
assert_eq!(follower.await.unwrap().unwrap(), Some(1));
|
||||
assert!(
|
||||
follower_started.elapsed() < Duration::from_millis(80),
|
||||
"follower should return stale value without waiting for refresh"
|
||||
);
|
||||
assert_eq!(calls.load(Ordering::Acquire), 0);
|
||||
}
|
||||
|
||||
|
||||
@@ -247,8 +247,7 @@ pub(super) fn detect_public_models_auth_signature(uri: &Uri, headers: &http::Hea
|
||||
|
||||
let has_codex_client_version = uri.path() == "/v1/models"
|
||||
&& uri.query().is_some_and(|query| {
|
||||
url::form_urlencoded::parse(query.as_bytes())
|
||||
.any(|(key, value)| key == "client_version" && !value.trim().is_empty())
|
||||
url::form_urlencoded::parse(query.as_bytes()).any(|(key, _)| key == "client_version")
|
||||
});
|
||||
if has_codex_client_version {
|
||||
return "openai:responses".to_string();
|
||||
|
||||
@@ -39,7 +39,7 @@ fn classifies_codex_models_list_with_responses_auth_signature() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_codex_client_version_keeps_standard_openai_models_signature() {
|
||||
fn empty_codex_client_version_uses_responses_signature_for_bounded_fallback() {
|
||||
let headers = headers(&[("authorization", "Bearer sk-test")]);
|
||||
let uri: Uri = "/v1/models?client_version="
|
||||
.parse()
|
||||
@@ -49,7 +49,7 @@ fn empty_codex_client_version_keeps_standard_openai_models_signature() {
|
||||
|
||||
assert_eq!(
|
||||
decision.auth_endpoint_signature.as_deref(),
|
||||
Some("openai:chat")
|
||||
Some("openai:responses")
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -282,6 +282,16 @@ impl GatewayDataState {
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn list_provider_catalog_keys_by_ids_strong(
|
||||
&self,
|
||||
key_ids: &[String],
|
||||
) -> Result<Vec<StoredProviderCatalogKey>, DataLayerError> {
|
||||
match &self.provider_catalog_reader {
|
||||
Some(repository) => repository.list_keys_by_ids_strong(key_ids).await,
|
||||
None => Ok(Vec::new()),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn list_provider_catalog_keys_by_provider_ids(
|
||||
&self,
|
||||
provider_ids: &[String],
|
||||
|
||||
@@ -334,6 +334,13 @@ impl ProviderCatalogReadRepository for CachedProviderCatalogReadRepository {
|
||||
}
|
||||
}
|
||||
|
||||
async fn list_keys_by_ids_strong(
|
||||
&self,
|
||||
key_ids: &[String],
|
||||
) -> Result<Vec<StoredProviderCatalogKey>, DataLayerError> {
|
||||
self.inner.list_keys_by_ids_strong(key_ids).await
|
||||
}
|
||||
|
||||
async fn list_keys_by_provider_ids(
|
||||
&self,
|
||||
provider_ids: &[String],
|
||||
@@ -538,6 +545,7 @@ fn normalize_ids(ids: &[String]) -> Vec<String> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
use aether_data::repository::provider_catalog::InMemoryProviderCatalogReadRepository;
|
||||
use aether_data_contracts::repository::provider_catalog::ProviderCatalogWriteRepository;
|
||||
|
||||
fn cache() -> CachedProviderCatalogReadRepository {
|
||||
CachedProviderCatalogReadRepository::new(Arc::new(
|
||||
@@ -555,6 +563,55 @@ mod tests {
|
||||
.expect("provider should be valid")
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn provider_catalog_strong_key_read_bypasses_fresh_cached_generation() {
|
||||
let old_metadata = serde_json::json!({
|
||||
"codex": {"credential_generation": "old"}
|
||||
});
|
||||
let mut key = StoredProviderCatalogKey::new(
|
||||
"key-1".to_string(),
|
||||
"provider-1".to_string(),
|
||||
"key-1".to_string(),
|
||||
"oauth".to_string(),
|
||||
None,
|
||||
true,
|
||||
)
|
||||
.expect("key should be valid");
|
||||
key.upstream_metadata = Some(old_metadata.clone());
|
||||
let inner = Arc::new(InMemoryProviderCatalogReadRepository::seed(
|
||||
vec![provider("provider-1")],
|
||||
Vec::new(),
|
||||
vec![key],
|
||||
));
|
||||
let cache = CachedProviderCatalogReadRepository::new(inner.clone());
|
||||
let key_ids = vec!["key-1".to_string()];
|
||||
|
||||
let first = cache
|
||||
.list_keys_by_ids(&key_ids)
|
||||
.await
|
||||
.expect("initial key read should succeed");
|
||||
assert_eq!(first[0].upstream_metadata.as_ref(), Some(&old_metadata));
|
||||
|
||||
let new_metadata = serde_json::json!({
|
||||
"codex": {"credential_generation": "new"}
|
||||
});
|
||||
assert!(inner
|
||||
.upsert_key_upstream_metadata_namespace("key-1", "codex", &new_metadata["codex"], None,)
|
||||
.await
|
||||
.expect("inner metadata update should succeed"));
|
||||
|
||||
let cached = cache
|
||||
.list_keys_by_ids(&key_ids)
|
||||
.await
|
||||
.expect("cached key read should succeed");
|
||||
assert_eq!(cached[0].upstream_metadata.as_ref(), Some(&old_metadata));
|
||||
let strong = cache
|
||||
.list_keys_by_ids_strong(&key_ids)
|
||||
.await
|
||||
.expect("strong key read should succeed");
|
||||
assert_eq!(strong[0].upstream_metadata.as_ref(), Some(&new_metadata));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn provider_catalog_follower_observes_completion_before_first_poll() {
|
||||
let cache = cache();
|
||||
|
||||
@@ -55,8 +55,9 @@ use crate::execution_runtime::submission::{
|
||||
resolve_local_sync_error_status_code, submit_local_core_error_or_sync_finalize,
|
||||
};
|
||||
use crate::execution_runtime::transport::{
|
||||
append_upstream_response_body_chunk, build_execution_response_body, build_request_body,
|
||||
collect_response_headers, decode_response_body_bytes, execution_response_body_mode,
|
||||
append_upstream_response_body_chunk_with_limit, build_execution_response_body,
|
||||
build_request_body, collect_response_headers, decode_response_body_bytes_with_limit,
|
||||
execution_plan_response_body_limit_bytes, execution_response_body_mode,
|
||||
format_hyper_error_chain, format_upstream_request_error, format_wreq_upstream_request_error,
|
||||
response_body_is_json, send_request, DirectHttpResponse, DirectSyncExecutionRuntime,
|
||||
ExecutionRuntimeTransportError,
|
||||
@@ -1491,6 +1492,7 @@ async fn execute_openai_image_sync_upstream_sse_candidate(
|
||||
progress_snapshot: Option<Arc<Mutex<OpenAiImageSyncProgressSnapshot>>>,
|
||||
) -> Result<ExecutionResult, SyncExecutionFailure> {
|
||||
let request_body = build_request_body(plan).map_err(SyncExecutionFailure::from_transport)?;
|
||||
let response_body_limit_bytes = execution_plan_response_body_limit_bytes(plan);
|
||||
let started_at = Instant::now();
|
||||
let mut progress =
|
||||
OpenAiImageSyncProgressRecorder::new(state, plan, report_context, progress_snapshot);
|
||||
@@ -1529,8 +1531,12 @@ async fn execute_openai_image_sync_upstream_sse_candidate(
|
||||
),
|
||||
)
|
||||
})?;
|
||||
append_upstream_response_body_chunk(&mut body_bytes, &chunk)
|
||||
.map_err(SyncExecutionFailure::from_transport)?;
|
||||
append_upstream_response_body_chunk_with_limit(
|
||||
&mut body_bytes,
|
||||
&chunk,
|
||||
response_body_limit_bytes,
|
||||
)
|
||||
.map_err(SyncExecutionFailure::from_transport)?;
|
||||
let elapsed_ms = started_at.elapsed().as_millis() as u64;
|
||||
progress
|
||||
.observe_chunk(&chunk, status_code, elapsed_ms)
|
||||
@@ -1547,8 +1553,12 @@ async fn execute_openai_image_sync_upstream_sse_candidate(
|
||||
)),
|
||||
)
|
||||
})?;
|
||||
append_upstream_response_body_chunk(&mut body_bytes, &chunk)
|
||||
.map_err(SyncExecutionFailure::from_transport)?;
|
||||
append_upstream_response_body_chunk_with_limit(
|
||||
&mut body_bytes,
|
||||
&chunk,
|
||||
response_body_limit_bytes,
|
||||
)
|
||||
.map_err(SyncExecutionFailure::from_transport)?;
|
||||
let elapsed_ms = started_at.elapsed().as_millis() as u64;
|
||||
progress
|
||||
.observe_chunk(&chunk, status_code, elapsed_ms)
|
||||
@@ -1565,8 +1575,12 @@ async fn execute_openai_image_sync_upstream_sse_candidate(
|
||||
),
|
||||
)
|
||||
})?;
|
||||
append_upstream_response_body_chunk(&mut body_bytes, &chunk)
|
||||
.map_err(SyncExecutionFailure::from_transport)?;
|
||||
append_upstream_response_body_chunk_with_limit(
|
||||
&mut body_bytes,
|
||||
&chunk,
|
||||
response_body_limit_bytes,
|
||||
)
|
||||
.map_err(SyncExecutionFailure::from_transport)?;
|
||||
let elapsed_ms = started_at.elapsed().as_millis() as u64;
|
||||
progress
|
||||
.observe_chunk(&chunk, status_code, elapsed_ms)
|
||||
@@ -1575,8 +1589,9 @@ async fn execute_openai_image_sync_upstream_sse_candidate(
|
||||
}
|
||||
}
|
||||
|
||||
let decoded_body_bytes = decode_response_body_bytes(&headers, &body_bytes)
|
||||
.map_err(SyncExecutionFailure::from_transport)?;
|
||||
let decoded_body_bytes =
|
||||
decode_response_body_bytes_with_limit(&headers, &body_bytes, response_body_limit_bytes)
|
||||
.map_err(SyncExecutionFailure::from_transport)?;
|
||||
let elapsed_ms = started_at.elapsed().as_millis() as u64;
|
||||
let upstream_bytes = body_bytes.len() as u64;
|
||||
progress.finish(status_code, elapsed_ms).await;
|
||||
|
||||
@@ -21,6 +21,7 @@ use aether_http::{apply_http_client_config, HttpClientConfig};
|
||||
use aether_runtime::{MetricKind, MetricSample};
|
||||
use axum::body::Bytes;
|
||||
use base64::Engine as _;
|
||||
use brotli::Decompressor as BrotliDecoder;
|
||||
use flate2::read::{DeflateDecoder, GzDecoder};
|
||||
use flate2::write::GzEncoder;
|
||||
use flate2::Compression;
|
||||
@@ -62,6 +63,10 @@ const DEFAULT_STREAM_FIRST_BYTE_TIMEOUT_MS: u64 = 30_000;
|
||||
const DEFAULT_NON_STREAM_TOTAL_TIMEOUT_MS: u64 = 300_000;
|
||||
const DEFAULT_CODEX_COMPACT_TOTAL_TIMEOUT_MS: u64 = 1_200_000;
|
||||
const MIN_TUNNEL_TIMEOUT_SECS: u64 = 1;
|
||||
const EXECUTION_RESPONSE_BODY_LIMIT_HEADER: &str = "x-aether-execution-response-body-limit-bytes";
|
||||
const DEFAULT_SCOPED_RESPONSE_BODY_LIMIT_BYTES: usize = 8 * 1024 * 1024;
|
||||
const MIN_SCOPED_RESPONSE_BODY_LIMIT_BYTES: usize = 64 * 1024;
|
||||
const MAX_SCOPED_RESPONSE_BODY_LIMIT_BYTES: usize = 64 * 1024 * 1024;
|
||||
const DIRECT_REQWEST_H2_CLIENT_SHARDS_ENV: &str = "AETHER_GATEWAY_DIRECT_REQWEST_H2_CLIENT_SHARDS";
|
||||
const DIRECT_REQWEST_CLIENT_SHARDS_ENV: &str = "AETHER_GATEWAY_DIRECT_REQWEST_CLIENT_SHARDS";
|
||||
const DIRECT_REQWEST_H2_TARGET_STREAMS_PER_CLIENT_ENV: &str =
|
||||
@@ -607,6 +612,56 @@ impl std::fmt::Display for UpstreamResponseBodyPhase {
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn with_upstream_response_body_limit(
|
||||
plan: &ExecutionPlan,
|
||||
limit_bytes: usize,
|
||||
) -> ExecutionPlan {
|
||||
let mut bounded_plan = plan.clone();
|
||||
bounded_plan
|
||||
.headers
|
||||
.retain(|name, _| !name.eq_ignore_ascii_case(EXECUTION_RESPONSE_BODY_LIMIT_HEADER));
|
||||
bounded_plan.headers.insert(
|
||||
EXECUTION_RESPONSE_BODY_LIMIT_HEADER.to_string(),
|
||||
normalize_scoped_response_body_limit(limit_bytes)
|
||||
.unwrap_or(DEFAULT_SCOPED_RESPONSE_BODY_LIMIT_BYTES)
|
||||
.to_string(),
|
||||
);
|
||||
bounded_plan
|
||||
}
|
||||
|
||||
pub(crate) fn execution_plan_response_body_limit_bytes(plan: &ExecutionPlan) -> usize {
|
||||
effective_response_body_limit_bytes(
|
||||
execution_transport_header_value(&plan.headers, EXECUTION_RESPONSE_BODY_LIMIT_HEADER),
|
||||
crate::headers::max_internal_buffered_body_bytes(),
|
||||
)
|
||||
}
|
||||
|
||||
fn effective_response_body_limit_bytes(
|
||||
raw_scoped_limit: Option<&str>,
|
||||
global_limit: usize,
|
||||
) -> usize {
|
||||
let Some(raw_scoped_limit) = raw_scoped_limit else {
|
||||
return global_limit;
|
||||
};
|
||||
parse_scoped_response_body_limit(raw_scoped_limit)
|
||||
.unwrap_or(DEFAULT_SCOPED_RESPONSE_BODY_LIMIT_BYTES)
|
||||
.min(global_limit)
|
||||
}
|
||||
|
||||
fn parse_scoped_response_body_limit(value: &str) -> Option<usize> {
|
||||
let raw_limit = value.trim().parse::<u64>().ok()?;
|
||||
usize::try_from(raw_limit)
|
||||
.ok()
|
||||
.and_then(normalize_scoped_response_body_limit)
|
||||
}
|
||||
|
||||
fn normalize_scoped_response_body_limit(limit_bytes: usize) -> Option<usize> {
|
||||
(limit_bytes > 0).then_some(limit_bytes.clamp(
|
||||
MIN_SCOPED_RESPONSE_BODY_LIMIT_BYTES,
|
||||
MAX_SCOPED_RESPONSE_BODY_LIMIT_BYTES,
|
||||
))
|
||||
}
|
||||
|
||||
pub(crate) fn append_upstream_response_body_chunk(
|
||||
body: &mut Vec<u8>,
|
||||
chunk: &[u8],
|
||||
@@ -618,7 +673,7 @@ pub(crate) fn append_upstream_response_body_chunk(
|
||||
)
|
||||
}
|
||||
|
||||
fn append_upstream_response_body_chunk_with_limit(
|
||||
pub(crate) fn append_upstream_response_body_chunk_with_limit(
|
||||
body: &mut Vec<u8>,
|
||||
chunk: &[u8],
|
||||
limit_bytes: usize,
|
||||
@@ -724,6 +779,7 @@ impl DirectSyncExecutionRuntime {
|
||||
F: FnOnce(DirectSyncResponseStarted),
|
||||
{
|
||||
let body_bytes = build_request_body(plan)?;
|
||||
let response_body_limit_bytes = execution_plan_response_body_limit_bytes(plan);
|
||||
|
||||
let started_at = Instant::now();
|
||||
let request_started_at_unix_ms = crate::clock::current_unix_ms();
|
||||
@@ -744,9 +800,14 @@ impl DirectSyncExecutionRuntime {
|
||||
ttfb_ms,
|
||||
response_observation: response_observation.clone(),
|
||||
});
|
||||
let (body_bytes, stream_ttfb_ms) =
|
||||
response.bytes_with_stream_timeout(plan, started_at).await?;
|
||||
let decoded_body_bytes = decode_response_body_bytes(&headers, &body_bytes)?;
|
||||
let (body_bytes, stream_ttfb_ms) = response
|
||||
.bytes_with_stream_timeout(plan, started_at, response_body_limit_bytes)
|
||||
.await?;
|
||||
let decoded_body_bytes = decode_response_body_bytes_with_limit(
|
||||
&headers,
|
||||
&body_bytes,
|
||||
response_body_limit_bytes,
|
||||
)?;
|
||||
let elapsed_ms = started_at.elapsed().as_millis() as u64;
|
||||
let upstream_bytes = body_bytes.len() as u64;
|
||||
|
||||
@@ -1058,6 +1119,7 @@ async fn execute_sync_plan_via_local_tunnel_inner(
|
||||
}
|
||||
|
||||
let body_bytes = build_request_body(plan)?;
|
||||
let response_body_limit_bytes = execution_plan_response_body_limit_bytes(plan);
|
||||
let transport_controls = resolve_execution_transport_controls(&plan.headers);
|
||||
let headers = build_request_headers(
|
||||
&plan.headers,
|
||||
@@ -1113,8 +1175,10 @@ async fn execute_sync_plan_via_local_tunnel_inner(
|
||||
);
|
||||
let proxy_timing = execution_header_for_log(&headers, "x-proxy-timing").unwrap_or("-");
|
||||
let (body_bytes, stream_ttfb_ms) =
|
||||
collect_local_tunnel_response_body(response, plan, started_at).await?;
|
||||
let decoded_body_bytes = decode_response_body_bytes(&headers, &body_bytes)?;
|
||||
collect_local_tunnel_response_body(response, plan, started_at, response_body_limit_bytes)
|
||||
.await?;
|
||||
let decoded_body_bytes =
|
||||
decode_response_body_bytes_with_limit(&headers, &body_bytes, response_body_limit_bytes)?;
|
||||
let elapsed_ms = started_at.elapsed().as_millis() as u64;
|
||||
let upstream_bytes = body_bytes.len() as u64;
|
||||
if status_code >= 400 {
|
||||
@@ -1179,6 +1243,7 @@ async fn collect_local_tunnel_response_body(
|
||||
mut response: tunnel::DirectRelayResponse,
|
||||
plan: &ExecutionPlan,
|
||||
started_at: Instant,
|
||||
response_body_limit_bytes: usize,
|
||||
) -> Result<(Vec<u8>, Option<u64>), ExecutionRuntimeTransportError> {
|
||||
let mut body_bytes = Vec::new();
|
||||
let mut first_byte_ms = None;
|
||||
@@ -1201,7 +1266,11 @@ async fn collect_local_tunnel_response_body(
|
||||
if plan.stream && first_byte_ms.is_none() && !chunk.is_empty() {
|
||||
first_byte_ms = Some(started_at.elapsed().as_millis() as u64);
|
||||
}
|
||||
append_upstream_response_body_chunk(&mut body_bytes, &chunk)?;
|
||||
append_upstream_response_body_chunk_with_limit(
|
||||
&mut body_bytes,
|
||||
&chunk,
|
||||
response_body_limit_bytes,
|
||||
)?;
|
||||
}
|
||||
|
||||
Ok((body_bytes, first_byte_ms))
|
||||
@@ -1362,20 +1431,28 @@ impl DirectHttpResponse {
|
||||
}
|
||||
|
||||
pub(crate) async fn bytes(self) -> Result<Bytes, ExecutionRuntimeTransportError> {
|
||||
self.bytes_with_limit(crate::headers::max_internal_buffered_body_bytes())
|
||||
.await
|
||||
}
|
||||
|
||||
async fn bytes_with_limit(
|
||||
self,
|
||||
response_body_limit_bytes: usize,
|
||||
) -> Result<Bytes, ExecutionRuntimeTransportError> {
|
||||
let started_at = Instant::now();
|
||||
match self {
|
||||
DirectHttpResponse::Reqwest(response) => {
|
||||
collect_reqwest_stream_body(response, started_at, None)
|
||||
collect_reqwest_stream_body(response, started_at, None, response_body_limit_bytes)
|
||||
.await
|
||||
.map(|(body, _)| body)
|
||||
}
|
||||
DirectHttpResponse::HyperH2c(response) => {
|
||||
collect_hyper_stream_body(response, started_at, None)
|
||||
collect_hyper_stream_body(response, started_at, None, response_body_limit_bytes)
|
||||
.await
|
||||
.map(|(body, _)| body)
|
||||
}
|
||||
DirectHttpResponse::BrowserWreq(response) => {
|
||||
collect_wreq_stream_body(response, started_at, None)
|
||||
collect_wreq_stream_body(response, started_at, None, response_body_limit_bytes)
|
||||
.await
|
||||
.map(|(body, _)| body)
|
||||
}
|
||||
@@ -1386,21 +1463,43 @@ impl DirectHttpResponse {
|
||||
self,
|
||||
plan: &ExecutionPlan,
|
||||
started_at: Instant,
|
||||
response_body_limit_bytes: usize,
|
||||
) -> Result<(Bytes, Option<u64>), ExecutionRuntimeTransportError> {
|
||||
if !plan.stream {
|
||||
return self.bytes().await.map(|bytes| (bytes, None));
|
||||
return self
|
||||
.bytes_with_limit(response_body_limit_bytes)
|
||||
.await
|
||||
.map(|bytes| (bytes, None));
|
||||
}
|
||||
|
||||
let first_byte_timeout = resolve_stream_first_byte_timeout(plan);
|
||||
match self {
|
||||
DirectHttpResponse::Reqwest(response) => {
|
||||
collect_reqwest_stream_body(response, started_at, first_byte_timeout).await
|
||||
collect_reqwest_stream_body(
|
||||
response,
|
||||
started_at,
|
||||
first_byte_timeout,
|
||||
response_body_limit_bytes,
|
||||
)
|
||||
.await
|
||||
}
|
||||
DirectHttpResponse::HyperH2c(response) => {
|
||||
collect_hyper_stream_body(response, started_at, first_byte_timeout).await
|
||||
collect_hyper_stream_body(
|
||||
response,
|
||||
started_at,
|
||||
first_byte_timeout,
|
||||
response_body_limit_bytes,
|
||||
)
|
||||
.await
|
||||
}
|
||||
DirectHttpResponse::BrowserWreq(response) => {
|
||||
collect_wreq_stream_body(response, started_at, first_byte_timeout).await
|
||||
collect_wreq_stream_body(
|
||||
response,
|
||||
started_at,
|
||||
first_byte_timeout,
|
||||
response_body_limit_bytes,
|
||||
)
|
||||
.await
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1446,6 +1545,7 @@ async fn collect_reqwest_stream_body(
|
||||
response: reqwest::Response,
|
||||
started_at: Instant,
|
||||
first_byte_timeout: Option<Duration>,
|
||||
response_body_limit_bytes: usize,
|
||||
) -> Result<(Bytes, Option<u64>), ExecutionRuntimeTransportError> {
|
||||
let mut stream = response.bytes_stream();
|
||||
let mut body_bytes = Vec::new();
|
||||
@@ -1466,7 +1566,11 @@ async fn collect_reqwest_stream_body(
|
||||
if first_byte_ms.is_none() && !chunk.is_empty() {
|
||||
first_byte_ms = Some(started_at.elapsed().as_millis() as u64);
|
||||
}
|
||||
append_upstream_response_body_chunk(&mut body_bytes, &chunk)?;
|
||||
append_upstream_response_body_chunk_with_limit(
|
||||
&mut body_bytes,
|
||||
&chunk,
|
||||
response_body_limit_bytes,
|
||||
)?;
|
||||
}
|
||||
|
||||
Ok((Bytes::from(body_bytes), first_byte_ms))
|
||||
@@ -1476,6 +1580,7 @@ async fn collect_hyper_stream_body(
|
||||
response: hyper::Response<HyperIncomingBody>,
|
||||
started_at: Instant,
|
||||
first_byte_timeout: Option<Duration>,
|
||||
response_body_limit_bytes: usize,
|
||||
) -> Result<(Bytes, Option<u64>), ExecutionRuntimeTransportError> {
|
||||
let mut stream = response.into_body().into_data_stream();
|
||||
let mut body_bytes = Vec::new();
|
||||
@@ -1496,7 +1601,11 @@ async fn collect_hyper_stream_body(
|
||||
if first_byte_ms.is_none() && !chunk.is_empty() {
|
||||
first_byte_ms = Some(started_at.elapsed().as_millis() as u64);
|
||||
}
|
||||
append_upstream_response_body_chunk(&mut body_bytes, &chunk)?;
|
||||
append_upstream_response_body_chunk_with_limit(
|
||||
&mut body_bytes,
|
||||
&chunk,
|
||||
response_body_limit_bytes,
|
||||
)?;
|
||||
}
|
||||
|
||||
Ok((Bytes::from(body_bytes), first_byte_ms))
|
||||
@@ -1506,6 +1615,7 @@ async fn collect_wreq_stream_body(
|
||||
response: wreq::Response,
|
||||
started_at: Instant,
|
||||
first_byte_timeout: Option<Duration>,
|
||||
response_body_limit_bytes: usize,
|
||||
) -> Result<(Bytes, Option<u64>), ExecutionRuntimeTransportError> {
|
||||
let mut stream = response.bytes_stream();
|
||||
let mut body_bytes = Vec::new();
|
||||
@@ -1526,7 +1636,11 @@ async fn collect_wreq_stream_body(
|
||||
if first_byte_ms.is_none() && !chunk.is_empty() {
|
||||
first_byte_ms = Some(started_at.elapsed().as_millis() as u64);
|
||||
}
|
||||
append_upstream_response_body_chunk(&mut body_bytes, &chunk)?;
|
||||
append_upstream_response_body_chunk_with_limit(
|
||||
&mut body_bytes,
|
||||
&chunk,
|
||||
response_body_limit_bytes,
|
||||
)?;
|
||||
}
|
||||
|
||||
Ok((Bytes::from(body_bytes), first_byte_ms))
|
||||
@@ -2378,10 +2492,31 @@ async fn send_via_tunnel_relay(
|
||||
error_kind = %kind,
|
||||
"gateway execution runtime tunnel relay returned relay error"
|
||||
);
|
||||
let message = response
|
||||
.text()
|
||||
.await
|
||||
.unwrap_or_else(|_| format!("hub relay error: {kind}"));
|
||||
let response_headers = collect_response_headers(response.headers());
|
||||
let response_body_limit_bytes = execution_plan_response_body_limit_bytes(plan);
|
||||
let (wire_body, _) =
|
||||
collect_reqwest_stream_body(response, Instant::now(), None, response_body_limit_bytes)
|
||||
.await
|
||||
.map_err(|error| {
|
||||
ExecutionRuntimeTransportError::RelayError(format!(
|
||||
"hub relay error: {kind}: bounded error body read failed: {error}"
|
||||
))
|
||||
})?;
|
||||
let decoded_body = decode_response_body_bytes_with_limit(
|
||||
&response_headers,
|
||||
&wire_body,
|
||||
response_body_limit_bytes,
|
||||
)
|
||||
.map_err(|error| {
|
||||
ExecutionRuntimeTransportError::RelayError(format!(
|
||||
"hub relay error: {kind}: bounded error body decode failed: {error}"
|
||||
))
|
||||
})?;
|
||||
let message = if decoded_body.is_empty() {
|
||||
format!("hub relay error: {kind}")
|
||||
} else {
|
||||
String::from_utf8_lossy(decoded_body.as_ref()).into_owned()
|
||||
};
|
||||
return Err(ExecutionRuntimeTransportError::RelayError(message));
|
||||
}
|
||||
|
||||
@@ -3903,6 +4038,7 @@ pub(crate) fn build_request_headers(
|
||||
|| normalized_key == EXECUTION_REQUEST_HTTP1_ONLY_HEADER
|
||||
|| normalized_key == EXECUTION_REQUEST_ACCEPT_INVALID_CERTS_HEADER
|
||||
|| normalized_key == EXECUTION_RESPONSE_BODY_MODE_HEADER
|
||||
|| normalized_key == EXECUTION_RESPONSE_BODY_LIMIT_HEADER
|
||||
{
|
||||
continue;
|
||||
}
|
||||
@@ -4051,7 +4187,7 @@ pub(crate) fn decode_response_body_bytes<'a>(
|
||||
)
|
||||
}
|
||||
|
||||
fn decode_response_body_bytes_with_limit<'a>(
|
||||
pub(crate) fn decode_response_body_bytes_with_limit<'a>(
|
||||
headers: &BTreeMap<String, String>,
|
||||
body_bytes: &'a [u8],
|
||||
limit_bytes: usize,
|
||||
@@ -4073,6 +4209,11 @@ fn decode_response_body_bytes_with_limit<'a>(
|
||||
read_upstream_response_decoder_with_limit("deflate", &mut decoder, limit_bytes)
|
||||
.map(Cow::Owned)
|
||||
}
|
||||
Some("br") => {
|
||||
let mut decoder = BrotliDecoder::new(body_bytes, 4_096);
|
||||
read_upstream_response_decoder_with_limit("br", &mut decoder, limit_bytes)
|
||||
.map(Cow::Owned)
|
||||
}
|
||||
_ => Ok(Cow::Borrowed(body_bytes)),
|
||||
}
|
||||
}
|
||||
@@ -4192,12 +4333,16 @@ mod tests {
|
||||
use super::{
|
||||
append_upstream_response_body_chunk_with_limit, build_browser_wreq_client, build_client,
|
||||
build_direct_tunnel_request_meta, build_execution_response_body, build_request_headers,
|
||||
decode_response_body_bytes_with_limit, execute_sync_plan, execution_response_body_mode,
|
||||
decode_response_body_bytes_with_limit, effective_response_body_limit_bytes,
|
||||
execute_sync_plan, execution_plan_response_body_limit_bytes, execution_response_body_mode,
|
||||
record_manual_proxy_request_failure, record_manual_proxy_request_outcome,
|
||||
record_manual_proxy_request_success, record_manual_proxy_stream_error,
|
||||
resolve_execution_transport_controls, resolve_non_stream_total_timeout,
|
||||
resolve_stream_first_byte_timeout, response_body_is_json, DirectSyncExecutionRuntime,
|
||||
resolve_stream_first_byte_timeout, response_body_is_json,
|
||||
with_upstream_response_body_limit, DirectSyncExecutionRuntime,
|
||||
ExecutionRuntimeTransportError, ExecutionTransportControls, UpstreamResponseBodyPhase,
|
||||
DEFAULT_SCOPED_RESPONSE_BODY_LIMIT_BYTES, EXECUTION_RESPONSE_BODY_LIMIT_HEADER,
|
||||
MAX_SCOPED_RESPONSE_BODY_LIMIT_BYTES, MIN_SCOPED_RESPONSE_BODY_LIMIT_BYTES,
|
||||
};
|
||||
use crate::constants::{
|
||||
EXECUTION_RUNTIME_LOOP_GUARD_HEADER, EXECUTION_RUNTIME_LOOP_GUARD_VIA_TOKEN,
|
||||
@@ -4252,6 +4397,162 @@ mod tests {
|
||||
assert!(!materialized.contains_key("x-aether-future-control"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scoped_response_body_limit_injection_preserves_transport_profile_and_extra() {
|
||||
let mut plan = tunnel_timeout_plan(false);
|
||||
let original_profile = ResolvedTransportProfile {
|
||||
profile_id: "existing-profile".into(),
|
||||
backend: TRANSPORT_BACKEND_BROWSER_WREQ.into(),
|
||||
http_mode: TRANSPORT_HTTP_MODE_HTTP1_ONLY.into(),
|
||||
pool_scope: "provider".into(),
|
||||
header_fingerprint: Some(json!({"user_agent": "existing"})),
|
||||
extra: Some(json!({"existing": {"nested": true}})),
|
||||
};
|
||||
plan.transport_profile = Some(original_profile.clone());
|
||||
|
||||
let bounded_plan =
|
||||
with_upstream_response_body_limit(&plan, DEFAULT_SCOPED_RESPONSE_BODY_LIMIT_BYTES);
|
||||
|
||||
assert_eq!(plan.transport_profile, Some(original_profile.clone()));
|
||||
assert_eq!(bounded_plan.transport_profile, Some(original_profile));
|
||||
assert_eq!(
|
||||
bounded_plan
|
||||
.headers
|
||||
.get(EXECUTION_RESPONSE_BODY_LIMIT_HEADER)
|
||||
.and_then(|value| value.parse::<usize>().ok()),
|
||||
Some(DEFAULT_SCOPED_RESPONSE_BODY_LIMIT_BYTES)
|
||||
);
|
||||
assert_eq!(
|
||||
execution_plan_response_body_limit_bytes(&bounded_plan),
|
||||
DEFAULT_SCOPED_RESPONSE_BODY_LIMIT_BYTES
|
||||
);
|
||||
|
||||
let unprofiled_plan = tunnel_timeout_plan(false);
|
||||
let bounded_unprofiled_plan = with_upstream_response_body_limit(
|
||||
&unprofiled_plan,
|
||||
DEFAULT_SCOPED_RESPONSE_BODY_LIMIT_BYTES,
|
||||
);
|
||||
assert!(unprofiled_plan.transport_profile.is_none());
|
||||
assert!(bounded_unprofiled_plan.transport_profile.is_none());
|
||||
assert_eq!(
|
||||
execution_plan_response_body_limit_bytes(&bounded_unprofiled_plan),
|
||||
DEFAULT_SCOPED_RESPONSE_BODY_LIMIT_BYTES
|
||||
);
|
||||
|
||||
let mut shadowed_plan = tunnel_timeout_plan(false);
|
||||
shadowed_plan.headers.insert(
|
||||
EXECUTION_RESPONSE_BODY_LIMIT_HEADER.to_ascii_uppercase(),
|
||||
"65536".to_string(),
|
||||
);
|
||||
let bounded_shadowed_plan = with_upstream_response_body_limit(
|
||||
&shadowed_plan,
|
||||
DEFAULT_SCOPED_RESPONSE_BODY_LIMIT_BYTES,
|
||||
);
|
||||
assert_eq!(
|
||||
bounded_shadowed_plan
|
||||
.headers
|
||||
.keys()
|
||||
.filter(|name| name.eq_ignore_ascii_case(EXECUTION_RESPONSE_BODY_LIMIT_HEADER))
|
||||
.count(),
|
||||
1
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scoped_response_body_limit_parsing_rejects_invalid_values_and_clamps_bounds() {
|
||||
let scoped_plan = |raw_limit: &str| {
|
||||
let mut plan = tunnel_timeout_plan(false);
|
||||
plan.headers.insert(
|
||||
EXECUTION_RESPONSE_BODY_LIMIT_HEADER.to_string(),
|
||||
raw_limit.to_string(),
|
||||
);
|
||||
plan
|
||||
};
|
||||
|
||||
for invalid in ["0", "-1", "1.5", "", "invalid"] {
|
||||
assert_eq!(
|
||||
execution_plan_response_body_limit_bytes(&scoped_plan(invalid)),
|
||||
DEFAULT_SCOPED_RESPONSE_BODY_LIMIT_BYTES
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
execution_plan_response_body_limit_bytes(&scoped_plan("1")),
|
||||
MIN_SCOPED_RESPONSE_BODY_LIMIT_BYTES
|
||||
);
|
||||
assert_eq!(
|
||||
execution_plan_response_body_limit_bytes(&scoped_plan(
|
||||
&(MAX_SCOPED_RESPONSE_BODY_LIMIT_BYTES as u64 + 1).to_string()
|
||||
)),
|
||||
MAX_SCOPED_RESPONSE_BODY_LIMIT_BYTES
|
||||
);
|
||||
assert_eq!(
|
||||
execution_plan_response_body_limit_bytes(&scoped_plan("1048576")),
|
||||
1_048_576
|
||||
);
|
||||
assert_eq!(
|
||||
effective_response_body_limit_bytes(
|
||||
Some(&(DEFAULT_SCOPED_RESPONSE_BODY_LIMIT_BYTES * 2).to_string()),
|
||||
1024 * 1024,
|
||||
),
|
||||
1024 * 1024,
|
||||
"a scoped limit must never raise the operator's global cap"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scoped_response_body_wire_limit_rejects_overflow() {
|
||||
let bounded_plan = with_upstream_response_body_limit(
|
||||
&tunnel_timeout_plan(false),
|
||||
MIN_SCOPED_RESPONSE_BODY_LIMIT_BYTES,
|
||||
);
|
||||
let limit_bytes = execution_plan_response_body_limit_bytes(&bounded_plan);
|
||||
let mut body = vec![b'x'; limit_bytes];
|
||||
|
||||
let error =
|
||||
append_upstream_response_body_chunk_with_limit(&mut body, b"overflow", limit_bytes)
|
||||
.expect_err("wire body above the plan-scoped limit should fail");
|
||||
|
||||
assert!(matches!(
|
||||
error,
|
||||
ExecutionRuntimeTransportError::UpstreamResponseTooLarge {
|
||||
phase: UpstreamResponseBodyPhase::Wire,
|
||||
limit_bytes: MIN_SCOPED_RESPONSE_BODY_LIMIT_BYTES,
|
||||
}
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scoped_response_body_limit_rejects_gzip_bomb_after_wire_check() {
|
||||
let bounded_plan = with_upstream_response_body_limit(
|
||||
&tunnel_timeout_plan(false),
|
||||
MIN_SCOPED_RESPONSE_BODY_LIMIT_BYTES,
|
||||
);
|
||||
let limit_bytes = execution_plan_response_body_limit_bytes(&bounded_plan);
|
||||
let payload = vec![b'x'; limit_bytes + 1];
|
||||
let mut encoder = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default());
|
||||
encoder
|
||||
.write_all(&payload)
|
||||
.expect("gzip payload should encode");
|
||||
let encoded = encoder.finish().expect("gzip payload should finish");
|
||||
assert!(encoded.len() < limit_bytes);
|
||||
|
||||
let mut wire_body = Vec::new();
|
||||
append_upstream_response_body_chunk_with_limit(&mut wire_body, &encoded, limit_bytes)
|
||||
.expect("compressed wire body should fit within the plan-scoped limit");
|
||||
let headers = BTreeMap::from([("content-encoding".to_string(), "gzip".to_string())]);
|
||||
|
||||
let error = decode_response_body_bytes_with_limit(&headers, &wire_body, limit_bytes)
|
||||
.expect_err("decoded body above the plan-scoped limit should fail");
|
||||
|
||||
assert!(matches!(
|
||||
error,
|
||||
ExecutionRuntimeTransportError::UpstreamResponseTooLarge {
|
||||
phase: UpstreamResponseBodyPhase::Decoded,
|
||||
limit_bytes: MIN_SCOPED_RESPONSE_BODY_LIMIT_BYTES,
|
||||
}
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn upstream_response_wire_limit_allows_exact_body_and_rejects_next_byte() {
|
||||
let mut body = Vec::new();
|
||||
|
||||
@@ -1241,7 +1241,7 @@ async fn refresh_codex_provider_quota_locally_with_reset_fence(
|
||||
continue;
|
||||
}
|
||||
let persisted_key = state
|
||||
.read_provider_catalog_keys_by_ids(&[key.id.clone()])
|
||||
.read_provider_catalog_keys_by_ids(std::slice::from_ref(&key.id))
|
||||
.await?
|
||||
.into_iter()
|
||||
.next();
|
||||
|
||||
@@ -22,6 +22,8 @@ pub(crate) use self::system_modules_helpers::{
|
||||
serialize_public_capability, supported_capability_names, PUBLIC_CAPABILITY_DEFINITIONS,
|
||||
};
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) use self::support::filter_eligible_model_rows;
|
||||
pub(crate) use self::support::{
|
||||
build_api_key_install_session_response, build_proxy_node_install_session_response,
|
||||
build_unhandled_public_support_response, matches_model_mapping_for_models,
|
||||
|
||||
@@ -52,6 +52,8 @@ mod support_user_me;
|
||||
mod support_wallet;
|
||||
|
||||
pub(crate) use self::support_announcements::maybe_build_local_admin_announcements_response;
|
||||
#[cfg(test)]
|
||||
pub(crate) use self::support_models::filter_eligible_model_rows;
|
||||
pub(crate) use self::support_models::matches_model_mapping_for_models;
|
||||
|
||||
use self::support_announcements::{
|
||||
|
||||
@@ -10,6 +10,8 @@ mod models_route;
|
||||
mod models_shared;
|
||||
|
||||
pub(crate) use self::models_responses::build_models_auth_error_response;
|
||||
#[cfg(test)]
|
||||
pub(crate) use self::models_shared::filter_eligible_model_rows;
|
||||
pub(crate) use self::models_shared::{matches_model_mapping_for_models, models_api_format};
|
||||
|
||||
pub(super) async fn maybe_build_local_models_response(
|
||||
|
||||
@@ -102,8 +102,15 @@ pub(super) fn build_empty_models_list_response(api_format: &str) -> Response<Bod
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn build_codex_models_list_response(models: Vec<serde_json::Value>) -> Response<Body> {
|
||||
Json(json!({ "models": models })).into_response()
|
||||
pub(super) fn build_codex_models_list_response(
|
||||
models: Vec<serde_json::Value>,
|
||||
etag: Option<&str>,
|
||||
) -> Response<Body> {
|
||||
let mut response = Json(json!({ "models": models })).into_response();
|
||||
if let Some(etag) = etag.and_then(|value| http::HeaderValue::from_str(value).ok()) {
|
||||
response.headers_mut().insert(http::header::ETAG, etag);
|
||||
}
|
||||
response
|
||||
}
|
||||
|
||||
pub(super) fn build_openai_models_list_response(
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
use std::collections::BTreeSet;
|
||||
use std::fmt::Debug;
|
||||
use std::future::Future;
|
||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||
@@ -25,8 +25,18 @@ use super::{query_param_value, AppState, GatewayPublicRequestContext};
|
||||
#[cfg(not(test))]
|
||||
const MODELS_ROUTE_READ_TIMEOUT: Duration = Duration::from_secs(5);
|
||||
#[cfg(test)]
|
||||
const MODELS_ROUTE_READ_TIMEOUT: Duration = Duration::from_millis(50);
|
||||
const MODELS_ROUTE_READ_TIMEOUT: Duration = Duration::from_secs(1);
|
||||
const CODEX_MODELS_QUERY_API_FORMATS: &[&str] = &["openai:responses"];
|
||||
const CODEX_MODELS_MAX_RESPONSE_MODELS: usize = 512;
|
||||
const CODEX_MODELS_MAX_RESPONSE_JSON_BYTES: usize = 8 * 1024 * 1024;
|
||||
|
||||
fn codex_projected_catalog_fits_response_limits(cards: &[Value]) -> bool {
|
||||
if cards.len() > CODEX_MODELS_MAX_RESPONSE_MODELS {
|
||||
return false;
|
||||
}
|
||||
serde_json::to_vec(&serde_json::json!({ "models": cards }))
|
||||
.is_ok_and(|body| body.len() <= CODEX_MODELS_MAX_RESPONSE_JSON_BYTES)
|
||||
}
|
||||
|
||||
async fn await_models_route_read<T, E, Fut>(operation: &'static str, future: Fut) -> Option<T>
|
||||
where
|
||||
@@ -115,124 +125,130 @@ fn is_codex_provider_row(row: &StoredMinimalCandidateSelectionRow) -> bool {
|
||||
row.provider_type.trim().eq_ignore_ascii_case("codex")
|
||||
}
|
||||
|
||||
fn codex_model_card_is_complete(card: &serde_json::Map<String, Value>) -> bool {
|
||||
card.get("slug").and_then(Value::as_str).is_some()
|
||||
&& card.get("display_name").and_then(Value::as_str).is_some()
|
||||
&& card
|
||||
.get("supported_reasoning_levels")
|
||||
.and_then(Value::as_array)
|
||||
.is_some()
|
||||
&& card.get("shell_type").and_then(Value::as_str).is_some()
|
||||
&& card.get("visibility").and_then(Value::as_str).is_some()
|
||||
&& card
|
||||
.get("supported_in_api")
|
||||
.and_then(Value::as_bool)
|
||||
.is_some()
|
||||
&& card.get("priority").and_then(Value::as_i64).is_some()
|
||||
&& card
|
||||
.get("base_instructions")
|
||||
.and_then(Value::as_str)
|
||||
.is_some()
|
||||
&& card
|
||||
.get("supports_reasoning_summary_parameter")
|
||||
.is_none_or(Value::is_boolean)
|
||||
&& card
|
||||
.get("support_verbosity")
|
||||
.and_then(Value::as_bool)
|
||||
.is_some()
|
||||
&& card
|
||||
.get("truncation_policy")
|
||||
.and_then(Value::as_object)
|
||||
.is_some()
|
||||
&& card
|
||||
.get("supports_parallel_tool_calls")
|
||||
.and_then(Value::as_bool)
|
||||
.is_some()
|
||||
&& card
|
||||
.get("experimental_supported_tools")
|
||||
.and_then(Value::as_array)
|
||||
.is_some()
|
||||
}
|
||||
|
||||
fn project_codex_model_card(
|
||||
cached_models: &[Value],
|
||||
source_model: &str,
|
||||
global_model: &str,
|
||||
) -> Option<Value> {
|
||||
let mut card = cached_models
|
||||
.iter()
|
||||
.find(|model| {
|
||||
model.get("id").and_then(Value::as_str) == Some(source_model)
|
||||
|| model.get("slug").and_then(Value::as_str) == Some(source_model)
|
||||
})?
|
||||
.as_object()?
|
||||
.clone();
|
||||
if !codex_model_card_is_complete(&card) {
|
||||
return None;
|
||||
}
|
||||
|
||||
card.remove("id");
|
||||
card.remove("api_formats");
|
||||
card.insert("slug".to_string(), Value::String(global_model.to_string()));
|
||||
Some(Value::Object(card))
|
||||
}
|
||||
|
||||
async fn load_codex_model_cards(
|
||||
state: &AppState,
|
||||
rows: &[StoredMinimalCandidateSelectionRow],
|
||||
) -> Vec<Value> {
|
||||
let cache_keys = rows
|
||||
targets: &[crate::model_fetch::CodexCatalogTarget],
|
||||
client_version: crate::model_fetch::NormalizedCodexClientVersion,
|
||||
) -> (Vec<Value>, Option<String>) {
|
||||
let catalogs = crate::model_fetch::load_codex_catalogs(state, targets, &client_version).await;
|
||||
for target in catalogs.stale_targets() {
|
||||
let state = state.clone();
|
||||
let target = target.clone();
|
||||
let client_version = client_version.clone();
|
||||
tokio::spawn(async move {
|
||||
crate::model_fetch::refresh_codex_catalog_target(&state, &target, &client_version)
|
||||
.await;
|
||||
});
|
||||
}
|
||||
if !catalogs.is_complete() {
|
||||
warn!(
|
||||
event_name = "codex_catalog_aggregate_incomplete",
|
||||
client_version = %client_version.as_str(),
|
||||
target_count = targets.len(),
|
||||
"Codex catalog aggregation was incomplete; returning an empty remote catalog so the client can use its bundled fallback"
|
||||
);
|
||||
return (Vec::new(), None);
|
||||
}
|
||||
let mut seen_global_models = BTreeSet::new();
|
||||
let possible_inference_catalogs = rows
|
||||
.iter()
|
||||
.filter(|row| is_codex_provider_row(row))
|
||||
.map(|row| format!("upstream_models:{}:{}", row.provider_id, row.key_id))
|
||||
.collect::<BTreeSet<_>>()
|
||||
.into_iter()
|
||||
.collect::<Vec<_>>();
|
||||
let cached_values = await_models_route_read(
|
||||
"codex_models_cache",
|
||||
state.runtime_state.kv_get_many(&cache_keys),
|
||||
)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
let cached_models_by_key = cache_keys
|
||||
.into_iter()
|
||||
.zip(cached_values)
|
||||
.filter_map(|(key, raw)| {
|
||||
let models = serde_json::from_str::<Vec<Value>>(raw.as_deref()?).ok()?;
|
||||
Some((key, models))
|
||||
})
|
||||
.collect::<BTreeMap<_, _>>();
|
||||
|
||||
let mut seen_global_models = BTreeSet::new();
|
||||
.map(|row| (row.provider_id.clone(), row.key_id.clone()))
|
||||
.collect::<BTreeSet<_>>();
|
||||
let expected_global_models = rows
|
||||
.iter()
|
||||
.filter(|row| is_codex_provider_row(row))
|
||||
.map(|row| row.global_model_name.clone())
|
||||
.collect::<BTreeSet<_>>();
|
||||
let mut cards = Vec::new();
|
||||
for row in rows.iter().filter(|row| is_codex_provider_row(row)) {
|
||||
if seen_global_models.contains(&row.global_model_name) {
|
||||
continue;
|
||||
}
|
||||
let cache_key = format!("upstream_models:{}:{}", row.provider_id, row.key_id);
|
||||
let Some(cached_models) = cached_models_by_key.get(&cache_key) else {
|
||||
let Some(snapshot) = catalogs.snapshot(&row.provider_id, &row.key_id) else {
|
||||
continue;
|
||||
};
|
||||
let source_model =
|
||||
aether_scheduler_core::select_provider_model_name(row, "openai:responses");
|
||||
let Some(card) = project_codex_model_card(
|
||||
cached_models,
|
||||
let Some(card) = crate::ai_serving::project_codex_catalog_model_card(
|
||||
&snapshot.models,
|
||||
source_model.as_str(),
|
||||
row.global_model_name.as_str(),
|
||||
) else {
|
||||
warn!(
|
||||
event_name = "codex_catalog_authorized_model_missing",
|
||||
provider_id = %row.provider_id,
|
||||
key_id = %row.key_id,
|
||||
client_version = %client_version.as_str(),
|
||||
source_model = %source_model,
|
||||
global_model = %row.global_model_name,
|
||||
"authorized Codex model was not present in this upstream catalog mapping"
|
||||
);
|
||||
continue;
|
||||
};
|
||||
seen_global_models.insert(row.global_model_name.clone());
|
||||
cards.push(card);
|
||||
if cards.len() > CODEX_MODELS_MAX_RESPONSE_MODELS {
|
||||
warn!(
|
||||
event_name = "codex_catalog_aggregate_model_limit",
|
||||
client_version = %client_version.as_str(),
|
||||
model_count = cards.len(),
|
||||
limit = CODEX_MODELS_MAX_RESPONSE_MODELS,
|
||||
"Codex projected catalog exceeded the aggregate model limit; returning an empty remote catalog"
|
||||
);
|
||||
return (Vec::new(), None);
|
||||
}
|
||||
}
|
||||
cards
|
||||
let missing_model_count = expected_global_models
|
||||
.difference(&seen_global_models)
|
||||
.count();
|
||||
if missing_model_count > 0 {
|
||||
warn!(
|
||||
event_name = "codex_catalog_authorized_models_incomplete",
|
||||
client_version = %client_version.as_str(),
|
||||
expected_model_count = expected_global_models.len(),
|
||||
projected_model_count = cards.len(),
|
||||
missing_model_count,
|
||||
"Codex upstream catalogs omitted authorized mappings; returning an empty remote catalog so the client can use its bundled fallback"
|
||||
);
|
||||
return (Vec::new(), None);
|
||||
}
|
||||
if !codex_projected_catalog_fits_response_limits(&cards) {
|
||||
warn!(
|
||||
event_name = "codex_catalog_aggregate_body_limit",
|
||||
client_version = %client_version.as_str(),
|
||||
model_count = cards.len(),
|
||||
limit_bytes = CODEX_MODELS_MAX_RESPONSE_JSON_BYTES,
|
||||
"Codex projected catalog exceeded the aggregate response body limit; returning an empty remote catalog"
|
||||
);
|
||||
return (Vec::new(), None);
|
||||
}
|
||||
if cards.is_empty() {
|
||||
return (cards, None);
|
||||
}
|
||||
let etag = if possible_inference_catalogs.len() == 1 {
|
||||
possible_inference_catalogs
|
||||
.iter()
|
||||
.next()
|
||||
.and_then(|(provider_id, key_id)| catalogs.snapshot(provider_id, key_id))
|
||||
.and_then(|snapshot| snapshot.etag.clone())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
(cards, etag)
|
||||
}
|
||||
|
||||
struct ModelRowsForClientFormat {
|
||||
rows: Vec<StoredMinimalCandidateSelectionRow>,
|
||||
codex_catalog_targets: Vec<crate::model_fetch::CodexCatalogTarget>,
|
||||
}
|
||||
|
||||
async fn list_model_rows_for_client_format(
|
||||
state: &AppState,
|
||||
api_format: &str,
|
||||
auth_snapshot: Option<&crate::data::auth::GatewayAuthApiKeySnapshot>,
|
||||
) -> Option<Vec<StoredMinimalCandidateSelectionRow>> {
|
||||
) -> Option<ModelRowsForClientFormat> {
|
||||
let mut collected = Vec::new();
|
||||
let query_api_formats = if is_codex_models_api_format(api_format) {
|
||||
CODEX_MODELS_QUERY_API_FORMATS
|
||||
@@ -254,9 +270,16 @@ async fn list_model_rows_for_client_format(
|
||||
}
|
||||
if is_codex_models_api_format(api_format) {
|
||||
collected.retain(is_codex_provider_row);
|
||||
Some(sort_model_rows(collected))
|
||||
let codex_catalog_targets = crate::model_fetch::codex_catalog_targets(&collected);
|
||||
Some(ModelRowsForClientFormat {
|
||||
rows: sort_model_rows(collected),
|
||||
codex_catalog_targets,
|
||||
})
|
||||
} else {
|
||||
Some(sort_and_dedup_model_rows(collected))
|
||||
Some(ModelRowsForClientFormat {
|
||||
rows: sort_and_dedup_model_rows(collected),
|
||||
codex_catalog_targets: Vec::new(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -296,6 +319,9 @@ pub(super) async fn maybe_build_local_models_route_response(
|
||||
}
|
||||
|
||||
let auth_context = decision.auth_context.as_ref()?;
|
||||
if !auth_context.access_allowed || auth_context.local_rejection.is_some() {
|
||||
return Some(build_models_auth_error_response(api_format));
|
||||
}
|
||||
let now_unix_secs = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
@@ -318,11 +344,23 @@ pub(super) async fn maybe_build_local_models_route_response(
|
||||
))
|
||||
}
|
||||
};
|
||||
let auth_snapshot = auth_snapshot.as_ref();
|
||||
let Some(auth_snapshot) = auth_snapshot.as_ref() else {
|
||||
warn!(
|
||||
event_name = "models_route_auth_snapshot_missing",
|
||||
user_id = %auth_context.user_id,
|
||||
api_key_id = %auth_context.api_key_id,
|
||||
"gateway models route rejected a request whose authenticated API key snapshot disappeared"
|
||||
);
|
||||
return Some(build_models_auth_error_response(api_format));
|
||||
};
|
||||
if !auth_snapshot.currently_usable {
|
||||
return Some(build_models_auth_error_response(api_format));
|
||||
}
|
||||
let auth_snapshot = Some(auth_snapshot);
|
||||
|
||||
match decision.route_kind.as_deref() {
|
||||
Some("list") => {
|
||||
let rows =
|
||||
let listed =
|
||||
match list_model_rows_for_client_format(state, api_format, auth_snapshot).await {
|
||||
Some(rows) => rows,
|
||||
None => {
|
||||
@@ -332,12 +370,34 @@ pub(super) async fn maybe_build_local_models_route_response(
|
||||
))
|
||||
}
|
||||
};
|
||||
let rows = listed.rows;
|
||||
if rows.is_empty() {
|
||||
return Some(build_empty_models_list_response(api_format));
|
||||
}
|
||||
if is_codex_models_api_format(api_format) {
|
||||
let models = load_codex_model_cards(state, &rows).await;
|
||||
return Some(build_codex_models_list_response(models));
|
||||
let raw_client_version = query_param_value(
|
||||
request_context.request_query_string.as_deref(),
|
||||
"client_version",
|
||||
);
|
||||
let client_version = crate::model_fetch::normalize_codex_client_version(
|
||||
raw_client_version.as_deref(),
|
||||
);
|
||||
if client_version.used_fallback() {
|
||||
warn!(
|
||||
event_name = "codex_catalog_invalid_client_version",
|
||||
raw_length = raw_client_version.as_ref().map_or(0, String::len),
|
||||
fallback_version = %client_version.as_str(),
|
||||
"invalid Codex client_version used the bounded fallback version"
|
||||
);
|
||||
}
|
||||
let (models, etag) = load_codex_model_cards(
|
||||
state,
|
||||
&rows,
|
||||
&listed.codex_catalog_targets,
|
||||
client_version,
|
||||
)
|
||||
.await;
|
||||
return Some(build_codex_models_list_response(models, etag.as_deref()));
|
||||
}
|
||||
let response = match api_format {
|
||||
"claude:messages" => {
|
||||
@@ -410,3 +470,32 @@ pub(super) async fn maybe_build_local_models_route_response(
|
||||
_ => Some(build_models_auth_error_response(api_format)),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use serde_json::json;
|
||||
|
||||
use super::{
|
||||
codex_projected_catalog_fits_response_limits, CODEX_MODELS_MAX_RESPONSE_JSON_BYTES,
|
||||
CODEX_MODELS_MAX_RESPONSE_MODELS,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn projected_codex_catalog_enforces_aggregate_count_and_body_limits() {
|
||||
assert!(codex_projected_catalog_fits_response_limits(&[json!({
|
||||
"slug": "gpt-future-dynamic",
|
||||
"model_messages": {"instructions_template": "opaque"}
|
||||
})]));
|
||||
|
||||
let too_many = (0..=CODEX_MODELS_MAX_RESPONSE_MODELS)
|
||||
.map(|index| json!({"slug": format!("gpt-future-{index}")}))
|
||||
.collect::<Vec<_>>();
|
||||
assert!(!codex_projected_catalog_fits_response_limits(&too_many));
|
||||
|
||||
let oversized = vec![json!({
|
||||
"slug": "gpt-future-oversized",
|
||||
"future_capability": "x".repeat(CODEX_MODELS_MAX_RESPONSE_JSON_BYTES)
|
||||
})];
|
||||
assert!(!codex_projected_catalog_fits_response_limits(&oversized));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -194,7 +194,7 @@ fn row_exposes_global_model_for_models(
|
||||
false
|
||||
}
|
||||
|
||||
pub(super) fn filter_eligible_model_rows(
|
||||
pub(crate) fn filter_eligible_model_rows(
|
||||
rows: Vec<StoredMinimalCandidateSelectionRow>,
|
||||
auth_snapshot: Option<&crate::data::auth::GatewayAuthApiKeySnapshot>,
|
||||
api_format: &str,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,8 +1,15 @@
|
||||
mod catalog;
|
||||
mod runtime;
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
pub(crate) use aether_model_fetch::ModelFetchRunSummary;
|
||||
pub(crate) use catalog::{
|
||||
codex_catalog_credential_scope_from_stored_key, codex_catalog_targets, load_codex_catalogs,
|
||||
normalize_codex_client_version, read_recent_codex_catalog_client_version,
|
||||
refresh_codex_catalog_target, CodexCatalogLoad, CodexCatalogRuntime, CodexCatalogTarget,
|
||||
NormalizedCodexClientVersion,
|
||||
};
|
||||
pub(crate) use runtime::state::ModelFetchRuntimeState;
|
||||
pub(crate) use runtime::{
|
||||
perform_model_fetch_for_key, perform_model_fetch_for_keys, perform_model_fetch_once,
|
||||
|
||||
@@ -7,7 +7,7 @@ use aether_data_contracts::repository::provider_catalog::{
|
||||
StoredProviderCatalogKey, StoredProviderCatalogProvider,
|
||||
};
|
||||
use aether_model_fetch::{
|
||||
apply_model_filters, fetch_models_from_transports, json_string_list,
|
||||
apply_model_filters, fetch_models_from_transports_for_client_version, json_string_list,
|
||||
model_catalog_upstream_metadata, model_fetch_interval_minutes,
|
||||
model_fetch_startup_delay_seconds, model_fetch_startup_enabled, preset_models_for_provider,
|
||||
selected_models_fetch_endpoints, sync_provider_model_whitelist_associations,
|
||||
@@ -315,7 +315,25 @@ async fn fetch_and_persist_key_models(
|
||||
return Ok(KeyFetchDisposition::Skipped);
|
||||
}
|
||||
|
||||
let result = match fetch_models_from_transports(state, &transports).await {
|
||||
let codex_client_version = if target
|
||||
.provider
|
||||
.provider_type
|
||||
.trim()
|
||||
.eq_ignore_ascii_case("codex")
|
||||
{
|
||||
state
|
||||
.read_recent_codex_catalog_client_version(&target.provider.id, &target.key.id)
|
||||
.await
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let result = match fetch_models_from_transports_for_client_version(
|
||||
state,
|
||||
&transports,
|
||||
codex_client_version.as_deref(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(result) => result,
|
||||
Err(err) => {
|
||||
persist_key_fetch_failure(state, &target.key, now_unix_secs, err.clone()).await?;
|
||||
|
||||
@@ -43,6 +43,14 @@ pub(crate) trait ModelFetchRuntimeState:
|
||||
plan: &ExecutionPlan,
|
||||
) -> Result<ExecutionResult, GatewayError>;
|
||||
|
||||
async fn read_recent_codex_catalog_client_version(
|
||||
&self,
|
||||
_provider_id: &str,
|
||||
_key_id: &str,
|
||||
) -> Option<String> {
|
||||
None
|
||||
}
|
||||
|
||||
async fn update_provider_catalog_key_model_fetch_state(
|
||||
&self,
|
||||
key_id: &str,
|
||||
|
||||
@@ -3838,7 +3838,9 @@ mod tests {
|
||||
.expect("overflow should resume after repository recovery")
|
||||
.expect("overflow task should complete");
|
||||
tokio::time::timeout(Duration::from_secs(2), async {
|
||||
while runtime.metrics.pending_current.load(Ordering::Acquire) != 0 {
|
||||
while runtime.metrics.pending_current.load(Ordering::Acquire) != 0
|
||||
|| runtime.priority_admission.available_permits() != 2
|
||||
{
|
||||
tokio::task::yield_now().await;
|
||||
}
|
||||
})
|
||||
|
||||
@@ -480,6 +480,16 @@ impl AppState {
|
||||
.map_err(|err| GatewayError::Internal(err.to_string()))
|
||||
}
|
||||
|
||||
pub(crate) async fn list_provider_catalog_keys_by_ids_strong(
|
||||
&self,
|
||||
key_ids: &[String],
|
||||
) -> Result<Vec<provider_catalog::StoredProviderCatalogKey>, GatewayError> {
|
||||
self.data
|
||||
.list_provider_catalog_keys_by_ids_strong(key_ids)
|
||||
.await
|
||||
.map_err(|err| GatewayError::Internal(err.to_string()))
|
||||
}
|
||||
|
||||
pub(crate) async fn list_provider_catalog_key_page(
|
||||
&self,
|
||||
query: &provider_catalog::ProviderCatalogKeyListQuery,
|
||||
|
||||
@@ -25,7 +25,7 @@ use tracing::{debug, warn};
|
||||
|
||||
use super::{AppState, GatewayError};
|
||||
use crate::clock::current_unix_secs;
|
||||
use crate::model_fetch::ModelFetchRuntimeState;
|
||||
use crate::model_fetch::{CodexCatalogRuntime, ModelFetchRuntimeState};
|
||||
use crate::provider_transport::{GatewayProviderTransportSnapshot, LocalResolvedOAuthRequestAuth};
|
||||
use crate::request_candidate_runtime::{
|
||||
RequestCandidateRuntimeCapabilityReader, RequestCandidateRuntimeReader,
|
||||
@@ -34,6 +34,8 @@ use crate::request_candidate_runtime::{
|
||||
use crate::scheduler::state::SchedulerRuntimeState;
|
||||
use crate::{execution_runtime, provider_transport};
|
||||
|
||||
const MODEL_FETCH_RESPONSE_BODY_LIMIT_BYTES: usize = 8 * 1024 * 1024;
|
||||
|
||||
impl AppState {
|
||||
pub(crate) async fn hydrate_antigravity_project_metadata_for_transport(
|
||||
&self,
|
||||
@@ -274,12 +276,90 @@ impl ModelFetchTransportRuntime for AppState {
|
||||
&self,
|
||||
plan: &ExecutionPlan,
|
||||
) -> Result<ExecutionResult, String> {
|
||||
execution_runtime::execute_execution_runtime_sync_plan(self, None, plan)
|
||||
let bounded_plan = execution_runtime::transport::with_upstream_response_body_limit(
|
||||
plan,
|
||||
MODEL_FETCH_RESPONSE_BODY_LIMIT_BYTES,
|
||||
);
|
||||
execution_runtime::execute_execution_runtime_sync_plan(self, None, &bounded_plan)
|
||||
.await
|
||||
.map_err(GatewayError::into_message)
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl CodexCatalogRuntime for AppState {
|
||||
fn codex_catalog_runtime_state(&self) -> &aether_runtime_state::RuntimeState {
|
||||
self.runtime_state.as_ref()
|
||||
}
|
||||
|
||||
async fn read_codex_catalog_transport_snapshot(
|
||||
&self,
|
||||
provider_id: &str,
|
||||
endpoint_id: &str,
|
||||
key_id: &str,
|
||||
) -> Result<Option<GatewayProviderTransportSnapshot>, String> {
|
||||
self.read_provider_transport_snapshot(provider_id, endpoint_id, key_id)
|
||||
.await
|
||||
.map_err(GatewayError::into_message)
|
||||
}
|
||||
|
||||
async fn read_codex_catalog_credential_scope_strong(
|
||||
&self,
|
||||
provider_id: &str,
|
||||
key_id: &str,
|
||||
) -> Result<Option<String>, String> {
|
||||
let Some(key) = self
|
||||
.list_provider_catalog_keys_by_ids_strong(&[key_id.to_string()])
|
||||
.await
|
||||
.map_err(GatewayError::into_message)?
|
||||
.into_iter()
|
||||
.find(|key| key.id == key_id && key.provider_id == provider_id && key.is_active)
|
||||
else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
if let Some(scope) =
|
||||
crate::model_fetch::codex_catalog_credential_scope_from_stored_key(&key, None, None)
|
||||
{
|
||||
return Ok(Some(scope));
|
||||
}
|
||||
|
||||
let decrypted_auth_config = match key.encrypted_auth_config.as_deref() {
|
||||
Some(ciphertext) => Some(
|
||||
crate::handlers::shared::decrypt_catalog_secret_with_fallbacks(
|
||||
self.encryption_key(),
|
||||
ciphertext,
|
||||
)
|
||||
.ok_or_else(|| {
|
||||
"Codex catalog auth config could not be verified for credential fencing"
|
||||
.to_string()
|
||||
})?,
|
||||
),
|
||||
None => None,
|
||||
};
|
||||
let decrypted_api_key = match key.encrypted_api_key.as_deref() {
|
||||
Some(ciphertext) => Some(
|
||||
crate::handlers::shared::decrypt_catalog_secret_with_fallbacks(
|
||||
self.encryption_key(),
|
||||
ciphertext,
|
||||
)
|
||||
.ok_or_else(|| {
|
||||
"Codex catalog API key could not be verified for credential fencing".to_string()
|
||||
})?,
|
||||
),
|
||||
None => None,
|
||||
};
|
||||
|
||||
Ok(
|
||||
crate::model_fetch::codex_catalog_credential_scope_from_stored_key(
|
||||
&key,
|
||||
decrypted_auth_config.as_deref(),
|
||||
decrypted_api_key.as_deref(),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ModelFetchRuntimeState for AppState {
|
||||
fn has_provider_catalog_data_reader(&self) -> bool {
|
||||
@@ -320,6 +400,29 @@ impl ModelFetchRuntimeState for AppState {
|
||||
execution_runtime::execute_execution_runtime_sync_plan(self, None, plan).await
|
||||
}
|
||||
|
||||
async fn read_recent_codex_catalog_client_version(
|
||||
&self,
|
||||
provider_id: &str,
|
||||
key_id: &str,
|
||||
) -> Option<String> {
|
||||
let credential_scope =
|
||||
<AppState as CodexCatalogRuntime>::read_codex_catalog_credential_scope_strong(
|
||||
self,
|
||||
provider_id,
|
||||
key_id,
|
||||
)
|
||||
.await
|
||||
.ok()
|
||||
.flatten()?;
|
||||
crate::model_fetch::read_recent_codex_catalog_client_version(
|
||||
self.runtime_state.as_ref(),
|
||||
provider_id,
|
||||
key_id,
|
||||
&credential_scope,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn update_provider_catalog_key_model_fetch_state(
|
||||
&self,
|
||||
key_id: &str,
|
||||
|
||||
@@ -8266,24 +8266,21 @@ async fn gateway_manual_oauth_refresh_prefers_fresher_transport_auth_config_over
|
||||
"Bearer cached-codex-access-token"
|
||||
);
|
||||
|
||||
let mut updated_key = provider_catalog_repository
|
||||
.list_keys_by_ids(&["key-codex-oauth-stale-cache".to_string()])
|
||||
.await
|
||||
.expect("keys should list")
|
||||
.into_iter()
|
||||
.next()
|
||||
.expect("key should exist");
|
||||
updated_key.encrypted_auth_config = Some(
|
||||
encrypt_python_fernet_plaintext(
|
||||
DEVELOPMENT_ENCRYPTION_KEY,
|
||||
r#"{"provider_type":"codex","refresh_token":"fresh-codex-refresh-token","email":"alice@example.com","account_id":"acct-codex-123","plan_type":"plus","expires_at":1,"updated_at":4102444810}"#,
|
||||
let fresh_auth_config = encrypt_python_fernet_plaintext(
|
||||
DEVELOPMENT_ENCRYPTION_KEY,
|
||||
r#"{"provider_type":"codex","refresh_token":"fresh-codex-refresh-token","email":"alice@example.com","account_id":"acct-codex-123","plan_type":"plus","expires_at":1,"updated_at":4102444810}"#,
|
||||
)
|
||||
.expect("updated auth config ciphertext should build");
|
||||
assert!(provider_catalog_repository
|
||||
.update_key_oauth_runtime_state(
|
||||
"key-codex-oauth-stale-cache",
|
||||
None,
|
||||
None,
|
||||
Some(&fresh_auth_config),
|
||||
Some(4_102_444_810),
|
||||
)
|
||||
.expect("updated auth config ciphertext should build"),
|
||||
);
|
||||
provider_catalog_repository
|
||||
.update_key(&updated_key)
|
||||
.await
|
||||
.expect("key should update");
|
||||
.expect("OAuth runtime state should update"));
|
||||
|
||||
let gateway = build_router_with_state(app_state);
|
||||
let (gateway_url, gateway_handle) = start_server(gateway).await;
|
||||
|
||||
@@ -7,11 +7,16 @@ use super::{
|
||||
use crate::image_capabilities::openai_image_gateway_max_generation_count;
|
||||
use crate::tests::{
|
||||
any, build_router_with_state, build_state_with_execution_runtime_override, json, start_server,
|
||||
to_bytes, AppState, Arc, Body, Json, Mutex, Request, Router, StatusCode, EXECUTION_PATH_HEADER,
|
||||
EXECUTION_PATH_LOCAL_AI_PUBLIC, EXECUTION_PATH_LOCAL_EXECUTION_RUNTIME_MISS,
|
||||
to_bytes, wait_until, AppState, Arc, Body, Json, Mutex, Request, Router, StatusCode,
|
||||
EXECUTION_PATH_HEADER, EXECUTION_PATH_LOCAL_AI_PUBLIC,
|
||||
EXECUTION_PATH_LOCAL_EXECUTION_RUNTIME_MISS,
|
||||
};
|
||||
use aether_contracts::{ExecutionResult, ExecutionTelemetry, ResponseBody};
|
||||
use aether_crypto::encrypt_python_fernet_plaintext;
|
||||
use aether_data::repository::global_models::InMemoryGlobalModelReadRepository;
|
||||
use aether_data::repository::provider_catalog::InMemoryProviderCatalogReadRepository;
|
||||
use aether_data::DataLayerError;
|
||||
use aether_data_contracts::repository::auth::AuthApiKeyWriteRepository;
|
||||
use aether_data_contracts::repository::candidate_selection::{
|
||||
MinimalCandidateSelectionReadRepository, StoredMinimalCandidateSelectionRow,
|
||||
StoredPoolKeyCandidateRowsByKeyIdsQuery, StoredPoolKeyCandidateRowsQuery,
|
||||
@@ -20,13 +25,21 @@ use aether_data_contracts::repository::candidate_selection::{
|
||||
use aether_data_contracts::repository::global_models::{
|
||||
StoredAdminGlobalModel, UpdateAdminGlobalModelRecord,
|
||||
};
|
||||
use aether_data_contracts::repository::provider_catalog::{
|
||||
ProviderCatalogReadRepository, StoredProviderCatalogEndpoint, StoredProviderCatalogKey,
|
||||
StoredProviderCatalogProvider,
|
||||
};
|
||||
use async_trait::async_trait;
|
||||
use axum::response::IntoResponse;
|
||||
use std::collections::HashMap;
|
||||
use std::future::pending;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
|
||||
|
||||
fn codex_models_snapshot(api_key_id: &str, user_id: &str) -> StoredAuthApiKeySnapshot {
|
||||
fn codex_models_snapshot(
|
||||
api_key_id: &str,
|
||||
user_id: &str,
|
||||
allowed_models: &[&str],
|
||||
) -> StoredAuthApiKeySnapshot {
|
||||
StoredAuthApiKeySnapshot::new(
|
||||
user_id.to_string(),
|
||||
"alice".to_string(),
|
||||
@@ -37,7 +50,7 @@ fn codex_models_snapshot(api_key_id: &str, user_id: &str) -> StoredAuthApiKeySna
|
||||
false,
|
||||
Some(json!(["codex"])),
|
||||
Some(json!(["openai:responses"])),
|
||||
Some(json!(["frontier-sol", "broken-luna"])),
|
||||
Some(json!(allowed_models)),
|
||||
api_key_id.to_string(),
|
||||
Some("codex-models".to_string()),
|
||||
true,
|
||||
@@ -48,7 +61,7 @@ fn codex_models_snapshot(api_key_id: &str, user_id: &str) -> StoredAuthApiKeySna
|
||||
Some(4_102_444_800),
|
||||
Some(json!(["codex"])),
|
||||
Some(json!(["openai:responses"])),
|
||||
Some(json!(["frontier-sol", "broken-luna"])),
|
||||
Some(json!(allowed_models)),
|
||||
)
|
||||
.expect("Codex models auth snapshot should build")
|
||||
}
|
||||
@@ -104,6 +117,7 @@ fn complete_codex_model_card(source_model_name: &str) -> serde_json::Value {
|
||||
"upgrade": null,
|
||||
"base_instructions": "Use the current Codex instructions.",
|
||||
"model_messages": null,
|
||||
"available_in_plans": ["plus", "pro"],
|
||||
"support_verbosity": true,
|
||||
"default_verbosity": "low",
|
||||
"apply_patch_tool_type": "freeform",
|
||||
@@ -115,6 +129,105 @@ fn complete_codex_model_card(source_model_name: &str) -> serde_json::Value {
|
||||
})
|
||||
}
|
||||
|
||||
fn codex_catalog_provider(provider_id: &str) -> StoredProviderCatalogProvider {
|
||||
StoredProviderCatalogProvider::new(
|
||||
provider_id.to_string(),
|
||||
"codex".to_string(),
|
||||
Some("https://chatgpt.com".to_string()),
|
||||
"codex".to_string(),
|
||||
)
|
||||
.expect("Codex provider should build")
|
||||
}
|
||||
|
||||
fn codex_catalog_endpoint(provider_id: &str, endpoint_id: &str) -> StoredProviderCatalogEndpoint {
|
||||
StoredProviderCatalogEndpoint::new(
|
||||
endpoint_id.to_string(),
|
||||
provider_id.to_string(),
|
||||
"openai:responses".to_string(),
|
||||
Some("openai".to_string()),
|
||||
Some("responses".to_string()),
|
||||
true,
|
||||
)
|
||||
.expect("Codex endpoint should build")
|
||||
.with_transport_fields(
|
||||
"https://chatgpt.example/backend-api/codex".to_string(),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.expect("Codex endpoint transport should build")
|
||||
}
|
||||
|
||||
fn codex_catalog_key(
|
||||
provider_id: &str,
|
||||
key_id: &str,
|
||||
allowed_models: &[&str],
|
||||
) -> StoredProviderCatalogKey {
|
||||
let mut key = StoredProviderCatalogKey::new(
|
||||
key_id.to_string(),
|
||||
provider_id.to_string(),
|
||||
"manual".to_string(),
|
||||
"bearer".to_string(),
|
||||
None,
|
||||
true,
|
||||
)
|
||||
.expect("Codex key should build")
|
||||
.with_transport_fields(
|
||||
Some(json!(["openai:responses"])),
|
||||
encrypt_python_fernet_plaintext(DEVELOPMENT_ENCRYPTION_KEY, "oauth-upstream-secret")
|
||||
.expect("Codex test token should encrypt"),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
Some(json!(allowed_models)),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.expect("Codex key transport should build");
|
||||
key.auto_fetch_models = false;
|
||||
key.locked_models = Some(json!(["manual-locked-model"]));
|
||||
key.model_include_patterns = Some(json!(["gpt-future-*"]));
|
||||
key.model_exclude_patterns = Some(json!(["gpt-future-denied"]));
|
||||
key
|
||||
}
|
||||
|
||||
fn codex_catalog_execution_result(
|
||||
plan: &aether_contracts::ExecutionPlan,
|
||||
status_code: u16,
|
||||
body: serde_json::Value,
|
||||
etag: Option<&str>,
|
||||
) -> ExecutionResult {
|
||||
let mut headers = std::collections::BTreeMap::from([(
|
||||
"content-type".to_string(),
|
||||
"application/json".to_string(),
|
||||
)]);
|
||||
if let Some(etag) = etag {
|
||||
headers.insert("ETag".to_string(), etag.to_string());
|
||||
}
|
||||
ExecutionResult {
|
||||
request_id: plan.request_id.clone(),
|
||||
candidate_id: plan.candidate_id.clone(),
|
||||
status_code,
|
||||
headers,
|
||||
response_observation: None,
|
||||
body: Some(ResponseBody {
|
||||
json_body: Some(body),
|
||||
body_bytes_b64: None,
|
||||
}),
|
||||
telemetry: Some(ExecutionTelemetry {
|
||||
ttfb_ms: Some(1),
|
||||
elapsed_ms: Some(2),
|
||||
upstream_bytes: None,
|
||||
}),
|
||||
error: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn gemini_operation_status_label(status: VideoTaskStatus) -> &'static str {
|
||||
match status {
|
||||
VideoTaskStatus::Pending => "Pending",
|
||||
@@ -449,102 +562,618 @@ async fn gateway_handles_public_openai_models_without_hitting_fallback_probe() {
|
||||
fallback_probe_handle.abort();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn gateway_serves_codex_model_cards_for_versioned_models_requests() {
|
||||
let codex_row =
|
||||
sample_codex_models_candidate_row("provider-codex-models", "frontier-sol", "gpt-5.6-sol");
|
||||
let incomplete_codex_row = sample_codex_models_candidate_row(
|
||||
"provider-codex-incomplete",
|
||||
"broken-luna",
|
||||
"gpt-5.6-luna",
|
||||
);
|
||||
let candidate_repository =
|
||||
Arc::new(InMemoryMinimalCandidateSelectionReadRepository::seed(vec![
|
||||
codex_row.clone(),
|
||||
incomplete_codex_row.clone(),
|
||||
sample_models_candidate_row(
|
||||
"provider-openai-responses",
|
||||
"openai",
|
||||
"openai:responses",
|
||||
"custom-responses-model",
|
||||
20,
|
||||
#[test]
|
||||
fn gateway_versioned_models_fail_closed_when_cached_auth_becomes_unusable_or_missing() {
|
||||
std::thread::Builder::new()
|
||||
.name("codex-model-catalog-auth-race".to_string())
|
||||
.stack_size(16 * 1024 * 1024)
|
||||
.spawn(|| {
|
||||
tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
.expect("Codex auth race test runtime should build")
|
||||
.block_on(run_versioned_models_auth_race_scenario());
|
||||
})
|
||||
.expect("Codex auth race test thread should spawn")
|
||||
.join()
|
||||
.expect("Codex auth race test thread should finish");
|
||||
}
|
||||
|
||||
async fn run_versioned_models_auth_race_scenario() {
|
||||
let auth_repository = Arc::new(InMemoryAuthApiKeySnapshotRepository::seed(vec![(
|
||||
Some(hash_api_key("sk-codex-models-auth-race")),
|
||||
codex_models_snapshot(
|
||||
"key-codex-models-auth-race",
|
||||
"user-codex-models-auth-race",
|
||||
&["future-alias"],
|
||||
),
|
||||
)]));
|
||||
let candidate_repository = Arc::new(InMemoryMinimalCandidateSelectionReadRepository::seed(
|
||||
Vec::new(),
|
||||
));
|
||||
let gateway = build_router_with_state(
|
||||
AppState::new()
|
||||
.expect("gateway should build")
|
||||
.with_data_state_for_tests(
|
||||
crate::data::GatewayDataState::with_minimal_candidate_selection_and_auth_for_tests(
|
||||
candidate_repository,
|
||||
auth_repository.clone(),
|
||||
),
|
||||
),
|
||||
]));
|
||||
);
|
||||
let (gateway_url, gateway_handle) = start_server(gateway).await;
|
||||
let client = reqwest::Client::new();
|
||||
let models_url = format!("{gateway_url}/v1/models?client_version=0.145.2");
|
||||
|
||||
let warm_response = client
|
||||
.get(&models_url)
|
||||
.header("authorization", "Bearer sk-codex-models-auth-race")
|
||||
.send()
|
||||
.await
|
||||
.expect("initial versioned models request should succeed");
|
||||
assert_eq!(warm_response.status(), StatusCode::OK);
|
||||
|
||||
assert!(auth_repository
|
||||
.set_user_api_key_locked(
|
||||
"user-codex-models-auth-race",
|
||||
"key-codex-models-auth-race",
|
||||
true,
|
||||
)
|
||||
.await
|
||||
.expect("locking the API key should succeed"));
|
||||
let locked_response = client
|
||||
.get(&models_url)
|
||||
.header("authorization", "Bearer sk-codex-models-auth-race")
|
||||
.send()
|
||||
.await
|
||||
.expect("locked versioned models request should complete");
|
||||
assert_eq!(locked_response.status(), StatusCode::UNAUTHORIZED);
|
||||
|
||||
assert!(auth_repository
|
||||
.delete_user_api_key("user-codex-models-auth-race", "key-codex-models-auth-race",)
|
||||
.await
|
||||
.expect("deleting the API key should succeed"));
|
||||
let missing_response = client
|
||||
.get(&models_url)
|
||||
.header("authorization", "Bearer sk-codex-models-auth-race")
|
||||
.send()
|
||||
.await
|
||||
.expect("missing versioned models request should complete");
|
||||
assert_eq!(missing_response.status(), StatusCode::UNAUTHORIZED);
|
||||
|
||||
gateway_handle.abort();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gateway_serves_codex_model_cards_for_versioned_models_requests() {
|
||||
std::thread::Builder::new()
|
||||
.name("codex-model-catalog-frontdoor".to_string())
|
||||
.stack_size(16 * 1024 * 1024)
|
||||
.spawn(|| {
|
||||
tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
.expect("Codex frontdoor test runtime should build")
|
||||
.block_on(run_versioned_codex_model_cards_frontdoor_scenario());
|
||||
})
|
||||
.expect("Codex frontdoor test thread should spawn")
|
||||
.join()
|
||||
.expect("Codex frontdoor test thread should finish");
|
||||
}
|
||||
|
||||
async fn run_versioned_codex_model_cards_frontdoor_scenario() {
|
||||
const PROVIDER_ID: &str = "provider-codex-models";
|
||||
const CATALOG_KEY_ID: &str = "key-provider-codex-models";
|
||||
const CATALOG_ENDPOINT_ID: &str = "endpoint-provider-codex-models";
|
||||
const SOURCE_MODELS: &[&str] = &[
|
||||
"gpt-future-dynamic",
|
||||
"gpt-future-legacy",
|
||||
"gpt-future-second",
|
||||
"gpt-hidden-direct",
|
||||
];
|
||||
const GLOBAL_MODELS: &[&str] = &["future-alias", "legacy-alias"];
|
||||
|
||||
let mut codex_rows = vec![
|
||||
sample_codex_models_candidate_row(PROVIDER_ID, "future-alias", "gpt-future-dynamic"),
|
||||
sample_codex_models_candidate_row(PROVIDER_ID, "legacy-alias", "gpt-future-legacy"),
|
||||
sample_codex_models_candidate_row(PROVIDER_ID, "second-alias", "gpt-future-second"),
|
||||
sample_codex_models_candidate_row(PROVIDER_ID, "hidden-alias", "gpt-hidden-direct"),
|
||||
];
|
||||
for row in &mut codex_rows {
|
||||
row.key_allowed_models = Some(
|
||||
SOURCE_MODELS
|
||||
.iter()
|
||||
.map(|value| value.to_string())
|
||||
.collect(),
|
||||
);
|
||||
}
|
||||
let mut all_candidate_rows = codex_rows.clone();
|
||||
all_candidate_rows.push(sample_models_candidate_row(
|
||||
"provider-openai-responses",
|
||||
"openai",
|
||||
"openai:responses",
|
||||
"custom-responses-model",
|
||||
20,
|
||||
));
|
||||
let candidate_repository = Arc::new(InMemoryMinimalCandidateSelectionReadRepository::seed(
|
||||
all_candidate_rows,
|
||||
));
|
||||
let auth_repository = Arc::new(InMemoryAuthApiKeySnapshotRepository::seed(vec![
|
||||
(
|
||||
Some(hash_api_key("sk-codex-models")),
|
||||
codex_models_snapshot("key-codex-models", "user-codex-models"),
|
||||
codex_models_snapshot("key-codex-models", "user-codex-models", GLOBAL_MODELS),
|
||||
),
|
||||
(
|
||||
Some(hash_api_key("sk-standard-models")),
|
||||
unrestricted_models_snapshot("key-standard-models", "user-standard-models"),
|
||||
),
|
||||
(
|
||||
Some(hash_api_key("sk-codex-legacy-only")),
|
||||
codex_models_snapshot(
|
||||
"key-codex-legacy-only",
|
||||
"user-codex-legacy-only",
|
||||
&["legacy-alias"],
|
||||
),
|
||||
),
|
||||
(
|
||||
Some(hash_api_key("sk-codex-hidden-mixed")),
|
||||
codex_models_snapshot(
|
||||
"key-codex-hidden-mixed",
|
||||
"user-codex-hidden-mixed",
|
||||
&["future-alias", "hidden-alias"],
|
||||
),
|
||||
),
|
||||
(
|
||||
Some(hash_api_key("sk-codex-second-mixed")),
|
||||
codex_models_snapshot(
|
||||
"key-codex-second-mixed",
|
||||
"user-codex-second-mixed",
|
||||
&["future-alias", "second-alias"],
|
||||
),
|
||||
),
|
||||
]));
|
||||
let state = AppState::new()
|
||||
.expect("gateway should build")
|
||||
let original_catalog_key = codex_catalog_key(PROVIDER_ID, CATALOG_KEY_ID, SOURCE_MODELS);
|
||||
assert!(!original_catalog_key.auto_fetch_models);
|
||||
let provider_catalog_repository = Arc::new(InMemoryProviderCatalogReadRepository::seed(
|
||||
vec![codex_catalog_provider(PROVIDER_ID)],
|
||||
vec![codex_catalog_endpoint(PROVIDER_ID, CATALOG_ENDPOINT_ID)],
|
||||
vec![original_catalog_key.clone()],
|
||||
));
|
||||
let rows_before = candidate_repository
|
||||
.list_for_exact_api_format("openai:responses")
|
||||
.await
|
||||
.expect("candidate rows should load before request");
|
||||
|
||||
let catalog_generation = Arc::new(AtomicUsize::new(0));
|
||||
let catalog_hits = Arc::new(AtomicUsize::new(0));
|
||||
let captured_plans = Arc::new(Mutex::new(Vec::<(String, Option<String>)>::new()));
|
||||
let generation_for_runtime = Arc::clone(&catalog_generation);
|
||||
let hits_for_runtime = Arc::clone(&catalog_hits);
|
||||
let plans_for_runtime = Arc::clone(&captured_plans);
|
||||
let execution_runtime = Router::new().route(
|
||||
"/v1/execute/sync",
|
||||
any(move |request: Request| {
|
||||
let generation_for_request = Arc::clone(&generation_for_runtime);
|
||||
let hits_for_request = Arc::clone(&hits_for_runtime);
|
||||
let plans_for_request = Arc::clone(&plans_for_runtime);
|
||||
async move {
|
||||
let (_parts, body) = request.into_parts();
|
||||
let raw_body = to_bytes(body, usize::MAX)
|
||||
.await
|
||||
.expect("execution runtime request body should read");
|
||||
let plan: aether_contracts::ExecutionPlan =
|
||||
serde_json::from_slice(&raw_body).expect("execution runtime plan should parse");
|
||||
plans_for_request
|
||||
.lock()
|
||||
.expect("plans mutex")
|
||||
.push((plan.url.clone(), plan.headers.get("user-agent").cloned()));
|
||||
if plan.url.contains("/models?") {
|
||||
hits_for_request.fetch_add(1, Ordering::SeqCst);
|
||||
let generation = generation_for_request.load(Ordering::SeqCst);
|
||||
if generation == 1 {
|
||||
return Json(codex_catalog_execution_result(
|
||||
&plan,
|
||||
503,
|
||||
json!({"error":{"message":"temporary catalog outage"}}),
|
||||
None,
|
||||
));
|
||||
}
|
||||
|
||||
let mut current = complete_codex_model_card("gpt-future-dynamic");
|
||||
let current_object = current.as_object_mut().expect("current card object");
|
||||
current_object.remove("base_instructions");
|
||||
current_object.insert(
|
||||
"model_messages".to_string(),
|
||||
json!({"instructions_template":"Use future dynamic instructions."}),
|
||||
);
|
||||
current_object.insert(
|
||||
"future_capability".to_string(),
|
||||
json!({"mode":"opaque-current"}),
|
||||
);
|
||||
|
||||
let mut legacy = complete_codex_model_card("gpt-future-legacy");
|
||||
legacy["base_instructions"] = json!("Use legacy future instructions.");
|
||||
legacy["future_capability"] = json!({"mode":"opaque-legacy"});
|
||||
|
||||
let mut models = vec![current, legacy];
|
||||
if generation >= 2 {
|
||||
let mut second = complete_codex_model_card("gpt-future-second");
|
||||
second
|
||||
.as_object_mut()
|
||||
.expect("second card object")
|
||||
.remove("base_instructions");
|
||||
second["model_messages"] =
|
||||
json!({"instructions_template":"Use second future instructions."});
|
||||
second["future_capability"] = json!({"mode":"added-without-code-change"});
|
||||
models.push(second);
|
||||
models.push(complete_codex_model_card("gpt-future-unmapped"));
|
||||
}
|
||||
let etag = if generation >= 2 {
|
||||
"\"catalog-etag-v2\""
|
||||
} else {
|
||||
"\"catalog-etag-v1\""
|
||||
};
|
||||
return Json(codex_catalog_execution_result(
|
||||
&plan,
|
||||
200,
|
||||
json!({"models": models, "future_top_level": true}),
|
||||
Some(etag),
|
||||
));
|
||||
}
|
||||
|
||||
Json(codex_catalog_execution_result(
|
||||
&plan,
|
||||
200,
|
||||
json!({
|
||||
"id": "resp-future-dynamic",
|
||||
"object": "response",
|
||||
"model": "gpt-future-dynamic",
|
||||
"output": [],
|
||||
"usage": {
|
||||
"input_tokens": 1,
|
||||
"output_tokens": 2,
|
||||
"total_tokens": 3
|
||||
}
|
||||
}),
|
||||
Some("\"catalog-etag-v2\""),
|
||||
))
|
||||
}
|
||||
}),
|
||||
);
|
||||
let (execution_runtime_url, execution_runtime_handle) = start_server(execution_runtime).await;
|
||||
let state = build_state_with_execution_runtime_override(execution_runtime_url)
|
||||
.with_data_state_for_tests(
|
||||
crate::data::GatewayDataState::with_minimal_candidate_selection_and_auth_for_tests(
|
||||
candidate_repository,
|
||||
candidate_repository.clone(),
|
||||
auth_repository,
|
||||
),
|
||||
)
|
||||
.attach_provider_catalog_repository_for_tests(provider_catalog_repository.clone())
|
||||
.with_encryption_key_for_tests(DEVELOPMENT_ENCRYPTION_KEY),
|
||||
);
|
||||
state
|
||||
.runtime_kv_setex(
|
||||
&format!(
|
||||
"upstream_models:{}:{}",
|
||||
codex_row.provider_id, codex_row.key_id
|
||||
),
|
||||
&serde_json::to_string(&vec![complete_codex_model_card("gpt-5.6-sol")])
|
||||
.expect("model cache should serialize"),
|
||||
60,
|
||||
|
||||
let configured_rows = state
|
||||
.list_minimal_candidate_selection_rows_for_api_format("openai:responses")
|
||||
.await
|
||||
.expect("configured Codex candidate rows should be readable");
|
||||
assert_eq!(
|
||||
configured_rows
|
||||
.iter()
|
||||
.filter(|row| row.provider_type == "codex")
|
||||
.count(),
|
||||
codex_rows.len()
|
||||
);
|
||||
let resolved_auth =
|
||||
aether_data_contracts::repository::auth::ResolvedAuthApiKeySnapshot::from_stored(
|
||||
codex_models_snapshot("key-codex-models", "user-codex-models", GLOBAL_MODELS),
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs(),
|
||||
);
|
||||
let eligible_rows = crate::handlers::public::filter_eligible_model_rows(
|
||||
configured_rows.clone(),
|
||||
Some(&resolved_auth),
|
||||
"openai:responses",
|
||||
);
|
||||
assert_eq!(
|
||||
eligible_rows
|
||||
.iter()
|
||||
.filter(|row| row.provider_type == "codex")
|
||||
.count(),
|
||||
GLOBAL_MODELS.len(),
|
||||
"Codex fixture rows must survive the same provider/model/key authorization filters as the route"
|
||||
);
|
||||
let actual_auth = state
|
||||
.data
|
||||
.read_auth_api_key_snapshot(
|
||||
"user-codex-models",
|
||||
"key-codex-models",
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs(),
|
||||
)
|
||||
.await
|
||||
.expect("model cache should seed");
|
||||
state
|
||||
.runtime_kv_setex(
|
||||
&format!(
|
||||
"upstream_models:{}:{}",
|
||||
incomplete_codex_row.provider_id, incomplete_codex_row.key_id
|
||||
),
|
||||
&serde_json::to_string(&vec![json!({
|
||||
"id": "gpt-5.6-luna",
|
||||
"slug": "gpt-5.6-luna",
|
||||
"display_name": "GPT-5.6-Luna"
|
||||
})])
|
||||
.expect("incomplete model cache should serialize"),
|
||||
60,
|
||||
.expect("Codex auth snapshot read should succeed")
|
||||
.expect("Codex auth snapshot should exist");
|
||||
assert_eq!(
|
||||
crate::handlers::public::filter_eligible_model_rows(
|
||||
configured_rows.clone(),
|
||||
Some(&actual_auth),
|
||||
"openai:responses",
|
||||
)
|
||||
.iter()
|
||||
.filter(|row| row.provider_type == "codex")
|
||||
.count(),
|
||||
GLOBAL_MODELS.len(),
|
||||
"stored Codex auth snapshot must preserve every authorized manual mapping"
|
||||
);
|
||||
assert!(
|
||||
<AppState as crate::model_fetch::CodexCatalogRuntime>::read_codex_catalog_transport_snapshot(
|
||||
&state,
|
||||
PROVIDER_ID,
|
||||
CATALOG_ENDPOINT_ID,
|
||||
CATALOG_KEY_ID,
|
||||
)
|
||||
.await
|
||||
.expect("incomplete model cache should seed");
|
||||
.expect("Codex catalog transport lookup should succeed")
|
||||
.is_some(),
|
||||
"Codex catalog transport must be available even when auto_fetch_models is disabled"
|
||||
);
|
||||
|
||||
let gateway = build_router_with_state(state);
|
||||
let (gateway_url, gateway_handle) = start_server(gateway).await;
|
||||
let client = reqwest::Client::new();
|
||||
|
||||
let codex_response = client
|
||||
.get(format!("{gateway_url}/v1/models?client_version=0.144.1"))
|
||||
.get(format!(
|
||||
"{gateway_url}/v1/models?client_version=0.145.2-beta.7%2Bdesktop.9"
|
||||
))
|
||||
.header("authorization", "Bearer sk-codex-models")
|
||||
.send()
|
||||
.await
|
||||
.expect("Codex models request should succeed");
|
||||
assert_eq!(codex_response.status(), StatusCode::OK);
|
||||
let codex_etag = codex_response
|
||||
.headers()
|
||||
.get(http::header::ETAG)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.map(ToOwned::to_owned);
|
||||
let codex_payload: serde_json::Value = codex_response
|
||||
.json()
|
||||
.await
|
||||
.expect("Codex models body should parse");
|
||||
assert_eq!(codex_payload["models"].as_array().map(Vec::len), Some(1));
|
||||
assert_eq!(codex_payload["models"][0]["slug"], "frontier-sol");
|
||||
assert_eq!(
|
||||
codex_payload["models"][0]["supported_reasoning_levels"][5]["effort"],
|
||||
"ultra"
|
||||
catalog_hits.load(Ordering::SeqCst),
|
||||
1,
|
||||
"cold Codex request must reach the upstream catalog once; payload={codex_payload}"
|
||||
);
|
||||
assert_eq!(codex_etag.as_deref(), Some("\"catalog-etag-v1\""));
|
||||
assert_eq!(codex_payload["models"].as_array().map(Vec::len), Some(2));
|
||||
let current_card = codex_payload["models"]
|
||||
.as_array()
|
||||
.and_then(|models| models.iter().find(|model| model["slug"] == "future-alias"))
|
||||
.expect("current model card should be projected");
|
||||
assert_eq!(
|
||||
current_card["model_messages"]["instructions_template"],
|
||||
"Use future dynamic instructions."
|
||||
);
|
||||
assert_eq!(
|
||||
codex_payload["models"][0]["future_capability"],
|
||||
json!({"enabled": true})
|
||||
current_card["future_capability"],
|
||||
json!({"mode":"opaque-current"})
|
||||
);
|
||||
assert!(codex_payload["models"][0].get("id").is_none());
|
||||
assert!(codex_payload["models"][0].get("api_formats").is_none());
|
||||
assert_eq!(current_card["available_in_plans"], json!(["plus", "pro"]));
|
||||
assert!(current_card.get("base_instructions").is_none());
|
||||
assert!(current_card.get("id").is_none());
|
||||
assert!(current_card.get("api_formats").is_none());
|
||||
let legacy_card = codex_payload["models"]
|
||||
.as_array()
|
||||
.and_then(|models| models.iter().find(|model| model["slug"] == "legacy-alias"))
|
||||
.expect("legacy model card should be projected");
|
||||
assert_eq!(
|
||||
legacy_card["base_instructions"],
|
||||
"Use legacy future instructions."
|
||||
);
|
||||
assert_eq!(
|
||||
legacy_card["future_capability"],
|
||||
json!({"mode":"opaque-legacy"})
|
||||
);
|
||||
assert!(codex_payload["models"]
|
||||
.as_array()
|
||||
.is_some_and(
|
||||
|models| models.iter().all(|model| model["slug"] != "hidden-alias"
|
||||
&& model["slug"] != "second-alias"
|
||||
&& model["slug"] != "gpt-future-unmapped")
|
||||
));
|
||||
assert!(codex_payload.get("object").is_none());
|
||||
assert_eq!(catalog_hits.load(Ordering::SeqCst), 1);
|
||||
|
||||
let restricted_response = client
|
||||
.get(format!("{gateway_url}/v1/models?client_version=0.145.2"))
|
||||
.header("authorization", "Bearer sk-codex-legacy-only")
|
||||
.send()
|
||||
.await
|
||||
.expect("restricted Codex models request should succeed");
|
||||
assert_eq!(restricted_response.status(), StatusCode::OK);
|
||||
let restricted_payload: serde_json::Value = restricted_response
|
||||
.json()
|
||||
.await
|
||||
.expect("restricted Codex models body should parse");
|
||||
assert_eq!(
|
||||
restricted_payload["models"].as_array().map(|models| models
|
||||
.iter()
|
||||
.map(|model| model["slug"].as_str().unwrap_or_default())
|
||||
.collect::<Vec<_>>()),
|
||||
Some(vec!["legacy-alias"])
|
||||
);
|
||||
assert_eq!(catalog_hits.load(Ordering::SeqCst), 1);
|
||||
|
||||
let incomplete_authorized_response = client
|
||||
.get(format!("{gateway_url}/v1/models?client_version=0.145.2"))
|
||||
.header("authorization", "Bearer sk-codex-hidden-mixed")
|
||||
.send()
|
||||
.await
|
||||
.expect("incomplete authorized Codex catalog request should succeed");
|
||||
assert_eq!(incomplete_authorized_response.status(), StatusCode::OK);
|
||||
assert!(incomplete_authorized_response
|
||||
.headers()
|
||||
.get(http::header::ETAG)
|
||||
.is_none());
|
||||
let incomplete_authorized_payload: serde_json::Value = incomplete_authorized_response
|
||||
.json()
|
||||
.await
|
||||
.expect("incomplete authorized Codex body should parse");
|
||||
assert_eq!(
|
||||
incomplete_authorized_payload["models"]
|
||||
.as_array()
|
||||
.map(Vec::len),
|
||||
Some(0),
|
||||
"a partial non-empty remote catalog would hide the client's bundled fallback models"
|
||||
);
|
||||
assert_eq!(catalog_hits.load(Ordering::SeqCst), 1);
|
||||
|
||||
let pending_second_response = client
|
||||
.get(format!("{gateway_url}/v1/models?client_version=0.145.2"))
|
||||
.header("authorization", "Bearer sk-codex-second-mixed")
|
||||
.send()
|
||||
.await
|
||||
.expect("not-yet-published authorized model request should succeed");
|
||||
assert_eq!(pending_second_response.status(), StatusCode::OK);
|
||||
let pending_second_payload: serde_json::Value = pending_second_response
|
||||
.json()
|
||||
.await
|
||||
.expect("not-yet-published authorized model body should parse");
|
||||
assert_eq!(
|
||||
pending_second_payload["models"].as_array().map(Vec::len),
|
||||
Some(0)
|
||||
);
|
||||
assert_eq!(catalog_hits.load(Ordering::SeqCst), 1);
|
||||
|
||||
let captured_catalog_plan = captured_plans
|
||||
.lock()
|
||||
.expect("plans mutex")
|
||||
.iter()
|
||||
.find(|(url, _)| url.contains("/models?"))
|
||||
.cloned()
|
||||
.expect("catalog execution plan should be captured");
|
||||
assert_eq!(
|
||||
captured_catalog_plan.0,
|
||||
"https://chatgpt.example/backend-api/codex/models?client_version=0.145.2"
|
||||
);
|
||||
assert_eq!(
|
||||
captured_catalog_plan.1.as_deref(),
|
||||
Some("codex_cli_rs/0.145.2")
|
||||
);
|
||||
|
||||
let fresh_response = client
|
||||
.get(format!("{gateway_url}/v1/models?client_version=0.145.2"))
|
||||
.header("authorization", "Bearer sk-codex-models")
|
||||
.send()
|
||||
.await
|
||||
.expect("fresh Codex models request should succeed");
|
||||
assert_eq!(fresh_response.status(), StatusCode::OK);
|
||||
assert_eq!(catalog_hits.load(Ordering::SeqCst), 1);
|
||||
|
||||
tokio::time::sleep(std::time::Duration::from_millis(1_100)).await;
|
||||
catalog_generation.store(1, Ordering::SeqCst);
|
||||
let stale_started = std::time::Instant::now();
|
||||
let stale_response = client
|
||||
.get(format!("{gateway_url}/v1/models?client_version=0.145.2"))
|
||||
.header("authorization", "Bearer sk-codex-models")
|
||||
.send()
|
||||
.await
|
||||
.expect("stale Codex models request should succeed");
|
||||
assert_eq!(stale_response.status(), StatusCode::OK);
|
||||
assert!(stale_started.elapsed() < std::time::Duration::from_millis(400));
|
||||
let stale_payload: serde_json::Value = stale_response
|
||||
.json()
|
||||
.await
|
||||
.expect("stale body should parse");
|
||||
assert!(stale_payload["models"]
|
||||
.as_array()
|
||||
.is_some_and(|models| models.iter().any(|model| model["slug"] == "future-alias")));
|
||||
wait_until(1_000, || catalog_hits.load(Ordering::SeqCst) >= 2).await;
|
||||
|
||||
let failed_refresh_lkg_response = client
|
||||
.get(format!("{gateway_url}/v1/models?client_version=0.145.2"))
|
||||
.header("authorization", "Bearer sk-codex-models")
|
||||
.send()
|
||||
.await
|
||||
.expect("failed refresh should keep serving LKG");
|
||||
let failed_refresh_lkg_payload: serde_json::Value = failed_refresh_lkg_response
|
||||
.json()
|
||||
.await
|
||||
.expect("failed refresh LKG body should parse");
|
||||
assert!(failed_refresh_lkg_payload["models"]
|
||||
.as_array()
|
||||
.is_some_and(|models| models.iter().any(|model| model["slug"] == "future-alias")));
|
||||
assert_eq!(catalog_hits.load(Ordering::SeqCst), 2);
|
||||
|
||||
tokio::time::sleep(std::time::Duration::from_millis(150)).await;
|
||||
catalog_generation.store(2, Ordering::SeqCst);
|
||||
let refresh_trigger = client
|
||||
.get(format!("{gateway_url}/v1/models?client_version=0.145.2"))
|
||||
.header("authorization", "Bearer sk-codex-models")
|
||||
.send()
|
||||
.await
|
||||
.expect("recovered refresh trigger should succeed");
|
||||
assert_eq!(refresh_trigger.status(), StatusCode::OK);
|
||||
wait_until(1_000, || catalog_hits.load(Ordering::SeqCst) >= 3).await;
|
||||
|
||||
let updated_response = client
|
||||
.get(format!("{gateway_url}/v1/models?client_version=0.145.2"))
|
||||
.header("authorization", "Bearer sk-codex-second-mixed")
|
||||
.send()
|
||||
.await
|
||||
.expect("updated catalog request should succeed");
|
||||
assert_eq!(
|
||||
updated_response
|
||||
.headers()
|
||||
.get(http::header::ETAG)
|
||||
.and_then(|value| value.to_str().ok()),
|
||||
Some("\"catalog-etag-v2\"")
|
||||
);
|
||||
let updated_payload: serde_json::Value = updated_response
|
||||
.json()
|
||||
.await
|
||||
.expect("updated body should parse");
|
||||
assert!(updated_payload["models"]
|
||||
.as_array()
|
||||
.is_some_and(|models| models.iter().any(|model| {
|
||||
model["slug"] == "second-alias"
|
||||
&& model["future_capability"] == json!({"mode":"added-without-code-change"})
|
||||
})));
|
||||
assert!(updated_payload["models"]
|
||||
.as_array()
|
||||
.is_some_and(|models| models.iter().all(|model| {
|
||||
model["slug"] != "hidden-alias" && model["slug"] != "gpt-future-unmapped"
|
||||
})));
|
||||
|
||||
let stored_keys = provider_catalog_repository
|
||||
.list_keys_by_ids(&[CATALOG_KEY_ID.to_string()])
|
||||
.await
|
||||
.expect("catalog key should remain readable");
|
||||
assert_eq!(stored_keys.as_slice(), &[original_catalog_key]);
|
||||
let rows_after = candidate_repository
|
||||
.list_for_exact_api_format("openai:responses")
|
||||
.await
|
||||
.expect("candidate rows should load after request");
|
||||
assert_eq!(rows_after, rows_before);
|
||||
|
||||
let inference_response = client
|
||||
.post(format!("{gateway_url}/v1/responses"))
|
||||
.header(http::header::CONTENT_TYPE, "application/json")
|
||||
.header("authorization", "Bearer sk-codex-models")
|
||||
.body(r#"{"model":"future-alias","input":"hello","store":false}"#)
|
||||
.send()
|
||||
.await
|
||||
.expect("manual alias inference should succeed");
|
||||
assert_eq!(inference_response.status(), StatusCode::OK);
|
||||
let inference_payload: serde_json::Value = inference_response
|
||||
.json()
|
||||
.await
|
||||
.expect("inference body should parse");
|
||||
assert_eq!(inference_payload["model"], "gpt-future-dynamic");
|
||||
|
||||
let standard_response = client
|
||||
.get(format!("{gateway_url}/v1/models"))
|
||||
@@ -562,6 +1191,7 @@ async fn gateway_serves_codex_model_cards_for_versioned_models_requests() {
|
||||
assert!(standard_payload.get("models").is_none());
|
||||
|
||||
gateway_handle.abort();
|
||||
execution_runtime_handle.abort();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -695,7 +1325,7 @@ async fn gateway_returns_empty_openai_models_when_candidate_rows_stall() {
|
||||
let (gateway_url, gateway_handle) = start_server(gateway).await;
|
||||
|
||||
let response = reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_millis(500))
|
||||
.timeout(std::time::Duration::from_secs(3))
|
||||
.build()
|
||||
.expect("client should build")
|
||||
.get(format!("{gateway_url}/v1/models"))
|
||||
@@ -755,7 +1385,7 @@ async fn gateway_returns_not_found_for_openai_model_detail_when_candidate_rows_s
|
||||
let (gateway_url, gateway_handle) = start_server(gateway).await;
|
||||
|
||||
let response = reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_millis(500))
|
||||
.timeout(std::time::Duration::from_secs(3))
|
||||
.build()
|
||||
.expect("client should build")
|
||||
.get(format!("{gateway_url}/v1/models/gpt-stalled"))
|
||||
|
||||
@@ -188,9 +188,9 @@ pub use crate::formats::{
|
||||
apply_codex_openai_special_headers,
|
||||
apply_openai_responses_compact_special_body_edits,
|
||||
build_codex_model_catalog_metadata, parse_codex_auth_identity,
|
||||
resolve_codex_responses_model_capabilities, CodexAuthIdentity,
|
||||
CodexResponsesModelCapabilities, CODEX_OPENAI_IMAGE_DEFAULT_MODEL,
|
||||
CODEX_OPENAI_IMAGE_DEFAULT_OUTPUT_FORMAT,
|
||||
project_codex_catalog_model_card, resolve_codex_responses_model_capabilities,
|
||||
CodexAuthIdentity, CodexResponsesModelCapabilities,
|
||||
CODEX_OPENAI_IMAGE_DEFAULT_MODEL, CODEX_OPENAI_IMAGE_DEFAULT_OUTPUT_FORMAT,
|
||||
CODEX_OPENAI_IMAGE_DEFAULT_VARIATION_MODEL,
|
||||
CODEX_OPENAI_IMAGE_DEFAULT_VARIATION_PROMPT, CODEX_OPENAI_IMAGE_INTERNAL_MODEL,
|
||||
},
|
||||
|
||||
@@ -236,6 +236,40 @@ pub fn build_codex_model_catalog_metadata(cards: &[Value]) -> Value {
|
||||
})
|
||||
}
|
||||
|
||||
/// Projects an upstream Codex catalog card onto an authorized Aether model name.
|
||||
///
|
||||
/// Catalog cards are versioned by Codex and may gain fields that Aether does not know about. Keep
|
||||
/// the matching card opaque: only the Aether-internal identity fields are removed and `slug` is
|
||||
/// replaced with the downstream model name. A non-empty, exact `id` or `slug` match is the only
|
||||
/// schema requirement.
|
||||
pub fn project_codex_catalog_model_card(
|
||||
catalog_models: &[Value],
|
||||
source_model: &str,
|
||||
global_model: &str,
|
||||
) -> Option<Value> {
|
||||
if source_model.trim().is_empty() || global_model.trim().is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut card = catalog_models
|
||||
.iter()
|
||||
.filter_map(Value::as_object)
|
||||
.find(|card| {
|
||||
["id", "slug"].iter().any(|field| {
|
||||
card.get(*field)
|
||||
.and_then(Value::as_str)
|
||||
.filter(|identity| !identity.trim().is_empty())
|
||||
.is_some_and(|identity| identity == source_model)
|
||||
})
|
||||
})?
|
||||
.clone();
|
||||
|
||||
card.remove("id");
|
||||
card.remove("api_formats");
|
||||
card.insert("slug".to_string(), Value::String(global_model.to_string()));
|
||||
Some(Value::Object(card))
|
||||
}
|
||||
|
||||
fn codex_execution_model_card(card: &Value) -> Value {
|
||||
const EXCLUDED_FIELDS: [&str; 3] =
|
||||
["base_instructions", "model_messages", "available_in_plans"];
|
||||
@@ -1989,7 +2023,7 @@ mod tests {
|
||||
apply_codex_openai_responses_special_body_edits_with_source_model_and_capabilities,
|
||||
apply_codex_openai_special_headers, apply_openai_responses_compact_special_body_edits,
|
||||
build_codex_model_catalog_metadata, bundled_codex_model_cards, effective_codex_model_cards,
|
||||
resolve_codex_responses_model_capabilities,
|
||||
project_codex_catalog_model_card, resolve_codex_responses_model_capabilities,
|
||||
validate_codex_openai_responses_compact_request_contract, CODEX_CLIENT_ORIGINATOR,
|
||||
CODEX_CLIENT_USER_AGENT, CODEX_OPENAI_IMAGE_INTERNAL_MODEL,
|
||||
CODEX_OPENAI_RESPONSES_UNSUPPORTED_BODY_FIELDS, CODEX_RESPONSES_LITE_HEADER,
|
||||
@@ -2091,6 +2125,105 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn projects_current_codex_catalog_card_as_opaque_json() {
|
||||
let card = json!({
|
||||
"id": "upstream-internal-id",
|
||||
"slug": "gpt-future-dynamic",
|
||||
"api_formats": ["openai:responses"],
|
||||
"model_messages": {
|
||||
"instructions_template": "Current instructions for {{ personality }}"
|
||||
},
|
||||
"available_in_plans": ["plus", "pro"],
|
||||
"future_capability": {
|
||||
"mode": "native",
|
||||
"unknown_nested_field": [1, 2, 3]
|
||||
}
|
||||
});
|
||||
|
||||
let projected =
|
||||
project_codex_catalog_model_card(&[card], "gpt-future-dynamic", "future-model-alias")
|
||||
.expect("current Codex card should project");
|
||||
|
||||
assert_eq!(projected["slug"], "future-model-alias");
|
||||
assert!(projected.get("id").is_none());
|
||||
assert!(projected.get("api_formats").is_none());
|
||||
assert_eq!(
|
||||
projected["model_messages"]["instructions_template"],
|
||||
"Current instructions for {{ personality }}"
|
||||
);
|
||||
assert_eq!(projected["available_in_plans"], json!(["plus", "pro"]));
|
||||
assert_eq!(
|
||||
projected["future_capability"],
|
||||
json!({
|
||||
"mode": "native",
|
||||
"unknown_nested_field": [1, 2, 3]
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn projects_legacy_codex_catalog_card_by_id_without_known_schema_fields() {
|
||||
let card = json!({
|
||||
"id": "gpt-future-dynamic",
|
||||
"base_instructions": "Legacy instructions",
|
||||
"available_in_plans": ["team"],
|
||||
"future_capability": true
|
||||
});
|
||||
|
||||
let projected = project_codex_catalog_model_card(
|
||||
&[json!("not an object"), card],
|
||||
"gpt-future-dynamic",
|
||||
"legacy-model-alias",
|
||||
)
|
||||
.expect("legacy Codex card should project by id");
|
||||
|
||||
assert_eq!(
|
||||
projected,
|
||||
json!({
|
||||
"slug": "legacy-model-alias",
|
||||
"base_instructions": "Legacy instructions",
|
||||
"available_in_plans": ["team"],
|
||||
"future_capability": true
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codex_catalog_projection_requires_a_non_empty_exact_identity() {
|
||||
let cards = [
|
||||
json!(null),
|
||||
json!({}),
|
||||
json!({"id": "", "slug": " "}),
|
||||
json!({"slug": "gpt-future-dynamic-preview"}),
|
||||
];
|
||||
|
||||
assert!(project_codex_catalog_model_card(
|
||||
&cards,
|
||||
"gpt-future-dynamic",
|
||||
"future-model-alias"
|
||||
)
|
||||
.is_none());
|
||||
assert!(project_codex_catalog_model_card(
|
||||
&[json!({"slug": "gpt-future-dynamic"})],
|
||||
"",
|
||||
"future-model-alias"
|
||||
)
|
||||
.is_none());
|
||||
assert!(project_codex_catalog_model_card(
|
||||
&[json!({"slug": "gpt-future-dynamic"})],
|
||||
" gpt-future-dynamic ",
|
||||
"future-model-alias"
|
||||
)
|
||||
.is_none());
|
||||
assert!(project_codex_catalog_model_card(
|
||||
&[json!({"slug": "gpt-future-dynamic"})],
|
||||
"gpt-future-dynamic",
|
||||
" "
|
||||
)
|
||||
.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codex_responses_lite_header_is_omitted_for_server_side_compaction() {
|
||||
let mut headers = std::collections::BTreeMap::from([(
|
||||
|
||||
@@ -397,8 +397,8 @@ mod tests {
|
||||
|
||||
use super::{
|
||||
expand_previous_response_for_chat, hydrate_response_history,
|
||||
record_converted_response_history, response_history_storage_key, response_history_store,
|
||||
ResponseHistoryStore,
|
||||
record_converted_response_history, response_history_key, response_history_storage_key,
|
||||
response_history_store,
|
||||
};
|
||||
|
||||
fn conversion_report_context(original_request_body: serde_json::Value) -> serde_json::Value {
|
||||
@@ -496,9 +496,13 @@ mod tests {
|
||||
assert!(!record.storage_key.contains("distributed-history-key-a"));
|
||||
assert!(!record.storage_key.contains("resp_distributed_history_1"));
|
||||
|
||||
*response_history_store()
|
||||
response_history_store()
|
||||
.lock()
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner()) = ResponseHistoryStore::default();
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner())
|
||||
.remove(&response_history_key(
|
||||
"resp_distributed_history_1",
|
||||
Some("distributed-history-key-a"),
|
||||
));
|
||||
let continuation = json!({
|
||||
"model": "deepseek-v4-flash",
|
||||
"previous_response_id": "resp_distributed_history_1",
|
||||
|
||||
@@ -44,9 +44,10 @@ pub use formats::openai::request_contract::{
|
||||
};
|
||||
pub use formats::openai::responses::codex::{
|
||||
build_codex_model_catalog_metadata, bundled_codex_model_cards, effective_codex_model_cards,
|
||||
parse_codex_auth_identity, resolve_codex_responses_model_capabilities, CodexAuthIdentity,
|
||||
CodexResponsesModelCapabilities, CODEX_CLIENT_ORIGINATOR, CODEX_CLIENT_USER_AGENT,
|
||||
CODEX_CLIENT_VERSION, CODEX_MODEL_CATALOG_METADATA_FIELD, CODEX_RESPONSES_LITE_HEADER,
|
||||
parse_codex_auth_identity, project_codex_catalog_model_card,
|
||||
resolve_codex_responses_model_capabilities, CodexAuthIdentity, CodexResponsesModelCapabilities,
|
||||
CODEX_CLIENT_ORIGINATOR, CODEX_CLIENT_USER_AGENT, CODEX_CLIENT_VERSION,
|
||||
CODEX_MODEL_CATALOG_METADATA_FIELD, CODEX_RESPONSES_LITE_HEADER,
|
||||
};
|
||||
pub use formats::openai::responses::request::{
|
||||
validate_openai_responses_request_contract, OpenAiResponsesRequestContractViolation,
|
||||
|
||||
@@ -797,6 +797,18 @@ pub trait ProviderCatalogReadRepository: Send + Sync {
|
||||
key_ids: &[String],
|
||||
) -> Result<Vec<StoredProviderCatalogKey>, crate::DataLayerError>;
|
||||
|
||||
/// Reads credential-bearing key records without an intervening repository cache.
|
||||
///
|
||||
/// Callers use this only for security-sensitive generation fences where a short-lived cached
|
||||
/// key record could bind data to credentials that an administrator has already replaced.
|
||||
/// Repository implementations that do not add a read cache can use the default behavior.
|
||||
async fn list_keys_by_ids_strong(
|
||||
&self,
|
||||
key_ids: &[String],
|
||||
) -> Result<Vec<StoredProviderCatalogKey>, crate::DataLayerError> {
|
||||
self.list_keys_by_ids(key_ids).await
|
||||
}
|
||||
|
||||
async fn list_keys_by_provider_ids(
|
||||
&self,
|
||||
provider_ids: &[String],
|
||||
|
||||
@@ -12,22 +12,24 @@ pub use config::{
|
||||
};
|
||||
pub use logic::{
|
||||
aggregate_models_for_cache, apply_model_filters, build_models_fetch_url,
|
||||
deepseek_anthropic_models_fetch_uses_openai_auth, 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,
|
||||
build_models_fetch_url_for_client_version, deepseek_anthropic_models_fetch_uses_openai_auth,
|
||||
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,
|
||||
};
|
||||
pub use strategy::{
|
||||
fetch_models_from_transports, ModelFetchStrategy, ModelFetchStrategyKind, ModelsFetchOutcome,
|
||||
SelectedModelFetchStrategy,
|
||||
fetch_models_from_transports, fetch_models_from_transports_for_client_version,
|
||||
ModelFetchStrategy, ModelFetchStrategyKind, ModelsFetchOutcome, SelectedModelFetchStrategy,
|
||||
};
|
||||
pub use transport::{
|
||||
build_antigravity_fetch_available_models_plan, build_antigravity_load_code_assist_plan,
|
||||
build_gemini_cli_load_code_assist_plan, build_kiro_list_available_models_plan,
|
||||
build_models_fetch_execution_plan, build_standard_models_fetch_execution_plan,
|
||||
build_models_fetch_execution_plan, build_models_fetch_execution_plan_for_client_version,
|
||||
build_standard_models_fetch_execution_plan,
|
||||
build_standard_models_fetch_execution_plan_for_client_version,
|
||||
build_vertex_models_fetch_execution_plan, build_windsurf_model_configs_execution_plan,
|
||||
ModelFetchTransportRuntime,
|
||||
};
|
||||
|
||||
@@ -21,6 +21,9 @@ const MODEL_FETCH_FORMAT_PRIORITY: &[&[&str]] = &[
|
||||
&["gemini:generate_content"],
|
||||
];
|
||||
|
||||
pub(crate) const CODEX_MODELS_MAX_ITEMS: usize = 512;
|
||||
pub(crate) const CODEX_MODELS_MAX_JSON_BYTES: usize = 8 * 1024 * 1024;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct ModelFetchRunSummary {
|
||||
pub attempted: usize,
|
||||
@@ -66,6 +69,15 @@ pub fn build_models_fetch_url(
|
||||
provider_type: &str,
|
||||
endpoint_api_format: &str,
|
||||
base_url: &str,
|
||||
) -> Option<(String, String)> {
|
||||
build_models_fetch_url_for_client_version(provider_type, endpoint_api_format, base_url, None)
|
||||
}
|
||||
|
||||
pub fn build_models_fetch_url_for_client_version(
|
||||
provider_type: &str,
|
||||
endpoint_api_format: &str,
|
||||
base_url: &str,
|
||||
codex_client_version: Option<&str>,
|
||||
) -> Option<(String, String)> {
|
||||
let api_format = normalize_api_format(endpoint_api_format);
|
||||
if !endpoint_supports_rust_models_fetch(&api_format) {
|
||||
@@ -73,7 +85,7 @@ pub fn build_models_fetch_url(
|
||||
}
|
||||
let provider_type = provider_type.trim().to_ascii_lowercase();
|
||||
let url = if provider_type == "codex" && api_format.starts_with("openai:") {
|
||||
build_codex_models_url(base_url)
|
||||
build_codex_models_url(base_url, codex_client_version)
|
||||
} else if api_format.starts_with("openai:") {
|
||||
build_v1_models_url(base_url)
|
||||
} else if api_format.starts_with("claude:") {
|
||||
@@ -173,6 +185,142 @@ pub fn parse_models_response_page(
|
||||
})
|
||||
}
|
||||
|
||||
/// Parses the Codex `/models` response without applying the generic cache projection.
|
||||
///
|
||||
/// Codex model cards are versioned protocol data. They must remain opaque so future fields and
|
||||
/// instruction representations survive catalog caching and downstream projection. Invalid entries
|
||||
/// reject the whole response instead of being skipped and accidentally replacing a complete LKG
|
||||
/// with a partial directory.
|
||||
pub(crate) fn parse_codex_models_response_page(body: &Value) -> Result<ModelsFetchPage, String> {
|
||||
let serialized = serde_json::to_vec(body)
|
||||
.map_err(|_| "Codex models response could not be serialized".to_string())?;
|
||||
if serialized.len() > CODEX_MODELS_MAX_JSON_BYTES {
|
||||
return Err(format!(
|
||||
"Codex models response exceeds {CODEX_MODELS_MAX_JSON_BYTES} bytes"
|
||||
));
|
||||
}
|
||||
|
||||
let items = body
|
||||
.get("models")
|
||||
.and_then(Value::as_array)
|
||||
.ok_or_else(|| "Codex models response is missing models array".to_string())?;
|
||||
if items.is_empty() {
|
||||
return Err("Codex models response contains no models".to_string());
|
||||
}
|
||||
if items.len() > CODEX_MODELS_MAX_ITEMS {
|
||||
return Err(format!(
|
||||
"Codex models response exceeds {CODEX_MODELS_MAX_ITEMS} models"
|
||||
));
|
||||
}
|
||||
|
||||
let cached_models = merge_codex_models_preserving_cards(items)?;
|
||||
let fetched_model_ids = cached_models
|
||||
.iter()
|
||||
.filter_map(codex_model_identity)
|
||||
.map(ToOwned::to_owned)
|
||||
.collect();
|
||||
|
||||
Ok(ModelsFetchPage {
|
||||
fetched_model_ids,
|
||||
cached_models,
|
||||
has_more: false,
|
||||
next_after_id: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Merges opaque Codex model cards without silently selecting one of two conflicting cards.
|
||||
///
|
||||
/// Both `id` and `slug` are mapping identities. Exact duplicate JSON cards can occur when the
|
||||
/// same catalog is fetched through multiple endpoint transports and are collapsed. If any valid
|
||||
/// identity is reused by a different card, the response is ambiguous and must not replace a
|
||||
/// last-known-good catalog.
|
||||
pub(crate) fn merge_codex_models_preserving_cards(models: &[Value]) -> Result<Vec<Value>, String> {
|
||||
let mut merged = Vec::<Value>::with_capacity(models.len());
|
||||
let mut index_by_identity = BTreeMap::<String, usize>::new();
|
||||
|
||||
for model in models {
|
||||
let identities = codex_model_identities(model)?;
|
||||
let mut duplicate_index = None;
|
||||
for identity in &identities {
|
||||
let Some(existing_index) = index_by_identity.get(*identity).copied() else {
|
||||
continue;
|
||||
};
|
||||
if merged.get(existing_index) != Some(model) {
|
||||
return Err(format!(
|
||||
"Codex models response contains conflicting cards for identity '{identity}'"
|
||||
));
|
||||
}
|
||||
if duplicate_index.is_some_and(|index| index != existing_index) {
|
||||
return Err(format!(
|
||||
"Codex models response contains conflicting cards for identity '{identity}'"
|
||||
));
|
||||
}
|
||||
duplicate_index = Some(existing_index);
|
||||
}
|
||||
|
||||
if let Some(existing_index) = duplicate_index {
|
||||
for identity in identities {
|
||||
index_by_identity
|
||||
.entry(identity.to_string())
|
||||
.or_insert(existing_index);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
let model_index = merged.len();
|
||||
merged.push(model.clone());
|
||||
for identity in identities {
|
||||
index_by_identity.insert(identity.to_string(), model_index);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(merged)
|
||||
}
|
||||
|
||||
fn codex_model_identities(model: &Value) -> Result<Vec<&str>, String> {
|
||||
let object = model
|
||||
.as_object()
|
||||
.ok_or_else(|| "Codex models response contains a non-object model card".to_string())?;
|
||||
let mut identities = Vec::with_capacity(2);
|
||||
for field in ["id", "slug"] {
|
||||
let Some(identity) = object
|
||||
.get(field)
|
||||
.and_then(Value::as_str)
|
||||
.filter(|value| *value == value.trim())
|
||||
.filter(|value| valid_codex_model_identity(value))
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
if !identities.contains(&identity) {
|
||||
identities.push(identity);
|
||||
}
|
||||
}
|
||||
if identities.is_empty() {
|
||||
return Err("Codex models response contains a card without a valid id or slug".to_string());
|
||||
}
|
||||
Ok(identities)
|
||||
}
|
||||
|
||||
pub(crate) fn codex_model_identity(model: &Value) -> Option<&str> {
|
||||
let object = model.as_object()?;
|
||||
["id", "slug"].iter().find_map(|field| {
|
||||
object
|
||||
.get(*field)
|
||||
.and_then(Value::as_str)
|
||||
.filter(|value| *value == value.trim())
|
||||
.filter(|value| valid_codex_model_identity(value))
|
||||
})
|
||||
}
|
||||
|
||||
fn valid_codex_model_identity(value: &str) -> bool {
|
||||
!value.is_empty()
|
||||
&& value.len() <= 256
|
||||
&& value == value.trim()
|
||||
&& value
|
||||
.chars()
|
||||
.all(|character| !character.is_whitespace() && !character.is_control())
|
||||
}
|
||||
|
||||
pub fn parse_windsurf_model_configs_response(
|
||||
body: &Value,
|
||||
updated_at_unix_secs: u64,
|
||||
@@ -678,9 +826,17 @@ fn build_deepseek_anthropic_models_url(base_url: &str) -> Option<String> {
|
||||
Some(url)
|
||||
}
|
||||
|
||||
fn build_codex_models_url(base_url: &str) -> Option<String> {
|
||||
fn build_codex_models_url(base_url: &str, client_version: Option<&str>) -> Option<String> {
|
||||
if let Some(url) = build_bigmodel_coding_models_url(base_url) {
|
||||
return Some(url);
|
||||
return Some(
|
||||
client_version
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(|client_version| {
|
||||
replace_or_append_query_param(&url, "client_version", client_version)
|
||||
})
|
||||
.unwrap_or(url),
|
||||
);
|
||||
}
|
||||
|
||||
let (trimmed_base_url, query) = split_url_query(base_url);
|
||||
@@ -692,34 +848,77 @@ fn build_codex_models_url(base_url: &str) -> Option<String> {
|
||||
|| trimmed_base_url.ends_with("/codex")
|
||||
|| trimmed_base_url.ends_with("/models");
|
||||
if !is_codex_backend && openai_compatible_base_includes_unversioned_api_root(base_url) {
|
||||
return build_openai_compatible_models_url(base_url);
|
||||
let url = build_openai_compatible_models_url(base_url)?;
|
||||
return Some(
|
||||
client_version
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(|client_version| {
|
||||
replace_or_append_query_param(&url, "client_version", client_version)
|
||||
})
|
||||
.unwrap_or(url),
|
||||
);
|
||||
}
|
||||
let mut url = if trimmed_base_url.ends_with("/models") {
|
||||
trimmed_base_url.to_string()
|
||||
} else {
|
||||
format!("{trimmed_base_url}/models")
|
||||
};
|
||||
let mut has_client_version = false;
|
||||
if let Some(query) = query.filter(|value| !value.trim().is_empty()) {
|
||||
has_client_version = query.split('&').any(|part| {
|
||||
part.split_once('=')
|
||||
let explicit_client_version = client_version
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty());
|
||||
let mut query_parts = query
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.map(|value| value.split('&').map(ToOwned::to_owned).collect::<Vec<_>>())
|
||||
.unwrap_or_default();
|
||||
let has_client_version = query_parts.iter().any(|part| {
|
||||
part.split_once('=')
|
||||
.map(|(key, _)| key)
|
||||
.unwrap_or(part)
|
||||
.trim()
|
||||
.eq_ignore_ascii_case("client_version")
|
||||
});
|
||||
if let Some(client_version) = explicit_client_version {
|
||||
query_parts.retain(|part| {
|
||||
!part
|
||||
.split_once('=')
|
||||
.map(|(key, _)| key)
|
||||
.unwrap_or(part)
|
||||
.trim()
|
||||
.eq_ignore_ascii_case("client_version")
|
||||
});
|
||||
url.push('?');
|
||||
url.push_str(query);
|
||||
query_parts.push(format!("client_version={client_version}"));
|
||||
} else if !has_client_version {
|
||||
query_parts.push(format!(
|
||||
"client_version={}",
|
||||
aether_ai_formats::CODEX_CLIENT_VERSION
|
||||
));
|
||||
}
|
||||
if !has_client_version {
|
||||
let separator = if url.contains('?') { '&' } else { '?' };
|
||||
url.push(separator);
|
||||
url.push_str("client_version=");
|
||||
url.push_str(aether_ai_formats::CODEX_CLIENT_VERSION);
|
||||
if !query_parts.is_empty() {
|
||||
url.push('?');
|
||||
url.push_str(&query_parts.join("&"));
|
||||
}
|
||||
Some(url)
|
||||
}
|
||||
|
||||
fn replace_or_append_query_param(url: &str, name: &str, value: &str) -> String {
|
||||
let (base, query) = split_url_query(url);
|
||||
let mut query_parts = query
|
||||
.filter(|query| !query.trim().is_empty())
|
||||
.map(|query| query.split('&').map(ToOwned::to_owned).collect::<Vec<_>>())
|
||||
.unwrap_or_default();
|
||||
query_parts.retain(|part| {
|
||||
!part
|
||||
.split_once('=')
|
||||
.map(|(key, _)| key)
|
||||
.unwrap_or(part)
|
||||
.trim()
|
||||
.eq_ignore_ascii_case(name)
|
||||
});
|
||||
query_parts.push(format!("{name}={value}"));
|
||||
format!("{base}?{}", query_parts.join("&"))
|
||||
}
|
||||
|
||||
fn build_gemini_models_url(base_url: &str) -> Option<String> {
|
||||
let (trimmed_base_url, base_query) = split_url_query(base_url);
|
||||
let trimmed_base_url = trimmed_base_url.trim_end_matches('/');
|
||||
@@ -793,12 +992,13 @@ fn windsurf_json_f64(value: &Value) -> Option<f64> {
|
||||
fn collect_cached_model_ids(models: &[Value]) -> Vec<String> {
|
||||
let mut ids = Vec::new();
|
||||
for model in models {
|
||||
let Some(model_id) = model
|
||||
.get("id")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
else {
|
||||
let Some(model_id) = codex_model_identity(model).or_else(|| {
|
||||
model
|
||||
.get("id")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
}) else {
|
||||
continue;
|
||||
};
|
||||
ids.push(model_id.to_string());
|
||||
@@ -880,8 +1080,9 @@ mod tests {
|
||||
|
||||
use super::{
|
||||
aggregate_models_for_cache, apply_model_filters, build_gemini_models_url,
|
||||
build_models_fetch_url, merge_upstream_metadata, parse_models_response,
|
||||
parse_models_response_page, preset_models_for_provider, selected_models_fetch_endpoints,
|
||||
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,
|
||||
};
|
||||
|
||||
fn sample_endpoint(
|
||||
@@ -1036,6 +1237,56 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_models_fetch_url_uses_explicit_codex_client_version() {
|
||||
assert_eq!(
|
||||
build_models_fetch_url_for_client_version(
|
||||
"codex",
|
||||
"openai:responses",
|
||||
"https://chatgpt.com/backend-api/codex",
|
||||
Some("0.145.2"),
|
||||
),
|
||||
Some((
|
||||
"https://chatgpt.com/backend-api/codex/models?client_version=0.145.2".to_string(),
|
||||
"openai:responses".to_string()
|
||||
))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_codex_client_version_replaces_stale_base_query_value() {
|
||||
assert_eq!(
|
||||
build_models_fetch_url_for_client_version(
|
||||
"codex",
|
||||
"openai:responses",
|
||||
"https://chatgpt.com/backend-api/codex?feature=on&client_version=0.144.1",
|
||||
Some("0.145.2"),
|
||||
),
|
||||
Some((
|
||||
"https://chatgpt.com/backend-api/codex/models?feature=on&client_version=0.145.2"
|
||||
.to_string(),
|
||||
"openai:responses".to_string()
|
||||
))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_codex_client_version_is_forwarded_through_compatible_proxy_roots() {
|
||||
assert_eq!(
|
||||
build_models_fetch_url_for_client_version(
|
||||
"codex",
|
||||
"openai:responses",
|
||||
"https://proxy.example.com/api?feature=on&client_version=0.144.1",
|
||||
Some("0.145.2"),
|
||||
),
|
||||
Some((
|
||||
"https://proxy.example.com/api/models?feature=on&client_version=0.145.2"
|
||||
.to_string(),
|
||||
"openai:responses".to_string()
|
||||
))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_models_fetch_url_supports_bigmodel_coding_paas_root() {
|
||||
assert_eq!(
|
||||
@@ -1060,6 +1311,19 @@ mod tests {
|
||||
"openai:responses".to_string()
|
||||
))
|
||||
);
|
||||
assert_eq!(
|
||||
build_models_fetch_url_for_client_version(
|
||||
"codex",
|
||||
"openai:responses",
|
||||
"https://open.bigmodel.cn/api/coding/paas/v4?tenant=demo&client_version=0.144.1",
|
||||
Some("0.145.2"),
|
||||
),
|
||||
Some((
|
||||
"https://open.bigmodel.cn/api/coding/paas/v4/models?tenant=demo&client_version=0.145.2"
|
||||
.to_string(),
|
||||
"openai:responses".to_string()
|
||||
))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1185,6 +1449,97 @@ mod tests {
|
||||
assert_eq!(cached["api_formats"], json!(["openai:responses"]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strict_codex_parser_preserves_opaque_cards_without_cache_projection() {
|
||||
let card = json!({
|
||||
"id": "gpt-future-dynamic",
|
||||
"slug": "gpt-future-dynamic",
|
||||
"api_format": "future-protocol-field",
|
||||
"model_messages": {"instructions_template": "Future instructions"},
|
||||
"available_in_plans": ["plus"],
|
||||
"future_capability": {"opaque": true}
|
||||
});
|
||||
let parsed = parse_codex_models_response_page(&json!({"models": [card.clone()]}))
|
||||
.expect("opaque Codex card should parse");
|
||||
|
||||
assert_eq!(parsed.fetched_model_ids, vec!["gpt-future-dynamic"]);
|
||||
assert_eq!(parsed.cached_models, vec![card]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strict_codex_parser_rejects_empty_models_array() {
|
||||
let error = parse_codex_models_response_page(&json!({"models": []}))
|
||||
.expect_err("empty Codex catalog must fail");
|
||||
|
||||
assert!(error.contains("no models"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strict_codex_parser_merges_only_exact_duplicate_cards() {
|
||||
let card = json!({
|
||||
"id": "gpt-future-duplicate",
|
||||
"slug": "gpt-future-duplicate",
|
||||
"model_messages": {"instructions_template": "Opaque instructions"},
|
||||
"future_capability": {"opaque": true}
|
||||
});
|
||||
let parsed = parse_codex_models_response_page(&json!({
|
||||
"models": [card.clone(), card.clone()]
|
||||
}))
|
||||
.expect("exact duplicate Codex cards should merge");
|
||||
|
||||
assert_eq!(parsed.fetched_model_ids, vec!["gpt-future-duplicate"]);
|
||||
assert_eq!(parsed.cached_models, vec![card]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strict_codex_parser_rejects_same_or_cross_identity_conflicts() {
|
||||
let conflicts = [
|
||||
json!({
|
||||
"models": [
|
||||
{"id": "gpt-conflict", "slug": "gpt-conflict", "future": 1},
|
||||
{"id": "gpt-conflict", "slug": "gpt-conflict", "future": 2}
|
||||
]
|
||||
}),
|
||||
json!({
|
||||
"models": [
|
||||
{"id": "gpt-id-one", "slug": "gpt-cross-identity", "future": 1},
|
||||
{"id": "gpt-cross-identity", "slug": "gpt-slug-two", "future": 2}
|
||||
]
|
||||
}),
|
||||
];
|
||||
|
||||
for body in conflicts {
|
||||
let error = parse_codex_models_response_page(&body)
|
||||
.expect_err("ambiguous Codex identities must fail");
|
||||
assert!(error.contains("conflicting cards"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strict_codex_parser_rejects_non_object_or_synthetic_identity_cards() {
|
||||
for body in [
|
||||
json!({"models": ["gpt-future-dynamic"]}),
|
||||
json!({"models": [{"model": "gpt-future-dynamic"}]}),
|
||||
json!({"models": [{"name": "gpt-future-dynamic"}]}),
|
||||
json!({"models": [{"slug": " gpt-future-dynamic "}]}),
|
||||
] {
|
||||
assert!(parse_codex_models_response_page(&body).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strict_codex_parser_does_not_impose_an_ascii_symbol_allowlist_on_identities() {
|
||||
let card = json!({
|
||||
"slug": "gpt+future@dynamic",
|
||||
"model_messages": {"instructions_template": "Future instructions"}
|
||||
});
|
||||
let parsed = parse_codex_models_response_page(&json!({"models": [card.clone()]}))
|
||||
.expect("future identity punctuation should remain opaque");
|
||||
|
||||
assert_eq!(parsed.fetched_model_ids, vec!["gpt+future@dynamic"]);
|
||||
assert_eq!(parsed.cached_models, vec![card]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_models_response_page_reads_claude_pagination_state() {
|
||||
let parsed = parse_models_response_page(
|
||||
|
||||
@@ -20,14 +20,16 @@ use serde_json::{json, Value};
|
||||
use sha2::Sha256;
|
||||
|
||||
use crate::logic::{
|
||||
aggregate_models_for_cache, extract_error_message, parse_models_response_page,
|
||||
parse_windsurf_model_configs_response, preset_models_for_provider,
|
||||
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,
|
||||
};
|
||||
use crate::transport::{
|
||||
build_antigravity_fetch_available_models_plan, build_antigravity_load_code_assist_plan,
|
||||
build_gemini_cli_load_code_assist_plan, build_kiro_list_available_models_plan,
|
||||
build_standard_models_fetch_execution_plan, build_vertex_models_fetch_execution_plan,
|
||||
build_windsurf_model_configs_execution_plan, ModelFetchTransportRuntime,
|
||||
build_standard_models_fetch_execution_plan_for_client_version,
|
||||
build_vertex_models_fetch_execution_plan, build_windsurf_model_configs_execution_plan,
|
||||
ModelFetchTransportRuntime,
|
||||
};
|
||||
|
||||
const ANTIGRAVITY_SANDBOX_BASE_URL: &str = "https://daily-cloudcode-pa.sandbox.googleapis.com";
|
||||
@@ -48,6 +50,46 @@ pub struct ModelsFetchOutcome {
|
||||
pub errors: Vec<String>,
|
||||
pub has_success: bool,
|
||||
pub upstream_metadata: Option<Value>,
|
||||
pub etag: Option<String>,
|
||||
pub upstream_status: Option<u16>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct ConsistentValue<T> {
|
||||
value: Option<T>,
|
||||
observed: bool,
|
||||
consistent: bool,
|
||||
}
|
||||
|
||||
impl<T> Default for ConsistentValue<T> {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
value: None,
|
||||
observed: false,
|
||||
consistent: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: PartialEq> ConsistentValue<T> {
|
||||
fn observe(&mut self, candidate: Option<T>) {
|
||||
if !self.observed {
|
||||
self.consistent = candidate.is_some();
|
||||
self.value = candidate;
|
||||
self.observed = true;
|
||||
return;
|
||||
}
|
||||
if self.value.as_ref() != candidate.as_ref() {
|
||||
self.consistent = false;
|
||||
self.value = None;
|
||||
}
|
||||
}
|
||||
|
||||
fn finish(self) -> Option<T> {
|
||||
(self.observed && self.consistent)
|
||||
.then_some(self.value)
|
||||
.flatten()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
@@ -87,9 +129,17 @@ impl ModelFetchStrategy for SelectedModelFetchStrategy {
|
||||
pub async fn fetch_models_from_transports(
|
||||
runtime: &(impl ModelFetchTransportRuntime + ?Sized),
|
||||
transports: &[GatewayProviderTransportSnapshot],
|
||||
) -> Result<ModelsFetchOutcome, String> {
|
||||
fetch_models_from_transports_for_client_version(runtime, transports, None).await
|
||||
}
|
||||
|
||||
pub async fn fetch_models_from_transports_for_client_version(
|
||||
runtime: &(impl ModelFetchTransportRuntime + ?Sized),
|
||||
transports: &[GatewayProviderTransportSnapshot],
|
||||
codex_client_version: Option<&str>,
|
||||
) -> Result<ModelsFetchOutcome, String> {
|
||||
let strategy = select_model_fetch_strategy(transports)?;
|
||||
execute_model_fetch_strategy(runtime, transports, strategy).await
|
||||
execute_model_fetch_strategy(runtime, transports, strategy, codex_client_version).await
|
||||
}
|
||||
|
||||
fn select_model_fetch_strategy(
|
||||
@@ -158,6 +208,7 @@ async fn execute_model_fetch_strategy(
|
||||
runtime: &(impl ModelFetchTransportRuntime + ?Sized),
|
||||
transports: &[GatewayProviderTransportSnapshot],
|
||||
strategy: SelectedModelFetchStrategy,
|
||||
codex_client_version: Option<&str>,
|
||||
) -> Result<ModelsFetchOutcome, String> {
|
||||
let Some(first_transport) = transports.first() else {
|
||||
return Err("No transport snapshots available for models fetch".to_string());
|
||||
@@ -170,7 +221,13 @@ async fn execute_model_fetch_strategy(
|
||||
true,
|
||||
)),
|
||||
ModelFetchStrategyKind::StandardTransport => {
|
||||
fetch_standard_models(runtime, transports, strategy.provider_id()).await
|
||||
fetch_standard_models(
|
||||
runtime,
|
||||
transports,
|
||||
strategy.provider_id(),
|
||||
codex_client_version,
|
||||
)
|
||||
.await
|
||||
}
|
||||
ModelFetchStrategyKind::Vertex => fetch_vertex_models(runtime, transports).await,
|
||||
ModelFetchStrategyKind::Antigravity => {
|
||||
@@ -193,60 +250,110 @@ async fn fetch_standard_models(
|
||||
runtime: &(impl ModelFetchTransportRuntime + ?Sized),
|
||||
transports: &[GatewayProviderTransportSnapshot],
|
||||
provider_type: &str,
|
||||
codex_client_version: Option<&str>,
|
||||
) -> Result<ModelsFetchOutcome, String> {
|
||||
let mut all_models = Vec::new();
|
||||
let mut errors = Vec::new();
|
||||
let mut has_success = false;
|
||||
let mut etag = ConsistentValue::default();
|
||||
let mut upstream_status = ConsistentValue::default();
|
||||
|
||||
for transport in transports {
|
||||
match fetch_standard_models_for_transport(runtime, transport).await {
|
||||
match fetch_standard_models_for_transport(runtime, transport, codex_client_version).await {
|
||||
Ok(outcome) => {
|
||||
all_models.extend(outcome.cached_models);
|
||||
has_success |= outcome.has_success;
|
||||
if outcome.has_success {
|
||||
etag.observe(outcome.etag);
|
||||
upstream_status.observe(outcome.upstream_status);
|
||||
}
|
||||
}
|
||||
Err((err, status)) => {
|
||||
upstream_status.observe(status);
|
||||
errors.push(format!("{}: {err}", transport.endpoint.api_format.trim()));
|
||||
}
|
||||
Err(err) => errors.push(format!("{}: {err}", transport.endpoint.api_format.trim())),
|
||||
}
|
||||
}
|
||||
|
||||
let merged_models = aggregate_models_for_cache(&all_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 {
|
||||
aggregate_models_for_cache(&all_models)
|
||||
};
|
||||
let codex_model_ids = is_codex.then(|| collect_codex_model_ids(&merged_models));
|
||||
let upstream_metadata =
|
||||
crate::logic::model_catalog_upstream_metadata(provider_type, &merged_models);
|
||||
Ok(build_success_outcome(merged_models, upstream_metadata, has_success).with_errors(errors))
|
||||
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;
|
||||
}
|
||||
Ok(outcome
|
||||
.with_errors(errors)
|
||||
.with_etag(etag.finish())
|
||||
.with_upstream_status(upstream_status.finish()))
|
||||
}
|
||||
|
||||
async fn fetch_standard_models_for_transport(
|
||||
runtime: &(impl ModelFetchTransportRuntime + ?Sized),
|
||||
transport: &GatewayProviderTransportSnapshot,
|
||||
) -> Result<ModelsFetchOutcome, String> {
|
||||
codex_client_version: Option<&str>,
|
||||
) -> Result<ModelsFetchOutcome, (String, Option<u16>)> {
|
||||
let mut all_models = Vec::new();
|
||||
let mut seen_ids = BTreeSet::new();
|
||||
let mut next_after_id = None;
|
||||
let mut has_success = false;
|
||||
let mut etag = ConsistentValue::default();
|
||||
let mut upstream_status = ConsistentValue::default();
|
||||
let is_codex = transport
|
||||
.provider
|
||||
.provider_type
|
||||
.trim()
|
||||
.eq_ignore_ascii_case("codex");
|
||||
|
||||
for _ in 0..20 {
|
||||
let plan = build_standard_models_fetch_execution_plan(
|
||||
let plan = build_standard_models_fetch_execution_plan_for_client_version(
|
||||
runtime,
|
||||
transport,
|
||||
next_after_id.as_deref(),
|
||||
codex_client_version,
|
||||
)
|
||||
.await?;
|
||||
let result = runtime.execute_model_fetch_execution_plan(&plan).await?;
|
||||
let body_json = execution_result_json_body(&result)?;
|
||||
let parsed = parse_models_response_page(&transport.endpoint.api_format, &body_json)?;
|
||||
.await
|
||||
.map_err(|err| (err, None))?;
|
||||
let result = runtime
|
||||
.execute_model_fetch_execution_plan(&plan)
|
||||
.await
|
||||
.map_err(|err| (err, None))?;
|
||||
upstream_status.observe(Some(result.status_code));
|
||||
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)
|
||||
} else {
|
||||
parse_models_response_page(&transport.endpoint.api_format, &body_json)
|
||||
}
|
||||
.map_err(|err| (err, Some(result.status_code)))?;
|
||||
etag.observe(execution_result_header(&result, "etag"));
|
||||
has_success = true;
|
||||
for model in parsed.cached_models {
|
||||
let Some(model_id) = model
|
||||
.get("id")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
if !seen_ids.insert(model_id.to_string()) {
|
||||
continue;
|
||||
if is_codex {
|
||||
// Preserve every opaque card until the catalog-wide merge can distinguish exact
|
||||
// duplicates from conflicting `id`/`slug` identities across endpoint transports.
|
||||
all_models.extend(parsed.cached_models);
|
||||
} else {
|
||||
for model in parsed.cached_models {
|
||||
let Some(model_id) = model
|
||||
.get("id")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
if !seen_ids.insert(model_id.to_string()) {
|
||||
continue;
|
||||
}
|
||||
all_models.push(model);
|
||||
}
|
||||
all_models.push(model);
|
||||
}
|
||||
|
||||
let Some(next_cursor) = parsed
|
||||
@@ -260,7 +367,9 @@ async fn fetch_standard_models_for_transport(
|
||||
next_after_id = Some(next_cursor);
|
||||
}
|
||||
|
||||
Ok(build_success_outcome(all_models, None, has_success))
|
||||
Ok(build_success_outcome(all_models, None, has_success)
|
||||
.with_etag(etag.finish())
|
||||
.with_upstream_status(upstream_status.finish()))
|
||||
}
|
||||
|
||||
async fn fetch_antigravity_models(
|
||||
@@ -319,6 +428,8 @@ async fn fetch_antigravity_models(
|
||||
errors,
|
||||
has_success: false,
|
||||
upstream_metadata: None,
|
||||
etag: None,
|
||||
upstream_status: None,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -499,6 +610,8 @@ async fn fetch_vertex_api_key_models(
|
||||
errors: vec!["vertex_ai(api_key): missing api key".to_string()],
|
||||
has_success: false,
|
||||
upstream_metadata: None,
|
||||
etag: None,
|
||||
upstream_status: None,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -556,6 +669,8 @@ async fn fetch_vertex_api_key_models(
|
||||
errors,
|
||||
has_success,
|
||||
upstream_metadata: None,
|
||||
etag: None,
|
||||
upstream_status: None,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -571,6 +686,8 @@ async fn fetch_vertex_service_account_models(
|
||||
errors: vec!["vertex_ai(service_account): missing auth_config".to_string()],
|
||||
has_success: false,
|
||||
upstream_metadata: None,
|
||||
etag: None,
|
||||
upstream_status: None,
|
||||
});
|
||||
};
|
||||
let token = exchange_vertex_service_account_token(runtime, &transports[0], auth_config).await?;
|
||||
@@ -639,6 +756,8 @@ async fn fetch_vertex_service_account_models(
|
||||
errors,
|
||||
has_success,
|
||||
upstream_metadata: None,
|
||||
etag: None,
|
||||
upstream_status: None,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -822,8 +941,18 @@ fn execution_result_json_body_allow_empty(result: &ExecutionResult) -> Result<Va
|
||||
.ok_or_else(|| "models fetch response body is missing JSON payload".to_string())
|
||||
}
|
||||
|
||||
fn execution_result_error_message(result: &ExecutionResult) -> String {
|
||||
fn execution_result_header(result: &ExecutionResult, name: &str) -> Option<String> {
|
||||
result
|
||||
.headers
|
||||
.iter()
|
||||
.find(|(header_name, _)| header_name.eq_ignore_ascii_case(name))
|
||||
.map(|(_, value)| value.trim())
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToOwned::to_owned)
|
||||
}
|
||||
|
||||
fn execution_result_error_message(result: &ExecutionResult) -> String {
|
||||
let detail = result
|
||||
.body
|
||||
.as_ref()
|
||||
.and_then(|body| body.json_body.as_ref())
|
||||
@@ -833,8 +962,14 @@ fn execution_result_error_message(result: &ExecutionResult) -> String {
|
||||
let message = error.message.trim();
|
||||
(!message.is_empty()).then_some(message.to_string())
|
||||
})
|
||||
})
|
||||
.unwrap_or_else(|| format!("HTTP {}: upstream request failed", result.status_code))
|
||||
});
|
||||
match detail {
|
||||
Some(detail) if !(200..300).contains(&result.status_code) => {
|
||||
format!("HTTP {}: {detail}", result.status_code)
|
||||
}
|
||||
Some(detail) => detail,
|
||||
None => format!("HTTP {}: upstream request failed", result.status_code),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_antigravity_models_response(body: &Value) -> Result<(Vec<Value>, Option<Value>), String> {
|
||||
@@ -1238,6 +1373,8 @@ fn build_success_outcome(
|
||||
errors: Vec::new(),
|
||||
has_success,
|
||||
upstream_metadata,
|
||||
etag: None,
|
||||
upstream_status: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1260,6 +1397,16 @@ fn collect_model_ids(models: &[Value]) -> Vec<String> {
|
||||
ids
|
||||
}
|
||||
|
||||
fn collect_codex_model_ids(models: &[Value]) -> Vec<String> {
|
||||
let mut seen = BTreeSet::new();
|
||||
models
|
||||
.iter()
|
||||
.filter_map(codex_model_identity)
|
||||
.filter(|model_id| seen.insert((*model_id).to_string()))
|
||||
.map(ToOwned::to_owned)
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn transport_auth_config(transport: &GatewayProviderTransportSnapshot) -> Option<Value> {
|
||||
transport
|
||||
.key
|
||||
@@ -1397,6 +1544,8 @@ fn now_unix_secs() -> u64 {
|
||||
|
||||
trait OutcomeExt {
|
||||
fn with_errors(self, errors: Vec<String>) -> Self;
|
||||
fn with_etag(self, etag: Option<String>) -> Self;
|
||||
fn with_upstream_status(self, upstream_status: Option<u16>) -> Self;
|
||||
}
|
||||
|
||||
impl OutcomeExt for ModelsFetchOutcome {
|
||||
@@ -1404,6 +1553,16 @@ impl OutcomeExt for ModelsFetchOutcome {
|
||||
self.errors = errors;
|
||||
self
|
||||
}
|
||||
|
||||
fn with_etag(mut self, etag: Option<String>) -> Self {
|
||||
self.etag = etag;
|
||||
self
|
||||
}
|
||||
|
||||
fn with_upstream_status(mut self, upstream_status: Option<u16>) -> Self {
|
||||
self.upstream_status = upstream_status;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -1423,8 +1582,8 @@ mod tests {
|
||||
build_vertex_google_list_url, build_vertex_service_account_list_url,
|
||||
select_model_fetch_strategy, ModelFetchStrategy, ModelFetchStrategyKind,
|
||||
};
|
||||
use crate::fetch_models_from_transports;
|
||||
use crate::transport::ModelFetchTransportRuntime;
|
||||
use crate::{fetch_models_from_transports, fetch_models_from_transports_for_client_version};
|
||||
|
||||
type RouteResult = Result<(u16, Value), String>;
|
||||
type ModelFetchRoute = (String, RouteResult);
|
||||
@@ -1433,6 +1592,7 @@ mod tests {
|
||||
executed_urls: Arc<Mutex<Vec<String>>>,
|
||||
response_body: Value,
|
||||
status_code: u16,
|
||||
response_headers: BTreeMap<String, String>,
|
||||
}
|
||||
|
||||
struct RoutingTestRuntime {
|
||||
@@ -1474,7 +1634,7 @@ mod tests {
|
||||
request_id: plan.request_id.clone(),
|
||||
candidate_id: plan.candidate_id.clone(),
|
||||
status_code: self.status_code,
|
||||
headers: BTreeMap::new(),
|
||||
headers: self.response_headers.clone(),
|
||||
response_observation: None,
|
||||
body: Some(ResponseBody {
|
||||
json_body: Some(self.response_body.clone()),
|
||||
@@ -1666,6 +1826,16 @@ mod tests {
|
||||
transport
|
||||
}
|
||||
|
||||
fn sample_codex_transport_for_base(
|
||||
endpoint_id: &str,
|
||||
base_url: &str,
|
||||
) -> GatewayProviderTransportSnapshot {
|
||||
let mut transport = sample_codex_transport();
|
||||
transport.endpoint.id = endpoint_id.to_string();
|
||||
transport.endpoint.base_url = base_url.to_string();
|
||||
transport
|
||||
}
|
||||
|
||||
fn sample_kiro_transport() -> GatewayProviderTransportSnapshot {
|
||||
let mut transport = sample_custom_aiplatform_transport();
|
||||
transport.provider.provider_type = "kiro".to_string();
|
||||
@@ -1801,6 +1971,7 @@ mod tests {
|
||||
}]
|
||||
}),
|
||||
status_code: 200,
|
||||
response_headers: BTreeMap::new(),
|
||||
};
|
||||
let outcome =
|
||||
fetch_models_from_transports(&runtime, &[sample_custom_aiplatform_transport()])
|
||||
@@ -2004,24 +2175,40 @@ mod tests {
|
||||
"models": [{
|
||||
"id": "gpt-5.6-future",
|
||||
"slug": "gpt-5.6-future",
|
||||
"api_format": "opaque-future-field",
|
||||
"default_reasoning_level": "high",
|
||||
"supported_reasoning_levels": [{"effort": "high"}],
|
||||
"future_capability": {"mode": "preserve-me"}
|
||||
}]
|
||||
}),
|
||||
status_code: 200,
|
||||
response_headers: BTreeMap::from([(
|
||||
"ETag".to_string(),
|
||||
"\"codex-models-0.145.2\"".to_string(),
|
||||
)]),
|
||||
};
|
||||
let outcome = fetch_models_from_transports(&runtime, &[sample_codex_transport()])
|
||||
.await
|
||||
.expect("models fetch should succeed");
|
||||
let outcome = fetch_models_from_transports_for_client_version(
|
||||
&runtime,
|
||||
&[sample_codex_transport()],
|
||||
Some("0.145.2"),
|
||||
)
|
||||
.await
|
||||
.expect("models fetch should succeed");
|
||||
|
||||
let urls = executed_urls.lock().expect("executed_urls lock");
|
||||
assert_eq!(
|
||||
urls.as_slice(),
|
||||
&["https://chatgpt.com/backend-api/codex/models?client_version=0.144.1"]
|
||||
&["https://chatgpt.com/backend-api/codex/models?client_version=0.145.2"]
|
||||
);
|
||||
assert_eq!(outcome.etag.as_deref(), Some("\"codex-models-0.145.2\""));
|
||||
assert_eq!(outcome.upstream_status, Some(200));
|
||||
assert_eq!(outcome.fetched_model_ids, vec!["gpt-5.6-future"]);
|
||||
assert_eq!(outcome.cached_models.len(), 1);
|
||||
assert_eq!(
|
||||
outcome.cached_models[0]["api_format"],
|
||||
"opaque-future-field"
|
||||
);
|
||||
assert!(outcome.cached_models[0].get("api_formats").is_none());
|
||||
let card = &outcome
|
||||
.upstream_metadata
|
||||
.as_ref()
|
||||
@@ -2030,6 +2217,159 @@ mod tests {
|
||||
assert_eq!(card["future_capability"]["mode"], "preserve-me");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn codex_transport_reports_slug_only_ids_without_rewriting_opaque_cards() {
|
||||
let executed_urls = Arc::new(Mutex::new(Vec::new()));
|
||||
let card = json!({
|
||||
"slug": "gpt-slug-only-future",
|
||||
"model_messages": {"instructions_template": "Slug-only instructions"},
|
||||
"future_capability": {"opaque": true}
|
||||
});
|
||||
let runtime = TestRuntime {
|
||||
executed_urls,
|
||||
response_body: json!({"models": [card.clone()]}),
|
||||
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("slug-only Codex card should fetch");
|
||||
|
||||
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());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn codex_transport_merges_exact_duplicate_cards_across_endpoints() {
|
||||
let executed_urls = Arc::new(Mutex::new(Vec::new()));
|
||||
let card = json!({
|
||||
"id": "gpt-exact-duplicate",
|
||||
"slug": "gpt-exact-duplicate",
|
||||
"model_messages": {"instructions_template": "Opaque instructions"},
|
||||
"future_capability": {"opaque": true}
|
||||
});
|
||||
let runtime = RoutingTestRuntime {
|
||||
executed_urls,
|
||||
routes: vec![
|
||||
(
|
||||
"first.example.com/backend-api/codex/models".to_string(),
|
||||
Ok((200, json!({"models": [card.clone()]}))),
|
||||
),
|
||||
(
|
||||
"second.example.com/backend-api/codex/models".to_string(),
|
||||
Ok((200, json!({"models": [card.clone()]}))),
|
||||
),
|
||||
],
|
||||
};
|
||||
let transports = vec![
|
||||
sample_codex_transport_for_base(
|
||||
"endpoint-first",
|
||||
"https://first.example.com/backend-api/codex",
|
||||
),
|
||||
sample_codex_transport_for_base(
|
||||
"endpoint-second",
|
||||
"https://second.example.com/backend-api/codex",
|
||||
),
|
||||
];
|
||||
|
||||
let outcome =
|
||||
fetch_models_from_transports_for_client_version(&runtime, &transports, Some("0.145.2"))
|
||||
.await
|
||||
.expect("exact duplicate endpoint catalogs should merge");
|
||||
|
||||
assert!(outcome.has_success);
|
||||
assert!(outcome.errors.is_empty());
|
||||
assert_eq!(outcome.fetched_model_ids, vec!["gpt-exact-duplicate"]);
|
||||
assert_eq!(outcome.cached_models, vec![card]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn codex_transport_rejects_cross_identity_conflicts_across_endpoints() {
|
||||
let executed_urls = Arc::new(Mutex::new(Vec::new()));
|
||||
let runtime = RoutingTestRuntime {
|
||||
executed_urls,
|
||||
routes: vec![
|
||||
(
|
||||
"first.example.com/backend-api/codex/models".to_string(),
|
||||
Ok((
|
||||
200,
|
||||
json!({
|
||||
"models": [{
|
||||
"id": "gpt-id-one",
|
||||
"slug": "gpt-cross-identity",
|
||||
"future_capability": {"source": "first"}
|
||||
}]
|
||||
}),
|
||||
)),
|
||||
),
|
||||
(
|
||||
"second.example.com/backend-api/codex/models".to_string(),
|
||||
Ok((
|
||||
200,
|
||||
json!({
|
||||
"models": [{
|
||||
"id": "gpt-cross-identity",
|
||||
"slug": "gpt-slug-two",
|
||||
"future_capability": {"source": "second"}
|
||||
}]
|
||||
}),
|
||||
)),
|
||||
),
|
||||
],
|
||||
};
|
||||
let transports = vec![
|
||||
sample_codex_transport_for_base(
|
||||
"endpoint-first",
|
||||
"https://first.example.com/backend-api/codex",
|
||||
),
|
||||
sample_codex_transport_for_base(
|
||||
"endpoint-second",
|
||||
"https://second.example.com/backend-api/codex",
|
||||
),
|
||||
];
|
||||
|
||||
let error =
|
||||
fetch_models_from_transports_for_client_version(&runtime, &transports, Some("0.145.2"))
|
||||
.await
|
||||
.expect_err("conflicting endpoint catalogs must fail");
|
||||
|
||||
assert!(error.contains("conflicting cards"));
|
||||
assert!(error.contains("gpt-cross-identity"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn codex_transport_reports_non_success_upstream_status() {
|
||||
let executed_urls = Arc::new(Mutex::new(Vec::new()));
|
||||
let runtime = TestRuntime {
|
||||
executed_urls,
|
||||
response_body: json!({
|
||||
"error": { "message": "temporarily unavailable" }
|
||||
}),
|
||||
status_code: 503,
|
||||
response_headers: BTreeMap::new(),
|
||||
};
|
||||
|
||||
let outcome = fetch_models_from_transports_for_client_version(
|
||||
&runtime,
|
||||
&[sample_codex_transport()],
|
||||
Some("0.145.2"),
|
||||
)
|
||||
.await
|
||||
.expect("models fetch should return an observable failed outcome");
|
||||
|
||||
assert!(!outcome.has_success);
|
||||
assert_eq!(outcome.upstream_status, Some(503));
|
||||
assert_eq!(outcome.etag, None);
|
||||
assert_eq!(outcome.errors.len(), 1);
|
||||
assert!(outcome.errors[0].contains("HTTP 503: temporarily unavailable"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn gemini_cli_load_code_assist_preserves_paid_tier_credits() {
|
||||
let executed_urls = Arc::new(Mutex::new(Vec::new()));
|
||||
@@ -2053,6 +2393,7 @@ mod tests {
|
||||
}
|
||||
}),
|
||||
status_code: 200,
|
||||
response_headers: BTreeMap::new(),
|
||||
};
|
||||
let outcome = fetch_models_from_transports(&runtime, &[sample_gemini_cli_transport()])
|
||||
.await
|
||||
@@ -2189,6 +2530,7 @@ mod tests {
|
||||
]
|
||||
}),
|
||||
status_code: 200,
|
||||
response_headers: BTreeMap::new(),
|
||||
};
|
||||
let outcome = fetch_models_from_transports(&runtime, &[sample_kiro_transport()])
|
||||
.await
|
||||
@@ -2252,6 +2594,7 @@ mod tests {
|
||||
}
|
||||
}),
|
||||
status_code: 200,
|
||||
response_headers: BTreeMap::new(),
|
||||
};
|
||||
let outcome = fetch_models_from_transports(&runtime, &[sample_windsurf_transport()])
|
||||
.await
|
||||
|
||||
@@ -23,7 +23,9 @@ use aether_provider_transport::{
|
||||
use async_trait::async_trait;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::{build_models_fetch_url, deepseek_anthropic_models_fetch_uses_openai_auth};
|
||||
use crate::{
|
||||
build_models_fetch_url_for_client_version, deepseek_anthropic_models_fetch_uses_openai_auth,
|
||||
};
|
||||
|
||||
const CLAUDE_CLI_USER_AGENT: &str = "claude-code/1.0.1";
|
||||
const GEMINI_CLI_USER_AGENT: &str = "GeminiCLI/0.1.5 (Windows; AMD64)";
|
||||
@@ -75,7 +77,21 @@ pub async fn build_models_fetch_execution_plan(
|
||||
runtime: &(impl ModelFetchTransportRuntime + ?Sized),
|
||||
transport: &GatewayProviderTransportSnapshot,
|
||||
) -> Result<ExecutionPlan, String> {
|
||||
build_standard_models_fetch_execution_plan(runtime, transport, None).await
|
||||
build_models_fetch_execution_plan_for_client_version(runtime, transport, None).await
|
||||
}
|
||||
|
||||
pub async fn build_models_fetch_execution_plan_for_client_version(
|
||||
runtime: &(impl ModelFetchTransportRuntime + ?Sized),
|
||||
transport: &GatewayProviderTransportSnapshot,
|
||||
codex_client_version: Option<&str>,
|
||||
) -> Result<ExecutionPlan, String> {
|
||||
build_standard_models_fetch_execution_plan_for_client_version(
|
||||
runtime,
|
||||
transport,
|
||||
None,
|
||||
codex_client_version,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
struct ModelFetchExecutionPlanRequest {
|
||||
@@ -93,6 +109,18 @@ pub async fn build_standard_models_fetch_execution_plan(
|
||||
runtime: &(impl ModelFetchTransportRuntime + ?Sized),
|
||||
transport: &GatewayProviderTransportSnapshot,
|
||||
after_id: Option<&str>,
|
||||
) -> Result<ExecutionPlan, String> {
|
||||
build_standard_models_fetch_execution_plan_for_client_version(
|
||||
runtime, transport, after_id, None,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn build_standard_models_fetch_execution_plan_for_client_version(
|
||||
runtime: &(impl ModelFetchTransportRuntime + ?Sized),
|
||||
transport: &GatewayProviderTransportSnapshot,
|
||||
after_id: Option<&str>,
|
||||
codex_client_version: Option<&str>,
|
||||
) -> Result<ExecutionPlan, String> {
|
||||
let api_format = transport.endpoint.api_format.trim().to_ascii_lowercase();
|
||||
let provider_api_format = api_format.clone();
|
||||
@@ -101,7 +129,8 @@ pub async fn build_standard_models_fetch_execution_plan(
|
||||
provider_type == "codex" && api_format.starts_with("openai:");
|
||||
let is_deepseek_anthropic_models_fetch = api_format.starts_with("claude:")
|
||||
&& deepseek_anthropic_models_fetch_uses_openai_auth(&transport.endpoint.base_url);
|
||||
let mut headers = standard_models_fetch_headers(&api_format, &provider_type);
|
||||
let mut headers =
|
||||
standard_models_fetch_headers(&api_format, &provider_type, codex_client_version);
|
||||
if is_codex_openai_models_fetch {
|
||||
headers.insert("accept".to_string(), "application/json".to_string());
|
||||
}
|
||||
@@ -110,6 +139,10 @@ pub async fn build_standard_models_fetch_execution_plan(
|
||||
headers.insert("accept".to_string(), "application/json".to_string());
|
||||
}
|
||||
let mut protected_headers = Vec::<String>::new();
|
||||
if is_codex_openai_models_fetch {
|
||||
protected_headers.push("user-agent".to_string());
|
||||
protected_headers.push("originator".to_string());
|
||||
}
|
||||
|
||||
if api_format.starts_with("openai:") || api_format.starts_with("claude:") {
|
||||
let resolved_auth = if is_deepseek_anthropic_models_fetch {
|
||||
@@ -155,7 +188,7 @@ pub async fn build_standard_models_fetch_execution_plan(
|
||||
headers = apply_fetch_header_rules(transport, headers, &protected_headers)?;
|
||||
}
|
||||
|
||||
let upstream_url = build_standard_models_fetch_url(transport, after_id)?;
|
||||
let upstream_url = build_standard_models_fetch_url(transport, after_id, codex_client_version)?;
|
||||
build_execution_plan(
|
||||
runtime,
|
||||
transport,
|
||||
@@ -426,7 +459,8 @@ pub async fn build_vertex_models_fetch_execution_plan(
|
||||
api_format: &str,
|
||||
auth_header: Option<(String, String)>,
|
||||
) -> Result<ExecutionPlan, String> {
|
||||
let mut headers = standard_models_fetch_headers(api_format, &transport.provider.provider_type);
|
||||
let mut headers =
|
||||
standard_models_fetch_headers(api_format, &transport.provider.provider_type, None);
|
||||
let mut protected_headers = Vec::<String>::new();
|
||||
if let Some((name, value)) = auth_header {
|
||||
insert_non_empty_auth_header(&mut headers, &mut protected_headers, &name, &value);
|
||||
@@ -588,23 +622,34 @@ fn apply_fetch_header_rules(
|
||||
fn standard_models_fetch_headers(
|
||||
api_format: &str,
|
||||
provider_type: &str,
|
||||
codex_client_version: Option<&str>,
|
||||
) -> BTreeMap<String, String> {
|
||||
let api_format = aether_ai_formats::normalize_api_format_alias(api_format);
|
||||
let provider_type = provider_type.trim().to_ascii_lowercase();
|
||||
match api_format.as_str() {
|
||||
"openai:responses" | "openai:responses:compact" => {
|
||||
let mut headers = BTreeMap::from([(
|
||||
if provider_type == "codex" && api_format.starts_with("openai:") {
|
||||
let client_version = codex_client_version
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or(aether_ai_formats::CODEX_CLIENT_VERSION);
|
||||
return BTreeMap::from([
|
||||
(
|
||||
"user-agent".to_string(),
|
||||
aether_ai_formats::CODEX_CLIENT_USER_AGENT.to_string(),
|
||||
)]);
|
||||
if provider_type == "codex" {
|
||||
headers.insert(
|
||||
"originator".to_string(),
|
||||
aether_ai_formats::CODEX_CLIENT_ORIGINATOR.to_string(),
|
||||
);
|
||||
}
|
||||
headers
|
||||
}
|
||||
format!(
|
||||
"{}/{client_version}",
|
||||
aether_ai_formats::CODEX_CLIENT_ORIGINATOR
|
||||
),
|
||||
),
|
||||
(
|
||||
"originator".to_string(),
|
||||
aether_ai_formats::CODEX_CLIENT_ORIGINATOR.to_string(),
|
||||
),
|
||||
]);
|
||||
}
|
||||
match api_format.as_str() {
|
||||
"openai:responses" | "openai:responses:compact" => BTreeMap::from([(
|
||||
"user-agent".to_string(),
|
||||
aether_ai_formats::CODEX_CLIENT_USER_AGENT.to_string(),
|
||||
)]),
|
||||
"claude:messages" => {
|
||||
let mut headers = BTreeMap::from([(
|
||||
"anthropic-version".to_string(),
|
||||
@@ -632,6 +677,7 @@ fn standard_models_fetch_headers(
|
||||
fn build_standard_models_fetch_url(
|
||||
transport: &GatewayProviderTransportSnapshot,
|
||||
after_id: Option<&str>,
|
||||
codex_client_version: Option<&str>,
|
||||
) -> Result<String, String> {
|
||||
let api_format = transport.endpoint.api_format.trim().to_ascii_lowercase();
|
||||
if api_format.starts_with("gemini:") {
|
||||
@@ -648,19 +694,21 @@ fn build_standard_models_fetch_url(
|
||||
})
|
||||
.ok_or_else(|| "Gemini models fetch requires an API key".to_string())?;
|
||||
|
||||
let (url, _) = build_models_fetch_url(
|
||||
let (url, _) = build_models_fetch_url_for_client_version(
|
||||
&transport.provider.provider_type,
|
||||
&transport.endpoint.api_format,
|
||||
&transport.endpoint.base_url,
|
||||
codex_client_version,
|
||||
)
|
||||
.ok_or_else(|| "Rust models fetch does not support this provider format yet".to_string())?;
|
||||
return Ok(append_query_param(url, "key", &secret));
|
||||
}
|
||||
|
||||
let (mut url, _) = build_models_fetch_url(
|
||||
let (mut url, _) = build_models_fetch_url_for_client_version(
|
||||
&transport.provider.provider_type,
|
||||
&transport.endpoint.api_format,
|
||||
&transport.endpoint.base_url,
|
||||
codex_client_version,
|
||||
)
|
||||
.ok_or_else(|| "Rust models fetch does not support this provider format yet".to_string())?;
|
||||
|
||||
@@ -727,9 +775,9 @@ mod tests {
|
||||
use super::{
|
||||
build_antigravity_fetch_available_models_plan, build_antigravity_load_code_assist_plan,
|
||||
build_gemini_cli_load_code_assist_plan, build_kiro_list_available_models_plan,
|
||||
build_models_fetch_execution_plan, build_standard_models_fetch_execution_plan,
|
||||
build_vertex_models_fetch_execution_plan, ModelFetchTransportRuntime,
|
||||
ANTIGRAVITY_REQUEST_USER_AGENT,
|
||||
build_models_fetch_execution_plan, build_models_fetch_execution_plan_for_client_version,
|
||||
build_standard_models_fetch_execution_plan, build_vertex_models_fetch_execution_plan,
|
||||
ModelFetchTransportRuntime, ANTIGRAVITY_REQUEST_USER_AGENT,
|
||||
};
|
||||
|
||||
struct TestRuntime {
|
||||
@@ -922,7 +970,7 @@ mod tests {
|
||||
),
|
||||
proxy: None,
|
||||
};
|
||||
let mut transport = sample_transport("codex", "openai:responses", "oauth");
|
||||
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"},
|
||||
@@ -959,9 +1007,67 @@ mod tests {
|
||||
plan.headers.get("originator").map(String::as_str),
|
||||
Some("codex_cli_rs")
|
||||
);
|
||||
assert_eq!(
|
||||
plan.headers.get("user-agent").map(String::as_str),
|
||||
Some(aether_ai_formats::CODEX_CLIENT_USER_AGENT)
|
||||
);
|
||||
assert!(!plan.headers.contains_key("version"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn builds_codex_models_fetch_plan_with_explicit_client_version() {
|
||||
let runtime = TestRuntime {
|
||||
oauth_auth: Some(
|
||||
aether_provider_transport::LocalResolvedOAuthRequestAuth::Header {
|
||||
name: "authorization".to_string(),
|
||||
value: "Bearer access-token".to_string(),
|
||||
},
|
||||
),
|
||||
proxy: None,
|
||||
};
|
||||
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"}
|
||||
]));
|
||||
transport.key.decrypted_auth_config =
|
||||
Some(r#"{"account_id":"account-1","chatgpt_account_is_fedramp":true}"#.to_string());
|
||||
|
||||
let plan = build_models_fetch_execution_plan_for_client_version(
|
||||
&runtime,
|
||||
&transport,
|
||||
Some("0.145.2"),
|
||||
)
|
||||
.await
|
||||
.expect("plan");
|
||||
|
||||
assert_eq!(
|
||||
plan.url,
|
||||
"https://chatgpt.com/backend-api/codex/models?client_version=0.145.2"
|
||||
);
|
||||
assert_eq!(
|
||||
plan.headers.get("user-agent").map(String::as_str),
|
||||
Some("codex_cli_rs/0.145.2")
|
||||
);
|
||||
assert_eq!(
|
||||
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")
|
||||
);
|
||||
assert_eq!(
|
||||
plan.headers.get("x-openai-fedramp").map(String::as_str),
|
||||
Some("true")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn builds_claude_models_fetch_plan_with_pagination() {
|
||||
let runtime = TestRuntime {
|
||||
|
||||
Reference in New Issue
Block a user