fix(gemini): preserve Hermes web search functions

This commit is contained in:
chinnsenn
2026-07-26 22:09:26 +09:00
parent 71d7f86883
commit 3e08106116
2 changed files with 70 additions and 11 deletions
@@ -3428,17 +3428,12 @@ func normalizeGeminiRequestForAIStudio(body []byte) []byte {
func isClaudeWebSearchToolMap(tool map[string]any) bool {
toolType, _ := tool["type"].(string)
if strings.HasPrefix(toolType, "web_search") || toolType == "google_search" {
return true
}
name, _ := tool["name"].(string)
switch strings.TrimSpace(name) {
case "web_search", "google_search", "web_search_20250305":
return true
default:
return false
}
// A function named web_search is still a client-side function. This is
// especially important for Chat Completions clients such as Hermes, whose
// built-in runtime tools are represented as ordinary function tools.
// Promote only explicitly typed server-side search tools to Gemini's
// built-in googleSearch tool.
return strings.HasPrefix(toolType, "web_search") || toolType == "google_search"
}
// cleanToolSchema 清理工具的 JSON Schema,移除 Gemini 不支持的字段
@@ -170,6 +170,70 @@ func TestGeminiForwardAsChatCompletions_StreamsOpenAIChunksFromGeminiSSE(t *test
require.Contains(t, out, "data: [DONE]")
}
func TestGeminiForwardAsChatCompletions_FunctionNamedWebSearchStaysClientSide(t *testing.T) {
gin.SetMode(gin.TestMode)
httpStub := &geminiCompatHTTPUpstreamStub{
response: &http.Response{
StatusCode: http.StatusOK,
Header: http.Header{"Content-Type": []string{"application/json"}},
Body: io.NopCloser(strings.NewReader(
`{"candidates":[{"content":{"parts":[{"text":"hello"}]},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":2,"candidatesTokenCount":1}}`,
)),
},
}
svc := &GeminiMessagesCompatService{
httpUpstream: httpStub,
cfg: &config.Config{},
}
account := &Account{
ID: 103,
Platform: PlatformGemini,
Type: AccountTypeAPIKey,
Credentials: map[string]any{
"api_key": "gemini-api-key",
},
Concurrency: 1,
}
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
body := []byte(`{
"model":"gemini-3.6-flash-high",
"messages":[{"role":"user","content":"search and read"}],
"tools":[
{"type":"function","function":{"name":"web_search","description":"Search through the Hermes client","parameters":{"type":"object","properties":{"query":{"type":"string"}},"required":["query"]}}},
{"type":"function","function":{"name":"read_file","description":"Read a local file","parameters":{"type":"object","properties":{"path":{"type":"string"}},"required":["path"]}}}
]
}`)
c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", bytes.NewReader(body))
result, err := svc.ForwardAsChatCompletions(context.Background(), c, account, body)
require.NoError(t, err)
require.NotNil(t, result)
require.Equal(t, http.StatusOK, rec.Code)
require.NotNil(t, httpStub.lastReq)
postedBody, err := io.ReadAll(httpStub.lastReq.Body)
require.NoError(t, err)
var posted map[string]any
require.NoError(t, json.Unmarshal(postedBody, &posted))
tools, ok := posted["tools"].([]any)
require.True(t, ok)
require.Len(t, tools, 1, "Chat Completions function tools must not be promoted to Gemini built-ins by name")
functionTool, ok := tools[0].(map[string]any)
require.True(t, ok)
functionDecls, ok := functionTool["functionDeclarations"].([]any)
require.True(t, ok)
require.Len(t, functionDecls, 2)
require.Equal(t, "web_search", functionDecls[0].(map[string]any)["name"])
require.Equal(t, "read_file", functionDecls[1].(map[string]any)["name"])
require.NotContains(t, functionTool, "googleSearch")
require.NotContains(t, functionTool, "google_search")
}
// TestConvertClaudeToolsToGeminiTools_CustomType 测试custom类型工具转换
func TestConvertClaudeToolsToGeminiTools_CustomType(t *testing.T) {
tests := []struct {