Skip to main content

codex_model_provider/
provider.rs

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/// Optional provider-backed features that Codex may expose at runtime.
29///
30/// These capabilities are a provider-owned upper bound. Callers can disable
31/// more functionality through normal config, but should not expose a feature
32/// that the active provider marks unsupported here.
33#[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/// Current app-visible account state for a model provider.
51#[derive(Debug, Clone, PartialEq, Eq)]
52pub struct ProviderAccountState {
53    pub account: Option<ProviderAccount>,
54    pub requires_openai_auth: bool,
55}
56
57/// Error returned when a provider cannot construct its app-visible account state.
58#[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
84/// Default model used for automatic approval review when a provider does not
85/// require a backend-specific model ID.
86pub const DEFAULT_APPROVAL_REVIEW_PREFERRED_MODEL: &str = "codex-auto-review";
87
88/// Default model used for memory extraction when a provider does not require a
89/// backend-specific model ID.
90pub const DEFAULT_MEMORY_EXTRACTION_PREFERRED_MODEL: &str = "gpt-5.6-luna";
91
92/// Default model used for memory consolidation when a provider does not require
93/// a backend-specific model ID.
94pub const DEFAULT_MEMORY_CONSOLIDATION_PREFERRED_MODEL: &str = "gpt-5.6-terra";
95
96/// Runtime provider abstraction used by model execution.
97///
98/// Implementations own provider-specific behavior for a model backend. The
99/// `ModelProviderInfo` returned by `info` is the serialized/configured provider
100/// metadata used by the default OpenAI-compatible implementation.
101pub trait ModelProvider: fmt::Debug + Send + Sync {
102    /// Returns the configured provider metadata.
103    fn info(&self) -> &ModelProviderInfo;
104
105    /// Returns the provider-owned capability upper bounds.
106    fn capabilities(&self) -> ProviderCapabilities {
107        ProviderCapabilities::default()
108    }
109
110    /// Returns the preferred model used for automatic approval review.
111    ///
112    /// Providers that require backend-specific model IDs should override this.
113    fn approval_review_preferred_model(&self) -> &'static str {
114        DEFAULT_APPROVAL_REVIEW_PREFERRED_MODEL
115    }
116
117    /// Returns the preferred model used for memory extraction.
118    ///
119    /// Providers that require backend-specific model IDs should override this.
120    fn memory_extraction_preferred_model(&self) -> &'static str {
121        DEFAULT_MEMORY_EXTRACTION_PREFERRED_MODEL
122    }
123
124    /// Returns the preferred model used for memory consolidation.
125    ///
126    /// Providers that require backend-specific model IDs should override this.
127    fn memory_consolidation_preferred_model(&self) -> &'static str {
128        DEFAULT_MEMORY_CONSOLIDATION_PREFERRED_MODEL
129    }
130
131    /// Returns whether requests made through this provider should include attestation.
132    fn supports_attestation(&self) -> bool {
133        false
134    }
135
136    /// Returns the provider-scoped auth manager, when this provider uses one.
137    ///
138    /// TODO(celia-oai): Make auth manager access internal to this crate so callers
139    /// resolve provider-specific auth only through `ModelProvider`. We first need
140    /// to think through whether Codex should have a unified provider-specific auth
141    /// manager throughout the codebase; that is a larger refactor than this change.
142    fn auth_manager(&self) -> Option<Arc<AuthManager>>;
143
144    /// Returns the current provider-scoped auth value, if one is configured.
145    fn auth(&self) -> ModelProviderFuture<'_, Option<CodexAuth>>;
146
147    /// Returns the current app-visible account state for this provider.
148    fn account_state(&self) -> ProviderAccountResult;
149
150    /// Maps an API client error into the provider's user-facing error representation.
151    fn map_api_error(&self, error: ApiError) -> CodexErr {
152        codex_api::map_api_error(error)
153    }
154
155    /// Returns provider configuration adapted for the API client.
156    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    /// Returns the provider base URL that will be used at request time.
165    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    /// Returns the auth provider used to attach request credentials.
172    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    /// Returns request credentials, optionally scoped to a Codex session task.
182    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    /// Creates the model manager implementation appropriate for this provider.
197    fn models_manager(
198        &self,
199        codex_home: PathBuf,
200        config_model_catalog: Option<ModelsResponse>,
201    ) -> SharedModelsManager;
202
203    /// Creates a model manager with caching disabled.
204    ///
205    /// Providers that fetch model catalogs should override this method. The default uses an
206    /// authoritative in-memory catalog so hosted callers cannot accidentally write to disk.
207    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
220/// Shared runtime model provider handle.
221pub 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
231/// Creates the default runtime model provider for configured provider metadata.
232pub 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/// Runtime model provider backed by configured `ModelProviderInfo`.
244#[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(/*base_url*/ 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            /*auth_manager*/ 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(/*base_url*/ None),
503            /*auth_manager*/ 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(/*base_url*/ None),
513            /*auth_manager*/ 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            /*auth_manager*/ 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            /*auth_manager*/ 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(/*aws*/ 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(/*base_url*/ None),
582            /*auth_manager*/ 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(/*base_url*/ 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(/*base_url*/ 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(/*base_url*/ 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            /*auth_manager*/ 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(/*aws*/ None),
672            /*auth_manager*/ 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(/*aws*/ None),
690            /*auth_manager*/ None,
691        );
692        let manager =
693            provider.models_manager(test_codex_home(), /*config_model_catalog*/ None);
694        let uncached_manager =
695            provider.models_manager_without_cache(/*config_model_catalog*/ 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(/*aws*/ None),
768            /*auth_manager*/ 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(), /*config_model_catalog*/ 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}