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:
2026-08-17 15:47:31 +08:00
parent f534ccc698
commit 18728a4a2e

View File

@@ -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,
},
// Anthropicthinking 时省略温度、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}"
);
}
}