mirror of
https://github.com/openai/codex.git
synced 2026-10-21 17:48:44 +00:00
## Why Synchronous auth header construction could silently return no credentials when managed authentication became unavailable. Callers need an error to avoid sending requests with missing or partial authentication. ## What changed - Make `AuthProvider::add_auth_headers`, `to_auth_headers`, and auth header telemetry fallible, and propagate failures through request and WebSocket setup. - Return a transient error when managed authentication is unavailable, and preserve network policy denials through auth error conversion. - Retry transient MCP authentication errors while treating build errors and policy denials as terminal. - Cancel local MCP event streams before resolving authentication so cleanup still works when credentials become unavailable. ## Testing Add regression coverage for rejected partial authentication, async header resolution, terminal auth errors without sending requests, managed account changes, MCP retry classification, and local stream cancellation after authentication becomes unavailable. GitOrigin-RevId: 39d981e232cba1e302de502a2fa6239101114ed1
202 lines
5.7 KiB
Rust
202 lines
5.7 KiB
Rust
#![allow(clippy::expect_used)]
|
|
use std::sync::Arc;
|
|
use std::time::Duration;
|
|
|
|
use anyhow::Result;
|
|
use bytes::Bytes;
|
|
use codex_api::AuthProvider;
|
|
use codex_api::Compression;
|
|
use codex_api::Provider;
|
|
use codex_api::ResponseEvent;
|
|
use codex_api::ResponsesClient;
|
|
use codex_client::HttpTransport;
|
|
use codex_client::Request;
|
|
use codex_client::Response;
|
|
use codex_client::StreamResponse;
|
|
use codex_client::TransportError;
|
|
use codex_protocol::models::ResponseItem;
|
|
use futures::StreamExt;
|
|
use http::HeaderMap;
|
|
use http::StatusCode;
|
|
use pretty_assertions::assert_eq;
|
|
use serde_json::Value;
|
|
|
|
#[derive(Clone)]
|
|
struct FixtureSseTransport {
|
|
body: String,
|
|
}
|
|
|
|
impl FixtureSseTransport {
|
|
fn new(body: String) -> Self {
|
|
Self { body }
|
|
}
|
|
}
|
|
|
|
impl HttpTransport for FixtureSseTransport {
|
|
async fn execute(&self, _req: Request) -> Result<Response, TransportError> {
|
|
Err(TransportError::Build("execute should not run".to_string()))
|
|
}
|
|
|
|
async fn stream(&self, _req: Request) -> Result<StreamResponse, TransportError> {
|
|
let stream = futures::stream::iter(vec![Ok::<Bytes, TransportError>(Bytes::from(
|
|
self.body.clone(),
|
|
))]);
|
|
Ok(StreamResponse {
|
|
status: StatusCode::OK,
|
|
headers: HeaderMap::new(),
|
|
bytes: Box::pin(stream),
|
|
})
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Default)]
|
|
struct NoAuth;
|
|
|
|
impl AuthProvider for NoAuth {
|
|
fn add_auth_headers(&self, _headers: &mut HeaderMap) -> Result<(), codex_api::AuthError> {
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
fn provider(name: &str) -> Provider {
|
|
Provider {
|
|
name: name.to_string(),
|
|
base_url: "https://example.com/v1".to_string(),
|
|
query_params: None,
|
|
headers: HeaderMap::new(),
|
|
retry: codex_api::RetryConfig {
|
|
max_attempts: 1,
|
|
base_delay: Duration::from_millis(1),
|
|
retry_429: false,
|
|
retry_5xx: false,
|
|
retry_transport: true,
|
|
},
|
|
stream_idle_timeout: Duration::from_millis(50),
|
|
}
|
|
}
|
|
|
|
fn build_responses_body(events: Vec<Value>) -> String {
|
|
let mut body = String::new();
|
|
for e in events {
|
|
let kind = e
|
|
.get("type")
|
|
.and_then(|v| v.as_str())
|
|
.expect("SSE fixture event should have a type");
|
|
if e.as_object().map(|o| o.len() == 1).unwrap_or(false) {
|
|
body.push_str(&format!("event: {kind}\n\n"));
|
|
} else {
|
|
body.push_str(&format!("event: {kind}\ndata: {e}\n\n"));
|
|
}
|
|
}
|
|
body
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn responses_stream_parses_items_and_completed_end_to_end() -> Result<()> {
|
|
let item1 = serde_json::json!({
|
|
"type": "response.output_item.done",
|
|
"item": {
|
|
"type": "message",
|
|
"role": "assistant",
|
|
"content": [{"type": "output_text", "text": "Hello"}]
|
|
}
|
|
});
|
|
|
|
let item2 = serde_json::json!({
|
|
"type": "response.output_item.done",
|
|
"item": {
|
|
"type": "message",
|
|
"role": "assistant",
|
|
"content": [{"type": "output_text", "text": "World"}]
|
|
}
|
|
});
|
|
|
|
let completed = serde_json::json!({
|
|
"type": "response.completed",
|
|
"response": {
|
|
"id": "resp1",
|
|
"usage_metadata": { "amount": "0.12345678901234567890" },
|
|
"usage": {
|
|
"input_tokens": 10,
|
|
"output_tokens": 5,
|
|
"total_tokens": 15,
|
|
"extra": { "label": "example", "items": [0, null, true] },
|
|
"codex_rollout_budget_units": 2.5
|
|
}
|
|
}
|
|
});
|
|
|
|
let expected_metadata = completed["response"]["usage"].clone();
|
|
let body = build_responses_body(vec![item1, item2, completed]);
|
|
let transport = FixtureSseTransport::new(body);
|
|
let client = ResponsesClient::new(transport, provider("openai"), Arc::new(NoAuth));
|
|
|
|
let mut stream = client
|
|
.stream(
|
|
serde_json::json!({"echo": true}),
|
|
HeaderMap::new(),
|
|
Compression::None,
|
|
/*turn_state*/ None,
|
|
)
|
|
.await?;
|
|
|
|
let mut events = Vec::new();
|
|
while let Some(ev) = stream.next().await {
|
|
events.push(ev?);
|
|
}
|
|
|
|
let events: Vec<ResponseEvent> = events
|
|
.into_iter()
|
|
.filter(|ev| !matches!(ev, ResponseEvent::RateLimits(_)))
|
|
.collect();
|
|
|
|
assert_eq!(events.len(), 3);
|
|
|
|
match &events[0] {
|
|
ResponseEvent::OutputItemDone(ResponseItem::Message { role, .. }) => {
|
|
assert_eq!(role, "assistant");
|
|
}
|
|
other => panic!("unexpected first event: {other:?}"),
|
|
}
|
|
|
|
match &events[1] {
|
|
ResponseEvent::OutputItemDone(ResponseItem::Message { role, .. }) => {
|
|
assert_eq!(role, "assistant");
|
|
}
|
|
other => panic!("unexpected second event: {other:?}"),
|
|
}
|
|
|
|
match &events[2] {
|
|
ResponseEvent::Completed {
|
|
response_id,
|
|
token_usage,
|
|
usage_metadata,
|
|
end_turn,
|
|
} => {
|
|
assert_eq!(response_id, "resp1");
|
|
assert_eq!(
|
|
usage_metadata,
|
|
&Some(codex_protocol::ResponseUsageMetadata {
|
|
amount: Some("0.12345678901234567890".to_string()),
|
|
metadata: Some(expected_metadata),
|
|
})
|
|
);
|
|
assert_eq!(
|
|
token_usage.as_ref().map(|usage| usage.total_tokens),
|
|
Some(15)
|
|
);
|
|
assert_eq!(
|
|
token_usage
|
|
.as_ref()
|
|
.and_then(|usage| usage.codex_rollout_budget_units.as_ref())
|
|
.and_then(serde_json::Number::as_f64),
|
|
Some(2.5)
|
|
);
|
|
assert!(end_turn.is_none());
|
|
}
|
|
other => panic!("unexpected third event: {other:?}"),
|
|
}
|
|
|
|
Ok(())
|
|
}
|