Skip to main content

deepstrike_sdk/providers/
openai.rs

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