Add projects
This commit is contained in:
parent
2d3a9ad623
commit
8b607dd700
1802 changed files with 503346 additions and 2 deletions
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
183
backend/examples/plugin/claude-web-search-router/go/detect.go
Normal file
183
backend/examples/plugin/claude-web-search-router/go/detect.go
Normal 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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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"}},
|
||||
})
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
107
backend/examples/plugin/claude-web-search-router/go/fallback.go
Normal file
107
backend/examples/plugin/claude-web-search-router/go/fallback.go
Normal 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",
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
18
backend/examples/plugin/claude-web-search-router/go/go.mod
Normal file
18
backend/examples/plugin/claude-web-search-router/go/go.mod
Normal 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 => ../../../..
|
||||
25
backend/examples/plugin/claude-web-search-router/go/go.sum
Normal file
25
backend/examples/plugin/claude-web-search-router/go/go.sum
Normal 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=
|
||||
482
backend/examples/plugin/claude-web-search-router/go/main.go
Normal file
482
backend/examples/plugin/claude-web-search-router/go/main.go
Normal 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))
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
144
backend/examples/plugin/claude-web-search-router/go/tavily.go
Normal file
144
backend/examples/plugin/claude-web-search-router/go/tavily.go
Normal 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] + "..."
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue