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