Account for Anthropic web search costs

This commit is contained in:
whit3rabbit
2026-09-12 08:59:52 -05:00
parent 517b6f4ea4
commit e44efa709e
5 changed files with 142 additions and 13 deletions
+70 -5
View File
@@ -214,6 +214,26 @@ impl ModelPricing {
}
}
const ANTHROPIC_WEB_SEARCH_COST_USD: f64 = 0.01;
/// Calculate the non-token cost Anthropic reports for hosted server tools.
///
/// Anthropic bills web search at $10 per 1,000 successful searches, in addition
/// to the model tokens reported in the same usage object. Other server-tool
/// counters are deliberately ignored until they have separate billable pricing.
pub fn anthropic_web_search_cost(requests: u64) -> f64 {
requests as f64 * ANTHROPIC_WEB_SEARCH_COST_USD
}
pub fn anthropic_server_tool_usage_cost(
usage: Option<&anyllm_translate::anthropic::messages::ServerToolUsage>,
) -> f64 {
usage
.and_then(|usage| usage.web_search_requests)
.map(|requests| anthropic_web_search_cost(requests as u64))
.unwrap_or(0.0)
}
/// Record cost for a completed request against a virtual key.
///
/// Calculates cost from token usage and the resolved model name, then
@@ -226,7 +246,24 @@ pub fn record_cost(
input_tokens: u64,
output_tokens: u64,
) -> f64 {
let cost = pricing().cost_for_usage(model, input_tokens, output_tokens);
record_cost_with_extra(shared, vk_ctx, model, input_tokens, output_tokens, 0.0)
}
/// Record token cost plus an explicit non-token surcharge against a virtual key.
pub fn record_cost_with_extra(
shared: &Option<crate::admin::state::SharedState>,
vk_ctx: &Option<crate::server::middleware::VirtualKeyContext>,
model: &str,
input_tokens: u64,
output_tokens: u64,
extra_cost_usd: f64,
) -> f64 {
let token_cost = if input_tokens == 0 && output_tokens == 0 {
0.0
} else {
pricing().cost_for_usage(model, input_tokens, output_tokens)
};
let cost = token_cost + extra_cost_usd.max(0.0);
if cost <= 0.0 {
return cost;
}
@@ -295,6 +332,19 @@ mod tests {
}
}
#[test]
fn anthropic_server_tool_usage_cost_counts_web_search_only() {
let usage = anyllm_translate::anthropic::messages::ServerToolUsage {
web_search_requests: Some(3),
web_fetch_requests: Some(9),
tool_search_requests: Some(7),
extra: serde_json::Map::new(),
};
assert!((anthropic_server_tool_usage_cost(Some(&usage)) - 0.03).abs() < 1e-12);
assert_eq!(anthropic_server_tool_usage_cost(None), 0.0);
}
#[test]
fn exact_match() {
let pricing = test_pricing();
@@ -467,24 +517,39 @@ mod tests {
period_reset: None,
};
let shared_opt = Some(shared);
let vk_ctx_opt = Some(vk_ctx);
// record_cost uses tokio::task::spawn_blocking, so we need a runtime.
let rt = tokio::runtime::Runtime::new().unwrap();
rt.block_on(async {
let cost = record_cost(&Some(shared), &Some(vk_ctx), "gpt-4o", 1000, 500);
let cost = record_cost(&shared_opt, &vk_ctx_opt, "gpt-4o", 1000, 500);
assert!(cost > 0.0);
// Wait for the spawned blocking task to complete.
tokio::task::yield_now().await;
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
let extra_only_cost = record_cost_with_extra(
&shared_opt,
&vk_ctx_opt,
"unknown-anthropic-model",
0,
0,
0.03,
);
assert!((extra_only_cost - 0.03).abs() < 1e-12);
tokio::task::yield_now().await;
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
});
// Verify the spend was persisted.
// Verify the spend was persisted, including an extra-cost-only hosted tool charge.
let conn = db.lock().unwrap();
let spend = db::get_key_spend(&conn, key_id).unwrap().unwrap();
assert!(spend.total_cost_usd > 0.0);
assert!(spend.total_cost_usd > 0.03);
assert_eq!(spend.total_input_tokens, 1000);
assert_eq!(spend.total_output_tokens, 500);
assert_eq!(spend.request_count, 1);
assert_eq!(spend.request_count, 2);
}
// -- Spend threshold detection tests --
+15 -3
View File
@@ -333,12 +333,16 @@ pub(crate) async fn chat_completions(
let oai_response =
translate_anthropic_to_openai_response(&anthropic_resp, &original_model);
let cost = super::routes::record_virtual_key_usage(
let server_tool_cost = crate::cost::anthropic_server_tool_usage_cost(
anthropic_resp.usage.server_tool_use.as_ref(),
);
let cost = super::routes::record_virtual_key_usage_with_extra(
&state.shared,
&vk_ctx,
&mapped_model,
anthropic_resp.usage.input_tokens as u64,
anthropic_resp.usage.output_tokens as u64,
server_tool_cost,
);
log_request(
&state.shared,
@@ -415,12 +419,16 @@ pub(crate) async fn chat_completions(
);
let oai_response =
translate_anthropic_to_openai_response(&anthropic_resp, &original_model);
let cost = super::routes::record_virtual_key_usage(
let server_tool_cost = crate::cost::anthropic_server_tool_usage_cost(
anthropic_resp.usage.server_tool_use.as_ref(),
);
let cost = super::routes::record_virtual_key_usage_with_extra(
&state.shared,
&vk_ctx,
&mapped_model,
anthropic_resp.usage.input_tokens as u64,
anthropic_resp.usage.output_tokens as u64,
server_tool_cost,
);
log_request(
&state.shared,
@@ -536,12 +544,16 @@ pub(crate) async fn chat_completions(
&original_model,
&tool_context,
);
let cost = super::routes::record_virtual_key_usage(
let server_tool_cost = crate::cost::anthropic_server_tool_usage_cost(
anthropic_resp.usage.server_tool_use.as_ref(),
);
let cost = super::routes::record_virtual_key_usage_with_extra(
&state.shared,
&vk_ctx,
&mapped_model,
anthropic_resp.usage.input_tokens as u64,
anthropic_resp.usage.output_tokens as u64,
server_tool_cost,
);
log_request(
&state.shared,
@@ -199,15 +199,20 @@ async fn anthropic_chat_completions_stream(
}
let tokens = usage.tokens();
let cost = tokens.map(|(input_t, output_t)| {
crate::server::routes::record_virtual_key_usage(
let server_tool_cost = crate::cost::anthropic_web_search_cost(usage.web_search_requests());
let cost = if tokens.is_some() || server_tool_cost > 0.0 {
let (input_t, output_t) = tokens.unwrap_or((0, 0));
Some(crate::server::routes::record_virtual_key_usage_with_extra(
&log_shared,
&vk_ctx,
&mapped_model,
input_t,
output_t,
)
});
server_tool_cost,
))
} else {
None
};
let (status, err) = outcome.record(&metrics);
log_request(
&log_shared,
+19 -1
View File
@@ -1291,10 +1291,28 @@ pub(crate) fn record_virtual_key_usage(
model: &str,
input_tokens: u64,
output_tokens: u64,
) -> f64 {
record_virtual_key_usage_with_extra(shared, vk_ctx, model, input_tokens, output_tokens, 0.0)
}
pub(crate) fn record_virtual_key_usage_with_extra(
shared: &Option<SharedState>,
vk_ctx: &Option<super::middleware::VirtualKeyContext>,
model: &str,
input_tokens: u64,
output_tokens: u64,
extra_cost_usd: f64,
) -> f64 {
let capped_output = output_tokens.min(u32::MAX as u64) as u32;
record_vk_tpm(vk_ctx, capped_output);
crate::cost::record_cost(shared, vk_ctx, model, input_tokens, output_tokens)
crate::cost::record_cost_with_extra(
shared,
vk_ctx,
model,
input_tokens,
output_tokens,
extra_cost_usd,
)
}
/// Global webhook callback config, set once at startup.
+29
View File
@@ -82,12 +82,23 @@ data: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\",\"stop
assert_eq!(usage.tokens(), Some((11, 7)));
assert!(buffer.is_empty());
}
#[test]
fn anthropic_stream_usage_counts_successful_web_search_results() {
let mut usage = AnthropicStreamUsage::default();
usage.observe_data(r#"{"type":"content_block_start","index":1,"content_block":{"type":"web_search_tool_result","tool_use_id":"srvtoolu_ok","content":[]}}"#);
usage.observe_data(r#"{"type":"content_block_start","index":2,"content_block":{"type":"web_search_tool_result","tool_use_id":"srvtoolu_err","is_error":true}}"#);
assert_eq!(usage.web_search_requests(), 1);
}
}
#[derive(Debug, Default, Clone, Copy)]
pub(crate) struct AnthropicStreamUsage {
input_tokens: Option<u64>,
output_tokens: Option<u64>,
web_search_requests: u64,
}
impl AnthropicStreamUsage {
@@ -107,6 +118,20 @@ impl AnthropicStreamUsage {
} => {
self.output_tokens = Some(usage.output_tokens as u64);
}
anthropic::StreamEvent::ContentBlockStart {
content_block:
anthropic::ContentBlock::WebSearchToolResult {
is_error: Some(true),
..
},
..
} => {}
anthropic::StreamEvent::ContentBlockStart {
content_block: anthropic::ContentBlock::WebSearchToolResult { .. },
..
} => {
self.web_search_requests = self.web_search_requests.saturating_add(1);
}
_ => {}
}
}
@@ -119,6 +144,10 @@ impl AnthropicStreamUsage {
(None, None) => None,
}
}
pub(crate) fn web_search_requests(&self) -> u64 {
self.web_search_requests
}
}
pub(crate) fn observe_anthropic_sse_frames(