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