From 9f177f7a1f7a04a2ea1b151cb9941420936ba164 Mon Sep 17 00:00:00 2001 From: SidneyZhang Date: Mon, 17 Aug 2026 15:52:41 +0800 Subject: [PATCH] =?UTF-8?q?feat(llm):=20=E6=96=B0=E9=97=A8=E9=9D=A2?= =?UTF-8?q?=E6=8E=A5=E5=85=A5=20OpenAI=20=E4=B8=8E=20OpenRouter?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - OpenAI 使用 chat completions 协议面(completions_api),兼容第三方兼容网关 - o 系列模型非 thinking 传 reasoning_effort=none;thinking 走流式思考显示 - OpenRouter 携带 QuiCommit 应用标识头(X-OpenRouter-Title/HTTP-Referer) - 新增 5 个测试:请求形状、o 系列规则、标识头、流式事件 --- src/llm/rig/mod.rs | 230 ++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 225 insertions(+), 5 deletions(-) diff --git a/src/llm/rig/mod.rs b/src/llm/rig/mod.rs index 24c0f50..d3be321 100644 --- a/src/llm/rig/mod.rs +++ b/src/llm/rig/mod.rs @@ -10,7 +10,7 @@ use rig_core::{ client::{CompletionClient, Nothing, VerifyClient}, completion::{AssistantContent, CompletionError, CompletionModel}, http_client::{HttpClientExt, ReqwestClient}, - providers::{anthropic, deepseek, moonshot, ollama}, + providers::{anthropic, deepseek, moonshot, ollama, openai, openrouter}, streaming::StreamedAssistantContent, wasm_compat::{WasmCompatSend, WasmCompatSync}, }; @@ -48,6 +48,8 @@ pub enum Backend { DeepSeek(deepseek::Client), Kimi(moonshot::Client), Anthropic(anthropic::Client), + OpenAi(openai::CompletionsClient), + OpenRouter(openrouter::Client), } /// 基于 rig 的 LLM 客户端门面。 @@ -114,9 +116,13 @@ impl LlmClient { let key = required_api_key(&provider, manager.get_api_key())?; Backend::Anthropic(build_anthropic_client(&key, &base_url, http)?) } - // 其余 provider 在后续 ticket 接入,扩张期内新门面尚未被应用使用。 - "openai" | "openrouter" => { - bail!("Provider '{}' is not available in the new LLM backend yet", provider) + "openai" => { + let key = required_api_key(&provider, manager.get_api_key())?; + Backend::OpenAi(build_openai_client(&key, &base_url, http)?) + } + "openrouter" => { + let key = required_api_key(&provider, manager.get_api_key())?; + Backend::OpenRouter(build_openrouter_client(&key, &base_url, http)?) } _ => bail!("Unknown LLM provider: {}", provider), }; @@ -222,6 +228,31 @@ impl LlmClient { stream: self.thinking_enabled, } } + // OpenAI:thinking 时省略温度走流式;o 系列模型非 thinking 显式关闭思考。 + Backend::OpenAi(_) => { + let is_reasoning_model = self.model.starts_with('o'); + RequestOptions { + temperature: if self.thinking_enabled { + None + } else { + Some(self.config.temperature) + }, + max_tokens: Some(self.config.max_tokens), + additional_params: if !self.thinking_enabled && is_reasoning_model { + Some(serde_json::json!({ "reasoning_effort": "none" })) + } else { + None + }, + stream: self.thinking_enabled, + } + } + // OpenRouter:透传模型名,普通参数,不支持 thinking。 + Backend::OpenRouter(_) => RequestOptions { + temperature: Some(self.config.temperature), + max_tokens: Some(self.config.max_tokens), + additional_params: None, + stream: false, + }, } } } @@ -258,6 +289,14 @@ where let model = client.completion_model(self.model.as_str()); self.run(&model, system, user, &options).await } + Backend::OpenAi(client) => { + let model = client.completion_model(self.model.as_str()); + self.run(&model, system, user, &options).await + } + Backend::OpenRouter(client) => { + let model = client.completion_model(self.model.as_str()); + self.run(&model, system, user, &options).await + } } } @@ -268,6 +307,8 @@ where Backend::DeepSeek(client) => client.verify().await.is_ok(), Backend::Kimi(client) => client.verify().await.is_ok(), Backend::Anthropic(client) => client.verify().await.is_ok(), + Backend::OpenAi(client) => client.verify().await.is_ok(), + Backend::OpenRouter(client) => client.verify().await.is_ok(), } } @@ -394,6 +435,32 @@ fn build_anthropic_client(key: &str, base_url: &str, http: ReqwestClient) -> Res .map_err(|e| anyhow::anyhow!("Failed to build Anthropic client: {}", e)) } +/// 构建 OpenAI 客户端:使用 chat completions 协议面(兼容第三方网关)。 +fn build_openai_client( + key: &str, + base_url: &str, + http: ReqwestClient, +) -> Result { + openai::Client::builder() + .api_key(key) + .base_url(base_url) + .http_client(http) + .build() + .map(|client| client.completions_api()) + .map_err(|e| anyhow::anyhow!("Failed to build OpenAI client: {}", e)) +} + +/// 构建 OpenRouter 客户端(携带 QuiCommit 应用标识头)。 +fn build_openrouter_client(key: &str, base_url: &str, http: ReqwestClient) -> Result { + openrouter::Client::builder() + .api_key(key) + .base_url(base_url) + .with_app_identity("QuiCommit", "https://quicommit.dev") + .http_client(http) + .build() + .map_err(|e| anyhow::anyhow!("Failed to build OpenRouter client: {}", e)) +} + /// 组装统一的补全请求(preamble/temperature/max_tokens/additional_params)。 fn build_request( model: &M, @@ -871,7 +938,7 @@ mod tests { assert_eq!(options.temperature, None, "thinking 时必须省略温度"); // max_tokens 不低于预算 + 100 assert_eq!(options.max_tokens, Some(2100)); - assert_eq!(options.stream, true); + assert!(options.stream); let params = options.additional_params.unwrap(); assert_eq!(params["thinking"]["type"], "enabled"); assert_eq!(params["thinking"]["budget_tokens"], 2000); @@ -968,4 +1035,157 @@ mod tests { "got: {msg}" ); } + + // ---- OpenAI / OpenRouter ---- + + const OPENAI_OK: &str = r#"{ + "id": "chatcmpl-1", + "object": "chat.completion", + "created": 0, + "model": "gpt-4o", + "choices": [{"index": 0, "message": {"role": "assistant", "content": "hello from openai"}, "logprobs": null, "finish_reason": "stop"}] + }"#; + + fn openai_client( + recorder: RecordingHttpClient, + model: &str, + thinking: bool, + ) -> (LlmClient, RecordingHttpClient) { + let backend = Backend::OpenAi( + openai::Client::builder() + .api_key("sk-openai-test") + .base_url("https://api.openai.com/v1") + .http_client(recorder.clone()) + .build() + .map(|client| client.completions_api()) + .expect("build openai client with mock backend"), + ); + let client = LlmClient::new(backend, model, "openai", test_config(), thinking, None); + (client, recorder) + } + + #[tokio::test] + async fn openai_normal_request_uses_chat_completions() { + let recorder = RecordingHttpClient::new(OPENAI_OK); + let (client, recorder) = openai_client(recorder, "gpt-4o", false); + + let text = client.generate(Some("sys"), "hi").await.unwrap(); + assert_eq!(text, "hello from openai"); + + let captured = recorder.requests(); + assert_eq!(captured[0].uri, "https://api.openai.com/v1/chat/completions"); + let body: serde_json::Value = serde_json::from_slice(&captured[0].body).unwrap(); + assert_eq!(body["model"], "gpt-4o"); + assert_eq!(body["temperature"], 0.5); + assert_eq!(body["max_tokens"], 123); + assert!(body.get("reasoning_effort").is_none(), "非 o 系列不应传 reasoning_effort"); + } + + #[test] + fn openai_o_series_normal_sets_reasoning_effort_none() { + let recorder = RecordingHttpClient::new(OPENAI_OK); + let (client, _) = openai_client(recorder, "o3", false); + + let options = client.request_options(); + let params = options.additional_params.expect("o 系列应传 reasoning_effort"); + assert_eq!(params["reasoning_effort"], "none"); + assert!(!options.stream); + } + + #[test] + fn openai_thinking_options_stream_without_temperature() { + let recorder = RecordingHttpClient::new(OPENAI_OK); + let (client, _) = openai_client(recorder, "gpt-4o", true); + + let options = client.request_options(); + assert_eq!(options.temperature, None); + assert!(options.stream); + assert!(options.additional_params.is_none()); + } + + #[tokio::test] + async fn openrouter_request_carries_app_identity_headers() { + let recorder = RecordingHttpClient::new(OPENAI_OK); + let backend = Backend::OpenRouter( + openrouter::Client::builder() + .api_key("sk-or-test") + .base_url("https://openrouter.ai/api/v1") + .with_app_identity("QuiCommit", "https://quicommit.dev") + .http_client(recorder.clone()) + .build() + .expect("build openrouter client with mock backend"), + ); + let client = LlmClient::new( + backend, + "openai/gpt-4o", + "openrouter", + test_config(), + false, + None, + ); + + let text = client.generate(None, "hi").await.unwrap(); + assert_eq!(text, "hello from openai"); + + let captured = recorder.requests(); + assert_eq!( + captured[0].uri, + "https://openrouter.ai/api/v1/chat/completions" + ); + assert_eq!( + captured[0].headers.get("x-openrouter-title").unwrap(), + "QuiCommit" + ); + assert_eq!( + captured[0].headers.get("http-referer").unwrap(), + "https://quicommit.dev" + ); + let body: serde_json::Value = serde_json::from_slice(&captured[0].body).unwrap(); + assert_eq!(body["model"], "openai/gpt-4o"); + } + + #[tokio::test] + async fn openai_streaming_drives_thinking_state() { + let start_count = Arc::new(AtomicUsize::new(0)); + let end_count = Arc::new(AtomicUsize::new(0)); + let state = Arc::new( + ThinkingStateManager::new() + .on_thinking_start({ + let c = start_count.clone(); + move || { + c.fetch_add(1, Ordering::SeqCst); + } + }) + .on_thinking_end({ + let c = end_count.clone(); + move || { + c.fetch_add(1, Ordering::SeqCst); + } + }), + ); + let backend = Backend::OpenAi( + openai::Client::builder() + .api_key("sk-openai-test") + .base_url("https://api.openai.com/v1") + .http_client(MockStreamingClient { + sse_bytes: concat!( + "data: {\"choices\":[{\"index\":0,\"delta\":{\"reasoning_content\":\"thinking\"},\"finish_reason\":null}]}\n\n", + "data: {\"choices\":[{\"index\":0,\"delta\":{\"content\":\"hi\"},\"finish_reason\":null}]}\n\n", + "data: {\"choices\":[{\"index\":0,\"delta\":{},\"finish_reason\":\"stop\"}]}\n\n", + "data: [DONE]\n\n", + ) + .to_string() + .into(), + }) + .build() + .map(|client| client.completions_api()) + .expect("build openai streaming mock"), + ); + let client = LlmClient::new(backend, "o3", "openai", test_config(), true, Some(state)); + + let text = client.generate(None, "hi").await.unwrap(); + assert_eq!(text, "hi"); + assert_eq!(start_count.load(Ordering::SeqCst), 1); + assert_eq!(end_count.load(Ordering::SeqCst), 1); + } } \ No newline at end of file