Skip to main content

tiny_agent/providers/openai_compatible/
mod.rs

1// src/agent/providers/openai_compatible.rs
2use 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                    // 流自然结束。是否合法(收到过 finish_reason)在循环外判定。
186                    Ok(None) => break,
187                    // 传输中途断开:必须显式 yield 错误,不能静默吞掉当成正常结束。
188                    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            // 流结束了却从未见过 [DONE] / finish_reason:多半是被截断的半截响应,
275            // 视为错误而非成功,避免上层把残缺内容当正常结果落库。
276            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        // 凭证从环境读取,绝不硬编码进源码。未配置则跳过这个联网测试。
512        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}