feat: publish v1.0.0-rc.36-custom.1
Publish Docker image (Multi-arch) / Build & push (amd64) (push) Failing after 9m20s
Release (Linux, macOS, Windows) / Linux Release (push) Failing after 55m7s
Publish Docker image (Multi-arch) / Build & push (arm64) (push) Canceled after 0s
Release (Linux, macOS, Windows) / macOS Release (push) Canceled after 0s
Release (Linux, macOS, Windows) / Windows Release (push) Canceled after 0s
Publish Docker image (Multi-arch) / Create multi-arch manifests (push) Canceled after 0s

This commit is contained in:
Codex committed 2026-09-11 05:07:48 +08:00
commit bb7968b612
2381 files changed
+551494

No files matched your search

@@ -0,0 +1,367 @@
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
}
@@ -0,0 +1,300 @@
package geminichat
import (
"fmt"
"strings"
"github.com/QuantumNous/new-api/relaykit/dto"
"github.com/QuantumNous/new-api/relaykit/relayconvert/convmeta"
"github.com/QuantumNous/new-api/relaykit/relayconvert/internal/jsonutil"
kitutil "github.com/QuantumNous/new-api/relaykit/relayconvert/kitutil"
"github.com/QuantumNous/new-api/relaykit/relayconvert/reasoning"
)
func GeminiGenerateContentRequestToOpenAIChat(geminiRequest *dto.GeminiChatRequest, info convmeta.Meta) (*dto.GeneralOpenAIRequest, error) {
modelName := ""
isStream := false
if info != nil {
isStream = info.GetIsStream()
}
modelName = convmeta.UpstreamModelName(info)
openaiRequest := &dto.GeneralOpenAIRequest{
Model: modelName,
Stream: kitutil.GetPointer(isStream),
}
reasoningIntent, err := reasoning.FromGemini(geminiRequest)
if err != nil {
return nil, reasoning.AsClientError(err)
}
sourceModelName := modelName
if info != nil && info.GetOriginModelName() != "" {
sourceModelName = info.GetOriginModelName()
}
baseSourceModel := sourceModelName
opts := convmeta.OptionsOf(info)
preserveSuffix := opts.ShouldPreserveThinkingSuffix(sourceModelName)
if !preserveSuffix {
if suffix := reasoning.IntentFromState(convmeta.ReasoningStateOf(info)); !suffix.IsEmpty() {
reasoningIntent, err = reasoning.MergeExplicitAndSuffix(reasoningIntent, suffix, sourceModelName)
if err != nil {
return nil, reasoning.AsClientError(err)
}
}
}
if baseSourceModel != "" && geminiRequest.GenerationConfig.ThinkingConfig != nil {
_, err = reasoning.ValidateGeminiThinkingConfig(baseSourceModel, geminiRequest.GenerationConfig.ThinkingConfig)
if err != nil {
return nil, reasoning.AsClientError(err)
}
}
reasoningIntent = reasoning.ResolveGeminiDefault(baseSourceModel, reasoningIntent)
effectiveEffort := reasoning.EffectiveEffort(reasoningIntent)
if err := reasoning.ApplyToOpenAIChat(openaiRequest, reasoningIntent); err != nil {
return nil, reasoning.AsClientError(err)
}
if effectiveEffort != "" && info != nil {
info.SetReasoningEffort(string(effectiveEffort))
}
callHistory := newGeminiFunctionCallHistory(geminiRequest.Contents)
var messages []dto.Message
for _, content := range geminiRequest.Contents {
message := dto.Message{
Role: convertGeminiRoleToOpenAI(content.Role),
}
var mediaContents []dto.MediaContent
var toolCalls []dto.ToolCallRequest
var reasoningTexts []string
for _, part := range content.Parts {
if part.Text != "" {
if part.Thought {
reasoningTexts = append(reasoningTexts, part.Text)
continue
}
mediaContent := dto.MediaContent{
Type: "text",
Text: part.Text,
}
mediaContents = append(mediaContents, mediaContent)
} else if part.InlineData != nil {
mediaContent := dto.MediaContent{
Type: "image_url",
ImageUrl: &dto.MessageImageUrl{
Url: fmt.Sprintf("data:%s;base64,%s", part.InlineData.MimeType, part.InlineData.Data),
Detail: "auto",
MimeType: part.InlineData.MimeType,
},
}
mediaContents = append(mediaContents, mediaContent)
} else if part.FileData != nil {
mediaContent := dto.MediaContent{
Type: "image_url",
ImageUrl: &dto.MessageImageUrl{
Url: part.FileData.FileUri,
Detail: "auto",
MimeType: part.FileData.MimeType,
},
}
mediaContents = append(mediaContents, mediaContent)
} else if part.FunctionCall != nil {
toolCall := dto.ToolCallRequest{
ID: callHistory.add(part.FunctionCall),
Type: "function",
Function: dto.FunctionRequest{
Name: part.FunctionCall.FunctionName,
Arguments: jsonutil.ToJSONString(part.FunctionCall.Arguments),
},
}
toolCalls = append(toolCalls, toolCall)
} else if part.FunctionResponse != nil {
toolMessage := dto.Message{
Role: "tool",
ToolCallId: callHistory.match(part.FunctionResponse),
}
toolMessage.SetStringContent(jsonutil.ToJSONString(part.FunctionResponse.Response))
messages = append(messages, toolMessage)
}
}
if len(toolCalls) > 0 {
message.SetToolCalls(toolCalls)
} else if len(mediaContents) == 1 && mediaContents[0].Type == "text" {
message.Content = mediaContents[0].Text
} else if len(mediaContents) > 0 {
message.SetMediaContent(mediaContents)
}
if len(reasoningTexts) > 0 {
reasoningContent := strings.Join(reasoningTexts, "\n")
message.ReasoningContent = &reasoningContent
}
if len(message.ParseContent()) > 0 || len(message.ToolCalls) > 0 || message.ReasoningContent != nil {
messages = append(messages, message)
}
}
openaiRequest.Messages = messages
if geminiRequest.GenerationConfig.Temperature != nil {
openaiRequest.Temperature = geminiRequest.GenerationConfig.Temperature
}
if geminiRequest.GenerationConfig.TopP != nil {
openaiRequest.TopP = kitutil.GetPointer(*geminiRequest.GenerationConfig.TopP)
}
if geminiRequest.GenerationConfig.TopK != nil {
openaiRequest.TopK = kitutil.GetPointer(int(*geminiRequest.GenerationConfig.TopK))
}
if geminiRequest.GenerationConfig.MaxOutputTokens != nil {
openaiRequest.MaxTokens = kitutil.GetPointer(*geminiRequest.GenerationConfig.MaxOutputTokens)
}
if len(geminiRequest.GenerationConfig.StopSequences) > 0 {
openaiRequest.Stop = geminiRequest.GenerationConfig.StopSequences[:min(len(geminiRequest.GenerationConfig.StopSequences), 4)]
}
if geminiRequest.GenerationConfig.CandidateCount != nil {
openaiRequest.N = kitutil.GetPointer(*geminiRequest.GenerationConfig.CandidateCount)
}
if len(geminiRequest.GetTools()) > 0 {
var tools []dto.ToolCallRequest
for _, tool := range geminiRequest.GetTools() {
if tool.FunctionDeclarations == nil {
continue
}
functionDeclarations, err := kitutil.Any2Type[[]dto.FunctionRequest](tool.FunctionDeclarations)
if err != nil {
kitutil.LogSystemError(fmt.Sprintf("failed to parse gemini function declarations: %v (type=%T)", err, tool.FunctionDeclarations))
continue
}
for _, function := range functionDeclarations {
openAITool := dto.ToolCallRequest{
Type: "function",
Function: dto.FunctionRequest{
Name: function.Name,
Description: function.Description,
Parameters: function.Parameters,
},
}
tools = append(tools, openAITool)
}
}
if len(tools) > 0 {
openaiRequest.Tools = tools
}
}
if geminiRequest.SystemInstructions != nil {
systemMessage := dto.Message{
Role: "system",
Content: extractTextFromGeminiParts(geminiRequest.SystemInstructions.Parts),
}
openaiRequest.Messages = append([]dto.Message{systemMessage}, openaiRequest.Messages...)
}
return openaiRequest, nil
}
type geminiPendingFunctionCall struct {
id string
name string
}
// geminiFunctionCallHistory keeps legacy Gemini histories without call IDs
// correlated across content boundaries. Named matching permits results for
// different parallel functions to arrive out of order; same-name calls use
// their original call order because old payloads contain no stronger identity.
type geminiFunctionCallHistory struct {
reservedIDs map[string]struct{}
pending []geminiPendingFunctionCall
nextID int
}
func newGeminiFunctionCallHistory(contents []dto.GeminiChatContent) *geminiFunctionCallHistory {
history := &geminiFunctionCallHistory{
reservedIDs: make(map[string]struct{}),
nextID: 1,
}
for _, content := range contents {
for _, part := range content.Parts {
if part.FunctionCall != nil && part.FunctionCall.ID != "" {
history.reservedIDs[part.FunctionCall.ID] = struct{}{}
}
if part.FunctionResponse != nil {
if id := kitutil.JsonRawMessageToString(part.FunctionResponse.ID); id != "" {
history.reservedIDs[id] = struct{}{}
}
}
}
}
return history
}
func (h *geminiFunctionCallHistory) add(call *dto.FunctionCall) string {
id := call.ID
if id == "" {
id = h.newFallbackID()
}
h.pending = append(h.pending, geminiPendingFunctionCall{id: id, name: call.FunctionName})
return id
}
func (h *geminiFunctionCallHistory) match(response *dto.GeminiFunctionResponse) string {
if id := kitutil.JsonRawMessageToString(response.ID); id != "" {
h.removePendingByID(id)
return id
}
for i, call := range h.pending {
if response.Name != "" && call.name != response.Name {
continue
}
h.pending = append(h.pending[:i], h.pending[i+1:]...)
return call.id
}
return h.newFallbackID()
}
func (h *geminiFunctionCallHistory) removePendingByID(id string) {
for i, call := range h.pending {
if call.id != id {
continue
}
h.pending = append(h.pending[:i], h.pending[i+1:]...)
return
}
}
func (h *geminiFunctionCallHistory) newFallbackID() string {
for {
id := fmt.Sprintf("call_%d", h.nextID)
h.nextID++
if _, exists := h.reservedIDs[id]; exists {
continue
}
h.reservedIDs[id] = struct{}{}
return id
}
}
func convertGeminiRoleToOpenAI(geminiRole string) string {
switch geminiRole {
case "user":
return "user"
case "model":
return "assistant"
case "function":
return "function"
default:
return "user"
}
}
func extractTextFromGeminiParts(parts []dto.GeminiPart) string {
texts := make([]string, 0)
for _, part := range parts {
if part.Text != "" {
texts = append(texts, part.Text)
}
}
return strings.Join(texts, "\n")
}
@@ -0,0 +1,739 @@
package geminichat
import (
"fmt"
"sort"
"strconv"
"strings"
"github.com/QuantumNous/new-api/relaykit/dto"
kitutil "github.com/QuantumNous/new-api/relaykit/relayconvert/kitutil"
"github.com/QuantumNous/new-api/relaykit/types"
)
func UsageFromGeminiMetadata(metadata *dto.GeminiUsageMetadata, fallbackPromptTokens int) *dto.Usage {
if metadata == nil {
if fallbackPromptTokens <= 0 {
return nil
}
usage := &dto.Usage{PromptTokens: fallbackPromptTokens}
usage.PromptTokensDetails.TextTokens = fallbackPromptTokens
return usage
}
promptTokens := metadata.PromptTokenCount + metadata.ToolUsePromptTokenCount
if promptTokens <= 0 && fallbackPromptTokens > 0 {
promptTokens = fallbackPromptTokens
}
usage := &dto.Usage{
PromptTokens: promptTokens,
CompletionTokens: metadata.CandidatesTokenCount + metadata.ThoughtsTokenCount,
TotalTokens: metadata.TotalTokenCount,
BillingUsage: dto.CloneBillingUsage(metadata.BillingUsage),
}
if usage.BillingUsage == nil {
usage.BillingUsage = dto.NewGeminiChatBillingUsage(metadata)
}
usage.CompletionTokenDetails.ReasoningTokens = metadata.ThoughtsTokenCount
usage.PromptTokensDetails.CachedTokens = metadata.CachedContentTokenCount
for _, detail := range metadata.PromptTokensDetails {
if detail.Modality == "AUDIO" {
usage.PromptTokensDetails.AudioTokens += detail.TokenCount
} else if detail.Modality == "IMAGE" {
usage.PromptTokensDetails.ImageTokens += detail.TokenCount
} else if detail.Modality == "TEXT" {
usage.PromptTokensDetails.TextTokens += detail.TokenCount
}
}
for _, detail := range metadata.ToolUsePromptTokensDetails {
if detail.Modality == "AUDIO" {
usage.PromptTokensDetails.AudioTokens += detail.TokenCount
} else if detail.Modality == "IMAGE" {
usage.PromptTokensDetails.ImageTokens += detail.TokenCount
} else if detail.Modality == "TEXT" {
usage.PromptTokensDetails.TextTokens += detail.TokenCount
}
}
for _, detail := range metadata.CandidatesTokensDetails {
switch detail.Modality {
case "IMAGE":
usage.CompletionTokenDetails.ImageTokens += detail.TokenCount
case "AUDIO":
usage.CompletionTokenDetails.AudioTokens += detail.TokenCount
case "TEXT":
usage.CompletionTokenDetails.TextTokens += detail.TokenCount
}
}
if usage.TotalTokens > 0 && usage.CompletionTokens <= 0 {
usage.CompletionTokens = usage.TotalTokens - usage.PromptTokens
}
if usage.PromptTokens > 0 && usage.PromptTokensDetails.TextTokens == 0 && usage.PromptTokensDetails.AudioTokens == 0 {
usage.PromptTokensDetails.TextTokens = usage.PromptTokens
}
return usage
}
func ResponseGeminiChat2OpenAI(id string, created int64, response *dto.GeminiChatResponse) *dto.OpenAITextResponse {
fullTextResponse := dto.OpenAITextResponse{
Id: id,
Object: "chat.completion",
Created: created,
Choices: make([]dto.OpenAITextResponseChoice, 0, len(response.Candidates)),
}
isToolCall := false
for _, candidate := range response.Candidates {
choice := dto.OpenAITextResponseChoice{
Index: int(candidate.Index),
Message: dto.Message{
Role: "assistant",
Content: "",
},
FinishReason: types.FinishReasonStop,
}
if len(candidate.Content.Parts) > 0 {
var content strings.Builder
var inlineGrow int
for _, part := range candidate.Content.Parts {
if part.InlineData != nil {
inlineGrow += len(part.InlineData.MimeType) + len(part.InlineData.Data) + 32
}
}
if inlineGrow > 0 {
content.Grow(inlineGrow)
}
appended := 0
writeSep := func() {
if appended > 0 {
content.WriteByte('\n')
}
appended++
}
var toolCalls []dto.ToolCallResponse
for _, part := range candidate.Content.Parts {
if part.InlineData != nil {
if strings.HasPrefix(part.InlineData.MimeType, "image") {
writeSep()
content.WriteString("![image](data:")
content.WriteString(part.InlineData.MimeType)
content.WriteString(";base64,")
content.WriteString(part.InlineData.Data)
content.WriteByte(')')
} else {
writeSep()
content.WriteString("[media](data:")
content.WriteString(part.InlineData.MimeType)
content.WriteString(";base64,")
content.WriteString(part.InlineData.Data)
content.WriteByte(')')
}
} else if part.FunctionCall != nil {
choice.FinishReason = types.FinishReasonToolCalls
if call := geminiResponseToolCall(&part); call != nil {
toolCalls = append(toolCalls, *call)
}
} else if part.Thought {
choice.Message.ReasoningContent = &part.Text
} else {
if part.ExecutableCode != nil {
writeSep()
content.WriteString("```")
content.WriteString(part.ExecutableCode.Language)
content.WriteByte('\n')
content.WriteString(part.ExecutableCode.Code)
content.WriteString("\n```")
} else if part.CodeExecutionResult != nil {
writeSep()
content.WriteString("```output\n")
content.WriteString(part.CodeExecutionResult.Output)
content.WriteString("\n```")
} else if part.Text != "\n" {
writeSep()
content.WriteString(part.Text)
}
}
}
if len(toolCalls) > 0 {
choice.Message.SetToolCalls(toolCalls)
isToolCall = true
}
choice.Message.SetStringContent(content.String())
}
if candidate.FinishReason != nil {
switch *candidate.FinishReason {
case "STOP":
choice.FinishReason = types.FinishReasonStop
case "MAX_TOKENS":
choice.FinishReason = types.FinishReasonLength
case "SAFETY", "RECITATION", "BLOCKLIST", "PROHIBITED_CONTENT", "SPII", "OTHER":
choice.FinishReason = types.FinishReasonContentFilter
default:
choice.FinishReason = types.FinishReasonContentFilter
}
}
if isToolCall {
choice.FinishReason = types.FinishReasonToolCalls
}
choice.Message.Annotations = groundingAnnotationsToChat(candidate.GroundingMetadata, candidate.Content, choice.Message.StringContent())
fullTextResponse.Choices = append(fullTextResponse.Choices, choice)
}
return &fullTextResponse
}
func StreamResponseGeminiChat2OpenAI(geminiResponse *dto.GeminiChatResponse) (*dto.ChatCompletionsStreamResponse, bool) {
choices := make([]dto.ChatCompletionsStreamResponseChoice, 0, len(geminiResponse.Candidates))
isStop := false
for _, candidate := range geminiResponse.Candidates {
if candidate.FinishReason != nil && *candidate.FinishReason == "STOP" {
isStop = true
candidate.FinishReason = nil
}
choice := dto.ChatCompletionsStreamResponseChoice{
Index: int(candidate.Index),
Delta: dto.ChatCompletionsStreamResponseChoiceDelta{},
}
var content strings.Builder
var inlineGrow int
for _, part := range candidate.Content.Parts {
if part.InlineData != nil {
inlineGrow += len(part.InlineData.MimeType) + len(part.InlineData.Data) + 32
}
}
if inlineGrow > 0 {
content.Grow(inlineGrow)
}
appended := 0
writeSep := func() {
if appended > 0 {
content.WriteByte('\n')
}
appended++
}
isTools := false
isThought := false
if candidate.FinishReason != nil {
switch *candidate.FinishReason {
case "STOP":
choice.FinishReason = &types.FinishReasonStop
case "MAX_TOKENS":
choice.FinishReason = &types.FinishReasonLength
case "SAFETY", "RECITATION", "BLOCKLIST", "PROHIBITED_CONTENT", "SPII", "OTHER":
choice.FinishReason = &types.FinishReasonContentFilter
default:
choice.FinishReason = &types.FinishReasonContentFilter
}
}
for _, part := range candidate.Content.Parts {
if part.InlineData != nil {
if strings.HasPrefix(part.InlineData.MimeType, "image") {
writeSep()
content.WriteString("![image](data:")
content.WriteString(part.InlineData.MimeType)
content.WriteString(";base64,")
content.WriteString(part.InlineData.Data)
content.WriteByte(')')
}
} else if part.FunctionCall != nil {
isTools = true
if call := geminiResponseToolCall(&part); call != nil {
call.SetIndex(len(choice.Delta.ToolCalls))
choice.Delta.ToolCalls = append(choice.Delta.ToolCalls, *call)
}
} else if part.Thought {
isThought = true
writeSep()
content.WriteString(part.Text)
} else {
if part.ExecutableCode != nil {
writeSep()
content.WriteString("```")
content.WriteString(part.ExecutableCode.Language)
content.WriteByte('\n')
content.WriteString(part.ExecutableCode.Code)
content.WriteString("\n```\n")
} else if part.CodeExecutionResult != nil {
writeSep()
content.WriteString("```output\n")
content.WriteString(part.CodeExecutionResult.Output)
content.WriteString("\n```\n")
} else if part.Text != "\n" {
writeSep()
content.WriteString(part.Text)
}
}
}
if isThought {
choice.Delta.SetReasoningContent(content.String())
} else {
choice.Delta.SetContentString(content.String())
}
if isTools {
choice.FinishReason = &types.FinishReasonToolCalls
}
choice.Delta.Annotations = groundingAnnotationsToChat(candidate.GroundingMetadata, candidate.Content, content.String())
choices = append(choices, choice)
}
response := dto.ChatCompletionsStreamResponse{
Object: "chat.completion.chunk",
Choices: choices,
}
// Only attach usage the chunk actually reported. Do not fall back to a
// local prompt estimate — converters treat this as first-frame truth.
if metadata := geminiResponse.GetUsageMetadata(); dto.HasGeminiUsageMetadataTokens(metadata) {
response.Usage = UsageFromGeminiMetadata(metadata, 0)
}
return &response, isStop
}
type GeminiToChatStreamState struct {
id string
created int64
sawToolCall bool
finishEmitted bool
latestUsage *dto.Usage
// Gemini generateContent streams complete function calls. Keep their
// occurrence indexes monotonic because chunk-local indexes restart at zero.
nextToolIndexByCandidate map[int64]int
toolIndexByCandidateID map[int64]map[string]int
partialToolByCandidate map[int64]*geminiPartialToolCall
groundingByCandidate map[int64]*geminiGroundingStreamCandidate
sentGroundingAnnotations map[string]struct{}
}
type geminiPartialToolCall struct {
id string
name string
arguments map[string]interface{}
}
type geminiPartialArgPathSegment struct {
member string
index int
isIndex bool
}
const maxGeminiPartialArgArrayIndex = 4095
func NewGeminiToChatStreamState(id string, created int64) *GeminiToChatStreamState {
id = strings.TrimSpace(id)
if id == "" {
id = fmt.Sprintf("chatcmpl-%s", kitutil.GetUUID())
}
if created == 0 {
created = kitutil.GetTimestamp()
}
return &GeminiToChatStreamState{
id: id,
created: created,
nextToolIndexByCandidate: make(map[int64]int),
toolIndexByCandidateID: make(map[int64]map[string]int),
partialToolByCandidate: make(map[int64]*geminiPartialToolCall),
groundingByCandidate: make(map[int64]*geminiGroundingStreamCandidate),
sentGroundingAnnotations: make(map[string]struct{}),
}
}
func (s *GeminiToChatStreamState) ConvertChunk(geminiResponse *dto.GeminiChatResponse, model string, usage *dto.Usage) ([]*dto.ChatCompletionsStreamResponse, error) {
if s == nil || geminiResponse == nil {
return nil, nil
}
if s.groundingByCandidate == nil {
s.groundingByCandidate = make(map[int64]*geminiGroundingStreamCandidate)
}
if s.sentGroundingAnnotations == nil {
s.sentGroundingAnnotations = make(map[string]struct{})
}
if s.nextToolIndexByCandidate == nil {
s.nextToolIndexByCandidate = make(map[int64]int)
}
if s.toolIndexByCandidateID == nil {
s.toolIndexByCandidateID = make(map[int64]map[string]int)
}
if s.partialToolByCandidate == nil {
s.partialToolByCandidate = make(map[int64]*geminiPartialToolCall)
}
var err error
geminiResponse, err = s.preparePartialFunctionCalls(geminiResponse)
if err != nil {
return nil, err
}
hasNonStopFinish := false
for _, candidate := range geminiResponse.Candidates {
if candidate.FinishReason != nil && *candidate.FinishReason != "" && *candidate.FinishReason != "STOP" {
hasNonStopFinish = true
break
}
}
response, isStop := StreamResponseGeminiChat2OpenAI(geminiResponse)
if response == nil {
return nil, nil
}
response.Id = s.id
response.Created = s.created
response.Model = model
response.Usage = usage
for index := range geminiResponse.Candidates {
if index >= len(response.Choices) {
break
}
candidate := &geminiResponse.Candidates[index]
choice := &response.Choices[index]
for toolIndex := range choice.Delta.ToolCalls {
callID := strings.TrimSpace(choice.Delta.ToolCalls[toolIndex].ID)
indexesByID := s.toolIndexByCandidateID[candidate.Index]
if indexesByID == nil {
indexesByID = make(map[string]int)
s.toolIndexByCandidateID[candidate.Index] = indexesByID
}
stableIndex, exists := indexesByID[callID]
if callID == "" || !exists {
stableIndex = s.nextToolIndexByCandidate[candidate.Index]
s.nextToolIndexByCandidate[candidate.Index] = stableIndex + 1
if callID != "" {
indexesByID[callID] = stableIndex
}
}
choice.Delta.ToolCalls[toolIndex].SetIndex(stableIndex)
}
grounding := s.groundingByCandidate[candidate.Index]
if grounding == nil {
grounding = newGeminiGroundingStreamCandidate()
s.groundingByCandidate[candidate.Index] = grounding
}
grounding.appendContent(candidate.Content, response.Choices[index].Delta.GetContentString())
response.Choices[index].Delta.Annotations = grounding.groundingAnnotations(
candidate.GroundingMetadata,
candidate.Index,
s.sentGroundingAnnotations,
)
}
if response.IsToolCall() {
s.sawToolCall = true
if !hasNonStopFinish {
for i := range response.Choices {
if response.Choices[i].FinishReason != nil && *response.Choices[i].FinishReason == types.FinishReasonToolCalls {
response.Choices[i].FinishReason = nil
}
}
}
}
if usage != nil {
s.latestUsage = usage
}
for _, choice := range response.Choices {
if choice.FinishReason != nil && *choice.FinishReason != "" {
s.finishEmitted = true
break
}
}
responses := []*dto.ChatCompletionsStreamResponse{response}
if isStop && !s.finishEmitted {
responses = append(responses, s.terminalChunk(model))
}
return responses, nil
}
func (s *GeminiToChatStreamState) Finalize(model string) ([]*dto.ChatCompletionsStreamResponse, error) {
if s == nil {
return nil, nil
}
if len(s.partialToolByCandidate) > 0 {
candidateIndexes := make([]int64, 0, len(s.partialToolByCandidate))
for candidateIndex := range s.partialToolByCandidate {
candidateIndexes = append(candidateIndexes, candidateIndex)
}
sort.Slice(candidateIndexes, func(i, j int) bool {
return candidateIndexes[i] < candidateIndexes[j]
})
candidateIndex := candidateIndexes[0]
partial := s.partialToolByCandidate[candidateIndex]
return nil, fmt.Errorf("Gemini stream ended with an incomplete function call for candidate %d (id %q, name %q)", candidateIndex, partial.id, partial.name)
}
if s.finishEmitted {
return nil, nil
}
return []*dto.ChatCompletionsStreamResponse{s.terminalChunk(model)}, nil
}
func (s *GeminiToChatStreamState) Usage() *dto.Usage {
if s == nil {
return nil
}
return s.latestUsage
}
func (s *GeminiToChatStreamState) preparePartialFunctionCalls(response *dto.GeminiChatResponse) (*dto.GeminiChatResponse, error) {
prepared := *response
prepared.Candidates = append([]dto.GeminiChatCandidate(nil), response.Candidates...)
for candidateIndex := range prepared.Candidates {
candidate := &prepared.Candidates[candidateIndex]
parts := make([]dto.GeminiPart, 0, len(candidate.Content.Parts))
for _, part := range candidate.Content.Parts {
call := part.FunctionCall
if call == nil || (s.partialToolByCandidate[candidate.Index] == nil && call.WillContinue == nil && len(call.PartialArgs) == 0) {
parts = append(parts, part)
continue
}
completed, ready, err := s.appendPartialFunctionCall(candidate.Index, call)
if err != nil {
return nil, fmt.Errorf("reconstruct Gemini streamed function arguments: %w", err)
}
if ready {
part.FunctionCall = completed
parts = append(parts, part)
}
}
candidate.Content.Parts = parts
}
return &prepared, nil
}
func (s *GeminiToChatStreamState) appendPartialFunctionCall(candidateIndex int64, call *dto.FunctionCall) (*dto.FunctionCall, bool, error) {
current := s.partialToolByCandidate[candidateIndex]
if current == nil {
current = &geminiPartialToolCall{arguments: make(map[string]interface{})}
s.partialToolByCandidate[candidateIndex] = current
}
if id := strings.TrimSpace(call.ID); id != "" {
if current.id != "" && current.id != id {
return nil, false, fmt.Errorf("candidate %d function call changed id from %q to %q", candidateIndex, current.id, id)
}
current.id = id
}
if name := strings.TrimSpace(call.FunctionName); name != "" {
if current.name != "" && current.name != name {
return nil, false, fmt.Errorf("candidate %d function call changed name from %q to %q", candidateIndex, current.name, name)
}
current.name = name
}
for _, partial := range call.PartialArgs {
path, err := parseGeminiPartialArgPath(partial.JSONPath)
if err != nil {
return nil, false, err
}
value, present := geminiPartialArgValue(partial)
if !present {
continue
}
updated, err := setGeminiPartialArgValue(current.arguments, path, value, partial.StringValue != nil)
if err != nil {
return nil, false, fmt.Errorf("set partial argument %q: %w", partial.JSONPath, err)
}
arguments, ok := updated.(map[string]interface{})
if !ok {
return nil, false, fmt.Errorf("partial argument path %q replaced the arguments object", partial.JSONPath)
}
current.arguments = arguments
}
if call.WillContinue != nil && *call.WillContinue {
return nil, false, nil
}
if current.name == "" {
return nil, false, fmt.Errorf("candidate %d completed a partial function call without a name", candidateIndex)
}
completed := &dto.FunctionCall{ID: current.id, FunctionName: current.name, Arguments: current.arguments}
delete(s.partialToolByCandidate, candidateIndex)
return completed, true, nil
}
func parseGeminiPartialArgPath(jsonPath string) ([]geminiPartialArgPathSegment, error) {
path := strings.TrimSpace(jsonPath)
if path == "" || path[0] != '$' {
return nil, fmt.Errorf("unsupported Gemini partial argument path %q", jsonPath)
}
segments := make([]geminiPartialArgPathSegment, 0)
for offset := 1; offset < len(path); {
switch path[offset] {
case '.':
offset++
start := offset
for offset < len(path) && path[offset] != '.' && path[offset] != '[' {
offset++
}
if start == offset {
return nil, fmt.Errorf("empty member in Gemini partial argument path %q", jsonPath)
}
member := path[start:offset]
if strings.ContainsAny(member, "]*?") {
return nil, fmt.Errorf("unsupported member %q in Gemini partial argument path", member)
}
segments = append(segments, geminiPartialArgPathSegment{member: member})
case '[':
offset++
if offset >= len(path) {
return nil, fmt.Errorf("unterminated selector in Gemini partial argument path %q", jsonPath)
}
if path[offset] == '\'' || path[offset] == '"' {
member, next, err := parseGeminiPartialArgMember(path, offset)
if err != nil {
return nil, fmt.Errorf("invalid Gemini partial argument path %q: %w", jsonPath, err)
}
offset = next
if offset >= len(path) || path[offset] != ']' {
return nil, fmt.Errorf("unterminated member selector in Gemini partial argument path %q", jsonPath)
}
offset++
segments = append(segments, geminiPartialArgPathSegment{member: member})
continue
}
start := offset
for offset < len(path) && path[offset] >= '0' && path[offset] <= '9' {
offset++
}
if start == offset || offset >= len(path) || path[offset] != ']' {
return nil, fmt.Errorf("unsupported selector in Gemini partial argument path %q", jsonPath)
}
index, err := strconv.Atoi(path[start:offset])
if err != nil {
return nil, fmt.Errorf("invalid array index in Gemini partial argument path %q: %w", jsonPath, err)
}
if index > maxGeminiPartialArgArrayIndex {
return nil, fmt.Errorf("array index %d exceeds Gemini partial argument materialization limit %d", index, maxGeminiPartialArgArrayIndex)
}
offset++
segments = append(segments, geminiPartialArgPathSegment{index: index, isIndex: true})
default:
return nil, fmt.Errorf("unsupported selector at offset %d in Gemini partial argument path %q", offset, jsonPath)
}
}
if len(segments) == 0 {
return nil, fmt.Errorf("Gemini partial argument path %q targets the arguments root", jsonPath)
}
return segments, nil
}
func geminiPartialArgValue(partial dto.GeminiPartialArg) (any, bool) {
switch {
case partial.StringValue != nil:
return *partial.StringValue, true
case partial.NumberValue != nil:
return *partial.NumberValue, true
case partial.BoolValue != nil:
return *partial.BoolValue, true
case partial.NullValue != nil:
return nil, true
default:
return nil, false
}
}
func parseGeminiPartialArgMember(path string, offset int) (string, int, error) {
quote := path[offset]
start := offset
offset++
for offset < len(path) {
if path[offset] == '\\' {
offset += 2
continue
}
if path[offset] == quote {
raw := path[start : offset+1]
if quote == '\'' {
raw = `"` + strings.ReplaceAll(strings.ReplaceAll(raw[1:len(raw)-1], `"`, `\"`), `\'`, `'`) + `"`
}
var member string
if err := kitutil.Unmarshal([]byte(raw), &member); err != nil {
return "", 0, err
}
return member, offset + 1, nil
}
offset++
}
return "", 0, fmt.Errorf("unterminated quoted member")
}
func setGeminiPartialArgValue(current any, path []geminiPartialArgPathSegment, value any, appendString bool) (any, error) {
if len(path) == 0 {
if appendString {
if existing, ok := current.(string); ok {
return existing + value.(string), nil
}
}
return value, nil
}
segment := path[0]
if segment.isIndex {
var array []interface{}
switch typed := current.(type) {
case nil:
array = make([]interface{}, segment.index+1)
case []interface{}:
array = typed
if len(array) <= segment.index {
array = append(array, make([]interface{}, segment.index-len(array)+1)...)
}
default:
return nil, fmt.Errorf("array index %d traverses %T", segment.index, current)
}
updated, err := setGeminiPartialArgValue(array[segment.index], path[1:], value, appendString)
if err != nil {
return nil, err
}
array[segment.index] = updated
return array, nil
}
var object map[string]interface{}
switch typed := current.(type) {
case nil:
object = make(map[string]interface{})
case map[string]interface{}:
object = typed
default:
return nil, fmt.Errorf("member %q traverses %T", segment.member, current)
}
updated, err := setGeminiPartialArgValue(object[segment.member], path[1:], value, appendString)
if err != nil {
return nil, err
}
object[segment.member] = updated
return object, nil
}
func (s *GeminiToChatStreamState) terminalChunk(model string) *dto.ChatCompletionsStreamResponse {
finishReason := types.FinishReasonStop
if s.sawToolCall {
finishReason = types.FinishReasonToolCalls
}
s.finishEmitted = true
return &dto.ChatCompletionsStreamResponse{
Id: s.id,
Object: "chat.completion.chunk",
Created: s.created,
Model: model,
Choices: []dto.ChatCompletionsStreamResponseChoice{
{
Delta: dto.ChatCompletionsStreamResponseChoiceDelta{},
FinishReason: &finishReason,
},
},
Usage: s.latestUsage,
}
}
func geminiResponseToolCall(item *dto.GeminiPart) *dto.ToolCallResponse {
argsBytes, err := kitutil.Marshal(item.FunctionCall.Arguments)
if err != nil {
return nil
}
callID := strings.TrimSpace(item.FunctionCall.ID)
if callID == "" {
callID = fmt.Sprintf("call_%s", kitutil.GetUUID())
}
return &dto.ToolCallResponse{
ID: callID,
Type: "function",
Function: dto.FunctionResponse{
Arguments: string(argsBytes),
Name: item.FunctionCall.FunctionName,
},
}
}
@@ -0,0 +1,71 @@
package geminichat
import (
"fmt"
"github.com/QuantumNous/new-api/relaykit/dto"
oaichat "github.com/QuantumNous/new-api/relaykit/relayconvert/internal/oai_chat"
kitutil "github.com/QuantumNous/new-api/relaykit/relayconvert/kitutil"
)
// GeminiHostedStreamBridge accumulates grounding metadata until the provider
// stream ends. Gemini commonly reports the search queries after emitting the
// answer text; delaying the synthetic Responses item keeps one complete,
// canonical action instead of emitting partial or duplicate tool calls.
type GeminiHostedStreamBridge struct {
queries []string
seen map[string]struct{}
}
func NewGeminiHostedStreamBridge() *GeminiHostedStreamBridge {
return &GeminiHostedStreamBridge{seen: make(map[string]struct{})}
}
func (b *GeminiHostedStreamBridge) Observe(response *dto.GeminiChatResponse) {
if b == nil {
return
}
if b.seen == nil {
b.seen = make(map[string]struct{})
}
for _, query := range GroundingWebSearchQueries(response) {
if _, exists := b.seen[query]; exists {
continue
}
b.seen[query] = struct{}{}
b.queries = append(b.queries, query)
}
}
func (b *GeminiHostedStreamBridge) Finalize(state *oaichat.ChatToResponsesStreamState) ([]oaichat.ChatToResponsesStreamEvent, error) {
if b == nil || len(b.queries) == 0 {
return nil, nil
}
if state == nil {
return nil, fmt.Errorf("Chat-to-Responses stream state is required")
}
action, err := kitutil.Marshal(map[string]any{
"type": "search",
"queries": append([]string(nil), b.queries...),
})
if err != nil {
return nil, fmt.Errorf("marshal Gemini web-search action: %w", err)
}
callID := fmt.Sprintf("ws_%s", kitutil.GetUUID())
events, err := state.StartHostedTool(oaichat.HostedToolStreamStart{
Type: "web_search_call",
ID: callID,
Action: action,
})
if err != nil {
return nil, err
}
completed, err := state.CompleteHostedTool(oaichat.HostedToolStreamResult{
Type: "web_search_call",
ID: callID,
})
if err != nil {
return nil, err
}
return append(events, completed...), nil
}