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]>
This commit is contained in:
stabey
2026-09-17 09:38:04 +08:00
co-authored by Claude Opus 5
parent 6c92db2ba5
commit bcb2308000
11 changed files with 707 additions and 15 deletions
@@ -74,6 +74,14 @@ pub enum CanonicalStreamEvent {
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>,