1use std::fmt;
2use std::future::Future;
3use std::path::PathBuf;
4use std::pin::Pin;
5use std::sync::Arc;
6
7use codex_api::ApiError;
8use codex_api::Provider;
9use codex_api::SharedAuthProvider;
10use codex_login::AuthManager;
11use codex_login::CodexAuth;
12use codex_model_provider_info::ModelProviderInfo;
13use codex_models_manager::manager::OpenAiModelsManager;
14use codex_models_manager::manager::SharedModelsManager;
15use codex_models_manager::manager::StaticModelsManager;
16use codex_protocol::account::ProviderAccount;
17use codex_protocol::error::CodexErr;
18use codex_protocol::openai_models::ModelsResponse;
19
20use crate::amazon_bedrock::AmazonBedrockModelProvider;
21use crate::auth::ProviderAuthScope;
22use crate::auth::ResolvedProviderAuth;
23use crate::auth::auth_manager_for_provider;
24use crate::auth::resolve_provider_auth;
25use crate::auth::resolve_provider_auth_for_scope;
26use crate::models_endpoint::OpenAiModelsEndpoint;
27
28#[derive(Debug, Clone, Copy, PartialEq, Eq)]
34pub struct ProviderCapabilities {
35 pub namespace_tools: bool,
36 pub image_generation: bool,
37 pub web_search: bool,
38}
39
40impl Default for ProviderCapabilities {
41 fn default() -> Self {
42 Self {
43 namespace_tools: true,
44 image_generation: true,
45 web_search: true,
46 }
47 }
48}
49
50#[derive(Debug, Clone, PartialEq, Eq)]
52pub struct ProviderAccountState {
53 pub account: Option<ProviderAccount>,
54 pub requires_openai_auth: bool,
55}
56
57#[derive(Debug, Clone, Copy, PartialEq, Eq)]
59pub enum ProviderAccountError {
60 MissingChatgptAccountDetails,
61 UnsupportedBedrockApiKeyAuth,
62}
63
64impl fmt::Display for ProviderAccountError {
65 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
66 match self {
67 Self::MissingChatgptAccountDetails => {
68 write!(f, "plan type is required for chatgpt authentication")
69 }
70 Self::UnsupportedBedrockApiKeyAuth => {
71 write!(
72 f,
73 "Bedrock API key auth is only supported by the Amazon Bedrock model provider"
74 )
75 }
76 }
77 }
78}
79
80impl std::error::Error for ProviderAccountError {}
81
82pub type ProviderAccountResult = std::result::Result<ProviderAccountState, ProviderAccountError>;
83
84pub const DEFAULT_APPROVAL_REVIEW_PREFERRED_MODEL: &str = "codex-auto-review";
87
88pub const DEFAULT_MEMORY_EXTRACTION_PREFERRED_MODEL: &str = "gpt-5.6-luna";
91
92pub const DEFAULT_MEMORY_CONSOLIDATION_PREFERRED_MODEL: &str = "gpt-5.6-terra";
95
96pub trait ModelProvider: fmt::Debug + Send + Sync {
102 fn info(&self) -> &ModelProviderInfo;
104
105 fn capabilities(&self) -> ProviderCapabilities {
107 ProviderCapabilities::default()
108 }
109
110 fn approval_review_preferred_model(&self) -> &'static str {
114 DEFAULT_APPROVAL_REVIEW_PREFERRED_MODEL
115 }
116
117 fn memory_extraction_preferred_model(&self) -> &'static str {
121 DEFAULT_MEMORY_EXTRACTION_PREFERRED_MODEL
122 }
123
124 fn memory_consolidation_preferred_model(&self) -> &'static str {
128 DEFAULT_MEMORY_CONSOLIDATION_PREFERRED_MODEL
129 }
130
131 fn supports_attestation(&self) -> bool {
133 false
134 }
135
136 fn auth_manager(&self) -> Option<Arc<AuthManager>>;
143
144 fn auth(&self) -> ModelProviderFuture<'_, Option<CodexAuth>>;
146
147 fn account_state(&self) -> ProviderAccountResult;
149
150 fn map_api_error(&self, error: ApiError) -> CodexErr {
152 codex_api::map_api_error(error)
153 }
154
155 fn api_provider(&self) -> ModelProviderFuture<'_, codex_protocol::error::Result<Provider>> {
157 Box::pin(async move {
158 let auth = self.auth().await;
159 self.info()
160 .to_api_provider(auth.as_ref().map(CodexAuth::auth_mode))
161 })
162 }
163
164 fn runtime_base_url(
166 &self,
167 ) -> ModelProviderFuture<'_, codex_protocol::error::Result<Option<String>>> {
168 Box::pin(async { Ok(self.info().base_url.clone()) })
169 }
170
171 fn api_auth(
173 &self,
174 ) -> ModelProviderFuture<'_, codex_protocol::error::Result<SharedAuthProvider>> {
175 Box::pin(async move {
176 let auth = self.auth().await;
177 resolve_provider_auth(auth.as_ref(), self.info())
178 })
179 }
180
181 fn api_auth_for_scope(
183 &self,
184 scope: ProviderAuthScope,
185 ) -> ModelProviderFuture<'_, codex_protocol::error::Result<ResolvedProviderAuth>> {
186 Box::pin(async move {
187 if !provider_uses_first_party_auth_path(self.info()) {
188 return self.api_auth().await.map(ResolvedProviderAuth::new);
189 }
190 let auth = self.auth().await;
191 resolve_provider_auth_for_scope(self.auth_manager(), auth.as_ref(), self.info(), scope)
192 .await
193 })
194 }
195
196 fn models_manager(
198 &self,
199 codex_home: PathBuf,
200 config_model_catalog: Option<ModelsResponse>,
201 ) -> SharedModelsManager;
202
203 fn models_manager_without_cache(
208 &self,
209 config_model_catalog: Option<ModelsResponse>,
210 ) -> SharedModelsManager {
211 let model_catalog = config_model_catalog
212 .or_else(|| codex_models_manager::bundled_models_response().ok())
213 .unwrap_or_default();
214 Arc::new(StaticModelsManager::new(self.auth_manager(), model_catalog))
215 }
216}
217
218pub type ModelProviderFuture<'a, T> = Pin<Box<dyn Future<Output = T> + Send + 'a>>;
219
220pub type SharedModelProvider = Arc<dyn ModelProvider>;
222
223fn provider_uses_first_party_auth_path(provider: &ModelProviderInfo) -> bool {
224 provider.requires_openai_auth
225 && provider.env_key.is_none()
226 && provider.experimental_bearer_token.is_none()
227 && provider.auth.is_none()
228 && provider.aws.is_none()
229}
230
231pub fn create_model_provider(
233 provider_info: ModelProviderInfo,
234 auth_manager: Option<Arc<AuthManager>>,
235) -> SharedModelProvider {
236 if provider_info.is_amazon_bedrock() {
237 Arc::new(AmazonBedrockModelProvider::new(provider_info, auth_manager))
238 } else {
239 Arc::new(ConfiguredModelProvider::new(provider_info, auth_manager))
240 }
241}
242
243#[derive(Clone, Debug)]
245struct ConfiguredModelProvider {
246 info: ModelProviderInfo,
247 auth_manager: Option<Arc<AuthManager>>,
248}
249
250impl ConfiguredModelProvider {
251 fn new(provider_info: ModelProviderInfo, auth_manager: Option<Arc<AuthManager>>) -> Self {
252 let auth_manager = auth_manager_for_provider(auth_manager, &provider_info);
253 Self {
254 info: provider_info,
255 auth_manager,
256 }
257 }
258}
259
260impl ModelProvider for ConfiguredModelProvider {
261 fn info(&self) -> &ModelProviderInfo {
262 &self.info
263 }
264
265 fn auth_manager(&self) -> Option<Arc<AuthManager>> {
266 self.auth_manager.clone()
267 }
268
269 fn supports_attestation(&self) -> bool {
270 self.auth_manager
271 .as_ref()
272 .and_then(|auth_manager| auth_manager.auth_cached())
273 .is_some_and(|auth| auth.is_chatgpt_auth())
274 }
275
276 fn auth(&self) -> ModelProviderFuture<'_, Option<CodexAuth>> {
277 Box::pin(async move {
278 match self.auth_manager.as_ref() {
279 Some(auth_manager) => auth_manager.auth().await,
280 None => None,
281 }
282 })
283 }
284
285 fn account_state(&self) -> ProviderAccountResult {
286 let account = if self.info.requires_openai_auth {
287 self.auth_manager
288 .as_ref()
289 .and_then(|auth_manager| {
290 let auth = auth_manager.auth_cached()?;
291 if auth_manager.refresh_failure_for_auth(&auth).is_some() {
292 return None;
293 }
294 if matches!(auth, CodexAuth::Headers(_)) {
295 return None;
296 }
297 Some(auth)
298 })
299 .map(|auth| match &auth {
300 CodexAuth::ApiKey(_) => Ok(ProviderAccount::ApiKey),
301 CodexAuth::BedrockApiKey(_) => {
302 Err(ProviderAccountError::UnsupportedBedrockApiKeyAuth)
303 }
304 CodexAuth::Chatgpt(_)
305 | CodexAuth::ChatgptAuthTokens(_)
306 | CodexAuth::Headers(_)
307 | CodexAuth::AgentIdentity(_)
308 | CodexAuth::PersonalAccessToken(_) => {
309 let email = auth.get_account_email();
310 let plan_type = auth.account_plan_type();
311
312 plan_type
313 .map(|plan_type| ProviderAccount::Chatgpt { email, plan_type })
314 .ok_or(ProviderAccountError::MissingChatgptAccountDetails)
315 }
316 })
317 .transpose()?
318 } else {
319 None
320 };
321
322 Ok(ProviderAccountState {
323 account,
324 requires_openai_auth: self.info.requires_openai_auth,
325 })
326 }
327
328 fn models_manager(
329 &self,
330 codex_home: PathBuf,
331 config_model_catalog: Option<ModelsResponse>,
332 ) -> SharedModelsManager {
333 match config_model_catalog {
334 Some(model_catalog) => Arc::new(StaticModelsManager::new(
335 self.auth_manager.clone(),
336 model_catalog,
337 )),
338 None => {
339 let endpoint = Arc::new(OpenAiModelsEndpoint::new(
340 self.info.clone(),
341 self.auth_manager.clone(),
342 ));
343 Arc::new(OpenAiModelsManager::new(
344 codex_home,
345 endpoint,
346 self.auth_manager.clone(),
347 ))
348 }
349 }
350 }
351
352 fn models_manager_without_cache(
353 &self,
354 config_model_catalog: Option<ModelsResponse>,
355 ) -> SharedModelsManager {
356 match config_model_catalog {
357 Some(model_catalog) => Arc::new(StaticModelsManager::new(
358 self.auth_manager.clone(),
359 model_catalog,
360 )),
361 None => {
362 let endpoint = Arc::new(OpenAiModelsEndpoint::new(
363 self.info.clone(),
364 self.auth_manager.clone(),
365 ));
366 Arc::new(OpenAiModelsManager::new_without_cache(
367 endpoint,
368 self.auth_manager.clone(),
369 ))
370 }
371 }
372 }
373}
374
375#[cfg(test)]
376mod tests {
377 use std::num::NonZeroU64;
378
379 use codex_http_client::HttpClientFactory;
380 use codex_http_client::OutboundProxyPolicy;
381 use codex_login::auth::AgentIdentityAuthPolicy;
382 use codex_login::auth::BedrockApiKeyAuth;
383 use codex_model_provider_info::ModelProviderAwsAuthInfo;
384 use codex_model_provider_info::WireApi;
385 use codex_model_provider_info::create_oss_provider_with_base_url;
386 use codex_models_manager::manager::RefreshStrategy;
387 use codex_protocol::account::PlanType;
388 use codex_protocol::config_types::ModelProviderAuthInfo;
389 use codex_protocol::openai_models::ModelInfo;
390 use codex_protocol::openai_models::ModelsResponse;
391 use codex_protocol::protocol::SessionSource;
392 use pretty_assertions::assert_eq;
393 use serde_json::json;
394 use wiremock::Mock;
395 use wiremock::MockServer;
396 use wiremock::ResponseTemplate;
397 use wiremock::matchers::header_regex;
398 use wiremock::matchers::method;
399 use wiremock::matchers::path;
400
401 use super::*;
402 use crate::auth::AgentIdentitySessionFallback;
403
404 fn provider_info_with_command_auth() -> ModelProviderInfo {
405 ModelProviderInfo {
406 auth: Some(ModelProviderAuthInfo {
407 command: "print-token".to_string(),
408 args: Vec::new(),
409 timeout_ms: NonZeroU64::new(5_000).expect("timeout should be non-zero"),
410 refresh_interval_ms: 300_000,
411 cwd: std::env::current_dir()
412 .expect("current dir should be available")
413 .try_into()
414 .expect("current dir should be absolute"),
415 }),
416 requires_openai_auth: false,
417 ..ModelProviderInfo::create_openai_provider(None)
418 }
419 }
420
421 fn test_codex_home() -> std::path::PathBuf {
422 std::env::temp_dir().join(format!("codex-model-provider-test-{}", std::process::id()))
423 }
424
425 fn provider_for(base_url: String) -> ModelProviderInfo {
426 ModelProviderInfo {
427 name: "mock".into(),
428 base_url: Some(base_url),
429 env_key: None,
430 env_key_instructions: None,
431 experimental_bearer_token: None,
432 auth: None,
433 aws: None,
434 wire_api: WireApi::Responses,
435 query_params: None,
436 http_headers: None,
437 env_http_headers: None,
438 request_max_retries: Some(0),
439 stream_max_retries: Some(0),
440 stream_idle_timeout_ms: Some(5_000),
441 websocket_connect_timeout_ms: None,
442 requires_openai_auth: false,
443 supports_websockets: false,
444 }
445 }
446
447 fn remote_model(slug: &str) -> ModelInfo {
448 serde_json::from_value(json!({
449 "slug": slug,
450 "display_name": slug,
451 "description": null,
452 "default_reasoning_level": "medium",
453 "supported_reasoning_levels": [],
454 "shell_type": "shell_command",
455 "visibility": "list",
456 "supported_in_api": true,
457 "priority": 0,
458 "upgrade": null,
459 "base_instructions": "base instructions",
460 "support_verbosity": false,
461 "default_verbosity": null,
462 "apply_patch_tool_type": null,
463 "truncation_policy": {"mode": "bytes", "limit": 10_000},
464 "supports_parallel_tool_calls": false,
465 "supports_image_detail_original": false,
466 "context_window": 272_000,
467 "max_context_window": 272_000,
468 "experimental_supported_tools": [],
469 }))
470 .expect("valid model")
471 }
472
473 fn bedrock_api_key_auth() -> CodexAuth {
474 CodexAuth::BedrockApiKey(BedrockApiKeyAuth {
475 api_key: "bedrock-api-key-test".to_string(),
476 region: "us-east-1".to_string(),
477 })
478 }
479
480 #[tokio::test]
481 async fn scoped_auth_ignores_scope_for_non_openai_provider() {
482 let provider = create_model_provider(
483 create_oss_provider_with_base_url("http://localhost:11434/v1", WireApi::Responses),
484 None,
485 );
486
487 let auth = provider
488 .api_auth_for_scope(ProviderAuthScope {
489 agent_identity_policy: AgentIdentityAuthPolicy::JwtOnly,
490 session_source: SessionSource::Cli,
491 agent_identity_session_fallback: AgentIdentitySessionFallback::default(),
492 })
493 .await
494 .expect("auth should resolve");
495
496 assert!(auth.auth.to_auth_headers().is_empty());
497 }
498
499 #[test]
500 fn configured_provider_uses_default_capabilities() {
501 let provider = create_model_provider(
502 ModelProviderInfo::create_openai_provider(None),
503 None,
504 );
505
506 assert_eq!(provider.capabilities(), ProviderCapabilities::default());
507 }
508
509 #[test]
510 fn configured_provider_uses_default_approval_review_preferred_model() {
511 let provider = create_model_provider(
512 ModelProviderInfo::create_openai_provider(None),
513 None,
514 );
515
516 assert_eq!(
517 provider.approval_review_preferred_model(),
518 DEFAULT_APPROVAL_REVIEW_PREFERRED_MODEL
519 );
520 }
521
522 #[tokio::test]
523 async fn configured_provider_runtime_base_url_uses_configured_base_url() {
524 let provider = create_model_provider(
525 provider_for("https://example.test/v1".to_string()),
526 None,
527 );
528
529 assert_eq!(
530 provider
531 .runtime_base_url()
532 .await
533 .expect("runtime base URL should resolve"),
534 Some("https://example.test/v1".to_string())
535 );
536 }
537
538 #[test]
539 fn create_model_provider_builds_command_auth_manager_without_base_manager() {
540 let provider = create_model_provider(
541 provider_info_with_command_auth(),
542 None,
543 );
544
545 let auth_manager = provider
546 .auth_manager()
547 .expect("command auth provider should have an auth manager");
548
549 assert!(auth_manager.has_external_auth());
550 }
551
552 #[test]
553 fn create_model_provider_does_not_use_openai_auth_manager_for_amazon_bedrock_provider() {
554 let provider = create_model_provider(
555 ModelProviderInfo::create_amazon_bedrock_provider(Some(ModelProviderAwsAuthInfo {
556 profile: Some("codex-bedrock".to_string()),
557 region: None,
558 })),
559 Some(AuthManager::from_auth_for_testing(CodexAuth::from_api_key(
560 "openai-api-key",
561 ))),
562 );
563
564 assert!(provider.auth_manager().is_none());
565 }
566
567 #[tokio::test]
568 async fn create_model_provider_uses_managed_auth_for_amazon_bedrock_provider() {
569 let auth = bedrock_api_key_auth();
570 let provider = create_model_provider(
571 ModelProviderInfo::create_amazon_bedrock_provider(None),
572 Some(AuthManager::from_auth_for_testing(auth.clone())),
573 );
574
575 assert_eq!(provider.auth().await, Some(auth));
576 }
577
578 #[test]
579 fn openai_provider_returns_unauthenticated_openai_account_state() {
580 let provider = create_model_provider(
581 ModelProviderInfo::create_openai_provider(None),
582 None,
583 );
584
585 assert_eq!(
586 provider.account_state(),
587 Ok(ProviderAccountState {
588 account: None,
589 requires_openai_auth: true,
590 })
591 );
592 }
593
594 #[test]
595 fn openai_provider_returns_api_key_account_state() {
596 let provider = create_model_provider(
597 ModelProviderInfo::create_openai_provider(None),
598 Some(AuthManager::from_auth_for_testing(CodexAuth::from_api_key(
599 "openai-api-key",
600 ))),
601 );
602
603 assert_eq!(
604 provider.account_state(),
605 Ok(ProviderAccountState {
606 account: Some(ProviderAccount::ApiKey),
607 requires_openai_auth: true,
608 })
609 );
610 }
611
612 #[test]
613 fn openai_provider_returns_chatgpt_account_state_without_email() {
614 let provider = create_model_provider(
615 ModelProviderInfo::create_openai_provider(None),
616 Some(AuthManager::from_auth_for_testing(
617 CodexAuth::create_dummy_chatgpt_auth_for_testing(),
618 )),
619 );
620
621 assert_eq!(
622 provider.account_state(),
623 Ok(ProviderAccountState {
624 account: Some(ProviderAccount::Chatgpt {
625 email: None,
626 plan_type: PlanType::Unknown,
627 }),
628 requires_openai_auth: true,
629 })
630 );
631 }
632
633 #[test]
634 fn openai_provider_rejects_bedrock_api_key_account_state() {
635 let provider = create_model_provider(
636 ModelProviderInfo::create_openai_provider(None),
637 Some(AuthManager::from_auth_for_testing(bedrock_api_key_auth())),
638 );
639
640 assert_eq!(
641 provider.account_state(),
642 Err(ProviderAccountError::UnsupportedBedrockApiKeyAuth)
643 );
644 }
645
646 #[test]
647 fn custom_non_openai_provider_returns_no_account_state() {
648 let provider = create_model_provider(
649 ModelProviderInfo {
650 name: "Custom".to_string(),
651 base_url: Some("http://localhost:1234/v1".to_string()),
652 wire_api: WireApi::Responses,
653 requires_openai_auth: false,
654 ..Default::default()
655 },
656 None,
657 );
658
659 assert_eq!(
660 provider.account_state(),
661 Ok(ProviderAccountState {
662 account: None,
663 requires_openai_auth: false,
664 })
665 );
666 }
667
668 #[test]
669 fn amazon_bedrock_provider_returns_bedrock_account_state() {
670 let provider = create_model_provider(
671 ModelProviderInfo::create_amazon_bedrock_provider(None),
672 None,
673 );
674
675 assert_eq!(
676 provider.account_state(),
677 Ok(ProviderAccountState {
678 account: Some(ProviderAccount::AmazonBedrock {
679 uses_codex_managed_credentials: false,
680 }),
681 requires_openai_auth: false,
682 })
683 );
684 }
685
686 #[tokio::test]
687 async fn amazon_bedrock_provider_creates_static_models_manager() {
688 let provider = create_model_provider(
689 ModelProviderInfo::create_amazon_bedrock_provider(None),
690 None,
691 );
692 let manager =
693 provider.models_manager(test_codex_home(), None);
694 let uncached_manager =
695 provider.models_manager_without_cache(None);
696
697 let catalog = manager
698 .raw_model_catalog(
699 RefreshStrategy::Online,
700 HttpClientFactory::new(OutboundProxyPolicy::ReqwestDefault),
701 )
702 .await;
703 let uncached_catalog = uncached_manager
704 .raw_model_catalog(
705 RefreshStrategy::Online,
706 HttpClientFactory::new(OutboundProxyPolicy::ReqwestDefault),
707 )
708 .await;
709 assert_eq!(uncached_catalog, catalog);
710 let models = catalog
711 .models
712 .iter()
713 .map(|model| (model.slug.as_str(), model.display_name.as_str()))
714 .collect::<Vec<_>>();
715
716 assert_eq!(
717 models,
718 vec![
719 ("openai.gpt-5.6-sol", "GPT-5.6 Sol"),
720 ("openai.gpt-5.6-terra", "GPT-5.6 Terra"),
721 ("openai.gpt-5.6-luna", "GPT-5.6 Luna"),
722 ("openai.gpt-5.5", "GPT-5.5"),
723 ("openai.gpt-5.4", "GPT-5.4"),
724 ]
725 );
726
727 let available_models = manager
728 .list_models(
729 RefreshStrategy::Online,
730 HttpClientFactory::new(OutboundProxyPolicy::ReqwestDefault),
731 )
732 .await;
733 assert_eq!(
734 available_models
735 .iter()
736 .map(|preset| preset.model.as_str())
737 .collect::<Vec<_>>(),
738 vec![
739 "openai.gpt-5.6-sol",
740 "openai.gpt-5.6-terra",
741 "openai.gpt-5.6-luna",
742 "openai.gpt-5.5",
743 "openai.gpt-5.4",
744 ]
745 );
746
747 let default_model = available_models
748 .iter()
749 .find(|preset| preset.is_default)
750 .expect("Bedrock catalog should have a default model");
751
752 assert_eq!(default_model.model, "openai.gpt-5.6-sol");
753 }
754
755 #[tokio::test]
756 async fn configured_bedrock_catalog_only_allows_default_service_tier() {
757 let configured_model = codex_models_manager::bundled_models_response()
758 .expect("bundled models should parse")
759 .models
760 .into_iter()
761 .find(|model| model.slug == "gpt-5.5")
762 .expect("bundled models should include GPT-5.5");
763 assert!(!configured_model.additional_speed_tiers.is_empty());
764 assert!(!configured_model.service_tiers.is_empty());
765
766 let provider = create_model_provider(
767 ModelProviderInfo::create_amazon_bedrock_provider(None),
768 None,
769 );
770 let manager = provider.models_manager(
771 test_codex_home(),
772 Some(ModelsResponse {
773 models: vec![configured_model],
774 }),
775 );
776
777 let catalog = manager
778 .raw_model_catalog(
779 RefreshStrategy::Online,
780 HttpClientFactory::new(OutboundProxyPolicy::ReqwestDefault),
781 )
782 .await;
783
784 assert_eq!(catalog.models.len(), 1);
785 assert_eq!(catalog.models[0].slug, "gpt-5.5");
786 assert_eq!(
787 catalog.models[0].additional_speed_tiers,
788 Vec::<String>::new()
789 );
790 assert_eq!(catalog.models[0].service_tiers, Vec::new());
791 assert_eq!(catalog.models[0].default_service_tier, None);
792 }
793
794 #[tokio::test]
795 async fn configured_provider_models_manager_uses_provider_bearer_token() {
796 let server = MockServer::start().await;
797 let remote_models = vec![remote_model("provider-model")];
798
799 Mock::given(method("GET"))
800 .and(path("/models"))
801 .and(header_regex("Authorization", "Bearer provider-token"))
802 .respond_with(
803 ResponseTemplate::new(200)
804 .insert_header("content-type", "application/json")
805 .set_body_json(ModelsResponse {
806 models: remote_models.clone(),
807 }),
808 )
809 .expect(1)
810 .mount(&server)
811 .await;
812
813 let mut provider_info = provider_for(server.uri());
814 provider_info.experimental_bearer_token = Some("provider-token".to_string());
815 let provider = create_model_provider(
816 provider_info,
817 Some(AuthManager::from_auth_for_testing(
818 CodexAuth::create_dummy_chatgpt_auth_for_testing(),
819 )),
820 );
821
822 let manager =
823 provider.models_manager(test_codex_home(), None);
824 let catalog = manager
825 .raw_model_catalog(
826 RefreshStrategy::Online,
827 HttpClientFactory::new(OutboundProxyPolicy::ReqwestDefault),
828 )
829 .await;
830
831 assert!(
832 catalog
833 .models
834 .iter()
835 .any(|model| model.slug == "provider-model")
836 );
837 }
838}