feat: publish sanitized custom.97 source
This commit is contained in:
1022 files changed
+69903
-19207
No files matched your search
@@ -103,7 +103,7 @@ func TestConvertOpenAIResponsesRequestToGeminiFunctionCallConversation(t *testin
|
||||
assert.Equal(t, map[string]any{"ok": true}, got.Contents[1].Parts[0].FunctionResponse.Response)
|
||||
}
|
||||
|
||||
func TestConvertOpenAIResponsesRequestToGeminiSkipsCustomToolCalls(t *testing.T) {
|
||||
func TestConvertOpenAIResponsesRequestToGeminiKeepsCustomToolCalls(t *testing.T) {
|
||||
got := mustConvertResponsesToGemini(t, dto.OpenAIResponsesRequest{
|
||||
Model: "gemini-test",
|
||||
Input: mustGeminiRawMessage(t, []map[string]any{
|
||||
@@ -140,17 +140,34 @@ func TestConvertOpenAIResponsesRequestToGeminiSkipsCustomToolCalls(t *testing.T)
|
||||
}),
|
||||
})
|
||||
|
||||
assert.Empty(t, got.GetTools())
|
||||
require.Len(t, got.Contents, 2)
|
||||
// The cleaned schema has no additionalProperties, and the unknown tool is dropped.
|
||||
assert.JSONEq(t, `[{"functionDeclarations":[{
|
||||
"name":"apply_patch",
|
||||
"description":"This tool takes freeform text. Put the complete raw text in the \"input\" argument.",
|
||||
"parameters":{"type":"OBJECT","properties":{"input":{"type":"STRING","description":"Raw input for the tool."}},"required":["input"]}
|
||||
}]}]`, string(got.Tools))
|
||||
|
||||
require.Len(t, got.Contents, 3)
|
||||
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)
|
||||
require.Len(t, got.Contents[0].Parts, 2)
|
||||
require.NotNil(t, got.Contents[0].Parts[0].FunctionCall)
|
||||
assert.Equal(t, "apply_patch", got.Contents[0].Parts[0].FunctionCall.FunctionName)
|
||||
assert.Equal(t, map[string]any{"input": "patch body"}, got.Contents[0].Parts[0].FunctionCall.Arguments)
|
||||
assert.Equal(t, "before custom", got.Contents[0].Parts[1].Text)
|
||||
|
||||
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)
|
||||
require.Len(t, got.Contents[1].Parts, 2)
|
||||
for i, output := range []string{"ok", "legacy custom output"} {
|
||||
response := got.Contents[1].Parts[i].FunctionResponse
|
||||
require.NotNil(t, response)
|
||||
assert.Equal(t, "apply_patch", response.Name)
|
||||
assert.Equal(t, map[string]any{"content": output}, response.Response)
|
||||
assert.JSONEq(t, `"call_custom"`, string(response.ID))
|
||||
}
|
||||
|
||||
assert.Equal(t, "user", got.Contents[2].Role)
|
||||
require.Len(t, got.Contents[2].Parts, 1)
|
||||
assert.Equal(t, "next turn", got.Contents[2].Parts[0].Text)
|
||||
}
|
||||
|
||||
func mustConvertResponsesToGemini(t *testing.T, req dto.OpenAIResponsesRequest) *dto.GeminiChatRequest {
|
||||
|
||||
@@ -36,6 +36,9 @@ func GeminiTextGenerationHandler(c *gin.Context, info *relaycommon.RelayInfo, re
|
||||
return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError)
|
||||
}
|
||||
info.ObserveResponseModel(gjson.GetBytes(responseBody, "modelVersion").Str)
|
||||
var grounding geminiGroundingCounter
|
||||
grounding.observe(&geminiResponse)
|
||||
grounding.commit(info)
|
||||
countGeminiBillableFunctionCalls(info, &geminiResponse)
|
||||
|
||||
if len(geminiResponse.Candidates) == 0 && geminiResponse.PromptFeedback != nil && geminiResponse.PromptFeedback.BlockReason != nil {
|
||||
@@ -88,7 +91,7 @@ func GeminiTextGenerationStreamHandler(c *gin.Context, info *relaycommon.RelayIn
|
||||
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())
|
||||
logger.LogError(c, common.LogText("failed to write stream data: %s", err.Error()))
|
||||
return false
|
||||
}
|
||||
info.SendResponseCount++
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -75,23 +76,58 @@ func geminiResponseUsageText(response *dto.GeminiChatResponse) string {
|
||||
if part.Text != "" {
|
||||
text.WriteString(part.Text)
|
||||
}
|
||||
// Function calls are most of an agent turn's output.
|
||||
if part.FunctionCall != nil {
|
||||
text.WriteString(part.FunctionCall.FunctionName)
|
||||
args, _ := common.Marshal(part.FunctionCall.Arguments)
|
||||
text.Write(args)
|
||||
}
|
||||
}
|
||||
}
|
||||
return text.String()
|
||||
}
|
||||
|
||||
func markGeminiGoogleSearchCall(c *gin.Context, response *dto.GeminiChatResponse) {
|
||||
if c == nil || response == nil {
|
||||
return
|
||||
}
|
||||
// geminiGroundingCounter accumulates Google Search grounding across one
|
||||
// upstream response (every stream frame and candidate). Gemini 3 bills each
|
||||
// unique non-empty search query ("we ignore the empty web search queries when
|
||||
// counting unique queries", ai.google.dev/gemini-api/docs/google-search);
|
||||
// Gemini 2.5 and older bill once per grounded prompt, counted here when the
|
||||
// response carries any search query. Both quantities are recorded and the
|
||||
// tool price table decides which one costs money for a model. Image-search
|
||||
// queries count like web-search queries; Google documents no separate rule.
|
||||
// Google documents no response field holding the billed count, so the unique
|
||||
// query set is the best available evidence, not a guaranteed match.
|
||||
type geminiGroundingCounter struct {
|
||||
queries map[string]struct{}
|
||||
}
|
||||
|
||||
func (g *geminiGroundingCounter) observe(response *dto.GeminiChatResponse) {
|
||||
for _, candidate := range response.Candidates {
|
||||
if candidate.GroundingMetadata != nil && len(candidate.GroundingMetadata.WebSearchQueries) > 0 {
|
||||
c.Set("gemini_google_search_call", true)
|
||||
return
|
||||
metadata := candidate.GroundingMetadata
|
||||
if metadata == nil {
|
||||
continue
|
||||
}
|
||||
for _, query := range slices.Concat(metadata.WebSearchQueries, metadata.ImageSearchQueries) {
|
||||
query = strings.TrimSpace(query)
|
||||
if query == "" {
|
||||
continue
|
||||
}
|
||||
if g.queries == nil {
|
||||
g.queries = make(map[string]struct{})
|
||||
}
|
||||
g.queries[query] = struct{}{}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (g *geminiGroundingCounter) commit(info *relaycommon.RelayInfo) {
|
||||
if len(g.queries) == 0 {
|
||||
return
|
||||
}
|
||||
info.SetBillableToolCount(dto.BuildInToolGoogleSearch, len(g.queries))
|
||||
info.SetBillableToolCount(relaycommon.GoogleSearchGroundedPromptTool, 1)
|
||||
}
|
||||
|
||||
func countGeminiBillableFunctionCalls(info *relaycommon.RelayInfo, response *dto.GeminiChatResponse) {
|
||||
if info == nil || response == nil {
|
||||
return
|
||||
@@ -171,6 +207,7 @@ func geminiStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http
|
||||
var hasBillableUsageMetadata bool
|
||||
var streamErr error
|
||||
var accumulatedUsageMetadata *dto.GeminiUsageMetadata
|
||||
var grounding geminiGroundingCounter
|
||||
responseText := strings.Builder{}
|
||||
|
||||
helper.StreamScannerHandler(c, resp, info, func(data string, sr *helper.StreamResult) {
|
||||
@@ -197,7 +234,9 @@ func geminiStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http
|
||||
info.StreamStatus.MarkCompleted()
|
||||
}
|
||||
|
||||
markGeminiGoogleSearchCall(c, &geminiResponse)
|
||||
// Commit on every frame so a client abort keeps the queries seen so far.
|
||||
grounding.observe(&geminiResponse)
|
||||
grounding.commit(info)
|
||||
countGeminiBillableFunctionCalls(info, &geminiResponse)
|
||||
|
||||
// 统计图片数量
|
||||
@@ -206,11 +245,9 @@ func geminiStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http
|
||||
if part.InlineData != nil && part.InlineData.MimeType != "" {
|
||||
imageCount++
|
||||
}
|
||||
if part.Text != "" {
|
||||
responseText.WriteString(part.Text)
|
||||
}
|
||||
}
|
||||
}
|
||||
responseText.WriteString(geminiResponseUsageText(&geminiResponse))
|
||||
|
||||
// 更新使用量统计
|
||||
if metadata := geminiResponse.GetUsageMetadata(); dto.HasGeminiUsageMetadataTokens(metadata) {
|
||||
@@ -251,7 +288,7 @@ func geminiStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http
|
||||
return usage, types.NewOpenAIError(streamErr, types.ErrorCodeBadResponseBody, http.StatusInternalServerError)
|
||||
}
|
||||
if info.StreamStatus != nil && !info.StreamStatus.IsNormalEnd() {
|
||||
logger.LogWarn(c, fmt.Sprintf("Gemini stream ended unexpectedly: %s", info.StreamStatus.Summary()))
|
||||
logger.LogWarn(c, common.LogText("Gemini stream ended unexpectedly: %s", info.StreamStatus.Summary()))
|
||||
}
|
||||
|
||||
return usage, nil
|
||||
@@ -367,7 +404,7 @@ func GeminiChatStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *
|
||||
}
|
||||
handleErr := handleFinalStream(c, info, response)
|
||||
if handleErr != nil {
|
||||
common.SysLog("send final response failed: " + handleErr.Error())
|
||||
common.SysLog(common.LogText("send final response failed: %s", handleErr.Error()))
|
||||
}
|
||||
return usage, nil
|
||||
}
|
||||
@@ -385,7 +422,9 @@ func GeminiChatHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.R
|
||||
return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError)
|
||||
}
|
||||
info.ObserveResponseModel(gjson.GetBytes(responseBody, "modelVersion").Str)
|
||||
markGeminiGoogleSearchCall(c, &geminiResponse)
|
||||
var grounding geminiGroundingCounter
|
||||
grounding.observe(&geminiResponse)
|
||||
grounding.commit(info)
|
||||
countGeminiBillableFunctionCalls(info, &geminiResponse)
|
||||
if len(geminiResponse.Candidates) == 0 {
|
||||
usage := buildUsageFromGeminiResponse(c, info, &geminiResponse)
|
||||
@@ -414,11 +453,11 @@ func GeminiChatHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.R
|
||||
case types.RelayFormatClaude:
|
||||
c.JSON(newAPIError.StatusCode, gin.H{
|
||||
"type": "error",
|
||||
"error": newAPIError.ToClaudeError(),
|
||||
"error": newAPIError.ToClaudeError(common.UpstreamAddresses(c)...),
|
||||
})
|
||||
default:
|
||||
c.JSON(newAPIError.StatusCode, gin.H{
|
||||
"error": newAPIError.ToOpenAIError(),
|
||||
"error": newAPIError.ToOpenAIError(common.UpstreamAddresses(c)...),
|
||||
})
|
||||
}
|
||||
return &usage, nil
|
||||
@@ -558,10 +597,10 @@ type GeminiModelsResponse struct {
|
||||
NextPageToken string `json:"nextPageToken"`
|
||||
}
|
||||
|
||||
func FetchGeminiModels(baseURL, apiKey, proxyURL string) ([]string, error) {
|
||||
client, err := service.GetHttpClientWithProxy(proxyURL)
|
||||
func FetchGeminiModels(baseURL, apiKey string, setting dto.ChannelSettings) ([]string, error) {
|
||||
client, err := service.GetHttpClientWithProxySettings(setting.Proxy, setting)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("创建HTTP客户端失败: %v", err)
|
||||
return nil, fmt.Errorf("failed to create HTTP client: %v", err)
|
||||
}
|
||||
|
||||
allModels := make([]string, 0)
|
||||
@@ -578,7 +617,7 @@ func FetchGeminiModels(baseURL, apiKey, proxyURL string) ([]string, error) {
|
||||
request, err := http.NewRequestWithContext(ctx, "GET", url, nil)
|
||||
if err != nil {
|
||||
cancel()
|
||||
return nil, fmt.Errorf("创建请求失败: %v", err)
|
||||
return nil, fmt.Errorf("failed to create request: %v", err)
|
||||
}
|
||||
|
||||
request.Header.Set("x-goog-api-key", apiKey)
|
||||
@@ -586,26 +625,26 @@ func FetchGeminiModels(baseURL, apiKey, proxyURL string) ([]string, error) {
|
||||
response, err := client.Do(request)
|
||||
if err != nil {
|
||||
cancel()
|
||||
return nil, fmt.Errorf("请求失败: %v", err)
|
||||
return nil, fmt.Errorf("request failed: %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))
|
||||
return nil, fmt.Errorf("server returned error %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)
|
||||
return nil, fmt.Errorf("failed to read response: %v", err)
|
||||
}
|
||||
|
||||
var modelsResponse GeminiModelsResponse
|
||||
if err = common.Unmarshal(body, &modelsResponse); err != nil {
|
||||
return nil, fmt.Errorf("解析响应失败: %v", err)
|
||||
return nil, fmt.Errorf("failed to parse response: %v", err)
|
||||
}
|
||||
|
||||
for _, model := range modelsResponse.Models {
|
||||
|
||||
@@ -13,7 +13,9 @@ import (
|
||||
relaycommon "github.com/QuantumNous/new-api/relay/common"
|
||||
"github.com/QuantumNous/new-api/relaykit/dto"
|
||||
"github.com/QuantumNous/new-api/relaykit/types"
|
||||
"github.com/QuantumNous/new-api/tokenkit"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
@@ -494,8 +496,6 @@ func TestGeminiChatHandlerMissingUsageMetadataBuildsEstimatedBillingUsage(t *tes
|
||||
|
||||
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
|
||||
@@ -503,53 +503,67 @@ func TestGeminiStreamHandlerPromptOnlyUsageMetadataEstimatesCompletionTokens(t *
|
||||
constant.StreamingTimeout = oldStreamingTimeout
|
||||
})
|
||||
|
||||
info := &relaycommon.RelayInfo{
|
||||
OriginModelName: "gemini-3-flash-preview",
|
||||
ChannelMeta: &relaycommon.ChannelMeta{
|
||||
UpstreamModelName: "gemini-3-flash-preview",
|
||||
},
|
||||
}
|
||||
info.SetEstimatePromptTokens(20)
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
part dto.GeminiPart
|
||||
usageText string
|
||||
}{
|
||||
{"text", dto.GeminiPart{Text: "partial streamed answer before disconnect"}, "partial streamed answer before disconnect"},
|
||||
// An agent turn that only calls a tool still produced output tokens.
|
||||
{"function call", dto.GeminiPart{FunctionCall: &dto.FunctionCall{FunctionName: "read_file", Arguments: map[string]any{"path": "main.go"}}}, `read_file{"path":"main.go"}`},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
c, _ := gin.CreateTestContext(httptest.NewRecorder())
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", nil)
|
||||
|
||||
// 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"},
|
||||
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: output 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{tc.part},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
UsageMetadata: dto.GeminiUsageMetadata{
|
||||
PromptTokenCount: 151,
|
||||
TotalTokenCount: 151,
|
||||
},
|
||||
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.Equal(t, tokenkit.Estimate("gemini-3-flash-preview", tc.usageText), usage.CompletionTokens)
|
||||
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)
|
||||
})
|
||||
}
|
||||
|
||||
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) {
|
||||
@@ -639,3 +653,104 @@ func TestGeminiStreamHandlerEmptyUsageMetadataBuildsEstimatedBillingUsage(t *tes
|
||||
require.Equal(t, usage.CompletionTokens, usage.BillingUsage.GeminiUsageMetadata.CandidatesTokenCount)
|
||||
require.True(t, common.GetContextKeyBool(c, constant.ContextKeyLocalCountTokens))
|
||||
}
|
||||
|
||||
// Google Search grounding records the unique trimmed non-empty queries
|
||||
// (webSearchQueries and imageSearchQueries of every candidate, across every
|
||||
// stream frame) under google_search and one grounded prompt when any query
|
||||
// exists; the tool price table decides which of the two a model pays for.
|
||||
func TestGeminiHandlersCountGroundingQueries(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
oldStreamingTimeout := constant.StreamingTimeout
|
||||
constant.StreamingTimeout = 300
|
||||
t.Cleanup(func() {
|
||||
constant.StreamingTimeout = oldStreamingTimeout
|
||||
})
|
||||
const text = `"content":{"role":"model","parts":[{"text":"ok"}]}`
|
||||
grounded := func(queries int) map[string]int {
|
||||
return map[string]int{dto.BuildInToolGoogleSearch: queries, relaycommon.GoogleSearchGroundedPromptTool: 1}
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
stream bool
|
||||
frames []string
|
||||
want map[string]int
|
||||
}{
|
||||
{
|
||||
name: "native non-stream",
|
||||
frames: []string{`{"candidates":[{` + text + `,"groundingMetadata":{"webSearchQueries":["Shanghai weather today","current weather in Shanghai"],"groundingChunks":[{"web":{"uri":"https://a.example"}}]}}]}`},
|
||||
want: grounded(2),
|
||||
},
|
||||
{
|
||||
name: "duplicates, whitespace and empty queries",
|
||||
frames: []string{`{"candidates":[{` + text + `,"groundingMetadata":{"webSearchQueries":["go release",""," go release "," ","tokyo"]}}]}`},
|
||||
want: grounded(2),
|
||||
},
|
||||
{
|
||||
name: "union across candidates",
|
||||
frames: []string{`{"candidates":[{"index":0,` + text + `,"groundingMetadata":{"webSearchQueries":["q1","q2"]}},{"index":1,` + text + `,"groundingMetadata":{"webSearchQueries":["q2","q3"]}}]}`},
|
||||
want: grounded(3),
|
||||
},
|
||||
{
|
||||
name: "image queries count like web queries",
|
||||
frames: []string{`{"candidates":[{` + text + `,"groundingMetadata":{"webSearchQueries":["cat breeds","orange cat"],"imageSearchQueries":["orange tabby cat photo","orange cat"]}}]}`},
|
||||
want: grounded(3),
|
||||
},
|
||||
{
|
||||
name: "only empty queries",
|
||||
frames: []string{`{"candidates":[{` + text + `,"groundingMetadata":{"webSearchQueries":[""," "],"imageSearchQueries":[""]}}]}`},
|
||||
want: map[string]int{},
|
||||
},
|
||||
{
|
||||
name: "no grounding metadata",
|
||||
frames: []string{`{"candidates":[{` + text + `,"finishReason":"STOP"}]}`},
|
||||
want: map[string]int{},
|
||||
},
|
||||
{
|
||||
name: "stream union across frames",
|
||||
stream: true,
|
||||
frames: []string{
|
||||
`{"candidates":[{` + text + `,"groundingMetadata":{"webSearchQueries":["q1"]}}]}`,
|
||||
`{"candidates":[{` + text + `,"groundingMetadata":{"webSearchQueries":["q2","q1 "]}}]}`,
|
||||
`{"candidates":[{` + text + `,"groundingMetadata":{"webSearchQueries":["q3"]}}]}`,
|
||||
},
|
||||
want: grounded(3),
|
||||
},
|
||||
{
|
||||
name: "stream repeats are not added up",
|
||||
stream: true,
|
||||
frames: []string{
|
||||
`{"candidates":[{` + text + `,"groundingMetadata":{"webSearchQueries":["q1","q2"]}}]}`,
|
||||
`{"candidates":[{` + text + `,"groundingMetadata":{"webSearchQueries":["q1","q2"]}}]}`,
|
||||
},
|
||||
want: grounded(2),
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
c, _ := gin.CreateTestContext(httptest.NewRecorder())
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/v1beta/models/gemini-3.7-flash:generateContent", nil)
|
||||
info := &relaycommon.RelayInfo{
|
||||
OriginModelName: "gemini-3.7-flash",
|
||||
ChannelMeta: &relaycommon.ChannelMeta{UpstreamModelName: "gemini-3.7-flash"},
|
||||
}
|
||||
var apiErr *types.NewAPIError
|
||||
if tt.stream {
|
||||
body := "data: " + strings.Join(tt.frames, "\ndata: ") + "\ndata: [DONE]\n"
|
||||
_, apiErr = geminiStreamHandler(c, info, &http.Response{Body: io.NopCloser(strings.NewReader(body))}, func(string, *dto.GeminiChatResponse) bool {
|
||||
return true
|
||||
})
|
||||
} else {
|
||||
_, apiErr = GeminiTextGenerationHandler(c, info, &http.Response{Body: io.NopCloser(strings.NewReader(tt.frames[0]))})
|
||||
}
|
||||
require.Nil(t, apiErr)
|
||||
counts := map[string]int{}
|
||||
if info.ResponsesUsageInfo != nil {
|
||||
for name, tool := range info.ResponsesUsageInfo.BuiltInTools {
|
||||
counts[name] = tool.CallCount
|
||||
}
|
||||
}
|
||||
assert.Equal(t, tt.want, counts)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -33,7 +33,9 @@ func GeminiResponsesHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *h
|
||||
return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError)
|
||||
}
|
||||
info.ObserveResponseModel(gjson.GetBytes(responseBody, "modelVersion").Str)
|
||||
markGeminiGoogleSearchCall(c, &geminiResponse)
|
||||
var grounding geminiGroundingCounter
|
||||
grounding.observe(&geminiResponse)
|
||||
grounding.commit(info)
|
||||
countGeminiBillableFunctionCalls(info, &geminiResponse)
|
||||
if len(geminiResponse.Candidates) == 0 {
|
||||
usage := buildUsageFromGeminiResponse(c, info, &geminiResponse)
|
||||
|
||||
Reference in new issue
Block a user