Files
cursor-byok/server/src/provider/anthropic.rs
T
nanoha e4515dc4e0 fix: omit tools field from provider requests when empty
vLLM and some OpenAI-compatible APIs reject requests containing an
empty `tools: []` array with a 400 Bad Request. This affects the
model connectivity test which sends no tools. Instead of always
including the tools field, conditionally add it only when tools are
present.
2026-08-27 14:02:16 +08:00

446 lines
20 KiB
Rust

use async_stream::try_stream;
use base64::{engine::general_purpose::STANDARD, Engine};
use eventsource_stream::Eventsource;
use futures_util::StreamExt;
use serde_json::{json, Value};
use crate::{
config::ProviderConfig,
model::{ContentPart, ModelInvocation, ProjectedContent, ProjectedMessage, Role, Usage},
Error, Result,
};
use super::{
merge_extra_params, recorder::recorded_headers, CallRecorder, FinishReason, ModelEvent,
Provider, ProviderStream,
};
const DEFAULT_MAX_OUTPUT_TOKENS: u64 = 65_000;
pub struct AnthropicProvider {
client: reqwest::Client,
config: ProviderConfig,
recorder: Option<CallRecorder>,
}
impl AnthropicProvider {
pub fn new(client: reqwest::Client, config: ProviderConfig) -> Self {
Self {
client,
config,
recorder: None,
}
}
pub fn with_recorder(mut self, recorder: Option<CallRecorder>) -> Self {
self.recorder = recorder;
self
}
}
impl Provider for AnthropicProvider {
fn stream(
&self,
invocation: ModelInvocation,
cancellation: tokio_util::sync::CancellationToken,
) -> ProviderStream {
let client = self.client.clone();
let config = self.config.clone();
let recorder = self.recorder.clone();
Box::pin(try_stream! {
let ModelInvocation { call_id, request, .. } = invocation;
let messages = anthropic_messages(&request.history)?;
let max_tokens = request.model.max_output_tokens.or(config.max_output_tokens)
.unwrap_or(DEFAULT_MAX_OUTPUT_TOKENS);
let mut body = json!({
"model": request.model.model_id, "system": request.prompt.instructions, "messages": messages,
"max_tokens": max_tokens, "stream": true
});
if !request.prompt.tools.is_empty() {
body["tools"] = json!(request.prompt.tools.iter().map(|tool| json!({
"name": tool.name, "description": tool.description, "input_schema": tool.parameters
})).collect::<Vec<_>>());
}
apply_model(&mut body, &request.model)?;
merge_extra_params(&mut body, &request.model.extra_params)?;
if let Some(recorder) = &recorder {
recorder.request(recorded_headers(&config, &[("content-type", "application/json"), ("anthropic-version", "2023-06-01")]), &body).await?;
}
let request = client.post(&config.request_url)
.header("x-api-key", &config.api_key).header("anthropic-version", "2023-06-01")
.headers(config.custom_headers.clone())
.json(&body).send();
let response = tokio::select! {
_ = cancellation.cancelled() => return,
response = request => response,
};
let response = response?;
if let Some(recorder) = &recorder {
recorder.response_headers(response.status().as_u16()).await?;
}
if !response.status().is_success() {
let status = response.status(); let bytes = response.bytes().await?;
if let Some(recorder) = &recorder { recorder.response_chunk(&bytes).await?; }
let text = String::from_utf8_lossy(&bytes);
Err(Error::Provider(format!("Anthropic {status}: {text}")))?;
return;
}
yield ModelEvent::Start { model_call_id: call_id };
let chunk_recorder = recorder.clone();
let chunks = response.bytes_stream()
.map(|chunk| chunk.map_err(Error::from))
.then(move |chunk| {
let recorder = chunk_recorder.clone();
async move {
let chunk = chunk?;
if let Some(recorder) = recorder { recorder.response_chunk(&chunk).await?; }
Ok::<_, Error>(chunk)
}
});
let source = chunks.eventsource();
futures_util::pin_mut!(source);
let mut block_types = std::collections::BTreeMap::<usize, String>::new();
let mut thinking_text = std::collections::HashMap::<usize, String>::new();
let mut thinking_signatures = std::collections::HashMap::<usize, String>::new();
let mut thinking_blocks = Vec::new();
let mut finish = None;
let mut saw_tool = false;
let mut terminal = false;
let mut final_usage = None::<Usage>;
while let Some(event) = tokio::select! {
_ = cancellation.cancelled() => { return; }
event = source.next() => event,
} {
let event = event.map_err(|error| Error::Provider(format!("Anthropic SSE: {error}")))?;
let value: Value = serde_json::from_str(&event.data)?;
let data_kind = value.get("type").and_then(Value::as_str);
let kind = match event.event.as_str() {
"" | "message" => data_kind.unwrap_or(event.event.as_str()),
kind => kind,
};
match kind {
"message_start" => if let Some(usage) = value.pointer("/message/usage") {
merge_usage(final_usage.get_or_insert_default(), anthropic_usage(usage));
},
"content_block_start" => {
let index = required_u64(&value, "index")? as usize;
let block = value.get("content_block").unwrap_or(&Value::Null);
let kind = required_string(block, "type")?;
block_types.insert(index, kind.into());
match kind {
"text" => yield ModelEvent::TextStart,
"thinking" => {
thinking_text.insert(index, String::new());
thinking_signatures.insert(index, String::new());
yield ModelEvent::ThinkingStart;
}
"redacted_thinking" => thinking_blocks.push(block.clone()),
"tool_use" => {
saw_tool = true;
yield ModelEvent::ToolCallStart {
index,
call_id: required_string(block, "id")?.into(),
name: required_string(block, "name")?.into(),
};
}
_ => {}
}
}
"content_block_delta" => {
let index = required_u64(&value, "index")? as usize;
let delta = value.get("delta").unwrap_or(&Value::Null);
let delta_kind = required_string(delta, "type")?;
if let std::collections::btree_map::Entry::Vacant(entry) = block_types.entry(index) {
match delta_kind {
"text_delta" => {
entry.insert("text".into());
yield ModelEvent::TextStart;
}
"thinking_delta" | "signature_delta" => {
entry.insert("thinking".into());
thinking_text.insert(index, String::new());
thinking_signatures.insert(index, String::new());
yield ModelEvent::ThinkingStart;
}
_ => {}
}
}
match delta_kind {
"text_delta" => if let Some(text) = delta.get("text").and_then(Value::as_str) { yield ModelEvent::TextDelta(text.into()); },
"thinking_delta" => if let Some(text) = delta.get("thinking").and_then(Value::as_str) {
thinking_text.entry(index).or_default().push_str(text);
yield ModelEvent::ThinkingDelta(text.into());
},
"signature_delta" => if let Some(signature) = delta.get("signature").and_then(Value::as_str) {
thinking_signatures.entry(index).or_default().push_str(signature);
},
"input_json_delta" => if let Some(text) = delta.get("partial_json").and_then(Value::as_str) { yield ModelEvent::ToolCallArgumentsDelta { index, delta: text.into() }; },
_ => {}
}
}
"content_block_stop" => {
let index = required_u64(&value, "index")? as usize;
if let Some(kind) = block_types.remove(&index) {
for event in close_anthropic_block(index, &kind, &mut thinking_text, &mut thinking_signatures, &mut thinking_blocks) {
yield event;
}
}
}
"message_delta" => {
if let Some(usage) = value.get("usage") {
merge_usage(final_usage.get_or_insert_default(), anthropic_usage(usage));
}
finish = match value.pointer("/delta/stop_reason").and_then(Value::as_str) {
Some("tool_use") => Some(FinishReason::ToolUse),
Some("max_tokens" | "model_context_window_exceeded") => Some(FinishReason::Length),
Some("end_turn" | "stop_sequence" | "pause_turn" | "refusal") => Some(FinishReason::Stop),
None => finish,
Some(_) => Some(if saw_tool { FinishReason::ToolUse } else { FinishReason::Stop }),
};
}
"message_stop" => {
for (index, kind) in std::mem::take(&mut block_types) {
for event in close_anthropic_block(index, &kind, &mut thinking_text, &mut thinking_signatures, &mut thinking_blocks) {
yield event;
}
}
terminal = true;
if !thinking_blocks.is_empty() {
yield ModelEvent::ProviderReplayState(
crate::model::ProviderReplayState {
provider_kind: "anthropic".into(),
value: json!({"blocks": std::mem::take(&mut thinking_blocks)}),
},
);
}
if let Some(usage) = final_usage {
yield ModelEvent::Usage(usage);
}
let finish = match finish {
Some(FinishReason::Length) => FinishReason::Length,
_ if saw_tool => FinishReason::ToolUse,
Some(finish) => finish,
None => FinishReason::Stop,
};
yield ModelEvent::Done(finish);
}
"error" => Err(Error::Provider(format!("Anthropic stream error: {}", event.data)))?,
_ => {}
}
}
if !terminal && finish.is_some() {
for (index, kind) in std::mem::take(&mut block_types) {
for event in close_anthropic_block(index, &kind, &mut thinking_text, &mut thinking_signatures, &mut thinking_blocks) {
yield event;
}
}
if !thinking_blocks.is_empty() {
yield ModelEvent::ProviderReplayState(crate::model::ProviderReplayState {
provider_kind: "anthropic".into(),
value: json!({"blocks": std::mem::take(&mut thinking_blocks)}),
});
}
if let Some(usage) = final_usage { yield ModelEvent::Usage(usage); }
terminal = true;
let finish = match finish {
Some(FinishReason::Length) => FinishReason::Length,
_ if saw_tool => FinishReason::ToolUse,
Some(finish) => finish,
None => FinishReason::Stop,
};
yield ModelEvent::Done(finish);
}
if !terminal {
Err(Error::Provider("Anthropic stream ended without message_stop".into()))?;
}
})
}
}
fn close_anthropic_block(
index: usize,
kind: &str,
thinking_text: &mut std::collections::HashMap<usize, String>,
thinking_signatures: &mut std::collections::HashMap<usize, String>,
thinking_blocks: &mut Vec<Value>,
) -> Vec<ModelEvent> {
match kind {
"text" => vec![ModelEvent::TextEnd],
"thinking" => {
let thinking = thinking_text.remove(&index).unwrap_or_default();
let signature = thinking_signatures.remove(&index).unwrap_or_default();
if !signature.is_empty() {
thinking_blocks.push(json!({
"type": "thinking",
"thinking": thinking,
"signature": signature,
}));
}
vec![ModelEvent::ThinkingEnd]
}
"tool_use" => vec![ModelEvent::ToolCallEnd { index }],
_ => Vec::new(),
}
}
fn apply_model(body: &mut Value, model: &crate::model::ModelSpec) -> Result<()> {
let object = body
.as_object_mut()
.ok_or_else(|| Error::Provider("Anthropic request body is not an object".into()))?;
if model.reasoning.enabled {
object.insert(
"thinking".into(),
json!({"type":"adaptive", "display":"summarized"}),
);
}
if let Some(effort) = &model.reasoning.effort {
object.insert("output_config".into(), json!({"effort":effort}));
}
Ok(())
}
fn merge_usage(total: &mut Usage, update: Usage) {
merge_usage_field(&mut total.input_tokens, update.input_tokens);
merge_usage_field(&mut total.output_tokens, update.output_tokens);
merge_usage_field(&mut total.cache_read_tokens, update.cache_read_tokens);
merge_usage_field(&mut total.cache_write_tokens, update.cache_write_tokens);
merge_usage_field(&mut total.reasoning_tokens, update.reasoning_tokens);
}
fn merge_usage_field(total: &mut Option<u64>, update: Option<u64>) {
if let Some(update) = update {
*total = Some(total.map_or(update, |current| current.max(update)));
}
}
fn anthropic_messages(messages: &[ProjectedMessage]) -> Result<Vec<Value>> {
let mut output = Vec::new();
for message in messages {
match &message.content {
ProjectedContent::Parts(parts) => {
let content = anthropic_parts(&message.role, parts)?;
if !content.is_empty() {
push_anthropic(&mut output, role_name(&message.role), content);
}
}
ProjectedContent::ToolResult(result) => {
let content = if result.provider_parts.is_empty() {
Value::String(result.content.clone())
} else {
Value::Array(anthropic_parts(&Role::User, &result.provider_parts)?)
};
push_anthropic(
&mut output,
"user",
vec![json!({
"type": "tool_result",
"tool_use_id": result.call_id,
"content": content,
})],
);
}
ProjectedContent::Assistant {
text,
replay_state,
calls,
..
} => {
let mut content = Vec::new();
if let Some(blocks) = replay_state
.as_ref()
.filter(|state| state.provider_kind == "anthropic")
.and_then(|state| state.value.get("blocks"))
.and_then(Value::as_array)
{
content.extend(blocks.iter().cloned());
}
if !text.is_empty() {
content.push(json!({"type": "text", "text": text}));
}
content.extend(calls.iter().map(|call| {
json!({
"type": "tool_use",
"id": call.call_id,
"name": call.name,
"input": call.arguments,
})
}));
if !content.is_empty() {
push_anthropic(&mut output, "assistant", content);
}
}
}
}
Ok(output)
}
fn anthropic_parts(role: &Role, parts: &[ContentPart]) -> Result<Vec<Value>> {
parts
.iter()
.filter_map(|part| match part {
ContentPart::Text { text } if text.is_empty() => None,
ContentPart::Text { text } => Some(Ok(json!({"type":"text", "text":text}))),
ContentPart::Image { mime_type, data } if *role == Role::User => Some(Ok(json!({
"type":"image",
"source":{
"type":"base64",
"media_type":mime_type,
"data":STANDARD.encode(data),
},
}))),
ContentPart::Image { .. } => Some(Err(Error::Protocol(
"Anthropic only accepts images in user messages".into(),
))),
})
.collect()
}
fn push_anthropic(output: &mut Vec<Value>, role: &str, mut content: Vec<Value>) {
if let Some(last) = output
.last_mut()
.filter(|last| last.get("role").and_then(Value::as_str) == Some(role))
{
if let Some(existing) = last.get_mut("content").and_then(Value::as_array_mut) {
existing.append(&mut content);
return;
}
}
output.push(json!({"role":role, "content":content}));
}
fn role_name(role: &Role) -> &'static str {
match role {
Role::System => "user",
Role::User => "user",
Role::Assistant => "assistant",
Role::Tool => "user",
}
}
fn required_string<'a>(value: &'a Value, name: &str) -> Result<&'a str> {
value
.get(name)
.and_then(Value::as_str)
.ok_or_else(|| Error::Provider(format!("Anthropic event is missing {name}")))
}
fn required_u64(value: &Value, name: &str) -> Result<u64> {
value
.get(name)
.and_then(Value::as_u64)
.ok_or_else(|| Error::Provider(format!("Anthropic event is missing {name}")))
}
fn anthropic_usage(value: &Value) -> Usage {
Usage {
input_tokens: value.get("input_tokens").and_then(Value::as_u64),
output_tokens: value.get("output_tokens").and_then(Value::as_u64),
total_tokens: value.get("total_tokens").and_then(Value::as_u64),
cache_read_tokens: value.get("cache_read_input_tokens").and_then(Value::as_u64),
cache_write_tokens: value
.get("cache_creation_input_tokens")
.and_then(Value::as_u64),
reasoning_tokens: None,
}
}