feat(llm): 新门面接入 OpenAI 与 OpenRouter

- OpenAI 使用 chat completions 协议面(completions_api),兼容第三方兼容网关
- o 系列模型非 thinking 传 reasoning_effort=none;thinking 走流式思考显示
- OpenRouter 携带 QuiCommit 应用标识头(X-OpenRouter-Title/HTTP-Referer)
- 新增 5 个测试:请求形状、o 系列规则、标识头、流式事件
This commit is contained in:
2026-08-17 15:52:41 +08:00
parent 18728a4a2e
commit 9f177f7a1f

View File

@@ -10,7 +10,7 @@ use rig_core::{
client::{CompletionClient, Nothing, VerifyClient}, client::{CompletionClient, Nothing, VerifyClient},
completion::{AssistantContent, CompletionError, CompletionModel}, completion::{AssistantContent, CompletionError, CompletionModel},
http_client::{HttpClientExt, ReqwestClient}, http_client::{HttpClientExt, ReqwestClient},
providers::{anthropic, deepseek, moonshot, ollama}, providers::{anthropic, deepseek, moonshot, ollama, openai, openrouter},
streaming::StreamedAssistantContent, streaming::StreamedAssistantContent,
wasm_compat::{WasmCompatSend, WasmCompatSync}, wasm_compat::{WasmCompatSend, WasmCompatSync},
}; };
@@ -48,6 +48,8 @@ pub enum Backend<H = ReqwestClient> {
DeepSeek(deepseek::Client<H>), DeepSeek(deepseek::Client<H>),
Kimi(moonshot::Client<H>), Kimi(moonshot::Client<H>),
Anthropic(anthropic::Client<H>), Anthropic(anthropic::Client<H>),
OpenAi(openai::CompletionsClient<H>),
OpenRouter(openrouter::Client<H>),
} }
/// 基于 rig 的 LLM 客户端门面。 /// 基于 rig 的 LLM 客户端门面。
@@ -114,9 +116,13 @@ impl LlmClient {
let key = required_api_key(&provider, manager.get_api_key())?; let key = required_api_key(&provider, manager.get_api_key())?;
Backend::Anthropic(build_anthropic_client(&key, &base_url, http)?) Backend::Anthropic(build_anthropic_client(&key, &base_url, http)?)
} }
// 其余 provider 在后续 ticket 接入,扩张期内新门面尚未被应用使用。 "openai" => {
"openai" | "openrouter" => { let key = required_api_key(&provider, manager.get_api_key())?;
bail!("Provider '{}' is not available in the new LLM backend yet", provider) 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), _ => bail!("Unknown LLM provider: {}", provider),
}; };
@@ -222,6 +228,31 @@ impl<H> LlmClient<H> {
stream: self.thinking_enabled, stream: self.thinking_enabled,
} }
} }
// OpenAIthinking 时省略温度走流式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()); let model = client.completion_model(self.model.as_str());
self.run(&model, system, user, &options).await 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::DeepSeek(client) => client.verify().await.is_ok(),
Backend::Kimi(client) => client.verify().await.is_ok(), Backend::Kimi(client) => client.verify().await.is_ok(),
Backend::Anthropic(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)) .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::CompletionsClient> {
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> {
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 /// 组装统一的补全请求preamble/temperature/max_tokens/additional_params
fn build_request<M: CompletionModel>( fn build_request<M: CompletionModel>(
model: &M, model: &M,
@@ -871,7 +938,7 @@ mod tests {
assert_eq!(options.temperature, None, "thinking 时必须省略温度"); assert_eq!(options.temperature, None, "thinking 时必须省略温度");
// max_tokens 不低于预算 + 100 // max_tokens 不低于预算 + 100
assert_eq!(options.max_tokens, Some(2100)); assert_eq!(options.max_tokens, Some(2100));
assert_eq!(options.stream, true); assert!(options.stream);
let params = options.additional_params.unwrap(); let params = options.additional_params.unwrap();
assert_eq!(params["thinking"]["type"], "enabled"); assert_eq!(params["thinking"]["type"], "enabled");
assert_eq!(params["thinking"]["budget_tokens"], 2000); assert_eq!(params["thinking"]["budget_tokens"], 2000);
@@ -968,4 +1035,157 @@ mod tests {
"got: {msg}" "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>, 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);
}
} }