diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json index 30e8ac86b1..ec5053bc3d 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json @@ -15110,6 +15110,18 @@ } ] }, + "availableAccessPrograms": { + "anyOf": [ + { + "$ref": "#/definitions/v2/ModelAccessPrograms" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Null when the catalog does not provide access-program metadata." + }, "defaultReasoningEffort": { "$ref": "#/definitions/v2/ReasoningEffort" }, @@ -15213,6 +15225,22 @@ ], "type": "object" }, + "ModelAccessPrograms": { + "description": "Caller-specific explicit access programs advertised by model discovery.", + "properties": { + "cyber": { + "description": "Accepted explicit selections.", + "items": { + "$ref": "#/definitions/v2/CyberAccessProgram" + }, + "type": "array" + } + }, + "required": [ + "cyber" + ], + "type": "object" + }, "ModelAvailabilityNux": { "properties": { "message": { diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json index e74feb1665..61cf3b0eba 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json @@ -11162,6 +11162,18 @@ } ] }, + "availableAccessPrograms": { + "anyOf": [ + { + "$ref": "#/definitions/ModelAccessPrograms" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Null when the catalog does not provide access-program metadata." + }, "defaultReasoningEffort": { "$ref": "#/definitions/ReasoningEffort" }, @@ -11265,6 +11277,22 @@ ], "type": "object" }, + "ModelAccessPrograms": { + "description": "Caller-specific explicit access programs advertised by model discovery.", + "properties": { + "cyber": { + "description": "Accepted explicit selections.", + "items": { + "$ref": "#/definitions/CyberAccessProgram" + }, + "type": "array" + } + }, + "required": [ + "cyber" + ], + "type": "object" + }, "ModelAvailabilityNux": { "properties": { "message": { diff --git a/codex-rs/app-server-protocol/schema/json/v2/ModelListResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ModelListResponse.json index 657a433f88..3868cb9341 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ModelListResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ModelListResponse.json @@ -1,6 +1,15 @@ { "$schema": "http://json-schema.org/draft-07/schema#", "definitions": { + "CyberAccessProgram": { + "description": "Requested cyber treatment for a ChatGPT-authenticated Codex turn. Authorization and model-tier restrictions remain server-owned.", + "enum": [ + "standard", + "daybreakBlue", + "daybreakRed" + ], + "type": "string" + }, "InputModality": { "description": "Canonical user-input modality tags advertised by a model.", "oneOf": [ @@ -47,6 +56,18 @@ } ] }, + "availableAccessPrograms": { + "anyOf": [ + { + "$ref": "#/definitions/ModelAccessPrograms" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Null when the catalog does not provide access-program metadata." + }, "defaultReasoningEffort": { "$ref": "#/definitions/ReasoningEffort" }, @@ -150,6 +171,22 @@ ], "type": "object" }, + "ModelAccessPrograms": { + "description": "Caller-specific explicit access programs advertised by model discovery.", + "properties": { + "cyber": { + "description": "Accepted explicit selections.", + "items": { + "$ref": "#/definitions/CyberAccessProgram" + }, + "type": "array" + } + }, + "required": [ + "cyber" + ], + "type": "object" + }, "ModelAvailabilityNux": { "properties": { "message": { diff --git a/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-experimental.json.zst b/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-experimental.json.zst index b4ac7f5b02..963ea9088e 100644 Binary files a/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-experimental.json.zst and b/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-experimental.json.zst differ diff --git a/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-stable.json.zst b/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-stable.json.zst index b80ae23637..0c24ab3b61 100644 Binary files a/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-stable.json.zst and b/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-stable.json.zst differ diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/Model.ts b/codex-rs/app-server-protocol/schema/typescript/v2/Model.ts index 29defcdb3d..9508b3480c 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/Model.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/Model.ts @@ -3,6 +3,7 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { InputModality } from "../InputModality"; import type { ReasoningEffort } from "../ReasoningEffort"; +import type { ModelAccessPrograms } from "./ModelAccessPrograms"; import type { ModelAvailabilityNux } from "./ModelAvailabilityNux"; import type { ModelServiceTier } from "./ModelServiceTier"; import type { ModelUpgradeInfo } from "./ModelUpgradeInfo"; @@ -21,4 +22,8 @@ additionalSpeedTiers: Array, serviceTiers: Array, /** * Catalog default service tier id for this model, when one is configured. */ -defaultServiceTier: string | null, isDefault: boolean, }; +defaultServiceTier: string | null, +/** + * Null when the catalog does not provide access-program metadata. + */ +availableAccessPrograms: ModelAccessPrograms | null, isDefault: boolean, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ModelAccessPrograms.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ModelAccessPrograms.ts new file mode 100644 index 0000000000..6df203a476 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ModelAccessPrograms.ts @@ -0,0 +1,13 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { CyberAccessProgram } from "./CyberAccessProgram"; + +/** + * Caller-specific explicit access programs advertised by model discovery. + */ +export type ModelAccessPrograms = { +/** + * Accepted explicit selections. + */ +cyber: Array, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts index e3df4541a4..a537a41004 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts @@ -306,6 +306,7 @@ export type { MigrationDetails } from "./MigrationDetails"; export type { MisalignmentErrorDetails } from "./MisalignmentErrorDetails"; export type { MisalignmentSteer } from "./MisalignmentSteer"; export type { Model } from "./Model"; +export type { ModelAccessPrograms } from "./ModelAccessPrograms"; export type { ModelAvailabilityNux } from "./ModelAvailabilityNux"; export type { ModelListParams } from "./ModelListParams"; export type { ModelListResponse } from "./ModelListResponse"; diff --git a/codex-rs/app-server-protocol/src/protocol/v2/model.rs b/codex-rs/app-server-protocol/src/protocol/v2/model.rs index f59451e3ac..0bf6de04e0 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/model.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/model.rs @@ -1,7 +1,9 @@ use super::shared::v2_enum_from_core; +use super::turn::CyberAccessProgram; use crate::JsonSchema; use crate::TS; use codex_protocol::openai_models::InputModality; +use codex_protocol::openai_models::ModelAccessPrograms as CoreModelAccessPrograms; use codex_protocol::openai_models::ModelAvailabilityNux as CoreModelAvailabilityNux; use codex_protocol::openai_models::ReasoningEffort; use codex_protocol::openai_models::default_input_modalities; @@ -86,6 +88,31 @@ pub struct ModelServiceTier { pub description: String, } +/// Caller-specific explicit access programs advertised by model discovery. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ModelAccessPrograms { + /// Accepted explicit selections. + pub cyber: Vec, +} + +impl From for ModelAccessPrograms { + fn from(value: CoreModelAccessPrograms) -> Self { + Self { + cyber: value.cyber.into_iter().map(Into::into).collect(), + } + } +} + +impl From for CoreModelAccessPrograms { + fn from(value: ModelAccessPrograms) -> Self { + Self { + cyber: value.cyber.into_iter().map(Into::into).collect(), + } + } +} + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] @@ -116,6 +143,9 @@ pub struct Model { /// Catalog default service tier id for this model, when one is configured. #[serde(default)] pub default_service_tier: Option, + /// Null when the catalog does not provide access-program metadata. + #[serde(default)] + pub available_access_programs: Option, // Only one model should be marked as default. pub is_default: bool, } diff --git a/codex-rs/app-server-protocol/src/protocol/v2/turn.rs b/codex-rs/app-server-protocol/src/protocol/v2/turn.rs index 9629a78410..394dec951c 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/turn.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/turn.rs @@ -139,6 +139,16 @@ impl From for CoreCyberAccessProgram { } } +impl From for CyberAccessProgram { + fn from(value: CoreCyberAccessProgram) -> Self { + match value { + CoreCyberAccessProgram::Standard => Self::Standard, + CoreCyberAccessProgram::DaybreakBlue => Self::DaybreakBlue, + CoreCyberAccessProgram::DaybreakRed => Self::DaybreakRed, + } + } +} + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] diff --git a/codex-rs/app-server/src/models.rs b/codex-rs/app-server/src/models.rs index 1063e64c33..b42c8bccaf 100644 --- a/codex-rs/app-server/src/models.rs +++ b/codex-rs/app-server/src/models.rs @@ -62,6 +62,7 @@ fn model_from_preset(preset: ModelPreset) -> Model { }) .collect(), default_service_tier: preset.default_service_tier, + available_access_programs: preset.available_access_programs.map(Into::into), is_default: preset.is_default, } } diff --git a/codex-rs/app-server/tests/common/models_cache.rs b/codex-rs/app-server/tests/common/models_cache.rs index 6f44943e3d..e00e269222 100644 --- a/codex-rs/app-server/tests/common/models_cache.rs +++ b/codex-rs/app-server/tests/common/models_cache.rs @@ -28,6 +28,7 @@ fn preset_to_info(preset: &ModelPreset, priority: i32) -> ModelInfo { additional_speed_tiers: preset.additional_speed_tiers.clone(), service_tiers: preset.service_tiers.clone(), default_service_tier: preset.default_service_tier.clone(), + available_access_programs: preset.available_access_programs.clone(), upgrade: preset.upgrade.as_ref().map(Into::into), model_messages: Some(ModelMessages { persistent_instructions: None, diff --git a/codex-rs/app-server/tests/suite/v2/model_list.rs b/codex-rs/app-server/tests/suite/v2/model_list.rs index d01454a356..217f0cafa7 100644 --- a/codex-rs/app-server/tests/suite/v2/model_list.rs +++ b/codex-rs/app-server/tests/suite/v2/model_list.rs @@ -188,6 +188,7 @@ fn model_from_preset(preset: &ModelPreset) -> Model { }) .collect(), default_service_tier: preset.default_service_tier.clone(), + available_access_programs: preset.available_access_programs.clone().map(Into::into), is_default: preset.is_default, } } @@ -275,43 +276,52 @@ async fn list_models_uses_remote_catalog_as_source_of_truth( api_key: Option<&str>, ) -> Result<()> { let server = MockServer::start().await; - let remote_models = [json!("2030-01-01T00:00:00Z"), serde_json::Value::Null] - .into_iter() - .enumerate() - .map(|(priority, retirement_at)| { - serde_json::from_value::(json!({ - "slug": format!("remote-only-{priority}"), - "display_name": "Remote Only", - "description": "Remote-only model for app-server model/list coverage", - "model_specialty": MODEL_SPECIALTY_CYBER, - "default_reasoning_level": "max", - "supported_reasoning_levels": [ - {"effort": "max", "description": "Maximum"}, - {"effort": "low", "description": "Low"}, - {"effort": "focused", "description": "Focused"} - ], - "shell_type": "shell_command", - "visibility": "list", - "minimal_client_version": [0, 1, 0], - "supported_in_api": true, - "priority": priority, - "upgrade": { - "model": "replacement-model", - "migration_markdown": "Use the replacement model.", - "retirement_at": retirement_at, - }, - "support_verbosity": false, - "default_verbosity": null, - "apply_patch_tool_type": null, - "truncation_policy": {"mode": "bytes", "limit": 10_000}, - "supports_image_detail_original": false, - "multi_agent_version": "v2", - "context_window": 272_000, - "max_context_window": 272_000, - "experimental_supported_tools": [], - })) - }) - .collect::, _>>()?; + let remote_models = [ + ( + json!("2030-01-01T00:00:00Z"), + json!({ "cyber": ["standard", "daybreak_blue"] }), + ), + (json!(null), json!({ "cyber": ["daybreak_red"] })), + (json!(null), json!({ "cyber": [] })), + (json!(null), json!(null)), + ] + .into_iter() + .enumerate() + .map(|(priority, (retirement_at, access_programs))| { + serde_json::from_value::(json!({ + "slug": format!("remote-only-{priority}"), + "display_name": "Remote Only", + "description": "Remote-only model for app-server model/list coverage", + "model_specialty": MODEL_SPECIALTY_CYBER, + "available_access_programs": access_programs, + "default_reasoning_level": "max", + "supported_reasoning_levels": [ + {"effort": "max", "description": "Maximum"}, + {"effort": "low", "description": "Low"}, + {"effort": "focused", "description": "Focused"} + ], + "shell_type": "shell_command", + "visibility": "list", + "minimal_client_version": [0, 1, 0], + "supported_in_api": true, + "priority": priority, + "upgrade": { + "model": "replacement-model", + "migration_markdown": "Use the replacement model.", + "retirement_at": retirement_at, + }, + "support_verbosity": false, + "default_verbosity": null, + "apply_patch_tool_type": null, + "truncation_policy": {"mode": "bytes", "limit": 10_000}, + "supports_image_detail_original": false, + "multi_agent_version": "v2", + "context_window": 272_000, + "max_context_window": 272_000, + "experimental_supported_tools": [], + })) + }) + .collect::, _>>()?; // The startup refresh worker and model/list can both fetch before the cache is populated. let _models_mock = Mock::given(method("GET")) .and(path("/v1/models")) @@ -379,6 +389,20 @@ api_key_model_discovery = true response.result["data"][1]["upgradeInfo"]["retirementAt"], serde_json::Value::Null ); + assert_eq!( + response.result["data"] + .as_array() + .expect("model/list data should be an array") + .iter() + .map(|model| model["availableAccessPrograms"].clone()) + .collect::>(), + vec![ + json!({ "cyber": ["standard", "daybreakBlue"] }), + json!({ "cyber": ["daybreakRed"] }), + json!({ "cyber": [] }), + json!(null), + ] + ); let ModelListResponse { data: items, next_cursor, diff --git a/codex-rs/codex-api/tests/models_integration.rs b/codex-rs/codex-api/tests/models_integration.rs index eb3ae763e7..297925c31d 100644 --- a/codex-rs/codex-api/tests/models_integration.rs +++ b/codex-rs/codex-api/tests/models_integration.rs @@ -78,6 +78,7 @@ async fn models_client_hits_models_endpoint() { additional_speed_tiers: Vec::new(), service_tiers: Vec::new(), default_service_tier: None, + available_access_programs: None, upgrade: None, model_messages: None, include_skills_usage_instructions: false, diff --git a/codex-rs/core/src/tools/handlers/multi_agents_spec_tests.rs b/codex-rs/core/src/tools/handlers/multi_agents_spec_tests.rs index 9e100aa3b7..e789a55da8 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_spec_tests.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_spec_tests.rs @@ -28,6 +28,7 @@ fn model_preset(id: &str, show_in_picker: bool) -> ModelPreset { description: "1.5x speed, increased usage".to_string(), }], default_service_tier: None, + available_access_programs: None, is_default: false, upgrade: None, show_in_picker, diff --git a/codex-rs/core/tests/suite/auto_review.rs b/codex-rs/core/tests/suite/auto_review.rs index e529144589..7f6b1a6eb2 100644 --- a/codex-rs/core/tests/suite/auto_review.rs +++ b/codex-rs/core/tests/suite/auto_review.rs @@ -626,6 +626,7 @@ fn remote_model_with_auto_review_override(slug: &str, review_model: &str) -> Mod additional_speed_tiers: Vec::new(), service_tiers: Vec::new(), default_service_tier: None, + available_access_programs: None, upgrade: None, model_messages: None, include_skills_usage_instructions: false, diff --git a/codex-rs/core/tests/suite/model_switching.rs b/codex-rs/core/tests/suite/model_switching.rs index 7ffaf90347..7338b7b437 100644 --- a/codex-rs/core/tests/suite/model_switching.rs +++ b/codex-rs/core/tests/suite/model_switching.rs @@ -143,6 +143,7 @@ fn test_model_info( additional_speed_tiers: Vec::new(), service_tiers: Vec::new(), default_service_tier: None, + available_access_programs: None, upgrade: None, model_messages: None, include_skills_usage_instructions: false, @@ -1509,6 +1510,7 @@ async fn model_switch_to_smaller_model_updates_token_context_window() -> Result< additional_speed_tiers: Vec::new(), service_tiers: Vec::new(), default_service_tier: None, + available_access_programs: None, upgrade: None, model_messages: None, include_skills_usage_instructions: false, diff --git a/codex-rs/core/tests/suite/models_cache_ttl.rs b/codex-rs/core/tests/suite/models_cache_ttl.rs index 7a3fa935ba..a50a509766 100644 --- a/codex-rs/core/tests/suite/models_cache_ttl.rs +++ b/codex-rs/core/tests/suite/models_cache_ttl.rs @@ -534,6 +534,7 @@ fn test_remote_model(slug: &str, priority: i32) -> ModelInfo { additional_speed_tiers: Vec::new(), service_tiers: Vec::new(), default_service_tier: None, + available_access_programs: None, upgrade: None, model_messages: Some(ModelMessages { persistent_instructions: None, diff --git a/codex-rs/core/tests/suite/personality.rs b/codex-rs/core/tests/suite/personality.rs index 01feebe0e0..8ed78070bc 100644 --- a/codex-rs/core/tests/suite/personality.rs +++ b/codex-rs/core/tests/suite/personality.rs @@ -633,6 +633,7 @@ async fn remote_model_friendly_personality_instructions_with_feature() -> anyhow additional_speed_tiers: Vec::new(), service_tiers: Vec::new(), default_service_tier: None, + available_access_programs: None, upgrade: None, model_messages: Some(ModelMessages { persistent_instructions: None, @@ -765,6 +766,7 @@ async fn user_turn_personality_remote_model_template_includes_update_message() - additional_speed_tiers: Vec::new(), service_tiers: Vec::new(), default_service_tier: None, + available_access_programs: None, upgrade: None, model_messages: Some(ModelMessages { persistent_instructions: None, diff --git a/codex-rs/core/tests/suite/remote_models.rs b/codex-rs/core/tests/suite/remote_models.rs index a8e7a41535..f83623b2c0 100644 --- a/codex-rs/core/tests/suite/remote_models.rs +++ b/codex-rs/core/tests/suite/remote_models.rs @@ -624,6 +624,7 @@ async fn remote_models_remote_model_uses_unified_exec() -> Result<()> { additional_speed_tiers: Vec::new(), service_tiers: Vec::new(), default_service_tier: None, + available_access_programs: None, upgrade: None, model_messages: None, include_skills_usage_instructions: false, @@ -897,6 +898,7 @@ async fn remote_models_apply_legacy_instructions(auth: CodexAuth) -> Result<()> additional_speed_tiers: Vec::new(), service_tiers: Vec::new(), default_service_tier: None, + available_access_programs: None, upgrade: None, model_messages: Some(ModelMessages { persistent_instructions: None, @@ -1488,6 +1490,7 @@ fn test_remote_model_with_policy( additional_speed_tiers: Vec::new(), service_tiers: Vec::new(), default_service_tier: None, + available_access_programs: None, upgrade: None, model_messages: None, include_skills_usage_instructions: false, diff --git a/codex-rs/core/tests/suite/rmcp_client.rs b/codex-rs/core/tests/suite/rmcp_client.rs index 93765c59ca..78999fca06 100644 --- a/codex-rs/core/tests/suite/rmcp_client.rs +++ b/codex-rs/core/tests/suite/rmcp_client.rs @@ -2815,6 +2815,7 @@ async fn stdio_image_responses_are_sanitized_for_text_only_model() -> anyhow::Re additional_speed_tiers: Vec::new(), service_tiers: Vec::new(), default_service_tier: None, + available_access_programs: None, upgrade: None, model_messages: None, include_skills_usage_instructions: false, diff --git a/codex-rs/core/tests/suite/spawn_agent_description.rs b/codex-rs/core/tests/suite/spawn_agent_description.rs index 5d451683b7..2df387602f 100644 --- a/codex-rs/core/tests/suite/spawn_agent_description.rs +++ b/codex-rs/core/tests/suite/spawn_agent_description.rs @@ -104,6 +104,7 @@ fn test_model_info( additional_speed_tiers: Vec::new(), service_tiers, default_service_tier: None, + available_access_programs: None, upgrade: None, model_messages: None, include_skills_usage_instructions: false, diff --git a/codex-rs/core/tests/suite/view_image.rs b/codex-rs/core/tests/suite/view_image.rs index 0256da4ec2..e29c41d6cd 100644 --- a/codex-rs/core/tests/suite/view_image.rs +++ b/codex-rs/core/tests/suite/view_image.rs @@ -1636,6 +1636,7 @@ async fn view_image_tool_returns_unsupported_message_for_text_only_model() -> an additional_speed_tiers: Vec::new(), service_tiers: Vec::new(), default_service_tier: None, + available_access_programs: None, upgrade: None, model_messages: None, include_skills_usage_instructions: false, diff --git a/codex-rs/models-manager/src/api_key_discovery_tests.rs b/codex-rs/models-manager/src/api_key_discovery_tests.rs index f0c62fae08..6cda6088f4 100644 --- a/codex-rs/models-manager/src/api_key_discovery_tests.rs +++ b/codex-rs/models-manager/src/api_key_discovery_tests.rs @@ -14,6 +14,7 @@ async fn api_key_discovery_disabled_preserves_command_auth_discovery_and_merging has_command_auth: true, uses_codex_backend: false, responses: Mutex::new(vec![models.clone()].into()), + etag: None, fetch_count: AtomicUsize::new(0), observed_proxy_policy: Mutex::new(None), }); diff --git a/codex-rs/models-manager/src/manager_tests.rs b/codex-rs/models-manager/src/manager_tests.rs index 1408ad8928..4ef297142a 100644 --- a/codex-rs/models-manager/src/manager_tests.rs +++ b/codex-rs/models-manager/src/manager_tests.rs @@ -16,7 +16,9 @@ use codex_login::ExternalAuth; use codex_login::ExternalAuthRefreshContext; use codex_login::TokenData; use codex_protocol::auth::AuthMode; +use codex_protocol::openai_models::ModelAccessPrograms; use codex_protocol::openai_models::ModelsResponse; +use codex_protocol::turn_input::CyberAccessProgram; use pretty_assertions::assert_eq; use serde_json::json; use std::collections::VecDeque; @@ -95,6 +97,7 @@ struct TestModelsEndpoint { has_command_auth: bool, uses_codex_backend: bool, responses: Mutex>>, + etag: Option, fetch_count: AtomicUsize, observed_proxy_policy: Mutex>, } @@ -196,6 +199,7 @@ impl TestModelsEndpoint { has_command_auth: false, uses_codex_backend: true, responses: Mutex::new(responses.into()), + etag: None, fetch_count: AtomicUsize::new(0), observed_proxy_policy: Mutex::new(None), }) @@ -206,6 +210,7 @@ impl TestModelsEndpoint { has_command_auth: false, uses_codex_backend: false, responses: Mutex::new(responses.into()), + etag: None, fetch_count: AtomicUsize::new(0), observed_proxy_policy: Mutex::new(None), }) @@ -232,7 +237,7 @@ impl TestModelsEndpoint { .unwrap_or_default(); Ok(ModelsEndpointResponse { models, - etag: None, + etag: self.etag.clone(), identity: self.identity().expect("test endpoint identity"), }) } @@ -356,11 +361,15 @@ async fn file_cache_implements_models_cache_contract() { fetched_at: Utc::now(), etag: Some("file-etag".to_string()), client_version: Some(client_version.clone()), - models: vec![remote_model( - "file-cached", - "File Cached", - /*priority*/ 0, - )], + models: vec![ModelInfo { + available_access_programs: Some(ModelAccessPrograms { + cyber: vec![ + CyberAccessProgram::Standard, + CyberAccessProgram::DaybreakBlue, + ], + }), + ..remote_model("file-cached", "File Cached", /*priority*/ 0) + }], }; cache.store(&entry).await.expect("cache store succeeds"); @@ -1044,6 +1053,7 @@ async fn refresh_available_models_keeps_merging_for_custom_api_auth() { has_command_auth: true, uses_codex_backend: false, responses: Mutex::new(vec![remote_models.clone()].into()), + etag: None, fetch_count: AtomicUsize::new(0), observed_proxy_policy: Mutex::new(None), }); @@ -1101,6 +1111,71 @@ async fn refresh_available_models_uses_cache_when_fresh() { ); } +#[tokio::test] +async fn online_refresh_updates_access_programs_with_unchanged_etag() { + let codex_home = tempdir().expect("temp dir"); + let granted_model = ModelInfo { + available_access_programs: Some(ModelAccessPrograms { + cyber: vec![ + CyberAccessProgram::Standard, + CyberAccessProgram::DaybreakBlue, + ], + }), + ..remote_model("access-programs", "Access Programs", /*priority*/ 0) + }; + let revoked_model = ModelInfo { + available_access_programs: Some(ModelAccessPrograms { cyber: Vec::new() }), + ..granted_model.clone() + }; + let responses = vec![vec![granted_model.clone()], vec![revoked_model.clone()]]; + let endpoint = Arc::new(TestModelsEndpoint { + has_command_auth: false, + uses_codex_backend: true, + responses: Mutex::new(responses.into()), + etag: Some("stable-catalog-etag".to_string()), + fetch_count: AtomicUsize::new(0), + observed_proxy_policy: Mutex::new(None), + }); + let manager = openai_manager_for_tests(codex_home.path().to_path_buf(), endpoint.clone()); + + // Caller-specific access can change without changing the catalog ETag. + for model in [granted_model, revoked_model] { + let mut expected = ModelPreset::from(model.clone()); + expected.is_default = true; + assert_eq!( + manager + .list_models(RefreshStrategy::Online, DEFAULT_HTTP_CLIENT_FACTORY) + .await, + vec![expected.clone()] + ); + assert_eq!(manager.get_remote_models().await, vec![model]); + assert_eq!( + manager.remote_models.read().await.etag.as_deref(), + Some("stable-catalog-etag") + ); + + // A new manager must read the latest access metadata from the disk cache. + let cache_endpoint = TestModelsEndpoint::new(Vec::new()); + let cache_manager = + openai_manager_for_tests(codex_home.path().to_path_buf(), cache_endpoint.clone()); + assert_eq!( + cache_manager + .list_models( + RefreshStrategy::OnlineIfUncached, + DEFAULT_HTTP_CLIENT_FACTORY + ) + .await, + vec![expected] + ); + assert_eq!(cache_endpoint.fetch_count(), 0); + assert_eq!( + cache_manager.remote_models.read().await.etag.as_deref(), + Some("stable-catalog-etag") + ); + } + assert_eq!(endpoint.fetch_count(), 2); +} + #[tokio::test] async fn refresh_available_models_refetches_when_cache_stale() { let initial_models = vec![remote_model("stale", "Stale", /*priority*/ 1)]; diff --git a/codex-rs/models-manager/src/model_info.rs b/codex-rs/models-manager/src/model_info.rs index ca10069931..fe5e055d9b 100644 --- a/codex-rs/models-manager/src/model_info.rs +++ b/codex-rs/models-manager/src/model_info.rs @@ -155,6 +155,7 @@ pub fn model_info_from_slug(slug: &str) -> ModelInfo { additional_speed_tiers: Vec::new(), service_tiers: Vec::new(), default_service_tier: None, + available_access_programs: None, availability_nux: None, upgrade: None, model_messages: Some(local_model_messages_for_slug(slug)), diff --git a/codex-rs/protocol/src/openai_models.rs b/codex-rs/protocol/src/openai_models.rs index 8c021b17c2..e54d5df755 100644 --- a/codex-rs/protocol/src/openai_models.rs +++ b/codex-rs/protocol/src/openai_models.rs @@ -33,6 +33,7 @@ use crate::config_types::ServiceTier; use crate::config_types::Verbosity; use crate::protocol::MultiAgentVersion; +mod access_programs; #[path = "openai_models/guardian.rs"] mod guardian; pub use guardian::GuardianModelPolicy; @@ -44,6 +45,7 @@ mod guardian_v2; #[path = "openai_models/reasoning_effort.rs"] mod reasoning_effort; +pub use access_programs::ModelAccessPrograms; pub use guardian_v2::GuardianV2ModelConfig; pub use guardian_v2::GuardianV2TranscriptModelConfig; @@ -258,6 +260,9 @@ pub struct ModelPreset { /// Catalog default service tier id for this model. #[serde(default, skip_serializing_if = "Option::is_none")] pub default_service_tier: Option, + /// Caller-specific explicit access programs, when discovery resolved them. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub available_access_programs: Option, /// Whether this is the default model for new users. pub is_default: bool, /// recommended upgrade model @@ -419,6 +424,8 @@ pub struct ModelInfo { pub service_tiers: Vec, #[serde(default, skip_serializing_if = "Option::is_none")] pub default_service_tier: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub available_access_programs: Option, pub availability_nux: Option, pub upgrade: Option, #[serde(default, skip_serializing_if = "Option::is_none")] @@ -883,6 +890,7 @@ impl From for ModelPreset { additional_speed_tiers: info.additional_speed_tiers, service_tiers: info.service_tiers, default_service_tier: info.default_service_tier, + available_access_programs: info.available_access_programs, is_default: false, // default is the highest priority available model upgrade: info.upgrade.as_ref().map(|upgrade| ModelUpgrade { id: upgrade.model.clone(), @@ -991,6 +999,7 @@ mod tests { additional_speed_tiers: Vec::new(), service_tiers: Vec::new(), default_service_tier: None, + available_access_programs: None, availability_nux: None, upgrade: None, model_messages: spec, @@ -2035,6 +2044,40 @@ mod tests { ); } + #[test] + fn model_access_programs_preserve_explicit_selection_availability() { + use crate::turn_input::CyberAccessProgram; + + for (metadata, expected) in [ + (None, None), + (Some(serde_json::Value::Null), None), + ( + Some(serde_json::json!({ "cyber": [] })), + Some(ModelAccessPrograms { cyber: vec![] }), + ), + ( + Some(serde_json::json!({ + "cyber": ["standard", "daybreak_blue", "future_program", "daybreak_red"] + })), + Some(ModelAccessPrograms { + cyber: vec![ + CyberAccessProgram::Standard, + CyberAccessProgram::DaybreakBlue, + CyberAccessProgram::DaybreakRed, + ], + }), + ), + ] { + let mut value = serde_json::to_value(test_model(/*spec*/ None)).unwrap(); + if let Some(metadata) = metadata { + value["available_access_programs"] = metadata; + } + let model: ModelInfo = serde_json::from_value(value).unwrap(); + assert_eq!(model.available_access_programs, expected); + assert_eq!(ModelPreset::from(model).available_access_programs, expected); + } + } + #[test] fn model_preset_supports_fast_mode_from_service_tiers() { let preset = ModelPreset::from(ModelInfo { diff --git a/codex-rs/protocol/src/openai_models/access_programs.rs b/codex-rs/protocol/src/openai_models/access_programs.rs new file mode 100644 index 0000000000..9a0571448d --- /dev/null +++ b/codex-rs/protocol/src/openai_models/access_programs.rs @@ -0,0 +1,31 @@ +//! Caller-specific explicit access programs advertised by the model catalog. +//! +//! Discovery metadata does not grant access; inference still enforces authorization. + +use schemars::JsonSchema; +use serde::Deserialize; +use serde::Deserializer; +use serde::Serialize; +use ts_rs::TS; + +use crate::turn_input::CyberAccessProgram; + +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq, TS, JsonSchema)] +pub struct ModelAccessPrograms { + /// Accepted explicit selections. An empty list is distinct from missing metadata. + #[serde(deserialize_with = "deserialize_known_cyber_access_programs")] + pub cyber: Vec, +} + +fn deserialize_known_cyber_access_programs<'de, D>( + deserializer: D, +) -> Result, D::Error> +where + D: Deserializer<'de>, +{ + // New server programs must not prevent older clients from loading the catalog. + Ok(Vec::::deserialize(deserializer)? + .into_iter() + .filter_map(|program| serde_json::from_value(serde_json::Value::String(program)).ok()) + .collect()) +} diff --git a/codex-rs/tui/src/app_server_session.rs b/codex-rs/tui/src/app_server_session.rs index 1d25109f72..cd0d3ea36b 100644 --- a/codex-rs/tui/src/app_server_session.rs +++ b/codex-rs/tui/src/app_server_session.rs @@ -1803,6 +1803,7 @@ fn model_preset_from_api_model(model: ApiModel) -> ModelPreset { }) .collect(), default_service_tier: model.default_service_tier, + available_access_programs: model.available_access_programs.map(Into::into), is_default: model.is_default, upgrade, show_in_picker: !model.hidden, @@ -2643,6 +2644,7 @@ mod tests { additional_speed_tiers: Vec::new(), service_tiers: Vec::new(), default_service_tier: None, + available_access_programs: None, is_default: false, } } diff --git a/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs b/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs index 17ae637c2c..554fa3532d 100644 --- a/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs +++ b/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs @@ -3628,6 +3628,7 @@ async fn model_picker_hides_show_in_picker_false_models_from_cache() { additional_speed_tiers: Vec::new(), service_tiers: Vec::new(), default_service_tier: None, + available_access_programs: None, is_default: false, upgrade: None, show_in_picker, @@ -4112,6 +4113,7 @@ async fn single_reasoning_option_skips_selection() { additional_speed_tiers: Vec::new(), service_tiers: Vec::new(), default_service_tier: None, + available_access_programs: None, is_default: false, upgrade: None, show_in_picker: true, diff --git a/sdk/python/src/openai_codex/generated/v2_all.py b/sdk/python/src/openai_codex/generated/v2_all.py index 8d33a27c78..cc90634fd2 100644 --- a/sdk/python/src/openai_codex/generated/v2_all.py +++ b/sdk/python/src/openai_codex/generated/v2_all.py @@ -2700,6 +2700,13 @@ class ModeKind(Enum): default = "default" +class ModelAccessPrograms(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + cyber: Annotated[list[CyberAccessProgram], Field(description="Accepted explicit selections.")] + + class ModelAvailabilityNux(BaseModel): model_config = ConfigDict( populate_by_name=True, @@ -8415,6 +8422,13 @@ class Model(BaseModel): Field(alias="additionalSpeedTiers", description="Deprecated: use `serviceTiers` instead."), ] = [] availability_nux: Annotated[ModelAvailabilityNux | None, Field(alias="availabilityNux")] = None + available_access_programs: Annotated[ + ModelAccessPrograms | None, + Field( + alias="availableAccessPrograms", + description="Null when the catalog does not provide access-program metadata.", + ), + ] = None default_reasoning_effort: Annotated[ReasoningEffort, Field(alias="defaultReasoningEffort")] default_service_tier: Annotated[ str | None,