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 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 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 "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-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-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 "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-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 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, None::<String>),
378 move |(mut stream, mut buf, mut tool_accum, mut flushed, mut finish_reason)| async move {
379 if flushed {
380 return None;
381 }
382 loop {
383 if let Some(pos) = buf.find('\n') {
384 let line = buf[..pos].trim().to_string();
385 buf = buf[pos + 1..].to_string();
386
387 if !line.starts_with("data: ") {
388 continue;
389 }
390 let data = &line[6..];
391 if data == "[DONE]" {
392 if let Some((_, (id, name, args_buf))) = tool_accum.iter().next() {
394 let arguments: Value = serde_json::from_str(args_buf)
395 .unwrap_or(Value::Object(Default::default()));
396 let evt = StreamEvent::ToolCall {
397 id: id.clone(),
398 name: name.clone(),
399 arguments,
400 };
401 flushed = true;
402 return Some((Ok(evt), (stream, buf, tool_accum, flushed, finish_reason)));
403 }
404 return None;
405 }
406
407 let Ok(chunk) = serde_json::from_str::<Value>(data) else {
408 continue;
409 };
410 if let Some(fr) = chunk["choices"][0]["finish_reason"].as_str() {
413 finish_reason = Some(fr.to_string());
414 }
415 if let Some(total) = chunk["usage"]["total_tokens"].as_u64() {
416 let usage = &chunk["usage"];
417 return Some((
418 Ok(StreamEvent::Usage {
419 total_tokens: total as u32,
420 input_tokens: usage["prompt_tokens"].as_u64().unwrap_or(0) as u32,
421 output_tokens: usage["completion_tokens"].as_u64().unwrap_or(0) as u32,
422 cache_read_input_tokens: openai_cached_prompt_tokens(usage),
423 cache_creation_input_tokens: 0,
424 cache_read_input_tokens_by_slot: None,
426 stop_reason: finish_reason.clone(),
429 }),
430 (stream, buf, tool_accum, flushed, finish_reason),
431 ));
432 }
433 let delta = &chunk["choices"][0]["delta"];
434 if expose_reasoning {
435 if let Some(reasoning) = delta["reasoning_content"].as_str() {
436 if !reasoning.is_empty() {
437 return Some((
438 Ok(StreamEvent::ThinkingDelta {
439 delta: reasoning.to_string(),
440 }),
441 (stream, buf, tool_accum, flushed, finish_reason),
442 ));
443 }
444 }
445 }
446 if let Some(content) = delta["content"].as_str() {
447 if !content.is_empty() {
448 return Some((
449 Ok(StreamEvent::TextDelta {
450 delta: content.to_string(),
451 }),
452 (stream, buf, tool_accum, flushed, finish_reason),
453 ));
454 }
455 }
456 if let Some(tcs) = delta["tool_calls"].as_array() {
457 for tc in tcs {
458 let idx = tc["index"].as_u64().unwrap_or(0) as usize;
459 let entry = tool_accum.entry(idx).or_insert_with(|| {
460 (
461 tc["id"].as_str().unwrap_or("").to_string(),
462 tc["function"]["name"].as_str().unwrap_or("").to_string(),
463 String::new(),
464 )
465 });
466 entry
467 .2
468 .push_str(tc["function"]["arguments"].as_str().unwrap_or(""));
469 }
470 }
471 continue;
472 }
473
474 match stream.next().await {
475 Some(Ok(chunk)) => buf.push_str(&String::from_utf8_lossy(&chunk)),
476 Some(Err(e)) => {
477 return Some((
478 Err(Error::Provider(e.to_string())),
479 (stream, buf, tool_accum, flushed, finish_reason),
480 ));
481 }
482 None => return None,
483 }
484 }
485 },
486 )
487}
488
489#[cfg(test)]
490mod tests {
491 use super::*;
492 use compact_str::CompactString;
493 use deepstrike_core::types::message::{ContentPart, Message, ToolCall};
494
495 #[test]
496 fn context_replays_tool_calls_and_results_natively() {
497 let context = RenderedContext {
498 system_text: "system rules".into(),
499 system_stable: "system rules".into(),
500 system_knowledge: String::new(),
501 turns: vec![
502 Message::user("What is the weather?"),
503 Message {
504 role: Role::Assistant,
505 content: Content::Text("I'll check.".into()),
506 tool_calls: vec![ToolCall {
507 id: CompactString::new("call_1"),
508 name: CompactString::new("get_weather"),
509 arguments: json!({ "city": "Shanghai" }),
510 }],
511 token_count: None,
512 },
513 Message::tool(vec![ContentPart::ToolResult {
514 call_id: CompactString::new("call_1"),
515 output: "sunny".into(),
516 is_error: false,
517 }]),
518 ],
519 state_turn: None,
520 frozen_prefix_len: None,
521 };
522
523 assert_eq!(
524 context_to_openai(&context),
525 vec![
526 json!({ "role": "system", "content": "system rules" }),
527 json!({ "role": "user", "content": "What is the weather?" }),
528 json!({
529 "role": "assistant",
530 "content": "I'll check.",
531 "tool_calls": [{
532 "id": "call_1",
533 "type": "function",
534 "function": {
535 "name": "get_weather",
536 "arguments": "{\"city\":\"Shanghai\"}",
537 }
538 }],
539 }),
540 json!({ "role": "tool", "tool_call_id": "call_1", "content": "sunny" }),
541 ]
542 );
543 }
544
545 #[test]
546 fn state_turn_appended_as_latest_turn() {
547 let context = RenderedContext {
548 system_text: "sys".into(),
549 system_stable: "sys".into(),
550 system_knowledge: String::new(),
551 turns: vec![Message::user("history msg")],
552 state_turn: Some(Message::user("[TASK STATE] goal: g\n\nProceed.")),
553 frozen_prefix_len: None,
554 };
555 let msgs = context_to_openai(&context);
556 assert_eq!(msgs[0]["role"], "system");
558 assert_eq!(msgs[1]["content"], "history msg");
559 assert_eq!(msgs[2]["role"], "user");
560 assert!(msgs[2]["content"].as_str().unwrap().contains("[TASK STATE]"));
561 }
562}