mirror of
https://github.com/fawney19/Aether.git
synced 2026-10-05 00:47:48 +08:00
fix(codex): unify management model catalogs and refresh fingerprints
This commit is contained in:
@@ -505,7 +505,7 @@ fn projects_uuid_prompt_cache_identity_into_missing_session_headers() {
|
|||||||
assert_eq!(headers.get("x-client-request-id"), None);
|
assert_eq!(headers.get("x-client-request-id"), None);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
headers.get("user-agent"),
|
headers.get("user-agent"),
|
||||||
Some(&"codex_cli_rs/0.144.1".to_string())
|
Some(&"codex_cli_rs/0.153.3".to_string())
|
||||||
);
|
);
|
||||||
assert_eq!(headers.get("originator"), Some(&"codex_cli_rs".to_string()));
|
assert_eq!(headers.get("originator"), Some(&"codex_cli_rs".to_string()));
|
||||||
assert!(!headers.contains_key("version"));
|
assert!(!headers.contains_key("version"));
|
||||||
@@ -615,7 +615,7 @@ fn injects_only_codex_client_headers_for_images_requests() {
|
|||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
headers.get("user-agent"),
|
headers.get("user-agent"),
|
||||||
Some(&"codex_cli_rs/0.144.1".to_string())
|
Some(&"codex_cli_rs/0.153.3".to_string())
|
||||||
);
|
);
|
||||||
assert_eq!(headers.get("originator"), Some(&"codex_cli_rs".to_string()));
|
assert_eq!(headers.get("originator"), Some(&"codex_cli_rs".to_string()));
|
||||||
assert!(!headers.contains_key("version"));
|
assert!(!headers.contains_key("version"));
|
||||||
@@ -699,7 +699,7 @@ fn preserves_client_context_headers_and_enforces_codex_provider_identity() {
|
|||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
headers.get("user-agent"),
|
headers.get("user-agent"),
|
||||||
Some(&"codex_cli_rs/0.144.1".to_string())
|
Some(&"codex_cli_rs/0.153.3".to_string())
|
||||||
);
|
);
|
||||||
assert_eq!(headers.get("originator"), Some(&"codex_cli_rs".to_string()));
|
assert_eq!(headers.get("originator"), Some(&"codex_cli_rs".to_string()));
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -763,7 +763,7 @@ fn compact_projects_uuid_prompt_cache_identity_into_session_headers() {
|
|||||||
assert_eq!(headers.get("x-client-request-id"), None);
|
assert_eq!(headers.get("x-client-request-id"), None);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
headers.get("user-agent"),
|
headers.get("user-agent"),
|
||||||
Some(&"codex_cli_rs/0.144.1".to_string())
|
Some(&"codex_cli_rs/0.153.3".to_string())
|
||||||
);
|
);
|
||||||
assert_eq!(headers.get("originator"), Some(&"codex_cli_rs".to_string()));
|
assert_eq!(headers.get("originator"), Some(&"codex_cli_rs".to_string()));
|
||||||
assert!(!headers.contains_key("version"));
|
assert!(!headers.contains_key("version"));
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ use aether_data_contracts::repository::provider_catalog::{
|
|||||||
StoredProviderCatalogEndpoint, StoredProviderCatalogKey, StoredProviderCatalogProvider,
|
StoredProviderCatalogEndpoint, StoredProviderCatalogKey, StoredProviderCatalogProvider,
|
||||||
};
|
};
|
||||||
use aether_model_fetch::{
|
use aether_model_fetch::{
|
||||||
aggregate_models_for_cache, fetch_models_from_transports, json_string_list,
|
aggregate_models_for_cache, fetch_models_from_transports_for_management, json_string_list,
|
||||||
model_catalog_upstream_metadata, preset_models_for_provider, selected_models_fetch_endpoints,
|
model_catalog_upstream_metadata, preset_models_for_provider, selected_models_fetch_endpoints,
|
||||||
upstream_metadata_namespace_updates,
|
upstream_metadata_namespace_updates,
|
||||||
};
|
};
|
||||||
@@ -496,10 +496,34 @@ async fn provider_query_fetch_models_for_key(
|
|||||||
key: &StoredProviderCatalogKey,
|
key: &StoredProviderCatalogKey,
|
||||||
force_refresh: bool,
|
force_refresh: bool,
|
||||||
) -> Result<ProviderQueryKeyFetchResult, GatewayError> {
|
) -> Result<ProviderQueryKeyFetchResult, GatewayError> {
|
||||||
|
let is_codex = provider.provider_type.trim().eq_ignore_ascii_case("codex");
|
||||||
|
let codex_catalog = if is_codex {
|
||||||
|
crate::model_fetch::read_codex_management_catalog(state.app(), &provider.id, &key.id).await
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
if !force_refresh {
|
if !force_refresh {
|
||||||
if let Some(cached_models) =
|
// Read through the live, credential-scoped directory before the admin cache.
|
||||||
|
// Never reuse the old versionless cache for Codex (including cached presets).
|
||||||
|
let cached_models = if is_codex {
|
||||||
|
codex_catalog
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|catalog| catalog.models.as_ref())
|
||||||
|
.filter(|_| {
|
||||||
|
selected_models_fetch_endpoints(endpoints, key)
|
||||||
|
.iter()
|
||||||
|
.any(|endpoint| endpoint.api_format == "openai:responses")
|
||||||
|
})
|
||||||
|
.map(|models| {
|
||||||
|
aether_model_fetch::project_codex_models_for_legacy_cache([(
|
||||||
|
"openai:responses",
|
||||||
|
models.as_slice(),
|
||||||
|
)])
|
||||||
|
})
|
||||||
|
} else {
|
||||||
provider_query_read_cached_models(state, &provider.id, &key.id).await
|
provider_query_read_cached_models(state, &provider.id, &key.id).await
|
||||||
{
|
};
|
||||||
|
if let Some(cached_models) = cached_models {
|
||||||
let models = provider_query_filter_models_for_key(provider, key, cached_models);
|
let models = provider_query_filter_models_for_key(provider, key, cached_models);
|
||||||
return Ok(ProviderQueryKeyFetchResult {
|
return Ok(ProviderQueryKeyFetchResult {
|
||||||
models,
|
models,
|
||||||
@@ -561,30 +585,51 @@ async fn provider_query_fetch_models_for_key(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
let outcome = match fetch_models_from_transports(state.app(), &transports).await {
|
let client_version = is_codex.then(|| {
|
||||||
Ok(outcome) => outcome,
|
codex_catalog
|
||||||
Err(err) => {
|
.as_ref()
|
||||||
all_errors.push(err);
|
.map(|catalog| catalog.client_version.as_str())
|
||||||
if let Some(fallback) =
|
.unwrap_or(aether_ai_formats::CODEX_CLIENT_VERSION)
|
||||||
provider_query_codex_preset_fallback(provider, &all_errors.join("; "))
|
});
|
||||||
{
|
let outcome =
|
||||||
provider_query_persist_preset_models(state, provider, key, &fallback.models)
|
match fetch_models_from_transports_for_management(state.app(), &transports, client_version)
|
||||||
.await?;
|
.await
|
||||||
return Ok(fallback);
|
{
|
||||||
|
Ok(outcome) => outcome,
|
||||||
|
Err(err) => {
|
||||||
|
all_errors.push(err);
|
||||||
|
if let Some(fallback) =
|
||||||
|
provider_query_codex_preset_fallback(provider, &all_errors.join("; "))
|
||||||
|
{
|
||||||
|
provider_query_persist_preset_models(state, provider, key, &fallback.models)
|
||||||
|
.await?;
|
||||||
|
return Ok(fallback);
|
||||||
|
}
|
||||||
|
return Ok(ProviderQueryKeyFetchResult {
|
||||||
|
models: Vec::new(),
|
||||||
|
error: Some(all_errors.join("; ")),
|
||||||
|
warning: None,
|
||||||
|
from_cache: false,
|
||||||
|
has_success: false,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
return Ok(ProviderQueryKeyFetchResult {
|
};
|
||||||
models: Vec::new(),
|
|
||||||
error: Some(all_errors.join("; ")),
|
|
||||||
warning: None,
|
|
||||||
from_cache: false,
|
|
||||||
has_success: false,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
all_errors.extend(outcome.errors);
|
all_errors.extend(outcome.errors);
|
||||||
let unique_models = outcome.legacy_models;
|
let unique_models = outcome.legacy_models;
|
||||||
if outcome.has_success && !unique_models.is_empty() {
|
if outcome.has_success && !unique_models.is_empty() {
|
||||||
|
if all_errors.is_empty() && outcome.native_codex_catalog {
|
||||||
|
if let Some(catalog) = codex_catalog.as_ref() {
|
||||||
|
crate::model_fetch::store_codex_management_catalog(
|
||||||
|
state.app(),
|
||||||
|
catalog,
|
||||||
|
&transports,
|
||||||
|
outcome.cached_models,
|
||||||
|
outcome.etag.as_deref(),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
}
|
||||||
<AppState as ModelFetchRuntimeState>::write_upstream_models_cache(
|
<AppState as ModelFetchRuntimeState>::write_upstream_models_cache(
|
||||||
state.app(),
|
state.app(),
|
||||||
&provider.id,
|
&provider.id,
|
||||||
|
|||||||
@@ -1494,6 +1494,133 @@ pub(crate) async fn read_recent_codex_catalog_client_version(
|
|||||||
(!normalized.used_fallback()).then_some(normalized.value)
|
(!normalized.used_fallback()).then_some(normalized.value)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Management is not tied to a downstream client's compatibility version. Keep its
|
||||||
|
/// directory at least as new as the built-in fingerprint and successful catalogs.
|
||||||
|
pub(crate) struct CodexManagementCatalog {
|
||||||
|
pub(crate) client_version: String,
|
||||||
|
pub(crate) models: Option<Vec<Value>>,
|
||||||
|
target: CodexCatalogTarget,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn read_codex_management_catalog<R>(
|
||||||
|
runtime: &R,
|
||||||
|
provider_id: &str,
|
||||||
|
key_id: &str,
|
||||||
|
) -> Option<CodexManagementCatalog>
|
||||||
|
where
|
||||||
|
R: CodexCatalogRuntime + ?Sized,
|
||||||
|
{
|
||||||
|
let target = bind_codex_catalog_target(
|
||||||
|
runtime,
|
||||||
|
&CodexCatalogTarget {
|
||||||
|
identity: CodexCatalogIdentity::new(provider_id, key_id),
|
||||||
|
endpoint_ids: Vec::new(),
|
||||||
|
credential_scope: None,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
let scope = target.credential_scope()?;
|
||||||
|
let state = runtime.codex_catalog_runtime_state();
|
||||||
|
let mut version = Version::parse(crate::ai_serving::CODEX_CLIENT_VERSION).ok()?;
|
||||||
|
if let Some(recent) =
|
||||||
|
read_recent_codex_catalog_client_version(state, provider_id, key_id, scope).await
|
||||||
|
{
|
||||||
|
version = version.max(Version::parse(&recent).ok()?);
|
||||||
|
}
|
||||||
|
// The most recently seen client can be older than an already successful catalog.
|
||||||
|
// Only consider the current credential generation's bounded success index.
|
||||||
|
for member in state
|
||||||
|
.score_range_by_min(&catalog_versions_key(&target.identity), 0.0)
|
||||||
|
.await
|
||||||
|
.unwrap_or_default()
|
||||||
|
{
|
||||||
|
if let Some((stored_scope, stored_version)) = parse_catalog_version_member(&member) {
|
||||||
|
if stored_scope == scope {
|
||||||
|
if let Ok(candidate) = Version::parse(stored_version) {
|
||||||
|
version = version.max(candidate);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let client_version = version.to_string();
|
||||||
|
let models = if let Some(snapshot) = read_lkg_snapshot(runtime, &target, &client_version).await
|
||||||
|
{
|
||||||
|
let fresh = state
|
||||||
|
.kv_get(&catalog_fresh_key(&target, &client_version))
|
||||||
|
.await
|
||||||
|
.ok()
|
||||||
|
.flatten();
|
||||||
|
(fresh.as_deref() == Some(snapshot.content_sha256.as_str())).then_some(snapshot.models)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
if !codex_catalog_credential_scope_is_current(runtime, &target).await {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(CodexManagementCatalog {
|
||||||
|
client_version,
|
||||||
|
models,
|
||||||
|
target,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Publish a successful management fetch into the same versioned directory used by
|
||||||
|
/// clients. A credential replacement while the request is in flight must not leak
|
||||||
|
/// the previous account's catalog into the new account.
|
||||||
|
pub(crate) async fn store_codex_management_catalog<R>(
|
||||||
|
runtime: &R,
|
||||||
|
catalog: &CodexManagementCatalog,
|
||||||
|
transports: &[GatewayProviderTransportSnapshot],
|
||||||
|
models: Vec<Value>,
|
||||||
|
etag: Option<&str>,
|
||||||
|
) where
|
||||||
|
R: CodexCatalogRuntime + ?Sized,
|
||||||
|
{
|
||||||
|
let target = &catalog.target;
|
||||||
|
if transports.is_empty()
|
||||||
|
|| transports.iter().any(|transport| {
|
||||||
|
codex_catalog_credential_scope_from_transport(transport).as_deref()
|
||||||
|
!= target.credential_scope()
|
||||||
|
})
|
||||||
|
|| !codex_catalog_credential_scope_is_current(runtime, target).await
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let Ok(serialized) = validate_catalog_models(&models) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let now = current_unix_secs();
|
||||||
|
let snapshot = CodexCatalogSnapshot {
|
||||||
|
schema_version: CODEX_CATALOG_SCHEMA_VERSION,
|
||||||
|
provider_id: target.identity.provider_id.clone(),
|
||||||
|
key_id: target.identity.key_id.clone(),
|
||||||
|
credential_scope: target.credential_scope().unwrap_or_default().to_string(),
|
||||||
|
client_version: catalog.client_version.clone(),
|
||||||
|
models,
|
||||||
|
etag: etag.and_then(normalize_etag),
|
||||||
|
fetched_at_unix_secs: now,
|
||||||
|
last_checked_at_unix_secs: now,
|
||||||
|
content_sha256: sha256_hex(&serialized),
|
||||||
|
};
|
||||||
|
persist_catalog_success(
|
||||||
|
runtime,
|
||||||
|
target,
|
||||||
|
&catalog.client_version,
|
||||||
|
&snapshot,
|
||||||
|
Some(200),
|
||||||
|
Duration::ZERO,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
if !codex_catalog_credential_scope_is_current(runtime, target).await {
|
||||||
|
discard_catalog_success_after_scope_change(
|
||||||
|
runtime.codex_catalog_runtime_state(),
|
||||||
|
target,
|
||||||
|
&catalog.client_version,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async fn retain_catalog_version(
|
async fn retain_catalog_version(
|
||||||
state: &RuntimeState,
|
state: &RuntimeState,
|
||||||
target: &CodexCatalogTarget,
|
target: &CodexCatalogTarget,
|
||||||
@@ -2327,6 +2454,101 @@ mod tests {
|
|||||||
assert!(!prerelease.used_fallback());
|
assert!(!prerelease.used_fallback());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn management_catalog_uses_version_floor_and_newest_success_not_last_client() {
|
||||||
|
let runtime = TestRuntime::new(vec![successful_execution("gpt-new", "etag")]);
|
||||||
|
remember_seen_version(&runtime.state, &target(), "0.144.1").await;
|
||||||
|
let initial = read_codex_management_catalog(&runtime, TEST_PROVIDER_ID, TEST_KEY_ID)
|
||||||
|
.await
|
||||||
|
.expect("management context");
|
||||||
|
assert_eq!(
|
||||||
|
initial.client_version,
|
||||||
|
crate::ai_serving::CODEX_CLIENT_VERSION
|
||||||
|
);
|
||||||
|
assert!(initial.models.is_none());
|
||||||
|
|
||||||
|
seed_catalog(&runtime, &version("0.200.0")).await;
|
||||||
|
remember_seen_version(&runtime.state, &target(), "0.144.1").await;
|
||||||
|
let current = read_codex_management_catalog(&runtime, TEST_PROVIDER_ID, TEST_KEY_ID)
|
||||||
|
.await
|
||||||
|
.expect("management context");
|
||||||
|
assert_eq!(current.client_version, "0.200.0");
|
||||||
|
assert_eq!(current.models.unwrap()[0]["slug"], "gpt-new");
|
||||||
|
assert_eq!(
|
||||||
|
runtime.execution_count(),
|
||||||
|
1,
|
||||||
|
"management read does not fetch upstream"
|
||||||
|
);
|
||||||
|
|
||||||
|
runtime
|
||||||
|
.state
|
||||||
|
.kv_delete(&catalog_fresh_key(&target(), "0.200.0"))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let stale = read_codex_management_catalog(&runtime, TEST_PROVIDER_ID, TEST_KEY_ID)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(stale.client_version, "0.200.0");
|
||||||
|
assert!(
|
||||||
|
stale.models.is_none(),
|
||||||
|
"expired LKG must not become a fresh admin cache"
|
||||||
|
);
|
||||||
|
|
||||||
|
runtime.set_credential_generation(TEST_CREDENTIAL_GENERATION_B);
|
||||||
|
let rebound = read_codex_management_catalog(&runtime, TEST_PROVIDER_ID, TEST_KEY_ID)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
rebound.client_version,
|
||||||
|
crate::ai_serving::CODEX_CLIENT_VERSION
|
||||||
|
);
|
||||||
|
assert!(rebound.models.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn management_refresh_updates_shared_catalog_but_rejects_replaced_credentials() {
|
||||||
|
let runtime = TestRuntime::new(vec![]);
|
||||||
|
let context = read_codex_management_catalog(&runtime, TEST_PROVIDER_ID, TEST_KEY_ID)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let transports = vec![sample_codex_transport()];
|
||||||
|
for slug in ["gpt-old", "gpt-6-astra"] {
|
||||||
|
store_codex_management_catalog(
|
||||||
|
&runtime,
|
||||||
|
&context,
|
||||||
|
&transports,
|
||||||
|
vec![codex_model(slug)],
|
||||||
|
Some("etag"),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
let shared = read_lkg_snapshot(&runtime, &target(), &context.client_version)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(shared.models[0]["slug"], slug);
|
||||||
|
let admin = read_codex_management_catalog(&runtime, TEST_PROVIDER_ID, TEST_KEY_ID)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(admin.models.unwrap()[0]["slug"], slug);
|
||||||
|
}
|
||||||
|
runtime.set_credential_generation(TEST_CREDENTIAL_GENERATION_B);
|
||||||
|
store_codex_management_catalog(
|
||||||
|
&runtime,
|
||||||
|
&context,
|
||||||
|
&transports,
|
||||||
|
vec![codex_model("gpt-leaked")],
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
let rebound = read_codex_management_catalog(&runtime, TEST_PROVIDER_ID, TEST_KEY_ID)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert!(rebound.models.is_none());
|
||||||
|
let old = read_raw_snapshot(&runtime, &target(), &context.client_version)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(old.models[0]["slug"], "gpt-6-astra");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn invalid_or_oversized_client_versions_use_bounded_fallback_identity() {
|
fn invalid_or_oversized_client_versions_use_bounded_fallback_identity() {
|
||||||
for raw in [
|
for raw in [
|
||||||
|
|||||||
@@ -6,8 +6,8 @@ mod tests;
|
|||||||
pub(crate) use aether_model_fetch::ModelFetchRunSummary;
|
pub(crate) use aether_model_fetch::ModelFetchRunSummary;
|
||||||
pub(crate) use catalog::{
|
pub(crate) use catalog::{
|
||||||
codex_catalog_credential_scope_from_stored_key, codex_catalog_targets, load_codex_catalogs,
|
codex_catalog_credential_scope_from_stored_key, codex_catalog_targets, load_codex_catalogs,
|
||||||
normalize_codex_client_version, read_recent_codex_catalog_client_version,
|
normalize_codex_client_version, read_codex_management_catalog, refresh_codex_catalog_target,
|
||||||
refresh_codex_catalog_target, CodexCatalogLoad, CodexCatalogRuntime, CodexCatalogTarget,
|
store_codex_management_catalog, CodexCatalogLoad, CodexCatalogRuntime, CodexCatalogTarget,
|
||||||
NormalizedCodexClientVersion,
|
NormalizedCodexClientVersion,
|
||||||
};
|
};
|
||||||
pub(crate) use runtime::state::ModelFetchRuntimeState;
|
pub(crate) use runtime::state::ModelFetchRuntimeState;
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ use aether_data_contracts::repository::provider_catalog::{
|
|||||||
StoredProviderCatalogKey, StoredProviderCatalogProvider,
|
StoredProviderCatalogKey, StoredProviderCatalogProvider,
|
||||||
};
|
};
|
||||||
use aether_model_fetch::{
|
use aether_model_fetch::{
|
||||||
apply_model_filters, fetch_models_from_transports_for_client_version, json_string_list,
|
apply_model_filters, fetch_models_from_transports_for_management, json_string_list,
|
||||||
model_catalog_upstream_metadata, model_fetch_interval_minutes,
|
model_catalog_upstream_metadata, model_fetch_interval_minutes,
|
||||||
model_fetch_startup_delay_seconds, model_fetch_startup_enabled, preset_models_for_provider,
|
model_fetch_startup_delay_seconds, model_fetch_startup_enabled, preset_models_for_provider,
|
||||||
selected_models_fetch_endpoints, sync_provider_model_whitelist_associations,
|
selected_models_fetch_endpoints, sync_provider_model_whitelist_associations,
|
||||||
@@ -327,7 +327,7 @@ async fn fetch_and_persist_key_models(
|
|||||||
} else {
|
} else {
|
||||||
None
|
None
|
||||||
};
|
};
|
||||||
let result = match fetch_models_from_transports_for_client_version(
|
let result = match fetch_models_from_transports_for_management(
|
||||||
state,
|
state,
|
||||||
&transports,
|
&transports,
|
||||||
codex_client_version.as_deref(),
|
codex_client_version.as_deref(),
|
||||||
|
|||||||
@@ -405,22 +405,9 @@ impl ModelFetchRuntimeState for AppState {
|
|||||||
provider_id: &str,
|
provider_id: &str,
|
||||||
key_id: &str,
|
key_id: &str,
|
||||||
) -> Option<String> {
|
) -> Option<String> {
|
||||||
let credential_scope =
|
crate::model_fetch::read_codex_management_catalog(self, provider_id, key_id)
|
||||||
<AppState as CodexCatalogRuntime>::read_codex_catalog_credential_scope_strong(
|
|
||||||
self,
|
|
||||||
provider_id,
|
|
||||||
key_id,
|
|
||||||
)
|
|
||||||
.await
|
.await
|
||||||
.ok()
|
.map(|catalog| catalog.client_version)
|
||||||
.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(
|
async fn update_provider_catalog_key_model_fetch_state(
|
||||||
|
|||||||
@@ -421,7 +421,7 @@ async fn gateway_executes_codex_image_stream_via_local_decision_gate_after_oauth
|
|||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
seen_execution_runtime_request.headers["user-agent"],
|
seen_execution_runtime_request.headers["user-agent"],
|
||||||
"codex_cli_rs/0.144.1"
|
"codex_cli_rs/0.153.3"
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
seen_execution_runtime_request.headers["originator"],
|
seen_execution_runtime_request.headers["originator"],
|
||||||
|
|||||||
@@ -1119,7 +1119,7 @@ async fn gateway_executes_codex_image_sync_via_local_decision_gate_after_oauth_r
|
|||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
seen_execution_runtime_request.headers["user-agent"],
|
seen_execution_runtime_request.headers["user-agent"],
|
||||||
"codex_cli_rs/0.144.1"
|
"codex_cli_rs/0.153.3"
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
seen_execution_runtime_request.headers["originator"],
|
seen_execution_runtime_request.headers["originator"],
|
||||||
|
|||||||
@@ -538,14 +538,14 @@ async fn gateway_handles_admin_provider_query_models_with_openai_responses_endpo
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn gateway_recovers_codex_slug_only_models_from_an_empty_legacy_cache() {
|
fn gateway_recovers_codex_slug_only_models_from_a_stale_legacy_cache() {
|
||||||
run_provider_query_test(
|
run_provider_query_test(
|
||||||
"gateway_recovers_codex_slug_only_models_from_an_empty_legacy_cache",
|
"gateway_recovers_codex_slug_only_models_from_a_stale_legacy_cache",
|
||||||
gateway_recovers_codex_slug_only_models_from_an_empty_legacy_cache_impl,
|
gateway_recovers_codex_slug_only_models_from_a_stale_legacy_cache_impl,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn gateway_recovers_codex_slug_only_models_from_an_empty_legacy_cache_impl() {
|
async fn gateway_recovers_codex_slug_only_models_from_a_stale_legacy_cache_impl() {
|
||||||
let execution_runtime_hits = Arc::new(Mutex::new(0usize));
|
let execution_runtime_hits = Arc::new(Mutex::new(0usize));
|
||||||
let execution_runtime_hits_clone = Arc::clone(&execution_runtime_hits);
|
let execution_runtime_hits_clone = Arc::clone(&execution_runtime_hits);
|
||||||
let execution_runtime = Router::new().route(
|
let execution_runtime = Router::new().route(
|
||||||
@@ -558,7 +558,11 @@ async fn gateway_recovers_codex_slug_only_models_from_an_empty_legacy_cache_impl
|
|||||||
.expect("mutex should lock") += 1;
|
.expect("mutex should lock") += 1;
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
plan.url,
|
plan.url,
|
||||||
"https://chatgpt.com/backend-api/codex/models?client_version=0.144.1"
|
"https://chatgpt.com/backend-api/codex/models?client_version=0.153.3"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
plan.headers.get("user-agent").map(String::as_str),
|
||||||
|
Some(aether_ai_formats::CODEX_CLIENT_USER_AGENT)
|
||||||
);
|
);
|
||||||
assert_eq!(plan.provider_api_format, "openai:responses");
|
assert_eq!(plan.provider_api_format, "openai:responses");
|
||||||
Json(json!({
|
Json(json!({
|
||||||
@@ -617,16 +621,21 @@ async fn gateway_recovers_codex_slug_only_models_from_an_empty_legacy_cache_impl
|
|||||||
.runtime_state()
|
.runtime_state()
|
||||||
.kv_set(
|
.kv_set(
|
||||||
"upstream_models:provider-codex-dynamic:key-codex-dynamic",
|
"upstream_models:provider-codex-dynamic:key-codex-dynamic",
|
||||||
"[]".to_string(),
|
json!([{"id": "gpt-stale-legacy"}]).to_string(),
|
||||||
None,
|
None,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.expect("empty legacy cache should seed");
|
.expect("stale legacy cache should seed");
|
||||||
let cache_state = state.clone();
|
let cache_state = state.clone();
|
||||||
let gateway = build_router_with_state(state);
|
let gateway = build_router_with_state(state);
|
||||||
let (gateway_url, gateway_handle) = start_server(gateway).await;
|
let (gateway_url, gateway_handle) = start_server(gateway).await;
|
||||||
|
|
||||||
for (request_index, expected_from_cache) in [(0usize, false), (1usize, true)] {
|
for (request_index, expected_from_cache) in [
|
||||||
|
(0usize, false),
|
||||||
|
(1usize, true),
|
||||||
|
(2usize, false),
|
||||||
|
(3usize, true),
|
||||||
|
] {
|
||||||
let response = reqwest::Client::new()
|
let response = reqwest::Client::new()
|
||||||
.post(format!("{gateway_url}/api/admin/provider-query/models"))
|
.post(format!("{gateway_url}/api/admin/provider-query/models"))
|
||||||
.header(crate::constants::GATEWAY_HEADER, "rust-phase3b")
|
.header(crate::constants::GATEWAY_HEADER, "rust-phase3b")
|
||||||
@@ -635,7 +644,8 @@ async fn gateway_recovers_codex_slug_only_models_from_an_empty_legacy_cache_impl
|
|||||||
.header(TRUSTED_ADMIN_SESSION_ID_HEADER, "session-123")
|
.header(TRUSTED_ADMIN_SESSION_ID_HEADER, "session-123")
|
||||||
.json(&json!({
|
.json(&json!({
|
||||||
"provider_id": "provider-codex-dynamic",
|
"provider_id": "provider-codex-dynamic",
|
||||||
"api_key_id": "key-codex-dynamic"
|
"api_key_id": "key-codex-dynamic",
|
||||||
|
"force_refresh": request_index == 2,
|
||||||
}))
|
}))
|
||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
@@ -672,10 +682,47 @@ async fn gateway_recovers_codex_slug_only_models_from_an_empty_legacy_cache_impl
|
|||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
*execution_runtime_hits.lock().expect("mutex should lock"),
|
*execution_runtime_hits.lock().expect("mutex should lock"),
|
||||||
1,
|
if request_index < 2 { 1 } else { 2 },
|
||||||
"request {request_index} must not cause another upstream fetch"
|
"request {request_index} must not cause another upstream fetch"
|
||||||
);
|
);
|
||||||
|
assert_eq!(
|
||||||
|
payload["data"]["models"][0]["display_name"],
|
||||||
|
if request_index == 1 {
|
||||||
|
"Updated shared catalog"
|
||||||
|
} else {
|
||||||
|
"Future Dynamic"
|
||||||
|
}
|
||||||
|
);
|
||||||
if request_index == 0 {
|
if request_index == 0 {
|
||||||
|
let context = crate::model_fetch::read_codex_management_catalog(
|
||||||
|
&cache_state,
|
||||||
|
"provider-codex-dynamic",
|
||||||
|
"key-codex-dynamic",
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let mut models = context
|
||||||
|
.models
|
||||||
|
.clone()
|
||||||
|
.expect("admin fetch publishes shared catalog");
|
||||||
|
models[0]["display_name"] = json!("Updated shared catalog");
|
||||||
|
let transport = cache_state
|
||||||
|
.read_provider_transport_snapshot(
|
||||||
|
"provider-codex-dynamic",
|
||||||
|
"endpoint-codex-dynamic",
|
||||||
|
"key-codex-dynamic",
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.unwrap();
|
||||||
|
crate::model_fetch::store_codex_management_catalog(
|
||||||
|
&cache_state,
|
||||||
|
&context,
|
||||||
|
&[transport],
|
||||||
|
models,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
<AppState as crate::model_fetch::ModelFetchRuntimeState>::write_upstream_models_cache(
|
<AppState as crate::model_fetch::ModelFetchRuntimeState>::write_upstream_models_cache(
|
||||||
&cache_state,
|
&cache_state,
|
||||||
"provider-codex-dynamic",
|
"provider-codex-dynamic",
|
||||||
@@ -712,7 +759,7 @@ async fn gateway_handles_admin_provider_query_models_falls_back_to_codex_preset_
|
|||||||
.expect("mutex should lock") += 1;
|
.expect("mutex should lock") += 1;
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
plan.url,
|
plan.url,
|
||||||
"https://chatgpt.com/backend-api/codex/models?client_version=0.144.1"
|
"https://chatgpt.com/backend-api/codex/models?client_version=0.153.3"
|
||||||
);
|
);
|
||||||
Json(json!({
|
Json(json!({
|
||||||
"request_id": "req-provider-query-codex-invalidated",
|
"request_id": "req-provider-query-codex-invalidated",
|
||||||
|
|||||||
@@ -36,8 +36,8 @@ const CODEX_OPENAI_RESPONSES_COMPACT_BODY_FIELDS: &[&str] = &[
|
|||||||
"prompt_cache_key",
|
"prompt_cache_key",
|
||||||
"text",
|
"text",
|
||||||
];
|
];
|
||||||
pub const CODEX_CLIENT_VERSION: &str = "0.144.1";
|
pub const CODEX_CLIENT_VERSION: &str = "0.153.3";
|
||||||
pub const CODEX_CLIENT_USER_AGENT: &str = "codex_cli_rs/0.144.1";
|
pub const CODEX_CLIENT_USER_AGENT: &str = "codex_cli_rs/0.153.3";
|
||||||
pub const CODEX_CLIENT_ORIGINATOR: &str = "codex_cli_rs";
|
pub const CODEX_CLIENT_ORIGINATOR: &str = "codex_cli_rs";
|
||||||
pub const CODEX_OPENAI_IMAGE_INTERNAL_MODEL: &str = "gpt-5.4-mini";
|
pub const CODEX_OPENAI_IMAGE_INTERNAL_MODEL: &str = "gpt-5.4-mini";
|
||||||
pub const CODEX_OPENAI_IMAGE_DEFAULT_MODEL: &str = "gpt-image-2";
|
pub const CODEX_OPENAI_IMAGE_DEFAULT_MODEL: &str = "gpt-image-2";
|
||||||
|
|||||||
@@ -22,8 +22,8 @@ pub use logic::{
|
|||||||
};
|
};
|
||||||
pub use strategy::{
|
pub use strategy::{
|
||||||
antigravity_model_id_is_routable, fetch_models_from_transports,
|
antigravity_model_id_is_routable, fetch_models_from_transports,
|
||||||
fetch_models_from_transports_for_client_version, ModelFetchStrategy, ModelFetchStrategyKind,
|
fetch_models_from_transports_for_client_version, fetch_models_from_transports_for_management,
|
||||||
ModelsFetchOutcome, SelectedModelFetchStrategy,
|
ModelFetchStrategy, ModelFetchStrategyKind, ModelsFetchOutcome, SelectedModelFetchStrategy,
|
||||||
};
|
};
|
||||||
pub use transport::{
|
pub use transport::{
|
||||||
build_antigravity_fetch_available_models_plan, build_antigravity_load_code_assist_plan,
|
build_antigravity_fetch_available_models_plan, build_antigravity_load_code_assist_plan,
|
||||||
|
|||||||
@@ -1339,7 +1339,7 @@ mod tests {
|
|||||||
"https://chatgpt.com/backend-api/codex"
|
"https://chatgpt.com/backend-api/codex"
|
||||||
),
|
),
|
||||||
Some((
|
Some((
|
||||||
"https://chatgpt.com/backend-api/codex/models?client_version=0.144.1".to_string(),
|
"https://chatgpt.com/backend-api/codex/models?client_version=0.153.3".to_string(),
|
||||||
"openai:responses".to_string()
|
"openai:responses".to_string()
|
||||||
))
|
))
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -53,6 +53,8 @@ pub struct ModelsFetchOutcome {
|
|||||||
pub legacy_models: Vec<Value>,
|
pub legacy_models: Vec<Value>,
|
||||||
pub errors: Vec<String>,
|
pub errors: Vec<String>,
|
||||||
pub has_success: bool,
|
pub has_success: bool,
|
||||||
|
/// Only native `models` responses may populate the opaque Codex client catalog.
|
||||||
|
pub native_codex_catalog: bool,
|
||||||
pub upstream_metadata: Option<Value>,
|
pub upstream_metadata: Option<Value>,
|
||||||
pub etag: Option<String>,
|
pub etag: Option<String>,
|
||||||
pub upstream_status: Option<u16>,
|
pub upstream_status: Option<u16>,
|
||||||
@@ -143,7 +145,25 @@ pub async fn fetch_models_from_transports_for_client_version(
|
|||||||
codex_client_version: Option<&str>,
|
codex_client_version: Option<&str>,
|
||||||
) -> Result<ModelsFetchOutcome, String> {
|
) -> Result<ModelsFetchOutcome, String> {
|
||||||
let strategy = select_model_fetch_strategy(transports)?;
|
let strategy = select_model_fetch_strategy(transports)?;
|
||||||
execute_model_fetch_strategy(runtime, transports, strategy, codex_client_version).await
|
execute_model_fetch_strategy(
|
||||||
|
runtime,
|
||||||
|
transports,
|
||||||
|
strategy,
|
||||||
|
codex_client_version,
|
||||||
|
codex_client_version.is_none(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Management also supports Codex-compatible proxies returning OpenAI `data` arrays,
|
||||||
|
/// independently of the fingerprint sent upstream. Public client catalogs stay strict.
|
||||||
|
pub async fn fetch_models_from_transports_for_management(
|
||||||
|
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, codex_client_version, true).await
|
||||||
}
|
}
|
||||||
|
|
||||||
fn select_model_fetch_strategy(
|
fn select_model_fetch_strategy(
|
||||||
@@ -213,6 +233,7 @@ async fn execute_model_fetch_strategy(
|
|||||||
transports: &[GatewayProviderTransportSnapshot],
|
transports: &[GatewayProviderTransportSnapshot],
|
||||||
strategy: SelectedModelFetchStrategy,
|
strategy: SelectedModelFetchStrategy,
|
||||||
codex_client_version: Option<&str>,
|
codex_client_version: Option<&str>,
|
||||||
|
allow_codex_legacy_response: bool,
|
||||||
) -> Result<ModelsFetchOutcome, String> {
|
) -> Result<ModelsFetchOutcome, String> {
|
||||||
let Some(first_transport) = transports.first() else {
|
let Some(first_transport) = transports.first() else {
|
||||||
return Err("No transport snapshots available for models fetch".to_string());
|
return Err("No transport snapshots available for models fetch".to_string());
|
||||||
@@ -230,6 +251,7 @@ async fn execute_model_fetch_strategy(
|
|||||||
transports,
|
transports,
|
||||||
strategy.provider_id(),
|
strategy.provider_id(),
|
||||||
codex_client_version,
|
codex_client_version,
|
||||||
|
allow_codex_legacy_response,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
@@ -255,6 +277,7 @@ async fn fetch_standard_models(
|
|||||||
transports: &[GatewayProviderTransportSnapshot],
|
transports: &[GatewayProviderTransportSnapshot],
|
||||||
provider_type: &str,
|
provider_type: &str,
|
||||||
codex_client_version: Option<&str>,
|
codex_client_version: Option<&str>,
|
||||||
|
allow_codex_legacy_response: bool,
|
||||||
) -> Result<ModelsFetchOutcome, String> {
|
) -> Result<ModelsFetchOutcome, String> {
|
||||||
let mut all_models = Vec::new();
|
let mut all_models = Vec::new();
|
||||||
let mut successful_codex_catalogs = Vec::<(String, Vec<Value>)>::new();
|
let mut successful_codex_catalogs = Vec::<(String, Vec<Value>)>::new();
|
||||||
@@ -263,10 +286,19 @@ async fn fetch_standard_models(
|
|||||||
let mut etag = ConsistentValue::default();
|
let mut etag = ConsistentValue::default();
|
||||||
let mut upstream_status = ConsistentValue::default();
|
let mut upstream_status = ConsistentValue::default();
|
||||||
let is_codex = provider_type.trim().eq_ignore_ascii_case("codex");
|
let is_codex = provider_type.trim().eq_ignore_ascii_case("codex");
|
||||||
|
let mut native_codex_catalog = is_codex;
|
||||||
|
|
||||||
for transport in transports {
|
for transport in transports {
|
||||||
match fetch_standard_models_for_transport(runtime, transport, codex_client_version).await {
|
match fetch_standard_models_for_transport(
|
||||||
|
runtime,
|
||||||
|
transport,
|
||||||
|
codex_client_version,
|
||||||
|
allow_codex_legacy_response,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
Ok(outcome) => {
|
Ok(outcome) => {
|
||||||
|
native_codex_catalog &= outcome.native_codex_catalog;
|
||||||
all_models.extend(outcome.cached_models.iter().cloned());
|
all_models.extend(outcome.cached_models.iter().cloned());
|
||||||
if is_codex && outcome.has_success {
|
if is_codex && outcome.has_success {
|
||||||
successful_codex_catalogs
|
successful_codex_catalogs
|
||||||
@@ -294,6 +326,7 @@ async fn fetch_standard_models(
|
|||||||
let upstream_metadata =
|
let upstream_metadata =
|
||||||
crate::logic::model_catalog_upstream_metadata(provider_type, &merged_models);
|
crate::logic::model_catalog_upstream_metadata(provider_type, &merged_models);
|
||||||
let mut outcome = build_success_outcome(merged_models, upstream_metadata, has_success);
|
let mut outcome = build_success_outcome(merged_models, upstream_metadata, has_success);
|
||||||
|
outcome.native_codex_catalog = native_codex_catalog && has_success;
|
||||||
if let Some(model_ids) = codex_model_ids {
|
if let Some(model_ids) = codex_model_ids {
|
||||||
outcome.fetched_model_ids = model_ids;
|
outcome.fetched_model_ids = model_ids;
|
||||||
outcome.legacy_models = project_codex_models_for_legacy_cache(
|
outcome.legacy_models = project_codex_models_for_legacy_cache(
|
||||||
@@ -312,6 +345,7 @@ async fn fetch_standard_models_for_transport(
|
|||||||
runtime: &(impl ModelFetchTransportRuntime + ?Sized),
|
runtime: &(impl ModelFetchTransportRuntime + ?Sized),
|
||||||
transport: &GatewayProviderTransportSnapshot,
|
transport: &GatewayProviderTransportSnapshot,
|
||||||
codex_client_version: Option<&str>,
|
codex_client_version: Option<&str>,
|
||||||
|
allow_codex_legacy_response: bool,
|
||||||
) -> Result<ModelsFetchOutcome, (String, Option<u16>)> {
|
) -> Result<ModelsFetchOutcome, (String, Option<u16>)> {
|
||||||
let mut all_models = Vec::new();
|
let mut all_models = Vec::new();
|
||||||
let mut seen_ids = BTreeSet::new();
|
let mut seen_ids = BTreeSet::new();
|
||||||
@@ -324,6 +358,7 @@ async fn fetch_standard_models_for_transport(
|
|||||||
.provider_type
|
.provider_type
|
||||||
.trim()
|
.trim()
|
||||||
.eq_ignore_ascii_case("codex");
|
.eq_ignore_ascii_case("codex");
|
||||||
|
let mut native_codex_catalog = is_codex;
|
||||||
|
|
||||||
for _ in 0..20 {
|
for _ in 0..20 {
|
||||||
let plan = build_standard_models_fetch_execution_plan_for_client_version(
|
let plan = build_standard_models_fetch_execution_plan_for_client_version(
|
||||||
@@ -341,11 +376,16 @@ async fn fetch_standard_models_for_transport(
|
|||||||
upstream_status.observe(Some(result.status_code));
|
upstream_status.observe(Some(result.status_code));
|
||||||
let body_json =
|
let body_json =
|
||||||
execution_result_json_body(&result).map_err(|err| (err, Some(result.status_code)))?;
|
execution_result_json_body(&result).map_err(|err| (err, Some(result.status_code)))?;
|
||||||
|
native_codex_catalog &= body_json.get("models").and_then(Value::as_array).is_some();
|
||||||
let parsed = if is_codex {
|
let parsed = if is_codex {
|
||||||
parse_codex_models_response_for_request(
|
parse_codex_models_response_for_request(
|
||||||
&transport.endpoint.api_format,
|
&transport.endpoint.api_format,
|
||||||
&body_json,
|
&body_json,
|
||||||
codex_client_version,
|
if allow_codex_legacy_response {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
codex_client_version
|
||||||
|
},
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
parse_models_response_page(&transport.endpoint.api_format, &body_json)
|
parse_models_response_page(&transport.endpoint.api_format, &body_json)
|
||||||
@@ -385,7 +425,9 @@ async fn fetch_standard_models_for_transport(
|
|||||||
next_after_id = Some(next_cursor);
|
next_after_id = Some(next_cursor);
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(build_success_outcome(all_models, None, has_success)
|
let mut outcome = build_success_outcome(all_models, None, has_success);
|
||||||
|
outcome.native_codex_catalog = native_codex_catalog && has_success;
|
||||||
|
Ok(outcome
|
||||||
.with_etag(etag.finish())
|
.with_etag(etag.finish())
|
||||||
.with_upstream_status(upstream_status.finish()))
|
.with_upstream_status(upstream_status.finish()))
|
||||||
}
|
}
|
||||||
@@ -459,6 +501,7 @@ async fn fetch_antigravity_models(
|
|||||||
legacy_models: Vec::new(),
|
legacy_models: Vec::new(),
|
||||||
errors,
|
errors,
|
||||||
has_success: false,
|
has_success: false,
|
||||||
|
native_codex_catalog: false,
|
||||||
upstream_metadata: None,
|
upstream_metadata: None,
|
||||||
etag: None,
|
etag: None,
|
||||||
upstream_status: None,
|
upstream_status: None,
|
||||||
@@ -642,6 +685,7 @@ async fn fetch_vertex_api_key_models(
|
|||||||
legacy_models: Vec::new(),
|
legacy_models: Vec::new(),
|
||||||
errors: vec!["vertex_ai(api_key): missing api key".to_string()],
|
errors: vec!["vertex_ai(api_key): missing api key".to_string()],
|
||||||
has_success: false,
|
has_success: false,
|
||||||
|
native_codex_catalog: false,
|
||||||
upstream_metadata: None,
|
upstream_metadata: None,
|
||||||
etag: None,
|
etag: None,
|
||||||
upstream_status: None,
|
upstream_status: None,
|
||||||
@@ -702,6 +746,7 @@ async fn fetch_vertex_api_key_models(
|
|||||||
legacy_models: Vec::new(),
|
legacy_models: Vec::new(),
|
||||||
errors,
|
errors,
|
||||||
has_success,
|
has_success,
|
||||||
|
native_codex_catalog: false,
|
||||||
upstream_metadata: None,
|
upstream_metadata: None,
|
||||||
etag: None,
|
etag: None,
|
||||||
upstream_status: None,
|
upstream_status: None,
|
||||||
@@ -720,6 +765,7 @@ async fn fetch_vertex_service_account_models(
|
|||||||
legacy_models: Vec::new(),
|
legacy_models: Vec::new(),
|
||||||
errors: vec!["vertex_ai(service_account): missing auth_config".to_string()],
|
errors: vec!["vertex_ai(service_account): missing auth_config".to_string()],
|
||||||
has_success: false,
|
has_success: false,
|
||||||
|
native_codex_catalog: false,
|
||||||
upstream_metadata: None,
|
upstream_metadata: None,
|
||||||
etag: None,
|
etag: None,
|
||||||
upstream_status: None,
|
upstream_status: None,
|
||||||
@@ -791,6 +837,7 @@ async fn fetch_vertex_service_account_models(
|
|||||||
legacy_models: Vec::new(),
|
legacy_models: Vec::new(),
|
||||||
errors,
|
errors,
|
||||||
has_success,
|
has_success,
|
||||||
|
native_codex_catalog: false,
|
||||||
upstream_metadata: None,
|
upstream_metadata: None,
|
||||||
etag: None,
|
etag: None,
|
||||||
upstream_status: None,
|
upstream_status: None,
|
||||||
@@ -1426,6 +1473,7 @@ fn build_success_outcome(
|
|||||||
legacy_models,
|
legacy_models,
|
||||||
errors: Vec::new(),
|
errors: Vec::new(),
|
||||||
has_success,
|
has_success,
|
||||||
|
native_codex_catalog: false,
|
||||||
upstream_metadata,
|
upstream_metadata,
|
||||||
etag: None,
|
etag: None,
|
||||||
upstream_status: None,
|
upstream_status: None,
|
||||||
@@ -2428,6 +2476,20 @@ mod tests {
|
|||||||
assert!(outcome.has_success);
|
assert!(outcome.has_success);
|
||||||
assert_eq!(outcome.fetched_model_ids, vec!["gpt-legacy-compatible"]);
|
assert_eq!(outcome.fetched_model_ids, vec!["gpt-legacy-compatible"]);
|
||||||
assert_eq!(outcome.cached_models[0]["id"], "gpt-legacy-compatible");
|
assert_eq!(outcome.cached_models[0]["id"], "gpt-legacy-compatible");
|
||||||
|
assert!(!outcome.native_codex_catalog);
|
||||||
|
let versioned = crate::fetch_models_from_transports_for_management(
|
||||||
|
&runtime,
|
||||||
|
&[sample_codex_transport()],
|
||||||
|
Some("0.153.3"),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("management retains data-array compatibility with a new fingerprint");
|
||||||
|
assert!(versioned.has_success);
|
||||||
|
assert!(
|
||||||
|
!versioned.native_codex_catalog,
|
||||||
|
"generic responses cannot replace opaque catalogs"
|
||||||
|
);
|
||||||
|
assert_eq!(versioned.fetched_model_ids, outcome.fetched_model_ids);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
outcome.legacy_models[0]["api_formats"],
|
outcome.legacy_models[0]["api_formats"],
|
||||||
json!(["openai:responses"])
|
json!(["openai:responses"])
|
||||||
|
|||||||
@@ -986,7 +986,7 @@ mod tests {
|
|||||||
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
plan.url,
|
plan.url,
|
||||||
"https://chatgpt.com/backend-api/codex/models?client_version=0.144.1"
|
"https://chatgpt.com/backend-api/codex/models?client_version=0.153.3"
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
plan.headers.get("authorization").map(String::as_str),
|
plan.headers.get("authorization").map(String::as_str),
|
||||||
|
|||||||
@@ -70,7 +70,7 @@
|
|||||||
class="p-2 hover:bg-muted rounded-md transition-colors shrink-0"
|
class="p-2 hover:bg-muted rounded-md transition-colors shrink-0"
|
||||||
:disabled="fetchingUpstreamModels"
|
:disabled="fetchingUpstreamModels"
|
||||||
title="刷新上游模型"
|
title="刷新上游模型"
|
||||||
@click="fetchUpstreamModels()"
|
@click="fetchUpstreamModels(true)"
|
||||||
>
|
>
|
||||||
<RefreshCw
|
<RefreshCw
|
||||||
class="w-4 h-4"
|
class="w-4 h-4"
|
||||||
@@ -82,7 +82,7 @@
|
|||||||
type="button"
|
type="button"
|
||||||
class="p-2 hover:bg-muted rounded-md transition-colors shrink-0"
|
class="p-2 hover:bg-muted rounded-md transition-colors shrink-0"
|
||||||
title="从提供商获取模型"
|
title="从提供商获取模型"
|
||||||
@click="fetchUpstreamModels()"
|
@click="fetchUpstreamModels(true)"
|
||||||
>
|
>
|
||||||
<Zap class="w-4 h-4" />
|
<Zap class="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
@@ -678,12 +678,12 @@ function toggleGroupCollapse(group: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 从提供商获取模型(使用缓存)
|
// 从提供商获取模型(使用缓存)
|
||||||
async function fetchUpstreamModels() {
|
async function fetchUpstreamModels(forceRefresh = false) {
|
||||||
if (!props.providerId) return
|
if (!props.providerId) return
|
||||||
try {
|
try {
|
||||||
loadingModels.value = true
|
loadingModels.value = true
|
||||||
fetchingUpstreamModels.value = true
|
fetchingUpstreamModels.value = true
|
||||||
const result = await fetchCachedModels(props.providerId)
|
const result = await fetchCachedModels(props.providerId, undefined, forceRefresh)
|
||||||
if (result.models.length > 0) {
|
if (result.models.length > 0) {
|
||||||
upstreamModels.value = result.models
|
upstreamModels.value = result.models
|
||||||
upstreamModelsLoaded.value = true
|
upstreamModelsLoaded.value = true
|
||||||
|
|||||||
@@ -153,7 +153,24 @@ describe('ModelMappingDialog', () => {
|
|||||||
mountedApps.push({ app, root })
|
mountedApps.push({ app, root })
|
||||||
|
|
||||||
await vi.waitFor(() => expect(upstreamModelMocks.fetchModels).toHaveBeenCalledTimes(1))
|
await vi.waitFor(() => expect(upstreamModelMocks.fetchModels).toHaveBeenCalledTimes(1))
|
||||||
expect(upstreamModelMocks.fetchModels).toHaveBeenCalledWith('provider-1')
|
expect(upstreamModelMocks.fetchModels).toHaveBeenCalledWith('provider-1', undefined, false)
|
||||||
|
|
||||||
|
// Both manual fetch and refresh must bypass the backend cache.
|
||||||
|
upstreamModelMocks.fetchModels.mockResolvedValue({ models: [{ id: 'gpt-old' }] })
|
||||||
|
await nextTick()
|
||||||
|
const fetchButton = root.querySelector<HTMLButtonElement>('[title="从提供商获取模型"]')
|
||||||
|
expect(fetchButton).not.toBeNull()
|
||||||
|
fetchButton!.click()
|
||||||
|
await vi.waitFor(() => expect(root.textContent).toContain('gpt-old'))
|
||||||
|
expect(upstreamModelMocks.fetchModels).toHaveBeenLastCalledWith('provider-1', undefined, true)
|
||||||
|
|
||||||
|
upstreamModelMocks.fetchModels.mockResolvedValue({ models: [{ id: 'gpt-6-astra' }] })
|
||||||
|
const refreshButton = root.querySelector<HTMLButtonElement>('[title="刷新上游模型"]')
|
||||||
|
expect(refreshButton).not.toBeNull()
|
||||||
|
refreshButton!.click()
|
||||||
|
await vi.waitFor(() => expect(root.textContent).toContain('gpt-6-astra'))
|
||||||
|
expect(root.textContent).not.toContain('gpt-old')
|
||||||
|
expect(upstreamModelMocks.fetchModels).toHaveBeenLastCalledWith('provider-1', undefined, true)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('offers session compaction only for an explicitly selected Responses endpoint', async () => {
|
it('offers session compaction only for an explicitly selected Responses endpoint', async () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user