1use anyhow::{anyhow, Result};
2use clap::ValueEnum;
3use reqwest::Client;
4use serde::{Deserialize, Serialize};
5use serde_json::{json, Value};
6use std::time::Duration;
7use tokio::time::sleep;
8use url::Url;
9
10use crate::events::{AgentEvent, EventSink};
11use crate::types::{FunctionCall, Message, ToolCall, ToolDefinition, Usage};
12
13mod backend;
14mod chat;
15mod chatgpt_codex;
16mod client;
17mod requests;
18mod responses;
19mod types;
20mod usage;
21
22pub use backend::detect_backend;
23pub use chatgpt_codex::{
24 codex_auth_login, codex_auth_login_access_token, codex_auth_login_api_key, codex_auth_logout,
25 codex_auth_status,
26};
27pub use client::ModelClient;
28pub use types::*;
29
30use backend::*;
31use chat::*;
32use requests::*;
33use responses::*;
34use usage::*;
35
36#[cfg(test)]
37mod tests {
38 use super::*;
39 use crate::test_env_lock;
40 use std::ffi::OsString;
41
42 fn restore_env(name: &str, value: Option<OsString>) {
43 match value {
44 Some(value) => unsafe { std::env::set_var(name, value) },
45 None => unsafe { std::env::remove_var(name) },
46 }
47 }
48
49 #[test]
50 fn test_missing_api_key_error() {
51 let _guard = test_env_lock();
52
53 let original = std::env::var("OPENAI_API_KEY").ok();
54 unsafe {
55 std::env::remove_var("OPENAI_API_KEY");
56 }
57
58 let result = ModelClient::from_env();
59 assert!(result.is_err(), "Expected error when API key missing");
60 let err_msg = result
61 .err()
62 .expect("Expected missing-key error")
63 .to_string();
64 assert!(
65 err_msg.contains("OPENAI_API_KEY"),
66 "Error should mention OPENAI_API_KEY, got: {}",
67 err_msg
68 );
69
70 if let Some(key) = original {
71 unsafe {
72 std::env::set_var("OPENAI_API_KEY", key);
73 }
74 } else {
75 unsafe {
76 std::env::remove_var("OPENAI_API_KEY");
77 }
78 }
79 }
80
81 #[test]
82 fn explicit_deepseek_backend_defaults_to_deepseek_url_and_model() {
83 let _guard = test_env_lock();
84
85 let original_openai_key = std::env::var_os("OPENAI_API_KEY");
86 let original_base_url = std::env::var_os("OPENAI_BASE_URL");
87 let original_model = std::env::var_os("OPENAI_MODEL");
88
89 unsafe {
90 std::env::set_var("OPENAI_API_KEY", "test_openai_key");
91 std::env::remove_var("OPENAI_BASE_URL");
92 std::env::remove_var("OPENAI_MODEL");
93 }
94
95 let client = ModelClient::from_env_with_overrides(ClientOverrides {
96 backend: Some(BackendKind::DeepSeekChat),
97 ..ClientOverrides::default()
98 })
99 .unwrap();
100
101 assert_eq!(client.base_url(), "https://api.deepseek.com");
102 assert_eq!(client.backend(), BackendKind::DeepSeekChat);
103 assert_eq!(client.model, "deepseek-v4-pro");
104 assert_eq!(client.reasoning_effort(), None);
105
106 restore_env("OPENAI_API_KEY", original_openai_key);
107 restore_env("OPENAI_BASE_URL", original_base_url);
108 restore_env("OPENAI_MODEL", original_model);
109 }
110
111 #[test]
112 fn config_api_key_is_used_when_env_is_missing() {
113 let _guard = test_env_lock();
114
115 let original_openai_key = std::env::var_os("OPENAI_API_KEY");
116 let original_base_url = std::env::var_os("OPENAI_BASE_URL");
117 let original_model = std::env::var_os("OPENAI_MODEL");
118
119 unsafe {
120 std::env::remove_var("OPENAI_API_KEY");
121 std::env::remove_var("OPENAI_BASE_URL");
122 std::env::remove_var("OPENAI_MODEL");
123 }
124
125 let client = ModelClient::from_env_with_overrides(ClientOverrides {
126 api_key: Some("config-secret".to_string()),
127 ..ClientOverrides::default()
128 })
129 .unwrap();
130
131 assert_eq!(client.base_url(), "https://api.openai.com/v1");
132 assert_eq!(client.backend(), BackendKind::OpenAiResponses);
133 assert_eq!(client.model, "gpt-5.5");
134
135 restore_env("OPENAI_API_KEY", original_openai_key);
136 restore_env("OPENAI_BASE_URL", original_base_url);
137 restore_env("OPENAI_MODEL", original_model);
138 }
139
140 #[test]
141 fn config_api_key_beats_standard_env_api_key() {
142 let _guard = test_env_lock();
143
144 let original_openai_key = std::env::var_os("OPENAI_API_KEY");
145 let original_base_url = std::env::var_os("OPENAI_BASE_URL");
146 let original_model = std::env::var_os("OPENAI_MODEL");
147
148 unsafe {
149 std::env::set_var("OPENAI_API_KEY", "env-secret");
150 std::env::remove_var("OPENAI_BASE_URL");
151 std::env::remove_var("OPENAI_MODEL");
152 }
153
154 let client = ModelClient::from_env_with_overrides(ClientOverrides {
155 api_key: Some("config-secret".to_string()),
156 ..ClientOverrides::default()
157 })
158 .unwrap();
159
160 assert_eq!(client.backend(), BackendKind::OpenAiResponses);
161 assert_eq!(client.model, "gpt-5.5");
162
163 restore_env("OPENAI_API_KEY", original_openai_key);
164 restore_env("OPENAI_BASE_URL", original_base_url);
165 restore_env("OPENAI_MODEL", original_model);
166 }
167
168 #[test]
169 fn config_api_key_env_beats_standard_env_api_key() {
170 let _guard = test_env_lock();
171
172 let original_openai_key = std::env::var_os("OPENAI_API_KEY");
173 let original_alt_key = std::env::var_os("ALT_KEY");
174 let original_base_url = std::env::var_os("OPENAI_BASE_URL");
175 let original_model = std::env::var_os("OPENAI_MODEL");
176
177 unsafe {
178 std::env::remove_var("OPENAI_API_KEY");
179 std::env::set_var("ALT_KEY", "alt-secret");
180 std::env::remove_var("OPENAI_BASE_URL");
181 std::env::remove_var("OPENAI_MODEL");
182 }
183
184 let client = ModelClient::from_env_with_overrides(ClientOverrides {
185 api_key_env: Some("ALT_KEY".to_string()),
186 api_key: Some("config-secret".to_string()),
187 ..ClientOverrides::default()
188 })
189 .unwrap();
190
191 assert_eq!(client.backend(), BackendKind::OpenAiResponses);
192
193 restore_env("OPENAI_API_KEY", original_openai_key);
194 restore_env("ALT_KEY", original_alt_key);
195 restore_env("OPENAI_BASE_URL", original_base_url);
196 restore_env("OPENAI_MODEL", original_model);
197 }
198
199 #[test]
200 fn detects_backend_from_url() {
201 assert_eq!(
202 detect_backend("https://api.openai.com/v1").unwrap(),
203 BackendKind::OpenAiResponses
204 );
205 assert_eq!(
206 detect_backend("https://api.fireworks.ai/inference/v1").unwrap(),
207 BackendKind::FireworksChat
208 );
209 assert_eq!(
210 detect_backend("https://api.deepseek.com").unwrap(),
211 BackendKind::DeepSeekChat
212 );
213 assert!(detect_backend("https://example.com/v1").is_err());
214 }
215
216 #[test]
217 fn deepseek_chat_request_enables_max_thinking_and_preserves_reasoning() {
218 let request = deepseek_chat_request(
219 "deepseek-v4-pro",
220 &[Message::Assistant {
221 content: Some("calling a tool".to_string()),
222 reasoning_text: Some("need current context".to_string()),
223 reasoning_details: None,
224 tool_calls: Some(vec![ToolCall {
225 id: "call_1".to_string(),
226 call_type: "function".to_string(),
227 function: FunctionCall {
228 name: "read".to_string(),
229 arguments: "{\"path\":\"src/main.rs\"}".to_string(),
230 },
231 }]),
232 }],
233 &[ToolDefinition {
234 def_type: "function".to_string(),
235 function: crate::types::FunctionDef {
236 name: "read".to_string(),
237 description: "Read a file".to_string(),
238 parameters: json!({
239 "type": "object",
240 "properties": {
241 "path": {"type": "string"}
242 },
243 "required": ["path"]
244 }),
245 },
246 }],
247 );
248
249 assert_eq!(request["model"], "deepseek-v4-pro");
250 assert_eq!(request["thinking"]["type"], "enabled");
251 assert_eq!(request["reasoning_effort"], "max");
252 assert!(request.get("temperature").is_none());
253 assert_eq!(
254 request["messages"][0]["reasoning_content"],
255 "need current context"
256 );
257 assert_eq!(request["tools"][0]["type"], "function");
258 }
259
260 #[test]
261 fn responses_input_items_expand_reasoning_and_tool_state() {
262 let items = responses_input_items(&[
263 Message::System {
264 content: "system".to_string(),
265 },
266 Message::Assistant {
267 content: Some("assistant text".to_string()),
268 reasoning_text: Some("hidden".to_string()),
269 reasoning_details: Some(json!([{
270 "type": "reasoning",
271 "id": "rs_1",
272 "summary": [{"type": "summary_text", "text": "keep this"}]
273 }])),
274 tool_calls: Some(vec![ToolCall {
275 id: "call_1".to_string(),
276 call_type: "function".to_string(),
277 function: FunctionCall {
278 name: "read".to_string(),
279 arguments: "{\"path\":\"src/main.rs\"}".to_string(),
280 },
281 }]),
282 },
283 Message::Tool {
284 tool_call_id: "call_1".to_string(),
285 content: "tool output".to_string(),
286 },
287 ]);
288
289 assert_eq!(items.len(), 5);
290 assert_eq!(items[0]["role"], "system");
291 assert_eq!(items[1]["type"], "reasoning");
292 assert_eq!(items[2]["type"], "function_call");
293 assert_eq!(items[3]["role"], "assistant");
294 assert_eq!(items[4]["type"], "function_call_output");
295 }
296
297 #[test]
298 fn parses_deepseek_chat_output() {
299 let parsed = parse_chat_completions_response(
300 &json!({
301 "choices": [
302 {
303 "finish_reason": "stop",
304 "message": {
305 "content": "done",
306 "reasoning_content": "worked through it",
307 "tool_calls": null
308 }
309 }
310 ],
311 "usage": {
312 "prompt_tokens": 10,
313 "completion_tokens": 20,
314 "total_tokens": 30,
315 "completion_tokens_details": {
316 "reasoning_tokens": 9
317 }
318 }
319 }),
320 "https://api.deepseek.com/chat/completions",
321 )
322 .unwrap();
323
324 assert_eq!(parsed.assistant.content.as_deref(), Some("done"));
325 assert_eq!(
326 parsed.assistant.reasoning_text.as_deref(),
327 Some("worked through it")
328 );
329 assert!(parsed.assistant.tool_calls.is_none());
330 assert_eq!(parsed.usage.reasoning_tokens, Some(9));
331 }
332
333 #[test]
334 fn parses_openai_responses_output() {
335 let parsed = parse_openai_responses_response(
336 &json!({
337 "status": "completed",
338 "output": [
339 {
340 "type": "reasoning",
341 "id": "rs_1",
342 "summary": [{"type": "summary_text", "text": "thought summary"}]
343 },
344 {
345 "type": "function_call",
346 "call_id": "call_1",
347 "name": "read",
348 "arguments": "{\"path\":\"src/main.rs\"}"
349 },
350 {
351 "type": "message",
352 "content": [
353 {"type": "output_text", "text": "hello world"}
354 ]
355 }
356 ],
357 "usage": {
358 "input_tokens": 10,
359 "output_tokens": 20,
360 "total_tokens": 30,
361 "output_tokens_details": {
362 "reasoning_tokens": 7
363 }
364 }
365 }),
366 "https://api.openai.com/v1/responses",
367 )
368 .unwrap();
369
370 assert_eq!(parsed.assistant.content.as_deref(), Some("hello world"));
371 assert_eq!(
372 parsed.assistant.reasoning_text.as_deref(),
373 Some("thought summary")
374 );
375 assert_eq!(
376 parsed
377 .assistant
378 .tool_calls
379 .as_ref()
380 .expect("tool calls should be parsed")
381 .len(),
382 1
383 );
384 assert_eq!(parsed.usage.reasoning_tokens, Some(7));
385 }
386}