diff --git a/codex-rs/config/src/thread_config/proto/codex.thread_config.v1.proto b/codex-rs/config/src/thread_config/proto/codex.thread_config.v1.proto index 0208d5ece3..1e7d72bf33 100644 --- a/codex-rs/config/src/thread_config/proto/codex.thread_config.v1.proto +++ b/codex-rs/config/src/thread_config/proto/codex.thread_config.v1.proto @@ -53,6 +53,11 @@ message ModelProvider { optional string project_id = 20; optional string location = 21; optional string credential_file = 22; + optional ModelProviderCapabilities capabilities = 23; +} + +message ModelProviderCapabilities { + optional bool cyber_access_programs = 1; } message StringMap { diff --git a/codex-rs/config/src/thread_config/proto/codex.thread_config.v1.rs b/codex-rs/config/src/thread_config/proto/codex.thread_config.v1.rs index 23fe7a280f..c6e5b16196 100644 --- a/codex-rs/config/src/thread_config/proto/codex.thread_config.v1.rs +++ b/codex-rs/config/src/thread_config/proto/codex.thread_config.v1.rs @@ -85,6 +85,13 @@ pub struct ModelProvider { pub location: ::core::option::Option<::prost::alloc::string::String>, #[prost(string, optional, tag = "22")] pub credential_file: ::core::option::Option<::prost::alloc::string::String>, + #[prost(message, optional, tag = "23")] + pub capabilities: ::core::option::Option, +} +#[derive(Clone, Copy, PartialEq, Eq, Hash, ::prost::Message)] +pub struct ModelProviderCapabilities { + #[prost(bool, optional, tag = "1")] + pub cyber_access_programs: ::core::option::Option, } #[derive(Clone, PartialEq, ::prost::Message)] pub struct StringMap { diff --git a/codex-rs/config/src/thread_config/remote.rs b/codex-rs/config/src/thread_config/remote.rs index a1dfa40c04..fd616daa27 100644 --- a/codex-rs/config/src/thread_config/remote.rs +++ b/codex-rs/config/src/thread_config/remote.rs @@ -3,6 +3,7 @@ use std::collections::HashMap; use std::num::NonZeroU64; use std::time::Duration; +use codex_model_provider_info::ModelProviderCapabilities; use codex_model_provider_info::ModelProviderInfo; use codex_model_provider_info::WireApi; use codex_protocol::config_types::ModelProviderAuthInfo; @@ -194,7 +195,12 @@ fn model_provider_from_proto( requires_openai_auth: provider.requires_openai_auth, supports_websockets: provider.supports_websockets, supports_standalone_web_search: provider.supports_standalone_web_search, - capabilities: None, + capabilities: provider + .capabilities + .map(|capabilities| ModelProviderCapabilities { + cyber_access_programs: capabilities.cyber_access_programs, + ..Default::default() + }), project_id: provider.project_id, location: provider.location, credential_file: provider.credential_file.map(Into::into), @@ -233,7 +239,7 @@ fn model_provider_to_proto( supports_standalone_web_search, include_internal_metadata: _, credential_file, - capabilities: _, + capabilities, } = provider; proto::ModelProvider { @@ -246,6 +252,9 @@ fn model_provider_to_proto( env_key_instructions, experimental_bearer_token: experimental_bearer_token.map(RedactedString::into_inner), auth: auth.map(model_provider_auth_to_proto), + capabilities: capabilities.map(|capabilities| proto::ModelProviderCapabilities { + cyber_access_programs: capabilities.cyber_access_programs, + }), wire_api: proto_wire_api(wire_api).into(), query_params: query_params.map(proto_string_map), http_headers: http_headers.map(proto_string_map), @@ -340,6 +349,7 @@ mod tests { use std::collections::HashMap; use std::num::NonZeroU64; + use codex_model_provider_info::ModelProviderCapabilities; use codex_model_provider_info::ModelProviderInfo; use codex_model_provider_info::WireApi; use codex_protocol::config_types::ModelProviderAuthInfo; @@ -459,12 +469,25 @@ mod tests { expected.credential_file = Some(workspace_dir().join("adc.json").into_path_buf()); expected.auth = None; expected.experimental_bearer_token = Some("synthetic-provider-token".into()); - let proto = model_provider_to_proto("local", expected.clone()); - assert!(proto.supports_standalone_web_search); - let (id, actual) = model_provider_from_proto(proto).expect("model provider from proto"); - - assert_eq!(id, "local"); - assert_eq!(actual, expected); + for capabilities in [ + None, + Some(ModelProviderCapabilities::default()), + Some(ModelProviderCapabilities { + cyber_access_programs: Some(false), + ..Default::default() + }), + Some(ModelProviderCapabilities { + cyber_access_programs: Some(true), + ..Default::default() + }), + ] { + expected.capabilities = capabilities; + let proto = model_provider_to_proto("local", expected.clone()); + assert!(proto.supports_standalone_web_search); + let (id, actual) = model_provider_from_proto(proto).expect("model provider from proto"); + assert_eq!(id, "local"); + assert_eq!(actual, expected); + } } #[test] @@ -495,6 +518,9 @@ mod tests { model_catalog_url: Some( "http://127.0.0.1:8061/api/codex/models".to_string(), ), + capabilities: Some(proto::ModelProviderCapabilities { + cyber_access_programs: Some(true), + }), credential_file: None, env_key: None, env_key_instructions: None, @@ -598,7 +624,10 @@ mod tests { supports_standalone_web_search: true, gateway_oauth: None, aws: None, - capabilities: None, + capabilities: Some(ModelProviderCapabilities { + cyber_access_programs: Some(true), + ..Default::default() + }), project_id: None, location: None, credential_file: None, diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index 168c26dbdc..3947d5da2f 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -3042,6 +3042,10 @@ "additionalProperties": false, "description": "Overrides for the optional API features supported by a custom provider.", "properties": { + "cyber_access_programs": { + "description": "Whether the provider accepts explicit `access_programs.cyber` selections. Defaults to enabled for the built-in OpenAI provider and disabled otherwise. This does not grant program access or change authentication.", + "type": "boolean" + }, "external_web_access": { "description": "Whether hosted web search may access the live web.", "type": "boolean" diff --git a/codex-rs/core/src/cyber_access_program.rs b/codex-rs/core/src/cyber_access_program.rs index bbc50b90c5..4c3cd19ff5 100644 --- a/codex-rs/core/src/cyber_access_program.rs +++ b/codex-rs/core/src/cyber_access_program.rs @@ -3,7 +3,6 @@ use codex_api::AccessPrograms; use codex_features::Feature; use codex_login::CodexAuth; -use codex_model_provider_info::OPENAI_PROVIDER_ID; use codex_protocol::error::CodexErr; use codex_protocol::error::Result; use codex_protocol::turn_input::CyberAccessProgram; @@ -19,7 +18,10 @@ pub enum ApiKeyCyberAccessPrograms { impl ApiKeyCyberAccessPrograms { /// Captures the same provider and feature policy used by ordinary model requests. pub fn from_config(config: &crate::config::Config) -> Self { - if config.model_provider_id != OPENAI_PROVIDER_ID { + if !config + .model_provider + .supports_cyber_access_programs(&config.model_provider_id) + { Self::UnsupportedProvider } else if config.features.enabled(Feature::ApiKeyCyberAccessPrograms) { Self::Enabled @@ -30,10 +32,14 @@ impl ApiKeyCyberAccessPrograms { } pub(crate) fn for_provider( - provider_id: &str, + config: &crate::config::Config, program: Option, ) -> Option { - program.filter(|_| provider_id == OPENAI_PROVIDER_ID) + program.filter(|_| { + config + .model_provider + .supports_cyber_access_programs(&config.model_provider_id) + }) } /// Resolves an explicit program using the request's current authentication. diff --git a/codex-rs/core/src/session/turn.rs b/codex-rs/core/src/session/turn.rs index dcce346788..65e7592a67 100644 --- a/codex-rs/core/src/session/turn.rs +++ b/codex-rs/core/src/session/turn.rs @@ -1472,7 +1472,7 @@ async fn maybe_run_previous_model_inline_compact( // Combining the previous model with the current turn's program can produce a pair // that the server rejects. previous_model_turn_context.cyber_access_program = cyber_access_program::for_provider( - &previous_model_turn_context.config.model_provider_id, + &previous_model_turn_context.config, previous_turn_settings.cyber_access_program, ); let previous_model_turn_context = Arc::new(previous_model_turn_context); diff --git a/codex-rs/core/src/session/turn_context.rs b/codex-rs/core/src/session/turn_context.rs index a25dd60102..8bab50c7ec 100644 --- a/codex-rs/core/src/session/turn_context.rs +++ b/codex-rs/core/src/session/turn_context.rs @@ -1386,10 +1386,8 @@ impl Session { turn_context.realtime_active = self.conversation.running_state().await.is_some(); turn_context.final_output_json_schema = options.final_output_json_schema; - turn_context.cyber_access_program = cyber_access_program::for_provider( - &turn_context.config.model_provider_id, - options.cyber_access_program, - ); + turn_context.cyber_access_program = + cyber_access_program::for_provider(&turn_context.config, options.cyber_access_program); // Tool observers retain this turn's selection even after a later turn starts. if let Some(program) = turn_context.cyber_access_program { turn_context.extension_data.insert(program); diff --git a/codex-rs/core/tests/suite/cyber_access_program.rs b/codex-rs/core/tests/suite/cyber_access_program.rs index 829ec85c50..0529a776b4 100644 --- a/codex-rs/core/tests/suite/cyber_access_program.rs +++ b/codex-rs/core/tests/suite/cyber_access_program.rs @@ -161,13 +161,23 @@ async fn recover_turn_restores_cyber_access_program_without_making_it_sticky() - Ok(()) } +#[test_case(None; "omitted")] +#[test_case(Some(false); "disabled")] +#[test_case(Some(true); "enabled")] #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn cyber_access_program_omits_spoofed_custom_providers() -> Result<()> { +async fn cyber_access_program_custom_provider_requires_capability( + capability: Option, +) -> Result<()> { core_test_support::skip_if_no_network!(Ok(())); for auth in [ CodexAuth::from_api_key("test-key"), CodexAuth::create_dummy_chatgpt_auth_for_testing(), ] { + let expected_authorization = if auth.is_api_key_auth() { + "Bearer test-key" + } else { + "Bearer Access Token" + }; let server = responses::start_mock_server().await; let request = responses::mount_sse_once( &server, @@ -176,26 +186,85 @@ async fn cyber_access_program_omits_spoofed_custom_providers() -> Result<()> { .await; let test = test_codex() .with_auth(auth) - .with_config(|config| { + .with_config(move |config| { // Keep the display name "OpenAI": provider identity must not use it. config.model_provider_id = "custom".to_owned(); + config.model_provider.capabilities = + Some(codex_model_provider_info::ModelProviderCapabilities { + cyber_access_programs: capability, + ..Default::default() + }); + config + .features + .enable(Feature::ApiKeyCyberAccessPrograms) + .expect("enable API-key Cyber access programs"); }) .build_with_auto_env(&server) .await?; submit(&test, Some(CyberAccessProgram::DaybreakRed)).await?; assert_eq!( - request.single_request().body_json().get("access_programs"), - None + request.single_request().header("authorization").as_deref(), + Some(expected_authorization) + ); + assert_eq!( + request + .single_request() + .body_json() + .get("access_programs") + .cloned(), + (capability == Some(true)).then(|| json!({"cyber": "daybreak_red"})) ); } Ok(()) } -#[test_case(CodexAuth::create_dummy_chatgpt_auth_for_testing(); "chatgpt")] -#[test_case(CodexAuth::from_api_key("test-api-key"); "api_key")] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn cyber_access_program_custom_provider_preserves_api_key_feature_gate() -> Result<()> { + let server = responses::start_mock_server().await; + let test = test_codex() + .with_auth(CodexAuth::from_api_key("test-api-key")) + .with_config(|config| { + config.model_provider_id = "proxy".to_owned(); + config.model_provider.capabilities = + Some(codex_model_provider_info::ModelProviderCapabilities { + cyber_access_programs: Some(true), + ..Default::default() + }); + config + .features + .disable(Feature::ApiKeyCyberAccessPrograms) + .unwrap(); + }) + .build_with_auto_env(&server) + .await?; + let error = submit(&test, Some(CyberAccessProgram::DaybreakBlue)) + .await + .unwrap_err(); + assert!( + error + .to_string() + .contains("Cyber access programs are disabled for this API-key session.") + ); + assert!( + server + .received_requests() + .await + .unwrap() + .iter() + .all(|request| !request.url.path().ends_with("/responses")) + ); + test.codex.shutdown_and_wait().await?; + Ok(()) +} + +#[test_case(CodexAuth::create_dummy_chatgpt_auth_for_testing(), "openai"; "chatgpt")] +#[test_case(CodexAuth::from_api_key("test-api-key"), "openai"; "api_key")] +#[test_case(CodexAuth::create_dummy_chatgpt_auth_for_testing(), "proxy"; "custom_chatgpt")] +#[test_case(CodexAuth::from_api_key("test-api-key"), "proxy"; "custom_api_key")] #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn cyber_access_program_survives_mid_turn_remote_compaction_v2( auth: CodexAuth, + provider_id: &'static str, ) -> Result<()> { core_test_support::skip_if_no_network!(Ok(())); let server = responses::start_mock_server().await; @@ -224,7 +293,15 @@ async fn cyber_access_program_survives_mid_turn_remote_compaction_v2( .await; let test = test_codex() .with_auth(auth) - .with_config(|config| { + .with_config(move |config| { + config.model_provider_id = provider_id.to_owned(); + if provider_id == "proxy" { + config.model_provider.capabilities = + Some(codex_model_provider_info::ModelProviderCapabilities { + cyber_access_programs: Some(true), + ..Default::default() + }); + } config.model_auto_compact_token_limit = Some(200); config .features @@ -408,11 +485,14 @@ async fn cyber_access_program_is_inherited_by_child_turns() -> Result<()> { Ok(()) } -#[test_case(CodexAuth::create_dummy_chatgpt_auth_for_testing(); "chatgpt")] -#[test_case(CodexAuth::from_api_key("test-api-key"); "api_key")] +#[test_case(CodexAuth::create_dummy_chatgpt_auth_for_testing(), "openai"; "chatgpt")] +#[test_case(CodexAuth::from_api_key("test-api-key"), "openai"; "api_key")] +#[test_case(CodexAuth::create_dummy_chatgpt_auth_for_testing(), "proxy"; "custom_chatgpt")] +#[test_case(CodexAuth::from_api_key("test-api-key"), "proxy"; "custom_api_key")] #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn cyber_access_program_changes_on_one_websocket_with_response_reuse( auth: CodexAuth, + provider_id: &'static str, ) -> Result<()> { core_test_support::skip_if_no_network!(Ok(())); let response_ids = [ @@ -427,7 +507,15 @@ async fn cyber_access_program_changes_on_one_websocket_with_response_reuse( .await; let test = test_codex() .with_auth(auth) - .with_config(|config| { + .with_config(move |config| { + config.model_provider_id = provider_id.to_owned(); + if provider_id == "proxy" { + config.model_provider.capabilities = + Some(codex_model_provider_info::ModelProviderCapabilities { + cyber_access_programs: Some(true), + ..Default::default() + }); + } config .features .enable(Feature::ApiKeyModelDiscovery) diff --git a/codex-rs/exec/src/daybreak.rs b/codex-rs/exec/src/daybreak.rs index 9f4a1cb8b7..db511be6f4 100644 --- a/codex-rs/exec/src/daybreak.rs +++ b/codex-rs/exec/src/daybreak.rs @@ -11,6 +11,7 @@ use codex_app_server_protocol::ModelListParams; use codex_app_server_protocol::ModelListResponse; use codex_features::Feature; use codex_features::Features; +use codex_model_provider_info::ModelProviderInfo; use codex_protocol::openai_models::ModelAccessPrograms; pub(crate) async fn program_for_turn( @@ -18,11 +19,15 @@ pub(crate) async fn program_for_turn( client: &InProcessAppServerClient, request_ids: &mut RequestIdSequencer, model: &str, - provider: &str, + provider_id: &str, + provider: Option<&ModelProviderInfo>, enabled: bool, ) -> anyhow::Result> { - if provider != "openai" { - anyhow::ensure!(!enabled, "Daybreak requires the OpenAI model provider"); + if !provider.is_some_and(|provider| provider.supports_cyber_access_programs(provider_id)) { + anyhow::ensure!( + !enabled, + "Daybreak requires a model provider with Cyber access program support" + ); return Ok(None); } diff --git a/codex-rs/exec/src/lib.rs b/codex-rs/exec/src/lib.rs index 7f71e60b84..0a6a5cbc0f 100644 --- a/codex-rs/exec/src/lib.rs +++ b/codex-rs/exec/src/lib.rs @@ -1228,6 +1228,9 @@ async fn run_exec_session(args: ExecRunArgs) -> anyhow::Result<()> { &mut request_ids, &session_configured.model, &session_configured.model_provider_id, + config + .model_providers + .get(&session_configured.model_provider_id), daybreak_enabled, ) .await? diff --git a/codex-rs/exec/tests/suite/daybreak.rs b/codex-rs/exec/tests/suite/daybreak.rs index d134a77c05..3faf58d8cd 100644 --- a/codex-rs/exec/tests/suite/daybreak.rs +++ b/codex-rs/exec/tests/suite/daybreak.rs @@ -98,6 +98,67 @@ async fn exec_rejects_daybreak_for_non_openai_provider() -> anyhow::Result<()> { Ok(()) } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn exec_custom_provider_daybreak_uses_capability() -> anyhow::Result<()> { + let (test, server) = configured_daybreak_exec().await?; + std::fs::remove_file(test.home_path().join("auth.json"))?; + let config_path = test.home_path().join("config.toml"); + let config = std::fs::read_to_string(&config_path)?; + for capability in [None, Some(false), Some(true)] { + let capability_config = capability + .map(|value| format!("capabilities.cyber_access_programs = {value}\n")) + .unwrap_or_default(); + std::fs::write( + &config_path, + format!( + "{config}model_provider = 'proxy'\n[model_providers.proxy]\nname = 'Proxy'\nbase_url = '{}'\nwire_api = 'responses'\nrequires_openai_auth = true\n{capability_config}", + server.uri() + ), + )?; + for (enabled, expected) in [(true, "daybreak_blue"), (false, "standard")] { + let rejected = enabled && capability != Some(true); + let previous_requests = responses::received_responses_requests(&server).await.len(); + let response = if rejected { + None + } else { + Some(responses::mount_sse_once(&server, responses::sse_completed("response")).await) + }; + let assertion = test + .cmd_with_server(&server) + .env_remove("CODEX_ACCESS_TOKEN") + .args([ + "--skip-git-repo-check", + "-c", + &format!("daybreak={enabled}"), + "hello", + ]) + .assert(); + if rejected { + assertion.failure().stderr(predicate::str::contains( + "Daybreak requires a model provider with Cyber access program support", + )); + assert_eq!( + responses::received_responses_requests(&server).await.len(), + previous_requests + ); + } else { + assertion.success(); + assert_eq!( + response + .expect("successful turn response") + .single_request() + .body_json() + .get("access_programs"), + (capability == Some(true)) + .then(|| json!({"cyber": expected})) + .as_ref() + ); + } + } + } + Ok(()) +} + async fn configured_daybreak_exec() -> anyhow::Result<(TestCodexExecBuilder, wiremock::MockServer)> { let test = test_codex_exec(); diff --git a/codex-rs/model-provider-info/src/capabilities.rs b/codex-rs/model-provider-info/src/capabilities.rs index 18db46cf66..8571bb1513 100644 --- a/codex-rs/model-provider-info/src/capabilities.rs +++ b/codex-rs/model-provider-info/src/capabilities.rs @@ -11,6 +11,10 @@ use serde::Serialize; #[derive(Debug, Clone, Copy, Default, Deserialize, Serialize, PartialEq, Eq, JsonSchema)] #[schemars(deny_unknown_fields)] pub struct ModelProviderCapabilities { + /// Whether the provider accepts explicit `access_programs.cyber` selections. + /// Defaults to enabled for the built-in OpenAI provider and disabled otherwise. + /// This does not grant program access or change authentication. + pub cyber_access_programs: Option, /// Whether hosted web search may access the live web. pub external_web_access: Option, /// Remote context-compaction protocol; omission preserves provider defaults. diff --git a/codex-rs/model-provider-info/src/lib.rs b/codex-rs/model-provider-info/src/lib.rs index 9bf505dfa9..64417fc3ee 100644 --- a/codex-rs/model-provider-info/src/lib.rs +++ b/codex-rs/model-provider-info/src/lib.rs @@ -694,6 +694,14 @@ other non-default provider fields are not supported" } } + /// Whether explicit Cyber programs may be selected and forwarded to this provider. + /// Use the configured provider ID, never its user-controlled display name, for defaults. + pub fn supports_cyber_access_programs(&self, provider_id: &str) -> bool { + self.capabilities + .and_then(|capabilities| capabilities.cyber_access_programs) + .unwrap_or(provider_id == OPENAI_PROVIDER_ID) + } + pub fn is_openai(&self) -> bool { self.name == OPENAI_PROVIDER_NAME } diff --git a/codex-rs/model-provider-info/src/model_provider_info_tests.rs b/codex-rs/model-provider-info/src/model_provider_info_tests.rs index 0d3742f81e..68b48aadc5 100644 --- a/codex-rs/model-provider-info/src/model_provider_info_tests.rs +++ b/codex-rs/model-provider-info/src/model_provider_info_tests.rs @@ -1081,3 +1081,31 @@ fn builtin_openai_catalog_accepts_existing_chatgpt_hosts() { ); } } + +#[test] +fn cyber_access_program_capability_uses_provider_identity_and_explicit_overrides() { + for provider_id in [ + OPENAI_PROVIDER_ID, + "proxy", + AMAZON_BEDROCK_PROVIDER_ID, + GCP_PROVIDER_ID, + ] { + for configured in [None, Some(false), Some(true)] { + // A custom provider cannot gain the default by copying OpenAI's display name. + let config = match configured { + Some(enabled) => format!( + "name = \"OpenAI\"\n[capabilities]\ncyber_access_programs = {enabled}\n" + ), + None => "name = \"OpenAI\"\n".to_owned(), + }; + let provider: ModelProviderInfo = toml::from_str(&config).unwrap(); + assert_eq!( + provider.supports_cyber_access_programs(provider_id), + configured.unwrap_or(provider_id == OPENAI_PROVIDER_ID), + ); + let round_trip: ModelProviderInfo = + toml::from_str(&toml::to_string(&provider).unwrap()).unwrap(); + assert_eq!(round_trip, provider); + } + } +} diff --git a/codex-rs/tui/src/chatwidget/settings.rs b/codex-rs/tui/src/chatwidget/settings.rs index 432fa653e8..f6dea221f4 100644 --- a/codex-rs/tui/src/chatwidget/settings.rs +++ b/codex-rs/tui/src/chatwidget/settings.rs @@ -29,7 +29,13 @@ impl ChatWidget { /// UI and turn-selection eligibility; the catalog and server still determine program access. pub(crate) fn daybreak_account_eligible(&self) -> bool { self.daybreak_feature_enabled() - && self.config.model_provider_id == "openai" + && self + .config + .model_providers + .get(&self.config.model_provider_id) + .is_some_and(|provider| { + provider.supports_cyber_access_programs(&self.config.model_provider_id) + }) && (self.has_chatgpt_account || matches!( self.status_account_display, @@ -504,6 +510,7 @@ impl ChatWidget { let cwd_changed = self.config.cwd != settings.cwd; self.apply_thread_settings_cwd(settings.cwd.clone()); self.config.model_provider_id = settings.model_provider.clone(); + self.set_daybreak_enabled(self.daybreak_enabled); self.set_service_tier(settings.service_tier.clone()); if let Err(err) = self .config diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__daybreak_hidden_after_provider_settings_change.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__daybreak_hidden_after_provider_settings_change.snap new file mode 100644 index 0000000000..83accb757d --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__daybreak_hidden_after_provider_settings_change.snap @@ -0,0 +1,8 @@ +--- +source: tui/src/chatwidget/tests/app_server.rs +assertion_line: 200 +expression: popup +--- +› /daybreak + + profile: work · provider: server-updated Plan mode (⇧tab to cycle) diff --git a/codex-rs/tui/src/chatwidget/tests/app_server.rs b/codex-rs/tui/src/chatwidget/tests/app_server.rs index c6dc2d4c6b..e9cae92a47 100644 --- a/codex-rs/tui/src/chatwidget/tests/app_server.rs +++ b/codex-rs/tui/src/chatwidget/tests/app_server.rs @@ -107,6 +107,14 @@ async fn session_header_uses_catalog_display_name_without_changing_model() { async fn session_and_settings_sync_server_provider_id() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.2")).await; chat.config.model_provider.base_url = Some("https://local-provider.example/v1".to_string()); + chat.has_chatgpt_account = true; + chat.config + .features + .enable(Feature::CliDaybreak) + .expect("enable Daybreak"); + chat.config + .model_providers + .insert("server-bedrock".into(), chat.config.model_provider.clone()); chat.local_settings.tui.status_line = Some(vec!["profile".into(), "provider".into()]); chat.refresh_status_line(); assert_eq!(status_line_text(&chat), None); @@ -133,10 +141,15 @@ async fn session_and_settings_sync_server_provider_id() { }); let mut provider = chat.config.model_provider.clone(); provider.env_key = Some("CUSTOM_API_KEY".to_string()); + provider.capabilities = Some(codex_model_provider_info::ModelProviderCapabilities { + cyber_access_programs: Some(true), + ..Default::default() + }); chat.config .model_providers .insert(first.model_provider_id.clone(), provider); chat.handle_thread_session(first.clone()); + assert!(chat.daybreak_account_eligible()); let displayed = status(&mut chat); assert!(displayed.contains("Model provider:") && displayed.contains("server-ollama")); assert!(!displayed.contains("local-provider.example")); @@ -149,12 +162,17 @@ async fn session_and_settings_sync_server_provider_id() { let mut other = configured_thread_session(ThreadId::new()); other.model_provider_id = "server-bedrock".to_string(); chat.handle_thread_session_quiet(other); + assert!(!chat.daybreak_account_eligible()); let displayed = status(&mut chat); assert!(displayed.contains("server-bedrock")); assert!(!displayed.contains("server-ollama")); chat.handle_thread_session_quiet(first); assert_eq!(chat.config.model_provider_id, "server-ollama"); + assert!(chat.daybreak_account_eligible()); + chat.bottom_pane + .set_composer_text("/daybreak".to_string(), Vec::new(), Vec::new()); + assert!(render_bottom_popup(&chat, /*width*/ 80).contains("broader access")); chat.config.config_layer_stack = chat .config @@ -172,10 +190,23 @@ async fn session_and_settings_sync_server_provider_id() { /*replay_kind*/ None, ); assert!(status(&mut chat).contains("server-updated")); + assert!(!chat.daybreak_account_eligible()); insta::assert_snapshot!( status_line_text(&chat).unwrap(), @"profile: work · provider: server-updated" ); + let popup = render_bottom_popup(&chat, /*width*/ 80); + assert!(!popup.contains("broader access")); + assert_chatwidget_snapshot!("daybreak_hidden_after_provider_settings_change", popup); + + let mut restored = thread_settings_for_test("gpt-5.4", first_id); + restored.thread_settings.model_provider = "server-ollama".to_string(); + chat.handle_server_notification( + ServerNotification::ThreadSettingsUpdated(restored), + /*replay_kind*/ None, + ); + assert!(chat.daybreak_account_eligible()); + assert!(render_bottom_popup(&chat, /*width*/ 80).contains("broader access")); } fn start_safety_buffering_test_turn( diff --git a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs index e68d202b00..a8ce5ef1a1 100644 --- a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs +++ b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs @@ -2231,9 +2231,46 @@ async fn slash_copy_picker_remains_available_from_parent_owned_threads() { #[tokio::test] async fn slash_daybreak_offers_an_application_when_unavailable() { + assert_daybreak_catalog_and_account_gates("openai").await; +} + +#[tokio::test] +async fn slash_daybreak_custom_provider_requires_capability() { + assert_daybreak_catalog_and_account_gates("proxy").await; +} + +async fn assert_daybreak_catalog_and_account_gates(provider_id: &str) { let (mut chat, mut rx, _ops) = make_chatwidget_manual(/*model_override*/ None).await; chat.has_chatgpt_account = true; - chat.config.model_provider_id = "openai".into(); + chat.config.model_provider_id = provider_id.into(); + if provider_id == "proxy" { + for capability in [None, Some(false)] { + chat.config.model_provider.capabilities = + Some(codex_model_provider_info::ModelProviderCapabilities { + cyber_access_programs: capability, + ..Default::default() + }); + chat.config + .model_providers + .insert(provider_id.into(), chat.config.model_provider.clone()); + assert!(!chat.daybreak_account_eligible()); + assert!(chat.daybreak_command_description().is_none()); + } + chat.config.model_provider.capabilities = + Some(codex_model_provider_info::ModelProviderCapabilities { + cyber_access_programs: Some(true), + ..Default::default() + }); + } + chat.config + .model_providers + .insert(provider_id.into(), chat.config.model_provider.clone()); + // Session updates change the provider ID; eligibility must not use the startup definition. + chat.config.model_provider.capabilities = None; + assert!(chat.daybreak_account_eligible()); + chat.config.model_provider_id = "unknown-provider".into(); + assert!(!chat.daybreak_account_eligible()); + chat.config.model_provider_id = provider_id.into(); let mut model = crate::test_support::TEST_MODEL_PRESETS[0].clone(); model.available_access_programs = Some(codex_protocol::openai_models::ModelAccessPrograms { cyber: vec![codex_protocol::turn_input::CyberAccessProgram::Standard],