diff --git a/examples/plugin/Makefile b/examples/plugin/Makefile --- a/examples/plugin/Makefile +++ b/examples/plugin/Makefile @@ -1,4 +1,4 @@ -EXAMPLES := simple model auth frontend-auth executor protocol-format request-translator request-normalizer response-translator response-normalizer thinking usage cli management-api host-callback host-callback-auth-files host-model-callback +EXAMPLES := simple model auth frontend-auth executor protocol-format request-translator request-normalizer response-translator response-normalizer thinking usage cli management-api host-callback host-callback-auth-files host-model-callback claude-web-search-router LANGUAGES := go c rust BIN_DIR := $(CURDIR)/bin BUILD_DIR := $(BIN_DIR)/build diff --git a/examples/plugin/README.md b/examples/plugin/README.md --- a/examples/plugin/README.md +++ b/examples/plugin/README.md @@ -16,6 +16,7 @@ - `request-normalizer/`: request normalization capability only. - `codex-service-tier/`: Go-only request normalizer that sets Codex `gpt-5.5` requests to the priority service tier when enabled. - `scheduler/`: Go-only scheduler that can select a configured auth ID, delegate to a built-in scheduler, or deny picks. +- `claude-web-search-router/`: ModelRouter + executor for Claude Code built-in `web_search` (antigravity / codex / xai / Tavily). See `claude-web-search-router/README.md`. - `response-translator/`: response translation capability only. - `response-normalizer/`: response normalization capability only. - `thinking/`: thinking applier capability only. diff --git a/internal/pluginhost/executor_route.go b/internal/pluginhost/executor_route.go new file mode 100644 --- /dev/null +++ b/internal/pluginhost/executor_route.go @@ -0,0 +1,139 @@ +package pluginhost + +import ( + "context" + "fmt" + "strings" + + coreauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth" + coreexecutor "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/executor" + "github.com/router-for-me/CLIProxyAPI/v7/sdk/pluginapi" + sdktranslator "github.com/router-for-me/CLIProxyAPI/v7/sdk/translator" +) + +// executorPluginReady reports whether the named plugin can actually execute a +// request right now: it must declare an executor capability AND resolve a +// non-empty provider identifier (the same requirement enforced by +// executorAdapterForPlugin at execution time), allow static execution without +// selected auth, and declare formats compatible with the current request. +// Routing pre-checks use this so that targets which would fail at execution are +// treated as unhandled and fall through to lower-priority routers instead of +// returning handled then 500ing. +func (h *Host) executorPluginReady(pluginID string, routeReq pluginapi.ModelRouteRequest) bool { + if h == nil { + return false + } + pluginID = strings.TrimSpace(pluginID) + if pluginID == "" { + return false + } + for _, record := range h.Snapshot().records { + if record.id != pluginID || h.isPluginFused(record.id) { + continue + } + executor := record.plugin.Capabilities.Executor + if executor == nil { + return false + } + if !executorScopeAllowsStaticModels(record.plugin.Capabilities) { + return false + } + provider, okProvider := h.executorProvider(record, executor) + if !okProvider { + return false + } + adapter := newExecutorAdapterRegistration(h, record, provider, executor).adapter + return adapter.supportsExecutorFormats( + coreexecutor.Request{Model: routeReq.RequestedModel, Payload: routeReq.Body}, + coreexecutor.Options{ + Stream: routeReq.Stream, + OriginalRequest: routeReq.Body, + SourceFormat: sdktranslator.FromString(routeReq.SourceFormat), + ResponseFormat: sdktranslator.FromString(routeReq.SourceFormat), + Headers: cloneHeader(routeReq.Headers), + Query: cloneValues(routeReq.Query), + Metadata: cloneInterceptorMetadata(routeReq.Metadata), + }, + ) + } + return false +} + +func (a *executorAdapter) supportsExecutorFormats(req coreexecutor.Request, opts coreexecutor.Options) bool { + if a == nil { + return false + } + inputRequested := executorInputFormat(req, opts) + requestedFormat := executorRequestedFormat(req, opts) + inputFormat, errInput := a.selectExecutorInputFormat(inputRequested) + if errInput != nil { + return false + } + _, errOutput := a.selectExecutorOutputFormat(requestedFormat, inputFormat) + return errOutput == nil +} + +// PluginExecutorRequestToFormat reports the executor input format selected for a direct plugin executor route. +func (h *Host) PluginExecutorRequestToFormat(pluginID string, req coreexecutor.Request, opts coreexecutor.Options) sdktranslator.Format { + adapter, errAdapter := h.executorAdapterForPlugin(pluginID) + if errAdapter != nil { + return "" + } + return adapter.RequestToFormat(req, opts) +} + +// ExecutePluginExecutor executes a request with the named plugin executor without changing the requested model. +func (h *Host) ExecutePluginExecutor(ctx context.Context, pluginID string, req coreexecutor.Request, opts coreexecutor.Options) (coreexecutor.Response, error) { + adapter, errAdapter := h.executorAdapterForPlugin(pluginID) + if errAdapter != nil { + return coreexecutor.Response{}, errAdapter + } + return adapter.Execute(ctx, (*coreauth.Auth)(nil), req, opts) +} + +// ExecutePluginExecutorStream executes a streaming request with the named plugin executor without changing the requested model. +func (h *Host) ExecutePluginExecutorStream(ctx context.Context, pluginID string, req coreexecutor.Request, opts coreexecutor.Options) (*coreexecutor.StreamResult, error) { + adapter, errAdapter := h.executorAdapterForPlugin(pluginID) + if errAdapter != nil { + return nil, errAdapter + } + return adapter.ExecuteStream(ctx, (*coreauth.Auth)(nil), req, opts) +} + +// CountPluginExecutor executes a count-tokens request with the named plugin executor without changing the requested model. +func (h *Host) CountPluginExecutor(ctx context.Context, pluginID string, req coreexecutor.Request, opts coreexecutor.Options) (coreexecutor.Response, error) { + adapter, errAdapter := h.executorAdapterForPlugin(pluginID) + if errAdapter != nil { + return coreexecutor.Response{}, errAdapter + } + return adapter.CountTokens(ctx, (*coreauth.Auth)(nil), req, opts) +} + +func (h *Host) executorAdapterForPlugin(pluginID string) (*executorAdapter, error) { + if h == nil { + return nil, fmt.Errorf("plugin host is unavailable") + } + pluginID = strings.TrimSpace(pluginID) + if pluginID == "" { + return nil, fmt.Errorf("target executor plugin id is required") + } + for _, record := range h.Snapshot().records { + if record.id != pluginID { + continue + } + if h.isPluginFused(record.id) { + return nil, fmt.Errorf("plugin executor %s is unavailable", pluginID) + } + executor := record.plugin.Capabilities.Executor + if executor == nil { + return nil, fmt.Errorf("plugin %s does not declare an executor", pluginID) + } + provider, okProvider := h.executorProvider(record, executor) + if !okProvider { + return nil, fmt.Errorf("plugin executor %s has no provider identifier", pluginID) + } + registration := newExecutorAdapterRegistration(h, record, provider, executor) + return registration.adapter, nil + } + return nil, fmt.Errorf("plugin executor %s not found", pluginID) +} diff --git a/internal/pluginhost/host.go b/internal/pluginhost/host.go --- a/internal/pluginhost/host.go +++ b/internal/pluginhost/host.go @@ -198,6 +198,7 @@ if !okCall { continue } + plugin.Metadata = clonePluginMetadata(plugin.Metadata) records = append(records, capabilityRecord{ id: file.ID, priority: item.Priority, @@ -413,6 +414,7 @@ caps.AuthProvider != nil || caps.FrontendAuthProvider != nil || caps.Scheduler != nil || + caps.ModelRouter != nil || caps.Executor != nil || caps.RequestTranslator != nil || caps.RequestNormalizer != nil || diff --git a/internal/pluginhost/host_callbacks.go b/internal/pluginhost/host_callbacks.go --- a/internal/pluginhost/host_callbacks.go +++ b/internal/pluginhost/host_callbacks.go @@ -302,6 +302,7 @@ Query: cloneValues(req.Query), Alt: req.Alt, SkipInterceptorPluginID: skipPluginID, + SkipRouterPluginID: skipPluginID, } } diff --git a/internal/pluginhost/host_callbacks_test.go b/internal/pluginhost/host_callbacks_test.go --- a/internal/pluginhost/host_callbacks_test.go +++ b/internal/pluginhost/host_callbacks_test.go @@ -323,6 +323,9 @@ if got.SkipInterceptorPluginID != "origin-plugin" { t.Fatalf("SkipInterceptorPluginID = %q, want origin-plugin", got.SkipInterceptorPluginID) } + if got.SkipRouterPluginID != "origin-plugin" { + t.Fatalf("SkipRouterPluginID = %q, want origin-plugin", got.SkipRouterPluginID) + } } func TestHostModelStreamClosesWithCallbackScope(t *testing.T) { diff --git a/internal/pluginhost/model_router.go b/internal/pluginhost/model_router.go new file mode 100644 --- /dev/null +++ b/internal/pluginhost/model_router.go @@ -0,0 +1,155 @@ +package pluginhost + +import ( + "bytes" + "context" + "strings" + + "github.com/router-for-me/CLIProxyAPI/v7/sdk/pluginapi" + log "github.com/sirupsen/logrus" +) + +func (h *Host) RouteModel(ctx context.Context, req pluginapi.ModelRouteRequest) (pluginapi.ModelRouteResponse, bool) { + return h.RouteModelExcept(ctx, req, "") +} + +func (h *Host) HasModelRouters() bool { + return h.HasModelRoutersExcept("") +} + +func (h *Host) HasModelRoutersExcept(skipPluginID string) bool { + if h == nil { + return false + } + skipPluginID = strings.TrimSpace(skipPluginID) + for _, record := range h.Snapshot().records { + if record.plugin.Capabilities.ModelRouter != nil && !h.isPluginFused(record.id) && record.id != skipPluginID { + return true + } + } + return false +} + +func (h *Host) RouteModelExcept(ctx context.Context, req pluginapi.ModelRouteRequest, skipPluginID string) (pluginapi.ModelRouteResponse, bool) { + if h == nil { + return pluginapi.ModelRouteResponse{}, false + } + skipPluginID = strings.TrimSpace(skipPluginID) + req.AvailableProviders = h.availableProvidersSnapshot() + for _, record := range h.Snapshot().records { + router := record.plugin.Capabilities.ModelRouter + if router == nil || h.isPluginFused(record.id) || record.id == skipPluginID { + continue + } + nextReq := cloneModelRouteRequest(req) + nextReq.Plugin = clonePluginMetadata(record.meta) + nextReq.PluginID = record.id + resp, ok := h.callModelRouter(ctx, record.id, router, nextReq) + if !ok || !resp.Handled { + continue + } + resp, valid := normalizeModelRouteResponse(record.id, resp) + if !valid { + log.WithFields(log.Fields{"plugin_id": record.id, "target_kind": resp.TargetKind, "target": resp.Target}).Warn("pluginhost: model router returned invalid target") + continue + } + switch resp.TargetKind { + case pluginapi.ModelRouteTargetProvider: + if !h.HasBuiltinProvider(resp.Target) { + log.WithFields(log.Fields{"plugin_id": record.id, "target_provider": resp.Target}).Warn("pluginhost: model router returned unavailable provider") + continue + } + return resp, true + case pluginapi.ModelRouteTargetSelf, pluginapi.ModelRouteTargetExecutor: + if !h.executorPluginReady(resp.Target, nextReq) { + log.WithFields(log.Fields{"plugin_id": record.id, "target_plugin_id": resp.Target}).Warn("pluginhost: model router returned unavailable executor plugin") + continue + } + return resp, true + default: + log.WithFields(log.Fields{"plugin_id": record.id, "target_kind": resp.TargetKind}).Warn("pluginhost: model router returned unsupported target kind") + continue + } + } + return pluginapi.ModelRouteResponse{}, false +} + +func (h *Host) callModelRouter(ctx context.Context, pluginID string, router pluginapi.ModelRouter, req pluginapi.ModelRouteRequest) (out pluginapi.ModelRouteResponse, ok bool) { + if h == nil || router == nil || h.isPluginFused(pluginID) { + return pluginapi.ModelRouteResponse{}, false + } + defer func() { + if recovered := recover(); recovered != nil { + h.fusePlugin(pluginID, "ModelRouter.RouteModel", recovered) + out = pluginapi.ModelRouteResponse{} + ok = false + } + }() + resp, errRoute := router.RouteModel(ctx, req) + if errRoute != nil { + log.WithField("plugin_id", pluginID).WithError(errRoute).Warn("pluginhost: model router failed") + return pluginapi.ModelRouteResponse{}, false + } + return resp, true +} + +func normalizeModelRouteResponse(routerPluginID string, resp pluginapi.ModelRouteResponse) (pluginapi.ModelRouteResponse, bool) { + resp.TargetModel = strings.TrimSpace(resp.TargetModel) + switch resp.TargetKind { + case pluginapi.ModelRouteTargetSelf: + resp.Target = strings.TrimSpace(routerPluginID) + if resp.Target == "" { + return pluginapi.ModelRouteResponse{}, false + } + return resp, true + case pluginapi.ModelRouteTargetExecutor: + resp.Target = strings.TrimSpace(resp.Target) + if resp.Target == "" { + return pluginapi.ModelRouteResponse{}, false + } + return resp, true + case pluginapi.ModelRouteTargetProvider: + resp.Target = strings.ToLower(strings.TrimSpace(resp.Target)) + if resp.Target == "" { + return pluginapi.ModelRouteResponse{}, false + } + return resp, true + default: + return pluginapi.ModelRouteResponse{}, false + } +} + +func cloneModelRouteRequest(req pluginapi.ModelRouteRequest) pluginapi.ModelRouteRequest { + req.Headers = cloneHeader(req.Headers) + req.Query = cloneValues(req.Query) + req.Body = bytes.Clone(req.Body) + req.Metadata = cloneInterceptorMetadata(req.Metadata) + req.AvailableProviders = cloneStringSlice(req.AvailableProviders) + return req +} + +// HasBuiltinProvider reports whether a built-in provider currently has at least one +// registered auth record. +func (h *Host) HasBuiltinProvider(provider string) bool { + if h == nil || h.authManager == nil { + return false + } + return h.authManager.HasProviderAuth(provider) +} + +// BuiltinProviders returns built-in provider keys that currently have auth registered. +func (h *Host) BuiltinProviders() []string { + if h == nil || h.authManager == nil { + return nil + } + return h.authManager.AvailableProviders() +} + +// availableProvidersSnapshot returns a defensive copy of BuiltinProviders for routing input. +func (h *Host) availableProvidersSnapshot() []string { + providers := h.BuiltinProviders() + if len(providers) == 0 { + return nil + } + return cloneStringSlice(providers) +} diff --git a/internal/pluginhost/model_router_test.go b/internal/pluginhost/model_router_test.go new file mode 100644 --- /dev/null +++ b/internal/pluginhost/model_router_test.go @@ -0,0 +1,613 @@ +package pluginhost + +import ( + "context" + "errors" + "fmt" + "testing" + + coreauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth" + coreexecutor "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/executor" + "github.com/router-for-me/CLIProxyAPI/v7/sdk/pluginapi" +) + +func newRouteModelHostWithRecords(records ...capabilityRecord) *Host { + for i := range records { + caps := &records[i].plugin.Capabilities + if caps.Executor == nil { + continue + } + if len(caps.ExecutorInputFormats) == 0 { + caps.ExecutorInputFormats = []string{"openai"} + } + if len(caps.ExecutorOutputFormats) == 0 { + caps.ExecutorOutputFormats = []string{"openai"} + } + } + return newHostWithRecords(records...) +} + +func TestHostRouteModelUsesHighestPriorityFirstMatch(t *testing.T) { + var lowCalled bool + host := newRouteModelHostWithRecords( + capabilityRecord{ + id: "low", + priority: 1, + plugin: pluginapi.Plugin{Capabilities: pluginapi.Capabilities{ + Executor: &fakeExecutor{identifier: "fake-provider"}, + ModelRouter: modelRouterFunc(func(ctx context.Context, req pluginapi.ModelRouteRequest) (pluginapi.ModelRouteResponse, error) { + lowCalled = true + return pluginapi.ModelRouteResponse{Handled: true, TargetKind: pluginapi.ModelRouteTargetSelf}, nil + }), + }}, + }, + capabilityRecord{ + id: "high", + priority: 10, + meta: pluginapi.Metadata{Name: "High Router"}, + plugin: pluginapi.Plugin{Capabilities: pluginapi.Capabilities{ + Executor: &fakeExecutor{identifier: "fake-provider"}, + ModelRouter: modelRouterFunc(func(ctx context.Context, req pluginapi.ModelRouteRequest) (pluginapi.ModelRouteResponse, error) { + if req.Plugin.Name != "High Router" { + t.Fatalf("Plugin metadata = %#v, want High Router", req.Plugin) + } + return pluginapi.ModelRouteResponse{Handled: true, TargetKind: pluginapi.ModelRouteTargetSelf, Reason: "match"}, nil + }), + }}, + }, + ) + + resp, ok := host.RouteModel(context.Background(), pluginapi.ModelRouteRequest{RequestedModel: "original-model"}) + if !ok || !resp.Handled || resp.Target != "high" || resp.Reason != "match" { + t.Fatalf("RouteModel() = %#v, %v; want high executor handled", resp, ok) + } + if lowCalled { + t.Fatal("low priority router was called after high priority match") + } +} + +func TestHostRouteModelContinuesAfterUnhandled(t *testing.T) { + var lowCalled bool + host := newRouteModelHostWithRecords( + capabilityRecord{ + id: "low", + priority: 1, + plugin: pluginapi.Plugin{Capabilities: pluginapi.Capabilities{ + Executor: &fakeExecutor{identifier: "fake-provider"}, + ModelRouter: modelRouterFunc(func(ctx context.Context, req pluginapi.ModelRouteRequest) (pluginapi.ModelRouteResponse, error) { + lowCalled = true + return pluginapi.ModelRouteResponse{Handled: true, TargetKind: pluginapi.ModelRouteTargetSelf}, nil + }), + }}, + }, + capabilityRecord{ + id: "high", + priority: 10, + plugin: pluginapi.Plugin{Capabilities: pluginapi.Capabilities{ + Executor: &fakeExecutor{identifier: "fake-provider"}, + ModelRouter: modelRouterFunc(func(ctx context.Context, req pluginapi.ModelRouteRequest) (pluginapi.ModelRouteResponse, error) { + return pluginapi.ModelRouteResponse{Handled: false}, nil + }), + }}, + }, + ) + + resp, ok := host.RouteModel(context.Background(), pluginapi.ModelRouteRequest{RequestedModel: "original-model"}) + if !lowCalled { + t.Fatal("low priority router was not called after unhandled high priority router") + } + if !ok || resp.Target != "low" { + t.Fatalf("RouteModel() = %#v, %v; want low executor handled", resp, ok) + } +} + +func TestHostRouteModelAllowsExplicitExecutorPluginTarget(t *testing.T) { + host := newRouteModelHostWithRecords( + capabilityRecord{ + id: "executor", + plugin: pluginapi.Plugin{Capabilities: pluginapi.Capabilities{ + Executor: &fakeExecutor{identifier: "fake-provider"}, + }}, + }, + capabilityRecord{ + id: "router", + priority: 10, + plugin: pluginapi.Plugin{Capabilities: pluginapi.Capabilities{ + ModelRouter: modelRouterFunc(func(ctx context.Context, req pluginapi.ModelRouteRequest) (pluginapi.ModelRouteResponse, error) { + if req.PluginID != "router" { + t.Fatalf("PluginID = %q, want router", req.PluginID) + } + return pluginapi.ModelRouteResponse{Handled: true, TargetKind: pluginapi.ModelRouteTargetExecutor, Target: "executor"}, nil + }), + }}, + }, + ) + + resp, ok := host.RouteModel(context.Background(), pluginapi.ModelRouteRequest{RequestedModel: "original-model"}) + if !ok || !resp.Handled || resp.Target != "executor" { + t.Fatalf("RouteModel() = %#v, %v; want executor target handled", resp, ok) + } +} + +func TestHostExecutePluginExecutorByPluginIDPreservesModel(t *testing.T) { + var gotReq pluginapi.ExecutorRequest + executor := &fakeExecutor{ + identifier: "plugin-provider", + execute: func(ctx context.Context, req pluginapi.ExecutorRequest) (pluginapi.ExecutorResponse, error) { + gotReq = req + return pluginapi.ExecutorResponse{Payload: []byte("plugin-ok")}, nil + }, + } + host := newRouteModelHostWithRecords(capabilityRecord{ + id: "executor", + plugin: pluginapi.Plugin{Capabilities: pluginapi.Capabilities{ + Executor: executor, + ExecutorInputFormats: []string{"openai"}, + ExecutorOutputFormats: []string{"openai"}, + }}, + }) + + resp, errExecute := host.ExecutePluginExecutor(context.Background(), "executor", coreexecutor.Request{Model: "client-model", Payload: []byte(`{"model":"client-model"}`)}, coreexecutor.Options{OriginalRequest: []byte(`{"model":"client-model"}`)}) + if errExecute != nil { + t.Fatalf("ExecutePluginExecutor() error = %v", errExecute) + } + if string(resp.Payload) != "plugin-ok" { + t.Fatalf("payload = %q, want plugin-ok", resp.Payload) + } + if gotReq.AuthID != "" || gotReq.AuthProvider != "" { + t.Fatalf("auth fields = %q/%q, want empty static executor auth", gotReq.AuthID, gotReq.AuthProvider) + } + if gotReq.Model != "client-model" { + t.Fatalf("executor request model = %q, want client-model", gotReq.Model) + } +} + +func TestHostRouteModelDefaultsHandledRouterToOwnExecutor(t *testing.T) { + host := newRouteModelHostWithRecords(capabilityRecord{ + id: "router", + plugin: pluginapi.Plugin{Capabilities: pluginapi.Capabilities{ + Executor: &fakeExecutor{identifier: "fake-provider"}, + ModelRouter: modelRouterFunc(func(ctx context.Context, req pluginapi.ModelRouteRequest) (pluginapi.ModelRouteResponse, error) { + return pluginapi.ModelRouteResponse{Handled: true, TargetKind: pluginapi.ModelRouteTargetSelf}, nil + }), + }}, + }) + + resp, ok := host.RouteModel(context.Background(), pluginapi.ModelRouteRequest{RequestedModel: "original-model"}) + if !ok || resp.Target != "router" { + t.Fatalf("RouteModel() = %#v, %v; want router executor handled", resp, ok) + } +} + +func TestHostRouteModelSkipsUnavailableExecutorTargets(t *testing.T) { + calls := 0 + host := newRouteModelHostWithRecords( + capabilityRecord{ + id: "fallback", + priority: 1, + plugin: pluginapi.Plugin{Capabilities: pluginapi.Capabilities{ + Executor: &fakeExecutor{identifier: "fake-provider"}, + ModelRouter: modelRouterFunc(func(ctx context.Context, req pluginapi.ModelRouteRequest) (pluginapi.ModelRouteResponse, error) { + calls++ + return pluginapi.ModelRouteResponse{Handled: true, TargetKind: pluginapi.ModelRouteTargetSelf}, nil + }), + }}, + }, + capabilityRecord{ + id: "missing-target", + priority: 20, + plugin: pluginapi.Plugin{Capabilities: pluginapi.Capabilities{ + Executor: &fakeExecutor{identifier: "fake-provider"}, + ModelRouter: modelRouterFunc(func(ctx context.Context, req pluginapi.ModelRouteRequest) (pluginapi.ModelRouteResponse, error) { + calls++ + return pluginapi.ModelRouteResponse{Handled: true, TargetKind: pluginapi.ModelRouteTargetExecutor, Target: "missing"}, nil + }), + }}, + }, + capabilityRecord{ + id: "no-executor", + priority: 10, + plugin: pluginapi.Plugin{Capabilities: pluginapi.Capabilities{ + ModelRouter: modelRouterFunc(func(ctx context.Context, req pluginapi.ModelRouteRequest) (pluginapi.ModelRouteResponse, error) { + calls++ + return pluginapi.ModelRouteResponse{Handled: true, TargetKind: pluginapi.ModelRouteTargetSelf}, nil + }), + }}, + }, + ) + + resp, ok := host.RouteModel(context.Background(), pluginapi.ModelRouteRequest{RequestedModel: "original-model"}) + if calls != 3 { + t.Fatalf("router calls = %d, want all routers tried", calls) + } + if !ok || resp.Target != "fallback" { + t.Fatalf("RouteModel() = %#v, %v; want fallback executor handled", resp, ok) + } +} + +func TestHostRouteModelErrorAndPanicDoNotBreakFallback(t *testing.T) { + host := newRouteModelHostWithRecords( + capabilityRecord{ + id: "fallback", + priority: 1, + plugin: pluginapi.Plugin{Capabilities: pluginapi.Capabilities{ + Executor: &fakeExecutor{identifier: "fake-provider"}, + ModelRouter: modelRouterFunc(func(ctx context.Context, req pluginapi.ModelRouteRequest) (pluginapi.ModelRouteResponse, error) { + return pluginapi.ModelRouteResponse{Handled: true, TargetKind: pluginapi.ModelRouteTargetSelf}, nil + }), + }}, + }, + capabilityRecord{ + id: "panic", + priority: 20, + plugin: pluginapi.Plugin{Capabilities: pluginapi.Capabilities{ + Executor: &fakeExecutor{identifier: "fake-provider"}, + ModelRouter: modelRouterFunc(func(ctx context.Context, req pluginapi.ModelRouteRequest) (pluginapi.ModelRouteResponse, error) { + panic("router panic") + }), + }}, + }, + capabilityRecord{ + id: "error", + priority: 10, + plugin: pluginapi.Plugin{Capabilities: pluginapi.Capabilities{ + Executor: &fakeExecutor{identifier: "fake-provider"}, + ModelRouter: modelRouterFunc(func(ctx context.Context, req pluginapi.ModelRouteRequest) (pluginapi.ModelRouteResponse, error) { + return pluginapi.ModelRouteResponse{}, errors.New("temporary route failure") + }), + }}, + }, + ) + + resp, ok := host.RouteModel(context.Background(), pluginapi.ModelRouteRequest{RequestedModel: "original-model"}) + if !ok || resp.Target != "fallback" { + t.Fatalf("RouteModel() = %#v, %v; want fallback executor handled", resp, ok) + } + if !host.isPluginFused("panic") { + t.Fatal("panic router was not fused") + } +} + +func TestHostHasModelRoutersReportsAvailableRouters(t *testing.T) { + host := newRouteModelHostWithRecords( + capabilityRecord{ + id: "router", + plugin: pluginapi.Plugin{Capabilities: pluginapi.Capabilities{ + ModelRouter: modelRouterFunc(func(ctx context.Context, req pluginapi.ModelRouteRequest) (pluginapi.ModelRouteResponse, error) { + return pluginapi.ModelRouteResponse{}, nil + }), + }}, + }, + capabilityRecord{id: "other"}, + ) + + if !host.HasModelRouters() { + t.Fatal("HasModelRouters() = false, want true") + } + if host.HasModelRoutersExcept("router") { + t.Fatal("HasModelRoutersExcept(router) = true, want false") + } +} + +func TestHostRouteModelClonesPluginMetadata(t *testing.T) { + host := newRouteModelHostWithRecords(capabilityRecord{ + id: "router", + meta: pluginapi.Metadata{ + Name: "Router", + ConfigFields: []pluginapi.ConfigField{{ + Name: "mode", + EnumValues: []string{"safe", "fast"}, + }}, + }, + plugin: pluginapi.Plugin{Capabilities: pluginapi.Capabilities{ + Executor: &fakeExecutor{identifier: "fake-provider"}, + ModelRouter: modelRouterFunc(func(ctx context.Context, req pluginapi.ModelRouteRequest) (pluginapi.ModelRouteResponse, error) { + req.Plugin.ConfigFields[0].Name = "mutated" + req.Plugin.ConfigFields[0].EnumValues[0] = "mutated" + return pluginapi.ModelRouteResponse{Handled: true, TargetKind: pluginapi.ModelRouteTargetSelf}, nil + }), + }}, + }) + + resp, ok := host.RouteModel(context.Background(), pluginapi.ModelRouteRequest{RequestedModel: "original"}) + if !ok || resp.Target != "router" { + t.Fatalf("RouteModel() = %#v, %v; want router executor handled", resp, ok) + } + meta := host.Snapshot().records[0].meta + if meta.ConfigFields[0].Name != "mode" || meta.ConfigFields[0].EnumValues[0] != "safe" { + t.Fatalf("snapshot metadata was mutated: %#v", meta.ConfigFields[0]) + } +} + +func TestHostRouteModelSkipsOriginatingPlugin(t *testing.T) { + var originCalled bool + host := newRouteModelHostWithRecords( + capabilityRecord{ + id: "origin", + priority: 10, + plugin: pluginapi.Plugin{Capabilities: pluginapi.Capabilities{ + Executor: &fakeExecutor{identifier: "fake-provider"}, + ModelRouter: modelRouterFunc(func(ctx context.Context, req pluginapi.ModelRouteRequest) (pluginapi.ModelRouteResponse, error) { + originCalled = true + return pluginapi.ModelRouteResponse{Handled: true, TargetKind: pluginapi.ModelRouteTargetSelf}, nil + }), + }}, + }, + capabilityRecord{ + id: "other", + priority: 1, + plugin: pluginapi.Plugin{Capabilities: pluginapi.Capabilities{ + Executor: &fakeExecutor{identifier: "fake-provider"}, + ModelRouter: modelRouterFunc(func(ctx context.Context, req pluginapi.ModelRouteRequest) (pluginapi.ModelRouteResponse, error) { + return pluginapi.ModelRouteResponse{Handled: true, TargetKind: pluginapi.ModelRouteTargetSelf}, nil + }), + }}, + }, + ) + + resp, ok := host.RouteModelExcept(context.Background(), pluginapi.ModelRouteRequest{RequestedModel: "original-model"}, "origin") + if originCalled { + t.Fatal("origin router was called despite skip") + } + if !ok || resp.Target != "other" { + t.Fatalf("RouteModelExcept() = %#v, %v; want other executor handled", resp, ok) + } +} + +// newHostWithAuthProviders builds a host whose AuthManager registers auths for the given +// provider keys, so built-in provider routing can be exercised. +func newHostWithAuthProviders(t *testing.T, providers []string, records ...capabilityRecord) *Host { + t.Helper() + host := newRouteModelHostWithRecords(records...) + manager := coreauth.NewManager(nil, nil, nil) + for i, provider := range providers { + auth := &coreauth.Auth{ID: fmt.Sprintf("auth-%s-%d", provider, i), Provider: provider} + if _, errRegister := manager.Register(context.Background(), auth); errRegister != nil { + t.Fatalf("Register(%s) error = %v", provider, errRegister) + } + } + host.authManager = manager + return host +} + +func TestHostRouteModelRoutesToBuiltinProvider(t *testing.T) { + host := newHostWithAuthProviders(t, []string{"claude"}, capabilityRecord{ + id: "router", + plugin: pluginapi.Plugin{Capabilities: pluginapi.Capabilities{ + ModelRouter: modelRouterFunc(func(ctx context.Context, req pluginapi.ModelRouteRequest) (pluginapi.ModelRouteResponse, error) { + return pluginapi.ModelRouteResponse{Handled: true, TargetKind: pluginapi.ModelRouteTargetProvider, Target: "claude", TargetModel: "claude-sonnet-4"}, nil + }), + }}, + }) + + resp, ok := host.RouteModel(context.Background(), pluginapi.ModelRouteRequest{RequestedModel: "original-model"}) + if !ok || !resp.Handled || resp.Target != "claude" { + t.Fatalf("RouteModel() = %#v, %v; want claude provider handled", resp, ok) + } + if resp.TargetKind != pluginapi.ModelRouteTargetProvider { + t.Fatalf("TargetKind = %q, want provider", resp.TargetKind) + } + if resp.TargetModel != "claude-sonnet-4" { + t.Fatalf("TargetModel = %q, want claude-sonnet-4", resp.TargetModel) + } +} + +func TestHostRouteModelSkipsUnavailableBuiltinProvider(t *testing.T) { + var fallbackCalled bool + host := newHostWithAuthProviders(t, []string{"claude"}, + capabilityRecord{ + id: "fallback", + priority: 1, + plugin: pluginapi.Plugin{Capabilities: pluginapi.Capabilities{ + Executor: &fakeExecutor{identifier: "fake-provider"}, + ModelRouter: modelRouterFunc(func(ctx context.Context, req pluginapi.ModelRouteRequest) (pluginapi.ModelRouteResponse, error) { + fallbackCalled = true + return pluginapi.ModelRouteResponse{Handled: true, TargetKind: pluginapi.ModelRouteTargetSelf}, nil + }), + }}, + }, + capabilityRecord{ + id: "missing-provider", + priority: 10, + plugin: pluginapi.Plugin{Capabilities: pluginapi.Capabilities{ + ModelRouter: modelRouterFunc(func(ctx context.Context, req pluginapi.ModelRouteRequest) (pluginapi.ModelRouteResponse, error) { + return pluginapi.ModelRouteResponse{Handled: true, TargetKind: pluginapi.ModelRouteTargetProvider, Target: "unknown-provider"}, nil + }), + }}, + }, + ) + + resp, ok := host.RouteModel(context.Background(), pluginapi.ModelRouteRequest{RequestedModel: "original-model"}) + if !fallbackCalled { + t.Fatal("fallback router was not called after unavailable provider target") + } + if !ok || resp.Target != "fallback" { + t.Fatalf("RouteModel() = %#v, %v; want fallback executor handled", resp, ok) + } +} + +func TestHostRouteModelRejectsProviderAndExecutorBothSet(t *testing.T) { + var fallbackCalled bool + host := newHostWithAuthProviders(t, []string{"claude"}, + capabilityRecord{ + id: "fallback", + priority: 1, + plugin: pluginapi.Plugin{Capabilities: pluginapi.Capabilities{ + Executor: &fakeExecutor{identifier: "fake-provider"}, + ModelRouter: modelRouterFunc(func(ctx context.Context, req pluginapi.ModelRouteRequest) (pluginapi.ModelRouteResponse, error) { + fallbackCalled = true + return pluginapi.ModelRouteResponse{Handled: true, TargetKind: pluginapi.ModelRouteTargetSelf}, nil + }), + }}, + }, + capabilityRecord{ + id: "both", + priority: 10, + plugin: pluginapi.Plugin{Capabilities: pluginapi.Capabilities{ + Executor: &fakeExecutor{identifier: "fake-provider"}, + ModelRouter: modelRouterFunc(func(ctx context.Context, req pluginapi.ModelRouteRequest) (pluginapi.ModelRouteResponse, error) { + return pluginapi.ModelRouteResponse{Handled: true, TargetKind: pluginapi.ModelRouteTargetKind("both"), Target: "claude"}, nil + }), + }}, + }, + ) + + resp, ok := host.RouteModel(context.Background(), pluginapi.ModelRouteRequest{RequestedModel: "original-model"}) + if !fallbackCalled { + t.Fatal("fallback router was not called after mutually exclusive targets") + } + if !ok || resp.Target != "fallback" { + t.Fatalf("RouteModel() = %#v, %v; want fallback executor handled", resp, ok) + } +} + +func TestHostRouteModelPropagatesAvailableProviders(t *testing.T) { + var gotProviders []string + host := newHostWithAuthProviders(t, []string{"claude", "gemini"}, capabilityRecord{ + id: "router", + plugin: pluginapi.Plugin{Capabilities: pluginapi.Capabilities{ + Executor: &fakeExecutor{identifier: "fake-provider"}, + ModelRouter: modelRouterFunc(func(ctx context.Context, req pluginapi.ModelRouteRequest) (pluginapi.ModelRouteResponse, error) { + gotProviders = append([]string(nil), req.AvailableProviders...) + return pluginapi.ModelRouteResponse{Handled: true, TargetKind: pluginapi.ModelRouteTargetSelf}, nil + }), + }}, + }) + + if _, ok := host.RouteModel(context.Background(), pluginapi.ModelRouteRequest{RequestedModel: "original"}); !ok { + t.Fatal("RouteModel() not handled") + } + want := []string{"claude", "gemini"} + if fmt.Sprint(gotProviders) != fmt.Sprint(want) { + t.Fatalf("AvailableProviders = %v, want %v", gotProviders, want) + } +} + +func TestHostBuiltinProviderLookup(t *testing.T) { + host := newHostWithAuthProviders(t, []string{"Claude", "codex"}) + if !host.HasBuiltinProvider("claude") { + t.Fatal("HasBuiltinProvider(claude) = false, want true") + } + if host.HasBuiltinProvider("missing") { + t.Fatal("HasBuiltinProvider(missing) = true, want false") + } + providers := host.BuiltinProviders() + if fmt.Sprint(providers) != fmt.Sprint([]string{"claude", "codex"}) { + t.Fatalf("BuiltinProviders() = %v, want [claude codex]", providers) + } +} + +func TestHostRouteModelSkipsExecutorWithoutProviderIdentifier(t *testing.T) { + var fallbackCalled bool + host := newRouteModelHostWithRecords( + capabilityRecord{ + id: "fallback", + priority: 1, + plugin: pluginapi.Plugin{Capabilities: pluginapi.Capabilities{ + Executor: &fakeExecutor{identifier: "fallback-provider"}, + ModelRouter: modelRouterFunc(func(ctx context.Context, req pluginapi.ModelRouteRequest) (pluginapi.ModelRouteResponse, error) { + fallbackCalled = true + return pluginapi.ModelRouteResponse{Handled: true, TargetKind: pluginapi.ModelRouteTargetSelf}, nil + }), + }}, + }, + capabilityRecord{ + id: "no-provider", + priority: 10, + plugin: pluginapi.Plugin{Capabilities: pluginapi.Capabilities{ + // Executor is declared but resolves no provider identifier, so execution + // would fail. Routing must skip it and fall through to the lower-priority router. + Executor: &fakeExecutor{identifierFunc: func() string { return "" }}, + ModelRouter: modelRouterFunc(func(ctx context.Context, req pluginapi.ModelRouteRequest) (pluginapi.ModelRouteResponse, error) { + return pluginapi.ModelRouteResponse{Handled: true, TargetKind: pluginapi.ModelRouteTargetSelf}, nil + }), + }}, + }, + ) + + resp, ok := host.RouteModel(context.Background(), pluginapi.ModelRouteRequest{RequestedModel: "original-model"}) + if !fallbackCalled { + t.Fatal("fallback router was not called after executor without provider identifier was skipped") + } + if !ok || resp.Target != "fallback" { + t.Fatalf("RouteModel() = %#v, %v; want fallback executor handled", resp, ok) + } +} + +func TestHostRouteModelSkipsExecutorWithUnsupportedFormats(t *testing.T) { + var fallbackCalled bool + host := newHostWithRecords( + capabilityRecord{ + id: "fallback", + priority: 1, + plugin: pluginapi.Plugin{Capabilities: pluginapi.Capabilities{ + Executor: &fakeExecutor{identifier: "fallback-provider"}, + ExecutorInputFormats: []string{"openai"}, + ExecutorOutputFormats: []string{"openai"}, + ModelRouter: modelRouterFunc(func(ctx context.Context, req pluginapi.ModelRouteRequest) (pluginapi.ModelRouteResponse, error) { + fallbackCalled = true + return pluginapi.ModelRouteResponse{Handled: true, TargetKind: pluginapi.ModelRouteTargetSelf}, nil + }), + }}, + }, + capabilityRecord{ + id: "unsupported-formats", + priority: 10, + plugin: pluginapi.Plugin{Capabilities: pluginapi.Capabilities{ + Executor: &fakeExecutor{identifier: "unsupported-provider"}, + ModelRouter: modelRouterFunc(func(ctx context.Context, req pluginapi.ModelRouteRequest) (pluginapi.ModelRouteResponse, error) { + return pluginapi.ModelRouteResponse{Handled: true, TargetKind: pluginapi.ModelRouteTargetSelf}, nil + }), + }}, + }, + ) + + resp, ok := host.RouteModel(context.Background(), pluginapi.ModelRouteRequest{RequestedModel: "original-model", SourceFormat: "openai"}) + if !fallbackCalled { + t.Fatal("fallback router was not called after executor with unsupported formats was skipped") + } + if !ok || resp.Target != "fallback" { + t.Fatalf("RouteModel() = %#v, %v; want fallback executor handled", resp, ok) + } +} + +func TestHostRouteModelSkipsOAuthOnlyExecutorTargets(t *testing.T) { + var fallbackCalled bool + host := newHostWithRecords( + capabilityRecord{ + id: "fallback", + priority: 1, + plugin: pluginapi.Plugin{Capabilities: pluginapi.Capabilities{ + Executor: &fakeExecutor{identifier: "fallback-provider"}, + ExecutorModelScope: pluginapi.ExecutorModelScopeStatic, + ExecutorInputFormats: []string{"openai"}, + ExecutorOutputFormats: []string{"openai"}, + ModelRouter: modelRouterFunc(func(ctx context.Context, req pluginapi.ModelRouteRequest) (pluginapi.ModelRouteResponse, error) { + fallbackCalled = true + return pluginapi.ModelRouteResponse{Handled: true, TargetKind: pluginapi.ModelRouteTargetSelf}, nil + }), + }}, + }, + capabilityRecord{ + id: "oauth-only", + priority: 10, + plugin: pluginapi.Plugin{Capabilities: pluginapi.Capabilities{ + Executor: &fakeExecutor{identifier: "oauth-provider"}, + ExecutorModelScope: pluginapi.ExecutorModelScopeOAuth, + ExecutorInputFormats: []string{"openai"}, + ExecutorOutputFormats: []string{"openai"}, + ModelRouter: modelRouterFunc(func(ctx context.Context, req pluginapi.ModelRouteRequest) (pluginapi.ModelRouteResponse, error) { + return pluginapi.ModelRouteResponse{Handled: true, TargetKind: pluginapi.ModelRouteTargetSelf}, nil + }), + }}, + }, + ) + + resp, ok := host.RouteModel(context.Background(), pluginapi.ModelRouteRequest{RequestedModel: "original-model", SourceFormat: "openai"}) + if !fallbackCalled { + t.Fatal("fallback router was not called after OAuth-only executor target was skipped") + } + if !ok || resp.Target != "fallback" { + t.Fatalf("RouteModel() = %#v, %v; want fallback executor handled", resp, ok) + } +} diff --git a/internal/pluginhost/rpc_client.go b/internal/pluginhost/rpc_client.go --- a/internal/pluginhost/rpc_client.go +++ b/internal/pluginhost/rpc_client.go @@ -44,9 +44,15 @@ if client == nil { return pluginapi.Plugin{}, fmt.Errorf("plugin client is nil") } - resp, errCall := callPlugin[rpcRegistration](ctx, client, method, rpcLifecycleRequest{ConfigYAML: bytes.Clone(configYAML)}) + resp, errCall := callPlugin[rpcRegistration](ctx, client, method, rpcLifecycleRequest{ + ConfigYAML: bytes.Clone(configYAML), + SchemaVersion: pluginabi.SchemaVersion, + }) if errCall != nil { return pluginapi.Plugin{}, errCall + } + if resp.SchemaVersion > pluginabi.SchemaVersion { + return pluginapi.Plugin{}, fmt.Errorf("plugin schema version %d is not supported", resp.SchemaVersion) } adapter := &rpcPluginAdapter{id: id, host: host, client: client} plugin := pluginapi.Plugin{ @@ -72,6 +78,9 @@ } if resp.Capabilities.Scheduler { plugin.Capabilities.Scheduler = adapter + } + if resp.Capabilities.ModelRouter { + plugin.Capabilities.ModelRouter = adapter } if resp.Capabilities.Executor { plugin.Capabilities.Executor = rpcProviderExecutor{rpcPluginAdapter: adapter} @@ -156,6 +165,9 @@ req.Candidates[index].Metadata = sanitizePluginMetadata(req.Candidates[index].Metadata) } return req + case pluginapi.ModelRouteRequest: + req.Metadata = sanitizePluginMetadata(req.Metadata) + return req case pluginapi.ExecutorRequest: req.HTTPClient = nil req.Metadata = sanitizePluginMetadata(req.Metadata) @@ -170,6 +182,9 @@ req.Metadata = sanitizePluginMetadata(req.Metadata) return req case rpcRequestInterceptRequest: + req.Metadata = sanitizePluginMetadata(req.Metadata) + return req + case rpcModelRouteRequest: req.Metadata = sanitizePluginMetadata(req.Metadata) return req case rpcResponseInterceptRequest: @@ -307,6 +322,15 @@ func (a *rpcPluginAdapter) Pick(ctx context.Context, req pluginapi.SchedulerPickRequest) (pluginapi.SchedulerPickResponse, error) { return callPlugin[pluginapi.SchedulerPickResponse](ctx, a.client, pluginabi.MethodSchedulerPick, req) +} + +func (a *rpcPluginAdapter) RouteModel(ctx context.Context, req pluginapi.ModelRouteRequest) (pluginapi.ModelRouteResponse, error) { + callbackID, closeCallback := a.openHostCallbackContext(ctx) + defer closeCallback() + return callPlugin[pluginapi.ModelRouteResponse](ctx, a.client, pluginabi.MethodModelRoute, rpcModelRouteRequest{ + ModelRouteRequest: req, + HostCallbackID: callbackID, + }) } func callPluginIdentifier(client pluginClient, method string) string { diff --git a/internal/pluginhost/rpc_schema.go b/internal/pluginhost/rpc_schema.go --- a/internal/pluginhost/rpc_schema.go +++ b/internal/pluginhost/rpc_schema.go @@ -8,7 +8,8 @@ ) type rpcLifecycleRequest struct { - ConfigYAML []byte `json:"config_yaml"` + ConfigYAML []byte `json:"config_yaml"` + SchemaVersion uint32 `json:"schema_version"` } type rpcRegistration struct { @@ -24,6 +25,7 @@ FrontendAuthProvider bool `json:"frontend_auth_provider"` FrontendAuthProviderExclusive bool `json:"frontend_auth_provider_exclusive"` Scheduler bool `json:"scheduler"` + ModelRouter bool `json:"model_router"` Executor bool `json:"executor"` ExecutorModelScope pluginapi.ExecutorModelScope `json:"executor_model_scope"` ExecutorInputFormats []string `json:"executor_input_formats,omitempty"` @@ -87,6 +89,11 @@ HostCallbackID string `json:"host_callback_id,omitempty"` } +type rpcModelRouteRequest struct { + pluginapi.ModelRouteRequest + HostCallbackID string `json:"host_callback_id,omitempty"` +} + type rpcResponseInterceptRequest struct { pluginapi.ResponseInterceptRequest HostCallbackID string `json:"host_callback_id,omitempty"` @@ -123,6 +130,7 @@ FrontendAuthProvider: caps.FrontendAuthProvider != nil, FrontendAuthProviderExclusive: caps.FrontendAuthProvider != nil && caps.FrontendAuthProviderExclusive, Scheduler: caps.Scheduler != nil, + ModelRouter: caps.ModelRouter != nil, Executor: caps.Executor != nil, ExecutorModelScope: normalizedExecutorModelScope(caps), ExecutorInputFormats: append([]string(nil), caps.ExecutorInputFormats...), diff --git a/internal/pluginhost/rpc_schema_test.go b/internal/pluginhost/rpc_schema_test.go --- a/internal/pluginhost/rpc_schema_test.go +++ b/internal/pluginhost/rpc_schema_test.go @@ -4,6 +4,7 @@ "context" "encoding/json" "reflect" + "strings" "testing" "github.com/router-for-me/CLIProxyAPI/v7/sdk/pluginabi" @@ -69,6 +70,151 @@ } if decoded["scheduler"] != true { t.Fatalf("scheduler = %#v, want true", decoded["scheduler"]) + } +} + +func TestRPCCapabilitiesIncludeModelRouter(t *testing.T) { + plugin := pluginapi.Plugin{ + Capabilities: pluginapi.Capabilities{ + ModelRouter: modelRouterFunc(func(context.Context, pluginapi.ModelRouteRequest) (pluginapi.ModelRouteResponse, error) { + return pluginapi.ModelRouteResponse{}, nil + }), + }, + } + + caps := rpcCapabilitiesFromPlugin(plugin) + if !caps.ModelRouter { + t.Fatal("ModelRouter = false, want true") + } + + raw, errMarshal := json.Marshal(caps) + if errMarshal != nil { + t.Fatalf("Marshal() error = %v", errMarshal) + } + if !json.Valid(raw) { + t.Fatalf("marshaled capabilities are invalid JSON: %s", raw) + } + var decoded map[string]any + if errUnmarshal := json.Unmarshal(raw, &decoded); errUnmarshal != nil { + t.Fatalf("Unmarshal() error = %v", errUnmarshal) + } + if decoded["model_router"] != true { + t.Fatalf("model_router = %#v, want true", decoded["model_router"]) + } +} + +func TestRegisterRPCPluginSendsHostSchemaVersion(t *testing.T) { + lookup := newTestSymbolLookup(&testPlugin{ + registerResult: validTestPlugin("schema"), + }) + + if _, errRegister := registerRPCPlugin(context.Background(), nil, "schema", lookup, pluginabi.MethodPluginRegister, []byte("mode: test")); errRegister != nil { + t.Fatalf("registerRPCPlugin() error = %v", errRegister) + } + if lookup.lastLifecycle.SchemaVersion != pluginabi.SchemaVersion { + t.Fatalf("lifecycle schema_version = %d, want %d", lookup.lastLifecycle.SchemaVersion, pluginabi.SchemaVersion) + } + if string(lookup.lastLifecycle.ConfigYAML) != "mode: test" { + t.Fatalf("lifecycle config = %q, want input config", lookup.lastLifecycle.ConfigYAML) + } +} + +func TestRegisterRPCPluginRejectsFutureSchemaVersion(t *testing.T) { + lookup := newTestSymbolLookup(&testPlugin{ + registerResult: validTestPlugin("future-schema"), + }) + lookup.schemaVersion = pluginabi.SchemaVersion + 1 + + _, errRegister := registerRPCPlugin(context.Background(), nil, "future-schema", lookup, pluginabi.MethodPluginRegister, nil) + if errRegister == nil || !strings.Contains(errRegister.Error(), "schema version") { + t.Fatalf("registerRPCPlugin() error = %v, want unsupported schema version", errRegister) + } +} + +func TestRegisterRPCPluginAcceptsModelRouterOnSchema1(t *testing.T) { + plugin := validTestPlugin("router-schema1") + plugin.Capabilities.ModelRouter = modelRouterFunc(func(context.Context, pluginapi.ModelRouteRequest) (pluginapi.ModelRouteResponse, error) { + return pluginapi.ModelRouteResponse{}, nil + }) + lookup := newTestSymbolLookup(&testPlugin{registerResult: plugin}) + lookup.schemaVersion = 1 + + registered, errRegister := registerRPCPlugin(context.Background(), nil, "router-schema1", lookup, pluginabi.MethodPluginRegister, nil) + if errRegister != nil { + t.Fatalf("registerRPCPlugin() error = %v, want model_router on schema 1", errRegister) + } + if registered.Capabilities.ModelRouter == nil { + t.Fatal("ModelRouter = nil, want adapter") + } +} + +func TestRPCModelRouteUsesAdapter(t *testing.T) { + var routeCalls int + var gotReq pluginapi.ModelRouteRequest + lookup := newTestSymbolLookup(&testPlugin{ + registerResult: pluginapi.Plugin{ + Metadata: pluginapi.Metadata{ + Name: "router", + Version: "1.0.0", + Author: "test", + GitHubRepository: "https://github.com/router-for-me/CLIProxyAPI", + }, + Capabilities: pluginapi.Capabilities{ + ModelRouter: modelRouterFunc(func(ctx context.Context, req pluginapi.ModelRouteRequest) (pluginapi.ModelRouteResponse, error) { + routeCalls++ + gotReq = req + return pluginapi.ModelRouteResponse{ + Handled: true, + TargetKind: pluginapi.ModelRouteTargetExecutor, + Target: "claude-websearch-plugin", + Reason: "typed websearch", + }, nil + }), + }, + }, + }) + + plugin, errRegister := registerRPCPlugin(context.Background(), nil, "router", lookup, pluginabi.MethodPluginRegister, nil) + if errRegister != nil { + t.Fatalf("registerRPCPlugin() error = %v", errRegister) + } + if plugin.Capabilities.ModelRouter == nil { + t.Fatal("ModelRouter = nil, want adapter") + } + + req := pluginapi.ModelRouteRequest{ + SourceFormat: "anthropic", + RequestedModel: "claude-sonnet", + Stream: true, + Headers: map[string][]string{"X-Test": {"one", "two"}}, + Query: map[string][]string{"beta": {"true"}}, + Body: []byte(`{"tools":[{"type":"web_search_20250305","name":"web_search"}]}`), + Metadata: map[string]any{ + "keep": "value", + }, + } + resp, errRoute := plugin.Capabilities.ModelRouter.RouteModel(context.Background(), req) + if errRoute != nil { + t.Fatalf("ModelRouter.RouteModel() error = %v", errRoute) + } + if !resp.Handled || resp.Target != "claude-websearch-plugin" || resp.Reason != "typed websearch" { + t.Fatalf("ModelRouter.RouteModel() response = %#v", resp) + } + if routeCalls != 1 { + t.Fatalf("route calls = %d, want 1", routeCalls) + } + if gotReq.SourceFormat != req.SourceFormat || gotReq.RequestedModel != req.RequestedModel || + gotReq.Stream != req.Stream || string(gotReq.Body) != string(req.Body) { + t.Fatalf("route request main fields = %#v, want %#v", gotReq, req) + } + if !reflect.DeepEqual(gotReq.Headers, req.Headers) { + t.Fatalf("route request headers = %#v, want %#v", gotReq.Headers, req.Headers) + } + if !reflect.DeepEqual(gotReq.Query, req.Query) { + t.Fatalf("route request query = %#v, want %#v", gotReq.Query, req.Query) + } + if gotReq.Metadata["keep"] != "value" { + t.Fatalf("route request metadata = %#v", gotReq.Metadata) } } diff --git a/internal/pluginhost/snapshot.go b/internal/pluginhost/snapshot.go --- a/internal/pluginhost/snapshot.go +++ b/internal/pluginhost/snapshot.go @@ -51,7 +51,7 @@ out = append(out, RegisteredPluginInfo{ ID: record.id, Priority: record.priority, - Metadata: record.meta, + Metadata: clonePluginMetadata(record.meta), SupportsOAuth: record.plugin.Capabilities.AuthProvider != nil, Menus: menusByPlugin[record.id], }) @@ -92,4 +92,24 @@ } return records[i].priority > records[j].priority }) +} + +func clonePluginMetadata(meta pluginapi.Metadata) pluginapi.Metadata { + if len(meta.ConfigFields) == 0 { + return meta + } + meta.ConfigFields = cloneConfigFields(meta.ConfigFields) + return meta +} + +func cloneConfigFields(fields []pluginapi.ConfigField) []pluginapi.ConfigField { + if len(fields) == 0 { + return nil + } + out := make([]pluginapi.ConfigField, len(fields)) + copy(out, fields) + for index := range out { + out[index].EnumValues = append([]string(nil), fields[index].EnumValues...) + } + return out } diff --git a/internal/pluginhost/test_helpers_test.go b/internal/pluginhost/test_helpers_test.go --- a/internal/pluginhost/test_helpers_test.go +++ b/internal/pluginhost/test_helpers_test.go @@ -37,6 +37,8 @@ shutdownCalls int registerOverride func([]byte) pluginapi.Plugin reconfigureOverride func([]byte) pluginapi.Plugin + schemaVersion uint32 + lastLifecycle rpcLifecycleRequest } func newTestSymbolLookup(plugin *testPlugin) *testSymbolLookup { @@ -134,6 +136,19 @@ return nil, errPick } return marshalRPCResult(resp) + case pluginabi.MethodModelRoute: + if l.active.Capabilities.ModelRouter == nil { + return nil, fmt.Errorf("missing model router") + } + var req pluginapi.ModelRouteRequest + if errUnmarshal := json.Unmarshal(request, &req); errUnmarshal != nil { + return nil, errUnmarshal + } + resp, errRoute := l.active.Capabilities.ModelRouter.RouteModel(ctx, req) + if errRoute != nil { + return nil, errRoute + } + return marshalRPCResult(resp) case pluginabi.MethodUsageHandle: if l.active.Capabilities.UsagePlugin == nil { return marshalRPCResult(rpcEmptyResponse{}) @@ -158,6 +173,7 @@ if errUnmarshal := json.Unmarshal(request, &req); errUnmarshal != nil { return nil, errUnmarshal } + l.lastLifecycle = req var plugin pluginapi.Plugin if reload { if l.reconfigureOverride != nil { @@ -173,8 +189,12 @@ } } l.active = plugin + schemaVersion := l.schemaVersion + if schemaVersion == 0 { + schemaVersion = pluginabi.SchemaVersion + } return marshalRPCResult(rpcRegistration{ - SchemaVersion: pluginabi.SchemaVersion, + SchemaVersion: schemaVersion, Metadata: plugin.Metadata, Capabilities: rpcCapabilitiesFromPlugin(plugin), }) @@ -266,6 +286,15 @@ func (f schedulerFunc) Pick(ctx context.Context, req pluginapi.SchedulerPickRequest) (pluginapi.SchedulerPickResponse, error) { if f == nil { return pluginapi.SchedulerPickResponse{}, fmt.Errorf("missing scheduler callback") + } + return f(ctx, req) +} + +type modelRouterFunc func(context.Context, pluginapi.ModelRouteRequest) (pluginapi.ModelRouteResponse, error) + +func (f modelRouterFunc) RouteModel(ctx context.Context, req pluginapi.ModelRouteRequest) (pluginapi.ModelRouteResponse, error) { + if f == nil { + return pluginapi.ModelRouteResponse{}, fmt.Errorf("missing model router callback") } return f(ctx, req) } diff --git a/sdk/pluginabi/types.go b/sdk/pluginabi/types.go --- a/sdk/pluginabi/types.go +++ b/sdk/pluginabi/types.go @@ -3,7 +3,11 @@ import "encoding/json" const ( - ABIVersion uint32 = 1 + // ABIVersion tracks the native C ABI shape (native plugin exports). + ABIVersion uint32 = 1 + // SchemaVersion tracks the RPC JSON contract exchanged at plugin.register. + // Increment only for breaking RPC changes. New capabilities such as ModelRouter + // are gated by capability flags and method names while the version stays at 1. SchemaVersion uint32 = 1 ) @@ -27,6 +31,8 @@ // MethodSchedulerPick asks a scheduler plugin to select an auth candidate. MethodSchedulerPick = "scheduler.pick" + // MethodModelRoute asks a router plugin to select a plugin executor for a matching request. + MethodModelRoute = "model.route" MethodExecutorIdentifier = "executor.identifier" MethodExecutorExecute = "executor.execute" diff --git a/sdk/pluginabi/types_test.go b/sdk/pluginabi/types_test.go --- a/sdk/pluginabi/types_test.go +++ b/sdk/pluginabi/types_test.go @@ -81,4 +81,7 @@ if MethodSchedulerPick != "scheduler.pick" { t.Fatalf("MethodSchedulerPick = %q", MethodSchedulerPick) } + if MethodModelRoute != "model.route" { + t.Fatalf("MethodModelRoute = %q", MethodModelRoute) + } } diff --git a/sdk/pluginapi/types.go b/sdk/pluginapi/types.go --- a/sdk/pluginapi/types.go +++ b/sdk/pluginapi/types.go @@ -79,6 +79,9 @@ FrontendAuthProviderExclusive bool // Scheduler chooses an auth candidate before the built-in scheduler runs. Scheduler Scheduler + // ModelRouter routes matching requests to a plugin executor, the router's own executor, + // or a built-in provider before model-to-provider resolution and auth selection. + ModelRouter ModelRouter // Executor sends requests to an upstream provider or local backend. Executor ProviderExecutor // ExecutorModelScope declares whether Executor serves static models, OAuth auth models, or both. @@ -456,6 +459,12 @@ Pick(context.Context, SchedulerPickRequest) (SchedulerPickResponse, error) } +// ModelRouter routes matching requests to a plugin executor, the router's own executor, +// or a built-in provider before model-to-provider resolution and auth selection. +type ModelRouter interface { + RouteModel(context.Context, ModelRouteRequest) (ModelRouteResponse, error) +} + // SchedulerPickRequest describes the routing context offered to a scheduler plugin. type SchedulerPickRequest struct { // Plugin is the metadata of the plugin being executed. @@ -506,6 +515,62 @@ DelegateBuiltin string // Handled reports whether the plugin made a scheduling decision. Handled bool +} + +// ModelRouteRequest describes the original request context offered to a model router plugin. +type ModelRouteRequest struct { + // Plugin is the metadata of the plugin being executed. + Plugin Metadata + // PluginID is the host-local plugin identifier for the router being executed. + PluginID string + // SourceFormat is the original client protocol format. + SourceFormat string + // RequestedModel is the client-requested model before provider/auth selection. + RequestedModel string + // Stream reports whether the request expects streaming output. + Stream bool + // Headers contains inbound request headers. + Headers http.Header + // Query contains inbound query parameters. + Query url.Values + // Body contains the raw client request payload. + Body []byte + // Metadata is a best-effort cloned context snapshot. Treat it as read-only and JSON-like. + Metadata map[string]any + // AvailableProviders lists built-in provider keys that currently have auth registered. + // A router may target one of them via TargetKind=provider to run the request through the + // built-in auth/executor path. Treat as read-only. + AvailableProviders []string +} + +// ModelRouteTargetKind selects the execution target for a handled model route decision. +type ModelRouteTargetKind string + +const ( + // ModelRouteTargetSelf routes to the router plugin's own executor. + ModelRouteTargetSelf ModelRouteTargetKind = "self" + // ModelRouteTargetExecutor routes to a specific plugin executor. + ModelRouteTargetExecutor ModelRouteTargetKind = "executor" + // ModelRouteTargetProvider routes through the built-in auth/executor path. + ModelRouteTargetProvider ModelRouteTargetKind = "provider" +) + +// ModelRouteResponse returns a model router plugin decision. +// +// When Handled is true, set TargetKind to one of self, executor, or provider. +// Target carries the plugin id for executor routes and the provider key for provider routes. +type ModelRouteResponse struct { + // Handled reports whether the plugin made a routing decision. + Handled bool + // TargetKind selects the execution target when Handled is true. + TargetKind ModelRouteTargetKind + // Target is the plugin executor id for executor routes and the provider key for provider routes. + Target string + // TargetModel is the model name used on the provider path. When empty, the host keeps + // the original client-requested model. Only meaningful with TargetKind=provider. + TargetModel string + // Reason is an optional diagnostic reason for the route decision. + Reason string } // ProviderExecutor handles model execution, streaming, HTTP bridging, and token counting. diff --git a/sdk/pluginapi/types_test.go b/sdk/pluginapi/types_test.go --- a/sdk/pluginapi/types_test.go +++ b/sdk/pluginapi/types_test.go @@ -16,6 +16,7 @@ var _ AuthProvider = (*compileTimePlugin)(nil) var _ FrontendAuthProvider = (*compileTimePlugin)(nil) var _ Scheduler = (*compileTimePlugin)(nil) +var _ ModelRouter = (*compileTimePlugin)(nil) var _ ProviderExecutor = (*compileTimePlugin)(nil) var _ HostHTTPClient = (*compileTimePlugin)(nil) var _ RequestTranslator = (*compileTimePlugin)(nil) @@ -327,6 +328,51 @@ } } +func TestModelRouteTypesExposeRoutingFields(t *testing.T) { + request := ModelRouteRequest{ + Plugin: Metadata{Name: "router-plugin"}, + PluginID: "router-plugin-id", + SourceFormat: "anthropic", + RequestedModel: "claude-sonnet", + Stream: true, + Headers: http.Header{"X-Test": []string{"1"}}, + Query: url.Values{"beta": []string{"true"}}, + Body: []byte(`{"model":"claude-sonnet"}`), + Metadata: map[string]any{"tenant": "demo"}, + } + response := ModelRouteResponse{ + Handled: true, + TargetKind: ModelRouteTargetExecutor, + Target: "claude-websearch-plugin", + Reason: "typed websearch", + } + + if request.Plugin.Name != "router-plugin" { + t.Fatalf("Plugin.Name = %q", request.Plugin.Name) + } + if request.PluginID != "router-plugin-id" { + t.Fatalf("PluginID = %q", request.PluginID) + } + if request.SourceFormat != "anthropic" || request.RequestedModel != "claude-sonnet" || !request.Stream { + t.Fatalf("request main fields = %#v", request) + } + if request.Headers.Get("X-Test") != "1" { + t.Fatalf("Headers = %#v", request.Headers) + } + if request.Query.Get("beta") != "true" { + t.Fatalf("Query = %#v", request.Query) + } + if string(request.Body) != `{"model":"claude-sonnet"}` { + t.Fatalf("Body = %q", request.Body) + } + if request.Metadata["tenant"] != "demo" { + t.Fatalf("Metadata = %#v", request.Metadata) + } + if !response.Handled || response.Target != "claude-websearch-plugin" || response.Reason != "typed websearch" { + t.Fatalf("ModelRouteResponse = %#v", response) + } +} + func (compileTimePlugin) RegisterModels(context.Context, ModelRegistrationRequest) (ModelRegistrationResponse, error) { return ModelRegistrationResponse{}, nil } @@ -363,6 +409,10 @@ func (compileTimePlugin) Pick(context.Context, SchedulerPickRequest) (SchedulerPickResponse, error) { return SchedulerPickResponse{}, nil +} + +func (compileTimePlugin) RouteModel(context.Context, ModelRouteRequest) (ModelRouteResponse, error) { + return ModelRouteResponse{}, nil } func (compileTimePlugin) Execute(context.Context, ExecutorRequest) (ExecutorResponse, error) { diff --git a/examples/plugin/claude-web-search-router/README.md b/examples/plugin/claude-web-search-router/README.md new file mode 100644 --- /dev/null +++ b/examples/plugin/claude-web-search-router/README.md @@ -0,0 +1,175 @@ +# Claude Code Web Search Router (ModelRouter example) + +This plugin demonstrates **ModelRouter** on Claude Code built-in `web_search` requests (see `temp/1.json` in the repo root for a captured request/response). + +## What it detects + +- Inbound protocol `claude` / `anthropic` +- `tools[]` with `type` `web_search_20250305` or `web_search_20260209` +- Optional Claude Code heuristics: system text like “web search tool use”, or user text + `Perform a web search for the query: …` + +## Routes (`route` config) + +| Value | Behavior | +| ------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `fallback` (**default**) | Plugin **executor** runs **antigravity → codex → xai → tavily** (built-ins via `host.model.*`, Tavily in-plugin). On **429/503/502**, tries the next backend in the same request. Backends that fail often are **deprioritized on later requests** (in-memory penalty; no extra config). | +| `antigravity_google` / `codex_web_search` / `xai_web_search` / `tavily` | Same orchestration for that backend’s chain member(s): execution retry + penalty apply when multiple backends are eligible. | +| `default_provider` | `default_provider` + optional `default_provider_model` via built-in AuthManager (not orchestrated). | +Routing for `fallback` requires at least one runnable backend (providers in `AvailableProviders` where needed, resolvable antigravity model, or `tavily_api_keys`). + +### xAI web search notes (aligned with upstream docs) + +- **Model**: xAI documents `grok-4.3` for server-side `web_search`. This example sets `TargetModel` to **`grok-4.3`** when `xai_model` is empty (do not forward `claude-sonnet-4-6` to xAI). +- **Request shape**: Responses API `input` + `tools[]` with `"type": "web_search"`. Optional `filters.allowed_domains` / `filters.excluded_domains` (max 5 each, mutually exclusive). +- **Claude mapping today**: `internal/translator/codex/claude` copies Claude `allowed_domains` → `filters.allowed_domains`. Claude `blocked_domains` is **not** mapped to `excluded_domains` yet. +- **Executor**: `xai_executor` normalizes tools (drops unsupported `external_web_access` if present) and posts to `/responses`. +- **Response**: Citations / server tool metadata come back through OpenAI Responses SSE and are converted toward Claude `server_tool_use` / `web_search_tool_result` where the response translator supports it. + +## Configuration + +Plugin config lives under `plugins.configs.claude-web-search-router` (key must match the plugin name). Load the shared library via `plugins.path`. + +### Recommended: fallback chain (default) + +Tries **antigravity → codex → xai → tavily**; configure `tavily_api_keys` so the last step can succeed when built-in providers are missing or unavailable. + +```yaml +plugins: + path: + - /absolute/path/to/examples/plugin/bin/claude-web-search-router-go.dylib + configs: + claude-web-search-router: + enabled: true + priority: 20 + route: fallback + antigravity_model: "" # empty: registry lookup, then first supports_web_search + codex_model: "gpt-5.4-mini" + xai_model: "grok-4.3" + tavily_api_keys: + - "tvly-xxxxxxxx" + # - "tvly-yyyyyyyy" # optional: round-robin + require_web_search_only: true +``` + +Omit `route` to use the same default (`fallback`). + +### Minimal fallback (Tavily as last resort only) + +```yaml +plugins: + configs: + claude-web-search-router: + enabled: true + priority: 20 + route: fallback + tavily_api_keys: + - "tvly-xxxxxxxx" + require_web_search_only: true +``` + +### Single backend (no fallback) + +**Antigravity only:** + +```yaml +plugins: + configs: + claude-web-search-router: + enabled: true + priority: 20 + route: antigravity_google + antigravity_model: "gemini-3.1-flash-lite" + require_web_search_only: true +``` + +**Codex only:** + +```yaml +plugins: + configs: + claude-web-search-router: + enabled: true + priority: 20 + route: codex_web_search + codex_model: "gpt-5.4-mini" + require_web_search_only: true +``` + +**xAI only:** + +```yaml +plugins: + configs: + claude-web-search-router: + enabled: true + priority: 20 + route: xai_web_search + xai_model: "grok-4.3" + require_web_search_only: true +``` + +**Tavily only (plugin executor):** + +```yaml +plugins: + configs: + claude-web-search-router: + enabled: true + priority: 20 + route: tavily + tavily_api_keys: + - "tvly-xxxxxxxx" + require_web_search_only: true +``` + +**Built-in provider via `default_provider`:** + +```yaml +plugins: + configs: + claude-web-search-router: + enabled: true + priority: 20 + route: default_provider + default_provider: claude + default_provider_model: "" + require_web_search_only: true +``` + +### Disable or relax detection + +```yaml +plugins: + configs: + claude-web-search-router: + enabled: false # plugin declines; host may use default Claude path + +# Or keep enabled but allow mixed tool lists: + claude-web-search-router: + enabled: true + route: fallback + require_web_search_only: false +``` + +### Config field reference + +| Field | Description | +| ----- | ----------- | +| `enabled` | `false` → `Handled: false` for all web_search matches | +| `priority` | Host plugin order for ModelRouter (higher runs earlier; see main repo plugins docs) | +| `route` | `fallback` (default), `antigravity_google`, `codex_web_search`, `xai_web_search`, `tavily`, `default_provider` | +| `antigravity_model` | Antigravity execution model; never the client Claude model name | +| `codex_model` | Codex model; empty → `gpt-5.4-mini` | +| `xai_model` | xAI model; empty → `grok-4.3` | +| `default_provider` / `default_provider_model` | Used when `route=default_provider` | +| `tavily_api_keys` | Required for `route=tavily` or fallback last step | +| `require_web_search_only` | `true` matches Claude Code–style exclusive `web_search` tools | + +## Build + +```bash +make -C examples/plugin bin/claude-web-search-router-go.dylib +``` + +Use `.so` on Linux and `.dll` on Windows. Point `plugins.path` at the built artifact. diff --git a/sdk/api/handlers/handlers.go b/sdk/api/handlers/handlers.go --- a/sdk/api/handlers/handlers.go +++ b/sdk/api/handlers/handlers.go @@ -9,6 +9,7 @@ "errors" "fmt" "net/http" + "net/url" "reflect" "strings" "sync" @@ -85,6 +86,35 @@ type requestInterceptorDetector interface { HasRequestInterceptors() bool +} + +// PluginModelRouterHost routes matching requests to a plugin executor, the router's own executor, +// or a built-in provider before model-to-provider resolution and auth selection. +type PluginModelRouterHost interface { + RouteModel(context.Context, pluginapi.ModelRouteRequest) (pluginapi.ModelRouteResponse, bool) +} + +// PluginExecutorHost executes a routed request with a specific plugin executor. +type PluginExecutorHost interface { + ExecutePluginExecutor(context.Context, string, coreexecutor.Request, coreexecutor.Options) (coreexecutor.Response, error) + ExecutePluginExecutorStream(context.Context, string, coreexecutor.Request, coreexecutor.Options) (*coreexecutor.StreamResult, error) + CountPluginExecutor(context.Context, string, coreexecutor.Request, coreexecutor.Options) (coreexecutor.Response, error) +} + +type pluginExecutorFormatResolver interface { + PluginExecutorRequestToFormat(string, coreexecutor.Request, coreexecutor.Options) sdktranslator.Format +} + +type pluginModelRouterSkipHost interface { + RouteModelExcept(context.Context, pluginapi.ModelRouteRequest, string) (pluginapi.ModelRouteResponse, bool) +} + +type modelRouterDetector interface { + HasModelRouters() bool +} + +type modelRouterSkipDetector interface { + HasModelRoutersExcept(string) bool } // WithPinnedAuthID returns a child context that requests execution on a specific auth ID. @@ -300,6 +330,20 @@ return nil } +// queryFromContext extracts the original HTTP request query parameters from the +// gin context embedded in the provided context. Mirrors headersFromContext so +// model routers can observe inbound query parameters for plain HTTP requests, +// where execOptions.Query is not populated by callers. +func queryFromContext(ctx context.Context) url.Values { + if ctx == nil { + return nil + } + if ginCtx, ok := ctx.Value("gin").(*gin.Context); ok && ginCtx != nil && ginCtx.Request != nil && ginCtx.Request.URL != nil { + return ginCtx.Request.URL.Query() + } + return nil +} + func pinnedAuthIDFromContext(ctx context.Context) string { if ctx == nil { return "" @@ -361,6 +405,10 @@ // PluginHost optionally applies plugin interceptors around upstream execution. PluginHost PluginInterceptorHost + + // ModelRouterHost optionally routes matching requests to a plugin executor, the router's own + // executor, or a built-in provider before model-to-provider resolution and auth selection. + ModelRouterHost PluginModelRouterHost } // NewBaseAPIHandlers creates a new API handlers instance. @@ -399,15 +447,35 @@ h.PluginHost = host } +// SetModelRouterHost configures the optional plugin model router host. +func (h *BaseAPIHandler) SetModelRouterHost(host PluginModelRouterHost) { + if h == nil { + return + } + if isNilPluginModelRouterHost(host) { + h.ModelRouterHost = nil + return + } + h.ModelRouterHost = host +} + func isNilPluginInterceptorHost(host PluginInterceptorHost) bool { - if host == nil { + return isNilInterface(host) +} + +func isNilPluginModelRouterHost(host PluginModelRouterHost) bool { + return isNilInterface(host) +} + +func isNilInterface(value any) bool { + if value == nil { return true } // A typed nil pointer stored in an interface is not equal to nil. - value := reflect.ValueOf(host) - switch value.Kind() { + reflected := reflect.ValueOf(value) + switch reflected.Kind() { case reflect.Chan, reflect.Func, reflect.Interface, reflect.Map, reflect.Pointer, reflect.Slice: - return value.IsNil() + return reflected.IsNil() default: return false } @@ -639,13 +707,18 @@ } func (h *BaseAPIHandler) executeWithAuthManagerFormats(ctx context.Context, entryProtocol, exitProtocol, modelName string, rawJSON []byte, alt string, allowImageModel bool, execOptions modelExecutionOptions) ([]byte, http.Header, *interfaces.ErrorMessage) { + originalRequestedModel := modelName + routeDecision := h.applyModelRouter(ctx, entryProtocol, modelName, rawJSON, false, execOptions) responseProtocol := modelExecutionResponseProtocol(entryProtocol, exitProtocol) - providers, normalizedModel, errMsg := h.getRequestDetailsWithOptions(modelName, allowImageModel) + if routeDecision.ExecutorPluginID != "" { + return h.executeWithPluginExecutor(ctx, entryProtocol, responseProtocol, modelName, originalRequestedModel, rawJSON, alt, routeDecision.ExecutorPluginID, execOptions) + } + providers, normalizedModel, errMsg := h.providersForExecution(modelName, originalRequestedModel, allowImageModel, routeDecision) if errMsg != nil { return nil, nil, errMsg } reqMeta := requestExecutionMetadata(ctx) - reqMeta[coreexecutor.RequestedModelMetadataKey] = modelName + reqMeta[coreexecutor.RequestedModelMetadataKey] = originalRequestedModel addModelExecutionSourceMetadata(reqMeta, execOptions.InternalSource) setReasoningEffortMetadata(reqMeta, entryProtocol, normalizedModel, rawJSON) setServiceTierMetadata(reqMeta, rawJSON) @@ -665,11 +738,11 @@ SourceFormat: sdktranslator.FromString(entryProtocol), ResponseFormat: sdktranslator.FromString(responseProtocol), Headers: modelExecutionHeaders(ctx, execOptions.Headers), - Query: cloneURLValues(execOptions.Query), + Query: modelExecutionQuery(ctx, execOptions.Query), RequestAfterAuthInterceptor: h.requestAfterAuthInterceptor(afterAuthCapture, execOptions.SkipInterceptorPluginID), } opts.Metadata = reqMeta - req, opts = h.applyRequestInterceptorsBeforeAuth(ctx, entryProtocol, modelName, req, opts, execOptions.SkipInterceptorPluginID) + req, opts = h.applyRequestInterceptorsBeforeAuth(ctx, entryProtocol, originalRequestedModel, req, opts, execOptions.SkipInterceptorPluginID) resp, err := h.AuthManager.Execute(ctx, providers, req, opts) if err != nil { err = enrichAuthSelectionError(err, providers, normalizedModel) @@ -690,19 +763,28 @@ executedReq, executedOpts := afterAuthCapture.apply(req, opts) rawResponseHeaders := cloneHeader(resp.Headers) responseHeaders := downstreamHeadersFromExecutor(rawResponseHeaders, PassthroughHeadersEnabled(h.Cfg)) - body, responseHeaders := h.applyResponseInterceptors(ctx, responseProtocol, normalizedModel, modelName, executedOpts, rawResponseHeaders, responseHeaders, executedOpts.OriginalRequest, executedReq.Payload, resp.Payload, http.StatusOK, execOptions.SkipInterceptorPluginID) + body, responseHeaders := h.applyResponseInterceptors(ctx, responseProtocol, normalizedModel, originalRequestedModel, executedOpts, rawResponseHeaders, responseHeaders, executedOpts.OriginalRequest, executedReq.Payload, resp.Payload, http.StatusOK, execOptions.SkipInterceptorPluginID) return body, responseHeaders, nil } // ExecuteCountWithAuthManager executes a non-streaming request via the core auth manager. // This path is the only supported execution route. func (h *BaseAPIHandler) ExecuteCountWithAuthManager(ctx context.Context, handlerType, modelName string, rawJSON []byte, alt string) ([]byte, http.Header, *interfaces.ErrorMessage) { - providers, normalizedModel, errMsg := h.getRequestDetails(modelName) + return h.executeCountWithAuthManager(ctx, handlerType, modelName, rawJSON, alt, modelExecutionOptions{}) +} + +func (h *BaseAPIHandler) executeCountWithAuthManager(ctx context.Context, handlerType, modelName string, rawJSON []byte, alt string, execOptions modelExecutionOptions) ([]byte, http.Header, *interfaces.ErrorMessage) { + originalRequestedModel := modelName + routeDecision := h.applyModelRouter(ctx, handlerType, modelName, rawJSON, false, execOptions) + if routeDecision.ExecutorPluginID != "" { + return h.countWithPluginExecutor(ctx, handlerType, modelName, originalRequestedModel, rawJSON, alt, routeDecision.ExecutorPluginID, execOptions) + } + providers, normalizedModel, errMsg := h.providersForExecution(modelName, originalRequestedModel, false, routeDecision) if errMsg != nil { return nil, nil, errMsg } reqMeta := requestExecutionMetadata(ctx) - reqMeta[coreexecutor.RequestedModelMetadataKey] = modelName + reqMeta[coreexecutor.RequestedModelMetadataKey] = originalRequestedModel setReasoningEffortMetadata(reqMeta, handlerType, normalizedModel, rawJSON) setServiceTierMetadata(reqMeta, rawJSON) payload := rawJSON @@ -719,11 +801,12 @@ Alt: alt, OriginalRequest: rawJSON, SourceFormat: sdktranslator.FromString(handlerType), - Headers: headersFromContext(ctx), - RequestAfterAuthInterceptor: h.requestAfterAuthInterceptor(afterAuthCapture, ""), + Headers: modelExecutionHeaders(ctx, execOptions.Headers), + Query: modelExecutionQuery(ctx, execOptions.Query), + RequestAfterAuthInterceptor: h.requestAfterAuthInterceptor(afterAuthCapture, execOptions.SkipInterceptorPluginID), } opts.Metadata = reqMeta - req, opts = h.applyRequestInterceptorsBeforeAuth(ctx, handlerType, modelName, req, opts, "") + req, opts = h.applyRequestInterceptorsBeforeAuth(ctx, handlerType, originalRequestedModel, req, opts, execOptions.SkipInterceptorPluginID) resp, err := h.AuthManager.ExecuteCount(ctx, providers, req, opts) if err != nil { err = enrichAuthSelectionError(err, providers, normalizedModel) @@ -744,8 +827,112 @@ executedReq, executedOpts := afterAuthCapture.apply(req, opts) rawResponseHeaders := cloneHeader(resp.Headers) responseHeaders := downstreamHeadersFromExecutor(rawResponseHeaders, PassthroughHeadersEnabled(h.Cfg)) - body, responseHeaders := h.applyResponseInterceptors(ctx, handlerType, normalizedModel, modelName, executedOpts, rawResponseHeaders, responseHeaders, executedOpts.OriginalRequest, executedReq.Payload, resp.Payload, http.StatusOK, "") + body, responseHeaders := h.applyResponseInterceptors(ctx, handlerType, normalizedModel, originalRequestedModel, executedOpts, rawResponseHeaders, responseHeaders, executedOpts.OriginalRequest, executedReq.Payload, resp.Payload, http.StatusOK, execOptions.SkipInterceptorPluginID) return body, responseHeaders, nil +} + +func (h *BaseAPIHandler) executeWithPluginExecutor(ctx context.Context, entryProtocol, responseProtocol, modelName, originalRequestedModel string, rawJSON []byte, alt, executorPluginID string, execOptions modelExecutionOptions) ([]byte, http.Header, *interfaces.ErrorMessage) { + host := h.pluginExecutorHost() + if host == nil { + return nil, nil, &interfaces.ErrorMessage{StatusCode: http.StatusBadGateway, Error: fmt.Errorf("plugin executor host is unavailable")} + } + req, opts := h.pluginExecutorRequest(ctx, entryProtocol, responseProtocol, modelName, originalRequestedModel, rawJSON, alt, false, execOptions) + req, opts = h.applyRequestInterceptorsBeforeAuth(ctx, entryProtocol, originalRequestedModel, req, opts, execOptions.SkipInterceptorPluginID) + req, opts = h.applyRequestInterceptorsAfterPluginExecutorRoute(ctx, host, executorPluginID, entryProtocol, originalRequestedModel, req, opts, execOptions.SkipInterceptorPluginID) + resp, errExecute := host.ExecutePluginExecutor(ctx, executorPluginID, req, opts) + if errExecute != nil { + return nil, nil, executionErrorMessage(errExecute) + } + rawResponseHeaders := cloneHeader(resp.Headers) + responseHeaders := downstreamHeadersFromExecutor(rawResponseHeaders, PassthroughHeadersEnabled(h.Cfg)) + body, responseHeaders := h.applyResponseInterceptors(ctx, responseProtocol, modelName, originalRequestedModel, opts, rawResponseHeaders, responseHeaders, opts.OriginalRequest, req.Payload, resp.Payload, http.StatusOK, execOptions.SkipInterceptorPluginID) + return body, responseHeaders, nil +} + +func (h *BaseAPIHandler) countWithPluginExecutor(ctx context.Context, handlerType, modelName, originalRequestedModel string, rawJSON []byte, alt, executorPluginID string, execOptions modelExecutionOptions) ([]byte, http.Header, *interfaces.ErrorMessage) { + host := h.pluginExecutorHost() + if host == nil { + return nil, nil, &interfaces.ErrorMessage{StatusCode: http.StatusBadGateway, Error: fmt.Errorf("plugin executor host is unavailable")} + } + req, opts := h.pluginExecutorRequest(ctx, handlerType, handlerType, modelName, originalRequestedModel, rawJSON, alt, false, execOptions) + req, opts = h.applyRequestInterceptorsBeforeAuth(ctx, handlerType, originalRequestedModel, req, opts, execOptions.SkipInterceptorPluginID) + req, opts = h.applyRequestInterceptorsAfterPluginExecutorRoute(ctx, host, executorPluginID, handlerType, originalRequestedModel, req, opts, execOptions.SkipInterceptorPluginID) + resp, errCount := host.CountPluginExecutor(ctx, executorPluginID, req, opts) + if errCount != nil { + return nil, nil, executionErrorMessage(errCount) + } + rawResponseHeaders := cloneHeader(resp.Headers) + responseHeaders := downstreamHeadersFromExecutor(rawResponseHeaders, PassthroughHeadersEnabled(h.Cfg)) + body, responseHeaders := h.applyResponseInterceptors(ctx, handlerType, modelName, originalRequestedModel, opts, rawResponseHeaders, responseHeaders, opts.OriginalRequest, req.Payload, resp.Payload, http.StatusOK, execOptions.SkipInterceptorPluginID) + return body, responseHeaders, nil +} + +func (h *BaseAPIHandler) pluginExecutorRequest(ctx context.Context, entryProtocol, responseProtocol, modelName, originalRequestedModel string, rawJSON []byte, alt string, stream bool, execOptions modelExecutionOptions) (coreexecutor.Request, coreexecutor.Options) { + reqMeta := requestExecutionMetadata(ctx) + reqMeta[coreexecutor.RequestedModelMetadataKey] = originalRequestedModel + addModelExecutionSourceMetadata(reqMeta, execOptions.InternalSource) + setReasoningEffortMetadata(reqMeta, entryProtocol, modelName, rawJSON) + setServiceTierMetadata(reqMeta, rawJSON) + payload := rawJSON + if len(payload) == 0 { + payload = nil + } + req := coreexecutor.Request{Model: modelName, Payload: payload} + opts := coreexecutor.Options{ + Stream: stream, + Alt: alt, + OriginalRequest: rawJSON, + SourceFormat: sdktranslator.FromString(entryProtocol), + ResponseFormat: sdktranslator.FromString(responseProtocol), + Headers: modelExecutionHeaders(ctx, execOptions.Headers), + Query: modelExecutionQuery(ctx, execOptions.Query), + Metadata: reqMeta, + } + return req, opts +} + +func (h *BaseAPIHandler) applyRequestInterceptorsAfterPluginExecutorRoute(ctx context.Context, host PluginExecutorHost, executorPluginID, entryProtocol, originalRequestedModel string, req coreexecutor.Request, opts coreexecutor.Options, skipPluginID string) (coreexecutor.Request, coreexecutor.Options) { + if !requestInterceptorsEnabled(h.interceptorHost()) { + return req, opts + } + toFormat := sdktranslator.FromString(entryProtocol) + if resolver, ok := host.(pluginExecutorFormatResolver); ok && resolver != nil { + if resolved := resolver.PluginExecutorRequestToFormat(executorPluginID, req, opts); resolved != "" { + toFormat = resolved + } + } + resp := h.applyRequestInterceptorsAfterAuth(ctx, coreexecutor.RequestAfterAuthInterceptRequest{ + SourceFormat: opts.SourceFormat, + ToFormat: toFormat, + Model: req.Model, + RequestedModel: originalRequestedModel, + Stream: opts.Stream, + Headers: cloneHeader(opts.Headers), + Body: cloneBytes(req.Payload), + Metadata: opts.Metadata, + }, skipPluginID) + opts.Headers = mergeRequestInterceptorHeaders(opts.Headers, resp.Headers, resp.ClearHeaders) + if len(resp.Body) > 0 { + req.Payload = cloneBytes(resp.Body) + opts.OriginalRequest = cloneBytes(resp.Body) + } + return req, opts +} + +func executionErrorMessage(err error) *interfaces.ErrorMessage { + status := http.StatusInternalServerError + if se, ok := err.(interface{ StatusCode() int }); ok && se != nil { + if code := se.StatusCode(); code > 0 { + status = code + } + } + var addon http.Header + if he, ok := err.(interface{ Headers() http.Header }); ok && he != nil { + if hdr := he.Headers(); hdr != nil { + addon = hdr.Clone() + } + } + return &interfaces.ErrorMessage{StatusCode: status, Error: err, Addon: addon} } // ExecuteStreamWithAuthManager executes a streaming request via the core auth manager. @@ -760,13 +947,160 @@ return h.executeStreamWithAuthManager(ctx, handlerType, modelName, rawJSON, alt, true) } +func (h *BaseAPIHandler) streamWithPluginExecutor(ctx context.Context, entryProtocol, responseProtocol, modelName, originalRequestedModel string, rawJSON []byte, alt, executorPluginID string, execOptions modelExecutionOptions) (<-chan []byte, http.Header, <-chan *interfaces.ErrorMessage) { + host := h.pluginExecutorHost() + if host == nil { + errChan := make(chan *interfaces.ErrorMessage, 1) + errChan <- &interfaces.ErrorMessage{StatusCode: http.StatusBadGateway, Error: fmt.Errorf("plugin executor host is unavailable")} + close(errChan) + return nil, nil, errChan + } + req, opts := h.pluginExecutorRequest(ctx, entryProtocol, responseProtocol, modelName, originalRequestedModel, rawJSON, alt, true, execOptions) + req, opts = h.applyRequestInterceptorsBeforeAuth(ctx, entryProtocol, originalRequestedModel, req, opts, execOptions.SkipInterceptorPluginID) + req, opts = h.applyRequestInterceptorsAfterPluginExecutorRoute(ctx, host, executorPluginID, entryProtocol, originalRequestedModel, req, opts, execOptions.SkipInterceptorPluginID) + streamResult, errStream := host.ExecutePluginExecutorStream(ctx, executorPluginID, req, opts) + if errStream != nil { + errChan := make(chan *interfaces.ErrorMessage, 1) + errChan <- executionErrorMessage(errStream) + close(errChan) + return nil, nil, errChan + } + if streamResult == nil { + errChan := make(chan *interfaces.ErrorMessage, 1) + errChan <- &interfaces.ErrorMessage{StatusCode: http.StatusBadGateway, Error: fmt.Errorf("plugin executor returned nil stream")} + close(errChan) + return nil, nil, errChan + } + + passthroughHeadersEnabled := PassthroughHeadersEnabled(h.Cfg) + interceptorHost := h.interceptorHost() + streamInterceptorsActive := streamInterceptorsEnabled(interceptorHost) + rawStreamHeaders := cloneHeader(streamResult.Headers) + baseStreamHeaders := cloneHeader(streamResult.Headers) + upstreamHeaders := downstreamHeadersFromExecutor(rawStreamHeaders, passthroughHeadersEnabled) + if upstreamHeaders == nil && (passthroughHeadersEnabled || streamInterceptorsActive) { + upstreamHeaders = make(http.Header) + } + streamHeadersCommitted := false + applyStreamHeaders := func(headers http.Header) { + rawStreamHeaders = finalInterceptorHeaders(rawStreamHeaders, headers) + if streamHeadersCommitted || upstreamHeaders == nil { + return + } + nextHeaders := downstreamHeadersAfterInterceptors(baseStreamHeaders, rawStreamHeaders, passthroughHeadersEnabled) + replaceHeader(upstreamHeaders, nextHeaders) + } + if streamInterceptorsActive { + intercepted := interceptStreamChunk(ctx, interceptorHost, pluginapi.StreamChunkInterceptRequest{ + SourceFormat: responseProtocol, + Model: modelName, + RequestedModel: originalRequestedModel, + RequestHeaders: cloneHeader(opts.Headers), + ResponseHeaders: cloneHeader(rawStreamHeaders), + OriginalRequest: cloneBytes(opts.OriginalRequest), + RequestBody: cloneBytes(req.Payload), + ChunkIndex: pluginapi.StreamChunkHeaderInitIndex, + Metadata: opts.Metadata, + }, execOptions.SkipInterceptorPluginID) + applyStreamHeaders(intercepted.Headers) + } + + dataChan := make(chan []byte) + errChan := make(chan *interfaces.ErrorMessage, 1) + var done <-chan struct{} + if ctx != nil { + done = ctx.Done() + } + chunks := streamResult.Chunks + if chunks == nil { + closed := make(chan coreexecutor.StreamChunk) + close(closed) + chunks = closed + } + go func() { + defer close(dataChan) + defer close(errChan) + chunkIndex := 0 + var historyChunks [][]byte + for { + chunk, ok, canceled := nextStreamChunk(ctx, nil, nil, chunks) + if canceled { + return + } + if !ok { + return + } + if chunk.Err != nil { + select { + case errChan <- executionErrorMessage(chunk.Err): + case <-done: + } + return + } + if len(chunk.Payload) == 0 { + continue + } + payload := cloneBytes(chunk.Payload) + if streamInterceptorsActive { + intercepted := interceptStreamChunk(ctx, interceptorHost, pluginapi.StreamChunkInterceptRequest{ + SourceFormat: responseProtocol, + Model: modelName, + RequestedModel: originalRequestedModel, + RequestHeaders: cloneHeader(opts.Headers), + ResponseHeaders: cloneHeader(rawStreamHeaders), + OriginalRequest: cloneBytes(opts.OriginalRequest), + RequestBody: cloneBytes(req.Payload), + Body: payload, + HistoryChunks: cloneByteSlices(historyChunks), + ChunkIndex: chunkIndex, + Metadata: opts.Metadata, + }, execOptions.SkipInterceptorPluginID) + applyStreamHeaders(intercepted.Headers) + if len(intercepted.Body) > 0 { + payload = cloneBytes(intercepted.Body) + } + chunkIndex++ + if intercepted.DropChunk { + continue + } + } else { + chunkIndex++ + } + if responseProtocol == "openai-response" { + if errValidate := validateSSEDataJSON(payload); errValidate != nil { + select { + case errChan <- &interfaces.ErrorMessage{StatusCode: http.StatusBadGateway, Error: errValidate}: + case <-done: + } + return + } + } + streamHeadersCommitted = true + select { + case dataChan <- payload: + if streamInterceptorsActive { + historyChunks = appendStreamInterceptorHistory(historyChunks, payload) + } + case <-done: + return + } + } + }() + return dataChan, upstreamHeaders, errChan +} + func (h *BaseAPIHandler) executeStreamWithAuthManager(ctx context.Context, handlerType, modelName string, rawJSON []byte, alt string, allowImageModel bool) (<-chan []byte, http.Header, <-chan *interfaces.ErrorMessage) { return h.executeStreamWithAuthManagerFormats(ctx, handlerType, handlerType, modelName, rawJSON, alt, allowImageModel, modelExecutionOptions{}) } func (h *BaseAPIHandler) executeStreamWithAuthManagerFormats(ctx context.Context, entryProtocol, exitProtocol, modelName string, rawJSON []byte, alt string, allowImageModel bool, execOptions modelExecutionOptions) (<-chan []byte, http.Header, <-chan *interfaces.ErrorMessage) { + originalRequestedModel := modelName + routeDecision := h.applyModelRouter(ctx, entryProtocol, modelName, rawJSON, true, execOptions) responseProtocol := modelExecutionResponseProtocol(entryProtocol, exitProtocol) - providers, normalizedModel, errMsg := h.getRequestDetailsWithOptions(modelName, allowImageModel) + if routeDecision.ExecutorPluginID != "" { + return h.streamWithPluginExecutor(ctx, entryProtocol, responseProtocol, modelName, originalRequestedModel, rawJSON, alt, routeDecision.ExecutorPluginID, execOptions) + } + providers, normalizedModel, errMsg := h.providersForExecution(modelName, originalRequestedModel, allowImageModel, routeDecision) if errMsg != nil { errChan := make(chan *interfaces.ErrorMessage, 1) errChan <- errMsg @@ -774,7 +1108,7 @@ return nil, nil, errChan } reqMeta := requestExecutionMetadata(ctx) - reqMeta[coreexecutor.RequestedModelMetadataKey] = modelName + reqMeta[coreexecutor.RequestedModelMetadataKey] = originalRequestedModel addModelExecutionSourceMetadata(reqMeta, execOptions.InternalSource) setReasoningEffortMetadata(reqMeta, entryProtocol, normalizedModel, rawJSON) setServiceTierMetadata(reqMeta, rawJSON) @@ -794,11 +1128,11 @@ SourceFormat: sdktranslator.FromString(entryProtocol), ResponseFormat: sdktranslator.FromString(responseProtocol), Headers: modelExecutionHeaders(ctx, execOptions.Headers), - Query: cloneURLValues(execOptions.Query), + Query: modelExecutionQuery(ctx, execOptions.Query), RequestAfterAuthInterceptor: h.requestAfterAuthInterceptor(afterAuthCapture, execOptions.SkipInterceptorPluginID), } opts.Metadata = reqMeta - req, opts = h.applyRequestInterceptorsBeforeAuth(ctx, entryProtocol, modelName, req, opts, execOptions.SkipInterceptorPluginID) + req, opts = h.applyRequestInterceptorsBeforeAuth(ctx, entryProtocol, originalRequestedModel, req, opts, execOptions.SkipInterceptorPluginID) streamResult, err := h.AuthManager.ExecuteStream(ctx, providers, req, opts) if err != nil { err = enrichAuthSelectionError(err, providers, normalizedModel) @@ -856,7 +1190,7 @@ intercepted := interceptStreamChunk(ctx, interceptorHost, pluginapi.StreamChunkInterceptRequest{ SourceFormat: responseProtocol, Model: normalizedModel, - RequestedModel: modelName, + RequestedModel: originalRequestedModel, RequestHeaders: cloneHeader(executedOpts.Headers), ResponseHeaders: cloneHeader(rawStreamHeaders), OriginalRequest: cloneBytes(executedOpts.OriginalRequest), @@ -1011,7 +1345,7 @@ intercepted := interceptStreamChunk(ctx, interceptorHost, pluginapi.StreamChunkInterceptRequest{ SourceFormat: responseProtocol, Model: normalizedModel, - RequestedModel: modelName, + RequestedModel: originalRequestedModel, RequestHeaders: cloneHeader(executedOpts.Headers), ResponseHeaders: cloneHeader(rawStreamHeaders), OriginalRequest: cloneBytes(executedOpts.OriginalRequest), @@ -1100,6 +1434,23 @@ return h.getRequestDetailsWithOptions(modelName, false) } +// providersForExecution resolves the providers and normalized model for a request. When a model +// router selected a built-in provider, it skips model->provider resolution and uses the router's +// provider (with an optional target model); otherwise it falls back to the registry-based path. +func (h *BaseAPIHandler) providersForExecution(modelName, originalRequestedModel string, allowImageModel bool, routeDecision modelRouteDecision) ([]string, string, *interfaces.ErrorMessage) { + if routeDecision.Provider != "" { + normalizedModel := originalRequestedModel + if routeDecision.Model != "" { + normalizedModel = routeDecision.Model + } + if errMsg := h.validateImageOnlyModel(normalizedModel, allowImageModel); errMsg != nil { + return nil, "", errMsg + } + return []string{routeDecision.Provider}, normalizedModel, nil + } + return h.getRequestDetailsWithOptions(modelName, allowImageModel) +} + func (h *BaseAPIHandler) getRequestDetailsWithOptions(modelName string, allowImageModel bool) (providers []string, normalizedModel string, err *interfaces.ErrorMessage) { resolvedModelName := modelName initialSuffix := thinking.ParseSuffix(modelName) @@ -1125,11 +1476,8 @@ parsed := thinking.ParseSuffix(resolvedModelName) baseModel := strings.TrimSpace(parsed.ModelName) - if strings.EqualFold(routeModelBaseName(baseModel), "gpt-image-2") && !allowImageModel { - return nil, "", &interfaces.ErrorMessage{ - StatusCode: http.StatusServiceUnavailable, - Error: fmt.Errorf("model %s is only supported on /v1/images/generations and /v1/images/edits", routeModelBaseName(baseModel)), - } + if errMsg := h.validateImageOnlyModel(baseModel, allowImageModel); errMsg != nil { + return nil, "", errMsg } if h != nil && h.AuthManager != nil && h.AuthManager.HomeEnabled() { @@ -1153,6 +1501,20 @@ // The thinking suffix is preserved in the model name itself, so no // metadata-based configuration passing is needed. return providers, resolvedModelName, nil +} + +func (h *BaseAPIHandler) validateImageOnlyModel(modelName string, allowImageModel bool) *interfaces.ErrorMessage { + baseModel := strings.TrimSpace(thinking.ParseSuffix(modelName).ModelName) + if baseModel == "" { + baseModel = strings.TrimSpace(modelName) + } + if strings.EqualFold(routeModelBaseName(baseModel), "gpt-image-2") && !allowImageModel { + return &interfaces.ErrorMessage{ + StatusCode: http.StatusServiceUnavailable, + Error: fmt.Errorf("model %s is only supported on /v1/images/generations and /v1/images/edits", routeModelBaseName(baseModel)), + } + } + return nil } func routeModelBaseName(model string) string { @@ -1316,6 +1678,109 @@ return nil } return h.PluginHost +} + +func (h *BaseAPIHandler) modelRouterHost() PluginModelRouterHost { + if h == nil { + return nil + } + if !isNilPluginModelRouterHost(h.ModelRouterHost) { + return h.ModelRouterHost + } + host := h.interceptorHost() + if host == nil { + return nil + } + router, ok := host.(PluginModelRouterHost) + if !ok { + return nil + } + return router +} + +func (h *BaseAPIHandler) pluginExecutorHost() PluginExecutorHost { + if h == nil { + return nil + } + if executorHost, ok := h.ModelRouterHost.(PluginExecutorHost); ok && executorHost != nil { + return executorHost + } + if executorHost, ok := h.PluginHost.(PluginExecutorHost); ok && executorHost != nil { + return executorHost + } + return nil +} + +type modelRouteDecision struct { + ExecutorPluginID string + Provider string + Model string +} + +func routeModel(ctx context.Context, host PluginModelRouterHost, req pluginapi.ModelRouteRequest, skipPluginID string) (pluginapi.ModelRouteResponse, bool) { + if host == nil { + return pluginapi.ModelRouteResponse{}, false + } + skipPluginID = strings.TrimSpace(skipPluginID) + if skipPluginID != "" { + if skipper, ok := host.(pluginModelRouterSkipHost); ok { + return skipper.RouteModelExcept(ctx, req, skipPluginID) + } + return pluginapi.ModelRouteResponse{}, false + } + return host.RouteModel(ctx, req) +} + +func modelRoutersEnabled(host PluginModelRouterHost, skipPluginID string) bool { + if host == nil { + return false + } + skipPluginID = strings.TrimSpace(skipPluginID) + if skipPluginID != "" { + if _, ok := host.(pluginModelRouterSkipHost); !ok { + return false + } + if detector, ok := host.(modelRouterSkipDetector); ok { + return detector.HasModelRoutersExcept(skipPluginID) + } + } + if detector, ok := host.(modelRouterDetector); ok { + return detector.HasModelRouters() + } + // No detector: treat routing as disabled (same conservative default as before any + // ModelRouter existed). Hosts that route must implement HasModelRouters (pluginhost.Host does). + return false +} + +func (h *BaseAPIHandler) applyModelRouter(ctx context.Context, handlerType, modelName string, rawJSON []byte, stream bool, execOptions modelExecutionOptions) modelRouteDecision { + var decision modelRouteDecision + host := h.modelRouterHost() + if host == nil || !modelRoutersEnabled(host, execOptions.SkipRouterPluginID) { + return decision + } + meta := requestExecutionMetadata(ctx) + meta[coreexecutor.RequestedModelMetadataKey] = modelName + addModelExecutionSourceMetadata(meta, execOptions.InternalSource) + resp, ok := routeModel(ctx, host, pluginapi.ModelRouteRequest{ + SourceFormat: handlerType, + RequestedModel: modelName, + Stream: stream, + Headers: modelExecutionHeaders(ctx, execOptions.Headers), + Query: modelExecutionQuery(ctx, execOptions.Query), + Body: cloneBytes(rawJSON), + Metadata: meta, + }, execOptions.SkipRouterPluginID) + if !ok || !resp.Handled { + return decision + } + switch resp.TargetKind { + case pluginapi.ModelRouteTargetSelf, pluginapi.ModelRouteTargetExecutor: + decision.ExecutorPluginID = strings.TrimSpace(resp.Target) + case pluginapi.ModelRouteTargetProvider: + decision.Provider = strings.ToLower(strings.TrimSpace(resp.Target)) + decision.Model = strings.TrimSpace(resp.TargetModel) + } + return decision } func streamInterceptorsEnabled(host PluginInterceptorHost) bool { diff --git a/sdk/api/handlers/handlers_interceptors_test.go b/sdk/api/handlers/handlers_interceptors_test.go --- a/sdk/api/handlers/handlers_interceptors_test.go +++ b/sdk/api/handlers/handlers_interceptors_test.go @@ -5,6 +5,7 @@ "fmt" "net/http" "net/http/httptest" + "net/url" "sync" "testing" @@ -180,6 +181,21 @@ c.Request.Header.Add(key, value) } } + return context.WithValue(context.Background(), "gin", c) +} + +// contextWithQuery builds a context whose embedded gin request carries the given +// query parameters, mirroring how plain HTTP requests expose inbound query to +// queryFromContext. +func contextWithQuery(query url.Values) context.Context { + gin.SetMode(gin.TestMode) + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + target := "/v1/chat/completions" + if encoded := query.Encode(); encoded != "" { + target = target + "?" + encoded + } + c.Request = httptest.NewRequest(http.MethodPost, target, nil) return context.WithValue(context.Background(), "gin", c) } diff --git a/sdk/api/handlers/handlers_model_router_test.go b/sdk/api/handlers/handlers_model_router_test.go new file mode 100644 --- /dev/null +++ b/sdk/api/handlers/handlers_model_router_test.go @@ -0,0 +1,634 @@ +package handlers + +import ( + "context" + "fmt" + "net/http" + "net/http/httptest" + "net/url" + "testing" + "time" + + "github.com/gin-gonic/gin" + coreauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth" + coreexecutor "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/executor" + sdkconfig "github.com/router-for-me/CLIProxyAPI/v7/sdk/config" + "github.com/router-for-me/CLIProxyAPI/v7/sdk/pluginapi" + sdktranslator "github.com/router-for-me/CLIProxyAPI/v7/sdk/translator" +) + +type handlerModelRouterTestHost struct { + hasRouters bool + route func(context.Context, pluginapi.ModelRouteRequest, string) (pluginapi.ModelRouteResponse, bool) + routeSkip string + lastReq *pluginapi.ModelRouteRequest +} + +func (h *handlerModelRouterTestHost) RouteModel(ctx context.Context, req pluginapi.ModelRouteRequest) (pluginapi.ModelRouteResponse, bool) { + return h.RouteModelExcept(ctx, req, "") +} + +func (h *handlerModelRouterTestHost) RouteModelExcept(ctx context.Context, req pluginapi.ModelRouteRequest, skipPluginID string) (pluginapi.ModelRouteResponse, bool) { + h.routeSkip = skipPluginID + reqCopy := req + h.lastReq = &reqCopy + if h != nil && h.route != nil { + return h.route(ctx, req, skipPluginID) + } + return pluginapi.ModelRouteResponse{}, false +} + +func (h *handlerModelRouterTestHost) HasModelRouters() bool { return h != nil && h.hasRouters } + +func (h *handlerModelRouterTestHost) HasModelRoutersExcept(skipPluginID string) bool { + return h != nil && h.hasRouters +} + +func (h *handlerModelRouterTestHost) HasRequestInterceptors() bool { return false } + +func (h *handlerModelRouterTestHost) HasStreamInterceptors() bool { return false } + +func (h *handlerModelRouterTestHost) InterceptRequestBeforeAuth(ctx context.Context, req pluginapi.RequestInterceptRequest) pluginapi.RequestInterceptResponse { + return pluginapi.RequestInterceptResponse{Headers: cloneHeader(req.Headers), Body: cloneBytes(req.Body)} +} + +func (h *handlerModelRouterTestHost) InterceptRequestAfterAuth(ctx context.Context, req pluginapi.RequestInterceptRequest) pluginapi.RequestInterceptResponse { + return pluginapi.RequestInterceptResponse{Headers: cloneHeader(req.Headers), Body: cloneBytes(req.Body)} +} + +func (h *handlerModelRouterTestHost) InterceptResponse(ctx context.Context, req pluginapi.ResponseInterceptRequest) pluginapi.ResponseInterceptResponse { + return pluginapi.ResponseInterceptResponse{Headers: cloneHeader(req.ResponseHeaders), Body: cloneBytes(req.Body)} +} + +func (h *handlerModelRouterTestHost) InterceptStreamChunk(ctx context.Context, req pluginapi.StreamChunkInterceptRequest) pluginapi.StreamChunkInterceptResponse { + return pluginapi.StreamChunkInterceptResponse{Headers: cloneHeader(req.ResponseHeaders), Body: cloneBytes(req.Body)} +} + +type handlerRouterOnlyTestHost struct { + route func(context.Context, pluginapi.ModelRouteRequest) (pluginapi.ModelRouteResponse, bool) + hasRouters bool + called bool +} + +func (h *handlerRouterOnlyTestHost) RouteModel(ctx context.Context, req pluginapi.ModelRouteRequest) (pluginapi.ModelRouteResponse, bool) { + if h != nil { + h.called = true + } + if h != nil && h.route != nil { + return h.route(ctx, req) + } + return pluginapi.ModelRouteResponse{}, false +} + +func (h *handlerRouterOnlyTestHost) HasModelRouters() bool { + return h != nil && h.hasRouters +} + +type handlerDirectExecutorRouteHost struct { + handlerRouterOnlyTestHost + lastPluginID string + lastRequest coreexecutor.Request + lastOptions coreexecutor.Options +} + +func (h *handlerDirectExecutorRouteHost) ExecutePluginExecutor(ctx context.Context, pluginID string, req coreexecutor.Request, opts coreexecutor.Options) (coreexecutor.Response, error) { + h.lastPluginID = pluginID + h.lastRequest = req + h.lastOptions = opts + return coreexecutor.Response{Payload: []byte("direct-ok")}, nil +} + +func (h *handlerDirectExecutorRouteHost) ExecutePluginExecutorStream(ctx context.Context, pluginID string, req coreexecutor.Request, opts coreexecutor.Options) (*coreexecutor.StreamResult, error) { + h.lastPluginID = pluginID + h.lastRequest = req + h.lastOptions = opts + chunks := make(chan coreexecutor.StreamChunk, 1) + chunks <- coreexecutor.StreamChunk{Payload: []byte("direct-stream")} + close(chunks) + return &coreexecutor.StreamResult{Chunks: chunks}, nil +} + +func (h *handlerDirectExecutorRouteHost) CountPluginExecutor(ctx context.Context, pluginID string, req coreexecutor.Request, opts coreexecutor.Options) (coreexecutor.Response, error) { + h.lastPluginID = pluginID + h.lastRequest = req + h.lastOptions = opts + return coreexecutor.Response{Payload: []byte("7")}, nil +} + +type handlerDirectExecutorInterceptorHost struct { + handlerDirectExecutorRouteHost + afterAuthCalled bool + afterAuthReq pluginapi.RequestInterceptRequest +} + +func (h *handlerDirectExecutorInterceptorHost) HasRequestInterceptors() bool { return true } + +func (h *handlerDirectExecutorInterceptorHost) HasStreamInterceptors() bool { return false } + +func (h *handlerDirectExecutorInterceptorHost) InterceptRequestBeforeAuth(ctx context.Context, req pluginapi.RequestInterceptRequest) pluginapi.RequestInterceptResponse { + return pluginapi.RequestInterceptResponse{Headers: cloneHeader(req.Headers), Body: cloneBytes(req.Body)} +} + +func (h *handlerDirectExecutorInterceptorHost) InterceptRequestAfterAuth(ctx context.Context, req pluginapi.RequestInterceptRequest) pluginapi.RequestInterceptResponse { + h.afterAuthCalled = true + h.afterAuthReq = req + headers := cloneHeader(req.Headers) + if headers == nil { + headers = make(http.Header) + } + headers.Set("X-After-Auth", "yes") + return pluginapi.RequestInterceptResponse{Headers: headers, Body: []byte(`{"after":true}`)} +} + +func (h *handlerDirectExecutorInterceptorHost) InterceptResponse(ctx context.Context, req pluginapi.ResponseInterceptRequest) pluginapi.ResponseInterceptResponse { + return pluginapi.ResponseInterceptResponse{Headers: cloneHeader(req.ResponseHeaders), Body: cloneBytes(req.Body)} +} + +func (h *handlerDirectExecutorInterceptorHost) InterceptStreamChunk(ctx context.Context, req pluginapi.StreamChunkInterceptRequest) pluginapi.StreamChunkInterceptResponse { + return pluginapi.StreamChunkInterceptResponse{Headers: cloneHeader(req.ResponseHeaders), Body: cloneBytes(req.Body)} +} + +func (h *handlerDirectExecutorInterceptorHost) PluginExecutorRequestToFormat(pluginID string, req coreexecutor.Request, opts coreexecutor.Options) sdktranslator.Format { + return sdktranslator.FormatCodex +} + +func TestHandlerModelRouterRoutesBeforeRequestDetails(t *testing.T) { + originalModel := "handler-router-original-model" + targetPluginID := "websearch-plugin" + host := &handlerDirectExecutorRouteHost{} + host.hasRouters = true + host.route = func(ctx context.Context, req pluginapi.ModelRouteRequest) (pluginapi.ModelRouteResponse, bool) { + if req.SourceFormat != "openai" || req.RequestedModel != originalModel || req.Stream { + t.Fatalf("unexpected route request = %#v", req) + } + if req.Headers.Get("X-Original") != "client" { + t.Fatalf("route headers = %#v, want client header", req.Headers) + } + if string(req.Body) != fmt.Sprintf(`{"model":%q}`, originalModel) { + t.Fatalf("route body = %q, want original body", req.Body) + } + return pluginapi.ModelRouteResponse{Handled: true, TargetKind: pluginapi.ModelRouteTargetExecutor, Target: targetPluginID, Reason: "test"}, true + } + handler := NewBaseAPIHandlers(&sdkconfig.SDKConfig{}, nil) + handler.SetModelRouterHost(host) + ctx := contextWithHeaders(http.Header{"X-Original": []string{"client"}}) + + body, _, errMsg := handler.ExecuteWithAuthManager(ctx, "openai", originalModel, []byte(fmt.Sprintf(`{"model":%q}`, originalModel)), "") + if errMsg != nil { + t.Fatalf("ExecuteWithAuthManager() error = %+v", errMsg) + } + if string(body) != "direct-ok" { + t.Fatalf("body = %q, want direct plugin executor response", body) + } + if host.lastPluginID != targetPluginID { + t.Fatalf("plugin id = %q, want %q", host.lastPluginID, targetPluginID) + } + if host.lastRequest.Model != originalModel { + t.Fatalf("executor model = %q, want original model", host.lastRequest.Model) + } + if host.lastOptions.Metadata[coreexecutor.RequestedModelMetadataKey] != originalModel { + t.Fatalf("requested model metadata = %#v, want original model", host.lastOptions.Metadata[coreexecutor.RequestedModelMetadataKey]) + } +} + +func TestHandlerModelRouterDirectExecutorRunsAfterAuthInterceptor(t *testing.T) { + originalModel := "handler-router-after-auth-original-model" + targetPluginID := "websearch-plugin" + host := &handlerDirectExecutorInterceptorHost{} + host.hasRouters = true + host.route = func(ctx context.Context, req pluginapi.ModelRouteRequest) (pluginapi.ModelRouteResponse, bool) { + return pluginapi.ModelRouteResponse{Handled: true, TargetKind: pluginapi.ModelRouteTargetExecutor, Target: targetPluginID}, true + } + handler := NewBaseAPIHandlers(&sdkconfig.SDKConfig{}, nil) + handler.SetPluginHost(host) + + body, _, errMsg := handler.ExecuteWithAuthManager(context.Background(), "openai", originalModel, []byte(fmt.Sprintf(`{"model":%q}`, originalModel)), "") + if errMsg != nil { + t.Fatalf("ExecuteWithAuthManager() error = %+v", errMsg) + } + if string(body) != "direct-ok" { + t.Fatalf("body = %q, want direct plugin executor response", body) + } + if !host.afterAuthCalled { + t.Fatal("after-auth interceptor was not called") + } + if host.afterAuthReq.SourceFormat != "openai" || host.afterAuthReq.ToFormat != "codex" { + t.Fatalf("after-auth formats = %q -> %q, want openai -> codex", host.afterAuthReq.SourceFormat, host.afterAuthReq.ToFormat) + } + if host.afterAuthReq.Model != originalModel || host.afterAuthReq.RequestedModel != originalModel { + t.Fatalf("after-auth models = %q/%q, want original model", host.afterAuthReq.Model, host.afterAuthReq.RequestedModel) + } + if string(host.lastRequest.Payload) != `{"after":true}` { + t.Fatalf("executor payload = %q, want after-auth body", host.lastRequest.Payload) + } + if host.lastOptions.Headers.Get("X-After-Auth") != "yes" { + t.Fatalf("executor headers = %#v, want after-auth header", host.lastOptions.Headers) + } + if string(host.lastOptions.OriginalRequest) != `{"after":true}` { + t.Fatalf("original request = %q, want after-auth body", host.lastOptions.OriginalRequest) + } +} + +func TestHandlerModelRouterRequiresPluginExecutorHost(t *testing.T) { + originalModel := "handler-router-only-original-model" + handler := NewBaseAPIHandlers(&sdkconfig.SDKConfig{}, nil) + handler.SetModelRouterHost(&handlerRouterOnlyTestHost{ + hasRouters: true, + route: func(ctx context.Context, req pluginapi.ModelRouteRequest) (pluginapi.ModelRouteResponse, bool) { + if req.RequestedModel != originalModel { + t.Fatalf("requested model = %q, want %q", req.RequestedModel, originalModel) + } + return pluginapi.ModelRouteResponse{Handled: true, TargetKind: pluginapi.ModelRouteTargetExecutor, Target: "websearch-plugin"}, true + }, + }) + + _, _, errMsg := handler.ExecuteWithAuthManager(context.Background(), "openai", originalModel, []byte(fmt.Sprintf(`{"model":%q}`, originalModel)), "") + if errMsg == nil || errMsg.StatusCode != http.StatusBadGateway { + t.Fatalf("ExecuteWithAuthManager() error = %+v, want BadGateway", errMsg) + } +} + +func TestHandlerModelRouterCanTargetPluginExecutorWithoutChangingModel(t *testing.T) { + originalModel := "handler-router-direct-original-model" + targetPluginID := "websearch-plugin" + host := &handlerDirectExecutorRouteHost{} + host.hasRouters = true + host.route = func(ctx context.Context, req pluginapi.ModelRouteRequest) (pluginapi.ModelRouteResponse, bool) { + if req.RequestedModel != originalModel { + t.Fatalf("requested model = %q, want %q", req.RequestedModel, originalModel) + } + return pluginapi.ModelRouteResponse{Handled: true, TargetKind: pluginapi.ModelRouteTargetExecutor, Target: targetPluginID}, true + } + handler := NewBaseAPIHandlers(&sdkconfig.SDKConfig{}, nil) + handler.SetModelRouterHost(host) + + body, _, errMsg := handler.ExecuteWithAuthManager(context.Background(), "claude", originalModel, []byte(fmt.Sprintf(`{"model":%q}`, originalModel)), "") + if errMsg != nil { + t.Fatalf("ExecuteWithAuthManager() error = %+v", errMsg) + } + if string(body) != "direct-ok" { + t.Fatalf("body = %q, want direct plugin executor response", body) + } + if host.lastPluginID != targetPluginID { + t.Fatalf("plugin id = %q, want %q", host.lastPluginID, targetPluginID) + } + if host.lastRequest.Model != originalModel { + t.Fatalf("executor model = %q, want original model", host.lastRequest.Model) + } + if host.lastOptions.Metadata[coreexecutor.RequestedModelMetadataKey] != originalModel { + t.Fatalf("requested model metadata = %#v, want original model", host.lastOptions.Metadata[coreexecutor.RequestedModelMetadataKey]) + } +} + +func TestHandlerModelRouterRoutesCountBeforeRequestDetails(t *testing.T) { + originalModel := "handler-router-count-original-model" + targetPluginID := "count-plugin" + host := &handlerDirectExecutorRouteHost{} + host.hasRouters = true + host.route = func(ctx context.Context, req pluginapi.ModelRouteRequest) (pluginapi.ModelRouteResponse, bool) { + if req.SourceFormat != "claude" || req.RequestedModel != originalModel || req.Stream { + t.Fatalf("unexpected count route request = %#v", req) + } + return pluginapi.ModelRouteResponse{Handled: true, TargetKind: pluginapi.ModelRouteTargetExecutor, Target: targetPluginID}, true + } + handler := NewBaseAPIHandlers(&sdkconfig.SDKConfig{}, nil) + handler.SetModelRouterHost(host) + + body, _, errMsg := handler.ExecuteCountWithAuthManager(context.Background(), "claude", originalModel, []byte(fmt.Sprintf(`{"model":%q}`, originalModel)), "") + if errMsg != nil { + t.Fatalf("ExecuteCountWithAuthManager() error = %+v", errMsg) + } + if string(body) != "7" { + t.Fatalf("body = %q, want count response", body) + } + if host.lastPluginID != targetPluginID { + t.Fatalf("plugin id = %q, want %q", host.lastPluginID, targetPluginID) + } + if host.lastRequest.Model != originalModel { + t.Fatalf("executor model = %q, want original model", host.lastRequest.Model) + } + if host.lastOptions.Metadata[coreexecutor.RequestedModelMetadataKey] != originalModel { + t.Fatalf("requested model metadata = %#v, want original model", host.lastOptions.Metadata[coreexecutor.RequestedModelMetadataKey]) + } +} + +func TestRouteModelDoesNotFallbackWhenSkipUnsupported(t *testing.T) { + host := &handlerRouterOnlyTestHost{hasRouters: true} + resp, ok := routeModel(context.Background(), host, pluginapi.ModelRouteRequest{RequestedModel: "model"}, "origin-plugin") + if ok || resp.Handled { + t.Fatalf("routeModel() = %#v, %v; want unhandled when skip is unsupported", resp, ok) + } + if host.called { + t.Fatal("RouteModel was called despite unsupported skip") + } +} + +func TestApplyModelRouterSkipsHostsWithoutRouters(t *testing.T) { + host := &handlerRouterOnlyTestHost{hasRouters: false} + handler := NewBaseAPIHandlers(&sdkconfig.SDKConfig{}, nil) + handler.SetModelRouterHost(host) + + got := handler.applyModelRouter(context.Background(), "openai", "model", []byte(`{"model":"model"}`), false, modelExecutionOptions{}) + if got.ExecutorPluginID != "" { + t.Fatalf("applyModelRouter() = %#v, want no routing decision", got) + } + if host.called { + t.Fatal("RouteModel was called even though detector reported no routers") + } +} + +// routeModelOnlyHost implements PluginModelRouterHost without HasModelRouters (conservative default). +type routeModelOnlyHost struct { + called bool +} + +func (h *routeModelOnlyHost) RouteModel(context.Context, pluginapi.ModelRouteRequest) (pluginapi.ModelRouteResponse, bool) { + if h != nil { + h.called = true + } + return pluginapi.ModelRouteResponse{}, false +} + +func TestModelRoutersEnabledFalseWithoutDetector(t *testing.T) { + host := &routeModelOnlyHost{} + if modelRoutersEnabled(host, "") { + t.Fatal("modelRoutersEnabled() = true, want false when host has no HasModelRouters") + } +} + +func TestApplyModelRouterSkipsHostWithoutDetector(t *testing.T) { + host := &routeModelOnlyHost{} + handler := NewBaseAPIHandlers(&sdkconfig.SDKConfig{}, nil) + handler.SetModelRouterHost(host) + + got := handler.applyModelRouter(context.Background(), "openai", "model", []byte(`{"model":"model"}`), false, modelExecutionOptions{}) + if got.ExecutorPluginID != "" || got.Provider != "" { + t.Fatalf("applyModelRouter() = %#v, want no routing decision", got) + } + if host.called { + t.Fatal("RouteModel was called on host without HasModelRouters") + } +} + +func TestApplyModelRouterRestoresQueryFromContext(t *testing.T) { + var gotQuery url.Values + host := &handlerRouterOnlyTestHost{hasRouters: true} + host.route = func(ctx context.Context, req pluginapi.ModelRouteRequest) (pluginapi.ModelRouteResponse, bool) { + gotQuery = cloneURLValues(req.Query) + return pluginapi.ModelRouteResponse{}, false + } + handler := NewBaseAPIHandlers(&sdkconfig.SDKConfig{}, nil) + handler.SetModelRouterHost(host) + + // execOptions.Query is intentionally empty; the inbound query must be recovered + // from the embedded gin context, mirroring plain HTTP requests. + ctx := contextWithQuery(url.Values{"session": []string{"abc"}}) + handler.applyModelRouter(ctx, "openai", "model", []byte(`{"model":"model"}`), false, modelExecutionOptions{}) + + if gotQuery.Get("session") != "abc" { + t.Fatalf("route query = %#v, want session=abc recovered from gin context", gotQuery) + } +} + +func TestHandlerModelRouterRoutesStreamBeforeRequestDetails(t *testing.T) { + originalModel := "handler-router-stream-original-model" + targetPluginID := "stream-plugin" + host := &handlerDirectExecutorRouteHost{} + host.hasRouters = true + host.route = func(ctx context.Context, req pluginapi.ModelRouteRequest) (pluginapi.ModelRouteResponse, bool) { + if req.SourceFormat != "openai" || req.RequestedModel != originalModel || !req.Stream { + t.Fatalf("unexpected stream route request = %#v", req) + } + return pluginapi.ModelRouteResponse{Handled: true, TargetKind: pluginapi.ModelRouteTargetExecutor, Target: targetPluginID}, true + } + handler := NewBaseAPIHandlers(&sdkconfig.SDKConfig{}, nil) + handler.SetModelRouterHost(host) + + dataChan, _, errChan := handler.ExecuteStreamWithAuthManager(context.Background(), "openai", originalModel, []byte(fmt.Sprintf(`{"model":%q,"stream":true}`, originalModel)), "") + var gotPayload bool + for range dataChan { + gotPayload = true + } + if !gotPayload { + t.Fatal("stream produced no payload") + } + if errMsg := <-errChan; errMsg != nil { + t.Fatalf("ExecuteStreamWithAuthManager() error = %+v", errMsg) + } + if host.lastPluginID != targetPluginID { + t.Fatalf("plugin id = %q, want %q", host.lastPluginID, targetPluginID) + } + if host.lastRequest.Model != originalModel { + t.Fatalf("executor model = %q, want original model", host.lastRequest.Model) + } + if host.lastOptions.Metadata[coreexecutor.RequestedModelMetadataKey] != originalModel { + t.Fatalf("requested model metadata = %#v, want original model", host.lastOptions.Metadata[coreexecutor.RequestedModelMetadataKey]) + } +} + +func TestExecuteModelPropagatesRouterSkipPluginID(t *testing.T) { + model := "model-execution-router-skip-model" + requestBody := []byte(fmt.Sprintf(`{"model":%q}`, model)) + executor := &modelExecutionCaptureExecutor{} + handler := newModelExecutionHandler(t, model, executor, &sdkconfig.SDKConfig{}) + routerHost := &handlerModelRouterTestHost{hasRouters: true} + handler.SetPluginHost(routerHost) + + resp, errMsg := handler.ExecuteModel(context.Background(), ModelExecutionRequest{ + EntryProtocol: "openai", + ExitProtocol: "openai", + Model: model, + Body: requestBody, + SkipRouterPluginID: "origin-plugin", + }) + if errMsg != nil { + t.Fatalf("ExecuteModel() error = %+v", errMsg) + } + if string(resp.Body) != "model-execution-ok" { + t.Fatalf("body = %q, want executor response", resp.Body) + } + if routerHost.routeSkip != "origin-plugin" { + t.Fatalf("router skip id = %q, want origin-plugin", routerHost.routeSkip) + } +} + +func TestHandlerProvidersForExecutionUsesRouterProvider(t *testing.T) { + handler := NewBaseAPIHandlers(&sdkconfig.SDKConfig{}, nil) + decision := modelRouteDecision{Provider: "claude", Model: "claude-sonnet-4"} + providers, normalizedModel, errMsg := handler.providersForExecution("ignored-by-router", "original-model", false, decision) + if errMsg != nil { + t.Fatalf("providersForExecution() error = %+v", errMsg) + } + if fmt.Sprint(providers) != "[claude]" { + t.Fatalf("providers = %v, want [claude]", providers) + } + if normalizedModel != "claude-sonnet-4" { + t.Fatalf("normalizedModel = %q, want claude-sonnet-4", normalizedModel) + } +} + +func TestHandlerProvidersForExecutionFallsBackToOriginalModel(t *testing.T) { + handler := NewBaseAPIHandlers(&sdkconfig.SDKConfig{}, nil) + decision := modelRouteDecision{Provider: "claude"} + providers, normalizedModel, errMsg := handler.providersForExecution("ignored-by-router", "original-model", false, decision) + if errMsg != nil { + t.Fatalf("providersForExecution() error = %+v", errMsg) + } + if fmt.Sprint(providers) != "[claude]" { + t.Fatalf("providers = %v, want [claude]", providers) + } + if normalizedModel != "original-model" { + t.Fatalf("normalizedModel = %q, want original-model", normalizedModel) + } +} + +func TestHandlerModelRouterProviderRouteUsesAuthManager(t *testing.T) { + originalModel := "provider-route-original-model" + host := &handlerDirectExecutorRouteHost{} + host.hasRouters = true + host.route = func(ctx context.Context, req pluginapi.ModelRouteRequest) (pluginapi.ModelRouteResponse, bool) { + return pluginapi.ModelRouteResponse{Handled: true, TargetKind: pluginapi.ModelRouteTargetProvider, Target: "claude"}, true + } + handler := NewBaseAPIHandlers(&sdkconfig.SDKConfig{}, nil) + handler.SetModelRouterHost(host) + handler.AuthManager = coreauth.NewManager(nil, nil, nil) + + _, _, errMsg := handler.ExecuteWithAuthManager(context.Background(), "openai", originalModel, []byte(fmt.Sprintf(`{"model":%q}`, originalModel)), "") + // The empty AuthManager has no claude auth, so execution surfaces an auth selection error + // rather than succeeding. The point is that the request reached the AuthManager path. + if errMsg == nil { + t.Fatal("ExecuteWithAuthManager() error = nil, want auth selection error for routed provider") + } + if !host.called { + t.Fatal("model router was not consulted") + } + if host.lastPluginID != "" { + t.Fatalf("plugin executor path was used (plugin id = %q); want provider path via AuthManager", host.lastPluginID) + } +} + +func TestHandlerProvidersForExecutionRejectsImageOnlyModelOnProviderRoute(t *testing.T) { + handler := NewBaseAPIHandlers(&sdkconfig.SDKConfig{}, nil) + cases := []struct { + name string + originalModel string + decision modelRouteDecision + }{ + { + name: "target-model", + originalModel: "original-model", + decision: modelRouteDecision{Provider: "claude", Model: "gpt-image-2"}, + }, + { + name: "target-model-thinking-suffix", + originalModel: "original-model", + decision: modelRouteDecision{Provider: "claude", Model: "gpt-image-2(auto)"}, + }, + { + name: "original-model-thinking-suffix", + originalModel: "gpt-image-2(auto)", + decision: modelRouteDecision{Provider: "claude"}, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + _, _, errMsg := handler.providersForExecution("ignored", tc.originalModel, false, tc.decision) + if errMsg == nil || errMsg.StatusCode != http.StatusServiceUnavailable { + t.Fatalf("providersForExecution() error = %+v, want image-only service unavailable", errMsg) + } + }) + } +} + +func TestExecuteCountWithAuthManagerPropagatesRouterSkipAndQuery(t *testing.T) { + model := "model-execution-count-router-context-model" + requestBody := []byte(fmt.Sprintf(`{"model":%q}`, model)) + handler := NewBaseAPIHandlers(&sdkconfig.SDKConfig{}, nil) + routerHost := &handlerModelRouterTestHost{hasRouters: true} + handler.SetPluginHost(routerHost) + ctx := contextWithQuery(url.Values{"session": []string{"abc"}}) + + _, _, errMsg := handler.executeCountWithAuthManager(ctx, "openai", model, requestBody, "", modelExecutionOptions{ + SkipRouterPluginID: "origin-plugin", + }) + if errMsg == nil { + t.Fatal("executeCountWithAuthManager() error = nil, want auth selection error on empty manager") + } + if routerHost.routeSkip != "origin-plugin" { + t.Fatalf("router skip id = %q, want origin-plugin", routerHost.routeSkip) + } + if routerHost.lastReq == nil || routerHost.lastReq.Query.Get("session") != "abc" { + t.Fatalf("route query = %#v, want session=abc", routerHost.lastReq) + } +} + +func TestHandlerModelRouterDirectExecutorPropagatesQueryFromContext(t *testing.T) { + originalModel := "handler-router-query-model" + targetPluginID := "query-plugin" + host := &handlerDirectExecutorRouteHost{} + host.hasRouters = true + host.route = func(ctx context.Context, req pluginapi.ModelRouteRequest) (pluginapi.ModelRouteResponse, bool) { + return pluginapi.ModelRouteResponse{Handled: true, TargetKind: pluginapi.ModelRouteTargetExecutor, Target: targetPluginID}, true + } + handler := NewBaseAPIHandlers(&sdkconfig.SDKConfig{}, nil) + handler.SetModelRouterHost(host) + ctx := contextWithQuery(url.Values{"session": []string{"abc"}}) + + _, _, errMsg := handler.ExecuteWithAuthManager(ctx, "openai", originalModel, []byte(fmt.Sprintf(`{"model":%q}`, originalModel)), "") + if errMsg != nil { + t.Fatalf("ExecuteWithAuthManager() error = %+v", errMsg) + } + if host.lastOptions.Query == nil || host.lastOptions.Query.Get("session") != "abc" { + t.Fatalf("executor query = %#v, want session=abc from gin context", host.lastOptions.Query) + } +} + +type handlerStuckPluginStreamHost struct { + handlerDirectExecutorRouteHost +} + +func (h *handlerStuckPluginStreamHost) ExecutePluginExecutorStream(ctx context.Context, pluginID string, req coreexecutor.Request, opts coreexecutor.Options) (*coreexecutor.StreamResult, error) { + chunks := make(chan coreexecutor.StreamChunk) + return &coreexecutor.StreamResult{Chunks: chunks}, nil +} + +func TestStreamWithPluginExecutorExitsOnContextCancel(t *testing.T) { + originalModel := "handler-router-stream-cancel-model" + targetPluginID := "stuck-stream-plugin" + host := &handlerStuckPluginStreamHost{} + host.hasRouters = true + host.route = func(ctx context.Context, req pluginapi.ModelRouteRequest) (pluginapi.ModelRouteResponse, bool) { + return pluginapi.ModelRouteResponse{Handled: true, TargetKind: pluginapi.ModelRouteTargetExecutor, Target: targetPluginID}, true + } + handler := NewBaseAPIHandlers(&sdkconfig.SDKConfig{}, nil) + handler.SetModelRouterHost(host) + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + dataChan, _, errChan := handler.ExecuteStreamWithAuthManager(ctx, "openai", originalModel, []byte(fmt.Sprintf(`{"model":%q,"stream":true}`, originalModel)), "") + deadline := time.After(2 * time.Second) + for { + select { + case _, ok := <-dataChan: + if !ok { + if errMsg := <-errChan; errMsg != nil { + t.Fatalf("unexpected stream error: %+v", errMsg) + } + return + } + case <-deadline: + t.Fatal("plugin executor stream goroutine did not exit after context cancel") + } + } +} + +func TestQueryFromContextNilURLDoesNotPanic(t *testing.T) { + gin.SetMode(gin.TestMode) + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + c.Request = &http.Request{Header: make(http.Header)} + ctx := context.WithValue(context.Background(), "gin", c) + if got := queryFromContext(ctx); got != nil { + t.Fatalf("queryFromContext() = %#v, want nil when URL is nil", got) + } +} diff --git a/sdk/api/handlers/model_execution.go b/sdk/api/handlers/model_execution.go --- a/sdk/api/handlers/model_execution.go +++ b/sdk/api/handlers/model_execution.go @@ -19,6 +19,7 @@ Query url.Values InternalSource bool SkipInterceptorPluginID string + SkipRouterPluginID string } // ModelExecutionRequest describes an internal model execution request. @@ -32,6 +33,7 @@ Query url.Values Alt string SkipInterceptorPluginID string + SkipRouterPluginID string } // ModelExecutionResponse describes a non-streaming internal model execution response. @@ -74,8 +76,8 @@ // ExecuteModel executes an internal non-streaming model request. // Host model callbacks are non-recursive for their caller: when -// SkipInterceptorPluginID is set, that plugin's interceptors are skipped for the -// nested model execution while other plugins may still run. +// skip plugin IDs are set, that plugin's interceptors and router are skipped +// for the nested model execution while other plugins may still run. func (h *BaseAPIHandler) ExecuteModel(ctx context.Context, req ModelExecutionRequest) (ModelExecutionResponse, *interfaces.ErrorMessage) { if req.Stream { return ModelExecutionResponse{}, modelExecutionModeError("ExecuteModel requires Stream=false") @@ -85,6 +87,7 @@ Query: req.Query, InternalSource: true, SkipInterceptorPluginID: req.SkipInterceptorPluginID, + SkipRouterPluginID: req.SkipRouterPluginID, }) if errMsg != nil { return ModelExecutionResponse{}, errMsg @@ -98,8 +101,8 @@ // ExecuteModelStream executes an internal streaming model request. // Host model callbacks are non-recursive for their caller: when -// SkipInterceptorPluginID is set, that plugin's interceptors are skipped for the -// nested model execution while other plugins may still run. +// skip plugin IDs are set, that plugin's interceptors and router are skipped +// for the nested model execution while other plugins may still run. func (h *BaseAPIHandler) ExecuteModelStream(ctx context.Context, req ModelExecutionRequest) (ModelExecutionStream, *interfaces.ErrorMessage) { if !req.Stream { return ModelExecutionStream{}, modelExecutionModeError("ExecuteModelStream requires Stream=true") @@ -109,6 +112,7 @@ Query: req.Query, InternalSource: true, SkipInterceptorPluginID: req.SkipInterceptorPluginID, + SkipRouterPluginID: req.SkipRouterPluginID, }) chunks, errMsg := prepareModelExecutionStream(ctx, dataChan, errChan) if errMsg != nil { @@ -137,6 +141,17 @@ return cloneHeader(headers) } return headersFromContext(ctx) +} + +// modelExecutionQuery prefers an explicitly provided query and otherwise falls +// back to the inbound query embedded in the request context. This lets model +// routers observe query parameters for plain HTTP requests even when callers +// do not populate execOptions.Query (mirrors modelExecutionHeaders). +func modelExecutionQuery(ctx context.Context, query url.Values) url.Values { + if len(query) > 0 { + return cloneURLValues(query) + } + return queryFromContext(ctx) } func cloneURLValues(src url.Values) url.Values { diff --git a/sdk/cliproxy/auth/conductor.go b/sdk/cliproxy/auth/conductor.go --- a/sdk/cliproxy/auth/conductor.go +++ b/sdk/cliproxy/auth/conductor.go @@ -2561,6 +2561,62 @@ return result } +// AvailableProviders returns the set of provider keys that currently have at least one +// registered auth record that is not disabled. It is a best-effort snapshot for routing +// decisions and does not account for per-model cooldowns or transient runtime availability. +// Disabled auths (Disabled flag or StatusDisabled) are excluded so routing does not target +// providers that auth selection would refuse to use, which would otherwise cause execution +// failures instead of falling back to lower-priority routers. +func (m *Manager) AvailableProviders() []string { + if m == nil { + return nil + } + m.mu.RLock() + defer m.mu.RUnlock() + seen := make(map[string]struct{}, len(m.auths)) + out := make([]string, 0, len(m.auths)) + for _, auth := range m.auths { + if auth == nil || auth.Disabled || auth.Status == StatusDisabled { + continue + } + provider := strings.ToLower(strings.TrimSpace(auth.Provider)) + if provider == "" { + continue + } + if _, ok := seen[provider]; ok { + continue + } + seen[provider] = struct{}{} + out = append(out, provider) + } + sort.Strings(out) + return out +} + +// HasProviderAuth reports whether at least one non-disabled auth record is registered for +// the provider. Disabled auths (Disabled flag or StatusDisabled) are excluded to match the +// behavior of auth selection, which refuses to pick disabled credentials. +func (m *Manager) HasProviderAuth(provider string) bool { + if m == nil { + return false + } + provider = strings.ToLower(strings.TrimSpace(provider)) + if provider == "" { + return false + } + m.mu.RLock() + defer m.mu.RUnlock() + for _, auth := range m.auths { + if auth == nil || auth.Disabled || auth.Status == StatusDisabled { + continue + } + if strings.ToLower(strings.TrimSpace(auth.Provider)) == provider { + return true + } + } + return false +} + func (m *Manager) retrySettings() (int, int, time.Duration) { if m == nil { return 0, 0, 0 diff --git a/sdk/cliproxy/auth/conductor_availability_test.go b/sdk/cliproxy/auth/conductor_availability_test.go --- a/sdk/cliproxy/auth/conductor_availability_test.go +++ b/sdk/cliproxy/auth/conductor_availability_test.go @@ -1,6 +1,7 @@ package auth import ( + "context" "testing" "time" ) @@ -57,5 +58,47 @@ } if auth.NextRetryAfter.Sub(next) > time.Second || next.Sub(auth.NextRetryAfter) > time.Second { t.Fatalf("auth.NextRetryAfter = %v, want %v", auth.NextRetryAfter, next) + } +} + +func TestManager_AvailableProvidersAndHasProviderAuth_ExcludeDisabled(t *testing.T) { + manager := NewManager(nil, nil, nil) + ctx := context.Background() + + if _, err := manager.Register(ctx, &Auth{ID: "active", Provider: "claude", Status: StatusActive}); err != nil { + t.Fatalf("register active auth: %v", err) + } + // Provider gemini only has an auth with the Disabled flag set. + if _, err := manager.Register(ctx, &Auth{ID: "flag-disabled", Provider: "gemini", Disabled: true}); err != nil { + t.Fatalf("register flag-disabled auth: %v", err) + } + // Provider codex only has an auth whose Status is StatusDisabled. + if _, err := manager.Register(ctx, &Auth{ID: "status-disabled", Provider: "codex", Status: StatusDisabled}); err != nil { + t.Fatalf("register status-disabled auth: %v", err) + } + + providers := manager.AvailableProviders() + present := make(map[string]bool, len(providers)) + for _, p := range providers { + present[p] = true + } + if !present["claude"] { + t.Errorf("AvailableProviders() = %v, want to include active provider claude", providers) + } + if present["gemini"] { + t.Errorf("AvailableProviders() = %v, want to exclude Disabled provider gemini", providers) + } + if present["codex"] { + t.Errorf("AvailableProviders() = %v, want to exclude StatusDisabled provider codex", providers) + } + + if !manager.HasProviderAuth("claude") { + t.Errorf("HasProviderAuth(claude) = false, want true") + } + if manager.HasProviderAuth("gemini") { + t.Errorf("HasProviderAuth(gemini) = true, want false (only Disabled auth registered)") + } + if manager.HasProviderAuth("codex") { + t.Errorf("HasProviderAuth(codex) = true, want false (only StatusDisabled auth registered)") } } diff --git a/examples/plugin/claude-web-search-router/go/claude_response.go b/examples/plugin/claude-web-search-router/go/claude_response.go new file mode 100644 --- /dev/null +++ b/examples/plugin/claude-web-search-router/go/claude_response.go @@ -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 +} diff --git a/examples/plugin/claude-web-search-router/go/config_test.go b/examples/plugin/claude-web-search-router/go/config_test.go new file mode 100644 --- /dev/null +++ b/examples/plugin/claude-web-search-router/go/config_test.go @@ -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) + } +} diff --git a/examples/plugin/claude-web-search-router/go/detect.go b/examples/plugin/claude-web-search-router/go/detect.go new file mode 100644 --- /dev/null +++ b/examples/plugin/claude-web-search-router/go/detect.go @@ -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 +} diff --git a/examples/plugin/claude-web-search-router/go/detect_test.go b/examples/plugin/claude-web-search-router/go/detect_test.go new file mode 100644 --- /dev/null +++ b/examples/plugin/claude-web-search-router/go/detect_test.go @@ -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 +} diff --git a/examples/plugin/claude-web-search-router/go/execute_stream.go b/examples/plugin/claude-web-search-router/go/execute_stream.go new file mode 100644 --- /dev/null +++ b/examples/plugin/claude-web-search-router/go/execute_stream.go @@ -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"}}, + }) +} diff --git a/examples/plugin/claude-web-search-router/go/execution_fallback.go b/examples/plugin/claude-web-search-router/go/execution_fallback.go new file mode 100644 --- /dev/null +++ b/examples/plugin/claude-web-search-router/go/execution_fallback.go @@ -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 +} diff --git a/examples/plugin/claude-web-search-router/go/execution_route_test.go b/examples/plugin/claude-web-search-router/go/execution_route_test.go new file mode 100644 --- /dev/null +++ b/examples/plugin/claude-web-search-router/go/execution_route_test.go @@ -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) + } +} diff --git a/examples/plugin/claude-web-search-router/go/fallback.go b/examples/plugin/claude-web-search-router/go/fallback.go new file mode 100644 --- /dev/null +++ b/examples/plugin/claude-web-search-router/go/fallback.go @@ -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", + } +} diff --git a/examples/plugin/claude-web-search-router/go/fallback_test.go b/examples/plugin/claude-web-search-router/go/fallback_test.go new file mode 100644 --- /dev/null +++ b/examples/plugin/claude-web-search-router/go/fallback_test.go @@ -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 +} diff --git a/examples/plugin/claude-web-search-router/go/go.mod b/examples/plugin/claude-web-search-router/go/go.mod new file mode 100644 --- /dev/null +++ b/examples/plugin/claude-web-search-router/go/go.mod @@ -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.38.0 // indirect +) + +replace github.com/router-for-me/CLIProxyAPI/v7 => ../../../.. diff --git a/examples/plugin/claude-web-search-router/go/go.sum b/examples/plugin/claude-web-search-router/go/go.sum new file mode 100644 --- /dev/null +++ b/examples/plugin/claude-web-search-router/go/go.sum @@ -0,0 +1,24 @@ +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= +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= diff --git a/examples/plugin/claude-web-search-router/go/main.go b/examples/plugin/claude-web-search-router/go/main.go new file mode 100644 --- /dev/null +++ b/examples/plugin/claude-web-search-router/go/main.go @@ -0,0 +1,482 @@ +package main + +/* +#include +#include + +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)) +} diff --git a/examples/plugin/claude-web-search-router/go/model_resolve.go b/examples/plugin/claude-web-search-router/go/model_resolve.go new file mode 100644 --- /dev/null +++ b/examples/plugin/claude-web-search-router/go/model_resolve.go @@ -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 +} diff --git a/examples/plugin/claude-web-search-router/go/model_resolve_test.go b/examples/plugin/claude-web-search-router/go/model_resolve_test.go new file mode 100644 --- /dev/null +++ b/examples/plugin/claude-web-search-router/go/model_resolve_test.go @@ -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) + } +} diff --git a/examples/plugin/claude-web-search-router/go/penalty.go b/examples/plugin/claude-web-search-router/go/penalty.go new file mode 100644 --- /dev/null +++ b/examples/plugin/claude-web-search-router/go/penalty.go @@ -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) +} diff --git a/examples/plugin/claude-web-search-router/go/penalty_test.go b/examples/plugin/claude-web-search-router/go/penalty_test.go new file mode 100644 --- /dev/null +++ b/examples/plugin/claude-web-search-router/go/penalty_test.go @@ -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) + } +} diff --git a/examples/plugin/claude-web-search-router/go/stream_forward.go b/examples/plugin/claude-web-search-router/go/stream_forward.go new file mode 100644 --- /dev/null +++ b/examples/plugin/claude-web-search-router/go/stream_forward.go @@ -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 +} diff --git a/examples/plugin/claude-web-search-router/go/stream_forward_test.go b/examples/plugin/claude-web-search-router/go/stream_forward_test.go new file mode 100644 --- /dev/null +++ b/examples/plugin/claude-web-search-router/go/stream_forward_test.go @@ -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") + } +} diff --git a/examples/plugin/claude-web-search-router/go/tavily.go b/examples/plugin/claude-web-search-router/go/tavily.go new file mode 100644 --- /dev/null +++ b/examples/plugin/claude-web-search-router/go/tavily.go @@ -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] + "..." +} diff --git a/examples/plugin/claude-web-search-router/go/tavily_test.go b/examples/plugin/claude-web-search-router/go/tavily_test.go new file mode 100644 --- /dev/null +++ b/examples/plugin/claude-web-search-router/go/tavily_test.go @@ -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) + } +}