Allow selecting a configured provider in model/list (#59776)

## Why

Clients need to list models for a configured provider other than the app-server's startup default.

## What changed

Add an optional `modelProvider` parameter to `model/list` and expose it in the generated schemas and client types. Explicit selections use provider definitions loaded at startup and respect current managed provider requirements. Unknown providers return a structured `modelProviderNotConfigured` error.

Use a temporary catalog when selecting another provider, preserving the default catalog and its disk cache while retaining runtime authentication. Requests without a selector keep the existing provider and caching behavior.

## Testing

Add coverage for provider selection, preservation of the default catalog and disk cache, unknown-provider errors, managed requirement enforcement, and runtime authentication for temporary catalogs.

GitOrigin-RevId: 6092df9f6141810c6ac215a8e1fb601f77e54747
This commit is contained in:
Abhinav
2026-11-06 01:10:49 +00:00
committed by copyberry
parent 73b1a3bc85
commit daa69c411c
24 changed files with 276 additions and 31 deletions
@@ -2459,6 +2459,13 @@
"integer",
"null"
]
},
"modelProvider": {
"description": "List models for this configured provider instead of the app-server default.",
"type": [
"string",
"null"
]
}
},
"type": "object"
@@ -16270,6 +16270,13 @@
"integer",
"null"
]
},
"modelProvider": {
"description": "List models for this configured provider instead of the app-server default.",
"type": [
"string",
"null"
]
}
},
"title": "ModelListParams",
@@ -12231,6 +12231,13 @@
"integer",
"null"
]
},
"modelProvider": {
"description": "List models for this configured provider instead of the app-server default.",
"type": [
"string",
"null"
]
}
},
"title": "ModelListParams",
@@ -23,6 +23,13 @@
"integer",
"null"
]
},
"modelProvider": {
"description": "List models for this configured provider instead of the app-server default.",
"type": [
"string",
"null"
]
}
},
"title": "ModelListParams",
@@ -3,6 +3,10 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type ModelListParams = {
/**
* List models for this configured provider instead of the app-server default.
*/
modelProvider?: string | null,
/**
* Opaque pagination cursor returned by a previous call.
*/
@@ -3782,7 +3782,8 @@ mod tests {
"params": {
"limit": null,
"cursor": null,
"includeHidden": null
"includeHidden": null,
"modelProvider": null
}
}),
serde_json::to_value(&request)?,
@@ -52,6 +52,9 @@ pub struct ModelProviderCapabilitiesReadResponse {
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct ModelListParams {
/// List models for this configured provider instead of the app-server default.
#[ts(optional = nullable)]
pub model_provider: Option<String>,
/// Opaque pagination cursor returned by a previous call.
#[ts(optional = nullable)]
pub cursor: Option<String>,
+10 -4
View File
@@ -121,12 +121,18 @@ denial limit, and interrupted status; neither emits a separate `Error` event.
# Model catalog provider requirements
`model/list` and periodic model catalog refreshes check the startup provider against
current managed provider requirements before using the catalog. If that provider no longer
`model/list` accepts an optional `modelProvider` to list models for a configured provider
other than the startup default. For example, `{"modelProvider": "my_gateway"}` returns that
provider's models after the user's default changes. Omit it to retain the startup provider
and caching behavior. Explicit selections use the provider definitions loaded at startup. Restart the server
to pick up edits to those definitions. An unknown selector returns JSON-RPC error
`-32600` with `data.type = "modelProviderNotConfigured"` and the requested `modelProvider`.
`model/list` checks its selected provider, and periodic model catalog refreshes check the
startup provider, against current managed provider requirements. If that provider no longer
complies, `model/list` returns JSON-RPC error `-32600` asking the client to restart Codex,
and background refreshes skip the old endpoint. Requirement load failures also block these
operations. Checks apply even when the catalog is cached. Existing startup provider selection
and caching behavior remain in effect while the provider satisfies current requirements.
operations. Checks apply even when the catalog is cached.
# MCP App UI
@@ -129,6 +129,7 @@ use codex_app_server_protocol::MockExperimentalMethodParams;
use codex_app_server_protocol::MockExperimentalMethodResponse;
use codex_app_server_protocol::ModelListParams;
use codex_app_server_protocol::ModelListResponse;
use codex_app_server_protocol::ModelProviderError;
use codex_app_server_protocol::PermissionProfileListParams;
use codex_app_server_protocol::PermissionProfileListResponse;
use codex_app_server_protocol::PermissionProfileSummary;
@@ -694,6 +695,25 @@ fn resolve_turn_environment_requests(
Ok(Some(requests))
}
/// Project-local layers cannot define providers, so explicit selectors can use the startup map.
fn config_for_provider(
startup: &Arc<Config>,
provider_id: &str,
) -> Result<Arc<Config>, JSONRPCErrorError> {
if startup.model_provider_id == provider_id {
return Ok(Arc::clone(startup));
}
let provider = startup.model_providers.get(provider_id).ok_or_else(|| {
model_provider_error(&ModelProviderError::ModelProviderNotConfigured {
model_provider: provider_id.to_owned(),
})
})?;
let mut config = startup.as_ref().clone();
config.model_provider_id = provider_id.to_owned();
config.model_provider = provider.clone();
Ok(Arc::new(config))
}
fn resolve_runtime_workspace_roots(workspace_roots: Vec<AbsolutePathBuf>) -> Vec<AbsolutePathBuf> {
let mut resolved_roots = Vec::new();
for root in workspace_roots {
@@ -179,11 +179,29 @@ impl CatalogRequestProcessor {
&self,
params: ModelListParams,
) -> Result<Option<ClientResponsePayload>, JSONRPCErrorError> {
let (config, models_manager) = self
let (active_config, active_models_manager) = self
.model_catalog
.current()
.await
.map_err(|err| config_load_error(&err))?;
let (config, models_manager) = match params.model_provider.as_deref() {
None => (active_config, active_models_manager),
Some(provider_id) => {
let config = config_for_provider(&self.config, provider_id)?;
if provider_id == active_config.model_provider_id
&& config.model_provider == active_config.model_provider
{
(active_config, active_models_manager)
} else {
// Temporary catalogs must not replace the active provider's disk cache.
let manager = self.thread_manager.temporary_models_manager_for_config(
&config,
&codex_models_manager::manager::ModelCatalogSettings::default(),
);
(config, manager)
}
}
};
// Gate the same provider used by the catalog, including when its model cache is warm.
// Resolving credentials may refresh them, but explicit host policy prevents browser login.
if let Some(gateway) = codex_model_provider::create_model_provider(
@@ -204,9 +222,11 @@ impl CatalogRequestProcessor {
.load_latest_config(/*fallback_cwd*/ None)
.await
.map_err(|err| config_load_error(&err))?;
// A direct config file edit can leave the catalog using the previous gateway.
if current.model_provider_id != config.model_provider_id
|| current.model_provider != config.model_provider
// An explicit selection need not match the current default.
if current.model_providers.get(&config.model_provider_id)
!= Some(&config.model_provider)
|| (params.model_provider.is_none()
&& current.model_provider_id != config.model_provider_id)
{
return Err(invalid_request(
"Model provider settings changed. Restart Codex to apply them, then retry fetching the model list",
@@ -293,6 +313,7 @@ impl CatalogRequestProcessor {
limit,
cursor,
include_hidden,
model_provider: _,
} = params;
let presets = self
.model_catalog
@@ -13,17 +13,19 @@ fn cloud_config_bundle_load_error(err: &std::io::Error) -> Option<&CloudConfigBu
None
}
pub(super) fn model_provider_error(provider: &ModelProviderError) -> JSONRPCErrorError {
let mut error = invalid_request(provider.to_string());
error.data = serde_json::to_value(provider).ok();
error
}
pub(crate) fn config_load_error(err: &std::io::Error) -> JSONRPCErrorError {
let mut current = err
.get_ref()
.map(|error| error as &(dyn std::error::Error + 'static));
while let Some(source) = current {
if let Some(provider_error) =
source.downcast_ref::<codex_app_server_protocol::ModelProviderError>()
{
let mut error = invalid_request(provider_error.to_string());
error.data = serde_json::to_value(provider_error).ok();
return error;
if let Some(provider_error) = source.downcast_ref::<ModelProviderError>() {
return model_provider_error(provider_error);
}
current = source.source();
}
@@ -36,6 +36,7 @@ region = "us-west-2"
cursor: None,
limit: Some(100),
include_hidden: Some(true),
..Default::default()
},
})
.await?;
@@ -118,6 +119,7 @@ region = "us-west-2"
cursor: None,
limit: Some(100),
include_hidden: Some(true),
..Default::default()
},
})
.await?;
@@ -156,6 +156,7 @@ requires_openai_auth = true
limit: Some(100),
include_hidden: Some(true),
cursor: None,
..Default::default()
},
})
.await?;
@@ -274,6 +275,7 @@ async fn list_models_returns_all_models_with_large_limit() -> Result<()> {
limit: Some(100),
cursor: None,
include_hidden: None,
..Default::default()
},
})
.await?;
@@ -304,6 +306,7 @@ async fn list_models_includes_hidden_models() -> Result<()> {
limit: Some(100),
cursor: None,
include_hidden: Some(true),
..Default::default()
},
})
.await?;
@@ -426,6 +429,7 @@ model_catalog_url = "{server_uri}/v1/models"
limit: Some(100),
cursor: None,
include_hidden: None,
..Default::default()
})
.await?;
let response = mcp
@@ -509,6 +513,7 @@ async fn list_models_pagination_works() -> Result<()> {
limit: Some(1),
cursor: cursor.clone(),
include_hidden: None,
..Default::default()
},
})
.await?;
@@ -545,6 +550,7 @@ async fn list_models_rejects_invalid_cursor() -> Result<()> {
limit: None,
cursor: Some("invalid".to_string()),
include_hidden: None,
..Default::default()
})
.await?;
@@ -1,7 +1,9 @@
//! Exercises live catalog routing through the public model/list RPC.
use anyhow::Result;
use app_test_support::ChatGptAuthFixture;
use app_test_support::TestAppServer;
use app_test_support::write_chatgpt_auth;
use codex_app_server_protocol::ClientRequest;
use codex_app_server_protocol::LoginAccountResponse;
use codex_app_server_protocol::LogoutAccountResponse;
@@ -153,6 +155,22 @@ requires_openai_auth = true
.collect::<Vec<_>>(),
vec!["gateway-model"]
);
// A provider selector cannot override a required selection, even before its catalog is cached.
let id = server
.send_raw_request("model/list", Some(json!({"modelProvider": "other"})))
.await?;
let error = server
.read_stream_until_error_message(RequestId::Integer(id))
.await?;
assert_eq!(error.error.code, -32600);
assert!(
other
.received_requests()
.await
.expect("request recording enabled")
.is_empty()
);
let updated_providers = if required_selection == "gateway" {
providers.replace(&gateway.uri(), &other.uri())
} else {
@@ -376,3 +394,83 @@ async fn model_list_preserves_invalid_startup_config_fallback() -> Result<()> {
assert!(!list_models(&mut server).await?.data.is_empty());
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn model_list_selects_a_provider_without_replacing_the_startup_catalog() -> Result<()> {
let first = MockServer::start().await;
let second = MockServer::start().await;
// Startup refresh and model/list can fetch concurrently from the default provider.
for (server, slug) in [(&first, "first-model"), (&second, "second-model")] {
Mock::given(method("GET"))
.and(path_regex(".*/models$"))
.respond_with(ResponseTemplate::new(200).set_body_json(catalog(slug)?))
.mount(server)
.await;
}
let home = TempDir::new()?;
std::fs::write(
home.path().join("config.toml"),
format!(
r#"
model_provider = "first"
[model_providers.first]
name = "First"
base_url = "{}/v1"
requires_openai_auth = true
[model_providers.second]
name = "Second"
base_url = "{}/v1"
requires_openai_auth = true
"#,
first.uri(),
second.uri(),
),
)?;
write_chatgpt_auth(
home.path(),
ChatGptAuthFixture::new("chatgpt-access-token").plan_type("pro"),
AuthCredentialsStoreMode::File,
)?;
let mut server = TestAppServer::builder()
.with_codex_home(home.path())
.with_env_overrides(&[("OPENAI_API_KEY", None)])
.build_initialized()
.await?;
let initial = list_models(&mut server).await?;
assert_eq!(initial.data.len(), 1);
assert_eq!(initial.data[0].id, "first-model");
let cache_path = home.path().join("models_cache.json");
let initial_cache: ModelsResponse = serde_json::from_slice(&std::fs::read(&cache_path)?)?;
let select_second = |request_id| ClientRequest::ModelList {
request_id,
params: ModelListParams {
model_provider: Some("second".into()),
..Default::default()
},
};
let selected: ModelListResponse = server.request(select_second).await?;
assert_eq!(selected.data.len(), 1);
assert_eq!(selected.data[0].id, "second-model");
let disk_catalog: ModelsResponse = serde_json::from_slice(&std::fs::read(&cache_path)?)?;
assert_eq!(disk_catalog, initial_cache);
assert_eq!(list_models(&mut server).await?, initial);
Ok(())
}
#[tokio::test]
async fn model_list_reports_an_unconfigured_provider() -> Result<()> {
let mut server = TestAppServer::builder().build_initialized().await?;
let id = server
.send_raw_request("model/list", Some(json!({"modelProvider": "missing"})))
.await?;
let error = server
.read_stream_until_error_message(RequestId::Integer(id))
.await?;
assert_eq!(error.error.code, -32600);
assert_eq!(
error.error.data,
Some(json!({"type": "modelProviderNotConfigured", "modelProvider": "missing"}))
);
Ok(())
}
+28 -6
View File
@@ -72,6 +72,8 @@ use codex_login::AuthManager;
use codex_login::CodexAuth;
use codex_login::default_client::CODEX_INTERNAL_ORIGINATOR_OVERRIDE_ENV_VAR;
use codex_login::default_client::originator;
use codex_model_provider::ModelCatalogFactory;
use codex_model_provider::ProviderAuthContext;
use codex_model_provider_info::ModelProviderInfo;
use codex_model_provider_info::OPENAI_PROVIDER_ID;
use codex_models_manager::manager::RefreshStrategy;
@@ -1039,6 +1041,29 @@ impl ThreadManager {
/// Select the app-server catalog using the same owner as thread requests.
pub fn models_manager_for_config(&self, config: &Config) -> SharedModelsManager {
self.state.model_catalog.for_config(
config,
config.model_catalog.as_ref(),
self.catalog_auth_context(config),
/*same_owner*/ true,
)
}
/// Build a separate catalog with the harness's runtime identity and the caller's cache policy.
pub fn temporary_models_manager_for_config(
&self,
config: &Config,
factory: &dyn ModelCatalogFactory,
) -> SharedModelsManager {
factory.build(
self.catalog_auth_context(config)
.model_provider(config.model_provider.clone()),
config.model_catalog.clone(),
config.features.enabled(Feature::ApiKeyModelDiscovery),
)
}
fn catalog_auth_context(&self, config: &Config) -> ProviderAuthContext {
let credentials = self.auth_manager();
let runtime = crate::runtime_auth::runtime_for_config(
&self.state.runtime_auth,
@@ -1046,13 +1071,10 @@ impl ThreadManager {
Arc::clone(&credentials),
&self.state.installation_id,
);
self.state.model_catalog.for_config(
config,
config.model_catalog.as_ref(),
ProviderAuthContext {
credentials,
runtime,
/*same_owner*/ true,
)
runtime_auth: runtime.map(|runtime| (runtime, config.runtime_auth_mode)),
}
}
pub fn set_api_key_model_discovery_enabled(&self, enabled: bool) {
@@ -127,32 +127,30 @@ impl ThreadModelCatalog {
.as_ref()
.filter(|_| self.provider == config.model_provider)
});
self.for_config(config, models, credentials, runtime, same_owner)
let auth = ProviderAuthContext {
credentials,
runtime_auth: runtime.map(|runtime| (runtime, config.runtime_auth_mode)),
};
self.for_config(config, models, auth, same_owner)
}
pub(super) fn for_config(
&self,
config: &Config,
models: Option<&ModelsResponse>,
credentials: Arc<AuthManager>,
runtime: Option<Arc<RuntimeAuthManager>>,
auth: ProviderAuthContext,
same_owner: bool,
) -> SharedModelsManager {
let runtime = runtime.map(|runtime| (runtime, config.runtime_auth_mode));
let api_key_model_discovery_default =
config.features.enabled(Feature::ApiKeyModelDiscovery);
if same_owner
&& self.runtime_mode == runtime.as_ref().map(|(_, mode)| *mode)
&& self.runtime_mode == auth.runtime_auth.as_ref().map(|(_, mode)| *mode)
&& self.provider == config.model_provider
&& self.catalog.as_ref() == models
&& self.api_key_model_discovery_default == api_key_model_discovery_default
{
return Arc::clone(&self.manager);
}
let auth = ProviderAuthContext {
credentials,
runtime_auth: runtime,
};
self.factory.build(
auth.model_provider(config.model_provider.clone()),
models.cloned(),
+23
View File
@@ -12,6 +12,7 @@ use codex_history::InitialHistory;
use codex_history::ResumedHistory;
use codex_login::CodexAuth;
use codex_model_provider::ProviderAuthContext;
use codex_models_manager::manager::ModelCatalogSettings;
use codex_models_manager::manager::RefreshStrategy;
use codex_protocol::mcp::ClientMcpExtensions;
use codex_protocol::protocol::EventMsg;
@@ -44,6 +45,7 @@ use wiremock::Mock;
use wiremock::MockServer;
use wiremock::ResponseTemplate;
use wiremock::matchers::body_string_contains;
use wiremock::matchers::header_regex;
use wiremock::matchers::method;
use wiremock::matchers::path;
@@ -132,6 +134,14 @@ async fn harness_shares_runtime_and_honors_thread_auth_config() -> Result<()> {
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/selected-catalog"))
.and(header_regex("authorization", "^AgentAssertion "))
.respond_with(ResponseTemplate::new(200).set_body_json(catalog_fixture()))
.expect(1)
.mount(&server)
.await;
for mode in ["dual", "assertion", "disabled"] {
Mock::given(method("GET"))
.and(path(format!("/catalog-reload/{mode}")))
@@ -365,6 +375,19 @@ async fn exercise_harness(server_uri: String) -> Result<()> {
};
catalog_config.model_provider.model_catalog_url =
Some(format!("https://api.openai.com/catalog-reload/{label}").into());
if mode == RuntimeAuthMode::AssertionOnly {
let mut selected = catalog_config.clone();
selected.model_provider.base_url = Some(server_uri.clone());
selected.model_provider.model_catalog_url =
Some("https://api.openai.com/selected-catalog".into());
let models = test
.thread_manager
.temporary_models_manager_for_config(&selected, &ModelCatalogSettings::default());
models
.raw_model_catalog(RefreshStrategy::Online, selected.http_client_factory())
.await;
assert_eq!(models.get_remote_models().await, catalog_fixture().models);
}
let catalog_thread = test
.thread_manager
.start_thread(StartThreadOptions {
+1
View File
@@ -50,6 +50,7 @@ pub(crate) async fn program_for_turn(
cursor,
limit: None,
include_hidden: Some(true),
model_provider: None,
},
})
.await;
+1
View File
@@ -820,6 +820,7 @@ impl AppServerSession {
cursor: None,
limit: None,
include_hidden: Some(true),
model_provider: None,
},
})
.await
@@ -33,6 +33,7 @@ impl AppServerSession {
cursor: None,
limit: None,
include_hidden: Some(true),
model_provider: None,
},
})
.await
+2 -1
View File
@@ -1290,7 +1290,8 @@ async fn background_turn_program(
params: ModelListParams {
cursor: None,
limit: None,
include_hidden: Some(true)
include_hidden: Some(true),
model_provider: None,
},
}),
);
@@ -2825,6 +2825,13 @@ class ModelListParams(BaseModel):
int | None,
Field(description="Optional page size; defaults to a reasonable server-side value.", ge=0),
] = None
model_provider: Annotated[
str | None,
Field(
alias="modelProvider",
description="List models for this configured provider instead of the app-server default.",
),
] = None
class ModelProviderCapabilitiesReadParams(BaseModel):