mirror of
https://github.com/openai/codex.git
synced 2026-10-18 17:18:52 +00:00
Support Amazon Bedrock login in the app server (#33170)
## What changed - Handle experimental `account/login/start` requests with `type: "amazonBedrock"`. - Validate the API key and Mantle region, persist the managed credential, select the `amazon-bedrock` model provider, and emit the standard login and account-update notifications. - Reload configuration for account and authentication status reads so the newly selected provider is visible immediately, with the startup configuration as a fallback. - Extend the app-server test client with an Amazon Bedrock login mode that redacts API keys from request logs. ## Testing - Add app-server coverage for successful login, credential and provider persistence, notifications, refreshed account state, invalid inputs, conflicting configuration, forced ChatGPT login, external authentication, and configuration reload failure. GitOrigin-RevId: bf3ae98375caee41e78d2cdc677127138184c17a
This commit is contained in:
@@ -22,6 +22,30 @@ cargo run -p codex-app-server-test-client -- model-list
|
||||
When Codex asks a question, choose a numbered option (or `o` for a free-form answer when offered)
|
||||
and the client will send the response and continue streaming the same turn.
|
||||
|
||||
## Testing Codex-managed Amazon Bedrock login
|
||||
|
||||
`test-login --amazon-bedrock` initializes the experimental app-server API, sends an
|
||||
`account/login/start` request with an Amazon Bedrock API key, and waits for the
|
||||
`account/login/completed` and `account/updated` notifications. Login replaces the current primary
|
||||
credential and sets `model_provider = "amazon-bedrock"`, so use an isolated `CODEX_HOME` when
|
||||
testing.
|
||||
|
||||
```bash
|
||||
export CODEX_HOME="$(mktemp -d)"
|
||||
printf 'cli_auth_credentials_store = "file"\n' > "$CODEX_HOME/config.toml"
|
||||
|
||||
cargo build -p codex-cli --bin codex
|
||||
cargo run -p codex-app-server-test-client -- \
|
||||
--codex-bin ./target/debug/codex \
|
||||
test-login \
|
||||
--amazon-bedrock \
|
||||
--api-key "<BEDROCK_API_KEY>" \
|
||||
--region us-west-2
|
||||
```
|
||||
|
||||
The test client redacts `apiKey` from its outbound request log. After login, start a fresh Codex
|
||||
process with the same `CODEX_HOME` to verify that it uses the persisted managed credential.
|
||||
|
||||
## Testing Plugin Analytics
|
||||
|
||||
The `plugin-analytics-smoke` command exercises `plugin/installed`, plugin
|
||||
|
||||
@@ -230,11 +230,20 @@ enum CliCommand {
|
||||
#[arg(long)]
|
||||
abort_on: Option<usize>,
|
||||
},
|
||||
/// Trigger the ChatGPT login flow and wait for completion.
|
||||
/// Trigger a ChatGPT or Amazon Bedrock login flow.
|
||||
TestLogin {
|
||||
/// Use the device-code login flow instead of the browser callback flow.
|
||||
#[arg(long, default_value_t = false)]
|
||||
#[arg(long, default_value_t = false, conflicts_with = "amazon_bedrock")]
|
||||
device_code: bool,
|
||||
/// Use a Codex-managed Amazon Bedrock API key.
|
||||
#[arg(long, default_value_t = false, conflicts_with = "device_code")]
|
||||
amazon_bedrock: bool,
|
||||
/// Amazon Bedrock API key.
|
||||
#[arg(long, value_name = "API_KEY")]
|
||||
api_key: Option<String>,
|
||||
/// AWS Region for the Amazon Bedrock Mantle endpoint.
|
||||
#[arg(long, value_name = "REGION")]
|
||||
region: Option<String>,
|
||||
},
|
||||
/// Fetch the current account rate limits from the Codex app-server.
|
||||
GetAccountRateLimits,
|
||||
@@ -313,6 +322,12 @@ enum CliCommand {
|
||||
},
|
||||
}
|
||||
|
||||
enum TestLoginMode {
|
||||
ChatgptBrowser,
|
||||
ChatgptDeviceCode,
|
||||
AmazonBedrock { api_key: String, region: String },
|
||||
}
|
||||
|
||||
pub async fn run() -> Result<()> {
|
||||
let Cli {
|
||||
codex_bin,
|
||||
@@ -415,10 +430,24 @@ pub async fn run() -> Result<()> {
|
||||
)
|
||||
.await
|
||||
}
|
||||
CliCommand::TestLogin { device_code } => {
|
||||
CliCommand::TestLogin {
|
||||
device_code,
|
||||
amazon_bedrock,
|
||||
api_key,
|
||||
region,
|
||||
} => {
|
||||
ensure_dynamic_tools_unused(&dynamic_tools, "test-login")?;
|
||||
let endpoint = resolve_endpoint(codex_bin, url)?;
|
||||
test_login(&endpoint, &config_overrides, device_code).await
|
||||
let mode = if amazon_bedrock {
|
||||
let api_key = api_key.context("--api-key is required with --amazon-bedrock")?;
|
||||
let region = region.context("--region is required with --amazon-bedrock")?;
|
||||
TestLoginMode::AmazonBedrock { api_key, region }
|
||||
} else if device_code {
|
||||
TestLoginMode::ChatgptDeviceCode
|
||||
} else {
|
||||
TestLoginMode::ChatgptBrowser
|
||||
};
|
||||
test_login(&endpoint, &config_overrides, mode).await
|
||||
}
|
||||
CliCommand::GetAccountRateLimits => {
|
||||
ensure_dynamic_tools_unused(&dynamic_tools, "get-account-rate-limits")?;
|
||||
@@ -1128,16 +1157,45 @@ async fn send_follow_up_v2(
|
||||
async fn test_login(
|
||||
endpoint: &Endpoint,
|
||||
config_overrides: &[String],
|
||||
device_code: bool,
|
||||
mode: TestLoginMode,
|
||||
) -> Result<()> {
|
||||
with_client("test-login", endpoint, config_overrides, |client| {
|
||||
let initialize = client.initialize()?;
|
||||
println!("< initialize response: {initialize:?}");
|
||||
|
||||
let login_response = if device_code {
|
||||
client.login_account_chatgpt_device_code()?
|
||||
} else {
|
||||
client.login_account_chatgpt()?
|
||||
let login_response = match mode {
|
||||
TestLoginMode::ChatgptBrowser => client.login_account_chatgpt()?,
|
||||
TestLoginMode::ChatgptDeviceCode => client.login_account_chatgpt_device_code()?,
|
||||
TestLoginMode::AmazonBedrock { api_key, region } => {
|
||||
let request_id = client.request_id();
|
||||
let login_response: LoginAccountResponse = client.send_request(
|
||||
ClientRequest::LoginAccount {
|
||||
request_id: request_id.clone(),
|
||||
params: codex_app_server_protocol::LoginAccountParams::AmazonBedrock {
|
||||
api_key,
|
||||
region,
|
||||
},
|
||||
},
|
||||
request_id,
|
||||
"account/login/start",
|
||||
)?;
|
||||
println!("< account/login/start response: {login_response:?}");
|
||||
|
||||
let completion =
|
||||
client.wait_for_account_login_completion(/*expected_login_id*/ None)?;
|
||||
println!("< account/login/completed notification: {completion:?}");
|
||||
|
||||
loop {
|
||||
let notification = client.next_notification()?;
|
||||
if let Ok(ServerNotification::AccountUpdated(account_updated)) =
|
||||
ServerNotification::try_from(notification)
|
||||
{
|
||||
println!("< account/updated notification: {account_updated:?}");
|
||||
break;
|
||||
}
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
println!("< account/login/start response: {login_response:?}");
|
||||
let login_id = match login_response {
|
||||
@@ -1158,7 +1216,7 @@ async fn test_login(
|
||||
_ => bail!("expected chatgpt login response"),
|
||||
};
|
||||
|
||||
let completion = client.wait_for_account_login_completion(&login_id)?;
|
||||
let completion = client.wait_for_account_login_completion(Some(&login_id))?;
|
||||
println!("< account/login/completed notification: {completion:?}");
|
||||
|
||||
if completion.success {
|
||||
@@ -1799,7 +1857,7 @@ impl CodexClient {
|
||||
|
||||
fn wait_for_account_login_completion(
|
||||
&mut self,
|
||||
expected_login_id: &str,
|
||||
expected_login_id: Option<&str>,
|
||||
) -> Result<AccountLoginCompletedNotification> {
|
||||
loop {
|
||||
let notification = self.next_notification()?;
|
||||
@@ -1807,7 +1865,7 @@ impl CodexClient {
|
||||
if let Ok(server_notification) = ServerNotification::try_from(notification) {
|
||||
match server_notification {
|
||||
ServerNotification::AccountLoginCompleted(completion) => {
|
||||
if completion.login_id.as_deref() == Some(expected_login_id) {
|
||||
if completion.login_id.as_deref() == expected_login_id {
|
||||
return Ok(completion);
|
||||
}
|
||||
|
||||
@@ -1956,7 +2014,13 @@ impl CodexClient {
|
||||
.context("client request was not a valid JSON-RPC request")?;
|
||||
request.trace = current_span_w3c_trace_context();
|
||||
let request_json = serde_json::to_string(&request)?;
|
||||
let request_pretty = serde_json::to_string_pretty(&request)?;
|
||||
let mut request_for_logging = serde_json::to_value(&request)?;
|
||||
if request.method == "account/login/start"
|
||||
&& let Some(api_key) = request_for_logging.pointer_mut("/params/apiKey")
|
||||
{
|
||||
*api_key = Value::String("<redacted>".to_string());
|
||||
}
|
||||
let request_pretty = serde_json::to_string_pretty(&request_for_logging)?;
|
||||
print_multiline_with_prefix("> ", &request_pretty);
|
||||
self.write_payload(&request_json)
|
||||
}
|
||||
|
||||
@@ -392,6 +392,7 @@ use codex_login::ServerOptions as LoginServerOptions;
|
||||
use codex_login::ShutdownHandle;
|
||||
use codex_login::complete_device_code_login;
|
||||
use codex_login::login_with_api_key;
|
||||
use codex_login::login_with_bedrock_api_key;
|
||||
use codex_login::oauth_client_id;
|
||||
use codex_login::request_device_code;
|
||||
use codex_login::run_login_server;
|
||||
@@ -506,6 +507,7 @@ use codex_app_server_protocol::ServerRequest;
|
||||
|
||||
mod account_processor;
|
||||
mod apps_processor;
|
||||
mod bedrock_auth;
|
||||
mod catalog_processor;
|
||||
mod command_exec_processor;
|
||||
mod config_processor;
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
use super::bedrock_auth::set_user_model_provider_to_bedrock;
|
||||
use super::*;
|
||||
use crate::auth_mode::auth_mode_to_api;
|
||||
use crate::external_auth::ExternalAuthBridge;
|
||||
use chrono::DateTime;
|
||||
use codex_model_provider::is_supported_amazon_bedrock_region;
|
||||
|
||||
mod rate_limit_resets;
|
||||
|
||||
@@ -191,6 +193,20 @@ impl AccountRequestProcessor {
|
||||
}
|
||||
}
|
||||
|
||||
async fn load_latest_config(&self) -> Config {
|
||||
match self
|
||||
.config_manager
|
||||
.load_latest_config(/*fallback_cwd*/ None)
|
||||
.await
|
||||
{
|
||||
Ok(config) => config,
|
||||
Err(err) => {
|
||||
tracing::warn!("failed to reload config, using startup config: {err}");
|
||||
self.config.as_ref().clone()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn maybe_refresh_plugin_caches_for_current_config(
|
||||
config_manager: &ConfigManager,
|
||||
thread_manager: &Arc<ThreadManager>,
|
||||
@@ -293,8 +309,9 @@ impl AccountRequestProcessor {
|
||||
)
|
||||
.await;
|
||||
}
|
||||
LoginAccountParams::AmazonBedrock { .. } => {
|
||||
return Err(invalid_request("Amazon Bedrock login is not implemented"));
|
||||
LoginAccountParams::AmazonBedrock { api_key, region } => {
|
||||
self.login_amazon_bedrock_v2(request_id, api_key, region)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
@@ -359,6 +376,65 @@ impl AccountRequestProcessor {
|
||||
}
|
||||
}
|
||||
|
||||
async fn login_amazon_bedrock_v2(
|
||||
&self,
|
||||
request_id: ConnectionRequestId,
|
||||
api_key: String,
|
||||
region: String,
|
||||
) {
|
||||
let result = async {
|
||||
if self.auth_manager.is_external_chatgpt_auth_active() {
|
||||
return Err(self.external_auth_active_error());
|
||||
}
|
||||
if matches!(
|
||||
self.config.forced_login_method,
|
||||
Some(ForcedLoginMethod::Chatgpt)
|
||||
) {
|
||||
return Err(invalid_request(
|
||||
"Amazon Bedrock login is disabled. Use ChatGPT login instead.",
|
||||
));
|
||||
}
|
||||
|
||||
let api_key = api_key.trim();
|
||||
if api_key.is_empty() {
|
||||
return Err(invalid_request("Amazon Bedrock API key must not be empty."));
|
||||
}
|
||||
let region = region.trim();
|
||||
if !is_supported_amazon_bedrock_region(region) {
|
||||
return Err(invalid_request(format!(
|
||||
"Amazon Bedrock Mantle does not support region `{region}`"
|
||||
)));
|
||||
}
|
||||
|
||||
{
|
||||
let mut guard = self.active_login.lock().await;
|
||||
if let Some(active) = guard.take() {
|
||||
drop(active);
|
||||
}
|
||||
}
|
||||
|
||||
set_user_model_provider_to_bedrock(&self.config_manager).await?;
|
||||
login_with_bedrock_api_key(
|
||||
&self.config.codex_home,
|
||||
api_key,
|
||||
region,
|
||||
self.config.cli_auth_credentials_store_mode,
|
||||
self.config.auth_keyring_backend_kind(),
|
||||
)
|
||||
.map_err(|err| internal_error(format!("failed to save Amazon Bedrock auth: {err}")))?;
|
||||
self.auth_manager.reload().await;
|
||||
Ok(LoginAccountResponse::AmazonBedrock {})
|
||||
}
|
||||
.await;
|
||||
let logged_in = result.is_ok();
|
||||
self.outgoing.send_result(request_id, result).await;
|
||||
|
||||
if logged_in {
|
||||
self.send_login_success_notifications(/*login_id*/ None)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
|
||||
// Build options for a ChatGPT login attempt; performs validation.
|
||||
async fn login_chatgpt_common(
|
||||
&self,
|
||||
@@ -837,7 +913,8 @@ impl AccountRequestProcessor {
|
||||
// Determine whether auth is required based on the active model provider.
|
||||
// If a custom provider is configured with `requires_openai_auth == false`,
|
||||
// then no auth step is required; otherwise, default to requiring auth.
|
||||
let requires_openai_auth = self.config.model_provider.requires_openai_auth;
|
||||
let config = self.load_latest_config().await;
|
||||
let requires_openai_auth = config.model_provider.requires_openai_auth;
|
||||
|
||||
let response = if !requires_openai_auth {
|
||||
GetAuthStatusResponse {
|
||||
@@ -905,10 +982,9 @@ impl AccountRequestProcessor {
|
||||
|
||||
self.refresh_token_if_requested(do_refresh).await;
|
||||
|
||||
let provider = create_model_provider(
|
||||
self.config.model_provider.clone(),
|
||||
Some(self.auth_manager.clone()),
|
||||
);
|
||||
let config = self.load_latest_config().await;
|
||||
let provider =
|
||||
create_model_provider(config.model_provider, Some(self.auth_manager.clone()));
|
||||
let account_state = match provider.account_state() {
|
||||
Ok(account_state) => account_state,
|
||||
Err(err) => return Err(invalid_request(err.to_string())),
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
use super::config_processor::map_error as map_config_error;
|
||||
use crate::config_manager::ConfigManager;
|
||||
use crate::error_code::internal_error;
|
||||
use crate::error_code::invalid_request;
|
||||
use codex_app_server_protocol::ConfigValueWriteParams;
|
||||
use codex_app_server_protocol::JSONRPCErrorError;
|
||||
use codex_app_server_protocol::MergeStrategy;
|
||||
use codex_config::CONFIG_TOML_FILE;
|
||||
use codex_config::ConfigLayerSource;
|
||||
use codex_config::format_config_layer_source;
|
||||
use codex_model_provider::AMAZON_BEDROCK_PROVIDER_ID;
|
||||
|
||||
pub(super) async fn set_user_model_provider_to_bedrock(
|
||||
config_manager: &ConfigManager,
|
||||
) -> Result<(), JSONRPCErrorError> {
|
||||
let layers = config_manager
|
||||
.load_config_layers(/*cwd*/ None)
|
||||
.await
|
||||
.map_err(|err| internal_error(format!("failed to load configuration layers: {err}")))?;
|
||||
let user_precedence = match layers.get_active_user_layer() {
|
||||
Some(layer) => layer.name.precedence(),
|
||||
None => ConfigLayerSource::User {
|
||||
file: config_manager.user_config_path().map_err(|err| {
|
||||
internal_error(format!("failed to resolve user config path: {err}"))
|
||||
})?,
|
||||
profile: None,
|
||||
}
|
||||
.precedence(),
|
||||
};
|
||||
if let Some((overriding_layer, effective_provider)) = layers
|
||||
.layers_high_to_low()
|
||||
.into_iter()
|
||||
.filter(|layer| layer.name.precedence() > user_precedence)
|
||||
.find_map(|layer| {
|
||||
layer
|
||||
.config
|
||||
.get("model_provider")
|
||||
.map(|value| (layer, value))
|
||||
})
|
||||
&& effective_provider.as_str() != Some(AMAZON_BEDROCK_PROVIDER_ID)
|
||||
{
|
||||
let source = format_config_layer_source(&overriding_layer.name, CONFIG_TOML_FILE);
|
||||
return Err(invalid_request(format!(
|
||||
"Amazon Bedrock login cannot select `{AMAZON_BEDROCK_PROVIDER_ID}` because {source} sets `model_provider` to {effective_provider}"
|
||||
)));
|
||||
}
|
||||
|
||||
write_user_model_provider(
|
||||
config_manager,
|
||||
serde_json::json!(AMAZON_BEDROCK_PROVIDER_ID),
|
||||
/*expected_version*/ None,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn write_user_model_provider(
|
||||
config_manager: &ConfigManager,
|
||||
value: serde_json::Value,
|
||||
expected_version: Option<String>,
|
||||
) -> Result<(), JSONRPCErrorError> {
|
||||
config_manager
|
||||
.write_value(ConfigValueWriteParams {
|
||||
key_path: "model_provider".to_string(),
|
||||
value,
|
||||
merge_strategy: MergeStrategy::Replace,
|
||||
file_path: None,
|
||||
expected_version,
|
||||
})
|
||||
.await
|
||||
.map(|_| ())
|
||||
.map_err(map_config_error)
|
||||
}
|
||||
@@ -555,7 +555,7 @@ fn map_network_unix_socket_permission_to_api(
|
||||
}
|
||||
}
|
||||
|
||||
fn map_error(err: ConfigManagerError) -> JSONRPCErrorError {
|
||||
pub(super) fn map_error(err: ConfigManagerError) -> JSONRPCErrorError {
|
||||
if let Some(code) = err.write_error_code() {
|
||||
return config_write_error(code, err.to_string());
|
||||
}
|
||||
|
||||
@@ -1335,6 +1335,20 @@ impl TestAppServer {
|
||||
self.send_login_account_request(params).await
|
||||
}
|
||||
|
||||
/// Send an `account/login/start` JSON-RPC request for managed Amazon Bedrock login.
|
||||
pub async fn send_login_account_amazon_bedrock_request(
|
||||
&mut self,
|
||||
api_key: &str,
|
||||
region: &str,
|
||||
) -> anyhow::Result<i64> {
|
||||
let params = serde_json::json!({
|
||||
"type": "amazonBedrock",
|
||||
"apiKey": api_key,
|
||||
"region": region,
|
||||
});
|
||||
self.send_request("account/login/start", Some(params)).await
|
||||
}
|
||||
|
||||
/// Send an `account/login/start` JSON-RPC request for ChatGPT login.
|
||||
pub async fn send_login_account_chatgpt_request(&mut self) -> anyhow::Result<i64> {
|
||||
let params = serde_json::json!({
|
||||
|
||||
@@ -5,24 +5,30 @@ use app_test_support::to_response;
|
||||
|
||||
use app_test_support::ChatGptAuthFixture;
|
||||
use app_test_support::ChatGptIdTokenClaims;
|
||||
use app_test_support::DEFAULT_CLIENT_NAME;
|
||||
use app_test_support::encode_id_token;
|
||||
use app_test_support::write_chatgpt_auth;
|
||||
use app_test_support::write_models_cache;
|
||||
use chrono::Duration as ChronoDuration;
|
||||
use chrono::Utc;
|
||||
use codex_app_server_protocol::Account;
|
||||
use codex_app_server_protocol::AccountLoginCompletedNotification;
|
||||
use codex_app_server_protocol::AccountUpdatedNotification;
|
||||
use codex_app_server_protocol::AuthMode;
|
||||
use codex_app_server_protocol::CancelLoginAccountParams;
|
||||
use codex_app_server_protocol::CancelLoginAccountResponse;
|
||||
use codex_app_server_protocol::CancelLoginAccountStatus;
|
||||
use codex_app_server_protocol::ChatgptAuthTokensRefreshReason;
|
||||
use codex_app_server_protocol::ChatgptAuthTokensRefreshResponse;
|
||||
use codex_app_server_protocol::ClientInfo;
|
||||
use codex_app_server_protocol::GetAccountParams;
|
||||
use codex_app_server_protocol::GetAccountResponse;
|
||||
use codex_app_server_protocol::GetAuthStatusParams;
|
||||
use codex_app_server_protocol::GetAuthStatusResponse;
|
||||
use codex_app_server_protocol::InitializeCapabilities;
|
||||
use codex_app_server_protocol::JSONRPCError;
|
||||
use codex_app_server_protocol::JSONRPCErrorError;
|
||||
use codex_app_server_protocol::JSONRPCMessage;
|
||||
use codex_app_server_protocol::JSONRPCNotification;
|
||||
use codex_app_server_protocol::JSONRPCResponse;
|
||||
use codex_app_server_protocol::LoginAccountResponse;
|
||||
@@ -33,13 +39,17 @@ use codex_app_server_protocol::ServerRequest;
|
||||
use codex_app_server_protocol::TurnCompletedNotification;
|
||||
use codex_app_server_protocol::TurnStatus;
|
||||
use codex_config::types::AuthCredentialsStoreMode;
|
||||
use codex_login::AuthDotJson;
|
||||
use codex_login::AuthKeyringBackendKind;
|
||||
use codex_login::CLIENT_ID_OVERRIDE_ENV_VAR;
|
||||
use codex_login::REFRESH_TOKEN_URL_OVERRIDE_ENV_VAR;
|
||||
use codex_login::auth::BedrockApiKeyAuth;
|
||||
use codex_login::load_auth_dot_json;
|
||||
use codex_login::login_with_api_key;
|
||||
use codex_login::login_with_bedrock_api_key;
|
||||
use codex_protocol::account::AmazonBedrockCredentialSource;
|
||||
use codex_protocol::account::PlanType as AccountPlanType;
|
||||
use codex_protocol::auth::AuthMode as DomainAuthMode;
|
||||
use core_test_support::responses;
|
||||
use pretty_assertions::assert_eq;
|
||||
use serde_json::json;
|
||||
@@ -142,6 +152,56 @@ shell_snapshot = false
|
||||
std::fs::write(config_toml, contents)
|
||||
}
|
||||
|
||||
fn read_config_toml(codex_home: &Path) -> Result<toml::Value> {
|
||||
Ok(toml::from_str(&std::fs::read_to_string(
|
||||
codex_home.join("config.toml"),
|
||||
)?)?)
|
||||
}
|
||||
|
||||
fn load_file_auth(codex_home: &Path) -> Result<Option<AuthDotJson>> {
|
||||
Ok(load_auth_dot_json(
|
||||
codex_home,
|
||||
AuthCredentialsStoreMode::File,
|
||||
AuthKeyringBackendKind::default(),
|
||||
)?)
|
||||
}
|
||||
|
||||
async fn read_account(mcp: &mut TestAppServer) -> Result<GetAccountResponse> {
|
||||
let request_id = mcp
|
||||
.send_get_account_request(GetAccountParams {
|
||||
refresh_token: false,
|
||||
})
|
||||
.await?;
|
||||
let response = timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
|
||||
)
|
||||
.await??;
|
||||
to_response(response)
|
||||
}
|
||||
|
||||
async fn assert_account_updated(
|
||||
mcp: &mut TestAppServer,
|
||||
auth_mode: Option<AuthMode>,
|
||||
) -> Result<()> {
|
||||
let notification = timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_notification_message("account/updated"),
|
||||
)
|
||||
.await??;
|
||||
let ServerNotification::AccountUpdated(payload) = notification.try_into()? else {
|
||||
bail!("unexpected notification")
|
||||
};
|
||||
assert_eq!(
|
||||
payload,
|
||||
AccountUpdatedNotification {
|
||||
auth_mode,
|
||||
plan_type: None,
|
||||
}
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn mock_device_code_usercode(server: &MockServer, interval_seconds: u64) {
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/api/accounts/deviceauth/usercode"))
|
||||
@@ -1011,6 +1071,420 @@ async fn login_account_api_key_succeeds_and_notifies() -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn login_amazon_bedrock_replaces_primary_auth_and_persists_provider() -> Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
create_config_toml(codex_home.path(), CreateConfigTomlParams::default())?;
|
||||
login_with_api_key(
|
||||
codex_home.path(),
|
||||
"sk-test-key",
|
||||
AuthCredentialsStoreMode::File,
|
||||
AuthKeyringBackendKind::default(),
|
||||
)?;
|
||||
let mut mcp = TestAppServer::builder()
|
||||
.with_codex_home(codex_home.path())
|
||||
.without_auto_env()
|
||||
.with_env_overrides(&[("OPENAI_API_KEY", None)])
|
||||
.build()
|
||||
.await?;
|
||||
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
|
||||
let mut expected_config = read_config_toml(codex_home.path())?;
|
||||
expected_config
|
||||
.as_table_mut()
|
||||
.expect("config should be a table")
|
||||
.insert(
|
||||
"model_provider".to_string(),
|
||||
toml::Value::String("amazon-bedrock".to_string()),
|
||||
);
|
||||
let request_id = mcp
|
||||
.send_login_account_amazon_bedrock_request(" managed-bedrock-api-key ", " us-west-2 ")
|
||||
.await?;
|
||||
let response: JSONRPCResponse = timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
|
||||
)
|
||||
.await??;
|
||||
assert_eq!(
|
||||
to_response::<LoginAccountResponse>(response)?,
|
||||
LoginAccountResponse::AmazonBedrock {}
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
load_file_auth(codex_home.path())?,
|
||||
Some(AuthDotJson {
|
||||
auth_mode: Some(DomainAuthMode::BedrockApiKey),
|
||||
openai_api_key: None,
|
||||
tokens: None,
|
||||
last_refresh: None,
|
||||
agent_identity: None,
|
||||
personal_access_token: None,
|
||||
bedrock_api_key: Some(BedrockApiKeyAuth {
|
||||
api_key: "managed-bedrock-api-key".to_string(),
|
||||
region: "us-west-2".to_string(),
|
||||
}),
|
||||
})
|
||||
);
|
||||
assert_eq!(read_config_toml(codex_home.path())?, expected_config);
|
||||
|
||||
let notification = timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_notification_message("account/login/completed"),
|
||||
)
|
||||
.await??;
|
||||
let ServerNotification::AccountLoginCompleted(payload) = notification.try_into()? else {
|
||||
bail!("unexpected notification")
|
||||
};
|
||||
assert_eq!(
|
||||
payload,
|
||||
AccountLoginCompletedNotification {
|
||||
login_id: None,
|
||||
success: true,
|
||||
error: None,
|
||||
}
|
||||
);
|
||||
assert_account_updated(&mut mcp, Some(AuthMode::BedrockApiKey)).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn login_amazon_bedrock_rejects_non_bedrock_provider_override_without_changes() -> Result<()>
|
||||
{
|
||||
let codex_home = TempDir::new()?;
|
||||
create_config_toml(codex_home.path(), CreateConfigTomlParams::default())?;
|
||||
login_with_api_key(
|
||||
codex_home.path(),
|
||||
"sk-test-key",
|
||||
AuthCredentialsStoreMode::File,
|
||||
AuthKeyringBackendKind::default(),
|
||||
)?;
|
||||
let expected_auth = load_file_auth(codex_home.path())?;
|
||||
let expected_config = read_config_toml(codex_home.path())?;
|
||||
|
||||
let mut mcp = TestAppServer::builder()
|
||||
.with_codex_home(codex_home.path())
|
||||
.without_auto_env()
|
||||
.with_env_overrides(&[("OPENAI_API_KEY", None)])
|
||||
.with_args(&["-c", "model_provider=\"mock_provider\""])
|
||||
.build()
|
||||
.await?;
|
||||
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
|
||||
|
||||
let request_id = mcp
|
||||
.send_login_account_amazon_bedrock_request("managed-bedrock-api-key", "us-west-2")
|
||||
.await?;
|
||||
let error = timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_error_message(RequestId::Integer(request_id)),
|
||||
)
|
||||
.await??;
|
||||
assert_eq!(
|
||||
error.error.message,
|
||||
"Amazon Bedrock login cannot select `amazon-bedrock` because session-flags sets `model_provider` to \"mock_provider\""
|
||||
);
|
||||
assert_eq!(load_file_auth(codex_home.path())?, expected_auth);
|
||||
assert_eq!(read_config_toml(codex_home.path())?, expected_config);
|
||||
|
||||
let maybe_completed = timeout(
|
||||
Duration::from_millis(500),
|
||||
mcp.read_stream_until_notification_message("account/login/completed"),
|
||||
)
|
||||
.await;
|
||||
assert!(
|
||||
maybe_completed.is_err(),
|
||||
"account/login/completed should not be emitted when the provider is overridden"
|
||||
);
|
||||
let maybe_updated = timeout(
|
||||
Duration::from_millis(500),
|
||||
mcp.read_stream_until_notification_message("account/updated"),
|
||||
)
|
||||
.await;
|
||||
assert!(
|
||||
maybe_updated.is_err(),
|
||||
"account/updated should not be emitted when the provider is overridden"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn login_amazon_bedrock_allows_bedrock_provider_override() -> Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
create_config_toml(codex_home.path(), CreateConfigTomlParams::default())?;
|
||||
let mut expected_config = read_config_toml(codex_home.path())?;
|
||||
expected_config
|
||||
.as_table_mut()
|
||||
.expect("config should be a table")
|
||||
.insert(
|
||||
"model_provider".to_string(),
|
||||
toml::Value::String("amazon-bedrock".to_string()),
|
||||
);
|
||||
|
||||
let mut mcp = TestAppServer::builder()
|
||||
.with_codex_home(codex_home.path())
|
||||
.without_auto_env()
|
||||
.with_env_overrides(&[("OPENAI_API_KEY", None)])
|
||||
.with_args(&["-c", "model_provider=\"amazon-bedrock\""])
|
||||
.build()
|
||||
.await?;
|
||||
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
|
||||
|
||||
let request_id = mcp
|
||||
.send_login_account_amazon_bedrock_request("managed-bedrock-api-key", "us-west-2")
|
||||
.await?;
|
||||
let response = timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
|
||||
)
|
||||
.await??;
|
||||
assert_eq!(
|
||||
to_response::<LoginAccountResponse>(response)?,
|
||||
LoginAccountResponse::AmazonBedrock {}
|
||||
);
|
||||
assert_eq!(
|
||||
load_file_auth(codex_home.path())?,
|
||||
Some(AuthDotJson {
|
||||
auth_mode: Some(DomainAuthMode::BedrockApiKey),
|
||||
openai_api_key: None,
|
||||
tokens: None,
|
||||
last_refresh: None,
|
||||
agent_identity: None,
|
||||
personal_access_token: None,
|
||||
bedrock_api_key: Some(BedrockApiKeyAuth {
|
||||
api_key: "managed-bedrock-api-key".to_string(),
|
||||
region: "us-west-2".to_string(),
|
||||
}),
|
||||
})
|
||||
);
|
||||
assert_eq!(read_config_toml(codex_home.path())?, expected_config);
|
||||
timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_notification_message("account/login/completed"),
|
||||
)
|
||||
.await??;
|
||||
assert_account_updated(&mut mcp, Some(AuthMode::BedrockApiKey)).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn managed_bedrock_login_requires_experimental_api() -> Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
create_config_toml(codex_home.path(), CreateConfigTomlParams::default())?;
|
||||
let mut mcp = TestAppServer::builder()
|
||||
.with_codex_home(codex_home.path())
|
||||
.without_auto_env()
|
||||
.build()
|
||||
.await?;
|
||||
let initialized = mcp
|
||||
.initialize_with_capabilities(
|
||||
ClientInfo {
|
||||
name: DEFAULT_CLIENT_NAME.to_string(),
|
||||
title: None,
|
||||
version: "0.1.0".to_string(),
|
||||
},
|
||||
Some(InitializeCapabilities {
|
||||
experimental_api: false,
|
||||
..Default::default()
|
||||
}),
|
||||
)
|
||||
.await?;
|
||||
assert!(matches!(initialized, JSONRPCMessage::Response(_)));
|
||||
|
||||
let request_id = mcp
|
||||
.send_login_account_amazon_bedrock_request("managed-bedrock-api-key", "us-west-2")
|
||||
.await?;
|
||||
let error = timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_error_message(RequestId::Integer(request_id)),
|
||||
)
|
||||
.await??;
|
||||
assert_eq!(
|
||||
error.error.message,
|
||||
"account/login/start.amazonBedrock requires experimentalApi capability"
|
||||
);
|
||||
assert_eq!(load_file_auth(codex_home.path())?, None);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn login_managed_bedrock_updates_active_bedrock_account() -> Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
create_config_toml(codex_home.path(), CreateConfigTomlParams::default())?;
|
||||
|
||||
let mut mcp = TestAppServer::builder()
|
||||
.with_codex_home(codex_home.path())
|
||||
.without_auto_env()
|
||||
.with_env_overrides(&[("OPENAI_API_KEY", None)])
|
||||
.build()
|
||||
.await?;
|
||||
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
|
||||
let request_id = mcp
|
||||
.send_login_account_amazon_bedrock_request("managed-bedrock-api-key", "us-west-2")
|
||||
.await?;
|
||||
let response: JSONRPCResponse = timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
|
||||
)
|
||||
.await??;
|
||||
assert_eq!(
|
||||
to_response::<LoginAccountResponse>(response)?,
|
||||
LoginAccountResponse::AmazonBedrock {}
|
||||
);
|
||||
timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_notification_message("account/login/completed"),
|
||||
)
|
||||
.await??;
|
||||
assert_account_updated(&mut mcp, Some(AuthMode::BedrockApiKey)).await?;
|
||||
assert_eq!(
|
||||
read_account(&mut mcp).await?,
|
||||
GetAccountResponse {
|
||||
account: Some(Account::AmazonBedrock {
|
||||
credential_source: AmazonBedrockCredentialSource::CodexManaged,
|
||||
}),
|
||||
requires_openai_auth: false,
|
||||
}
|
||||
);
|
||||
|
||||
assert!(codex_home.path().join("auth.json").exists());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn login_account_amazon_bedrock_rejects_invalid_credentials_without_changes() -> Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
create_config_toml(codex_home.path(), CreateConfigTomlParams::default())?;
|
||||
|
||||
let mut mcp = TestAppServer::builder()
|
||||
.with_codex_home(codex_home.path())
|
||||
.without_auto_env()
|
||||
.with_env_overrides(&[("OPENAI_API_KEY", None)])
|
||||
.build()
|
||||
.await?;
|
||||
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
|
||||
let expected_config = read_config_toml(codex_home.path())?;
|
||||
|
||||
let request_id = mcp
|
||||
.send_login_account_amazon_bedrock_request(" ", "us-west-2")
|
||||
.await?;
|
||||
let error = timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_error_message(RequestId::Integer(request_id)),
|
||||
)
|
||||
.await??;
|
||||
assert_eq!(
|
||||
error.error.message,
|
||||
"Amazon Bedrock API key must not be empty."
|
||||
);
|
||||
|
||||
let request_id = mcp
|
||||
.send_login_account_amazon_bedrock_request("managed-bedrock-api-key", "us-west-1")
|
||||
.await?;
|
||||
let error = timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_error_message(RequestId::Integer(request_id)),
|
||||
)
|
||||
.await??;
|
||||
assert_eq!(
|
||||
error.error.message,
|
||||
"Amazon Bedrock Mantle does not support region `us-west-1`"
|
||||
);
|
||||
assert_eq!(load_file_auth(codex_home.path())?, None);
|
||||
assert_eq!(read_config_toml(codex_home.path())?, expected_config);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn login_account_amazon_bedrock_rejected_when_forced_chatgpt() -> Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
create_config_toml(
|
||||
codex_home.path(),
|
||||
CreateConfigTomlParams {
|
||||
forced_method: Some("chatgpt".to_string()),
|
||||
..Default::default()
|
||||
},
|
||||
)?;
|
||||
|
||||
let mut mcp = TestAppServer::builder()
|
||||
.with_codex_home(codex_home.path())
|
||||
.without_auto_env()
|
||||
.build()
|
||||
.await?;
|
||||
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
|
||||
let request_id = mcp
|
||||
.send_login_account_amazon_bedrock_request("managed-bedrock-api-key", "us-west-2")
|
||||
.await?;
|
||||
let error = timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_error_message(RequestId::Integer(request_id)),
|
||||
)
|
||||
.await??;
|
||||
|
||||
assert_eq!(
|
||||
error.error.message,
|
||||
"Amazon Bedrock login is disabled. Use ChatGPT login instead."
|
||||
);
|
||||
assert_eq!(load_file_auth(codex_home.path())?, None);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn login_account_amazon_bedrock_rejected_with_external_chatgpt_auth() -> Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
create_config_toml(codex_home.path(), CreateConfigTomlParams::default())?;
|
||||
let access_token = encode_id_token(
|
||||
&ChatGptIdTokenClaims::new()
|
||||
.email("embedded@example.com")
|
||||
.plan_type("pro")
|
||||
.chatgpt_account_id(WORKSPACE_ID_EMBEDDED),
|
||||
)?;
|
||||
|
||||
let mut mcp = TestAppServer::builder()
|
||||
.with_codex_home(codex_home.path())
|
||||
.without_auto_env()
|
||||
.build()
|
||||
.await?;
|
||||
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
|
||||
let set_id = mcp
|
||||
.send_chatgpt_auth_tokens_login_request(
|
||||
access_token,
|
||||
WORKSPACE_ID_EMBEDDED.to_string(),
|
||||
Some("pro".to_string()),
|
||||
)
|
||||
.await?;
|
||||
let set_response = timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_response_message(RequestId::Integer(set_id)),
|
||||
)
|
||||
.await??;
|
||||
assert_eq!(
|
||||
to_response::<LoginAccountResponse>(set_response)?,
|
||||
LoginAccountResponse::ChatgptAuthTokens {}
|
||||
);
|
||||
timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_notification_message("account/updated"),
|
||||
)
|
||||
.await??;
|
||||
|
||||
let request_id = mcp
|
||||
.send_login_account_amazon_bedrock_request("managed-bedrock-api-key", "us-west-2")
|
||||
.await?;
|
||||
let error = timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_error_message(RequestId::Integer(request_id)),
|
||||
)
|
||||
.await??;
|
||||
assert_eq!(
|
||||
error.error.message,
|
||||
"External auth is active. Use account/login/start (chatgptAuthTokens) to update it or account/logout to clear it."
|
||||
);
|
||||
assert_eq!(load_file_auth(codex_home.path())?, None);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn login_account_api_key_rejected_when_forced_chatgpt() -> Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
@@ -1903,6 +2377,66 @@ region = "us-west-2"
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn account_reads_use_startup_config_when_config_reload_fails() -> Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
create_config_toml(
|
||||
codex_home.path(),
|
||||
CreateConfigTomlParams {
|
||||
model_provider_id: Some("amazon-bedrock".to_string()),
|
||||
extra_provider_config: Some(
|
||||
r#"[model_providers.amazon-bedrock.aws]
|
||||
profile = "codex-bedrock"
|
||||
region = "us-west-2"
|
||||
"#
|
||||
.to_string(),
|
||||
),
|
||||
..Default::default()
|
||||
},
|
||||
)?;
|
||||
|
||||
let mut mcp = TestAppServer::builder()
|
||||
.with_codex_home(codex_home.path())
|
||||
.without_auto_env()
|
||||
.build()
|
||||
.await?;
|
||||
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
|
||||
|
||||
std::fs::write(codex_home.path().join("config.toml"), "invalid = [")?;
|
||||
|
||||
assert_eq!(
|
||||
read_account(&mut mcp).await?,
|
||||
GetAccountResponse {
|
||||
account: Some(Account::AmazonBedrock {
|
||||
credential_source: AmazonBedrockCredentialSource::AwsManaged,
|
||||
}),
|
||||
requires_openai_auth: false,
|
||||
}
|
||||
);
|
||||
|
||||
let request_id = mcp
|
||||
.send_get_auth_status_request(GetAuthStatusParams {
|
||||
include_token: Some(false),
|
||||
refresh_token: Some(false),
|
||||
})
|
||||
.await?;
|
||||
let response = timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
|
||||
)
|
||||
.await??;
|
||||
assert_eq!(
|
||||
to_response::<GetAuthStatusResponse>(response)?,
|
||||
GetAuthStatusResponse {
|
||||
auth_method: None,
|
||||
auth_token: None,
|
||||
requires_openai_auth: Some(false),
|
||||
}
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_account_with_managed_bedrock_provider() -> Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
|
||||
@@ -39,8 +39,13 @@ pub(super) fn region_from_config(aws: &ModelProviderAwsAuthInfo) -> Option<Strin
|
||||
.map(str::to_string)
|
||||
}
|
||||
|
||||
/// Returns whether Amazon Bedrock Mantle is available in `region`.
|
||||
pub fn is_supported_amazon_bedrock_region(region: &str) -> bool {
|
||||
BEDROCK_MANTLE_SUPPORTED_REGIONS.contains(®ion)
|
||||
}
|
||||
|
||||
pub(super) fn base_url(region: &str) -> Result<String> {
|
||||
if BEDROCK_MANTLE_SUPPORTED_REGIONS.contains(®ion) {
|
||||
if is_supported_amazon_bedrock_region(region) {
|
||||
Ok(format!("https://bedrock-mantle.{region}.api.aws/openai/v1"))
|
||||
} else {
|
||||
Err(CodexErr::Fatal(format!(
|
||||
|
||||
@@ -31,6 +31,7 @@ use crate::provider::ProviderCapabilities;
|
||||
use auth::resolve_provider_auth;
|
||||
pub(crate) use catalog::static_model_catalog;
|
||||
use catalog::with_default_only_service_tier;
|
||||
pub use mantle::is_supported_amazon_bedrock_region;
|
||||
use mantle::runtime_base_url;
|
||||
|
||||
/// Runtime provider for Amazon Bedrock's OpenAI-compatible Mantle endpoint.
|
||||
|
||||
@@ -4,6 +4,7 @@ mod bearer_auth_provider;
|
||||
mod models_endpoint;
|
||||
mod provider;
|
||||
|
||||
pub use amazon_bedrock::is_supported_amazon_bedrock_region;
|
||||
pub use auth::AgentIdentitySessionFallback;
|
||||
pub use auth::ProviderAuthScope;
|
||||
pub use auth::ResolvedProviderAuth;
|
||||
@@ -12,6 +13,7 @@ pub use auth::auth_provider_from_auth_manager;
|
||||
pub use auth::unauthenticated_auth_provider;
|
||||
pub use bearer_auth_provider::BearerAuthProvider;
|
||||
pub use bearer_auth_provider::BearerAuthProvider as CoreAuthProvider;
|
||||
pub use codex_model_provider_info::AMAZON_BEDROCK_PROVIDER_ID;
|
||||
pub use codex_model_provider_info::CHATGPT_CODEX_BASE_URL;
|
||||
pub use codex_protocol::account::ProviderAccount;
|
||||
pub use provider::ModelProvider;
|
||||
|
||||
Reference in New Issue
Block a user