Files
Aether/crates/aether-ai/formats/src/protocol/stream.rs
T
stabeyandClaude Opus 5 bcb2308000 feat(ai-formats): deliver Gemini grounding to every client as native citations
Gemini runs `googleSearch` inside Google. The search leaves no
client-visible tool call, and the evidence arrives only as
`candidates[].groundingMetadata`. Every cross-format target dropped it
wholesale, so a grounded answer reached OpenAI- and Claude-shaped clients
as prose that names its sources with nothing structured behind it: no
`annotations`, no `citations`, no `url_citation`. Callers that verify
grounding — the common "did this model actually search?" check — saw a
200 with no evidence and had to treat the answer as ungrounded.

Adapters now normalise `groundingMetadata` into neutral citations and
each target renders its own family's standard shape: `url_citation`
annotations for `openai:chat` and `openai:responses`, and
`web_search_result_location` citations on the text block for
`claude:messages`. Gemini reports segment bounds as UTF-8 byte offsets
while both targets count characters, so the bounds are converted rather
than copied.

Streaming is covered too, since that is what grounded traffic actually
uses. A new `CanonicalStreamEvent::Citations` carries the neutral list
once the answer text is whole — the offsets index into the finished
answer, so it rides just ahead of `Finish` rather than as a delta per
chunk — and each client emitter renders it: `delta.annotations` chunks,
`response.output_text.annotation.added` events (also kept on the finished
message item so clients that only read `response.completed` see them),
and `citations_delta` content block deltas.

For reference, CLIProxyAPI projects grounding only in its
antigravity→Claude translator, and only when the client declared a typed
`web_search_*` tool; its OpenAI and plain Gemini translators have no
grounding handling at all. The citation shape here matches theirs, but
the coverage is deliberately wider: all three targets, streaming and
non-streaming, with no dependency on a declared tool.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-17 09:38:04 +08:00

98 lines
2.7 KiB
Rust

use serde::{Deserialize, Serialize};
use serde_json::Value;
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct CanonicalUsage {
pub input_tokens: u64,
/// True when `input_tokens` already includes cache read and cache creation
/// input tokens. Claude-style usage leaves cached input tokens separate.
#[serde(default, skip_serializing_if = "is_false")]
pub input_tokens_include_cache: bool,
pub output_tokens: u64,
pub total_tokens: u64,
pub cache_creation_tokens: u64,
pub cache_creation_ephemeral_5m_tokens: u64,
pub cache_creation_ephemeral_1h_tokens: u64,
pub cache_read_tokens: u64,
pub reasoning_tokens: u64,
}
fn is_false(value: &bool) -> bool {
!*value
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum CanonicalContentPart {
ImageUrl(String),
File {
file_data: Option<String>,
reference: Option<String>,
mime_type: Option<String>,
filename: Option<String>,
},
Audio {
data: String,
format: String,
},
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum CanonicalStreamEvent {
Start,
TextDelta(String),
ReasoningDelta(String),
ReasoningSummaryDone,
ReasoningSignature(String),
ContentPart(CanonicalContentPart),
ImageGenerationCall {
index: usize,
item: Value,
},
OpenAiResponsesOutputItem {
output_index: Option<usize>,
item: Value,
raw_event: Value,
},
ToolCallStart {
index: usize,
call_id: String,
name: String,
},
ToolCallSignature {
index: usize,
signature: String,
},
ToolCallArgumentsDelta {
index: usize,
arguments: String,
},
ToolResultDelta {
index: usize,
tool_use_id: String,
name: Option<String>,
content: String,
},
/// Provider-neutral source citations for the answer text streamed so far.
///
/// Emitted once, just before `Finish`, by providers that ground an answer
/// server-side and report the evidence as metadata instead of a tool call.
/// Each entry carries `url` plus optional `title`, `cited_text` and
/// `start_index`/`end_index` character offsets; every target renders them
/// into its own family's citation shape.
Citations(Vec<Value>),
UnknownEvent(Value),
Finish {
finish_reason: Option<String>,
usage: Option<CanonicalUsage>,
},
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct CanonicalStreamFrame {
pub id: String,
pub model: String,
pub event: CanonicalStreamEvent,
}