Allow provider selection for gateway OAuth reads and logins (#59819)

## Why

Gateway OAuth reads and logins previously used only the current provider, preventing clients from authorizing another configured provider without changing the default.

## What changed

- Add optional `modelProvider` params to `account/gatewayOAuth/read` and `account/gatewayOAuth/login`, preserving current-provider behavior for omitted or null params and selectors.
- Validate explicit providers against current managed policy without changing the default or any thread's provider.
- Retain readiness state across providers and send `account/gatewayOAuth/changed` notifications for every configured provider sharing the changed credential.
- Update protocol schemas, TypeScript types, Python models, and documentation.

## Testing

Add coverage for non-default provider login, retained readiness when alternating providers, notifications for shared credentials without starting login, and legacy TypeScript request compatibility.

GitOrigin-RevId: cfd472c322b821fe36b8c133a8de774f4ba48534
This commit is contained in:
Abhinav
2026-11-06 04:02:11 +00:00
committed by copyberry
parent a142c90e26
commit aece559654
22 changed files with 561 additions and 71 deletions
+42 -2
View File
@@ -1678,6 +1678,32 @@
],
"type": "object"
},
"GatewayOAuthLoginParams": {
"description": "Authorize a configured provider without changing the default or any thread's provider.",
"properties": {
"modelProvider": {
"description": "Defaults to the current provider when omitted.",
"type": [
"string",
"null"
]
}
},
"type": "object"
},
"GatewayOAuthReadParams": {
"description": "Read credential readiness for a configured provider without starting browser login.",
"properties": {
"modelProvider": {
"description": "Defaults to the current provider when omitted.",
"type": [
"string",
"null"
]
}
},
"type": "object"
},
"GcpProviderId": {
"enum": [
"gcp"
@@ -8185,7 +8211,14 @@
"type": "string"
},
"params": {
"type": "null"
"anyOf": [
{
"$ref": "#/definitions/GatewayOAuthReadParams"
},
{
"type": "null"
}
]
}
},
"required": [
@@ -8208,7 +8241,14 @@
"type": "string"
},
"params": {
"type": "null"
"anyOf": [
{
"$ref": "#/definitions/GatewayOAuthLoginParams"
},
{
"type": "null"
}
]
}
},
"required": [
@@ -1850,7 +1850,14 @@
"type": "string"
},
"params": {
"type": "null"
"anyOf": [
{
"$ref": "#/definitions/v2/GatewayOAuthReadParams"
},
{
"type": "null"
}
]
}
},
"required": [
@@ -1873,7 +1880,14 @@
"type": "string"
},
"params": {
"type": "null"
"anyOf": [
{
"$ref": "#/definitions/v2/GatewayOAuthLoginParams"
},
{
"type": "null"
}
]
}
},
"required": [
@@ -13177,11 +13191,37 @@
"title": "GatewayOAuthChangedNotification",
"type": "object"
},
"GatewayOAuthLoginParams": {
"description": "Authorize a configured provider without changing the default or any thread's provider.",
"properties": {
"modelProvider": {
"description": "Defaults to the current provider when omitted.",
"type": [
"string",
"null"
]
}
},
"type": "object"
},
"GatewayOAuthLoginResponse": {
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "GatewayOAuthLoginResponse",
"type": "object"
},
"GatewayOAuthReadParams": {
"description": "Read credential readiness for a configured provider without starting browser login.",
"properties": {
"modelProvider": {
"description": "Defaults to the current provider when omitted.",
"type": [
"string",
"null"
]
}
},
"type": "object"
},
"GatewayOAuthReadResponse": {
"$schema": "http://json-schema.org/draft-07/schema#",
"description": "Current effective gateway policy and credential readiness; never contains credentials.",
@@ -16886,6 +16926,30 @@
],
"type": "string"
},
"NullableGatewayOAuthLoginParams": {
"$schema": "http://json-schema.org/draft-07/schema#",
"anyOf": [
{
"$ref": "#/definitions/v2/GatewayOAuthLoginParams"
},
{
"type": "null"
}
],
"title": "Nullable_GatewayOAuthLoginParams"
},
"NullableGatewayOAuthReadParams": {
"$schema": "http://json-schema.org/draft-07/schema#",
"anyOf": [
{
"$ref": "#/definitions/v2/GatewayOAuthReadParams"
},
{
"type": "null"
}
],
"title": "Nullable_GatewayOAuthReadParams"
},
"NullableGetAccountRateLimitsParams": {
"$schema": "http://json-schema.org/draft-07/schema#",
"anyOf": [
@@ -3594,7 +3594,14 @@
"type": "string"
},
"params": {
"type": "null"
"anyOf": [
{
"$ref": "#/definitions/GatewayOAuthReadParams"
},
{
"type": "null"
}
]
}
},
"required": [
@@ -3617,7 +3624,14 @@
"type": "string"
},
"params": {
"type": "null"
"anyOf": [
{
"$ref": "#/definitions/GatewayOAuthLoginParams"
},
{
"type": "null"
}
]
}
},
"required": [
@@ -9069,11 +9083,37 @@
"title": "GatewayOAuthChangedNotification",
"type": "object"
},
"GatewayOAuthLoginParams": {
"description": "Authorize a configured provider without changing the default or any thread's provider.",
"properties": {
"modelProvider": {
"description": "Defaults to the current provider when omitted.",
"type": [
"string",
"null"
]
}
},
"type": "object"
},
"GatewayOAuthLoginResponse": {
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "GatewayOAuthLoginResponse",
"type": "object"
},
"GatewayOAuthReadParams": {
"description": "Read credential readiness for a configured provider without starting browser login.",
"properties": {
"modelProvider": {
"description": "Defaults to the current provider when omitted.",
"type": [
"string",
"null"
]
}
},
"type": "object"
},
"GatewayOAuthReadResponse": {
"$schema": "http://json-schema.org/draft-07/schema#",
"description": "Current effective gateway policy and credential readiness; never contains credentials.",
@@ -12847,6 +12887,30 @@
],
"type": "string"
},
"NullableGatewayOAuthLoginParams": {
"$schema": "http://json-schema.org/draft-07/schema#",
"anyOf": [
{
"$ref": "#/definitions/GatewayOAuthLoginParams"
},
{
"type": "null"
}
],
"title": "Nullable_GatewayOAuthLoginParams"
},
"NullableGatewayOAuthReadParams": {
"$schema": "http://json-schema.org/draft-07/schema#",
"anyOf": [
{
"$ref": "#/definitions/GatewayOAuthReadParams"
},
{
"type": "null"
}
],
"title": "Nullable_GatewayOAuthReadParams"
},
"NullableGetAccountRateLimitsParams": {
"$schema": "http://json-schema.org/draft-07/schema#",
"anyOf": [
@@ -0,0 +1,27 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"anyOf": [
{
"$ref": "#/definitions/GatewayOAuthLoginParams"
},
{
"type": "null"
}
],
"definitions": {
"GatewayOAuthLoginParams": {
"description": "Authorize a configured provider without changing the default or any thread's provider.",
"properties": {
"modelProvider": {
"description": "Defaults to the current provider when omitted.",
"type": [
"string",
"null"
]
}
},
"type": "object"
}
},
"title": "Nullable_GatewayOAuthLoginParams"
}
@@ -0,0 +1,27 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"anyOf": [
{
"$ref": "#/definitions/GatewayOAuthReadParams"
},
{
"type": "null"
}
],
"definitions": {
"GatewayOAuthReadParams": {
"description": "Read credential readiness for a configured provider without starting browser login.",
"properties": {
"modelProvider": {
"description": "Defaults to the current provider when omitted.",
"type": [
"string",
"null"
]
}
},
"type": "object"
}
},
"title": "Nullable_GatewayOAuthReadParams"
}
File diff suppressed because one or more lines are too long
@@ -0,0 +1,12 @@
// 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.
/**
* Authorize a configured provider without changing the default or any thread's provider.
*/
export type GatewayOAuthLoginParams = {
/**
* Defaults to the current provider when omitted.
*/
modelProvider?: string | null, };
@@ -0,0 +1,12 @@
// 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.
/**
* Read credential readiness for a configured provider without starting browser login.
*/
export type GatewayOAuthReadParams = {
/**
* Defaults to the current provider when omitted.
*/
modelProvider?: string | null, };
@@ -208,7 +208,9 @@ export type { FsWriteFileResponse } from "./FsWriteFileResponse";
export type { GatewayAuth } from "./GatewayAuth";
export type { GatewayOAuthCancelResponse } from "./GatewayOAuthCancelResponse";
export type { GatewayOAuthChangedNotification } from "./GatewayOAuthChangedNotification";
export type { GatewayOAuthLoginParams } from "./GatewayOAuthLoginParams";
export type { GatewayOAuthLoginResponse } from "./GatewayOAuthLoginResponse";
export type { GatewayOAuthReadParams } from "./GatewayOAuthReadParams";
export type { GatewayOAuthReadResponse } from "./GatewayOAuthReadResponse";
export type { GatewayOAuthStatus } from "./GatewayOAuthStatus";
export type { GcpProviderId } from "./GcpProviderId";
+15 -1
View File
@@ -2206,6 +2206,16 @@ mod tests {
"params?: GetAccountRateLimitsParams | undefined, }"
);
assert!(client_request_ts.contains(LEGACY_ACCOUNT_RATE_LIMITS_REQUEST));
const LEGACY_GATEWAY_READ_REQUEST: &str = concat!(
"{ \"method\": \"account/gatewayOAuth/read\", id: RequestId, ",
"params?: GatewayOAuthReadParams | null | undefined, }"
);
assert!(client_request_ts.contains(LEGACY_GATEWAY_READ_REQUEST));
const LEGACY_GATEWAY_LOGIN_REQUEST: &str = concat!(
"{ \"method\": \"account/gatewayOAuth/login\", id: RequestId, ",
"params?: GatewayOAuthLoginParams | null | undefined, }"
);
assert!(client_request_ts.contains(LEGACY_GATEWAY_LOGIN_REQUEST));
let account_usage_response_ts = std::str::from_utf8(
fixture_tree
.get(Path::new("v2/GetAccountTokenUsageResponse.ts"))
@@ -2290,18 +2300,22 @@ mod tests {
});
let contents = std::str::from_utf8(contents)?;
// Both stable usage RPCs originally required `params: undefined`. Preserve that
// These existing RPCs originally required `params: undefined`. Preserve that
// source compatibility only for these exact envelopes, not arbitrary new fields.
let checked_contents = if path == Path::new("ClientRequest.ts") {
contents
.replace(LEGACY_ACCOUNT_USAGE_REQUEST, "")
.replace(LEGACY_ACCOUNT_RATE_LIMITS_REQUEST, "")
.replace(LEGACY_GATEWAY_READ_REQUEST, "")
.replace(LEGACY_GATEWAY_LOGIN_REQUEST, "")
} else {
contents.to_owned()
};
if checked_contents.contains("| undefined") {
undefined_offenders.push(path.clone());
}
// The exact gateway envelopes above also accept legacy null params.
let contents = checked_contents.as_str();
const SKIP_PREFIXES: &[&str] = &[
"const ",
@@ -499,6 +499,26 @@ enum GetAccountRateLimitsParamsTypeScript {
Undefined,
}
/// Preserve null and explicit undefined params while adding provider selection to gateway reads.
#[allow(dead_code)]
#[derive(TS)]
#[ts(untagged)]
enum GatewayOAuthReadParamsTypeScript {
Params(Option<v2::GatewayOAuthReadParams>),
#[ts(type = "undefined")]
Undefined,
}
/// Preserve null and explicit undefined params while adding provider selection to gateway login.
#[allow(dead_code)]
#[derive(TS)]
#[ts(untagged)]
enum GatewayOAuthLoginParamsTypeScript {
Params(Option<v2::GatewayOAuthLoginParams>),
#[ts(type = "undefined")]
Undefined,
}
client_request_definitions! {
Initialize => "initialize" {
params: v1::InitializeParams,
@@ -1161,12 +1181,12 @@ client_request_definitions! {
response: v2::ModelListResponse,
},
GatewayOAuthRead => "account/gatewayOAuth/read" {
params: #[ts(type = "undefined")] #[serde(skip_serializing_if = "Option::is_none")] Option<()>,
params: #[ts(optional, as = "Option<GatewayOAuthReadParamsTypeScript>", inline)] #[serde(default, skip_serializing_if = "Option::is_none")] v2::NullableGatewayOAuthReadParams,
serialization: None,
response: v2::GatewayOAuthReadResponse,
},
GatewayOAuthLogin => "account/gatewayOAuth/login" {
params: #[ts(type = "undefined")] #[serde(skip_serializing_if = "Option::is_none")] Option<()>,
params: #[ts(optional, as = "Option<GatewayOAuthLoginParamsTypeScript>", inline)] #[serde(default, skip_serializing_if = "Option::is_none")] v2::NullableGatewayOAuthLoginParams,
serialization: None,
response: v2::GatewayOAuthLoginResponse,
},
@@ -652,6 +652,30 @@ impl std::fmt::Debug for GatewayOAuthChangedNotification {
}
}
/// Read credential readiness for a configured provider without starting browser login.
#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct GatewayOAuthReadParams {
/// Defaults to the current provider when omitted.
#[ts(optional = nullable)]
pub model_provider: Option<String>,
}
pub type NullableGatewayOAuthReadParams = Option<GatewayOAuthReadParams>;
/// Authorize a configured provider without changing the default or any thread's provider.
#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct GatewayOAuthLoginParams {
/// Defaults to the current provider when omitted.
#[ts(optional = nullable)]
pub model_provider: Option<String>,
}
pub type NullableGatewayOAuthLoginParams = Option<GatewayOAuthLoginParams>;
/// Current effective gateway policy and credential readiness; never contains credentials.
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
+12 -5
View File
@@ -648,7 +648,7 @@ cannot open a browser before initialization. Explicit opt-in is shared by gatewa
managers using the same home and network configuration within the process and
cannot be undone by a later connection that omits the capability.
- `account/gatewayOAuth/read` returns the current effective `providerId`,
- `account/gatewayOAuth/read` returns the selected `providerId`,
`providerName`, `required`, `status`, and `error`. `required` indicates that this
provider uses gateway OAuth, including when already signed in. With a credential
exporter, `required` is false and `status` is null. This read does not refresh
@@ -668,14 +668,21 @@ cannot be undone by a later connection that omits the capability.
start another login. Closing that connection also cancels its login and releases
the callback listener. Cancellation makes the pending login request fail.
- `account/gatewayOAuth/changed` reports `notReady`, `started`, `succeeded`, or
`failed`, with an optional `error`. Notifications apply to the current effective
gateway configuration. Clients can read readiness when connecting and after
`failed`, with an optional `error`. Notifications identify each configured
provider sharing the changed gateway credential, even when it is not the current
default. Clients can read readiness when connecting and after
changing configuration. Notifications follow the standard per-connection
`optOutNotificationMethods` setting.
These payloads never contain credentials.
Read, login, and cancel take no params. Read and login use the app's current
provider, reloading configuration and returning an error if it cannot be loaded.
Read and login accept an optional `modelProvider`, for example
`{"modelProvider": "my_gateway"}`. Omitted params, null params, and an omitted or
null selector use the current provider. An explicit selector uses the provider definitions
loaded at startup and is checked against current managed provider policy before
credentials are read. Unknown or forbidden providers return an error instead of
falling back to the current provider. Login keeps the selected provider for the
entire attempt, even if the default changes. Cancel takes no params and cancels
the calling connection's active login, regardless of its provider.
Status notifications include `providerId`. During browser sign-in, inference
requests fail promptly and can be retried when sign-in succeeds.
@@ -1,4 +1,4 @@
//! Forward OAuth progress for the app's current gateway.
//! Forward OAuth progress for every configured provider sharing the changed grant.
use crate::config_manager::ConfigManager;
use crate::outgoing_message::OutgoingMessageSender;
@@ -39,27 +39,30 @@ pub(crate) fn spawn(
else {
continue;
};
let Ok(Some(manager)) = codex_model_provider::create_model_provider(
config.model_provider.clone(),
Some(Arc::clone(&auth_manager)),
)
.gateway_auth_manager() else {
continue;
};
if !matches!(manager.oauth_session(), Some(oauth) if oauth.config() == &change.config) {
continue;
for (provider_id, provider) in &config.model_providers {
let Ok(Some(manager)) = codex_model_provider::create_model_provider(
provider.clone(),
Some(Arc::clone(&auth_manager)),
)
.gateway_auth_manager() else {
continue;
};
if !matches!(manager.oauth_session(), Some(oauth) if oauth.config() == &change.config)
{
continue;
}
let (status, error) = status_fields(change.status.clone());
outgoing
.send_server_notification(ServerNotification::GatewayOAuthChanged(
GatewayOAuthChangedNotification {
auth_url: None,
provider_id: provider_id.clone(),
status,
error,
},
))
.await;
}
let (status, error) = status_fields(change.status);
outgoing
.send_server_notification(ServerNotification::GatewayOAuthChanged(
GatewayOAuthChangedNotification {
auth_url: None,
provider_id: config.model_provider_id,
status,
error,
},
))
.await;
}
}))
}
+12 -10
View File
@@ -1890,12 +1890,13 @@ impl MessageProcessor {
.bedrock_check_gov_cloud_requirements()
.await
}
ClientRequest::GatewayOAuthRead { .. } => {
Box::pin(self.account_processor.gateway_oauth_read())
.await
.map(|response| Some(response.into()))
}
ClientRequest::GatewayOAuthLogin { .. } => {
ClientRequest::GatewayOAuthRead { params, .. } => Box::pin(
self.account_processor
.gateway_oauth_read(params.unwrap_or_default()),
)
.await
.map(|response| Some(response.into())),
ClientRequest::GatewayOAuthLogin { params, .. } => {
if session
.opted_out_notification_methods()
.contains("account/gatewayOAuth/changed")
@@ -1904,10 +1905,11 @@ impl MessageProcessor {
"Gateway login requires account/gatewayOAuth/changed notifications",
))
} else {
Box::pin(
self.account_processor
.gateway_oauth_login(connection_id, &session.rpc_gate),
)
Box::pin(self.account_processor.gateway_oauth_login(
params.unwrap_or_default(),
connection_id,
&session.rpc_gate,
))
.await
.map(|response| Some(response.into()))
}
@@ -24,6 +24,7 @@ use serde_json::json;
use std::sync::Arc;
use std::time::Duration;
use tempfile::TempDir;
use test_case::test_case;
use tokio::time::timeout;
use wiremock::Mock;
use wiremock::MockServer;
@@ -47,8 +48,10 @@ async fn send_request(
.await;
}
#[test_case(false; "default provider")]
#[test_case(true; "explicit non-default provider")]
#[tokio::test]
async fn gateway_oauth_login_updates_existing_requests() -> Result<()> {
async fn gateway_oauth_login_updates_existing_requests(selected: bool) -> Result<()> {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/token"))
@@ -121,10 +124,23 @@ delivery = {{ kind = "header", name = "X-Gateway-Authorization" }}
)
.await;
let _: InitializeResponse = read_response(&mut outgoing, /*request_id*/ 1).await;
// Existing request consumers retain the gateway when the default changes.
if selected {
let path = home.path().join("config.toml");
let text = std::fs::read_to_string(&path)?;
std::fs::write(
path,
text.replace(
"model_provider = \"gateway\"",
"model_provider = \"openai\"",
),
)?;
}
let params = selected.then(|| json!({"modelProvider": "gateway"}));
send_request(
&processor,
&session,
json!({"id": 2, "method": "account/gatewayOAuth/login"}),
json!({"id": 2, "method": "account/gatewayOAuth/login", "params": params}),
)
.await;
let authorization_url = timeout(Duration::from_secs(/*secs*/ 10), async {
@@ -166,7 +182,7 @@ delivery = {{ kind = "header", name = "X-Gateway-Authorization" }}
send_request(
&processor,
&session,
json!({"id": 3, "method": "account/gatewayOAuth/read"}),
json!({"id": 3, "method": "account/gatewayOAuth/read", "params": params}),
)
.await;
let response: GatewayOAuthReadResponse = read_response(&mut outgoing, /*request_id*/ 3).await;
@@ -288,3 +304,61 @@ delivery = {{ kind = "header", name = "X-Gateway-Authorization" }}
processor.drain_background_tasks().await;
Ok(())
}
/// Checking another provider must not recreate the first provider's readiness state.
#[tokio::test]
async fn gateway_oauth_read_retains_each_provider_status() -> Result<()> {
let home = TempDir::new()?;
let mut text = String::from("[features]\nsecret_auth_storage = true\n");
for id in ["first", "second"] {
text.push_str(&format!(
r#"
[model_providers.{id}]
name = "{id}"
base_url = "https://{id}.example/v1"
wire_api = "responses"
[model_providers.{id}.gateway_oauth]
authorization_url = "https://{id}.example/authorize"
token_url = "https://{id}.example/token"
client_id = "{id}"
delivery = {{ kind = "header", name = "X-Gateway-Authorization" }}
"#
));
}
std::fs::write(home.path().join("config.toml"), text)?;
let config = Arc::new(
ConfigBuilder::default()
.codex_home(home.path().to_path_buf())
.build()
.await?,
);
let auth = AuthManager::from_auth_for_testing_with_home(
CodexAuth::from_api_key("primary"),
home.path().to_path_buf(),
);
let mut changes = codex_login::subscribe_gateway_auth_status(&auth.runtime_config());
let (processor, _outgoing) = build_test_processor(config, auth).await;
for (id, changed) in [("first", true), ("second", true), ("first", false)] {
let state = processor
.account_processor
.gateway_oauth_read(codex_app_server_protocol::GatewayOAuthReadParams {
model_provider: Some(id.into()),
})
.await
.map_err(|err| anyhow::anyhow!("{}", err.message))?;
assert_eq!(state.provider_id, id);
assert_eq!(state.status, Some(GatewayOAuthStatus::NotReady));
if changed {
assert_eq!(
changes.try_recv()?.status,
codex_login::GatewayAuthStatus::NotReady
);
} else {
assert!(matches!(
changes.try_recv(),
Err(tokio::sync::broadcast::error::TryRecvError::Empty)
));
}
}
Ok(())
}
@@ -117,7 +117,8 @@ pub(crate) struct AccountRequestProcessor {
workspace_routing_fetches: Arc<Mutex<workspace_routing::WorkspaceRoutingFetches>>,
workspace_routing_shutdown: CancellationToken,
gateway_login: Arc<std::sync::Mutex<Option<gateway_oauth::ActiveGatewayLogin>>>,
gateway_client: Arc<std::sync::Mutex<Option<Arc<codex_model_provider::GatewayAuthManager>>>>,
gateway_clients:
Arc<std::sync::Mutex<HashMap<String, Arc<codex_model_provider::GatewayAuthManager>>>>,
_gateway_notifications: Arc<tokio_util::task::AbortOnDropHandle<()>>,
pub(super) enterprise_login: Arc<enterprise_login::EnterpriseLoginState>,
}
@@ -151,7 +152,7 @@ impl AccountRequestProcessor {
model_catalog,
active_login: Arc::new(Mutex::new(None)),
gateway_login: Arc::new(std::sync::Mutex::new(/*t*/ None)),
gateway_client: Arc::new(std::sync::Mutex::new(/*t*/ None)),
gateway_clients: Arc::new(std::sync::Mutex::new(HashMap::new())),
workspace_routing: Arc::new(Mutex::new(None)),
workspace_routing_fetches: Arc::new(Mutex::new(HashMap::new())),
workspace_routing_shutdown: CancellationToken::new(),
@@ -30,14 +30,25 @@ impl Drop for LoginGuard {
impl AccountRequestProcessor {
async fn gateway_client(
&self,
) -> Result<(Config, Option<Arc<OAuthSessionManager>>), JSONRPCErrorError> {
let config = self
.config_manager
.load_latest_config(/*fallback_cwd*/ None)
.await
.map_err(|err| {
internal_error(format!("failed to load gateway configuration: {err}"))
})?;
model_provider: Option<String>,
) -> Result<(Arc<Config>, Option<Arc<OAuthSessionManager>>), JSONRPCErrorError> {
let config = match model_provider.as_deref() {
Some(provider_id) => {
let config = config_for_provider(&self.config, provider_id)?;
// Explicit selectors use startup definitions, so validate against current policy.
self.config_manager
.check_thread_model_provider(&config)
.await
.map_err(|err| config_load_error(&err))?;
config
}
None => Arc::new(
self.config_manager
.load_latest_config(/*fallback_cwd*/ None)
.await
.map_err(|err| config_load_error(&err))?,
),
};
let manager = codex_model_provider::create_model_provider(
config.model_provider.clone(),
Some(Arc::clone(&self.auth_manager)),
@@ -47,17 +58,21 @@ impl AccountRequestProcessor {
let session = manager
.as_deref()
.and_then(GatewayAuthManager::oauth_session);
*self
.gateway_client
.lock()
.unwrap_or_else(PoisonError::into_inner) = manager;
// Keep sign-in status alive when clients alternate between providers.
if let Some(manager) = &manager {
self.gateway_clients
.lock()
.unwrap_or_else(PoisonError::into_inner)
.insert(config.model_provider_id.clone(), Arc::clone(manager));
}
Ok((config, session))
}
pub(crate) async fn gateway_oauth_read(
&self,
params: codex_app_server_protocol::GatewayOAuthReadParams,
) -> Result<GatewayOAuthReadResponse, JSONRPCErrorError> {
let (config, client) = self.gateway_client().await?;
let (config, client) = self.gateway_client(params.model_provider).await?;
let state = if let Some(client) = client {
Some(status_fields(
client
@@ -69,8 +84,8 @@ impl AccountRequestProcessor {
None
};
Ok(GatewayOAuthReadResponse {
provider_id: config.model_provider_id,
provider_name: config.model_provider.name,
provider_id: config.model_provider_id.clone(),
provider_name: config.model_provider.name.clone(),
required: state.is_some(),
status: state.as_ref().map(|state| state.0),
error: state.and_then(|state| state.1),
@@ -79,6 +94,7 @@ impl AccountRequestProcessor {
pub(crate) async fn gateway_oauth_login(
&self,
params: codex_app_server_protocol::GatewayOAuthLoginParams,
owner: ConnectionId,
connection_gate: &crate::connection_rpc_gate::ConnectionRpcGate,
) -> Result<GatewayOAuthLoginResponse, JSONRPCErrorError> {
@@ -101,9 +117,9 @@ impl AccountRequestProcessor {
});
LoginGuard(Arc::clone(&self.gateway_login))
};
let (config, client) = self.gateway_client().await?;
let (config, client) = self.gateway_client(params.model_provider).await?;
let client = client
.ok_or_else(|| invalid_request("The current provider does not use gateway OAuth"))?;
.ok_or_else(|| invalid_request("The selected provider does not use gateway OAuth"))?;
if config.model_provider.requires_openai_auth
&& config.model_provider.env_key.is_none()
&& config.model_provider.experimental_bearer_token.is_none()
@@ -401,7 +401,7 @@ async fn read_and_cancel_without_gateway_oauth() -> Result<()> {
.await??;
assert_eq!(
error.error.message,
"The current provider does not use gateway OAuth"
"The selected provider does not use gateway OAuth"
);
Ok(())
}
@@ -686,3 +686,54 @@ async fn initialization_selects_legacy_or_explicit_gateway_login() -> Result<()>
}
Ok(())
}
/// Non-default grants are readable and notify every configured alias without starting login.
#[tokio::test]
async fn selected_gateway_read_notifies_matching_providers() -> Result<()> {
let home = TempDir::new()?;
let oauth = MockServer::start().await;
let config = gateway_config(&oauth).replace(
"model_provider = \"gateway\"",
"model_provider = \"openai\"",
);
let alias = config[config.find("[model_providers.gateway]").unwrap()..]
.replace("model_providers.gateway", "model_providers.alias");
std::fs::write(
home.path().join("config.toml"),
format!("{config}\n{alias}"),
)?;
let mut server = gateway_server(home.path()).await?;
assert!(!read_gateway(&mut server).await?.required);
let id = server
.send_raw_request(
"account/gatewayOAuth/read",
Some(serde_json::json!({"modelProvider": "gateway"})),
)
.await?;
let state: GatewayOAuthReadResponse = server.read_response(id).await?;
assert_eq!(
state,
GatewayOAuthReadResponse {
provider_id: "gateway".into(),
provider_name: "Test gateway".into(),
required: true,
status: Some(GatewayOAuthStatus::NotReady),
error: None,
}
);
let mut providers = Vec::new();
for _ in 0..2 {
let change: GatewayOAuthChangedNotification = timeout(
TIMEOUT,
server.read_notification("account/gatewayOAuth/changed"),
)
.await??;
assert_eq!(change.status, GatewayOAuthStatus::NotReady);
assert_eq!(change.auth_url, None);
providers.push(change.provider_id);
}
providers.sort();
assert_eq!(providers, ["alias", "gateway"]);
assert!(oauth.received_requests().await.unwrap().is_empty());
Ok(())
}
@@ -1842,12 +1842,32 @@ class GatewayOAuthCancelResponse(BaseModel):
)
class GatewayOAuthLoginParams(BaseModel):
model_config = ConfigDict(
populate_by_name=True,
)
model_provider: Annotated[
str | None,
Field(alias="modelProvider", description="Defaults to the current provider when omitted."),
] = None
class GatewayOAuthLoginResponse(BaseModel):
model_config = ConfigDict(
populate_by_name=True,
)
class GatewayOAuthReadParams(BaseModel):
model_config = ConfigDict(
populate_by_name=True,
)
model_provider: Annotated[
str | None,
Field(alias="modelProvider", description="Defaults to the current provider when omitted."),
] = None
class GatewayOAuthStatus(Enum):
not_ready = "notReady"
started = "started"
@@ -3086,6 +3106,14 @@ class NonSteerableTurnKind(Enum):
compact = "compact"
class NullableGatewayOAuthLoginParams(RootModel[GatewayOAuthLoginParams | None]):
root: Annotated[GatewayOAuthLoginParams | None, Field(title="Nullable_GatewayOAuthLoginParams")]
class NullableGatewayOAuthReadParams(RootModel[GatewayOAuthReadParams | None]):
root: Annotated[GatewayOAuthReadParams | None, Field(title="Nullable_GatewayOAuthReadParams")]
class NullableGetAccountRateLimitsParams(RootModel[GetAccountRateLimitsParams | None]):
root: Annotated[
GetAccountRateLimitsParams | None, Field(title="Nullable_GetAccountRateLimitsParams")
@@ -7956,7 +7984,7 @@ class AccountGatewayOAuthReadRequest(BaseModel):
method: Annotated[
Literal["account/gatewayOAuth/read"], Field(title="Account/gatewayOAuth/readRequestMethod")
]
params: Annotated[None, Field(None)] = None
params: GatewayOAuthReadParams | None = None
class AccountGatewayOAuthLoginRequest(BaseModel):
@@ -7968,7 +7996,7 @@ class AccountGatewayOAuthLoginRequest(BaseModel):
Literal["account/gatewayOAuth/login"],
Field(title="Account/gatewayOAuth/loginRequestMethod"),
]
params: Annotated[None, Field(None)] = None
params: GatewayOAuthLoginParams | None = None
class AccountGatewayOAuthCancelRequest(BaseModel):