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:
@@ -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<H = ReqwestClient> {
|
||||
DeepSeek(deepseek::Client<H>),
|
||||
Kimi(moonshot::Client<H>),
|
||||
Anthropic(anthropic::Client<H>),
|
||||
OpenAi(openai::CompletionsClient<H>),
|
||||
OpenRouter(openrouter::Client<H>),
|
||||
}
|
||||
|
||||
/// 基于 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<H> LlmClient<H> {
|
||||
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::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)。
|
||||
fn build_request<M: CompletionModel>(
|
||||
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>, 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user