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