use std::sync::{
atomic::{AtomicUsize, Ordering},
Arc,
};
use super::support::*;
use super::*;
use crate::config::{
CredentialKind, CredentialSource, ModelRef, ProviderAuthConfig, ProviderId,
ProviderRuntimeConfig, ProviderTransportKind,
};
use crate::provider::retry::{
classify_provider_error, ProviderFailureKind, RetryDisposition, PROVIDER_MAX_RETRIES,
};
use axum::{extract::State, http::header, response::IntoResponse, routing::post, Json, Router};
use serde_json::{json, Value};
#[test]
fn build_candidate_routes_openai_model_refs() {
let fixture = test_config("openai/gpt-5.4", &[], Some("openai-key"), None, false);
let config = &fixture.config;
let model_ref = ModelRef::parse("openai/gpt-5.4").unwrap();
let candidate = build_candidate(config, &model_ref).unwrap();
assert_eq!(candidate.model_ref, "openai/gpt-5.4");
let provider = OpenAiProvider::from_config(config, "gpt-5.4").unwrap();
let _typed: Arc<dyn super::super::AgentProvider> = Arc::new(provider);
}
#[test]
fn build_candidate_routes_anthropic_model_refs() {
let fixture = test_config(
"anthropic/claude-sonnet-4-6",
&[],
None,
Some("anthropic-token"),
false,
);
let config = &fixture.config;
let model_ref = ModelRef::parse("anthropic/claude-sonnet-4-6").unwrap();
let candidate = build_candidate(config, &model_ref).unwrap();
assert_eq!(candidate.model_ref, "anthropic/claude-sonnet-4-6");
let provider = AnthropicProvider::from_config_with_model(config, "claude-sonnet-4-6").unwrap();
let _typed: Arc<dyn super::super::AgentProvider> = Arc::new(provider);
}
#[test]
fn build_candidate_routes_codex_model_refs() {
let fixture = test_config("openai-codex/gpt-5.4", &[], None, None, true);
let config = &fixture.config;
let model_ref = ModelRef::parse("openai-codex/gpt-5.4").unwrap();
let candidate = build_candidate(config, &model_ref).unwrap();
assert_eq!(candidate.model_ref, "openai-codex/gpt-5.4");
let provider = OpenAiCodexProvider::from_config(config, "gpt-5.4").unwrap();
let _typed: Arc<dyn super::super::AgentProvider> = Arc::new(provider);
}
#[test]
fn build_candidate_reports_missing_auth_for_each_provider() {
let openai = test_config("openai/gpt-5.4", &[], None, None, false);
let openai_err = build_candidate(&openai.config, &ModelRef::parse("openai/gpt-5.4").unwrap())
.err()
.expect("missing OPENAI_API_KEY should fail openai candidate");
assert!(openai_err.to_string().contains("missing OPENAI_API_KEY"));
let anthropic = test_config("anthropic/claude-sonnet-4-6", &[], None, None, false);
let anthropic_err = build_candidate(
&anthropic.config,
&ModelRef::parse("anthropic/claude-sonnet-4-6").unwrap(),
)
.err()
.expect("missing ANTHROPIC_AUTH_TOKEN should fail anthropic candidate");
assert!(anthropic_err
.to_string()
.contains("missing ANTHROPIC_AUTH_TOKEN"));
let codex = test_config("openai-codex/gpt-5.4", &[], None, None, false);
let codex_err = build_candidate(
&codex.config,
&ModelRef::parse("openai-codex/gpt-5.4").unwrap(),
)
.err()
.expect("missing Codex auth should fail codex candidate");
assert!(codex_err
.to_string()
.contains("no Codex CLI credentials found"));
}
#[test]
fn build_provider_from_config_uses_effective_fallback_order() {
let fixture = test_config(
"openai-codex/gpt-5.4",
&["openai/gpt-5.4", "anthropic/claude-sonnet-4-6"],
None,
Some("anthropic-token"),
true,
);
let config = &fixture.config;
let _provider = build_provider_from_config(config).unwrap();
let doctor = provider_doctor(config);
let providers = doctor["providers"].as_array().unwrap();
assert_eq!(providers[0]["model"], "openai-codex/gpt-5.4");
assert_eq!(providers[0]["availability"]["available"], Value::Bool(true));
assert_eq!(providers[1]["model"], "openai/gpt-5.4");
assert_eq!(
providers[1]["availability"]["available"],
Value::Bool(false)
);
assert_eq!(providers[2]["model"], "anthropic/claude-sonnet-4-6");
assert_eq!(providers[2]["availability"]["available"], Value::Bool(true));
}
#[test]
fn build_provider_from_config_fails_when_no_provider_is_available() {
let fixture = test_config(
"openai-codex/gpt-5.4",
&["openai/gpt-5.4", "anthropic/claude-sonnet-4-6"],
None,
None,
false,
);
let err = build_provider_from_config(&fixture.config)
.err()
.expect("missing all provider auth should fail provider build");
assert!(err
.to_string()
.contains("no available providers for configured model chain"));
assert!(err.to_string().contains("openai-codex/gpt-5.4"));
assert!(err.to_string().contains("openai/gpt-5.4"));
assert!(err.to_string().contains("anthropic/claude-sonnet-4-6"));
}
#[test]
fn fallback_provider_reports_conservative_prompt_capability_intersection() {
let fixture = test_config(
"openai/gpt-5.4",
&["anthropic/claude-sonnet-4-6"],
Some("sk-test-key"),
Some("sk-ant-test-token"),
false,
);
let provider = build_provider_from_config(&fixture.config).unwrap();
assert_eq!(
provider.prompt_capabilities(),
vec![ProviderPromptCapability::FullRequestOnly]
);
}
#[test]
fn provider_doctor_reports_success_and_failure_paths() {
let fixture = test_config(
"anthropic/claude-sonnet-4-6",
&["openai/gpt-5.4", "openai-codex/gpt-5.4"],
None,
Some("anthropic-token"),
false,
);
let doctor = provider_doctor(&fixture.config);
let providers = doctor["providers"].as_array().unwrap();
assert_eq!(providers.len(), 3);
assert_eq!(providers[0]["availability"]["available"], Value::Bool(true));
assert_eq!(
providers[1]["availability"]["available"],
Value::Bool(false)
);
assert_eq!(
providers[2]["availability"]["available"],
Value::Bool(false)
);
assert!(providers[1]["availability"]["error"]
.as_str()
.unwrap()
.contains("missing OPENAI_API_KEY"));
assert!(providers[2]["availability"]["error"]
.as_str()
.unwrap()
.contains("no Codex CLI credentials found"));
}
#[test]
fn provider_doctor_reports_when_provider_fallback_is_disabled() {
let mut fixture = test_config(
"anthropic/claude-sonnet-4-6",
&["openai/gpt-5.4", "openai-codex/gpt-5.4"],
Some("openai-key"),
Some("anthropic-token"),
true,
);
fixture
.config
.stored_config
.runtime
.disable_provider_fallback = Some(true);
fixture.config.disable_provider_fallback = true;
let doctor = provider_doctor(&fixture.config);
assert_eq!(doctor["disable_provider_fallback"], Value::Bool(true));
let providers = doctor["providers"].as_array().unwrap();
assert_eq!(providers.len(), 1);
assert_eq!(providers[0]["model"], "anthropic/claude-sonnet-4-6");
}
#[test]
fn provider_doctor_reports_codex_as_available_when_credentials_exist() {
let fixture = test_config("openai-codex/gpt-5.4", &[], None, None, true);
let doctor = provider_doctor(&fixture.config);
assert_eq!(
doctor["retry_policy"]["max_retries_per_provider"],
Value::from(PROVIDER_MAX_RETRIES as u64)
);
let provider = doctor["providers"].as_array().unwrap().first().unwrap();
assert_eq!(provider["provider"], "openai-codex");
assert_eq!(provider["availability"]["available"], Value::Bool(true));
assert_eq!(
provider["availability"]["credential"]["account_id"],
Value::String("acct_test".to_string())
);
}
#[test]
fn provider_doctor_reports_codex_credential_errors_separately() {
let fixture = test_config("openai-codex/gpt-5.4", &[], None, None, false);
let doctor = provider_doctor(&fixture.config);
let provider = doctor["providers"].as_array().unwrap().first().unwrap();
assert_eq!(provider["availability"]["available"], Value::Bool(false));
assert!(!provider["availability"]["credential_error"]
.as_str()
.unwrap()
.is_empty());
}
#[derive(Clone, Default)]
struct StreamingRequestCapture {
request_body: Arc<std::sync::Mutex<Option<Value>>>,
}
#[tokio::test]
async fn openai_codex_provider_sends_streaming_requests_and_parses_terminal_response() {
async fn handler(
State(capture): State<StreamingRequestCapture>,
Json(body): Json<Value>,
) -> impl IntoResponse {
*capture.request_body.lock().unwrap() = Some(body);
(
[(header::CONTENT_TYPE, "text/event-stream")],
concat!(
"event: response.created\n",
"data: {\"type\":\"response.created\",\"response\":{\"id\":\"resp_1\",\"status\":\"in_progress\"}}\n\n",
"event: response.output_text.delta\n",
"data: {\"type\":\"response.output_text.delta\",\"delta\":\"done\"}\n\n",
"event: response.completed\n",
"data: {\"type\":\"response.completed\",\"response\":{\"status\":\"completed\",\"usage\":{\"input_tokens\":11,\"output_tokens\":7},\"output\":[{\"type\":\"message\",\"content\":[{\"type\":\"output_text\",\"text\":\"done\"}]},{\"type\":\"function_call\",\"call_id\":\"call_1\",\"name\":\"ExecCommand\",\"arguments\":\"{\\\"cmd\\\":\\\"sed -n '1,40p' src/main.rs\\\",\\\"workdir\\\":\\\".\\\"}\"}]}}\n\n",
"event: response.output_text.delta\n",
"data: {\"type\":\"response.output_text.delta\",\"delta\":\"ignored-tail\"}\n\n",
"data: [DONE]\n\n"
),
)
}
let capture = StreamingRequestCapture::default();
let router = Router::new()
.route("/codex/responses", post(handler))
.with_state(capture.clone());
let server = spawn_test_server(router).await;
let fixture = test_config("openai-codex/gpt-5.4", &[], None, None, true);
let mut config = fixture.config.clone();
config
.providers
.get_mut(&ProviderId::openai_codex())
.unwrap()
.base_url = server;
let provider = OpenAiCodexProvider::from_config(&config, "gpt-5.4").unwrap();
let response = provider
.complete_turn(provider_turn_request())
.await
.unwrap();
let request_body = capture.request_body.lock().unwrap().clone().unwrap();
assert_eq!(request_body["stream"], Value::Bool(true));
assert!(request_body.get("max_output_tokens").is_none());
assert_eq!(response.input_tokens, 11);
assert_eq!(response.output_tokens, 7);
assert_eq!(response.blocks.len(), 2);
assert!(matches!(
&response.blocks[0],
ModelBlock::Text { text } if text == "done"
));
assert!(matches!(
&response.blocks[1],
ModelBlock::ToolUse { id, name, input }
if id == "call_1" && name == "ExecCommand" && input["cmd"] == "sed -n '1,40p' src/main.rs"
));
}
#[derive(Clone, Default)]
struct JsonRequestCapture {
request_body: Arc<std::sync::Mutex<Option<Value>>>,
}
#[tokio::test]
async fn openai_provider_sends_sleep_schema_in_request_payload() {
async fn handler(
State(capture): State<JsonRequestCapture>,
Json(body): Json<Value>,
) -> impl IntoResponse {
*capture.request_body.lock().unwrap() = Some(body);
Json(json!({
"status": "completed",
"usage": { "input_tokens": 3, "output_tokens": 1 },
"output": [
{
"type": "message",
"content": [{ "type": "output_text", "text": "ok" }]
}
]
}))
}
let capture = JsonRequestCapture::default();
let router = Router::new()
.route("/responses", post(handler))
.with_state(capture.clone());
let server = spawn_test_server(router).await;
let mut fixture = test_config("openai/gpt-5.4", &[], Some("openai-key"), None, false);
fixture
.config
.providers
.get_mut(&ProviderId::openai())
.unwrap()
.base_url = server;
let provider = OpenAiProvider::from_config(&fixture.config, "gpt-5.4").unwrap();
provider
.complete_turn(provider_turn_request_with_tools(vec![sleep_tool_spec()]))
.await
.unwrap();
let request_body = capture.request_body.lock().unwrap().clone().unwrap();
let parameters = &request_body["tools"][0]["parameters"];
assert_eq!(request_body["tools"][0]["name"], "Sleep");
assert_eq!(request_body["tools"][0]["strict"], Value::Bool(false));
assert!(parameters["properties"].get("reason").is_some());
assert_eq!(parameters["required"], json!([]));
assert_eq!(parameters["additionalProperties"], Value::Bool(false));
validate_emitted_tool_schema(parameters, ToolSchemaContract::Relaxed).unwrap();
}
#[tokio::test]
async fn openai_provider_sends_spawn_agent_schema_without_top_level_composition() {
async fn handler(
State(capture): State<JsonRequestCapture>,
Json(body): Json<Value>,
) -> impl IntoResponse {
*capture.request_body.lock().unwrap() = Some(body);
Json(json!({
"status": "completed",
"usage": { "input_tokens": 3, "output_tokens": 1 },
"output": [
{
"type": "message",
"content": [{ "type": "output_text", "text": "ok" }]
}
]
}))
}
let capture = JsonRequestCapture::default();
let router = Router::new()
.route("/responses", post(handler))
.with_state(capture.clone());
let server = spawn_test_server(router).await;
let mut fixture = test_config("openai/gpt-5.4", &[], Some("openai-key"), None, false);
fixture
.config
.providers
.get_mut(&ProviderId::openai())
.unwrap()
.base_url = server;
let provider = OpenAiProvider::from_config(&fixture.config, "gpt-5.4").unwrap();
let spawn_agent = trusted_tool_specs()
.into_iter()
.find(|spec| spec.name == "SpawnAgent")
.expect("SpawnAgent tool should be present");
provider
.complete_turn(provider_turn_request_with_tools(vec![spawn_agent]))
.await
.unwrap();
let request_body = capture.request_body.lock().unwrap().clone().unwrap();
let parameters = &request_body["tools"][0]["parameters"];
assert_eq!(request_body["tools"][0]["name"], "SpawnAgent");
assert_eq!(parameters["type"], "object");
for forbidden in ["allOf", "anyOf", "oneOf", "enum", "not"] {
assert!(
parameters.get(forbidden).is_none(),
"OpenAI SpawnAgent schema should not contain top-level {forbidden}: {parameters}"
);
}
validate_emitted_tool_schema(parameters, ToolSchemaContract::Relaxed).unwrap();
}
#[tokio::test]
async fn openai_codex_provider_sends_sleep_schema_in_request_payload() {
async fn handler(
State(capture): State<StreamingRequestCapture>,
Json(body): Json<Value>,
) -> impl IntoResponse {
*capture.request_body.lock().unwrap() = Some(body);
(
[(header::CONTENT_TYPE, "text/event-stream")],
concat!(
"event: response.created\n",
"data: {\"type\":\"response.created\",\"response\":{\"id\":\"resp_1\",\"status\":\"in_progress\"}}\n\n",
"event: response.completed\n",
"data: {\"type\":\"response.completed\",\"response\":{\"status\":\"completed\",\"usage\":{\"input_tokens\":3,\"output_tokens\":1},\"output\":[{\"type\":\"message\",\"content\":[{\"type\":\"output_text\",\"text\":\"ok\"}]}]}}\n\n",
"data: [DONE]\n\n"
),
)
}
let capture = StreamingRequestCapture::default();
let router = Router::new()
.route("/codex/responses", post(handler))
.with_state(capture.clone());
let server = spawn_test_server(router).await;
let fixture = test_config("openai-codex/gpt-5.4", &[], None, None, true);
let mut config = fixture.config.clone();
config
.providers
.get_mut(&ProviderId::openai_codex())
.unwrap()
.base_url = server;
let provider = OpenAiCodexProvider::from_config(&config, "gpt-5.4").unwrap();
provider
.complete_turn(provider_turn_request_with_tools(vec![sleep_tool_spec()]))
.await
.unwrap();
let request_body = capture.request_body.lock().unwrap().clone().unwrap();
let parameters = &request_body["tools"][0]["parameters"];
assert_eq!(request_body["tools"][0]["name"], "Sleep");
assert_eq!(request_body["tools"][0]["strict"], Value::Bool(false));
assert!(parameters["properties"].get("reason").is_some());
assert_eq!(parameters["required"], json!([]));
assert_eq!(parameters["additionalProperties"], Value::Bool(false));
validate_emitted_tool_schema(parameters, ToolSchemaContract::Relaxed).unwrap();
}
#[tokio::test]
async fn openai_codex_provider_reconstructs_output_from_stream_items() {
async fn handler(Json(_body): Json<Value>) -> impl IntoResponse {
(
[(header::CONTENT_TYPE, "text/event-stream")],
concat!(
"event: response.created\n",
"data: {\"type\":\"response.created\",\"response\":{\"id\":\"resp_1\",\"status\":\"in_progress\"}}\n\n",
"event: response.output_item.done\n",
"data: {\"type\":\"response.output_item.done\",\"item\":{\"type\":\"message\",\"role\":\"assistant\",\"content\":[{\"type\":\"output_text\",\"text\":\"stream item ok\"}]}}\n\n",
"event: response.completed\n",
"data: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_1\",\"status\":\"completed\",\"usage\":{\"input_tokens\":3,\"output_tokens\":1}}}\n\n",
"data: [DONE]\n\n"
),
)
}
let router = Router::new().route("/codex/responses", post(handler));
let server = spawn_test_server(router).await;
let fixture = test_config("openai-codex/gpt-5.4", &[], None, None, true);
let mut config = fixture.config.clone();
config
.providers
.get_mut(&ProviderId::openai_codex())
.unwrap()
.base_url = server;
let provider = OpenAiCodexProvider::from_config(&config, "gpt-5.4").unwrap();
let response = provider
.complete_turn(provider_turn_request())
.await
.unwrap();
assert_eq!(response.input_tokens, 3);
assert_eq!(response.output_tokens, 1);
assert_eq!(response.blocks.len(), 1);
assert!(matches!(
&response.blocks[0],
ModelBlock::Text { text } if text == "stream item ok"
));
}
#[tokio::test]
async fn openai_codex_provider_classifies_response_failed_as_contract_error() {
async fn handler(Json(_body): Json<Value>) -> impl IntoResponse {
(
[(header::CONTENT_TYPE, "text/event-stream")],
concat!(
"event: response.failed\n",
"data: {\"type\":\"response.failed\",\"response\":{\"id\":\"resp_1\",\"status\":\"failed\",\"error\":{\"code\":\"context_length_exceeded\",\"message\":\"input too long\"}}}\n\n",
"data: [DONE]\n\n"
),
)
}
let router = Router::new().route("/codex/responses", post(handler));
let server = spawn_test_server(router).await;
let fixture = test_config("openai-codex/gpt-5.4", &[], None, None, true);
let mut config = fixture.config.clone();
config
.providers
.get_mut(&ProviderId::openai_codex())
.unwrap()
.base_url = server;
let provider = OpenAiCodexProvider::from_config(&config, "gpt-5.4").unwrap();
let err = provider
.complete_turn(provider_turn_request())
.await
.err()
.expect("response.failed should fail");
let classification = classify_provider_error(&err);
assert_eq!(classification.kind, ProviderFailureKind::ContractError);
assert_eq!(classification.disposition, RetryDisposition::FailFast);
assert!(err.to_string().contains("context_length_exceeded"));
assert!(err.to_string().contains("input too long"));
assert!(!err.to_string().contains("\"response\""));
}
#[tokio::test]
async fn openai_codex_provider_retries_rate_limited_stream_failures() {
let attempts = Arc::new(AtomicUsize::new(0));
let server_attempts = attempts.clone();
let base_url = spawn_test_server(Router::new().route(
"/codex/responses",
post(move || {
let attempts = server_attempts.clone();
async move {
let attempt = attempts.fetch_add(1, Ordering::SeqCst);
if attempt == 0 {
(
[(header::CONTENT_TYPE, "text/event-stream")],
concat!(
"event: response.failed\n",
"data: {\"type\":\"response.failed\",\"response\":{\"id\":\"resp_rate_limited\",\"status\":\"failed\",\"error\":{\"code\":\"rate_limit_exceeded\",\"message\":\"try again in 2.5s\"}}}\n\n",
"data: [DONE]\n\n"
),
)
.into_response()
} else {
(
[(header::CONTENT_TYPE, "text/event-stream")],
concat!(
"event: response.output_item.done\n",
"data: {\"type\":\"response.output_item.done\",\"item\":{\"type\":\"message\",\"role\":\"assistant\",\"content\":[{\"type\":\"output_text\",\"text\":\"retry ok\"}]}}\n\n",
"event: response.completed\n",
"data: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_ok\",\"status\":\"completed\",\"usage\":{\"input_tokens\":2,\"output_tokens\":1}}}\n\n",
"data: [DONE]\n\n"
),
)
.into_response()
}
}
}),
))
.await;
let fixture = test_config("openai-codex/gpt-5.4", &[], None, None, true);
let mut config = fixture.config.clone();
config
.providers
.get_mut(&ProviderId::openai_codex())
.unwrap()
.base_url = base_url;
let provider = build_provider_from_config(&config).unwrap();
let (response, diagnostics) = provider
.complete_turn_with_diagnostics(provider_turn_request())
.await
.unwrap();
assert_eq!(attempts.load(Ordering::SeqCst), 2);
assert_eq!(response.blocks.len(), 1);
match &response.blocks[0] {
ModelBlock::Text { text } => assert_eq!(text, "retry ok"),
other => panic!("expected text block, got {other:?}"),
}
let timeline = diagnostics.expect("missing attempt timeline");
assert_eq!(timeline.attempts.len(), 2);
assert_eq!(
timeline.attempts[0].failure_kind.as_deref(),
Some("rate_limited")
);
assert_eq!(
timeline.attempts[0].disposition.as_deref(),
Some("retryable")
);
assert_eq!(
timeline.attempts[0].outcome,
ProviderAttemptOutcome::Retrying
);
assert_eq!(
timeline.attempts[1].outcome,
ProviderAttemptOutcome::Succeeded
);
}
#[tokio::test]
async fn openai_codex_provider_fails_when_streamed_output_item_count_exceeds_limit() {
async fn handler(Json(_body): Json<Value>) -> impl IntoResponse {
let mut body = String::new();
for idx in 0..129 {
body.push_str("event: response.output_item.done\n");
body.push_str(&format!(
"data: {{\"type\":\"response.output_item.done\",\"item\":{{\"type\":\"message\",\"role\":\"assistant\",\"content\":[{{\"type\":\"output_text\",\"text\":\"item-{idx}\"}}]}}}}\n\n"
));
}
body.push_str("event: response.completed\n");
body.push_str(
"data: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_1\",\"status\":\"completed\",\"usage\":{\"input_tokens\":3,\"output_tokens\":1}}}\n\n",
);
body.push_str("data: [DONE]\n\n");
([(header::CONTENT_TYPE, "text/event-stream")], body)
}
let router = Router::new().route("/codex/responses", post(handler));
let server = spawn_test_server(router).await;
let fixture = test_config("openai-codex/gpt-5.4", &[], None, None, true);
let mut config = fixture.config.clone();
config
.providers
.get_mut(&ProviderId::openai_codex())
.unwrap()
.base_url = server;
let provider = OpenAiCodexProvider::from_config(&config, "gpt-5.4").unwrap();
let err = provider
.complete_turn(provider_turn_request())
.await
.err()
.expect("streamed output item overflow should fail");
let classification = classify_provider_error(&err);
assert_eq!(classification.kind, ProviderFailureKind::InvalidResponse);
assert_eq!(classification.disposition, RetryDisposition::FailFast);
assert!(err
.to_string()
.contains("received more than 128 streamed output items"));
}
#[tokio::test]
async fn openai_codex_provider_fails_if_done_arrives_before_terminal_response() {
async fn handler(Json(_body): Json<Value>) -> impl IntoResponse {
(
[(header::CONTENT_TYPE, "text/event-stream")],
concat!(
"event: response.created\n",
"data: {\"type\":\"response.created\",\"response\":{\"id\":\"resp_1\",\"status\":\"in_progress\"}}\n\n",
"data: [DONE]\n\n",
),
)
}
let router = Router::new().route("/codex/responses", post(handler));
let server = spawn_test_server(router).await;
let fixture = test_config("openai-codex/gpt-5.4", &[], None, None, true);
let mut config = fixture.config.clone();
config
.providers
.get_mut(&ProviderId::openai_codex())
.unwrap()
.base_url = server;
let provider = OpenAiCodexProvider::from_config(&config, "gpt-5.4").unwrap();
let err = provider
.complete_turn(provider_turn_request())
.await
.err()
.expect("done before terminal response should fail");
let classification = classify_provider_error(&err);
assert_eq!(classification.kind, ProviderFailureKind::InvalidResponse);
assert_eq!(classification.disposition, RetryDisposition::FailFast);
assert!(err
.to_string()
.contains("[DONE] observed before terminal response"));
}
#[tokio::test]
async fn openai_codex_provider_fails_if_done_arrives_at_eof_before_terminal_response() {
async fn handler(Json(_body): Json<Value>) -> impl IntoResponse {
(
[(header::CONTENT_TYPE, "text/event-stream")],
concat!(
"event: response.created\n",
"data: {\"type\":\"response.created\",\"response\":{\"id\":\"resp_1\",\"status\":\"in_progress\"}}\n\n",
"data: [DONE]\n",
),
)
}
let router = Router::new().route("/codex/responses", post(handler));
let server = spawn_test_server(router).await;
let fixture = test_config("openai-codex/gpt-5.4", &[], None, None, true);
let mut config = fixture.config.clone();
config
.providers
.get_mut(&ProviderId::openai_codex())
.unwrap()
.base_url = server;
let provider = OpenAiCodexProvider::from_config(&config, "gpt-5.4").unwrap();
let err = provider
.complete_turn(provider_turn_request())
.await
.err()
.expect("done at EOF before terminal response should fail");
let classification = classify_provider_error(&err);
assert_eq!(classification.kind, ProviderFailureKind::InvalidResponse);
assert_eq!(classification.disposition, RetryDisposition::FailFast);
assert!(err
.to_string()
.contains("[DONE] observed before terminal response"));
}
#[tokio::test]
async fn openai_provider_retries_transient_server_errors() {
let attempts = Arc::new(AtomicUsize::new(0));
let server_attempts = attempts.clone();
let base_url = spawn_test_server(Router::new().route(
"/responses",
post(move || {
let attempts = server_attempts.clone();
async move {
let attempt = attempts.fetch_add(1, Ordering::SeqCst);
if attempt == 0 {
(
axum::http::StatusCode::INTERNAL_SERVER_ERROR,
"temporary failure",
)
.into_response()
} else {
Json(json!({
"status": "completed",
"usage": { "input_tokens": 3, "output_tokens": 2 },
"output": [
{
"type": "message",
"content": [{ "type": "output_text", "text": "retry ok" }]
}
]
}))
.into_response()
}
}
}),
))
.await;
let mut fixture = test_config("openai/gpt-5.4", &[], Some("openai-key"), None, false);
fixture
.config
.providers
.get_mut(&ProviderId::openai())
.unwrap()
.base_url = base_url;
let provider = build_provider_from_config(&fixture.config).unwrap();
let (response, diagnostics) = provider
.complete_turn_with_diagnostics(provider_turn_request())
.await
.unwrap();
assert_eq!(attempts.load(Ordering::SeqCst), 2);
assert_eq!(response.blocks.len(), 1);
let timeline = diagnostics.expect("missing attempt timeline");
assert_eq!(
timeline.winning_model_ref.as_deref(),
Some("openai/gpt-5.4")
);
assert_eq!(
timeline
.aggregated_token_usage
.as_ref()
.map(|usage| usage.total_tokens),
Some(5)
);
assert_eq!(timeline.attempts.len(), 2);
assert_eq!(
timeline.attempts[0].outcome,
ProviderAttemptOutcome::Retrying
);
assert_eq!(
timeline.attempts[1].outcome,
ProviderAttemptOutcome::Succeeded
);
match &response.blocks[0] {
ModelBlock::Text { text } => assert_eq!(text, "retry ok"),
_ => panic!("expected text block"),
}
}
#[tokio::test]
async fn openai_provider_fails_fast_on_contract_errors() {
let attempts = Arc::new(AtomicUsize::new(0));
let server_attempts = attempts.clone();
let base_url = spawn_test_server(Router::new().route(
"/responses",
post(move || {
let attempts = server_attempts.clone();
async move {
attempts.fetch_add(1, Ordering::SeqCst);
(axum::http::StatusCode::BAD_REQUEST, "bad request").into_response()
}
}),
))
.await;
let mut fixture = test_config("openai/gpt-5.4", &[], Some("openai-key"), None, false);
fixture
.config
.providers
.get_mut(&ProviderId::openai())
.unwrap()
.base_url = base_url;
let provider = build_provider_from_config(&fixture.config).unwrap();
let error = provider
.complete_turn(provider_turn_request())
.await
.err()
.expect("400 should fail fast without retry");
assert_eq!(attempts.load(Ordering::SeqCst), 1);
assert!(error
.to_string()
.contains("fail_fast (contract_error, status=400)"));
let timeline = provider_attempt_timeline(&error).expect("missing attempt timeline");
assert!(timeline.aggregated_token_usage.is_none());
assert_eq!(timeline.winning_model_ref, None);
assert_eq!(timeline.attempts.len(), 1);
assert_eq!(
timeline.attempts[0].outcome,
ProviderAttemptOutcome::FailFastAborted
);
let transport = timeline.attempts[0]
.transport_diagnostics
.as_ref()
.expect("status failures should carry transport diagnostics");
assert_eq!(transport.stage, "response_status");
assert_eq!(transport.provider.as_deref(), Some("openai"));
assert_eq!(transport.model_ref.as_deref(), Some("openai/gpt-5.4"));
assert_eq!(transport.status, Some(400));
assert_eq!(transport.reqwest, None);
assert_eq!(transport.http_trace, None);
assert!(!timeline.attempts[0].advanced_to_fallback);
}
#[tokio::test]
async fn openai_provider_retries_response_body_read_interruptions() {
let interrupted = b"HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: 100\r\nconnection: close\r\n\r\n{\"id\":\"resp_partial\"";
let complete = b"HTTP/1.1 200 OK\r\ncontent-type: application/json\r\nconnection: close\r\n\r\n{\"id\":\"resp_ok\",\"status\":\"completed\",\"usage\":{\"input_tokens\":2,\"output_tokens\":1},\"output\":[{\"type\":\"message\",\"role\":\"assistant\",\"content\":[{\"type\":\"output_text\",\"text\":\"retry ok\"}]}]}";
let base_url = spawn_raw_http_server_sequence(vec![interrupted, complete]).await;
let mut fixture = test_config("openai/gpt-5.4", &[], Some("openai-key"), None, false);
fixture
.config
.providers
.get_mut(&ProviderId::openai())
.unwrap()
.base_url = base_url;
let provider = build_provider_from_config(&fixture.config).unwrap();
let (response, diagnostics) = provider
.complete_turn_with_diagnostics(provider_turn_request())
.await
.expect("truncated response body should retry and recover");
assert!(matches!(
&response.blocks[0],
ModelBlock::Text { text } if text == "retry ok"
));
let timeline = diagnostics.expect("missing attempt timeline");
assert_eq!(timeline.attempts.len(), 2);
assert_eq!(
timeline.attempts[0].failure_kind.as_deref(),
Some("connection")
);
assert_eq!(
timeline.attempts[0].disposition.as_deref(),
Some("retryable")
);
assert_eq!(
timeline.attempts[0].outcome,
ProviderAttemptOutcome::Retrying
);
assert_eq!(
timeline.attempts[0]
.transport_diagnostics
.as_ref()
.map(|diag| diag.stage.as_str()),
Some("response_body")
);
assert_eq!(
timeline.attempts[1].outcome,
ProviderAttemptOutcome::Succeeded
);
}
#[tokio::test]
async fn provider_fallback_defers_after_retry_exhaustion() {
let openai_attempts = Arc::new(AtomicUsize::new(0));
let openai_server_attempts = openai_attempts.clone();
let openai_base_url = spawn_test_server(Router::new().route(
"/responses",
post(move || {
let attempts = openai_server_attempts.clone();
async move {
attempts.fetch_add(1, Ordering::SeqCst);
(
axum::http::StatusCode::INTERNAL_SERVER_ERROR,
"server unavailable",
)
.into_response()
}
}),
))
.await;
let anthropic_attempts = Arc::new(AtomicUsize::new(0));
let anthropic_server_attempts = anthropic_attempts.clone();
let anthropic_base_url = spawn_test_server(
Router::new()
.route(
"/v1/messages",
post(move |State(attempts): State<Arc<AtomicUsize>>| async move {
attempts.fetch_add(1, Ordering::SeqCst);
Json(json!({
"content": [{ "type": "text", "text": "anthropic fallback" }],
"stop_reason": "end_turn",
"usage": { "input_tokens": 4, "output_tokens": 2 }
}))
}),
)
.with_state(anthropic_server_attempts),
)
.await;
let mut fixture = test_config(
"openai/gpt-5.4",
&["anthropic/claude-sonnet-4-6"],
Some("openai-key"),
Some("anthropic-token"),
false,
);
fixture
.config
.providers
.get_mut(&ProviderId::openai())
.unwrap()
.base_url = openai_base_url;
fixture
.config
.providers
.get_mut(&ProviderId::anthropic())
.unwrap()
.base_url = anthropic_base_url;
let provider = build_provider_from_config(&fixture.config).unwrap();
let error = provider
.complete_turn_with_diagnostics(provider_turn_request())
.await
.expect_err("cross-lineage fallback should defer to the next turn");
assert_eq!(
openai_attempts.load(Ordering::SeqCst),
provider_max_attempts()
);
assert_eq!(anthropic_attempts.load(Ordering::SeqCst), 0);
let timeline = provider_attempt_timeline(&error).expect("missing attempt timeline");
assert_eq!(timeline.winning_model_ref.as_deref(), None);
assert_eq!(
timeline.pending_fallback_model_ref.as_deref(),
Some("anthropic/claude-sonnet-4-6")
);
assert_eq!(
timeline
.aggregated_token_usage
.as_ref()
.map(|usage| usage.total_tokens),
None
);
assert_eq!(timeline.attempts.len(), provider_max_attempts());
assert_eq!(
timeline.attempts[0].outcome,
ProviderAttemptOutcome::Retrying
);
assert_eq!(
timeline.attempts[1].outcome,
ProviderAttemptOutcome::Retrying
);
assert_eq!(
timeline.attempts[provider_max_attempts() - 1].outcome,
ProviderAttemptOutcome::RetriesExhausted
);
assert!(timeline.attempts[provider_max_attempts() - 1].advanced_to_fallback);
}
#[tokio::test]
async fn provider_fallback_defers_after_fail_fast_error() {
let openai_attempts = Arc::new(AtomicUsize::new(0));
let openai_server_attempts = openai_attempts.clone();
let openai_base_url = spawn_test_server(Router::new().route(
"/responses",
post(move || {
let attempts = openai_server_attempts.clone();
async move {
attempts.fetch_add(1, Ordering::SeqCst);
(axum::http::StatusCode::BAD_REQUEST, "bad request").into_response()
}
}),
))
.await;
let anthropic_attempts = Arc::new(AtomicUsize::new(0));
let anthropic_server_attempts = anthropic_attempts.clone();
let anthropic_base_url = spawn_test_server(
Router::new()
.route(
"/v1/messages",
post(move |State(attempts): State<Arc<AtomicUsize>>| async move {
attempts.fetch_add(1, Ordering::SeqCst);
Json(json!({
"content": [{ "type": "text", "text": "anthropic fallback" }],
"stop_reason": "end_turn",
"usage": { "input_tokens": 4, "output_tokens": 2 }
}))
}),
)
.with_state(anthropic_server_attempts),
)
.await;
let mut fixture = test_config(
"openai/gpt-5.4",
&["anthropic/claude-sonnet-4-6"],
Some("openai-key"),
Some("anthropic-token"),
false,
);
fixture
.config
.providers
.get_mut(&ProviderId::openai())
.unwrap()
.base_url = openai_base_url;
fixture
.config
.providers
.get_mut(&ProviderId::anthropic())
.unwrap()
.base_url = anthropic_base_url;
let provider = build_provider_from_config(&fixture.config).unwrap();
let error = provider
.complete_turn_with_diagnostics(provider_turn_request())
.await
.expect_err("cross-lineage fallback should defer to the next turn");
assert_eq!(openai_attempts.load(Ordering::SeqCst), 1);
assert_eq!(anthropic_attempts.load(Ordering::SeqCst), 0);
let timeline = provider_attempt_timeline(&error).expect("missing attempt timeline");
assert_eq!(timeline.attempts.len(), 1);
assert_eq!(timeline.requested_model_ref, "openai/gpt-5.4");
assert_eq!(timeline.active_model_ref.as_deref(), None);
assert_eq!(timeline.winning_model_ref.as_deref(), None);
assert_eq!(
timeline.pending_fallback_model_ref.as_deref(),
Some("anthropic/claude-sonnet-4-6")
);
assert_eq!(
timeline.attempts[0].outcome,
ProviderAttemptOutcome::FailFastAborted
);
assert_eq!(
timeline.attempts[0].failure_kind.as_deref(),
Some("contract_error")
);
assert_eq!(
timeline.attempts[0].disposition.as_deref(),
Some("fail_fast")
);
assert!(timeline.attempts[0].advanced_to_fallback);
assert_eq!(timeline.attempts[0].attempt, 1);
let transport = timeline.attempts[0]
.transport_diagnostics
.as_ref()
.expect("fallback status failure should preserve diagnostics");
assert_eq!(transport.stage, "response_status");
assert_eq!(transport.provider.as_deref(), Some("openai"));
assert_eq!(transport.status, Some(400));
}
#[tokio::test]
async fn anthropic_status_errors_preserve_transport_diagnostics() {
let attempts = Arc::new(AtomicUsize::new(0));
let server_attempts = attempts.clone();
let base_url = spawn_test_server(Router::new().route(
"/v1/messages",
post(move || {
let attempts = server_attempts.clone();
async move {
attempts.fetch_add(1, Ordering::SeqCst);
(axum::http::StatusCode::UNAUTHORIZED, "bad token").into_response()
}
}),
))
.await;
let mut fixture = test_config(
"anthropic/claude-sonnet-4-6",
&[],
None,
Some("anthropic-token"),
false,
);
fixture
.config
.providers
.get_mut(&ProviderId::anthropic())
.unwrap()
.base_url = base_url;
let provider = build_provider_from_config(&fixture.config).unwrap();
let error = provider
.complete_turn(provider_turn_request())
.await
.err()
.expect("401 should fail fast without retry");
assert_eq!(attempts.load(Ordering::SeqCst), 1);
let timeline = provider_attempt_timeline(&error).expect("missing attempt timeline");
assert_eq!(timeline.attempts.len(), 1);
assert_eq!(
timeline.attempts[0].failure_kind.as_deref(),
Some("auth_error")
);
let transport = timeline.attempts[0]
.transport_diagnostics
.as_ref()
.expect("anthropic status failure should preserve diagnostics");
assert_eq!(transport.stage, "response_status");
assert_eq!(transport.provider.as_deref(), Some("anthropic"));
assert_eq!(
transport.model_ref.as_deref(),
Some("anthropic/claude-sonnet-4-6")
);
assert_eq!(transport.status, Some(401));
assert!(transport
.url
.as_deref()
.is_some_and(|url| url.ends_with("/v1/messages")));
}
#[tokio::test]
async fn provider_fallback_can_be_disabled_for_retry_exhaustion() {
let openai_attempts = Arc::new(AtomicUsize::new(0));
let openai_server_attempts = openai_attempts.clone();
let openai_base_url = spawn_test_server(Router::new().route(
"/responses",
post(move || {
let attempts = openai_server_attempts.clone();
async move {
attempts.fetch_add(1, Ordering::SeqCst);
(
axum::http::StatusCode::INTERNAL_SERVER_ERROR,
"server unavailable",
)
.into_response()
}
}),
))
.await;
let anthropic_attempts = Arc::new(AtomicUsize::new(0));
let anthropic_server_attempts = anthropic_attempts.clone();
let anthropic_base_url = spawn_test_server(
Router::new()
.route(
"/v1/messages",
post(move |State(attempts): State<Arc<AtomicUsize>>| async move {
attempts.fetch_add(1, Ordering::SeqCst);
Json(json!({
"content": [{ "type": "text", "text": "anthropic fallback" }],
"stop_reason": "end_turn",
"usage": { "input_tokens": 4, "output_tokens": 2 }
}))
}),
)
.with_state(anthropic_server_attempts),
)
.await;
let mut fixture = test_config(
"openai/gpt-5.4",
&["anthropic/claude-sonnet-4-6"],
Some("openai-key"),
Some("anthropic-token"),
false,
);
fixture
.config
.providers
.get_mut(&ProviderId::openai())
.unwrap()
.base_url = openai_base_url;
fixture
.config
.providers
.get_mut(&ProviderId::anthropic())
.unwrap()
.base_url = anthropic_base_url;
fixture
.config
.stored_config
.runtime
.disable_provider_fallback = Some(true);
fixture.config.disable_provider_fallback = true;
let provider = build_provider_from_config(&fixture.config).unwrap();
let error = provider
.complete_turn(provider_turn_request())
.await
.err()
.expect("retry exhaustion should fail without advancing to fallback");
assert_eq!(
openai_attempts.load(Ordering::SeqCst),
provider_max_attempts()
);
assert_eq!(anthropic_attempts.load(Ordering::SeqCst), 0);
let timeline = provider_attempt_timeline(&error).expect("missing attempt timeline");
assert_eq!(timeline.attempts.len(), provider_max_attempts());
assert_eq!(timeline.winning_model_ref, None);
assert!(timeline
.attempts
.iter()
.all(|attempt| !attempt.advanced_to_fallback));
}
#[test]
fn build_provider_from_config_preserves_order_of_unique_models() {
let fixture = test_config(
"anthropic/claude-sonnet-4-6",
&[
"openai/gpt-5.4",
"anthropic/claude-sonnet-4-6",
"openai-codex/gpt-5.4",
],
Some("openai-key"),
Some("anthropic-token"),
true,
);
let provider = build_provider_from_config(&fixture.config).unwrap();
let refs = provider.configured_model_refs();
assert_eq!(
refs.as_slice(),
&[
"anthropic/claude-sonnet-4-6",
"openai/gpt-5.4",
"openai-codex/gpt-5.4"
]
);
}
#[test]
fn build_provider_from_config_skips_unavailable_models_and_continues() {
let fixture = test_config(
"anthropic/claude-sonnet-4-6",
&["openai/gpt-5.4", "anthropic/claude-sonnet-4-6"],
None,
Some("anthropic-token"),
false,
);
let provider = build_provider_from_config(&fixture.config).unwrap();
let refs = provider.configured_model_refs();
assert_eq!(refs.len(), 1);
assert_eq!(refs[0], "anthropic/claude-sonnet-4-6");
}
#[test]
fn build_provider_from_config_fails_on_unavailable_primary_when_fallback_disabled() {
let mut fixture = test_config(
"openai/gpt-5.4",
&["anthropic/claude-sonnet-4-6"],
None,
Some("anthropic-token"),
false,
);
fixture
.config
.stored_config
.runtime
.disable_provider_fallback = Some(true);
fixture.config.disable_provider_fallback = true;
let err = build_provider_from_config(&fixture.config)
.err()
.expect("deterministic mode should fail on unavailable primary provider");
assert!(err
.to_string()
.contains("no available providers for configured model chain"));
assert!(err.to_string().contains("openai/gpt-5.4"));
assert!(!err.to_string().contains("anthropic/claude-sonnet-4-6"));
}
#[test]
fn build_provider_from_config_fails_when_all_models_unavailable() {
let fixture = test_config(
"openai/gpt-5.4",
&["anthropic/claude-sonnet-4-6", "openai-codex/gpt-5.4"],
None,
None,
false,
);
let err = build_provider_from_config(&fixture.config)
.err()
.expect("should fail when all providers unavailable");
assert!(err.to_string().contains("no available providers"));
assert!(err.to_string().contains("openai/gpt-5.4"));
assert!(err.to_string().contains("anthropic/claude-sonnet-4-6"));
assert!(err.to_string().contains("openai-codex/gpt-5.4"));
}
#[test]
fn build_provider_from_config_uses_custom_openai_responses_provider() {
let mut fixture = test_config("openrouter/custom-model", &[], None, None, false);
let provider_id = ProviderId::parse("openrouter").unwrap();
fixture.config.providers.insert(
provider_id.clone(),
ProviderRuntimeConfig {
id: provider_id,
transport: ProviderTransportKind::OpenAiResponses,
base_url: "https://openrouter.example/v1".into(),
auth: ProviderAuthConfig {
source: CredentialSource::Env,
kind: CredentialKind::ApiKey,
env: Some("OPENROUTER_API_KEY".into()),
profile: None,
external: None,
},
credential: Some("openrouter-key".into()),
codex_home: None,
originator: None,
reasoning_effort: None,
context_management: Default::default(),
builtin_web_search: None,
},
);
let provider = build_provider_from_config(&fixture.config).unwrap();
assert_eq!(
provider.configured_model_refs(),
vec!["openrouter/custom-model"]
);
let doctor = provider_doctor(&fixture.config);
let provider_status = &doctor["providers"].as_array().unwrap()[0];
assert_eq!(provider_status["provider"], "openrouter");
assert_eq!(provider_status["settings"]["transport"], "openai_responses");
assert_eq!(provider_status["settings"]["auth"]["source"], "env");
assert_eq!(provider_status["settings"]["auth"]["kind"], "api_key");
assert_eq!(
provider_status["settings"]["auth"]["credential_configured"],
true
);
}
#[test]
fn build_candidate_accepts_openai_responses_without_auth() {
let mut fixture = test_config("local-openai/custom-model", &[], None, None, false);
let provider_id = ProviderId::parse("local-openai").unwrap();
fixture.config.providers.insert(
provider_id.clone(),
ProviderRuntimeConfig {
id: provider_id,
transport: ProviderTransportKind::OpenAiResponses,
base_url: "http://127.0.0.1:8080/v1".into(),
auth: ProviderAuthConfig {
source: CredentialSource::None,
kind: CredentialKind::None,
env: None,
profile: None,
external: None,
},
credential: None,
codex_home: None,
originator: None,
reasoning_effort: None,
context_management: Default::default(),
builtin_web_search: None,
},
);
let candidate = build_candidate(
&fixture.config,
&ModelRef::parse("local-openai/custom-model").unwrap(),
)
.unwrap();
assert_eq!(candidate.model_ref, "local-openai/custom-model");
assert_eq!(candidate.provider_name, "local-openai");
}
#[test]
fn build_candidate_handles_multiple_openai_models() {
let fixture = test_config("openai/gpt-5.4", &[], Some("openai-key"), None, false);
let config = &fixture.config;
let gpt54 = build_candidate(config, &ModelRef::parse("openai/gpt-5.4").unwrap()).unwrap();
let gpt53 = build_candidate(config, &ModelRef::parse("openai/gpt-5.3").unwrap()).unwrap();
assert_eq!(gpt54.model_ref, "openai/gpt-5.4");
assert_eq!(gpt53.model_ref, "openai/gpt-5.3");
assert_eq!(gpt54.provider_name, "openai");
assert_eq!(gpt53.provider_name, "openai");
}
#[test]
fn build_candidate_handles_multiple_anthropic_models() {
let fixture = test_config(
"anthropic/claude-sonnet-4-6",
&[],
None,
Some("anthropic-token"),
false,
);
let config = &fixture.config;
let sonnet = build_candidate(
config,
&ModelRef::parse("anthropic/claude-sonnet-4-6").unwrap(),
)
.unwrap();
let haiku = build_candidate(
config,
&ModelRef::parse("anthropic/claude-haiku-4-5").unwrap(),
)
.unwrap();
assert_eq!(sonnet.model_ref, "anthropic/claude-sonnet-4-6");
assert_eq!(haiku.model_ref, "anthropic/claude-haiku-4-5");
assert_eq!(sonnet.provider_name, "anthropic");
assert_eq!(haiku.provider_name, "anthropic");
}
#[test]
fn provider_doctor_includes_partial_availability_in_chain() {
let fixture = test_config(
"anthropic/claude-sonnet-4-6",
&["openai/gpt-5.4", "openai-codex/gpt-5.4"],
None,
Some("anthropic-token"),
false,
);
let doctor = provider_doctor(&fixture.config);
let providers = doctor["providers"].as_array().unwrap();
assert_eq!(providers.len(), 3);
assert_eq!(providers[0]["model"], "anthropic/claude-sonnet-4-6");
assert_eq!(providers[0]["availability"]["available"], Value::Bool(true));
assert_eq!(providers[1]["model"], "openai/gpt-5.4");
assert_eq!(
providers[1]["availability"]["available"],
Value::Bool(false)
);
assert_eq!(providers[2]["model"], "openai-codex/gpt-5.4");
assert_eq!(
providers[2]["availability"]["available"],
Value::Bool(false)
);
}
#[test]
fn build_candidate_reports_empty_api_key_as_missing() {
let mut fixture = test_config("openai/gpt-5.4", &[], Some(""), None, false);
fixture
.config
.providers
.get_mut(&ProviderId::openai())
.unwrap()
.credential = Some("".to_string());
let err = build_candidate(&fixture.config, &ModelRef::parse("openai/gpt-5.4").unwrap())
.err()
.expect("empty API key should fail");
assert!(err.to_string().contains("missing OPENAI_API_KEY"));
}
#[test]
fn build_candidate_reports_whitespace_api_key_as_missing() {
let mut fixture = test_config("openai/gpt-5.4", &[], Some(" "), None, false);
fixture
.config
.providers
.get_mut(&ProviderId::openai())
.unwrap()
.credential = Some(" ".to_string());
let err = build_candidate(&fixture.config, &ModelRef::parse("openai/gpt-5.4").unwrap())
.err()
.expect("whitespace API key should fail");
assert!(err.to_string().contains("missing OPENAI_API_KEY"));
}
#[test]
fn build_candidate_requires_at_least_one_available_provider_in_chain() {
let fixture = test_config(
"openai/gpt-5.4",
&["anthropic/claude-sonnet-4-6", "openai-codex/gpt-5.4"],
None,
None,
false,
);
let err = build_provider_from_config(&fixture.config)
.err()
.expect("should fail with no available providers");
assert!(err.to_string().contains("no available providers"));
}
#[test]
fn build_candidate_succeeds_with_valid_codex_auth() {
let fixture = test_config("openai-codex/gpt-5.4", &[], None, None, true);
let candidate = build_candidate(
&fixture.config,
&ModelRef::parse("openai-codex/gpt-5.4").unwrap(),
)
.unwrap();
assert_eq!(candidate.model_ref, "openai-codex/gpt-5.4");
assert_eq!(candidate.provider_name, "openai-codex");
}
#[test]
fn provider_doctor_distinguishes_auth_from_other_errors() {
let fixture = test_config("openai/gpt-5.4", &[], None, None, false);
let doctor = provider_doctor(&fixture.config);
let providers = doctor["providers"].as_array().unwrap();
let provider = &providers[0];
assert_eq!(provider["availability"]["available"], Value::Bool(false));
assert!(provider["availability"]["error"]
.as_str()
.unwrap()
.contains("missing OPENAI_API_KEY"));
assert_eq!(
provider["availability"]["failure_kind"],
Value::String("unknown".to_string())
);
assert_eq!(
provider["availability"]["disposition"],
Value::String("fail_fast".to_string())
);
}
#[test]
fn build_candidate_fails_when_openai_env_auth_missing() {
let fixture = test_config("openai/gpt-5.4", &[], None, None, false);
let err = build_candidate(&fixture.config, &ModelRef::parse("openai/gpt-5.4").unwrap())
.err()
.expect("missing env auth should fail");
assert!(err.to_string().contains("missing OPENAI_API_KEY"));
}
#[test]
fn build_candidate_fails_when_anthropic_env_auth_missing() {
let fixture = test_config("anthropic/claude-sonnet-4-6", &[], None, None, false);
let err = build_candidate(
&fixture.config,
&ModelRef::parse("anthropic/claude-sonnet-4-6").unwrap(),
)
.err()
.expect("missing env auth should fail");
assert!(err.to_string().contains("missing ANTHROPIC_AUTH_TOKEN"));
}
#[test]
fn build_candidate_fails_when_codex_external_cli_auth_missing() {
let fixture = test_config("openai-codex/gpt-5.4", &[], None, None, false);
let err = build_candidate(
&fixture.config,
&ModelRef::parse("openai-codex/gpt-5.4").unwrap(),
)
.err()
.expect("missing external CLI auth should fail");
assert!(err.to_string().contains("no Codex CLI credentials found"));
}
#[test]
fn build_candidate_succeeds_with_openai_env_auth() {
let fixture = test_config("openai/gpt-5.4", &[], Some("sk-test-key"), None, false);
let candidate = build_candidate(&fixture.config, &ModelRef::parse("openai/gpt-5.4").unwrap())
.expect("valid env auth should succeed");
assert_eq!(candidate.model_ref, "openai/gpt-5.4");
assert_eq!(candidate.provider_name, "openai");
}
#[test]
fn build_candidate_succeeds_with_anthropic_env_auth() {
let fixture = test_config(
"anthropic/claude-sonnet-4-6",
&[],
None,
Some("sk-ant-test-token"),
false,
);
let candidate = build_candidate(
&fixture.config,
&ModelRef::parse("anthropic/claude-sonnet-4-6").unwrap(),
)
.expect("valid env auth should succeed");
assert_eq!(candidate.model_ref, "anthropic/claude-sonnet-4-6");
assert_eq!(candidate.provider_name, "anthropic");
}
#[test]
fn provider_doctor_reports_missing_openai_env_auth() {
let fixture = test_config("openai/gpt-5.4", &[], None, None, false);
let doctor = provider_doctor(&fixture.config);
let providers = doctor["providers"].as_array().unwrap();
let openai_provider = providers
.iter()
.find(|p| p["provider"] == "openai")
.expect("should have openai provider");
assert_eq!(
openai_provider["availability"]["available"],
Value::Bool(false)
);
assert!(openai_provider["availability"]["error"]
.as_str()
.unwrap()
.contains("missing OPENAI_API_KEY"));
}
#[test]
fn provider_doctor_reports_missing_anthropic_env_auth() {
let fixture = test_config("anthropic/claude-sonnet-4-6", &[], None, None, false);
let doctor = provider_doctor(&fixture.config);
let providers = doctor["providers"].as_array().unwrap();
let anthropic_provider = providers
.iter()
.find(|p| p["provider"] == "anthropic")
.expect("should have anthropic provider");
assert_eq!(
anthropic_provider["availability"]["available"],
Value::Bool(false)
);
assert!(anthropic_provider["availability"]["error"]
.as_str()
.unwrap()
.contains("missing ANTHROPIC_AUTH_TOKEN"));
}
#[test]
fn provider_doctor_reports_missing_codex_external_cli_auth() {
let fixture = test_config("openai-codex/gpt-5.4", &[], None, None, false);
let doctor = provider_doctor(&fixture.config);
let providers = doctor["providers"].as_array().unwrap();
let codex_provider = providers
.iter()
.find(|p| p["provider"] == "openai-codex")
.expect("should have codex provider");
assert_eq!(
codex_provider["availability"]["available"],
Value::Bool(false)
);
assert!(codex_provider["availability"]["error"]
.as_str()
.unwrap()
.contains("no Codex CLI credentials found"));
}
#[test]
fn provider_doctor_reports_valid_openai_env_auth() {
let fixture = test_config("openai/gpt-5.4", &[], Some("sk-test-key"), None, false);
let doctor = provider_doctor(&fixture.config);
let providers = doctor["providers"].as_array().unwrap();
let openai_provider = providers
.iter()
.find(|p| p["provider"] == "openai")
.expect("should have openai provider");
assert_eq!(
openai_provider["availability"]["available"],
Value::Bool(true)
);
assert_eq!(
openai_provider["availability"]["prompt_capabilities"],
json!([
"full_request_only",
"prompt_cache_key",
"incremental_responses"
])
);
}
#[test]
fn provider_doctor_reports_valid_anthropic_env_auth() {
let fixture = test_config(
"anthropic/claude-sonnet-4-6",
&[],
None,
Some("sk-ant-test-token"),
false,
);
let doctor = provider_doctor(&fixture.config);
let providers = doctor["providers"].as_array().unwrap();
let anthropic_provider = providers
.iter()
.find(|p| p["provider"] == "anthropic")
.expect("should have anthropic provider");
assert_eq!(
anthropic_provider["availability"]["available"],
Value::Bool(true)
);
assert_eq!(
anthropic_provider["availability"]["prompt_capabilities"],
json!(["full_request_only", "prompt_cache_blocks"])
);
}
#[test]
fn provider_doctor_reports_anthropic_context_management_when_enabled() {
let mut fixture = test_config(
"anthropic/claude-sonnet-4-6",
&[],
None,
Some("sk-ant-test-token"),
false,
);
fixture
.config
.providers
.get_mut(&ProviderId::anthropic())
.unwrap()
.context_management
.enabled = true;
let doctor = provider_doctor(&fixture.config);
let providers = doctor["providers"].as_array().unwrap();
let anthropic_provider = providers
.iter()
.find(|p| p["provider"] == "anthropic")
.expect("should have anthropic provider");
assert_eq!(
anthropic_provider["availability"]["prompt_capabilities"],
json!([
"full_request_only",
"prompt_cache_blocks",
"context_management"
])
);
}
#[test]
fn provider_doctor_reports_valid_codex_external_cli_auth() {
let fixture = test_config("openai-codex/gpt-5.4", &[], None, None, true);
let doctor = provider_doctor(&fixture.config);
let providers = doctor["providers"].as_array().unwrap();
let codex_provider = providers
.iter()
.find(|p| p["provider"] == "openai-codex")
.expect("should have codex provider");
assert_eq!(
codex_provider["availability"]["available"],
Value::Bool(true)
);
}