mirror of
https://github.com/openai/codex.git
synced 2026-10-20 00:18:49 +00:00
Enable Cyber access programs for API-key sessions by default (#53717)
## What changed - Default `features.api_key_cyber_access_programs` to `true` and allow `codex exec` to select Daybreak with an OpenAI API key. Select the advertised standard program when `daybreak=false`, while respecting explicit feature opt-outs. - Resolve API-key eligibility for TUI background turns from the target thread's feature settings, including project opt-outs. Omit access-program overrides when the server cannot report those settings. - Preserve automatic server treatment for unlisted models and missing or empty Cyber program metadata in exec and TUI. Continue rejecting Daybreak when a nonempty program list does not offer it. ## Testing Update regression coverage for API-key Daybreak selection, the off override, explicit opt-outs, resumed sessions, target-thread settings, and missing model metadata. GitOrigin-RevId: f3cf7bd95a2754d4b4281bc70b18c7da4b177552
This commit is contained in:
@@ -245,6 +245,11 @@ async fn model_switch_program_pair(
|
||||
.features
|
||||
.enable(Feature::ApiKeyCyberAccessPrograms)
|
||||
.expect("enable API-key Cyber access programs");
|
||||
} else if matches!(history, History::ApiKeyResume) {
|
||||
config
|
||||
.features
|
||||
.disable(Feature::ApiKeyCyberAccessPrograms)
|
||||
.expect("disable API-key Cyber access programs");
|
||||
}
|
||||
if matches!(history, History::ApiKeyCustomProviderResume) {
|
||||
config.model_provider_id = "custom".to_owned();
|
||||
@@ -258,7 +263,7 @@ async fn model_switch_program_pair(
|
||||
}
|
||||
};
|
||||
if matches!(history, History::ApiKeyResume) {
|
||||
// Resuming a ChatGPT turn does not enable the API-key rollout features.
|
||||
// Resuming a ChatGPT turn cannot bypass an explicit API-key opt-out.
|
||||
let error = submit_pair(&thread, next_model, Some(CyberAccessProgram::DaybreakRed))
|
||||
.await
|
||||
.expect_err("API-key selections require the rollout features");
|
||||
|
||||
@@ -9,9 +9,12 @@ use codex_app_server_protocol::GetAccountParams;
|
||||
use codex_app_server_protocol::GetAccountResponse;
|
||||
use codex_app_server_protocol::ModelListParams;
|
||||
use codex_app_server_protocol::ModelListResponse;
|
||||
use codex_features::Feature;
|
||||
use codex_features::Features;
|
||||
use codex_protocol::openai_models::ModelAccessPrograms;
|
||||
|
||||
pub(crate) async fn program_for_turn(
|
||||
features: &Features,
|
||||
client: &InProcessAppServerClient,
|
||||
request_ids: &mut RequestIdSequencer,
|
||||
model: &str,
|
||||
@@ -25,8 +28,11 @@ pub(crate) async fn program_for_turn(
|
||||
|
||||
if enabled {
|
||||
anyhow::ensure!(
|
||||
has_chatgpt_account(client, request_ids).await?,
|
||||
"Daybreak requires a signed-in ChatGPT account"
|
||||
matches!(
|
||||
read_account(client, request_ids).await?,
|
||||
Some(Account::Chatgpt { .. } | Account::ApiKey { .. })
|
||||
),
|
||||
"Daybreak requires a signed-in ChatGPT account or an OpenAI API key"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -43,44 +49,48 @@ pub(crate) async fn program_for_turn(
|
||||
})
|
||||
.await;
|
||||
let Ok(result) = result else {
|
||||
anyhow::ensure!(!enabled, "Daybreak availability could not be determined");
|
||||
return Ok(None);
|
||||
};
|
||||
if let Some(selected) = result.data.into_iter().find(|entry| entry.model == model) {
|
||||
let programs = selected
|
||||
.available_access_programs
|
||||
.map(ModelAccessPrograms::from);
|
||||
.map(ModelAccessPrograms::from)
|
||||
.filter(|programs| !programs.cyber.is_empty());
|
||||
let Some(programs) = programs else {
|
||||
return Ok(None);
|
||||
};
|
||||
if enabled {
|
||||
return programs
|
||||
.and_then(|programs| programs.daybreak())
|
||||
.daybreak()
|
||||
.map(|program| Some(program.into()))
|
||||
.ok_or_else(|| anyhow::anyhow!("Daybreak is unavailable for model {model}; turn it off or choose a compatible model"));
|
||||
}
|
||||
let Some(standard) = programs.and_then(|programs| programs.standard()) else {
|
||||
let Some(standard) = programs.standard() else {
|
||||
return Ok(None);
|
||||
};
|
||||
return Ok(has_chatgpt_account(client, request_ids)
|
||||
.await
|
||||
.unwrap_or(false)
|
||||
.then_some(standard.into()));
|
||||
let eligible = match read_account(client, request_ids).await.unwrap_or(None) {
|
||||
Some(Account::Chatgpt { .. }) => true,
|
||||
Some(Account::ApiKey { .. }) => {
|
||||
features.enabled(Feature::ApiKeyCyberAccessPrograms)
|
||||
}
|
||||
Some(Account::AmazonBedrock { .. }) | None => false,
|
||||
};
|
||||
return Ok(eligible.then_some(standard.into()));
|
||||
}
|
||||
match result.next_cursor {
|
||||
Some(next) => cursor = Some(next),
|
||||
None => {
|
||||
anyhow::ensure!(
|
||||
!enabled,
|
||||
"Daybreak availability could not be determined for model {model}"
|
||||
);
|
||||
// Preserve automatic treatment for explicit model IDs absent from the catalog.
|
||||
return Ok(None);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn has_chatgpt_account(
|
||||
async fn read_account(
|
||||
client: &InProcessAppServerClient,
|
||||
request_ids: &mut RequestIdSequencer,
|
||||
) -> anyhow::Result<bool> {
|
||||
) -> anyhow::Result<Option<Account>> {
|
||||
let account = client
|
||||
.request_typed::<GetAccountResponse>(ClientRequest::GetAccount {
|
||||
request_id: request_ids.next(),
|
||||
@@ -92,5 +102,5 @@ async fn has_chatgpt_account(
|
||||
.map_err(|error| {
|
||||
anyhow::anyhow!("Daybreak account availability could not be determined: {error}")
|
||||
})?;
|
||||
Ok(matches!(account.account, Some(Account::Chatgpt { .. })))
|
||||
Ok(account.account)
|
||||
}
|
||||
|
||||
@@ -1215,6 +1215,7 @@ async fn run_exec_session(args: ExecRunArgs) -> anyhow::Result<()> {
|
||||
Some(program) => Some(program),
|
||||
None if config.features.enabled(Feature::CliDaybreak) => {
|
||||
daybreak::program_for_turn(
|
||||
&config.features,
|
||||
&client,
|
||||
&mut request_ids,
|
||||
&session_configured.model,
|
||||
|
||||
@@ -21,34 +21,52 @@ fn started_thread(output: std::process::Output) -> anyhow::Result<String> {
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn exec_rejects_daybreak_for_api_keys_and_allows_an_override() -> anyhow::Result<()> {
|
||||
async fn exec_resolves_daybreak_for_api_keys_and_allows_an_off_override() -> anyhow::Result<()> {
|
||||
let (test, server) = configured_daybreak_exec().await?;
|
||||
std::fs::remove_file(test.home_path().join("auth.json"))?;
|
||||
let response = responses::mount_sse_once(&server, responses::sse_completed("response")).await;
|
||||
|
||||
for (args, expected) in [
|
||||
(vec![], Some("daybreak_blue")),
|
||||
(vec!["-c", "daybreak=false"], Some("standard")),
|
||||
(vec!["-m", "unlisted-model"], None),
|
||||
(
|
||||
vec![
|
||||
"-c",
|
||||
"daybreak=false",
|
||||
"-c",
|
||||
"features.api_key_cyber_access_programs=false",
|
||||
],
|
||||
None,
|
||||
),
|
||||
] {
|
||||
let response =
|
||||
responses::mount_sse_once(&server, responses::sse_completed("response")).await;
|
||||
test.cmd_with_server(&server)
|
||||
.env_remove("CODEX_ACCESS_TOKEN")
|
||||
.arg("--skip-git-repo-check")
|
||||
.args(args)
|
||||
.arg("hello")
|
||||
.assert()
|
||||
.success();
|
||||
assert_eq!(
|
||||
response.single_request().body_json().get("access_programs"),
|
||||
expected.map(|program| json!({"cyber": program})).as_ref()
|
||||
);
|
||||
}
|
||||
let response = responses::mount_sse_once(&server, responses::sse_completed("unexpected")).await;
|
||||
test.cmd_with_server(&server)
|
||||
.env_remove("CODEX_ACCESS_TOKEN")
|
||||
.arg("--skip-git-repo-check")
|
||||
.arg("hello")
|
||||
.args([
|
||||
"--skip-git-repo-check",
|
||||
"-c",
|
||||
"features.api_key_cyber_access_programs=false",
|
||||
"hello",
|
||||
])
|
||||
.assert()
|
||||
.failure()
|
||||
.stderr(predicate::str::contains(
|
||||
"Daybreak requires a signed-in ChatGPT account",
|
||||
"Cyber access programs are disabled for this API-key session.",
|
||||
));
|
||||
assert!(response.requests().is_empty());
|
||||
|
||||
test.cmd_with_server(&server)
|
||||
.env_remove("CODEX_ACCESS_TOKEN")
|
||||
.arg("--skip-git-repo-check")
|
||||
.arg("-c")
|
||||
.arg("daybreak=false")
|
||||
.arg("hello")
|
||||
.assert()
|
||||
.success();
|
||||
assert_eq!(
|
||||
response.single_request().body_json().get("access_programs"),
|
||||
None
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
@@ -1418,7 +1418,7 @@ pub const FEATURES: &[FeatureSpec] = &[
|
||||
id: Feature::ApiKeyCyberAccessPrograms,
|
||||
key: "api_key_cyber_access_programs",
|
||||
stage: Stage::Stable,
|
||||
default_enabled: false,
|
||||
default_enabled: true,
|
||||
},
|
||||
FeatureSpec {
|
||||
id: Feature::EnableRequestCompression,
|
||||
|
||||
@@ -203,6 +203,8 @@ async fn daybreak_command_persists_and_confirms_each_selection() -> Result<()> {
|
||||
app.chat_widget
|
||||
.set_feature_enabled(Feature::CliDaybreak, /*enabled*/ true);
|
||||
app.chat_widget.set_daybreak_enabled(/*enabled*/ true);
|
||||
app.chat_widget
|
||||
.set_feature_enabled(Feature::ApiKeyCyberAccessPrograms, /*enabled*/ false);
|
||||
let models = app.chat_widget.model_catalog().models.clone();
|
||||
app.chat_widget.update_account_state(
|
||||
Some(crate::status::StatusAccountDisplay::ApiKey),
|
||||
@@ -1899,10 +1901,6 @@ async fn check_dynamic_tool_requests(
|
||||
app.config
|
||||
.features
|
||||
.set_enabled(Feature::CliDaybreak, rollout_enabled)?;
|
||||
app.config.features.set_enabled(
|
||||
Feature::ApiKeyCyberAccessPrograms,
|
||||
api_key_cyber_access_programs,
|
||||
)?;
|
||||
let codex_home = tempdir()?;
|
||||
let backend = wiremock::MockServer::start().await;
|
||||
let backend_url = format!("{}/backend-api", backend.uri());
|
||||
@@ -1919,10 +1917,11 @@ async fn check_dynamic_tool_requests(
|
||||
}
|
||||
let catalog_path = codex_home.path().join("models.json");
|
||||
std::fs::write(&catalog_path, serde_json::to_vec(&catalog)?)?;
|
||||
// The server's target-thread config may differ from the launching TUI's defaults.
|
||||
std::fs::write(
|
||||
codex_home.path().join("config.toml"),
|
||||
format!(
|
||||
"daybreak = true\nmodel_catalog_json = {:?}\nchatgpt_base_url = {backend_url:?}\ncli_auth_credentials_store = \"file\"\nopenai_base_url = {api_url:?}\n",
|
||||
"daybreak = true\nmodel_catalog_json = {:?}\nchatgpt_base_url = {backend_url:?}\ncli_auth_credentials_store = \"file\"\nopenai_base_url = {api_url:?}\nfeatures.cli_daybreak = {rollout_enabled}\nfeatures.api_key_cyber_access_programs = {api_key_cyber_access_programs}\n",
|
||||
catalog_path.display().to_string()
|
||||
),
|
||||
)?;
|
||||
|
||||
@@ -56,6 +56,7 @@ async fn resumed_initial_messages_render_history() {
|
||||
|
||||
// A saved On preference cannot bypass the API-key gate.
|
||||
chat.status_account_display = Some(StatusAccountDisplay::ApiKey);
|
||||
chat.set_feature_enabled(Feature::ApiKeyCyberAccessPrograms, /*enabled*/ false);
|
||||
chat.set_feature_enabled(Feature::CliDaybreak, /*enabled*/ true);
|
||||
configured.thread_id = ThreadId::new();
|
||||
chat.handle_thread_session(configured.clone());
|
||||
|
||||
@@ -21,14 +21,19 @@ pub(crate) fn program_for_turn(
|
||||
let programs = models
|
||||
.iter()
|
||||
.find(|entry| entry.model == model)
|
||||
.and_then(|entry| entry.available_access_programs.as_ref());
|
||||
.and_then(|entry| entry.available_access_programs.as_ref())
|
||||
.filter(|programs| !programs.cyber.is_empty());
|
||||
let Some(programs) = programs else {
|
||||
// Missing discovery metadata preserves the server's automatic treatment.
|
||||
return Ok(None);
|
||||
};
|
||||
if enabled {
|
||||
programs
|
||||
.and_then(codex_protocol::openai_models::ModelAccessPrograms::daybreak)
|
||||
.daybreak()
|
||||
.map(Some)
|
||||
.ok_or_else(|| format!("Daybreak support for model {model} could not be confirmed by the connected server. Use /daybreak to turn it off, or choose a compatible model and server."))
|
||||
} else {
|
||||
Ok(programs.and_then(codex_protocol::openai_models::ModelAccessPrograms::standard))
|
||||
Ok(programs.standard())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -24,14 +24,39 @@ fn turn_program_uses_catalog_and_preserves_legacy_models() {
|
||||
Some(CyberAccessProgram::Standard),
|
||||
),
|
||||
("unlisted-model", false, None),
|
||||
("unlisted-model", true, None),
|
||||
] {
|
||||
assert_eq!(
|
||||
program_for_turn(&models, name, /*eligible_account*/ true, enabled),
|
||||
Ok(expected)
|
||||
);
|
||||
}
|
||||
for (name, eligible_account) in [("unlisted-model", true), (model.model.as_str(), false)] {
|
||||
assert!(program_for_turn(&models, name, eligible_account, /*enabled*/ true).is_err());
|
||||
assert!(
|
||||
program_for_turn(
|
||||
&models,
|
||||
&model.model,
|
||||
/*eligible_account*/ false,
|
||||
/*enabled*/ true
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
for (programs, rejects) in [
|
||||
(None, false),
|
||||
(Some(vec![]), false),
|
||||
(Some(vec![CyberAccessProgram::Standard]), true),
|
||||
] {
|
||||
model.available_access_programs = programs.map(|cyber| ModelAccessPrograms { cyber });
|
||||
let result = program_for_turn(
|
||||
std::slice::from_ref(&model),
|
||||
&model.model,
|
||||
/*eligible_account*/ true,
|
||||
/*enabled*/ true,
|
||||
);
|
||||
if rejects {
|
||||
assert!(result.is_err());
|
||||
} else {
|
||||
assert_eq!(result, Ok(None));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -566,6 +566,7 @@ async fn execute_inner(
|
||||
let cyber_access_program = background_turn_program(
|
||||
&features,
|
||||
&handle,
|
||||
&thread_id,
|
||||
&started.model,
|
||||
&started.model_provider,
|
||||
daybreak_enabled,
|
||||
@@ -717,6 +718,7 @@ async fn execute_inner(
|
||||
let cyber_access_program = background_turn_program(
|
||||
&features,
|
||||
&handle,
|
||||
&arguments.thread_id,
|
||||
arguments.model.as_deref().unwrap_or(&resumed.model),
|
||||
&resumed.model_provider,
|
||||
daybreak_enabled,
|
||||
@@ -1262,6 +1264,7 @@ async fn start_turn(
|
||||
async fn background_turn_program(
|
||||
features: &Features,
|
||||
handle: &AppServerRequestHandle,
|
||||
thread_id: &str,
|
||||
model: &str,
|
||||
provider: &str,
|
||||
enabled: bool,
|
||||
@@ -1294,12 +1297,30 @@ async fn background_turn_program(
|
||||
}
|
||||
Err(_) => None,
|
||||
};
|
||||
let has_chatgpt_account = matches!(account, Some(Account::Chatgpt { .. }));
|
||||
let eligible = provider == "openai"
|
||||
&& (has_chatgpt_account
|
||||
|| enabled
|
||||
&& matches!(account, Some(Account::ApiKey { .. }))
|
||||
&& features.enabled(Feature::ApiKeyCyberAccessPrograms));
|
||||
&& match account {
|
||||
Some(Account::Chatgpt { .. }) => true,
|
||||
Some(Account::ApiKey { .. }) => {
|
||||
// Only the target thread can establish its forwarding policy, including
|
||||
// project opt-outs. Older servers that cannot report it keep omission.
|
||||
let (tx, rx) = tokio::sync::oneshot::channel();
|
||||
crate::experimental_features::fetch(
|
||||
handle.clone(),
|
||||
Some(ThreadId::from_string(thread_id).map_err(|error| error.to_string())?),
|
||||
format!("tui-dynamic-{}", Uuid::new_v4()),
|
||||
tx,
|
||||
);
|
||||
if !rx.await.ok().and_then(Result::ok).is_some_and(|features| {
|
||||
features.into_iter().any(|feature| {
|
||||
feature.name == Feature::ApiKeyCyberAccessPrograms.key() && feature.enabled
|
||||
})
|
||||
}) {
|
||||
return Ok(None);
|
||||
}
|
||||
true
|
||||
}
|
||||
Some(Account::AmazonBedrock { .. }) | None => false,
|
||||
};
|
||||
// Retain saved metadata while suppressing it for accounts that cannot use Daybreak.
|
||||
let enabled = enabled && eligible;
|
||||
let models = models
|
||||
|
||||
@@ -19,9 +19,10 @@ use tokio::sync::oneshot;
|
||||
pub(crate) fn fetch(
|
||||
request_handle: AppServerRequestHandle,
|
||||
thread_id: Option<ThreadId>,
|
||||
request_id: &'static str,
|
||||
request_id: impl Into<String>,
|
||||
mut response_tx: oneshot::Sender<Result<Vec<ExperimentalFeature>, String>>,
|
||||
) {
|
||||
let request_id = request_id.into();
|
||||
tokio::spawn(async move {
|
||||
let discovery = async {
|
||||
let mut features = Vec::new();
|
||||
|
||||
Reference in New Issue
Block a user