merge: integrate websearch emulation feature from worktree branch

Merge worktree-feature+websearch_support into release/custom-0.1.110.
Resolved conflicts in channel repo/service/handler/frontend (both
features_config and account_stats_pricing fields kept).
Fixed ProxySelector missing proxies prop in SettingsView.
This commit is contained in:
erio
2026-04-12 01:00:14 +08:00
38 changed files with 2486 additions and 47 deletions
@@ -34,6 +34,7 @@ type createChannelRequest struct {
BillingModelSource string `json:"billing_model_source" binding:"omitempty,oneof=requested upstream channel_mapped"`
RestrictModels bool `json:"restrict_models"`
Features string `json:"features"`
FeaturesConfig map[string]any `json:"features_config"`
ApplyPricingToAccountStats bool `json:"apply_pricing_to_account_stats"`
AccountStatsPricingRules []accountStatsPricingRuleRequest `json:"account_stats_pricing_rules"`
}
@@ -48,6 +49,7 @@ type updateChannelRequest struct {
BillingModelSource string `json:"billing_model_source" binding:"omitempty,oneof=requested upstream channel_mapped"`
RestrictModels *bool `json:"restrict_models"`
Features *string `json:"features"`
FeaturesConfig map[string]any `json:"features_config"`
ApplyPricingToAccountStats *bool `json:"apply_pricing_to_account_stats"`
AccountStatsPricingRules *[]accountStatsPricingRuleRequest `json:"account_stats_pricing_rules"`
}
@@ -92,6 +94,7 @@ type channelResponse struct {
BillingModelSource string `json:"billing_model_source"`
RestrictModels bool `json:"restrict_models"`
Features string `json:"features"`
FeaturesConfig map[string]any `json:"features_config"`
GroupIDs []int64 `json:"group_ids"`
ModelPricing []channelModelPricingResponse `json:"model_pricing"`
ModelMapping map[string]map[string]string `json:"model_mapping"`
@@ -147,6 +150,7 @@ func channelToResponse(ch *service.Channel) *channelResponse {
Status: ch.Status,
RestrictModels: ch.RestrictModels,
Features: ch.Features,
FeaturesConfig: ch.FeaturesConfig,
GroupIDs: ch.GroupIDs,
ModelMapping: ch.ModelMapping,
CreatedAt: ch.CreatedAt.Format("2006-01-02T15:04:05Z"),
@@ -365,6 +369,7 @@ func (h *ChannelHandler) Create(c *gin.Context) {
BillingModelSource: req.BillingModelSource,
RestrictModels: req.RestrictModels,
Features: req.Features,
FeaturesConfig: req.FeaturesConfig,
ApplyPricingToAccountStats: req.ApplyPricingToAccountStats,
AccountStatsPricingRules: statsRules,
})
@@ -400,6 +405,7 @@ func (h *ChannelHandler) Update(c *gin.Context) {
BillingModelSource: req.BillingModelSource,
RestrictModels: req.RestrictModels,
Features: req.Features,
FeaturesConfig: req.FeaturesConfig,
ApplyPricingToAccountStats: req.ApplyPricingToAccountStats,
}
if req.ModelPricing != nil {
@@ -175,6 +175,7 @@ func (h *SettingHandler) GetSettings(c *gin.Context) {
EnableFingerprintUnification: settings.EnableFingerprintUnification,
EnableMetadataPassthrough: settings.EnableMetadataPassthrough,
EnableCCHSigning: settings.EnableCCHSigning,
WebSearchEmulationEnabled: settings.WebSearchEmulationEnabled,
PaymentEnabled: paymentCfg.Enabled,
PaymentMinAmount: paymentCfg.MinAmount,
PaymentMaxAmount: paymentCfg.MaxAmount,
@@ -1847,3 +1848,37 @@ func (h *SettingHandler) UpdateStreamTimeoutSettings(c *gin.Context) {
ThresholdWindowMinutes: updatedSettings.ThresholdWindowMinutes,
})
}
// GetWebSearchEmulationConfig 获取 Web Search 模拟配置
// GET /api/v1/admin/settings/web-search-emulation
func (h *SettingHandler) GetWebSearchEmulationConfig(c *gin.Context) {
cfg, err := h.settingService.GetWebSearchEmulationConfig(c.Request.Context())
if err != nil {
response.ErrorFrom(c, err)
return
}
response.Success(c, service.SanitizeWebSearchConfig(cfg))
}
// UpdateWebSearchEmulationConfig 更新 Web Search 模拟配置
// PUT /api/v1/admin/settings/web-search-emulation
func (h *SettingHandler) UpdateWebSearchEmulationConfig(c *gin.Context) {
var cfg service.WebSearchEmulationConfig
if err := c.ShouldBindJSON(&cfg); err != nil {
response.BadRequest(c, "Invalid request: "+err.Error())
return
}
if err := h.settingService.SaveWebSearchEmulationConfig(c.Request.Context(), &cfg); err != nil {
response.ErrorFrom(c, err)
return
}
// Re-read (with sanitized api keys) to return current state
updated, err := h.settingService.GetWebSearchEmulationConfig(c.Request.Context())
if err != nil {
response.ErrorFrom(c, err)
return
}
response.Success(c, service.SanitizeWebSearchConfig(updated))
}
+3
View File
@@ -124,6 +124,9 @@ type SystemSettings struct {
EnableMetadataPassthrough bool `json:"enable_metadata_passthrough"`
EnableCCHSigning bool `json:"enable_cch_signing"`
// Web Search Emulation
WebSearchEmulationEnabled bool `json:"web_search_emulation_enabled"`
// Payment configuration
PaymentEnabled bool `json:"payment_enabled"`
PaymentMinAmount float64 `json:"payment_min_amount"`
+106
View File
@@ -0,0 +1,106 @@
package websearch
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
)
const (
braveSearchEndpoint = "https://api.search.brave.com/res/v1/web/search"
braveMaxCount = 20
braveProviderName = "brave"
)
// braveSearchURL is pre-parsed at init time; url.Parse cannot fail on a constant literal.
var braveSearchURL, _ = url.Parse(braveSearchEndpoint) //nolint:errcheck
// BraveProvider implements web search via the Brave Search API.
type BraveProvider struct {
apiKey string
httpClient *http.Client
}
// NewBraveProvider creates a Brave Search provider.
// The caller is responsible for configuring the http.Client with proxy/timeouts.
func NewBraveProvider(apiKey string, httpClient *http.Client) *BraveProvider {
if httpClient == nil {
httpClient = http.DefaultClient
}
return &BraveProvider{apiKey: apiKey, httpClient: httpClient}
}
func (b *BraveProvider) Name() string { return braveProviderName }
func (b *BraveProvider) Search(ctx context.Context, req SearchRequest) (*SearchResponse, error) {
count := req.MaxResults
if count <= 0 {
count = defaultMaxResults
}
if count > braveMaxCount {
count = braveMaxCount
}
u := *braveSearchURL // copy the pre-parsed URL
q := u.Query()
q.Set("q", req.Query)
q.Set("count", strconv.Itoa(count))
u.RawQuery = q.Encode()
httpReq, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil)
if err != nil {
return nil, fmt.Errorf("brave: build request: %w", err)
}
httpReq.Header.Set("X-Subscription-Token", b.apiKey)
httpReq.Header.Set("Accept", "application/json")
resp, err := b.httpClient.Do(httpReq)
if err != nil {
return nil, fmt.Errorf("brave: request failed: %w", err)
}
defer func() { _ = resp.Body.Close() }()
body, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseSize))
if err != nil {
return nil, fmt.Errorf("brave: read body: %w", err)
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("brave: status %d: %s", resp.StatusCode, truncateBody(body))
}
var raw braveResponse
if err := json.Unmarshal(body, &raw); err != nil {
return nil, fmt.Errorf("brave: decode response: %w", err)
}
results := make([]SearchResult, 0, len(raw.Web.Results))
for _, r := range raw.Web.Results {
results = append(results, SearchResult{
URL: r.URL,
Title: r.Title,
Snippet: r.Description,
PageAge: r.Age,
})
}
return &SearchResponse{Results: results, Query: req.Query}, nil
}
// braveResponse is the minimal structure of the Brave Search API response.
type braveResponse struct {
Web struct {
Results []braveResult `json:"results"`
} `json:"web"`
}
type braveResult struct {
URL string `json:"url"`
Title string `json:"title"`
Description string `json:"description"`
Age string `json:"age"`
}
@@ -0,0 +1,119 @@
package websearch
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/stretchr/testify/require"
)
func TestBraveProvider_Name(t *testing.T) {
p := NewBraveProvider("key", nil)
require.Equal(t, "brave", p.Name())
}
func TestBraveProvider_Search_Success(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, "test-key", r.Header.Get("X-Subscription-Token"))
require.Equal(t, "application/json", r.Header.Get("Accept"))
require.Equal(t, "golang", r.URL.Query().Get("q"))
require.Equal(t, "3", r.URL.Query().Get("count"))
resp := braveResponse{}
resp.Web.Results = []braveResult{
{URL: "https://go.dev", Title: "Go", Description: "Go lang", Age: "1 day"},
{URL: "https://pkg.go.dev", Title: "Pkg", Description: "Packages"},
{URL: "https://tour.go.dev", Title: "Tour", Description: "A Tour of Go", Age: "3 days"},
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(resp)
}))
defer srv.Close()
p := NewBraveProvider("test-key", srv.Client())
// Override the endpoint for testing
origURL := *braveSearchURL
u, _ := http.NewRequest("GET", srv.URL, nil)
*braveSearchURL = *u.URL
defer func() { *braveSearchURL = origURL }()
resp, err := p.Search(context.Background(), SearchRequest{Query: "golang", MaxResults: 3})
require.NoError(t, err)
require.Len(t, resp.Results, 3)
require.Equal(t, "https://go.dev", resp.Results[0].URL)
require.Equal(t, "Go lang", resp.Results[0].Snippet)
require.Equal(t, "1 day", resp.Results[0].PageAge)
}
func TestBraveProvider_Search_DefaultMaxResults(t *testing.T) {
var receivedCount string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
receivedCount = r.URL.Query().Get("count")
resp := braveResponse{}
_ = json.NewEncoder(w).Encode(resp)
}))
defer srv.Close()
p := NewBraveProvider("key", srv.Client())
origURL := *braveSearchURL
u, _ := http.NewRequest("GET", srv.URL, nil)
*braveSearchURL = *u.URL
defer func() { *braveSearchURL = origURL }()
_, _ = p.Search(context.Background(), SearchRequest{Query: "test", MaxResults: 0})
require.Equal(t, "5", receivedCount)
}
func TestBraveProvider_Search_HTTPError(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(429)
_, _ = w.Write([]byte("rate limited"))
}))
defer srv.Close()
p := NewBraveProvider("key", srv.Client())
origURL := *braveSearchURL
u, _ := http.NewRequest("GET", srv.URL, nil)
*braveSearchURL = *u.URL
defer func() { *braveSearchURL = origURL }()
_, err := p.Search(context.Background(), SearchRequest{Query: "test"})
require.ErrorContains(t, err, "brave: status 429")
}
func TestBraveProvider_Search_InvalidJSON(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte("not json"))
}))
defer srv.Close()
p := NewBraveProvider("key", srv.Client())
origURL := *braveSearchURL
u, _ := http.NewRequest("GET", srv.URL, nil)
*braveSearchURL = *u.URL
defer func() { *braveSearchURL = origURL }()
_, err := p.Search(context.Background(), SearchRequest{Query: "test"})
require.ErrorContains(t, err, "brave: decode response")
}
func TestBraveProvider_Search_EmptyResults(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
resp := braveResponse{}
_ = json.NewEncoder(w).Encode(resp)
}))
defer srv.Close()
p := NewBraveProvider("key", srv.Client())
origURL := *braveSearchURL
u, _ := http.NewRequest("GET", srv.URL, nil)
*braveSearchURL = *u.URL
defer func() { *braveSearchURL = origURL }()
resp, err := p.Search(context.Background(), SearchRequest{Query: "test"})
require.NoError(t, err)
require.Empty(t, resp.Results)
}
+14
View File
@@ -0,0 +1,14 @@
package websearch
const (
maxResponseSize = 1 << 20 // 1 MB
errorBodyTruncLen = 200
)
// truncateBody returns a truncated string of body for error messages.
func truncateBody(body []byte) string {
if len(body) <= errorBodyTruncLen {
return string(body)
}
return string(body[:errorBodyTruncLen]) + "...(truncated)"
}
@@ -0,0 +1,25 @@
package websearch
import (
"strings"
"testing"
"github.com/stretchr/testify/require"
)
func TestTruncateBody_Short(t *testing.T) {
body := []byte("short body")
require.Equal(t, "short body", truncateBody(body))
}
func TestTruncateBody_Long(t *testing.T) {
body := []byte(strings.Repeat("x", 500))
result := truncateBody(body)
require.Len(t, result, errorBodyTruncLen+len("...(truncated)"))
require.True(t, strings.HasSuffix(result, "...(truncated)"))
}
func TestTruncateBody_ExactBoundary(t *testing.T) {
body := []byte(strings.Repeat("x", errorBodyTruncLen))
require.Equal(t, string(body), truncateBody(body))
}
+273
View File
@@ -0,0 +1,273 @@
package websearch
import (
"context"
"crypto/tls"
"fmt"
"log/slog"
"net/http"
"net/url"
"sort"
"strings"
"sync"
"time"
"github.com/redis/go-redis/v9"
)
// Quota refresh interval constants.
const (
QuotaRefreshDaily = "daily"
QuotaRefreshWeekly = "weekly"
QuotaRefreshMonthly = "monthly"
)
// ProviderConfig holds the configuration for a single search provider.
type ProviderConfig struct {
Type string `json:"type"` // ProviderTypeBrave | ProviderTypeTavily
APIKey string `json:"api_key"` // secret
Priority int `json:"priority"` // lower = higher priority
QuotaLimit int64 `json:"quota_limit"` // 0 = unlimited
QuotaRefreshInterval string `json:"quota_refresh_interval"` // QuotaRefreshDaily / Weekly / Monthly
ProxyURL string `json:"-"` // resolved proxy URL (not persisted)
ExpiresAt *int64 `json:"expires_at,omitempty"` // optional expiration (unix seconds)
}
// Manager selects providers by priority and tracks quota via Redis.
type Manager struct {
configs []ProviderConfig
redis *redis.Client
clientMu sync.Mutex
clientCache map[string]*http.Client
}
const (
quotaKeyPrefix = "websearch:quota:"
searchRequestTimeout = 30 * time.Second
quotaTTLBuffer = 24 * time.Hour
maxCachedClients = 100
)
// quotaIncrScript atomically increments the counter and sets TTL on first creation.
// KEYS[1] = quota key, ARGV[1] = TTL in seconds.
// Returns the new counter value.
var quotaIncrScript = redis.NewScript(`
local val = redis.call('INCR', KEYS[1])
if val == 1 then
redis.call('EXPIRE', KEYS[1], ARGV[1])
else
-- Defensive: ensure TTL exists even if a prior EXPIRE failed
local ttl = redis.call('TTL', KEYS[1])
if ttl == -1 then
redis.call('EXPIRE', KEYS[1], ARGV[1])
end
end
return val
`)
// NewManager creates a Manager with the given provider configs and Redis client.
func NewManager(configs []ProviderConfig, redisClient *redis.Client) *Manager {
sorted := make([]ProviderConfig, len(configs))
copy(sorted, configs)
sort.Slice(sorted, func(i, j int) bool {
return sorted[i].Priority < sorted[j].Priority
})
return &Manager{
configs: sorted,
redis: redisClient,
clientCache: make(map[string]*http.Client),
}
}
// SearchWithBestProvider selects the highest-priority available provider,
// reserves quota, executes the search, and rolls back quota on failure.
func (m *Manager) SearchWithBestProvider(ctx context.Context, req SearchRequest) (*SearchResponse, string, error) {
if strings.TrimSpace(req.Query) == "" {
return nil, "", fmt.Errorf("websearch: empty search query")
}
for _, cfg := range m.configs {
if !m.isProviderAvailable(cfg) {
continue
}
allowed, incremented := m.tryReserveQuota(ctx, cfg)
if !allowed {
continue
}
resp, err := m.executeSearch(ctx, cfg, req)
if err != nil {
if incremented {
m.rollbackQuota(ctx, cfg)
}
slog.Warn("websearch: provider search failed",
"provider", cfg.Type, "error", err)
continue
}
return resp, cfg.Type, nil
}
return nil, "", fmt.Errorf("websearch: no available provider (all exhausted or failed)")
}
func (m *Manager) isProviderAvailable(cfg ProviderConfig) bool {
if cfg.APIKey == "" {
return false
}
if cfg.ExpiresAt != nil && time.Now().Unix() > *cfg.ExpiresAt {
slog.Info("websearch: provider expired, skipping",
"provider", cfg.Type, "expires_at", *cfg.ExpiresAt)
return false
}
return true
}
// tryReserveQuota atomically increments the counter via Lua script and checks limit.
// Returns (allowed, incremented): allowed=true means the request may proceed;
// incremented=true means the Redis counter was actually incremented (so rollback is needed on failure).
func (m *Manager) tryReserveQuota(ctx context.Context, cfg ProviderConfig) (bool, bool) {
if cfg.QuotaLimit <= 0 {
return true, false // unlimited, no INCR
}
if m.redis == nil {
slog.Warn("websearch: Redis unavailable, quota check skipped",
"provider", cfg.Type)
return true, false // allowed but not incremented
}
key := quotaRedisKey(cfg.Type, cfg.QuotaRefreshInterval)
ttlSec := int(quotaTTL(cfg.QuotaRefreshInterval).Seconds())
newVal, err := quotaIncrScript.Run(ctx, m.redis, []string{key}, ttlSec).Int64()
if err != nil {
slog.Warn("websearch: quota Lua INCR failed, allowing request",
"provider", cfg.Type, "error", err)
return true, false // allowed but not incremented
}
if newVal > cfg.QuotaLimit {
if decrErr := m.redis.Decr(ctx, key).Err(); decrErr != nil {
slog.Warn("websearch: quota over-limit DECR failed",
"provider", cfg.Type, "error", decrErr)
}
slog.Info("websearch: provider quota exhausted",
"provider", cfg.Type, "used", newVal, "limit", cfg.QuotaLimit)
return false, false // rejected, already rolled back
}
return true, true // allowed and incremented
}
// rollbackQuota decrements the counter after a search failure.
func (m *Manager) rollbackQuota(ctx context.Context, cfg ProviderConfig) {
if cfg.QuotaLimit <= 0 || m.redis == nil {
return
}
key := quotaRedisKey(cfg.Type, cfg.QuotaRefreshInterval)
if err := m.redis.Decr(ctx, key).Err(); err != nil {
slog.Warn("websearch: quota rollback DECR failed",
"provider", cfg.Type, "error", err)
}
}
func (m *Manager) executeSearch(ctx context.Context, cfg ProviderConfig, req SearchRequest) (*SearchResponse, error) {
proxyURL := cfg.ProxyURL
if req.ProxyURL != "" {
proxyURL = req.ProxyURL
}
client := m.getOrCreateHTTPClient(proxyURL)
provider := m.buildProvider(cfg, client)
return provider.Search(ctx, req)
}
// GetUsage returns the current usage count for the given provider.
func (m *Manager) GetUsage(ctx context.Context, providerType, refreshInterval string) (int64, error) {
if m.redis == nil {
return 0, nil
}
key := quotaRedisKey(providerType, refreshInterval)
val, err := m.redis.Get(ctx, key).Int64()
if err == redis.Nil {
return 0, nil
}
return val, err
}
// GetAllUsage returns usage for every configured provider.
func (m *Manager) GetAllUsage(ctx context.Context) map[string]int64 {
result := make(map[string]int64, len(m.configs))
for _, cfg := range m.configs {
used, _ := m.GetUsage(ctx, cfg.Type, cfg.QuotaRefreshInterval)
result[cfg.Type] = used
}
return result
}
// --- HTTP client cache (bounded) ---
func (m *Manager) getOrCreateHTTPClient(proxyURL string) *http.Client {
m.clientMu.Lock()
defer m.clientMu.Unlock()
if c, ok := m.clientCache[proxyURL]; ok {
return c
}
if len(m.clientCache) >= maxCachedClients {
m.clientCache = make(map[string]*http.Client) // evict all
}
c := newHTTPClient(proxyURL)
m.clientCache[proxyURL] = c
return c
}
func newHTTPClient(proxyURL string) *http.Client {
transport := &http.Transport{
TLSClientConfig: &tls.Config{MinVersion: tls.VersionTLS12},
}
if proxyURL != "" {
if u, err := url.Parse(proxyURL); err == nil {
transport.Proxy = http.ProxyURL(u)
}
}
return &http.Client{Transport: transport, Timeout: searchRequestTimeout}
}
// --- Provider factory ---
func (m *Manager) buildProvider(cfg ProviderConfig, client *http.Client) Provider {
switch cfg.Type {
case braveProviderName:
return NewBraveProvider(cfg.APIKey, client)
case tavilyProviderName:
return NewTavilyProvider(cfg.APIKey, client)
default:
slog.Warn("websearch: unknown provider type, falling back to brave",
"type", cfg.Type)
return NewBraveProvider(cfg.APIKey, client)
}
}
// --- Redis key helpers ---
func quotaRedisKey(providerType, refreshInterval string) string {
return quotaKeyPrefix + providerType + ":" + periodKey(refreshInterval)
}
func periodKey(refreshInterval string) string {
now := time.Now().UTC()
switch refreshInterval {
case QuotaRefreshDaily:
return now.Format("2006-01-02")
case QuotaRefreshWeekly:
year, week := now.ISOWeek()
return fmt.Sprintf("%d-W%02d", year, week)
default: // QuotaRefreshMonthly
return now.Format("2006-01")
}
}
func quotaTTL(refreshInterval string) time.Duration {
switch refreshInterval {
case QuotaRefreshDaily:
return 24*time.Hour + quotaTTLBuffer
case QuotaRefreshWeekly:
return 7*24*time.Hour + quotaTTLBuffer
default:
return 31*24*time.Hour + quotaTTLBuffer
}
}
@@ -0,0 +1,149 @@
package websearch
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/stretchr/testify/require"
)
func TestNewManager_SortsByPriority(t *testing.T) {
configs := []ProviderConfig{
{Type: "brave", APIKey: "k3", Priority: 30},
{Type: "tavily", APIKey: "k1", Priority: 10},
}
m := NewManager(configs, nil)
require.Equal(t, 10, m.configs[0].Priority)
require.Equal(t, 30, m.configs[1].Priority)
}
func TestManager_SearchWithBestProvider_EmptyQuery(t *testing.T) {
m := NewManager([]ProviderConfig{{Type: "brave", APIKey: "k"}}, nil)
_, _, err := m.SearchWithBestProvider(context.Background(), SearchRequest{Query: ""})
require.ErrorContains(t, err, "empty search query")
_, _, err = m.SearchWithBestProvider(context.Background(), SearchRequest{Query: " "})
require.ErrorContains(t, err, "empty search query")
}
func TestManager_SearchWithBestProvider_SkipEmptyAPIKey(t *testing.T) {
m := NewManager([]ProviderConfig{{Type: "brave", APIKey: ""}}, nil)
_, _, err := m.SearchWithBestProvider(context.Background(), SearchRequest{Query: "test"})
require.ErrorContains(t, err, "no available provider")
}
func TestManager_SearchWithBestProvider_SkipExpired(t *testing.T) {
past := time.Now().Add(-1 * time.Hour).Unix()
m := NewManager([]ProviderConfig{
{Type: "brave", APIKey: "k", ExpiresAt: &past},
}, nil)
_, _, err := m.SearchWithBestProvider(context.Background(), SearchRequest{Query: "test"})
require.ErrorContains(t, err, "no available provider")
}
func TestManager_SearchWithBestProvider_PriorityOrder(t *testing.T) {
// Create two mock servers that return different results
srvBrave := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
resp := braveResponse{}
resp.Web.Results = []braveResult{{URL: "https://brave.com", Title: "Brave", Description: "from brave"}}
_ = json.NewEncoder(w).Encode(resp)
}))
defer srvBrave.Close()
// Override brave endpoint for test
origURL := *braveSearchURL
u, _ := http.NewRequest("GET", srvBrave.URL, nil)
*braveSearchURL = *u.URL
defer func() { *braveSearchURL = origURL }()
m := NewManager([]ProviderConfig{
{Type: "brave", APIKey: "k1", Priority: 1},
{Type: "tavily", APIKey: "k2", Priority: 2},
}, nil)
// Inject the test server's client
m.clientCache[srvBrave.URL] = srvBrave.Client()
m.clientCache[""] = srvBrave.Client()
resp, providerName, err := m.SearchWithBestProvider(context.Background(), SearchRequest{Query: "test"})
require.NoError(t, err)
require.Equal(t, "brave", providerName)
require.Len(t, resp.Results, 1)
require.Equal(t, "from brave", resp.Results[0].Snippet)
}
func TestManager_SearchWithBestProvider_NilRedis(t *testing.T) {
// With nil Redis, quota check is skipped (always allowed)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
resp := braveResponse{}
resp.Web.Results = []braveResult{{URL: "https://test.com", Title: "Test", Description: "result"}}
_ = json.NewEncoder(w).Encode(resp)
}))
defer srv.Close()
origURL := *braveSearchURL
u, _ := http.NewRequest("GET", srv.URL, nil)
*braveSearchURL = *u.URL
defer func() { *braveSearchURL = origURL }()
m := NewManager([]ProviderConfig{
{Type: "brave", APIKey: "k", Priority: 1, QuotaLimit: 100},
}, nil) // nil Redis
m.clientCache[""] = srv.Client()
resp, _, err := m.SearchWithBestProvider(context.Background(), SearchRequest{Query: "test"})
require.NoError(t, err)
require.Len(t, resp.Results, 1)
}
func TestManager_GetUsage_NilRedis(t *testing.T) {
m := NewManager(nil, nil)
used, err := m.GetUsage(context.Background(), "brave", "monthly")
require.NoError(t, err)
require.Equal(t, int64(0), used)
}
func TestManager_GetAllUsage_NilRedis(t *testing.T) {
m := NewManager([]ProviderConfig{
{Type: "brave", QuotaRefreshInterval: "monthly"},
}, nil)
usage := m.GetAllUsage(context.Background())
require.Equal(t, int64(0), usage["brave"])
}
// --- Key/TTL helpers ---
func TestQuotaTTL_Daily(t *testing.T) {
require.Equal(t, 24*time.Hour+quotaTTLBuffer, quotaTTL(QuotaRefreshDaily))
}
func TestQuotaTTL_Weekly(t *testing.T) {
require.Equal(t, 7*24*time.Hour+quotaTTLBuffer, quotaTTL(QuotaRefreshWeekly))
}
func TestQuotaTTL_Monthly(t *testing.T) {
require.Equal(t, 31*24*time.Hour+quotaTTLBuffer, quotaTTL(QuotaRefreshMonthly))
}
func TestPeriodKey_Daily(t *testing.T) {
key := periodKey(QuotaRefreshDaily)
require.Regexp(t, `^\d{4}-\d{2}-\d{2}$`, key)
}
func TestPeriodKey_Weekly(t *testing.T) {
key := periodKey(QuotaRefreshWeekly)
require.Regexp(t, `^\d{4}-W\d{2}$`, key)
}
func TestPeriodKey_Monthly(t *testing.T) {
key := periodKey(QuotaRefreshMonthly)
require.Regexp(t, `^\d{4}-\d{2}$`, key)
}
func TestQuotaRedisKey_Format(t *testing.T) {
key := quotaRedisKey("brave", QuotaRefreshDaily)
require.Contains(t, key, "websearch:quota:brave:")
}
@@ -0,0 +1,11 @@
package websearch
import "context"
// Provider is the interface every search backend must implement.
type Provider interface {
// Name returns the provider identifier ("brave" or "tavily").
Name() string
// Search executes a web search and returns results.
Search(ctx context.Context, req SearchRequest) (*SearchResponse, error)
}
+107
View File
@@ -0,0 +1,107 @@
package websearch
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
)
const (
tavilySearchEndpoint = "https://api.tavily.com/search"
tavilyProviderName = "tavily"
tavilySearchDepthBasic = "basic"
)
// TavilyProvider implements web search via the Tavily Search API.
type TavilyProvider struct {
apiKey string
httpClient *http.Client
}
// NewTavilyProvider creates a Tavily Search provider.
// The caller is responsible for configuring the http.Client with proxy/timeouts.
func NewTavilyProvider(apiKey string, httpClient *http.Client) *TavilyProvider {
if httpClient == nil {
httpClient = http.DefaultClient
}
return &TavilyProvider{apiKey: apiKey, httpClient: httpClient}
}
func (t *TavilyProvider) Name() string { return tavilyProviderName }
func (t *TavilyProvider) Search(ctx context.Context, req SearchRequest) (*SearchResponse, error) {
maxResults := req.MaxResults
if maxResults <= 0 {
maxResults = defaultMaxResults
}
payload := tavilyRequest{
APIKey: t.apiKey,
Query: req.Query,
MaxResults: maxResults,
SearchDepth: tavilySearchDepthBasic,
}
bodyBytes, err := json.Marshal(payload)
if err != nil {
return nil, fmt.Errorf("tavily: encode request: %w", err)
}
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, tavilySearchEndpoint, bytes.NewReader(bodyBytes))
if err != nil {
return nil, fmt.Errorf("tavily: build request: %w", err)
}
httpReq.Header.Set("Content-Type", "application/json")
resp, err := t.httpClient.Do(httpReq)
if err != nil {
return nil, fmt.Errorf("tavily: request failed: %w", err)
}
defer func() { _ = resp.Body.Close() }()
body, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseSize))
if err != nil {
return nil, fmt.Errorf("tavily: read body: %w", err)
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("tavily: status %d: %s", resp.StatusCode, truncateBody(body))
}
var raw tavilyResponse
if err := json.Unmarshal(body, &raw); err != nil {
return nil, fmt.Errorf("tavily: decode response: %w", err)
}
results := make([]SearchResult, 0, len(raw.Results))
for _, r := range raw.Results {
results = append(results, SearchResult{
URL: r.URL,
Title: r.Title,
Snippet: r.Content,
})
}
return &SearchResponse{Results: results, Query: req.Query}, nil
}
type tavilyRequest struct {
APIKey string `json:"api_key"`
Query string `json:"query"`
MaxResults int `json:"max_results"`
SearchDepth string `json:"search_depth"`
}
type tavilyResponse struct {
Results []tavilyResult `json:"results"`
}
type tavilyResult struct {
URL string `json:"url"`
Title string `json:"title"`
Content string `json:"content"`
Score float64 `json:"score"`
}
@@ -0,0 +1,63 @@
package websearch
import (
"encoding/json"
"testing"
"github.com/stretchr/testify/require"
)
func TestTavilyProvider_Name(t *testing.T) {
p := NewTavilyProvider("key", nil)
require.Equal(t, "tavily", p.Name())
}
func TestTavilyProvider_Search_RequestConstruction(t *testing.T) {
// Verify tavilyRequest struct fields map correctly
req := tavilyRequest{
APIKey: "test-key",
Query: "golang",
MaxResults: 3,
SearchDepth: tavilySearchDepthBasic,
}
data, err := json.Marshal(req)
require.NoError(t, err)
var parsed map[string]any
require.NoError(t, json.Unmarshal(data, &parsed))
require.Equal(t, "test-key", parsed["api_key"])
require.Equal(t, "golang", parsed["query"])
require.Equal(t, float64(3), parsed["max_results"])
require.Equal(t, "basic", parsed["search_depth"])
}
func TestTavilyProvider_Search_ResponseParsing(t *testing.T) {
rawResp := `{"results":[{"url":"https://go.dev","title":"Go","content":"Go programming language","score":0.95}]}`
var resp tavilyResponse
require.NoError(t, json.Unmarshal([]byte(rawResp), &resp))
require.Len(t, resp.Results, 1)
require.Equal(t, "https://go.dev", resp.Results[0].URL)
require.Equal(t, "Go programming language", resp.Results[0].Content)
require.InDelta(t, 0.95, resp.Results[0].Score, 0.001)
// Verify mapping to SearchResult
results := make([]SearchResult, 0, len(resp.Results))
for _, r := range resp.Results {
results = append(results, SearchResult{
URL: r.URL, Title: r.Title, Snippet: r.Content,
})
}
require.Equal(t, "Go programming language", results[0].Snippet)
require.Equal(t, "", results[0].PageAge)
}
func TestTavilyProvider_Search_EmptyResults(t *testing.T) {
var resp tavilyResponse
require.NoError(t, json.Unmarshal([]byte(`{"results":[]}`), &resp))
require.Empty(t, resp.Results)
}
func TestTavilyProvider_Search_InvalidJSON(t *testing.T) {
var resp tavilyResponse
require.Error(t, json.Unmarshal([]byte("not json"), &resp))
}
+30
View File
@@ -0,0 +1,30 @@
package websearch
// SearchResult represents a single web search result.
type SearchResult struct {
URL string `json:"url"`
Title string `json:"title"`
Snippet string `json:"snippet"`
PageAge string `json:"page_age,omitempty"`
}
// SearchRequest describes a web search to perform.
type SearchRequest struct {
Query string
MaxResults int // defaults to defaultMaxResults if <= 0
ProxyURL string // optional HTTP proxy URL
}
// SearchResponse holds the results of a web search.
type SearchResponse struct {
Results []SearchResult
Query string // the query that was actually executed
}
const defaultMaxResults = 5
// Provider type identifiers.
const (
ProviderTypeBrave = "brave"
ProviderTypeTavily = "tavily"
)
+47 -14
View File
@@ -41,10 +41,14 @@ func (r *channelRepository) Create(ctx context.Context, channel *service.Channel
if err != nil {
return err
}
featuresConfigJSON, err := marshalFeaturesConfig(channel.FeaturesConfig)
if err != nil {
return err
}
err = tx.QueryRowContext(ctx,
`INSERT INTO channels (name, description, status, model_mapping, billing_model_source, restrict_models, features, apply_pricing_to_account_stats) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
`INSERT INTO channels (name, description, status, model_mapping, billing_model_source, restrict_models, features, features_config, apply_pricing_to_account_stats) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
RETURNING id, created_at, updated_at`,
channel.Name, channel.Description, channel.Status, modelMappingJSON, channel.BillingModelSource, channel.RestrictModels, channel.Features, channel.ApplyPricingToAccountStats,
channel.Name, channel.Description, channel.Status, modelMappingJSON, channel.BillingModelSource, channel.RestrictModels, channel.Features, featuresConfigJSON, channel.ApplyPricingToAccountStats,
).Scan(&channel.ID, &channel.CreatedAt, &channel.UpdatedAt)
if err != nil {
if isUniqueViolation(err) {
@@ -80,11 +84,11 @@ func (r *channelRepository) Create(ctx context.Context, channel *service.Channel
func (r *channelRepository) GetByID(ctx context.Context, id int64) (*service.Channel, error) {
ch := &service.Channel{}
var modelMappingJSON []byte
var modelMappingJSON, featuresConfigJSON []byte
err := r.db.QueryRowContext(ctx,
`SELECT id, name, description, status, model_mapping, billing_model_source, restrict_models, features, apply_pricing_to_account_stats, created_at, updated_at
`SELECT id, name, description, status, model_mapping, billing_model_source, restrict_models, features, features_config, apply_pricing_to_account_stats, created_at, updated_at
FROM channels WHERE id = $1`, id,
).Scan(&ch.ID, &ch.Name, &ch.Description, &ch.Status, &modelMappingJSON, &ch.BillingModelSource, &ch.RestrictModels, &ch.Features, &ch.ApplyPricingToAccountStats, &ch.CreatedAt, &ch.UpdatedAt)
).Scan(&ch.ID, &ch.Name, &ch.Description, &ch.Status, &modelMappingJSON, &ch.BillingModelSource, &ch.RestrictModels, &ch.Features, &featuresConfigJSON, &ch.ApplyPricingToAccountStats, &ch.CreatedAt, &ch.UpdatedAt)
if err == sql.ErrNoRows {
return nil, service.ErrChannelNotFound
}
@@ -92,6 +96,7 @@ func (r *channelRepository) GetByID(ctx context.Context, id int64) (*service.Cha
return nil, fmt.Errorf("get channel: %w", err)
}
ch.ModelMapping = unmarshalModelMapping(modelMappingJSON)
ch.FeaturesConfig = unmarshalFeaturesConfig(featuresConfigJSON)
groupIDs, err := r.GetGroupIDs(ctx, id)
if err != nil {
@@ -120,10 +125,14 @@ func (r *channelRepository) Update(ctx context.Context, channel *service.Channel
if err != nil {
return err
}
featuresConfigJSON, err := marshalFeaturesConfig(channel.FeaturesConfig)
if err != nil {
return err
}
result, err := tx.ExecContext(ctx,
`UPDATE channels SET name = $1, description = $2, status = $3, model_mapping = $4, billing_model_source = $5, restrict_models = $6, features = $7, apply_pricing_to_account_stats = $8, updated_at = NOW()
WHERE id = $9`,
channel.Name, channel.Description, channel.Status, modelMappingJSON, channel.BillingModelSource, channel.RestrictModels, channel.Features, channel.ApplyPricingToAccountStats, channel.ID,
`UPDATE channels SET name = $1, description = $2, status = $3, model_mapping = $4, billing_model_source = $5, restrict_models = $6, features = $7, features_config = $8, apply_pricing_to_account_stats = $9, updated_at = NOW()
WHERE id = $10`,
channel.Name, channel.Description, channel.Status, modelMappingJSON, channel.BillingModelSource, channel.RestrictModels, channel.Features, featuresConfigJSON, channel.ApplyPricingToAccountStats, channel.ID,
)
if err != nil {
if isUniqueViolation(err) {
@@ -207,7 +216,7 @@ func (r *channelRepository) List(ctx context.Context, params pagination.Paginati
// 查询 channel 列表
dataQuery := fmt.Sprintf(
`SELECT c.id, c.name, c.description, c.status, c.model_mapping, c.billing_model_source, c.restrict_models, c.features, c.apply_pricing_to_account_stats, c.created_at, c.updated_at
`SELECT c.id, c.name, c.description, c.status, c.model_mapping, c.billing_model_source, c.restrict_models, c.features, c.features_config, c.apply_pricing_to_account_stats, c.created_at, c.updated_at
FROM channels c WHERE %s ORDER BY %s LIMIT $%d OFFSET $%d`,
whereClause, channelListOrderBy(params), argIdx, argIdx+1,
)
@@ -223,11 +232,12 @@ func (r *channelRepository) List(ctx context.Context, params pagination.Paginati
var channelIDs []int64
for rows.Next() {
var ch service.Channel
var modelMappingJSON []byte
if err := rows.Scan(&ch.ID, &ch.Name, &ch.Description, &ch.Status, &modelMappingJSON, &ch.BillingModelSource, &ch.RestrictModels, &ch.Features, &ch.ApplyPricingToAccountStats, &ch.CreatedAt, &ch.UpdatedAt); err != nil {
var modelMappingJSON, featuresConfigJSON []byte
if err := rows.Scan(&ch.ID, &ch.Name, &ch.Description, &ch.Status, &modelMappingJSON, &ch.BillingModelSource, &ch.RestrictModels, &ch.Features, &featuresConfigJSON, &ch.ApplyPricingToAccountStats, &ch.CreatedAt, &ch.UpdatedAt); err != nil {
return nil, nil, fmt.Errorf("scan channel: %w", err)
}
ch.ModelMapping = unmarshalModelMapping(modelMappingJSON)
ch.FeaturesConfig = unmarshalFeaturesConfig(featuresConfigJSON)
channels = append(channels, ch)
channelIDs = append(channelIDs, ch.ID)
}
@@ -298,7 +308,7 @@ func channelListOrderBy(params pagination.PaginationParams) string {
func (r *channelRepository) ListAll(ctx context.Context) ([]service.Channel, error) {
rows, err := r.db.QueryContext(ctx,
`SELECT id, name, description, status, model_mapping, billing_model_source, restrict_models, features, apply_pricing_to_account_stats, created_at, updated_at FROM channels ORDER BY id`,
`SELECT id, name, description, status, model_mapping, billing_model_source, restrict_models, features, features_config, apply_pricing_to_account_stats, created_at, updated_at FROM channels ORDER BY id`,
)
if err != nil {
return nil, fmt.Errorf("query all channels: %w", err)
@@ -309,11 +319,12 @@ func (r *channelRepository) ListAll(ctx context.Context) ([]service.Channel, err
var channelIDs []int64
for rows.Next() {
var ch service.Channel
var modelMappingJSON []byte
if err := rows.Scan(&ch.ID, &ch.Name, &ch.Description, &ch.Status, &modelMappingJSON, &ch.BillingModelSource, &ch.RestrictModels, &ch.Features, &ch.ApplyPricingToAccountStats, &ch.CreatedAt, &ch.UpdatedAt); err != nil {
var modelMappingJSON, featuresConfigJSON []byte
if err := rows.Scan(&ch.ID, &ch.Name, &ch.Description, &ch.Status, &modelMappingJSON, &ch.BillingModelSource, &ch.RestrictModels, &ch.Features, &featuresConfigJSON, &ch.ApplyPricingToAccountStats, &ch.CreatedAt, &ch.UpdatedAt); err != nil {
return nil, fmt.Errorf("scan channel: %w", err)
}
ch.ModelMapping = unmarshalModelMapping(modelMappingJSON)
ch.FeaturesConfig = unmarshalFeaturesConfig(featuresConfigJSON)
channels = append(channels, ch)
channelIDs = append(channelIDs, ch.ID)
}
@@ -488,6 +499,28 @@ func unmarshalModelMapping(data []byte) map[string]map[string]string {
return m
}
func marshalFeaturesConfig(m map[string]any) ([]byte, error) {
if len(m) == 0 {
return []byte("{}"), nil
}
data, err := json.Marshal(m)
if err != nil {
return nil, fmt.Errorf("marshal features_config: %w", err)
}
return data, nil
}
func unmarshalFeaturesConfig(data []byte) map[string]any {
if len(data) == 0 {
return nil
}
var m map[string]any
if err := json.Unmarshal(data, &m); err != nil {
return nil
}
return m
}
// GetGroupPlatforms 批量查询分组 ID 对应的平台
func (r *channelRepository) GetGroupPlatforms(ctx context.Context, groupIDs []int64) (map[int64]string, error) {
if len(groupIDs) == 0 {
@@ -586,6 +586,7 @@ func TestAPIContracts(t *testing.T) {
"enable_cch_signing": false,
"enable_fingerprint_unification": true,
"enable_metadata_passthrough": false,
"web_search_emulation_enabled": false,
"custom_menu_items": [],
"custom_endpoints": [],
"payment_enabled": false,
+3
View File
@@ -407,6 +407,9 @@ func registerSettingsRoutes(admin *gin.RouterGroup, h *handler.Handlers) {
// Beta 策略配置
adminSettings.GET("/beta-policy", h.Admin.Setting.GetBetaPolicySettings)
adminSettings.PUT("/beta-policy", h.Admin.Setting.UpdateBetaPolicySettings)
// Web Search 模拟配置
adminSettings.GET("/web-search-emulation", h.Admin.Setting.GetWebSearchEmulationConfig)
adminSettings.PUT("/web-search-emulation", h.Admin.Setting.UpdateWebSearchEmulationConfig)
}
}
+15 -4
View File
@@ -969,7 +969,7 @@ func (a *Account) IsOveragesEnabled() bool {
return false
}
// IsOpenAIPassthroughEnabled 返回 OpenAI 账号是否启用“自动透传(仅替换认证)”。
// IsOpenAIPassthroughEnabled 返回 OpenAI 账号是否启用"自动透传(仅替换认证)"。
//
// 新字段:accounts.extra.openai_passthrough。
// 兼容字段:accounts.extra.openai_oauth_passthrough(历史 OAuth 开关)。
@@ -1133,7 +1133,7 @@ func (a *Account) ResolveOpenAIResponsesWebSocketV2Mode(defaultMode string) stri
return resolvedDefault
}
// IsOpenAIWSForceHTTPEnabled 返回账号级“强制 HTTP”开关。
// IsOpenAIWSForceHTTPEnabled 返回账号级"强制 HTTP"开关。
// 字段:accounts.extra.openai_ws_force_http。
func (a *Account) IsOpenAIWSForceHTTPEnabled() bool {
if a == nil || !a.IsOpenAI() || a.Extra == nil {
@@ -1158,7 +1158,7 @@ func (a *Account) IsOpenAIOAuthPassthroughEnabled() bool {
return a != nil && a.IsOpenAIOAuth() && a.IsOpenAIPassthroughEnabled()
}
// IsAnthropicAPIKeyPassthroughEnabled 返回 Anthropic API Key 账号是否启用“自动透传(仅替换认证)”。
// IsAnthropicAPIKeyPassthroughEnabled 返回 Anthropic API Key 账号是否启用"自动透传(仅替换认证)"。
// 字段:accounts.extra.anthropic_passthrough。
// 字段缺失或类型不正确时,按 false(关闭)处理。
func (a *Account) IsAnthropicAPIKeyPassthroughEnabled() bool {
@@ -1169,7 +1169,18 @@ func (a *Account) IsAnthropicAPIKeyPassthroughEnabled() bool {
return ok && enabled
}
// IsCodexCLIOnlyEnabled 返回 OpenAI OAuth 账号是否启用“仅允许 Codex 官方客户端”。
// IsWebSearchEmulationEnabled 返回 Anthropic API Key 账号是否启用 web search 模拟。
// 字段:accounts.extra.web_search_emulation。
// 字段缺失或类型不正确时,按 false(关闭)处理。
func (a *Account) IsWebSearchEmulationEnabled() bool {
if a == nil || a.Platform != PlatformAnthropic || a.Type != AccountTypeAPIKey || a.Extra == nil {
return false
}
enabled, ok := a.Extra[featureKeyWebSearchEmulation].(bool)
return ok && enabled
}
// IsCodexCLIOnlyEnabled 返回 OpenAI OAuth 账号是否启用"仅允许 Codex 官方客户端"。
// 字段:accounts.extra.codex_cli_only。
// 字段缺失或类型不正确时,按 false(关闭)处理。
func (a *Account) IsCodexCLIOnlyEnabled() bool {
@@ -0,0 +1,71 @@
package service
import (
"testing"
"github.com/stretchr/testify/require"
)
func TestAccount_IsWebSearchEmulationEnabled_Enabled(t *testing.T) {
a := &Account{
Platform: PlatformAnthropic,
Type: AccountTypeAPIKey,
Extra: map[string]any{featureKeyWebSearchEmulation: true},
}
require.True(t, a.IsWebSearchEmulationEnabled())
}
func TestAccount_IsWebSearchEmulationEnabled_Disabled(t *testing.T) {
a := &Account{
Platform: PlatformAnthropic,
Type: AccountTypeAPIKey,
Extra: map[string]any{featureKeyWebSearchEmulation: false},
}
require.False(t, a.IsWebSearchEmulationEnabled())
}
func TestAccount_IsWebSearchEmulationEnabled_MissingField(t *testing.T) {
a := &Account{
Platform: PlatformAnthropic,
Type: AccountTypeAPIKey,
Extra: map[string]any{},
}
require.False(t, a.IsWebSearchEmulationEnabled())
}
func TestAccount_IsWebSearchEmulationEnabled_WrongType(t *testing.T) {
a := &Account{
Platform: PlatformAnthropic,
Type: AccountTypeAPIKey,
Extra: map[string]any{featureKeyWebSearchEmulation: "true"},
}
require.False(t, a.IsWebSearchEmulationEnabled())
}
func TestAccount_IsWebSearchEmulationEnabled_NilExtra(t *testing.T) {
a := &Account{Platform: PlatformAnthropic, Type: AccountTypeAPIKey, Extra: nil}
require.False(t, a.IsWebSearchEmulationEnabled())
}
func TestAccount_IsWebSearchEmulationEnabled_NilAccount(t *testing.T) {
var a *Account
require.False(t, a.IsWebSearchEmulationEnabled())
}
func TestAccount_IsWebSearchEmulationEnabled_NonAnthropicPlatform(t *testing.T) {
a := &Account{
Platform: PlatformOpenAI,
Type: AccountTypeAPIKey,
Extra: map[string]any{featureKeyWebSearchEmulation: true},
}
require.False(t, a.IsWebSearchEmulationEnabled())
}
func TestAccount_IsWebSearchEmulationEnabled_NonAPIKeyType(t *testing.T) {
a := &Account{
Platform: PlatformAnthropic,
Type: AccountTypeOAuth,
Extra: map[string]any{featureKeyWebSearchEmulation: true},
}
require.False(t, a.IsWebSearchEmulationEnabled())
}
+15
View File
@@ -49,6 +49,8 @@ type Channel struct {
ModelPricing []ChannelModelPricing
// 渠道级模型映射(按平台分组:platform → {src→dst})
ModelMapping map[string]map[string]string
// 渠道特性配置(如 {"web_search_emulation": {"anthropic": true}})
FeaturesConfig map[string]any
// 账号统计定价
ApplyPricingToAccountStats bool // 是否应用渠道模型定价到账号统计
@@ -70,6 +72,19 @@ type AccountStatsPricingRule struct {
UpdatedAt time.Time
}
// IsWebSearchEmulationEnabled 返回该渠道是否为指定平台启用了 web search 模拟。
func (c *Channel) IsWebSearchEmulationEnabled(platform string) bool {
if c == nil || c.FeaturesConfig == nil {
return false
}
wse, ok := c.FeaturesConfig[featureKeyWebSearchEmulation].(map[string]any)
if !ok {
return false
}
enabled, ok := wse[platform].(bool)
return ok && enabled
}
// ChannelModelPricing 渠道模型定价条目
type ChannelModelPricing struct {
ID int64
@@ -674,6 +674,7 @@ func (s *ChannelService) Create(ctx context.Context, input *CreateChannelInput)
ModelPricing: input.ModelPricing,
ModelMapping: input.ModelMapping,
Features: input.Features,
FeaturesConfig: input.FeaturesConfig,
ApplyPricingToAccountStats: input.ApplyPricingToAccountStats,
AccountStatsPricingRules: input.AccountStatsPricingRules,
}
@@ -764,6 +765,9 @@ func (s *ChannelService) applyUpdateInput(ctx context.Context, channel *Channel,
if input.BillingModelSource != "" {
channel.BillingModelSource = input.BillingModelSource
}
if input.FeaturesConfig != nil {
channel.FeaturesConfig = input.FeaturesConfig
}
if input.ApplyPricingToAccountStats != nil {
channel.ApplyPricingToAccountStats = *input.ApplyPricingToAccountStats
}
@@ -943,6 +947,7 @@ type CreateChannelInput struct {
BillingModelSource string
RestrictModels bool
Features string
FeaturesConfig map[string]any
ApplyPricingToAccountStats bool
AccountStatsPricingRules []AccountStatsPricingRule
}
@@ -958,6 +963,7 @@ type UpdateChannelInput struct {
BillingModelSource string
RestrictModels *bool
Features *string
FeaturesConfig map[string]any
ApplyPricingToAccountStats *bool
AccountStatsPricingRules *[]AccountStatsPricingRule
}
@@ -0,0 +1,62 @@
package service
import (
"testing"
"github.com/stretchr/testify/require"
)
func TestChannel_IsWebSearchEmulationEnabled_Enabled(t *testing.T) {
c := &Channel{
FeaturesConfig: map[string]any{
featureKeyWebSearchEmulation: map[string]any{"anthropic": true},
},
}
require.True(t, c.IsWebSearchEmulationEnabled("anthropic"))
}
func TestChannel_IsWebSearchEmulationEnabled_DifferentPlatform(t *testing.T) {
c := &Channel{
FeaturesConfig: map[string]any{
featureKeyWebSearchEmulation: map[string]any{"anthropic": true},
},
}
require.False(t, c.IsWebSearchEmulationEnabled("openai"))
}
func TestChannel_IsWebSearchEmulationEnabled_Disabled(t *testing.T) {
c := &Channel{
FeaturesConfig: map[string]any{
featureKeyWebSearchEmulation: map[string]any{"anthropic": false},
},
}
require.False(t, c.IsWebSearchEmulationEnabled("anthropic"))
}
func TestChannel_IsWebSearchEmulationEnabled_NilFeaturesConfig(t *testing.T) {
c := &Channel{FeaturesConfig: nil}
require.False(t, c.IsWebSearchEmulationEnabled("anthropic"))
}
func TestChannel_IsWebSearchEmulationEnabled_NilChannel(t *testing.T) {
var c *Channel
require.False(t, c.IsWebSearchEmulationEnabled("anthropic"))
}
func TestChannel_IsWebSearchEmulationEnabled_WrongStructure(t *testing.T) {
c := &Channel{
FeaturesConfig: map[string]any{
featureKeyWebSearchEmulation: true, // not a map
},
}
require.False(t, c.IsWebSearchEmulationEnabled("anthropic"))
}
func TestChannel_IsWebSearchEmulationEnabled_PlatformValueNotBool(t *testing.T) {
c := &Channel{
FeaturesConfig: map[string]any{
featureKeyWebSearchEmulation: map[string]any{"anthropic": "yes"},
},
}
require.False(t, c.IsWebSearchEmulationEnabled("anthropic"))
}
@@ -249,6 +249,10 @@ const (
SettingKeyEnableMetadataPassthrough = "enable_metadata_passthrough"
// SettingKeyEnableCCHSigning 是否对 billing header 中的 cch 进行 xxHash64 签名(默认 false)
SettingKeyEnableCCHSigning = "enable_cch_signing"
// Web Search Emulation
// SettingKeyWebSearchEmulationConfig 全局 web search 模拟配置(JSON)
SettingKeyWebSearchEmulationConfig = "web_search_emulation_config"
)
// AdminAPIKeyPrefix is the prefix for admin API keys (distinct from user "sk-" keys).
@@ -3932,6 +3932,11 @@ func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *A
return nil, fmt.Errorf("parse request: empty request")
}
// Web Search 模拟:纯 web_search 请求时,直接调用搜索 API 构造响应
if account != nil && s.shouldEmulateWebSearch(ctx, account, parsed.Body) {
return s.handleWebSearchEmulation(ctx, c, account, parsed)
}
if account != nil && account.IsAnthropicAPIKeyPassthroughEnabled() {
passthroughBody := parsed.Body
passthroughModel := parsed.Model
@@ -0,0 +1,358 @@
package service
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"net/http"
"strings"
"sync/atomic"
"time"
"github.com/Wei-Shaw/sub2api/internal/pkg/websearch"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/tidwall/gjson"
)
// Web search emulation constants
const (
toolTypeWebSearchPrefix = "web_search"
toolTypeGoogleSearch = "google_search"
toolNameWebSearch = "web_search"
toolNameGoogleSearch = "google_search"
toolNameWebSearch2025 = "web_search_20250305"
webSearchDefaultMaxResults = 5
defaultWebSearchModel = "claude-sonnet-4-6"
webSearchMsgIDPrefix = "msg_ws_"
webSearchToolUseIDPrefix = "srvtoolu_ws_"
tokenEstimateDivisor = 4
// featureKeyWebSearchEmulation is the key used in Account.Extra and Channel.FeaturesConfig.
featureKeyWebSearchEmulation = "web_search_emulation"
)
// webSearchManagerPtr stores *websearch.Manager atomically for concurrent safety.
var webSearchManagerPtr atomic.Pointer[websearch.Manager]
// SetWebSearchManager wires the websearch.Manager into the gateway (goroutine-safe).
func SetWebSearchManager(m *websearch.Manager) {
webSearchManagerPtr.Store(m)
}
func getWebSearchManager() *websearch.Manager {
return webSearchManagerPtr.Load()
}
// shouldEmulateWebSearch checks whether a request should be intercepted.
//
// Judgment chain: manager exists → only web_search tool → global enabled → account enabled.
// Note: channel-level control is enforced via the account's extra field; the channel toggle
// in the admin UI sets the account's flag for all accounts in that channel's groups.
func (s *GatewayService) shouldEmulateWebSearch(ctx context.Context, account *Account, body []byte) bool {
if getWebSearchManager() == nil {
return false
}
if !isOnlyWebSearchToolInBody(body) {
return false
}
if !s.settingService.IsWebSearchEmulationEnabled(ctx) {
return false
}
if !account.IsWebSearchEmulationEnabled() {
return false
}
return true
}
// isOnlyWebSearchToolInBody checks if the body contains exactly one web_search tool.
func isOnlyWebSearchToolInBody(body []byte) bool {
tools := gjson.GetBytes(body, "tools")
if !tools.IsArray() {
return false
}
arr := tools.Array()
if len(arr) != 1 {
return false
}
return isWebSearchToolJSON(arr[0])
}
func isWebSearchToolJSON(tool gjson.Result) bool {
toolType := tool.Get("type").String()
if strings.HasPrefix(toolType, toolTypeWebSearchPrefix) || toolType == toolTypeGoogleSearch {
return true
}
switch tool.Get("name").String() {
case toolNameWebSearch, toolNameGoogleSearch, toolNameWebSearch2025:
return true
}
return false
}
// extractSearchQueryFromBody extracts the last user message text as the search query.
func extractSearchQueryFromBody(body []byte) string {
messages := gjson.GetBytes(body, "messages")
if !messages.IsArray() {
return ""
}
arr := messages.Array()
if len(arr) == 0 {
return ""
}
lastMsg := arr[len(arr)-1]
if lastMsg.Get("role").String() != "user" {
return ""
}
return extractWebSearchTextFromContent(lastMsg.Get("content"))
}
func extractWebSearchTextFromContent(content gjson.Result) string {
if content.Type == gjson.String {
return content.String()
}
if content.IsArray() {
for _, block := range content.Array() {
if block.Get("type").String() == "text" {
if text := block.Get("text").String(); text != "" {
return text
}
}
}
}
return ""
}
// handleWebSearchEmulation intercepts a web-search-only request,
// calls a third-party search API, and constructs an Anthropic-format response.
func (s *GatewayService) handleWebSearchEmulation(
ctx context.Context, c *gin.Context, account *Account, parsed *ParsedRequest,
) (*ForwardResult, error) {
startTime := time.Now()
// Release the serial queue lock immediately — we don't need upstream.
if parsed.OnUpstreamAccepted != nil {
parsed.OnUpstreamAccepted()
}
query := extractSearchQueryFromBody(parsed.Body)
if query == "" {
return nil, fmt.Errorf("web search emulation: no query found in messages")
}
slog.Info("web search emulation: executing search",
"account_id", account.ID, "account_name", account.Name, "query", query)
resp, providerName, err := doWebSearch(ctx, account, query)
if err != nil {
return nil, err
}
slog.Info("web search emulation: search completed",
"provider", providerName, "results_count", len(resp.Results))
model := parsed.Model
if model == "" {
model = defaultWebSearchModel
}
if parsed.Stream {
return writeWebSearchStreamResponse(c, query, resp, model, startTime)
}
return writeWebSearchNonStreamResponse(c, query, resp, model, startTime)
}
func doWebSearch(ctx context.Context, account *Account, query string) (*websearch.SearchResponse, string, error) {
proxyURL := resolveAccountProxyURL(account)
mgr := getWebSearchManager()
if mgr == nil {
return nil, "", fmt.Errorf("web search emulation: manager not initialized")
}
resp, providerName, err := mgr.SearchWithBestProvider(ctx, websearch.SearchRequest{
Query: query, MaxResults: webSearchDefaultMaxResults, ProxyURL: proxyURL,
})
if err != nil {
slog.Error("web search emulation: search failed", "error", err)
return nil, "", fmt.Errorf("web search emulation: %w", err)
}
return resp, providerName, nil
}
func resolveAccountProxyURL(account *Account) string {
if account.ProxyID != nil && account.Proxy != nil {
return account.Proxy.URL()
}
return ""
}
// --- SSE streaming response ---
func writeWebSearchStreamResponse(
c *gin.Context, query string, resp *websearch.SearchResponse, model string, startTime time.Time,
) (*ForwardResult, error) {
msgID := webSearchMsgIDPrefix + uuid.New().String()
toolUseID := webSearchToolUseIDPrefix + uuid.New().String()[:16]
setSSEHeaders(c)
if err := writeSSEMessageStart(c.Writer, msgID, model); err != nil {
return nil, fmt.Errorf("web search emulation: SSE write: %w", err)
}
writeSSEServerToolUse(c.Writer, toolUseID, query, 0)
writeSSEToolResult(c.Writer, toolUseID, resp.Results, 1)
textSummary := buildTextSummary(query, resp.Results)
writeSSETextBlock(c.Writer, textSummary, 2)
writeSSEMessageEnd(c.Writer, len(textSummary)/tokenEstimateDivisor)
c.Writer.Flush()
return &ForwardResult{Model: model, Duration: time.Since(startTime), Usage: ClaudeUsage{}}, nil
}
func setSSEHeaders(c *gin.Context) {
c.Writer.Header().Set("Content-Type", "text/event-stream")
c.Writer.Header().Set("Cache-Control", "no-cache")
c.Writer.Header().Set("Connection", "keep-alive")
c.Writer.Header().Set("X-Accel-Buffering", "no")
c.Writer.WriteHeader(http.StatusOK)
}
func writeSSEMessageStart(w http.ResponseWriter, msgID, model string) error {
evt := map[string]any{
"type": "message_start",
"message": map[string]any{
"id": msgID, "type": "message", "role": "assistant", "model": model,
"content": []any{}, "stop_reason": nil, "stop_sequence": nil,
"usage": map[string]int{"input_tokens": 0, "output_tokens": 0},
},
}
return flushSSEJSON(w, "message_start", evt)
}
func writeSSEServerToolUse(w http.ResponseWriter, toolUseID, query string, index int) {
start := map[string]any{
"type": "content_block_start", "index": index,
"content_block": map[string]any{
"type": "server_tool_use", "id": toolUseID,
"name": toolNameWebSearch, "input": map[string]string{"query": query},
},
}
_ = flushSSEJSON(w, "content_block_start", start)
_ = flushSSEJSON(w, "content_block_stop", map[string]any{"type": "content_block_stop", "index": index})
}
func writeSSEToolResult(w http.ResponseWriter, toolUseID string, results []websearch.SearchResult, index int) {
start := map[string]any{
"type": "content_block_start", "index": index,
"content_block": map[string]any{
"type": "web_search_tool_result", "tool_use_id": toolUseID,
"content": buildSearchResultBlocks(results),
},
}
_ = flushSSEJSON(w, "content_block_start", start)
_ = flushSSEJSON(w, "content_block_stop", map[string]any{"type": "content_block_stop", "index": index})
}
func writeSSETextBlock(w http.ResponseWriter, text string, index int) {
_ = flushSSEJSON(w, "content_block_start", map[string]any{
"type": "content_block_start", "index": index,
"content_block": map[string]any{"type": "text", "text": ""},
})
_ = flushSSEJSON(w, "content_block_delta", map[string]any{
"type": "content_block_delta", "index": index,
"delta": map[string]string{"type": "text_delta", "text": text},
})
_ = flushSSEJSON(w, "content_block_stop", map[string]any{"type": "content_block_stop", "index": index})
}
func writeSSEMessageEnd(w http.ResponseWriter, outputTokens int) {
_ = flushSSEJSON(w, "message_delta", map[string]any{
"type": "message_delta",
"delta": map[string]any{"stop_reason": "end_turn", "stop_sequence": nil},
"usage": map[string]int{"output_tokens": outputTokens},
})
_ = flushSSEJSON(w, "message_stop", map[string]string{"type": "message_stop"})
}
// flushSSEJSON marshals data to JSON and writes an SSE event. Returns error on marshal failure.
func flushSSEJSON(w http.ResponseWriter, event string, data any) error {
b, err := json.Marshal(data)
if err != nil {
slog.Error("web search emulation: failed to marshal SSE event",
"event", event, "error", err)
return err
}
fmt.Fprintf(w, "event: %s\ndata: %s\n\n", event, b)
if f, ok := w.(http.Flusher); ok {
f.Flush()
}
return nil
}
// --- Non-streaming JSON response ---
func writeWebSearchNonStreamResponse(
c *gin.Context, query string, resp *websearch.SearchResponse, model string, startTime time.Time,
) (*ForwardResult, error) {
msgID := webSearchMsgIDPrefix + uuid.New().String()
toolUseID := webSearchToolUseIDPrefix + uuid.New().String()[:16]
textSummary := buildTextSummary(query, resp.Results)
msg := map[string]any{
"id": msgID, "type": "message", "role": "assistant", "model": model,
"content": []any{
map[string]any{
"type": "server_tool_use", "id": toolUseID,
"name": toolNameWebSearch, "input": map[string]string{"query": query},
},
map[string]any{
"type": "web_search_tool_result", "tool_use_id": toolUseID,
"content": buildSearchResultBlocks(resp.Results),
},
map[string]any{"type": "text", "text": textSummary},
},
"stop_reason": "end_turn", "stop_sequence": nil,
"usage": map[string]int{"input_tokens": 0, "output_tokens": len(textSummary) / tokenEstimateDivisor},
}
body, err := json.Marshal(msg)
if err != nil {
return nil, fmt.Errorf("web search emulation: marshal response: %w", err)
}
c.Data(http.StatusOK, "application/json", body)
return &ForwardResult{Model: model, Duration: time.Since(startTime), Usage: ClaudeUsage{}}, nil
}
// --- Helpers ---
func buildSearchResultBlocks(results []websearch.SearchResult) []map[string]string {
blocks := make([]map[string]string, 0, len(results))
for _, r := range results {
block := map[string]string{
"type": "web_search_result",
"url": r.URL,
"title": r.Title,
}
if r.Snippet != "" {
block["page_content"] = r.Snippet
}
if r.PageAge != "" {
block["page_age"] = r.PageAge
}
blocks = append(blocks, block)
}
return blocks
}
func buildTextSummary(query string, results []websearch.SearchResult) string {
if len(results) == 0 {
return "No search results found for: " + query
}
var sb strings.Builder
fmt.Fprintf(&sb, "Here are the search results for \"%s\":\n\n", query)
for i, r := range results {
fmt.Fprintf(&sb, "%d. **%s**\n %s\n %s\n\n", i+1, r.Title, r.URL, r.Snippet)
}
return sb.String()
}
@@ -0,0 +1,142 @@
package service
import (
"testing"
"github.com/Wei-Shaw/sub2api/internal/pkg/websearch"
"github.com/stretchr/testify/require"
)
// --- isOnlyWebSearchToolInBody ---
func TestIsOnlyWebSearchToolInBody_WebSearchType(t *testing.T) {
require.True(t, isOnlyWebSearchToolInBody([]byte(`{"tools":[{"type":"web_search"}]}`)))
}
func TestIsOnlyWebSearchToolInBody_WebSearch2025Type(t *testing.T) {
require.True(t, isOnlyWebSearchToolInBody([]byte(`{"tools":[{"type":"web_search_20250305"}]}`)))
}
func TestIsOnlyWebSearchToolInBody_GoogleSearchType(t *testing.T) {
require.True(t, isOnlyWebSearchToolInBody([]byte(`{"tools":[{"type":"google_search"}]}`)))
}
func TestIsOnlyWebSearchToolInBody_NameWebSearch(t *testing.T) {
require.True(t, isOnlyWebSearchToolInBody([]byte(`{"tools":[{"name":"web_search"}]}`)))
}
func TestIsOnlyWebSearchToolInBody_NameWebSearch2025(t *testing.T) {
require.True(t, isOnlyWebSearchToolInBody([]byte(`{"tools":[{"name":"web_search_20250305"}]}`)))
}
func TestIsOnlyWebSearchToolInBody_NameGoogleSearch(t *testing.T) {
require.True(t, isOnlyWebSearchToolInBody([]byte(`{"tools":[{"name":"google_search"}]}`)))
}
func TestIsOnlyWebSearchToolInBody_MultipleTools(t *testing.T) {
require.False(t, isOnlyWebSearchToolInBody(
[]byte(`{"tools":[{"type":"web_search"},{"type":"text_editor"}]}`)))
}
func TestIsOnlyWebSearchToolInBody_NoTools(t *testing.T) {
require.False(t, isOnlyWebSearchToolInBody([]byte(`{"model":"claude-3"}`)))
}
func TestIsOnlyWebSearchToolInBody_EmptyToolsArray(t *testing.T) {
require.False(t, isOnlyWebSearchToolInBody([]byte(`{"tools":[]}`)))
}
func TestIsOnlyWebSearchToolInBody_NonWebSearchTool(t *testing.T) {
require.False(t, isOnlyWebSearchToolInBody([]byte(`{"tools":[{"type":"text_editor"}]}`)))
}
func TestIsOnlyWebSearchToolInBody_ToolsNotArray(t *testing.T) {
require.False(t, isOnlyWebSearchToolInBody([]byte(`{"tools":"web_search"}`)))
}
// --- extractSearchQueryFromBody ---
func TestExtractSearchQueryFromBody_StringContent(t *testing.T) {
body := `{"messages":[{"role":"user","content":"what is golang"}]}`
require.Equal(t, "what is golang", extractSearchQueryFromBody([]byte(body)))
}
func TestExtractSearchQueryFromBody_ArrayContent(t *testing.T) {
body := `{"messages":[{"role":"user","content":[{"type":"text","text":"search this"}]}]}`
require.Equal(t, "search this", extractSearchQueryFromBody([]byte(body)))
}
func TestExtractSearchQueryFromBody_MultipleMessages(t *testing.T) {
body := `{"messages":[{"role":"user","content":"first"},{"role":"assistant","content":"ok"},{"role":"user","content":"second"}]}`
require.Equal(t, "second", extractSearchQueryFromBody([]byte(body)))
}
func TestExtractSearchQueryFromBody_LastMessageNotUser(t *testing.T) {
body := `{"messages":[{"role":"user","content":"q"},{"role":"assistant","content":"a"}]}`
require.Equal(t, "", extractSearchQueryFromBody([]byte(body)))
}
func TestExtractSearchQueryFromBody_EmptyMessages(t *testing.T) {
require.Equal(t, "", extractSearchQueryFromBody([]byte(`{"messages":[]}`)))
}
func TestExtractSearchQueryFromBody_NoMessages(t *testing.T) {
require.Equal(t, "", extractSearchQueryFromBody([]byte(`{"model":"claude-3"}`)))
}
func TestExtractSearchQueryFromBody_ArrayContentSkipsEmptyText(t *testing.T) {
body := `{"messages":[{"role":"user","content":[{"type":"image"},{"type":"text","text":""},{"type":"text","text":"real query"}]}]}`
require.Equal(t, "real query", extractSearchQueryFromBody([]byte(body)))
}
func TestExtractSearchQueryFromBody_ArrayContentNoTextBlock(t *testing.T) {
body := `{"messages":[{"role":"user","content":[{"type":"image","source":{}}]}]}`
require.Equal(t, "", extractSearchQueryFromBody([]byte(body)))
}
// --- buildSearchResultBlocks ---
func TestBuildSearchResultBlocks_WithResults(t *testing.T) {
results := []websearch.SearchResult{
{URL: "https://a.com", Title: "A", Snippet: "snippet a", PageAge: "2 days"},
{URL: "https://b.com", Title: "B", Snippet: "snippet b"},
}
blocks := buildSearchResultBlocks(results)
require.Len(t, blocks, 2)
require.Equal(t, "web_search_result", blocks[0]["type"])
require.Equal(t, "https://a.com", blocks[0]["url"])
require.Equal(t, "snippet a", blocks[0]["page_content"])
require.Equal(t, "2 days", blocks[0]["page_age"])
// Second result has no PageAge
require.Equal(t, "https://b.com", blocks[1]["url"])
_, hasPageAge := blocks[1]["page_age"]
require.False(t, hasPageAge)
}
func TestBuildSearchResultBlocks_Empty(t *testing.T) {
blocks := buildSearchResultBlocks(nil)
require.Empty(t, blocks)
}
func TestBuildSearchResultBlocks_SnippetEmpty(t *testing.T) {
blocks := buildSearchResultBlocks([]websearch.SearchResult{{URL: "https://x.com", Title: "X", Snippet: ""}})
_, hasContent := blocks[0]["page_content"]
require.False(t, hasContent)
}
// --- buildTextSummary ---
func TestBuildTextSummary_WithResults(t *testing.T) {
results := []websearch.SearchResult{
{URL: "https://a.com", Title: "A", Snippet: "desc a"},
}
summary := buildTextSummary("test query", results)
require.Contains(t, summary, "test query")
require.Contains(t, summary, "1. **A**")
require.Contains(t, summary, "https://a.com")
}
func TestBuildTextSummary_NoResults(t *testing.T) {
summary := buildTextSummary("test", nil)
require.Contains(t, summary, "No search results found for: test")
}
+17 -5
View File
@@ -99,13 +99,17 @@ type DefaultSubscriptionGroupReader interface {
GetByID(ctx context.Context, id int64) (*Group, error)
}
// WebSearchManagerBuilder creates a websearch.Manager from config (injected by infra layer).
type WebSearchManagerBuilder func(cfg *WebSearchEmulationConfig)
// SettingService 系统设置服务
type SettingService struct {
settingRepo SettingRepository
defaultSubGroupReader DefaultSubscriptionGroupReader
cfg *config.Config
onUpdate func() // Callback when settings are updated (for cache invalidation)
version string // Application version
settingRepo SettingRepository
defaultSubGroupReader DefaultSubscriptionGroupReader
cfg *config.Config
onUpdate func() // Callback when settings are updated (for cache invalidation)
version string // Application version
webSearchManagerBuilder WebSearchManagerBuilder
}
// NewSettingService 创建系统设置服务实例
@@ -1217,6 +1221,14 @@ func (s *SettingService) parseSettings(settings map[string]string) *SystemSettin
result.EnableMetadataPassthrough = settings[SettingKeyEnableMetadataPassthrough] == "true"
result.EnableCCHSigning = settings[SettingKeyEnableCCHSigning] == "true"
// Web search emulation: quick enabled check from the JSON config
if raw := settings[SettingKeyWebSearchEmulationConfig]; raw != "" {
var wsCfg WebSearchEmulationConfig
if err := json.Unmarshal([]byte(raw), &wsCfg); err == nil {
result.WebSearchEmulationEnabled = wsCfg.Enabled && len(wsCfg.Providers) > 0
}
}
return result
}
@@ -106,6 +106,9 @@ type SystemSettings struct {
EnableFingerprintUnification bool // 是否统一 OAuth 账号的指纹头(默认 true)
EnableMetadataPassthrough bool // 是否透传客户端原始 metadata(默认 false)
EnableCCHSigning bool // 是否对 billing header cch 进行签名(默认 false)
// Web Search Emulation (read-only quick check; full config via dedicated API)
WebSearchEmulationEnabled bool
}
type DefaultSubscriptionSetting struct {
@@ -0,0 +1,245 @@
package service
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"sync/atomic"
"time"
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
"github.com/Wei-Shaw/sub2api/internal/pkg/websearch"
"golang.org/x/sync/singleflight"
)
// WebSearchEmulationConfig holds the global web search emulation configuration.
type WebSearchEmulationConfig struct {
Enabled bool `json:"enabled"`
Providers []WebSearchProviderConfig `json:"providers"`
}
// WebSearchProviderConfig describes a single search provider (Brave or Tavily).
type WebSearchProviderConfig struct {
Type string `json:"type"` // websearch.ProviderTypeBrave | Tavily
APIKey string `json:"api_key,omitempty"` // secret — omitted in API responses
APIKeyConfigured bool `json:"api_key_configured"` // read-only mask
Priority int `json:"priority"` // lower = higher priority
QuotaLimit int64 `json:"quota_limit"` // 0 = unlimited
QuotaRefreshInterval string `json:"quota_refresh_interval"` // websearch.QuotaRefresh*
QuotaUsed int64 `json:"quota_used,omitempty"` // read-only: current period usage
ProxyID *int64 `json:"proxy_id"` // optional proxy association
ExpiresAt *int64 `json:"expires_at,omitempty"` // optional expiration timestamp
}
// --- Validation ---
const maxWebSearchProviders = 10
var validProviderTypes = map[string]bool{
websearch.ProviderTypeBrave: true,
websearch.ProviderTypeTavily: true,
}
var validQuotaIntervals = map[string]bool{
websearch.QuotaRefreshDaily: true,
websearch.QuotaRefreshWeekly: true,
websearch.QuotaRefreshMonthly: true,
"": true, // defaults to monthly
}
func validateWebSearchConfig(cfg *WebSearchEmulationConfig) error {
if cfg == nil {
return nil
}
if len(cfg.Providers) > maxWebSearchProviders {
return fmt.Errorf("too many providers (max %d)", maxWebSearchProviders)
}
seen := make(map[string]bool, len(cfg.Providers))
for i, p := range cfg.Providers {
if !validProviderTypes[p.Type] {
return fmt.Errorf("provider[%d]: invalid type %q", i, p.Type)
}
if !validQuotaIntervals[p.QuotaRefreshInterval] {
return fmt.Errorf("provider[%d]: invalid quota_refresh_interval %q", i, p.QuotaRefreshInterval)
}
if p.QuotaLimit < 0 {
return fmt.Errorf("provider[%d]: quota_limit must be >= 0", i)
}
if seen[p.Type] {
return fmt.Errorf("provider[%d]: duplicate type %q", i, p.Type)
}
seen[p.Type] = true
}
return nil
}
// --- In-process cache (same pattern as gateway forwarding settings) ---
const sfKeyWebSearchConfig = "web_search_emulation_config"
type cachedWebSearchEmulationConfig struct {
config *WebSearchEmulationConfig
expiresAt int64 // unix nano
}
var webSearchEmulationCache atomic.Value // *cachedWebSearchEmulationConfig
var webSearchEmulationSF singleflight.Group
const (
webSearchEmulationCacheTTL = 60 * time.Second
webSearchEmulationErrorTTL = 5 * time.Second
webSearchEmulationDBTimeout = 5 * time.Second
)
// GetWebSearchEmulationConfig returns the configuration with in-process cache + singleflight.
func (s *SettingService) GetWebSearchEmulationConfig(ctx context.Context) (*WebSearchEmulationConfig, error) {
if cached := webSearchEmulationCache.Load(); cached != nil {
if c, ok := cached.(*cachedWebSearchEmulationConfig); ok && time.Now().UnixNano() < c.expiresAt {
return c.config, nil
}
}
result, err, _ := webSearchEmulationSF.Do(sfKeyWebSearchConfig, func() (any, error) {
return s.loadWebSearchConfigFromDB()
})
if err != nil {
return &WebSearchEmulationConfig{}, err
}
if cfg, ok := result.(*WebSearchEmulationConfig); ok {
return cfg, nil
}
return &WebSearchEmulationConfig{}, nil
}
func (s *SettingService) loadWebSearchConfigFromDB() (*WebSearchEmulationConfig, error) {
dbCtx, cancel := context.WithTimeout(context.Background(), webSearchEmulationDBTimeout)
defer cancel()
raw, err := s.settingRepo.GetValue(dbCtx, SettingKeyWebSearchEmulationConfig)
if err != nil {
webSearchEmulationCache.Store(&cachedWebSearchEmulationConfig{
config: &WebSearchEmulationConfig{},
expiresAt: time.Now().Add(webSearchEmulationErrorTTL).UnixNano(),
})
return &WebSearchEmulationConfig{}, err
}
cfg := parseWebSearchConfigJSON(raw)
webSearchEmulationCache.Store(&cachedWebSearchEmulationConfig{
config: cfg,
expiresAt: time.Now().Add(webSearchEmulationCacheTTL).UnixNano(),
})
return cfg, nil
}
func parseWebSearchConfigJSON(raw string) *WebSearchEmulationConfig {
cfg := &WebSearchEmulationConfig{}
if raw == "" {
return cfg
}
if err := json.Unmarshal([]byte(raw), cfg); err != nil {
slog.Warn("websearch: failed to parse config JSON", "error", err)
return &WebSearchEmulationConfig{}
}
return cfg
}
// SaveWebSearchEmulationConfig validates and persists the configuration.
// Empty API keys in the input are preserved from the existing config.
func (s *SettingService) SaveWebSearchEmulationConfig(ctx context.Context, cfg *WebSearchEmulationConfig) error {
if err := validateWebSearchConfig(cfg); err != nil {
return infraerrors.BadRequest("INVALID_WEB_SEARCH_CONFIG", err.Error())
}
s.mergeExistingAPIKeys(ctx, cfg)
data, err := json.Marshal(cfg)
if err != nil {
return fmt.Errorf("websearch: marshal config: %w", err)
}
if err := s.settingRepo.Set(ctx, SettingKeyWebSearchEmulationConfig, string(data)); err != nil {
return fmt.Errorf("websearch: save config: %w", err)
}
// Invalidate: forget singleflight first, then store new value
webSearchEmulationSF.Forget(sfKeyWebSearchConfig)
webSearchEmulationCache.Store(&cachedWebSearchEmulationConfig{
config: cfg,
expiresAt: time.Now().Add(webSearchEmulationCacheTTL).UnixNano(),
})
// Hot-reload: rebuild the global Manager with new config
s.rebuildWebSearchManager(ctx)
return nil
}
// mergeExistingAPIKeys preserves API keys from the current config when incoming value is empty.
func (s *SettingService) mergeExistingAPIKeys(ctx context.Context, cfg *WebSearchEmulationConfig) {
existing, _ := s.getWebSearchEmulationConfigRaw(ctx)
if existing == nil || cfg == nil {
return
}
existingByType := make(map[string]string, len(existing.Providers))
for _, p := range existing.Providers {
if p.APIKey != "" {
existingByType[p.Type] = p.APIKey
}
}
for i := range cfg.Providers {
if cfg.Providers[i].APIKey == "" {
if key, ok := existingByType[cfg.Providers[i].Type]; ok {
cfg.Providers[i].APIKey = key
}
}
}
}
func (s *SettingService) getWebSearchEmulationConfigRaw(ctx context.Context) (*WebSearchEmulationConfig, error) {
raw, err := s.settingRepo.GetValue(ctx, SettingKeyWebSearchEmulationConfig)
if err != nil {
return nil, err
}
return parseWebSearchConfigJSON(raw), nil
}
// IsWebSearchEmulationEnabled is a quick check for whether the global switch is on.
func (s *SettingService) IsWebSearchEmulationEnabled(ctx context.Context) bool {
cfg, err := s.GetWebSearchEmulationConfig(ctx)
if err != nil {
return false
}
return cfg.Enabled && len(cfg.Providers) > 0
}
// SetWebSearchManagerBuilder injects a callback that creates and wires a websearch.Manager.
// The infra layer (main/wire) provides this builder, keeping redis out of the service layer.
// Triggers initial build.
func (s *SettingService) SetWebSearchManagerBuilder(ctx context.Context, builder WebSearchManagerBuilder) {
s.webSearchManagerBuilder = builder
s.rebuildWebSearchManager(ctx)
}
// rebuildWebSearchManager reads the current config and invokes the builder.
func (s *SettingService) rebuildWebSearchManager(ctx context.Context) {
if s.webSearchManagerBuilder == nil {
return
}
cfg, err := s.GetWebSearchEmulationConfig(ctx)
if err != nil {
SetWebSearchManager(nil)
return
}
s.webSearchManagerBuilder(cfg)
}
// SanitizeWebSearchConfig returns a copy with api_key fields masked for API responses.
func SanitizeWebSearchConfig(cfg *WebSearchEmulationConfig) *WebSearchEmulationConfig {
if cfg == nil {
return nil
}
out := *cfg
out.Providers = make([]WebSearchProviderConfig, len(cfg.Providers))
for i, p := range cfg.Providers {
out.Providers[i] = p
out.Providers[i].APIKeyConfigured = p.APIKey != ""
out.Providers[i].APIKey = "" // never return the secret
}
return &out
}
@@ -0,0 +1,148 @@
package service
import (
"testing"
"github.com/stretchr/testify/require"
)
// --- validateWebSearchConfig ---
func TestValidateWebSearchConfig_Nil(t *testing.T) {
require.NoError(t, validateWebSearchConfig(nil))
}
func TestValidateWebSearchConfig_Valid(t *testing.T) {
cfg := &WebSearchEmulationConfig{
Enabled: true,
Providers: []WebSearchProviderConfig{
{Type: "brave", Priority: 1, QuotaLimit: 1000, QuotaRefreshInterval: "monthly"},
{Type: "tavily", Priority: 2, QuotaLimit: 500, QuotaRefreshInterval: "daily"},
},
}
require.NoError(t, validateWebSearchConfig(cfg))
}
func TestValidateWebSearchConfig_TooManyProviders(t *testing.T) {
cfg := &WebSearchEmulationConfig{Providers: make([]WebSearchProviderConfig, 11)}
for i := range cfg.Providers {
cfg.Providers[i] = WebSearchProviderConfig{Type: "brave"}
}
err := validateWebSearchConfig(cfg)
require.ErrorContains(t, err, "too many providers")
}
func TestValidateWebSearchConfig_InvalidType(t *testing.T) {
cfg := &WebSearchEmulationConfig{
Providers: []WebSearchProviderConfig{{Type: "bing"}},
}
require.ErrorContains(t, validateWebSearchConfig(cfg), "invalid type")
}
func TestValidateWebSearchConfig_InvalidQuotaInterval(t *testing.T) {
cfg := &WebSearchEmulationConfig{
Providers: []WebSearchProviderConfig{{Type: "brave", QuotaRefreshInterval: "hourly"}},
}
require.ErrorContains(t, validateWebSearchConfig(cfg), "invalid quota_refresh_interval")
}
func TestValidateWebSearchConfig_NegativeQuotaLimit(t *testing.T) {
cfg := &WebSearchEmulationConfig{
Providers: []WebSearchProviderConfig{{Type: "brave", QuotaLimit: -1}},
}
require.ErrorContains(t, validateWebSearchConfig(cfg), "quota_limit must be >= 0")
}
func TestValidateWebSearchConfig_DuplicateType(t *testing.T) {
cfg := &WebSearchEmulationConfig{
Providers: []WebSearchProviderConfig{
{Type: "brave", Priority: 1},
{Type: "brave", Priority: 2},
},
}
require.ErrorContains(t, validateWebSearchConfig(cfg), "duplicate type")
}
func TestValidateWebSearchConfig_EmptyQuotaInterval(t *testing.T) {
cfg := &WebSearchEmulationConfig{
Providers: []WebSearchProviderConfig{{Type: "brave", QuotaRefreshInterval: ""}},
}
require.NoError(t, validateWebSearchConfig(cfg))
}
func TestValidateWebSearchConfig_ZeroQuotaLimit(t *testing.T) {
cfg := &WebSearchEmulationConfig{
Providers: []WebSearchProviderConfig{{Type: "brave", QuotaLimit: 0}},
}
require.NoError(t, validateWebSearchConfig(cfg))
}
// --- parseWebSearchConfigJSON ---
func TestParseWebSearchConfigJSON_ValidJSON(t *testing.T) {
raw := `{"enabled":true,"providers":[{"type":"brave","api_key":"sk-xxx"}]}`
cfg := parseWebSearchConfigJSON(raw)
require.True(t, cfg.Enabled)
require.Len(t, cfg.Providers, 1)
require.Equal(t, "brave", cfg.Providers[0].Type)
}
func TestParseWebSearchConfigJSON_EmptyString(t *testing.T) {
cfg := parseWebSearchConfigJSON("")
require.False(t, cfg.Enabled)
require.Empty(t, cfg.Providers)
}
func TestParseWebSearchConfigJSON_InvalidJSON(t *testing.T) {
cfg := parseWebSearchConfigJSON("not{json")
require.False(t, cfg.Enabled)
require.Empty(t, cfg.Providers)
}
// --- SanitizeWebSearchConfig ---
func TestSanitizeWebSearchConfig_MaskAPIKey(t *testing.T) {
cfg := &WebSearchEmulationConfig{
Enabled: true,
Providers: []WebSearchProviderConfig{
{Type: "brave", APIKey: "sk-secret-xxx"},
},
}
out := SanitizeWebSearchConfig(cfg)
require.Equal(t, "", out.Providers[0].APIKey)
require.True(t, out.Providers[0].APIKeyConfigured)
}
func TestSanitizeWebSearchConfig_NoAPIKey(t *testing.T) {
cfg := &WebSearchEmulationConfig{
Providers: []WebSearchProviderConfig{{Type: "brave", APIKey: ""}},
}
out := SanitizeWebSearchConfig(cfg)
require.Equal(t, "", out.Providers[0].APIKey)
require.False(t, out.Providers[0].APIKeyConfigured)
}
func TestSanitizeWebSearchConfig_Nil(t *testing.T) {
require.Nil(t, SanitizeWebSearchConfig(nil))
}
func TestSanitizeWebSearchConfig_PreservesOtherFields(t *testing.T) {
cfg := &WebSearchEmulationConfig{
Enabled: true,
Providers: []WebSearchProviderConfig{
{Type: "brave", APIKey: "secret", Priority: 10, QuotaLimit: 1000},
},
}
out := SanitizeWebSearchConfig(cfg)
require.True(t, out.Enabled)
require.Equal(t, 10, out.Providers[0].Priority)
require.Equal(t, int64(1000), out.Providers[0].QuotaLimit)
}
func TestSanitizeWebSearchConfig_DoesNotMutateOriginal(t *testing.T) {
cfg := &WebSearchEmulationConfig{
Providers: []WebSearchProviderConfig{{Type: "brave", APIKey: "secret"}},
}
_ = SanitizeWebSearchConfig(cfg)
require.Equal(t, "secret", cfg.Providers[0].APIKey)
}
@@ -0,0 +1,2 @@
ALTER TABLE channels ADD COLUMN IF NOT EXISTS features_config JSONB NOT NULL DEFAULT '{}';
COMMENT ON COLUMN channels.features_config IS '渠道特性配置(如 web_search_emulation),JSON 对象格式';
+3
View File
@@ -49,6 +49,7 @@ export interface Channel {
status: string
billing_model_source: string // "requested" | "upstream"
restrict_models: boolean
features_config?: Record<string, unknown>
group_ids: number[]
model_pricing: ChannelModelPricing[]
model_mapping: Record<string, Record<string, string>> // platform → {src→dst}
@@ -66,6 +67,7 @@ export interface CreateChannelRequest {
model_mapping?: Record<string, Record<string, string>>
billing_model_source?: string
restrict_models?: boolean
features_config?: Record<string, unknown>
apply_pricing_to_account_stats?: boolean
account_stats_pricing_rules?: AccountStatsPricingRule[]
}
@@ -79,6 +81,7 @@ export interface UpdateChannelRequest {
model_mapping?: Record<string, Record<string, string>>
billing_model_source?: string
restrict_models?: boolean
features_config?: Record<string, unknown>
apply_pricing_to_account_stats?: boolean
account_stats_pricing_rules?: AccountStatsPricingRule[]
}
+39 -1
View File
@@ -482,6 +482,42 @@ export async function updateBetaPolicySettings(
return data
}
// --- Web Search Emulation Config ---
export interface WebSearchProviderConfig {
type: 'brave' | 'tavily'
api_key: string
api_key_configured: boolean
priority: number
quota_limit: number
quota_refresh_interval: 'daily' | 'weekly' | 'monthly'
quota_used?: number
proxy_id: number | null
expires_at: number | null
}
export interface WebSearchEmulationConfig {
enabled: boolean
providers: WebSearchProviderConfig[]
}
export async function getWebSearchEmulationConfig(): Promise<WebSearchEmulationConfig> {
const { data } = await apiClient.get<WebSearchEmulationConfig>(
'/admin/settings/web-search-emulation'
)
return data
}
export async function updateWebSearchEmulationConfig(
config: WebSearchEmulationConfig
): Promise<WebSearchEmulationConfig> {
const { data } = await apiClient.put<WebSearchEmulationConfig>(
'/admin/settings/web-search-emulation',
config
)
return data
}
export const settingsAPI = {
getSettings,
updateSettings,
@@ -497,7 +533,9 @@ export const settingsAPI = {
getRectifierSettings,
updateRectifierSettings,
getBetaPolicySettings,
updateBetaPolicySettings
updateBetaPolicySettings,
getWebSearchEmulationConfig,
updateWebSearchEmulationConfig
}
export default settingsAPI
@@ -2361,6 +2361,22 @@
</div>
</div>
<!-- Anthropic API Key: Web Search Emulation -->
<div
v-if="form.platform === 'anthropic' && accountCategory === 'apikey'"
class="border-t border-gray-200 pt-4 dark:border-dark-600"
>
<div class="flex items-center justify-between">
<div>
<label class="input-label mb-0">{{ t('admin.accounts.anthropic.webSearchEmulation') }}</label>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.accounts.anthropic.webSearchEmulationDesc') }}
</p>
</div>
<Toggle v-model="webSearchEmulationEnabled" />
</div>
</div>
<!-- OpenAI OAuth Codex 官方客户端限制开关 -->
<div
v-if="form.platform === 'openai' && accountCategory === 'oauth-based'"
@@ -2866,6 +2882,7 @@ import ConfirmDialog from '@/components/common/ConfirmDialog.vue'
import Select from '@/components/common/Select.vue'
import Icon from '@/components/icons/Icon.vue'
import ProxySelector from '@/components/common/ProxySelector.vue'
import Toggle from '@/components/common/Toggle.vue'
import GroupSelector from '@/components/common/GroupSelector.vue'
import ModelWhitelistSelector from '@/components/account/ModelWhitelistSelector.vue'
import QuotaLimitCard from '@/components/account/QuotaLimitCard.vue'
@@ -3016,6 +3033,7 @@ const openaiOAuthResponsesWebSocketV2Mode = ref<OpenAIWSMode>(OPENAI_WS_MODE_OFF
const openaiAPIKeyResponsesWebSocketV2Mode = ref<OpenAIWSMode>(OPENAI_WS_MODE_OFF)
const codexCLIOnlyEnabled = ref(false)
const anthropicPassthroughEnabled = ref(false)
const webSearchEmulationEnabled = ref(false)
const mixedScheduling = ref(false) // For antigravity accounts: enable mixed scheduling
const allowOverages = ref(false) // For antigravity accounts: enable AI Credits overages
const antigravityAccountType = ref<'oauth' | 'upstream'>('oauth') // For antigravity: oauth or upstream
@@ -3343,6 +3361,7 @@ watch(
}
if (newPlatform !== 'anthropic') {
anthropicPassthroughEnabled.value = false
webSearchEmulationEnabled.value = false
}
// Reset OAuth states
oauth.resetState()
@@ -3362,6 +3381,7 @@ watch(
}
if (platform !== 'anthropic' || category !== 'apikey') {
anthropicPassthroughEnabled.value = false
webSearchEmulationEnabled.value = false
}
}
)
@@ -3726,6 +3746,7 @@ const resetForm = () => {
openaiAPIKeyResponsesWebSocketV2Mode.value = OPENAI_WS_MODE_OFF
codexCLIOnlyEnabled.value = false
anthropicPassthroughEnabled.value = false
webSearchEmulationEnabled.value = false
// Reset quota control state
windowCostEnabled.value = false
windowCostLimit.value = null
@@ -3813,6 +3834,11 @@ const buildAnthropicExtra = (base?: Record<string, unknown>): Record<string, unk
} else {
delete extra.anthropic_passthrough
}
if (webSearchEmulationEnabled.value) {
extra.web_search_emulation = true
} else {
delete extra.web_search_emulation
}
return Object.keys(extra).length > 0 ? extra : undefined
}
@@ -1149,6 +1149,22 @@
</div>
</div>
<!-- Anthropic API Key: Web Search Emulation -->
<div
v-if="account?.platform === 'anthropic' && account?.type === 'apikey'"
class="border-t border-gray-200 pt-4 dark:border-dark-600"
>
<div class="flex items-center justify-between">
<div>
<label class="input-label mb-0">{{ t('admin.accounts.anthropic.webSearchEmulation') }}</label>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.accounts.anthropic.webSearchEmulationDesc') }}
</p>
</div>
<Toggle v-model="webSearchEmulationEnabled" />
</div>
</div>
<!-- 配额控制 (Anthropic apikey/bedrock: 配额限制 + 亲和) -->
<div
v-if="account?.platform === 'anthropic' && (account?.type === 'apikey' || account?.type === 'bedrock')"
@@ -1792,6 +1808,7 @@ import ConfirmDialog from '@/components/common/ConfirmDialog.vue'
import Select from '@/components/common/Select.vue'
import Icon from '@/components/icons/Icon.vue'
import ProxySelector from '@/components/common/ProxySelector.vue'
import Toggle from '@/components/common/Toggle.vue'
import GroupSelector from '@/components/common/GroupSelector.vue'
import ModelWhitelistSelector from '@/components/account/ModelWhitelistSelector.vue'
import QuotaLimitCard from '@/components/account/QuotaLimitCard.vue'
@@ -1933,6 +1950,7 @@ const openaiOAuthResponsesWebSocketV2Mode = ref<OpenAIWSMode>(OPENAI_WS_MODE_OFF
const openaiAPIKeyResponsesWebSocketV2Mode = ref<OpenAIWSMode>(OPENAI_WS_MODE_OFF)
const codexCLIOnlyEnabled = ref(false)
const anthropicPassthroughEnabled = ref(false)
const webSearchEmulationEnabled = ref(false)
const editQuotaLimit = ref<number | null>(null)
const editQuotaDailyLimit = ref<number | null>(null)
const editQuotaWeeklyLimit = ref<number | null>(null)
@@ -2102,6 +2120,7 @@ const syncFormFromAccount = (newAccount: Account | null) => {
openaiAPIKeyResponsesWebSocketV2Mode.value = OPENAI_WS_MODE_OFF
codexCLIOnlyEnabled.value = false
anthropicPassthroughEnabled.value = false
webSearchEmulationEnabled.value = false
if (newAccount.platform === 'openai' && (newAccount.type === 'oauth' || newAccount.type === 'apikey')) {
openaiPassthroughEnabled.value = extra?.openai_passthrough === true || extra?.openai_oauth_passthrough === true
openaiOAuthResponsesWebSocketV2Mode.value = resolveOpenAIWSModeFromExtra(extra, {
@@ -2122,6 +2141,7 @@ const syncFormFromAccount = (newAccount: Account | null) => {
}
if (newAccount.platform === 'anthropic' && newAccount.type === 'apikey') {
anthropicPassthroughEnabled.value = extra?.anthropic_passthrough === true
webSearchEmulationEnabled.value = extra?.web_search_emulation === true
}
// Load quota limit for apikey/bedrock accounts (bedrock quota is also loaded in its own branch above)
@@ -3077,7 +3097,7 @@ const handleSubmit = async () => {
updatePayload.extra = newExtra
}
// For Anthropic API Key accounts, handle passthrough mode in extra
// For Anthropic API Key accounts, handle passthrough mode + web search emulation in extra
if (props.account.platform === 'anthropic' && props.account.type === 'apikey') {
const currentExtra = (updatePayload.extra as Record<string, unknown>) || (props.account.extra as Record<string, unknown>) || {}
const newExtra: Record<string, unknown> = { ...currentExtra }
@@ -3086,6 +3106,11 @@ const handleSubmit = async () => {
} else {
delete newExtra.anthropic_passthrough
}
if (webSearchEmulationEnabled.value) {
newExtra.web_search_emulation = true
} else {
delete newExtra.web_search_emulation
}
updatePayload.extra = newExtra
}
+32 -1
View File
@@ -1815,6 +1815,9 @@ export default {
defaultPerRequestPrice: 'Default per-request price (fallback when no tier matches)',
defaultImagePrice: 'Default image price (fallback when no tier matches)',
platformConfig: 'Platform Configuration',
webSearchEmulation: 'Web Search Emulation',
webSearchEmulationHint: '⚠️ When enabled, all accounts in this channel\'s Anthropic groups will intercept web_search requests. Use with caution.',
webSearchEmulationGlobalDisabled: 'Please enable the global switch first in Settings → Gateway → Web Search Emulation',
basicSettings: 'Basic Settings',
addPlatform: 'Add Platform',
noPlatforms: 'Click "Add Platform" to start configuring the channel',
@@ -2315,7 +2318,10 @@ export default {
anthropic: {
apiKeyPassthrough: 'Auto passthrough (auth only)',
apiKeyPassthroughDesc:
'Only applies to Anthropic API Key accounts. When enabled, messages/count_tokens are forwarded in passthrough mode with auth replacement only, while billing/concurrency/audit and safety filtering are preserved. Disable to roll back immediately.'
'Only applies to Anthropic API Key accounts. When enabled, messages/count_tokens are forwarded in passthrough mode with auth replacement only, while billing/concurrency/audit and safety filtering are preserved. Disable to roll back immediately.',
webSearchEmulation: 'Web Search Emulation',
webSearchEmulationDesc:
'Enable web search emulation for this API Key account. When a pure web_search request is detected, the gateway calls a third-party search API and constructs the response locally.',
},
modelRestriction: 'Model Restriction (Optional)',
modelWhitelist: 'Model Whitelist',
@@ -4330,6 +4336,31 @@ export default {
cchSigning: 'CCH Signing',
cchSigningHint: 'Sign the billing header in forwarded requests with CCH hash. When disabled, the placeholder is preserved.',
},
webSearchEmulation: {
title: 'Web Search Emulation',
description: 'Inject web search capability for Anthropic API Key accounts that don\'t natively support it',
enabled: 'Enable Web Search Emulation',
enabledHint: 'Global switch. When disabled, web search emulation is inactive for all channels and accounts.',
providers: 'Search Providers',
addProvider: 'Add Provider',
providerType: 'Provider Type',
apiKey: 'API Key',
apiKeyPlaceholder: 'Enter API Key',
apiKeyConfigured: 'Configured',
priority: 'Priority',
priorityHint: 'Lower number = higher priority',
quotaLimit: 'Quota Limit',
quotaLimitHint: '0 = unlimited',
quotaRefreshInterval: 'Refresh Interval',
quotaUsed: 'Used',
proxy: 'Proxy',
expiresAt: 'Expires At',
removeProvider: 'Remove',
daily: 'Daily',
weekly: 'Weekly',
monthly: 'Monthly',
noProviders: 'No search providers configured',
},
site: {
title: 'Site Settings',
description: 'Customize site branding',
+32 -1
View File
@@ -1902,6 +1902,9 @@ export default {
defaultPerRequestPrice: '默认单次价格(未命中层级时使用)',
defaultImagePrice: '默认图片价格(未命中层级时使用)',
platformConfig: '平台配置',
webSearchEmulation: 'Web Search 模拟',
webSearchEmulationHint: '⚠️ 开启后该渠道下所有 Anthropic 分组的账号将自动拦截 web_search 请求,请谨慎操作',
webSearchEmulationGlobalDisabled: '请先在系统设置 → 网关 → Web Search 模拟中启用全局开关',
basicSettings: '基础设置',
addPlatform: '添加平台',
noPlatforms: '点击"添加平台"开始配置渠道',
@@ -2470,7 +2473,10 @@ export default {
anthropic: {
apiKeyPassthrough: '自动透传(仅替换认证)',
apiKeyPassthroughDesc:
'仅对 Anthropic API Key 生效。开启后,messages/count_tokens 请求将透传上游并仅替换认证,保留计费/并发/审计及必要安全过滤;关闭即可回滚到现有兼容链路。'
'仅对 Anthropic API Key 生效。开启后,messages/count_tokens 请求将透传上游并仅替换认证,保留计费/并发/审计及必要安全过滤;关闭即可回滚到现有兼容链路。',
webSearchEmulation: 'Web Search 模拟',
webSearchEmulationDesc:
'为该 API Key 账号启用 web search 模拟。客户端发送纯 web_search 请求时,由网关调用第三方搜索 API 并构造响应返回。',
},
modelRestriction: '模型限制(可选)',
modelWhitelist: '模型白名单',
@@ -4500,6 +4506,31 @@ export default {
cchSigning: 'CCH 签名',
cchSigningHint: '对转发请求的 billing header 进行 CCH 哈希签名。关闭时保留原始占位符。',
},
webSearchEmulation: {
title: 'Web Search 模拟',
description: '为不原生支持搜索的 Anthropic API Key 账号注入 web search 能力',
enabled: '启用 Web Search 模拟',
enabledHint: '全局开关。关闭后所有渠道和账号的 web search 模拟均不生效。',
providers: '搜索服务商',
addProvider: '添加服务商',
providerType: '服务商类型',
apiKey: 'API Key',
apiKeyPlaceholder: '输入 API Key',
apiKeyConfigured: '已配置',
priority: '优先级',
priorityHint: '数值越小优先级越高',
quotaLimit: '配额上限',
quotaLimitHint: '0 表示无限制',
quotaRefreshInterval: '刷新周期',
quotaUsed: '已使用',
proxy: '代理',
expiresAt: '过期时间',
removeProvider: '删除',
daily: '每日',
weekly: '每周',
monthly: '每月',
noProviders: '未配置搜索服务商',
},
site: {
title: '站点设置',
description: '自定义站点品牌',
+72 -17
View File
@@ -306,6 +306,24 @@
</div>
</div>
<!-- Web Search Emulation (Anthropic only) -->
<div v-if="section.platform === 'anthropic'" class="border-t border-gray-200 pt-3 dark:border-dark-600">
<div class="flex items-center justify-between">
<div>
<label class="text-xs font-medium text-orange-600 dark:text-orange-400">
{{ t('admin.channels.form.webSearchEmulation') }}
</label>
<p v-if="webSearchGlobalEnabled" class="mt-0.5 text-[11px] text-amber-500 dark:text-amber-400">
{{ t('admin.channels.form.webSearchEmulationHint') }}
</p>
<p v-else class="mt-0.5 text-[11px] text-gray-400">
{{ t('admin.channels.form.webSearchEmulationGlobalDisabled') }}
</p>
</div>
<Toggle v-model="section.web_search_emulation" :disabled="!webSearchGlobalEnabled" />
</div>
</div>
<!-- Model Mapping -->
<div>
<div class="mb-1 flex items-center justify-between">
@@ -560,6 +578,7 @@
import { ref, reactive, computed, onMounted, onUnmounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { useAppStore } from '@/stores/app'
import { extractApiErrorMessage } from '@/utils/apiError'
import { adminAPI } from '@/api/admin'
import type { Channel, ChannelModelPricing, CreateChannelRequest, UpdateChannelRequest, AccountStatsPricingRule } from '@/api/admin/channels'
import type { PricingFormEntry } from '@/components/admin/channel/types'
@@ -583,6 +602,18 @@ import { getPersistedPageSize } from '@/composables/usePersistedPageSize'
const { t } = useI18n()
const appStore = useAppStore()
// Web Search global enabled state (loaded once on mount)
const webSearchGlobalEnabled = ref(false)
async function loadWebSearchGlobalState() {
try {
const cfg = await adminAPI.settings.getWebSearchEmulationConfig()
webSearchGlobalEnabled.value = cfg?.enabled === true && (cfg?.providers?.length ?? 0) > 0
} catch (err: unknown) {
console.warn('Failed to load web search global state:', err)
webSearchGlobalEnabled.value = false
}
}
// ── Platform Section type ──
interface PlatformSection {
platform: GroupPlatform
@@ -591,6 +622,7 @@ interface PlatformSection {
group_ids: number[]
model_mapping: Record<string, string>
model_pricing: PricingFormEntry[]
web_search_emulation: boolean
}
// ── Table columns ──
@@ -709,7 +741,8 @@ function addPlatformSection(platform: GroupPlatform) {
collapsed: false,
group_ids: [],
model_mapping: {},
model_pricing: []
model_pricing: [],
web_search_emulation: false,
})
}
@@ -901,10 +934,14 @@ function accountStatsRulesToAPI(): AccountStatsPricingRule[] {
}
// ── Form ↔ API conversion ──
function formToAPI(): { group_ids: number[], model_pricing: ChannelModelPricing[], model_mapping: Record<string, Record<string, string>> } {
function formToAPI(): { group_ids: number[], model_pricing: ChannelModelPricing[], model_mapping: Record<string, Record<string, string>>, features_config: Record<string, unknown> } {
const group_ids: number[] = []
const model_pricing: ChannelModelPricing[] = []
const model_mapping: Record<string, Record<string, string>> = {}
// Preserve existing features_config fields not managed by the form
const featuresConfig: Record<string, unknown> = editingChannel.value?.features_config
? { ...editingChannel.value.features_config }
: {}
for (const section of form.platforms) {
if (!section.enabled) continue
@@ -933,7 +970,19 @@ function formToAPI(): { group_ids: number[], model_pricing: ChannelModelPricing[
}
}
return { group_ids, model_pricing, model_mapping }
// Collect web_search_emulation (only anthropic platform supports it)
const wsEmulation: Record<string, boolean> = {}
for (const section of form.platforms) {
if (!section.enabled) continue
if (section.web_search_emulation && section.platform === 'anthropic') {
wsEmulation[section.platform] = true
}
}
if (Object.keys(wsEmulation).length > 0) {
featuresConfig.web_search_emulation = wsEmulation
}
return { group_ids, model_pricing, model_mapping, features_config: featuresConfig }
}
function apiToForm(channel: Channel): PlatformSection[] {
@@ -977,13 +1026,19 @@ function apiToForm(channel: Channel): PlatformSection[] {
intervals: apiIntervalsToForm(p.intervals || [])
} as PricingFormEntry))
// Read web_search_emulation from features_config
const fc = channel.features_config
const wsEmulation = fc?.web_search_emulation as Record<string, boolean> | undefined
const webSearchEnabled = wsEmulation?.[platform] === true
sections.push({
platform,
enabled: true,
collapsed: false,
group_ids: groupIds,
model_mapping: { ...mapping },
model_pricing: pricing
model_pricing: pricing,
web_search_emulation: webSearchEnabled,
})
}
@@ -1008,10 +1063,10 @@ async function loadChannels() {
if (ctrl.signal.aborted || abortController !== ctrl) return
channels.value = response.items || []
pagination.total = response.total
} catch (error: any) {
if (error?.name === 'AbortError' || error?.code === 'ERR_CANCELED') return
appStore.showError(t('admin.channels.loadError', 'Failed to load channels'))
console.error('Error loading channels:', error)
} catch (error: unknown) {
const e = error as { name?: string; code?: string }
if (e?.name === 'AbortError' || e?.code === 'ERR_CANCELED') return
appStore.showError(extractApiErrorMessage(error, t('admin.channels.loadError', 'Failed to load channels')))
} finally {
if (abortController === ctrl) {
loading.value = false
@@ -1210,7 +1265,7 @@ async function handleSubmit() {
}
}
const { group_ids, model_pricing, model_mapping } = formToAPI()
const { group_ids, model_pricing, model_mapping, features_config } = formToAPI()
submitting.value = true
try {
@@ -1224,6 +1279,7 @@ async function handleSubmit() {
model_mapping: Object.keys(model_mapping).length > 0 ? model_mapping : {},
billing_model_source: form.billing_model_source,
restrict_models: form.restrict_models,
features_config,
apply_pricing_to_account_stats: form.apply_pricing_to_account_stats,
account_stats_pricing_rules: accountStatsRulesToAPI()
}
@@ -1238,6 +1294,7 @@ async function handleSubmit() {
model_mapping: Object.keys(model_mapping).length > 0 ? model_mapping : {},
billing_model_source: form.billing_model_source,
restrict_models: form.restrict_models,
features_config,
apply_pricing_to_account_stats: form.apply_pricing_to_account_stats,
account_stats_pricing_rules: accountStatsRulesToAPI()
}
@@ -1246,12 +1303,10 @@ async function handleSubmit() {
}
closeDialog()
loadChannels()
} catch (error: any) {
const msg = error.response?.data?.detail || (editingChannel.value
} catch (error: unknown) {
appStore.showError(extractApiErrorMessage(error, editingChannel.value
? t('admin.channels.updateError', 'Failed to update channel')
: t('admin.channels.createError', 'Failed to create channel'))
appStore.showError(msg)
console.error('Error saving channel:', error)
: t('admin.channels.createError', 'Failed to create channel')))
} finally {
submitting.value = false
}
@@ -1289,9 +1344,8 @@ async function confirmDelete() {
showDeleteDialog.value = false
deletingChannel.value = null
loadChannels()
} catch (error: any) {
appStore.showError(error.response?.data?.detail || t('admin.channels.deleteError', 'Failed to delete channel'))
console.error('Error deleting channel:', error)
} catch (error: unknown) {
appStore.showError(extractApiErrorMessage(error, t('admin.channels.deleteError', 'Failed to delete channel')))
}
}
@@ -1299,6 +1353,7 @@ async function confirmDelete() {
onMounted(() => {
loadChannels()
loadGroups()
loadWebSearchGlobalState()
})
onUnmounted(() => {
+171 -3
View File
@@ -1711,6 +1711,107 @@
</div>
</div>
</div>
<!-- Web Search Emulation -->
<div class="card">
<div class="border-b border-gray-100 px-6 py-4 dark:border-dark-700">
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">
{{ t('admin.settings.webSearchEmulation.title') }}
</h2>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
{{ t('admin.settings.webSearchEmulation.description') }}
</p>
</div>
<div class="space-y-5 p-6">
<!-- Global Toggle -->
<div class="flex items-center justify-between">
<div>
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">
{{ t('admin.settings.webSearchEmulation.enabled') }}
</label>
<p class="mt-0.5 text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.settings.webSearchEmulation.enabledHint') }}
</p>
</div>
<Toggle v-model="webSearchConfig.enabled" />
</div>
<!-- Providers -->
<div v-if="webSearchConfig.enabled" class="space-y-4">
<div class="flex items-center justify-between">
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">
{{ t('admin.settings.webSearchEmulation.providers') }}
</label>
<button type="button" class="btn btn-secondary btn-sm" @click="addWebSearchProvider">
{{ t('admin.settings.webSearchEmulation.addProvider') }}
</button>
</div>
<div v-if="webSearchConfig.providers.length === 0" class="rounded-lg border border-dashed border-gray-300 p-4 text-center text-sm text-gray-400 dark:border-dark-600">
{{ t('admin.settings.webSearchEmulation.noProviders') }}
</div>
<div v-for="(provider, pIdx) in webSearchConfig.providers" :key="pIdx"
class="rounded-lg border border-gray-200 p-4 space-y-3 dark:border-dark-600">
<div class="flex items-center justify-between">
<Select
v-model="provider.type"
:options="[
{ value: 'brave', label: 'Brave Search' },
{ value: 'tavily', label: 'Tavily' },
]"
class="w-40"
/>
<button type="button" class="text-red-500 hover:text-red-700 text-xs" @click="webSearchConfig.providers.splice(pIdx, 1)">
{{ t('admin.settings.webSearchEmulation.removeProvider') }}
</button>
</div>
<div class="grid grid-cols-2 gap-3">
<div>
<label class="text-xs text-gray-500">{{ t('admin.settings.webSearchEmulation.apiKey') }}</label>
<input
v-model="provider.api_key"
type="password"
class="input text-sm"
:placeholder="provider.api_key_configured ? '••••••••' : t('admin.settings.webSearchEmulation.apiKeyPlaceholder')"
/>
</div>
<div>
<label class="text-xs text-gray-500">{{ t('admin.settings.webSearchEmulation.priority') }}</label>
<input v-model.number="provider.priority" type="number" min="1" class="input text-sm" />
<p class="mt-0.5 text-xs text-gray-400">{{ t('admin.settings.webSearchEmulation.priorityHint') }}</p>
</div>
<div>
<label class="text-xs text-gray-500">{{ t('admin.settings.webSearchEmulation.quotaLimit') }}</label>
<input v-model.number="provider.quota_limit" type="number" min="0" class="input text-sm" />
<p class="mt-0.5 text-xs text-gray-400">{{ t('admin.settings.webSearchEmulation.quotaLimitHint') }}</p>
<p v-if="provider.quota_used != null" class="mt-0.5 text-xs text-gray-400">
{{ t('admin.settings.webSearchEmulation.quotaUsed') }}: {{ provider.quota_used }} / {{ provider.quota_limit || '∞' }}
</p>
</div>
<div>
<label class="text-xs text-gray-500">{{ t('admin.settings.webSearchEmulation.quotaRefreshInterval') }}</label>
<Select
v-model="provider.quota_refresh_interval"
:options="[
{ value: 'daily', label: t('admin.settings.webSearchEmulation.daily') },
{ value: 'weekly', label: t('admin.settings.webSearchEmulation.weekly') },
{ value: 'monthly', label: t('admin.settings.webSearchEmulation.monthly') },
]"
class="w-full"
/>
</div>
</div>
<div>
<label class="text-xs text-gray-500">{{ t('admin.settings.webSearchEmulation.proxy') }}</label>
<ProxySelector v-model="provider.proxy_id" :proxies="webSearchProxies" />
</div>
</div>
</div>
</div>
</div>
</div><!-- /Tab: Gateway — Claude Code, Scheduling -->
<!-- Tab: General -->
@@ -2523,9 +2624,11 @@ import { adminAPI } from '@/api'
import type {
SystemSettings,
UpdateSettingsRequest,
DefaultSubscriptionSetting
DefaultSubscriptionSetting,
WebSearchEmulationConfig,
WebSearchProviderConfig,
} from '@/api/admin/settings'
import type { AdminGroup } from '@/types'
import type { AdminGroup, Proxy } from '@/types'
import type { ProviderInstance } from '@/types/payment'
import AppLayout from '@/components/layout/AppLayout.vue'
import Icon from '@/components/icons/Icon.vue'
@@ -2536,6 +2639,7 @@ import PaymentProviderDialog from '@/components/payment/PaymentProviderDialog.vu
import GroupBadge from '@/components/common/GroupBadge.vue'
import GroupOptionItem from '@/components/common/GroupOptionItem.vue'
import Toggle from '@/components/common/Toggle.vue'
import ProxySelector from '@/components/common/ProxySelector.vue'
import ImageUpload from '@/components/common/ImageUpload.vue'
import BackupSettings from '@/views/admin/BackupView.vue'
import { useClipboard } from '@/composables/useClipboard'
@@ -2746,6 +2850,63 @@ const form = reactive<SettingsForm>({
enable_cch_signing: false
})
// Proxies for web search emulation ProxySelector
const webSearchProxies = ref<Proxy[]>([])
// Web Search Emulation config (loaded/saved separately)
const DEFAULT_WEB_SEARCH_QUOTA_LIMIT = 1000
const webSearchConfig = reactive<WebSearchEmulationConfig>({
enabled: false,
providers: [],
})
function addWebSearchProvider() {
webSearchConfig.providers.push({
type: 'brave',
api_key: '',
api_key_configured: false,
priority: webSearchConfig.providers.length + 1,
quota_limit: DEFAULT_WEB_SEARCH_QUOTA_LIMIT,
quota_refresh_interval: 'monthly',
proxy_id: null,
expires_at: null,
} as WebSearchProviderConfig)
}
async function loadWebSearchConfig() {
try {
const [resp, proxiesResp] = await Promise.all([
adminAPI.settings.getWebSearchEmulationConfig(),
adminAPI.proxies.list().catch(() => ({ items: [] as Proxy[] })),
])
if (resp) {
webSearchConfig.enabled = resp.enabled || false
webSearchConfig.providers = resp.providers || []
}
webSearchProxies.value = proxiesResp.items || []
} catch (err: unknown) {
// 404 is expected when config hasn't been created yet; show error for other failures
const status = (err as { status?: number })?.status
if (status !== 404 && status !== undefined) {
appStore.showError(extractApiErrorMessage(err, t('common.error')))
}
}
}
async function saveWebSearchConfig(): Promise<boolean> {
try {
await adminAPI.settings.updateWebSearchEmulationConfig({
enabled: webSearchConfig.enabled,
providers: webSearchConfig.providers as WebSearchProviderConfig[],
})
return true
} catch (err: unknown) {
appStore.showError(extractApiErrorMessage(err, t('common.error')))
return false
}
}
const defaultSubscriptionGroupOptions = computed<DefaultSubscriptionGroupOption[]>(() =>
subscriptionGroups.value.map((group) => ({
value: group.id,
@@ -2960,6 +3121,9 @@ async function loadSettings() {
form.turnstile_secret_key = ''
form.linuxdo_connect_client_secret = ''
form.oidc_connect_client_secret = ''
// Load web search emulation config separately
await loadWebSearchConfig()
} catch (error: unknown) {
loadFailed.value = true
appStore.showError(extractApiErrorMessage(error, t('admin.settings.failedToLoad')))
@@ -3181,10 +3345,14 @@ async function saveSettings() {
form.turnstile_secret_key = ''
form.linuxdo_connect_client_secret = ''
form.oidc_connect_client_secret = ''
// Save web search emulation config separately (errors handled internally)
const wsOk = await saveWebSearchConfig()
// Refresh cached settings so sidebar/header update immediately
await appStore.fetchPublicSettings(true)
await adminSettingsStore.fetch(true)
appStore.showSuccess(t('admin.settings.settingsSaved'))
if (wsOk) {
appStore.showSuccess(t('admin.settings.settingsSaved'))
}
} catch (error: unknown) {
appStore.showError(extractApiErrorMessage(error, t('admin.settings.failedToSave')))
} finally {