feat(gateway): add web search emulation for Anthropic API Key accounts

Inject web search capability for Claude Console (API Key) accounts that
don't natively support Anthropic's web_search tool. When a pure
web_search request is detected, the gateway calls Brave Search or Tavily
API directly and constructs an Anthropic-protocol-compliant SSE/JSON
response without forwarding to upstream.

Backend:
- New `pkg/websearch/` SDK: Brave and Tavily provider implementations
  with io.LimitReader, proxy support, and Redis-based quota tracking
  (Lua atomic INCR + TTL, DECR rollback on failure)
- Global config via `settings.web_search_emulation_config` (JSON) with
  in-process cache + singleflight, input validation, API key merge on
  save, and sanitized API responses
- Channel-level toggle via `channels.features_config` JSONB column
  (DB migration 101)
- Account-level toggle via `accounts.extra.web_search_emulation`
- Request interception in `Forward()` with SSE streaming response
  construction using json.Marshal (no manual string concatenation)
- Manager hot-reload: `RebuildWebSearchManager()` called on config save
  and startup via `SetWebSearchRedisClient()`
- 70 unit tests covering providers, manager, config validation,
  sanitization, tool detection, query extraction, and response building

Frontend:
- Settings → Gateway tab: Web Search Emulation config card with global
  toggle, provider list (add/remove, API key, priority, quota, proxy)
- Channels → Anthropic tab: web search emulation toggle with global
  state linkage (disabled when global off)
- Account Create/Edit modals: web search emulation toggle for API Key
  type with Toggle component
- Full i18n coverage (zh + en)
This commit is contained in:
erio
2026-04-12 00:02:26 +08:00
parent c738cfec93
commit 1b53ffcac7
37 changed files with 3507 additions and 238 deletions

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">
@@ -423,6 +441,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 } from '@/api/admin/channels'
import type { PricingFormEntry } from '@/components/admin/channel/types'
@@ -446,6 +465,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
@@ -454,6 +485,7 @@ interface PlatformSection {
group_ids: number[]
model_mapping: Record<string, string>
model_pricing: PricingFormEntry[]
web_search_emulation: boolean
}
// ── Table columns ──
@@ -565,7 +597,8 @@ function addPlatformSection(platform: GroupPlatform) {
collapsed: false,
group_ids: [],
model_mapping: {},
model_pricing: []
model_pricing: [],
web_search_emulation: false,
})
}
@@ -679,10 +712,14 @@ function renameMappingKey(sectionIdx: number, oldKey: string, newKey: string) {
}
// ── 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
@@ -711,7 +748,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[] {
@@ -755,13 +804,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,
})
}
@@ -786,10 +841,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
@@ -969,8 +1024,7 @@ async function handleSubmit() {
}
}
const { group_ids, model_pricing, model_mapping } = formToAPI()
console.log('[handleSubmit] model_pricing to send:', JSON.stringify(model_pricing))
const { group_ids, model_pricing, model_mapping, features_config } = formToAPI()
submitting.value = true
try {
@@ -983,7 +1037,8 @@ async function handleSubmit() {
model_pricing,
model_mapping: Object.keys(model_mapping).length > 0 ? model_mapping : {},
billing_model_source: form.billing_model_source,
restrict_models: form.restrict_models
restrict_models: form.restrict_models,
features_config,
}
await adminAPI.channels.update(editingChannel.value.id, req)
appStore.showSuccess(t('admin.channels.updateSuccess', 'Channel updated'))
@@ -995,19 +1050,18 @@ async function handleSubmit() {
model_pricing,
model_mapping: Object.keys(model_mapping).length > 0 ? model_mapping : {},
billing_model_source: form.billing_model_source,
restrict_models: form.restrict_models
restrict_models: form.restrict_models,
features_config,
}
await adminAPI.channels.create(req)
appStore.showSuccess(t('admin.channels.createSuccess', 'Channel created'))
}
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
}
@@ -1045,9 +1099,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')))
}
}
@@ -1055,6 +1108,7 @@ async function confirmDelete() {
onMounted(() => {
loadChannels()
loadGroups()
loadWebSearchGlobalState()
})
onUnmounted(() => {

File diff suppressed because it is too large Load Diff