Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions config.example.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,19 @@ force-model-prefix: false
# Default is false (disabled).
passthrough-headers: false

# Forward requests that carry an Anthropic web_search server tool (tools[].type starts with
# "web_search", e.g. "web_search_20250305") to a different model. Useful when the requested
# model's upstream does not support Anthropic's server-side web search.
# The request body is forwarded unchanged; the existing pipeline (translation, auth selection,
# headers, upstream model remapping) handles the target. The response model field is rewritten
# back to the client's originally requested model so forwarding stays transparent.
# A request whose model already equals the configured target is NOT forwarded (avoids a self-loop).
# No target-capability validation is performed: forwarding to a target that cannot handle the
# request is the user's responsibility.
# websearch-forward:
# enable: false
# model: "deepseek" # target model (alias) to forward web_search requests to

# Number of times to retry a request. Retries will occur if the HTTP response code is 403, 408, 500, 502, 503, or 504.
request-retry: 3

Expand Down
14 changes: 14 additions & 0 deletions internal/config/sdk_config.go
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,20 @@ type SDKConfig struct {
// NonStreamKeepAliveInterval controls how often blank lines are emitted for non-streaming responses.
// <= 0 disables keep-alives. Value is in seconds.
NonStreamKeepAliveInterval int `yaml:"nonstream-keepalive-interval,omitempty" json:"nonstream-keepalive-interval,omitempty"`

// WebSearchForward reroutes requests carrying an Anthropic web_search server tool
// (tools[].type = "web_search_*") to a different model, for upstreams that lack server-side
// web search. The request body is forwarded unchanged; the existing pipeline handles the target.
// The response model field is rewritten back to the client's originally requested model.
WebSearchForward WebSearchForwardConfig `yaml:"websearch-forward,omitempty" json:"websearch-forward,omitempty"`
}

// WebSearchForwardConfig controls rerouting of web_search tool requests to another model.
type WebSearchForwardConfig struct {
// Enable activates web_search request forwarding.
Enable bool `yaml:"enable" json:"enable"`
// Model is the target model name (alias) to forward web_search requests to.
Model string `yaml:"model,omitempty" json:"model,omitempty"`
}

// StreamingConfig holds server streaming behavior configuration.
Expand Down
33 changes: 33 additions & 0 deletions internal/config/web_search_forward_test.go
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)
}
}
33 changes: 30 additions & 3 deletions sdk/api/handlers/handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -713,12 +713,21 @@ func (h *BaseAPIHandler) executeWithAuthManagerFormats(ctx context.Context, entr
if routeDecision.ExecutorPluginID != "" {
return h.executeWithPluginExecutor(ctx, entryProtocol, responseProtocol, modelName, originalRequestedModel, rawJSON, alt, routeDecision.ExecutorPluginID, execOptions)
}
providers, normalizedModel, errMsg := h.providersForExecution(modelName, originalRequestedModel, allowImageModel, routeDecision)
routingModel := originalRequestedModel
if routeDecision.Provider == "" {
if target := h.webSearchForwardTarget(originalRequestedModel, rawJSON); target != "" {
routingModel = target
}
}
providers, normalizedModel, errMsg := h.providersForExecution(routingModel, originalRequestedModel, allowImageModel, routeDecision)
if errMsg != nil {
return nil, nil, errMsg
}
reqMeta := requestExecutionMetadata(ctx)
reqMeta[coreexecutor.RequestedModelMetadataKey] = originalRequestedModel
if routingModel != originalRequestedModel {
reqMeta[coreexecutor.ForcedResponseModelMetadataKey] = originalRequestedModel
}
addModelExecutionSourceMetadata(reqMeta, execOptions.InternalSource)
setReasoningEffortMetadata(reqMeta, entryProtocol, normalizedModel, rawJSON)
setServiceTierMetadata(reqMeta, rawJSON)
Expand Down Expand Up @@ -779,12 +788,21 @@ func (h *BaseAPIHandler) executeCountWithAuthManager(ctx context.Context, handle
if routeDecision.ExecutorPluginID != "" {
return h.countWithPluginExecutor(ctx, handlerType, modelName, originalRequestedModel, rawJSON, alt, routeDecision.ExecutorPluginID, execOptions)
}
providers, normalizedModel, errMsg := h.providersForExecution(modelName, originalRequestedModel, false, routeDecision)
routingModel := originalRequestedModel
if routeDecision.Provider == "" {
if target := h.webSearchForwardTarget(originalRequestedModel, rawJSON); target != "" {
routingModel = target
}
}
providers, normalizedModel, errMsg := h.providersForExecution(routingModel, originalRequestedModel, false, routeDecision)
if errMsg != nil {
return nil, nil, errMsg
}
reqMeta := requestExecutionMetadata(ctx)
reqMeta[coreexecutor.RequestedModelMetadataKey] = originalRequestedModel
if routingModel != originalRequestedModel {
reqMeta[coreexecutor.ForcedResponseModelMetadataKey] = originalRequestedModel
}
setReasoningEffortMetadata(reqMeta, handlerType, normalizedModel, rawJSON)
setServiceTierMetadata(reqMeta, rawJSON)
payload := rawJSON
Expand Down Expand Up @@ -1100,7 +1118,13 @@ func (h *BaseAPIHandler) executeStreamWithAuthManagerFormats(ctx context.Context
if routeDecision.ExecutorPluginID != "" {
return h.streamWithPluginExecutor(ctx, entryProtocol, responseProtocol, modelName, originalRequestedModel, rawJSON, alt, routeDecision.ExecutorPluginID, execOptions)
}
providers, normalizedModel, errMsg := h.providersForExecution(modelName, originalRequestedModel, allowImageModel, routeDecision)
routingModel := originalRequestedModel
if routeDecision.Provider == "" {
if target := h.webSearchForwardTarget(originalRequestedModel, rawJSON); target != "" {
routingModel = target
}
}
providers, normalizedModel, errMsg := h.providersForExecution(routingModel, originalRequestedModel, allowImageModel, routeDecision)
if errMsg != nil {
errChan := make(chan *interfaces.ErrorMessage, 1)
errChan <- errMsg
Expand All @@ -1109,6 +1133,9 @@ func (h *BaseAPIHandler) executeStreamWithAuthManagerFormats(ctx context.Context
}
reqMeta := requestExecutionMetadata(ctx)
reqMeta[coreexecutor.RequestedModelMetadataKey] = originalRequestedModel
if routingModel != originalRequestedModel {
reqMeta[coreexecutor.ForcedResponseModelMetadataKey] = originalRequestedModel
}
addModelExecutionSourceMetadata(reqMeta, execOptions.InternalSource)
setReasoningEffortMetadata(reqMeta, entryProtocol, normalizedModel, rawJSON)
setServiceTierMetadata(reqMeta, rawJSON)
Expand Down
62 changes: 62 additions & 0 deletions sdk/api/handlers/web_search_forward.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
package handlers

import (
"strings"

"github.com/tidwall/gjson"
)
Comment on lines +3 to +7

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Import thinking and util packages to support resolving auto-models and parsing suffixes in webSearchForwardTarget.

Suggested change
import (
"strings"
"github.com/tidwall/gjson"
)
import (
"strings"
"github.com/router-for-me/CLIProxyAPI/v7/internal/thinking"
"github.com/router-for-me/CLIProxyAPI/v7/internal/util"
"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) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Exclude OpenAI web_search tools from forwarding

When websearch-forward is enabled, this prefix check also matches OpenAI/Codex built-in tools such as {"type":"web_search"} (the repo already preserves that shape in test/builtin_tools_translation_test.go lines 16 and 25), not just Anthropic's dated web_search_YYYYMMDD tools. Because the handler does not check the entry protocol before calling this function, OpenAI requests that intentionally use the existing built-in web search path will be rerouted to the configured target model and have their response model rewritten, which can send them to an incompatible provider.

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Comparing the raw target and requestedModel strings directly can lead to unintended forwarding loops or accidental stripping of model suffixes (such as (thinking)). For example, if the target model is deepseek and the client requests deepseek(thinking), they will not match under strings.EqualFold, causing the request to be forwarded to deepseek and stripping the thinking suffix from the upstream execution.

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
}
72 changes: 72 additions & 0 deletions sdk/api/handlers/web_search_forward_test.go
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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Add a test case to verify that the self-forward guard correctly handles models with thinking suffixes (e.g., deepseek(thinking) vs deepseek).

		{"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)
}
})
}
34 changes: 34 additions & 0 deletions sdk/cliproxy/auth/conductor.go
Original file line number Diff line number Diff line change
Expand Up @@ -2510,6 +2510,7 @@ func (m *Manager) executeMixedOnce(ctx context.Context, providers []string, req
execCtx = contextWithRequestedModelAlias(execCtx, opts, routeModel)

models, pooled, aliasResult := m.preparedExecutionModelsWithAlias(auth, routeModel)
applyForcedResponseModel(opts, &aliasResult)
if len(models) == 0 {
continue
}
Expand Down Expand Up @@ -2612,6 +2613,7 @@ func (m *Manager) executeCountMixedOnce(ctx context.Context, providers []string,
execCtx = contextWithRequestedModelAlias(execCtx, opts, routeModel)

models, pooled, aliasResult := m.preparedExecutionModelsWithAlias(auth, routeModel)
applyForcedResponseModel(opts, &aliasResult)
if len(models) == 0 {
continue
}
Expand Down Expand Up @@ -2712,6 +2714,7 @@ func (m *Manager) executeStreamMixedOnce(ctx context.Context, providers []string
execCtx = context.WithValue(execCtx, "cliproxy.roundtripper", rt)
}
models, pooled, aliasResult := m.preparedExecutionModelsWithAlias(auth, routeModel)
applyForcedResponseModel(opts, &aliasResult)
if len(models) == 0 {
continue
}
Expand Down Expand Up @@ -2926,6 +2929,35 @@ func requestedModelAliasFromOptions(opts cliproxyexecutor.Options, fallback stri
}
}

// applyForcedResponseModel rewrites aliasResult so the response model field is rewritten to the
// client's originally requested model, when the request was rerouted transparently (e.g. web_search
// forwarding sets ForcedResponseModelMetadataKey to the client model). It is a no-op when the
// metadata key is absent, nil, or empty. This layers on top of the existing force-mapping rewrite
// (rewriteForceMappedResponse / wrapStreamResult) without changing it.
func applyForcedResponseModel(opts cliproxyexecutor.Options, aliasResult *OAuthModelAliasResult) {
if aliasResult == nil || len(opts.Metadata) == 0 {
return
}
raw, ok := opts.Metadata[cliproxyexecutor.ForcedResponseModelMetadataKey]
if !ok || raw == nil {
return
}
var model string
switch value := raw.(type) {
case string:
model = value
case []byte:
model = string(value)
default:
return
}
if model = strings.TrimSpace(model); model == "" {
return
}
aliasResult.ForceMapping = true
aliasResult.OriginalAlias = model
Comment on lines +2954 to +2958
}

func reasoningEffortFromOptions(opts cliproxyexecutor.Options) string {
if len(opts.Metadata) == 0 {
return ""
Expand Down Expand Up @@ -5137,6 +5169,7 @@ func (m *Manager) tryAntigravityCreditsExecute(ctx context.Context, req cliproxy
c.auth = preparedAuth
publishSelectedAuthMetadata(creditsOpts.Metadata, c.auth.ID)
models, pooled, aliasResult := m.executionModelCandidatesWithAlias(c.auth, routeModel)
applyForcedResponseModel(creditsOpts, &aliasResult)
if len(models) == 0 {
continue
}
Expand Down Expand Up @@ -5188,6 +5221,7 @@ func (m *Manager) tryAntigravityCreditsExecuteStream(ctx context.Context, req cl
c.auth = preparedAuth
publishSelectedAuthMetadata(creditsOpts.Metadata, c.auth.ID)
models, pooled, aliasResult := m.executionModelCandidatesWithAlias(c.auth, routeModel)
applyForcedResponseModel(creditsOpts, &aliasResult)
if len(models) == 0 {
continue
}
Expand Down
Loading
Loading