feat: add opt-in provider runtime abstraction (#17713)

## Summary

- Add `codex-model-provider` as the runtime home for model-provider
behavior that does not belong in `codex-core`, `codex-login`, or
`codex-api`.
- The new crate wraps configured `ModelProviderInfo` in a
`ModelProvider` trait object that can resolve the API provider config,
provider-scoped auth manager, and request auth provider for each call.
- This centralizes provider auth behavior in one place today, and gives
us an extension point for future provider-specific auth, model listing,
request setup, and related runtime behavior.

## Tests
Ran tests manually to make sure that provider auth under different
configs still work as expected.

---------

Co-authored-by: pakrym-oai <pakrym@openai.com>
This commit is contained in:
Celia Chen
2026-04-16 19:27:45 -07:00
committed by GitHub
Unverified
parent 91e8eebd03
commit a803790a10
45 changed files with 577 additions and 369 deletions
+16 -1
View File
@@ -1697,6 +1697,7 @@ dependencies = [
"codex-login",
"codex-mcp",
"codex-mcp-server",
"codex-model-provider",
"codex-protocol",
"codex-responses-api-proxy",
"codex-rmcp-client",
@@ -1933,6 +1934,7 @@ dependencies = [
"codex-instructions",
"codex-login",
"codex-mcp",
"codex-model-provider",
"codex-model-provider-info",
"codex-models-manager",
"codex-network-proxy",
@@ -2369,7 +2371,6 @@ dependencies = [
"async-trait",
"base64 0.22.1",
"chrono",
"codex-api",
"codex-app-server-protocol",
"codex-client",
"codex-config",
@@ -2465,6 +2466,19 @@ dependencies = [
"wiremock",
]
[[package]]
name = "codex-model-provider"
version = "0.0.0"
dependencies = [
"async-trait",
"codex-api",
"codex-login",
"codex-model-provider-info",
"codex-protocol",
"http 1.4.0",
"pretty_assertions",
]
[[package]]
name = "codex-model-provider-info"
version = "0.0.0"
@@ -2494,6 +2508,7 @@ dependencies = [
"codex-config",
"codex-feedback",
"codex-login",
"codex-model-provider",
"codex-model-provider-info",
"codex-otel",
"codex-protocol",
+2
View File
@@ -92,6 +92,7 @@ members = [
"thread-store",
"codex-experimental-api-macros",
"plugin",
"model-provider",
]
resolver = "2"
@@ -154,6 +155,7 @@ codex-network-proxy = { path = "network-proxy" }
codex-ollama = { path = "ollama" }
codex-otel = { path = "otel" }
codex-plugin = { path = "plugin" }
codex-model-provider = { path = "model-provider" }
codex-process-hardening = { path = "process-hardening" }
codex-protocol = { path = "protocol" }
codex-realtime-webrtc = { path = "realtime-webrtc" }
+1
View File
@@ -37,6 +37,7 @@ codex-features = { workspace = true }
codex-login = { workspace = true }
codex-mcp = { workspace = true }
codex-mcp-server = { workspace = true }
codex-model-provider = { workspace = true }
codex-protocol = { workspace = true }
codex-responses-api-proxy = { workspace = true }
codex-rmcp-client = { workspace = true }
+4 -10
View File
@@ -1,5 +1,6 @@
use clap::Parser;
use codex_core::config::Config;
use codex_model_provider::create_model_provider;
use codex_utils_cli::CliConfigOverrides;
use serde_json::json;
use tokio::io::AsyncReadExt;
@@ -29,16 +30,9 @@ pub(crate) async fn run_responses_command(
let base_auth_manager = codex_login::AuthManager::shared_from_config(
&config, /*enable_codex_api_key_env*/ true,
);
let auth_manager =
codex_login::auth_manager_for_provider(Some(base_auth_manager), &config.model_provider);
let auth = match auth_manager {
Some(auth_manager) => auth_manager.auth().await,
None => None,
};
let api_provider = config
.model_provider
.to_api_provider(auth.as_ref().map(codex_login::CodexAuth::auth_mode))?;
let api_auth = codex_login::auth_provider_from_auth(auth, &config.model_provider)?;
let model_provider = create_model_provider(config.model_provider, Some(base_auth_manager));
let api_provider = model_provider.api_provider().await?;
let api_auth = model_provider.api_auth().await?;
let client = codex_api::ResponsesClient::new(
codex_api::ReqwestTransport::new(codex_login::default_client::build_reqwest_client()),
api_provider,
-47
View File
@@ -1,4 +1,3 @@
use crate::AuthProvider as ApiAuthProvider;
use crate::TransportError;
use crate::error::ApiError;
use crate::rate_limits::parse_promo_message;
@@ -12,7 +11,6 @@ use codex_protocol::error::RetryLimitReachedError;
use codex_protocol::error::UnexpectedResponseError;
use codex_protocol::error::UsageLimitReachedError;
use http::HeaderMap;
use http::HeaderValue;
use serde::Deserialize;
use serde_json::Value;
@@ -174,48 +172,3 @@ struct UsageErrorBody {
plan_type: Option<PlanType>,
resets_at: Option<i64>,
}
#[derive(Clone, Default)]
pub struct CoreAuthProvider {
pub token: Option<String>,
pub account_id: Option<String>,
pub is_fedramp_account: bool,
}
impl CoreAuthProvider {
pub fn auth_header_attached(&self) -> bool {
self.token
.as_ref()
.is_some_and(|token| http::HeaderValue::from_str(&format!("Bearer {token}")).is_ok())
}
pub fn auth_header_name(&self) -> Option<&'static str> {
self.auth_header_attached().then_some("authorization")
}
pub fn for_test(token: Option<&str>, account_id: Option<&str>) -> Self {
Self {
token: token.map(str::to_string),
account_id: account_id.map(str::to_string),
is_fedramp_account: false,
}
}
}
impl ApiAuthProvider for CoreAuthProvider {
fn add_auth_headers(&self, headers: &mut HeaderMap) {
if let Some(token) = self.token.as_ref()
&& let Ok(header) = HeaderValue::from_str(&format!("Bearer {token}"))
{
let _ = headers.insert(http::header::AUTHORIZATION, header);
}
if let Some(account_id) = self.account_id.as_ref()
&& let Ok(header) = HeaderValue::from_str(account_id)
{
let _ = headers.insert("ChatGPT-Account-ID", header);
}
if self.is_fedramp_account {
crate::auth::add_fedramp_routing_header(headers);
}
}
}
@@ -130,55 +130,3 @@ fn map_api_error_extracts_identity_auth_details_from_headers() {
);
assert_eq!(err.identity_error_code.as_deref(), Some("token_expired"));
}
#[test]
fn core_auth_provider_reports_when_auth_header_will_attach() {
let auth = CoreAuthProvider {
token: Some("access-token".to_string()),
account_id: None,
is_fedramp_account: false,
};
assert!(auth.auth_header_attached());
assert_eq!(auth.auth_header_name(), Some("authorization"));
}
#[test]
fn core_auth_provider_adds_auth_headers() {
let auth = CoreAuthProvider::for_test(Some("access-token"), Some("workspace-123"));
let mut headers = HeaderMap::new();
crate::AuthProvider::add_auth_headers(&auth, &mut headers);
assert_eq!(
headers
.get(http::header::AUTHORIZATION)
.and_then(|value| value.to_str().ok()),
Some("Bearer access-token")
);
assert_eq!(
headers
.get("ChatGPT-Account-ID")
.and_then(|value| value.to_str().ok()),
Some("workspace-123")
);
}
#[test]
fn core_auth_provider_adds_fedramp_routing_header_for_fedramp_accounts() {
let auth = CoreAuthProvider {
token: Some("access-token".to_string()),
account_id: Some("workspace-123".to_string()),
is_fedramp_account: true,
};
let mut headers = HeaderMap::new();
crate::AuthProvider::add_auth_headers(&auth, &mut headers);
assert_eq!(
headers
.get("X-OpenAI-Fedramp")
.and_then(|value| value.to_str().ok()),
Some("true")
);
}
+17 -19
View File
@@ -1,5 +1,5 @@
use http::HeaderMap;
use http::HeaderValue;
use std::sync::Arc;
/// Adds authentication headers to API requests.
///
@@ -10,25 +10,23 @@ pub trait AuthProvider: Send + Sync {
fn add_auth_headers(&self, headers: &mut HeaderMap);
}
pub(crate) fn add_fedramp_routing_header(headers: &mut HeaderMap) {
headers.insert("X-OpenAI-Fedramp", HeaderValue::from_static("true"));
/// Shared auth handle passed through API clients.
pub type SharedAuthProvider = Arc<dyn AuthProvider>;
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct AuthHeaderTelemetry {
pub attached: bool,
pub name: Option<&'static str>,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn add_fedramp_routing_header_sets_header() {
let mut headers = HeaderMap::new();
add_fedramp_routing_header(&mut headers);
assert_eq!(
headers
.get("X-OpenAI-Fedramp")
.and_then(|v| v.to_str().ok()),
Some("true")
);
pub fn auth_header_telemetry(auth: &dyn AuthProvider) -> AuthHeaderTelemetry {
let mut headers = HeaderMap::new();
auth.add_auth_headers(&mut headers);
let name = headers
.contains_key(http::header::AUTHORIZATION)
.then_some("authorization");
AuthHeaderTelemetry {
attached: name.is_some(),
name,
}
}
+6 -16
View File
@@ -1,4 +1,4 @@
use crate::auth::AuthProvider;
use crate::auth::SharedAuthProvider;
use crate::common::CompactionInput;
use crate::endpoint::session::EndpointSession;
use crate::error::ApiError;
@@ -12,12 +12,12 @@ use serde::Deserialize;
use serde_json::to_value;
use std::sync::Arc;
pub struct CompactClient<T: HttpTransport, A: AuthProvider> {
session: EndpointSession<T, A>,
pub struct CompactClient<T: HttpTransport> {
session: EndpointSession<T>,
}
impl<T: HttpTransport, A: AuthProvider> CompactClient<T, A> {
pub fn new(transport: T, provider: Provider, auth: A) -> Self {
impl<T: HttpTransport> CompactClient<T> {
pub fn new(transport: T, provider: Provider, auth: SharedAuthProvider) -> Self {
Self {
session: EndpointSession::new(transport, provider, auth),
}
@@ -86,18 +86,8 @@ mod tests {
}
}
#[derive(Clone, Default)]
struct DummyAuth;
impl AuthProvider for DummyAuth {
fn add_auth_headers(&self, _headers: &mut HeaderMap) {}
}
#[test]
fn path_is_responses_compact() {
assert_eq!(
CompactClient::<DummyTransport, DummyAuth>::path(),
"responses/compact"
);
assert_eq!(CompactClient::<DummyTransport>::path(), "responses/compact");
}
}
+8 -7
View File
@@ -1,4 +1,4 @@
use crate::auth::AuthProvider;
use crate::auth::SharedAuthProvider;
use crate::common::MemorySummarizeInput;
use crate::common::MemorySummarizeOutput;
use crate::endpoint::session::EndpointSession;
@@ -12,12 +12,12 @@ use serde::Deserialize;
use serde_json::to_value;
use std::sync::Arc;
pub struct MemoriesClient<T: HttpTransport, A: AuthProvider> {
session: EndpointSession<T, A>,
pub struct MemoriesClient<T: HttpTransport> {
session: EndpointSession<T>,
}
impl<T: HttpTransport, A: AuthProvider> MemoriesClient<T, A> {
pub fn new(transport: T, provider: Provider, auth: A) -> Self {
impl<T: HttpTransport> MemoriesClient<T> {
pub fn new(transport: T, provider: Provider, auth: SharedAuthProvider) -> Self {
Self {
session: EndpointSession::new(transport, provider, auth),
}
@@ -67,6 +67,7 @@ struct SummarizeResponse {
#[cfg(test)]
mod tests {
use super::*;
use crate::auth::AuthProvider;
use crate::common::RawMemory;
use crate::common::RawMemoryMetadata;
use crate::provider::RetryConfig;
@@ -157,7 +158,7 @@ mod tests {
#[test]
fn path_is_memories_trace_summarize_for_wire_compatibility() {
assert_eq!(
MemoriesClient::<DummyTransport, DummyAuth>::path(),
MemoriesClient::<DummyTransport>::path(),
"memories/trace_summarize"
);
}
@@ -178,7 +179,7 @@ mod tests {
let client = MemoriesClient::new(
transport.clone(),
provider("https://example.com/api/codex"),
DummyAuth,
Arc::new(DummyAuth),
);
let input = MemorySummarizeInput {
+9 -8
View File
@@ -1,4 +1,4 @@
use crate::auth::AuthProvider;
use crate::auth::SharedAuthProvider;
use crate::endpoint::session::EndpointSession;
use crate::error::ApiError;
use crate::provider::Provider;
@@ -11,12 +11,12 @@ use http::Method;
use http::header::ETAG;
use std::sync::Arc;
pub struct ModelsClient<T: HttpTransport, A: AuthProvider> {
session: EndpointSession<T, A>,
pub struct ModelsClient<T: HttpTransport> {
session: EndpointSession<T>,
}
impl<T: HttpTransport, A: AuthProvider> ModelsClient<T, A> {
pub fn new(transport: T, provider: Provider, auth: A) -> Self {
impl<T: HttpTransport> ModelsClient<T> {
pub fn new(transport: T, provider: Provider, auth: SharedAuthProvider) -> Self {
Self {
session: EndpointSession::new(transport, provider, auth),
}
@@ -76,6 +76,7 @@ impl<T: HttpTransport, A: AuthProvider> ModelsClient<T, A> {
#[cfg(test)]
mod tests {
use super::*;
use crate::auth::AuthProvider;
use crate::provider::RetryConfig;
use async_trait::async_trait;
use codex_client::Request;
@@ -165,7 +166,7 @@ mod tests {
let client = ModelsClient::new(
transport.clone(),
provider("https://example.com/api/codex"),
DummyAuth,
Arc::new(DummyAuth),
);
let (models, _) = client
@@ -229,7 +230,7 @@ mod tests {
let client = ModelsClient::new(
transport,
provider("https://example.com/api/codex"),
DummyAuth,
Arc::new(DummyAuth),
);
let (models, _) = client
@@ -256,7 +257,7 @@ mod tests {
let client = ModelsClient::new(
transport,
provider("https://example.com/api/codex"),
DummyAuth,
Arc::new(DummyAuth),
);
let (models, etag) = client
@@ -1,4 +1,4 @@
use crate::auth::AuthProvider;
use crate::auth::SharedAuthProvider;
use crate::endpoint::realtime_websocket::RealtimeSessionConfig;
use crate::endpoint::realtime_websocket::session_update_session_json;
use crate::endpoint::session::EndpointSession;
@@ -24,8 +24,8 @@ use tracing::trace;
const MULTIPART_BOUNDARY: &str = "codex-realtime-call-boundary";
const MULTIPART_CONTENT_TYPE: &str = "multipart/form-data; boundary=codex-realtime-call-boundary";
pub struct RealtimeCallClient<T: HttpTransport, A: AuthProvider> {
session: EndpointSession<T, A>,
pub struct RealtimeCallClient<T: HttpTransport> {
session: EndpointSession<T>,
}
/// Answer from creating a WebRTC Realtime call.
@@ -44,8 +44,8 @@ struct BackendRealtimeCallRequest<'a> {
session: &'a Value,
}
impl<T: HttpTransport, A: AuthProvider> RealtimeCallClient<T, A> {
pub fn new(transport: T, provider: Provider, auth: A) -> Self {
impl<T: HttpTransport> RealtimeCallClient<T> {
pub fn new(transport: T, provider: Provider, auth: SharedAuthProvider) -> Self {
Self {
session: EndpointSession::new(transport, provider, auth),
}
@@ -221,6 +221,7 @@ fn decode_call_id_from_location(headers: &HeaderMap) -> Result<String, ApiError>
#[cfg(test)]
mod tests {
use super::*;
use crate::auth::AuthProvider;
use crate::endpoint::realtime_websocket::RealtimeEventParser;
use crate::endpoint::realtime_websocket::RealtimeOutputModality;
use crate::endpoint::realtime_websocket::RealtimeSessionMode;
@@ -327,7 +328,7 @@ mod tests {
let client = RealtimeCallClient::new(
transport.clone(),
provider("https://api.openai.com/v1"),
DummyAuth,
Arc::new(DummyAuth),
);
let response = client
@@ -370,7 +371,7 @@ mod tests {
let client = RealtimeCallClient::new(
transport.clone(),
provider("https://chatgpt.com/backend-api/codex"),
DummyAuth,
Arc::new(DummyAuth),
);
let response = client
@@ -404,7 +405,7 @@ mod tests {
let client = RealtimeCallClient::new(
transport.clone(),
provider("https://api.openai.com/v1"),
DummyAuth,
Arc::new(DummyAuth),
);
let response = client
@@ -466,7 +467,7 @@ mod tests {
let client = RealtimeCallClient::new(
transport.clone(),
provider("https://chatgpt.com/backend-api/codex"),
DummyAuth,
Arc::new(DummyAuth),
);
let response = client
@@ -512,8 +513,11 @@ mod tests {
#[tokio::test]
async fn errors_when_location_is_missing() {
let transport = CapturingTransport::without_location();
let client =
RealtimeCallClient::new(transport, provider("https://api.openai.com/v1"), DummyAuth);
let client = RealtimeCallClient::new(
transport,
provider("https://api.openai.com/v1"),
Arc::new(DummyAuth),
);
let err = client
.create("v=offer\r\n".to_string())
+5 -5
View File
@@ -1,4 +1,4 @@
use crate::auth::AuthProvider;
use crate::auth::SharedAuthProvider;
use crate::common::ResponseStream;
use crate::common::ResponsesApiRequest;
use crate::endpoint::session::EndpointSession;
@@ -23,8 +23,8 @@ use std::sync::Arc;
use std::sync::OnceLock;
use tracing::instrument;
pub struct ResponsesClient<T: HttpTransport, A: AuthProvider> {
session: EndpointSession<T, A>,
pub struct ResponsesClient<T: HttpTransport> {
session: EndpointSession<T>,
sse_telemetry: Option<Arc<dyn SseTelemetry>>,
}
@@ -37,8 +37,8 @@ pub struct ResponsesOptions {
pub turn_state: Option<Arc<OnceLock<String>>>,
}
impl<T: HttpTransport, A: AuthProvider> ResponsesClient<T, A> {
pub fn new(transport: T, provider: Provider, auth: A) -> Self {
impl<T: HttpTransport> ResponsesClient<T> {
pub fn new(transport: T, provider: Provider, auth: SharedAuthProvider) -> Self {
Self {
session: EndpointSession::new(transport, provider, auth),
sse_telemetry: None,
@@ -1,4 +1,4 @@
use crate::auth::AuthProvider;
use crate::auth::SharedAuthProvider;
use crate::common::ResponseEvent;
use crate::common::ResponseStream;
use crate::common::ResponsesWsRequest;
@@ -279,13 +279,13 @@ impl ResponsesWebsocketConnection {
}
}
pub struct ResponsesWebsocketClient<A: AuthProvider> {
pub struct ResponsesWebsocketClient {
provider: Provider,
auth: A,
auth: SharedAuthProvider,
}
impl<A: AuthProvider> ResponsesWebsocketClient<A> {
pub fn new(provider: Provider, auth: A) -> Self {
impl ResponsesWebsocketClient {
pub fn new(provider: Provider, auth: SharedAuthProvider) -> Self {
Self { provider, auth }
}
+5 -5
View File
@@ -1,4 +1,4 @@
use crate::auth::AuthProvider;
use crate::auth::SharedAuthProvider;
use crate::error::ApiError;
use crate::provider::Provider;
use crate::telemetry::run_with_request_telemetry;
@@ -14,15 +14,15 @@ use serde_json::Value;
use std::sync::Arc;
use tracing::instrument;
pub(crate) struct EndpointSession<T: HttpTransport, A: AuthProvider> {
pub(crate) struct EndpointSession<T: HttpTransport> {
transport: T,
provider: Provider,
auth: A,
auth: SharedAuthProvider,
request_telemetry: Option<Arc<dyn RequestTelemetry>>,
}
impl<T: HttpTransport, A: AuthProvider> EndpointSession<T, A> {
pub(crate) fn new(transport: T, provider: Provider, auth: A) -> Self {
impl<T: HttpTransport> EndpointSession<T> {
pub(crate) fn new(transport: T, provider: Provider, auth: SharedAuthProvider) -> Self {
Self {
transport,
provider,
+18 -5
View File
@@ -96,7 +96,7 @@ pub fn openai_file_uri(file_id: &str) -> String {
pub async fn upload_local_file(
base_url: &str,
auth: &impl AuthProvider,
auth: &dyn AuthProvider,
path: &Path,
) -> Result<UploadedOpenAiFile, OpenAiFileError> {
let metadata = tokio::fs::metadata(path)
@@ -252,7 +252,7 @@ pub async fn upload_local_file(
}
fn authorized_request(
auth: &impl AuthProvider,
auth: &dyn AuthProvider,
method: reqwest::Method,
url: &str,
) -> reqwest::RequestBuilder {
@@ -276,8 +276,8 @@ fn build_reqwest_client() -> reqwest::Client {
#[cfg(test)]
mod tests {
use super::*;
use crate::CoreAuthProvider;
use pretty_assertions::assert_eq;
use reqwest::header::HeaderValue;
use std::sync::Arc;
use std::sync::atomic::AtomicUsize;
use std::sync::atomic::Ordering;
@@ -291,8 +291,21 @@ mod tests {
use wiremock::matchers::method;
use wiremock::matchers::path;
fn chatgpt_auth() -> CoreAuthProvider {
CoreAuthProvider::for_test(Some("token"), Some("account_id"))
#[derive(Clone, Copy)]
struct ChatGptTestAuth;
impl AuthProvider for ChatGptTestAuth {
fn add_auth_headers(&self, headers: &mut reqwest::header::HeaderMap) {
headers.insert(
reqwest::header::AUTHORIZATION,
HeaderValue::from_static("Bearer token"),
);
headers.insert("ChatGPT-Account-ID", HeaderValue::from_static("account_id"));
}
}
fn chatgpt_auth() -> ChatGptTestAuth {
ChatGptTestAuth
}
fn base_url_for(server: &MockServer) -> String {
+3 -1
View File
@@ -15,9 +15,11 @@ pub use codex_client::RequestTelemetry;
pub use codex_client::ReqwestTransport;
pub use codex_client::TransportError;
pub use crate::api_bridge::CoreAuthProvider;
pub use crate::api_bridge::map_api_error;
pub use crate::auth::AuthHeaderTelemetry;
pub use crate::auth::AuthProvider;
pub use crate::auth::SharedAuthProvider;
pub use crate::auth::auth_header_telemetry;
pub use crate::common::CompactionInput;
pub use crate::common::MemorySummarizeInput;
pub use crate::common::MemorySummarizeOutput;
+4 -4
View File
@@ -200,7 +200,7 @@ data: {"id":"resp-1","output":[{"type":"message","role":"assistant","content":[{
async fn responses_client_uses_responses_path() -> Result<()> {
let state = RecordingState::default();
let transport = RecordingTransport::new(state.clone());
let client = ResponsesClient::new(transport, provider("openai"), NoAuth);
let client = ResponsesClient::new(transport, provider("openai"), Arc::new(NoAuth));
let body = serde_json::json!({ "echo": true });
let _stream = client
@@ -221,7 +221,7 @@ async fn responses_client_uses_responses_path() -> Result<()> {
async fn streaming_client_adds_auth_headers() -> Result<()> {
let state = RecordingState::default();
let transport = RecordingTransport::new(state.clone());
let auth = StaticAuth::new("secret-token", "acct-1");
let auth = Arc::new(StaticAuth::new("secret-token", "acct-1"));
let client = ResponsesClient::new(transport, provider("openai"), auth);
let body = serde_json::json!({ "model": "gpt-test" });
@@ -281,7 +281,7 @@ async fn streaming_client_retries_on_transport_error() -> Result<()> {
text: None,
client_metadata: None,
};
let client = ResponsesClient::new(transport.clone(), provider, NoAuth);
let client = ResponsesClient::new(transport.clone(), provider, Arc::new(NoAuth));
let _stream = client
.stream_request(
@@ -300,7 +300,7 @@ async fn streaming_client_retries_on_transport_error() -> Result<()> {
async fn azure_default_store_attaches_ids_and_headers() -> Result<()> {
let state = RecordingState::default();
let transport = RecordingTransport::new(state.clone());
let client = ResponsesClient::new(transport, provider("azure"), NoAuth);
let client = ResponsesClient::new(transport, provider("azure"), Arc::new(NoAuth));
let request = ResponsesApiRequest {
model: "gpt-test".into(),
@@ -14,6 +14,7 @@ use codex_protocol::openai_models::TruncationPolicyConfig;
use codex_protocol::openai_models::default_input_modalities;
use http::HeaderMap;
use http::Method;
use std::sync::Arc;
use wiremock::Mock;
use wiremock::MockServer;
use wiremock::ResponseTemplate;
@@ -108,7 +109,7 @@ async fn models_client_hits_models_endpoint() {
.await;
let transport = ReqwestTransport::new(reqwest::Client::new());
let client = ModelsClient::new(transport, provider(&base_url), DummyAuth);
let client = ModelsClient::new(transport, provider(&base_url), Arc::new(DummyAuth));
let (models, _) = client
.list_models("0.1.0", HeaderMap::new())
+2 -1
View File
@@ -1,3 +1,4 @@
use std::sync::Arc;
use std::time::Duration;
use anyhow::Result;
@@ -116,7 +117,7 @@ async fn responses_stream_parses_items_and_completed_end_to_end() -> Result<()>
let body = build_responses_body(vec![item1, item2, completed]);
let transport = FixtureSseTransport::new(body);
let client = ResponsesClient::new(transport, provider("openai"), NoAuth);
let client = ResponsesClient::new(transport, provider("openai"), Arc::new(NoAuth));
let mut stream = client
.stream(
+1
View File
@@ -52,6 +52,7 @@ codex-instructions = { workspace = true }
codex-network-proxy = { workspace = true }
codex-otel = { workspace = true }
codex-plugin = { workspace = true }
codex-model-provider = { workspace = true }
codex-protocol = { workspace = true }
codex-response-debug-context = { workspace = true }
codex-rollout = { workspace = true }
+45 -46
View File
@@ -39,6 +39,7 @@ use codex_api::Compression;
use codex_api::MemoriesClient as ApiMemoriesClient;
use codex_api::MemorySummarizeInput as ApiMemorySummarizeInput;
use codex_api::MemorySummarizeOutput as ApiMemorySummarizeOutput;
use codex_api::Provider as ApiProvider;
use codex_api::RawMemory as ApiRawMemory;
use codex_api::RealtimeCallClient as ApiRealtimeCallClient;
use codex_api::RealtimeSessionConfig as ApiRealtimeSessionConfig;
@@ -52,9 +53,11 @@ use codex_api::ResponsesOptions as ApiResponsesOptions;
use codex_api::ResponsesWebsocketClient as ApiWebSocketResponsesClient;
use codex_api::ResponsesWebsocketConnection as ApiWebSocketConnection;
use codex_api::ResponsesWsRequest;
use codex_api::SharedAuthProvider;
use codex_api::SseTelemetry;
use codex_api::TransportError;
use codex_api::WebsocketTelemetry;
use codex_api::auth_header_telemetry;
use codex_api::build_conversation_headers;
use codex_api::create_text_param_for_request;
use codex_api::response_create_client_metadata;
@@ -101,14 +104,13 @@ use crate::client_common::ResponseEvent;
use crate::client_common::ResponseStream;
use crate::flags::CODEX_RS_SSE_FIXTURE;
use crate::util::emit_feedback_auth_recovery_tags;
use codex_api::CoreAuthProvider;
use codex_api::map_api_error;
use codex_feedback::FeedbackRequestTags;
use codex_feedback::emit_feedback_request_tags_with_auth_env;
use codex_login::api_bridge::auth_provider_from_auth;
use codex_login::auth_env_telemetry::AuthEnvTelemetry;
use codex_login::auth_env_telemetry::collect_auth_env_telemetry;
use codex_login::provider_auth::auth_manager_for_provider;
use codex_model_provider::SharedModelProvider;
use codex_model_provider::create_model_provider;
#[cfg(test)]
use codex_model_provider_info::DEFAULT_WEBSOCKET_CONNECT_TIMEOUT_MS;
use codex_model_provider_info::ModelProviderInfo;
@@ -143,11 +145,10 @@ pub(crate) const WEBSOCKET_CONNECT_TIMEOUT: Duration =
/// configuration is per turn and is passed explicitly to streaming/unary methods.
#[derive(Debug)]
struct ModelClientState {
auth_manager: Option<Arc<AuthManager>>,
conversation_id: ThreadId,
window_generation: AtomicU64,
installation_id: String,
provider: ModelProviderInfo,
provider: SharedModelProvider,
auth_env_telemetry: AuthEnvTelemetry,
session_source: SessionSource,
model_verbosity: Option<VerbosityConfig>,
@@ -164,8 +165,8 @@ struct ModelClientState {
/// share the same auth/provider setup flow.
struct CurrentClientSetup {
auth: Option<CodexAuth>,
api_provider: codex_api::Provider,
api_auth: CoreAuthProvider,
api_provider: ApiProvider,
api_auth: SharedAuthProvider,
}
#[derive(Clone, Copy)]
@@ -275,7 +276,7 @@ pub(crate) struct RealtimeWebrtcCallStart {
/// API-key sessions send that API bearer. ChatGPT-auth sessions send their bearer plus account id;
/// transceiver is responsible for accepting that same call-create identity on the direct
/// `api.openai.com` sideband path.
fn sideband_websocket_auth_headers(api_auth: &CoreAuthProvider) -> ApiHeaderMap {
fn sideband_websocket_auth_headers(api_auth: &dyn AuthProvider) -> ApiHeaderMap {
let mut headers = ApiHeaderMap::new();
api_auth.add_auth_headers(&mut headers);
headers
@@ -291,25 +292,26 @@ impl ModelClient {
auth_manager: Option<Arc<AuthManager>>,
conversation_id: ThreadId,
installation_id: String,
provider: ModelProviderInfo,
provider_info: ModelProviderInfo,
session_source: SessionSource,
model_verbosity: Option<VerbosityConfig>,
enable_request_compression: bool,
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
let model_provider = create_model_provider(provider_info, auth_manager);
let codex_api_key_env_enabled = model_provider
.auth_manager()
.as_ref()
.is_some_and(|manager| manager.codex_api_key_env_enabled());
let auth_env_telemetry = collect_auth_env_telemetry(&provider, codex_api_key_env_enabled);
let auth_env_telemetry =
collect_auth_env_telemetry(model_provider.info(), codex_api_key_env_enabled);
Self {
state: Arc::new(ModelClientState {
auth_manager,
conversation_id,
window_generation: AtomicU64::new(0),
installation_id,
provider,
provider: model_provider,
auth_env_telemetry,
session_source,
model_verbosity,
@@ -335,7 +337,7 @@ impl ModelClient {
}
pub(crate) fn auth_manager(&self) -> Option<Arc<AuthManager>> {
self.state.auth_manager.clone()
self.state.provider.auth_manager()
}
pub(crate) fn set_window_generation(&self, window_generation: u64) {
@@ -418,7 +420,7 @@ impl ModelClient {
session_telemetry,
AuthRequestTelemetryContext::new(
client_setup.auth.as_ref().map(CodexAuth::auth_mode),
&client_setup.api_auth,
client_setup.api_auth.as_ref(),
PendingUnauthorizedRetry::default(),
),
RequestRouteTelemetry::for_endpoint(RESPONSES_COMPACT_ENDPOINT),
@@ -478,7 +480,9 @@ impl ModelClient {
// the server-side control WebSocket to the call id from that HTTP response.
let client_setup = self.current_client_setup().await?;
let mut sideband_headers = extra_headers.clone();
sideband_headers.extend(sideband_websocket_auth_headers(&client_setup.api_auth));
sideband_headers.extend(sideband_websocket_auth_headers(
client_setup.api_auth.as_ref(),
));
let transport = ReqwestTransport::new(build_reqwest_client());
let response =
ApiRealtimeCallClient::new(transport, client_setup.api_provider, client_setup.api_auth)
@@ -515,7 +519,7 @@ impl ModelClient {
session_telemetry,
AuthRequestTelemetryContext::new(
client_setup.auth.as_ref().map(CodexAuth::auth_mode),
&client_setup.api_auth,
client_setup.api_auth.as_ref(),
PendingUnauthorizedRetry::default(),
),
RequestRouteTelemetry::for_endpoint(MEMORIES_SUMMARIZE_ENDPOINT),
@@ -636,7 +640,7 @@ impl ModelClient {
///
/// WebSocket use is controlled by provider capability and session-scoped fallback state.
pub fn responses_websocket_enabled(&self) -> bool {
if !self.state.provider.supports_websockets
if !self.state.provider.info().supports_websockets
|| self.state.disable_websockets.load(Ordering::Relaxed)
|| (*CODEX_RS_SSE_FIXTURE).is_some()
{
@@ -651,15 +655,9 @@ impl ModelClient {
/// This centralizes setup used by both prewarm and normal request paths so they stay in
/// lockstep when auth/provider resolution changes.
async fn current_client_setup(&self) -> Result<CurrentClientSetup> {
let auth = match self.state.auth_manager.as_ref() {
Some(manager) => manager.auth().await,
None => None,
};
let api_provider = self
.state
.provider
.to_api_provider(auth.as_ref().map(CodexAuth::auth_mode))?;
let api_auth = auth_provider_from_auth(auth.clone(), &self.state.provider)?;
let auth = self.state.provider.auth().await;
let api_provider = self.state.provider.api_provider().await?;
let api_auth = self.state.provider.api_auth().await?;
Ok(CurrentClientSetup {
auth,
api_provider,
@@ -676,7 +674,7 @@ impl ModelClient {
&self,
session_telemetry: &SessionTelemetry,
api_provider: codex_api::Provider,
api_auth: CoreAuthProvider,
api_auth: SharedAuthProvider,
turn_state: Option<Arc<OnceLock<String>>>,
turn_metadata_header: Option<&str>,
auth_context: AuthRequestTelemetryContext,
@@ -689,7 +687,7 @@ impl ModelClient {
request_route_telemetry,
self.state.auth_env_telemetry.clone(),
);
let websocket_connect_timeout = self.state.provider.websocket_connect_timeout();
let websocket_connect_timeout = self.state.provider.info().websocket_connect_timeout();
let start = Instant::now();
let result = match tokio::time::timeout(
websocket_connect_timeout,
@@ -1007,7 +1005,7 @@ impl ModelClientSession {
})?;
let auth_context = AuthRequestTelemetryContext::new(
client_setup.auth.as_ref().map(CodexAuth::auth_mode),
&client_setup.api_auth,
client_setup.api_auth.as_ref(),
PendingUnauthorizedRetry::default(),
);
let connection = self
@@ -1033,8 +1031,8 @@ impl ModelClientSession {
level = "info",
skip_all,
fields(
provider = %self.client.state.provider.name,
wire_api = %self.client.state.provider.wire_api,
provider = %self.client.state.provider.info().name,
wire_api = %self.client.state.provider.info().wire_api,
transport = "responses_websocket",
api.path = "responses",
turn.has_metadata_header = params.turn_metadata_header.is_some()
@@ -1105,7 +1103,7 @@ impl ModelClientSession {
fn responses_request_compression(&self, auth: Option<&CodexAuth>) -> Compression {
if self.client.state.enable_request_compression
&& auth.is_some_and(CodexAuth::is_chatgpt_auth)
&& self.client.state.provider.is_openai()
&& self.client.state.provider.info().is_openai()
{
Compression::Zstd
} else {
@@ -1124,7 +1122,7 @@ impl ModelClientSession {
skip_all,
fields(
model = %model_info.slug,
wire_api = %self.client.state.provider.wire_api,
wire_api = %self.client.state.provider.info().wire_api,
transport = "responses_http",
http.method = "POST",
api.path = "responses",
@@ -1145,14 +1143,14 @@ impl ModelClientSession {
warn!(path, "Streaming from fixture");
let stream = codex_api::stream_from_fixture(
path,
self.client.state.provider.stream_idle_timeout(),
self.client.state.provider.info().stream_idle_timeout(),
)
.map_err(map_api_error)?;
let (stream, _last_request_rx) = map_response_stream(stream, session_telemetry.clone());
return Ok(stream);
}
let auth_manager = self.client.state.auth_manager.clone();
let auth_manager = self.client.state.provider.auth_manager();
let mut auth_recovery = auth_manager
.as_ref()
.map(AuthManager::unauthorized_recovery);
@@ -1162,7 +1160,7 @@ impl ModelClientSession {
let transport = ReqwestTransport::new(build_reqwest_client());
let request_auth_context = AuthRequestTelemetryContext::new(
client_setup.auth.as_ref().map(CodexAuth::auth_mode),
&client_setup.api_auth,
client_setup.api_auth.as_ref(),
pending_retry,
);
let (request_telemetry, sse_telemetry) = Self::build_streaming_telemetry(
@@ -1221,7 +1219,7 @@ impl ModelClientSession {
skip_all,
fields(
model = %model_info.slug,
wire_api = %self.client.state.provider.wire_api,
wire_api = %self.client.state.provider.info().wire_api,
transport = "responses_websocket",
api.path = "responses",
turn.has_metadata_header = turn_metadata_header.is_some(),
@@ -1240,7 +1238,7 @@ impl ModelClientSession {
warmup: bool,
request_trace: Option<W3cTraceContext>,
) -> Result<WebsocketStreamOutcome> {
let auth_manager = self.client.state.auth_manager.clone();
let auth_manager = self.client.state.provider.auth_manager();
let mut auth_recovery = auth_manager
.as_ref()
@@ -1250,7 +1248,7 @@ impl ModelClientSession {
let client_setup = self.client.current_client_setup().await?;
let request_auth_context = AuthRequestTelemetryContext::new(
client_setup.auth.as_ref().map(CodexAuth::auth_mode),
&client_setup.api_auth,
client_setup.api_auth.as_ref(),
pending_retry,
);
let compression = self.responses_request_compression(client_setup.auth.as_ref());
@@ -1432,7 +1430,7 @@ impl ModelClientSession {
service_tier: Option<ServiceTier>,
turn_metadata_header: Option<&str>,
) -> Result<ResponseStream> {
let wire_api = self.client.state.provider.wire_api;
let wire_api = self.client.state.provider.info().wire_api;
match wire_api {
WireApi::Responses => {
if self.client.responses_websocket_enabled() {
@@ -1680,16 +1678,17 @@ struct AuthRequestTelemetryContext {
impl AuthRequestTelemetryContext {
fn new(
auth_mode: Option<AuthMode>,
api_auth: &CoreAuthProvider,
api_auth: &dyn AuthProvider,
retry: PendingUnauthorizedRetry,
) -> Self {
let auth_telemetry = auth_header_telemetry(api_auth);
Self {
auth_mode: auth_mode.map(|mode| match mode {
AuthMode::ApiKey => "ApiKey",
AuthMode::Chatgpt | AuthMode::ChatgptAuthTokens => "Chatgpt",
}),
auth_header_attached: api_auth.auth_header_attached(),
auth_header_name: api_auth.auth_header_name(),
auth_header_attached: auth_telemetry.attached,
auth_header_name: auth_telemetry.name,
retry_after_unauthorized: retry.retry_after_unauthorized,
recovery_mode: retry.recovery_mode,
recovery_phase: retry.recovery_phase,
@@ -1700,7 +1699,7 @@ impl AuthRequestTelemetryContext {
struct WebsocketConnectParams<'a> {
session_telemetry: &'a SessionTelemetry,
api_provider: codex_api::Provider,
api_auth: CoreAuthProvider,
api_auth: SharedAuthProvider,
turn_metadata_header: Option<&'a str>,
options: &'a ApiResponsesOptions,
auth_context: AuthRequestTelemetryContext,
+2 -2
View File
@@ -7,8 +7,8 @@ use super::X_CODEX_PARENT_THREAD_ID_HEADER;
use super::X_CODEX_TURN_METADATA_HEADER;
use super::X_CODEX_WINDOW_ID_HEADER;
use super::X_OPENAI_SUBAGENT_HEADER;
use codex_api::CoreAuthProvider;
use codex_app_server_protocol::AuthMode;
use codex_model_provider::BearerAuthProvider;
use codex_model_provider_info::WireApi;
use codex_model_provider_info::create_oss_provider_with_base_url;
use codex_otel::SessionTelemetry;
@@ -155,7 +155,7 @@ async fn summarize_memories_returns_empty_for_empty_input() {
fn auth_request_telemetry_context_tracks_attached_auth_and_retry_phase() {
let auth_context = AuthRequestTelemetryContext::new(
Some(AuthMode::Chatgpt),
&CoreAuthProvider::for_test(Some("access-token"), Some("workspace-123")),
&BearerAuthProvider::for_test(Some("access-token"), Some("workspace-123")),
PendingUnauthorizedRetry::from_recovery(UnauthorizedRecoveryExecution {
mode: "managed",
phase: "refresh_token",
+2 -2
View File
@@ -793,7 +793,7 @@ async fn run_auto_compact(
reason: CompactionReason,
phase: CompactionPhase,
) -> CodexResult<()> {
if should_use_remote_compact_task(&turn_context.provider) {
if should_use_remote_compact_task(turn_context.provider.info()) {
run_inline_remote_auto_compact_task(
Arc::clone(sess),
Arc::clone(turn_context),
@@ -1074,7 +1074,7 @@ async fn run_sampling_request(
}
// Use the configured provider-specific stream retry budget.
let max_retries = turn_context.provider.stream_max_retries();
let max_retries = turn_context.provider.info().stream_max_retries();
if retries >= max_retries
&& client_session.try_switch_fallback_transport(
&turn_context.session_telemetry,
+5 -3
View File
@@ -1,4 +1,6 @@
use super::*;
use codex_model_provider::SharedModelProvider;
use codex_model_provider::create_model_provider;
pub(super) fn image_generation_tool_auth_allowed(auth_manager: Option<&AuthManager>) -> bool {
matches!(
@@ -32,7 +34,7 @@ pub(crate) struct TurnContext {
pub(crate) auth_manager: Option<Arc<AuthManager>>,
pub(crate) model_info: ModelInfo,
pub(crate) session_telemetry: SessionTelemetry,
pub(crate) provider: ModelProviderInfo,
pub(crate) provider: SharedModelProvider,
pub(crate) reasoning_effort: Option<ReasoningEffortConfig>,
pub(crate) reasoning_summary: ReasoningSummaryConfig,
pub(crate) session_source: SessionSource,
@@ -354,8 +356,8 @@ impl Session {
let session_source = session_configuration.session_source.clone();
let image_generation_tool_auth_allowed =
image_generation_tool_auth_allowed(auth_manager.as_deref());
let auth_manager_for_context = auth_manager;
let provider_for_context = provider;
let auth_manager_for_context = auth_manager.clone();
let provider_for_context = create_model_provider(provider, auth_manager);
let session_telemetry_for_context = session_telemetry;
let tools_config = ToolsConfig::new(&ToolsConfigParams {
model_info: &model_info,
+5 -1
View File
@@ -16,6 +16,7 @@ use codex_execpolicy::Decision;
use codex_execpolicy::Evaluation;
use codex_execpolicy::RuleMatch;
use codex_features::Feature;
use codex_model_provider::create_model_provider;
use codex_protocol::models::ContentItem;
use codex_protocol::models::NetworkPermissions;
use codex_protocol::models::PermissionProfile;
@@ -101,7 +102,10 @@ async fn guardian_allows_shell_additional_permissions_requests_past_policy_valid
));
session.services.models_manager = models_manager;
turn_context_raw.config = Arc::clone(&config);
turn_context_raw.provider = config.model_provider.clone();
turn_context_raw.provider = create_model_provider(
config.model_provider.clone(),
turn_context_raw.auth_manager.clone(),
);
let session = Arc::new(session);
let turn_context = Arc::new(turn_context_raw);
let expiration_ms: u64 = if cfg!(windows) { 2_500 } else { 1_000 };
+3 -2
View File
@@ -19,7 +19,6 @@ use codex_analytics::CompactionStrategy;
use codex_analytics::CompactionTrigger;
use codex_analytics::now_unix_seconds;
use codex_features::Feature;
use codex_model_provider_info::ModelProviderInfo;
use codex_protocol::error::CodexErr;
use codex_protocol::error::Result as CodexResult;
use codex_protocol::items::ContextCompactionItem;
@@ -38,6 +37,8 @@ use codex_utils_output_truncation::truncate_text;
use futures::prelude::*;
use tracing::error;
use codex_model_provider_info::ModelProviderInfo;
pub const SUMMARIZATION_PROMPT: &str = include_str!("../templates/compact/prompt.md");
pub const SUMMARY_PREFIX: &str = include_str!("../templates/compact/summary_prefix.md");
const COMPACT_USER_MESSAGE_MAX_TOKENS: usize = 20_000;
@@ -166,7 +167,7 @@ async fn run_compact_task_inner_impl(
let mut truncated_count = 0usize;
let max_retries = turn_context.provider.stream_max_retries();
let max_retries = turn_context.provider.info().stream_max_retries();
let mut retries = 0;
let mut client_session = sess.services.model_client.new_session();
// Reuse one client session so turn-scoped state (sticky routing, websocket incremental
+5 -3
View File
@@ -17,6 +17,7 @@ use crate::config_loader::Sourced;
use crate::test_support;
use codex_config::config_toml::ConfigToml;
use codex_exec_server::LOCAL_FS;
use codex_model_provider::create_model_provider;
use codex_network_proxy::NetworkProxyConfig;
use codex_protocol::ThreadId;
use codex_protocol::approvals::NetworkApprovalProtocol;
@@ -83,7 +84,7 @@ async fn guardian_test_session_and_turn_with_base_url(
));
session.services.models_manager = models_manager;
turn.config = Arc::clone(&config);
turn.provider = config.model_provider.clone();
turn.provider = create_model_provider(config.model_provider.clone(), turn.auth_manager.clone());
turn.user_instructions = None;
(Arc::new(session), Arc::new(turn))
@@ -889,7 +890,7 @@ async fn guardian_review_request_layout_matches_model_visible_request_snapshot()
));
session.services.models_manager = models_manager;
turn.config = Arc::clone(&config);
turn.provider = config.model_provider.clone();
turn.provider = create_model_provider(config.model_provider.clone(), turn.auth_manager.clone());
let session = Arc::new(session);
let turn = Arc::new(turn);
seed_guardian_parent_history(&session, &turn).await;
@@ -1261,7 +1262,8 @@ async fn guardian_review_surfaces_responses_api_errors_in_rejection_reason() ->
.models_manager = models_manager;
let turn_mut = Arc::get_mut(&mut turn).expect("turn should be uniquely owned");
turn_mut.config = Arc::clone(&config);
turn_mut.provider = config.model_provider.clone();
turn_mut.provider =
create_model_provider(config.model_provider.clone(), turn_mut.auth_manager.clone());
turn_mut.user_instructions = None;
seed_guardian_parent_history(&session, &turn).await;
+2 -2
View File
@@ -12,9 +12,9 @@
use crate::codex::Session;
use crate::codex::TurnContext;
use codex_api::CoreAuthProvider;
use codex_api::upload_local_file;
use codex_login::CodexAuth;
use codex_model_provider::BearerAuthProvider;
use serde_json::Value as JsonValue;
pub(crate) async fn rewrite_mcp_tool_arguments_for_openai_files(
@@ -112,7 +112,7 @@ async fn build_uploaded_local_argument_value(
let token_data = auth
.get_token_data()
.map_err(|error| format!("failed to read ChatGPT auth for file upload: {error}"))?;
let upload_auth = CoreAuthProvider {
let upload_auth = BearerAuthProvider {
token: Some(token_data.access_token),
account_id: token_data.account_id,
is_fedramp_account: auth.is_fedramp_account(),
+13 -3
View File
@@ -12,6 +12,7 @@ use codex_config::types::ApprovalsReviewer;
use codex_config::types::AppsConfigToml;
use codex_config::types::McpServerConfig;
use codex_config::types::McpServerToolConfig;
use codex_model_provider::create_model_provider;
use codex_protocol::protocol::AskForApproval;
use codex_protocol::protocol::SandboxPolicy;
use core_test_support::PathExt;
@@ -1390,7 +1391,10 @@ async fn guardian_mode_skips_auto_when_annotations_do_not_require_approval() {
));
session.services.models_manager = models_manager;
turn_context.config = Arc::clone(&config);
turn_context.provider = config.model_provider.clone();
turn_context.provider = create_model_provider(
config.model_provider.clone(),
turn_context.auth_manager.clone(),
);
let session = Arc::new(session);
let turn_context = Arc::new(turn_context);
@@ -1466,7 +1470,10 @@ async fn guardian_mode_mcp_denial_returns_rationale_message() {
));
session.services.models_manager = models_manager;
turn_context.config = Arc::clone(&config);
turn_context.provider = config.model_provider.clone();
turn_context.provider = create_model_provider(
config.model_provider.clone(),
turn_context.auth_manager.clone(),
);
let session = Arc::new(session);
let turn_context = Arc::new(turn_context);
@@ -1920,7 +1927,10 @@ async fn approve_mode_routes_arc_ask_user_to_guardian_when_guardian_reviewer_is_
));
session.services.models_manager = models_manager;
turn_context.config = Arc::clone(&config);
turn_context.provider = config.model_provider.clone();
turn_context.provider = create_model_provider(
config.model_provider.clone(),
turn_context.auth_manager.clone(),
);
let session = Arc::new(session);
let turn_context = Arc::new(turn_context);
+1 -1
View File
@@ -27,7 +27,7 @@ impl SessionTask for CompactTask {
_cancellation_token: CancellationToken,
) -> Option<String> {
let session = session.clone_session();
let _ = if crate::compact::should_use_remote_compact_task(&ctx.provider) {
let _ = if crate::compact::should_use_remote_compact_task(ctx.provider.info()) {
session.services.session_telemetry.counter(
"codex.task.compact",
/*inc*/ 1,
@@ -224,7 +224,7 @@ fn build_agent_shared_config(turn: &TurnContext) -> Result<Config, FunctionCallE
let base_config = turn.config.clone();
let mut config = (*base_config).clone();
config.model = Some(turn.model_info.slug.clone());
config.model_provider = turn.provider.clone();
config.model_provider = turn.provider.info().clone();
config.model_reasoning_effort = turn
.reasoning_effort
.or(turn.model_info.default_reasoning_level);
@@ -21,6 +21,7 @@ use codex_config::types::ShellEnvironmentPolicy;
use codex_features::Feature;
use codex_login::AuthManager;
use codex_login::CodexAuth;
use codex_model_provider::create_model_provider;
use codex_model_provider_info::built_in_model_providers;
use codex_protocol::AgentPath;
use codex_protocol::ThreadId;
@@ -353,10 +354,10 @@ async fn spawn_agent_uses_explorer_role_and_preserves_approval_policy() {
let manager = thread_manager();
session.services.agent_control = manager.agent_control();
let mut config = (*turn.config).clone();
let provider =
let provider_info =
built_in_model_providers(/* openai_base_url */ /*openai_base_url*/ None)["ollama"].clone();
config.model_provider_id = "ollama".to_string();
config.model_provider = provider.clone();
config.model_provider = provider_info.clone();
config
.permissions
.approval_policy
@@ -365,7 +366,7 @@ async fn spawn_agent_uses_explorer_role_and_preserves_approval_policy() {
turn.approval_policy
.set(AskForApproval::OnRequest)
.expect("approval policy should be set");
turn.provider = provider;
turn.provider = create_model_provider(provider_info, turn.auth_manager.clone());
turn.config = Arc::new(config);
let invocation = invocation(
@@ -3505,7 +3506,7 @@ async fn build_agent_spawn_config_uses_turn_context_values() {
let mut expected = (*turn.config).clone();
expected.base_instructions = Some(base_instructions.text);
expected.model = Some(turn.model_info.slug.clone());
expected.model_provider = turn.provider.clone();
expected.model_provider = turn.provider.info().clone();
expected.model_reasoning_effort = turn.reasoning_effort;
expected.model_reasoning_summary = Some(turn.reasoning_summary);
expected.developer_instructions = turn.developer_instructions.clone();
@@ -3559,7 +3560,7 @@ async fn build_agent_resume_config_clears_base_instructions() {
let mut expected = (*turn.config).clone();
expected.base_instructions = None;
expected.model = Some(turn.model_info.slug.clone());
expected.model_provider = turn.provider.clone();
expected.model_provider = turn.provider.info().clone();
expected.model_reasoning_effort = turn.reasoning_effort;
expected.model_reasoning_summary = Some(turn.reasoning_summary);
expected.developer_instructions = turn.developer_instructions.clone();
+1 -1
View File
@@ -234,7 +234,7 @@ move /y tokens.next tokens.txt >nul
ModelProviderAuthInfo {
command: self.command.clone(),
args: self.args.clone(),
// Match the provider-auth default to avoid brittle shell-startup timing in CI.
// Match the model-provider default to avoid brittle shell-startup timing in CI.
timeout_ms: non_zero_u64(/*value*/ 5_000),
refresh_interval_ms: 60_000,
cwd: match codex_utils_absolute_path::AbsolutePathBuf::try_from(self.tempdir.path()) {
-1
View File
@@ -12,7 +12,6 @@ async-trait = { workspace = true }
base64 = { workspace = true }
chrono = { workspace = true, features = ["serde"] }
codex-app-server-protocol = { workspace = true }
codex-api = { workspace = true }
codex-client = { workspace = true }
codex-config = { workspace = true }
codex-keyring-store = { workspace = true }
-40
View File
@@ -1,40 +0,0 @@
use codex_api::CoreAuthProvider;
use codex_model_provider_info::ModelProviderInfo;
use crate::CodexAuth;
pub fn auth_provider_from_auth(
auth: Option<CodexAuth>,
provider: &ModelProviderInfo,
) -> codex_protocol::error::Result<CoreAuthProvider> {
if let Some(api_key) = provider.api_key()? {
return Ok(CoreAuthProvider {
token: Some(api_key),
account_id: None,
is_fedramp_account: false,
});
}
if let Some(token) = provider.experimental_bearer_token.clone() {
return Ok(CoreAuthProvider {
token: Some(token),
account_id: None,
is_fedramp_account: false,
});
}
if let Some(auth) = auth {
let token = auth.get_token()?;
Ok(CoreAuthProvider {
token: Some(token),
account_id: auth.get_account_id(),
is_fedramp_account: auth.is_fedramp_account(),
})
} else {
Ok(CoreAuthProvider {
token: None,
account_id: None,
is_fedramp_account: false,
})
}
}
-5
View File
@@ -1,7 +1,5 @@
pub mod api_bridge;
pub mod auth;
pub mod auth_env_telemetry;
pub mod provider_auth;
pub mod token_data;
mod device_code_auth;
@@ -19,7 +17,6 @@ pub use server::ServerOptions;
pub use server::ShutdownHandle;
pub use server::run_login_server;
pub use api_bridge::auth_provider_from_auth;
pub use auth::AgentIdentityAuthRecord;
pub use auth::AuthConfig;
pub use auth::AuthDotJson;
@@ -46,6 +43,4 @@ pub use auth::read_openai_api_key_from_env;
pub use auth::save_auth;
pub use auth_env_telemetry::AuthEnvTelemetry;
pub use auth_env_telemetry::collect_auth_env_telemetry;
pub use provider_auth::auth_manager_for_provider;
pub use provider_auth::required_auth_manager_for_provider;
pub use token_data::TokenData;
-32
View File
@@ -1,32 +0,0 @@
use std::sync::Arc;
use codex_model_provider_info::ModelProviderInfo;
use crate::AuthManager;
/// 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 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 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
View File
@@ -0,0 +1,6 @@
load("//:defs.bzl", "codex_rust_crate")
codex_rust_crate(
name = "model-provider",
crate_name = "codex_model_provider",
)
+24
View File
@@ -0,0 +1,24 @@
[package]
edition.workspace = true
license.workspace = true
name = "codex-model-provider"
version.workspace = true
[lib]
doctest = false
name = "codex_model_provider"
path = "src/lib.rs"
[lints]
workspace = true
[dependencies]
async-trait = { workspace = true }
codex-api = { workspace = true }
codex-login = { workspace = true }
codex-model-provider-info = { workspace = true }
codex-protocol = { workspace = true }
http = { workspace = true }
[dev-dependencies]
pretty_assertions = { workspace = true }
+64
View File
@@ -0,0 +1,64 @@
use std::sync::Arc;
use codex_api::SharedAuthProvider;
use codex_login::AuthManager;
use codex_login::CodexAuth;
use codex_model_provider_info::ModelProviderInfo;
use crate::bearer_auth_provider::BearerAuthProvider;
/// Returns the provider-scoped auth manager when this provider uses command-backed auth.
///
/// Providers without custom auth continue using the caller-supplied base manager, when present.
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,
}
}
fn bearer_auth_provider_from_auth(
auth: Option<&CodexAuth>,
provider: &ModelProviderInfo,
) -> codex_protocol::error::Result<BearerAuthProvider> {
if let Some(api_key) = provider.api_key()? {
return Ok(BearerAuthProvider {
token: Some(api_key),
account_id: None,
is_fedramp_account: false,
});
}
if let Some(token) = provider.experimental_bearer_token.clone() {
return Ok(BearerAuthProvider {
token: Some(token),
account_id: None,
is_fedramp_account: false,
});
}
if let Some(auth) = auth {
let token = auth.get_token()?;
Ok(BearerAuthProvider {
token: Some(token),
account_id: auth.get_account_id(),
is_fedramp_account: auth.is_fedramp_account(),
})
} else {
Ok(BearerAuthProvider {
token: None,
account_id: None,
is_fedramp_account: false,
})
}
}
pub(crate) fn resolve_provider_auth(
auth: Option<&CodexAuth>,
provider: &ModelProviderInfo,
) -> codex_protocol::error::Result<SharedAuthProvider> {
Ok(Arc::new(bearer_auth_provider_from_auth(auth, provider)?))
}
@@ -0,0 +1,102 @@
use codex_api::AuthProvider;
use http::HeaderMap;
use http::HeaderValue;
/// Bearer-token auth provider for OpenAI-compatible model-provider requests.
#[derive(Clone, Default)]
pub struct BearerAuthProvider {
pub token: Option<String>,
pub account_id: Option<String>,
pub is_fedramp_account: bool,
}
impl BearerAuthProvider {
pub fn for_test(token: Option<&str>, account_id: Option<&str>) -> Self {
Self {
token: token.map(str::to_string),
account_id: account_id.map(str::to_string),
is_fedramp_account: false,
}
}
}
impl AuthProvider for BearerAuthProvider {
fn add_auth_headers(&self, headers: &mut HeaderMap) {
if let Some(token) = self.token.as_ref()
&& let Ok(header) = HeaderValue::from_str(&format!("Bearer {token}"))
{
let _ = headers.insert(http::header::AUTHORIZATION, header);
}
if let Some(account_id) = self.account_id.as_ref()
&& let Ok(header) = HeaderValue::from_str(account_id)
{
let _ = headers.insert("ChatGPT-Account-ID", header);
}
if self.is_fedramp_account {
let _ = headers.insert("X-OpenAI-Fedramp", HeaderValue::from_static("true"));
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use pretty_assertions::assert_eq;
#[test]
fn bearer_auth_provider_reports_when_auth_header_will_attach() {
let auth = BearerAuthProvider {
token: Some("access-token".to_string()),
account_id: None,
is_fedramp_account: false,
};
assert_eq!(
codex_api::auth_header_telemetry(&auth),
codex_api::AuthHeaderTelemetry {
attached: true,
name: Some("authorization"),
}
);
}
#[test]
fn bearer_auth_provider_adds_auth_headers() {
let auth = BearerAuthProvider::for_test(Some("access-token"), Some("workspace-123"));
let mut headers = HeaderMap::new();
auth.add_auth_headers(&mut headers);
assert_eq!(
headers
.get(http::header::AUTHORIZATION)
.and_then(|value| value.to_str().ok()),
Some("Bearer access-token")
);
assert_eq!(
headers
.get("ChatGPT-Account-ID")
.and_then(|value| value.to_str().ok()),
Some("workspace-123")
);
}
#[test]
fn bearer_auth_provider_adds_fedramp_routing_header_for_fedramp_accounts() {
let auth = BearerAuthProvider {
token: Some("access-token".to_string()),
account_id: Some("workspace-123".to_string()),
is_fedramp_account: true,
};
let mut headers = HeaderMap::new();
auth.add_auth_headers(&mut headers);
assert_eq!(
headers
.get("X-OpenAI-Fedramp")
.and_then(|value| value.to_str().ok()),
Some("true")
);
}
}
+9
View File
@@ -0,0 +1,9 @@
mod auth;
mod bearer_auth_provider;
mod provider;
pub use bearer_auth_provider::BearerAuthProvider;
pub use bearer_auth_provider::BearerAuthProvider as CoreAuthProvider;
pub use provider::ModelProvider;
pub use provider::SharedModelProvider;
pub use provider::create_model_provider;
+126
View File
@@ -0,0 +1,126 @@
use std::fmt;
use std::sync::Arc;
use codex_api::Provider;
use codex_api::SharedAuthProvider;
use codex_login::AuthManager;
use codex_login::CodexAuth;
use codex_model_provider_info::ModelProviderInfo;
use crate::auth::auth_manager_for_provider;
use crate::auth::resolve_provider_auth;
/// Runtime provider abstraction used by model execution.
///
/// Implementations own provider-specific behavior for a model backend. The
/// `ModelProviderInfo` returned by `info` is the serialized/configured provider
/// metadata used by the default OpenAI-compatible implementation.
#[async_trait::async_trait]
pub trait ModelProvider: fmt::Debug + Send + Sync {
/// Returns the configured provider metadata.
fn info(&self) -> &ModelProviderInfo;
/// Returns the provider-scoped auth manager, when this provider uses one.
///
/// TODO(celia-oai): Make auth manager access internal to this crate so callers
/// resolve provider-specific auth only through `ModelProvider`. We first need
/// to think through whether Codex should have a unified provider-specific auth
/// manager throughout the codebase; that is a larger refactor than this change.
fn auth_manager(&self) -> Option<Arc<AuthManager>>;
/// Returns the current provider-scoped auth value, if one is configured.
async fn auth(&self) -> Option<CodexAuth>;
/// Returns provider configuration adapted for the API client.
async fn api_provider(&self) -> codex_protocol::error::Result<Provider> {
let auth = self.auth().await;
self.info()
.to_api_provider(auth.as_ref().map(CodexAuth::auth_mode))
}
/// Returns the auth provider used to attach request credentials.
async fn api_auth(&self) -> codex_protocol::error::Result<SharedAuthProvider> {
let auth = self.auth().await;
resolve_provider_auth(auth.as_ref(), self.info())
}
}
/// Shared runtime model provider handle.
pub type SharedModelProvider = Arc<dyn ModelProvider>;
/// Creates the default runtime model provider for configured provider metadata.
pub fn create_model_provider(
provider_info: ModelProviderInfo,
auth_manager: Option<Arc<AuthManager>>,
) -> SharedModelProvider {
let auth_manager = auth_manager_for_provider(auth_manager, &provider_info);
Arc::new(ConfiguredModelProvider {
info: provider_info,
auth_manager,
})
}
/// Runtime model provider backed by configured `ModelProviderInfo`.
#[derive(Clone, Debug)]
struct ConfiguredModelProvider {
info: ModelProviderInfo,
auth_manager: Option<Arc<AuthManager>>,
}
#[async_trait::async_trait]
impl ModelProvider for ConfiguredModelProvider {
fn info(&self) -> &ModelProviderInfo {
&self.info
}
fn auth_manager(&self) -> Option<Arc<AuthManager>> {
self.auth_manager.clone()
}
async fn auth(&self) -> Option<CodexAuth> {
match self.auth_manager.as_ref() {
Some(auth_manager) => auth_manager.auth().await,
None => None,
}
}
}
#[cfg(test)]
mod tests {
use std::num::NonZeroU64;
use codex_protocol::config_types::ModelProviderAuthInfo;
use super::*;
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)
}
}
#[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());
}
}
+1
View File
@@ -22,6 +22,7 @@ codex-feedback = { workspace = true }
codex-login = { workspace = true }
codex-model-provider-info = { workspace = true }
codex-otel = { workspace = true }
codex-model-provider = { workspace = true }
codex-protocol = { workspace = true }
codex-response-debug-context = { workspace = true }
codex-utils-output-truncation = { workspace = true }
+31 -21
View File
@@ -7,6 +7,7 @@ use codex_api::ModelsClient;
use codex_api::RequestTelemetry;
use codex_api::ReqwestTransport;
use codex_api::TransportError;
use codex_api::auth_header_telemetry;
use codex_api::map_api_error;
use codex_app_server_protocol::AuthMode;
use codex_feedback::FeedbackRequestTags;
@@ -14,10 +15,10 @@ use codex_feedback::emit_feedback_request_tags_with_auth_env;
use codex_login::AuthEnvTelemetry;
use codex_login::AuthManager;
use codex_login::CodexAuth;
use codex_login::auth_provider_from_auth;
use codex_login::collect_auth_env_telemetry;
use codex_login::default_client::build_reqwest_client;
use codex_login::required_auth_manager_for_provider;
use codex_model_provider::SharedModelProvider;
use codex_model_provider::create_model_provider;
use codex_model_provider_info::ModelProviderInfo;
use codex_otel::TelemetryAuthMode;
use codex_protocol::config_types::CollaborationModeMask;
@@ -178,10 +179,9 @@ pub struct ModelsManager {
remote_models: RwLock<Vec<ModelInfo>>,
catalog_mode: CatalogMode,
collaboration_modes_config: CollaborationModesConfig,
auth_manager: Arc<AuthManager>,
etag: RwLock<Option<String>>,
cache_manager: ModelsCacheManager,
provider: ModelProviderInfo,
provider: SharedModelProvider,
}
impl ModelsManager {
@@ -206,14 +206,17 @@ impl ModelsManager {
}
/// Construct a manager with an explicit provider used for remote model refreshes.
// TODO(celia-oai): Revisit this ownership direction: the model provider should likely
// own or return the models manager instead of requiring the manager to construct and use
// a provider from provider info.
pub fn new_with_provider(
codex_home: PathBuf,
auth_manager: Arc<AuthManager>,
model_catalog: Option<ModelsResponse>,
collaboration_modes_config: CollaborationModesConfig,
provider: ModelProviderInfo,
provider_info: ModelProviderInfo,
) -> Self {
let auth_manager = required_auth_manager_for_provider(auth_manager, &provider);
let model_provider = create_model_provider(provider_info, Some(auth_manager));
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() {
@@ -228,10 +231,9 @@ impl ModelsManager {
remote_models: RwLock::new(remote_models),
catalog_mode,
collaboration_modes_config,
auth_manager,
etag: RwLock::new(None),
cache_manager,
provider,
provider: model_provider,
}
}
@@ -395,9 +397,11 @@ impl ModelsManager {
return Ok(());
}
if self.auth_manager.auth_mode() != Some(AuthMode::Chatgpt)
&& !self.provider.has_command_auth()
{
let auth_mode = self
.provider
.auth_manager()
.and_then(|auth_manager| auth_manager.auth_mode());
if auth_mode != Some(AuthMode::Chatgpt) && !self.provider.info().has_command_auth() {
if matches!(
refresh_strategy,
RefreshStrategy::Offline | RefreshStrategy::OnlineIfUncached
@@ -432,19 +436,21 @@ impl ModelsManager {
async fn fetch_and_update_models(&self) -> CoreResult<()> {
let _timer =
codex_otel::start_global_timer("codex.remote_models.fetch_update.duration_ms", &[]);
let auth = self.auth_manager.auth().await;
let auth_manager = self.provider.auth_manager();
let codex_api_key_env_enabled = auth_manager
.as_ref()
.is_some_and(|auth_manager| auth_manager.codex_api_key_env_enabled());
let auth = self.provider.auth().await;
let auth_mode = auth.as_ref().map(CodexAuth::auth_mode);
let api_provider = self.provider.to_api_provider(auth_mode)?;
let api_auth = auth_provider_from_auth(auth.clone(), &self.provider)?;
let auth_env = collect_auth_env_telemetry(
&self.provider,
self.auth_manager.codex_api_key_env_enabled(),
);
let api_provider = self.provider.api_provider().await?;
let api_auth = self.provider.api_auth().await?;
let auth_env = collect_auth_env_telemetry(self.provider.info(), codex_api_key_env_enabled);
let transport = ReqwestTransport::new(build_reqwest_client());
let auth_telemetry = auth_header_telemetry(api_auth.as_ref());
let request_telemetry: Arc<dyn RequestTelemetry> = Arc::new(ModelsRequestTelemetry {
auth_mode: auth_mode.map(|mode| TelemetryAuthMode::from(mode).to_string()),
auth_header_attached: api_auth.auth_header_attached(),
auth_header_name: api_auth.auth_header_name(),
auth_header_attached: auth_telemetry.attached,
auth_header_name: auth_telemetry.name,
auth_env,
});
let client = ModelsClient::new(transport, api_provider, api_auth)
@@ -520,7 +526,11 @@ impl ModelsManager {
remote_models.sort_by(|a, b| a.priority.cmp(&b.priority));
let mut presets: Vec<ModelPreset> = remote_models.into_iter().map(Into::into).collect();
let chatgpt_mode = matches!(self.auth_manager.auth_mode(), Some(AuthMode::Chatgpt));
let auth_mode = self
.provider
.auth_manager()
.and_then(|auth_manager| auth_manager.auth_mode());
let chatgpt_mode = matches!(auth_mode, Some(AuthMode::Chatgpt));
presets = ModelPreset::filter_by_auth(presets, chatgpt_mode);
ModelPreset::mark_default_by_picker_visibility(&mut presets);