重构前
This commit is contained in:
@@ -0,0 +1,302 @@
|
||||
package gemini
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/QuantumNous/new-api/relay/channel"
|
||||
relaycommon "github.com/QuantumNous/new-api/relay/common"
|
||||
"github.com/QuantumNous/new-api/relay/constant"
|
||||
"github.com/QuantumNous/new-api/relaykit/dto"
|
||||
"github.com/QuantumNous/new-api/relaykit/relayconvert"
|
||||
"github.com/QuantumNous/new-api/relaykit/types"
|
||||
"github.com/QuantumNous/new-api/setting/model_setting"
|
||||
"github.com/QuantumNous/new-api/setting/reasoning"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/samber/lo"
|
||||
)
|
||||
|
||||
type Adaptor struct {
|
||||
}
|
||||
|
||||
func (a *Adaptor) ConvertGeminiRequest(c *gin.Context, info *relaycommon.RelayInfo, request *dto.GeminiChatRequest) (any, error) {
|
||||
if len(request.Contents) > 0 {
|
||||
for i, content := range request.Contents {
|
||||
if i == 0 {
|
||||
if request.Contents[0].Role == "" {
|
||||
request.Contents[0].Role = "user"
|
||||
}
|
||||
}
|
||||
for _, part := range content.Parts {
|
||||
if part.FileData != nil {
|
||||
if part.FileData.MimeType == "" && strings.Contains(part.FileData.FileUri, "www.youtube.com") {
|
||||
part.FileData.MimeType = "video/webm"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return request, nil
|
||||
}
|
||||
|
||||
func (a *Adaptor) ConvertClaudeRequest(c *gin.Context, info *relaycommon.RelayInfo, req *dto.ClaudeRequest) (any, error) {
|
||||
result, err := relayconvert.ConvertRequest(c, info, types.RelayFormatGemini, req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
geminiRequest, ok := result.Value.(*dto.GeminiChatRequest)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("expected Gemini generateContent request, got %T", result.Value)
|
||||
}
|
||||
return geminiRequest, nil
|
||||
}
|
||||
|
||||
func (a *Adaptor) ConvertAudioRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.AudioRequest) (io.Reader, error) {
|
||||
//TODO implement me
|
||||
return nil, errors.New("not implemented")
|
||||
}
|
||||
|
||||
func (a *Adaptor) ConvertImageRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.ImageRequest) (any, error) {
|
||||
if !strings.HasPrefix(info.UpstreamModelName, "imagen") {
|
||||
return nil, errors.New("not supported model for image generation, only imagen models are supported")
|
||||
}
|
||||
|
||||
// convert size to aspect ratio but allow user to specify aspect ratio
|
||||
aspectRatio := "1:1" // default aspect ratio
|
||||
size := strings.TrimSpace(request.Size)
|
||||
if size != "" {
|
||||
if strings.Contains(size, ":") {
|
||||
aspectRatio = size
|
||||
} else {
|
||||
switch size {
|
||||
case "256x256", "512x512", "1024x1024":
|
||||
aspectRatio = "1:1"
|
||||
case "1536x1024":
|
||||
aspectRatio = "3:2"
|
||||
case "1024x1536":
|
||||
aspectRatio = "2:3"
|
||||
case "1024x1792":
|
||||
aspectRatio = "9:16"
|
||||
case "1792x1024":
|
||||
aspectRatio = "16:9"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// build gemini imagen request
|
||||
geminiRequest := dto.GeminiImageRequest{
|
||||
Instances: []dto.GeminiImageInstance{
|
||||
{
|
||||
Prompt: request.Prompt,
|
||||
},
|
||||
},
|
||||
Parameters: dto.GeminiImageParameters{
|
||||
SampleCount: int(lo.FromPtrOr(request.N, uint(1))),
|
||||
AspectRatio: aspectRatio,
|
||||
PersonGeneration: "allow_adult", // default allow adult
|
||||
},
|
||||
}
|
||||
|
||||
// Set imageSize when quality parameter is specified
|
||||
// Map quality parameter to imageSize (only supported by Standard and Ultra models)
|
||||
// quality values: auto, high, medium, low (for gpt-image-1), hd, standard (for dall-e-3)
|
||||
// imageSize values: 1K (default), 2K
|
||||
// https://ai.google.dev/gemini-api/docs/imagen
|
||||
// https://platform.openai.com/docs/api-reference/images/create
|
||||
if request.Quality != "" {
|
||||
imageSize := "1K" // default
|
||||
switch request.Quality {
|
||||
case "hd", "high":
|
||||
imageSize = "2K"
|
||||
case "2K":
|
||||
imageSize = "2K"
|
||||
case "standard", "medium", "low", "auto", "1K":
|
||||
imageSize = "1K"
|
||||
default:
|
||||
// unknown quality value, default to 1K
|
||||
imageSize = "1K"
|
||||
}
|
||||
geminiRequest.Parameters.ImageSize = imageSize
|
||||
}
|
||||
|
||||
return geminiRequest, nil
|
||||
}
|
||||
|
||||
func (a *Adaptor) Init(info *relaycommon.RelayInfo) {
|
||||
|
||||
}
|
||||
|
||||
func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) {
|
||||
|
||||
if model_setting.GetGeminiSettings().ThinkingAdapterEnabled &&
|
||||
!model_setting.ShouldPreserveThinkingSuffix(info.OriginModelName) {
|
||||
// 新增逻辑:处理 -thinking-<budget> 格式
|
||||
if strings.Contains(info.UpstreamModelName, "-thinking-") {
|
||||
parts := strings.Split(info.UpstreamModelName, "-thinking-")
|
||||
info.UpstreamModelName = parts[0]
|
||||
} else if strings.HasSuffix(info.UpstreamModelName, "-thinking") { // 旧的适配
|
||||
info.UpstreamModelName = strings.TrimSuffix(info.UpstreamModelName, "-thinking")
|
||||
} else if strings.HasSuffix(info.UpstreamModelName, "-nothinking") {
|
||||
info.UpstreamModelName = strings.TrimSuffix(info.UpstreamModelName, "-nothinking")
|
||||
} else if baseModel, level, ok := reasoning.TrimEffortSuffix(info.UpstreamModelName); ok && level != "" {
|
||||
info.UpstreamModelName = baseModel
|
||||
}
|
||||
}
|
||||
|
||||
version := model_setting.GetGeminiVersionSetting(info.UpstreamModelName)
|
||||
|
||||
if strings.HasPrefix(info.UpstreamModelName, "imagen") {
|
||||
return fmt.Sprintf("%s/%s/models/%s:predict", info.ChannelBaseUrl, version, info.UpstreamModelName), nil
|
||||
}
|
||||
|
||||
if strings.HasPrefix(info.UpstreamModelName, "text-embedding") ||
|
||||
strings.HasPrefix(info.UpstreamModelName, "embedding") ||
|
||||
strings.HasPrefix(info.UpstreamModelName, "gemini-embedding") {
|
||||
action := "embedContent"
|
||||
if info.IsGeminiBatchEmbedding {
|
||||
action = "batchEmbedContents"
|
||||
}
|
||||
return fmt.Sprintf("%s/%s/models/%s:%s", info.ChannelBaseUrl, version, info.UpstreamModelName, action), nil
|
||||
}
|
||||
|
||||
action := "generateContent"
|
||||
if info.IsStream {
|
||||
action = "streamGenerateContent?alt=sse"
|
||||
if info.RelayMode == constant.RelayModeGemini {
|
||||
info.DisablePing = true
|
||||
}
|
||||
}
|
||||
return fmt.Sprintf("%s/%s/models/%s:%s", info.ChannelBaseUrl, version, info.UpstreamModelName, action), nil
|
||||
}
|
||||
|
||||
func (a *Adaptor) SetupRequestHeader(c *gin.Context, req *http.Header, info *relaycommon.RelayInfo) error {
|
||||
channel.SetupApiRequestHeader(info, c, req)
|
||||
req.Set("x-goog-api-key", info.ApiKey)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *Adaptor) ConvertOpenAIRequest(c *gin.Context, info *relaycommon.RelayInfo, request *dto.GeneralOpenAIRequest) (any, error) {
|
||||
if request == nil {
|
||||
return nil, errors.New("request is nil")
|
||||
}
|
||||
result, err := relayconvert.ConvertRequest(c, info, types.RelayFormatGemini, request)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return result.Value, nil
|
||||
}
|
||||
|
||||
func (a *Adaptor) ConvertRerankRequest(c *gin.Context, relayMode int, request dto.RerankRequest) (any, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (a *Adaptor) ConvertEmbeddingRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.EmbeddingRequest) (any, error) {
|
||||
if request.Input == nil {
|
||||
return nil, errors.New("input is required")
|
||||
}
|
||||
|
||||
inputs := request.ParseInput()
|
||||
if len(inputs) == 0 {
|
||||
return nil, errors.New("input is empty")
|
||||
}
|
||||
// We always build a batch-style payload with `requests`, so ensure we call the
|
||||
// batch endpoint upstream to avoid payload/endpoint mismatches.
|
||||
info.IsGeminiBatchEmbedding = true
|
||||
// process all inputs
|
||||
geminiRequests := make([]map[string]interface{}, 0, len(inputs))
|
||||
for _, input := range inputs {
|
||||
geminiRequest := map[string]interface{}{
|
||||
"model": fmt.Sprintf("models/%s", info.UpstreamModelName),
|
||||
"content": dto.GeminiChatContent{
|
||||
Parts: []dto.GeminiPart{
|
||||
{
|
||||
Text: input,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// set specific parameters for different models
|
||||
// https://ai.google.dev/api/embeddings?hl=zh-cn#method:-models.embedcontent
|
||||
switch info.UpstreamModelName {
|
||||
case "text-embedding-004", "gemini-embedding-exp-03-07", "gemini-embedding-001":
|
||||
// Only newer models introduced after 2024 support OutputDimensionality
|
||||
dimensions := lo.FromPtrOr(request.Dimensions, 0)
|
||||
if dimensions > 0 {
|
||||
geminiRequest["outputDimensionality"] = dimensions
|
||||
}
|
||||
}
|
||||
geminiRequests = append(geminiRequests, geminiRequest)
|
||||
}
|
||||
|
||||
return map[string]interface{}{
|
||||
"requests": geminiRequests,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (a *Adaptor) ConvertOpenAIResponsesRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.OpenAIResponsesRequest) (any, error) {
|
||||
result, err := relayconvert.ConvertRequest(c, info, types.RelayFormatGemini, &request)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
geminiRequest, ok := result.Value.(*dto.GeminiChatRequest)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("expected Gemini generateContent request, got %T", result.Value)
|
||||
}
|
||||
return geminiRequest, nil
|
||||
}
|
||||
|
||||
func (a *Adaptor) DoRequest(c *gin.Context, info *relaycommon.RelayInfo, requestBody io.Reader) (any, error) {
|
||||
return channel.DoApiRequest(a, c, info, requestBody)
|
||||
}
|
||||
|
||||
func (a *Adaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (usage any, err *types.NewAPIError) {
|
||||
if info.RelayMode == constant.RelayModeResponses {
|
||||
if info.IsStream {
|
||||
return GeminiResponsesStreamHandler(c, info, resp)
|
||||
}
|
||||
return GeminiResponsesHandler(c, info, resp)
|
||||
}
|
||||
|
||||
if info.RelayMode == constant.RelayModeGemini {
|
||||
if strings.Contains(info.RequestURLPath, ":embedContent") ||
|
||||
strings.Contains(info.RequestURLPath, ":batchEmbedContents") {
|
||||
return NativeGeminiEmbeddingHandler(c, resp, info)
|
||||
}
|
||||
if info.IsStream {
|
||||
return GeminiTextGenerationStreamHandler(c, info, resp)
|
||||
} else {
|
||||
return GeminiTextGenerationHandler(c, info, resp)
|
||||
}
|
||||
}
|
||||
|
||||
if strings.HasPrefix(info.UpstreamModelName, "imagen") {
|
||||
return GeminiImageHandler(c, info, resp)
|
||||
}
|
||||
|
||||
// check if the model is an embedding model
|
||||
if strings.HasPrefix(info.UpstreamModelName, "text-embedding") ||
|
||||
strings.HasPrefix(info.UpstreamModelName, "embedding") ||
|
||||
strings.HasPrefix(info.UpstreamModelName, "gemini-embedding") {
|
||||
return GeminiEmbeddingHandler(c, info, resp)
|
||||
}
|
||||
|
||||
if info.IsStream {
|
||||
return GeminiChatStreamHandler(c, info, resp)
|
||||
} else {
|
||||
return GeminiChatHandler(c, info, resp)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func (a *Adaptor) GetModelList() []string {
|
||||
return ModelList
|
||||
}
|
||||
|
||||
func (a *Adaptor) GetChannelName() string {
|
||||
return ChannelName
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
package gemini
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
relaycommon "github.com/QuantumNous/new-api/relay/common"
|
||||
"github.com/QuantumNous/new-api/relaykit/dto"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
func TestConvertOpenAIResponsesRequestToGeminiInstructionsAndInput(t *testing.T) {
|
||||
got := mustConvertResponsesToGemini(t, dto.OpenAIResponsesRequest{
|
||||
Model: "gemini-test",
|
||||
Instructions: mustGeminiRawMessage(t, "system rules"),
|
||||
Input: mustGeminiRawMessage(t, "hello"),
|
||||
})
|
||||
|
||||
require.NotNil(t, got.SystemInstructions)
|
||||
require.Len(t, got.SystemInstructions.Parts, 1)
|
||||
assert.Equal(t, "system rules", got.SystemInstructions.Parts[0].Text)
|
||||
require.Len(t, got.Contents, 1)
|
||||
assert.Equal(t, "user", got.Contents[0].Role)
|
||||
require.Len(t, got.Contents[0].Parts, 1)
|
||||
assert.Equal(t, "hello", got.Contents[0].Parts[0].Text)
|
||||
}
|
||||
|
||||
func TestConvertOpenAIResponsesRequestToGeminiFunctionToolAndChoice(t *testing.T) {
|
||||
got := mustConvertResponsesToGemini(t, dto.OpenAIResponsesRequest{
|
||||
Model: "gemini-test",
|
||||
Input: mustGeminiRawMessage(t, "lookup weather"),
|
||||
Tools: mustGeminiRawMessage(t, []map[string]any{
|
||||
{
|
||||
"type": "function",
|
||||
"name": "lookup",
|
||||
"description": "Lookup data",
|
||||
"parameters": map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"q": map[string]any{"type": "string"},
|
||||
},
|
||||
},
|
||||
},
|
||||
{"type": "custom", "name": "freeform"},
|
||||
}),
|
||||
ToolChoice: mustGeminiRawMessage(t, map[string]any{
|
||||
"type": "function",
|
||||
"name": "lookup",
|
||||
}),
|
||||
})
|
||||
|
||||
tools := got.GetTools()
|
||||
require.Len(t, tools, 1)
|
||||
assert.Equal(t, "lookup", gjson.GetBytes(got.Tools, "0.functionDeclarations.0.name").String())
|
||||
assert.Equal(t, "Lookup data", gjson.GetBytes(got.Tools, "0.functionDeclarations.0.description").String())
|
||||
require.NotNil(t, got.ToolConfig)
|
||||
require.NotNil(t, got.ToolConfig.FunctionCallingConfig)
|
||||
assert.Equal(t, dto.FunctionCallingConfigMode("ANY"), got.ToolConfig.FunctionCallingConfig.Mode)
|
||||
assert.Equal(t, []string{"lookup"}, got.ToolConfig.FunctionCallingConfig.AllowedFunctionNames)
|
||||
}
|
||||
|
||||
func TestConvertOpenAIResponsesRequestToGeminiFunctionCallConversation(t *testing.T) {
|
||||
got := mustConvertResponsesToGemini(t, dto.OpenAIResponsesRequest{
|
||||
Model: "gemini-test",
|
||||
Input: mustGeminiRawMessage(t, []map[string]any{
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": []map[string]any{
|
||||
{"type": "output_text", "text": "I will call."},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function_call",
|
||||
"call_id": "call_1",
|
||||
"name": "lookup",
|
||||
"arguments": map[string]any{"q": "x"},
|
||||
},
|
||||
{
|
||||
"type": "function_call_output",
|
||||
"call_id": "call_1",
|
||||
"output": map[string]any{"ok": true},
|
||||
},
|
||||
}),
|
||||
Tools: mustGeminiRawMessage(t, []map[string]any{
|
||||
{"type": "function", "name": "lookup", "parameters": map[string]any{"type": "object"}},
|
||||
}),
|
||||
})
|
||||
|
||||
require.Len(t, got.Contents, 2)
|
||||
assert.Equal(t, "model", got.Contents[0].Role)
|
||||
require.Len(t, got.Contents[0].Parts, 2)
|
||||
require.NotNil(t, got.Contents[0].Parts[0].FunctionCall)
|
||||
assert.Equal(t, "lookup", got.Contents[0].Parts[0].FunctionCall.FunctionName)
|
||||
assert.Equal(t, map[string]interface{}{"q": "x"}, got.Contents[0].Parts[0].FunctionCall.Arguments)
|
||||
assert.Equal(t, "I will call.", got.Contents[0].Parts[1].Text)
|
||||
|
||||
assert.Equal(t, "user", got.Contents[1].Role)
|
||||
require.Len(t, got.Contents[1].Parts, 1)
|
||||
require.NotNil(t, got.Contents[1].Parts[0].FunctionResponse)
|
||||
assert.Equal(t, "lookup", got.Contents[1].Parts[0].FunctionResponse.Name)
|
||||
assert.Equal(t, map[string]interface{}{"ok": true}, got.Contents[1].Parts[0].FunctionResponse.Response)
|
||||
}
|
||||
|
||||
func TestConvertOpenAIResponsesRequestToGeminiSkipsCustomToolCalls(t *testing.T) {
|
||||
got := mustConvertResponsesToGemini(t, dto.OpenAIResponsesRequest{
|
||||
Model: "gemini-test",
|
||||
Input: mustGeminiRawMessage(t, []map[string]any{
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": []map[string]any{
|
||||
{"type": "output_text", "text": "before custom"},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "custom_tool_call",
|
||||
"call_id": "call_custom",
|
||||
"name": "apply_patch",
|
||||
"input": "patch body",
|
||||
},
|
||||
{
|
||||
"type": "custom_tool_call_output",
|
||||
"call_id": "call_custom",
|
||||
"output": "ok",
|
||||
},
|
||||
{
|
||||
"type": "function_call_output",
|
||||
"call_id": "call_custom",
|
||||
"output": "legacy custom output",
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": "next turn",
|
||||
},
|
||||
}),
|
||||
Tools: mustGeminiRawMessage(t, []map[string]any{
|
||||
{"type": "custom", "name": "apply_patch"},
|
||||
{"type": "unknown", "name": "unknown"},
|
||||
}),
|
||||
})
|
||||
|
||||
assert.Empty(t, got.GetTools())
|
||||
require.Len(t, got.Contents, 2)
|
||||
assert.Equal(t, "model", got.Contents[0].Role)
|
||||
require.Len(t, got.Contents[0].Parts, 1)
|
||||
assert.Equal(t, "before custom", got.Contents[0].Parts[0].Text)
|
||||
assert.Nil(t, got.Contents[0].Parts[0].FunctionCall)
|
||||
|
||||
assert.Equal(t, "user", got.Contents[1].Role)
|
||||
require.Len(t, got.Contents[1].Parts, 1)
|
||||
assert.Equal(t, "next turn", got.Contents[1].Parts[0].Text)
|
||||
assert.Nil(t, got.Contents[1].Parts[0].FunctionResponse)
|
||||
}
|
||||
|
||||
func mustConvertResponsesToGemini(t *testing.T, req dto.OpenAIResponsesRequest) *dto.GeminiChatRequest {
|
||||
t.Helper()
|
||||
info := &relaycommon.RelayInfo{
|
||||
OriginModelName: req.Model,
|
||||
ChannelMeta: &relaycommon.ChannelMeta{
|
||||
UpstreamModelName: req.Model,
|
||||
},
|
||||
}
|
||||
got, err := (&Adaptor{}).ConvertOpenAIResponsesRequest(nil, info, req)
|
||||
require.NoError(t, err)
|
||||
geminiReq, ok := got.(*dto.GeminiChatRequest)
|
||||
require.True(t, ok)
|
||||
return geminiReq
|
||||
}
|
||||
|
||||
func mustGeminiRawMessage(t *testing.T, value any) []byte {
|
||||
t.Helper()
|
||||
raw, err := common.Marshal(value)
|
||||
require.NoError(t, err)
|
||||
return raw
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package gemini
|
||||
|
||||
var ModelList = []string{
|
||||
// stable version
|
||||
"gemini-2.5-flash", "gemini-2.5-pro", "gemini-2.0-flash",
|
||||
"gemini-2.0-flash-001", "gemini-2.0-flash-lite-001", "gemini-2.0-flash-lite",
|
||||
"gemini-2.5-flash-lite",
|
||||
"gemini-3-pro-image", "gemini-3.1-flash-image",
|
||||
// latest version
|
||||
"gemini-flash-latest", "gemini-flash-lite-latest", "gemini-pro-latest",
|
||||
"gemini-2.5-flash-native-audio-latest",
|
||||
// preview version
|
||||
"gemini-2.5-flash-preview-tts", "gemini-2.5-pro-preview-tts",
|
||||
"gemini-2.5-flash-image", "gemini-2.5-flash-lite-preview-09-2025",
|
||||
"gemini-3-pro-preview", "gemini-3-flash-preview", "gemini-3.1-pro-preview",
|
||||
"gemini-3.1-pro-preview-customtools", "gemini-3.1-flash-lite-preview",
|
||||
"gemini-3-pro-image-preview", "nano-banana-pro-preview",
|
||||
"gemini-3.1-flash-image-preview", "gemini-robotics-er-1.5-preview",
|
||||
"gemini-2.5-computer-use-preview-10-2025", "deep-research-pro-preview-12-2025",
|
||||
"gemini-2.5-flash-native-audio-preview-09-2025", "gemini-2.5-flash-native-audio-preview-12-2025",
|
||||
// gemma models
|
||||
"gemma-3-1b-it", "gemma-3-4b-it", "gemma-3-12b-it",
|
||||
"gemma-3-27b-it", "gemma-3n-e4b-it", "gemma-3n-e2b-it",
|
||||
// embedding models
|
||||
"gemini-embedding-001", "gemini-embedding-2-preview",
|
||||
// imagen models
|
||||
"imagen-4.0-generate-001", "imagen-4.0-ultra-generate-001",
|
||||
"imagen-4.0-fast-generate-001",
|
||||
// veo models
|
||||
"veo-2.0-generate-001", "veo-3.0-generate-001", "veo-3.0-fast-generate-001",
|
||||
"veo-3.1-generate-preview", "veo-3.1-fast-generate-preview",
|
||||
// other models
|
||||
"aqa",
|
||||
}
|
||||
|
||||
var SafetySettingList = []string{
|
||||
"HARM_CATEGORY_HARASSMENT",
|
||||
"HARM_CATEGORY_HATE_SPEECH",
|
||||
"HARM_CATEGORY_SEXUALLY_EXPLICIT",
|
||||
"HARM_CATEGORY_DANGEROUS_CONTENT",
|
||||
//"HARM_CATEGORY_CIVIC_INTEGRITY", This item is deprecated!
|
||||
}
|
||||
|
||||
var ChannelName = "google gemini"
|
||||
@@ -0,0 +1,93 @@
|
||||
package gemini
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/constant"
|
||||
"github.com/QuantumNous/new-api/logger"
|
||||
relaycommon "github.com/QuantumNous/new-api/relay/common"
|
||||
"github.com/QuantumNous/new-api/relay/helper"
|
||||
"github.com/QuantumNous/new-api/relaykit/dto"
|
||||
"github.com/QuantumNous/new-api/relaykit/types"
|
||||
"github.com/QuantumNous/new-api/service"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func GeminiTextGenerationHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Response) (*dto.Usage, *types.NewAPIError) {
|
||||
defer service.CloseResponseBodyGracefully(resp)
|
||||
|
||||
// 读取响应体
|
||||
responseBody, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError)
|
||||
}
|
||||
|
||||
logger.LogDebug(c, "Gemini native response body: %s", responseBody)
|
||||
|
||||
// 解析为 Gemini 原生响应格式
|
||||
var geminiResponse dto.GeminiChatResponse
|
||||
err = common.Unmarshal(responseBody, &geminiResponse)
|
||||
if err != nil {
|
||||
return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError)
|
||||
}
|
||||
|
||||
if len(geminiResponse.Candidates) == 0 && geminiResponse.PromptFeedback != nil && geminiResponse.PromptFeedback.BlockReason != nil {
|
||||
common.SetContextKey(c, constant.ContextKeyAdminRejectReason, fmt.Sprintf("gemini_block_reason=%s", *geminiResponse.PromptFeedback.BlockReason))
|
||||
}
|
||||
|
||||
// 计算使用量(优先上游 UsageMetadata,缺失时本地估算并保留 Gemini 计费语义)
|
||||
usage := buildUsageFromGeminiResponse(c, info, &geminiResponse)
|
||||
|
||||
service.IOCopyBytesGracefully(c, resp, responseBody)
|
||||
|
||||
return &usage, nil
|
||||
}
|
||||
|
||||
func NativeGeminiEmbeddingHandler(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (*dto.Usage, *types.NewAPIError) {
|
||||
defer service.CloseResponseBodyGracefully(resp)
|
||||
|
||||
responseBody, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError)
|
||||
}
|
||||
|
||||
logger.LogDebug(c, "Gemini native embedding response body: %s", responseBody)
|
||||
|
||||
usage := service.ResponseText2Usage(c, "", info.UpstreamModelName, info.GetEstimatePromptTokens())
|
||||
|
||||
if info.IsGeminiBatchEmbedding {
|
||||
var geminiResponse dto.GeminiBatchEmbeddingResponse
|
||||
err = common.Unmarshal(responseBody, &geminiResponse)
|
||||
if err != nil {
|
||||
return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError)
|
||||
}
|
||||
} else {
|
||||
var geminiResponse dto.GeminiEmbeddingResponse
|
||||
err = common.Unmarshal(responseBody, &geminiResponse)
|
||||
if err != nil {
|
||||
return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
service.IOCopyBytesGracefully(c, resp, responseBody)
|
||||
|
||||
return usage, nil
|
||||
}
|
||||
|
||||
func GeminiTextGenerationStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Response) (*dto.Usage, *types.NewAPIError) {
|
||||
helper.SetEventStreamHeaders(c)
|
||||
|
||||
return geminiStreamHandler(c, info, resp, func(data string, geminiResponse *dto.GeminiChatResponse) bool {
|
||||
err := helper.StringData(c, data)
|
||||
if err != nil {
|
||||
logger.LogError(c, "failed to write stream data: "+err.Error())
|
||||
return false
|
||||
}
|
||||
info.SendResponseCount++
|
||||
return true
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,562 @@
|
||||
package gemini
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/constant"
|
||||
"github.com/QuantumNous/new-api/logger"
|
||||
"github.com/QuantumNous/new-api/relay/channel/openai"
|
||||
relaycommon "github.com/QuantumNous/new-api/relay/common"
|
||||
"github.com/QuantumNous/new-api/relay/helper"
|
||||
"github.com/QuantumNous/new-api/relaykit/dto"
|
||||
"github.com/QuantumNous/new-api/relaykit/relayconvert"
|
||||
"github.com/QuantumNous/new-api/relaykit/types"
|
||||
"github.com/QuantumNous/new-api/service"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func buildUsageFromGeminiMetadata(metadata *dto.GeminiUsageMetadata, fallbackPromptTokens int) dto.Usage {
|
||||
usage := relayconvert.UsageFromGeminiMetadata(metadata, fallbackPromptTokens)
|
||||
if usage == nil {
|
||||
return dto.Usage{}
|
||||
}
|
||||
return *usage
|
||||
}
|
||||
|
||||
func attachEstimatedGeminiBillingUsage(usage *dto.Usage) *dto.Usage {
|
||||
if usage != nil && usage.BillingUsage == nil {
|
||||
usage.BillingUsage = dto.NewEstimatedGeminiChatBillingUsage(usage)
|
||||
}
|
||||
return usage
|
||||
}
|
||||
|
||||
// patchGeminiZeroCompletionUsage estimates completion tokens locally when upstream
|
||||
// usageMetadata was billable but reported zero completion tokens even though output
|
||||
// content was actually received. Typical case: the client aborts a stream before the
|
||||
// final chunk that carries candidatesTokenCount, leaving prompt-only metadata; without
|
||||
// this patch the output side would settle at zero quota.
|
||||
func patchGeminiZeroCompletionUsage(c *gin.Context, info *relaycommon.RelayInfo, usage *dto.Usage, responseText string, imageCount int) {
|
||||
if usage == nil || usage.CompletionTokens > 0 {
|
||||
return
|
||||
}
|
||||
if responseText == "" && imageCount == 0 {
|
||||
return
|
||||
}
|
||||
estimated := service.ResponseText2Usage(c, responseText, info.UpstreamModelName, usage.PromptTokens)
|
||||
usage.CompletionTokens = estimated.CompletionTokens
|
||||
if imageCount != 0 && usage.CompletionTokens == 0 {
|
||||
usage.CompletionTokens = imageCount * 1400
|
||||
}
|
||||
usage.TotalTokens = usage.PromptTokens + usage.CompletionTokens
|
||||
// Overwrite the metadata-derived billing usage: effectiveBillingUsage prefers
|
||||
// BillingUsage during settlement, so keeping the prompt-only metadata there
|
||||
// would still bill zero completion tokens.
|
||||
usage.BillingUsage = dto.NewEstimatedGeminiChatBillingUsage(usage)
|
||||
}
|
||||
|
||||
func geminiResponseUsageText(response *dto.GeminiChatResponse) string {
|
||||
if response == nil {
|
||||
return ""
|
||||
}
|
||||
var text strings.Builder
|
||||
for _, candidate := range response.Candidates {
|
||||
for _, part := range candidate.Content.Parts {
|
||||
if part.Text != "" {
|
||||
text.WriteString(part.Text)
|
||||
}
|
||||
}
|
||||
}
|
||||
return text.String()
|
||||
}
|
||||
|
||||
func markGeminiGoogleSearchCall(c *gin.Context, response *dto.GeminiChatResponse) {
|
||||
if c == nil || response == nil {
|
||||
return
|
||||
}
|
||||
for _, candidate := range response.Candidates {
|
||||
if candidate.GroundingMetadata != nil && len(candidate.GroundingMetadata.WebSearchQueries) > 0 {
|
||||
c.Set("gemini_google_search_call", true)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func buildUsageFromGeminiResponse(c *gin.Context, info *relaycommon.RelayInfo, response *dto.GeminiChatResponse) dto.Usage {
|
||||
metadata := response.GetUsageMetadata()
|
||||
if dto.HasGeminiUsageMetadataTokens(metadata) {
|
||||
usage := buildUsageFromGeminiMetadata(metadata, info.GetEstimatePromptTokens())
|
||||
patchGeminiZeroCompletionUsage(c, info, &usage, geminiResponseUsageText(response), geminiResponseInlineImageCount(response))
|
||||
return usage
|
||||
}
|
||||
usage := service.ResponseText2Usage(c, geminiResponseUsageText(response), info.UpstreamModelName, info.GetEstimatePromptTokens())
|
||||
attachEstimatedGeminiBillingUsage(usage)
|
||||
return *usage
|
||||
}
|
||||
|
||||
func geminiResponseInlineImageCount(response *dto.GeminiChatResponse) int {
|
||||
if response == nil {
|
||||
return 0
|
||||
}
|
||||
count := 0
|
||||
for _, candidate := range response.Candidates {
|
||||
for _, part := range candidate.Content.Parts {
|
||||
if part.InlineData != nil && part.InlineData.MimeType != "" {
|
||||
count++
|
||||
}
|
||||
}
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
func responseGeminiChat2OpenAI(c *gin.Context, response *dto.GeminiChatResponse) *dto.OpenAITextResponse {
|
||||
return relayconvert.ResponseGeminiChat2OpenAI(helper.GetResponseID(c), common.GetTimestamp(), response)
|
||||
}
|
||||
|
||||
func streamResponseGeminiChat2OpenAI(geminiResponse *dto.GeminiChatResponse) (*dto.ChatCompletionsStreamResponse, bool) {
|
||||
return relayconvert.StreamResponseGeminiChat2OpenAI(geminiResponse)
|
||||
}
|
||||
|
||||
func handleStream(c *gin.Context, info *relaycommon.RelayInfo, resp *dto.ChatCompletionsStreamResponse) error {
|
||||
streamData, err := common.Marshal(resp)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal stream response: %w", err)
|
||||
}
|
||||
err = openai.HandleStreamFormat(c, info, string(streamData), info.ChannelSetting.ForceFormat, info.ChannelSetting.ThinkingToContent)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to handle stream format: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func handleFinalStream(c *gin.Context, info *relaycommon.RelayInfo, resp *dto.ChatCompletionsStreamResponse) error {
|
||||
streamData, err := common.Marshal(resp)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal stream response: %w", err)
|
||||
}
|
||||
openai.HandleFinalResponse(c, info, string(streamData), resp.Id, resp.Created, resp.Model, resp.GetSystemFingerprint(), resp.Usage, false)
|
||||
return nil
|
||||
}
|
||||
|
||||
func geminiStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Response, callback func(data string, geminiResponse *dto.GeminiChatResponse) bool) (*dto.Usage, *types.NewAPIError) {
|
||||
var usage = &dto.Usage{}
|
||||
var imageCount int
|
||||
var hasBillableUsageMetadata bool
|
||||
responseText := strings.Builder{}
|
||||
|
||||
helper.StreamScannerHandler(c, resp, info, func(data string, sr *helper.StreamResult) {
|
||||
var geminiResponse dto.GeminiChatResponse
|
||||
if err := common.UnmarshalJsonStr(data, &geminiResponse); err != nil {
|
||||
sr.Stop(fmt.Errorf("unmarshal: %w", err))
|
||||
return
|
||||
}
|
||||
|
||||
if len(geminiResponse.Candidates) == 0 && geminiResponse.PromptFeedback != nil && geminiResponse.PromptFeedback.BlockReason != nil {
|
||||
common.SetContextKey(c, constant.ContextKeyAdminRejectReason, fmt.Sprintf("gemini_block_reason=%s", *geminiResponse.PromptFeedback.BlockReason))
|
||||
}
|
||||
|
||||
markGeminiGoogleSearchCall(c, &geminiResponse)
|
||||
|
||||
// 统计图片数量
|
||||
for _, candidate := range geminiResponse.Candidates {
|
||||
for _, part := range candidate.Content.Parts {
|
||||
if part.InlineData != nil && part.InlineData.MimeType != "" {
|
||||
imageCount++
|
||||
}
|
||||
if part.Text != "" {
|
||||
responseText.WriteString(part.Text)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 更新使用量统计
|
||||
if metadata := geminiResponse.GetUsageMetadata(); dto.HasGeminiUsageMetadataTokens(metadata) {
|
||||
mappedUsage := buildUsageFromGeminiMetadata(metadata, info.GetEstimatePromptTokens())
|
||||
*usage = mappedUsage
|
||||
hasBillableUsageMetadata = true
|
||||
}
|
||||
|
||||
if !callback(data, &geminiResponse) {
|
||||
sr.Stop(fmt.Errorf("gemini callback stopped"))
|
||||
}
|
||||
})
|
||||
|
||||
if !hasBillableUsageMetadata {
|
||||
if info.ReceivedResponseCount > 0 {
|
||||
usage = service.ResponseText2Usage(c, responseText.String(), info.UpstreamModelName, info.GetEstimatePromptTokens())
|
||||
} else {
|
||||
usage = &dto.Usage{}
|
||||
}
|
||||
if imageCount != 0 && usage.CompletionTokens == 0 {
|
||||
usage.CompletionTokens = imageCount * 1400
|
||||
usage.TotalTokens = usage.PromptTokens + usage.CompletionTokens
|
||||
common.SetContextKey(c, constant.ContextKeyLocalCountTokens, true)
|
||||
}
|
||||
attachEstimatedGeminiBillingUsage(usage)
|
||||
} else {
|
||||
patchGeminiZeroCompletionUsage(c, info, usage, responseText.String(), imageCount)
|
||||
}
|
||||
|
||||
return usage, nil
|
||||
}
|
||||
|
||||
func GeminiChatStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Response) (*dto.Usage, *types.NewAPIError) {
|
||||
id := helper.GetResponseID(c)
|
||||
createAt := common.GetTimestamp()
|
||||
finishReason := constant.FinishReasonStop
|
||||
toolCallIndexByChoice := make(map[int]map[string]int)
|
||||
nextToolCallIndexByChoice := make(map[int]int)
|
||||
|
||||
usage, err := geminiStreamHandler(c, info, resp, func(data string, geminiResponse *dto.GeminiChatResponse) bool {
|
||||
response, isStop := streamResponseGeminiChat2OpenAI(geminiResponse)
|
||||
|
||||
response.Id = id
|
||||
response.Created = createAt
|
||||
response.Model = info.UpstreamModelName
|
||||
if response.IsToolCall() {
|
||||
finishReason = constant.FinishReasonToolCalls
|
||||
if info.RelayFormat == types.RelayFormatClaude {
|
||||
for choiceIdx := range response.Choices {
|
||||
response.Choices[choiceIdx].FinishReason = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
for choiceIdx := range response.Choices {
|
||||
choiceKey := response.Choices[choiceIdx].Index
|
||||
for toolIdx := range response.Choices[choiceIdx].Delta.ToolCalls {
|
||||
tool := &response.Choices[choiceIdx].Delta.ToolCalls[toolIdx]
|
||||
if tool.ID == "" {
|
||||
continue
|
||||
}
|
||||
m := toolCallIndexByChoice[choiceKey]
|
||||
if m == nil {
|
||||
m = make(map[string]int)
|
||||
toolCallIndexByChoice[choiceKey] = m
|
||||
}
|
||||
if idx, ok := m[tool.ID]; ok {
|
||||
tool.SetIndex(idx)
|
||||
continue
|
||||
}
|
||||
idx := nextToolCallIndexByChoice[choiceKey]
|
||||
nextToolCallIndexByChoice[choiceKey] = idx + 1
|
||||
m[tool.ID] = idx
|
||||
tool.SetIndex(idx)
|
||||
}
|
||||
}
|
||||
|
||||
logger.LogDebug(c, "info.SendResponseCount = %d", info.SendResponseCount)
|
||||
if info.SendResponseCount == 0 {
|
||||
// send first response
|
||||
emptyResponse := helper.GenerateStartEmptyResponse(id, createAt, info.UpstreamModelName, nil)
|
||||
if response.IsToolCall() {
|
||||
if len(emptyResponse.Choices) > 0 && len(response.Choices) > 0 {
|
||||
toolCalls := response.Choices[0].Delta.ToolCalls
|
||||
copiedToolCalls := make([]dto.ToolCallResponse, len(toolCalls))
|
||||
for idx := range toolCalls {
|
||||
copiedToolCalls[idx] = toolCalls[idx]
|
||||
copiedToolCalls[idx].Function.Arguments = ""
|
||||
}
|
||||
emptyResponse.Choices[0].Delta.ToolCalls = copiedToolCalls
|
||||
}
|
||||
finishReason = constant.FinishReasonToolCalls
|
||||
err := handleStream(c, info, emptyResponse)
|
||||
if err != nil {
|
||||
logger.LogError(c, err.Error())
|
||||
}
|
||||
|
||||
response.ClearToolCalls()
|
||||
if response.IsFinished() {
|
||||
response.Choices[0].FinishReason = nil
|
||||
}
|
||||
} else {
|
||||
err := handleStream(c, info, emptyResponse)
|
||||
if err != nil {
|
||||
logger.LogError(c, err.Error())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
err := handleStream(c, info, response)
|
||||
if err != nil {
|
||||
logger.LogError(c, err.Error())
|
||||
}
|
||||
if isStop {
|
||||
if info.RelayFormat != types.RelayFormatClaude {
|
||||
_ = handleStream(c, info, helper.GenerateStopResponse(id, createAt, info.UpstreamModelName, finishReason))
|
||||
}
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return usage, err
|
||||
}
|
||||
|
||||
response := helper.GenerateFinalUsageResponse(id, createAt, info.UpstreamModelName, *usage)
|
||||
if info.RelayFormat == types.RelayFormatClaude && info.ClaudeConvertInfo != nil && !info.ClaudeConvertInfo.Done {
|
||||
response = helper.GenerateStopResponse(id, createAt, info.UpstreamModelName, finishReason)
|
||||
response.Usage = usage
|
||||
}
|
||||
handleErr := handleFinalStream(c, info, response)
|
||||
if handleErr != nil {
|
||||
common.SysLog("send final response failed: " + handleErr.Error())
|
||||
}
|
||||
return usage, nil
|
||||
}
|
||||
|
||||
func GeminiChatHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Response) (*dto.Usage, *types.NewAPIError) {
|
||||
responseBody, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError)
|
||||
}
|
||||
service.CloseResponseBodyGracefully(resp)
|
||||
logger.LogDebug(c, "Gemini response body: %s", responseBody)
|
||||
var geminiResponse dto.GeminiChatResponse
|
||||
err = common.Unmarshal(responseBody, &geminiResponse)
|
||||
if err != nil {
|
||||
return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError)
|
||||
}
|
||||
markGeminiGoogleSearchCall(c, &geminiResponse)
|
||||
if len(geminiResponse.Candidates) == 0 {
|
||||
usage := buildUsageFromGeminiResponse(c, info, &geminiResponse)
|
||||
|
||||
var newAPIError *types.NewAPIError
|
||||
if geminiResponse.PromptFeedback != nil && geminiResponse.PromptFeedback.BlockReason != nil {
|
||||
common.SetContextKey(c, constant.ContextKeyAdminRejectReason, fmt.Sprintf("gemini_block_reason=%s", *geminiResponse.PromptFeedback.BlockReason))
|
||||
newAPIError = types.NewOpenAIError(
|
||||
errors.New("request blocked by Gemini API: "+*geminiResponse.PromptFeedback.BlockReason),
|
||||
types.ErrorCodePromptBlocked,
|
||||
http.StatusBadRequest,
|
||||
)
|
||||
} else {
|
||||
common.SetContextKey(c, constant.ContextKeyAdminRejectReason, "gemini_empty_candidates")
|
||||
newAPIError = types.NewOpenAIError(
|
||||
errors.New("empty response from Gemini API"),
|
||||
types.ErrorCodeEmptyResponse,
|
||||
http.StatusInternalServerError,
|
||||
)
|
||||
}
|
||||
|
||||
service.ResetStatusCode(newAPIError, c.GetString("status_code_mapping"))
|
||||
|
||||
switch info.RelayFormat {
|
||||
case types.RelayFormatClaude:
|
||||
c.JSON(newAPIError.StatusCode, gin.H{
|
||||
"type": "error",
|
||||
"error": newAPIError.ToClaudeError(),
|
||||
})
|
||||
default:
|
||||
c.JSON(newAPIError.StatusCode, gin.H{
|
||||
"error": newAPIError.ToOpenAIError(),
|
||||
})
|
||||
}
|
||||
return &usage, nil
|
||||
}
|
||||
fullTextResponse := responseGeminiChat2OpenAI(c, &geminiResponse)
|
||||
fullTextResponse.Model = info.UpstreamModelName
|
||||
usage := buildUsageFromGeminiResponse(c, info, &geminiResponse)
|
||||
|
||||
fullTextResponse.Usage = usage
|
||||
|
||||
switch info.RelayFormat {
|
||||
case types.RelayFormatOpenAI:
|
||||
responseBody, err = common.Marshal(fullTextResponse)
|
||||
if err != nil {
|
||||
return nil, types.NewError(err, types.ErrorCodeBadResponseBody)
|
||||
}
|
||||
case types.RelayFormatClaude:
|
||||
convertResult, err := relayconvert.ConvertResponse(c, info, types.RelayFormatClaude, fullTextResponse)
|
||||
if err != nil {
|
||||
return nil, types.NewError(err, types.ErrorCodeBadResponseBody)
|
||||
}
|
||||
claudeRespStr, err := common.Marshal(convertResult.Value)
|
||||
if err != nil {
|
||||
return nil, types.NewError(err, types.ErrorCodeBadResponseBody)
|
||||
}
|
||||
responseBody = claudeRespStr
|
||||
case types.RelayFormatGemini:
|
||||
break
|
||||
}
|
||||
|
||||
service.IOCopyBytesGracefully(c, resp, responseBody)
|
||||
|
||||
return &usage, nil
|
||||
}
|
||||
|
||||
func GeminiEmbeddingHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Response) (*dto.Usage, *types.NewAPIError) {
|
||||
defer service.CloseResponseBodyGracefully(resp)
|
||||
|
||||
responseBody, readErr := io.ReadAll(resp.Body)
|
||||
if readErr != nil {
|
||||
return nil, types.NewOpenAIError(readErr, types.ErrorCodeBadResponseBody, http.StatusInternalServerError)
|
||||
}
|
||||
|
||||
var geminiResponse dto.GeminiBatchEmbeddingResponse
|
||||
if jsonErr := common.Unmarshal(responseBody, &geminiResponse); jsonErr != nil {
|
||||
return nil, types.NewOpenAIError(jsonErr, types.ErrorCodeBadResponseBody, http.StatusInternalServerError)
|
||||
}
|
||||
|
||||
// convert to openai format response
|
||||
openAIResponse := dto.OpenAIEmbeddingResponse{
|
||||
Object: "list",
|
||||
Data: make([]dto.OpenAIEmbeddingResponseItem, 0, len(geminiResponse.Embeddings)),
|
||||
Model: info.UpstreamModelName,
|
||||
}
|
||||
|
||||
for i, embedding := range geminiResponse.Embeddings {
|
||||
openAIResponse.Data = append(openAIResponse.Data, dto.OpenAIEmbeddingResponseItem{
|
||||
Object: "embedding",
|
||||
Embedding: embedding.Values,
|
||||
Index: i,
|
||||
})
|
||||
}
|
||||
|
||||
// calculate usage
|
||||
// https://ai.google.dev/gemini-api/docs/pricing?hl=zh-cn#text-embedding-004
|
||||
// Google has not yet clarified how embedding models will be billed
|
||||
// refer to openai billing method to use input tokens billing
|
||||
// https://platform.openai.com/docs/guides/embeddings#what-are-embeddings
|
||||
usage := service.ResponseText2Usage(c, "", info.UpstreamModelName, info.GetEstimatePromptTokens())
|
||||
openAIResponse.Usage = *usage
|
||||
|
||||
jsonResponse, jsonErr := common.Marshal(openAIResponse)
|
||||
if jsonErr != nil {
|
||||
return nil, types.NewOpenAIError(jsonErr, types.ErrorCodeBadResponseBody, http.StatusInternalServerError)
|
||||
}
|
||||
|
||||
service.IOCopyBytesGracefully(c, resp, jsonResponse)
|
||||
return usage, nil
|
||||
}
|
||||
|
||||
func GeminiImageHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Response) (*dto.Usage, *types.NewAPIError) {
|
||||
responseBody, readErr := io.ReadAll(resp.Body)
|
||||
if readErr != nil {
|
||||
return nil, types.NewOpenAIError(readErr, types.ErrorCodeBadResponseBody, http.StatusInternalServerError)
|
||||
}
|
||||
_ = resp.Body.Close()
|
||||
|
||||
var geminiResponse dto.GeminiImageResponse
|
||||
if jsonErr := common.Unmarshal(responseBody, &geminiResponse); jsonErr != nil {
|
||||
return nil, types.NewOpenAIError(jsonErr, types.ErrorCodeBadResponseBody, http.StatusInternalServerError)
|
||||
}
|
||||
|
||||
if len(geminiResponse.Predictions) == 0 {
|
||||
return nil, types.NewOpenAIError(errors.New("no images generated"), types.ErrorCodeBadResponseBody, http.StatusInternalServerError)
|
||||
}
|
||||
|
||||
// convert to openai format response
|
||||
openAIResponse := dto.ImageResponse{
|
||||
Created: common.GetTimestamp(),
|
||||
Data: make([]dto.ImageData, 0, len(geminiResponse.Predictions)),
|
||||
}
|
||||
|
||||
for _, prediction := range geminiResponse.Predictions {
|
||||
if prediction.RaiFilteredReason != "" {
|
||||
continue // skip filtered image
|
||||
}
|
||||
openAIResponse.Data = append(openAIResponse.Data, dto.ImageData{
|
||||
B64Json: prediction.BytesBase64Encoded,
|
||||
})
|
||||
}
|
||||
|
||||
jsonResponse, jsonErr := common.Marshal(openAIResponse)
|
||||
if jsonErr != nil {
|
||||
return nil, types.NewError(jsonErr, types.ErrorCodeBadResponseBody)
|
||||
}
|
||||
|
||||
c.Writer.Header().Set("Content-Type", "application/json")
|
||||
c.Writer.WriteHeader(resp.StatusCode)
|
||||
_, _ = c.Writer.Write(jsonResponse)
|
||||
|
||||
// https://github.com/google-gemini/cookbook/blob/719a27d752aac33f39de18a8d3cb42a70874917e/quickstarts/Counting_Tokens.ipynb
|
||||
// each image has fixed 258 tokens
|
||||
const imageTokens = 258
|
||||
generatedImages := len(openAIResponse.Data)
|
||||
|
||||
usage := &dto.Usage{
|
||||
PromptTokens: imageTokens * generatedImages, // each generated image has fixed 258 tokens
|
||||
CompletionTokens: 0, // image generation does not calculate completion tokens
|
||||
TotalTokens: imageTokens * generatedImages,
|
||||
}
|
||||
|
||||
return usage, nil
|
||||
}
|
||||
|
||||
type GeminiModelsResponse struct {
|
||||
Models []dto.GeminiModel `json:"models"`
|
||||
NextPageToken string `json:"nextPageToken"`
|
||||
}
|
||||
|
||||
func FetchGeminiModels(baseURL, apiKey, proxyURL string) ([]string, error) {
|
||||
client, err := service.GetHttpClientWithProxy(proxyURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("创建HTTP客户端失败: %v", err)
|
||||
}
|
||||
|
||||
allModels := make([]string, 0)
|
||||
nextPageToken := ""
|
||||
maxPages := 100 // Safety limit to prevent infinite loops
|
||||
|
||||
for page := 0; page < maxPages; page++ {
|
||||
url := fmt.Sprintf("%s/v1beta/models", baseURL)
|
||||
if nextPageToken != "" {
|
||||
url = fmt.Sprintf("%s?pageToken=%s", url, nextPageToken)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
request, err := http.NewRequestWithContext(ctx, "GET", url, nil)
|
||||
if err != nil {
|
||||
cancel()
|
||||
return nil, fmt.Errorf("创建请求失败: %v", err)
|
||||
}
|
||||
|
||||
request.Header.Set("x-goog-api-key", apiKey)
|
||||
|
||||
response, err := client.Do(request)
|
||||
if err != nil {
|
||||
cancel()
|
||||
return nil, fmt.Errorf("请求失败: %v", err)
|
||||
}
|
||||
|
||||
if response.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(response.Body)
|
||||
response.Body.Close()
|
||||
cancel()
|
||||
return nil, fmt.Errorf("服务器返回错误 %d: %s", response.StatusCode, string(body))
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(response.Body)
|
||||
response.Body.Close()
|
||||
cancel()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("读取响应失败: %v", err)
|
||||
}
|
||||
|
||||
var modelsResponse GeminiModelsResponse
|
||||
if err = common.Unmarshal(body, &modelsResponse); err != nil {
|
||||
return nil, fmt.Errorf("解析响应失败: %v", err)
|
||||
}
|
||||
|
||||
for _, model := range modelsResponse.Models {
|
||||
modelNameValue, ok := model.Name.(string)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
modelName := strings.TrimPrefix(modelNameValue, "models/")
|
||||
allModels = append(allModels, modelName)
|
||||
}
|
||||
|
||||
nextPageToken = modelsResponse.NextPageToken
|
||||
if nextPageToken == "" {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return allModels, nil
|
||||
}
|
||||
@@ -0,0 +1,516 @@
|
||||
package gemini
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/constant"
|
||||
relaycommon "github.com/QuantumNous/new-api/relay/common"
|
||||
"github.com/QuantumNous/new-api/relaykit/dto"
|
||||
"github.com/QuantumNous/new-api/relaykit/types"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestGeminiChatHandlerCompletionTokensExcludeToolUsePromptTokens(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
gin.SetMode(gin.TestMode)
|
||||
c, _ := gin.CreateTestContext(httptest.NewRecorder())
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", nil)
|
||||
|
||||
info := &relaycommon.RelayInfo{
|
||||
RelayFormat: types.RelayFormatGemini,
|
||||
OriginModelName: "gemini-3-flash-preview",
|
||||
ChannelMeta: &relaycommon.ChannelMeta{
|
||||
UpstreamModelName: "gemini-3-flash-preview",
|
||||
},
|
||||
}
|
||||
|
||||
payload := dto.GeminiChatResponse{
|
||||
Candidates: []dto.GeminiChatCandidate{
|
||||
{
|
||||
Content: dto.GeminiChatContent{
|
||||
Role: "model",
|
||||
Parts: []dto.GeminiPart{
|
||||
{Text: "ok"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
UsageMetadata: dto.GeminiUsageMetadata{
|
||||
PromptTokenCount: 151,
|
||||
ToolUsePromptTokenCount: 18329,
|
||||
CandidatesTokenCount: 1089,
|
||||
ThoughtsTokenCount: 1120,
|
||||
TotalTokenCount: 20689,
|
||||
},
|
||||
}
|
||||
|
||||
body, err := common.Marshal(payload)
|
||||
require.NoError(t, err)
|
||||
|
||||
resp := &http.Response{
|
||||
Body: io.NopCloser(bytes.NewReader(body)),
|
||||
}
|
||||
|
||||
usage, newAPIError := GeminiChatHandler(c, info, resp)
|
||||
require.Nil(t, newAPIError)
|
||||
require.NotNil(t, usage)
|
||||
require.Equal(t, 18480, usage.PromptTokens)
|
||||
require.Equal(t, 2209, usage.CompletionTokens)
|
||||
require.Equal(t, 20689, usage.TotalTokens)
|
||||
require.Equal(t, 1120, usage.CompletionTokenDetails.ReasoningTokens)
|
||||
}
|
||||
|
||||
func TestGeminiStreamHandlerCompletionTokensExcludeToolUsePromptTokens(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
c, _ := gin.CreateTestContext(httptest.NewRecorder())
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", nil)
|
||||
|
||||
oldStreamingTimeout := constant.StreamingTimeout
|
||||
constant.StreamingTimeout = 300
|
||||
t.Cleanup(func() {
|
||||
constant.StreamingTimeout = oldStreamingTimeout
|
||||
})
|
||||
|
||||
info := &relaycommon.RelayInfo{
|
||||
OriginModelName: "gemini-3-flash-preview",
|
||||
ChannelMeta: &relaycommon.ChannelMeta{
|
||||
UpstreamModelName: "gemini-3-flash-preview",
|
||||
},
|
||||
}
|
||||
|
||||
chunk := dto.GeminiChatResponse{
|
||||
Candidates: []dto.GeminiChatCandidate{
|
||||
{
|
||||
Content: dto.GeminiChatContent{
|
||||
Role: "model",
|
||||
Parts: []dto.GeminiPart{
|
||||
{Text: "partial"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
UsageMetadata: dto.GeminiUsageMetadata{
|
||||
PromptTokenCount: 151,
|
||||
ToolUsePromptTokenCount: 18329,
|
||||
CandidatesTokenCount: 1089,
|
||||
ThoughtsTokenCount: 1120,
|
||||
TotalTokenCount: 20689,
|
||||
},
|
||||
}
|
||||
|
||||
chunkData, err := common.Marshal(chunk)
|
||||
require.NoError(t, err)
|
||||
|
||||
streamBody := []byte("data: " + string(chunkData) + "\n" + "data: [DONE]\n")
|
||||
resp := &http.Response{
|
||||
Body: io.NopCloser(bytes.NewReader(streamBody)),
|
||||
}
|
||||
|
||||
usage, newAPIError := geminiStreamHandler(c, info, resp, func(_ string, _ *dto.GeminiChatResponse) bool {
|
||||
return true
|
||||
})
|
||||
require.Nil(t, newAPIError)
|
||||
require.NotNil(t, usage)
|
||||
require.Equal(t, 18480, usage.PromptTokens)
|
||||
require.Equal(t, 2209, usage.CompletionTokens)
|
||||
require.Equal(t, 20689, usage.TotalTokens)
|
||||
require.Equal(t, 1120, usage.CompletionTokenDetails.ReasoningTokens)
|
||||
}
|
||||
|
||||
func TestGeminiTextGenerationHandlerPromptTokensIncludeToolUsePromptTokens(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
gin.SetMode(gin.TestMode)
|
||||
c, _ := gin.CreateTestContext(httptest.NewRecorder())
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/v1beta/models/gemini-3-flash-preview:generateContent", nil)
|
||||
|
||||
info := &relaycommon.RelayInfo{
|
||||
OriginModelName: "gemini-3-flash-preview",
|
||||
ChannelMeta: &relaycommon.ChannelMeta{
|
||||
UpstreamModelName: "gemini-3-flash-preview",
|
||||
},
|
||||
}
|
||||
|
||||
payload := dto.GeminiChatResponse{
|
||||
Candidates: []dto.GeminiChatCandidate{
|
||||
{
|
||||
Content: dto.GeminiChatContent{
|
||||
Role: "model",
|
||||
Parts: []dto.GeminiPart{
|
||||
{Text: "ok"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
UsageMetadata: dto.GeminiUsageMetadata{
|
||||
PromptTokenCount: 151,
|
||||
ToolUsePromptTokenCount: 18329,
|
||||
CandidatesTokenCount: 1089,
|
||||
ThoughtsTokenCount: 1120,
|
||||
TotalTokenCount: 20689,
|
||||
},
|
||||
}
|
||||
|
||||
body, err := common.Marshal(payload)
|
||||
require.NoError(t, err)
|
||||
|
||||
resp := &http.Response{
|
||||
Body: io.NopCloser(bytes.NewReader(body)),
|
||||
}
|
||||
|
||||
usage, newAPIError := GeminiTextGenerationHandler(c, info, resp)
|
||||
require.Nil(t, newAPIError)
|
||||
require.NotNil(t, usage)
|
||||
require.Equal(t, 18480, usage.PromptTokens)
|
||||
require.Equal(t, 2209, usage.CompletionTokens)
|
||||
require.Equal(t, 20689, usage.TotalTokens)
|
||||
require.Equal(t, 1120, usage.CompletionTokenDetails.ReasoningTokens)
|
||||
}
|
||||
|
||||
func TestGeminiChatHandlerUsesEstimatedPromptTokensWhenUsagePromptMissing(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
gin.SetMode(gin.TestMode)
|
||||
c, _ := gin.CreateTestContext(httptest.NewRecorder())
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", nil)
|
||||
|
||||
info := &relaycommon.RelayInfo{
|
||||
RelayFormat: types.RelayFormatGemini,
|
||||
OriginModelName: "gemini-3-flash-preview",
|
||||
ChannelMeta: &relaycommon.ChannelMeta{
|
||||
UpstreamModelName: "gemini-3-flash-preview",
|
||||
},
|
||||
}
|
||||
info.SetEstimatePromptTokens(20)
|
||||
|
||||
payload := dto.GeminiChatResponse{
|
||||
Candidates: []dto.GeminiChatCandidate{
|
||||
{
|
||||
Content: dto.GeminiChatContent{
|
||||
Role: "model",
|
||||
Parts: []dto.GeminiPart{
|
||||
{Text: "ok"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
UsageMetadata: dto.GeminiUsageMetadata{
|
||||
PromptTokenCount: 0,
|
||||
ToolUsePromptTokenCount: 0,
|
||||
CandidatesTokenCount: 90,
|
||||
ThoughtsTokenCount: 10,
|
||||
TotalTokenCount: 110,
|
||||
},
|
||||
}
|
||||
|
||||
body, err := common.Marshal(payload)
|
||||
require.NoError(t, err)
|
||||
|
||||
resp := &http.Response{
|
||||
Body: io.NopCloser(bytes.NewReader(body)),
|
||||
}
|
||||
|
||||
usage, newAPIError := GeminiChatHandler(c, info, resp)
|
||||
require.Nil(t, newAPIError)
|
||||
require.NotNil(t, usage)
|
||||
require.Equal(t, 20, usage.PromptTokens)
|
||||
require.Equal(t, 100, usage.CompletionTokens)
|
||||
require.Equal(t, 110, usage.TotalTokens)
|
||||
}
|
||||
|
||||
func TestGeminiStreamHandlerUsesEstimatedPromptTokensWhenUsagePromptMissing(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
c, _ := gin.CreateTestContext(httptest.NewRecorder())
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", nil)
|
||||
|
||||
oldStreamingTimeout := constant.StreamingTimeout
|
||||
constant.StreamingTimeout = 300
|
||||
t.Cleanup(func() {
|
||||
constant.StreamingTimeout = oldStreamingTimeout
|
||||
})
|
||||
|
||||
info := &relaycommon.RelayInfo{
|
||||
OriginModelName: "gemini-3-flash-preview",
|
||||
ChannelMeta: &relaycommon.ChannelMeta{
|
||||
UpstreamModelName: "gemini-3-flash-preview",
|
||||
},
|
||||
}
|
||||
info.SetEstimatePromptTokens(20)
|
||||
|
||||
chunk := dto.GeminiChatResponse{
|
||||
Candidates: []dto.GeminiChatCandidate{
|
||||
{
|
||||
Content: dto.GeminiChatContent{
|
||||
Role: "model",
|
||||
Parts: []dto.GeminiPart{
|
||||
{Text: "partial"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
UsageMetadata: dto.GeminiUsageMetadata{
|
||||
PromptTokenCount: 0,
|
||||
ToolUsePromptTokenCount: 0,
|
||||
CandidatesTokenCount: 90,
|
||||
ThoughtsTokenCount: 10,
|
||||
TotalTokenCount: 110,
|
||||
},
|
||||
}
|
||||
|
||||
chunkData, err := common.Marshal(chunk)
|
||||
require.NoError(t, err)
|
||||
|
||||
streamBody := []byte("data: " + string(chunkData) + "\n" + "data: [DONE]\n")
|
||||
resp := &http.Response{
|
||||
Body: io.NopCloser(bytes.NewReader(streamBody)),
|
||||
}
|
||||
|
||||
usage, newAPIError := geminiStreamHandler(c, info, resp, func(_ string, _ *dto.GeminiChatResponse) bool {
|
||||
return true
|
||||
})
|
||||
require.Nil(t, newAPIError)
|
||||
require.NotNil(t, usage)
|
||||
require.Equal(t, 20, usage.PromptTokens)
|
||||
require.Equal(t, 100, usage.CompletionTokens)
|
||||
require.Equal(t, 110, usage.TotalTokens)
|
||||
}
|
||||
|
||||
func TestGeminiTextGenerationHandlerUsesEstimatedPromptTokensWhenUsagePromptMissing(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
gin.SetMode(gin.TestMode)
|
||||
c, _ := gin.CreateTestContext(httptest.NewRecorder())
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/v1beta/models/gemini-3-flash-preview:generateContent", nil)
|
||||
|
||||
info := &relaycommon.RelayInfo{
|
||||
OriginModelName: "gemini-3-flash-preview",
|
||||
ChannelMeta: &relaycommon.ChannelMeta{
|
||||
UpstreamModelName: "gemini-3-flash-preview",
|
||||
},
|
||||
}
|
||||
info.SetEstimatePromptTokens(20)
|
||||
|
||||
payload := dto.GeminiChatResponse{
|
||||
Candidates: []dto.GeminiChatCandidate{
|
||||
{
|
||||
Content: dto.GeminiChatContent{
|
||||
Role: "model",
|
||||
Parts: []dto.GeminiPart{
|
||||
{Text: "ok"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
UsageMetadata: dto.GeminiUsageMetadata{
|
||||
PromptTokenCount: 0,
|
||||
ToolUsePromptTokenCount: 0,
|
||||
CandidatesTokenCount: 90,
|
||||
ThoughtsTokenCount: 10,
|
||||
TotalTokenCount: 110,
|
||||
},
|
||||
}
|
||||
|
||||
body, err := common.Marshal(payload)
|
||||
require.NoError(t, err)
|
||||
|
||||
resp := &http.Response{
|
||||
Body: io.NopCloser(bytes.NewReader(body)),
|
||||
}
|
||||
|
||||
usage, newAPIError := GeminiTextGenerationHandler(c, info, resp)
|
||||
require.Nil(t, newAPIError)
|
||||
require.NotNil(t, usage)
|
||||
require.Equal(t, 20, usage.PromptTokens)
|
||||
require.Equal(t, 100, usage.CompletionTokens)
|
||||
require.Equal(t, 110, usage.TotalTokens)
|
||||
}
|
||||
|
||||
func TestGeminiChatHandlerMissingUsageMetadataBuildsEstimatedBillingUsage(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
gin.SetMode(gin.TestMode)
|
||||
c, _ := gin.CreateTestContext(httptest.NewRecorder())
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", nil)
|
||||
|
||||
info := &relaycommon.RelayInfo{
|
||||
RelayFormat: types.RelayFormatGemini,
|
||||
OriginModelName: "gemini-3-flash-preview",
|
||||
ChannelMeta: &relaycommon.ChannelMeta{
|
||||
UpstreamModelName: "gemini-3-flash-preview",
|
||||
},
|
||||
}
|
||||
info.SetEstimatePromptTokens(20)
|
||||
|
||||
body := []byte(`{"candidates":[{"content":{"role":"model","parts":[{"text":"ok"}]}}]}`)
|
||||
resp := &http.Response{
|
||||
Body: io.NopCloser(bytes.NewReader(body)),
|
||||
}
|
||||
|
||||
usage, newAPIError := GeminiChatHandler(c, info, resp)
|
||||
require.Nil(t, newAPIError)
|
||||
require.NotNil(t, usage)
|
||||
require.Equal(t, 20, usage.PromptTokens)
|
||||
require.NotNil(t, usage.BillingUsage)
|
||||
require.True(t, usage.BillingUsage.Estimated)
|
||||
require.Equal(t, dto.BillingUsageSourceGeminiChat, usage.BillingUsage.Source)
|
||||
require.Equal(t, dto.BillingUsageSemanticGemini, usage.BillingUsage.Semantic)
|
||||
require.NotNil(t, usage.BillingUsage.GeminiUsageMetadata)
|
||||
require.Equal(t, usage.PromptTokens, usage.BillingUsage.GeminiUsageMetadata.PromptTokenCount)
|
||||
require.Equal(t, usage.CompletionTokens, usage.BillingUsage.GeminiUsageMetadata.CandidatesTokenCount)
|
||||
require.True(t, common.GetContextKeyBool(c, constant.ContextKeyLocalCountTokens))
|
||||
}
|
||||
|
||||
func TestGeminiStreamHandlerPromptOnlyUsageMetadataEstimatesCompletionTokens(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
c, _ := gin.CreateTestContext(httptest.NewRecorder())
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", nil)
|
||||
|
||||
oldStreamingTimeout := constant.StreamingTimeout
|
||||
constant.StreamingTimeout = 300
|
||||
t.Cleanup(func() {
|
||||
constant.StreamingTimeout = oldStreamingTimeout
|
||||
})
|
||||
|
||||
info := &relaycommon.RelayInfo{
|
||||
OriginModelName: "gemini-3-flash-preview",
|
||||
ChannelMeta: &relaycommon.ChannelMeta{
|
||||
UpstreamModelName: "gemini-3-flash-preview",
|
||||
},
|
||||
}
|
||||
info.SetEstimatePromptTokens(20)
|
||||
|
||||
// Simulates a client aborting the stream before the final chunk: text was
|
||||
// streamed but the last observed usageMetadata only carries prompt tokens.
|
||||
chunk := dto.GeminiChatResponse{
|
||||
Candidates: []dto.GeminiChatCandidate{
|
||||
{
|
||||
Content: dto.GeminiChatContent{
|
||||
Role: "model",
|
||||
Parts: []dto.GeminiPart{
|
||||
{Text: "partial streamed answer before disconnect"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
UsageMetadata: dto.GeminiUsageMetadata{
|
||||
PromptTokenCount: 151,
|
||||
TotalTokenCount: 151,
|
||||
},
|
||||
}
|
||||
|
||||
chunkData, err := common.Marshal(chunk)
|
||||
require.NoError(t, err)
|
||||
|
||||
streamBody := []byte("data: " + string(chunkData) + "\n" + "data: [DONE]\n")
|
||||
resp := &http.Response{
|
||||
Body: io.NopCloser(bytes.NewReader(streamBody)),
|
||||
}
|
||||
|
||||
usage, newAPIError := geminiStreamHandler(c, info, resp, func(_ string, _ *dto.GeminiChatResponse) bool {
|
||||
return true
|
||||
})
|
||||
require.Nil(t, newAPIError)
|
||||
require.NotNil(t, usage)
|
||||
require.Equal(t, 151, usage.PromptTokens)
|
||||
require.Greater(t, usage.CompletionTokens, 0)
|
||||
require.Equal(t, usage.PromptTokens+usage.CompletionTokens, usage.TotalTokens)
|
||||
require.NotNil(t, usage.BillingUsage)
|
||||
require.True(t, usage.BillingUsage.Estimated)
|
||||
require.NotNil(t, usage.BillingUsage.GeminiUsageMetadata)
|
||||
require.Equal(t, usage.CompletionTokens, usage.BillingUsage.GeminiUsageMetadata.CandidatesTokenCount)
|
||||
}
|
||||
|
||||
func TestGeminiChatHandlerPromptOnlyUsageMetadataEstimatesCompletionTokens(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
gin.SetMode(gin.TestMode)
|
||||
c, _ := gin.CreateTestContext(httptest.NewRecorder())
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", nil)
|
||||
|
||||
info := &relaycommon.RelayInfo{
|
||||
RelayFormat: types.RelayFormatGemini,
|
||||
OriginModelName: "gemini-3-flash-preview",
|
||||
ChannelMeta: &relaycommon.ChannelMeta{
|
||||
UpstreamModelName: "gemini-3-flash-preview",
|
||||
},
|
||||
}
|
||||
|
||||
payload := dto.GeminiChatResponse{
|
||||
Candidates: []dto.GeminiChatCandidate{
|
||||
{
|
||||
Content: dto.GeminiChatContent{
|
||||
Role: "model",
|
||||
Parts: []dto.GeminiPart{
|
||||
{Text: "answer text without candidate token count"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
UsageMetadata: dto.GeminiUsageMetadata{
|
||||
PromptTokenCount: 151,
|
||||
TotalTokenCount: 151,
|
||||
},
|
||||
}
|
||||
|
||||
body, err := common.Marshal(payload)
|
||||
require.NoError(t, err)
|
||||
|
||||
resp := &http.Response{
|
||||
Body: io.NopCloser(bytes.NewReader(body)),
|
||||
}
|
||||
|
||||
usage, newAPIError := GeminiChatHandler(c, info, resp)
|
||||
require.Nil(t, newAPIError)
|
||||
require.NotNil(t, usage)
|
||||
require.Equal(t, 151, usage.PromptTokens)
|
||||
require.Greater(t, usage.CompletionTokens, 0)
|
||||
require.Equal(t, usage.PromptTokens+usage.CompletionTokens, usage.TotalTokens)
|
||||
require.NotNil(t, usage.BillingUsage)
|
||||
require.True(t, usage.BillingUsage.Estimated)
|
||||
}
|
||||
|
||||
func TestGeminiStreamHandlerEmptyUsageMetadataBuildsEstimatedBillingUsage(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
c, _ := gin.CreateTestContext(httptest.NewRecorder())
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", nil)
|
||||
|
||||
oldStreamingTimeout := constant.StreamingTimeout
|
||||
constant.StreamingTimeout = 300
|
||||
t.Cleanup(func() {
|
||||
constant.StreamingTimeout = oldStreamingTimeout
|
||||
})
|
||||
|
||||
info := &relaycommon.RelayInfo{
|
||||
OriginModelName: "gemini-3-flash-preview",
|
||||
ChannelMeta: &relaycommon.ChannelMeta{
|
||||
UpstreamModelName: "gemini-3-flash-preview",
|
||||
},
|
||||
}
|
||||
info.SetEstimatePromptTokens(20)
|
||||
|
||||
streamBody := []byte("data: {\"candidates\":[{\"content\":{\"role\":\"model\",\"parts\":[{\"text\":\"partial\"}]}}],\"usageMetadata\":{}}\n" + "data: [DONE]\n")
|
||||
resp := &http.Response{
|
||||
Body: io.NopCloser(bytes.NewReader(streamBody)),
|
||||
}
|
||||
|
||||
usage, newAPIError := geminiStreamHandler(c, info, resp, func(_ string, _ *dto.GeminiChatResponse) bool {
|
||||
return true
|
||||
})
|
||||
require.Nil(t, newAPIError)
|
||||
require.NotNil(t, usage)
|
||||
require.Equal(t, 20, usage.PromptTokens)
|
||||
require.NotNil(t, usage.BillingUsage)
|
||||
require.True(t, usage.BillingUsage.Estimated)
|
||||
require.Equal(t, dto.BillingUsageSourceGeminiChat, usage.BillingUsage.Source)
|
||||
require.NotNil(t, usage.BillingUsage.GeminiUsageMetadata)
|
||||
require.Equal(t, usage.PromptTokens, usage.BillingUsage.GeminiUsageMetadata.PromptTokenCount)
|
||||
require.Equal(t, usage.CompletionTokens, usage.BillingUsage.GeminiUsageMetadata.CandidatesTokenCount)
|
||||
require.True(t, common.GetContextKeyBool(c, constant.ContextKeyLocalCountTokens))
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
package gemini
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/constant"
|
||||
"github.com/QuantumNous/new-api/logger"
|
||||
relaycommon "github.com/QuantumNous/new-api/relay/common"
|
||||
"github.com/QuantumNous/new-api/relay/helper"
|
||||
"github.com/QuantumNous/new-api/relaykit/dto"
|
||||
"github.com/QuantumNous/new-api/relaykit/relayconvert"
|
||||
"github.com/QuantumNous/new-api/relaykit/types"
|
||||
"github.com/QuantumNous/new-api/service"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func GeminiResponsesHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Response) (*dto.Usage, *types.NewAPIError) {
|
||||
defer service.CloseResponseBodyGracefully(resp)
|
||||
|
||||
responseBody, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError)
|
||||
}
|
||||
logger.LogDebug(c, "Gemini responses response body: %s", responseBody)
|
||||
|
||||
var geminiResponse dto.GeminiChatResponse
|
||||
if err := common.Unmarshal(responseBody, &geminiResponse); err != nil {
|
||||
return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError)
|
||||
}
|
||||
markGeminiGoogleSearchCall(c, &geminiResponse)
|
||||
if len(geminiResponse.Candidates) == 0 {
|
||||
usage := buildUsageFromGeminiResponse(c, info, &geminiResponse)
|
||||
if geminiResponse.PromptFeedback != nil && geminiResponse.PromptFeedback.BlockReason != nil {
|
||||
common.SetContextKey(c, constant.ContextKeyAdminRejectReason, fmt.Sprintf("gemini_block_reason=%s", *geminiResponse.PromptFeedback.BlockReason))
|
||||
return &usage, types.NewOpenAIError(
|
||||
errors.New("request blocked by Gemini API: "+*geminiResponse.PromptFeedback.BlockReason),
|
||||
types.ErrorCodePromptBlocked,
|
||||
http.StatusBadRequest,
|
||||
)
|
||||
}
|
||||
common.SetContextKey(c, constant.ContextKeyAdminRejectReason, "gemini_empty_candidates")
|
||||
return &usage, types.NewOpenAIError(
|
||||
errors.New("empty response from Gemini API"),
|
||||
types.ErrorCodeEmptyResponse,
|
||||
http.StatusInternalServerError,
|
||||
)
|
||||
}
|
||||
|
||||
chatResp := responseGeminiChat2OpenAI(c, &geminiResponse)
|
||||
chatResp.Model = info.UpstreamModelName
|
||||
if responseID := helper.GetResponseID(c); responseID != "" {
|
||||
chatResp.Id = responseID
|
||||
}
|
||||
usage := buildUsageFromGeminiResponse(c, info, &geminiResponse)
|
||||
chatResp.Usage = usage
|
||||
|
||||
convertResult, err := relayconvert.ConvertResponse(c, info, types.RelayFormatOpenAIResponses, chatResp)
|
||||
if err != nil {
|
||||
return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError)
|
||||
}
|
||||
responsesResp, ok := convertResult.Value.(*dto.OpenAIResponsesResponse)
|
||||
if !ok {
|
||||
return nil, types.NewOpenAIError(fmt.Errorf("expected OpenAI responses response, got %T", convertResult.Value), types.ErrorCodeBadResponseBody, http.StatusInternalServerError)
|
||||
}
|
||||
responsesUsage := convertResult.Usage
|
||||
if responsesUsage == nil || responsesUsage.TotalTokens == 0 {
|
||||
responsesResp.Usage = relayconvert.UsageFromChatUsage(&usage)
|
||||
}
|
||||
|
||||
responseBody, err = common.Marshal(responsesResp)
|
||||
if err != nil {
|
||||
return nil, types.NewOpenAIError(err, types.ErrorCodeJsonMarshalFailed, http.StatusInternalServerError)
|
||||
}
|
||||
service.IOCopyBytesGracefully(c, resp, responseBody)
|
||||
return &usage, nil
|
||||
}
|
||||
|
||||
func GeminiResponsesStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Response) (*dto.Usage, *types.NewAPIError) {
|
||||
responseID := helper.GetResponseID(c)
|
||||
created := common.GetTimestamp()
|
||||
state, err := relayconvert.NewResponseStreamState(types.RelayFormatOpenAI, types.RelayFormatOpenAIResponses, relayconvert.ResponseStreamOptions{
|
||||
ID: responseID,
|
||||
Model: info.UpstreamModelName,
|
||||
Created: created,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponse, http.StatusInternalServerError)
|
||||
}
|
||||
finishReason := constant.FinishReasonStop
|
||||
toolCallIndexByChoice := make(map[int]map[string]int)
|
||||
nextToolCallIndexByChoice := make(map[int]int)
|
||||
var streamErr *types.NewAPIError
|
||||
|
||||
sendEvent := func(event relayconvert.ChatToResponsesStreamEvent) bool {
|
||||
data, err := common.Marshal(event.Payload)
|
||||
if err != nil {
|
||||
streamErr = types.NewOpenAIError(err, types.ErrorCodeJsonMarshalFailed, http.StatusInternalServerError)
|
||||
return false
|
||||
}
|
||||
helper.ResponseChunkData(c, dto.ResponsesStreamResponse{Type: event.Type}, string(data))
|
||||
return true
|
||||
}
|
||||
sendChunk := func(chunk *dto.ChatCompletionsStreamResponse) bool {
|
||||
results, err := relayconvert.ConvertStreamResponseChunk(c, info, state, chunk)
|
||||
if err != nil {
|
||||
streamErr = types.NewOpenAIError(err, types.ErrorCodeBadResponse, http.StatusInternalServerError)
|
||||
return false
|
||||
}
|
||||
for _, result := range results {
|
||||
event, ok := result.Value.(relayconvert.ChatToResponsesStreamEvent)
|
||||
if !ok {
|
||||
streamErr = types.NewOpenAIError(fmt.Errorf("expected OAI responses stream event, got %T", result.Value), types.ErrorCodeBadResponse, http.StatusInternalServerError)
|
||||
return false
|
||||
}
|
||||
if !sendEvent(event) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
usage, streamAPIError := geminiStreamHandler(c, info, resp, func(data string, geminiResponse *dto.GeminiChatResponse) bool {
|
||||
response, isStop := streamResponseGeminiChat2OpenAI(geminiResponse)
|
||||
response.Id = responseID
|
||||
response.Created = created
|
||||
response.Model = info.UpstreamModelName
|
||||
|
||||
if response.IsToolCall() {
|
||||
finishReason = constant.FinishReasonToolCalls
|
||||
}
|
||||
for choiceIdx := range response.Choices {
|
||||
choiceKey := response.Choices[choiceIdx].Index
|
||||
for toolIdx := range response.Choices[choiceIdx].Delta.ToolCalls {
|
||||
tool := &response.Choices[choiceIdx].Delta.ToolCalls[toolIdx]
|
||||
if tool.ID == "" {
|
||||
continue
|
||||
}
|
||||
indexByID := toolCallIndexByChoice[choiceKey]
|
||||
if indexByID == nil {
|
||||
indexByID = make(map[string]int)
|
||||
toolCallIndexByChoice[choiceKey] = indexByID
|
||||
}
|
||||
if idx, ok := indexByID[tool.ID]; ok {
|
||||
tool.SetIndex(idx)
|
||||
continue
|
||||
}
|
||||
idx := nextToolCallIndexByChoice[choiceKey]
|
||||
nextToolCallIndexByChoice[choiceKey] = idx + 1
|
||||
indexByID[tool.ID] = idx
|
||||
tool.SetIndex(idx)
|
||||
}
|
||||
}
|
||||
|
||||
if !sendChunk(response) {
|
||||
return false
|
||||
}
|
||||
if isStop {
|
||||
return sendChunk(helper.GenerateStopResponse(responseID, created, info.UpstreamModelName, finishReason))
|
||||
}
|
||||
return true
|
||||
})
|
||||
if streamAPIError != nil {
|
||||
return usage, streamAPIError
|
||||
}
|
||||
if streamErr != nil {
|
||||
return nil, streamErr
|
||||
}
|
||||
|
||||
if usage != nil {
|
||||
state.SetUsage(usage)
|
||||
}
|
||||
finalResults, err := relayconvert.FinalizeStreamResponse(c, info, state)
|
||||
if err != nil {
|
||||
return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponse, http.StatusInternalServerError)
|
||||
}
|
||||
for _, result := range finalResults {
|
||||
event, ok := result.Value.(relayconvert.ChatToResponsesStreamEvent)
|
||||
if !ok {
|
||||
return nil, types.NewOpenAIError(fmt.Errorf("expected OAI responses stream event, got %T", result.Value), types.ErrorCodeBadResponse, http.StatusInternalServerError)
|
||||
}
|
||||
if !sendEvent(event) {
|
||||
return nil, streamErr
|
||||
}
|
||||
}
|
||||
return usage, nil
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
package gemini
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/constant"
|
||||
relaycommon "github.com/QuantumNous/new-api/relay/common"
|
||||
relayconstant "github.com/QuantumNous/new-api/relay/constant"
|
||||
"github.com/QuantumNous/new-api/relaykit/dto"
|
||||
"github.com/QuantumNous/new-api/relaykit/types"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestGeminiResponsesHandlerReturnsOpenAIResponsesJSON(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
recorder := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(recorder)
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", nil)
|
||||
c.Set(common.RequestIdKey, "gemini-responses-test")
|
||||
|
||||
info := newGeminiResponsesRelayInfo(false)
|
||||
payload := dto.GeminiChatResponse{
|
||||
Candidates: []dto.GeminiChatCandidate{
|
||||
{
|
||||
Content: dto.GeminiChatContent{
|
||||
Role: "model",
|
||||
Parts: []dto.GeminiPart{
|
||||
{Text: "hello"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
UsageMetadata: dto.GeminiUsageMetadata{
|
||||
PromptTokenCount: 2,
|
||||
CandidatesTokenCount: 3,
|
||||
TotalTokenCount: 5,
|
||||
},
|
||||
}
|
||||
body, err := common.Marshal(payload)
|
||||
require.NoError(t, err)
|
||||
|
||||
usage, newAPIError := GeminiResponsesHandler(c, info, &http.Response{
|
||||
Body: io.NopCloser(bytes.NewReader(body)),
|
||||
})
|
||||
require.Nil(t, newAPIError)
|
||||
require.NotNil(t, usage)
|
||||
assert.Equal(t, 2, usage.PromptTokens)
|
||||
assert.Equal(t, 3, usage.CompletionTokens)
|
||||
|
||||
got := recorder.Body.String()
|
||||
assert.Contains(t, got, `"object":"response"`)
|
||||
assert.Contains(t, got, `"status":"completed"`)
|
||||
assert.Contains(t, got, `"type":"output_text"`)
|
||||
assert.Contains(t, got, `"text":"hello"`)
|
||||
assert.Contains(t, got, `"input_tokens":2`)
|
||||
assert.Contains(t, got, `"output_tokens":3`)
|
||||
assert.NotContains(t, got, `"choices"`)
|
||||
assert.NotContains(t, got, `"candidates"`)
|
||||
}
|
||||
|
||||
func TestGeminiResponsesHandlerClosesBodyOnReadError(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
recorder := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(recorder)
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", nil)
|
||||
c.Set(common.RequestIdKey, "gemini-responses-read-error-test")
|
||||
|
||||
body := &failingReadCloser{}
|
||||
usage, newAPIError := GeminiResponsesHandler(c, newGeminiResponsesRelayInfo(false), &http.Response{Body: body})
|
||||
|
||||
require.Nil(t, usage)
|
||||
require.NotNil(t, newAPIError)
|
||||
assert.True(t, body.closed)
|
||||
}
|
||||
|
||||
func TestGeminiResponsesStreamHandlerReturnsOpenAIResponsesSSE(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
recorder := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(recorder)
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", nil)
|
||||
c.Set(common.RequestIdKey, "gemini-responses-stream-test")
|
||||
|
||||
oldStreamingTimeout := constant.StreamingTimeout
|
||||
constant.StreamingTimeout = 300
|
||||
t.Cleanup(func() { constant.StreamingTimeout = oldStreamingTimeout })
|
||||
|
||||
info := newGeminiResponsesRelayInfo(true)
|
||||
first := dto.GeminiChatResponse{
|
||||
Candidates: []dto.GeminiChatCandidate{
|
||||
{
|
||||
Content: dto.GeminiChatContent{
|
||||
Role: "model",
|
||||
Parts: []dto.GeminiPart{
|
||||
{Text: "hello"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
UsageMetadata: dto.GeminiUsageMetadata{
|
||||
PromptTokenCount: 2,
|
||||
CandidatesTokenCount: 3,
|
||||
TotalTokenCount: 5,
|
||||
},
|
||||
}
|
||||
stop := "STOP"
|
||||
final := dto.GeminiChatResponse{
|
||||
Candidates: []dto.GeminiChatCandidate{
|
||||
{
|
||||
FinishReason: &stop,
|
||||
Content: dto.GeminiChatContent{
|
||||
Role: "model",
|
||||
Parts: []dto.GeminiPart{{Text: ""}},
|
||||
},
|
||||
},
|
||||
},
|
||||
UsageMetadata: dto.GeminiUsageMetadata{
|
||||
PromptTokenCount: 2,
|
||||
CandidatesTokenCount: 3,
|
||||
TotalTokenCount: 5,
|
||||
},
|
||||
}
|
||||
firstData, err := common.Marshal(first)
|
||||
require.NoError(t, err)
|
||||
finalData, err := common.Marshal(final)
|
||||
require.NoError(t, err)
|
||||
streamBody := strings.Join([]string{
|
||||
"data: " + string(firstData),
|
||||
"",
|
||||
"data: " + string(finalData),
|
||||
"",
|
||||
"data: [DONE]",
|
||||
"",
|
||||
}, "\n")
|
||||
|
||||
usage, newAPIError := GeminiResponsesStreamHandler(c, info, &http.Response{
|
||||
Body: io.NopCloser(strings.NewReader(streamBody)),
|
||||
})
|
||||
require.Nil(t, newAPIError)
|
||||
require.NotNil(t, usage)
|
||||
assert.Equal(t, 5, usage.TotalTokens)
|
||||
|
||||
got := recorder.Body.String()
|
||||
assert.Equal(t, "text/event-stream", recorder.Header().Get("Content-Type"))
|
||||
assert.Contains(t, got, `event: response.created`)
|
||||
assert.Contains(t, got, `event: response.output_text.delta`)
|
||||
assert.Contains(t, got, `"delta":"hello"`)
|
||||
assert.Contains(t, got, `event: response.completed`)
|
||||
assert.Contains(t, got, `"input_tokens":2`)
|
||||
assert.Contains(t, got, `"output_tokens":3`)
|
||||
assert.NotContains(t, got, `"choices"`)
|
||||
assert.NotContains(t, got, `"candidates"`)
|
||||
requireOrderedGeminiResponsesSubstrings(t, got,
|
||||
`event: response.created`,
|
||||
`event: response.output_item.added`,
|
||||
`event: response.output_text.delta`,
|
||||
`event: response.output_text.done`,
|
||||
`event: response.completed`,
|
||||
)
|
||||
}
|
||||
|
||||
func newGeminiResponsesRelayInfo(isStream bool) *relaycommon.RelayInfo {
|
||||
return &relaycommon.RelayInfo{
|
||||
IsStream: isStream,
|
||||
RelayMode: relayconstant.RelayModeResponses,
|
||||
RelayFormat: types.RelayFormatOpenAIResponses,
|
||||
RequestURLPath: "/v1/responses",
|
||||
DisablePing: true,
|
||||
OriginModelName: "gemini-test",
|
||||
ChannelMeta: &relaycommon.ChannelMeta{
|
||||
UpstreamModelName: "gemini-test",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
type failingReadCloser struct {
|
||||
closed bool
|
||||
}
|
||||
|
||||
func (r *failingReadCloser) Read([]byte) (int, error) {
|
||||
return 0, errors.New("read failed")
|
||||
}
|
||||
|
||||
func (r *failingReadCloser) Close() error {
|
||||
r.closed = true
|
||||
return nil
|
||||
}
|
||||
|
||||
func requireOrderedGeminiResponsesSubstrings(t *testing.T, s string, parts ...string) {
|
||||
t.Helper()
|
||||
offset := 0
|
||||
for _, part := range parts {
|
||||
idx := strings.Index(s[offset:], part)
|
||||
require.NotEqualf(t, -1, idx, "missing %q after byte offset %d", part, offset)
|
||||
offset += idx + len(part)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user