mirror of
https://github.com/openai/codex.git
synced 2026-10-20 00:18:49 +00:00
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:
+28
@@ -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": {
|
||||
|
||||
+28
@@ -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": {
|
||||
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
+6
-1
@@ -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/")]
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
|
||||
@@ -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)),
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user