Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions crates/aisix-provider-anthropic/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,8 @@ pub use bridge::{AnthropicBridge, ANTHROPIC_DEFAULT_BASE, ANTHROPIC_VERSION};
/// - [`AnthropicSseEncoder`] re-encodes the bridge's `ChatChunk`
/// stream as Anthropic typed SSE events.
pub use wire::{
chat_response_into_anthropic_json, parse_inbound_request,
chat_response_into_anthropic_json, parse_inbound_request, stream_error_into_bridge_error,
translate_anthropic_tool_choice_to_openai, translate_anthropic_tools_to_openai,
translate_extras_to_openai_shape, AnthropicInboundError, AnthropicSseEncoder,
AnthropicSseEvent,
AnthropicSseEvent, AnthropicStreamErrorBody,
};
61 changes: 58 additions & 3 deletions crates/aisix-provider-anthropic/src/wire.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1744,6 +1744,13 @@ pub struct AnthropicSseEncoder {
seen_input_tokens: u32,
seen_output_tokens: u32,
usage_seen: bool,
/// Usage folded ADDITIVELY into the closing pair on top of the
/// `seen_*` counters (mid-stream failover, AISIX-Cloud#1222): the
/// estimated spend of failed partial attempts. Anthropic wire usage
/// is cumulative per message, so the terminal counts must also
/// cover the partial text the client received before the switch.
extra_input_tokens: u32,
extra_output_tokens: u32,
}

impl AnthropicSseEncoder {
Expand All @@ -1770,9 +1777,49 @@ impl AnthropicSseEncoder {
seen_input_tokens: 0,
seen_output_tokens: 0,
usage_seen: false,
extra_input_tokens: 0,
extra_output_tokens: 0,
}
}

/// Rebuild an encoder positioned mid-message, for the passthrough
/// mid-stream failover (AISIX-Cloud#1222): the client already holds
/// the upstream's own `message_start` (and possibly an open text
/// block), so the resumed encoder must continue that envelope
/// instead of opening its own. `open_text_block` is the index of
/// the text content block the upstream left open (`None` → the
/// next text delta opens a fresh block at `next_block_index`).
pub fn resume(
message_id: impl Into<String>,
model_display_name: impl Into<String>,
open_text_block: Option<usize>,
next_block_index: usize,
) -> Self {
let mut enc = Self::new(message_id, model_display_name, 0);
enc.sent_message_start = true;
enc.text_block_index = open_text_block;
enc.next_block_index = next_block_index;
enc
}

/// Fold failed-attempt usage additively into the closing pair (see
/// `extra_input_tokens`). Refreshed by the pump whenever the
/// serving attempt changes.
pub fn set_extra_usage(&mut self, input_tokens: u32, output_tokens: u32) {
self.extra_input_tokens = input_tokens;
self.extra_output_tokens = output_tokens;
}

/// Zero the cumulative usage counters when the serving attempt
/// changes (mid-stream failover): max-wins folding across attempts
/// would otherwise mix the failed attempt's counters into the
/// serving attempt's totals.
pub fn reset_usage_counters(&mut self) {
self.seen_input_tokens = 0;
self.seen_output_tokens = 0;
self.usage_seen = false;
}

/// Translate one chunk into the Anthropic SSE events to emit.
/// Returns an empty Vec on no-op chunks.
pub fn next_events(&mut self, chunk: &ChatChunk) -> Vec<AnthropicSseEvent> {
Expand Down Expand Up @@ -1951,10 +1998,18 @@ impl AnthropicSseEncoder {
/// place the client can learn the prompt token count.
fn closing_pair(&mut self, stop_reason: &'static str) -> Vec<AnthropicSseEvent> {
let mut usage = serde_json::Map::new();
if self.seen_input_tokens > 0 {
usage.insert("input_tokens".into(), self.seen_input_tokens.into());
let input_total = self
.seen_input_tokens
.saturating_add(self.extra_input_tokens);
if input_total > 0 {
usage.insert("input_tokens".into(), input_total.into());
}
usage.insert("output_tokens".into(), self.seen_output_tokens.into());
usage.insert(
"output_tokens".into(),
self.seen_output_tokens
.saturating_add(self.extra_output_tokens)
.into(),
);
self.finished = true;
vec![
AnthropicSseEvent {
Expand Down
61 changes: 53 additions & 8 deletions crates/aisix-proxy/src/chat.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1665,6 +1665,8 @@ async fn dispatch(
upstream,
crate::stream_failover::MidStreamPlan {
cfg,
endpoint: crate::stream_failover::MidStreamEndpoint::Chat,
structured_output: crate::stream_failover::expects_structured_output(req),
remaining: attempt_models[winner_target_idx + 1..].to_vec(),
state: state.clone(),
auth: auth.clone(),
Expand Down Expand Up @@ -3794,6 +3796,50 @@ fn emit_usage_event(
guardrail_blocked: bool,
client: &ClientContext,
content: Option<CapturedContent>,
) {
emit_usage_event_stamped(
state,
"chat",
"openai",
request_id,
model_id,
requested_model,
api_key_id,
status_code,
elapsed,
prompt_tokens,
completion_tokens,
extras,
cost_usd,
guardrail_blocked,
client,
content,
)
}

/// `emit_usage_event` with the endpoint stamps explicit — the
/// mid-stream failover per-attempt events reuse chat's emit pipeline
/// (attribution-tag lookup, guardrail counters, OTLP fan-out) from the
/// `/v1/messages` and `/v1/responses` combinators, which stamp their
/// own `inbound_protocol` + usage-sink handler label.
#[allow(clippy::too_many_arguments)]
fn emit_usage_event_stamped(
state: &ProxyState,
sink_label: &'static str,
inbound_protocol: &'static str,
request_id: &str,
model_id: &str,
requested_model: &str,
api_key_id: &str,
status_code: u16,
elapsed: Duration,
prompt_tokens: u32,
completion_tokens: u32,
extras: UsageExtras,
cost_usd: f64,
guardrail_blocked: bool,
client: &ClientContext,
content: Option<CapturedContent>,
) {
// Look up per-PK telemetry attribution tags from the live snapshot.
// Empty `provider_key_id` (pre-dispatch error paths) → default
Expand Down Expand Up @@ -3839,11 +3885,7 @@ fn emit_usage_event(
cache_hit_saved_input_tokens: extras.cache_hit_saved_input_tokens,
cache_hit_saved_output_tokens: extras.cache_hit_saved_output_tokens,
upstream_ttft_ms: extras.upstream_ttft_ms,
// chat.rs is the OpenAI-shape /v1/chat/completions handler.
// /v1/responses / /v1/embeddings / /v1/audio* / /v1/images* /
// /v1/rerank don't emit UsageEvents today; when they do they
// also pass `"openai"` here.
inbound_protocol: "openai".to_string(),
inbound_protocol: inbound_protocol.to_string(),
attempt_index: extras.attempt_index,
attempt_kind: extras.attempt_kind,
attempt_model: extras.attempt_model,
Expand All @@ -3870,10 +3912,10 @@ fn emit_usage_event(
// MCP attribution does not apply to the chat path.
..Default::default()
};
// Handler label "chat" matches the documented enumeration for
// Handler labels match the documented enumeration for
// `aisix_usage_events_emitted_total` (#408). Keep `&'static str`
// so prometheus cardinality stays bounded.
state.usage_sink.try_emit("chat", event.clone());
state.usage_sink.try_emit(sink_label, event.clone());
// Guardrail outcome counters (#379). Recorded here — the one place every
// chat path (success / error / streaming / cache-hit) funnels through —
// from the same guardrail fields the UsageEvent carries.
Expand Down Expand Up @@ -4077,6 +4119,7 @@ fn emit_failed_attempts(
#[allow(clippy::too_many_arguments)]
pub(crate) fn emit_mid_stream_failed_attempt(
state: &ProxyState,
endpoint: crate::stream_failover::MidStreamEndpoint,
request_id: &str,
requested_model: &str,
api_key_id: &str,
Expand All @@ -4086,8 +4129,10 @@ pub(crate) fn emit_mid_stream_failed_attempt(
prompt_tokens: u32,
completion_tokens: u32,
) {
emit_usage_event(
emit_usage_event_stamped(
state,
endpoint.sink_label(),
endpoint.inbound_protocol(),
request_id,
&rec.target_model_id,
requested_model,
Expand Down
Loading
Loading