mirror of
https://github.com/fawney19/Aether.git
synced 2026-10-06 01:17:46 +08:00
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]>
98 lines
2.7 KiB
Rust
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,
|
|
}
|