diff --git a/internal/tools/web_search.go b/internal/tools/web_search.go index c50a5d9e..08f872bb 100644 --- a/internal/tools/web_search.go +++ b/internal/tools/web_search.go @@ -182,9 +182,14 @@ func (t *WebSearchTool) Execute(ctx context.Context, args map[string]any) *Resul return NewResult(cached) } + // Resolve per-request provider chain (tenant may reorder / disable providers + // via builtin_tool_tenant_configs.settings → ctx → ResolveWebSearchChain). + // Defaults preserved when no override — backward-compat. + chain := ResolveWebSearchChain(ctx, t.providers) + // Try providers in order (first success wins) var lastErr error - for _, provider := range t.providers { + for _, provider := range chain { results, err := provider.Search(ctx, params) if err != nil { slog.Warn("web_search provider failed", "provider", provider.Name(), "error", err) diff --git a/internal/tools/web_search_chain.go b/internal/tools/web_search_chain.go new file mode 100644 index 00000000..c83d45d1 --- /dev/null +++ b/internal/tools/web_search_chain.go @@ -0,0 +1,158 @@ +package tools + +import ( + "context" + "encoding/json" + "log/slog" +) + +// web_search_chain.go — tenant-aware provider chain resolution. +// +// The web_search tool is registered as a singleton at gateway startup with a +// hardcoded provider list built from the global config (see NewWebSearchTool +// in web_search.go). To honor per-tenant overrides without re-constructing +// the singleton, Execute() calls ResolveWebSearchChain per request to decide +// which of its existing providers to try and in what order. +// +// Tenant settings schema (stored in builtin_tool_tenant_configs.settings): +// +// { +// "provider_order": ["duckduckgo", "brave"], // optional reorder +// "brave": { "enabled": false }, // optional per-provider disable +// "duckduckgo": { "enabled": true } +// } +// +// Secrets (API keys) stay in config_secrets — the tenant cannot inject keys +// via this settings blob. Tenant admins that need to supply their own key +// use the existing tenant-scoped config_secrets path. + +// WebSearchProviderOverride is the per-provider override envelope. Only +// non-nil fields override the default. Unknown fields in the JSON blob are +// ignored to stay forward-compatible with future tuning knobs. +type WebSearchProviderOverride struct { + Enabled *bool `json:"enabled,omitempty"` +} + +// WebSearchChainOverride is the full tenant settings shape for web_search. +// All fields optional — an empty/nil override results in the default chain. +type WebSearchChainOverride struct { + ProviderOrder []string `json:"provider_order,omitempty"` + Providers map[string]WebSearchProviderOverride `json:"-"` + // Per-provider sections are unmarshaled into Providers via custom logic + // below so admins can keep the natural JSON shape: + // { "brave": {...}, "duckduckgo": {...} } +} + +// UnmarshalJSON accepts the flat admin-facing shape: +// +// { "provider_order": [...], "brave": {...}, "duckduckgo": {...} } +// +// Keeps ProviderOrder top-level and collects every other object field into +// the Providers map keyed by provider name. +func (w *WebSearchChainOverride) UnmarshalJSON(data []byte) error { + var raw map[string]json.RawMessage + if err := json.Unmarshal(data, &raw); err != nil { + return err + } + if orderRaw, ok := raw["provider_order"]; ok { + if err := json.Unmarshal(orderRaw, &w.ProviderOrder); err != nil { + return err + } + delete(raw, "provider_order") + } + if len(raw) > 0 { + w.Providers = make(map[string]WebSearchProviderOverride, len(raw)) + for name, blob := range raw { + var po WebSearchProviderOverride + if err := json.Unmarshal(blob, &po); err != nil { + // Skip unknown keys that aren't provider overrides (forward-compat). + slog.Debug("web_search: skipping unrecognized override key", "key", name, "error", err) + continue + } + w.Providers[name] = po + } + } + return nil +} + +// ResolveWebSearchChain returns the ordered slice of providers to try for +// the current request, honoring any tenant override in ctx. +// +// Precedence: +// 1. Tenant override via BuiltinToolSettingsFromCtx["web_search"] +// — reorders via provider_order and/or disables individual providers +// 2. Defaults — the singleton's built-in provider order +// +// A provider is included only if it: +// - exists in the defaults (tenant can't inject new providers this way) +// - is not explicitly disabled (enabled=false) by the tenant +// +// Returns a fresh slice even on the fast-path so the caller can iterate +// freely — the underlying providers are shared singletons (stateless). +func ResolveWebSearchChain(ctx context.Context, defaults []SearchProvider) []SearchProvider { + if len(defaults) == 0 { + return nil + } + + // Fast path: no ctx settings → return defaults unchanged. + settings := BuiltinToolSettingsFromCtx(ctx) + raw, hasKey := settings["web_search"] + if !hasKey || len(raw) == 0 { + return defaults + } + + var override WebSearchChainOverride + if err := json.Unmarshal(raw, &override); err != nil { + slog.Warn("web_search: failed to parse tenant override, using defaults", "error", err) + return defaults + } + + // Build a name → provider index from defaults so we can reorder / filter + // without losing the actual SearchProvider instance. + byName := make(map[string]SearchProvider, len(defaults)) + for _, p := range defaults { + byName[p.Name()] = p + } + + // isDisabled returns true if the tenant explicitly disabled a provider. + isDisabled := func(name string) bool { + po, ok := override.Providers[name] + return ok && po.Enabled != nil && !*po.Enabled + } + + // If tenant set provider_order, use it as the authoritative iteration + // order. Providers not in the order list fall off (admin intent: "only + // try these, in this order"). Unknown providers in the list are skipped + // with a warning — no injection path. + if len(override.ProviderOrder) > 0 { + chain := make([]SearchProvider, 0, len(override.ProviderOrder)) + for _, name := range override.ProviderOrder { + p, ok := byName[name] + if !ok { + slog.Warn("web_search: tenant provider_order references unknown provider", "name", name) + continue + } + if isDisabled(name) { + continue + } + chain = append(chain, p) + } + return chain + } + + // No provider_order but per-provider enabled flags may still filter. + // Preserve default order, drop disabled entries. + if len(override.Providers) > 0 { + chain := make([]SearchProvider, 0, len(defaults)) + for _, p := range defaults { + if isDisabled(p.Name()) { + continue + } + chain = append(chain, p) + } + return chain + } + + // Override present but empty — use defaults. + return defaults +} diff --git a/internal/tools/web_search_chain_test.go b/internal/tools/web_search_chain_test.go new file mode 100644 index 00000000..cc1c4d41 --- /dev/null +++ b/internal/tools/web_search_chain_test.go @@ -0,0 +1,167 @@ +package tools + +import ( + "context" + "testing" +) + +// fakeSearchProvider implements SearchProvider for chain-resolution tests. +// Only Name() is exercised — Search is never called. +type fakeSearchProvider struct{ name string } + +func (f *fakeSearchProvider) Name() string { return f.name } +func (f *fakeSearchProvider) Search(_ context.Context, _ searchParams) ([]searchResult, error) { + return nil, nil +} + +// defaultChain builds a deterministic default provider list used across tests. +func defaultChain() []SearchProvider { + return []SearchProvider{ + &fakeSearchProvider{name: "brave"}, + &fakeSearchProvider{name: "duckduckgo"}, + } +} + +// chainNames extracts provider names in order for terse assertions. +func chainNames(chain []SearchProvider) []string { + out := make([]string, 0, len(chain)) + for _, p := range chain { + out = append(out, p.Name()) + } + return out +} + +// ---- No override → defaults unchanged ---- + +func TestResolveWebSearchChain_NoOverride_ReturnsDefaults(t *testing.T) { + got := ResolveWebSearchChain(context.Background(), defaultChain()) + if len(got) != 2 || got[0].Name() != "brave" || got[1].Name() != "duckduckgo" { + t.Errorf("no override: got %v, want [brave duckduckgo]", chainNames(got)) + } +} + +// Empty defaults → nil result. +func TestResolveWebSearchChain_EmptyDefaults_ReturnsNil(t *testing.T) { + got := ResolveWebSearchChain(context.Background(), nil) + if got != nil { + t.Errorf("empty defaults: got %v, want nil", got) + } +} + +// ---- provider_order reorders ---- + +func TestResolveWebSearchChain_ProviderOrderReorders(t *testing.T) { + override := []byte(`{"provider_order":["duckduckgo","brave"]}`) + tenant := BuiltinToolSettings{"web_search": override} + ctx := WithTenantToolSettings(context.Background(), tenant) + + got := chainNames(ResolveWebSearchChain(ctx, defaultChain())) + want := []string{"duckduckgo", "brave"} + if len(got) != 2 || got[0] != want[0] || got[1] != want[1] { + t.Errorf("reorder: got %v, want %v", got, want) + } +} + +// provider_order including only one provider → chain has only that provider. +func TestResolveWebSearchChain_ProviderOrderFiltersOut(t *testing.T) { + override := []byte(`{"provider_order":["duckduckgo"]}`) + tenant := BuiltinToolSettings{"web_search": override} + ctx := WithTenantToolSettings(context.Background(), tenant) + + got := chainNames(ResolveWebSearchChain(ctx, defaultChain())) + if len(got) != 1 || got[0] != "duckduckgo" { + t.Errorf("filter: got %v, want [duckduckgo]", got) + } +} + +// Unknown providers in provider_order are skipped (no injection path). +func TestResolveWebSearchChain_ProviderOrderIgnoresUnknown(t *testing.T) { + override := []byte(`{"provider_order":["malicious_new_provider","brave"]}`) + tenant := BuiltinToolSettings{"web_search": override} + ctx := WithTenantToolSettings(context.Background(), tenant) + + got := chainNames(ResolveWebSearchChain(ctx, defaultChain())) + if len(got) != 1 || got[0] != "brave" { + t.Errorf("unknown skip: got %v, want [brave]", got) + } +} + +// ---- per-provider enabled flag ---- + +func TestResolveWebSearchChain_DisablesProviderViaEnabledFalse(t *testing.T) { + override := []byte(`{"brave":{"enabled":false}}`) + tenant := BuiltinToolSettings{"web_search": override} + ctx := WithTenantToolSettings(context.Background(), tenant) + + got := chainNames(ResolveWebSearchChain(ctx, defaultChain())) + if len(got) != 1 || got[0] != "duckduckgo" { + t.Errorf("disable brave: got %v, want [duckduckgo]", got) + } +} + +// enabled=true is a no-op (default is always "enabled" by virtue of being in defaults). +func TestResolveWebSearchChain_EnabledTrueIsNoOp(t *testing.T) { + override := []byte(`{"brave":{"enabled":true},"duckduckgo":{"enabled":true}}`) + tenant := BuiltinToolSettings{"web_search": override} + ctx := WithTenantToolSettings(context.Background(), tenant) + + got := chainNames(ResolveWebSearchChain(ctx, defaultChain())) + if len(got) != 2 { + t.Errorf("enabled=true no-op: got %v, want 2 providers", got) + } +} + +// ---- malformed JSON → falls back to defaults ---- + +func TestResolveWebSearchChain_MalformedJSON_FallsBackToDefaults(t *testing.T) { + override := []byte(`{not valid json`) + tenant := BuiltinToolSettings{"web_search": override} + ctx := WithTenantToolSettings(context.Background(), tenant) + + got := chainNames(ResolveWebSearchChain(ctx, defaultChain())) + if len(got) != 2 { + t.Errorf("malformed: got %v, want defaults", got) + } +} + +// ---- combined: provider_order + disabled provider in the order ---- + +func TestResolveWebSearchChain_OrderAndDisableCombined(t *testing.T) { + override := []byte(`{"provider_order":["brave","duckduckgo"],"brave":{"enabled":false}}`) + tenant := BuiltinToolSettings{"web_search": override} + ctx := WithTenantToolSettings(context.Background(), tenant) + + got := chainNames(ResolveWebSearchChain(ctx, defaultChain())) + // brave is in the order but disabled → skipped; duckduckgo survives. + if len(got) != 1 || got[0] != "duckduckgo" { + t.Errorf("order+disable: got %v, want [duckduckgo]", got) + } +} + +// ---- global layer (WithBuiltinToolSettings) also drives the override ---- +// Ensures the merged view from BuiltinToolSettingsFromCtx surfaces global-layer +// web_search settings just like tenant-layer. +func TestResolveWebSearchChain_GlobalLayerDrivesOverride(t *testing.T) { + override := []byte(`{"provider_order":["duckduckgo"]}`) + global := BuiltinToolSettings{"web_search": override} + ctx := WithBuiltinToolSettings(context.Background(), global) + + got := chainNames(ResolveWebSearchChain(ctx, defaultChain())) + if len(got) != 1 || got[0] != "duckduckgo" { + t.Errorf("global layer: got %v, want [duckduckgo]", got) + } +} + +// Tenant layer beats global for the same tool (regression for Phase 3 precedence). +func TestResolveWebSearchChain_TenantBeatsGlobal(t *testing.T) { + global := BuiltinToolSettings{"web_search": []byte(`{"provider_order":["brave"]}`)} + tenant := BuiltinToolSettings{"web_search": []byte(`{"provider_order":["duckduckgo"]}`)} + + ctx := WithBuiltinToolSettings(context.Background(), global) + ctx = WithTenantToolSettings(ctx, tenant) + + got := chainNames(ResolveWebSearchChain(ctx, defaultChain())) + if len(got) != 1 || got[0] != "duckduckgo" { + t.Errorf("tenant beats global: got %v, want [duckduckgo]", got) + } +}