From be00b7143e3acd809875a1cad1cee094606b1d5c Mon Sep 17 00:00:00 2001 From: Menci Date: Tue, 28 Jul 2026 23:42:39 +0800 Subject: [PATCH 1/3] fix: name the Microsoft search provider Web IQ 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 unreleased and documented only internally when the provider landed, so it was carried under the internal name "Microsoft Grounding". That name was never public, and the product it most resembles, Grounding with Bing Search, is a different service that is still generally available on api.bing.microsoft.com behind an Azure Microsoft.Bing/accounts resource. An operator reading the old label would provision that resource and receive a key this provider cannot use; the endpoint ignores Ocp-Apim-Subscription-Key outright. Rename the provider identifier to web-iq across the config contract, the stored column, and recorded usage, and migrate existing rows so credentials keep working and per-provider history stays continuous. The dashboard now labels it "Microsoft Web IQ", the name the product uses for itself, links to the Web IQ portal where its keys are actually issued, and drops the Bing mark for the Microsoft one -- Web IQ ships no mark of its own. The search-usage provider rejection message named two of the three providers and would have drifted again here, so derive it from the provider list. --- apps/web/src/api/types.ts | 4 +- apps/web/src/assets/bing.svg | 1 - apps/web/src/assets/icons/NOTICE | 5 +- apps/web/src/assets/icons/microsoft.svg | 1 + apps/web/src/components/usage/chart-model.ts | 2 +- apps/web/src/i18n/locales/en.ts | 6 +-- apps/web/src/i18n/locales/zh-Hans.ts | 5 +- .../src/routes/dashboard-providers-search.tsx | 18 +++---- .../data-transfer/routes_test.ts | 16 +++---- .../search-config/routes_test.ts | 2 +- .../control-plane/search-usage/routes_test.ts | 12 ++--- .../data-plane/alpha-search/routes_test.ts | 2 +- .../interceptors/server-tool-shim_test.ts | 6 +-- .../tools/web-search/config_test.ts | 34 ++++++------- .../tools/web-search/domain-normalize_test.ts | 4 +- .../tools/web-search/provider_test.ts | 24 +++++----- ...osoft-grounding_test.ts => web-iq_test.ts} | 48 +++++++++---------- .../tools/web-search/search_test.ts | 4 +- .../__tests__/repo/search-usage_test.ts | 4 +- .../0069_web_iq_provider_rename.sql | 48 +++++++++++++++++++ packages/gateway/src/control-plane/schemas.ts | 4 +- .../src/control-plane/search-usage/routes.ts | 4 +- .../interceptors/server-tools/web-search.ts | 2 +- .../src/data-plane/tools/web-search/config.ts | 12 ++--- .../tools/web-search/domain-normalize.ts | 4 +- .../data-plane/tools/web-search/operations.ts | 4 +- .../data-plane/tools/web-search/provider.ts | 4 +- .../tools/web-search/providers/jina.ts | 8 ++-- .../{microsoft-grounding.ts => web-iq.ts} | 32 ++++++------- packages/gateway/src/repo/sql.ts | 14 +++--- .../src/shared/web-search-providers.ts | 4 +- 31 files changed, 193 insertions(+), 145 deletions(-) delete mode 100644 apps/web/src/assets/bing.svg create mode 100644 apps/web/src/assets/icons/microsoft.svg rename packages/gateway/__tests__/data-plane/tools/web-search/providers/{microsoft-grounding_test.ts => web-iq_test.ts} (78%) create mode 100644 packages/gateway/migrations/0069_web_iq_provider_rename.sql rename packages/gateway/src/data-plane/tools/web-search/providers/{microsoft-grounding.ts => web-iq.ts} (86%) diff --git a/apps/web/src/api/types.ts b/apps/web/src/api/types.ts index b411da336..d139fcb10 100644 --- a/apps/web/src/api/types.ts +++ b/apps/web/src/api/types.ts @@ -114,9 +114,9 @@ export interface ControlPlaneModel extends PublicModel { } export interface SearchConfig { - provider: 'disabled' | 'tavily' | 'microsoft-grounding' | 'jina'; + provider: 'disabled' | 'tavily' | 'web-iq' | 'jina'; tavily: { apiKey: string }; - microsoftGrounding: { apiKey: string }; + webIq: { apiKey: string }; jina: { apiKey: string }; passthroughOpenAiSearch: { enabled: boolean; upstreamId: string; model: string }; } 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/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 3a001a573..233b5ef58 100644 --- a/apps/web/src/i18n/locales/en.ts +++ b/apps/web/src/i18n/locales/en.ts @@ -1025,7 +1025,7 @@ const en = { provider: { disabled: 'Disabled', tavily: 'Tavily', - microsoftGrounding: 'Microsoft Grounding', + webIq: 'Microsoft Web IQ', jina: 'Jina', }, passthrough: { @@ -1037,8 +1037,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 →', diff --git a/apps/web/src/i18n/locales/zh-Hans.ts b/apps/web/src/i18n/locales/zh-Hans.ts index 1899b5aba..e7adaac19 100644 --- a/apps/web/src/i18n/locales/zh-Hans.ts +++ b/apps/web/src/i18n/locales/zh-Hans.ts @@ -982,13 +982,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 密钥', diff --git a/apps/web/src/routes/dashboard-providers-search.tsx b/apps/web/src/routes/dashboard-providers-search.tsx index 75b34b330..acd2b2c97 100644 --- a/apps/web/src/routes/dashboard-providers-search.tsx +++ b/apps/web/src/routes/dashboard-providers-search.tsx @@ -8,8 +8,8 @@ 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'; @@ -62,7 +62,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: '' }, }; @@ -99,13 +99,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', 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..d3debcf03 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', @@ -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: '' }, }); @@ -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: '' }, }); @@ -1351,7 +1351,7 @@ 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: '' }, }; 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 97fedc93a..d3cc0c2d9 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: '' }, }); @@ -60,7 +60,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.activeProvider, 'microsoft-grounding'); + assertEquals(body.activeProvider, 'web-iq'); assertEquals(body.keys, [ { id: apiKey.id, name: apiKey.name, createdAt: apiKey.createdAt }, ]); @@ -86,11 +86,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 b36263a89..f8f7c021a 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 78% 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..3b88412eb 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,11 @@ test('createMicrosoftGroundingWebSearchProvider maps 413 to request_too_large', ); }); -test('createMicrosoftGroundingWebSearchProvider surfaces malformed payload as an error', async () => { +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 +222,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 +237,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 +260,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 +273,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 +281,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 +291,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/schemas.ts b/packages/gateway/src/control-plane/schemas.ts index d7ca3e017..851708111 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(), diff --git a/packages/gateway/src/control-plane/search-usage/routes.ts b/packages/gateway/src/control-plane/search-usage/routes.ts index da8785cce..c43b901de 100644 --- a/packages/gateway/src/control-plane/search-usage/routes.ts +++ b/packages/gateway/src/control-plane/search-usage/routes.ts @@ -9,7 +9,7 @@ import type { SearchUsageByKeyResponse, SearchUsageByUserResponse } from '../usa 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/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; From 90e23f1c98f6bed4aa88af5504a2613ca0a8a8a9 Mon Sep 17 00:00:00 2001 From: Menci Date: Tue, 28 Jul 2026 23:53:22 +0800 Subject: [PATCH 2/3] feat(gateway): retire the pre-rename export format MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The dump's searchConfig carries the web search provider under its own key, so a backup taken before the Web IQ rename spells it microsoftGrounding and names the provider microsoft-grounding. Strict parsing already rejects both, but it would report a field-level complaint about a file that is simply too old. Bump the export format to 18 instead. Import already gates on an exact version with an actionable message, so a stale backup now fails on the envelope and says to re-export, and no key-shape compatibility has to live in the parser. The dashboard kept a hand-copied version constant, which the bump would have left one behind — enough to reject every fresh backup file as unreadable. Type it against the gateway's own literal so the next bump breaks the build here rather than at runtime. --- .../routes/dashboard-admin-backup-restore.tsx | 6 +- .../data-transfer/routes_test.ts | 62 +++++++++---------- .../src/control-plane/data-transfer/routes.ts | 4 +- packages/gateway/src/control-plane/schemas.ts | 2 +- 4 files changed, 39 insertions(+), 35 deletions(-) diff --git a/apps/web/src/routes/dashboard-admin-backup-restore.tsx b/apps/web/src/routes/dashboard-admin-backup-restore.tsx index b077325bf..61767bfdb 100644 --- a/apps/web/src/routes/dashboard-admin-backup-restore.tsx +++ b/apps/web/src/routes/dashboard-admin-backup-restore.tsx @@ -1,4 +1,5 @@ import { ArrowDownloadRegular, ArrowUploadRegular } from '@fluentui/react-icons'; +import type { InferResponseType } from 'hono/client'; import { useCallback, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { redirect } from 'react-router'; @@ -128,7 +129,10 @@ const PREVIEW_LABEL_KEYS = [ 'searchUsage', 'performance', ] as const; -const EXPORT_VERSION = 17; +// Annotated with the gateway's own literal so a bump there fails this +// assignment rather than silently leaving the dashboard rejecting every fresh +// backup file as unreadable. +const EXPORT_VERSION: InferResponseType['version'] = 18; function formatFileSize(bytes: number): string { if (bytes < 1024) return `${bytes} B`; 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 d3debcf03..8a0e8f1d7 100644 --- a/packages/gateway/__tests__/control-plane/data-transfer/routes_test.ts +++ b/packages/gateway/__tests__/control-plane/data-transfer/routes_test.ts @@ -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, []); @@ -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', { @@ -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); @@ -1358,12 +1358,12 @@ test('a full v17 export re-imports verbatim — the export→import round trip i 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/src/control-plane/data-transfer/routes.ts b/packages/gateway/src/control-plane/data-transfer/routes.ts index c4e09d341..2268c8583 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 851708111..3b3442a91 100644 --- a/packages/gateway/src/control-plane/schemas.ts +++ b/packages/gateway/src/control-plane/schemas.ts @@ -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(), }); From 5fe4af1eb8e09783a2307203241106e7882191d8 Mon Sep 17 00:00:00 2001 From: Menci Date: Wed, 29 Jul 2026 00:22:33 +0800 Subject: [PATCH 3/3] fix: make the search test result legible Three things the Fluent migration left rough on this page, found by driving it in a browser against a live Web IQ rejection. The OK/Error pill and the failure box carried hardcoded light-dark() hex pairs, which do not track the theme the way every other surface here does. They become a Fluent Badge and a MessageBar -- the same MessageBar the save path above already uses, so a failed test now reads like every other failure on the page. The upstream and model pickers had become bare native selects, dropping the per-option descriptions the previous dashboard showed. They are Fluent Dropdowns again: an upstream carries the same kind description the upstreams list uses, and a model carries its id when the display name differs, staying silent when the two are the same rather than printing it twice. The summary line printed the raw provider identifier and hardcoded English around it. It now reads the provider's own label, falling back to the id verbatim if the gateway names one this build does not know, and both fragments go through i18n. Behind the panel, the provider error extractor had no rung for Web IQ's error envelope, so its whole JSON body reached the operator and overflowed the panel. Reading userMessage -- with technicalDetails behind it -- turns that into the one sentence Microsoft wrote for exactly this purpose. --- apps/web/src/i18n/locales/en.ts | 2 + apps/web/src/i18n/locales/zh-Hans.ts | 2 + .../src/routes/dashboard-providers-search.tsx | 117 +++++++++++------- .../tools/web-search/providers/web-iq_test.ts | 25 ++++ .../tools/web-search/providers/shared.ts | 13 ++ 5 files changed, 112 insertions(+), 47 deletions(-) diff --git a/apps/web/src/i18n/locales/en.ts b/apps/web/src/i18n/locales/en.ts index f8075d663..d29cd25b6 100644 --- a/apps/web/src/i18n/locales/en.ts +++ b/apps/web/src/i18n/locales/en.ts @@ -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 af121ea6b..873f8ff0e 100644 --- a/apps/web/src/i18n/locales/zh-Hans.ts +++ b/apps/web/src/i18n/locales/zh-Hans.ts @@ -1001,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 328dc47f2..1c6f97347 100644 --- a/apps/web/src/routes/dashboard-providers-search.tsx +++ b/apps/web/src/routes/dashboard-providers-search.tsx @@ -14,18 +14,20 @@ 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, @@ -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__/data-plane/tools/web-search/providers/web-iq_test.ts b/packages/gateway/__tests__/data-plane/tools/web-search/providers/web-iq_test.ts index 3b88412eb..51f95ec87 100644 --- a/packages/gateway/__tests__/data-plane/tools/web-search/providers/web-iq_test.ts +++ b/packages/gateway/__tests__/data-plane/tools/web-search/providers/web-iq_test.ts @@ -209,6 +209,31 @@ test('createWebIqWebSearchProvider maps 413 to request_too_large', 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' }), 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; }