feat(llm): 新门面接入 Anthropic(thinking 预算与温度规则)
- provider 枚举增加 Anthropic 变体;base_url 由 rig 规范化(修复旧实现忽略配置) - thinking 透传 thinking 块与预算 token(缺省 1024);thinking 时省略温度 - max_tokens 下限规则:不低于预算 + 100(沿用旧实现) - 流式思考事件驱动显示与正文聚合与 DeepSeek/Kimi 机制一致 - 新增 5 个测试:请求形状、request_options 规则、SSE 事件序列
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::{deepseek, moonshot, ollama},
|
||||
providers::{anthropic, deepseek, moonshot, ollama},
|
||||
streaming::StreamedAssistantContent,
|
||||
wasm_compat::{WasmCompatSend, WasmCompatSync},
|
||||
};
|
||||
@@ -23,6 +23,8 @@ pub struct LlmClientConfig {
|
||||
pub max_tokens: u64,
|
||||
pub temperature: f64,
|
||||
pub timeout: Duration,
|
||||
/// Anthropic thinking 预算 token(配置缺省 1024)。
|
||||
pub thinking_budget_tokens: Option<u32>,
|
||||
}
|
||||
|
||||
impl Default for LlmClientConfig {
|
||||
@@ -31,6 +33,7 @@ impl Default for LlmClientConfig {
|
||||
max_tokens: 500,
|
||||
temperature: 0.7,
|
||||
timeout: Duration::from_secs(30),
|
||||
thinking_budget_tokens: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -44,6 +47,7 @@ pub enum Backend<H = ReqwestClient> {
|
||||
Ollama(ollama::Client<H>),
|
||||
DeepSeek(deepseek::Client<H>),
|
||||
Kimi(moonshot::Client<H>),
|
||||
Anthropic(anthropic::Client<H>),
|
||||
}
|
||||
|
||||
/// 基于 rig 的 LLM 客户端门面。
|
||||
@@ -85,6 +89,7 @@ impl LlmClient {
|
||||
max_tokens: config.llm.max_tokens as u64,
|
||||
temperature: config.llm.temperature as f64,
|
||||
timeout: Duration::from_secs(config.llm.timeout),
|
||||
thinking_budget_tokens: config.llm.thinking_budget_tokens,
|
||||
};
|
||||
let provider = manager.llm_provider().to_string();
|
||||
let model = manager.llm_model().to_string();
|
||||
@@ -105,8 +110,12 @@ impl LlmClient {
|
||||
let key = required_api_key(&provider, manager.get_api_key())?;
|
||||
Backend::Kimi(build_kimi_client(&key, &base_url, http)?)
|
||||
}
|
||||
"anthropic" => {
|
||||
let key = required_api_key(&provider, manager.get_api_key())?;
|
||||
Backend::Anthropic(build_anthropic_client(&key, &base_url, http)?)
|
||||
}
|
||||
// 其余 provider 在后续 ticket 接入,扩张期内新门面尚未被应用使用。
|
||||
"openai" | "anthropic" | "openrouter" => {
|
||||
"openai" | "openrouter" => {
|
||||
bail!("Provider '{}' is not available in the new LLM backend yet", provider)
|
||||
}
|
||||
_ => bail!("Unknown LLM provider: {}", provider),
|
||||
@@ -189,6 +198,30 @@ impl<H> LlmClient<H> {
|
||||
})),
|
||||
stream: self.thinking_enabled,
|
||||
},
|
||||
// Anthropic:thinking 时省略温度、max_tokens 不低于预算 + 100,预算透传。
|
||||
Backend::Anthropic(_) => {
|
||||
let budget = self.config.thinking_budget_tokens.unwrap_or(1024);
|
||||
let max_tokens = if self.thinking_enabled {
|
||||
self.config.max_tokens.max(u64::from(budget) + 100)
|
||||
} else {
|
||||
self.config.max_tokens
|
||||
};
|
||||
let thinking_param = if self.thinking_enabled {
|
||||
serde_json::json!({ "thinking": { "type": "enabled", "budget_tokens": budget } })
|
||||
} else {
|
||||
serde_json::json!({ "thinking": { "type": "disabled" } })
|
||||
};
|
||||
RequestOptions {
|
||||
temperature: if self.thinking_enabled {
|
||||
None
|
||||
} else {
|
||||
Some(self.config.temperature)
|
||||
},
|
||||
max_tokens: Some(max_tokens),
|
||||
additional_params: Some(thinking_param),
|
||||
stream: self.thinking_enabled,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -221,6 +254,10 @@ where
|
||||
let model = client.completion_model(self.model.as_str());
|
||||
self.run(&model, system, user, &options).await
|
||||
}
|
||||
Backend::Anthropic(client) => {
|
||||
let model = client.completion_model(self.model.as_str());
|
||||
self.run(&model, system, user, &options).await
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -230,6 +267,7 @@ where
|
||||
Backend::Ollama(client) => client.verify().await.is_ok(),
|
||||
Backend::DeepSeek(client) => client.verify().await.is_ok(),
|
||||
Backend::Kimi(client) => client.verify().await.is_ok(),
|
||||
Backend::Anthropic(client) => client.verify().await.is_ok(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -346,6 +384,16 @@ fn build_kimi_client(key: &str, base_url: &str, http: ReqwestClient) -> Result<m
|
||||
.map_err(|e| anyhow::anyhow!("Failed to build Kimi client: {}", e))
|
||||
}
|
||||
|
||||
/// 构建 Anthropic 客户端(rig 会自动规范化 base_url 的 /v1 等后缀)。
|
||||
fn build_anthropic_client(key: &str, base_url: &str, http: ReqwestClient) -> Result<anthropic::Client> {
|
||||
anthropic::Client::builder()
|
||||
.api_key(key)
|
||||
.base_url(base_url)
|
||||
.http_client(http)
|
||||
.build()
|
||||
.map_err(|e| anyhow::anyhow!("Failed to build Anthropic client: {}", e))
|
||||
}
|
||||
|
||||
/// 组装统一的补全请求(preamble/temperature/max_tokens/additional_params)。
|
||||
fn build_request<M: CompletionModel>(
|
||||
model: &M,
|
||||
@@ -454,6 +502,7 @@ mod tests {
|
||||
max_tokens: 123,
|
||||
temperature: 0.5,
|
||||
timeout: Duration::from_secs(30),
|
||||
thinking_budget_tokens: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -757,4 +806,166 @@ mod tests {
|
||||
assert!(!supports_thinking(provider), "{provider} should not support thinking");
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Anthropic ----
|
||||
|
||||
const ANTHROPIC_OK: &str = r#"{
|
||||
"id": "msg_01",
|
||||
"type": "message",
|
||||
"role": "assistant",
|
||||
"content": [{"type": "text", "text": "hello from claude"}],
|
||||
"model": "claude-sonnet-4-6",
|
||||
"stop_reason": "end_turn",
|
||||
"stop_sequence": null,
|
||||
"usage": {"input_tokens": 5, "output_tokens": 3}
|
||||
}"#;
|
||||
|
||||
fn anthropic_client(
|
||||
recorder: RecordingHttpClient,
|
||||
thinking: bool,
|
||||
budget: Option<u32>,
|
||||
) -> (LlmClient<RecordingHttpClient>, RecordingHttpClient) {
|
||||
let backend = Backend::Anthropic(
|
||||
anthropic::Client::builder()
|
||||
.api_key("sk-ant-test")
|
||||
.base_url("https://api.anthropic.com/v1")
|
||||
.http_client(recorder.clone())
|
||||
.build()
|
||||
.expect("build anthropic client with mock backend"),
|
||||
);
|
||||
let mut config = test_config();
|
||||
config.thinking_budget_tokens = budget;
|
||||
let client = LlmClient::new(backend, "claude-sonnet-4-6", "anthropic", config, thinking, None);
|
||||
(client, recorder)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn anthropic_normal_request_shape() {
|
||||
let recorder = RecordingHttpClient::new(ANTHROPIC_OK);
|
||||
let (client, recorder) = anthropic_client(recorder, false, None);
|
||||
|
||||
let text = client.generate(Some("sys"), "hi").await.unwrap();
|
||||
assert_eq!(text, "hello from claude");
|
||||
|
||||
let captured = recorder.requests();
|
||||
assert_eq!(captured[0].uri, "https://api.anthropic.com/v1/messages");
|
||||
let body: serde_json::Value = serde_json::from_slice(&captured[0].body).unwrap();
|
||||
assert_eq!(body["model"], "claude-sonnet-4-6");
|
||||
assert_eq!(body["max_tokens"], 123);
|
||||
assert_eq!(body["temperature"], 0.5);
|
||||
assert_eq!(body["system"][0]["text"], "sys");
|
||||
assert_eq!(body["messages"][0]["role"], "user");
|
||||
assert_eq!(body["messages"][0]["content"][0]["type"], "text");
|
||||
assert_eq!(body["messages"][0]["content"][0]["text"], "hi");
|
||||
// 与旧实现一致:非 thinking 显式传 disabled
|
||||
assert_eq!(body["thinking"]["type"], "disabled");
|
||||
assert!(body.get("stream").is_none(), "非流式请求不应带 stream 字段");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn anthropic_thinking_request_options() {
|
||||
let recorder = RecordingHttpClient::new(ANTHROPIC_OK);
|
||||
let (client, _) = anthropic_client(recorder, true, Some(2000));
|
||||
|
||||
let options = client.request_options();
|
||||
assert_eq!(options.temperature, None, "thinking 时必须省略温度");
|
||||
// max_tokens 不低于预算 + 100
|
||||
assert_eq!(options.max_tokens, Some(2100));
|
||||
assert_eq!(options.stream, true);
|
||||
let params = options.additional_params.unwrap();
|
||||
assert_eq!(params["thinking"]["type"], "enabled");
|
||||
assert_eq!(params["thinking"]["budget_tokens"], 2000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn anthropic_thinking_default_budget() {
|
||||
let recorder = RecordingHttpClient::new(ANTHROPIC_OK);
|
||||
let (client, _) = anthropic_client(recorder, true, None);
|
||||
|
||||
let options = client.request_options();
|
||||
let params = options.additional_params.unwrap();
|
||||
assert_eq!(params["thinking"]["budget_tokens"], 1024);
|
||||
// 缺省预算时 max_tokens = max(123, 1124) = 1124
|
||||
assert_eq!(options.max_tokens, Some(1124));
|
||||
}
|
||||
|
||||
const ANTHROPIC_SSE_THINK_THEN_TEXT: &str = concat!(
|
||||
"data: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\"let me think\"}}\n\n",
|
||||
"data: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"hello\"}}\n\n",
|
||||
"data: {\"type\":\"content_block_stop\",\"index\":0}\n\n",
|
||||
"data: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\"},\"usage\":{\"output_tokens\":5}}\n\n",
|
||||
);
|
||||
|
||||
const ANTHROPIC_SSE_REASONING_ONLY: &str = concat!(
|
||||
"data: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\"only thoughts\"}}\n\n",
|
||||
"data: {\"type\":\"content_block_stop\",\"index\":0}\n\n",
|
||||
"data: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\"},\"usage\":{\"output_tokens\":5}}\n\n",
|
||||
);
|
||||
|
||||
fn anthropic_streaming_client(
|
||||
sse: &str,
|
||||
) -> (
|
||||
LlmClient<MockStreamingClient>,
|
||||
Arc<AtomicUsize>,
|
||||
Arc<AtomicUsize>,
|
||||
) {
|
||||
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::Anthropic(
|
||||
anthropic::Client::builder()
|
||||
.api_key("sk-ant-test")
|
||||
.base_url("https://api.anthropic.com/v1")
|
||||
.http_client(MockStreamingClient { sse_bytes: sse.to_string().into() })
|
||||
.build()
|
||||
.expect("build anthropic client with streaming mock"),
|
||||
);
|
||||
let client = LlmClient::new(
|
||||
backend,
|
||||
"claude-sonnet-4-6",
|
||||
"anthropic",
|
||||
test_config(),
|
||||
true,
|
||||
Some(state),
|
||||
);
|
||||
(client, start_count, end_count)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn anthropic_streaming_aggregates_text_and_drives_thinking() {
|
||||
let (client, start_count, end_count) =
|
||||
anthropic_streaming_client(ANTHROPIC_SSE_THINK_THEN_TEXT);
|
||||
|
||||
let text = client.generate(None, "hi").await.unwrap();
|
||||
assert_eq!(text, "hello");
|
||||
assert_eq!(start_count.load(Ordering::SeqCst), 1);
|
||||
assert_eq!(end_count.load(Ordering::SeqCst), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn anthropic_streaming_reasoning_only_errors_with_hint() {
|
||||
let (client, _, _) = anthropic_streaming_client(ANTHROPIC_SSE_REASONING_ONLY);
|
||||
|
||||
let err = client.generate(None, "hi").await.unwrap_err();
|
||||
let msg = err.to_string();
|
||||
assert!(
|
||||
msg.contains("Anthropic returned reasoning content but no final answer"),
|
||||
"got: {msg}"
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user