Files
new-api/relaykit/relayconvert/internal/gemini_chat/grounding.go
T
Calcium-Ion 0ed497f066 feat(relay): hosted-tool conversion fidelity, reasoning normalization, and billing usage integrity (#7137)
* feat(relaykit): preserve hosted tools across conversions

- add protocol-neutral hosted-tool DTOs, conversion metadata, and loss policies
- bridge citations, grounding metadata, and hosted-tool stream lifecycles
- document the public conversion behavior and channel policy controls

* refactor(relaykit): normalize reasoning and thinking intent

- centralize provider-neutral reasoning intent, effort, and budget mappings
- parse model suffixes at the host entry boundary while preserving provider-owned tails
- keep adaptive Claude thinking and explicit zero-token compatibility consistent

* fix(billing): preserve authoritative usage across relay hops

- carry native BillingUsage sidecars through direct and streamed protocol bridges
- merge partial and terminal usage monotonically with safe fallback settlement
- retain cache metadata, penultimate usage, and per-call Gemini tool surcharges

* feat(relay): bridge Responses with Claude and Gemini protocols

- add direct request, response, and stream converters across supported relay formats
- expose Claude count_tokens and Chat-to-Responses compatibility endpoints
- carry conversion diagnostics through the host while retaining the curated public goldens

* fix(relay): wire relaykit conversions into host channels

- connect handlers, adaptors, and channel settings to the standalone conversion layer
- keep model mapping, pricing identity, retries, and provider-specific suffix behavior aligned
- ignore local audit artifacts and retain focused public regression coverage
2026-09-01 21:53:35 +08:00

368 lines
11 KiB
Go

package geminichat
import (
"fmt"
"strings"
"unicode/utf8"
"github.com/QuantumNous/new-api/relaykit/dto"
kitutil "github.com/QuantumNous/new-api/relaykit/relayconvert/kitutil"
)
type geminiGroundingChunk struct {
Web *geminiGroundingSource `json:"web,omitempty"`
RetrievedContext *geminiGroundingSource `json:"retrievedContext,omitempty"`
}
type geminiGroundingSource struct {
URI string `json:"uri,omitempty"`
Title string `json:"title,omitempty"`
}
type geminiGroundingSupport struct {
Segment struct {
PartIndex *int `json:"partIndex,omitempty"`
StartIndex int `json:"startIndex,omitempty"`
EndIndex int `json:"endIndex,omitempty"`
Text string `json:"text,omitempty"`
} `json:"segment"`
GroundingChunkIndices []int `json:"groundingChunkIndices"`
}
type renderedGeminiPart struct {
text string
startByte int
}
type streamedGeminiPartSpan struct {
partStartByte int
partEndByte int
renderedStartByte int
}
type streamedGeminiPart struct {
text strings.Builder
spans []streamedGeminiPartSpan
}
// geminiGroundingStreamCandidate retains the protocol state needed to resolve
// grounding metadata emitted after the text it describes. Gemini's streaming
// contract makes grounding chunk indexes cumulative across response chunks and
// keeps segment offsets relative to the accumulated candidate part.
type geminiGroundingStreamCandidate struct {
rendered strings.Builder
parts map[int]*streamedGeminiPart
chunks []geminiGroundingChunk
}
// GroundingWebSearchQueries returns the distinct hosted-search queries that
// Gemini reports for a response. The provider may repeat metadata across
// candidates or stream chunks, so callers can safely accumulate this result
// without manufacturing duplicate Responses tool calls.
func GroundingWebSearchQueries(response *dto.GeminiChatResponse) []string {
if response == nil {
return nil
}
queries := make([]string, 0)
seen := make(map[string]struct{})
for candidateIndex := range response.Candidates {
metadata := response.Candidates[candidateIndex].GroundingMetadata
if metadata == nil {
continue
}
for _, query := range metadata.WebSearchQueries {
query = strings.TrimSpace(query)
if query == "" {
continue
}
if _, exists := seen[query]; exists {
continue
}
seen[query] = struct{}{}
queries = append(queries, query)
}
}
return queries
}
func groundingAnnotationsToChat(metadata *dto.GeminiGroundingMetadata, content dto.GeminiChatContent, rendered string) []byte {
if metadata == nil || len(metadata.GroundingChunks) == 0 || len(metadata.GroundingSupports) == 0 {
return nil
}
var chunks []geminiGroundingChunk
if err := kitutil.Unmarshal(metadata.GroundingChunks, &chunks); err != nil {
return nil
}
var supports []geminiGroundingSupport
if err := kitutil.Unmarshal(metadata.GroundingSupports, &supports); err != nil {
return nil
}
parts := locateRenderedGeminiParts(content, rendered)
textPartCount := 0
soleTextPart := -1
for index := range parts {
if parts[index].startByte < 0 {
continue
}
textPartCount++
soleTextPart = index
}
annotations := make([]any, 0)
seen := make(map[string]struct{})
for _, support := range supports {
partIndex := soleTextPart
if support.Segment.PartIndex != nil {
partIndex = *support.Segment.PartIndex
} else if textPartCount != 1 {
continue
}
if partIndex < 0 || partIndex >= len(parts) || parts[partIndex].startByte < 0 {
continue
}
part := parts[partIndex]
start, end, ok := groundingRuneRange(rendered, part, support.Segment.StartIndex, support.Segment.EndIndex)
if !ok {
continue
}
if support.Segment.Text != "" && part.text[support.Segment.StartIndex:support.Segment.EndIndex] != support.Segment.Text {
continue
}
annotations = appendGroundingAnnotations(annotations, chunks, support, start, end, "", seen)
}
return marshalGroundingAnnotations(annotations)
}
func newGeminiGroundingStreamCandidate() *geminiGroundingStreamCandidate {
return &geminiGroundingStreamCandidate{parts: make(map[int]*streamedGeminiPart)}
}
func (s *geminiGroundingStreamCandidate) appendContent(content dto.GeminiChatContent, rendered string) {
if s == nil {
return
}
if s.parts == nil {
s.parts = make(map[int]*streamedGeminiPart)
}
renderedParts := locateRenderedGeminiParts(content, rendered)
renderedBase := s.rendered.Len()
for index := range content.Parts {
partContent := content.Parts[index]
text := partContent.Text
if text == "" || partContent.Thought {
continue
}
part := s.parts[index]
if part == nil {
part = &streamedGeminiPart{}
s.parts[index] = part
}
partStart := part.text.Len()
part.text.WriteString(text)
// A standalone newline is intentionally omitted by the existing Gemini
// renderer. Keep it in the source part so later byte offsets stay correct,
// but do not claim that it has a corresponding rendered span.
if text == "\n" || index >= len(renderedParts) || renderedParts[index].startByte < 0 {
continue
}
renderedStart := renderedBase + renderedParts[index].startByte
part.spans = append(part.spans, streamedGeminiPartSpan{
partStartByte: partStart,
partEndByte: partStart + len(text),
renderedStartByte: renderedStart,
})
}
s.rendered.WriteString(rendered)
}
func (s *geminiGroundingStreamCandidate) appendGroundingChunks(metadata *dto.GeminiGroundingMetadata) {
if s == nil || metadata == nil || len(metadata.GroundingChunks) == 0 {
return
}
var chunks []geminiGroundingChunk
if err := kitutil.Unmarshal(metadata.GroundingChunks, &chunks); err != nil {
return
}
s.chunks = append(s.chunks, chunks...)
}
func (s *geminiGroundingStreamCandidate) groundingAnnotations(
metadata *dto.GeminiGroundingMetadata,
candidateIndex int64,
seen map[string]struct{},
) []byte {
if s == nil || metadata == nil {
return nil
}
s.appendGroundingChunks(metadata)
if len(s.chunks) == 0 || len(metadata.GroundingSupports) == 0 {
return nil
}
var supports []geminiGroundingSupport
if err := kitutil.Unmarshal(metadata.GroundingSupports, &supports); err != nil {
return nil
}
annotations := make([]any, 0)
keyPrefix := fmt.Sprintf("%d:", candidateIndex)
for _, support := range supports {
partIndex, ok := s.groundingPartIndex(support)
if !ok {
continue
}
start, end, ok := s.groundingRuneRange(partIndex, support.Segment.StartIndex, support.Segment.EndIndex)
if !ok {
continue
}
part := s.parts[partIndex]
if support.Segment.Text != "" && part.text.String()[support.Segment.StartIndex:support.Segment.EndIndex] != support.Segment.Text {
continue
}
annotations = appendGroundingAnnotations(annotations, s.chunks, support, start, end, keyPrefix, seen)
}
return marshalGroundingAnnotations(annotations)
}
func (s *geminiGroundingStreamCandidate) groundingPartIndex(support geminiGroundingSupport) (int, bool) {
if support.Segment.PartIndex != nil {
partIndex := *support.Segment.PartIndex
part := s.parts[partIndex]
return partIndex, part != nil && len(part.spans) > 0
}
solePartIndex := -1
for partIndex, part := range s.parts {
if part == nil || len(part.spans) == 0 {
continue
}
if solePartIndex >= 0 {
return 0, false
}
solePartIndex = partIndex
}
return solePartIndex, solePartIndex >= 0
}
func (s *geminiGroundingStreamCandidate) groundingRuneRange(partIndex int, startByte int, endByte int) (int, int, bool) {
if s == nil {
return 0, 0, false
}
part := s.parts[partIndex]
if part == nil {
return 0, 0, false
}
partText := part.text.String()
if startByte < 0 || endByte <= startByte || endByte > len(partText) {
return 0, 0, false
}
if !utf8.ValidString(partText[:startByte]) || !utf8.ValidString(partText[:endByte]) {
return 0, 0, false
}
renderedStart, renderedEnd := -1, -1
for _, span := range part.spans {
if renderedStart < 0 && startByte >= span.partStartByte && startByte < span.partEndByte {
renderedStart = span.renderedStartByte + startByte - span.partStartByte
}
if endByte > span.partStartByte && endByte <= span.partEndByte {
renderedEnd = span.renderedStartByte + endByte - span.partStartByte
}
}
if renderedStart < 0 || renderedEnd <= renderedStart {
return 0, 0, false
}
rendered := s.rendered.String()
if renderedEnd > len(rendered) || rendered[renderedStart:renderedEnd] != partText[startByte:endByte] {
return 0, 0, false
}
if !utf8.ValidString(rendered[:renderedStart]) || !utf8.ValidString(rendered[:renderedEnd]) {
return 0, 0, false
}
return utf8.RuneCountInString(rendered[:renderedStart]), utf8.RuneCountInString(rendered[:renderedEnd]), true
}
func appendGroundingAnnotations(
annotations []any,
chunks []geminiGroundingChunk,
support geminiGroundingSupport,
start int,
end int,
keyPrefix string,
seen map[string]struct{},
) []any {
for _, chunkIndex := range support.GroundingChunkIndices {
if chunkIndex < 0 || chunkIndex >= len(chunks) {
continue
}
source := chunks[chunkIndex].Web
if source == nil {
source = chunks[chunkIndex].RetrievedContext
}
if source == nil || source.URI == "" {
continue
}
key := fmt.Sprintf("%s%d:%d:%s", keyPrefix, start, end, source.URI)
if _, exists := seen[key]; exists {
continue
}
seen[key] = struct{}{}
annotations = append(annotations, map[string]any{
"type": "url_citation",
"url_citation": map[string]any{
"start_index": start,
"end_index": end,
"url": source.URI,
"title": source.Title,
},
})
}
return annotations
}
func marshalGroundingAnnotations(annotations []any) []byte {
if len(annotations) == 0 {
return nil
}
encoded, err := kitutil.Marshal(annotations)
if err != nil {
return nil
}
return encoded
}
func locateRenderedGeminiParts(content dto.GeminiChatContent, rendered string) []renderedGeminiPart {
parts := make([]renderedGeminiPart, len(content.Parts))
cursor := 0
for index := range content.Parts {
part := content.Parts[index]
text := part.Text
parts[index] = renderedGeminiPart{text: text, startByte: -1}
if text == "" || part.Thought || cursor > len(rendered) {
continue
}
relative := strings.Index(rendered[cursor:], text)
if relative < 0 {
continue
}
start := cursor + relative
parts[index].startByte = start
cursor = start + len(text)
}
return parts
}
func groundingRuneRange(rendered string, part renderedGeminiPart, startByte int, endByte int) (int, int, bool) {
if startByte < 0 || endByte <= startByte || endByte > len(part.text) {
return 0, 0, false
}
if !utf8.ValidString(part.text[:startByte]) || !utf8.ValidString(part.text[:endByte]) {
return 0, 0, false
}
partStartRunes := utf8.RuneCountInString(rendered[:part.startByte])
start := partStartRunes + utf8.RuneCountInString(part.text[:startByte])
end := partStartRunes + utf8.RuneCountInString(part.text[:endByte])
return start, end, true
}