diff --git a/apps/web/__tests__/routes/dashboard-admin-backup-restore_test.ts b/apps/web/__tests__/routes/dashboard-admin-backup-restore_test.ts index af42f7e57..f02bacab2 100644 --- a/apps/web/__tests__/routes/dashboard-admin-backup-restore_test.ts +++ b/apps/web/__tests__/routes/dashboard-admin-backup-restore_test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; -import { parseBackupFile } from '../../src/components/backup-restore/backup-file'; +import { BACKUP_FILE_VERSION, parseBackupFile } from '../../src/components/backup-restore/backup-file'; const data = { users: [], @@ -14,17 +14,21 @@ const data = { }; const backup = (overrides: Record = {}) => JSON.stringify({ - version: 17, + version: BACKUP_FILE_VERSION, exportedAt: '2026-07-28T00:00:00.000Z', data, ...overrides, }); describe('backup file validation', () => { - it('accepts the exact version-17 envelope', () => { + it('accepts the envelope version this deployment writes', () => { expect(parseBackupFile(backup()).ok).toBe(true); }); + it('rejects a superseded envelope version outright', () => { + expect(parseBackupFile(backup({ version: BACKUP_FILE_VERSION - 1 })).ok).toBe(false); + }); + it('rejects unknown fields instead of stripping them', () => { expect(parseBackupFile(backup({ typo: true })).ok).toBe(false); expect(parseBackupFile(backup({ data: { ...data, typo: [] } })).ok).toBe(false); diff --git a/apps/web/src/assets/bing.svg b/apps/web/src/assets/bing.svg deleted file mode 100644 index 39db83df2..000000000 --- a/apps/web/src/assets/bing.svg +++ /dev/null @@ -1 +0,0 @@ -Bing \ No newline at end of file diff --git a/apps/web/src/assets/icons/NOTICE b/apps/web/src/assets/icons/NOTICE index 3a5700215..a137a0e02 100644 --- a/apps/web/src/assets/icons/NOTICE +++ b/apps/web/src/assets/icons/NOTICE @@ -1,5 +1,6 @@ -The SVGs in this directory are taken verbatim from lobe-icons, which Iconify -does not carry in any collection. +The SVGs in this directory are taken from lobe-icons, which Iconify does not +carry in any collection. Only microsoft.svg is modified: upstream titles it +"Azure". https://github.com/lobehub/lobe-icons diff --git a/apps/web/src/assets/icons/microsoft.svg b/apps/web/src/assets/icons/microsoft.svg new file mode 100644 index 000000000..b7e62ea30 --- /dev/null +++ b/apps/web/src/assets/icons/microsoft.svg @@ -0,0 +1 @@ +Microsoft \ No newline at end of file diff --git a/apps/web/src/components/backup-restore/backup-file.ts b/apps/web/src/components/backup-restore/backup-file.ts index 442dd0bbf..275d933c0 100644 --- a/apps/web/src/components/backup-restore/backup-file.ts +++ b/apps/web/src/components/backup-restore/backup-file.ts @@ -1,6 +1,12 @@ +import type { InferResponseType } from 'hono/client'; import { z } from 'zod'; -export const BACKUP_FILE_VERSION = 17; +import type { api } from '../../api/client'; + +// Annotated with the gateway's own literal so a bump there fails this +// assignment rather than silently leaving the dashboard rejecting every backup +// file the current deployment writes. +export const BACKUP_FILE_VERSION: InferResponseType['version'] = 18; const backupFileSchema = z.object({ version: z.literal(BACKUP_FILE_VERSION), diff --git a/apps/web/src/components/usage/chart-model.ts b/apps/web/src/components/usage/chart-model.ts index 934243ec3..05f003700 100644 --- a/apps/web/src/components/usage/chart-model.ts +++ b/apps/web/src/components/usage/chart-model.ts @@ -506,7 +506,7 @@ function formatPlottedCost(value: number): string { } export function formatProvider(provider: string): string { - if (provider === 'microsoft-grounding') return 'Microsoft Grounding'; + if (provider === 'web-iq') return 'Microsoft Web IQ'; if (provider === 'tavily') return 'Tavily'; if (provider === 'jina') return 'Jina'; return provider; diff --git a/apps/web/src/i18n/locales/en.ts b/apps/web/src/i18n/locales/en.ts index 24a336f40..d29cd25b6 100644 --- a/apps/web/src/i18n/locales/en.ts +++ b/apps/web/src/i18n/locales/en.ts @@ -1026,7 +1026,7 @@ const en = { provider: { disabled: 'Disabled', tavily: 'Tavily', - microsoftGrounding: 'Microsoft Grounding', + webIq: 'Microsoft Web IQ', jina: 'Jina', }, passthrough: { @@ -1038,8 +1038,8 @@ const en = { }, providerDescTavily: 'Tavily is a search engine optimized for LLMs and RAG workflows.', - providerDescMicrosoftGrounding: - 'Microsoft Grounding leverages Bing Search APIs for grounding LLM responses.', + providerDescWebIq: + 'Microsoft Web IQ provides web search and page retrieval APIs built for AI agents.', providerDescJina: 'Jina AI provides web search and content extraction APIs.', getKeyLink: 'Get API key →', @@ -1053,6 +1053,8 @@ const en = { testing: 'Testing…', testDisabledHint: 'Select a provider to enable testing.', testResults: 'Test Results', + testedProvider: 'Provider: {{provider}}', + testedQuery: 'Query: {{query}}', testSuccess: 'Connection test successful — {{count}} results returned.', testFailed: 'Test failed: {{message}}', diff --git a/apps/web/src/i18n/locales/zh-Hans.ts b/apps/web/src/i18n/locales/zh-Hans.ts index 5c0e17da1..873f8ff0e 100644 --- a/apps/web/src/i18n/locales/zh-Hans.ts +++ b/apps/web/src/i18n/locales/zh-Hans.ts @@ -983,13 +983,12 @@ const zhHansCN = { provider: { disabled: '禁用', tavily: 'Tavily', - microsoftGrounding: 'Microsoft Grounding', + webIq: 'Microsoft Web IQ', jina: 'Jina', }, passthrough: { title: '透传 OpenAI 搜索', description: '将 /alpha/search 和 Responses 托管搜索转发到指定的 Codex 或 OpenAI 兼容上游。', upstream: '搜索上游', model: '搜索模型', empty: '请添加一个已启用且包含聊天模型的 Codex 或自定义上游。' }, providerDescTavily: 'Tavily 是专为 LLM 和 RAG 工作流优化的搜索引擎。', - providerDescMicrosoftGrounding: - 'Microsoft Grounding 利用 Bing Search API 为 LLM 提供答案依据。', + providerDescWebIq: 'Microsoft Web IQ 提供面向 AI agent 的网络搜索与网页抓取 API。', providerDescJina: 'Jina AI 提供网络搜索和内容提取 API。', getKeyLink: '获取 API 密钥 →', apiKeyLabel: 'API 密钥', @@ -1002,6 +1001,8 @@ const zhHansCN = { testing: '测试中…', testDisabledHint: '选择提供商以启用测试。', testResults: '测试结果', + testedProvider: '提供商:{{provider}}', + testedQuery: '查询:{{query}}', testSuccess: '连接测试成功 — 返回 {{count}} 条结果。', testFailed: '测试失败:{{message}}', pageAge: '{{age}} 前', diff --git a/apps/web/src/routes/dashboard-providers-search.tsx b/apps/web/src/routes/dashboard-providers-search.tsx index 9d6694637..1c6f97347 100644 --- a/apps/web/src/routes/dashboard-providers-search.tsx +++ b/apps/web/src/routes/dashboard-providers-search.tsx @@ -8,24 +8,26 @@ import { useDashboardOutletContext } from './dashboard'; import { callApi } from '../api/auth'; import { api } from '../api/client'; import type { ControlPlaneModel, SearchConfig, UpstreamRecord } from '../api/types'; -import bingIconUrl from '../assets/bing.svg'; import jinaIconUrl from '../assets/icons/jina.svg'; +import microsoftIconUrl from '../assets/icons/microsoft.svg'; import tavilyIconUrl from '../assets/icons/tavily.svg'; import { getSessionToken } from '../auth/session'; import { AdminOnlyNotice } from '../components/admin-only-notice'; import { DashboardPageHeader } from '../components/ui/dashboard-page-header'; -import { Dropdown, Input, Select } from '../components/ui/fluent-form-controls'; +import { Dropdown, Input } from '../components/ui/fluent-form-controls'; import { Panel } from '../components/ui/panel'; import { fluentComponents } from '../fluent'; type SearchConfigTestResult = InferResponseType; const { + Badge, Button, Field, Link, MessageBar, MessageBarBody, + MessageBarTitle, Option, Spinner, Switch, @@ -61,7 +63,7 @@ export function meta({}: Route.MetaArgs) { const DEFAULT_CONFIG: SearchConfig = { provider: 'disabled', tavily: { apiKey: '' }, - microsoftGrounding: { apiKey: '' }, + webIq: { apiKey: '' }, jina: { apiKey: '' }, passthroughOpenAiSearch: { enabled: false, upstreamId: '', model: '' }, }; @@ -98,13 +100,13 @@ const PROVIDER_OPTIONS: ProviderOption[] = [ setApiKey: (c, k) => ({ ...c, tavily: { apiKey: k } }), }, { - value: 'microsoft-grounding', - labelKey: 'dashboard.searchConfig.provider.microsoftGrounding', - iconUrl: bingIconUrl, - descKey: 'dashboard.searchConfig.providerDescMicrosoftGrounding', - url: 'https://www.microsoft.com/en-us/bing/apis', - getApiKey: c => c.microsoftGrounding.apiKey, - setApiKey: (c, k) => ({ ...c, microsoftGrounding: { apiKey: k } }), + value: 'web-iq', + labelKey: 'dashboard.searchConfig.provider.webIq', + iconUrl: microsoftIconUrl, + descKey: 'dashboard.searchConfig.providerDescWebIq', + url: 'https://webiq.microsoft.ai/profiles', + getApiKey: c => c.webIq.apiKey, + setApiKey: (c, k) => ({ ...c, webIq: { apiKey: k } }), }, { value: 'jina', @@ -144,9 +146,16 @@ export default function DashboardProvidersSearch({ loaderData }: Route.Component ); const activeOption = findProviderOption(draft.provider); + // The tested provider is whatever the gateway echoed back, which need not be + // one this build knows about; an unrecognized id is shown verbatim rather + // than collapsed onto a familiar one. + const testedOption = PROVIDER_OPTIONS.find(option => option.value === testResult?.provider); + const testedProviderLabel = testedOption ? t(testedOption.labelKey) : testResult?.provider; const eligibleUpstreams = useMemo(() => eligibleSearchUpstreams(upstreams, models), [models, upstreams]); const modelsForSelectedUpstream = useMemo(() => models.filter(model => model.kind === 'chat' && model.upstreams.some(binding => binding.id === draft.passthroughOpenAiSearch.upstreamId)), [draft.passthroughOpenAiSearch.upstreamId, models]); + const selectedUpstream = eligibleUpstreams.find(upstream => upstream.id === draft.passthroughOpenAiSearch.upstreamId); + const selectedModel = modelsForSelectedUpstream.find(model => model.id === draft.passthroughOpenAiSearch.model); const setPassthroughUpstream = useCallback((upstreamId: string, preferredModel?: string) => { const candidates = models.filter(model => model.kind === 'chat' @@ -328,14 +337,41 @@ export default function DashboardProvidersSearch({ loaderData }: Route.Component {draft.passthroughOpenAiSearch.enabled &&
- + data.optionValue && setPassthroughUpstream(data.optionValue)} + selectedOptions={[draft.passthroughOpenAiSearch.upstreamId]} + value={selectedUpstream?.name ?? ''} + > + {eligibleUpstreams.map(upstream => ( + + ))} + - + { + const model = data.optionValue; + if (!model) return; + setDraft(current => ({ ...current, passthroughOpenAiSearch: { ...current.passthroughOpenAiSearch, model } })); + setSaveSuccess(false); + }} + selectedOptions={[draft.passthroughOpenAiSearch.model]} + value={selectedModel ? modelLabel(selectedModel) : ''} + > + {modelsForSelectedUpstream.map(model => ( + + ))} +
} {eligibleUpstreams.length === 0 && {t('dashboard.searchConfig.passthrough.empty')}} @@ -390,30 +426,12 @@ export default function DashboardProvidersSearch({ loaderData }: Route.Component
- {testResult.ok ? ( - - OK - - ) : ( - - Error - - )} + + {testResult.ok ? 'OK' : 'Error'} + - Provider: {testResult.provider} - {testResult.query ? ` · Query: ${testResult.query}` : ''} + {t('dashboard.searchConfig.testedProvider', { provider: testedProviderLabel })} + {testResult.query ? ` · ${t('dashboard.searchConfig.testedQuery', { query: testResult.query })}` : ''}
@@ -461,24 +479,12 @@ export default function DashboardProvidersSearch({ loaderData }: Route.Component ) ) : !testResult.ok ? ( -
- - {testResult.error.code} - - + + + {testResult.error.code} {testResult.error.message} - -
+ + ) : null} )} @@ -486,6 +492,23 @@ export default function DashboardProvidersSearch({ loaderData }: Route.Component ); } +// A model's display name is often just its id; showing both would read as a +// stutter, so the second line only appears when it carries something new. +const modelLabel = (model: ControlPlaneModel) => model.display_name ?? model.id; + +function DescribedOptionLabel({ description, label }: { description?: string; label: string }) { + return ( + + {label} + {description && ( + + {description} + + )} + + ); +} + function ProviderOptionLabel({ iconUrl, label }: { iconUrl?: string; label: string }) { return ( diff --git a/packages/gateway/__tests__/control-plane/data-transfer/routes_test.ts b/packages/gateway/__tests__/control-plane/data-transfer/routes_test.ts index 71c5a0146..8a0e8f1d7 100644 --- a/packages/gateway/__tests__/control-plane/data-transfer/routes_test.ts +++ b/packages/gateway/__tests__/control-plane/data-transfer/routes_test.ts @@ -218,7 +218,7 @@ const WEB_SEARCH_USAGE_1: WebSearchUsageRecord = { }; const WEB_SEARCH_USAGE_2: WebSearchUsageRecord = { - provider: 'microsoft-grounding', + provider: 'web-iq', keyId: 'key-b', action: 'fetch_page', hour: '2026-01-01T11', @@ -290,7 +290,7 @@ const doExport = async (app: Hono, includePerformance = false) => { return (await resp.json()) as Record; }; -const doImport = async (app: Hono, mode: string, data: unknown, version: unknown = 17) => { +const doImport = async (app: Hono, mode: string, data: unknown, version: unknown = 18) => { const resp = await app.request('/import', { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -328,13 +328,13 @@ test('import validates generic pricing selectors', async () => { assertEquals(String(fractional.body.error).includes('positive safe integer'), true); }); -test('export emits the v17 envelope with users and upstreams', async () => { +test('export emits the v18 envelope with users and upstreams', async () => { const { app, repo } = setup(); await repo.users.save(SEED_ADMIN); const result = await doExport(app); - assertEquals(result.version, 17); + assertEquals(result.version, 18); assertEquals(typeof result.exportedAt, 'string'); assertEquals(result.data.users, [SEED_ADMIN]); assertEquals(result.data.apiKeys, []); @@ -361,7 +361,7 @@ test('export includes full upstream configs and omits performance by default', a await repo.webSearchConfig.save({ provider: 'tavily', tavily: { apiKey: 'tvly-test' }, - microsoftGrounding: { apiKey: 'ms-test' }, + webIq: { apiKey: 'ms-test' }, jina: { apiKey: '' }, passthroughOpenAiSearch: { enabled: false, upstreamId: '', model: '' }, }); @@ -399,7 +399,7 @@ test('import rejects any version other than the current one before deleting data await repo.apiKeys.save(KEY_A); await repo.upstreams.save(CUSTOM_UPSTREAM); - const VERSION_ERROR = 'version must be 17 — older export formats are not supported; re-export from the current deployment'; + const VERSION_ERROR = 'version must be 18 — older export formats are not supported; re-export from the current deployment'; const previousV11 = await doImport(app, 'replace', latestImportData(), 11); const ancientVersion = await doImport(app, 'replace', { apiKeys: [] }, 1); const missingVersionResponse = await app.request('/import', { @@ -429,7 +429,7 @@ test('import replace writes upstreams and clears replaced collections', async () await repo.webSearchConfig.save({ provider: 'tavily', tavily: { apiKey: 'old' }, - microsoftGrounding: { apiKey: '' }, + webIq: { apiKey: '' }, jina: { apiKey: '' }, passthroughOpenAiSearch: { enabled: false, upstreamId: '', model: '' }, }); @@ -442,9 +442,9 @@ test('import replace writes upstreams and clears replaced collections', async () searchUsage: [WEB_SEARCH_USAGE_2], performanceIncluded: false, searchConfig: { - provider: 'microsoft-grounding', + provider: 'web-iq', tavily: { apiKey: '' }, - microsoftGrounding: { apiKey: 'ms-new' }, + webIq: { apiKey: 'ms-new' }, jina: { apiKey: '' }, passthroughOpenAiSearch: { enabled: false, upstreamId: '', model: '' }, }, @@ -460,9 +460,9 @@ test('import replace writes upstreams and clears replaced collections', async () assertEquals(await repo.webSearchUsage.listAll(), [WEB_SEARCH_USAGE_2]); assertEquals(await repo.responsesItems.lookupMany('key-a', [STORED_RESPONSES_ITEM.id], 0), []); assertEquals(await repo.webSearchConfig.get(), { - provider: 'microsoft-grounding', + provider: 'web-iq', tavily: { apiKey: '' }, - microsoftGrounding: { apiKey: 'ms-new' }, + webIq: { apiKey: 'ms-new' }, jina: { apiKey: '' }, passthroughOpenAiSearch: { enabled: false, upstreamId: '', model: '' }, }); @@ -737,7 +737,7 @@ test('import rejects negative historical unit prices with a metric-specific erro assertEquals(result.body.error, 'invalid usage at index 0: metric unitPrice must be non-negative: "-0.01"'); }); -test('v17 import validates usage metric rows', async () => { +test('v18 import validates usage metric rows', async () => { const { app } = setup(); const missingMetrics = await doImport(app, 'replace', latestImportData({ usage: [{ ...USAGE_2, metrics: undefined }], @@ -901,7 +901,7 @@ test('import preserves a positive dumpRetentionSeconds on api keys', async () => assertEquals(restored?.dumpRetentionSeconds, 3600); }); -test('v17 import preserves and validates Responses retention', async () => { +test('v18 import preserves and validates Responses retention', async () => { const { app, repo } = setup(); const retained = await doImport(app, 'replace', latestImportData({ apiKeys: [{ ...KEY_A, responsesRetentionSeconds: 7 * 24 * 60 * 60 }], @@ -987,7 +987,7 @@ test('import rejects legacy enabled_fixes payloads before mutating', async () => assertEquals(await repo.upstreams.list(), [CUSTOM_UPSTREAM]); }); -test('import rejects missing latest-v17 arrays before clearing existing data', async () => { +test('import rejects missing latest-v18 arrays before clearing existing data', async () => { const { app, repo } = setup(); await repo.apiKeys.save(KEY_A); await repo.upstreams.save(CUSTOM_UPSTREAM); @@ -1013,14 +1013,14 @@ test('import rejects missing latest-v17 arrays before clearing existing data', a test('import validates mode and data before mutating', async () => { const { app } = setup(); - const invalidMode = await doImport(app, 'invalid', {}, 17); + const invalidMode = await doImport(app, 'invalid', {}, 18); const missingData = await app.request('/import', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ mode: 'replace', version: 17 }), + body: JSON.stringify({ mode: 'replace', version: 18 }), }); - const missingUpstreams = await doImport(app, 'merge', {}, 17); - const emptyMerge = await doImport(app, 'merge', latestImportData(), 17); + const missingUpstreams = await doImport(app, 'merge', {}, 18); + const emptyMerge = await doImport(app, 'merge', latestImportData(), 18); assertEquals(invalidMode.status, 400); assertEquals(invalidMode.body.error, "mode must be 'merge' or 'replace'"); @@ -1172,7 +1172,7 @@ test('import replace wipes proxy_upstream_backoffs alongside the proxies it cool assertEquals(await repo.proxyBackoffs.listAll(), []); }); -test('v17 export/import round-trips users and per-key user_id', async () => { +test('v18 export/import round-trips users and per-key user_id', async () => { const { app, repo } = setup(); await repo.users.save(SEED_ADMIN); await repo.users.save(USER_BOB); @@ -1180,10 +1180,10 @@ test('v17 export/import round-trips users and per-key user_id', async () => { await repo.apiKeys.save({ ...KEY_B, userId: USER_BOB.id }); const exportResult = await doExport(app); - assertEquals(exportResult.version, 17); + assertEquals(exportResult.version, 18); assertEquals(exportResult.data.users.map((u: any) => u.id).sort(), [SEED_ADMIN.id, USER_BOB.id]); - const result = await doImport(app, 'replace', exportResult.data, 17); + const result = await doImport(app, 'replace', exportResult.data, 18); assertEquals(result.status, 200); assertEquals(result.body.imported.users, 2); assertEquals(result.body.imported.apiKeys, 2); @@ -1194,7 +1194,7 @@ test('v17 export/import round-trips users and per-key user_id', async () => { assertEquals(restoredKey?.userId, USER_BOB.id); }); -test('v17 import rejects api_keys whose user_id does not appear in the payload', async () => { +test('v18 import rejects api_keys whose user_id does not appear in the payload', async () => { const { app, repo } = setup(); await repo.users.save(SEED_ADMIN); @@ -1206,13 +1206,13 @@ test('v17 import rejects api_keys whose user_id does not appear in the payload', searchUsage: [], performanceIncluded: false, searchConfig: DEFAULT_WEB_SEARCH_CONFIG, - }, 17); + }, 18); assertEquals(result.status, 400); assertEquals(result.body.error, 'invalid apiKeys at index 0: user_id 99 does not match any user in the payload'); }); -test('v17 import rejects malformed users (bad username, bad password_hash)', async () => { +test('v18 import rejects malformed users (bad username, bad password_hash)', async () => { const { app } = setup(); const badUsername = await doImport(app, 'replace', { @@ -1223,7 +1223,7 @@ test('v17 import rejects malformed users (bad username, bad password_hash)', asy searchUsage: [], performanceIncluded: false, searchConfig: DEFAULT_WEB_SEARCH_CONFIG, - }, 17); + }, 18); assertEquals(badUsername.status, 400); assertEquals(String(badUsername.body.error).startsWith('invalid users at index 0:'), true); @@ -1235,7 +1235,7 @@ test('v17 import rejects malformed users (bad username, bad password_hash)', asy searchUsage: [], performanceIncluded: false, searchConfig: DEFAULT_WEB_SEARCH_CONFIG, - }, 17); + }, 18); assertEquals(badHash.status, 400); assertEquals(String(badHash.body.error).includes('passwordHash'), true); }); @@ -1257,7 +1257,7 @@ test('import rejects a pre-accounts v3 export instead of coercing its legacy api }, 3); assertEquals(result.status, 400); - assertEquals(String(result.body.error).includes('version must be 17'), true); + assertEquals(String(result.body.error).includes('version must be 18'), true); // Rejected at the version gate, before touching any data. assertEquals(await repo.apiKeys.list(), [KEY_A]); assertEquals((await repo.users.list()).map(u => u.id), [SEED_ADMIN.id]); @@ -1278,7 +1278,7 @@ test('replace-mode import clears sessions before writing users', async () => { searchUsage: [], performanceIncluded: false, searchConfig: DEFAULT_WEB_SEARCH_CONFIG, - }, 17); + }, 18); assertEquals(result.status, 200); // No public listAll on sessions; create a fresh session and check the @@ -1287,7 +1287,7 @@ test('replace-mode import clears sessions before writing users', async () => { assertEquals(await repo.sessions.deleteByUserId(USER_BOB.id), 0); }); -test('v17 import rejects users[i].upstreamIds === undefined', async () => { +test('v18 import rejects users[i].upstreamIds === undefined', async () => { const { app } = setup(); const result = await doImport(app, 'replace', { users: [SEED_ADMIN, { ...USER_BOB, upstreamIds: undefined }], @@ -1297,12 +1297,12 @@ test('v17 import rejects users[i].upstreamIds === undefined', async () => { searchUsage: [], performanceIncluded: false, searchConfig: DEFAULT_WEB_SEARCH_CONFIG, - }, 17); + }, 18); assertEquals(result.status, 400); expect(result.body.error).toMatch(/upstreamIds/); }); -test('v17 import rejects users[i].deletedAt of non-string non-null type', async () => { +test('v18 import rejects users[i].deletedAt of non-string non-null type', async () => { const { app } = setup(); const result = await doImport(app, 'replace', { users: [SEED_ADMIN, { ...USER_BOB, deletedAt: 42 }], @@ -1312,12 +1312,12 @@ test('v17 import rejects users[i].deletedAt of non-string non-null type', async searchUsage: [], performanceIncluded: false, searchConfig: DEFAULT_WEB_SEARCH_CONFIG, - }, 17); + }, 18); assertEquals(result.status, 400); expect(result.body.error).toMatch(/deletedAt/); }); -test('v17 replace import refuses payload missing user 1', async () => { +test('v18 replace import refuses payload missing user 1', async () => { const { app } = setup(); const result = await doImport(app, 'replace', { users: [USER_BOB], @@ -1327,12 +1327,12 @@ test('v17 replace import refuses payload missing user 1', async () => { searchUsage: [], performanceIncluded: false, searchConfig: DEFAULT_WEB_SEARCH_CONFIG, - }, 17); + }, 18); assertEquals(result.status, 400); expect(result.body.error).toMatch(/user 1/); }); -test('a full v17 export re-imports verbatim — the export→import round trip is closed', async () => { +test('a full v18 export re-imports verbatim — the export→import round trip is closed', async () => { const { app, repo } = setup(); await repo.users.save(SEED_ADMIN); await repo.users.save(USER_BOB); @@ -1351,19 +1351,19 @@ test('a full v17 export re-imports verbatim — the export→import round trip i const config = { provider: 'tavily' as const, tavily: { apiKey: 'tk' }, - microsoftGrounding: { apiKey: '' }, + webIq: { apiKey: '' }, jina: { apiKey: '' }, passthroughOpenAiSearch: { enabled: false, upstreamId: '', model: '' }, }; await repo.webSearchConfig.save(config); const exported = await doExport(app, true); - assertEquals(exported.version, 17); + assertEquals(exported.version, 18); // Replace-import the export's own `data`, verbatim. If the export emits any // shape the import parser rejects, this 400s — the round trip is the // invariant, so this test fails the moment the two sides drift. - const result = await doImport(app, 'replace', exported.data, 17); + const result = await doImport(app, 'replace', exported.data, 18); assertEquals(result.status, 200); assertEquals(result.body.imported, { users: 2, apiKeys: 2, upstreams: 4, proxies: 0, usage: 2, searchUsage: 2, performance: 2 }); @@ -1399,7 +1399,7 @@ test('any data bearing a historical version is rejected on the version gate, bef for (const version of [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16]) { const result = await doImport(app, 'replace', wellFormed, version); assertEquals(result.status, 400); - assertEquals(String(result.body.error).includes('version must be 17'), true); + assertEquals(String(result.body.error).includes('version must be 18'), true); } // Nothing was touched — the version gate runs before any delete or write. diff --git a/packages/gateway/__tests__/control-plane/search-config/routes_test.ts b/packages/gateway/__tests__/control-plane/search-config/routes_test.ts index 6dbc730c1..6d2a1a8f1 100644 --- a/packages/gateway/__tests__/control-plane/search-config/routes_test.ts +++ b/packages/gateway/__tests__/control-plane/search-config/routes_test.ts @@ -53,7 +53,7 @@ test('/api/search-config PUT persists config and POST /test returns preview', as const config = { provider: 'tavily', tavily: { apiKey: 'tvly-test' }, - microsoftGrounding: { apiKey: 'ms-test' }, + webIq: { apiKey: 'ms-test' }, jina: { apiKey: 'jina-test' }, passthroughOpenAiSearch: { enabled: false, upstreamId: '', model: '' }, }; diff --git a/packages/gateway/__tests__/control-plane/search-usage/routes_test.ts b/packages/gateway/__tests__/control-plane/search-usage/routes_test.ts index 8254a5a2f..d06a2adc0 100644 --- a/packages/gateway/__tests__/control-plane/search-usage/routes_test.ts +++ b/packages/gateway/__tests__/control-plane/search-usage/routes_test.ts @@ -19,7 +19,7 @@ const seedWebSearchUsage = async (repo: import('../../repo/memory.ts').InMemoryR await repo.webSearchUsage.set({ provider: 'tavily', keyId: primaryKeyId, action: 'search', hour: '2026-03-15T10', requests: 2 }); await repo.webSearchUsage.set({ provider: 'tavily', keyId: primaryKeyId, action: 'fetch_page', hour: '2026-03-15T10', requests: 3 }); - await repo.webSearchUsage.set({ provider: 'microsoft-grounding', keyId: 'key_other', action: 'search', hour: '2026-03-15T11', requests: 4 }); + await repo.webSearchUsage.set({ provider: 'web-iq', keyId: 'key_other', action: 'search', hour: '2026-03-15T11', requests: 4 }); }; test('/api/search-usage scopes to the actor\'s keys when called with an API key', async () => { @@ -47,9 +47,9 @@ test('/api/search-usage in self-by-key mode includes per-key metadata for the ac const { repo, apiKey } = await setupAppTest(); await seedWebSearchUsage(repo, apiKey.id); await repo.webSearchConfig.save({ - provider: 'microsoft-grounding', + provider: 'web-iq', tavily: { apiKey: 'tvly-test' }, - microsoftGrounding: { apiKey: 'ms-test' }, + webIq: { apiKey: 'ms-test' }, jina: { apiKey: '' }, passthroughOpenAiSearch: { enabled: false, upstreamId: '', model: '' }, }); @@ -61,7 +61,7 @@ test('/api/search-usage in self-by-key mode includes per-key metadata for the ac assertEquals(response.status, 200); const body = await response.json(); assertEquals(body.view, 'self-by-key'); - assertEquals(body.activeProvider, 'microsoft-grounding'); + assertEquals(body.activeProvider, 'web-iq'); assertEquals(body.keys, [ { id: apiKey.id, name: apiKey.name, createdAt: apiKey.createdAt }, ]); @@ -87,11 +87,11 @@ test('/api/search-usage all-by-user view aggregates across keys per user', async assertEquals(response.status, 200); const body = await response.json(); - // Two distinct rows: tavily/user2 (apiKey.userId === 2) and microsoft-grounding/user1. + // Two distinct rows: tavily/user2 (apiKey.userId === 2) and web-iq/user1. // Aggregation summed both actions for the tavily row. Sort order is hour, userId, provider. assertEquals(body, [ { provider: 'tavily', userId: 2, hour: '2026-03-15T10', requests: 5 }, - { provider: 'microsoft-grounding', userId: 1, hour: '2026-03-15T11', requests: 4 }, + { provider: 'web-iq', userId: 1, hour: '2026-03-15T11', requests: 4 }, ]); }); diff --git a/packages/gateway/__tests__/data-plane/alpha-search/routes_test.ts b/packages/gateway/__tests__/data-plane/alpha-search/routes_test.ts index 9c9f5229b..18df702b2 100644 --- a/packages/gateway/__tests__/data-plane/alpha-search/routes_test.ts +++ b/packages/gateway/__tests__/data-plane/alpha-search/routes_test.ts @@ -20,7 +20,7 @@ const mockResolveConfigured = vi.mocked(resolveConfiguredWebSearchProvider); const TAVILY_CONFIG: WebSearchConfig = { provider: 'tavily', tavily: { apiKey: 'test-key' }, - microsoftGrounding: { apiKey: '' }, + webIq: { apiKey: '' }, jina: { apiKey: '' }, passthroughOpenAiSearch: { enabled: false, upstreamId: '', model: '' }, }; diff --git a/packages/gateway/__tests__/data-plane/chat/responses/interceptors/server-tool-shim_test.ts b/packages/gateway/__tests__/data-plane/chat/responses/interceptors/server-tool-shim_test.ts index d9e3c7a9a..a97216d0c 100644 --- a/packages/gateway/__tests__/data-plane/chat/responses/interceptors/server-tool-shim_test.ts +++ b/packages/gateway/__tests__/data-plane/chat/responses/interceptors/server-tool-shim_test.ts @@ -295,7 +295,7 @@ beforeEach(() => { void repo.webSearchConfig.save({ provider: 'tavily', tavily: { apiKey: 'test-key' }, - microsoftGrounding: { apiKey: '' }, + webIq: { apiKey: '' }, jina: { apiKey: '' }, passthroughOpenAiSearch: { enabled: false, upstreamId: '', model: '' }, } satisfies WebSearchConfig); @@ -3945,7 +3945,7 @@ test('responses target with OpenAI passthrough forwards the complete alpha-searc await getRepo().webSearchConfig.save({ provider: 'tavily', tavily: { apiKey: 'test-key' }, - microsoftGrounding: { apiKey: '' }, + webIq: { apiKey: '' }, jina: { apiKey: '' }, passthroughOpenAiSearch: { enabled: true, upstreamId: 'up_codex', model: 'gpt-search' }, } satisfies WebSearchConfig); @@ -4012,7 +4012,7 @@ test('local and cascaded Floway unsupported commands produce the same agent-visi await getRepo().webSearchConfig.save({ provider: 'tavily', tavily: { apiKey: 'test-key' }, - microsoftGrounding: { apiKey: '' }, + webIq: { apiKey: '' }, jina: { apiKey: '' }, passthroughOpenAiSearch: { enabled: true, upstreamId: 'up_floway', model: 'gpt-search' }, } satisfies WebSearchConfig); diff --git a/packages/gateway/__tests__/data-plane/tools/web-search/config_test.ts b/packages/gateway/__tests__/data-plane/tools/web-search/config_test.ts index d511eb86d..87992aa7a 100644 --- a/packages/gateway/__tests__/data-plane/tools/web-search/config_test.ts +++ b/packages/gateway/__tests__/data-plane/tools/web-search/config_test.ts @@ -11,20 +11,20 @@ import { assertEquals, assertRejects, assertThrows } from '@floway-dev/test-util interface WebSearchConfigRow { provider: string; tavily_api_key: string; - microsoft_grounding_api_key: string; + web_iq_api_key: string; jina_api_key: string; passthrough_openai_search: number; alpha_search_upstream_id: string; alpha_search_model: string; } -const SELECT_SQL = 'SELECT provider, tavily_api_key, microsoft_grounding_api_key, jina_api_key, passthrough_openai_search, alpha_search_upstream_id, alpha_search_model FROM search_config WHERE id = 1'; -const UPSERT_SQL = `INSERT INTO search_config (id, provider, tavily_api_key, microsoft_grounding_api_key, jina_api_key, passthrough_openai_search, alpha_search_upstream_id, alpha_search_model, updated_at) +const SELECT_SQL = 'SELECT provider, tavily_api_key, web_iq_api_key, jina_api_key, passthrough_openai_search, alpha_search_upstream_id, alpha_search_model FROM search_config WHERE id = 1'; +const UPSERT_SQL = `INSERT INTO search_config (id, provider, tavily_api_key, web_iq_api_key, jina_api_key, passthrough_openai_search, alpha_search_upstream_id, alpha_search_model, updated_at) VALUES (1, ?, ?, ?, ?, ?, ?, ?, strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) ON CONFLICT (id) DO UPDATE SET provider = excluded.provider, tavily_api_key = excluded.tavily_api_key, - microsoft_grounding_api_key = excluded.microsoft_grounding_api_key, + web_iq_api_key = excluded.web_iq_api_key, jina_api_key = excluded.jina_api_key, passthrough_openai_search = excluded.passthrough_openai_search, alpha_search_upstream_id = excluded.alpha_search_upstream_id, @@ -58,7 +58,7 @@ class FakeSqlPreparedStatement { this.db.webSearchConfig = { provider: String(this.binds[0]), tavily_api_key: String(this.binds[1]), - microsoft_grounding_api_key: String(this.binds[2]), + web_iq_api_key: String(this.binds[2]), jina_api_key: String(this.binds[3]), passthrough_openai_search: Number(this.binds[4]), alpha_search_upstream_id: String(this.binds[5]), @@ -90,7 +90,7 @@ test('search config repo defaults to disabled and round-trips provider keys', as await saveWebSearchConfig({ provider: 'tavily', tavily: { apiKey: 'tvly-test' }, - microsoftGrounding: { apiKey: 'ms-test' }, + webIq: { apiKey: 'ms-test' }, jina: { apiKey: 'jina-test' }, passthroughOpenAiSearch: { enabled: false, upstreamId: '', model: '' }, }); @@ -98,7 +98,7 @@ test('search config repo defaults to disabled and round-trips provider keys', as assertEquals(await loadWebSearchConfig(), { provider: 'tavily', tavily: { apiKey: 'tvly-test' }, - microsoftGrounding: { apiKey: 'ms-test' }, + webIq: { apiKey: 'ms-test' }, jina: { apiKey: 'jina-test' }, passthroughOpenAiSearch: { enabled: false, upstreamId: '', model: '' }, }); @@ -112,7 +112,7 @@ test('loadWebSearchConfig strict-parses a stored row and rejects unknown provide await repo.webSearchConfig.save({ provider: 'unknown-provider', tavily: { apiKey: ' tvly-test ' }, - microsoftGrounding: { apiKey: ' ms-test ' }, + webIq: { apiKey: ' ms-test ' }, jina: { apiKey: '' }, passthroughOpenAiSearch: { enabled: false, upstreamId: '', model: '' }, } as unknown as WebSearchConfig); @@ -127,7 +127,7 @@ test('loadWebSearchConfig strict-parses a stored row and trims valid api keys', await repo.webSearchConfig.save({ provider: 'jina', tavily: { apiKey: ' tvly-trim ' }, - microsoftGrounding: { apiKey: ' ms-trim ' }, + webIq: { apiKey: ' ms-trim ' }, jina: { apiKey: ' jina-trim ' }, passthroughOpenAiSearch: { enabled: false, upstreamId: '', model: '' }, }); @@ -135,7 +135,7 @@ test('loadWebSearchConfig strict-parses a stored row and trims valid api keys', assertEquals(await loadWebSearchConfig(), { provider: 'jina', tavily: { apiKey: 'tvly-trim' }, - microsoftGrounding: { apiKey: 'ms-trim' }, + webIq: { apiKey: 'ms-trim' }, jina: { apiKey: 'jina-trim' }, passthroughOpenAiSearch: { enabled: false, upstreamId: '', model: '' }, }); @@ -155,15 +155,15 @@ test('parseWebSearchConfigStrict throws on missing required fields', () => { assertThrows( () => parseWebSearchConfigStrict({ provider: 'disabled', tavily: { apiKey: '' } }), Error, - 'microsoftGrounding', + 'webIq', ); assertThrows( - () => parseWebSearchConfigStrict({ provider: 'disabled', tavily: {}, microsoftGrounding: { apiKey: '' }, jina: { apiKey: '' } }), + () => parseWebSearchConfigStrict({ provider: 'disabled', tavily: {}, webIq: { apiKey: '' }, jina: { apiKey: '' } }), Error, 'tavily.apiKey', ); assertThrows( - () => parseWebSearchConfigStrict({ provider: 'disabled', tavily: { apiKey: '' }, microsoftGrounding: { apiKey: '' } }), + () => parseWebSearchConfigStrict({ provider: 'disabled', tavily: { apiKey: '' }, webIq: { apiKey: '' } }), Error, 'jina', ); @@ -183,7 +183,7 @@ test('saveWebSearchConfig writes the typed columns and round-trips through the s const saved = await saveWebSearchConfig({ provider: 'disabled', tavily: { apiKey: ' tvly-test ' }, - microsoftGrounding: { apiKey: ' ms-test ' }, + webIq: { apiKey: ' ms-test ' }, jina: { apiKey: ' jina-test ' }, passthroughOpenAiSearch: { enabled: false, upstreamId: '', model: '' }, }); @@ -191,14 +191,14 @@ test('saveWebSearchConfig writes the typed columns and round-trips through the s assertEquals(saved, { provider: 'disabled', tavily: { apiKey: 'tvly-test' }, - microsoftGrounding: { apiKey: 'ms-test' }, + webIq: { apiKey: 'ms-test' }, jina: { apiKey: 'jina-test' }, passthroughOpenAiSearch: { enabled: false, upstreamId: '', model: '' }, }); assertEquals(db.webSearchConfig, { provider: 'disabled', tavily_api_key: 'tvly-test', - microsoft_grounding_api_key: 'ms-test', + web_iq_api_key: 'ms-test', jina_api_key: 'jina-test', passthrough_openai_search: 0, alpha_search_upstream_id: '', @@ -207,7 +207,7 @@ test('saveWebSearchConfig writes the typed columns and round-trips through the s assertEquals(await loadWebSearchConfig(), { provider: 'disabled', tavily: { apiKey: 'tvly-test' }, - microsoftGrounding: { apiKey: 'ms-test' }, + webIq: { apiKey: 'ms-test' }, jina: { apiKey: 'jina-test' }, passthroughOpenAiSearch: { enabled: false, upstreamId: '', model: '' }, }); diff --git a/packages/gateway/__tests__/data-plane/tools/web-search/domain-normalize_test.ts b/packages/gateway/__tests__/data-plane/tools/web-search/domain-normalize_test.ts index 5b73d70e6..68b5ce40a 100644 --- a/packages/gateway/__tests__/data-plane/tools/web-search/domain-normalize_test.ts +++ b/packages/gateway/__tests__/data-plane/tools/web-search/domain-normalize_test.ts @@ -13,7 +13,7 @@ test('normalizeDomainEntry rejects empty, whitespace-only, single-label, or non- assertEquals(normalizeDomainEntry(''), null); assertEquals(normalizeDomainEntry(' '), null); assertEquals(normalizeDomainEntry('localhost'), null); - // Query-operator smuggling (the original Microsoft Grounding regression). + // Query-operator smuggling (the original Web IQ regression). assertEquals(normalizeDomainEntry('example.com OR site:evil.com'), null); assertEquals(normalizeDomainEntry('bad.com test'), null); assertEquals(normalizeDomainEntry('https://example.com'), null); @@ -34,7 +34,7 @@ test('normalizeDomainList drops invalid entries and keeps valid ones in order', // Cross-site parity: the same input must be treated identically by the // local URL-allowed filter, the Tavily request builder, and the -// Microsoft Grounding query builder. All three route through these +// Web IQ query builder. All three route through these // helpers; this test pins down the contract. test('normalizeDomainEntry parity contract for the three call sites', () => { const input = ' Example.COM '; diff --git a/packages/gateway/__tests__/data-plane/tools/web-search/provider_test.ts b/packages/gateway/__tests__/data-plane/tools/web-search/provider_test.ts index 07c1d95f2..5073b5681 100644 --- a/packages/gateway/__tests__/data-plane/tools/web-search/provider_test.ts +++ b/packages/gateway/__tests__/data-plane/tools/web-search/provider_test.ts @@ -15,7 +15,7 @@ test('resolveConfiguredWebSearchProvider returns disabled, missing-credential, o resolveConfiguredWebSearchProvider({ provider: 'tavily', tavily: { apiKey: '' }, - microsoftGrounding: { apiKey: 'ms-test' }, + webIq: { apiKey: 'ms-test' }, jina: { apiKey: '' }, passthroughOpenAiSearch: { enabled: false, upstreamId: '', model: '' }, }), @@ -26,9 +26,9 @@ test('resolveConfiguredWebSearchProvider returns disabled, missing-credential, o ); const resolved = resolveConfiguredWebSearchProvider({ - provider: 'microsoft-grounding', + provider: 'web-iq', tavily: { apiKey: 'tvly-test' }, - microsoftGrounding: { apiKey: 'ms-test' }, + webIq: { apiKey: 'ms-test' }, jina: { apiKey: '' }, passthroughOpenAiSearch: { enabled: false, upstreamId: '', model: '' }, }); @@ -37,7 +37,7 @@ test('resolveConfiguredWebSearchProvider returns disabled, missing-credential, o if (resolved.type !== 'enabled') { throw new Error('expected enabled provider'); } - assertEquals(resolved.provider, 'microsoft-grounding'); + assertEquals(resolved.provider, 'web-iq'); }); test('testWebSearchConfigConnection returns structured disabled and missing-credential errors', async () => { @@ -55,7 +55,7 @@ test('testWebSearchConfigConnection returns structured disabled and missing-cred await testWebSearchConfigConnection({ provider: 'tavily', tavily: { apiKey: '' }, - microsoftGrounding: { apiKey: 'ms-test' }, + webIq: { apiKey: 'ms-test' }, jina: { apiKey: '' }, passthroughOpenAiSearch: { enabled: false, upstreamId: '', model: '' }, }), @@ -103,7 +103,7 @@ test('testWebSearchConfigConnection previews at most three normalized results', const result = await testWebSearchConfigConnection({ provider: 'tavily', tavily: { apiKey: 'tvly-test' }, - microsoftGrounding: { apiKey: 'ms-test' }, + webIq: { apiKey: 'ms-test' }, jina: { apiKey: '' }, passthroughOpenAiSearch: { enabled: false, upstreamId: '', model: '' }, }); @@ -133,7 +133,7 @@ test('testWebSearchConfigConnection returns no_results when the provider returns await testWebSearchConfigConnection({ provider: 'tavily', tavily: { apiKey: 'tvly-test' }, - microsoftGrounding: { apiKey: 'ms-test' }, + webIq: { apiKey: 'ms-test' }, jina: { apiKey: '' }, passthroughOpenAiSearch: { enabled: false, upstreamId: '', model: '' }, }), @@ -151,7 +151,7 @@ test('testWebSearchConfigConnection returns no_results when the provider returns ); }); -test('testWebSearchConfigConnection returns preview results for Microsoft Grounding too', async () => { +test('testWebSearchConfigConnection returns preview results for Web IQ too', async () => { await withMockedFetch( () => jsonResponse({ @@ -166,9 +166,9 @@ test('testWebSearchConfigConnection returns preview results for Microsoft Ground }), async () => { const result = await testWebSearchConfigConnection({ - provider: 'microsoft-grounding', + provider: 'web-iq', tavily: { apiKey: 'tvly-test' }, - microsoftGrounding: { apiKey: 'ms-test' }, + webIq: { apiKey: 'ms-test' }, jina: { apiKey: '' }, passthroughOpenAiSearch: { enabled: false, upstreamId: '', model: '' }, }); @@ -178,7 +178,7 @@ test('testWebSearchConfigConnection returns preview results for Microsoft Ground throw new Error('expected a successful Microsoft preview result'); } - assertEquals(result.provider, 'microsoft-grounding'); + assertEquals(result.provider, 'web-iq'); assertEquals(result.query, FIXED_WEB_SEARCH_CONFIG_TEST_QUERY); assertEquals(result.results, [ { @@ -211,7 +211,7 @@ test('testWebSearchConfigConnection does not record search usage', async () => { const result = await testWebSearchConfigConnection({ provider: 'tavily', tavily: { apiKey: 'tvly-test' }, - microsoftGrounding: { apiKey: '' }, + webIq: { apiKey: '' }, jina: { apiKey: '' }, passthroughOpenAiSearch: { enabled: false, upstreamId: '', model: '' }, }); diff --git a/packages/gateway/__tests__/data-plane/tools/web-search/providers/microsoft-grounding_test.ts b/packages/gateway/__tests__/data-plane/tools/web-search/providers/web-iq_test.ts similarity index 74% rename from packages/gateway/__tests__/data-plane/tools/web-search/providers/microsoft-grounding_test.ts rename to packages/gateway/__tests__/data-plane/tools/web-search/providers/web-iq_test.ts index 32e360734..51f95ec87 100644 --- a/packages/gateway/__tests__/data-plane/tools/web-search/providers/microsoft-grounding_test.ts +++ b/packages/gateway/__tests__/data-plane/tools/web-search/providers/web-iq_test.ts @@ -1,10 +1,10 @@ import { test } from 'vitest'; -import { createMicrosoftGroundingWebSearchProvider } from '../../../../../src/data-plane/tools/web-search/providers/microsoft-grounding.ts'; +import { createWebIqWebSearchProvider } from '../../../../../src/data-plane/tools/web-search/providers/web-iq.ts'; import { FakeTime } from '../../../../test-time.ts'; import { assertEquals, jsonResponse, withMockedFetch } from '@floway-dev/test-utils'; -test('createMicrosoftGroundingWebSearchProvider calls v3 search/web with passage content', async () => { +test('createWebIqWebSearchProvider calls v3 search/web with passage content', async () => { let request: Request | undefined; await withMockedFetch( @@ -22,7 +22,7 @@ test('createMicrosoftGroundingWebSearchProvider calls v3 search/web with passage }); }, async () => { - const provider = createMicrosoftGroundingWebSearchProvider('ms-test'); + const provider = createWebIqWebSearchProvider('ms-test'); const result = await provider.search({ query: 'React documentation', allowedDomains: ['react.dev', 'example.com OR site:evil.com'], @@ -42,14 +42,14 @@ test('createMicrosoftGroundingWebSearchProvider calls v3 search/web with passage assertEquals(body.region, 'GB'); assertEquals(result.type, 'ok'); if (result.type !== 'ok') { - throw new Error('expected successful Microsoft Grounding result'); + throw new Error('expected successful Web IQ result'); } assertEquals(result.results[0].pageAge, '2026-04-01T00:00:00Z'); }, ); }); -test('createMicrosoftGroundingWebSearchProvider forwards maxResults to upstream count', async () => { +test('createWebIqWebSearchProvider forwards maxResults to upstream count', async () => { let request: Request | undefined; await withMockedFetch( incoming => { @@ -57,7 +57,7 @@ test('createMicrosoftGroundingWebSearchProvider forwards maxResults to upstream return jsonResponse({ webResults: [] }); }, async () => { - const provider = createMicrosoftGroundingWebSearchProvider('ms-test'); + const provider = createWebIqWebSearchProvider('ms-test'); await provider.search({ query: 'React documentation', maxResults: 4 }); const body = JSON.parse(await request!.text()); assertEquals(body.count, 4); @@ -65,7 +65,7 @@ test('createMicrosoftGroundingWebSearchProvider forwards maxResults to upstream ); }); -test('createMicrosoftGroundingWebSearchProvider rejects blank and overlong queries before fetch', async () => { +test('createWebIqWebSearchProvider rejects blank and overlong queries before fetch', async () => { let called = false; await withMockedFetch( @@ -74,7 +74,7 @@ test('createMicrosoftGroundingWebSearchProvider rejects blank and overlong queri return jsonResponse({ webResults: [] }); }, async () => { - const provider = createMicrosoftGroundingWebSearchProvider('ms-test'); + const provider = createWebIqWebSearchProvider('ms-test'); assertEquals(await provider.search({ query: ' ' }), { type: 'error', @@ -93,7 +93,7 @@ test('createMicrosoftGroundingWebSearchProvider rejects blank and overlong queri assertEquals(called, false); }); -test('createMicrosoftGroundingWebSearchProvider retries 429 with by-design 1s/2s/4s/8s backoff and ignores retryAfter when the next attempt succeeds', async () => { +test('createWebIqWebSearchProvider retries 429 with by-design 1s/2s/4s/8s backoff and ignores retryAfter when the next attempt succeeds', async () => { const fakeTime = new FakeTime(); const attemptTimes: number[] = []; let attempts = 0; @@ -119,7 +119,7 @@ test('createMicrosoftGroundingWebSearchProvider retries 429 with by-design 1s/2s }); }, async () => { - const provider = createMicrosoftGroundingWebSearchProvider('ms-test'); + const provider = createWebIqWebSearchProvider('ms-test'); const resultPromise = provider.search({ query: 'React documentation' }); fakeTime.runMicrotasks(); @@ -150,7 +150,7 @@ test('createMicrosoftGroundingWebSearchProvider retries 429 with by-design 1s/2s } }); -test('createMicrosoftGroundingWebSearchProvider returns too_many_requests after four by-design 429 retries and ignores retryAfter', async () => { +test('createWebIqWebSearchProvider returns too_many_requests after four by-design 429 retries and ignores retryAfter', async () => { const fakeTime = new FakeTime(); const attemptTimes: number[] = []; @@ -161,7 +161,7 @@ test('createMicrosoftGroundingWebSearchProvider returns too_many_requests after return jsonResponse({ message: 'rate limited', retryAfter: '60s' }, 429); }, async () => { - const provider = createMicrosoftGroundingWebSearchProvider('ms-test'); + const provider = createWebIqWebSearchProvider('ms-test'); const resultPromise = provider.search({ query: 'React documentation' }); fakeTime.runMicrotasks(); @@ -195,11 +195,11 @@ test('createMicrosoftGroundingWebSearchProvider returns too_many_requests after } }); -test('createMicrosoftGroundingWebSearchProvider maps 413 to request_too_large', async () => { +test('createWebIqWebSearchProvider maps 413 to request_too_large', async () => { await withMockedFetch( () => jsonResponse({ message: 'too large' }, 413), async () => { - const provider = createMicrosoftGroundingWebSearchProvider('ms-test'); + const provider = createWebIqWebSearchProvider('ms-test'); assertEquals(await provider.search({ query: 'React documentation' }), { type: 'error', errorCode: 'request_too_large', @@ -209,11 +209,36 @@ test('createMicrosoftGroundingWebSearchProvider maps 413 to request_too_large', ); }); -test('createMicrosoftGroundingWebSearchProvider surfaces malformed payload as an error', async () => { +test('createWebIqWebSearchProvider surfaces the envelope userMessage rather than raw JSON', async () => { + // Shape captured from POST https://api.microsoft.ai/v3/search/web with an + // invalid key. None of its keys are the generic ones, so without the + // envelope rung the operator would read the whole body. + await withMockedFetch( + () => jsonResponse({ + errorCode: 'AuthInvalidApiKey', + errorCategory: 'UserError', + userMessage: 'Invalid API key provided.', + technicalDetails: 'Invalid API key', + retryAfter: null, + requestId: '6a68d5eb52c34a62a2d8a4c09c6d2dd2', + traceId: '6a68d5eb52c34a62a2d8a4c09c6d2dd2', + }, 401), + async () => { + const provider = createWebIqWebSearchProvider('ms-test'); + assertEquals(await provider.search({ query: 'React documentation' }), { + type: 'error', + errorCode: 'unavailable', + message: 'Invalid API key provided.', + }); + }, + ); +}); + +test('createWebIqWebSearchProvider surfaces malformed payload as an error', async () => { await withMockedFetch( () => jsonResponse({ message: 'unexpected' }), async () => { - const provider = createMicrosoftGroundingWebSearchProvider('ms-test'); + const provider = createWebIqWebSearchProvider('ms-test'); const result = await provider.search({ query: 'React documentation' }); assertEquals(result.type, 'error'); if (result.type !== 'error') throw new Error('expected error'); @@ -222,7 +247,7 @@ test('createMicrosoftGroundingWebSearchProvider surfaces malformed payload as an ); }); -test('Microsoft Grounding fetchPage issues one /v3/browse call per URL in parallel', async () => { +test('Web IQ fetchPage issues one /v3/browse call per URL in parallel', async () => { const callBodies: Array> = []; await withMockedFetch( @@ -237,7 +262,7 @@ test('Microsoft Grounding fetchPage issues one /v3/browse call per URL in parall }); }, async () => { - const provider = createMicrosoftGroundingWebSearchProvider('ms-test'); + const provider = createWebIqWebSearchProvider('ms-test'); const result = await provider.fetchPage({ urls: ['https://a.com', 'https://b.com'] }); assertEquals(callBodies.length, 2); @@ -260,11 +285,11 @@ test('Microsoft Grounding fetchPage issues one /v3/browse call per URL in parall ); }); -test('Microsoft Grounding fetchPage treats HTTP 202 as a per-URL failure (cold cache)', async () => { +test('Web IQ fetchPage treats HTTP 202 as a per-URL failure (cold cache)', async () => { await withMockedFetch( () => jsonResponse({ retryAfter: '30' }, 202), async () => { - const provider = createMicrosoftGroundingWebSearchProvider('ms-test'); + const provider = createWebIqWebSearchProvider('ms-test'); const result = await provider.fetchPage({ urls: ['https://cold.com'] }); if (result.type !== 'ok') throw new Error('expected ok'); assertEquals(result.failures, [{ url: 'https://cold.com', errorCode: 'unavailable', message: 'live crawl pending' }]); @@ -273,7 +298,7 @@ test('Microsoft Grounding fetchPage treats HTTP 202 as a per-URL failure (cold c ); }); -test('Microsoft Grounding fetchPage truncates long pages to MAX_FETCH_PAGE_BYTES', async () => { +test('Web IQ fetchPage truncates long pages to MAX_FETCH_PAGE_BYTES', async () => { const long = 'y'.repeat(30_000); await withMockedFetch( async incoming => { @@ -281,7 +306,7 @@ test('Microsoft Grounding fetchPage truncates long pages to MAX_FETCH_PAGE_BYTES return jsonResponse({ url: body.url, title: 'T', content: long }); }, async () => { - const provider = createMicrosoftGroundingWebSearchProvider('ms-test'); + const provider = createWebIqWebSearchProvider('ms-test'); const result = await provider.fetchPage({ urls: ['https://a.com'] }); if (result.type !== 'ok') throw new Error('expected ok'); assertEquals(result.pages[0].truncated, true); @@ -291,13 +316,13 @@ test('Microsoft Grounding fetchPage truncates long pages to MAX_FETCH_PAGE_BYTES ); }); -test('Microsoft Grounding fetchPage returns whole-batch error on HTTP 5xx after retry exhaustion', async () => { +test('Web IQ fetchPage returns whole-batch error on HTTP 5xx after retry exhaustion', async () => { const fakeTime = new FakeTime(); try { await withMockedFetch( () => new Response('upstream broken', { status: 502 }), async () => { - const provider = createMicrosoftGroundingWebSearchProvider('ms-test'); + const provider = createWebIqWebSearchProvider('ms-test'); const resultPromise = provider.fetchPage({ urls: ['https://a.com'] }); await fakeTime.tickAsync(15_000); const result = await resultPromise; diff --git a/packages/gateway/__tests__/data-plane/tools/web-search/search_test.ts b/packages/gateway/__tests__/data-plane/tools/web-search/search_test.ts index 49716d2d8..be841e6ab 100644 --- a/packages/gateway/__tests__/data-plane/tools/web-search/search_test.ts +++ b/packages/gateway/__tests__/data-plane/tools/web-search/search_test.ts @@ -35,7 +35,7 @@ test('runWebSearchAndRecordUsage records provider error results', async () => { initRepo(repo); const result = await runWebSearchAndRecordUsage({ - providerName: 'microsoft-grounding', + providerName: 'web-iq', keyId: 'key_b', request: { query: 'React' }, provider: stubProvider(() => @@ -49,7 +49,7 @@ test('runWebSearchAndRecordUsage records provider error results', async () => { assertEquals(result.type, 'error'); const records = await repo.webSearchUsage.listAll(); assertEquals(records.length, 1); - assertEquals(records[0].provider, 'microsoft-grounding'); + assertEquals(records[0].provider, 'web-iq'); assertEquals(records[0].keyId, 'key_b'); assertEquals(records[0].requests, 1); }); diff --git a/packages/gateway/__tests__/repo/search-usage_test.ts b/packages/gateway/__tests__/repo/search-usage_test.ts index 90fe08ba0..03a97e51b 100644 --- a/packages/gateway/__tests__/repo/search-usage_test.ts +++ b/packages/gateway/__tests__/repo/search-usage_test.ts @@ -16,7 +16,7 @@ const exerciseWebSearchUsageRepo = async (repo: WebSearchUsageRepo) => { await repo.deleteAll(); await repo.record({ provider: 'tavily', keyId: 'key_a', action: 'search', hour: '2026-04-25T10', requests: 1 }); await repo.record({ provider: 'tavily', keyId: 'key_a', action: 'search', hour: '2026-04-25T10', requests: 2 }); - await repo.record({ provider: 'microsoft-grounding', keyId: 'key_a', action: 'search', hour: '2026-04-25T11', requests: 4 }); + await repo.record({ provider: 'web-iq', keyId: 'key_a', action: 'search', hour: '2026-04-25T11', requests: 4 }); await repo.record({ provider: 'tavily', keyId: 'key_b', action: 'search', hour: '2026-04-25T12', requests: 8 }); await repo.record({ provider: 'tavily', keyId: 'key_a', action: 'search', hour: '2026-04-25T13', requests: 16 }); @@ -59,7 +59,7 @@ const exerciseWebSearchUsageRepo = async (repo: WebSearchUsageRepo) => { requests: 3, }, { - provider: 'microsoft-grounding', + provider: 'web-iq', keyId: 'key_a', action: 'search', hour: '2026-04-25T11', diff --git a/packages/gateway/migrations/0069_web_iq_provider_rename.sql b/packages/gateway/migrations/0069_web_iq_provider_rename.sql new file mode 100644 index 000000000..34214bac4 --- /dev/null +++ b/packages/gateway/migrations/0069_web_iq_provider_rename.sql @@ -0,0 +1,48 @@ +-- Rename the Microsoft web search provider to the product's public name. +-- +-- The endpoints this provider has always called — api.microsoft.ai/v3/search/web +-- and /v3/browse, authenticated with `x-apikey` — belong to Microsoft Web IQ, +-- which was still unreleased and documented only internally when the provider +-- landed. "Microsoft Grounding" was never a public name, and the name it most +-- resembles, Grounding with Bing Search, is a different product that is still +-- generally available on api.bing.microsoft.com behind an Azure +-- Microsoft.Bing/accounts resource. Operators reading the old name would +-- provision the wrong resource and get a key this provider cannot use. +-- +-- https://webiq.microsoft.ai/ +-- https://github.com/Azure/azure-rest-api-specs/pull/43848 +-- +-- Stored credentials carry over untouched: the same key keeps working, only the +-- column and the provider identifier change. + +ALTER TABLE search_config RENAME COLUMN microsoft_grounding_api_key TO web_iq_api_key; + +UPDATE search_config SET provider = 'web-iq' WHERE provider = 'microsoft-grounding'; + +-- `search_usage.provider` carries a CHECK constraint listing the allowed names; +-- D1/SQLite cannot alter a CHECK constraint in place, so we rebuild the table +-- via swap (same pattern as 0043 — see the comment there). Recorded usage rows +-- are rewritten so per-provider history stays continuous across the rename. + +CREATE TABLE search_usage_new ( + provider TEXT NOT NULL CHECK (provider IN ('tavily', 'web-iq', 'jina')), + key_id TEXT NOT NULL, + action TEXT NOT NULL DEFAULT 'search' CHECK (action IN ('search', 'fetch_page')), + hour TEXT NOT NULL, + requests INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (provider, key_id, action, hour) +); + +INSERT INTO search_usage_new (provider, key_id, action, hour, requests) +SELECT + CASE provider WHEN 'microsoft-grounding' THEN 'web-iq' ELSE provider END, + key_id, + action, + hour, + requests +FROM search_usage; + +DROP INDEX IF EXISTS idx_search_usage_hour; +DROP TABLE search_usage; +ALTER TABLE search_usage_new RENAME TO search_usage; +CREATE INDEX idx_search_usage_hour ON search_usage (hour); diff --git a/packages/gateway/src/control-plane/data-transfer/routes.ts b/packages/gateway/src/control-plane/data-transfer/routes.ts index d9487a85d..0c06d9c66 100644 --- a/packages/gateway/src/control-plane/data-transfer/routes.ts +++ b/packages/gateway/src/control-plane/data-transfer/routes.ts @@ -47,7 +47,7 @@ interface SerializedProxy { } interface ExportPayload { - version: 17; + version: 18; exportedAt: string; data: { users: User[]; @@ -62,7 +62,7 @@ interface ExportPayload { }; } -const EXPORT_VERSION = 17; +const EXPORT_VERSION = 18; const SEARCH_USAGE_HOUR_PATTERN = /^\d{4}-\d{2}-\d{2}T\d{2}$/; const PERFORMANCE_METRICS = new Set(['ttft_ms', 'tpot_us']); const UPSTREAM_PROVIDERS = new Set(ALL_PROVIDER_KINDS); diff --git a/packages/gateway/src/control-plane/schemas.ts b/packages/gateway/src/control-plane/schemas.ts index d7ca3e017..3b3442a91 100644 --- a/packages/gateway/src/control-plane/schemas.ts +++ b/packages/gateway/src/control-plane/schemas.ts @@ -558,9 +558,9 @@ export const resetBackoffBody = z.object({ // --- search config --- export const webSearchConfigSchema = z.object({ - provider: z.enum(['disabled', 'tavily', 'microsoft-grounding', 'jina']), + provider: z.enum(['disabled', 'tavily', 'web-iq', 'jina']), tavily: z.object({ apiKey: z.string() }), - microsoftGrounding: z.object({ apiKey: z.string() }), + webIq: z.object({ apiKey: z.string() }), jina: z.object({ apiKey: z.string() }), passthroughOpenAiSearch: z.object({ enabled: z.boolean(), @@ -699,7 +699,7 @@ export const updateAliasBody = aliasBodyCore.superRefine(aliasBodyRulesRefinemen // --- data transfer --- export const importBody = z.object({ - version: z.literal(17, { error: 'version must be 17 — older export formats are not supported; re-export from the current deployment' }), + version: z.literal(18, { error: 'version must be 18 — older export formats are not supported; re-export from the current deployment' }), mode: z.enum(['merge', 'replace'], { error: "mode must be 'merge' or 'replace'" }), data: z.unknown().optional(), }); diff --git a/packages/gateway/src/control-plane/search-usage/routes.ts b/packages/gateway/src/control-plane/search-usage/routes.ts index 14a8f109d..0adf3dd38 100644 --- a/packages/gateway/src/control-plane/search-usage/routes.ts +++ b/packages/gateway/src/control-plane/search-usage/routes.ts @@ -8,7 +8,7 @@ import { aggregateWebSearchUsageByKey, aggregateWebSearchUsageByUser } from './a import { loadWebSearchConfig } from '../../data-plane/tools/web-search/config.ts'; import { type CtxWithQuery } from '../../middleware/zod-validator.ts'; import { getRepo } from '../../repo/index.ts'; -import { isWebSearchProviderName } from '../../shared/web-search-providers.ts'; +import { isWebSearchProviderName, WEB_SEARCH_PROVIDER_NAMES } from '../../shared/web-search-providers.ts'; import type { webSearchUsageQuery } from '../schemas.ts'; import { buildKeyToUserMap } from '../shared/key-to-user.ts'; import { resolveUsageView } from '../shared/usage-view.ts'; @@ -23,7 +23,7 @@ export const webSearchUsage = async (c: CtxWithQuery const { provider } = query; if (provider !== undefined && !isWebSearchProviderName(provider)) { - return c.json({ error: "provider must be 'tavily' or 'microsoft-grounding'" }, 400); + return c.json({ error: `provider must be one of ${WEB_SEARCH_PROVIDER_NAMES.map(name => `'${name}'`).join(', ')}` }, 400); } const resolved = resolveUsageView(c, query.view, query.key_id); diff --git a/packages/gateway/src/data-plane/chat/responses/interceptors/server-tools/web-search.ts b/packages/gateway/src/data-plane/chat/responses/interceptors/server-tools/web-search.ts index b6cb4ffda..5577788ae 100644 --- a/packages/gateway/src/data-plane/chat/responses/interceptors/server-tools/web-search.ts +++ b/packages/gateway/src/data-plane/chat/responses/interceptors/server-tools/web-search.ts @@ -57,7 +57,7 @@ const formatUserLocation = (loc: NonNullable): // The injected function mirrors the complete command object accepted by // OpenAI's alpha-search endpoint. Alpha passthrough can execute every field; -// local Tavily, Jina, and Microsoft Grounding execution rejects the fields it +// local Tavily, Jina, and Web IQ execution rejects the fields it // cannot implement before dispatch. // https://github.com/openai/codex/blob/2f19a57704fb7b1db032bc38cf995034254eaebb/codex-rs/codex-api/src/search.rs#L31-L213 export const buildShimFunctionTool = ( diff --git a/packages/gateway/src/data-plane/tools/web-search/config.ts b/packages/gateway/src/data-plane/tools/web-search/config.ts index b5f5e29d1..fd213a68e 100644 --- a/packages/gateway/src/data-plane/tools/web-search/config.ts +++ b/packages/gateway/src/data-plane/tools/web-search/config.ts @@ -6,7 +6,7 @@ import { WEB_SEARCH_PROVIDER_NAMES, isWebSearchProviderName } from '../../../sha export const DEFAULT_WEB_SEARCH_CONFIG: WebSearchConfig = { provider: 'disabled', tavily: { apiKey: '' }, - microsoftGrounding: { apiKey: '' }, + webIq: { apiKey: '' }, jina: { apiKey: '' }, passthroughOpenAiSearch: { enabled: false, upstreamId: '', model: '' }, }; @@ -33,11 +33,11 @@ export const parseWebSearchConfigStrict = (input: unknown): WebSearchConfig => { if (typeof input.tavily.apiKey !== 'string') { throw new Error('search config tavily.apiKey must be a string'); } - if (!isJsonObject(input.microsoftGrounding)) { - throw new Error('search config microsoftGrounding must be an object'); + if (!isJsonObject(input.webIq)) { + throw new Error('search config webIq must be an object'); } - if (typeof input.microsoftGrounding.apiKey !== 'string') { - throw new Error('search config microsoftGrounding.apiKey must be a string'); + if (typeof input.webIq.apiKey !== 'string') { + throw new Error('search config webIq.apiKey must be a string'); } if (!isJsonObject(input.jina)) { throw new Error('search config jina must be an object'); @@ -60,7 +60,7 @@ export const parseWebSearchConfigStrict = (input: unknown): WebSearchConfig => { return { provider: input.provider, tavily: { apiKey: input.tavily.apiKey.trim() }, - microsoftGrounding: { apiKey: input.microsoftGrounding.apiKey.trim() }, + webIq: { apiKey: input.webIq.apiKey.trim() }, jina: { apiKey: input.jina.apiKey.trim() }, passthroughOpenAiSearch: { enabled: passthrough.enabled, upstreamId, model }, }; diff --git a/packages/gateway/src/data-plane/tools/web-search/domain-normalize.ts b/packages/gateway/src/data-plane/tools/web-search/domain-normalize.ts index 46a790405..5e732bf60 100644 --- a/packages/gateway/src/data-plane/tools/web-search/domain-normalize.ts +++ b/packages/gateway/src/data-plane/tools/web-search/domain-normalize.ts @@ -1,9 +1,9 @@ // Shared normalizer for `allowed_domains` / `blocked_domains` list // entries, used by the local URL-allowed filter, the Tavily -// include/exclude payload, and the Microsoft Grounding `site:` builder. +// include/exclude payload, and the Web IQ `site:` builder. // All three must agree on what a "domain entry" means — we unify on // the strictest of the three: trim, lowercase, validate. Entries that -// fail validation drop, matching Grounding's behavior. +// fail validation drop, matching Web IQ's behavior. // // Pattern requires at least one dot; labels are 1-63 chars of // `[a-z0-9-]`, may not start or end with `-`. Schemes, ports, paths, diff --git a/packages/gateway/src/data-plane/tools/web-search/operations.ts b/packages/gateway/src/data-plane/tools/web-search/operations.ts index db394ec0d..097248f5e 100644 --- a/packages/gateway/src/data-plane/tools/web-search/operations.ts +++ b/packages/gateway/src/data-plane/tools/web-search/operations.ts @@ -149,7 +149,7 @@ const assertObjectHasOnly = ( } }; -// The local Tavily, Jina, and Microsoft Grounding adapters implement the +// The local Tavily, Jina, and Web IQ adapters implement the // common text-search/open/find subset. OpenAI's complete command object remains // valid for alpha-search passthrough; reaching a local adapter with any other // command or nested command parameter is an explicit error so no request field @@ -443,7 +443,7 @@ export const renderWebSearchCallOutput = (ir: WebSearchCallIR): string => // ── Domain filtering ── -// Suffix-match per Tavily and Microsoft Grounding search-side filter +// Suffix-match per Tavily and Web IQ search-side filter // semantics: `example.com` matches `example.com`, `www.example.com`, and // `sub.example.com`, but NOT `evil-example.com`. const matchesAnyDomain = (hostname: string, domains: readonly string[]): boolean => { diff --git a/packages/gateway/src/data-plane/tools/web-search/provider.ts b/packages/gateway/src/data-plane/tools/web-search/provider.ts index 943da3942..f5b3b9157 100644 --- a/packages/gateway/src/data-plane/tools/web-search/provider.ts +++ b/packages/gateway/src/data-plane/tools/web-search/provider.ts @@ -1,7 +1,7 @@ import { FIXED_WEB_SEARCH_CONFIG_TEST_QUERY } from './config.ts'; import { createJinaWebSearchProvider } from './providers/jina.ts'; -import { createMicrosoftGroundingWebSearchProvider } from './providers/microsoft-grounding.ts'; import { createTavilyWebSearchProvider } from './providers/tavily.ts'; +import { createWebIqWebSearchProvider } from './providers/web-iq.ts'; import type { ConfiguredWebSearchProvider, WebSearchConfig, WebSearchConfigConnectionTestResult, WebSearchProvider, WebSearchProviderName } from './types.ts'; const toPreviewText = (content: Array<{ type: 'text'; text: string }>): string => @@ -16,7 +16,7 @@ const toPreviewText = (content: Array<{ type: 'text'; text: string }>): string = // if-branch. const PROVIDER_FACTORIES: { [N in WebSearchProviderName]: (config: WebSearchConfig) => { apiKey: string; build: (apiKey: string) => WebSearchProvider } } = { tavily: config => ({ apiKey: config.tavily.apiKey, build: createTavilyWebSearchProvider }), - 'microsoft-grounding': config => ({ apiKey: config.microsoftGrounding.apiKey, build: createMicrosoftGroundingWebSearchProvider }), + 'web-iq': config => ({ apiKey: config.webIq.apiKey, build: createWebIqWebSearchProvider }), jina: config => ({ apiKey: config.jina.apiKey, build: createJinaWebSearchProvider }), }; diff --git a/packages/gateway/src/data-plane/tools/web-search/providers/jina.ts b/packages/gateway/src/data-plane/tools/web-search/providers/jina.ts index f1f1b9d84..938de50f7 100644 --- a/packages/gateway/src/data-plane/tools/web-search/providers/jina.ts +++ b/packages/gateway/src/data-plane/tools/web-search/providers/jina.ts @@ -19,7 +19,7 @@ const JINA_READER_URL = 'https://r.jina.ai/'; // `X-Max-Tokens` on s.jina.ai. Jina's default scrapes the full readability // markdown of each result page, which can run into double-digit KB; capping // to 500 tokens (~2 KB markdown) keeps each Jina result in the same size -// ballpark as Microsoft Grounding's `passage` mode (~300-400 tokens) and +// ballpark as Web IQ's `passage` mode (~300-400 tokens) and // Tavily's `basic` mode (~100 tokens / ~400 chars). 500 is also Jina's // documented minimum — values below trigger `Rejected by validator (v) => // v >= 500`. Verified against jina-ai/reader's `tokenTrim` call sites in @@ -80,7 +80,7 @@ const normalizeSearchResult = (value: unknown): Extract readOneUrl(httpFetch, apiKey, url, request.signal))); // Whole-batch transport / 5xx failure collapses into one envelope — - // mirrors Microsoft Grounding's policy. Per-URL 4xx stays granular so + // mirrors Web IQ's policy. Per-URL 4xx stays granular so // a single bad target doesn't poison the rest of the batch. const allHardFail = outcomes.every(outcome => outcome.kind === 'fail' && (outcome.httpStatus === 0 || outcome.httpStatus >= 500)); if (allHardFail) { diff --git a/packages/gateway/src/data-plane/tools/web-search/providers/shared.ts b/packages/gateway/src/data-plane/tools/web-search/providers/shared.ts index 9a4b18b90..342d4e419 100644 --- a/packages/gateway/src/data-plane/tools/web-search/providers/shared.ts +++ b/packages/gateway/src/data-plane/tools/web-search/providers/shared.ts @@ -133,6 +133,19 @@ export const extractWebSearchProviderErrorMessage = async (response: Response): if (typeof parsed.message === 'string') { return parsed.message; } + // Web IQ writes the human-readable half of its envelope to `userMessage` + // and the diagnostic half to `technicalDetails`, alongside `errorCode`, + // `errorCategory`, `requestId`, and `traceId`. Without this rung the whole + // envelope reaches the operator as raw JSON. Observed on + // POST https://api.microsoft.ai/v3/search/web with an invalid key: + // {"errorCode":"AuthInvalidApiKey","errorCategory":"UserError", + // "userMessage":"Invalid API key provided.","technicalDetails":"Invalid API key",…} + if (typeof parsed.userMessage === 'string') { + return parsed.userMessage; + } + if (typeof parsed.technicalDetails === 'string') { + return parsed.technicalDetails; + } } catch { return text; } diff --git a/packages/gateway/src/data-plane/tools/web-search/providers/microsoft-grounding.ts b/packages/gateway/src/data-plane/tools/web-search/providers/web-iq.ts similarity index 86% rename from packages/gateway/src/data-plane/tools/web-search/providers/microsoft-grounding.ts rename to packages/gateway/src/data-plane/tools/web-search/providers/web-iq.ts index 3a17b5a29..b36643867 100644 --- a/packages/gateway/src/data-plane/tools/web-search/providers/microsoft-grounding.ts +++ b/packages/gateway/src/data-plane/tools/web-search/providers/web-iq.ts @@ -13,14 +13,14 @@ import { type WebSearchProviderResult, } from '../types.ts'; -const MICROSOFT_GROUNDING_SEARCH_URL = 'https://api.microsoft.ai/v3/search/web'; -// Grounding `browse` API is single-URL; we issue Promise.all over the batch. +const WEB_IQ_SEARCH_URL = 'https://api.microsoft.ai/v3/search/web'; +// The Web IQ `browse` API is single-URL; we issue Promise.all over the batch. // Per-iteration concurrency is naturally bounded by the shim's iteration cap // (~30) and the model's parallel call count (≤4 in practice). -const MICROSOFT_GROUNDING_BROWSE_URL = 'https://api.microsoft.ai/v3/browse'; +const WEB_IQ_BROWSE_URL = 'https://api.microsoft.ai/v3/browse'; const toMicrosoftQuery = (request: WebSearchProviderRequest, query: string) => { - // Microsoft Grounding has no allow/block-domain fields, so domain + // Web IQ has no allow/block-domain fields, so domain // policy is biased through `site:` / `-site:` operators. Best-effort, // not strict. Smuggled query fragments (e.g. `example.com OR // site:evil.com`) get rejected at normalization. @@ -63,7 +63,7 @@ type BrowseOutcome = const browseOneUrl = async (httpFetch: typeof fetch, apiKey: string, url: string, signal?: AbortSignal): Promise => { try { - const response = await fetchWithRetry(() => httpFetch(MICROSOFT_GROUNDING_BROWSE_URL, { + const response = await fetchWithRetry(() => httpFetch(WEB_IQ_BROWSE_URL, { method: 'POST', headers: { 'content-type': 'application/json', @@ -79,7 +79,7 @@ const browseOneUrl = async (httpFetch: typeof fetch, apiKey: string, url: string ...(signal !== undefined ? { signal } : {}), }), signal); - // Grounding returns 202 with `retryAfter` when the page isn't + // Web IQ returns 202 with `retryAfter` when the page isn't // cached and a live crawl was kicked off. Don't poll — Workers // can't afford the budget and re-issuing from the next model turn // is fine. @@ -94,7 +94,7 @@ const browseOneUrl = async (httpFetch: typeof fetch, apiKey: string, url: string const payload = await response.json(); if (!isJsonObject(payload) || typeof payload.url !== 'string') { - return { kind: 'fail', url, httpStatus: response.status, message: 'Microsoft Grounding browse returned an unexpected payload.' }; + return { kind: 'fail', url, httpStatus: response.status, message: 'Web IQ browse returned an unexpected payload.' }; } return { kind: 'ok', @@ -112,7 +112,7 @@ const browseOneUrl = async (httpFetch: typeof fetch, apiKey: string, url: string } }; -export const createMicrosoftGroundingWebSearchProvider = (apiKey: string, deps?: { fetch?: typeof fetch }): WebSearchProvider => { +export const createWebIqWebSearchProvider = (apiKey: string, deps?: { fetch?: typeof fetch }): WebSearchProvider => { const httpFetch = deps?.fetch ?? fetch; const search = async (request: WebSearchProviderRequest): Promise => { @@ -133,7 +133,7 @@ export const createMicrosoftGroundingWebSearchProvider = (apiKey: string, deps?: } try { - const response = await fetchWithRetry(() => httpFetch(MICROSOFT_GROUNDING_SEARCH_URL, { + const response = await fetchWithRetry(() => httpFetch(WEB_IQ_SEARCH_URL, { method: 'POST', headers: { 'content-type': 'application/json', @@ -146,12 +146,12 @@ export const createMicrosoftGroundingWebSearchProvider = (apiKey: string, deps?: if (response.ok) { const payload = await response.json(); // Unexpected payload shape is a backend contract violation; - // returning empty results would mask a real Grounding outage. + // returning empty results would mask a real Web IQ outage. if (!isJsonObject(payload) || !Array.isArray(payload.webResults)) { return { type: 'error', errorCode: 'unavailable', - message: 'Microsoft Grounding returned an unexpected payload shape; check provider status.', + message: 'Web IQ returned an unexpected payload shape; check provider status.', }; } const results = payload.webResults.map(normalizeResult).filter((entry): entry is NonNullable => entry !== null); @@ -168,7 +168,7 @@ export const createMicrosoftGroundingWebSearchProvider = (apiKey: string, deps?: return { type: 'error', errorCode: httpStatusToErrorCode(response.status), - message: message ?? 'Microsoft Grounding rate limited the request.', + message: message ?? 'Web IQ rate limited the request.', }; } @@ -176,7 +176,7 @@ export const createMicrosoftGroundingWebSearchProvider = (apiKey: string, deps?: return { type: 'error', errorCode: httpStatusToErrorCode(response.status), - message: message ?? 'Microsoft Grounding rejected the search query.', + message: message ?? 'Web IQ rejected the search query.', }; } @@ -184,20 +184,20 @@ export const createMicrosoftGroundingWebSearchProvider = (apiKey: string, deps?: return { type: 'error', errorCode: httpStatusToErrorCode(response.status), - message: message ?? 'Microsoft Grounding rejected the request as too large.', + message: message ?? 'Web IQ rejected the request as too large.', }; } return { type: 'error', errorCode: httpStatusToErrorCode(response.status), - message: message ?? 'Microsoft Grounding search failed.', + message: message ?? 'Web IQ search failed.', }; } catch (error) { return { type: 'error', errorCode: 'unavailable', - message: error instanceof Error ? error.message : 'Microsoft Grounding search failed.', + message: error instanceof Error ? error.message : 'Web IQ search failed.', }; } }; diff --git a/packages/gateway/src/repo/sql.ts b/packages/gateway/src/repo/sql.ts index 4a5be85f8..c6239c3de 100644 --- a/packages/gateway/src/repo/sql.ts +++ b/packages/gateway/src/repo/sql.ts @@ -866,13 +866,13 @@ class SqlWebSearchConfigRepo implements WebSearchConfigRepo { async get(): Promise { const row = await this.db - .prepare('SELECT provider, tavily_api_key, microsoft_grounding_api_key, jina_api_key, passthrough_openai_search, alpha_search_upstream_id, alpha_search_model FROM search_config WHERE id = 1') - .first<{ provider: string; tavily_api_key: string; microsoft_grounding_api_key: string; jina_api_key: string; passthrough_openai_search: number; alpha_search_upstream_id: string; alpha_search_model: string }>(); + .prepare('SELECT provider, tavily_api_key, web_iq_api_key, jina_api_key, passthrough_openai_search, alpha_search_upstream_id, alpha_search_model FROM search_config WHERE id = 1') + .first<{ provider: string; tavily_api_key: string; web_iq_api_key: string; jina_api_key: string; passthrough_openai_search: number; alpha_search_upstream_id: string; alpha_search_model: string }>(); if (!row) throw new Error('search_config singleton row missing'); return { provider: row.provider, tavily: { apiKey: row.tavily_api_key }, - microsoftGrounding: { apiKey: row.microsoft_grounding_api_key }, + webIq: { apiKey: row.web_iq_api_key }, jina: { apiKey: row.jina_api_key }, passthroughOpenAiSearch: { enabled: row.passthrough_openai_search === 1, @@ -883,22 +883,22 @@ class SqlWebSearchConfigRepo implements WebSearchConfigRepo { } async save(config: WebSearchConfig): Promise { - const { provider, tavily, microsoftGrounding, jina, passthroughOpenAiSearch } = config; + const { provider, tavily, webIq, jina, passthroughOpenAiSearch } = config; await this.db .prepare( - `INSERT INTO search_config (id, provider, tavily_api_key, microsoft_grounding_api_key, jina_api_key, passthrough_openai_search, alpha_search_upstream_id, alpha_search_model, updated_at) + `INSERT INTO search_config (id, provider, tavily_api_key, web_iq_api_key, jina_api_key, passthrough_openai_search, alpha_search_upstream_id, alpha_search_model, updated_at) VALUES (1, ?, ?, ?, ?, ?, ?, ?, strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) ON CONFLICT (id) DO UPDATE SET provider = excluded.provider, tavily_api_key = excluded.tavily_api_key, - microsoft_grounding_api_key = excluded.microsoft_grounding_api_key, + web_iq_api_key = excluded.web_iq_api_key, jina_api_key = excluded.jina_api_key, passthrough_openai_search = excluded.passthrough_openai_search, alpha_search_upstream_id = excluded.alpha_search_upstream_id, alpha_search_model = excluded.alpha_search_model, updated_at = excluded.updated_at`, ) - .bind(provider, tavily.apiKey, microsoftGrounding.apiKey, jina.apiKey, passthroughOpenAiSearch.enabled ? 1 : 0, passthroughOpenAiSearch.upstreamId, passthroughOpenAiSearch.model) + .bind(provider, tavily.apiKey, webIq.apiKey, jina.apiKey, passthroughOpenAiSearch.enabled ? 1 : 0, passthroughOpenAiSearch.upstreamId, passthroughOpenAiSearch.model) .run(); } } diff --git a/packages/gateway/src/shared/web-search-providers.ts b/packages/gateway/src/shared/web-search-providers.ts index cecbfddfa..006d4f3d6 100644 --- a/packages/gateway/src/shared/web-search-providers.ts +++ b/packages/gateway/src/shared/web-search-providers.ts @@ -1,11 +1,11 @@ -export const WEB_SEARCH_PROVIDER_NAMES = ['tavily', 'microsoft-grounding', 'jina'] as const; +export const WEB_SEARCH_PROVIDER_NAMES = ['tavily', 'web-iq', 'jina'] as const; export type WebSearchProviderName = (typeof WEB_SEARCH_PROVIDER_NAMES)[number]; export interface WebSearchConfig { provider: 'disabled' | WebSearchProviderName; tavily: { apiKey: string }; - microsoftGrounding: { apiKey: string }; + webIq: { apiKey: string }; jina: { apiKey: string }; passthroughOpenAiSearch: { enabled: boolean;