Enable model catalog discovery with gateway OAuth (#60414)

## Why

Custom providers with gateway OAuth and an explicit model catalog should be able to discover models without an API key or API key model discovery enabled.

## What changed

- Enable catalog refreshes for custom gateway providers when `model_catalog_url` and `base_url` have the same origin.
- Reuse gateway credentials without starting an interactive login during catalog refreshes.
- Keep an unrelated saved API key from making gateway catalog discovery depend on the API key discovery setting.

## Testing

Add an app-server test for discovering a gateway catalog after OAuth login with no API key. Update coverage for catalog invalidation after gateway credentials require login, including across manager restarts, with API key discovery disabled.

GitOrigin-RevId: b133c428cb45a9037e4b2ef13ff2f9473ed96636
This commit is contained in:
smurching-oai
2026-11-09 16:37:22 +00:00
committed by copyberry
parent e54a5713ca
commit 6222faa3c4
5 changed files with 142 additions and 27 deletions
@@ -54,6 +54,7 @@ delivery = {{ kind = "header", name = "X-Gateway-Authorization" }}
async fn gateway_server(home: &std::path::Path) -> Result<TestAppServer> {
let mut server = TestAppServer::builder()
.with_codex_home(home)
.with_env_overrides(&[("OPENAI_API_KEY", None), ("CODEX_API_KEY", None)])
.build()
.await?;
server
@@ -238,10 +239,7 @@ async fn model_list_leaves_oauth_gateway_after_account_change() -> Result<()> {
Ok(())
}
#[tokio::test]
async fn file_credentials_reuse_gateway_login_after_restart() -> Result<()> {
let home = TempDir::new()?;
let oauth = MockServer::start().await;
async fn login_to_gateway(server: &mut TestAppServer, oauth: &MockServer) -> Result<()> {
Mock::given(method("POST"))
.and(path("/token"))
.and(body_string_contains("grant_type=authorization_code"))
@@ -249,18 +247,12 @@ async fn file_credentials_reuse_gateway_login_after_restart() -> Result<()> {
"access_token": "gateway-access", "refresh_token": "gateway-refresh", "expires_in": 3600,
})))
.expect(/*r*/ 1)
.mount(&oauth)
.mount(oauth)
.await;
// Exercise the managed provider definition used by headless installations.
std::fs::write(
home.path().join("requirements.toml"),
format!("{}\ncredentials_store = 'file'\n", gateway_config(&oauth)),
)?;
let mut server = gateway_server(home.path()).await?;
let login = server
.send_raw_request("account/gatewayOAuth/login", /*params*/ None)
.await?;
let url = authorization_url(&mut server).await?;
let url = authorization_url(server).await?;
let query = url
.query_pairs()
.collect::<std::collections::HashMap<_, _>>();
@@ -275,6 +267,73 @@ async fn file_credentials_reuse_gateway_login_after_restart() -> Result<()> {
.await?
.error_for_status()?;
let _: GatewayOAuthLoginResponse = server.read_response(login).await?;
Ok(())
}
#[tokio::test]
async fn gateway_oauth_without_api_key_discovers_explicit_catalog_by_default() -> Result<()> {
let home = TempDir::new()?;
let gateway = MockServer::start().await;
let url = gateway.uri();
std::fs::write(
home.path().join("config.toml"),
format!(
r#"
model_provider = "gateway"
[model_providers.gateway]
name = "Test gateway"
base_url = "{url}/v1"
model_catalog_url = "{url}/catalog"
[model_providers.gateway.gateway_oauth]
authorization_url = "{url}/authorize"
token_url = "{url}/token"
client_id = "catalog-test"
credentials_store = "file"
"#
),
)?;
let model = codex_protocol::openai_models::ModelInfo {
visibility: codex_protocol::openai_models::ModelVisibility::List,
..codex_models_manager::model_info::model_info_from_slug("gateway-coder")
};
Mock::given(method("GET"))
.and(path("/catalog"))
.and(wiremock::matchers::header(
"authorization",
"Bearer gateway-access",
))
.respond_with(ResponseTemplate::new(/*s*/ 200).set_body_json(
codex_protocol::openai_models::ModelsResponse {
models: vec![model],
},
))
.expect(1..)
.mount(&gateway)
.await;
let mut server = gateway_server(home.path()).await?;
login_to_gateway(&mut server, &gateway).await?;
let response: codex_app_server_protocol::ModelListResponse = server
.request(|request_id| ClientRequest::ModelList {
request_id,
params: Default::default(),
})
.await?;
let ids: Vec<_> = response.data.into_iter().map(|model| model.id).collect();
assert_eq!(ids, ["gateway-coder"]);
Ok(())
}
#[tokio::test]
async fn file_credentials_reuse_gateway_login_after_restart() -> Result<()> {
let home = TempDir::new()?;
let oauth = MockServer::start().await;
// Exercise the managed provider definition used by headless installations.
std::fs::write(
home.path().join("requirements.toml"),
format!("{}\ncredentials_store = 'file'\n", gateway_config(&oauth)),
)?;
let mut server = gateway_server(home.path()).await?;
login_to_gateway(&mut server, &oauth).await?;
server.shutdown_gracefully().await?;
let path = home.path().join("secrets/gateway_oauth.json");
@@ -65,6 +65,15 @@ impl GatewayAuthManager {
}
}
pub(crate) fn without_interactive_login(self: &Arc<Self>) -> Arc<Self> {
let GatewayCredentialSource::OAuth(session) = &self.0 else {
return Arc::clone(self);
};
Arc::new(Self(GatewayCredentialSource::OAuth(Arc::new(
session.as_ref().clone().without_interactive_login(),
))))
}
pub async fn resolve_access_token(&self) -> Result<Arc<str>> {
match &self.0 {
GatewayCredentialSource::Export(export) => export.token().await,
+36 -1
View File
@@ -50,6 +50,25 @@ const MODELS_REFRESH_TIMEOUT: Duration = Duration::from_secs(5);
const MODELS_ENDPOINT: &str = "/models";
const FEDRAMP_CODEX_BASE_URL: &str = "https://gov.chatgpt.com/backend-api/codex";
// Catalog requests use the model provider's credentials. Before enabling a fetch
// with gateway OAuth alone, check that the catalog URL has the same origin as the gateway.
fn supports_gateway_catalog(info: &ModelProviderInfo) -> bool {
if info.is_openai() || info.gateway_oauth.is_none() {
return false;
}
let (Some(base), Some(catalog)) = (info.base_url.as_deref(), info.model_catalog_url.as_deref())
else {
return false;
};
match (
codex_http_client::validate_endpoint_url(base, "base_url"),
codex_http_client::validate_endpoint_url(catalog, "model_catalog_url"),
) {
(Ok(base), Ok(catalog)) => base.origin() == catalog.origin(),
_ => false,
}
}
/// Provider-owned OpenAI-compatible `/models` endpoint.
#[derive(Debug)]
pub(crate) struct OpenAiModelsEndpoint {
@@ -64,8 +83,14 @@ impl OpenAiModelsEndpoint {
pub(crate) fn new(
provider_info: ModelProviderInfo,
auth_manager: Option<Arc<AuthManager>>,
gateway_auth_manager: Option<Result<Arc<GatewayAuthManager>, String>>,
mut gateway_auth_manager: Option<Result<Arc<GatewayAuthManager>, String>>,
) -> Self {
// Fetch with an existing gateway session; a catalog refresh must not open a browser.
if supports_gateway_catalog(&provider_info)
&& let Some(Ok(manager)) = &mut gateway_auth_manager
{
*manager = manager.without_interactive_login();
}
let redirect_policy = if provider_info.model_catalog_url.is_some()
|| provider_info
.gateway_oauth
@@ -299,6 +324,16 @@ impl ModelsEndpointClient for OpenAiModelsEndpoint {
|| self.provider_info.experimental_bearer_token.is_some()
}
fn supports_gateway_catalog(&self) -> bool {
supports_gateway_catalog(&self.provider_info)
}
fn uses_api_key_auth(&self, login: Option<codex_protocol::auth::AuthMode>) -> bool {
self.has_provider_api_key()
|| (login == Some(codex_protocol::auth::AuthMode::ApiKey)
&& (!self.supports_gateway_catalog() || self.provider_info.requires_openai_auth))
}
fn identity(&self) -> Option<String> {
let auth = self
.auth_manager
@@ -689,7 +689,9 @@ async fn gateway_login_required_invalidates_the_last_successful_catalog() {
home.path().to_path_buf(),
);
let info = ModelProviderInfo {
model_catalog_url: Some(format!("{}/models", server.uri()).into()),
name: "Gateway".to_string(),
base_url: Some(server.uri()),
model_catalog_url: Some(format!("{}/catalog", server.uri()).into()),
gateway_oauth: Some(GatewayOAuthConfig::OAuth(GatewayOAuthSettings {
authorization_url: format!("{}/authorize", server.uri()),
device_authorization_url: None,
@@ -699,12 +701,9 @@ async fn gateway_login_required_invalidates_the_last_successful_catalog() {
scopes: vec![],
redirect_port: None,
credentials_store: OAuthCredentialsStore::File,
delivery: GatewayOAuthDelivery::Header {
name: "x-gateway-auth".into(),
scheme: "Bearer".into(),
},
delivery: GatewayOAuthDelivery::default(),
})),
..ModelProviderInfo::create_openai_provider(Some(server.uri()))
..Default::default()
};
let login_control = codex_login::GatewayLoginControl::for_runtime(&primary.runtime_config());
login_control.require_explicit_login();
@@ -718,11 +717,13 @@ async fn gateway_login_required_invalidates_the_last_successful_catalog() {
home.path().to_path_buf(),
/*config_model_catalog*/ None,
);
models.set_api_key_model_discovery_enabled(/*enabled*/ true);
// Only the gateway token should enable this fetch; the saved API key is unrelated.
models.set_api_key_model_discovery_enabled(/*enabled*/ false);
let factory = HttpClientFactory::new(OutboundProxyPolicy::ReqwestDefault);
let catalog = codex_models_manager::bundled_models_response().unwrap();
Mock::given(method("GET"))
.and(path("/models"))
.and(path("/catalog"))
.and(wiremock::matchers::header("authorization", "Bearer first"))
.respond_with(ResponseTemplate::new(200).set_body_json(&catalog))
.expect(1)
.mount(&server)
@@ -758,7 +759,7 @@ async fn gateway_login_required_invalidates_the_last_successful_catalog() {
home.path().to_path_buf(),
/*config_model_catalog*/ None,
);
restarted.set_api_key_model_discovery_enabled(/*enabled*/ true);
restarted.set_api_key_model_discovery_enabled(/*enabled*/ false);
assert_eq!(
restarted
.raw_model_catalog(RefreshStrategy::Offline, factory)
+16 -5
View File
@@ -79,6 +79,16 @@ pub trait ModelsEndpointClient: fmt::Debug + Send + Sync {
false
}
/// Whether this custom provider can serve a Codex catalog using its gateway credentials.
fn supports_gateway_catalog(&self) -> bool {
false
}
/// Determines whether this provider uses API key auth to fetch the model catalog.
fn uses_api_key_auth(&self, login: Option<AuthMode>) -> bool {
self.has_provider_api_key() || login == Some(AuthMode::ApiKey)
}
/// Fetches the latest remote model catalog and optional ETag.
fn list_models<'a>(
&'a self,
@@ -936,15 +946,16 @@ impl OpenAiModelsManager {
}
fn uses_api_key_auth(&self) -> bool {
self.endpoint_client.has_provider_api_key()
|| self
.auth_manager
self.endpoint_client.uses_api_key_auth(
self.auth_manager
.as_ref()
.is_some_and(|auth_manager| auth_manager.auth_mode() == Some(AuthMode::ApiKey))
.and_then(|manager| manager.auth_mode()),
)
}
async fn should_refresh_models(&self) -> bool {
self.endpoint_client.uses_codex_backend().await
self.endpoint_client.supports_gateway_catalog()
|| self.endpoint_client.uses_codex_backend().await
|| self.endpoint_client.has_command_auth()
|| self.supports_api_key_discovery()
}