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::{json, Value};
7
8use super::{LLMProvider, RuntimePolicy, StreamEvent};
9use crate::{Error, Result};
10
11fn 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 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 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!(message
171 .tool_calls
172 .iter()
173 .map(|tc| json!({
174 "id": tc.id.as_str(),
175 "type": "function",
176 "function": {
177 "name": tc.name.as_str(),
178 "arguments": tc.arguments.to_string(),
179 }
180 }))
181 .collect::<Vec<_>>());
182 }
183 messages.push(next);
184 }
185 messages
186}
187
188#[async_trait]
189impl LLMProvider for OpenAIProvider {
190 fn runtime_policy(&self) -> RuntimePolicy {
191 match self.model.as_str() {
192 "gpt-4o" => RuntimePolicy {
194 max_turns: Some(25),
195 timeout_ms: None,
196 },
197 "gpt-4o-mini" => RuntimePolicy {
198 max_turns: Some(15),
199 timeout_ms: None,
200 },
201 "gpt-4.1" => RuntimePolicy {
202 max_turns: Some(35),
203 timeout_ms: None,
204 },
205 "gpt-4.1-mini" => RuntimePolicy {
206 max_turns: Some(20),
207 timeout_ms: None,
208 },
209 "gpt-4.1-nano" => RuntimePolicy {
210 max_turns: Some(15),
211 timeout_ms: None,
212 },
213 "gpt-5" => RuntimePolicy {
214 max_turns: Some(50),
215 timeout_ms: None,
216 },
217 "gpt-5-mini" => RuntimePolicy {
218 max_turns: Some(25),
219 timeout_ms: None,
220 },
221 "o3" | "o3-mini" | "o4-mini" => RuntimePolicy {
222 max_turns: Some(50),
223 timeout_ms: None,
224 },
225 "deepseek-chat" | "deepseek-v4-flash" => RuntimePolicy {
227 max_turns: Some(25),
228 timeout_ms: None,
229 },
230 "deepseek-reasoner" | "deepseek-r1" => RuntimePolicy {
231 max_turns: Some(50),
232 timeout_ms: None,
233 },
234 "deepseek-v4-pro" => RuntimePolicy {
235 max_turns: Some(35),
236 timeout_ms: None,
237 },
238 "qwen-max" => RuntimePolicy {
240 max_turns: Some(25),
241 timeout_ms: None,
242 },
243 "qwen-plus" => RuntimePolicy {
244 max_turns: Some(20),
245 timeout_ms: None,
246 },
247 "qwq-plus" | "qwq-32b" => RuntimePolicy {
248 max_turns: Some(40),
249 timeout_ms: None,
250 },
251 "qwen3-235b-a22b" => RuntimePolicy {
252 max_turns: Some(35),
253 timeout_ms: None,
254 },
255 "qwen3-72b" => RuntimePolicy {
256 max_turns: Some(25),
257 timeout_ms: None,
258 },
259 "qwen3-32b" | "qwen3-14b" | "qwen3-8b" => RuntimePolicy {
260 max_turns: Some(20),
261 timeout_ms: None,
262 },
263 "moonshot-v1-8k" => RuntimePolicy {
265 max_turns: Some(15),
266 timeout_ms: None,
267 },
268 "moonshot-v1-32k" => RuntimePolicy {
269 max_turns: Some(20),
270 timeout_ms: None,
271 },
272 "moonshot-v1-128k" | "kimi-k2.5" => RuntimePolicy {
273 max_turns: Some(30),
274 timeout_ms: None,
275 },
276 "kimi-k2.6" => RuntimePolicy {
277 max_turns: Some(35),
278 timeout_ms: None,
279 },
280 "MiniMax-M2.7" => RuntimePolicy {
282 max_turns: Some(35),
283 timeout_ms: None,
284 },
285 "MiniMax-M2.5" | "MiniMax-M1" => RuntimePolicy {
286 max_turns: Some(25),
287 timeout_ms: None,
288 },
289 "MiniMax-Text-01" => RuntimePolicy {
290 max_turns: Some(20),
291 timeout_ms: None,
292 },
293 m if m.starts_with("deepseek-r1") => RuntimePolicy {
295 max_turns: Some(40),
296 timeout_ms: None,
297 },
298 m if m.starts_with("qwq") => RuntimePolicy {
299 max_turns: Some(35),
300 timeout_ms: None,
301 },
302 m if m.starts_with("llama3") => RuntimePolicy {
303 max_turns: Some(20),
304 timeout_ms: None,
305 },
306 m if m.starts_with("mistral") || m.starts_with("gemma") || m.starts_with("phi") => {
307 RuntimePolicy {
308 max_turns: Some(20),
309 timeout_ms: None,
310 }
311 }
312 _ => RuntimePolicy {
313 max_turns: Some(20),
314 timeout_ms: None,
315 },
316 }
317 }
318
319 async fn stream(
320 &self,
321 context: &RenderedContext,
322 tools: &[ToolSchema],
323 extensions: Option<&Value>,
324 _state: Option<&super::ProviderRunState>,
325 ) -> Result<Box<dyn Stream<Item = Result<StreamEvent>> + Send + Unpin>> {
326 let mut body = json!({
327 "model": self.model,
328 "messages": context_to_openai(context),
329 "stream": true,
330 "stream_options": { "include_usage": true },
331 });
332 if !tools.is_empty() {
333 body["tools"] = json!(tools.iter().map(|t| json!({
334 "type": "function",
335 "function": { "name": t.name.as_str(), "description": t.description, "parameters": t.parameters }
336 })).collect::<Vec<_>>());
337 }
338 let mut expose_reasoning = false;
339 if let Some(ext) = extensions {
340 if let Some(obj) = ext.as_object() {
341 for (k, v) in obj {
342 if k == "expose_reasoning" {
343 expose_reasoning = v.as_bool().unwrap_or(false);
344 } else {
345 body[k] = v.clone();
346 }
347 }
348 }
349 }
350
351 let resp = self
352 .client
353 .post(format!("{}/chat/completions", self.base_url))
354 .header("Authorization", format!("Bearer {}", self.api_key))
355 .header("content-type", "application/json")
356 .body(body.to_string())
357 .send()
358 .await
359 .map_err(|e| Error::Provider(e.to_string()))?;
360
361 if !resp.status().is_success() {
362 let status = resp.status();
363 let text = resp.text().await.unwrap_or_default();
364 return Err(Error::Provider(format!("OpenAI {status}: {text}")));
365 }
366
367 let byte_stream = resp.bytes_stream();
368 let stream = parse_openai_sse(byte_stream, expose_reasoning);
369 Ok(Box::new(Box::pin(stream)))
370 }
371}
372
373fn parse_openai_sse(
374 byte_stream: impl Stream<Item = reqwest::Result<bytes::Bytes>> + Send + 'static,
375 expose_reasoning: bool,
376) -> impl Stream<Item = Result<StreamEvent>> + Send {
377 let tool_accum: std::collections::HashMap<usize, (String, String, String)> =
378 std::collections::HashMap::new();
379
380 futures::stream::unfold(
381 (
385 Box::pin(byte_stream),
386 String::new(),
387 tool_accum,
388 false,
389 None::<String>,
390 ),
391 move |(mut stream, mut buf, mut tool_accum, mut flushed, mut finish_reason)| async move {
392 if flushed {
393 return None;
394 }
395 loop {
396 if let Some(pos) = buf.find('\n') {
397 let line = buf[..pos].trim().to_string();
398 buf = buf[pos + 1..].to_string();
399
400 if !line.starts_with("data: ") {
401 continue;
402 }
403 let data = &line[6..];
404 if data == "[DONE]" {
405 if let Some((_, (id, name, args_buf))) = tool_accum.iter().next() {
407 let arguments: Value = serde_json::from_str(args_buf)
408 .unwrap_or(Value::Object(Default::default()));
409 let evt = StreamEvent::ToolCall {
410 id: id.clone(),
411 name: name.clone(),
412 arguments,
413 };
414 flushed = true;
415 return Some((
416 Ok(evt),
417 (stream, buf, tool_accum, flushed, finish_reason),
418 ));
419 }
420 return None;
421 }
422
423 let Ok(chunk) = serde_json::from_str::<Value>(data) else {
424 continue;
425 };
426 if let Some(fr) = chunk["choices"][0]["finish_reason"].as_str() {
429 finish_reason = Some(fr.to_string());
430 }
431 if let Some(total) = chunk["usage"]["total_tokens"].as_u64() {
432 let usage = &chunk["usage"];
433 return Some((
434 Ok(StreamEvent::Usage {
435 total_tokens: total as u32,
436 input_tokens: usage["prompt_tokens"].as_u64().unwrap_or(0) as u32,
437 output_tokens: usage["completion_tokens"].as_u64().unwrap_or(0)
438 as u32,
439 cache_read_input_tokens: openai_cached_prompt_tokens(usage),
440 cache_creation_input_tokens: 0,
441 cache_read_input_tokens_by_slot: None,
443 stop_reason: finish_reason.clone(),
446 }),
447 (stream, buf, tool_accum, flushed, finish_reason),
448 ));
449 }
450 let delta = &chunk["choices"][0]["delta"];
451 if expose_reasoning {
452 if let Some(reasoning) = delta["reasoning_content"].as_str() {
453 if !reasoning.is_empty() {
454 return Some((
455 Ok(StreamEvent::ThinkingDelta {
456 delta: reasoning.to_string(),
457 }),
458 (stream, buf, tool_accum, flushed, finish_reason),
459 ));
460 }
461 }
462 }
463 if let Some(content) = delta["content"].as_str() {
464 if !content.is_empty() {
465 return Some((
466 Ok(StreamEvent::TextDelta {
467 delta: content.to_string(),
468 }),
469 (stream, buf, tool_accum, flushed, finish_reason),
470 ));
471 }
472 }
473 if let Some(tcs) = delta["tool_calls"].as_array() {
474 for tc in tcs {
475 let idx = tc["index"].as_u64().unwrap_or(0) as usize;
476 let entry = tool_accum.entry(idx).or_insert_with(|| {
477 (
478 tc["id"].as_str().unwrap_or("").to_string(),
479 tc["function"]["name"].as_str().unwrap_or("").to_string(),
480 String::new(),
481 )
482 });
483 entry
484 .2
485 .push_str(tc["function"]["arguments"].as_str().unwrap_or(""));
486 }
487 }
488 continue;
489 }
490
491 match stream.next().await {
492 Some(Ok(chunk)) => buf.push_str(&String::from_utf8_lossy(&chunk)),
493 Some(Err(e)) => {
494 return Some((
495 Err(Error::Provider(e.to_string())),
496 (stream, buf, tool_accum, flushed, finish_reason),
497 ));
498 }
499 None => return None,
500 }
501 }
502 },
503 )
504}
505
506#[cfg(test)]
507mod tests {
508 use super::*;
509 use compact_str::CompactString;
510 use deepstrike_core::types::message::{ContentPart, Message, ToolCall};
511
512 #[test]
513 fn context_replays_tool_calls_and_results_natively() {
514 let context = RenderedContext {
515 system_text: "system rules".into(),
516 system_stable: "system rules".into(),
517 system_knowledge: String::new(),
518 budget_overflow: None,
519 turns: vec![
520 Message::user("What is the weather?"),
521 Message {
522 role: Role::Assistant,
523 content: Content::Text("I'll check.".into()),
524 tool_calls: vec![ToolCall {
525 id: CompactString::new("call_1"),
526 name: CompactString::new("get_weather"),
527 arguments: json!({ "city": "Shanghai" }),
528 }],
529 token_count: None,
530 },
531 Message::tool(vec![ContentPart::ToolResult {
532 call_id: CompactString::new("call_1"),
533 output: "sunny".into(),
534 is_error: false,
535 }]),
536 ],
537 state_turn: None,
538 frozen_prefix_len: None,
539 };
540
541 assert_eq!(
542 context_to_openai(&context),
543 vec![
544 json!({ "role": "system", "content": "system rules" }),
545 json!({ "role": "user", "content": "What is the weather?" }),
546 json!({
547 "role": "assistant",
548 "content": "I'll check.",
549 "tool_calls": [{
550 "id": "call_1",
551 "type": "function",
552 "function": {
553 "name": "get_weather",
554 "arguments": "{\"city\":\"Shanghai\"}",
555 }
556 }],
557 }),
558 json!({ "role": "tool", "tool_call_id": "call_1", "content": "sunny" }),
559 ]
560 );
561 }
562
563 #[test]
564 fn state_turn_appended_as_latest_turn() {
565 let context = RenderedContext {
566 system_text: "sys".into(),
567 system_stable: "sys".into(),
568 system_knowledge: String::new(),
569 turns: vec![Message::user("history msg")],
570 state_turn: Some(Message::user("[TASK STATE] goal: g\n\nProceed.")),
571 frozen_prefix_len: None,
572 budget_overflow: None,
573 };
574 let msgs = context_to_openai(&context);
575 assert_eq!(msgs[0]["role"], "system");
577 assert_eq!(msgs[1]["content"], "history msg");
578 assert_eq!(msgs[2]["role"], "user");
579 assert!(msgs[2]["content"]
580 .as_str()
581 .unwrap()
582 .contains("[TASK STATE]"));
583 }
584}