use anyhow::{anyhow, Result};
use clap::ValueEnum;
use reqwest::Client;
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use std::time::Duration;
use tokio::time::sleep;
use url::Url;
use crate::events::{AgentEvent, EventSink};
use crate::types::{FunctionCall, Message, ToolCall, ToolDefinition, Usage};
mod backend;
mod chat;
mod chatgpt_codex;
mod client;
mod requests;
mod responses;
mod types;
mod usage;
pub use backend::detect_backend;
pub use chatgpt_codex::{
codex_auth_login, codex_auth_login_access_token, codex_auth_login_api_key, codex_auth_logout,
codex_auth_status,
};
pub use client::ModelClient;
pub use types::*;
use backend::*;
use chat::*;
use requests::*;
use responses::*;
use usage::*;
#[cfg(test)]
mod tests {
use super::*;
use crate::test_env_lock;
use std::ffi::OsString;
fn restore_env(name: &str, value: Option<OsString>) {
match value {
Some(value) => unsafe { std::env::set_var(name, value) },
None => unsafe { std::env::remove_var(name) },
}
}
#[test]
fn test_missing_api_key_error() {
let _guard = test_env_lock();
let original = std::env::var("OPENAI_API_KEY").ok();
unsafe {
std::env::remove_var("OPENAI_API_KEY");
}
let result = ModelClient::from_env();
assert!(result.is_err(), "Expected error when API key missing");
let err_msg = result
.err()
.expect("Expected missing-key error")
.to_string();
assert!(
err_msg.contains("OPENAI_API_KEY"),
"Error should mention OPENAI_API_KEY, got: {}",
err_msg
);
if let Some(key) = original {
unsafe {
std::env::set_var("OPENAI_API_KEY", key);
}
} else {
unsafe {
std::env::remove_var("OPENAI_API_KEY");
}
}
}
#[test]
fn explicit_deepseek_backend_defaults_to_deepseek_url_and_model() {
let _guard = test_env_lock();
let original_openai_key = std::env::var_os("OPENAI_API_KEY");
let original_base_url = std::env::var_os("OPENAI_BASE_URL");
let original_model = std::env::var_os("OPENAI_MODEL");
unsafe {
std::env::set_var("OPENAI_API_KEY", "test_openai_key");
std::env::remove_var("OPENAI_BASE_URL");
std::env::remove_var("OPENAI_MODEL");
}
let client = ModelClient::from_env_with_overrides(ClientOverrides {
backend: Some(BackendKind::DeepSeekChat),
..ClientOverrides::default()
})
.unwrap();
assert_eq!(client.base_url(), "https://api.deepseek.com");
assert_eq!(client.backend(), BackendKind::DeepSeekChat);
assert_eq!(client.model, "deepseek-v4-pro");
assert_eq!(client.reasoning_effort(), None);
restore_env("OPENAI_API_KEY", original_openai_key);
restore_env("OPENAI_BASE_URL", original_base_url);
restore_env("OPENAI_MODEL", original_model);
}
#[test]
fn config_api_key_is_used_when_env_is_missing() {
let _guard = test_env_lock();
let original_openai_key = std::env::var_os("OPENAI_API_KEY");
let original_base_url = std::env::var_os("OPENAI_BASE_URL");
let original_model = std::env::var_os("OPENAI_MODEL");
unsafe {
std::env::remove_var("OPENAI_API_KEY");
std::env::remove_var("OPENAI_BASE_URL");
std::env::remove_var("OPENAI_MODEL");
}
let client = ModelClient::from_env_with_overrides(ClientOverrides {
api_key: Some("config-secret".to_string()),
..ClientOverrides::default()
})
.unwrap();
assert_eq!(client.base_url(), "https://api.openai.com/v1");
assert_eq!(client.backend(), BackendKind::OpenAiResponses);
assert_eq!(client.model, "gpt-5.5");
restore_env("OPENAI_API_KEY", original_openai_key);
restore_env("OPENAI_BASE_URL", original_base_url);
restore_env("OPENAI_MODEL", original_model);
}
#[test]
fn config_api_key_beats_standard_env_api_key() {
let _guard = test_env_lock();
let original_openai_key = std::env::var_os("OPENAI_API_KEY");
let original_base_url = std::env::var_os("OPENAI_BASE_URL");
let original_model = std::env::var_os("OPENAI_MODEL");
unsafe {
std::env::set_var("OPENAI_API_KEY", "env-secret");
std::env::remove_var("OPENAI_BASE_URL");
std::env::remove_var("OPENAI_MODEL");
}
let client = ModelClient::from_env_with_overrides(ClientOverrides {
api_key: Some("config-secret".to_string()),
..ClientOverrides::default()
})
.unwrap();
assert_eq!(client.backend(), BackendKind::OpenAiResponses);
assert_eq!(client.model, "gpt-5.5");
restore_env("OPENAI_API_KEY", original_openai_key);
restore_env("OPENAI_BASE_URL", original_base_url);
restore_env("OPENAI_MODEL", original_model);
}
#[test]
fn config_api_key_env_beats_standard_env_api_key() {
let _guard = test_env_lock();
let original_openai_key = std::env::var_os("OPENAI_API_KEY");
let original_alt_key = std::env::var_os("ALT_KEY");
let original_base_url = std::env::var_os("OPENAI_BASE_URL");
let original_model = std::env::var_os("OPENAI_MODEL");
unsafe {
std::env::remove_var("OPENAI_API_KEY");
std::env::set_var("ALT_KEY", "alt-secret");
std::env::remove_var("OPENAI_BASE_URL");
std::env::remove_var("OPENAI_MODEL");
}
let client = ModelClient::from_env_with_overrides(ClientOverrides {
api_key_env: Some("ALT_KEY".to_string()),
api_key: Some("config-secret".to_string()),
..ClientOverrides::default()
})
.unwrap();
assert_eq!(client.backend(), BackendKind::OpenAiResponses);
restore_env("OPENAI_API_KEY", original_openai_key);
restore_env("ALT_KEY", original_alt_key);
restore_env("OPENAI_BASE_URL", original_base_url);
restore_env("OPENAI_MODEL", original_model);
}
#[test]
fn detects_backend_from_url() {
assert_eq!(
detect_backend("https://api.openai.com/v1").unwrap(),
BackendKind::OpenAiResponses
);
assert_eq!(
detect_backend("https://api.fireworks.ai/inference/v1").unwrap(),
BackendKind::FireworksChat
);
assert_eq!(
detect_backend("https://api.deepseek.com").unwrap(),
BackendKind::DeepSeekChat
);
assert!(detect_backend("https://example.com/v1").is_err());
}
#[test]
fn deepseek_chat_request_enables_max_thinking_and_preserves_reasoning() {
let request = deepseek_chat_request(
"deepseek-v4-pro",
&[Message::Assistant {
content: Some("calling a tool".to_string()),
reasoning_text: Some("need current context".to_string()),
reasoning_details: None,
tool_calls: Some(vec![ToolCall {
id: "call_1".to_string(),
call_type: "function".to_string(),
function: FunctionCall {
name: "read".to_string(),
arguments: "{\"path\":\"src/main.rs\"}".to_string(),
},
}]),
}],
&[ToolDefinition {
def_type: "function".to_string(),
function: crate::types::FunctionDef {
name: "read".to_string(),
description: "Read a file".to_string(),
parameters: json!({
"type": "object",
"properties": {
"path": {"type": "string"}
},
"required": ["path"]
}),
},
}],
);
assert_eq!(request["model"], "deepseek-v4-pro");
assert_eq!(request["thinking"]["type"], "enabled");
assert_eq!(request["reasoning_effort"], "max");
assert!(request.get("temperature").is_none());
assert_eq!(
request["messages"][0]["reasoning_content"],
"need current context"
);
assert_eq!(request["tools"][0]["type"], "function");
}
#[test]
fn responses_input_items_expand_reasoning_and_tool_state() {
let items = responses_input_items(&[
Message::System {
content: "system".to_string(),
},
Message::Assistant {
content: Some("assistant text".to_string()),
reasoning_text: Some("hidden".to_string()),
reasoning_details: Some(json!([{
"type": "reasoning",
"id": "rs_1",
"summary": [{"type": "summary_text", "text": "keep this"}]
}])),
tool_calls: Some(vec![ToolCall {
id: "call_1".to_string(),
call_type: "function".to_string(),
function: FunctionCall {
name: "read".to_string(),
arguments: "{\"path\":\"src/main.rs\"}".to_string(),
},
}]),
},
Message::Tool {
tool_call_id: "call_1".to_string(),
content: "tool output".to_string(),
},
]);
assert_eq!(items.len(), 5);
assert_eq!(items[0]["role"], "system");
assert_eq!(items[1]["type"], "reasoning");
assert_eq!(items[2]["type"], "function_call");
assert_eq!(items[3]["role"], "assistant");
assert_eq!(items[4]["type"], "function_call_output");
}
#[test]
fn parses_deepseek_chat_output() {
let parsed = parse_chat_completions_response(
&json!({
"choices": [
{
"finish_reason": "stop",
"message": {
"content": "done",
"reasoning_content": "worked through it",
"tool_calls": null
}
}
],
"usage": {
"prompt_tokens": 10,
"completion_tokens": 20,
"total_tokens": 30,
"completion_tokens_details": {
"reasoning_tokens": 9
}
}
}),
"https://api.deepseek.com/chat/completions",
)
.unwrap();
assert_eq!(parsed.assistant.content.as_deref(), Some("done"));
assert_eq!(
parsed.assistant.reasoning_text.as_deref(),
Some("worked through it")
);
assert!(parsed.assistant.tool_calls.is_none());
assert_eq!(parsed.usage.reasoning_tokens, Some(9));
}
#[test]
fn parses_openai_responses_output() {
let parsed = parse_openai_responses_response(
&json!({
"status": "completed",
"output": [
{
"type": "reasoning",
"id": "rs_1",
"summary": [{"type": "summary_text", "text": "thought summary"}]
},
{
"type": "function_call",
"call_id": "call_1",
"name": "read",
"arguments": "{\"path\":\"src/main.rs\"}"
},
{
"type": "message",
"content": [
{"type": "output_text", "text": "hello world"}
]
}
],
"usage": {
"input_tokens": 10,
"output_tokens": 20,
"total_tokens": 30,
"output_tokens_details": {
"reasoning_tokens": 7
}
}
}),
"https://api.openai.com/v1/responses",
)
.unwrap();
assert_eq!(parsed.assistant.content.as_deref(), Some("hello world"));
assert_eq!(
parsed.assistant.reasoning_text.as_deref(),
Some("thought summary")
);
assert_eq!(
parsed
.assistant
.tool_calls
.as_ref()
.expect("tool calls should be parsed")
.len(),
1
);
assert_eq!(parsed.usage.reasoning_tokens, Some(7));
}
}