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:
@@ -41,6 +41,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}
|
||||
@@ -56,6 +57,7 @@ export interface CreateChannelRequest {
|
||||
model_mapping?: Record<string, Record<string, string>>
|
||||
billing_model_source?: string
|
||||
restrict_models?: boolean
|
||||
features_config?: Record<string, unknown>
|
||||
}
|
||||
|
||||
export interface UpdateChannelRequest {
|
||||
@@ -67,6 +69,7 @@ export interface UpdateChannelRequest {
|
||||
model_mapping?: Record<string, Record<string, string>>
|
||||
billing_model_source?: string
|
||||
restrict_models?: boolean
|
||||
features_config?: Record<string, unknown>
|
||||
}
|
||||
|
||||
interface PaginatedResponse<T> {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -2325,6 +2325,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'"
|
||||
@@ -2830,6 +2846,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'
|
||||
@@ -2980,6 +2997,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
|
||||
@@ -3307,6 +3325,7 @@ watch(
|
||||
}
|
||||
if (newPlatform !== 'anthropic') {
|
||||
anthropicPassthroughEnabled.value = false
|
||||
webSearchEmulationEnabled.value = false
|
||||
}
|
||||
// Reset OAuth states
|
||||
oauth.resetState()
|
||||
@@ -3326,6 +3345,7 @@ watch(
|
||||
}
|
||||
if (platform !== 'anthropic' || category !== 'apikey') {
|
||||
anthropicPassthroughEnabled.value = false
|
||||
webSearchEmulationEnabled.value = false
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -3690,6 +3710,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
|
||||
@@ -3777,6 +3798,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,10 +1149,61 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- API Key / Bedrock 账号配额限制 -->
|
||||
<div v-if="account?.type === 'apikey' || account?.type === 'bedrock'" class="border-t border-gray-200 pt-4 dark:border-dark-600 space-y-4">
|
||||
<!-- 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')"
|
||||
class="border-t border-gray-200 pt-4 dark:border-dark-600 space-y-4"
|
||||
>
|
||||
<div class="mb-3">
|
||||
<h3 class="input-label mb-0 text-base font-semibold">{{ t('admin.accounts.quotaLimit') }}</h3>
|
||||
<h3 class="input-label mb-0 text-base font-semibold">{{ t('admin.accounts.quotaControl.title') }}</h3>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.accounts.quotaControl.hint') }}
|
||||
</p>
|
||||
</div>
|
||||
<QuotaLimitCard
|
||||
:totalLimit="editQuotaLimit"
|
||||
:dailyLimit="editQuotaDailyLimit"
|
||||
:weeklyLimit="editQuotaWeeklyLimit"
|
||||
:dailyResetMode="editDailyResetMode"
|
||||
:dailyResetHour="editDailyResetHour"
|
||||
:weeklyResetMode="editWeeklyResetMode"
|
||||
:weeklyResetDay="editWeeklyResetDay"
|
||||
:weeklyResetHour="editWeeklyResetHour"
|
||||
:resetTimezone="editResetTimezone"
|
||||
@update:totalLimit="editQuotaLimit = $event"
|
||||
@update:dailyLimit="editQuotaDailyLimit = $event"
|
||||
@update:weeklyLimit="editQuotaWeeklyLimit = $event"
|
||||
@update:dailyResetMode="editDailyResetMode = $event"
|
||||
@update:dailyResetHour="editDailyResetHour = $event"
|
||||
@update:weeklyResetMode="editWeeklyResetMode = $event"
|
||||
@update:weeklyResetDay="editWeeklyResetDay = $event"
|
||||
@update:weeklyResetHour="editWeeklyResetHour = $event"
|
||||
@update:resetTimezone="editResetTimezone = $event"
|
||||
/>
|
||||
</div>
|
||||
<!-- 配额控制 (非 Anthropic apikey/bedrock) -->
|
||||
<div
|
||||
v-else-if="account?.type === 'apikey' || account?.type === 'bedrock'"
|
||||
class="border-t border-gray-200 pt-4 dark:border-dark-600 space-y-4"
|
||||
>
|
||||
<div class="mb-3">
|
||||
<h3 class="input-label mb-0 text-base font-semibold">{{ t('admin.accounts.quotaControl.title') }}</h3>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.accounts.quotaLimitHint') }}
|
||||
</p>
|
||||
@@ -1237,7 +1288,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Quota Control Section (Anthropic OAuth/SetupToken only) -->
|
||||
<!-- 配额控制 (Anthropic OAuth/SetupToken: 亲和 + 窗口费用 + 会话 + RPM 等) -->
|
||||
<div
|
||||
v-if="account?.platform === 'anthropic' && (account?.type === 'oauth' || account?.type === 'setup-token')"
|
||||
class="border-t border-gray-200 pt-4 dark:border-dark-600 space-y-4"
|
||||
@@ -1757,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'
|
||||
@@ -1898,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)
|
||||
@@ -2067,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, {
|
||||
@@ -2087,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)
|
||||
@@ -2522,8 +2577,13 @@ function loadQuotaControlSettings(account: Account) {
|
||||
customBaseUrlEnabled.value = false
|
||||
customBaseUrl.value = ''
|
||||
|
||||
// Only applies to Anthropic OAuth/SetupToken accounts
|
||||
if (account.platform !== 'anthropic' || (account.type !== 'oauth' && account.type !== 'setup-token')) {
|
||||
// Remaining quota control settings only apply to Anthropic accounts
|
||||
if (account.platform !== 'anthropic') {
|
||||
return
|
||||
}
|
||||
|
||||
// Window cost / session limit only apply to Anthropic OAuth/SetupToken accounts
|
||||
if (account.type !== 'oauth' && account.type !== 'setup-token') {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -2949,7 +3009,7 @@ const handleSubmit = async () => {
|
||||
|
||||
// For Anthropic OAuth/SetupToken accounts, handle quota control settings in extra
|
||||
if (props.account.platform === 'anthropic' && (props.account.type === 'oauth' || props.account.type === 'setup-token')) {
|
||||
const currentExtra = (props.account.extra as Record<string, unknown>) || {}
|
||||
const currentExtra = (updatePayload.extra as Record<string, unknown>) || (props.account.extra as Record<string, unknown>) || {}
|
||||
const newExtra: Record<string, unknown> = { ...currentExtra }
|
||||
|
||||
// Window cost limit settings
|
||||
@@ -3037,15 +3097,20 @@ 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 = (props.account.extra as Record<string, unknown>) || {}
|
||||
const currentExtra = (updatePayload.extra as Record<string, unknown>) || (props.account.extra as Record<string, unknown>) || {}
|
||||
const newExtra: Record<string, unknown> = { ...currentExtra }
|
||||
if (anthropicPassthroughEnabled.value) {
|
||||
newExtra.anthropic_passthrough = true
|
||||
} else {
|
||||
delete newExtra.anthropic_passthrough
|
||||
}
|
||||
if (webSearchEmulationEnabled.value) {
|
||||
newExtra.web_search_emulation = true
|
||||
} else {
|
||||
delete newExtra.web_search_emulation
|
||||
}
|
||||
updatePayload.extra = newExtra
|
||||
}
|
||||
|
||||
@@ -3089,20 +3154,27 @@ const handleSubmit = async () => {
|
||||
const currentExtra = (updatePayload.extra as Record<string, unknown>) ||
|
||||
(props.account.extra as Record<string, unknown>) || {}
|
||||
const newExtra: Record<string, unknown> = { ...currentExtra }
|
||||
// Total quota
|
||||
if (editQuotaLimit.value != null && editQuotaLimit.value > 0) {
|
||||
newExtra.quota_limit = editQuotaLimit.value
|
||||
} else {
|
||||
delete newExtra.quota_limit
|
||||
}
|
||||
// Daily quota
|
||||
if (editQuotaDailyLimit.value != null && editQuotaDailyLimit.value > 0) {
|
||||
newExtra.quota_daily_limit = editQuotaDailyLimit.value
|
||||
} else {
|
||||
delete newExtra.quota_daily_limit
|
||||
delete newExtra.quota_daily_used
|
||||
delete newExtra.quota_daily_start
|
||||
}
|
||||
// Weekly quota
|
||||
if (editQuotaWeeklyLimit.value != null && editQuotaWeeklyLimit.value > 0) {
|
||||
newExtra.quota_weekly_limit = editQuotaWeeklyLimit.value
|
||||
} else {
|
||||
delete newExtra.quota_weekly_limit
|
||||
delete newExtra.quota_weekly_used
|
||||
delete newExtra.quota_weekly_start
|
||||
}
|
||||
// Quota reset mode config
|
||||
if (editDailyResetMode.value === 'fixed') {
|
||||
|
||||
@@ -1836,6 +1836,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',
|
||||
@@ -2325,7 +2328,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',
|
||||
@@ -4358,6 +4364,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',
|
||||
|
||||
@@ -1915,6 +1915,9 @@ export default {
|
||||
defaultPerRequestPrice: '默认单次价格(未命中层级时使用)',
|
||||
defaultImagePrice: '默认图片价格(未命中层级时使用)',
|
||||
platformConfig: '平台配置',
|
||||
webSearchEmulation: 'Web Search 模拟',
|
||||
webSearchEmulationHint: '⚠️ 开启后该渠道下所有 Anthropic 分组的账号将自动拦截 web_search 请求,请谨慎操作',
|
||||
webSearchEmulationGlobalDisabled: '请先在系统设置 → 网关 → Web Search 模拟中启用全局开关',
|
||||
basicSettings: '基础设置',
|
||||
addPlatform: '添加平台',
|
||||
noPlatforms: '点击"添加平台"开始配置渠道',
|
||||
@@ -2472,7 +2475,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: '模型白名单',
|
||||
@@ -4520,6 +4526,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: '自定义站点品牌',
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user