1use super::LlmProvider;
3mod utils;
4use crate::{
5 providers::{FinishReason, LLMRequest, ProviderError, ProviderStreamChunk, TokenUsage},
6 shared::ToolDefinition,
7};
8use async_trait::async_trait;
9use futures_util::{StreamExt, stream::BoxStream};
10use reqwest::{self, StatusCode, header::HeaderMap};
11use serde::{Deserialize, Serialize};
12use serde_json::{Map, Value};
13use std::time::Duration;
14use utils::transform_messages;
15
16pub struct OpenAiCompatibleProvider {
17 pub api_key: String,
18 pub base_url: String,
19 client: reqwest::Client,
20}
21
22impl OpenAiCompatibleProvider {
23 pub fn new(api_key: impl Into<String>, base_url: impl Into<String>) -> Self {
24 Self::with_timeout(api_key, base_url, Duration::from_secs(120))
25 }
26
27 pub fn with_timeout(
28 api_key: impl Into<String>,
29 base_url: impl Into<String>,
30 timeout: Duration,
31 ) -> Self {
32 let client = reqwest::Client::builder()
33 .connect_timeout(Duration::from_secs(10))
34 .timeout(timeout)
35 .build()
36 .expect("reqwest client configuration should be valid");
37 Self {
38 api_key: api_key.into(),
39 base_url: base_url.into(),
40 client,
41 }
42 }
43}
44
45#[derive(Serialize)]
46struct ChatReq {
47 model: String,
48 messages: Vec<ResponseItem>,
49 #[serde(skip_serializing_if = "Vec::is_empty")]
50 tools: Vec<serde_json::Value>,
51 #[serde(skip_serializing_if = "Option::is_none")]
52 temperature: Option<f32>,
53 #[serde(skip_serializing_if = "Option::is_none")]
54 max_tokens: Option<u32>,
55 stream: bool,
56}
57
58#[derive(Serialize, Deserialize, Clone, Debug)]
59pub struct OpenAiToolCall {
60 pub index: usize,
61 pub id: Option<String>,
62 pub r#type: Option<String>,
63 pub function: Option<OpenAiFunc>,
64}
65
66#[derive(Serialize, Deserialize, Clone, Debug)]
67pub struct OpenAiFunc {
68 pub name: Option<String>,
69 pub arguments: Option<String>,
70}
71
72#[derive(Serialize, Deserialize, Clone, Debug)]
73pub struct ResponseItem {
74 pub role: String,
75 pub content: Option<String>,
76 #[serde(skip_serializing_if = "Option::is_none")]
77 pub tool_calls: Option<Vec<OpenAiToolCall>>,
78 #[serde(skip_serializing_if = "Option::is_none")]
79 pub tool_call_id: Option<String>,
80}
81#[derive(Deserialize, Debug)]
82struct ChatStreamChunk {
83 choices: Vec<ChatStreamChoice>,
84 usage: Option<OpenAiUsage>,
85}
86
87#[derive(Deserialize, Debug)]
88struct ChatStreamChoice {
89 delta: ChatStreamDelta,
90 finish_reason: Option<String>,
91}
92
93#[derive(Deserialize, Debug)]
94struct ChatStreamDelta {
95 content: Option<String>,
96 #[serde(default, alias = "reasoning_content", alias = "thinking")]
97 thinking: Option<String>,
98 tool_calls: Option<Vec<StreamToolCall>>,
99}
100
101#[derive(Deserialize, Debug)]
102struct StreamToolCall {
103 index: usize,
104 id: Option<String>,
105 function: Option<StreamFunc>,
106}
107
108#[derive(Deserialize, Debug)]
109struct StreamFunc {
110 name: Option<String>,
111 arguments: Option<String>,
112}
113
114#[derive(Deserialize, Debug)]
115struct OpenAiUsage {
116 prompt_tokens: Option<u32>,
117 completion_tokens: Option<u32>,
118 total_tokens: Option<u32>,
119}
120
121impl From<OpenAiUsage> for TokenUsage {
122 fn from(value: OpenAiUsage) -> Self {
123 Self {
124 input_tokens: value.prompt_tokens,
125 output_tokens: value.completion_tokens,
126 total_tokens: value.total_tokens,
127 }
128 }
129}
130
131fn map_finish_reason(reason: &str) -> FinishReason {
132 match reason {
133 "stop" => FinishReason::Stop,
134 "tool_calls" | "function_call" => FinishReason::ToolCalls,
135 "length" => FinishReason::Length,
136 "content_filter" => FinishReason::ContentFilter,
137 other => FinishReason::Unknown(other.to_string()),
138 }
139}
140
141#[async_trait]
142impl LlmProvider for OpenAiCompatibleProvider {
143 fn name(&self) -> &str {
144 "openai_compatible"
145 }
146
147 fn request_snapshot(&self, req: &LLMRequest) -> Value {
148 serde_json::to_value(build_chat_payload(req.clone())).unwrap_or(Value::Null)
149 }
150
151 async fn chat_completion(
152 &self,
153 req: LLMRequest,
154 ) -> Result<BoxStream<'static, Result<ProviderStreamChunk, ProviderError>>, ProviderError> {
155 let payload = build_chat_payload(req);
156
157 let res = self
158 .client
159 .post(format!(
160 "{}/chat/completions",
161 self.base_url.trim_end_matches('/')
162 ))
163 .header("Authorization", format!("Bearer {}", self.api_key))
164 .json(&payload)
165 .send()
166 .await
167 .map_err(map_transport_error)?;
168
169 let status = res.status();
170
171 if !status.is_success() {
172 let headers = res.headers().clone();
173 let err_text = res.text().await.unwrap_or_default();
174 return Err(map_status_error(status, &headers, err_text));
175 }
176 let mut res = res;
177
178 let output_stream = async_stream::stream! {
179 let mut emitted_done = false;
180 let mut pending_usage: Option<TokenUsage> = None;
181 let mut byte_buffer = Vec::<u8>::new();
182 loop {
183 let chunk = match res.chunk().await {
184 Ok(Some(chunk)) => chunk,
185 Ok(None) => break,
187 Err(e) => {
189 yield Err(map_transport_error(e));
190 return;
191 }
192 };
193 let lines = match take_sse_lines(&mut byte_buffer, &chunk) {
194 Ok(lines) => lines,
195 Err(e) => {
196 yield Err(e);
197 return;
198 }
199 };
200
201 for line in lines {
202 let line = line.trim();
203 if line.is_empty() { continue; }
204 if line == "data: [DONE]" {
205 if !emitted_done {
206 emitted_done = true;
207 yield Ok(ProviderStreamChunk::Done {
208 finish_reason: FinishReason::Stop,
209 usage: pending_usage.take(),
210 });
211 }
212 break;
213 }
214
215 if let Some(raw_json) = line.strip_prefix("data: ") {
216 match serde_json::from_str::<ChatStreamChunk>(raw_json) {
217 Ok(chunk_data) => {
218 let usage = chunk_data.usage.map(Into::into);
219 if usage.is_some() {
220 pending_usage = usage.clone();
221 }
222 if let Some(choice) = chunk_data.choices.into_iter().next() {
223 let delta = choice.delta;
224 let finish_reason = choice.finish_reason;
225
226 if let Some(thinking) = delta.thinking {
227 if !thinking.is_empty() {
228 yield Ok(ProviderStreamChunk::ThinkingDelta(thinking));
229 }
230 }
231
232 if let Some(text) = delta.content {
233 if !text.is_empty() {
234 yield Ok(ProviderStreamChunk::ContentDelta(text));
235 }
236 }
237
238 if let Some(tool_calls) = delta.tool_calls {
239 for tool in tool_calls {
240 let (name, args) = match tool.function {
241 Some(f) => (f.name, f.arguments.unwrap_or_default()),
242 None => (None, String::new()),
243 };
244
245 yield Ok(ProviderStreamChunk::ToolCallDelta {
246 index: tool.index,
247 id: tool.id,
248 name,
249 arguments_delta: args,
250 });
251 }
252 }
253
254 if let Some(reason) = finish_reason {
255 emitted_done = true;
256 yield Ok(ProviderStreamChunk::Done {
257 finish_reason: map_finish_reason(&reason),
258 usage,
259 });
260 }
261 }
262 }
263 Err(e) => {
264 yield Err(ProviderError::transient(format!(
265 "failed to parse SSE JSON line: {e}"
266 )));
267 return;
268 }
269 }
270 }
271 }
272 }
273
274 if !emitted_done {
277 yield Err(ProviderError::transient("Stream ended before a finish reason was received"));
278 }
279 };
280
281 Ok(output_stream.boxed())
282 }
283}
284
285fn build_chat_payload(req: LLMRequest) -> ChatReq {
286 let mut messages = req.messages;
287 if let Some(system) = req.system {
288 messages.insert(
289 0,
290 crate::shared::Message::text(crate::shared::Role::System, system),
291 );
292 }
293 let response_items: Vec<ResponseItem> = transform_messages(messages);
294 let tools_schema: Vec<serde_json::Value> = req.tools.iter().map(build_tool_schema).collect();
295
296 ChatReq {
297 model: req.model,
298 messages: response_items,
299 tools: tools_schema,
300 temperature: req.temperature,
301 max_tokens: req.max_tokens,
302 stream: true,
303 }
304}
305
306fn build_tool_schema(tool: &ToolDefinition) -> Value {
307 serde_json::json!({
308 "type": "function",
309 "function": {
310 "name": tool.name,
311 "description": tool.desc,
312 "parameters": normalize_openai_tool_parameters(tool.arguments.clone())
313 }
314 })
315}
316
317fn normalize_openai_tool_parameters(mut schema: Value) -> Value {
318 let definitions = schema
319 .get("definitions")
320 .or_else(|| schema.get("$defs"))
321 .cloned()
322 .unwrap_or_else(|| Value::Object(Map::new()));
323 inline_local_refs(&mut schema, &definitions);
324 simplify_openai_schema(&mut schema);
325 schema
326}
327
328fn inline_local_refs(value: &mut Value, definitions: &Value) {
329 match value {
330 Value::Object(map) => {
331 if let Some(ref_value) = map.get("$ref").and_then(Value::as_str) {
332 if let Some(mut resolved) = resolve_local_ref(ref_value, definitions) {
333 inline_local_refs(&mut resolved, definitions);
334 if let Value::Object(resolved_map) = &mut resolved {
335 for (key, value) in map.iter() {
336 if key != "$ref" {
337 resolved_map
338 .entry(key.clone())
339 .or_insert_with(|| value.clone());
340 }
341 }
342 }
343 *value = resolved;
344 return;
345 }
346 }
347
348 for child in map.values_mut() {
349 inline_local_refs(child, definitions);
350 }
351 }
352 Value::Array(items) => {
353 for item in items {
354 inline_local_refs(item, definitions);
355 }
356 }
357 _ => {}
358 }
359}
360
361fn resolve_local_ref(ref_value: &str, definitions: &Value) -> Option<Value> {
362 let key = ref_value
363 .strip_prefix("#/definitions/")
364 .or_else(|| ref_value.strip_prefix("#/$defs/"))?;
365 definitions.get(key).cloned()
366}
367
368fn simplify_openai_schema(value: &mut Value) {
369 match value {
370 Value::Object(map) => {
371 for child in map.values_mut() {
372 simplify_openai_schema(child);
373 }
374
375 if let Some(compressed) = compress_enum_union(map) {
376 *value = compressed;
377 return;
378 }
379
380 map.remove("$schema");
381 map.remove("$defs");
382 map.remove("definitions");
383 map.remove("title");
384 map.remove("format");
385
386 if is_object_schema(map) && !map.contains_key("additionalProperties") {
387 map.insert("additionalProperties".to_string(), Value::Bool(false));
388 }
389 }
390 Value::Array(items) => {
391 for item in items {
392 simplify_openai_schema(item);
393 }
394 }
395 _ => {}
396 }
397}
398
399fn compress_enum_union(map: &Map<String, Value>) -> Option<Value> {
400 let variants = map
401 .get("oneOf")
402 .or_else(|| map.get("anyOf"))
403 .and_then(Value::as_array)?;
404
405 let mut enum_values = Vec::<Value>::new();
406 let mut has_null = false;
407 for variant in variants {
408 let variant = variant.as_object()?;
409 if is_null_schema(variant) {
410 has_null = true;
411 continue;
412 }
413
414 let values = variant.get("enum")?.as_array()?;
415 if values.is_empty() || !values.iter().all(|value| value.is_string()) {
416 return None;
417 }
418 enum_values.extend(values.iter().cloned());
419 }
420 if enum_values.is_empty() {
421 return None;
422 }
423 if has_null {
424 enum_values.push(Value::Null);
425 }
426
427 let mut compressed = Map::new();
428 if let Some(description) = map.get("description") {
429 compressed.insert("description".to_string(), description.clone());
430 }
431 compressed.insert(
432 "type".to_string(),
433 if has_null {
434 Value::Array(vec![
435 Value::String("string".to_string()),
436 Value::String("null".to_string()),
437 ])
438 } else {
439 Value::String("string".to_string())
440 },
441 );
442 compressed.insert("enum".to_string(), Value::Array(enum_values));
443 Some(Value::Object(compressed))
444}
445
446fn is_null_schema(map: &Map<String, Value>) -> bool {
447 matches!(map.get("type").and_then(Value::as_str), Some("null"))
448 || matches!(
449 map.get("enum").and_then(Value::as_array),
450 Some(values) if values.len() == 1 && values[0].is_null()
451 )
452}
453
454fn is_object_schema(map: &Map<String, Value>) -> bool {
455 matches!(map.get("type").and_then(Value::as_str), Some("object"))
456 || map.contains_key("properties")
457}
458
459fn map_transport_error(error: reqwest::Error) -> ProviderError {
460 ProviderError::transient(format!("Network transport failed: {}", error))
461}
462
463fn map_status_error(status: StatusCode, headers: &HeaderMap, body: String) -> ProviderError {
464 let message = format!("OpenAI Compatible API error ({}): {}", status, body);
465 if status == StatusCode::TOO_MANY_REQUESTS {
466 return ProviderError::rate_limited(message, retry_after_secs(headers));
467 }
468 if status.is_client_error() {
469 return ProviderError::permanent(message);
470 }
471 ProviderError::transient(message)
472}
473
474fn retry_after_secs(headers: &HeaderMap) -> Option<u64> {
475 headers
476 .get(reqwest::header::RETRY_AFTER)
477 .and_then(|value| value.to_str().ok())
478 .and_then(|value| value.parse::<u64>().ok())
479}
480
481fn take_sse_lines(buffer: &mut Vec<u8>, chunk: &[u8]) -> Result<Vec<String>, ProviderError> {
482 buffer.extend_from_slice(chunk);
483 let mut lines = Vec::new();
484 while let Some(pos) = buffer.iter().position(|byte| *byte == b'\n') {
485 let mut line = buffer.drain(..=pos).collect::<Vec<_>>();
486 if line.last() == Some(&b'\n') {
487 line.pop();
488 }
489 if line.last() == Some(&b'\r') {
490 line.pop();
491 }
492 lines
493 .push(String::from_utf8(line).map_err(|e| {
494 ProviderError::transient(format!("invalid UTF-8 in SSE line: {e}"))
495 })?);
496 }
497 Ok(lines)
498}
499
500#[cfg(test)]
501mod tests {
502 use super::*;
503 use crate::providers::ProviderStreamChunk;
504 use crate::shared::{Message, Role, ToolDefinition};
505 use futures_util::StreamExt;
506 use std::env;
507 use std::io::Write;
508
509 #[tokio::test]
510 async fn test_openai_compatible_stream_completion() {
511 let Ok(api_key) = env::var("COMPATIBLE_API_KEY") else {
513 eprintln!("跳过: 未设置 COMPATIBLE_API_KEY");
514 return;
515 };
516 let base_url = env::var("COMPATIBLE_BASE_URL")
517 .unwrap_or_else(|_| "https://api.deepseek.com/v1".to_string());
518
519 let model_name =
520 env::var("COMPATIBLE_MODEL").unwrap_or_else(|_| "deepseek-v4-flash".to_string());
521
522 let provider = OpenAiCompatibleProvider::new(api_key, base_url);
523
524 let req = LLMRequest {
525 model: model_name,
526 system: Some("你是 kb,一个文学家".to_string()),
527 messages: vec![Message::text(
528 Role::User,
529 "你是谁?用三个字回答。并给我写一首中文诗歌",
530 )],
531 tools: vec![],
532 temperature: Some(0.3),
533 max_tokens: None,
534 };
535
536 let stream_result = provider.chat_completion(req).await;
537
538 match stream_result {
539 Ok(mut stream) => {
540 println!("\n=========== [Compatible Stream 开启流式接收] ===========");
541
542 let mut full_content = String::new();
543
544 while let Some(chunk_res) = stream.next().await {
545 match chunk_res {
546 Ok(ProviderStreamChunk::ContentDelta(text)) => {
547 print!("{}", text);
548 std::io::stdout().flush().ok();
549 full_content.push_str(&text);
550 }
551 Ok(ProviderStreamChunk::ThinkingDelta(_)) => {}
552 Ok(ProviderStreamChunk::ToolCallDelta { .. }) => {}
553 Ok(ProviderStreamChunk::Done { .. }) => {}
554 Err(stream_err) => {
555 panic!("流式传输中途发生网络断裂或解析崩溃: {}", stream_err);
556 }
557 }
558 }
559
560 println!("\n=====================================================\n");
561
562 assert!(!full_content.is_empty(), "模型最终回复的内容不能为空!");
563 }
564 Err(e) => {
565 panic!("stream failed: {}", e);
566 }
567 }
568 }
569
570 #[test]
571 fn sse_line_decoder_preserves_utf8_split_across_chunks() {
572 let line =
573 "data: {\"choices\":[{\"delta\":{\"content\":\"你好\"},\"finish_reason\":null}]}\n";
574 let bytes = line.as_bytes();
575 let split = bytes
576 .windows(3)
577 .position(|window| window == "你".as_bytes())
578 .expect("test line contains the target character")
579 + 1;
580
581 let mut buffer = Vec::new();
582 let first = take_sse_lines(&mut buffer, &bytes[..split]).unwrap();
583 assert!(first.is_empty());
584 let second = take_sse_lines(&mut buffer, &bytes[split..]).unwrap();
585
586 assert_eq!(second, vec![line.trim_end().to_string()]);
587 }
588
589 #[test]
590 fn openai_tool_schema_is_flattened_for_wire_payload() {
591 let tool = ToolDefinition {
592 name: "write_file".to_string(),
593 desc: "edit files".to_string(),
594 arguments: serde_json::json!({
595 "$schema": "http://json-schema.org/draft-07/schema#",
596 "title": "WriteFileArgs",
597 "type": "object",
598 "definitions": {
599 "WriteFileMode": {
600 "oneOf": [
601 {
602 "description": "write file",
603 "enum": ["write"],
604 "type": "string"
605 },
606 {
607 "description": "replace text",
608 "enum": ["replace"],
609 "type": "string"
610 }
611 ]
612 }
613 },
614 "properties": {
615 "path": {
616 "title": "Path",
617 "type": "string"
618 },
619 "mode": {
620 "description": "edit mode",
621 "anyOf": [
622 { "$ref": "#/definitions/WriteFileMode" },
623 { "type": "null" }
624 ]
625 }
626 },
627 "required": ["path"]
628 }),
629 };
630
631 let wire = build_tool_schema(&tool);
632 let parameters = &wire["function"]["parameters"];
633 assert!(!contains_key_recursive(parameters, "$schema"));
634 assert!(!contains_key_recursive(parameters, "definitions"));
635 assert!(!contains_key_recursive(parameters, "$defs"));
636 assert!(!contains_key_recursive(parameters, "$ref"));
637 assert!(!contains_key_recursive(parameters, "title"));
638 assert_eq!(parameters["additionalProperties"], false);
639 assert_eq!(
640 parameters["properties"]["mode"],
641 serde_json::json!({
642 "description": "edit mode",
643 "type": ["string", "null"],
644 "enum": ["write", "replace", null]
645 })
646 );
647 }
648
649 fn contains_key_recursive(value: &Value, key: &str) -> bool {
650 match value {
651 Value::Object(map) => {
652 map.contains_key(key)
653 || map.values().any(|value| contains_key_recursive(value, key))
654 }
655 Value::Array(items) => items.iter().any(|value| contains_key_recursive(value, key)),
656 _ => false,
657 }
658 }
659}