Files
CLIProxyAPI/internal/pluginhost/model_router_test.go
sususu98 87132e54d7 feat(plugin): add ModelRouter before auth with single-slot routing targets (#3865)
* feat(plugin): add ModelRouter before auth with single-slot routing targets

## Motivation

Plugins that need to change execution based on the **original inbound request**
(protocol format, raw body, headers, query, stream flag, metadata, etc.) often
resorted to virtual/trampoline models or routing inside interceptors. This
commit adds **ModelRouter**: a pluggable layer **before** model-to-provider
resolution and AuthManager credential selection, so plugins can declare who
executes a request without spoofing the client model name.

This is a **new capability**, not a bugfix on the existing chain. With no
ModelRouter plugins loaded, behavior matches upstream.

## Pipeline placement

- `execute`, `stream`, and `count` (and image paths via AuthManager) call
  `applyModelRouter()` before building `coreexecutor.Request`.
- Routing runs **before** the request interceptor (before auth), so routers see
  the client’s original context. After a plugin executor is chosen, the existing
  **after-auth interceptor → response/stream interceptor** chain still applies.
- Internal `ExecuteModel` / `ExecuteModelStream` (host callbacks) support
  `SkipRouterPluginID` so nested calls do not re-enter the same router.

## Routing API (single slot, mutually exclusive)

`ModelRouteResponse` uses **one target slot** to avoid ambiguity when both
`TargetExecutorPluginID` and `TargetProvider` were set and the host ignored one:

| Field | Meaning |
|-------|---------|
| `Handled` | `false`: this router declines; try the next router or default path |
| `TargetKind` | `self` \| `executor` \| `provider` (pick one) |
| `Target` | `self`/`executor`: plugin ID; `provider`: built-in provider key |
| `TargetModel` | Optional on `provider` only; empty keeps client `RequestedModel` |
| `Reason` | Optional diagnostic text |

- **self**: the router plugin’s own executor (`Target` normalized to the router’s plugin ID).
- **executor**: another plugin’s executor; host pre-checks with `executorPluginReady()`
  (executor declared and provider identifier resolvable) to avoid handled routes that 500 at execution.
- **provider**: skip registry model resolution; fixed built-in AuthManager path; optional
  `TargetModel` for execution model only—**does not** change outward requested-model metadata.

Routers run in **descending plugin priority** (tie-break: ascending plugin ID). Panic, error,
invalid target, or unavailable executor/provider → log and **fall through to the next router**;
if none handle, use the original provider+auth flow.

## Context exposed to routers

`ModelRouteRequest` includes:

- `SourceFormat`, `RequestedModel`, `Stream`
- `Headers`, `Query`, `Body` (defensive copies)
- `Metadata` (best-effort read-only context snapshot)
- `AvailableProviders`: built-in provider keys with at least one **non-disabled** auth
  (`AuthManager.AvailableProviders()`). **Does not** reflect per-model cooldown or transient
  unavailability—treat as an optimistic snapshot.

Adds `AuthManager.HasProviderAuth()` and `AvailableProviders()`, excluding `Disabled` and
`StatusDisabled` auths consistently with credential selection.

## Host and RPC

- Go plugins: `pluginapi.ModelRouter` + `RouteModel()`.
- RPC plugins: `pluginabi.MethodModelRoute` (`model.route`), capability flag `model_router`.
- `pluginhost.Host` implements `RouteModel` / `RouteModelExcept`; handlers use
  `SetModelRouterHost` or a `PluginHost` type assertion; **direct executor** paths use
  `ExecutePluginExecutor*` / `CountPluginExecutor`.
- No bundled example ModelRouter plugin; capability is active only when a third-party plugin
  declares `model_router` and loads.

## Plugin RPC schema (policy A, upstream-aligned)

- `pluginabi.SchemaVersion` stays **1**: capability additions (`model_router`, `model.route`)
  do not bump the number; increment only on breaking RPC JSON changes.
- Host sends `schema_version` at register; reject only if the plugin declares a **higher**
  version than the host.
- No unpublished “ModelRouter requires schema ≥ 3” gate (v3 single-slot API was never public).
- Existing plugins and examples without `model_router` (`schema_version: 1`) need no changes.
- RPC ModelRouter: `schema_version: 1` + `model_router: true` + implement `model.route`.

## Path consistency within this commit

- Provider routes reuse image-only model checks (e.g. `gpt-image-2`) on the normalized model,
  same as the default AuthManager path.
- `count` aligned with execute/stream: `SkipRouterPluginID`, query/headers injection,
  interceptor skip semantics.
- Handlers: `modelRoutersEnabled` treats hosts without `HasModelRouters` as disabled
  (same as before ModelRouter existed); `pluginhost.Host` implements the detector.
- API docs: `ModelRouter` explicitly includes built-in **provider** targets (in addition to
  plugin executors and the router’s own executor).

## Testing

go test ./internal/pluginhost ./sdk/api/handlers ./sdk/pluginapi ./sdk/pluginabi ./sdk/cliproxy/auth
go build -o test-output ./cmd/server && rm test-output
go test ./...

* fix(handlers): address ModelRouter review feedback

- Use modelExecutionQuery for plugin executor and AuthManager paths so
  inbound URL query matches router/header behavior
- Guard queryFromContext when gin Request.URL is nil
- Read plugin executor stream chunks via nextStreamChunk to exit on cancel
- Drop redundant clonePluginMetadata on capability record meta

Tests cover query propagation, stream cancel, and nil URL safety.

* feat(plugin): add Claude web search router example

Add a Claude Code web_search ModelRouter example that can route matching Claude requests through Antigravity, Codex, xAI, or Tavily.

The plugin includes executor orchestration, backend fallback/penalty handling, Tavily API key support, Claude-compatible response assembly, stream forwarding, and focused unit coverage for detection, fallback routing, model resolution, penalties, stream forwarding, and Tavily behavior.

Verification: go test -count=1 ./... in examples/plugin/claude-web-search-router/go; go build -buildmode=c-shared for the plugin; go build ./cmd/server; live local CPA curl coverage for plugin load, four explicit routes, fallback, and Codex spark routing.

* fix(pluginhost): validate executor routes before fallback

* fix(pluginhost): skip oauth-only executor routes
2026-06-16 19:15:34 +08:00

614 lines
24 KiB
Go

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)
}
}