Add projects

This commit is contained in:
Alois 2026-08-24 00:10:41 +02:00
commit 8b607dd700
Signed by: alois
SSH key fingerprint: SHA256:GBzT2DXvAuGV9XIV5W3WrzVpjU54FThmxHXdbz95J24
1802 changed files with 503346 additions and 2 deletions

View file

@ -0,0 +1,173 @@
package main
import (
"encoding/json"
"fmt"
"strings"
"time"
)
type claudeStreamBuilder struct {
model string
messageID string
toolUseID string
index int
inputTokens int
}
func newClaudeStreamBuilder(model string) *claudeStreamBuilder {
model = strings.TrimSpace(model)
if model == "" {
model = "claude-sonnet-4-6"
}
now := time.Now().UnixNano()
return &claudeStreamBuilder{
model: model,
messageID: fmt.Sprintf("msg_%x", now),
toolUseID: fmt.Sprintf("srvtoolu_%d", now),
inputTokens: 85,
}
}
func (b *claudeStreamBuilder) buildStreamWithQuery(query string, hits []claudeWebSearchHit, answer string) []byte {
var chunks []string
chunks = append(chunks, b.event("message_start", map[string]any{
"type": "message_start",
"message": map[string]any{
"id": b.messageID, "type": "message", "role": "assistant", "content": []any{},
"model": b.model, "stop_reason": nil, "stop_sequence": nil,
"usage": map[string]any{"input_tokens": b.inputTokens, "output_tokens": 0},
},
}))
chunks = append(chunks, b.blockStart(b.index, map[string]any{
"type": "server_tool_use", "id": b.toolUseID, "name": "web_search", "input": map[string]any{},
}))
partial, _ := json.Marshal(map[string]string{"query": query})
chunks = append(chunks, b.event("content_block_delta", map[string]any{
"type": "content_block_delta", "index": b.index,
"delta": map[string]any{"type": "input_json_delta", "partial_json": string(partial)},
}))
chunks = append(chunks, b.event("content_block_stop", map[string]any{"type": "content_block_stop", "index": b.index}))
b.index++
resultContent := webSearchResultBlocks(hits)
chunks = append(chunks, b.blockStart(b.index, map[string]any{
"type": "web_search_tool_result", "tool_use_id": b.toolUseID, "content": resultContent,
}))
chunks = append(chunks, b.event("content_block_stop", map[string]any{"type": "content_block_stop", "index": b.index}))
b.index++
text := composeAnswerText(answer, hits)
outputTokens := estimateTokens(text)
chunks = append(chunks, b.blockStart(b.index, map[string]any{"type": "text", "text": ""}))
chunks = append(chunks, b.event("content_block_delta", map[string]any{
"type": "content_block_delta", "index": b.index,
"delta": map[string]any{"type": "text_delta", "text": text},
}))
chunks = append(chunks, b.event("content_block_stop", map[string]any{"type": "content_block_stop", "index": b.index}))
chunks = append(chunks, b.event("message_delta", map[string]any{
"type": "message_delta",
"delta": map[string]any{"stop_reason": "end_turn", "stop_sequence": nil},
"usage": map[string]any{
"input_tokens": b.inputTokens, "output_tokens": outputTokens,
"server_tool_use": map[string]any{"web_search_requests": 1},
},
}))
chunks = append(chunks, b.event("message_stop", map[string]any{"type": "message_stop"}))
return []byte(strings.Join(chunks, ""))
}
func (b *claudeStreamBuilder) buildMessageJSON(query string, hits []claudeWebSearchHit, answer string) []byte {
text := composeAnswerText(answer, hits)
content := []map[string]any{
{"type": "server_tool_use", "id": b.toolUseID, "name": "web_search", "input": map[string]string{"query": query}},
{"type": "web_search_tool_result", "tool_use_id": b.toolUseID, "content": webSearchResultBlocks(hits)},
{"type": "text", "text": text},
}
out := map[string]any{
"id": b.messageID, "type": "message", "role": "assistant", "model": b.model,
"content": content, "stop_reason": "end_turn", "stop_sequence": nil,
"usage": map[string]any{
"input_tokens": b.inputTokens, "output_tokens": estimateTokens(text),
"server_tool_use": map[string]any{"web_search_requests": 1},
},
}
raw, _ := json.Marshal(out)
return raw
}
func webSearchResultBlocks(hits []claudeWebSearchHit) []map[string]any {
resultContent := make([]map[string]any, 0, len(hits))
for _, hit := range hits {
title := hit.Title
if title == "" {
title = hostFromURL(hit.URL)
}
resultContent = append(resultContent, map[string]any{
"type": "web_search_result", "title": title, "url": hit.URL, "page_age": nil,
})
}
return resultContent
}
func (b *claudeStreamBuilder) event(eventType string, data map[string]any) string {
raw, _ := json.Marshal(data)
return fmt.Sprintf("event: %s\ndata: %s\n\n", eventType, string(raw))
}
func (b *claudeStreamBuilder) blockStart(index int, block map[string]any) string {
return b.event("content_block_start", map[string]any{
"type": "content_block_start", "index": index, "content_block": block,
})
}
func composeAnswerText(answer string, hits []claudeWebSearchHit) string {
if strings.TrimSpace(answer) != "" {
return answer
}
if len(hits) == 0 {
return "No web search results were returned."
}
var buf strings.Builder
for i, hit := range hits {
if i > 0 {
buf.WriteString("\n\n")
}
if hit.Title != "" {
buf.WriteString(hit.Title)
buf.WriteString("\n")
}
if hit.URL != "" {
buf.WriteString(hit.URL)
buf.WriteString("\n")
}
if hit.Snippet != "" {
buf.WriteString(hit.Snippet)
}
}
return buf.String()
}
func hostFromURL(raw string) string {
raw = strings.TrimSpace(raw)
if raw == "" {
return ""
}
withoutScheme := raw
if idx := strings.Index(raw, "://"); idx >= 0 {
withoutScheme = raw[idx+3:]
}
if slash := strings.Index(withoutScheme, "/"); slash >= 0 {
return withoutScheme[:slash]
}
return withoutScheme
}
func estimateTokens(text string) int {
n := len([]rune(text)) / 4
if n < 1 {
return 1
}
return n
}

View file

@ -0,0 +1,22 @@
package main
import "testing"
func TestConfigurePreservesDefaultBooleansWhenConfigIsPartial(t *testing.T) {
raw := mustJSON(t, lifecycleRequest{ConfigYAML: []byte("route: codex_web_search\n")})
if errConfigure := configure(raw); errConfigure != nil {
t.Fatalf("configure() error = %v", errConfigure)
}
cfg := loadedConfig()
if !cfg.Enabled {
t.Fatal("Enabled = false, want default true")
}
if !cfg.RequireWebSearchOnly {
t.Fatal("RequireWebSearchOnly = false, want default true")
}
if cfg.Route != string(backendCodexWebSearch) {
t.Fatalf("Route = %q, want codex_web_search", cfg.Route)
}
}

View file

@ -0,0 +1,183 @@
package main
import (
"strings"
"github.com/tidwall/gjson"
)
const (
claudeWebSearchToolTypeA = "web_search_20250305"
claudeWebSearchToolTypeB = "web_search_20260209"
)
// isClaudeSourceFormat reports whether the inbound protocol is Claude / Anthropic Messages.
func isClaudeSourceFormat(source string) bool {
switch strings.ToLower(strings.TrimSpace(source)) {
case "claude", "anthropic":
return true
default:
return false
}
}
func isClaudeTypedWebSearchToolType(toolType string) bool {
return toolType == claudeWebSearchToolTypeA || toolType == claudeWebSearchToolTypeB
}
func hasClaudeTypedWebSearchTool(body []byte) bool {
tools := gjson.GetBytes(body, "tools")
if !tools.IsArray() {
return false
}
for _, tool := range tools.Array() {
if isClaudeTypedWebSearchToolType(tool.Get("type").String()) {
return true
}
}
return false
}
func hasOnlyClaudeTypedWebSearchTools(body []byte) bool {
tools := gjson.GetBytes(body, "tools")
if !tools.IsArray() {
return false
}
hasWebSearch := false
for _, tool := range tools.Array() {
if isClaudeTypedWebSearchToolType(tool.Get("type").String()) {
hasWebSearch = true
continue
}
if tool.Get("type").String() != "" || tool.Get("name").String() != "" {
return false
}
}
return hasWebSearch
}
func looksLikeClaudeCodeWebSearchAssistant(body []byte) bool {
system := gjson.GetBytes(body, "system")
if system.IsArray() {
for _, block := range system.Array() {
text := strings.ToLower(block.Get("text").String())
if strings.Contains(text, "web search tool use") ||
strings.Contains(text, "performing a web search") {
return true
}
}
}
if system.Type == gjson.String {
text := strings.ToLower(system.String())
if strings.Contains(text, "web search tool use") {
return true
}
}
messages := gjson.GetBytes(body, "messages")
if !messages.IsArray() {
return false
}
for _, message := range messages.Array() {
if message.Get("role").String() != "user" {
continue
}
text := strings.ToLower(extractClaudeMessageText(message.Get("content")))
if strings.HasPrefix(text, "perform a web search for the query:") {
return true
}
}
return false
}
func isClaudeCodeBuiltinWebSearchRequest(body []byte, requireWebSearchOnly bool) bool {
if !hasClaudeTypedWebSearchTool(body) {
return false
}
if requireWebSearchOnly && !hasOnlyClaudeTypedWebSearchTools(body) {
return false
}
return looksLikeClaudeCodeWebSearchAssistant(body) || hasOnlyClaudeTypedWebSearchTools(body)
}
func extractClaudeWebSearchQuery(body []byte) string {
if q := extractQueryFromPerformPrefix(body); q != "" {
return q
}
return extractQueryFromUserMessages(body)
}
func extractQueryFromPerformPrefix(body []byte) string {
messages := gjson.GetBytes(body, "messages")
if !messages.IsArray() {
return ""
}
const prefix = "perform a web search for the query:"
for _, message := range messages.Array() {
if message.Get("role").String() != "user" {
continue
}
text := strings.TrimSpace(extractClaudeMessageText(message.Get("content")))
lower := strings.ToLower(text)
if strings.HasPrefix(lower, prefix) {
return strings.TrimSpace(text[len(prefix):])
}
}
return ""
}
func extractQueryFromUserMessages(body []byte) string {
messages := gjson.GetBytes(body, "messages")
if !messages.IsArray() {
return ""
}
arr := messages.Array()
for i := len(arr) - 1; i >= 0; i-- {
message := arr[i]
role := message.Get("role").String()
if role != "" && role != "user" {
continue
}
if query := strings.TrimSpace(extractClaudeMessageText(message.Get("content"))); query != "" {
return query
}
}
return ""
}
func extractClaudeMessageText(content gjson.Result) string {
if content.Type == gjson.String {
return content.String()
}
if !content.IsArray() {
return ""
}
var parts []string
for _, block := range content.Array() {
if block.Get("type").String() != "text" {
continue
}
if text := strings.TrimSpace(block.Get("text").String()); text != "" {
parts = append(parts, text)
}
}
return strings.Join(parts, "\n")
}
func extractClaudeWebSearchMaxUses(body []byte, defaultMax int) int {
if defaultMax <= 0 {
defaultMax = 5
}
tools := gjson.GetBytes(body, "tools")
if !tools.IsArray() {
return defaultMax
}
for _, tool := range tools.Array() {
if !isClaudeTypedWebSearchToolType(tool.Get("type").String()) {
continue
}
if maxUses := int(tool.Get("max_uses").Int()); maxUses > 0 {
return maxUses
}
}
return defaultMax
}

View file

@ -0,0 +1,71 @@
package main
import (
"os"
"path/filepath"
"testing"
)
func TestDetectClaudeCodeWebSearchFromFixture(t *testing.T) {
root := filepath.Join("..", "..", "..", "..", "temp", "1.json")
raw, errRead := os.ReadFile(root)
if errRead != nil {
t.Skipf("fixture not found: %v", errRead)
}
// Fixture is HTTP capture; extract JSON request body between first blank line after headers.
body := extractHTTPJSONBody(raw)
if len(body) == 0 {
t.Fatal("empty JSON body in fixture")
}
if !hasClaudeTypedWebSearchTool(body) {
t.Fatal("fixture should declare web_search_20250305")
}
if !looksLikeClaudeCodeWebSearchAssistant(body) {
t.Fatal("fixture should match Claude Code web search assistant heuristics")
}
if !isClaudeCodeBuiltinWebSearchRequest(body, true) {
t.Fatal("expected match with require_web_search_only=true")
}
query := extractClaudeWebSearchQuery(body)
if query == "" {
t.Fatal("expected non-empty search query")
}
if want := "北京天气 2026年6月16日"; query != want {
t.Fatalf("query = %q, want %q", query, want)
}
}
func extractHTTPJSONBody(raw []byte) []byte {
text := string(raw)
idx := 0
for {
next := findDoubleNewline(text, idx)
if next < 0 {
return nil
}
rest := trimLeft(text[next:])
if len(rest) > 0 && rest[0] == '{' {
return []byte(rest)
}
idx = next + 1
}
}
func findDoubleNewline(s string, from int) int {
for i := from; i+1 < len(s); i++ {
if s[i] == '\n' && s[i+1] == '\n' {
return i + 2
}
if s[i] == '\r' && i+3 < len(s) && s[i+1] == '\n' && s[i+2] == '\r' && s[i+3] == '\n' {
return i + 4
}
}
return -1
}
func trimLeft(s string) string {
for len(s) > 0 && (s[0] == '\r' || s[0] == '\n' || s[0] == ' ') {
s = s[1:]
}
return s
}

View file

@ -0,0 +1,52 @@
package main
import (
"context"
"encoding/json"
"fmt"
"net/http"
"strings"
"github.com/router-for-me/CLIProxyAPI/v7/sdk/pluginapi"
)
type streamOrchestrationRunner func(context.Context, pluginapi.ExecutorRequest, string, string) error
type pluginStreamCloser func(string, string)
func executeStream(raw []byte) ([]byte, error) {
var req rpcExecutorRequest
if errUnmarshal := json.Unmarshal(raw, &req); errUnmarshal != nil {
return nil, errUnmarshal
}
return startExecutorStream(req, runWebSearchStreamOrchestration, closePluginStream)
}
func startExecutorStream(req rpcExecutorRequest, runner streamOrchestrationRunner, closeStream pluginStreamCloser) ([]byte, error) {
streamID := strings.TrimSpace(req.StreamID)
if streamID == "" {
return errorEnvelope("executor_error", "stream_id is required for executor.execute_stream"), nil
}
if runner == nil {
return errorEnvelope("executor_error", "stream orchestration runner is unavailable"), nil
}
if closeStream == nil {
closeStream = func(string, string) {}
}
go func() {
defer func() {
if recovered := recover(); recovered != nil {
closeStream(streamID, fmt.Sprintf("stream orchestration panic: %v", recovered))
}
}()
errRun := runner(context.Background(), req.ExecutorRequest, req.HostCallbackID, streamID)
if errRun != nil {
closeStream(streamID, errRun.Error())
return
}
closeStream(streamID, "")
}()
return okEnvelope(map[string]any{
"headers": http.Header{"Content-Type": []string{"text/event-stream"}},
})
}

View file

@ -0,0 +1,334 @@
package main
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"strings"
"github.com/router-for-me/CLIProxyAPI/v7/sdk/pluginabi"
"github.com/router-for-me/CLIProxyAPI/v7/sdk/pluginapi"
)
type executionPlan struct {
backend routeBackend
model string
}
func buildExecutionPlans(cfg pluginConfig, req pluginapi.ModelRouteRequest) []executionPlan {
return buildExecutionPlansInternal(cfg, req, true)
}
func buildExecutionPlansForExecute(cfg pluginConfig, req pluginapi.ModelRouteRequest) []executionPlan {
route := strings.TrimSpace(cfg.Route)
if isFallbackRoute(route) {
return buildExecutionPlansInternal(cfg, req, false)
}
return executionPlansForExecuteRoute(cfg, req, route)
}
// executionPlansForExecuteRoute builds plans for plugin executor without requiring
// ModelRouteRequest.AvailableProviders (host does not pass it on executor.execute_stream).
func executionPlansForExecuteRoute(cfg pluginConfig, req pluginapi.ModelRouteRequest, route string) []executionPlan {
backend := routeBackend(strings.TrimSpace(route))
if !backendRunnableLenient(backend, cfg, req) {
return nil
}
var plans []executionPlan
switch backend {
case backendAntigravityGoogle:
model := resolveAntigravityWebSearchTargetModel(cfg.AntigravityModel, req.RequestedModel)
if model == "" {
return nil
}
plans = append(plans, executionPlan{backend: backend, model: model})
case backendCodexWebSearch:
plans = append(plans, executionPlan{backend: backend, model: resolveCodexWebSearchTargetModel(cfg.CodexModel)})
case backendXAIWebSearch:
plans = append(plans, executionPlan{backend: backend, model: resolveXAIWebSearchTargetModel(cfg.XAIModel)})
case backendTavily:
if !newTavilyClient(cfg.TavilyAPIKeys).available() {
return nil
}
plans = append(plans, executionPlan{backend: backend})
default:
return nil
}
return plans
}
func buildExecutionPlansInternal(cfg pluginConfig, req pluginapi.ModelRouteRequest, requireProviders bool) []executionPlan {
var plans []executionPlan
for _, backend := range defaultWebSearchFallbackChain() {
if requireProviders {
if _, ok := tryRouteBackend(backend, cfg, req); !ok {
continue
}
} else if !backendRunnableLenient(backend, cfg, req) {
continue
}
switch backend {
case backendAntigravityGoogle:
plans = append(plans, executionPlan{
backend: backend,
model: resolveAntigravityWebSearchTargetModel(cfg.AntigravityModel, req.RequestedModel),
})
case backendCodexWebSearch:
plans = append(plans, executionPlan{
backend: backend,
model: resolveCodexWebSearchTargetModel(cfg.CodexModel),
})
case backendXAIWebSearch:
plans = append(plans, executionPlan{
backend: backend,
model: resolveXAIWebSearchTargetModel(cfg.XAIModel),
})
case backendTavily:
plans = append(plans, executionPlan{backend: backend})
default:
continue
}
}
return plans
}
func backendRunnableLenient(backend routeBackend, cfg pluginConfig, req pluginapi.ModelRouteRequest) bool {
switch backend {
case backendTavily:
return newTavilyClient(cfg.TavilyAPIKeys).available()
case backendAntigravityGoogle:
return resolveAntigravityWebSearchTargetModel(cfg.AntigravityModel, req.RequestedModel) != ""
case backendCodexWebSearch, backendXAIWebSearch:
return true
default:
return false
}
}
func executionPlansForRoute(cfg pluginConfig, req pluginapi.ModelRouteRequest, route string) []executionPlan {
if isFallbackRoute(route) {
return buildExecutionPlans(cfg, req)
}
backend := routeBackend(strings.TrimSpace(route))
if _, ok := tryRouteBackend(backend, cfg, req); !ok {
return nil
}
var plans []executionPlan
for _, b := range []routeBackend{backend} {
if !backendRunnableLenient(b, cfg, req) {
continue
}
switch b {
case backendAntigravityGoogle:
plans = append(plans, executionPlan{backend: b, model: resolveAntigravityWebSearchTargetModel(cfg.AntigravityModel, req.RequestedModel)})
case backendCodexWebSearch:
plans = append(plans, executionPlan{backend: b, model: resolveCodexWebSearchTargetModel(cfg.CodexModel)})
case backendXAIWebSearch:
plans = append(plans, executionPlan{backend: b, model: resolveXAIWebSearchTargetModel(cfg.XAIModel)})
case backendTavily:
plans = append(plans, executionPlan{backend: b})
}
}
return plans
}
func claudeRequestBody(exec pluginapi.ExecutorRequest) []byte {
if len(exec.OriginalRequest) > 0 {
return exec.OriginalRequest
}
return exec.Payload
}
func runWebSearchWithExecutionFallback(ctx context.Context, exec pluginapi.ExecutorRequest, hostCallbackID string) ([]byte, http.Header, error) {
cfg := loadedConfig()
req := pluginapi.ModelRouteRequest{
SourceFormat: "claude",
RequestedModel: strings.TrimSpace(exec.Model),
Body: claudeRequestBody(exec),
AvailableProviders: availableProvidersFromMetadata(exec.Metadata),
}
return runOrderedExecutionPlans(ctx, exec, hostCallbackID, cfg, buildExecutionPlansForExecute(cfg, req), false)
}
// runWebSearchStreamWithExecutionFallback buffers the full host stream (non-streaming RPC path only).
func runWebSearchStreamWithExecutionFallback(ctx context.Context, exec pluginapi.ExecutorRequest, hostCallbackID string) ([]byte, http.Header, error) {
cfg := loadedConfig()
req := pluginapi.ModelRouteRequest{
SourceFormat: "claude",
RequestedModel: strings.TrimSpace(exec.Model),
Body: claudeRequestBody(exec),
AvailableProviders: availableProvidersFromMetadata(exec.Metadata),
}
return runOrderedExecutionPlans(ctx, exec, hostCallbackID, cfg, buildExecutionPlansForExecute(cfg, req), true)
}
func runOrderedExecutionPlans(ctx context.Context, exec pluginapi.ExecutorRequest, hostCallbackID string, cfg pluginConfig, plans []executionPlan, stream bool) ([]byte, http.Header, error) {
if len(plans) == 0 {
return nil, nil, fmt.Errorf("web search execution: no backend available")
}
backends := make([]routeBackend, 0, len(plans))
for _, p := range plans {
backends = append(backends, p.backend)
}
ordered := sortBackendsByPenalty(backends)
planByBackend := make(map[routeBackend]executionPlan, len(plans))
for _, p := range plans {
planByBackend[p.backend] = p
}
body := claudeRequestBody(exec)
var lastErr error
for _, backend := range ordered {
plan := planByBackend[backend]
switch backend {
case backendTavily:
var payload []byte
var headers http.Header
var errRun error
if stream {
payload, headers, errRun = runTavilyClaudeStreamWithClient(ctx, exec, newTavilyClient(cfg.TavilyAPIKeys))
} else {
payload, headers, errRun = runTavilyClaudeWithClient(ctx, exec, newTavilyClient(cfg.TavilyAPIKeys))
}
if errRun != nil {
lastErr = errRun
continue
}
recordBackendSuccess(backend)
return payload, headers, nil
default:
payload, status, errRun := hostModelExecuteClaude(ctx, hostCallbackID, plan.model, body, stream)
if errRun != nil {
lastErr = errRun
if isRetryableHTTPStatus(hostHTTPStatusFromError(errRun)) {
recordBackendFailure(backend)
}
continue
}
if isRetryableHTTPStatus(status) {
recordBackendFailure(backend)
lastErr = fmt.Errorf("host model status %d", status)
continue
}
recordBackendSuccess(backend)
headers := http.Header{"Content-Type": []string{"application/json"}}
if stream {
headers = http.Header{"Content-Type": []string{"text/event-stream"}}
}
return payload, headers, nil
}
}
if lastErr != nil {
return nil, nil, lastErr
}
return nil, nil, fmt.Errorf("web search execution: all backends failed")
}
func availableProvidersFromMetadata(meta map[string]any) []string {
if meta == nil {
return nil
}
raw, ok := meta["available_providers"]
if !ok {
return nil
}
switch v := raw.(type) {
case []string:
return v
case []any:
out := make([]string, 0, len(v))
for _, item := range v {
if s, okItem := item.(string); okItem {
out = append(out, s)
}
}
return out
default:
return nil
}
}
func hostModelExecuteClaude(ctx context.Context, hostCallbackID, execModel string, body []byte, stream bool) ([]byte, int, error) {
if stream {
return hostModelStreamClaude(ctx, hostCallbackID, execModel, body)
}
raw, errCall := callHost(pluginabi.MethodHostModelExecute, hostModelExecutionRequest{
HostModelExecutionRequest: pluginapi.HostModelExecutionRequest{
EntryProtocol: "claude",
ExitProtocol: "claude",
Model: execModel,
Stream: false,
Body: body,
},
HostCallbackID: hostCallbackID,
})
if errCall != nil {
return nil, hostHTTPStatusFromError(errCall), errCall
}
var resp pluginapi.HostModelExecutionResponse
if errDecode := json.Unmarshal(raw, &resp); errDecode != nil {
return nil, 0, errDecode
}
if resp.StatusCode >= 400 {
return nil, resp.StatusCode, fmt.Errorf("host model status %d", resp.StatusCode)
}
return resp.Body, resp.StatusCode, nil
}
func hostModelStreamClaude(ctx context.Context, hostCallbackID, execModel string, body []byte) ([]byte, int, error) {
raw, errCall := callHost(pluginabi.MethodHostModelExecuteStream, hostModelExecutionRequest{
HostModelExecutionRequest: pluginapi.HostModelExecutionRequest{
EntryProtocol: "claude",
ExitProtocol: "claude",
Model: execModel,
Stream: true,
Body: body,
},
HostCallbackID: hostCallbackID,
})
if errCall != nil {
return nil, hostHTTPStatusFromError(errCall), errCall
}
var resp pluginapi.HostModelStreamResponse
if errDecode := json.Unmarshal(raw, &resp); errDecode != nil {
return nil, 0, errDecode
}
if resp.StatusCode >= 400 {
_ = closeHostModelStream(resp.StreamID)
return nil, resp.StatusCode, fmt.Errorf("host model status %d", resp.StatusCode)
}
if strings.TrimSpace(resp.StreamID) == "" {
return nil, 0, fmt.Errorf("host model stream: empty stream_id")
}
defer func() { _ = closeHostModelStream(resp.StreamID) }()
var buf bytes.Buffer
for {
chunkRaw, errRead := callHost(pluginabi.MethodHostModelStreamRead, pluginapi.HostModelStreamReadRequest{StreamID: resp.StreamID})
if errRead != nil {
return nil, hostHTTPStatusFromError(errRead), errRead
}
var chunk pluginapi.HostModelStreamReadResponse
if errDecode := json.Unmarshal(chunkRaw, &chunk); errDecode != nil {
return nil, 0, errDecode
}
if chunk.Error != "" {
code := hostHTTPStatusFromError(fmt.Errorf("%s", chunk.Error))
return nil, code, fmt.Errorf("%s", chunk.Error)
}
if len(chunk.Payload) > 0 {
buf.Write(chunk.Payload)
}
if chunk.Done {
break
}
}
return buf.Bytes(), http.StatusOK, nil
}
func closeHostModelStream(streamID string) error {
_, errCall := callHost(pluginabi.MethodHostModelStreamClose, pluginapi.HostModelStreamCloseRequest{StreamID: streamID})
return errCall
}

View file

@ -0,0 +1,28 @@
package main
import (
"testing"
"github.com/router-for-me/CLIProxyAPI/v7/sdk/pluginapi"
)
func TestBuildExecutionPlansForExecuteRespectsRouteTavily(t *testing.T) {
currentConfig.Store(pluginConfig{
Enabled: true,
Route: string(backendTavily),
TavilyAPIKeys: []string{"tvly-test"},
})
cfg := loadedConfig()
req := pluginapi.ModelRouteRequest{
SourceFormat: "claude",
RequestedModel: "claude-sonnet-4-6",
AvailableProviders: []string{"antigravity", "codex", "xai"},
}
plans := buildExecutionPlansForExecute(cfg, req)
if len(plans) != 1 {
t.Fatalf("plans len = %d, want 1 for route=tavily", len(plans))
}
if plans[0].backend != backendTavily {
t.Fatalf("backend = %q, want tavily", plans[0].backend)
}
}

View file

@ -0,0 +1,107 @@
package main
import (
"strings"
"github.com/router-for-me/CLIProxyAPI/v7/sdk/pluginapi"
)
// defaultWebSearchFallbackChain is the ordered backend try list when route=fallback.
func defaultWebSearchFallbackChain() []routeBackend {
return []routeBackend{
backendAntigravityGoogle,
backendCodexWebSearch,
backendXAIWebSearch,
backendTavily,
}
}
func isFallbackRoute(route string) bool {
r := strings.ToLower(strings.TrimSpace(route))
return r == "" || r == string(backendFallback)
}
// tryRouteBackend returns a handled ModelRouteResponse and true when this backend can serve the request.
func tryRouteBackend(backend routeBackend, cfg pluginConfig, req pluginapi.ModelRouteRequest) (pluginapi.ModelRouteResponse, bool) {
switch backend {
case backendTavily:
client := newTavilyClient(cfg.TavilyAPIKeys)
if !client.available() {
return pluginapi.ModelRouteResponse{Handled: false, Reason: "tavily_unavailable"}, false
}
return pluginapi.ModelRouteResponse{
Handled: true,
TargetKind: pluginapi.ModelRouteTargetSelf,
Reason: "claude_code_web_search_tavily",
}, true
case backendAntigravityGoogle:
if !hasProvider(req.AvailableProviders, "antigravity") {
return pluginapi.ModelRouteResponse{Handled: false, Reason: "antigravity_unavailable"}, false
}
targetModel := resolveAntigravityWebSearchTargetModel(cfg.AntigravityModel, req.RequestedModel)
if targetModel == "" {
return pluginapi.ModelRouteResponse{Handled: false, Reason: "antigravity_web_search_model_unresolved"}, false
}
return pluginapi.ModelRouteResponse{
Handled: true,
TargetKind: pluginapi.ModelRouteTargetProvider,
Target: "antigravity",
TargetModel: targetModel,
Reason: "claude_code_web_search_antigravity_google",
}, true
case backendCodexWebSearch:
if !hasProvider(req.AvailableProviders, "codex") {
return pluginapi.ModelRouteResponse{Handled: false, Reason: "codex_unavailable"}, false
}
targetModel := resolveCodexWebSearchTargetModel(cfg.CodexModel)
return pluginapi.ModelRouteResponse{
Handled: true,
TargetKind: pluginapi.ModelRouteTargetProvider,
Target: "codex",
TargetModel: targetModel,
Reason: "claude_code_web_search_codex",
}, true
case backendXAIWebSearch:
if !hasProvider(req.AvailableProviders, "xai") {
return pluginapi.ModelRouteResponse{Handled: false, Reason: "xai_unavailable"}, false
}
targetModel := resolveXAIWebSearchTargetModel(cfg.XAIModel)
return pluginapi.ModelRouteResponse{
Handled: true,
TargetKind: pluginapi.ModelRouteTargetProvider,
Target: "xai",
TargetModel: targetModel,
Reason: "claude_code_web_search_xai",
}, true
case backendDefaultProvider:
provider := cfg.DefaultProvider
if provider == "" || !hasProvider(req.AvailableProviders, provider) {
return pluginapi.ModelRouteResponse{Handled: false, Reason: "default_provider_unavailable"}, false
}
return pluginapi.ModelRouteResponse{
Handled: true,
TargetKind: pluginapi.ModelRouteTargetProvider,
Target: provider,
TargetModel: cfg.DefaultProviderModel,
Reason: "claude_code_web_search_default_provider",
}, true
default:
return pluginapi.ModelRouteResponse{Handled: false}, false
}
}
func routeWithFallback(cfg pluginConfig, req pluginapi.ModelRouteRequest) pluginapi.ModelRouteResponse {
return routeWithExecutionOrchestration(cfg, req, string(backendFallback))
}
func routeWithExecutionOrchestration(cfg pluginConfig, req pluginapi.ModelRouteRequest, route string) pluginapi.ModelRouteResponse {
plans := executionPlansForRoute(cfg, req, route)
if len(plans) == 0 {
return pluginapi.ModelRouteResponse{Handled: false, Reason: "web_search_fallback_exhausted"}
}
return pluginapi.ModelRouteResponse{
Handled: true,
TargetKind: pluginapi.ModelRouteTargetSelf,
Reason: "claude_code_web_search_orchestrated",
}
}

View file

@ -0,0 +1,138 @@
package main
import (
"encoding/json"
"testing"
"github.com/router-for-me/CLIProxyAPI/v7/internal/registry"
"github.com/router-for-me/CLIProxyAPI/v7/sdk/pluginapi"
)
func claudeWebSearchRouteBody(t *testing.T) []byte {
t.Helper()
body := []byte(`{
"tools":[{"type":"web_search_20250305","name":"web_search","max_uses":5}],
"system":[{"type":"text","text":"You have access to the web search tool use."}],
"messages":[{"role":"user","content":[{"type":"text","text":"Perform a web search for the query: test"}]}]
}`)
return body
}
func decodeModelRouteResponse(t *testing.T, raw []byte) pluginapi.ModelRouteResponse {
t.Helper()
var env envelope
if err := json.Unmarshal(raw, &env); err != nil {
t.Fatal(err)
}
var resp pluginapi.ModelRouteResponse
if err := json.Unmarshal(env.Result, &resp); err != nil {
t.Fatal(err)
}
return resp
}
func TestRouteWithFallbackAntigravityFirst(t *testing.T) {
reg := registry.GetGlobalRegistry()
const clientID = "test-fallback-antigravity"
reg.RegisterClient(clientID, "antigravity", []*registry.ModelInfo{
{ID: "gem-fallback-test", SupportsWebSearch: true},
})
t.Cleanup(func() { reg.UnregisterClient(clientID) })
currentConfig.Store(pluginConfig{
Enabled: true,
Route: string(backendFallback),
})
raw, err := routeModel(mustJSON(t, rpcModelRouteRequest{
ModelRouteRequest: pluginapi.ModelRouteRequest{
SourceFormat: "claude",
Body: claudeWebSearchRouteBody(t),
RequestedModel: "claude-sonnet-4-6",
AvailableProviders: []string{"antigravity", "codex", "xai"},
},
}))
if err != nil {
t.Fatal(err)
}
resp := decodeModelRouteResponse(t, raw)
if !resp.Handled || resp.TargetKind != pluginapi.ModelRouteTargetSelf {
t.Fatalf("resp = %#v", resp)
}
}
func TestRouteWithFallbackSkipsAntigravityToCodex(t *testing.T) {
currentConfig.Store(pluginConfig{
Enabled: true,
Route: string(backendFallback),
})
raw, err := routeModel(mustJSON(t, rpcModelRouteRequest{
ModelRouteRequest: pluginapi.ModelRouteRequest{
SourceFormat: "claude",
Body: claudeWebSearchRouteBody(t),
RequestedModel: "claude-sonnet-4-6",
AvailableProviders: []string{"codex", "xai"},
},
}))
if err != nil {
t.Fatal(err)
}
resp := decodeModelRouteResponse(t, raw)
if !resp.Handled || resp.TargetKind != pluginapi.ModelRouteTargetSelf {
t.Fatalf("resp = %#v", resp)
}
}
func TestRouteWithFallbackToTavily(t *testing.T) {
currentConfig.Store(pluginConfig{
Enabled: true,
Route: string(backendFallback),
TavilyAPIKeys: []string{"tvly-test"},
})
raw, err := routeModel(mustJSON(t, rpcModelRouteRequest{
ModelRouteRequest: pluginapi.ModelRouteRequest{
SourceFormat: "claude",
Body: claudeWebSearchRouteBody(t),
AvailableProviders: []string{},
},
}))
if err != nil {
t.Fatal(err)
}
resp := decodeModelRouteResponse(t, raw)
if !resp.Handled || resp.TargetKind != pluginapi.ModelRouteTargetSelf {
t.Fatalf("resp = %#v", resp)
}
}
func TestRouteWithFallbackExhausted(t *testing.T) {
currentConfig.Store(pluginConfig{
Enabled: true,
Route: string(backendFallback),
})
raw, err := routeModel(mustJSON(t, rpcModelRouteRequest{
ModelRouteRequest: pluginapi.ModelRouteRequest{
SourceFormat: "claude",
Body: claudeWebSearchRouteBody(t),
AvailableProviders: []string{},
},
}))
if err != nil {
t.Fatal(err)
}
resp := decodeModelRouteResponse(t, raw)
if resp.Handled {
t.Fatalf("expected declined, got %#v", resp)
}
if resp.Reason == "" || resp.Reason[:len("web_search_fallback_exhausted")] != "web_search_fallback_exhausted" {
t.Fatalf("reason = %q", resp.Reason)
}
}
func mustJSON(t *testing.T, v any) []byte {
t.Helper()
raw, err := json.Marshal(v)
if err != nil {
t.Fatal(err)
}
return raw
}

View file

@ -0,0 +1,18 @@
module github.com/router-for-me/CLIProxyAPI/v7/examples/plugin/claude-web-search-router/go
go 1.26.0
require (
github.com/router-for-me/CLIProxyAPI/v7 v7.0.0
github.com/tidwall/gjson v1.18.0
gopkg.in/yaml.v3 v3.0.1
)
require (
github.com/sirupsen/logrus v1.9.3 // indirect
github.com/tidwall/match v1.1.1 // indirect
github.com/tidwall/pretty v1.2.0 // indirect
golang.org/x/sys v0.47.0 // indirect
)
replace github.com/router-for-me/CLIProxyAPI/v7 => ../../../..

View file

@ -0,0 +1,25 @@
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY=
github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
github.com/tidwall/pretty v1.2.0 h1:RWIZEg2iJ8/g6fDDYzMpobmaoGh5OLl4AXtGUGPcqCs=
github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View file

@ -0,0 +1,482 @@
package main
/*
#include <stdint.h>
#include <stdlib.h>
typedef struct {
void* ptr;
size_t len;
} cliproxy_buffer;
typedef int (*cliproxy_host_call_fn)(void*, const char*, const uint8_t*, size_t, cliproxy_buffer*);
typedef void (*cliproxy_host_free_fn)(void*, size_t);
typedef struct {
uint32_t abi_version;
void* host_ctx;
cliproxy_host_call_fn call;
cliproxy_host_free_fn free_buffer;
} cliproxy_host_api;
typedef int (*cliproxy_plugin_call_fn)(char*, uint8_t*, size_t, cliproxy_buffer*);
typedef void (*cliproxy_plugin_free_fn)(void*, size_t);
typedef void (*cliproxy_plugin_shutdown_fn)(void);
typedef struct {
uint32_t abi_version;
cliproxy_plugin_call_fn call;
cliproxy_plugin_free_fn free_buffer;
cliproxy_plugin_shutdown_fn shutdown;
} cliproxy_plugin_api;
extern int cliproxyPluginCall(char*, uint8_t*, size_t, cliproxy_buffer*);
extern void cliproxyPluginFree(void*, size_t);
extern void cliproxyPluginShutdown(void);
static const cliproxy_host_api* stored_host;
static void store_host_api(const cliproxy_host_api* host) {
stored_host = host;
}
static int call_host_api(const char* method, const uint8_t* request, size_t request_len, cliproxy_buffer* response) {
if (stored_host == NULL || stored_host->call == NULL) {
return 1;
}
return stored_host->call(stored_host->host_ctx, method, request, request_len, response);
}
static void free_host_buffer(void* ptr, size_t len) {
if (stored_host != NULL && stored_host->free_buffer != NULL && ptr != NULL) {
stored_host->free_buffer(ptr, len);
}
}
*/
import "C"
import (
"context"
"encoding/json"
"fmt"
"net/http"
"strings"
"sync/atomic"
"unsafe"
"github.com/router-for-me/CLIProxyAPI/v7/sdk/pluginabi"
"github.com/router-for-me/CLIProxyAPI/v7/sdk/pluginapi"
"gopkg.in/yaml.v3"
)
const pluginIdentifier = "claude-web-search-router"
type routeBackend string
const (
backendFallback routeBackend = "fallback"
backendAntigravityGoogle routeBackend = "antigravity_google"
backendCodexWebSearch routeBackend = "codex_web_search"
backendXAIWebSearch routeBackend = "xai_web_search"
backendTavily routeBackend = "tavily"
backendDefaultProvider routeBackend = "default_provider"
)
var currentConfig atomic.Value
type envelope struct {
OK bool `json:"ok"`
Result json.RawMessage `json:"result,omitempty"`
Error *envelopeError `json:"error,omitempty"`
}
type envelopeError struct {
Code string `json:"code"`
Message string `json:"message"`
}
type lifecycleRequest struct {
ConfigYAML []byte `json:"config_yaml"`
}
type pluginConfig struct {
Enabled bool `yaml:"enabled"`
Route string `yaml:"route"`
AntigravityModel string `yaml:"antigravity_model"`
CodexModel string `yaml:"codex_model"`
XAIModel string `yaml:"xai_model"`
DefaultProvider string `yaml:"default_provider"`
DefaultProviderModel string `yaml:"default_provider_model"`
TavilyAPIKeys []string `yaml:"tavily_api_keys"`
RequireWebSearchOnly bool `yaml:"require_web_search_only"`
}
type registration struct {
SchemaVersion uint32 `json:"schema_version"`
Metadata pluginapi.Metadata `json:"metadata"`
Capabilities registrationCapability `json:"capabilities"`
}
type registrationCapability struct {
ModelRouter bool `json:"model_router"`
Executor bool `json:"executor"`
ExecutorModelScope string `json:"executor_model_scope"`
ExecutorInputFormats []string `json:"executor_input_formats"`
ExecutorOutputFormats []string `json:"executor_output_formats"`
}
type rpcExecutorRequest struct {
pluginapi.ExecutorRequest
StreamID string `json:"stream_id,omitempty"`
HostCallbackID string `json:"host_callback_id,omitempty"`
}
type rpcModelRouteRequest struct {
pluginapi.ModelRouteRequest
HostCallbackID string `json:"host_callback_id,omitempty"`
}
func main() {}
//export cliproxy_plugin_init
func cliproxy_plugin_init(host *C.cliproxy_host_api, plugin *C.cliproxy_plugin_api) C.int {
if plugin == nil {
return 1
}
C.store_host_api(host)
plugin.abi_version = C.uint32_t(pluginabi.ABIVersion)
plugin.call = C.cliproxy_plugin_call_fn(C.cliproxyPluginCall)
plugin.free_buffer = C.cliproxy_plugin_free_fn(C.cliproxyPluginFree)
plugin.shutdown = C.cliproxy_plugin_shutdown_fn(C.cliproxyPluginShutdown)
return 0
}
//export cliproxyPluginCall
func cliproxyPluginCall(method *C.char, request *C.uint8_t, requestLen C.size_t, response *C.cliproxy_buffer) C.int {
if response != nil {
response.ptr = nil
response.len = 0
}
if method == nil {
writeResponse(response, errorEnvelope("invalid_method", "method is required"))
return 1
}
var requestBytes []byte
if request != nil && requestLen > 0 {
requestBytes = C.GoBytes(unsafe.Pointer(request), C.int(requestLen))
}
raw, errHandle := handleMethod(C.GoString(method), requestBytes)
if errHandle != nil {
writeResponse(response, errorEnvelope("plugin_error", errHandle.Error()))
return 1
}
writeResponse(response, raw)
return 0
}
//export cliproxyPluginFree
func cliproxyPluginFree(ptr unsafe.Pointer, _ C.size_t) {
if ptr != nil {
C.free(ptr)
}
}
//export cliproxyPluginShutdown
func cliproxyPluginShutdown() {}
func handleMethod(method string, request []byte) ([]byte, error) {
switch method {
case pluginabi.MethodPluginRegister, pluginabi.MethodPluginReconfigure:
if errConfigure := configure(request); errConfigure != nil {
return nil, errConfigure
}
return okEnvelope(pluginRegistration())
case pluginabi.MethodModelRoute:
return routeModel(request)
case pluginabi.MethodExecutorIdentifier:
return okEnvelope(map[string]string{"identifier": pluginIdentifier})
case pluginabi.MethodExecutorExecute:
return execute(request)
case pluginabi.MethodExecutorExecuteStream:
return executeStream(request)
case pluginabi.MethodExecutorCountTokens:
return okEnvelope(pluginapi.ExecutorResponse{Payload: []byte(`{"input_tokens":0}`)})
default:
return errorEnvelope("unknown_method", "unknown method: "+method), nil
}
}
func configure(raw []byte) error {
var req lifecycleRequest
if len(raw) > 0 {
if errUnmarshal := json.Unmarshal(raw, &req); errUnmarshal != nil {
return errUnmarshal
}
}
cfg := defaultPluginConfig()
if len(req.ConfigYAML) > 0 {
decoded, errDecode := decodeConfig(req.ConfigYAML)
if errDecode != nil {
return errDecode
}
cfg = decoded
}
currentConfig.Store(cfg)
return nil
}
func defaultPluginConfig() pluginConfig {
return pluginConfig{
Enabled: true,
Route: string(backendFallback),
RequireWebSearchOnly: true,
}
}
func decodeConfig(raw []byte) (pluginConfig, error) {
cfg := defaultPluginConfig()
if errUnmarshal := yaml.Unmarshal(raw, &cfg); errUnmarshal != nil {
return pluginConfig{}, errUnmarshal
}
cfg.Route = strings.TrimSpace(cfg.Route)
cfg.AntigravityModel = strings.TrimSpace(cfg.AntigravityModel)
cfg.CodexModel = strings.TrimSpace(cfg.CodexModel)
cfg.XAIModel = strings.TrimSpace(cfg.XAIModel)
cfg.DefaultProvider = strings.ToLower(strings.TrimSpace(cfg.DefaultProvider))
cfg.DefaultProviderModel = strings.TrimSpace(cfg.DefaultProviderModel)
return cfg, nil
}
func loadedConfig() pluginConfig {
raw := currentConfig.Load()
if cfg, ok := raw.(pluginConfig); ok {
return cfg
}
return defaultPluginConfig()
}
func pluginRegistration() registration {
return registration{
SchemaVersion: pluginabi.SchemaVersion,
Metadata: pluginapi.Metadata{
Name: "claude-web-search-router",
Version: "0.1.0",
Author: "router-for-me",
GitHubRepository: "https://github.com/router-for-me/CLIProxyAPI",
ConfigFields: []pluginapi.ConfigField{
{Name: "enabled", Type: pluginapi.ConfigFieldTypeBoolean, Description: "When false, the router declines all Claude web_search requests."},
{Name: "route", Type: pluginapi.ConfigFieldTypeEnum, EnumValues: []string{
string(backendFallback), string(backendAntigravityGoogle), string(backendCodexWebSearch),
string(backendXAIWebSearch), string(backendTavily), string(backendDefaultProvider),
}, Description: "Backend for Claude Code web_search. fallback (default): antigravity → codex → xai → tavily."},
{Name: "antigravity_model", Type: pluginapi.ConfigFieldTypeString, Description: "Antigravity googleSearch model (empty: registry lookup, then first supports_web_search)."},
{Name: "codex_model", Type: pluginapi.ConfigFieldTypeString, Description: "Codex Responses model for web_search (empty defaults to gpt-5.4, never client Claude model)."},
{Name: "xai_model", Type: pluginapi.ConfigFieldTypeString, Description: "xAI Responses model with web_search (empty uses grok-4.3, not the client Claude model)."},
{Name: "default_provider", Type: pluginapi.ConfigFieldTypeString, Description: "Built-in provider key when route=default_provider."},
{Name: "default_provider_model", Type: pluginapi.ConfigFieldTypeString, Description: "Optional execution model on default_provider route."},
{Name: "tavily_api_keys", Type: pluginapi.ConfigFieldTypeArray, Description: "Tavily API keys (round-robin) when route=tavily."},
{Name: "require_web_search_only", Type: pluginapi.ConfigFieldTypeBoolean, Description: "Require tools to be exclusively typed web_search (matches antigravity-only path)."},
},
},
Capabilities: registrationCapability{
ModelRouter: true,
Executor: true,
ExecutorModelScope: string(pluginapi.ExecutorModelScopeStatic),
ExecutorInputFormats: []string{"claude"},
ExecutorOutputFormats: []string{"claude"},
},
}
}
func routeModel(raw []byte) ([]byte, error) {
var req rpcModelRouteRequest
if errUnmarshal := json.Unmarshal(raw, &req); errUnmarshal != nil {
return nil, errUnmarshal
}
cfg := loadedConfig()
if !cfg.Enabled {
return okEnvelope(pluginapi.ModelRouteResponse{Handled: false})
}
if !isClaudeSourceFormat(req.SourceFormat) {
return okEnvelope(pluginapi.ModelRouteResponse{Handled: false})
}
if !isClaudeCodeBuiltinWebSearchRequest(req.Body, cfg.RequireWebSearchOnly) {
return okEnvelope(pluginapi.ModelRouteResponse{Handled: false})
}
route := strings.TrimSpace(cfg.Route)
if isFallbackRoute(route) {
return okEnvelope(routeWithFallback(cfg, req.ModelRouteRequest))
}
if plans := executionPlansForRoute(cfg, req.ModelRouteRequest, route); len(plans) > 0 {
return okEnvelope(pluginapi.ModelRouteResponse{
Handled: true,
TargetKind: pluginapi.ModelRouteTargetSelf,
Reason: "claude_code_web_search_orchestrated",
})
}
backend := routeBackend(route)
resp, ok := tryRouteBackend(backend, cfg, req.ModelRouteRequest)
if ok {
return okEnvelope(resp)
}
if strings.TrimSpace(resp.Reason) != "" {
return okEnvelope(resp)
}
return okEnvelope(pluginapi.ModelRouteResponse{Handled: false})
}
func hasProvider(providers []string, key string) bool {
key = strings.ToLower(strings.TrimSpace(key))
for _, p := range providers {
if strings.ToLower(strings.TrimSpace(p)) == key {
return true
}
}
return false
}
func execute(raw []byte) ([]byte, error) {
var req rpcExecutorRequest
if errUnmarshal := json.Unmarshal(raw, &req); errUnmarshal != nil {
return nil, errUnmarshal
}
body, headers, errRun := runWebSearchWithExecutionFallback(context.Background(), req.ExecutorRequest, req.HostCallbackID)
if errRun != nil {
return errorEnvelope("executor_error", errRun.Error()), nil
}
return okEnvelope(pluginapi.ExecutorResponse{Payload: body, Headers: headers})
}
func runTavilyClaude(ctx context.Context, req pluginapi.ExecutorRequest) ([]byte, http.Header, error) {
return runTavilyClaudeWithClient(ctx, req, newTavilyClient(loadedConfig().TavilyAPIKeys))
}
func runTavilyClaudeWithClient(ctx context.Context, req pluginapi.ExecutorRequest, client *tavilyClient) ([]byte, http.Header, error) {
query := extractClaudeWebSearchQuery(req.OriginalRequest)
if query == "" {
query = extractClaudeWebSearchQuery(req.Payload)
}
maxResults := extractClaudeWebSearchMaxUses(req.OriginalRequest, 5)
hits, answer, errSearch := client.search(ctx, query, maxResults)
if errSearch != nil {
return nil, nil, errSearch
}
model := strings.TrimSpace(req.Model)
builder := newClaudeStreamBuilder(model)
payload := builder.buildMessageJSON(query, hits, answer)
headers := http.Header{"Content-Type": []string{"application/json"}}
return payload, headers, nil
}
func runTavilyClaudeStream(ctx context.Context, req pluginapi.ExecutorRequest) ([]byte, http.Header, error) {
return runTavilyClaudeStreamWithClient(ctx, req, newTavilyClient(loadedConfig().TavilyAPIKeys))
}
func runTavilyClaudeStreamWithClient(ctx context.Context, req pluginapi.ExecutorRequest, client *tavilyClient) ([]byte, http.Header, error) {
query := extractClaudeWebSearchQuery(req.OriginalRequest)
if query == "" {
query = extractClaudeWebSearchQuery(req.Payload)
}
maxResults := extractClaudeWebSearchMaxUses(req.OriginalRequest, 5)
hits, answer, errSearch := client.search(ctx, query, maxResults)
if errSearch != nil {
return nil, nil, errSearch
}
model := strings.TrimSpace(req.Model)
builder := newClaudeStreamBuilder(model)
payload := builder.buildStreamWithQuery(query, hits, answer)
headers := http.Header{"Content-Type": []string{"text/event-stream"}}
return payload, headers, nil
}
type hostModelExecutionRequest struct {
pluginapi.HostModelExecutionRequest
HostCallbackID string `json:"host_callback_id,omitempty"`
}
func callHost(method string, payload any) (json.RawMessage, error) {
rawPayload, errMarshal := json.Marshal(payload)
if errMarshal != nil {
return nil, fmt.Errorf("marshal host callback %s: %w", method, errMarshal)
}
cMethod := C.CString(method)
defer C.free(unsafe.Pointer(cMethod))
var response C.cliproxy_buffer
var requestPtr *C.uint8_t
if len(rawPayload) > 0 {
cPayload := C.CBytes(rawPayload)
if cPayload == nil {
return nil, fmt.Errorf("allocate host callback %s", method)
}
defer C.free(cPayload)
requestPtr = (*C.uint8_t)(cPayload)
}
callCode := C.call_host_api(cMethod, requestPtr, C.size_t(len(rawPayload)), &response)
var rawResponse []byte
if response.ptr != nil && response.len > 0 {
rawResponse = C.GoBytes(response.ptr, C.int(response.len))
}
if response.ptr != nil {
C.free_host_buffer(response.ptr, response.len)
}
if len(rawResponse) == 0 {
return nil, fmt.Errorf("host callback %s returned no response, code=%d", method, int(callCode))
}
var env envelope
if errUnmarshal := json.Unmarshal(rawResponse, &env); errUnmarshal != nil {
return nil, fmt.Errorf("decode host envelope %s: %w", method, errUnmarshal)
}
if !env.OK {
if env.Error != nil {
return nil, fmt.Errorf("%s: %s", env.Error.Code, env.Error.Message)
}
return nil, fmt.Errorf("host callback %s failed", method)
}
if callCode != 0 {
return nil, fmt.Errorf("host callback %s returned code=%d", method, int(callCode))
}
return append(json.RawMessage(nil), env.Result...), nil
}
func hostHTTPStatusFromError(err error) int {
if err == nil {
return 0
}
msg := err.Error()
for _, code := range []int{429, 503, 502} {
if strings.Contains(msg, fmt.Sprintf("%d", code)) {
return code
}
}
return 0
}
func isRetryableHTTPStatus(code int) bool {
return code == 429 || code == 503 || code == 502
}
func okEnvelope(v any) ([]byte, error) {
raw, errMarshal := json.Marshal(v)
if errMarshal != nil {
return nil, errMarshal
}
return json.Marshal(envelope{OK: true, Result: raw})
}
func errorEnvelope(code, message string) []byte {
raw, _ := json.Marshal(envelope{OK: false, Error: &envelopeError{Code: code, Message: message}})
return raw
}
func writeResponse(response *C.cliproxy_buffer, raw []byte) {
if response == nil || len(raw) == 0 {
return
}
ptr := C.CBytes(raw)
if ptr == nil {
return
}
response.ptr = ptr
response.len = C.size_t(len(raw))
}

View file

@ -0,0 +1,51 @@
package main
import (
"strings"
"github.com/router-for-me/CLIProxyAPI/v7/internal/registry"
)
const (
// Default Codex model for Claude web_search → Codex Responses (override with codex_model).
defaultCodexWebSearchModel = "gpt-5.4-mini"
// Default xAI model for server-side web_search per https://docs.x.ai/developers/tools/web-search
defaultXAIWebSearchModel = "grok-4.3"
)
// resolveAntigravityWebSearchTargetModel picks an Antigravity model that can run native googleSearch.
// Config antigravity_model wins; otherwise registry.AntigravityWebSearchModelFor(requested) or the
// first available antigravity model with SupportsWebSearch.
func resolveAntigravityWebSearchTargetModel(configured, requested string) string {
if m := strings.TrimSpace(configured); m != "" {
return m
}
if m := registry.AntigravityWebSearchModelFor(strings.TrimSpace(requested)); m != "" {
return m
}
for _, model := range registry.GetGlobalRegistry().GetAvailableModelsByProvider("antigravity") {
if model == nil || !model.SupportsWebSearch {
continue
}
if id := strings.TrimSpace(model.ID); id != "" {
return id
}
}
return ""
}
// resolveCodexWebSearchTargetModel never forwards the client Claude model to Codex.
func resolveCodexWebSearchTargetModel(configured string) string {
if m := strings.TrimSpace(configured); m != "" {
return m
}
return defaultCodexWebSearchModel
}
// resolveXAIWebSearchTargetModel never forwards the client Claude model to xAI Responses.
func resolveXAIWebSearchTargetModel(configured string) string {
if m := strings.TrimSpace(configured); m != "" {
return m
}
return defaultXAIWebSearchModel
}

View file

@ -0,0 +1,43 @@
package main
import (
"testing"
"github.com/router-for-me/CLIProxyAPI/v7/internal/registry"
)
func TestResolveCodexWebSearchTargetModelNeverUsesClaudeName(t *testing.T) {
got := resolveCodexWebSearchTargetModel("")
if got != defaultCodexWebSearchModel {
t.Fatalf("empty config = %q, want %q", got, defaultCodexWebSearchModel)
}
if got := resolveCodexWebSearchTargetModel("gpt-5.5"); got != "gpt-5.5" {
t.Fatalf("configured = %q", got)
}
}
func TestResolveXAIWebSearchTargetModelNeverUsesClaudeName(t *testing.T) {
got := resolveXAIWebSearchTargetModel("")
if got != defaultXAIWebSearchModel {
t.Fatalf("empty config = %q, want %q", got, defaultXAIWebSearchModel)
}
}
func TestResolveAntigravityWebSearchTargetModelConfiguredWins(t *testing.T) {
if got := resolveAntigravityWebSearchTargetModel("my-gemini", "claude-sonnet-4-6"); got != "my-gemini" {
t.Fatalf("configured = %q", got)
}
}
func TestResolveAntigravityWebSearchTargetModelFromRegistry(t *testing.T) {
reg := registry.GetGlobalRegistry()
const clientID = "test-claude-web-search-router-antigravity"
reg.RegisterClient(clientID, "antigravity", []*registry.ModelInfo{
{ID: "gemini-web-search-test", SupportsWebSearch: true},
})
t.Cleanup(func() { reg.UnregisterClient(clientID) })
got := resolveAntigravityWebSearchTargetModel("", "claude-sonnet-4-6")
if got != "gemini-web-search-test" {
t.Fatalf("fallback = %q, want gemini-web-search-test", got)
}
}

View file

@ -0,0 +1,57 @@
package main
import (
"sort"
"sync"
)
const (
penaltyBumpOn429503 = 5
penaltyDecaySuccess = 1
)
var backendPenalties = struct {
sync.Mutex
scores map[routeBackend]int
}{
scores: make(map[routeBackend]int),
}
func recordBackendFailure(backend routeBackend) {
backendPenalties.Lock()
defer backendPenalties.Unlock()
backendPenalties.scores[backend] += penaltyBumpOn429503
}
func recordBackendSuccess(backend routeBackend) {
backendPenalties.Lock()
defer backendPenalties.Unlock()
score := backendPenalties.scores[backend] - penaltyDecaySuccess
if score < 0 {
score = 0
}
backendPenalties.scores[backend] = score
}
func penaltyScore(backend routeBackend) int {
backendPenalties.Lock()
defer backendPenalties.Unlock()
return backendPenalties.scores[backend]
}
func sortBackendsByPenalty(backends []routeBackend) []routeBackend {
if len(backends) <= 1 {
return append([]routeBackend(nil), backends...)
}
out := append([]routeBackend(nil), backends...)
sort.SliceStable(out, func(i, j int) bool {
return penaltyScore(out[i]) < penaltyScore(out[j])
})
return out
}
func resetBackendPenaltiesForTest() {
backendPenalties.Lock()
defer backendPenalties.Unlock()
backendPenalties.scores = make(map[routeBackend]int)
}

View file

@ -0,0 +1,18 @@
package main
import "testing"
func TestSortBackendsByPenaltyDeprioritizesFailures(t *testing.T) {
resetBackendPenaltiesForTest()
t.Cleanup(resetBackendPenaltiesForTest)
recordBackendFailure(backendAntigravityGoogle)
recordBackendFailure(backendAntigravityGoogle)
ordered := sortBackendsByPenalty([]routeBackend{
backendAntigravityGoogle,
backendCodexWebSearch,
backendXAIWebSearch,
})
if ordered[0] != backendCodexWebSearch {
t.Fatalf("ordered = %v, want codex first after antigravity penalty", ordered)
}
}

View file

@ -0,0 +1,180 @@
package main
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"strings"
"github.com/router-for-me/CLIProxyAPI/v7/sdk/pluginabi"
"github.com/router-for-me/CLIProxyAPI/v7/sdk/pluginapi"
)
type rpcStreamEmitRequest struct {
StreamID string `json:"stream_id"`
Payload []byte `json:"payload,omitempty"`
Error string `json:"error,omitempty"`
}
type rpcStreamCloseRequest struct {
StreamID string `json:"stream_id"`
Error string `json:"error,omitempty"`
}
func emitPluginStreamChunk(streamID string, payload []byte) error {
if strings.TrimSpace(streamID) == "" {
return fmt.Errorf("plugin stream id is required")
}
_, errCall := callHost(pluginabi.MethodHostStreamEmit, rpcStreamEmitRequest{
StreamID: streamID,
Payload: payload,
})
return errCall
}
func closePluginStream(streamID, errMsg string) {
if strings.TrimSpace(streamID) == "" {
return
}
_, _ = callHost(pluginabi.MethodHostStreamClose, rpcStreamCloseRequest{
StreamID: streamID,
Error: strings.TrimSpace(errMsg),
})
}
func looksLikeOpenAIResponsesSSE(payload []byte) bool {
if len(payload) == 0 {
return false
}
s := string(payload)
if strings.Contains(s, "event: message_start") {
return false
}
return strings.Contains(s, "event: response.") ||
strings.Contains(s, `"type":"response.`) ||
strings.Contains(s, `"type": "response.`)
}
func runWebSearchStreamOrchestration(ctx context.Context, exec pluginapi.ExecutorRequest, hostCallbackID, pluginStreamID string) error {
cfg := loadedConfig()
req := pluginapi.ModelRouteRequest{
SourceFormat: "claude",
RequestedModel: strings.TrimSpace(exec.Model),
Body: claudeRequestBody(exec),
AvailableProviders: availableProvidersFromMetadata(exec.Metadata),
}
return runOrderedExecutionPlansStream(ctx, exec, hostCallbackID, pluginStreamID, cfg, buildExecutionPlansForExecute(cfg, req))
}
func runOrderedExecutionPlansStream(ctx context.Context, exec pluginapi.ExecutorRequest, hostCallbackID, pluginStreamID string, cfg pluginConfig, plans []executionPlan) error {
if len(plans) == 0 {
return fmt.Errorf("web search execution: no backend available")
}
backends := make([]routeBackend, 0, len(plans))
for _, p := range plans {
backends = append(backends, p.backend)
}
ordered := sortBackendsByPenalty(backends)
planByBackend := make(map[routeBackend]executionPlan, len(plans))
for _, p := range plans {
planByBackend[p.backend] = p
}
body := claudeRequestBody(exec)
var lastErr error
for _, backend := range ordered {
plan := planByBackend[backend]
switch backend {
case backendTavily:
payload, _, errRun := runTavilyClaudeStreamWithClient(ctx, exec, newTavilyClient(cfg.TavilyAPIKeys))
if errRun != nil {
lastErr = errRun
continue
}
if errEmit := emitPluginStreamChunk(pluginStreamID, payload); errEmit != nil {
return errEmit
}
recordBackendSuccess(backend)
return nil
default:
status, errRun := hostModelStreamForwardClaude(ctx, hostCallbackID, plan.model, body, pluginStreamID)
if errRun != nil {
lastErr = errRun
if isRetryableHTTPStatus(hostHTTPStatusFromError(errRun)) {
recordBackendFailure(backend)
}
continue
}
if isRetryableHTTPStatus(status) {
recordBackendFailure(backend)
lastErr = fmt.Errorf("host model status %d", status)
continue
}
recordBackendSuccess(backend)
return nil
}
}
if lastErr != nil {
return lastErr
}
return fmt.Errorf("web search execution: all backends failed")
}
func hostModelStreamForwardClaude(ctx context.Context, hostCallbackID, execModel string, body []byte, pluginStreamID string) (int, error) {
raw, errCall := callHost(pluginabi.MethodHostModelExecuteStream, hostModelExecutionRequest{
HostModelExecutionRequest: pluginapi.HostModelExecutionRequest{
EntryProtocol: "claude",
ExitProtocol: "claude",
Model: execModel,
Stream: true,
Body: body,
},
HostCallbackID: hostCallbackID,
})
if errCall != nil {
return hostHTTPStatusFromError(errCall), errCall
}
var resp pluginapi.HostModelStreamResponse
if errDecode := json.Unmarshal(raw, &resp); errDecode != nil {
return 0, errDecode
}
if resp.StatusCode >= 400 {
_ = closeHostModelStream(resp.StreamID)
return resp.StatusCode, fmt.Errorf("host model status %d", resp.StatusCode)
}
if strings.TrimSpace(resp.StreamID) == "" {
return 0, fmt.Errorf("host model stream: empty stream_id")
}
defer func() { _ = closeHostModelStream(resp.StreamID) }()
firstPayload := true
for {
chunkRaw, errRead := callHost(pluginabi.MethodHostModelStreamRead, pluginapi.HostModelStreamReadRequest{StreamID: resp.StreamID})
if errRead != nil {
return hostHTTPStatusFromError(errRead), errRead
}
var chunk pluginapi.HostModelStreamReadResponse
if errDecode := json.Unmarshal(chunkRaw, &chunk); errDecode != nil {
return 0, errDecode
}
if chunk.Error != "" {
code := hostHTTPStatusFromError(fmt.Errorf("%s", chunk.Error))
return code, fmt.Errorf("%s", chunk.Error)
}
if len(chunk.Payload) > 0 {
if firstPayload && looksLikeOpenAIResponsesSSE(chunk.Payload) {
return 0, fmt.Errorf("host model stream returned OpenAI Responses SSE instead of Claude Messages SSE")
}
firstPayload = false
if errEmit := emitPluginStreamChunk(pluginStreamID, bytes.Clone(chunk.Payload)); errEmit != nil {
return 0, errEmit
}
}
if chunk.Done {
break
}
}
return http.StatusOK, nil
}

View file

@ -0,0 +1,71 @@
package main
import (
"context"
"strings"
"testing"
"time"
"github.com/router-for-me/CLIProxyAPI/v7/sdk/pluginapi"
)
func TestLooksLikeOpenAIResponsesSSE(t *testing.T) {
if !looksLikeOpenAIResponsesSSE([]byte("event: response.created\ndata: {\"type\":\"response.created\"}\n\n")) {
t.Fatal("expected OpenAI Responses SSE detection")
}
if looksLikeOpenAIResponsesSSE([]byte("event: message_start\ndata: {\"type\":\"message_start\"}\n\n")) {
t.Fatal("expected Claude Messages SSE to not match Responses detector")
}
if looksLikeOpenAIResponsesSSE(nil) {
t.Fatal("empty payload should not match")
}
}
func TestStartExecutorStreamRunsOrchestrationAfterRPCReturns(t *testing.T) {
started := make(chan struct{})
release := make(chan struct{})
closed := make(chan string, 1)
req := rpcExecutorRequest{
ExecutorRequest: pluginapi.ExecutorRequest{Stream: true},
StreamID: "stream-1",
HostCallbackID: "callback-1",
}
raw, errStart := startExecutorStream(req, func(ctx context.Context, exec pluginapi.ExecutorRequest, hostCallbackID, pluginStreamID string) error {
if hostCallbackID != "callback-1" || pluginStreamID != "stream-1" {
t.Errorf("runner ids = %q/%q, want callback-1/stream-1", hostCallbackID, pluginStreamID)
}
close(started)
<-release
return nil
}, func(streamID, errMsg string) {
closed <- streamID + "|" + errMsg
})
if errStart != nil {
t.Fatalf("startExecutorStream() error = %v", errStart)
}
if !strings.Contains(string(raw), "text/event-stream") {
t.Fatalf("response does not include stream headers: %s", raw)
}
select {
case <-started:
case <-time.After(time.Second):
t.Fatal("orchestration did not start")
}
select {
case got := <-closed:
t.Fatalf("stream closed before orchestration finished: %q", got)
default:
}
close(release)
select {
case got := <-closed:
if got != "stream-1|" {
t.Fatalf("close call = %q, want stream-1|", got)
}
case <-time.After(time.Second):
t.Fatal("stream was not closed after orchestration finished")
}
}

View file

@ -0,0 +1,144 @@
package main
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"sync/atomic"
)
const tavilySearchURL = "https://api.tavily.com/search"
type tavilyClient struct {
keys []string
idx atomic.Uint64
http *http.Client
baseURL string // empty → https://api.tavily.com/search
}
func newTavilyClient(keys []string) *tavilyClient {
return newTavilyClientWithOptions(keys, nil, "")
}
func newTavilyClientWithOptions(keys []string, httpClient *http.Client, baseURL string) *tavilyClient {
trimmed := make([]string, 0, len(keys))
for _, key := range keys {
if k := strings.TrimSpace(key); k != "" {
trimmed = append(trimmed, k)
}
}
if httpClient == nil {
httpClient = &http.Client{}
}
return &tavilyClient{
keys: trimmed,
http: httpClient,
baseURL: strings.TrimSpace(baseURL),
}
}
func (c *tavilyClient) searchEndpoint() string {
if c != nil && c.baseURL != "" {
return c.baseURL
}
return tavilySearchURL
}
func (c *tavilyClient) available() bool {
return c != nil && len(c.keys) > 0
}
func (c *tavilyClient) nextKey() string {
if len(c.keys) == 0 {
return ""
}
n := c.idx.Add(1)
return c.keys[int(n-1)%len(c.keys)]
}
type tavilySearchRequest struct {
APIKey string `json:"api_key"`
Query string `json:"query"`
SearchDepth string `json:"search_depth,omitempty"`
MaxResults int `json:"max_results,omitempty"`
IncludeAnswer bool `json:"include_answer,omitempty"`
}
type tavilySearchResponse struct {
Answer string `json:"answer"`
Results []struct {
Title string `json:"title"`
URL string `json:"url"`
Content string `json:"content"`
} `json:"results"`
}
type claudeWebSearchHit struct {
Title string
URL string
Snippet string
}
func (c *tavilyClient) search(ctx context.Context, query string, maxResults int) ([]claudeWebSearchHit, string, error) {
if !c.available() {
return nil, "", fmt.Errorf("tavily_api_keys is empty")
}
query = strings.TrimSpace(query)
if query == "" {
return nil, "", fmt.Errorf("web search query is empty")
}
if maxResults <= 0 {
maxResults = 5
}
payload, errMarshal := json.Marshal(tavilySearchRequest{
APIKey: c.nextKey(),
Query: query,
SearchDepth: "basic",
MaxResults: maxResults,
IncludeAnswer: true,
})
if errMarshal != nil {
return nil, "", errMarshal
}
req, errNew := http.NewRequestWithContext(ctx, http.MethodPost, c.searchEndpoint(), bytes.NewReader(payload))
if errNew != nil {
return nil, "", errNew
}
req.Header.Set("Content-Type", "application/json")
resp, errDo := c.http.Do(req)
if errDo != nil {
return nil, "", errDo
}
defer func() { _ = resp.Body.Close() }()
body, errRead := io.ReadAll(resp.Body)
if errRead != nil {
return nil, "", errRead
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, "", fmt.Errorf("tavily http %d: %s", resp.StatusCode, truncate(string(body), 512))
}
var parsed tavilySearchResponse
if errDecode := json.Unmarshal(body, &parsed); errDecode != nil {
return nil, "", errDecode
}
hits := make([]claudeWebSearchHit, 0, len(parsed.Results))
for _, r := range parsed.Results {
hits = append(hits, claudeWebSearchHit{
Title: strings.TrimSpace(r.Title),
URL: strings.TrimSpace(r.URL),
Snippet: strings.TrimSpace(r.Content),
})
}
return hits, strings.TrimSpace(parsed.Answer), nil
}
func truncate(s string, max int) string {
if len(s) <= max {
return s
}
return s[:max] + "..."
}

View file

@ -0,0 +1,217 @@
package main
import (
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/router-for-me/CLIProxyAPI/v7/sdk/pluginapi"
"github.com/tidwall/gjson"
)
func TestTavilyClientSearchMockAPI(t *testing.T) {
var gotBody tavilySearchRequest
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
t.Errorf("method = %s, want POST", r.Method)
}
if ct := r.Header.Get("Content-Type"); !strings.Contains(ct, "application/json") {
t.Errorf("content-type = %q", ct)
}
raw, errRead := io.ReadAll(r.Body)
if errRead != nil {
t.Fatal(errRead)
}
if errDecode := json.Unmarshal(raw, &gotBody); errDecode != nil {
t.Fatal(errDecode)
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{
"query": "北京天气",
"answer": "明天晴。",
"results": [
{"title": "Example Weather", "url": "https://example.com/w", "content": "snippet one"}
]
}`))
}))
defer server.Close()
client := newTavilyClientWithOptions([]string{"tvly-test-key"}, server.Client(), server.URL)
hits, answer, errSearch := client.search(context.Background(), "北京天气", 3)
if errSearch != nil {
t.Fatalf("search() error = %v", errSearch)
}
if gotBody.APIKey != "tvly-test-key" {
t.Fatalf("api_key = %q", gotBody.APIKey)
}
if gotBody.Query != "北京天气" {
t.Fatalf("query = %q", gotBody.Query)
}
if gotBody.MaxResults != 3 {
t.Fatalf("max_results = %d, want 3", gotBody.MaxResults)
}
if !gotBody.IncludeAnswer {
t.Fatal("include_answer should be true")
}
if answer != "明天晴。" {
t.Fatalf("answer = %q", answer)
}
if len(hits) != 1 || hits[0].URL != "https://example.com/w" {
t.Fatalf("hits = %#v", hits)
}
}
func TestTavilyClientSearchEmptyKeys(t *testing.T) {
client := newTavilyClient(nil)
_, _, err := client.search(context.Background(), "q", 5)
if err == nil || !strings.Contains(err.Error(), "tavily_api_keys") {
t.Fatalf("err = %v", err)
}
}
func TestTavilyClientSearchHTTPError(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusUnauthorized)
_, _ = w.Write([]byte(`{"error":"bad key"}`))
}))
defer server.Close()
client := newTavilyClientWithOptions([]string{"bad"}, server.Client(), server.URL)
_, _, err := client.search(context.Background(), "q", 5)
if err == nil || !strings.Contains(err.Error(), "401") {
t.Fatalf("err = %v", err)
}
}
func TestTavilyClientRoundRobinKeys(t *testing.T) {
var keys []string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var body tavilySearchRequest
_ = json.NewDecoder(r.Body).Decode(&body)
keys = append(keys, body.APIKey)
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"results":[]}`))
}))
defer server.Close()
client := newTavilyClientWithOptions([]string{"k1", "k2"}, server.Client(), server.URL)
for i := 0; i < 4; i++ {
if _, _, err := client.search(context.Background(), "q", 1); err != nil {
t.Fatal(err)
}
}
if len(keys) != 4 || keys[0] != "k1" || keys[1] != "k2" || keys[2] != "k1" || keys[3] != "k2" {
t.Fatalf("key rotation = %v", keys)
}
}
func TestRunTavilyClaudeStreamWithMock(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{
"answer": "2026年6月16日北京多雨。",
"results": [
{"title": "bjmy.gov.cn", "url": "https://www.bjmy.gov.cn/x", "content": "预报"}
]
}`))
}))
defer server.Close()
claudeBody := []byte(`{
"model": "claude-sonnet-4-6",
"stream": true,
"tools": [{"type": "web_search_20250305", "name": "web_search", "max_uses": 5}],
"messages": [{"role": "user", "content": [{"type": "text", "text": "Perform a web search for the query: 北京天气 2026年6月16日"}]}]
}`)
client := newTavilyClientWithOptions([]string{"tvly-mock"}, server.Client(), server.URL)
payload, headers, errRun := runTavilyClaudeStreamWithClient(context.Background(), pluginapi.ExecutorRequest{
Model: "claude-sonnet-4-6",
Stream: true,
OriginalRequest: claudeBody,
}, client)
if errRun != nil {
t.Fatalf("runTavilyClaudeStreamWithClient() error = %v", errRun)
}
if headers.Get("Content-Type") != "text/event-stream" {
t.Fatalf("content-type = %q", headers.Get("Content-Type"))
}
text := string(payload)
for _, needle := range []string{
"event: message_start",
`"type":"server_tool_use"`,
`"name":"web_search"`,
`"type":"web_search_tool_result"`,
`"type":"web_search_result"`,
`https://www.bjmy.gov.cn/x`,
`"web_search_requests":1`,
"event: message_stop",
"北京天气 2026年6月16日",
"2026年6月16日北京多雨",
} {
if !strings.Contains(text, needle) {
t.Fatalf("SSE missing %q in:\n%s", needle, text)
}
}
}
func TestRunTavilyClaudeJSONWithMock(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte(`{"answer":"ok","results":[{"title":"T","url":"https://t.example","content":"c"}]}`))
}))
defer server.Close()
claudeBody := []byte(`{
"tools": [{"type": "web_search_20250305", "name": "web_search"}],
"messages": [{"role": "user", "content": "Perform a web search for the query: test query"}]
}`)
client := newTavilyClientWithOptions([]string{"k"}, server.Client(), server.URL)
payload, _, errRun := runTavilyClaudeWithClient(context.Background(), pluginapi.ExecutorRequest{
Model: "claude-sonnet-4-6",
OriginalRequest: claudeBody,
}, client)
if errRun != nil {
t.Fatal(errRun)
}
root := gjson.ParseBytes(payload)
if root.Get("type").String() != "message" {
t.Fatalf("type = %s", root.Get("type").String())
}
if root.Get("content.0.type").String() != "server_tool_use" {
t.Fatalf("content.0 = %s", root.Get("content.0.type").String())
}
if root.Get("content.1.type").String() != "web_search_tool_result" {
t.Fatalf("content.1 = %s", root.Get("content.1.type").String())
}
if root.Get("content.2.text").String() != "ok" {
t.Fatalf("text = %s", root.Get("content.2.text").String())
}
if root.Get("usage.server_tool_use.web_search_requests").Int() != 1 {
t.Fatalf("web_search_requests = %d", root.Get("usage.server_tool_use.web_search_requests").Int())
}
}
func TestExecuteStreamRPCWithMockTavily(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte(`{"answer":"rpc-ok","results":[]}`))
}))
defer server.Close()
currentConfig.Store(pluginConfig{
Route: string(backendTavily),
TavilyAPIKeys: []string{"k"},
})
// Override client by patching: executeStream uses loadedConfig keys + real URL.
// Test runTavilyClaudeStreamWithClient directly instead; for execute() we need config + mock URL.
// Use executor path with injected client via runTavilyClaudeStreamWithClient already covered.
_ = server
claudeBody := []byte(`{"messages":[{"role":"user","content":"Perform a web search for the query: q"}],"tools":[{"type":"web_search_20250305","name":"web_search"}]}`)
client := newTavilyClientWithOptions([]string{"k"}, server.Client(), server.URL)
body, _, err := runTavilyClaudeStreamWithClient(context.Background(), pluginapi.ExecutorRequest{
Model: "m", Stream: true, OriginalRequest: claudeBody,
}, client)
if err != nil || !strings.Contains(string(body), "rpc-ok") {
t.Fatalf("err=%v body=%s", err, body)
}
}