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},
|
client::{CompletionClient, Nothing, VerifyClient},
|
||||||
completion::{AssistantContent, CompletionError, CompletionModel},
|
completion::{AssistantContent, CompletionError, CompletionModel},
|
||||||
http_client::{HttpClientExt, ReqwestClient},
|
http_client::{HttpClientExt, ReqwestClient},
|
||||||
providers::{deepseek, moonshot, ollama},
|
providers::{anthropic, deepseek, moonshot, ollama},
|
||||||
streaming::StreamedAssistantContent,
|
streaming::StreamedAssistantContent,
|
||||||
wasm_compat::{WasmCompatSend, WasmCompatSync},
|
wasm_compat::{WasmCompatSend, WasmCompatSync},
|
||||||
};
|
};
|
||||||
@@ -23,6 +23,8 @@ pub struct LlmClientConfig {
|
|||||||
pub max_tokens: u64,
|
pub max_tokens: u64,
|
||||||
pub temperature: f64,
|
pub temperature: f64,
|
||||||
pub timeout: Duration,
|
pub timeout: Duration,
|
||||||
|
/// Anthropic thinking 预算 token(配置缺省 1024)。
|
||||||
|
pub thinking_budget_tokens: Option<u32>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for LlmClientConfig {
|
impl Default for LlmClientConfig {
|
||||||
@@ -31,6 +33,7 @@ impl Default for LlmClientConfig {
|
|||||||
max_tokens: 500,
|
max_tokens: 500,
|
||||||
temperature: 0.7,
|
temperature: 0.7,
|
||||||
timeout: Duration::from_secs(30),
|
timeout: Duration::from_secs(30),
|
||||||
|
thinking_budget_tokens: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -44,6 +47,7 @@ pub enum Backend<H = ReqwestClient> {
|
|||||||
Ollama(ollama::Client<H>),
|
Ollama(ollama::Client<H>),
|
||||||
DeepSeek(deepseek::Client<H>),
|
DeepSeek(deepseek::Client<H>),
|
||||||
Kimi(moonshot::Client<H>),
|
Kimi(moonshot::Client<H>),
|
||||||
|
Anthropic(anthropic::Client<H>),
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 基于 rig 的 LLM 客户端门面。
|
/// 基于 rig 的 LLM 客户端门面。
|
||||||
@@ -85,6 +89,7 @@ impl LlmClient {
|
|||||||
max_tokens: config.llm.max_tokens as u64,
|
max_tokens: config.llm.max_tokens as u64,
|
||||||
temperature: config.llm.temperature as f64,
|
temperature: config.llm.temperature as f64,
|
||||||
timeout: Duration::from_secs(config.llm.timeout),
|
timeout: Duration::from_secs(config.llm.timeout),
|
||||||
|
thinking_budget_tokens: config.llm.thinking_budget_tokens,
|
||||||
};
|
};
|
||||||
let provider = manager.llm_provider().to_string();
|
let provider = manager.llm_provider().to_string();
|
||||||
let model = manager.llm_model().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())?;
|
let key = required_api_key(&provider, manager.get_api_key())?;
|
||||||
Backend::Kimi(build_kimi_client(&key, &base_url, http)?)
|
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 接入,扩张期内新门面尚未被应用使用。
|
// 其余 provider 在后续 ticket 接入,扩张期内新门面尚未被应用使用。
|
||||||
"openai" | "anthropic" | "openrouter" => {
|
"openai" | "openrouter" => {
|
||||||
bail!("Provider '{}' is not available in the new LLM backend yet", provider)
|
bail!("Provider '{}' is not available in the new LLM backend yet", provider)
|
||||||
}
|
}
|
||||||
_ => bail!("Unknown LLM provider: {}", provider),
|
_ => bail!("Unknown LLM provider: {}", provider),
|
||||||
@@ -189,6 +198,30 @@ impl<H> LlmClient<H> {
|
|||||||
})),
|
})),
|
||||||
stream: self.thinking_enabled,
|
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());
|
let model = client.completion_model(self.model.as_str());
|
||||||
self.run(&model, system, user, &options).await
|
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::Ollama(client) => client.verify().await.is_ok(),
|
||||||
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(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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))
|
.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)。
|
/// 组装统一的补全请求(preamble/temperature/max_tokens/additional_params)。
|
||||||
fn build_request<M: CompletionModel>(
|
fn build_request<M: CompletionModel>(
|
||||||
model: &M,
|
model: &M,
|
||||||
@@ -454,6 +502,7 @@ mod tests {
|
|||||||
max_tokens: 123,
|
max_tokens: 123,
|
||||||
temperature: 0.5,
|
temperature: 0.5,
|
||||||
timeout: Duration::from_secs(30),
|
timeout: Duration::from_secs(30),
|
||||||
|
thinking_budget_tokens: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -757,4 +806,166 @@ mod tests {
|
|||||||
assert!(!supports_thinking(provider), "{provider} should not support thinking");
|
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