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
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:
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("
|
||||
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("
|
||||
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
|
||||
}
|
||||
Reference in new issue
Block a user