core: support dynamic auth tokens for model providers (#16288)

## Summary

Fixes #15189.

Custom model providers that set `requires_openai_auth = false` could
only use static credentials via `env_key` or
`experimental_bearer_token`. That is not enough for providers that mint
short-lived bearer tokens, because Codex had no way to run a command to
obtain a bearer token, cache it briefly in memory, and retry with a
refreshed token after a `401`.

This PR adds that provider config and wires it through the existing auth
design: request paths still go through `AuthManager.auth()` and
`UnauthorizedRecovery`, with `core` only choosing when to use a
provider-backed bearer-only `AuthManager`.

## Scope

To keep this PR reviewable, `/models` only uses provider auth for the
initial request in this change. It does **not** add a dedicated `401`
retry path for `/models`; that can be follow-up work if we still need it
after landing the main provider-token support.

## Example Usage

```toml
model_provider = "corp-openai"

[model_providers.corp-openai]
name = "Corp OpenAI"
base_url = "https://gateway.example.com/openai"
requires_openai_auth = false

[model_providers.corp-openai.auth]
command = "gcloud"
args = ["auth", "print-access-token"]
timeout_ms = 5000
refresh_interval_ms = 300000
```

The command contract is intentionally small:

- write the bearer token to `stdout`
- exit `0`
- any leading or trailing whitespace is trimmed before the token is used

## What Changed

- add `model_providers.<id>.auth` to the config model and generated
schema
- validate that command-backed provider auth is mutually exclusive with
`env_key`, `experimental_bearer_token`, and `requires_openai_auth`
- build a bearer-only `AuthManager` for `ModelClient` and
`ModelsManager` when a provider configures `auth`
- let normal Responses requests and realtime websocket connects use the
provider-backed bearer source through the same `AuthManager.auth()` path
- allow `/models` online refresh for command-auth providers and attach
the provider token to the initial `/models` request
- keep `auth.cwd` available as an advanced escape hatch and include it
in the generated config schema

## Testing

- `cargo test -p codex-core provider_auth_command`
- `cargo test -p codex-core
refresh_available_models_uses_provider_auth_token`
- `cargo test -p codex-core
test_deserialize_provider_auth_config_defaults`

## Docs

- `developers.openai.com/codex` should document the new
`[model_providers.<id>.auth]` block and the token-command contract
This commit is contained in:
Michael Bolin
2026-03-31 01:37:27 -07:00
committed by GitHub
Unverified
parent 0071968829
commit 20f43c1e05
17 changed files with 598 additions and 4 deletions
+52
View File
@@ -816,10 +816,62 @@
},
"type": "object"
},
"ModelProviderAuthInfo": {
"additionalProperties": false,
"description": "Configuration for obtaining a provider bearer token from a command.",
"properties": {
"args": {
"default": [],
"description": "Command arguments.",
"items": {
"type": "string"
},
"type": "array"
},
"command": {
"description": "Command to execute. Bare names are resolved via `PATH`; paths are resolved against `cwd`.",
"type": "string"
},
"cwd": {
"allOf": [
{
"$ref": "#/definitions/AbsolutePathBuf"
}
],
"description": "Working directory used when running the token command."
},
"refresh_interval_ms": {
"default": 300000,
"description": "Maximum age for the cached token before rerunning the command.",
"format": "uint64",
"minimum": 1.0,
"type": "integer"
},
"timeout_ms": {
"default": 5000,
"description": "Maximum time to wait for the token command to exit successfully.",
"format": "uint64",
"minimum": 1.0,
"type": "integer"
}
},
"required": [
"command"
],
"type": "object"
},
"ModelProviderInfo": {
"additionalProperties": false,
"description": "Serializable representation of a provider definition.",
"properties": {
"auth": {
"allOf": [
{
"$ref": "#/definitions/ModelProviderAuthInfo"
}
],
"description": "Command-backed bearer-token configuration for this provider."
},
"base_url": {
"description": "Base URL for the provider's OpenAI-compatible API.",
"type": "string"
+1
View File
@@ -64,6 +64,7 @@ mod tests {
env_key: Some("sk-should-not-leak".to_string()),
env_key_instructions: None,
experimental_bearer_token: None,
auth: None,
wire_api: crate::model_provider_info::WireApi::Responses,
query_params: None,
http_headers: None,
+6
View File
@@ -104,6 +104,7 @@ use crate::error::Result;
use crate::flags::CODEX_RS_SSE_FIXTURE;
use crate::model_provider_info::ModelProviderInfo;
use crate::model_provider_info::WireApi;
use crate::provider_auth::auth_manager_for_provider;
use crate::response_debug_context::extract_response_debug_context;
use crate::response_debug_context::extract_response_debug_context_from_api_error;
use crate::response_debug_context::telemetry_api_error_message;
@@ -261,6 +262,7 @@ impl ModelClient {
include_timing_metrics: bool,
beta_features_header: Option<String>,
) -> Self {
let auth_manager = auth_manager_for_provider(auth_manager, &provider);
let codex_api_key_env_enabled = auth_manager
.as_ref()
.is_some_and(|manager| manager.codex_api_key_env_enabled());
@@ -294,6 +296,10 @@ impl ModelClient {
}
}
pub(crate) fn auth_manager(&self) -> Option<Arc<AuthManager>> {
self.state.auth_manager.clone()
}
fn take_cached_websocket_session(&self) -> WebsocketSession {
let mut cached_websocket_session = self
.state
+21
View File
@@ -243,6 +243,26 @@ web_search = false
);
}
#[test]
fn rejects_provider_auth_with_env_key() {
let err = toml::from_str::<ConfigToml>(
r#"
[model_providers.corp]
name = "Corp"
env_key = "CORP_TOKEN"
[model_providers.corp.auth]
command = "print-token"
"#,
)
.unwrap_err();
assert!(
err.to_string()
.contains("model_providers.corp: provider auth cannot be combined with env_key")
);
}
#[test]
fn config_toml_deserializes_model_availability_nux() {
let toml = r#"
@@ -4315,6 +4335,7 @@ model_verbosity = "high"
wire_api: crate::WireApi::Responses,
env_key_instructions: None,
experimental_bearer_token: None,
auth: None,
query_params: None,
http_headers: None,
env_http_headers: None,
+14 -2
View File
@@ -1837,6 +1837,18 @@ Built-in providers cannot be overridden. Rename your custom provider (for exampl
}
}
fn validate_model_providers(
model_providers: &HashMap<String, ModelProviderInfo>,
) -> Result<(), String> {
validate_reserved_model_provider_ids(model_providers)?;
for (key, provider) in model_providers {
provider
.validate()
.map_err(|message| format!("model_providers.{key}: {message}"))?;
}
Ok(())
}
fn deserialize_model_providers<'de, D>(
deserializer: D,
) -> Result<HashMap<String, ModelProviderInfo>, D::Error>
@@ -1844,7 +1856,7 @@ where
D: serde::Deserializer<'de>,
{
let model_providers = HashMap::<String, ModelProviderInfo>::deserialize(deserializer)?;
validate_reserved_model_provider_ids(&model_providers).map_err(serde::de::Error::custom)?;
validate_model_providers(&model_providers).map_err(serde::de::Error::custom)?;
Ok(model_providers)
}
@@ -1969,7 +1981,7 @@ impl Config {
codex_home: PathBuf,
config_layer_stack: ConfigLayerStack,
) -> std::io::Result<Self> {
validate_reserved_model_provider_ids(&cfg.model_providers)
validate_model_providers(&cfg.model_providers)
.map_err(|message| std::io::Error::new(std::io::ErrorKind::InvalidInput, message))?;
// Ensure that every field of ConfigRequirements is applied to the final
// Config.
+2
View File
@@ -65,6 +65,7 @@ pub mod utils;
pub use utils::path_utils;
pub mod personality_migration;
pub mod plugins;
mod provider_auth;
pub(crate) mod mentions {
pub(crate) use crate::plugins::build_connector_slug_counts;
pub(crate) use crate::plugins::build_skill_name_counts;
@@ -104,6 +105,7 @@ mod text_encoding;
mod unified_exec;
pub mod windows_sandbox;
pub use client::X_RESPONSESAPI_INCLUDE_TIMING_METRICS_HEADER;
pub use codex_protocol::config_types::ModelProviderAuthInfo;
pub use model_provider_info::DEFAULT_LMSTUDIO_PORT;
pub use model_provider_info::DEFAULT_OLLAMA_PORT;
pub use model_provider_info::LMSTUDIO_OSS_PROVIDER_ID;
+40
View File
@@ -9,6 +9,7 @@ use crate::auth::AuthMode;
use crate::error::EnvVarError;
use codex_api::Provider as ApiProvider;
use codex_api::provider::RetryConfig as ApiRetryConfig;
use codex_protocol::config_types::ModelProviderAuthInfo;
use http::HeaderMap;
use http::header::HeaderName;
use http::header::HeaderValue;
@@ -86,6 +87,9 @@ pub struct ModelProviderInfo {
/// this may be necessary when using this programmatically.
pub experimental_bearer_token: Option<String>,
/// Command-backed bearer-token configuration for this provider.
pub auth: Option<ModelProviderAuthInfo>,
/// Which wire protocol this provider expects.
#[serde(default)]
pub wire_api: WireApi,
@@ -130,6 +134,36 @@ pub struct ModelProviderInfo {
}
impl ModelProviderInfo {
pub(crate) fn validate(&self) -> std::result::Result<(), String> {
let Some(auth) = self.auth.as_ref() else {
return Ok(());
};
if auth.command.trim().is_empty() {
return Err("provider auth.command must not be empty".to_string());
}
let mut conflicts = Vec::new();
if self.env_key.is_some() {
conflicts.push("env_key");
}
if self.experimental_bearer_token.is_some() {
conflicts.push("experimental_bearer_token");
}
if self.requires_openai_auth {
conflicts.push("requires_openai_auth");
}
if conflicts.is_empty() {
Ok(())
} else {
Err(format!(
"provider auth cannot be combined with {}",
conflicts.join(", ")
))
}
}
fn build_header_map(&self) -> crate::error::Result<HeaderMap> {
let capacity = self.http_headers.as_ref().map_or(0, HashMap::len)
+ self.env_http_headers.as_ref().map_or(0, HashMap::len);
@@ -246,6 +280,7 @@ impl ModelProviderInfo {
env_key: None,
env_key_instructions: None,
experimental_bearer_token: None,
auth: None,
wire_api: WireApi::Responses,
query_params: None,
http_headers: Some(
@@ -277,6 +312,10 @@ impl ModelProviderInfo {
pub fn is_openai(&self) -> bool {
self.name == OPENAI_PROVIDER_NAME
}
pub(crate) fn has_command_auth(&self) -> bool {
self.auth.is_some()
}
}
pub const DEFAULT_LMSTUDIO_PORT: u16 = 1234;
@@ -338,6 +377,7 @@ pub fn create_oss_provider_with_base_url(base_url: &str, wire_api: WireApi) -> M
env_key: None,
env_key_instructions: None,
experimental_bearer_token: None,
auth: None,
wire_api,
query_params: None,
http_headers: None,
@@ -1,5 +1,9 @@
use super::*;
use codex_utils_absolute_path::AbsolutePathBuf;
use codex_utils_absolute_path::AbsolutePathBufGuard;
use pretty_assertions::assert_eq;
use std::num::NonZeroU64;
use tempfile::tempdir;
#[test]
fn test_deserialize_ollama_model_provider_toml() {
@@ -13,6 +17,7 @@ base_url = "http://localhost:11434/v1"
env_key: None,
env_key_instructions: None,
experimental_bearer_token: None,
auth: None,
wire_api: WireApi::Responses,
query_params: None,
http_headers: None,
@@ -43,6 +48,7 @@ query_params = { api-version = "2025-04-01-preview" }
env_key: Some("AZURE_OPENAI_API_KEY".into()),
env_key_instructions: None,
experimental_bearer_token: None,
auth: None,
wire_api: WireApi::Responses,
query_params: Some(maplit::hashmap! {
"api-version".to_string() => "2025-04-01-preview".to_string(),
@@ -76,6 +82,7 @@ env_http_headers = { "X-Example-Env-Header" = "EXAMPLE_ENV_VAR" }
env_key: Some("API_KEY".into()),
env_key_instructions: None,
experimental_bearer_token: None,
auth: None,
wire_api: WireApi::Responses,
query_params: None,
http_headers: Some(maplit::hashmap! {
@@ -121,3 +128,31 @@ supports_websockets = true
let provider: ModelProviderInfo = toml::from_str(provider_toml).unwrap();
assert_eq!(provider.websocket_connect_timeout_ms, Some(15_000));
}
#[test]
fn test_deserialize_provider_auth_config_defaults() {
let base_dir = tempdir().unwrap();
let provider_toml = r#"
name = "Corp"
[auth]
command = "./scripts/print-token"
args = ["--format=text"]
"#;
let provider: ModelProviderInfo = {
let _guard = AbsolutePathBufGuard::new(base_dir.path());
toml::from_str(provider_toml).unwrap()
};
assert_eq!(
provider.auth,
Some(ModelProviderAuthInfo {
command: "./scripts/print-token".to_string(),
args: vec!["--format=text".to_string()],
timeout_ms: NonZeroU64::new(5_000).unwrap(),
refresh_interval_ms: NonZeroU64::new(300_000).unwrap(),
cwd: AbsolutePathBuf::resolve_path_against_base(".", base_dir.path()).unwrap(),
})
);
}
+5 -1
View File
@@ -14,6 +14,7 @@ use crate::model_provider_info::ModelProviderInfo;
use crate::models_manager::collaboration_mode_presets::CollaborationModesConfig;
use crate::models_manager::collaboration_mode_presets::builtin_collaboration_mode_presets;
use crate::models_manager::model_info;
use crate::provider_auth::required_auth_manager_for_provider;
use crate::response_debug_context::extract_response_debug_context;
use crate::response_debug_context::telemetry_transport_error_message;
use crate::util::FeedbackRequestTags;
@@ -212,6 +213,7 @@ impl ModelsManager {
collaboration_modes_config: CollaborationModesConfig,
provider: ModelProviderInfo,
) -> Self {
let auth_manager = required_auth_manager_for_provider(auth_manager, &provider);
let cache_path = codex_home.join(MODEL_CACHE_FILE);
let cache_manager = ModelsCacheManager::new(cache_path, DEFAULT_MODEL_CACHE_TTL);
let catalog_mode = if model_catalog.is_some() {
@@ -396,7 +398,9 @@ impl ModelsManager {
return Ok(());
}
if self.auth_manager.auth_mode() != Some(AuthMode::Chatgpt) {
if self.auth_manager.auth_mode() != Some(AuthMode::Chatgpt)
&& !self.provider.has_command_auth()
{
if matches!(
refresh_strategy,
RefreshStrategy::Offline | RefreshStrategy::OnlineIfUncached
@@ -1,5 +1,7 @@
use super::*;
use crate::AuthManager;
use crate::CodexAuth;
use crate::ModelProviderAuthInfo;
use crate::auth::AuthCredentialsStoreMode;
use crate::config::ConfigBuilder;
use crate::model_provider_info::WireApi;
@@ -13,8 +15,10 @@ use http::StatusCode;
use pretty_assertions::assert_eq;
use serde_json::json;
use std::collections::BTreeMap;
use std::num::NonZeroU64;
use std::sync::Arc;
use std::sync::Mutex;
use tempfile::TempDir;
use tempfile::tempdir;
use tracing::Event;
use tracing::Subscriber;
@@ -24,7 +28,12 @@ use tracing_subscriber::layer::Context;
use tracing_subscriber::layer::SubscriberExt;
use tracing_subscriber::registry::LookupSpan;
use tracing_subscriber::util::SubscriberInitExt;
use wiremock::Mock;
use wiremock::MockServer;
use wiremock::ResponseTemplate;
use wiremock::matchers::header_regex;
use wiremock::matchers::method;
use wiremock::matchers::path;
fn remote_model(slug: &str, display: &str, priority: i32) -> ModelInfo {
remote_model_with_visibility(slug, display, priority, "list")
@@ -79,6 +88,7 @@ fn provider_for(base_url: String) -> ModelProviderInfo {
env_key: None,
env_key_instructions: None,
experimental_bearer_token: None,
auth: None,
wire_api: WireApi::Responses,
query_params: None,
http_headers: None,
@@ -92,6 +102,95 @@ fn provider_for(base_url: String) -> ModelProviderInfo {
}
}
struct ProviderAuthScript {
tempdir: TempDir,
command: String,
args: Vec<String>,
}
impl ProviderAuthScript {
fn new(tokens: &[&str]) -> std::io::Result<Self> {
let tempdir = tempfile::tempdir()?;
let tokens_file = tempdir.path().join("tokens.txt");
let mut token_file_contents = String::new();
for token in tokens {
token_file_contents.push_str(token);
token_file_contents.push('\n');
}
std::fs::write(&tokens_file, token_file_contents)?;
#[cfg(unix)]
let (command, args) = {
let script_path = tempdir.path().join("print-token.sh");
std::fs::write(
&script_path,
r#"#!/bin/sh
first_line=$(sed -n '1p' tokens.txt)
printf '%s\n' "$first_line"
tail -n +2 tokens.txt > tokens.next
mv tokens.next tokens.txt
"#,
)?;
let mut permissions = std::fs::metadata(&script_path)?.permissions();
{
use std::os::unix::fs::PermissionsExt;
permissions.set_mode(0o755);
}
std::fs::set_permissions(&script_path, permissions)?;
("./print-token.sh".to_string(), Vec::new())
};
#[cfg(windows)]
let (command, args) = {
let script_path = tempdir.path().join("print-token.ps1");
std::fs::write(
&script_path,
r#"$lines = Get-Content -Path tokens.txt
if ($lines.Count -eq 0) { exit 1 }
Write-Output $lines[0]
$lines | Select-Object -Skip 1 | Set-Content -Path tokens.txt
"#,
)?;
(
"powershell".to_string(),
vec![
"-NoProfile".to_string(),
"-ExecutionPolicy".to_string(),
"Bypass".to_string(),
"-File".to_string(),
".\\print-token.ps1".to_string(),
],
)
};
Ok(Self {
tempdir,
command,
args,
})
}
fn auth_config(&self) -> ModelProviderAuthInfo {
ModelProviderAuthInfo {
command: self.command.clone(),
args: self.args.clone(),
timeout_ms: non_zero_u64(/*value*/ 1_000),
refresh_interval_ms: non_zero_u64(/*value*/ 60_000),
cwd: match codex_utils_absolute_path::AbsolutePathBuf::try_from(self.tempdir.path()) {
Ok(cwd) => cwd,
Err(err) => panic!("tempdir should be absolute: {err}"),
},
}
}
}
fn non_zero_u64(value: u64) -> NonZeroU64 {
match NonZeroU64::new(value) {
Some(value) => value,
None => panic!("expected non-zero value: {value}"),
}
}
#[derive(Default)]
struct TagCollectorVisitor {
tags: BTreeMap<String, String>,
@@ -310,6 +409,50 @@ async fn refresh_available_models_sorts_by_priority() {
);
}
#[tokio::test]
async fn refresh_available_models_uses_provider_auth_token() {
let server = MockServer::start().await;
let auth_script = ProviderAuthScript::new(&["provider-token"]).unwrap();
let remote_models = vec![remote_model(
"provider-model",
"Provider",
/*priority*/ 0,
)];
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(1)
.mount(&server)
.await;
let codex_home = tempdir().expect("temp dir");
let auth_manager = AuthManager::from_auth_for_testing(CodexAuth::from_api_key("unused"));
let provider = ModelProviderInfo {
auth: Some(auth_script.auth_config()),
..provider_for(server.uri())
};
let manager = ModelsManager::with_provider_for_tests(
codex_home.path().to_path_buf(),
auth_manager,
provider,
);
manager
.refresh_available_models(RefreshStrategy::Online)
.await
.expect("refresh succeeds");
assert_models_contain(&manager.get_remote_models().await, &remote_models);
}
#[tokio::test]
async fn refresh_available_models_uses_cache_when_fresh() {
let server = MockServer::start().await;
+31
View File
@@ -0,0 +1,31 @@
use std::sync::Arc;
use crate::AuthManager;
use crate::model_provider_info::ModelProviderInfo;
/// Returns the provider-scoped auth manager when this provider uses command-backed auth.
///
/// Providers without custom auth continue using the caller-supplied base manager.
pub(crate) fn auth_manager_for_provider(
auth_manager: Option<Arc<AuthManager>>,
provider: &ModelProviderInfo,
) -> Option<Arc<AuthManager>> {
match provider.auth.clone() {
Some(config) => Some(AuthManager::external_bearer_only(config)),
None => auth_manager,
}
}
/// Returns an auth manager for request paths that always require authentication.
///
/// Providers with command-backed auth get a bearer-only manager; otherwise the caller's manager
/// is reused unchanged.
pub(crate) fn required_auth_manager_for_provider(
auth_manager: Arc<AuthManager>,
provider: &ModelProviderInfo,
) -> Arc<AuthManager> {
match provider.auth.clone() {
Some(config) => AuthManager::external_bearer_only(config),
None => auth_manager,
}
}
+6 -1
View File
@@ -452,7 +452,12 @@ async fn prepare_realtime_start(
params: ConversationStartParams,
) -> CodexResult<PreparedRealtimeConversationStart> {
let provider = sess.provider().await;
let auth = sess.services.auth_manager.auth().await;
let auth_manager = sess
.services
.model_client
.auth_manager()
.unwrap_or_else(|| Arc::clone(&sess.services.auth_manager));
let auth = auth_manager.auth().await;
let realtime_api_key = realtime_api_key(auth.as_ref(), &provider)?;
let mut api_provider = provider.to_api_provider(Some(crate::auth::AuthMode::ApiKey))?;
let config = sess.get_config().await;
+3
View File
@@ -46,6 +46,7 @@ async fn responses_stream_includes_subagent_header_on_review() {
env_key: None,
env_key_instructions: None,
experimental_bearer_token: None,
auth: None,
wire_api: WireApi::Responses,
query_params: None,
http_headers: None,
@@ -158,6 +159,7 @@ async fn responses_stream_includes_subagent_header_on_other() {
env_key: None,
env_key_instructions: None,
experimental_bearer_token: None,
auth: None,
wire_api: WireApi::Responses,
query_params: None,
http_headers: None,
@@ -265,6 +267,7 @@ async fn responses_respects_model_info_overrides_from_config() {
env_key: None,
env_key_instructions: None,
experimental_bearer_token: None,
auth: None,
wire_api: WireApi::Responses,
query_params: None,
http_headers: None,
+236
View File
@@ -1,5 +1,7 @@
use codex_core::AuthManager;
use codex_core::CodexAuth;
use codex_core::ModelClient;
use codex_core::ModelProviderAuthInfo;
use codex_core::ModelProviderInfo;
use codex_core::NewThread;
use codex_core::Prompt;
@@ -64,6 +66,7 @@ use futures::StreamExt;
use pretty_assertions::assert_eq;
use serde_json::json;
use std::io::Write;
use std::num::NonZeroU64;
use std::sync::Arc;
use tempfile::TempDir;
use uuid::Uuid;
@@ -71,6 +74,7 @@ use wiremock::Mock;
use wiremock::MockServer;
use wiremock::ResponseTemplate;
use wiremock::matchers::body_string_contains;
use wiremock::matchers::header;
use wiremock::matchers::header_regex;
use wiremock::matchers::method;
use wiremock::matchers::path;
@@ -143,6 +147,95 @@ fn write_auth_json(
fake_jwt
}
struct ProviderAuthCommandFixture {
tempdir: TempDir,
command: String,
args: Vec<String>,
}
impl ProviderAuthCommandFixture {
fn new(tokens: &[&str]) -> std::io::Result<Self> {
let tempdir = tempfile::tempdir()?;
let tokens_file = tempdir.path().join("tokens.txt");
let mut token_file_contents = String::new();
for token in tokens {
token_file_contents.push_str(token);
token_file_contents.push('\n');
}
std::fs::write(&tokens_file, token_file_contents)?;
#[cfg(unix)]
let (command, args) = {
let script_path = tempdir.path().join("print-token.sh");
std::fs::write(
&script_path,
r#"#!/bin/sh
first_line=$(sed -n '1p' tokens.txt)
printf '%s\n' "$first_line"
tail -n +2 tokens.txt > tokens.next
mv tokens.next tokens.txt
"#,
)?;
let mut permissions = std::fs::metadata(&script_path)?.permissions();
{
use std::os::unix::fs::PermissionsExt;
permissions.set_mode(0o755);
}
std::fs::set_permissions(&script_path, permissions)?;
("./print-token.sh".to_string(), Vec::new())
};
#[cfg(windows)]
let (command, args) = {
let script_path = tempdir.path().join("print-token.ps1");
std::fs::write(
&script_path,
r#"$lines = Get-Content -Path tokens.txt
if ($lines.Count -eq 0) { exit 1 }
Write-Output $lines[0]
$lines | Select-Object -Skip 1 | Set-Content -Path tokens.txt
"#,
)?;
(
"powershell".to_string(),
vec![
"-NoProfile".to_string(),
"-ExecutionPolicy".to_string(),
"Bypass".to_string(),
"-File".to_string(),
".\\print-token.ps1".to_string(),
],
)
};
Ok(Self {
tempdir,
command,
args,
})
}
fn auth(&self) -> ModelProviderAuthInfo {
ModelProviderAuthInfo {
command: self.command.clone(),
args: self.args.clone(),
timeout_ms: non_zero_u64(/*value*/ 1_000),
refresh_interval_ms: non_zero_u64(/*value*/ 60_000),
cwd: match codex_utils_absolute_path::AbsolutePathBuf::try_from(self.tempdir.path()) {
Ok(cwd) => cwd,
Err(err) => panic!("tempdir should be absolute: {err}"),
},
}
}
}
fn non_zero_u64(value: u64) -> NonZeroU64 {
match NonZeroU64::new(value) {
Some(value) => value,
None => panic!("expected non-zero value: {value}"),
}
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn resume_includes_initial_messages_and_sends_prior_items() {
skip_if_no_network!();
@@ -659,6 +752,146 @@ async fn includes_conversation_id_and_model_headers_in_request() {
assert_eq!(request_authorization, "Bearer Test API Key");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn provider_auth_command_supplies_bearer_token() {
skip_if_no_network!();
let server = MockServer::start().await;
mount_sse_once_match(
&server,
header("authorization", "Bearer command-token"),
sse(vec![ev_response_created("resp1"), ev_completed("resp1")]),
)
.await;
let auth_fixture = ProviderAuthCommandFixture::new(&["command-token"]).unwrap();
send_provider_auth_request(&server, auth_fixture.auth()).await;
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn provider_auth_command_refreshes_after_401() {
skip_if_no_network!();
let server = MockServer::start().await;
let auth_fixture = ProviderAuthCommandFixture::new(&["first-token", "second-token"]).unwrap();
Mock::given(method("POST"))
.and(path("/v1/responses"))
.and(header_regex("Authorization", "Bearer first-token"))
.respond_with(ResponseTemplate::new(401).set_body_string("unauthorized"))
.expect(1)
.mount(&server)
.await;
Mock::given(method("POST"))
.and(path("/v1/responses"))
.and(header_regex("Authorization", "Bearer second-token"))
.respond_with(
ResponseTemplate::new(200)
.insert_header("content-type", "text/event-stream")
.set_body_raw(
sse(vec![ev_response_created("resp1"), ev_completed("resp1")]),
"text/event-stream",
),
)
.expect(1)
.mount(&server)
.await;
send_provider_auth_request(&server, auth_fixture.auth()).await;
}
/// Issues one streamed Responses request through a provider configured with command-backed auth.
///
/// The caller owns the server-side assertions, so this helper only validates that the request
/// reaches `Completed` without surfacing an auth or transport error to the client.
async fn send_provider_auth_request(server: &MockServer, auth: ModelProviderAuthInfo) {
let provider = ModelProviderInfo {
name: "corp".into(),
base_url: Some(format!("{}/v1", server.uri())),
env_key: None,
env_key_instructions: None,
experimental_bearer_token: None,
auth: Some(auth),
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,
};
let codex_home = TempDir::new().unwrap();
let mut config = load_default_config_for_test(&codex_home).await;
config.model_provider_id = provider.name.clone();
config.model_provider = provider.clone();
let effort = config.model_reasoning_effort;
let summary = config.model_reasoning_summary;
let model = codex_core::test_support::get_model_offline(config.model.as_deref());
config.model = Some(model.clone());
let config = Arc::new(config);
let model_info =
codex_core::test_support::construct_model_info_offline(model.as_str(), &config);
let conversation_id = ThreadId::new();
let session_telemetry = SessionTelemetry::new(
conversation_id,
model.as_str(),
model_info.slug.as_str(),
/*account_id*/ None,
Some("test@test.com".to_string()),
/*auth_mode*/ None,
"test_originator".to_string(),
/*log_user_prompts*/ false,
"test".to_string(),
SessionSource::Exec,
);
let client = ModelClient::new(
Some(AuthManager::from_auth_for_testing(CodexAuth::from_api_key(
"unused-api-key",
))),
conversation_id,
provider,
SessionSource::Exec,
config.model_verbosity,
/*enable_request_compression*/ false,
/*include_timing_metrics*/ false,
/*beta_features_header*/ None,
);
let mut client_session = client.new_session();
let mut prompt = Prompt::default();
prompt.input.push(ResponseItem::Message {
id: None,
role: "user".to_string(),
content: vec![ContentItem::InputText {
text: "hello".to_string(),
}],
end_turn: None,
phase: None,
});
let mut stream = client_session
.stream(
&prompt,
&model_info,
&session_telemetry,
effort,
summary.unwrap_or(ReasoningSummary::Auto),
/*service_tier*/ None,
/*turn_metadata_header*/ None,
)
.await
.expect("responses stream to start");
while let Some(event) = stream.next().await {
if let Ok(ResponseEvent::Completed { .. }) = event {
break;
}
}
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn includes_base_instructions_override_in_request() {
skip_if_no_network!();
@@ -1796,6 +2029,7 @@ async fn azure_responses_request_includes_store_and_reasoning_ids() {
env_key: None,
env_key_instructions: None,
experimental_bearer_token: None,
auth: None,
wire_api: WireApi::Responses,
query_params: None,
http_headers: None,
@@ -2396,6 +2630,7 @@ async fn azure_overrides_assign_properties_used_for_responses_url() {
// Reuse the existing environment variable to avoid using unsafe code
env_key: Some(existing_env_var_with_random_value.to_string()),
experimental_bearer_token: None,
auth: None,
query_params: Some(std::collections::HashMap::from([(
"api-version".to_string(),
"2025-04-01-preview".to_string(),
@@ -2486,6 +2721,7 @@ async fn env_var_overrides_loaded_auth() {
)])),
env_key_instructions: None,
experimental_bearer_token: None,
auth: None,
wire_api: WireApi::Responses,
http_headers: Some(std::collections::HashMap::from([(
"Custom-Header".to_string(),
@@ -1674,6 +1674,7 @@ fn websocket_provider_with_connect_timeout(
env_key: None,
env_key_instructions: None,
experimental_bearer_token: None,
auth: None,
wire_api: WireApi::Responses,
query_params: None,
http_headers: None,
@@ -69,6 +69,7 @@ async fn continue_after_stream_error() {
env_key: Some("PATH".into()),
env_key_instructions: None,
experimental_bearer_token: None,
auth: None,
wire_api: WireApi::Responses,
query_params: None,
http_headers: None,
@@ -53,6 +53,7 @@ async fn retries_on_early_close() {
env_key: Some("PATH".into()),
env_key_instructions: None,
experimental_bearer_token: None,
auth: None,
wire_api: WireApi::Responses,
query_params: None,
http_headers: None,