mirror of
https://github.com/openai/codex.git
synced 2026-11-13 21:30:47 +00:00
## What changed - Share the harness runtime identity with analytics delivery and turn-cost queries, and resolve request authentication together with its account-bound transport policy. - Add runtime authentication to ChatGPT request helpers and `codex cloud`, including environment discovery. Resolve environment credentials for each request and honor CLI configuration overrides when loading cloud authentication. - Preserve custom-backend authentication, validate runtime destinations, and keep cloud requests from following redirects. - Forward only organization and project selectors from model-provider headers to API-key analytics requests. ## Testing Add regression coverage for runtime request signing, credential refresh, account and resource selection, redirect credential handling, custom destinations, and cloud credential-store overrides. GitOrigin-RevId: 75f33961ee6482881e9ff9817e99d977f31b65b8
945 lines
32 KiB
Rust
945 lines
32 KiB
Rust
//! Unit tests for the provider module.
|
|
|
|
use std::future::Future;
|
|
use std::num::NonZeroU64;
|
|
use std::sync::atomic::AtomicUsize;
|
|
use std::sync::atomic::Ordering;
|
|
use std::task::Context;
|
|
use std::task::Waker;
|
|
|
|
use codex_http_client::HttpClientFactory;
|
|
use codex_http_client::OutboundProxyPolicy;
|
|
use codex_login::auth::AgentIdentityAuthPolicy;
|
|
use codex_login::auth::BedrockApiKeyAuth;
|
|
use codex_model_provider_info::AwsAuthRefreshConfig;
|
|
use codex_model_provider_info::AwsCredentialExportConfig;
|
|
use codex_model_provider_info::ModelProviderAwsAuthInfo;
|
|
use codex_model_provider_info::WireApi;
|
|
use codex_model_provider_info::create_oss_provider_with_base_url;
|
|
use codex_models_manager::ModelsManagerConfig;
|
|
use codex_models_manager::manager::RefreshStrategy;
|
|
use codex_protocol::account::PlanType;
|
|
use codex_protocol::config_types::ModelProviderAuthInfo;
|
|
use codex_protocol::openai_models::ModelInfo;
|
|
use codex_protocol::openai_models::ModelsResponse;
|
|
use codex_protocol::protocol::SessionSource;
|
|
use codex_utils_redacted_string::RedactedString;
|
|
use pretty_assertions::assert_eq;
|
|
use serde_json::json;
|
|
use wiremock::Mock;
|
|
use wiremock::MockServer;
|
|
use wiremock::ResponseTemplate;
|
|
use wiremock::matchers::header_regex;
|
|
use wiremock::matchers::method;
|
|
use wiremock::matchers::path;
|
|
|
|
use super::*;
|
|
use crate::RemoteCompactionSupport;
|
|
use crate::auth::AgentIdentitySessionFallback;
|
|
use crate::shared_state::process_shared_state;
|
|
|
|
fn provider_info_with_command_auth() -> ModelProviderInfo {
|
|
ModelProviderInfo {
|
|
auth: Some(ModelProviderAuthInfo {
|
|
command: "print-token".to_string(),
|
|
args: Vec::new(),
|
|
timeout_ms: NonZeroU64::new(5_000).expect("timeout should be non-zero"),
|
|
refresh_interval_ms: 300_000,
|
|
cwd: std::env::current_dir()
|
|
.expect("current dir should be available")
|
|
.try_into()
|
|
.expect("current dir should be absolute"),
|
|
}),
|
|
requires_openai_auth: false,
|
|
..ModelProviderInfo::create_openai_provider(/*base_url*/ None)
|
|
}
|
|
}
|
|
|
|
fn test_codex_home() -> std::path::PathBuf {
|
|
std::env::temp_dir().join(format!("codex-model-provider-test-{}", std::process::id()))
|
|
}
|
|
|
|
fn provider_for(base_url: String) -> ModelProviderInfo {
|
|
ModelProviderInfo {
|
|
name: "mock".into(),
|
|
base_url: Some(base_url),
|
|
model_catalog_url: None,
|
|
env_key: None,
|
|
env_key_instructions: None,
|
|
experimental_bearer_token: None,
|
|
auth: None,
|
|
gateway_oauth: None,
|
|
aws: None,
|
|
wire_api: WireApi::Responses,
|
|
query_params: None,
|
|
http_headers: None,
|
|
env_http_headers: None,
|
|
request_max_retries: Some(0),
|
|
stream_max_retries: Some(0),
|
|
stream_idle_timeout_ms: Some(5_000),
|
|
websocket_connect_timeout_ms: None,
|
|
requires_openai_auth: false,
|
|
supports_websockets: false,
|
|
supports_standalone_web_search: false,
|
|
capabilities: None,
|
|
project_id: None,
|
|
location: None,
|
|
credential_file: None,
|
|
include_internal_metadata: false,
|
|
}
|
|
}
|
|
|
|
fn remote_model(slug: &str) -> ModelInfo {
|
|
serde_json::from_value(json!({
|
|
"slug": slug,
|
|
"display_name": slug,
|
|
"description": null,
|
|
"default_reasoning_level": "medium",
|
|
"supported_reasoning_levels": [],
|
|
"shell_type": "shell_command",
|
|
"visibility": "list",
|
|
"supported_in_api": true,
|
|
"priority": 0,
|
|
"upgrade": null,
|
|
"support_verbosity": false,
|
|
"default_verbosity": null,
|
|
"apply_patch_tool_type": null,
|
|
"truncation_policy": {"mode": "bytes", "limit": 10_000},
|
|
"supports_image_detail_original": false,
|
|
"context_window": 272_000,
|
|
"max_context_window": 272_000,
|
|
"experimental_supported_tools": [],
|
|
}))
|
|
.expect("valid model")
|
|
}
|
|
|
|
fn bedrock_api_key_auth() -> CodexAuth {
|
|
CodexAuth::BedrockApiKey(BedrockApiKeyAuth {
|
|
api_key: "bedrock-api-key-test".to_string(),
|
|
region: "us-east-1".to_string(),
|
|
})
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn scoped_auth_ignores_scope_for_non_openai_provider() {
|
|
let provider = create_model_provider(
|
|
create_oss_provider_with_base_url("http://localhost:11434/v1", WireApi::Responses),
|
|
/*auth_manager*/ None,
|
|
);
|
|
|
|
let auth = provider
|
|
.api_auth_for_scope(ProviderAuthScope {
|
|
agent_identity_policy: AgentIdentityAuthPolicy::JwtOnly,
|
|
session_source: SessionSource::Cli,
|
|
agent_identity_session_fallback: AgentIdentitySessionFallback::default(),
|
|
})
|
|
.await
|
|
.expect("auth should resolve");
|
|
|
|
assert!(auth.auth.to_auth_headers().unwrap().is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn openai_provider_enables_remote_compaction() {
|
|
let provider = create_model_provider(
|
|
ModelProviderInfo::create_openai_provider(/*base_url*/ None),
|
|
/*auth_manager*/ None,
|
|
);
|
|
|
|
assert_eq!(
|
|
provider.capabilities(),
|
|
ProviderCapabilities {
|
|
remote_compaction: RemoteCompactionSupport::V2,
|
|
..ProviderCapabilities::default()
|
|
}
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn configured_provider_remote_compaction_matches_provider_support() {
|
|
let cases = [
|
|
(
|
|
ModelProviderInfo::create_openai_provider(/*base_url*/ None),
|
|
RemoteCompactionSupport::V2,
|
|
),
|
|
(
|
|
ModelProviderInfo {
|
|
name: "Azure".to_string(),
|
|
base_url: Some("https://example.com/openai".to_string()),
|
|
..ModelProviderInfo::default()
|
|
},
|
|
RemoteCompactionSupport::V2,
|
|
),
|
|
(
|
|
ModelProviderInfo {
|
|
name: "Custom".to_string(),
|
|
base_url: Some("https://example.openai.azure.com/openai/v1".to_string()),
|
|
..ModelProviderInfo::default()
|
|
},
|
|
RemoteCompactionSupport::V2,
|
|
),
|
|
(
|
|
provider_for("https://example.test/v1".to_string()),
|
|
RemoteCompactionSupport::Unsupported,
|
|
),
|
|
];
|
|
|
|
for (provider_info, expected) in cases {
|
|
let provider = create_model_provider(provider_info, /*auth_manager*/ None);
|
|
assert_eq!(provider.capabilities().remote_compaction, expected);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn configured_provider_uses_default_approval_review_preferred_model() {
|
|
let provider = create_model_provider(
|
|
ModelProviderInfo::create_openai_provider(/*base_url*/ None),
|
|
/*auth_manager*/ None,
|
|
);
|
|
|
|
assert_eq!(
|
|
provider.approval_review_preferred_model(),
|
|
DEFAULT_APPROVAL_REVIEW_PREFERRED_MODEL
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn configured_provider_uses_luna_for_approval_review_with_api_key_auth() {
|
|
let provider = create_model_provider(
|
|
ModelProviderInfo::create_openai_provider(/*base_url*/ None),
|
|
Some(AuthManager::from_auth_for_testing(CodexAuth::from_api_key(
|
|
"openai-api-key",
|
|
))),
|
|
);
|
|
|
|
assert_eq!(provider.approval_review_preferred_model(), "gpt-5.6-luna");
|
|
}
|
|
|
|
#[test]
|
|
fn configured_provider_uses_default_approval_review_model_with_chatgpt_auth() {
|
|
let provider = create_model_provider(
|
|
ModelProviderInfo::create_openai_provider(/*base_url*/ None),
|
|
Some(AuthManager::from_auth_for_testing(
|
|
CodexAuth::create_dummy_chatgpt_auth_for_testing(),
|
|
)),
|
|
);
|
|
|
|
assert_eq!(
|
|
provider.approval_review_preferred_model(),
|
|
DEFAULT_APPROVAL_REVIEW_PREFERRED_MODEL
|
|
);
|
|
}
|
|
|
|
struct RotatingAuth(AtomicUsize);
|
|
|
|
impl codex_login::ExternalAuth for RotatingAuth {
|
|
fn resolve(&self) -> codex_login::ExternalAuthFuture<'_, CodexAuth> {
|
|
Box::pin(async move {
|
|
let key = if self.0.fetch_add(1, Ordering::SeqCst).is_multiple_of(2) {
|
|
"sk-proj-fedramp-synthetic-test-key"
|
|
} else {
|
|
"commercial-key"
|
|
};
|
|
Ok(CodexAuth::from_api_key(key))
|
|
})
|
|
}
|
|
|
|
fn refresh(
|
|
&self,
|
|
_context: codex_login::ExternalAuthRefreshContext,
|
|
) -> codex_login::ExternalAuthFuture<'_, CodexAuth> {
|
|
self.resolve()
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn configured_provider_resolves_default_and_explicit_endpoints() {
|
|
let provider = create_model_provider(
|
|
provider_for("https://example.test/v1".to_string()),
|
|
/*auth_manager*/ None,
|
|
);
|
|
|
|
assert_eq!(
|
|
provider
|
|
.runtime_base_url()
|
|
.await
|
|
.expect("runtime base URL should resolve"),
|
|
Some("https://example.test/v1".to_string())
|
|
);
|
|
assert_eq!(
|
|
provider
|
|
.request_provider_and_auth(/*scope*/ None)
|
|
.await
|
|
.unwrap()
|
|
.0
|
|
.base_url,
|
|
"https://example.test/v1"
|
|
);
|
|
|
|
let home = tempfile::tempdir().unwrap();
|
|
let auth = AuthManager::from_auth_for_testing_with_home(
|
|
CodexAuth::from_api_key("initial-key"),
|
|
home.path().to_path_buf(),
|
|
);
|
|
let provider = create_model_provider(
|
|
ModelProviderInfo::create_openai_provider(/*base_url*/ None),
|
|
Some(auth.clone()),
|
|
);
|
|
for (api_key, expected_base_url) in [
|
|
("legacy-key", "https://api.openai.com/v1"),
|
|
(
|
|
"sk-proj-fedramp-synthetic-test-key",
|
|
"https://gov.api.openai.com/v1",
|
|
),
|
|
("commercial-key", "https://api.openai.com/v1"),
|
|
] {
|
|
codex_login::login_with_api_key(
|
|
home.path(),
|
|
api_key,
|
|
codex_login::AuthCredentialsStoreMode::File,
|
|
codex_login::AuthKeyringBackendKind::default(),
|
|
)
|
|
.unwrap();
|
|
auth.reload().await;
|
|
let (api_provider, resolved) = provider
|
|
.request_provider_and_auth(/*scope*/ None)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(api_provider.base_url, expected_base_url);
|
|
let headers = resolved.auth.to_auth_headers().unwrap();
|
|
assert_eq!(
|
|
headers[http::header::AUTHORIZATION].to_str().unwrap(),
|
|
format!("Bearer {api_key}")
|
|
);
|
|
}
|
|
assert_eq!(provider.info().base_url, None);
|
|
|
|
let rotating_auth = Arc::new(RotatingAuth(AtomicUsize::new(0)));
|
|
auth.set_external_auth(rotating_auth.clone()).await.unwrap();
|
|
for (api_key, expected_base_url, expected_fedramp_header) in [
|
|
("commercial-key", "https://api.openai.com/v1", None),
|
|
(
|
|
"sk-proj-fedramp-synthetic-test-key",
|
|
"https://gov.api.openai.com/v1",
|
|
Some("true"),
|
|
),
|
|
("commercial-key", "https://api.openai.com/v1", None),
|
|
] {
|
|
let reads_before = rotating_auth.0.load(Ordering::SeqCst);
|
|
let (api_provider, resolved) = provider
|
|
.request_provider_and_auth(/*scope*/ None)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(rotating_auth.0.load(Ordering::SeqCst), reads_before + 1);
|
|
let headers = resolved.auth.to_auth_headers().unwrap();
|
|
let expected_authorization = format!("Bearer {api_key}");
|
|
assert_eq!(
|
|
(
|
|
api_provider.base_url.as_str(),
|
|
headers
|
|
.get(http::header::AUTHORIZATION)
|
|
.and_then(|value| value.to_str().ok()),
|
|
headers
|
|
.get("X-OpenAI-Fedramp")
|
|
.and_then(|value| value.to_str().ok()),
|
|
),
|
|
(
|
|
expected_base_url,
|
|
Some(expected_authorization.as_str()),
|
|
expected_fedramp_header,
|
|
)
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn create_model_provider_builds_command_auth_manager_without_base_manager() {
|
|
let provider = create_model_provider(
|
|
provider_info_with_command_auth(),
|
|
/*auth_manager*/ None,
|
|
);
|
|
|
|
let auth_manager = provider
|
|
.auth_manager()
|
|
.expect("command auth provider should have an auth manager");
|
|
|
|
assert!(auth_manager.has_external_auth());
|
|
}
|
|
|
|
#[test]
|
|
fn create_model_provider_does_not_use_openai_auth_manager_for_amazon_bedrock_provider() {
|
|
let provider = create_model_provider(
|
|
ModelProviderInfo::create_amazon_bedrock_provider(Some(ModelProviderAwsAuthInfo {
|
|
profile: Some("codex-bedrock".to_string()),
|
|
region: None,
|
|
credential_export: None,
|
|
auth_refresh: None,
|
|
})),
|
|
Some(AuthManager::from_auth_for_testing(CodexAuth::from_api_key(
|
|
"openai-api-key",
|
|
))),
|
|
);
|
|
|
|
assert!(provider.auth_manager().is_none());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn shared_bedrock_auth_refresh_is_reused_only_for_matching_configuration() {
|
|
const TEST_NAME: &str =
|
|
"provider::tests::shared_bedrock_auth_refresh_is_reused_only_for_matching_configuration";
|
|
const HELPER_ARG: &str = "CODEX_BEDROCK_SHARED_AUTH_REFRESH_COMMAND";
|
|
const SUBPROCESS_ARG: &str = "CODEX_BEDROCK_SHARED_AUTH_REFRESH_SUBPROCESS";
|
|
let arguments = std::env::args().collect::<Vec<_>>();
|
|
if let Some(index) = arguments.iter().position(|argument| argument == HELPER_ARG) {
|
|
let counter = &arguments[index + 2];
|
|
std::thread::sleep(std::time::Duration::from_millis(50));
|
|
let mut counter = std::fs::OpenOptions::new()
|
|
.create(true)
|
|
.append(true)
|
|
.open(counter)
|
|
.expect("refresh invocation counter should open");
|
|
std::io::Write::write_all(&mut counter, b"1").expect("write invocation counter");
|
|
return;
|
|
}
|
|
|
|
let counter = std::env::temp_dir().join(format!("bedrock-refresh-{}", std::process::id()));
|
|
if !arguments.iter().any(|argument| argument == SUBPROCESS_ARG) {
|
|
std::fs::create_dir(&counter).expect("AWS command directory should be created");
|
|
let executable = std::env::current_exe().expect("test executable should be available");
|
|
let aws = counter.join(format!("aws{}", std::env::consts::EXE_SUFFIX));
|
|
std::fs::hard_link(&executable, &aws)
|
|
.or_else(|_| codex_utils_cargo_bin::copy_executable(&executable, &aws))
|
|
.expect("test executable should be installed as aws");
|
|
let existing_path = std::env::var_os("PATH").unwrap_or_default();
|
|
let path = std::env::join_paths(
|
|
std::iter::once(counter.clone()).chain(std::env::split_paths(&existing_path)),
|
|
)
|
|
.expect("test executable PATH should be valid");
|
|
let output = tokio::process::Command::new(executable)
|
|
.args(["--exact", TEST_NAME, "--skip", SUBPROCESS_ARG])
|
|
.env("PATH", path)
|
|
.env_remove("AWS_ACCESS_KEY_ID")
|
|
.env_remove("AWS_SECRET_ACCESS_KEY")
|
|
.output()
|
|
.await
|
|
.expect("isolated AWS refresh test should run");
|
|
std::fs::remove_dir_all(&counter).expect("AWS command directory should be removed");
|
|
assert!(output.status.success(), "{output:?}");
|
|
return;
|
|
}
|
|
let _ = std::fs::remove_file(&counter);
|
|
let aws = ModelProviderAwsAuthInfo {
|
|
profile: Some("codex-bedrock".to_string()),
|
|
region: Some("us-west-2".to_string()),
|
|
credential_export: None,
|
|
auth_refresh: Some(AwsAuthRefreshConfig {
|
|
command: "aws".to_string(),
|
|
args: Vec::from(
|
|
[
|
|
"--exact",
|
|
TEST_NAME,
|
|
"--skip",
|
|
HELPER_ARG,
|
|
"--skip",
|
|
counter.to_str().expect("counter path should be UTF-8"),
|
|
]
|
|
.map(RedactedString::from),
|
|
),
|
|
timeout_ms: NonZeroU64::new(10_000).expect("timeout should be non-zero"),
|
|
}),
|
|
};
|
|
let provider_info = ModelProviderInfo::create_amazon_bedrock_provider(Some(aws.clone()));
|
|
|
|
let first = create_model_provider(
|
|
provider_info.clone(),
|
|
Some(AuthManager::from_auth_for_testing(CodexAuth::from_api_key(
|
|
"openai-api-key",
|
|
))),
|
|
);
|
|
let second = create_model_provider(provider_info.clone(), /*auth_manager*/ None);
|
|
let shared_state = process_shared_state();
|
|
let shared_recovery = shared_state
|
|
.aws_auth_recovery(&aws)
|
|
.expect("test provider should have refresh configured");
|
|
assert_eq!(Arc::strong_count(&shared_recovery), 3);
|
|
assert!(first.auth_manager().is_none() && second.auth_manager().is_none());
|
|
|
|
for provider in [&first, &second] {
|
|
for (status, body, recoverable) in [
|
|
(http::StatusCode::UNAUTHORIZED, "ExpiredToken", true),
|
|
(http::StatusCode::FORBIDDEN, "InvalidClientTokenId", true),
|
|
(http::StatusCode::FORBIDDEN, "AccessDeniedException", false),
|
|
] {
|
|
let error = TransportError::Http {
|
|
retry_after: None,
|
|
status,
|
|
url: None,
|
|
headers: None,
|
|
body: Some(body.to_string()),
|
|
};
|
|
assert_eq!(provider.is_recoverable_auth_error(&error), recoverable);
|
|
}
|
|
}
|
|
|
|
let mut invalid_aws = aws.clone();
|
|
invalid_aws.auth_refresh.as_mut().expect("refresh").command = "not-aws".into();
|
|
let invalid_provider = create_model_provider(
|
|
ModelProviderInfo::create_amazon_bedrock_provider(Some(invalid_aws)),
|
|
/*auth_manager*/ None,
|
|
);
|
|
let error = invalid_provider
|
|
.recover_from_unauthorized()
|
|
.await
|
|
.expect_err("non-aws command should be rejected");
|
|
assert_eq!(error.retry_delay(/*retry_count*/ 1), None);
|
|
assert_eq!(error.to_string(), "AWS auth refresh command must be `aws`");
|
|
|
|
let (first_result, second_result) = tokio::join!(
|
|
first.recover_from_unauthorized(),
|
|
second.recover_from_unauthorized()
|
|
);
|
|
assert_eq!(
|
|
[first_result, second_result].map(|result| result.expect("provider should recover")),
|
|
[ProviderUnauthorizedRecovery::Recovered; 2]
|
|
);
|
|
let read_counter = || std::fs::read_to_string(&counter).expect("read counter");
|
|
assert_eq!(read_counter(), "1");
|
|
|
|
assert_eq!(
|
|
first
|
|
.recover_from_unauthorized()
|
|
.await
|
|
.expect("later generation should recover"),
|
|
ProviderUnauthorizedRecovery::Recovered
|
|
);
|
|
assert_eq!(read_counter(), "11");
|
|
|
|
let fixture = tempfile::tempdir().expect("export fixture should be created");
|
|
let export_counter = fixture.path().join("exports");
|
|
let export_gate = fixture.path().join("release");
|
|
#[cfg(unix)]
|
|
let (command, mut args) = (
|
|
"sh".to_string(),
|
|
vec![
|
|
"-c".to_string(),
|
|
r#"printf '1\n' >> "$1"
|
|
while [ ! -e "$2" ]; do :; done
|
|
printf '%s\n' '{"AccessKeyId":"exported","SecretAccessKey":"secret"}'
|
|
"#
|
|
.to_string(),
|
|
"export-credentials".to_string(),
|
|
],
|
|
);
|
|
#[cfg(windows)]
|
|
let (command, mut args) = {
|
|
let script = fixture.path().join("export.cmd");
|
|
std::fs::write(
|
|
&script,
|
|
concat!(
|
|
"@echo off\r\n>> \"%~1\" echo 1\r\n",
|
|
":wait\r\nif not exist \"%~2\" goto wait\r\n",
|
|
"echo {\"AccessKeyId\":\"exported\",\"SecretAccessKey\":\"secret\"}\r\n",
|
|
),
|
|
)
|
|
.expect("export script should be written");
|
|
(
|
|
"cmd.exe".to_string(),
|
|
vec![
|
|
"/D".to_string(),
|
|
"/Q".to_string(),
|
|
"/C".to_string(),
|
|
script.to_string_lossy().into_owned(),
|
|
],
|
|
)
|
|
};
|
|
args.extend([&export_counter, &export_gate].map(|path| path.to_string_lossy().into_owned()));
|
|
let provider_info =
|
|
ModelProviderInfo::create_amazon_bedrock_provider(Some(ModelProviderAwsAuthInfo {
|
|
profile: None,
|
|
credential_export: Some(AwsCredentialExportConfig {
|
|
command,
|
|
args: args.into_iter().map(RedactedString::from).collect(),
|
|
timeout_ms: NonZeroU64::new(5_000).expect("timeout should be non-zero"),
|
|
}),
|
|
..aws.clone()
|
|
}));
|
|
let first_export = create_model_provider(provider_info.clone(), /*auth_manager*/ None);
|
|
let second_export = create_model_provider(provider_info, /*auth_manager*/ None);
|
|
let first_refresh = first_export.recover_from_unauthorized();
|
|
let second_refresh = second_export.recover_from_unauthorized();
|
|
tokio::pin!(first_refresh, second_refresh);
|
|
tokio::time::timeout(std::time::Duration::from_secs(10), async {
|
|
tokio::select! {
|
|
result = &mut first_refresh => panic!("export should wait for its gate: {result:?}"),
|
|
() = async {
|
|
while !export_counter.exists() {
|
|
tokio::task::yield_now().await;
|
|
}
|
|
} => {}
|
|
}
|
|
})
|
|
.await
|
|
.expect("export should start after login");
|
|
assert_eq!(read_counter(), "111");
|
|
{
|
|
// Queue another recovery after login finishes, while export still holds the lock.
|
|
let mut context = Context::from_waker(Waker::noop());
|
|
assert!(second_refresh.as_mut().poll(&mut context).is_pending());
|
|
}
|
|
std::fs::write(&export_gate, []).expect("export gate should open");
|
|
let (first_result, second_result) = tokio::join!(first_refresh, second_refresh);
|
|
assert_eq!(
|
|
[first_result, second_result].map(|result| result.expect("provider should recover")),
|
|
[ProviderUnauthorizedRecovery::Recovered; 2]
|
|
);
|
|
assert_eq!(read_counter(), "111");
|
|
assert_eq!(
|
|
std::fs::read_to_string(export_counter)
|
|
.expect("read export counter")
|
|
.lines()
|
|
.collect::<Vec<_>>(),
|
|
vec!["1"]
|
|
);
|
|
std::fs::remove_file(&counter).expect("refresh invocation counter should be removed");
|
|
|
|
let different_profile = ModelProviderAwsAuthInfo {
|
|
profile: Some("another-bedrock-profile".to_string()),
|
|
..aws.clone()
|
|
};
|
|
let other_recovery = shared_state
|
|
.aws_auth_recovery(&different_profile)
|
|
.expect("test provider should have refresh configured");
|
|
assert!(!Arc::ptr_eq(&shared_recovery, &other_recovery));
|
|
|
|
let released_recovery = Arc::downgrade(&shared_recovery);
|
|
drop((first, second, shared_recovery));
|
|
assert!(released_recovery.upgrade().is_none());
|
|
assert!(shared_state.aws_auth_recovery(&aws).is_some());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn create_model_provider_uses_managed_auth_for_amazon_bedrock_provider() {
|
|
let auth = bedrock_api_key_auth();
|
|
let provider = create_model_provider(
|
|
ModelProviderInfo::create_amazon_bedrock_provider(/*aws*/ None),
|
|
Some(AuthManager::from_auth_for_testing(auth.clone())),
|
|
);
|
|
|
|
assert_eq!(provider.auth().await, Some(auth));
|
|
}
|
|
|
|
#[test]
|
|
fn openai_provider_returns_unauthenticated_openai_account_state() {
|
|
let provider = create_model_provider(
|
|
ModelProviderInfo::create_openai_provider(/*base_url*/ None),
|
|
/*auth_manager*/ None,
|
|
);
|
|
|
|
assert_eq!(
|
|
provider.account_state(),
|
|
Ok(ProviderAccountState {
|
|
account: None,
|
|
requires_openai_auth: true,
|
|
})
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn openai_provider_returns_api_key_account_state() {
|
|
let provider = create_model_provider(
|
|
ModelProviderInfo::create_openai_provider(/*base_url*/ None),
|
|
Some(AuthManager::from_auth_for_testing(CodexAuth::from_api_key(
|
|
"openai-api-key",
|
|
))),
|
|
);
|
|
|
|
assert_eq!(
|
|
provider.account_state(),
|
|
Ok(ProviderAccountState {
|
|
account: Some(ProviderAccount::ApiKey),
|
|
requires_openai_auth: true,
|
|
})
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn openai_provider_returns_chatgpt_account_state_without_email() {
|
|
let provider = create_model_provider(
|
|
ModelProviderInfo::create_openai_provider(/*base_url*/ None),
|
|
Some(AuthManager::from_auth_for_testing(
|
|
CodexAuth::create_dummy_chatgpt_auth_for_testing(),
|
|
)),
|
|
);
|
|
|
|
assert_eq!(
|
|
provider.account_state(),
|
|
Ok(ProviderAccountState {
|
|
account: Some(ProviderAccount::Chatgpt {
|
|
email: None,
|
|
plan_type: PlanType::Unknown,
|
|
}),
|
|
requires_openai_auth: true,
|
|
})
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn openai_provider_rejects_bedrock_api_key_account_state() {
|
|
let provider = create_model_provider(
|
|
ModelProviderInfo::create_openai_provider(/*base_url*/ None),
|
|
Some(AuthManager::from_auth_for_testing(bedrock_api_key_auth())),
|
|
);
|
|
|
|
assert_eq!(
|
|
provider.account_state(),
|
|
Err(ProviderAccountError::UnsupportedBedrockApiKeyAuth)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn custom_non_openai_provider_returns_no_account_state() {
|
|
let provider = create_model_provider(
|
|
ModelProviderInfo {
|
|
name: "Custom".to_string(),
|
|
base_url: Some("http://localhost:1234/v1".to_string()),
|
|
wire_api: WireApi::Responses,
|
|
requires_openai_auth: false,
|
|
..Default::default()
|
|
},
|
|
/*auth_manager*/ None,
|
|
);
|
|
|
|
assert_eq!(
|
|
provider.account_state(),
|
|
Ok(ProviderAccountState {
|
|
account: None,
|
|
requires_openai_auth: false,
|
|
})
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn amazon_bedrock_provider_returns_bedrock_account_state() {
|
|
let provider = create_model_provider(
|
|
ModelProviderInfo::create_amazon_bedrock_provider(/*aws*/ None),
|
|
/*auth_manager*/ None,
|
|
);
|
|
|
|
assert_eq!(
|
|
provider.account_state(),
|
|
Ok(ProviderAccountState {
|
|
account: Some(ProviderAccount::AmazonBedrock {
|
|
uses_codex_managed_credentials: false,
|
|
}),
|
|
requires_openai_auth: false,
|
|
})
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn amazon_bedrock_provider_creates_static_models_manager() {
|
|
let provider = create_model_provider(
|
|
ModelProviderInfo::create_amazon_bedrock_provider(/*aws*/ None),
|
|
/*auth_manager*/ None,
|
|
);
|
|
let manager = provider.models_manager(test_codex_home(), /*config_model_catalog*/ None);
|
|
let uncached_manager =
|
|
provider.models_manager_without_cache(/*config_model_catalog*/ None);
|
|
|
|
let catalog = manager
|
|
.raw_model_catalog(
|
|
RefreshStrategy::Online,
|
|
HttpClientFactory::new(OutboundProxyPolicy::ReqwestDefault),
|
|
)
|
|
.await;
|
|
let uncached_catalog = uncached_manager
|
|
.raw_model_catalog(
|
|
RefreshStrategy::Online,
|
|
HttpClientFactory::new(OutboundProxyPolicy::ReqwestDefault),
|
|
)
|
|
.await;
|
|
assert_eq!(uncached_catalog, catalog);
|
|
for slug in [
|
|
"openai.gpt-6.1-sol",
|
|
"openai.gpt-6.1-terra",
|
|
"openai.gpt-6-sol",
|
|
"openai.gpt-6-luna",
|
|
"openai.gpt-5.6-sol",
|
|
"openai.gpt-6-astra",
|
|
] {
|
|
let model_info = manager
|
|
.get_model_info(
|
|
slug,
|
|
&ModelsManagerConfig {
|
|
model_context_window: Some(1_000_000),
|
|
..Default::default()
|
|
},
|
|
)
|
|
.await;
|
|
let mut expected_model_info = manager
|
|
.get_model_info(slug, &ModelsManagerConfig::default())
|
|
.await;
|
|
expected_model_info.context_window = Some(872_000);
|
|
assert_eq!(model_info, expected_model_info);
|
|
}
|
|
|
|
let models = catalog
|
|
.models
|
|
.iter()
|
|
.map(|model| (model.slug.as_str(), model.display_name.as_str()))
|
|
.collect::<Vec<_>>();
|
|
|
|
assert_eq!(
|
|
models,
|
|
vec![
|
|
("openai.gpt-6.1-sol", "GPT-6.1 Sol"),
|
|
("openai.gpt-6-astra", "GPT-6-Astra"),
|
|
("openai.gpt-6.1-terra", "GPT-6.1 Terra"),
|
|
("openai.gpt-6-sol", "GPT-6 Sol"),
|
|
("openai.gpt-6-luna", "GPT-6 Luna"),
|
|
("openai.gpt-5.6-sol", "GPT-5.6 Sol"),
|
|
("openai.gpt-5.6-terra", "GPT-5.6 Terra"),
|
|
("openai.gpt-5.6-luna", "GPT-5.6 Luna"),
|
|
("openai.gpt-5.5", "GPT-5.5"),
|
|
]
|
|
);
|
|
|
|
let available_models = manager
|
|
.list_models(
|
|
RefreshStrategy::Online,
|
|
HttpClientFactory::new(OutboundProxyPolicy::ReqwestDefault),
|
|
)
|
|
.await;
|
|
assert_eq!(
|
|
available_models
|
|
.iter()
|
|
.map(|preset| preset.model.as_str())
|
|
.collect::<Vec<_>>(),
|
|
vec![
|
|
"openai.gpt-6.1-sol",
|
|
"openai.gpt-6-astra",
|
|
"openai.gpt-6.1-terra",
|
|
"openai.gpt-6-sol",
|
|
"openai.gpt-6-luna",
|
|
"openai.gpt-5.6-sol",
|
|
"openai.gpt-5.6-terra",
|
|
"openai.gpt-5.6-luna",
|
|
"openai.gpt-5.5",
|
|
]
|
|
);
|
|
|
|
let default_model = available_models
|
|
.iter()
|
|
.find(|preset| preset.is_default)
|
|
.expect("Bedrock catalog should have a default model");
|
|
|
|
assert_eq!(default_model.model, "openai.gpt-6.1-sol");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn configured_bedrock_catalog_preserves_service_tiers() {
|
|
let mut configured_model = codex_models_manager::bundled_models_response()
|
|
.expect("bundled models should parse")
|
|
.models
|
|
.into_iter()
|
|
.find(|model| model.slug == "gpt-5.5")
|
|
.expect("bundled models should include GPT-5.5");
|
|
configured_model.service_tiers = vec![codex_protocol::openai_models::ModelServiceTier {
|
|
id: "custom-tier".to_string(),
|
|
name: "Custom tier".to_string(),
|
|
description: "User-defined tier description.".to_string(),
|
|
}];
|
|
configured_model.default_service_tier = Some("custom-tier".to_string());
|
|
let configured_catalog = ModelsResponse {
|
|
models: vec![configured_model],
|
|
};
|
|
let mut expected = configured_catalog.clone();
|
|
expected.models[0].web_search_tool_type =
|
|
codex_protocol::openai_models::WebSearchToolType::Text;
|
|
|
|
let mut gov_provider = ModelProviderInfo::create_amazon_bedrock_provider(/*aws*/ None);
|
|
gov_provider.base_url =
|
|
Some("https://bedrock-mantle.us-gov-west-1.api.aws/openai/v1".to_string());
|
|
for provider_info in [
|
|
ModelProviderInfo::create_amazon_bedrock_provider(/*aws*/ None),
|
|
ModelProviderInfo::create_amazon_bedrock_runtime_provider(/*aws*/ None),
|
|
gov_provider,
|
|
] {
|
|
let provider = create_model_provider(provider_info, /*auth_manager*/ None);
|
|
for manager in [
|
|
provider.models_manager(test_codex_home(), Some(configured_catalog.clone())),
|
|
provider.models_manager_without_cache(Some(configured_catalog.clone())),
|
|
] {
|
|
let catalog = manager
|
|
.raw_model_catalog(
|
|
RefreshStrategy::Online,
|
|
HttpClientFactory::new(OutboundProxyPolicy::ReqwestDefault),
|
|
)
|
|
.await;
|
|
assert_eq!(catalog, expected);
|
|
}
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn configured_provider_models_manager_uses_provider_bearer_token() {
|
|
let server = MockServer::start().await;
|
|
let remote_models = vec![remote_model("provider-model")];
|
|
|
|
Mock::given(method("GET"))
|
|
.and(path("/models"))
|
|
.and(header_regex("Authorization", "Bearer provider-token"))
|
|
.respond_with(
|
|
ResponseTemplate::new(200)
|
|
.insert_header("content-type", "application/json")
|
|
.set_body_json(ModelsResponse {
|
|
models: remote_models.clone(),
|
|
}),
|
|
)
|
|
.expect(2)
|
|
.mount(&server)
|
|
.await;
|
|
|
|
let mut provider_info = provider_for(server.uri());
|
|
provider_info.experimental_bearer_token = Some("provider-token".into());
|
|
provider_info.model_catalog_url = Some(format!("{}/models", server.uri()).into());
|
|
provider_info.http_headers = Some(std::collections::HashMap::from([(
|
|
codex_login::default_client::RESIDENCY_HEADER_NAME.to_string(),
|
|
"us".into(),
|
|
)]));
|
|
for auth in [
|
|
None,
|
|
Some(CodexAuth::create_dummy_chatgpt_auth_for_testing()),
|
|
] {
|
|
// Disabled discovery must ignore the catalog cached by the enabled run.
|
|
for enabled in [true, false] {
|
|
let provider = create_model_provider(
|
|
provider_info.clone(),
|
|
auth.clone().map(AuthManager::from_auth_for_testing),
|
|
);
|
|
let manager =
|
|
provider.models_manager(test_codex_home(), /*config_model_catalog*/ None);
|
|
manager.set_api_key_model_discovery_enabled(enabled);
|
|
let refresh_strategy = if enabled {
|
|
RefreshStrategy::Online
|
|
} else {
|
|
RefreshStrategy::Offline
|
|
};
|
|
let catalog = manager
|
|
.raw_model_catalog(
|
|
refresh_strategy,
|
|
HttpClientFactory::new(OutboundProxyPolicy::ReqwestDefault),
|
|
)
|
|
.await;
|
|
|
|
assert_eq!(
|
|
catalog
|
|
.models
|
|
.iter()
|
|
.any(|model| model.slug == "provider-model"),
|
|
enabled
|
|
);
|
|
}
|
|
}
|
|
}
|