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
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!(
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 "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-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-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 "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-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 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 (
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 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 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 cache_read_input_tokens_by_slot: None,
445 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 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}