mirror of
https://github.com/openai/codex.git
synced 2026-10-19 17:28:46 +00:00
## What changed Carry optional `available_access_programs` metadata through model information, presets, caches, and the TUI, and expose it as `availableAccessPrograms` in app-server `model/list` responses. Update the generated JSON schemas, TypeScript types, and Python models. Preserve the distinction between missing metadata and an empty `cyber` list. Ignore unknown cyber program names when reading the catalog so new server programs do not prevent older clients from loading it. Discovery metadata does not grant access; inference still enforces authorization. ## Testing Add coverage for absent, null, empty, and populated metadata, unknown program names, and app-server serialization. Extend cache tests and verify that online refreshes persist changed access metadata even when the catalog ETag stays unchanged. GitOrigin-RevId: b3ef5805c1c61b8d64b9b896c9c0a79120143667
1626 lines
53 KiB
Rust
1626 lines
53 KiB
Rust
use super::*;
|
|
use crate::ModelsManagerConfig;
|
|
use crate::cache::FileModelsCache;
|
|
use crate::cache::ModelsCache;
|
|
use crate::cache::ModelsCacheEntry;
|
|
use crate::cache::ModelsCacheError;
|
|
use crate::cache::ModelsCacheFuture;
|
|
use chrono::Utc;
|
|
use codex_http_client::HttpClientFactory;
|
|
use codex_http_client::OutboundProxyPolicy;
|
|
use codex_login::AuthCredentialsStoreMode;
|
|
use codex_login::AuthKeyringBackendKind;
|
|
use codex_login::AuthManager;
|
|
use codex_login::CodexAuth;
|
|
use codex_login::ExternalAuth;
|
|
use codex_login::ExternalAuthRefreshContext;
|
|
use codex_login::TokenData;
|
|
use codex_protocol::auth::AuthMode;
|
|
use codex_protocol::openai_models::ModelAccessPrograms;
|
|
use codex_protocol::openai_models::ModelsResponse;
|
|
use codex_protocol::turn_input::CyberAccessProgram;
|
|
use pretty_assertions::assert_eq;
|
|
use serde_json::json;
|
|
use std::collections::VecDeque;
|
|
use std::path::Path;
|
|
use std::sync::Arc;
|
|
use std::sync::Mutex;
|
|
use std::sync::atomic::AtomicUsize;
|
|
use std::sync::atomic::Ordering;
|
|
use tempfile::tempdir;
|
|
|
|
#[path = "api_key_discovery_tests.rs"]
|
|
mod api_key_discovery_tests;
|
|
|
|
#[path = "cache_identity_tests.rs"]
|
|
mod cache_identity_tests;
|
|
|
|
#[path = "model_info_overrides_tests.rs"]
|
|
mod model_info_overrides_tests;
|
|
|
|
const DEFAULT_HTTP_CLIENT_FACTORY: HttpClientFactory =
|
|
HttpClientFactory::new(OutboundProxyPolicy::ReqwestDefault);
|
|
|
|
fn remote_model(slug: &str, display: &str, priority: i32) -> ModelInfo {
|
|
remote_model_with_visibility(slug, display, priority, "list")
|
|
}
|
|
|
|
fn remote_model_with_visibility(
|
|
slug: &str,
|
|
display: &str,
|
|
priority: i32,
|
|
visibility: &str,
|
|
) -> ModelInfo {
|
|
serde_json::from_value(json!({
|
|
"slug": slug,
|
|
"display_name": display,
|
|
"description": format!("{display} desc"),
|
|
"default_reasoning_level": "medium",
|
|
"supported_reasoning_levels": [{"effort": "low", "description": "low"}, {"effort": "medium", "description": "medium"}],
|
|
"shell_type": "shell_command",
|
|
"visibility": visibility,
|
|
"minimal_client_version": [0, 1, 0],
|
|
"supported_in_api": true,
|
|
"priority": priority,
|
|
"upgrade": null,
|
|
"model_messages": {
|
|
"instructions_template": "base instructions",
|
|
"instructions_variables": null,
|
|
"approvals": null,
|
|
"auto_review": null,
|
|
"permissions": null
|
|
},
|
|
"support_verbosity": false,
|
|
"default_verbosity": null,
|
|
"apply_patch_tool_type": null,
|
|
"truncation_policy": {"mode": "bytes", "limit": 10_000},
|
|
"supports_image_detail_original": false,
|
|
"context_window": 272_000,
|
|
"max_context_window": 272_000,
|
|
"experimental_supported_tools": [],
|
|
}))
|
|
.expect("valid model")
|
|
}
|
|
|
|
fn assert_models_contain(actual: &[ModelInfo], expected: &[ModelInfo]) {
|
|
for model in expected {
|
|
assert!(
|
|
actual.iter().any(|candidate| candidate.slug == model.slug),
|
|
"expected model {} in cached list",
|
|
model.slug
|
|
);
|
|
}
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
struct TestModelsEndpoint {
|
|
has_command_auth: bool,
|
|
uses_codex_backend: bool,
|
|
responses: Mutex<VecDeque<Vec<ModelInfo>>>,
|
|
etag: Option<String>,
|
|
fetch_count: AtomicUsize,
|
|
observed_proxy_policy: Mutex<Option<OutboundProxyPolicy>>,
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
struct TestModelsCache {
|
|
entry: Mutex<Option<ModelsCacheEntry>>,
|
|
load_error: bool,
|
|
store_error: bool,
|
|
stored_entries: Mutex<Vec<ModelsCacheEntry>>,
|
|
}
|
|
|
|
impl TestModelsCache {
|
|
fn with_entry(entry: ModelsCacheEntry) -> Arc<Self> {
|
|
Arc::new(Self {
|
|
entry: Mutex::new(Some(entry)),
|
|
load_error: false,
|
|
store_error: false,
|
|
stored_entries: Mutex::new(Vec::new()),
|
|
})
|
|
}
|
|
|
|
fn failing(load_error: bool, store_error: bool) -> Arc<Self> {
|
|
Arc::new(Self {
|
|
entry: Mutex::new(None),
|
|
load_error,
|
|
store_error,
|
|
stored_entries: Mutex::new(Vec::new()),
|
|
})
|
|
}
|
|
|
|
fn stored_entries(&self) -> Vec<ModelsCacheEntry> {
|
|
self.stored_entries
|
|
.lock()
|
|
.expect("stored entries lock should not be poisoned")
|
|
.clone()
|
|
}
|
|
}
|
|
|
|
impl ModelsCache for TestModelsCache {
|
|
fn load<'a>(
|
|
&'a self,
|
|
_client_version: &'a str,
|
|
) -> ModelsCacheFuture<'a, Result<Option<ModelsCacheEntry>, ModelsCacheError>> {
|
|
Box::pin(async move {
|
|
if self.load_error {
|
|
return Err(ModelsCacheError::new("test load failure"));
|
|
}
|
|
Ok(self
|
|
.entry
|
|
.lock()
|
|
.expect("cache entry lock should not be poisoned")
|
|
.clone())
|
|
})
|
|
}
|
|
|
|
fn store<'a>(
|
|
&'a self,
|
|
entry: &'a ModelsCacheEntry,
|
|
) -> ModelsCacheFuture<'a, Result<(), ModelsCacheError>> {
|
|
Box::pin(async move {
|
|
if self.store_error {
|
|
return Err(ModelsCacheError::new("test store failure"));
|
|
}
|
|
self.stored_entries
|
|
.lock()
|
|
.expect("stored entries lock should not be poisoned")
|
|
.push(entry.clone());
|
|
Ok(())
|
|
})
|
|
}
|
|
|
|
fn refresh_ttl<'a>(
|
|
&'a self,
|
|
_client_version: &'a str,
|
|
_identity: &'a str,
|
|
_etag: &'a str,
|
|
) -> ModelsCacheFuture<'a, Result<(), ModelsCacheError>> {
|
|
Box::pin(async move {
|
|
let refreshed = {
|
|
let mut entry = self
|
|
.entry
|
|
.lock()
|
|
.expect("cache entry lock should not be poisoned");
|
|
let entry = entry
|
|
.as_mut()
|
|
.ok_or_else(|| ModelsCacheError::new("cache not found"))?;
|
|
entry.fetched_at = Utc::now();
|
|
entry.clone()
|
|
};
|
|
self.store(&refreshed).await
|
|
})
|
|
}
|
|
}
|
|
|
|
impl TestModelsEndpoint {
|
|
fn new(responses: Vec<Vec<ModelInfo>>) -> Arc<Self> {
|
|
Arc::new(Self {
|
|
has_command_auth: false,
|
|
uses_codex_backend: true,
|
|
responses: Mutex::new(responses.into()),
|
|
etag: None,
|
|
fetch_count: AtomicUsize::new(0),
|
|
observed_proxy_policy: Mutex::new(None),
|
|
})
|
|
}
|
|
|
|
fn without_refresh(responses: Vec<Vec<ModelInfo>>) -> Arc<Self> {
|
|
Arc::new(Self {
|
|
has_command_auth: false,
|
|
uses_codex_backend: false,
|
|
responses: Mutex::new(responses.into()),
|
|
etag: None,
|
|
fetch_count: AtomicUsize::new(0),
|
|
observed_proxy_policy: Mutex::new(None),
|
|
})
|
|
}
|
|
|
|
fn fetch_count(&self) -> usize {
|
|
self.fetch_count.load(Ordering::SeqCst)
|
|
}
|
|
|
|
fn observed_proxy_policy(&self) -> Option<OutboundProxyPolicy> {
|
|
*self
|
|
.observed_proxy_policy
|
|
.lock()
|
|
.expect("observed proxy policy lock should not be poisoned")
|
|
}
|
|
|
|
async fn list_models(&self) -> CoreResult<ModelsEndpointResponse> {
|
|
self.fetch_count.fetch_add(1, Ordering::SeqCst);
|
|
let models = self
|
|
.responses
|
|
.lock()
|
|
.expect("responses lock should not be poisoned")
|
|
.pop_front()
|
|
.unwrap_or_default();
|
|
Ok(ModelsEndpointResponse {
|
|
models,
|
|
etag: self.etag.clone(),
|
|
identity: self.identity().expect("test endpoint identity"),
|
|
})
|
|
}
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
struct TestExternalApiKeyAuth;
|
|
|
|
impl ExternalAuth for TestExternalApiKeyAuth {
|
|
fn resolve(&self) -> codex_login::ExternalAuthFuture<'_, CodexAuth> {
|
|
Box::pin(async { Ok(CodexAuth::from_api_key("test-external-api-key")) })
|
|
}
|
|
|
|
fn refresh(
|
|
&self,
|
|
_context: ExternalAuthRefreshContext,
|
|
) -> codex_login::ExternalAuthFuture<'_, CodexAuth> {
|
|
Box::pin(async { Ok(CodexAuth::from_api_key("test-external-api-key")) })
|
|
}
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
struct TestUnresolvedExternalApiKeyAuth;
|
|
|
|
impl ExternalAuth for TestUnresolvedExternalApiKeyAuth {
|
|
fn resolve(&self) -> codex_login::ExternalAuthFuture<'_, CodexAuth> {
|
|
Box::pin(async { Err(std::io::Error::other("unresolved test auth")) })
|
|
}
|
|
|
|
fn refresh(
|
|
&self,
|
|
_context: ExternalAuthRefreshContext,
|
|
) -> codex_login::ExternalAuthFuture<'_, CodexAuth> {
|
|
Box::pin(async { Err(std::io::Error::other("unresolved test auth")) })
|
|
}
|
|
}
|
|
|
|
impl ModelsEndpointClient for TestModelsEndpoint {
|
|
fn supports_api_key_models(&self) -> bool {
|
|
true
|
|
}
|
|
|
|
fn identity(&self) -> Option<String> {
|
|
Some("test-provider".to_string())
|
|
}
|
|
|
|
fn has_command_auth(&self) -> bool {
|
|
self.has_command_auth
|
|
}
|
|
|
|
fn uses_codex_backend(&self) -> ModelsEndpointFuture<'_, bool> {
|
|
Box::pin(async { self.uses_codex_backend })
|
|
}
|
|
|
|
fn list_models<'a>(
|
|
&'a self,
|
|
_client_version: &'a str,
|
|
http_client_factory: HttpClientFactory,
|
|
) -> ModelsEndpointFuture<'a, CoreResult<ModelsEndpointResponse>> {
|
|
Box::pin(async move {
|
|
*self
|
|
.observed_proxy_policy
|
|
.lock()
|
|
.expect("observed proxy policy lock should not be poisoned") =
|
|
Some(http_client_factory.outbound_proxy_policy());
|
|
TestModelsEndpoint::list_models(self).await
|
|
})
|
|
}
|
|
}
|
|
|
|
fn openai_manager_for_tests(
|
|
codex_home: std::path::PathBuf,
|
|
endpoint_client: Arc<dyn ModelsEndpointClient>,
|
|
) -> OpenAiModelsManager {
|
|
openai_manager_for_tests_with_auth(
|
|
codex_home,
|
|
endpoint_client,
|
|
Some(AuthManager::from_auth_for_testing(
|
|
CodexAuth::create_dummy_chatgpt_auth_for_testing(),
|
|
)),
|
|
)
|
|
}
|
|
|
|
fn openai_manager_for_tests_with_auth(
|
|
codex_home: std::path::PathBuf,
|
|
endpoint_client: Arc<dyn ModelsEndpointClient>,
|
|
auth_manager: Option<Arc<AuthManager>>,
|
|
) -> OpenAiModelsManager {
|
|
OpenAiModelsManager::new(codex_home, endpoint_client, auth_manager)
|
|
}
|
|
|
|
async fn mutate_file_cache_for_test<F>(codex_home: &Path, f: F)
|
|
where
|
|
F: FnOnce(&mut ModelsCacheEntry),
|
|
{
|
|
let client_version = crate::client_version_to_whole();
|
|
let cache = FileModelsCache::new(codex_home.join(MODEL_CACHE_FILE), DEFAULT_MODEL_CACHE_TTL);
|
|
let mut entry = cache
|
|
.load(&client_version)
|
|
.await
|
|
.expect("cache load succeeds")
|
|
.expect("cache entry exists");
|
|
f(&mut entry);
|
|
cache.store(&entry).await.expect("cache store succeeds");
|
|
}
|
|
|
|
fn static_manager_for_tests(model_catalog: ModelsResponse) -> StaticModelsManager {
|
|
StaticModelsManager::new(/*auth_manager*/ None, model_catalog)
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn file_cache_implements_models_cache_contract() {
|
|
let codex_home = tempdir().expect("temp dir");
|
|
let cache = FileModelsCache::new(
|
|
codex_home.path().join(MODEL_CACHE_FILE),
|
|
DEFAULT_MODEL_CACHE_TTL,
|
|
);
|
|
let client_version = crate::client_version_to_whole();
|
|
let entry = ModelsCacheEntry {
|
|
identity: Some("test-provider".to_string()),
|
|
fetched_at: Utc::now(),
|
|
etag: Some("file-etag".to_string()),
|
|
client_version: Some(client_version.clone()),
|
|
models: vec![ModelInfo {
|
|
available_access_programs: Some(ModelAccessPrograms {
|
|
cyber: vec![
|
|
CyberAccessProgram::Standard,
|
|
CyberAccessProgram::DaybreakBlue,
|
|
],
|
|
}),
|
|
..remote_model("file-cached", "File Cached", /*priority*/ 0)
|
|
}],
|
|
};
|
|
|
|
cache.store(&entry).await.expect("cache store succeeds");
|
|
|
|
assert_eq!(
|
|
cache
|
|
.load(&client_version)
|
|
.await
|
|
.expect("cache load succeeds"),
|
|
Some(entry)
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn file_cache_refresh_ttl_renews_expired_entry_without_serving_it_stale() {
|
|
let codex_home = tempdir().expect("temp dir");
|
|
let cache = FileModelsCache::new(
|
|
codex_home.path().join(MODEL_CACHE_FILE),
|
|
DEFAULT_MODEL_CACHE_TTL,
|
|
);
|
|
let client_version = crate::client_version_to_whole();
|
|
let expired_at = Utc::now() - chrono::Duration::hours(1);
|
|
let entry = ModelsCacheEntry {
|
|
identity: Some("test-provider".to_string()),
|
|
fetched_at: expired_at,
|
|
etag: Some("expired-etag".to_string()),
|
|
client_version: Some(client_version.clone()),
|
|
models: vec![remote_model(
|
|
"expired-file-cache",
|
|
"Expired File Cache",
|
|
/*priority*/ 0,
|
|
)],
|
|
};
|
|
cache.store(&entry).await.expect("cache store succeeds");
|
|
|
|
assert_eq!(
|
|
cache
|
|
.load(&client_version)
|
|
.await
|
|
.expect("cache load succeeds"),
|
|
None,
|
|
"an expired entry must not be returned before revalidation"
|
|
);
|
|
|
|
cache
|
|
.refresh_ttl(
|
|
&client_version,
|
|
entry.identity.as_deref().unwrap(),
|
|
entry.etag.as_deref().unwrap(),
|
|
)
|
|
.await
|
|
.expect("TTL refresh succeeds");
|
|
|
|
let refreshed = cache
|
|
.load(&client_version)
|
|
.await
|
|
.expect("cache load succeeds")
|
|
.expect("revalidated entry is fresh");
|
|
assert!(refreshed.fetched_at > expired_at);
|
|
let mut expected = entry;
|
|
expected.fetched_at = refreshed.fetched_at;
|
|
assert_eq!(refreshed, expected);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn manager_without_cache_fetches_on_every_refresh() {
|
|
let remote_models = vec![remote_model("remote", "Remote", /*priority*/ 0)];
|
|
let endpoint = TestModelsEndpoint::new(vec![remote_models.clone(), remote_models.clone()]);
|
|
let manager = OpenAiModelsManager::new_without_cache(
|
|
endpoint.clone(),
|
|
Some(AuthManager::from_auth_for_testing(
|
|
CodexAuth::create_dummy_chatgpt_auth_for_testing(),
|
|
)),
|
|
);
|
|
|
|
let catalog = manager
|
|
.raw_model_catalog(
|
|
RefreshStrategy::OnlineIfUncached,
|
|
DEFAULT_HTTP_CLIENT_FACTORY,
|
|
)
|
|
.await;
|
|
let second_catalog = manager
|
|
.raw_model_catalog(
|
|
RefreshStrategy::OnlineIfUncached,
|
|
DEFAULT_HTTP_CLIENT_FACTORY,
|
|
)
|
|
.await;
|
|
|
|
assert_eq!(catalog.models, remote_models);
|
|
assert_eq!(second_catalog, catalog);
|
|
assert_eq!(manager.get_remote_models().await, remote_models);
|
|
assert_eq!(endpoint.fetch_count(), 2);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn injected_cache_hit_avoids_remote_fetch() {
|
|
let cached_models = vec![remote_model("cached", "Cached", /*priority*/ 0)];
|
|
let cache = TestModelsCache::with_entry(ModelsCacheEntry {
|
|
identity: Some("test-provider".to_string()),
|
|
fetched_at: Utc::now(),
|
|
etag: Some("cached-etag".to_string()),
|
|
client_version: Some(crate::client_version_to_whole()),
|
|
models: cached_models.clone(),
|
|
});
|
|
let endpoint = TestModelsEndpoint::new(vec![vec![remote_model(
|
|
"remote", "Remote", /*priority*/ 0,
|
|
)]]);
|
|
let manager = OpenAiModelsManager::new_with_cache(
|
|
cache,
|
|
endpoint.clone(),
|
|
Some(AuthManager::from_auth_for_testing(
|
|
CodexAuth::create_dummy_chatgpt_auth_for_testing(),
|
|
)),
|
|
);
|
|
|
|
let catalog = manager
|
|
.raw_model_catalog(
|
|
RefreshStrategy::OnlineIfUncached,
|
|
DEFAULT_HTTP_CLIENT_FACTORY,
|
|
)
|
|
.await;
|
|
|
|
assert_eq!(catalog.models, cached_models);
|
|
assert_eq!(endpoint.fetch_count(), 0);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn injected_cache_read_error_falls_back_and_persists_remote_models() {
|
|
let remote_models = vec![remote_model("remote", "Remote", /*priority*/ 0)];
|
|
let cache = TestModelsCache::failing(/*load_error*/ true, /*store_error*/ false);
|
|
let endpoint = TestModelsEndpoint::new(vec![remote_models.clone()]);
|
|
let manager = OpenAiModelsManager::new_with_cache(
|
|
cache.clone(),
|
|
endpoint.clone(),
|
|
Some(AuthManager::from_auth_for_testing(
|
|
CodexAuth::create_dummy_chatgpt_auth_for_testing(),
|
|
)),
|
|
);
|
|
|
|
let catalog = manager
|
|
.raw_model_catalog(
|
|
RefreshStrategy::OnlineIfUncached,
|
|
DEFAULT_HTTP_CLIENT_FACTORY,
|
|
)
|
|
.await;
|
|
|
|
assert_eq!(catalog.models, remote_models);
|
|
assert_eq!(endpoint.fetch_count(), 1);
|
|
let stored_entries = cache.stored_entries();
|
|
assert_eq!(
|
|
stored_entries,
|
|
vec![ModelsCacheEntry {
|
|
identity: Some("test-provider".to_string()),
|
|
fetched_at: stored_entries[0].fetched_at,
|
|
etag: None,
|
|
client_version: Some(crate::client_version_to_whole()),
|
|
models: remote_models,
|
|
}]
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn injected_cache_write_error_does_not_fail_remote_refresh() {
|
|
let remote_models = vec![remote_model("remote", "Remote", /*priority*/ 0)];
|
|
let cache = TestModelsCache::failing(/*load_error*/ false, /*store_error*/ true);
|
|
let endpoint = TestModelsEndpoint::new(vec![remote_models.clone()]);
|
|
let manager = OpenAiModelsManager::new_with_cache(
|
|
cache,
|
|
endpoint.clone(),
|
|
Some(AuthManager::from_auth_for_testing(
|
|
CodexAuth::create_dummy_chatgpt_auth_for_testing(),
|
|
)),
|
|
);
|
|
|
|
let catalog = manager
|
|
.raw_model_catalog(
|
|
RefreshStrategy::OnlineIfUncached,
|
|
DEFAULT_HTTP_CLIENT_FACTORY,
|
|
)
|
|
.await;
|
|
|
|
assert_eq!(catalog.models, remote_models);
|
|
assert_eq!(endpoint.fetch_count(), 1);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn injected_cache_ttl_refresh_preserves_cached_payload() {
|
|
let cached_models = vec![remote_model("cached", "Cached", /*priority*/ 0)];
|
|
let cached_at = Utc::now() - chrono::Duration::minutes(1);
|
|
let cache = TestModelsCache::with_entry(ModelsCacheEntry {
|
|
identity: Some("test-provider".to_string()),
|
|
fetched_at: cached_at,
|
|
etag: Some("cached-etag".to_string()),
|
|
client_version: Some(crate::client_version_to_whole()),
|
|
models: cached_models.clone(),
|
|
});
|
|
let manager = OpenAiModelsManager::new_with_cache(
|
|
cache.clone(),
|
|
TestModelsEndpoint::new(Vec::new()),
|
|
Some(AuthManager::from_auth_for_testing(CodexAuth::from_api_key(
|
|
"test-api-key",
|
|
))),
|
|
);
|
|
manager.set_api_key_model_discovery_enabled(/*enabled*/ true);
|
|
|
|
manager
|
|
.raw_model_catalog(
|
|
RefreshStrategy::OnlineIfUncached,
|
|
DEFAULT_HTTP_CLIENT_FACTORY,
|
|
)
|
|
.await;
|
|
manager
|
|
.refresh_if_new_etag("cached-etag".to_string(), DEFAULT_HTTP_CLIENT_FACTORY)
|
|
.await;
|
|
|
|
let stored_entries = cache.stored_entries();
|
|
assert_eq!(stored_entries.len(), 1);
|
|
assert!(stored_entries[0].fetched_at > cached_at);
|
|
assert_eq!(stored_entries[0].etag.as_deref(), Some("cached-etag"));
|
|
assert_eq!(
|
|
stored_entries[0].client_version,
|
|
Some(crate::client_version_to_whole())
|
|
);
|
|
assert_eq!(stored_entries[0].models, cached_models);
|
|
}
|
|
|
|
async fn chatgpt_auth_tokens_for_tests(codex_home: &Path) -> CodexAuth {
|
|
let auth_dot_json = codex_login::AuthDotJson {
|
|
auth_mode: Some(AuthMode::ChatgptAuthTokens),
|
|
openai_api_key: None,
|
|
tokens: Some(TokenData {
|
|
id_token: codex_login::token_data::parse_chatgpt_jwt_claims(
|
|
"eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.\
|
|
eyJlbWFpbCI6InVzZXJAZXhhbXBsZS5jb20iLCJodHRwczovL2FwaS5vcGVuYWkuY29tL2F1dGgiOnsiY2hhdGdwdF9wbGFuX3R5cGUiOiJwcm8iLCJjaGF0Z3B0X3VzZXJfaWQiOiJ1c2VyLWlkIiwiY2hhdGdwdF9hY2NvdW50X2lkIjoiYWNjb3VudC1pZCJ9fQ.\
|
|
c2ln",
|
|
)
|
|
.expect("fake id token should parse"),
|
|
access_token: "Access Token".to_string(),
|
|
refresh_token: "test".to_string(),
|
|
account_id: Some("account_id".to_string()),
|
|
}),
|
|
last_refresh: Some(Utc::now()),
|
|
agent_identity: None,
|
|
personal_access_token: None,
|
|
bedrock_api_key: None,
|
|
bedrock_access_keys: None,
|
|
};
|
|
std::fs::create_dir_all(codex_home).expect("codex home should be created");
|
|
std::fs::write(
|
|
codex_home.join("auth.json"),
|
|
serde_json::to_string(&auth_dot_json).expect("auth should serialize"),
|
|
)
|
|
.expect("auth.json should be written");
|
|
|
|
CodexAuth::from_auth_storage(
|
|
codex_home,
|
|
AuthCredentialsStoreMode::File,
|
|
/*chatgpt_base_url*/ None,
|
|
AuthKeyringBackendKind::default(),
|
|
&codex_login::test_support::transport_default_auth_route_config(),
|
|
)
|
|
.await
|
|
.expect("auth should load")
|
|
.expect("auth should be present")
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn static_manager_preserves_supported_requested_model_when_fallback_is_allowed() {
|
|
let manager = static_manager_for_tests(ModelsResponse {
|
|
models: vec![
|
|
remote_model("provider-default", "Default", /*priority*/ 0),
|
|
remote_model("provider-supported", "Supported", /*priority*/ 1),
|
|
],
|
|
});
|
|
let requested_model = Some("provider-supported".to_string());
|
|
|
|
let model = manager
|
|
.get_default_model(
|
|
&requested_model,
|
|
/*allow_provider_model_fallback*/ true,
|
|
RefreshStrategy::Offline,
|
|
DEFAULT_HTTP_CLIENT_FACTORY,
|
|
)
|
|
.await;
|
|
|
|
assert_eq!(model, "provider-supported");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn static_manager_falls_back_from_unsupported_requested_model_when_allowed() {
|
|
let manager = static_manager_for_tests(ModelsResponse {
|
|
models: vec![
|
|
remote_model("provider-default", "Default", /*priority*/ 0),
|
|
remote_model("provider-supported", "Supported", /*priority*/ 1),
|
|
],
|
|
});
|
|
let requested_model = Some("unsupported".to_string());
|
|
|
|
let model = manager
|
|
.get_default_model(
|
|
&requested_model,
|
|
/*allow_provider_model_fallback*/ true,
|
|
RefreshStrategy::Offline,
|
|
DEFAULT_HTTP_CLIENT_FACTORY,
|
|
)
|
|
.await;
|
|
|
|
assert_eq!(model, "provider-default");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn static_manager_preserves_unsupported_requested_model_when_fallback_is_disabled() {
|
|
let manager = static_manager_for_tests(ModelsResponse {
|
|
models: vec![remote_model(
|
|
"provider-default",
|
|
"Default",
|
|
/*priority*/ 0,
|
|
)],
|
|
});
|
|
let requested_model = Some("unsupported".to_string());
|
|
|
|
let model = manager
|
|
.get_default_model(
|
|
&requested_model,
|
|
/*allow_provider_model_fallback*/ false,
|
|
RefreshStrategy::Offline,
|
|
DEFAULT_HTTP_CLIENT_FACTORY,
|
|
)
|
|
.await;
|
|
|
|
assert_eq!(model, "unsupported");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn static_manager_uses_empty_default_when_fallback_is_allowed_and_catalog_is_empty() {
|
|
let manager = static_manager_for_tests(ModelsResponse { models: Vec::new() });
|
|
let requested_model = Some("unsupported".to_string());
|
|
|
|
let model = manager
|
|
.get_default_model(
|
|
&requested_model,
|
|
/*allow_provider_model_fallback*/ true,
|
|
RefreshStrategy::Offline,
|
|
DEFAULT_HTTP_CLIENT_FACTORY,
|
|
)
|
|
.await;
|
|
|
|
assert_eq!(model, "");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn dynamic_manager_preserves_requested_model_when_fallback_is_allowed() {
|
|
let codex_home = tempdir().expect("temp dir");
|
|
let endpoint = TestModelsEndpoint::new(Vec::new());
|
|
let manager = openai_manager_for_tests(codex_home.path().to_path_buf(), endpoint.clone());
|
|
let requested_model = Some("unsupported".to_string());
|
|
|
|
let model = manager
|
|
.get_default_model(
|
|
&requested_model,
|
|
/*allow_provider_model_fallback*/ true,
|
|
RefreshStrategy::Online,
|
|
DEFAULT_HTTP_CLIENT_FACTORY,
|
|
)
|
|
.await;
|
|
|
|
assert_eq!(model, "unsupported");
|
|
assert_eq!(endpoint.fetch_count(), 0);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn get_model_info_tracks_fallback_usage() {
|
|
let codex_home = tempdir().expect("temp dir");
|
|
let config = ModelsManagerConfig::default();
|
|
let manager = openai_manager_for_tests(
|
|
codex_home.path().to_path_buf(),
|
|
TestModelsEndpoint::new(Vec::new()),
|
|
);
|
|
let known_slug = manager
|
|
.get_remote_models()
|
|
.await
|
|
.first()
|
|
.expect("bundled models should include at least one model")
|
|
.slug
|
|
.clone();
|
|
|
|
let known = manager.get_model_info(known_slug.as_str(), &config).await;
|
|
assert!(!known.used_fallback_model_metadata);
|
|
assert_eq!(known.slug, known_slug);
|
|
|
|
let unknown = manager
|
|
.get_model_info("model-that-does-not-exist", &config)
|
|
.await;
|
|
assert!(unknown.used_fallback_model_metadata);
|
|
assert_eq!(unknown.slug, "model-that-does-not-exist");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn get_model_info_applies_long_context_override_to_bundled_gpt_5_6_models() {
|
|
let codex_home = tempdir().expect("temp dir");
|
|
let manager = openai_manager_for_tests(
|
|
codex_home.path().to_path_buf(),
|
|
TestModelsEndpoint::new(Vec::new()),
|
|
);
|
|
let config = ModelsManagerConfig {
|
|
model_context_window: Some(1_000_000),
|
|
..Default::default()
|
|
};
|
|
|
|
for slug in ["gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna"] {
|
|
let model_info = manager.get_model_info(slug, &config).await;
|
|
let mut expected = manager
|
|
.get_model_info(slug, &ModelsManagerConfig::default())
|
|
.await;
|
|
expected.context_window = Some(872_000);
|
|
|
|
assert_eq!(model_info, expected);
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn get_model_info_uses_custom_catalog() {
|
|
let config = ModelsManagerConfig::default();
|
|
let mut overlay = remote_model("gpt-overlay", "Overlay", /*priority*/ 0);
|
|
overlay.supports_image_detail_original = true;
|
|
|
|
let manager = static_manager_for_tests(ModelsResponse {
|
|
models: vec![overlay],
|
|
});
|
|
|
|
let model_info = manager
|
|
.get_model_info("gpt-overlay-experiment", &config)
|
|
.await;
|
|
|
|
assert_eq!(model_info.slug, "gpt-overlay-experiment");
|
|
assert_eq!(model_info.display_name, "Overlay");
|
|
assert_eq!(model_info.context_window, Some(272_000));
|
|
assert!(model_info.supports_image_detail_original);
|
|
assert!(!model_info.used_fallback_model_metadata);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn get_model_info_matches_namespaced_suffix() {
|
|
let config = ModelsManagerConfig::default();
|
|
let mut remote = remote_model("gpt-image", "Image", /*priority*/ 0);
|
|
remote.supports_image_detail_original = true;
|
|
let manager = static_manager_for_tests(ModelsResponse {
|
|
models: vec![remote],
|
|
});
|
|
let namespaced_model = "custom/gpt-image".to_string();
|
|
|
|
let model_info = manager.get_model_info(&namespaced_model, &config).await;
|
|
|
|
assert_eq!(model_info.slug, namespaced_model);
|
|
assert!(model_info.supports_image_detail_original);
|
|
assert!(!model_info.used_fallback_model_metadata);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn get_model_info_matches_hyphenated_provider_namespace_suffix() {
|
|
let config = ModelsManagerConfig::default();
|
|
let remote = remote_model("gpt-image", "Image", /*priority*/ 0);
|
|
let manager = static_manager_for_tests(ModelsResponse {
|
|
models: vec![remote],
|
|
});
|
|
let namespaced_model = "openai-codex/gpt-image".to_string();
|
|
|
|
let model_info = manager.get_model_info(&namespaced_model, &config).await;
|
|
|
|
assert_eq!(model_info.slug, namespaced_model);
|
|
assert!(!model_info.used_fallback_model_metadata);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn get_model_info_rejects_multi_segment_namespace_suffix_matching() {
|
|
let codex_home = tempdir().expect("temp dir");
|
|
let config = ModelsManagerConfig::default();
|
|
let manager = openai_manager_for_tests(
|
|
codex_home.path().to_path_buf(),
|
|
TestModelsEndpoint::new(Vec::new()),
|
|
);
|
|
let known_slug = manager
|
|
.get_remote_models()
|
|
.await
|
|
.first()
|
|
.expect("bundled models should include at least one model")
|
|
.slug
|
|
.clone();
|
|
let namespaced_model = format!("ns1/ns2/{known_slug}");
|
|
|
|
let model_info = manager.get_model_info(&namespaced_model, &config).await;
|
|
|
|
assert_eq!(model_info.slug, namespaced_model);
|
|
assert!(model_info.used_fallback_model_metadata);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn refresh_available_models_sorts_by_priority() {
|
|
let remote_models = vec![
|
|
remote_model("priority-low", "Low", /*priority*/ 1),
|
|
remote_model("priority-high", "High", /*priority*/ 0),
|
|
];
|
|
let codex_home = tempdir().expect("temp dir");
|
|
let endpoint = TestModelsEndpoint::new(vec![remote_models.clone()]);
|
|
let manager = openai_manager_for_tests(codex_home.path().to_path_buf(), endpoint.clone());
|
|
|
|
let available = manager
|
|
.list_models(
|
|
RefreshStrategy::Online,
|
|
HttpClientFactory::new(OutboundProxyPolicy::RespectSystemProxy),
|
|
)
|
|
.await;
|
|
assert_models_contain(&manager.get_remote_models().await, &remote_models);
|
|
assert_eq!(
|
|
endpoint.observed_proxy_policy(),
|
|
Some(OutboundProxyPolicy::RespectSystemProxy)
|
|
);
|
|
let high_idx = available
|
|
.iter()
|
|
.position(|model| model.model == "priority-high")
|
|
.expect("priority-high should be listed");
|
|
let low_idx = available
|
|
.iter()
|
|
.position(|model| model.model == "priority-low")
|
|
.expect("priority-low should be listed");
|
|
assert!(
|
|
high_idx < low_idx,
|
|
"higher priority should be listed before lower priority"
|
|
);
|
|
assert_eq!(endpoint.fetch_count(), 1, "expected a single model fetch");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn refresh_available_models_uses_remote_only_catalog_for_chatgpt_auth() {
|
|
let remote_models = vec![remote_model(
|
|
"chatgpt-visible-source-of-truth",
|
|
"ChatGPT Visible",
|
|
/*priority*/ 0,
|
|
)];
|
|
let codex_home = tempdir().expect("temp dir");
|
|
let endpoint = TestModelsEndpoint::new(vec![remote_models.clone()]);
|
|
let manager = openai_manager_for_tests(codex_home.path().to_path_buf(), endpoint.clone());
|
|
|
|
manager
|
|
.refresh_available_models(
|
|
RefreshStrategy::OnlineIfUncached,
|
|
&DEFAULT_HTTP_CLIENT_FACTORY,
|
|
)
|
|
.await
|
|
.expect("refresh succeeds");
|
|
|
|
assert_eq!(manager.get_remote_models().await, remote_models);
|
|
assert_eq!(endpoint.fetch_count(), 1, "expected a single model fetch");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn refresh_available_models_uses_cached_remote_only_catalog_for_chatgpt_auth() {
|
|
let remote_models = vec![remote_model(
|
|
"chatgpt-cached-source-of-truth",
|
|
"ChatGPT Cached",
|
|
/*priority*/ 0,
|
|
)];
|
|
let codex_home = tempdir().expect("temp dir");
|
|
let fetch_endpoint = TestModelsEndpoint::new(vec![remote_models.clone()]);
|
|
let fetch_manager =
|
|
openai_manager_for_tests(codex_home.path().to_path_buf(), fetch_endpoint.clone());
|
|
|
|
fetch_manager
|
|
.refresh_available_models(
|
|
RefreshStrategy::OnlineIfUncached,
|
|
&DEFAULT_HTTP_CLIENT_FACTORY,
|
|
)
|
|
.await
|
|
.expect("initial refresh succeeds");
|
|
|
|
let cache_endpoint = TestModelsEndpoint::new(Vec::new());
|
|
let cache_manager =
|
|
openai_manager_for_tests(codex_home.path().to_path_buf(), cache_endpoint.clone());
|
|
|
|
cache_manager
|
|
.refresh_available_models(
|
|
RefreshStrategy::OnlineIfUncached,
|
|
&DEFAULT_HTTP_CLIENT_FACTORY,
|
|
)
|
|
.await
|
|
.expect("cached refresh succeeds");
|
|
|
|
assert_eq!(cache_manager.get_remote_models().await, remote_models);
|
|
assert_eq!(
|
|
cache_endpoint.fetch_count(),
|
|
0,
|
|
"fresh cache should avoid a model fetch"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn get_model_info_uses_fallback_for_bundled_models_when_chatgpt_remote_is_authoritative() {
|
|
let remote_models = vec![remote_model(
|
|
"chatgpt-authoritative-model-info",
|
|
"ChatGPT Model Info",
|
|
/*priority*/ 0,
|
|
)];
|
|
let codex_home = tempdir().expect("temp dir");
|
|
let endpoint = TestModelsEndpoint::new(vec![remote_models]);
|
|
let manager = openai_manager_for_tests(codex_home.path().to_path_buf(), endpoint);
|
|
let bundled_slug = load_remote_models_from_file()
|
|
.expect("bundled models should parse")
|
|
.first()
|
|
.expect("bundled models should contain at least one model")
|
|
.slug
|
|
.clone();
|
|
|
|
manager
|
|
.refresh_available_models(
|
|
RefreshStrategy::OnlineIfUncached,
|
|
&DEFAULT_HTTP_CLIENT_FACTORY,
|
|
)
|
|
.await
|
|
.expect("refresh succeeds");
|
|
|
|
let model_info = manager
|
|
.get_model_info(&bundled_slug, &ModelsManagerConfig::default())
|
|
.await;
|
|
|
|
assert_eq!(model_info.slug, bundled_slug);
|
|
assert!(model_info.used_fallback_model_metadata);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn refresh_available_models_preserves_bundled_catalog_for_empty_chatgpt_remote() {
|
|
let codex_home = tempdir().expect("temp dir");
|
|
let endpoint = TestModelsEndpoint::new(vec![Vec::new()]);
|
|
let manager = openai_manager_for_tests(codex_home.path().to_path_buf(), endpoint);
|
|
let expected = load_remote_models_from_file().expect("bundled models should parse");
|
|
|
|
manager
|
|
.refresh_available_models(
|
|
RefreshStrategy::OnlineIfUncached,
|
|
&DEFAULT_HTTP_CLIENT_FACTORY,
|
|
)
|
|
.await
|
|
.expect("refresh succeeds");
|
|
|
|
assert_eq!(manager.get_remote_models().await, expected);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn refresh_available_models_merges_hidden_only_chatgpt_remote_with_bundled_catalog() {
|
|
let hidden_remote = remote_model_with_visibility(
|
|
"chatgpt-hidden-only",
|
|
"ChatGPT Hidden",
|
|
/*priority*/ 0,
|
|
"hide",
|
|
);
|
|
let codex_home = tempdir().expect("temp dir");
|
|
let endpoint = TestModelsEndpoint::new(vec![vec![hidden_remote.clone()]]);
|
|
let manager = openai_manager_for_tests(codex_home.path().to_path_buf(), endpoint);
|
|
let mut expected = load_remote_models_from_file().expect("bundled models should parse");
|
|
expected.push(hidden_remote);
|
|
|
|
manager
|
|
.refresh_available_models(
|
|
RefreshStrategy::OnlineIfUncached,
|
|
&DEFAULT_HTTP_CLIENT_FACTORY,
|
|
)
|
|
.await
|
|
.expect("refresh succeeds");
|
|
|
|
assert_eq!(manager.get_remote_models().await, expected);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn refresh_available_models_keeps_merging_for_custom_api_auth() {
|
|
let remote_models = vec![remote_model(
|
|
"api-auth-visible-remote",
|
|
"API Auth Visible",
|
|
/*priority*/ 0,
|
|
)];
|
|
let codex_home = tempdir().expect("temp dir");
|
|
let endpoint = Arc::new(TestModelsEndpoint {
|
|
has_command_auth: true,
|
|
uses_codex_backend: false,
|
|
responses: Mutex::new(vec![remote_models.clone()].into()),
|
|
etag: None,
|
|
fetch_count: AtomicUsize::new(0),
|
|
observed_proxy_policy: Mutex::new(None),
|
|
});
|
|
let manager = openai_manager_for_tests_with_auth(
|
|
codex_home.path().to_path_buf(),
|
|
endpoint.clone(),
|
|
Some(AuthManager::from_auth_for_testing(CodexAuth::from_api_key(
|
|
"test-api-key",
|
|
))),
|
|
);
|
|
let mut expected = load_remote_models_from_file().expect("bundled models should parse");
|
|
expected.extend(remote_models);
|
|
|
|
manager
|
|
.refresh_available_models(
|
|
RefreshStrategy::OnlineIfUncached,
|
|
&DEFAULT_HTTP_CLIENT_FACTORY,
|
|
)
|
|
.await
|
|
.expect("refresh succeeds");
|
|
|
|
assert_eq!(manager.get_remote_models().await, expected);
|
|
assert_eq!(endpoint.fetch_count(), 1, "expected a single model fetch");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn refresh_available_models_uses_cache_when_fresh() {
|
|
let remote_models = vec![remote_model("cached", "Cached", /*priority*/ 5)];
|
|
let codex_home = tempdir().expect("temp dir");
|
|
let endpoint = TestModelsEndpoint::new(vec![remote_models.clone()]);
|
|
let manager = openai_manager_for_tests(codex_home.path().to_path_buf(), endpoint.clone());
|
|
|
|
manager
|
|
.refresh_available_models(
|
|
RefreshStrategy::OnlineIfUncached,
|
|
&DEFAULT_HTTP_CLIENT_FACTORY,
|
|
)
|
|
.await
|
|
.expect("first refresh succeeds");
|
|
assert_models_contain(&manager.get_remote_models().await, &remote_models);
|
|
|
|
// Second call should read from cache and avoid the network.
|
|
manager
|
|
.refresh_available_models(
|
|
RefreshStrategy::OnlineIfUncached,
|
|
&DEFAULT_HTTP_CLIENT_FACTORY,
|
|
)
|
|
.await
|
|
.expect("cached refresh succeeds");
|
|
assert_models_contain(&manager.get_remote_models().await, &remote_models);
|
|
assert_eq!(
|
|
endpoint.fetch_count(),
|
|
1,
|
|
"cache hit should avoid a second model fetch"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn online_refresh_updates_access_programs_with_unchanged_etag() {
|
|
let codex_home = tempdir().expect("temp dir");
|
|
let granted_model = ModelInfo {
|
|
available_access_programs: Some(ModelAccessPrograms {
|
|
cyber: vec![
|
|
CyberAccessProgram::Standard,
|
|
CyberAccessProgram::DaybreakBlue,
|
|
],
|
|
}),
|
|
..remote_model("access-programs", "Access Programs", /*priority*/ 0)
|
|
};
|
|
let revoked_model = ModelInfo {
|
|
available_access_programs: Some(ModelAccessPrograms { cyber: Vec::new() }),
|
|
..granted_model.clone()
|
|
};
|
|
let responses = vec![vec![granted_model.clone()], vec![revoked_model.clone()]];
|
|
let endpoint = Arc::new(TestModelsEndpoint {
|
|
has_command_auth: false,
|
|
uses_codex_backend: true,
|
|
responses: Mutex::new(responses.into()),
|
|
etag: Some("stable-catalog-etag".to_string()),
|
|
fetch_count: AtomicUsize::new(0),
|
|
observed_proxy_policy: Mutex::new(None),
|
|
});
|
|
let manager = openai_manager_for_tests(codex_home.path().to_path_buf(), endpoint.clone());
|
|
|
|
// Caller-specific access can change without changing the catalog ETag.
|
|
for model in [granted_model, revoked_model] {
|
|
let mut expected = ModelPreset::from(model.clone());
|
|
expected.is_default = true;
|
|
assert_eq!(
|
|
manager
|
|
.list_models(RefreshStrategy::Online, DEFAULT_HTTP_CLIENT_FACTORY)
|
|
.await,
|
|
vec![expected.clone()]
|
|
);
|
|
assert_eq!(manager.get_remote_models().await, vec![model]);
|
|
assert_eq!(
|
|
manager.remote_models.read().await.etag.as_deref(),
|
|
Some("stable-catalog-etag")
|
|
);
|
|
|
|
// A new manager must read the latest access metadata from the disk cache.
|
|
let cache_endpoint = TestModelsEndpoint::new(Vec::new());
|
|
let cache_manager =
|
|
openai_manager_for_tests(codex_home.path().to_path_buf(), cache_endpoint.clone());
|
|
assert_eq!(
|
|
cache_manager
|
|
.list_models(
|
|
RefreshStrategy::OnlineIfUncached,
|
|
DEFAULT_HTTP_CLIENT_FACTORY
|
|
)
|
|
.await,
|
|
vec![expected]
|
|
);
|
|
assert_eq!(cache_endpoint.fetch_count(), 0);
|
|
assert_eq!(
|
|
cache_manager.remote_models.read().await.etag.as_deref(),
|
|
Some("stable-catalog-etag")
|
|
);
|
|
}
|
|
assert_eq!(endpoint.fetch_count(), 2);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn refresh_available_models_refetches_when_cache_stale() {
|
|
let initial_models = vec![remote_model("stale", "Stale", /*priority*/ 1)];
|
|
let codex_home = tempdir().expect("temp dir");
|
|
let updated_models = vec![remote_model("fresh", "Fresh", /*priority*/ 9)];
|
|
let endpoint = TestModelsEndpoint::new(vec![initial_models.clone(), updated_models.clone()]);
|
|
let manager = openai_manager_for_tests(codex_home.path().to_path_buf(), endpoint.clone());
|
|
|
|
manager
|
|
.refresh_available_models(
|
|
RefreshStrategy::OnlineIfUncached,
|
|
&DEFAULT_HTTP_CLIENT_FACTORY,
|
|
)
|
|
.await
|
|
.expect("initial refresh succeeds");
|
|
|
|
// Rewrite cache with an old timestamp so it is treated as stale.
|
|
mutate_file_cache_for_test(codex_home.path(), |cache| {
|
|
cache.fetched_at = Utc::now() - chrono::Duration::hours(1);
|
|
})
|
|
.await;
|
|
|
|
manager
|
|
.refresh_available_models(
|
|
RefreshStrategy::OnlineIfUncached,
|
|
&DEFAULT_HTTP_CLIENT_FACTORY,
|
|
)
|
|
.await
|
|
.expect("second refresh succeeds");
|
|
assert_models_contain(&manager.get_remote_models().await, &updated_models);
|
|
assert_eq!(
|
|
endpoint.fetch_count(),
|
|
2,
|
|
"stale cache refresh should fetch models again"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn refresh_available_models_refetches_when_version_mismatch() {
|
|
let initial_models = vec![remote_model("old", "Old", /*priority*/ 1)];
|
|
let codex_home = tempdir().expect("temp dir");
|
|
let updated_models = vec![remote_model("new", "New", /*priority*/ 2)];
|
|
let endpoint = TestModelsEndpoint::new(vec![initial_models.clone(), updated_models.clone()]);
|
|
let manager = openai_manager_for_tests(codex_home.path().to_path_buf(), endpoint.clone());
|
|
|
|
manager
|
|
.refresh_available_models(
|
|
RefreshStrategy::OnlineIfUncached,
|
|
&DEFAULT_HTTP_CLIENT_FACTORY,
|
|
)
|
|
.await
|
|
.expect("initial refresh succeeds");
|
|
|
|
mutate_file_cache_for_test(codex_home.path(), |cache| {
|
|
let client_version = crate::client_version_to_whole();
|
|
cache.client_version = Some(format!("{client_version}-mismatch"));
|
|
})
|
|
.await;
|
|
|
|
manager
|
|
.refresh_available_models(
|
|
RefreshStrategy::OnlineIfUncached,
|
|
&DEFAULT_HTTP_CLIENT_FACTORY,
|
|
)
|
|
.await
|
|
.expect("second refresh succeeds");
|
|
assert_models_contain(&manager.get_remote_models().await, &updated_models);
|
|
assert_eq!(
|
|
endpoint.fetch_count(),
|
|
2,
|
|
"version mismatch should fetch models again"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn refresh_available_models_drops_removed_remote_models() {
|
|
let initial_models = vec![remote_model(
|
|
"remote-old",
|
|
"Remote Old",
|
|
/*priority*/ 1,
|
|
)];
|
|
let codex_home = tempdir().expect("temp dir");
|
|
let refreshed_models = vec![remote_model(
|
|
"remote-new",
|
|
"Remote New",
|
|
/*priority*/ 1,
|
|
)];
|
|
let endpoint = TestModelsEndpoint::new(vec![initial_models, refreshed_models]);
|
|
let manager = OpenAiModelsManager::new_with_cache(
|
|
Arc::new(FileModelsCache::new(
|
|
codex_home.path().join(MODEL_CACHE_FILE),
|
|
Duration::ZERO,
|
|
)),
|
|
endpoint.clone(),
|
|
Some(AuthManager::from_auth_for_testing(
|
|
CodexAuth::create_dummy_chatgpt_auth_for_testing(),
|
|
)),
|
|
);
|
|
|
|
manager
|
|
.refresh_available_models(
|
|
RefreshStrategy::OnlineIfUncached,
|
|
&DEFAULT_HTTP_CLIENT_FACTORY,
|
|
)
|
|
.await
|
|
.expect("initial refresh succeeds");
|
|
|
|
manager
|
|
.refresh_available_models(
|
|
RefreshStrategy::OnlineIfUncached,
|
|
&DEFAULT_HTTP_CLIENT_FACTORY,
|
|
)
|
|
.await
|
|
.expect("second refresh succeeds");
|
|
|
|
let available = manager
|
|
.try_list_models()
|
|
.expect("models should be available");
|
|
assert!(
|
|
available.iter().any(|preset| preset.model == "remote-new"),
|
|
"new remote model should be listed"
|
|
);
|
|
assert!(
|
|
!available.iter().any(|preset| preset.model == "remote-old"),
|
|
"removed remote model should not be listed"
|
|
);
|
|
assert_eq!(
|
|
endpoint.fetch_count(),
|
|
2,
|
|
"second refresh should fetch models again"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn refresh_available_models_skips_network_without_auth() {
|
|
let dynamic_slug = "dynamic-model-only-for-test-noauth";
|
|
let codex_home = tempdir().expect("temp dir");
|
|
let endpoint = TestModelsEndpoint::without_refresh(vec![vec![remote_model(
|
|
dynamic_slug,
|
|
"No Auth",
|
|
/*priority*/ 1,
|
|
)]]);
|
|
let manager = openai_manager_for_tests_with_auth(
|
|
codex_home.path().to_path_buf(),
|
|
endpoint.clone(),
|
|
/*auth_manager*/ None,
|
|
);
|
|
|
|
manager
|
|
.refresh_available_models(RefreshStrategy::Online, &DEFAULT_HTTP_CLIENT_FACTORY)
|
|
.await
|
|
.expect("refresh should no-op without auth");
|
|
let cached_remote = manager.get_remote_models().await;
|
|
assert!(
|
|
!cached_remote
|
|
.iter()
|
|
.any(|candidate| candidate.slug == dynamic_slug),
|
|
"remote refresh should be skipped without auth"
|
|
);
|
|
assert_eq!(
|
|
endpoint.fetch_count(),
|
|
0,
|
|
"endpoint that cannot refresh should avoid model fetches"
|
|
);
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
struct TestAuthAwareModelsEndpoint {
|
|
auth_manager: Option<Arc<AuthManager>>,
|
|
responses: Mutex<VecDeque<Vec<ModelInfo>>>,
|
|
fetch_count: AtomicUsize,
|
|
}
|
|
|
|
impl TestAuthAwareModelsEndpoint {
|
|
fn new(auth_manager: Option<Arc<AuthManager>>, responses: Vec<Vec<ModelInfo>>) -> Arc<Self> {
|
|
Arc::new(Self {
|
|
auth_manager,
|
|
responses: Mutex::new(responses.into()),
|
|
fetch_count: AtomicUsize::new(0),
|
|
})
|
|
}
|
|
|
|
fn fetch_count(&self) -> usize {
|
|
self.fetch_count.load(Ordering::SeqCst)
|
|
}
|
|
|
|
async fn uses_codex_backend(&self) -> bool {
|
|
match self.auth_manager.as_ref() {
|
|
Some(auth_manager) => auth_manager
|
|
.auth()
|
|
.await
|
|
.as_ref()
|
|
.is_some_and(CodexAuth::uses_codex_backend),
|
|
None => false,
|
|
}
|
|
}
|
|
|
|
async fn list_models(&self) -> CoreResult<ModelsEndpointResponse> {
|
|
self.fetch_count.fetch_add(1, Ordering::SeqCst);
|
|
let models = self
|
|
.responses
|
|
.lock()
|
|
.expect("responses lock should not be poisoned")
|
|
.pop_front()
|
|
.unwrap_or_default();
|
|
Ok(ModelsEndpointResponse {
|
|
models,
|
|
etag: None,
|
|
identity: self.identity().expect("test endpoint identity"),
|
|
})
|
|
}
|
|
}
|
|
|
|
impl ModelsEndpointClient for TestAuthAwareModelsEndpoint {
|
|
fn supports_api_key_models(&self) -> bool {
|
|
true
|
|
}
|
|
|
|
fn identity(&self) -> Option<String> {
|
|
self.auth_manager
|
|
.as_ref()
|
|
.and_then(|manager| manager.auth_mode())
|
|
.map(|mode| format!("{mode:?}"))
|
|
}
|
|
|
|
fn has_command_auth(&self) -> bool {
|
|
false
|
|
}
|
|
|
|
fn uses_codex_backend(&self) -> ModelsEndpointFuture<'_, bool> {
|
|
Box::pin(TestAuthAwareModelsEndpoint::uses_codex_backend(self))
|
|
}
|
|
|
|
fn list_models<'a>(
|
|
&'a self,
|
|
_client_version: &'a str,
|
|
_http_client_factory: HttpClientFactory,
|
|
) -> ModelsEndpointFuture<'a, CoreResult<ModelsEndpointResponse>> {
|
|
Box::pin(TestAuthAwareModelsEndpoint::list_models(self))
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn refresh_available_models_fetches_when_external_api_key_overrides_chatgpt_auth() {
|
|
let dynamic_slug = "dynamic-model-only-for-test-external-api-key";
|
|
let codex_home = tempdir().expect("temp dir");
|
|
let auth_manager =
|
|
AuthManager::from_auth_for_testing(CodexAuth::create_dummy_chatgpt_auth_for_testing());
|
|
auth_manager
|
|
.set_external_auth(Arc::new(TestExternalApiKeyAuth))
|
|
.await
|
|
.expect("external API key auth should resolve");
|
|
let endpoint = TestAuthAwareModelsEndpoint::new(
|
|
Some(Arc::clone(&auth_manager)),
|
|
vec![vec![remote_model(
|
|
dynamic_slug,
|
|
"External API Key",
|
|
/*priority*/ 1,
|
|
)]],
|
|
);
|
|
let manager = openai_manager_for_tests_with_auth(
|
|
codex_home.path().to_path_buf(),
|
|
endpoint.clone(),
|
|
Some(auth_manager),
|
|
);
|
|
manager.set_api_key_model_discovery_enabled(/*enabled*/ true);
|
|
|
|
manager
|
|
.refresh_available_models(RefreshStrategy::Online, &DEFAULT_HTTP_CLIENT_FACTORY)
|
|
.await
|
|
.expect("refresh should fetch with API key auth");
|
|
let cached_remote = manager.get_remote_models().await;
|
|
|
|
assert_eq!(
|
|
cached_remote,
|
|
vec![remote_model(
|
|
dynamic_slug,
|
|
"External API Key",
|
|
/*priority*/ 1
|
|
)]
|
|
);
|
|
assert_eq!(endpoint.fetch_count(), 1);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn refresh_available_models_uses_cached_chatgpt_when_external_api_key_is_unresolved() {
|
|
let dynamic_slug = "dynamic-model-only-for-test-unresolved-external-api-key";
|
|
let codex_home = tempdir().expect("temp dir");
|
|
let auth_manager =
|
|
AuthManager::from_auth_for_testing(CodexAuth::create_dummy_chatgpt_auth_for_testing());
|
|
auth_manager
|
|
.set_external_auth(Arc::new(TestUnresolvedExternalApiKeyAuth))
|
|
.await
|
|
.expect_err("unresolved external auth should be rejected");
|
|
let endpoint = TestAuthAwareModelsEndpoint::new(
|
|
Some(Arc::clone(&auth_manager)),
|
|
vec![vec![remote_model(
|
|
dynamic_slug,
|
|
"Unresolved External API Key",
|
|
/*priority*/ 1,
|
|
)]],
|
|
);
|
|
let manager = openai_manager_for_tests_with_auth(
|
|
codex_home.path().to_path_buf(),
|
|
endpoint.clone(),
|
|
Some(auth_manager),
|
|
);
|
|
manager.set_api_key_model_discovery_enabled(/*enabled*/ true);
|
|
|
|
manager
|
|
.refresh_available_models(RefreshStrategy::Online, &DEFAULT_HTTP_CLIENT_FACTORY)
|
|
.await
|
|
.expect("refresh should fall back to cached ChatGPT auth");
|
|
|
|
assert!(
|
|
manager
|
|
.get_remote_models()
|
|
.await
|
|
.iter()
|
|
.any(|candidate| candidate.slug == dynamic_slug),
|
|
"remote refresh should include models fetched with cached ChatGPT auth"
|
|
);
|
|
assert_eq!(
|
|
endpoint.fetch_count(),
|
|
1,
|
|
"endpoint should fetch models when unresolved external API key falls back to ChatGPT auth"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn refresh_available_models_fetches_with_chatgpt_auth_tokens() {
|
|
let dynamic_slug = "dynamic-model-only-for-test-chatgpt-auth-tokens";
|
|
let codex_home = tempdir().expect("temp dir");
|
|
let endpoint = TestModelsEndpoint::new(vec![vec![remote_model(
|
|
dynamic_slug,
|
|
"ChatGPT Auth Tokens",
|
|
/*priority*/ 1,
|
|
)]]);
|
|
let auth = chatgpt_auth_tokens_for_tests(codex_home.path()).await;
|
|
let manager = openai_manager_for_tests_with_auth(
|
|
codex_home.path().to_path_buf(),
|
|
endpoint.clone(),
|
|
Some(AuthManager::from_auth_for_testing(auth)),
|
|
);
|
|
|
|
manager
|
|
.refresh_available_models(RefreshStrategy::Online, &DEFAULT_HTTP_CLIENT_FACTORY)
|
|
.await
|
|
.expect("refresh should fetch with ChatGPT auth tokens");
|
|
|
|
assert!(
|
|
manager
|
|
.get_remote_models()
|
|
.await
|
|
.iter()
|
|
.any(|candidate| candidate.slug == dynamic_slug),
|
|
"remote refresh should include models fetched with ChatGPT auth tokens"
|
|
);
|
|
assert_eq!(
|
|
endpoint.fetch_count(),
|
|
1,
|
|
"endpoint should fetch models with ChatGPT auth tokens"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn build_available_models_picks_default_after_hiding_hidden_models() {
|
|
let manager = static_manager_for_tests(ModelsResponse { models: Vec::new() });
|
|
|
|
let hidden_model =
|
|
remote_model_with_visibility("hidden", "Hidden", /*priority*/ 0, "hide");
|
|
let visible_model =
|
|
remote_model_with_visibility("visible", "Visible", /*priority*/ 1, "list");
|
|
|
|
let expected_hidden = ModelPreset::from(hidden_model.clone());
|
|
let mut expected_visible = ModelPreset::from(visible_model.clone());
|
|
expected_visible.is_default = true;
|
|
|
|
let available = manager.build_available_models(vec![hidden_model, visible_model]);
|
|
|
|
assert_eq!(available, vec![expected_hidden, expected_visible]);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn static_manager_reads_latest_auth_mode() {
|
|
let auth_manager =
|
|
AuthManager::from_auth_for_testing(CodexAuth::create_dummy_chatgpt_auth_for_testing());
|
|
let chatgpt_only_model = {
|
|
let mut model = remote_model("chatgpt-only", "ChatGPT Only", /*priority*/ 0);
|
|
model.supported_in_api = false;
|
|
model
|
|
};
|
|
let api_model = remote_model("api-model", "API Model", /*priority*/ 1);
|
|
let manager = StaticModelsManager::new(
|
|
Some(Arc::clone(&auth_manager)),
|
|
ModelsResponse {
|
|
models: vec![chatgpt_only_model, api_model],
|
|
},
|
|
);
|
|
|
|
let chatgpt_models = manager
|
|
.list_models(RefreshStrategy::Online, DEFAULT_HTTP_CLIENT_FACTORY)
|
|
.await;
|
|
assert_eq!(
|
|
chatgpt_models
|
|
.iter()
|
|
.map(|model| model.model.as_str())
|
|
.collect::<Vec<_>>(),
|
|
vec!["chatgpt-only", "api-model"]
|
|
);
|
|
|
|
auth_manager
|
|
.set_external_auth(Arc::new(TestExternalApiKeyAuth))
|
|
.await
|
|
.expect("external API key auth should resolve");
|
|
let api_models = manager
|
|
.list_models(RefreshStrategy::Online, DEFAULT_HTTP_CLIENT_FACTORY)
|
|
.await;
|
|
|
|
assert_eq!(
|
|
api_models
|
|
.iter()
|
|
.map(|model| model.model.as_str())
|
|
.collect::<Vec<_>>(),
|
|
vec!["api-model"]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn bundled_models_json_roundtrips() {
|
|
let response = crate::bundled_models_response()
|
|
.unwrap_or_else(|err| panic!("bundled models.json should parse: {err}"));
|
|
|
|
let serialized =
|
|
serde_json::to_string(&response).expect("bundled models.json should serialize");
|
|
let roundtripped: ModelsResponse =
|
|
serde_json::from_str(&serialized).expect("serialized models.json should deserialize");
|
|
|
|
assert_eq!(
|
|
response, roundtripped,
|
|
"bundled models.json should round trip through serde"
|
|
);
|
|
assert!(
|
|
!response.models.is_empty(),
|
|
"bundled models.json should contain at least one model"
|
|
);
|
|
}
|