From 68ab4ed36021098b95aadf1a20a39c4cb25940e1 Mon Sep 17 00:00:00 2001 From: Menci Date: Wed, 29 Jul 2026 03:26:27 +0800 Subject: [PATCH 1/2] refactor(search): rename Microsoft Web IQ provider Adopt Microsoft Web IQ across the provider discriminator, configuration surface, dashboard copy, telemetry types, provider module, and tests. Migrate the stored credential column and existing usage/config rows to the new contract while retaining the old name only inside SQL migration history. --- .../settings/SearchConfigSection_test.ts | 2 +- apps/web/src/api/types.ts | 4 +- .../settings/SearchConfigSection.vue | 10 +-- apps/web/src/pages/dashboard/settings.vue | 2 +- .../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 | 65 ++++++++++++++----- .../tools/web-search/domain-normalize_test.ts | 4 +- .../tools/web-search/provider_test.ts | 24 +++---- ...nding_test.ts => microsoft-web-iq_test.ts} | 48 +++++++------- .../tools/web-search/search_test.ts | 4 +- .../__tests__/repo/search-usage_test.ts | 4 +- .../migrations/0069_microsoft_web_iq.sql | 35 ++++++++++ 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 +-- ...osoft-grounding.ts => microsoft-web-iq.ts} | 35 +++++----- packages/gateway/src/repo/sql.ts | 14 ++-- .../src/shared/web-search-providers.ts | 4 +- 27 files changed, 202 insertions(+), 133 deletions(-) rename packages/gateway/__tests__/data-plane/tools/web-search/providers/{microsoft-grounding_test.ts => microsoft-web-iq_test.ts} (78%) create mode 100644 packages/gateway/migrations/0069_microsoft_web_iq.sql rename packages/gateway/src/data-plane/tools/web-search/providers/{microsoft-grounding.ts => microsoft-web-iq.ts} (86%) diff --git a/apps/web/__tests__/components/settings/SearchConfigSection_test.ts b/apps/web/__tests__/components/settings/SearchConfigSection_test.ts index c71aead86..aec534eae 100644 --- a/apps/web/__tests__/components/settings/SearchConfigSection_test.ts +++ b/apps/web/__tests__/components/settings/SearchConfigSection_test.ts @@ -16,7 +16,7 @@ const { default: SearchConfigSection } = await import('../../../src/components/s const config: SearchConfig = { provider: 'tavily', tavily: { apiKey: 'key' }, - microsoftGrounding: { apiKey: '' }, + microsoftWebIq: { apiKey: '' }, jina: { apiKey: '' }, passthroughOpenAiSearch: { enabled: false, upstreamId: '', model: '' }, }; diff --git a/apps/web/src/api/types.ts b/apps/web/src/api/types.ts index 988ba74d0..6b4d9aea7 100644 --- a/apps/web/src/api/types.ts +++ b/apps/web/src/api/types.ts @@ -355,9 +355,9 @@ export interface ControlPlaneModel extends PublicModel { } export interface SearchConfig { - provider: 'disabled' | 'tavily' | 'microsoft-grounding' | 'jina'; + provider: 'disabled' | 'tavily' | 'microsoft-web-iq' | 'jina'; tavily: { apiKey: string }; - microsoftGrounding: { apiKey: string }; + microsoftWebIq: { apiKey: string }; jina: { apiKey: string }; passthroughOpenAiSearch: { enabled: boolean; upstreamId: string; model: string }; } diff --git a/apps/web/src/components/settings/SearchConfigSection.vue b/apps/web/src/components/settings/SearchConfigSection.vue index 6b11b39f7..9addb5088 100644 --- a/apps/web/src/components/settings/SearchConfigSection.vue +++ b/apps/web/src/components/settings/SearchConfigSection.vue @@ -44,11 +44,11 @@ const PROVIDER_OPTIONS: ProviderOption[] = [ set: (config, apiKey) => ({ ...config, tavily: { apiKey } }), }, { - value: 'microsoft-grounding', - label: 'Microsoft Grounding', - description: 'Gateway-managed Microsoft Grounding key.', - apiKey: config => config.microsoftGrounding.apiKey, - set: (config, apiKey) => ({ ...config, microsoftGrounding: { apiKey } }), + value: 'microsoft-web-iq', + label: 'Microsoft Web IQ', + description: 'Gateway-managed Microsoft Web IQ key.', + apiKey: config => config.microsoftWebIq.apiKey, + set: (config, apiKey) => ({ ...config, microsoftWebIq: { apiKey } }), }, { value: 'jina', diff --git a/apps/web/src/pages/dashboard/settings.vue b/apps/web/src/pages/dashboard/settings.vue index fe87877b7..0f3294c04 100644 --- a/apps/web/src/pages/dashboard/settings.vue +++ b/apps/web/src/pages/dashboard/settings.vue @@ -23,7 +23,7 @@ import { useUpstreamsStore } from '../../composables/useUpstreams.ts'; const defaultSearchConfig: SearchConfig = { provider: 'disabled', tavily: { apiKey: '' }, - microsoftGrounding: { apiKey: '' }, + microsoftWebIq: { apiKey: '' }, jina: { apiKey: '' }, passthroughOpenAiSearch: { enabled: false, upstreamId: '', model: '' }, }; 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..e96292699 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: 'microsoft-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' }, + microsoftWebIq: { 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: '' }, + microsoftWebIq: { 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: 'microsoft-web-iq', tavily: { apiKey: '' }, - microsoftGrounding: { apiKey: 'ms-new' }, + microsoftWebIq: { 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: 'microsoft-web-iq', tavily: { apiKey: '' }, - microsoftGrounding: { apiKey: 'ms-new' }, + microsoftWebIq: { 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: '' }, + microsoftWebIq: { 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..49810258f 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' }, + microsoftWebIq: { 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..aa016c256 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: 'microsoft-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: 'microsoft-web-iq', tavily: { apiKey: 'tvly-test' }, - microsoftGrounding: { apiKey: 'ms-test' }, + microsoftWebIq: { 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, 'microsoft-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 microsoft-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: 'microsoft-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..d57c0b961 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: '' }, + microsoftWebIq: { 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..bea8a6fe4 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: '' }, + microsoftWebIq: { 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: '' }, + microsoftWebIq: { 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: '' }, + microsoftWebIq: { 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..7899b5818 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 @@ -5,26 +5,27 @@ import type { WebSearchConfig } from '../../../../src/data-plane/tools/web-searc import { initRepo } from '../../../../src/repo/index.ts'; import { SqlRepo } from '../../../../src/repo/sql.ts'; import { InMemoryRepo } from '../../../repo/memory.ts'; +import { createSqliteTestDb } from '../../../repo/test-sqlite.ts'; import type { SqlDatabase } from '@floway-dev/platform'; import { assertEquals, assertRejects, assertThrows } from '@floway-dev/test-utils'; interface WebSearchConfigRow { provider: string; tavily_api_key: string; - microsoft_grounding_api_key: string; + microsoft_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, microsoft_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, microsoft_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, + microsoft_web_iq_api_key = excluded.microsoft_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 +59,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]), + microsoft_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 +91,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' }, + microsoftWebIq: { apiKey: 'ms-test' }, jina: { apiKey: 'jina-test' }, passthroughOpenAiSearch: { enabled: false, upstreamId: '', model: '' }, }); @@ -98,7 +99,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' }, + microsoftWebIq: { apiKey: 'ms-test' }, jina: { apiKey: 'jina-test' }, passthroughOpenAiSearch: { enabled: false, upstreamId: '', model: '' }, }); @@ -112,7 +113,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 ' }, + microsoftWebIq: { apiKey: ' ms-test ' }, jina: { apiKey: '' }, passthroughOpenAiSearch: { enabled: false, upstreamId: '', model: '' }, } as unknown as WebSearchConfig); @@ -127,7 +128,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 ' }, + microsoftWebIq: { apiKey: ' ms-trim ' }, jina: { apiKey: ' jina-trim ' }, passthroughOpenAiSearch: { enabled: false, upstreamId: '', model: '' }, }); @@ -135,7 +136,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' }, + microsoftWebIq: { apiKey: 'ms-trim' }, jina: { apiKey: 'jina-trim' }, passthroughOpenAiSearch: { enabled: false, upstreamId: '', model: '' }, }); @@ -155,15 +156,15 @@ test('parseWebSearchConfigStrict throws on missing required fields', () => { assertThrows( () => parseWebSearchConfigStrict({ provider: 'disabled', tavily: { apiKey: '' } }), Error, - 'microsoftGrounding', + 'microsoftWebIq', ); assertThrows( - () => parseWebSearchConfigStrict({ provider: 'disabled', tavily: {}, microsoftGrounding: { apiKey: '' }, jina: { apiKey: '' } }), + () => parseWebSearchConfigStrict({ provider: 'disabled', tavily: {}, microsoftWebIq: { apiKey: '' }, jina: { apiKey: '' } }), Error, 'tavily.apiKey', ); assertThrows( - () => parseWebSearchConfigStrict({ provider: 'disabled', tavily: { apiKey: '' }, microsoftGrounding: { apiKey: '' } }), + () => parseWebSearchConfigStrict({ provider: 'disabled', tavily: { apiKey: '' }, microsoftWebIq: { apiKey: '' } }), Error, 'jina', ); @@ -183,7 +184,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 ' }, + microsoftWebIq: { apiKey: ' ms-test ' }, jina: { apiKey: ' jina-test ' }, passthroughOpenAiSearch: { enabled: false, upstreamId: '', model: '' }, }); @@ -191,14 +192,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' }, + microsoftWebIq: { 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', + microsoft_web_iq_api_key: 'ms-test', jina_api_key: 'jina-test', passthrough_openai_search: 0, alpha_search_upstream_id: '', @@ -207,8 +208,38 @@ 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' }, + microsoftWebIq: { apiKey: 'ms-test' }, jina: { apiKey: 'jina-test' }, passthroughOpenAiSearch: { enabled: false, upstreamId: '', model: '' }, }); }); + +test('current SQL schema round-trips Microsoft Web IQ configuration and usage', async () => { + const repo = new SqlRepo(await createSqliteTestDb()); + initRepo(repo); + + const config: WebSearchConfig = { + provider: 'microsoft-web-iq', + tavily: { apiKey: '' }, + microsoftWebIq: { apiKey: 'web-iq-test' }, + jina: { apiKey: '' }, + passthroughOpenAiSearch: { enabled: false, upstreamId: '', model: '' }, + }; + await repo.webSearchConfig.save(config); + assertEquals(await loadWebSearchConfig(), config); + + await repo.webSearchUsage.record({ + provider: 'microsoft-web-iq', + keyId: 'key-a', + action: 'search', + hour: '2026-07-29T00', + requests: 2, + }); + assertEquals(await repo.webSearchUsage.listAll(), [{ + provider: 'microsoft-web-iq', + keyId: 'key-a', + action: 'search', + hour: '2026-07-29T00', + requests: 2, + }]); +}); 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..068d74c33 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 Microsoft 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 +// Microsoft 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..00225ef63 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' }, + microsoftWebIq: { 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: 'microsoft-web-iq', tavily: { apiKey: 'tvly-test' }, - microsoftGrounding: { apiKey: 'ms-test' }, + microsoftWebIq: { 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, 'microsoft-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' }, + microsoftWebIq: { 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' }, + microsoftWebIq: { 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' }, + microsoftWebIq: { 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 Microsoft 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: 'microsoft-web-iq', tavily: { apiKey: 'tvly-test' }, - microsoftGrounding: { apiKey: 'ms-test' }, + microsoftWebIq: { 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, 'microsoft-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: '' }, + microsoftWebIq: { 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/microsoft-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/microsoft-web-iq_test.ts index 32e360734..628b33bf1 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/microsoft-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 { createMicrosoftWebIqWebSearchProvider } from '../../../../../src/data-plane/tools/web-search/providers/microsoft-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('createMicrosoftWebIqWebSearchProvider 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 = createMicrosoftWebIqWebSearchProvider('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 Microsoft Web IQ result'); } assertEquals(result.results[0].pageAge, '2026-04-01T00:00:00Z'); }, ); }); -test('createMicrosoftGroundingWebSearchProvider forwards maxResults to upstream count', async () => { +test('createMicrosoftWebIqWebSearchProvider 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 = createMicrosoftWebIqWebSearchProvider('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('createMicrosoftWebIqWebSearchProvider 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 = createMicrosoftWebIqWebSearchProvider('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('createMicrosoftWebIqWebSearchProvider 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 = createMicrosoftWebIqWebSearchProvider('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('createMicrosoftWebIqWebSearchProvider 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 = createMicrosoftWebIqWebSearchProvider('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('createMicrosoftWebIqWebSearchProvider maps 413 to request_too_large', async () => { await withMockedFetch( () => jsonResponse({ message: 'too large' }, 413), async () => { - const provider = createMicrosoftGroundingWebSearchProvider('ms-test'); + const provider = createMicrosoftWebIqWebSearchProvider('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('createMicrosoftWebIqWebSearchProvider surfaces malformed payload as an error', async () => { await withMockedFetch( () => jsonResponse({ message: 'unexpected' }), async () => { - const provider = createMicrosoftGroundingWebSearchProvider('ms-test'); + const provider = createMicrosoftWebIqWebSearchProvider('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('Microsoft 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 = createMicrosoftWebIqWebSearchProvider('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('Microsoft 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 = createMicrosoftWebIqWebSearchProvider('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('Microsoft 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 = createMicrosoftWebIqWebSearchProvider('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('Microsoft 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 = createMicrosoftWebIqWebSearchProvider('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..ee4e94b92 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: 'microsoft-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, 'microsoft-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..69bf66488 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: 'microsoft-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: 'microsoft-web-iq', keyId: 'key_a', action: 'search', hour: '2026-04-25T11', diff --git a/packages/gateway/migrations/0069_microsoft_web_iq.sql b/packages/gateway/migrations/0069_microsoft_web_iq.sql new file mode 100644 index 000000000..7a77b0c1d --- /dev/null +++ b/packages/gateway/migrations/0069_microsoft_web_iq.sql @@ -0,0 +1,35 @@ +-- Rename the Microsoft web-search provider and preserve every stored +-- credential and usage bucket. The configuration table has no provider CHECK, +-- while search_usage does, so the latter needs the standard SQLite table swap. + +ALTER TABLE search_config RENAME COLUMN microsoft_grounding_api_key TO microsoft_web_iq_api_key; + +UPDATE search_config +SET provider = 'microsoft-web-iq' +WHERE provider = 'microsoft-grounding'; + +CREATE TABLE search_usage_new ( + provider TEXT NOT NULL CHECK (provider IN ('tavily', 'microsoft-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 'microsoft-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..f8d5943a7 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', 'microsoft-web-iq', 'jina']), tavily: z.object({ apiKey: z.string() }), - microsoftGrounding: z.object({ apiKey: z.string() }), + microsoftWebIq: 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 ab1a520df..79131386e 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 { WEB_SEARCH_PROVIDER_NAMES, isWebSearchProviderName } 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'; @@ -22,7 +22,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..eff8be655 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 Microsoft 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..14dea203b 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: '' }, + microsoftWebIq: { 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.microsoftWebIq)) { + throw new Error('search config microsoftWebIq must be an object'); } - if (typeof input.microsoftGrounding.apiKey !== 'string') { - throw new Error('search config microsoftGrounding.apiKey must be a string'); + if (typeof input.microsoftWebIq.apiKey !== 'string') { + throw new Error('search config microsoftWebIq.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() }, + microsoftWebIq: { apiKey: input.microsoftWebIq.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..34bce95a8 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 Microsoft 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 Microsoft 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..a0018fc27 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 Microsoft 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 Microsoft 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..c1c9faee5 100644 --- a/packages/gateway/src/data-plane/tools/web-search/provider.ts +++ b/packages/gateway/src/data-plane/tools/web-search/provider.ts @@ -1,6 +1,6 @@ import { FIXED_WEB_SEARCH_CONFIG_TEST_QUERY } from './config.ts'; import { createJinaWebSearchProvider } from './providers/jina.ts'; -import { createMicrosoftGroundingWebSearchProvider } from './providers/microsoft-grounding.ts'; +import { createMicrosoftWebIqWebSearchProvider } from './providers/microsoft-web-iq.ts'; import { createTavilyWebSearchProvider } from './providers/tavily.ts'; import type { ConfiguredWebSearchProvider, WebSearchConfig, WebSearchConfigConnectionTestResult, WebSearchProvider, WebSearchProviderName } from './types.ts'; @@ -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 }), + 'microsoft-web-iq': config => ({ apiKey: config.microsoftWebIq.apiKey, build: createMicrosoftWebIqWebSearchProvider }), 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..2a66ea973 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 Microsoft 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 Microsoft 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/microsoft-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/microsoft-web-iq.ts index 3a17b5a29..5dc3da659 100644 --- a/packages/gateway/src/data-plane/tools/web-search/providers/microsoft-grounding.ts +++ b/packages/gateway/src/data-plane/tools/web-search/providers/microsoft-web-iq.ts @@ -13,14 +13,17 @@ 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. +// Microsoft publishes api.microsoft.ai/v3 as Web IQ's REST API base. +// https://webiq.microsoft.ai/documentation/quick-start/ +const MICROSOFT_WEB_IQ_SEARCH_URL = 'https://api.microsoft.ai/v3/search/web'; +// Microsoft Web IQ's `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'; +// https://webiq.microsoft.ai/documentation/quick-start/ +const MICROSOFT_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 + // Microsoft 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 +66,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(MICROSOFT_WEB_IQ_BROWSE_URL, { method: 'POST', headers: { 'content-type': 'application/json', @@ -79,7 +82,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 + // Microsoft 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 +97,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: 'Microsoft Web IQ browse returned an unexpected payload.' }; } return { kind: 'ok', @@ -112,7 +115,7 @@ const browseOneUrl = async (httpFetch: typeof fetch, apiKey: string, url: string } }; -export const createMicrosoftGroundingWebSearchProvider = (apiKey: string, deps?: { fetch?: typeof fetch }): WebSearchProvider => { +export const createMicrosoftWebIqWebSearchProvider = (apiKey: string, deps?: { fetch?: typeof fetch }): WebSearchProvider => { const httpFetch = deps?.fetch ?? fetch; const search = async (request: WebSearchProviderRequest): Promise => { @@ -133,7 +136,7 @@ export const createMicrosoftGroundingWebSearchProvider = (apiKey: string, deps?: } try { - const response = await fetchWithRetry(() => httpFetch(MICROSOFT_GROUNDING_SEARCH_URL, { + const response = await fetchWithRetry(() => httpFetch(MICROSOFT_WEB_IQ_SEARCH_URL, { method: 'POST', headers: { 'content-type': 'application/json', @@ -146,12 +149,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 Microsoft 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: 'Microsoft 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 +171,7 @@ export const createMicrosoftGroundingWebSearchProvider = (apiKey: string, deps?: return { type: 'error', errorCode: httpStatusToErrorCode(response.status), - message: message ?? 'Microsoft Grounding rate limited the request.', + message: message ?? 'Microsoft Web IQ rate limited the request.', }; } @@ -176,7 +179,7 @@ export const createMicrosoftGroundingWebSearchProvider = (apiKey: string, deps?: return { type: 'error', errorCode: httpStatusToErrorCode(response.status), - message: message ?? 'Microsoft Grounding rejected the search query.', + message: message ?? 'Microsoft Web IQ rejected the search query.', }; } @@ -184,20 +187,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 ?? 'Microsoft Web IQ rejected the request as too large.', }; } return { type: 'error', errorCode: httpStatusToErrorCode(response.status), - message: message ?? 'Microsoft Grounding search failed.', + message: message ?? 'Microsoft 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 : 'Microsoft Web IQ search failed.', }; } }; diff --git a/packages/gateway/src/repo/sql.ts b/packages/gateway/src/repo/sql.ts index 4a5be85f8..8a911fb4c 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, microsoft_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; microsoft_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 }, + microsoftWebIq: { apiKey: row.microsoft_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, microsoftWebIq, 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, microsoft_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, + microsoft_web_iq_api_key = excluded.microsoft_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, microsoftWebIq.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..7551f235e 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', 'microsoft-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 }; + microsoftWebIq: { apiKey: string }; jina: { apiKey: string }; passthroughOpenAiSearch: { enabled: boolean; From ef72a1b9ded28d7f0228e67ee1bffef412020af2 Mon Sep 17 00:00:00 2001 From: Menci Date: Wed, 29 Jul 2026 03:31:39 +0800 Subject: [PATCH 2/2] fix(data-transfer): version the Web IQ contract Advance export/import payloads to version 18 because search configuration and usage now carry the Microsoft Web IQ identifiers. Older exports remain explicitly rejected, and the dashboard and gateway agree on the new current version. --- .../src/components/settings/ImportSection.vue | 2 +- .../settings/SearchConfigSection.vue | 2 +- .../data-transfer/routes_test.ts | 42 +++++++++---------- .../src/control-plane/data-transfer/routes.ts | 4 +- packages/gateway/src/control-plane/schemas.ts | 2 +- 5 files changed, 26 insertions(+), 26 deletions(-) diff --git a/apps/web/src/components/settings/ImportSection.vue b/apps/web/src/components/settings/ImportSection.vue index 79b3ea3f4..c4a574f74 100644 --- a/apps/web/src/components/settings/ImportSection.vue +++ b/apps/web/src/components/settings/ImportSection.vue @@ -20,7 +20,7 @@ interface ExportPayload { // The dashboard only round-trips the current export format. Older exports are // rejected rather than silently coerced. -const EXPORT_VERSION = 17 as const; +const EXPORT_VERSION = 18 as const; const api = useApi(); diff --git a/apps/web/src/components/settings/SearchConfigSection.vue b/apps/web/src/components/settings/SearchConfigSection.vue index 9addb5088..3aef808d1 100644 --- a/apps/web/src/components/settings/SearchConfigSection.vue +++ b/apps/web/src/components/settings/SearchConfigSection.vue @@ -46,7 +46,7 @@ const PROVIDER_OPTIONS: ProviderOption[] = [ { value: 'microsoft-web-iq', label: 'Microsoft Web IQ', - description: 'Gateway-managed Microsoft Web IQ key.', + description: 'Gateway-managed Microsoft Web IQ API key.', apiKey: config => config.microsoftWebIq.apiKey, set: (config, apiKey) => ({ ...config, microsoftWebIq: { apiKey } }), }, 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 e96292699..196b8b69e 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' }, @@ -334,7 +334,7 @@ test('export emits the v17 envelope with users and upstreams', async () => { 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', { @@ -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'"); @@ -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); @@ -1206,7 +1206,7 @@ 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'); @@ -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 @@ -1297,7 +1297,7 @@ 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/); }); @@ -1312,7 +1312,7 @@ 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/); }); @@ -1327,7 +1327,7 @@ 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/); }); @@ -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 f8d5943a7..026564887 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(), });