feat: publish sanitized custom.97 source

This commit is contained in:
cneicy committed 2026-10-27 20:43:39 +08:00
1 parent 8e6ed0317b
commit 86c0276156
1022 files changed
+69903 -19207

No files matched your search

+26 -9
View File
@@ -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 {
+4 -1
View File
@@ -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++
+63 -24
View File
@@ -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 {
+160 -45
View File
@@ -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)
})
}
}
+3 -1
View File
@@ -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)