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 authored and copyberry committed 2026-09-11 19:13:04 +00:00
1 parent 4dcce4f0c4
commit e3a52b87b2
31 files changed
+405 -44

No files matched your search

@@ -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,