Skip to main content

sac/model/
mod.rs

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}