-
-
Notifications
You must be signed in to change notification settings - Fork 6.5k
feat(websearch): forward web_search tool requests to a configurable target model #4094
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: dev
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,33 @@ | ||
| package config | ||
|
|
||
| import "testing" | ||
|
|
||
| func TestParseConfigBytes_WebSearchForward(t *testing.T) { | ||
| cfg, err := ParseConfigBytes([]byte(` | ||
| websearch-forward: | ||
| enable: true | ||
| model: "deepseek" | ||
| `)) | ||
| if err != nil { | ||
| t.Fatalf("ParseConfigBytes() error = %v", err) | ||
| } | ||
| if !cfg.WebSearchForward.Enable { | ||
| t.Fatal("WebSearchForward.Enable = false, want true") | ||
| } | ||
| if cfg.WebSearchForward.Model != "deepseek" { | ||
| t.Fatalf("WebSearchForward.Model = %q, want %q", cfg.WebSearchForward.Model, "deepseek") | ||
| } | ||
| } | ||
|
|
||
| func TestParseConfigBytes_WebSearchForwardDefault(t *testing.T) { | ||
| cfg, err := ParseConfigBytes([]byte(`port: 8317`)) | ||
| if err != nil { | ||
| t.Fatalf("ParseConfigBytes() error = %v", err) | ||
| } | ||
| if cfg.WebSearchForward.Enable { | ||
| t.Fatal("WebSearchForward.Enable = true, want false by default") | ||
| } | ||
| if cfg.WebSearchForward.Model != "" { | ||
| t.Fatalf("WebSearchForward.Model = %q, want empty by default", cfg.WebSearchForward.Model) | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,62 @@ | ||
| package handlers | ||
|
|
||
| import ( | ||
| "strings" | ||
|
|
||
| "github.com/tidwall/gjson" | ||
| ) | ||
|
|
||
| // webSearchToolTypePrefix is the common prefix of Anthropic's typed web_search server tools. | ||
| // The full type is versioned with a date suffix (e.g. "web_search_20250305", | ||
| // "web_search_20260209"); matching by prefix future-proofs detection against new versions. | ||
| const webSearchToolTypePrefix = "web_search" | ||
|
|
||
| // hasWebSearchTool reports whether the request payload declares an Anthropic web_search server | ||
| // tool, i.e. any entry in the top-level "tools" array whose "type" starts with "web_search". | ||
| // | ||
| // Only Anthropic-format requests carry such typed tools, so this is the sole detection gate: | ||
| // OpenAI tools use type "function" and Gemini uses "functionDeclarations", neither of which | ||
| // matches. | ||
| func hasWebSearchTool(rawJSON []byte) bool { | ||
| tools := gjson.GetBytes(rawJSON, "tools") | ||
| if !tools.Exists() || !tools.IsArray() { | ||
| return false | ||
| } | ||
| for _, tool := range tools.Array() { | ||
| toolType := strings.ToLower(strings.TrimSpace(tool.Get("type").String())) | ||
| if strings.HasPrefix(toolType, webSearchToolTypePrefix) { | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When Useful? React with 👍 / 👎. |
||
| return true | ||
| } | ||
|
Comment on lines
+26
to
+29
|
||
| } | ||
| return false | ||
| } | ||
|
|
||
| // webSearchForwardTarget returns the model that a web_search-bearing request should be | ||
| // rerouted to, or "" when forwarding should not happen. | ||
| // | ||
| // The request body itself is never modified here — only the routing model is overridden by the | ||
| // caller. No target-capability validation is performed; forwarding to a target that cannot | ||
| // handle the request is the user's responsibility. | ||
| // | ||
| // Returns "" (no forwarding) when: | ||
| // - the feature is disabled, | ||
| // - no target model is configured, | ||
| // - the request carries no web_search tool, | ||
| // - the target equals the client-requested model (avoids a self-forward loop). | ||
| func (h *BaseAPIHandler) webSearchForwardTarget(requestedModel string, rawJSON []byte) string { | ||
| cfg := h.Cfg | ||
| if cfg == nil || !cfg.WebSearchForward.Enable { | ||
| return "" | ||
| } | ||
| target := strings.TrimSpace(cfg.WebSearchForward.Model) | ||
| if target == "" { | ||
| return "" | ||
| } | ||
| if !hasWebSearchTool(rawJSON) { | ||
| return "" | ||
| } | ||
| if strings.EqualFold(target, strings.TrimSpace(requestedModel)) { | ||
| return "" | ||
| } | ||
|
Comment on lines
+58
to
+60
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Comparing the raw To prevent this, resolve auto-models and parse suffixes on both models before comparing their base names. requestedBase := strings.TrimSpace(thinking.ParseSuffix(util.ResolveAutoModel(requestedModel)).ModelName)
targetBase := strings.TrimSpace(thinking.ParseSuffix(util.ResolveAutoModel(target)).ModelName)
if strings.EqualFold(targetBase, requestedBase) {
return ""
} |
||
| return target | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,72 @@ | ||
| package handlers | ||
|
|
||
| import ( | ||
| "testing" | ||
|
|
||
| "github.com/router-for-me/CLIProxyAPI/v7/sdk/config" | ||
| ) | ||
|
|
||
| func TestHasWebSearchTool(t *testing.T) { | ||
| tests := []struct { | ||
| name string | ||
| body string | ||
| want bool | ||
| }{ | ||
| {"basic 20250305", `{"tools":[{"type":"web_search_20250305","name":"web_search"}]}`, true}, | ||
| {"20260209", `{"tools":[{"type":"web_search_20260209","name":"web_search"}]}`, true}, | ||
| {"future dated version", `{"tools":[{"type":"web_search_20991231","name":"web_search"}]}`, true}, | ||
| {"openai function tool", `{"tools":[{"type":"function","function":{"name":"foo"}}]}`, false}, | ||
| {"empty type", `{"tools":[{"type":"","name":"x"}]}`, false}, | ||
| {"missing tools field", `{"model":"glm"}`, false}, | ||
| {"empty tools array", `{"tools":[]}`, false}, | ||
| {"mixed function and web_search", `{"tools":[{"type":"function","function":{"name":"foo"}},{"type":"web_search_20250305","name":"web_search"}]}`, true}, | ||
| {"tool without type field", `{"tools":[{"name":"web_search"}]}`, false}, | ||
| {"invalid json", `{"tools":[`, false}, | ||
| } | ||
| for _, tt := range tests { | ||
| t.Run(tt.name, func(t *testing.T) { | ||
| if got := hasWebSearchTool([]byte(tt.body)); got != tt.want { | ||
| t.Errorf("hasWebSearchTool() = %v, want %v", got, tt.want) | ||
| } | ||
| }) | ||
| } | ||
| } | ||
|
|
||
| func TestWebSearchForwardTarget(t *testing.T) { | ||
| wsBody := `{"tools":[{"type":"web_search_20250305","name":"web_search"}]}` | ||
| noToolBody := `{"tools":[{"type":"function","function":{"name":"foo"}}]}` | ||
|
|
||
| tests := []struct { | ||
| name string | ||
| enable bool | ||
| model string | ||
| requested string | ||
| body string | ||
| want string | ||
| }{ | ||
| {"forwards when enabled with tool", true, "deepseek", "glm", wsBody, "deepseek"}, | ||
| {"disabled returns empty", false, "deepseek", "glm", wsBody, ""}, | ||
| {"empty target model returns empty", true, " ", "glm", wsBody, ""}, | ||
| {"no web_search tool returns empty", true, "deepseek", "glm", noToolBody, ""}, | ||
| {"self-forward guard same model", true, "glm", "glm", wsBody, ""}, | ||
| {"self-forward guard case-insensitive", true, "DeepSeek", "deepseek", wsBody, ""}, | ||
| {"requested model trimmed before compare", true, "deepseek", " glm ", wsBody, "deepseek"}, | ||
|
Comment on lines
+51
to
+53
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Add a test case to verify that the self-forward guard correctly handles models with thinking suffixes (e.g., {"self-forward guard same model", true, "glm", "glm", wsBody, ""},
{"self-forward guard case-insensitive", true, "DeepSeek", "deepseek", wsBody, ""},
{"self-forward guard with thinking suffix", true, "deepseek", "deepseek(thinking)", wsBody, ""},
{"requested model trimmed before compare", true, "deepseek", " glm ", wsBody, "deepseek"}, |
||
| } | ||
| for _, tt := range tests { | ||
| t.Run(tt.name, func(t *testing.T) { | ||
| h := &BaseAPIHandler{Cfg: &config.SDKConfig{}} | ||
| h.Cfg.WebSearchForward.Enable = tt.enable | ||
| h.Cfg.WebSearchForward.Model = tt.model | ||
| if got := h.webSearchForwardTarget(tt.requested, []byte(tt.body)); got != tt.want { | ||
| t.Errorf("webSearchForwardTarget() = %q, want %q", got, tt.want) | ||
| } | ||
| }) | ||
| } | ||
|
|
||
| t.Run("nil cfg returns empty", func(t *testing.T) { | ||
| h := &BaseAPIHandler{} | ||
| if got := h.webSearchForwardTarget("glm", []byte(wsBody)); got != "" { | ||
| t.Errorf("webSearchForwardTarget() with nil Cfg = %q, want empty", got) | ||
| } | ||
| }) | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Import
thinkingandutilpackages to support resolving auto-models and parsing suffixes inwebSearchForwardTarget.