mirror of
https://github.com/openai/codex.git
synced 2026-11-06 20:20:34 +00:00
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:
+42
-2
@@ -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": [
|
||||
|
||||
+66
-2
@@ -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": [
|
||||
|
||||
+66
-2
@@ -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": [
|
||||
|
||||
+27
@@ -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"
|
||||
}
|
||||
+27
@@ -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"
|
||||
}
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
File diff suppressed because one or more lines are too long
+12
@@ -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";
|
||||
|
||||
@@ -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")]
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user