Expose available access programs in model discovery (#44893)

## What changed

Carry optional `available_access_programs` metadata through model information, presets, caches, and the TUI, and expose it as `availableAccessPrograms` in app-server `model/list` responses. Update the generated JSON schemas, TypeScript types, and Python models.

Preserve the distinction between missing metadata and an empty `cyber` list. Ignore unknown cyber program names when reading the catalog so new server programs do not prevent older clients from loading it. Discovery metadata does not grant access; inference still enforces authorization.

## Testing

Add coverage for absent, null, empty, and populated metadata, unknown program names, and app-server serialization. Extend cache tests and verify that online refreshes persist changed access metadata even when the catalog ETag stays unchanged.

GitOrigin-RevId: b3ef5805c1c61b8d64b9b896c9c0a79120143667
This commit is contained in:
faizan-oai
2026-09-11 19:13:04 +00:00
committed by copyberry
parent 4dcce4f0c4
commit e3a52b87b2
31 changed files with 405 additions and 44 deletions
@@ -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": {
@@ -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": {
@@ -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": {
+6 -1
View File
@@ -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<string>, serviceTiers: Array<ModelServiceTier>,
/**
* 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, };
@@ -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<CyberAccessProgram>, };
@@ -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";
@@ -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<CyberAccessProgram>,
}
impl From<CoreModelAccessPrograms> for ModelAccessPrograms {
fn from(value: CoreModelAccessPrograms) -> Self {
Self {
cyber: value.cyber.into_iter().map(Into::into).collect(),
}
}
}
impl From<ModelAccessPrograms> 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<String>,
/// Null when the catalog does not provide access-program metadata.
#[serde(default)]
pub available_access_programs: Option<ModelAccessPrograms>,
// Only one model should be marked as default.
pub is_default: bool,
}
@@ -139,6 +139,16 @@ impl From<CyberAccessProgram> for CoreCyberAccessProgram {
}
}
impl From<CoreCyberAccessProgram> 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/")]
+1
View File
@@ -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,
}
}
@@ -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,
@@ -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::<ModelInfo>(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::<Result<Vec<_>, _>>()?;
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::<ModelInfo>(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::<Result<Vec<_>, _>>()?;
// 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<_>>(),
vec![
json!({ "cyber": ["standard", "daybreakBlue"] }),
json!({ "cyber": ["daybreakRed"] }),
json!({ "cyber": [] }),
json!(null),
]
);
let ModelListResponse {
data: items,
next_cursor,
@@ -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,
@@ -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,
+1
View File
@@ -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,
@@ -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,
@@ -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,
+2
View File
@@ -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,
@@ -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,
+1
View File
@@ -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,
@@ -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,
+1
View File
@@ -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,
@@ -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),
});
+81 -6
View File
@@ -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<VecDeque<Vec<ModelInfo>>>,
etag: Option<String>,
fetch_count: AtomicUsize,
observed_proxy_policy: Mutex<Option<OutboundProxyPolicy>>,
}
@@ -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)];
@@ -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)),
+43
View File
@@ -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<String>,
/// Caller-specific explicit access programs, when discovery resolved them.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub available_access_programs: Option<ModelAccessPrograms>,
/// 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<ModelServiceTier>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub default_service_tier: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub available_access_programs: Option<ModelAccessPrograms>,
pub availability_nux: Option<ModelAvailabilityNux>,
pub upgrade: Option<ModelInfoUpgrade>,
#[serde(default, skip_serializing_if = "Option::is_none")]
@@ -883,6 +890,7 @@ impl From<ModelInfo> 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 {
@@ -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<CyberAccessProgram>,
}
fn deserialize_known_cyber_access_programs<'de, D>(
deserializer: D,
) -> Result<Vec<CyberAccessProgram>, D::Error>
where
D: Deserializer<'de>,
{
// New server programs must not prevent older clients from loading the catalog.
Ok(Vec::<String>::deserialize(deserializer)?
.into_iter()
.filter_map(|program| serde_json::from_value(serde_json::Value::String(program)).ok())
.collect())
}
+2
View File
@@ -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,
}
}
@@ -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,
@@ -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,