Skip to main content

deepstrike_sdk/providers/
openai.rs

1use async_trait::async_trait;
2use deepstrike_core::context::renderer::RenderedContext;
3use deepstrike_core::types::message::{Content, ContentPart, Role, ToolSchema};
4use futures::{Stream, StreamExt};
5use reqwest::Client;
6use serde_json::{Value, json};
7
8use super::{LLMProvider, RuntimePolicy, StreamEvent};
9use crate::{Error, Result};
10
11/// Cached-prompt-token count from an OpenAI-compatible usage object: the standard
12/// `prompt_tokens_details.cached_tokens` (OpenAI, Qwen, MiniMax, GLM, Kimi) and
13/// DeepSeek's `prompt_cache_hit_tokens`. These caches bill reads only (no
14/// cache-creation count); the figure is a subset of `prompt_tokens`.
15fn openai_cached_prompt_tokens(usage: &Value) -> u32 {
16    let standard = usage["prompt_tokens_details"]["cached_tokens"]
17        .as_u64()
18        .unwrap_or(0);
19    let deepseek = usage["prompt_cache_hit_tokens"].as_u64().unwrap_or(0);
20    standard.max(deepseek) as u32
21}
22
23pub struct OpenAIProvider {
24    client: Client,
25    api_key: String,
26    model: String,
27    base_url: String,
28}
29
30impl OpenAIProvider {
31    pub fn new(api_key: impl Into<String>) -> Self {
32        Self::with_base_url(api_key, "gpt-4o", "https://api.openai.com/v1")
33    }
34
35    pub fn with_base_url(
36        api_key: impl Into<String>,
37        model: impl Into<String>,
38        base_url: impl Into<String>,
39    ) -> Self {
40        Self {
41            client: Client::new(),
42            api_key: api_key.into(),
43            model: model.into(),
44            base_url: base_url.into(),
45        }
46    }
47}
48
49pub fn qwen(api_key: impl Into<String>) -> OpenAIProvider {
50    OpenAIProvider::with_base_url(
51        api_key,
52        "qwen-max",
53        "https://dashscope.aliyuncs.com/compatible-mode/v1",
54    )
55}
56
57pub fn deepseek(api_key: impl Into<String>) -> OpenAIProvider {
58    OpenAIProvider::with_base_url(api_key, "deepseek-chat", "https://api.deepseek.com/v1")
59}
60
61pub fn minimax(api_key: impl Into<String>) -> OpenAIProvider {
62    OpenAIProvider::with_base_url(api_key, "MiniMax-Text-01", "https://api.minimax.chat/v1")
63}
64
65pub fn ollama(model: impl Into<String>) -> OpenAIProvider {
66    OpenAIProvider::with_base_url("", model, "http://localhost:11434/v1")
67}
68
69pub fn kimi(api_key: impl Into<String>) -> OpenAIProvider {
70    OpenAIProvider::with_base_url(api_key, "moonshot-v1-8k", "https://api.moonshot.cn/v1")
71}
72
73fn content_part_to_openai(part: &ContentPart) -> Value {
74    match part {
75        ContentPart::Text { text } => json!({ "type": "text", "text": text }),
76        ContentPart::Image {
77            url: Some(url),
78            data: None,
79            detail,
80            ..
81        } => {
82            let image_url = match detail.as_deref() {
83                Some(d) => json!({ "url": url, "detail": d }),
84                None => json!({ "url": url }),
85            };
86            json!({ "type": "image_url", "image_url": image_url })
87        }
88        ContentPart::Image {
89            data: Some(data),
90            media_type,
91            detail,
92            ..
93        } => {
94            let mt = media_type.as_deref().unwrap_or("image/png");
95            let url = format!("data:{mt};base64,{data}");
96            let image_url = match detail.as_deref() {
97                Some(d) => json!({ "url": url, "detail": d }),
98                None => json!({ "url": url }),
99            };
100            json!({ "type": "image_url", "image_url": image_url })
101        }
102        ContentPart::Image { .. } => json!({ "type": "text", "text": "" }),
103        ContentPart::Audio { data, media_type } => {
104            let fmt = media_type.split('/').nth(1).unwrap_or("wav");
105            json!({ "type": "input_audio", "input_audio": { "data": data, "format": fmt } })
106        }
107        ContentPart::ToolResult { output, .. } => {
108            json!({ "type": "text", "text": output })
109        }
110    }
111}
112
113fn content_to_openai(content: &Content) -> Value {
114    match content {
115        Content::Text(s) => json!(s),
116        Content::Parts(parts) => {
117            let blocks: Vec<Value> = parts.iter().map(content_part_to_openai).collect();
118            json!(blocks)
119        }
120    }
121}
122
123fn context_to_openai(context: &RenderedContext) -> Vec<Value> {
124    let mut messages = Vec::new();
125    if !context.system_text.is_empty() {
126        messages.push(json!({ "role": "system", "content": context.system_text }));
127    }
128    // OpenAI auto-caches by prefix; the volatile State turn is appended as the
129    // latest turn so the history stays a stable cacheable prefix. `state_turn` is
130    // None on un-rebuilt bindings, where the state is still inside `turns`.
131    for message in context.turns.iter().chain(context.state_turn.iter()) {
132        if message.role == Role::Tool {
133            if let Content::Parts(parts) = &message.content {
134                for part in parts {
135                    if let ContentPart::ToolResult {
136                        call_id, output, ..
137                    } = part
138                    {
139                        messages.push(json!({
140                            "role": "tool",
141                            "tool_call_id": call_id.as_str(),
142                            "content": output,
143                        }));
144                    }
145                }
146            }
147            continue;
148        }
149
150        let role = match message.role {
151            Role::System => "system",
152            Role::User => "user",
153            Role::Tool => "tool",
154            Role::Assistant => "assistant",
155        };
156        let mut next = json!({
157            "role": role,
158            "content": content_to_openai(&message.content),
159        });
160        if message.role == Role::Assistant && !message.tool_calls.is_empty() {
161            next["tool_calls"] = json!(
162                message
163                    .tool_calls
164                    .iter()
165                    .map(|tc| json!({
166                        "id": tc.id.as_str(),
167                        "type": "function",
168                        "function": {
169                            "name": tc.name.as_str(),
170                            "arguments": tc.arguments.to_string(),
171                        }
172                    }))
173                    .collect::<Vec<_>>()
174            );
175        }
176        messages.push(next);
177    }
178    messages
179}
180
181#[async_trait]
182impl LLMProvider for OpenAIProvider {
183    fn runtime_policy(&self) -> RuntimePolicy {
184        match self.model.as_str() {
185            // OpenAI
186            "gpt-4o" => RuntimePolicy {
187                max_turns: Some(25),
188                timeout_ms: None,
189            },
190            "gpt-4o-mini" => RuntimePolicy {
191                max_turns: Some(15),
192                timeout_ms: None,
193            },
194            "gpt-4.1" => RuntimePolicy {
195                max_turns: Some(35),
196                timeout_ms: None,
197            },
198            "gpt-4.1-mini" => RuntimePolicy {
199                max_turns: Some(20),
200                timeout_ms: None,
201            },
202            "gpt-4.1-nano" => RuntimePolicy {
203                max_turns: Some(15),
204                timeout_ms: None,
205            },
206            "gpt-5" => RuntimePolicy {
207                max_turns: Some(50),
208                timeout_ms: None,
209            },
210            "gpt-5-mini" => RuntimePolicy {
211                max_turns: Some(25),
212                timeout_ms: None,
213            },
214            "o3" | "o3-mini" | "o4-mini" => RuntimePolicy {
215                max_turns: Some(50),
216                timeout_ms: None,
217            },
218            // DeepSeek
219            "deepseek-chat" | "deepseek-v4-flash" => RuntimePolicy {
220                max_turns: Some(25),
221                timeout_ms: None,
222            },
223            "deepseek-reasoner" | "deepseek-r1" => RuntimePolicy {
224                max_turns: Some(50),
225                timeout_ms: None,
226            },
227            "deepseek-v4-pro" => RuntimePolicy {
228                max_turns: Some(35),
229                timeout_ms: None,
230            },
231            // Qwen
232            "qwen-max" => RuntimePolicy {
233                max_turns: Some(25),
234                timeout_ms: None,
235            },
236            "qwen-plus" => RuntimePolicy {
237                max_turns: Some(20),
238                timeout_ms: None,
239            },
240            "qwq-plus" | "qwq-32b" => RuntimePolicy {
241                max_turns: Some(40),
242                timeout_ms: None,
243            },
244            "qwen3-235b-a22b" => RuntimePolicy {
245                max_turns: Some(35),
246                timeout_ms: None,
247            },
248            "qwen3-72b" => RuntimePolicy {
249                max_turns: Some(25),
250                timeout_ms: None,
251            },
252            "qwen3-32b" | "qwen3-14b" | "qwen3-8b" => RuntimePolicy {
253                max_turns: Some(20),
254                timeout_ms: None,
255            },
256            // Kimi (Moonshot)
257            "moonshot-v1-8k" => RuntimePolicy {
258                max_turns: Some(15),
259                timeout_ms: None,
260            },
261            "moonshot-v1-32k" => RuntimePolicy {
262                max_turns: Some(20),
263                timeout_ms: None,
264            },
265            "moonshot-v1-128k" | "kimi-k2.5" => RuntimePolicy {
266                max_turns: Some(30),
267                timeout_ms: None,
268            },
269            "kimi-k2.6" => RuntimePolicy {
270                max_turns: Some(35),
271                timeout_ms: None,
272            },
273            // MiniMax
274            "MiniMax-M2.7" => RuntimePolicy {
275                max_turns: Some(35),
276                timeout_ms: None,
277            },
278            "MiniMax-M2.5" | "MiniMax-M1" => RuntimePolicy {
279                max_turns: Some(25),
280                timeout_ms: None,
281            },
282            "MiniMax-Text-01" => RuntimePolicy {
283                max_turns: Some(20),
284                timeout_ms: None,
285            },
286            // Ollama prefix matching
287            m if m.starts_with("deepseek-r1") => RuntimePolicy {
288                max_turns: Some(40),
289                timeout_ms: None,
290            },
291            m if m.starts_with("qwq") => RuntimePolicy {
292                max_turns: Some(35),
293                timeout_ms: None,
294            },
295            m if m.starts_with("llama3") => RuntimePolicy {
296                max_turns: Some(20),
297                timeout_ms: None,
298            },
299            m if m.starts_with("mistral") || m.starts_with("gemma") || m.starts_with("phi") => {
300                RuntimePolicy {
301                    max_turns: Some(20),
302                    timeout_ms: None,
303                }
304            }
305            _ => RuntimePolicy {
306                max_turns: Some(20),
307                timeout_ms: None,
308            },
309        }
310    }
311
312    async fn stream(
313        &self,
314        context: &RenderedContext,
315        tools: &[ToolSchema],
316        extensions: Option<&Value>,
317        _state: Option<&super::ProviderRunState>,
318    ) -> Result<Box<dyn Stream<Item = Result<StreamEvent>> + Send + Unpin>> {
319        let mut body = json!({
320            "model": self.model,
321            "messages": context_to_openai(context),
322            "stream": true,
323            "stream_options": { "include_usage": true },
324        });
325        if !tools.is_empty() {
326            body["tools"] = json!(tools.iter().map(|t| json!({
327                "type": "function",
328                "function": { "name": t.name.as_str(), "description": t.description, "parameters": t.parameters }
329            })).collect::<Vec<_>>());
330        }
331        let mut expose_reasoning = false;
332        if let Some(ext) = extensions {
333            if let Some(obj) = ext.as_object() {
334                for (k, v) in obj {
335                    if k == "expose_reasoning" {
336                        expose_reasoning = v.as_bool().unwrap_or(false);
337                    } else {
338                        body[k] = v.clone();
339                    }
340                }
341            }
342        }
343
344        let resp = self
345            .client
346            .post(format!("{}/chat/completions", self.base_url))
347            .header("Authorization", format!("Bearer {}", self.api_key))
348            .header("content-type", "application/json")
349            .body(body.to_string())
350            .send()
351            .await
352            .map_err(|e| Error::Provider(e.to_string()))?;
353
354        if !resp.status().is_success() {
355            let status = resp.status();
356            let text = resp.text().await.unwrap_or_default();
357            return Err(Error::Provider(format!("OpenAI {status}: {text}")));
358        }
359
360        let byte_stream = resp.bytes_stream();
361        let stream = parse_openai_sse(byte_stream, expose_reasoning);
362        Ok(Box::new(Box::pin(stream)))
363    }
364}
365
366fn parse_openai_sse(
367    byte_stream: impl Stream<Item = reqwest::Result<bytes::Bytes>> + Send + 'static,
368    expose_reasoning: bool,
369) -> impl Stream<Item = Result<StreamEvent>> + Send {
370    let tool_accum: std::collections::HashMap<usize, (String, String, String)> =
371        std::collections::HashMap::new();
372
373    futures::stream::unfold(
374        (Box::pin(byte_stream), String::new(), tool_accum, false),
375        move |(mut stream, mut buf, mut tool_accum, mut flushed)| async move {
376            if flushed {
377                return None;
378            }
379            loop {
380                if let Some(pos) = buf.find('\n') {
381                    let line = buf[..pos].trim().to_string();
382                    buf = buf[pos + 1..].to_string();
383
384                    if !line.starts_with("data: ") {
385                        continue;
386                    }
387                    let data = &line[6..];
388                    if data == "[DONE]" {
389                        // flush accumulated tool calls
390                        if let Some((_, (id, name, args_buf))) = tool_accum.iter().next() {
391                            let arguments: Value = serde_json::from_str(args_buf)
392                                .unwrap_or(Value::Object(Default::default()));
393                            let evt = StreamEvent::ToolCall {
394                                id: id.clone(),
395                                name: name.clone(),
396                                arguments,
397                            };
398                            flushed = true;
399                            return Some((Ok(evt), (stream, buf, tool_accum, flushed)));
400                        }
401                        return None;
402                    }
403
404                    let Ok(chunk) = serde_json::from_str::<Value>(data) else {
405                        continue;
406                    };
407                    if let Some(total) = chunk["usage"]["total_tokens"].as_u64() {
408                        let usage = &chunk["usage"];
409                        return Some((
410                            Ok(StreamEvent::Usage {
411                                total_tokens: total as u32,
412                                input_tokens: usage["prompt_tokens"].as_u64().unwrap_or(0) as u32,
413                                output_tokens: usage["completion_tokens"].as_u64().unwrap_or(0) as u32,
414                                cache_read_input_tokens: openai_cached_prompt_tokens(usage),
415                                cache_creation_input_tokens: 0,
416                                // I1: OpenAI-family providers auto-cache; no per-slot attribution.
417                                cache_read_input_tokens_by_slot: None,
418                            }),
419                            (stream, buf, tool_accum, flushed),
420                        ));
421                    }
422                    let delta = &chunk["choices"][0]["delta"];
423                    if expose_reasoning {
424                        if let Some(reasoning) = delta["reasoning_content"].as_str() {
425                            if !reasoning.is_empty() {
426                                return Some((
427                                    Ok(StreamEvent::ThinkingDelta {
428                                        delta: reasoning.to_string(),
429                                    }),
430                                    (stream, buf, tool_accum, flushed),
431                                ));
432                            }
433                        }
434                    }
435                    if let Some(content) = delta["content"].as_str() {
436                        if !content.is_empty() {
437                            return Some((
438                                Ok(StreamEvent::TextDelta {
439                                    delta: content.to_string(),
440                                }),
441                                (stream, buf, tool_accum, flushed),
442                            ));
443                        }
444                    }
445                    if let Some(tcs) = delta["tool_calls"].as_array() {
446                        for tc in tcs {
447                            let idx = tc["index"].as_u64().unwrap_or(0) as usize;
448                            let entry = tool_accum.entry(idx).or_insert_with(|| {
449                                (
450                                    tc["id"].as_str().unwrap_or("").to_string(),
451                                    tc["function"]["name"].as_str().unwrap_or("").to_string(),
452                                    String::new(),
453                                )
454                            });
455                            entry
456                                .2
457                                .push_str(tc["function"]["arguments"].as_str().unwrap_or(""));
458                        }
459                    }
460                    continue;
461                }
462
463                match stream.next().await {
464                    Some(Ok(chunk)) => buf.push_str(&String::from_utf8_lossy(&chunk)),
465                    Some(Err(e)) => {
466                        return Some((
467                            Err(Error::Provider(e.to_string())),
468                            (stream, buf, tool_accum, flushed),
469                        ));
470                    }
471                    None => return None,
472                }
473            }
474        },
475    )
476}
477
478#[cfg(test)]
479mod tests {
480    use super::*;
481    use compact_str::CompactString;
482    use deepstrike_core::types::message::{ContentPart, Message, ToolCall};
483
484    #[test]
485    fn context_replays_tool_calls_and_results_natively() {
486        let context = RenderedContext {
487            system_text: "system rules".into(),
488            system_stable: "system rules".into(),
489            system_knowledge: String::new(),
490            turns: vec![
491                Message::user("What is the weather?"),
492                Message {
493                    role: Role::Assistant,
494                    content: Content::Text("I'll check.".into()),
495                    tool_calls: vec![ToolCall {
496                        id: CompactString::new("call_1"),
497                        name: CompactString::new("get_weather"),
498                        arguments: json!({ "city": "Shanghai" }),
499                    }],
500                    token_count: None,
501                },
502                Message::tool(vec![ContentPart::ToolResult {
503                    call_id: CompactString::new("call_1"),
504                    output: "sunny".into(),
505                    is_error: false,
506                }]),
507            ],
508            state_turn: None,
509            frozen_prefix_len: None,
510        };
511
512        assert_eq!(
513            context_to_openai(&context),
514            vec![
515                json!({ "role": "system", "content": "system rules" }),
516                json!({ "role": "user", "content": "What is the weather?" }),
517                json!({
518                    "role": "assistant",
519                    "content": "I'll check.",
520                    "tool_calls": [{
521                        "id": "call_1",
522                        "type": "function",
523                        "function": {
524                            "name": "get_weather",
525                            "arguments": "{\"city\":\"Shanghai\"}",
526                        }
527                    }],
528                }),
529                json!({ "role": "tool", "tool_call_id": "call_1", "content": "sunny" }),
530            ]
531        );
532    }
533
534    #[test]
535    fn state_turn_appended_as_latest_turn() {
536        let context = RenderedContext {
537            system_text: "sys".into(),
538            system_stable: "sys".into(),
539            system_knowledge: String::new(),
540            turns: vec![Message::user("history msg")],
541            state_turn: Some(Message::user("[TASK STATE] goal: g\n\nProceed.")),
542            frozen_prefix_len: None,
543        };
544        let msgs = context_to_openai(&context);
545        // [system][history][state] — history is the stable cacheable prefix, state last.
546        assert_eq!(msgs[0]["role"], "system");
547        assert_eq!(msgs[1]["content"], "history msg");
548        assert_eq!(msgs[2]["role"], "user");
549        assert!(msgs[2]["content"].as_str().unwrap().contains("[TASK STATE]"));
550    }
551}