@open-mercato/search 0.6.6-develop.6296.1.5e8bdfee17 → 0.6.6-develop.6299.1.29224e2d86

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/AGENTS.md +16 -2
  3. package/dist/modules/search/__integration__/TC-SEARCH-010.spec.js +45 -0
  4. package/dist/modules/search/__integration__/TC-SEARCH-010.spec.js.map +7 -0
  5. package/dist/modules/search/__integration__/TC-SEARCH-011.spec.js +25 -0
  6. package/dist/modules/search/__integration__/TC-SEARCH-011.spec.js.map +7 -0
  7. package/dist/modules/search/api/embeddings/route.js +50 -6
  8. package/dist/modules/search/api/embeddings/route.js.map +2 -2
  9. package/dist/modules/search/api/search/global/route.js +1 -1
  10. package/dist/modules/search/api/search/global/route.js.map +2 -2
  11. package/dist/modules/search/api/settings/global-search/route.js +8 -5
  12. package/dist/modules/search/api/settings/global-search/route.js.map +2 -2
  13. package/dist/modules/search/di.js +3 -1
  14. package/dist/modules/search/di.js.map +2 -2
  15. package/dist/modules/search/frontend/components/sections/VectorSearchSection.js +6 -3
  16. package/dist/modules/search/frontend/components/sections/VectorSearchSection.js.map +2 -2
  17. package/dist/modules/search/i18n/de.json +3 -0
  18. package/dist/modules/search/i18n/en.json +3 -0
  19. package/dist/modules/search/i18n/es.json +3 -0
  20. package/dist/modules/search/i18n/pl.json +3 -0
  21. package/dist/modules/search/lib/auto-indexing.js +4 -1
  22. package/dist/modules/search/lib/auto-indexing.js.map +2 -2
  23. package/dist/modules/search/lib/embedding-config.js +40 -3
  24. package/dist/modules/search/lib/embedding-config.js.map +2 -2
  25. package/dist/modules/search/lib/global-search-config.js +25 -3
  26. package/dist/modules/search/lib/global-search-config.js.map +2 -2
  27. package/dist/modules/search/lib/provider-probe.js +108 -0
  28. package/dist/modules/search/lib/provider-probe.js.map +7 -0
  29. package/package.json +4 -4
  30. package/src/modules/search/__integration__/TC-SEARCH-010.spec.ts +74 -0
  31. package/src/modules/search/__integration__/TC-SEARCH-011.spec.ts +46 -0
  32. package/src/modules/search/api/embeddings/__tests__/route.ollama-base-url.test.ts +3 -0
  33. package/src/modules/search/api/embeddings/route.ts +62 -6
  34. package/src/modules/search/api/search/global/route.ts +2 -2
  35. package/src/modules/search/api/settings/global-search/route.ts +9 -4
  36. package/src/modules/search/di.ts +2 -0
  37. package/src/modules/search/frontend/components/sections/VectorSearchSection.tsx +21 -2
  38. package/src/modules/search/i18n/de.json +3 -0
  39. package/src/modules/search/i18n/en.json +3 -0
  40. package/src/modules/search/i18n/es.json +3 -0
  41. package/src/modules/search/i18n/pl.json +3 -0
  42. package/src/modules/search/lib/__tests__/provider-probe.test.ts +137 -0
  43. package/src/modules/search/lib/__tests__/search-settings-scope.test.ts +152 -0
  44. package/src/modules/search/lib/auto-indexing.ts +6 -3
  45. package/src/modules/search/lib/embedding-config.ts +58 -5
  46. package/src/modules/search/lib/global-search-config.ts +36 -4
  47. package/src/modules/search/lib/provider-probe.ts +143 -0
@@ -9,7 +9,10 @@ async function resolveGlobalSearchStrategies(resolver, options) {
9
9
  return fallback;
10
10
  }
11
11
  try {
12
- const value = await service.getValue("search", GLOBAL_SEARCH_STRATEGIES_KEY, { defaultValue: fallback });
12
+ const value = await service.getValue("search", GLOBAL_SEARCH_STRATEGIES_KEY, {
13
+ defaultValue: fallback,
14
+ scope: options?.scope
15
+ });
13
16
  if (!Array.isArray(value) || value.length === 0) {
14
17
  return fallback;
15
18
  }
@@ -18,7 +21,7 @@ async function resolveGlobalSearchStrategies(resolver, options) {
18
21
  return fallback;
19
22
  }
20
23
  }
21
- async function saveGlobalSearchStrategies(resolver, strategies) {
24
+ async function saveGlobalSearchStrategies(resolver, strategies, options) {
22
25
  let service;
23
26
  try {
24
27
  service = resolver.resolve("moduleConfigService");
@@ -34,12 +37,31 @@ async function saveGlobalSearchStrategies(resolver, strategies) {
34
37
  if (validStrategies.length === 0) {
35
38
  throw new Error("At least one valid search strategy must be enabled");
36
39
  }
37
- await service.setValue("search", GLOBAL_SEARCH_STRATEGIES_KEY, validStrategies);
40
+ await service.setValue("search", GLOBAL_SEARCH_STRATEGIES_KEY, validStrategies, options?.scope);
41
+ }
42
+ async function resolveGlobalSearchStrategiesResult(resolver, options) {
43
+ const fallback = options?.defaultValue ?? DEFAULT_GLOBAL_SEARCH_STRATEGIES;
44
+ let service;
45
+ try {
46
+ service = resolver.resolve("moduleConfigService");
47
+ } catch {
48
+ return { strategies: fallback, source: "env" };
49
+ }
50
+ try {
51
+ const record = await service.getRecord("search", GLOBAL_SEARCH_STRATEGIES_KEY, options?.scope);
52
+ const value = record?.value;
53
+ if (Array.isArray(value) && value.length > 0) {
54
+ return { strategies: value, source: record.source };
55
+ }
56
+ } catch {
57
+ }
58
+ return { strategies: fallback, source: "env" };
38
59
  }
39
60
  export {
40
61
  DEFAULT_GLOBAL_SEARCH_STRATEGIES,
41
62
  GLOBAL_SEARCH_STRATEGIES_KEY,
42
63
  resolveGlobalSearchStrategies,
64
+ resolveGlobalSearchStrategiesResult,
43
65
  saveGlobalSearchStrategies
44
66
  };
45
67
  //# sourceMappingURL=global-search-config.js.map
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../../src/modules/search/lib/global-search-config.ts"],
4
- "sourcesContent": ["import type { ModuleConfigService } from '@open-mercato/core/modules/configs/lib/module-config-service'\nimport type { SearchStrategyId } from '@open-mercato/shared/modules/search'\n\nexport const GLOBAL_SEARCH_STRATEGIES_KEY = 'global_search_strategies'\n\n/** Default strategies when none are configured */\nexport const DEFAULT_GLOBAL_SEARCH_STRATEGIES: SearchStrategyId[] = ['fulltext', 'vector', 'tokens']\n\ntype Resolver = {\n resolve: <T = unknown>(name: string) => T\n}\n\n/**\n * Get the enabled strategies for global search (Cmd+K).\n * Falls back to all strategies if not configured.\n */\nexport async function resolveGlobalSearchStrategies(\n resolver: Resolver,\n options?: { defaultValue?: SearchStrategyId[] },\n): Promise<SearchStrategyId[]> {\n const fallback = options?.defaultValue ?? DEFAULT_GLOBAL_SEARCH_STRATEGIES\n let service: ModuleConfigService\n try {\n service = resolver.resolve<ModuleConfigService>('moduleConfigService')\n } catch {\n return fallback\n }\n try {\n const value = await service.getValue<SearchStrategyId[]>('search', GLOBAL_SEARCH_STRATEGIES_KEY, { defaultValue: fallback })\n // Ensure we always return a non-empty array\n if (!Array.isArray(value) || value.length === 0) {\n return fallback\n }\n return value\n } catch {\n return fallback\n }\n}\n\n/**\n * Save the enabled strategies for global search.\n */\nexport async function saveGlobalSearchStrategies(\n resolver: Resolver,\n strategies: SearchStrategyId[],\n): Promise<void> {\n let service: ModuleConfigService\n try {\n service = resolver.resolve<ModuleConfigService>('moduleConfigService')\n } catch {\n throw new Error('Module config service not available')\n }\n\n // Validate that at least one strategy is enabled\n if (!Array.isArray(strategies) || strategies.length === 0) {\n throw new Error('At least one search strategy must be enabled')\n }\n\n // Filter to only valid strategy IDs\n const validStrategies = strategies.filter(\n (s) => ['fulltext', 'vector', 'tokens'].includes(s)\n ) as SearchStrategyId[]\n\n if (validStrategies.length === 0) {\n throw new Error('At least one valid search strategy must be enabled')\n }\n\n await service.setValue('search', GLOBAL_SEARCH_STRATEGIES_KEY, validStrategies)\n}\n"],
5
- "mappings": "AAGO,MAAM,+BAA+B;AAGrC,MAAM,mCAAuD,CAAC,YAAY,UAAU,QAAQ;AAUnG,eAAsB,8BACpB,UACA,SAC6B;AAC7B,QAAM,WAAW,SAAS,gBAAgB;AAC1C,MAAI;AACJ,MAAI;AACF,cAAU,SAAS,QAA6B,qBAAqB;AAAA,EACvE,QAAQ;AACN,WAAO;AAAA,EACT;AACA,MAAI;AACF,UAAM,QAAQ,MAAM,QAAQ,SAA6B,UAAU,8BAA8B,EAAE,cAAc,SAAS,CAAC;AAE3H,QAAI,CAAC,MAAM,QAAQ,KAAK,KAAK,MAAM,WAAW,GAAG;AAC/C,aAAO;AAAA,IACT;AACA,WAAO;AAAA,EACT,QAAQ;AACN,WAAO;AAAA,EACT;AACF;AAKA,eAAsB,2BACpB,UACA,YACe;AACf,MAAI;AACJ,MAAI;AACF,cAAU,SAAS,QAA6B,qBAAqB;AAAA,EACvE,QAAQ;AACN,UAAM,IAAI,MAAM,qCAAqC;AAAA,EACvD;AAGA,MAAI,CAAC,MAAM,QAAQ,UAAU,KAAK,WAAW,WAAW,GAAG;AACzD,UAAM,IAAI,MAAM,8CAA8C;AAAA,EAChE;AAGA,QAAM,kBAAkB,WAAW;AAAA,IACjC,CAAC,MAAM,CAAC,YAAY,UAAU,QAAQ,EAAE,SAAS,CAAC;AAAA,EACpD;AAEA,MAAI,gBAAgB,WAAW,GAAG;AAChC,UAAM,IAAI,MAAM,oDAAoD;AAAA,EACtE;AAEA,QAAM,QAAQ,SAAS,UAAU,8BAA8B,eAAe;AAChF;",
4
+ "sourcesContent": ["import type { ConfigScope, ModuleConfigService } from '@open-mercato/core/modules/configs/lib/module-config-service'\nimport type { SearchStrategyId } from '@open-mercato/shared/modules/search'\n\nexport const GLOBAL_SEARCH_STRATEGIES_KEY = 'global_search_strategies'\n\n/** Default strategies when none are configured */\nexport const DEFAULT_GLOBAL_SEARCH_STRATEGIES: SearchStrategyId[] = ['fulltext', 'vector', 'tokens']\n\ntype Resolver = {\n resolve: <T = unknown>(name: string) => T\n}\n\n/**\n * Get the enabled strategies for global search (Cmd+K).\n * Falls back to all strategies if not configured.\n */\nexport async function resolveGlobalSearchStrategies(\n resolver: Resolver,\n options?: { defaultValue?: SearchStrategyId[]; scope?: ConfigScope },\n): Promise<SearchStrategyId[]> {\n const fallback = options?.defaultValue ?? DEFAULT_GLOBAL_SEARCH_STRATEGIES\n let service: ModuleConfigService\n try {\n service = resolver.resolve<ModuleConfigService>('moduleConfigService')\n } catch {\n return fallback\n }\n try {\n const value = await service.getValue<SearchStrategyId[]>('search', GLOBAL_SEARCH_STRATEGIES_KEY, {\n defaultValue: fallback,\n scope: options?.scope,\n })\n // Ensure we always return a non-empty array\n if (!Array.isArray(value) || value.length === 0) {\n return fallback\n }\n return value\n } catch {\n return fallback\n }\n}\n\n/**\n * Save the enabled strategies for global search.\n */\nexport async function saveGlobalSearchStrategies(\n resolver: Resolver,\n strategies: SearchStrategyId[],\n options?: { scope?: ConfigScope },\n): Promise<void> {\n let service: ModuleConfigService\n try {\n service = resolver.resolve<ModuleConfigService>('moduleConfigService')\n } catch {\n throw new Error('Module config service not available')\n }\n\n // Validate that at least one strategy is enabled\n if (!Array.isArray(strategies) || strategies.length === 0) {\n throw new Error('At least one search strategy must be enabled')\n }\n\n // Filter to only valid strategy IDs\n const validStrategies = strategies.filter(\n (s) => ['fulltext', 'vector', 'tokens'].includes(s)\n ) as SearchStrategyId[]\n\n if (validStrategies.length === 0) {\n throw new Error('At least one valid search strategy must be enabled')\n }\n\n await service.setValue('search', GLOBAL_SEARCH_STRATEGIES_KEY, validStrategies, options?.scope)\n}\n\nexport type GlobalSearchSource = 'tenant' | 'instance' | 'env'\n\n/**\n * Resolve the enabled strategies together with their source discriminator:\n * `tenant` (own scoped row), `instance` (inherited global row), or `env`\n * (no stored row -> default strategies).\n */\nexport async function resolveGlobalSearchStrategiesResult(\n resolver: Resolver,\n options?: { defaultValue?: SearchStrategyId[]; scope?: ConfigScope },\n): Promise<{ strategies: SearchStrategyId[]; source: GlobalSearchSource }> {\n const fallback = options?.defaultValue ?? DEFAULT_GLOBAL_SEARCH_STRATEGIES\n let service: ModuleConfigService\n try {\n service = resolver.resolve<ModuleConfigService>('moduleConfigService')\n } catch {\n return { strategies: fallback, source: 'env' }\n }\n try {\n const record = await service.getRecord('search', GLOBAL_SEARCH_STRATEGIES_KEY, options?.scope)\n const value = record?.value\n if (Array.isArray(value) && value.length > 0) {\n return { strategies: value as SearchStrategyId[], source: record!.source }\n }\n } catch {}\n return { strategies: fallback, source: 'env' }\n}\n"],
5
+ "mappings": "AAGO,MAAM,+BAA+B;AAGrC,MAAM,mCAAuD,CAAC,YAAY,UAAU,QAAQ;AAUnG,eAAsB,8BACpB,UACA,SAC6B;AAC7B,QAAM,WAAW,SAAS,gBAAgB;AAC1C,MAAI;AACJ,MAAI;AACF,cAAU,SAAS,QAA6B,qBAAqB;AAAA,EACvE,QAAQ;AACN,WAAO;AAAA,EACT;AACA,MAAI;AACF,UAAM,QAAQ,MAAM,QAAQ,SAA6B,UAAU,8BAA8B;AAAA,MAC/F,cAAc;AAAA,MACd,OAAO,SAAS;AAAA,IAClB,CAAC;AAED,QAAI,CAAC,MAAM,QAAQ,KAAK,KAAK,MAAM,WAAW,GAAG;AAC/C,aAAO;AAAA,IACT;AACA,WAAO;AAAA,EACT,QAAQ;AACN,WAAO;AAAA,EACT;AACF;AAKA,eAAsB,2BACpB,UACA,YACA,SACe;AACf,MAAI;AACJ,MAAI;AACF,cAAU,SAAS,QAA6B,qBAAqB;AAAA,EACvE,QAAQ;AACN,UAAM,IAAI,MAAM,qCAAqC;AAAA,EACvD;AAGA,MAAI,CAAC,MAAM,QAAQ,UAAU,KAAK,WAAW,WAAW,GAAG;AACzD,UAAM,IAAI,MAAM,8CAA8C;AAAA,EAChE;AAGA,QAAM,kBAAkB,WAAW;AAAA,IACjC,CAAC,MAAM,CAAC,YAAY,UAAU,QAAQ,EAAE,SAAS,CAAC;AAAA,EACpD;AAEA,MAAI,gBAAgB,WAAW,GAAG;AAChC,UAAM,IAAI,MAAM,oDAAoD;AAAA,EACtE;AAEA,QAAM,QAAQ,SAAS,UAAU,8BAA8B,iBAAiB,SAAS,KAAK;AAChG;AASA,eAAsB,oCACpB,UACA,SACyE;AACzE,QAAM,WAAW,SAAS,gBAAgB;AAC1C,MAAI;AACJ,MAAI;AACF,cAAU,SAAS,QAA6B,qBAAqB;AAAA,EACvE,QAAQ;AACN,WAAO,EAAE,YAAY,UAAU,QAAQ,MAAM;AAAA,EAC/C;AACA,MAAI;AACF,UAAM,SAAS,MAAM,QAAQ,UAAU,UAAU,8BAA8B,SAAS,KAAK;AAC7F,UAAM,QAAQ,QAAQ;AACtB,QAAI,MAAM,QAAQ,KAAK,KAAK,MAAM,SAAS,GAAG;AAC5C,aAAO,EAAE,YAAY,OAA6B,QAAQ,OAAQ,OAAO;AAAA,IAC3E;AAAA,EACF,QAAQ;AAAA,EAAC;AACT,SAAO,EAAE,YAAY,UAAU,QAAQ,MAAM;AAC/C;",
6
6
  "names": []
7
7
  }
@@ -0,0 +1,108 @@
1
+ import { EMBEDDING_PROVIDERS } from "../../../vector/index.js";
2
+ const CACHE_VERSION = "v1";
3
+ const CACHE_TTL_MS = 3e4;
4
+ const OLLAMA_PROBE_TIMEOUT_MS = 1500;
5
+ const ALL_PROVIDERS = ["openai", "google", "mistral", "cohere", "bedrock", "ollama"];
6
+ async function checkAllProviders(probe, options) {
7
+ return Promise.all(
8
+ ALL_PROVIDERS.map(async (providerId) => ({
9
+ providerId,
10
+ ...await probe.checkAvailability(providerId, options)
11
+ }))
12
+ );
13
+ }
14
+ const cacheKey = (providerId) => `embedding-provider-probe:${CACHE_VERSION}:${providerId}`;
15
+ const resolveCache = (container) => {
16
+ try {
17
+ return container.resolve("cache");
18
+ } catch {
19
+ return null;
20
+ }
21
+ };
22
+ const ollamaBaseUrl = () => process.env.OLLAMA_BASE_URL ?? "http://localhost:11434";
23
+ const keyPresence = (providerId) => {
24
+ const info = EMBEDDING_PROVIDERS[providerId];
25
+ const envKey = info?.envKeyRequired;
26
+ switch (providerId) {
27
+ case "openai":
28
+ return process.env.OPENAI_API_KEY?.trim() ? { available: true } : { available: false, reason: `Set ${envKey} to enable ${info?.name ?? providerId}` };
29
+ case "google":
30
+ return process.env.GOOGLE_GENERATIVE_AI_API_KEY?.trim() ? { available: true } : { available: false, reason: `Set ${envKey} to enable ${info?.name ?? providerId}` };
31
+ case "mistral":
32
+ return process.env.MISTRAL_API_KEY?.trim() ? { available: true } : { available: false, reason: `Set ${envKey} to enable ${info?.name ?? providerId}` };
33
+ case "cohere":
34
+ return process.env.COHERE_API_KEY?.trim() ? { available: true } : { available: false, reason: `Set ${envKey} to enable ${info?.name ?? providerId}` };
35
+ case "bedrock":
36
+ return process.env.AWS_ACCESS_KEY_ID?.trim() && process.env.AWS_SECRET_ACCESS_KEY?.trim() ? { available: true } : { available: false, reason: "Set AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY to enable Bedrock" };
37
+ default:
38
+ return { available: false, reason: `Unknown provider: ${providerId}` };
39
+ }
40
+ };
41
+ async function probeOllama(baseUrl) {
42
+ const controller = new AbortController();
43
+ const timer = setTimeout(() => controller.abort(), OLLAMA_PROBE_TIMEOUT_MS);
44
+ try {
45
+ const response = await fetch(`${baseUrl.replace(/\/$/, "")}/api/tags`, {
46
+ method: "GET",
47
+ signal: controller.signal
48
+ });
49
+ if (!response.ok) {
50
+ return { available: false, reason: `Ollama responded ${response.status} at ${baseUrl}` };
51
+ }
52
+ let models;
53
+ try {
54
+ const payload = await response.json();
55
+ if (Array.isArray(payload?.models)) models = payload.models.length;
56
+ } catch {
57
+ }
58
+ return { available: true, models };
59
+ } catch (error) {
60
+ const aborted = error instanceof Error && error.name === "AbortError";
61
+ return {
62
+ available: false,
63
+ reason: aborted ? `Ollama not reachable at ${baseUrl} (timed out)` : `Ollama not reachable at ${baseUrl}`
64
+ };
65
+ } finally {
66
+ clearTimeout(timer);
67
+ }
68
+ }
69
+ async function computeAvailability(providerId) {
70
+ try {
71
+ if (providerId === "ollama") {
72
+ return await probeOllama(ollamaBaseUrl());
73
+ }
74
+ return keyPresence(providerId);
75
+ } catch (error) {
76
+ return { available: false, reason: error instanceof Error ? error.message : "Availability check failed" };
77
+ }
78
+ }
79
+ function createEmbeddingProviderProbe(container) {
80
+ const checkAvailability = async (providerId, options) => {
81
+ const cache = resolveCache(container);
82
+ const key = cacheKey(providerId);
83
+ if (!options?.force && cache) {
84
+ try {
85
+ const cached = await cache.get(key);
86
+ if (cached && typeof cached === "object" && "available" in cached) {
87
+ return cached;
88
+ }
89
+ } catch {
90
+ }
91
+ }
92
+ const result = await computeAvailability(providerId);
93
+ if (cache) {
94
+ try {
95
+ await cache.set(key, result, { ttl: CACHE_TTL_MS });
96
+ } catch {
97
+ }
98
+ }
99
+ return result;
100
+ };
101
+ return { checkAvailability };
102
+ }
103
+ export {
104
+ checkAllProviders,
105
+ createEmbeddingProviderProbe,
106
+ probeOllama
107
+ };
108
+ //# sourceMappingURL=provider-probe.js.map
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../../../../src/modules/search/lib/provider-probe.ts"],
4
+ "sourcesContent": ["import type { CacheStrategy } from '@open-mercato/cache'\nimport type { AppContainer } from '@open-mercato/shared/lib/di/container'\nimport type { EmbeddingProviderId } from '../../../vector'\nimport { EMBEDDING_PROVIDERS } from '../../../vector'\n\nconst CACHE_VERSION = 'v1'\nconst CACHE_TTL_MS = 30_000\nconst OLLAMA_PROBE_TIMEOUT_MS = 1500\n\nexport type ProviderAvailability = {\n available: boolean\n reason?: string\n models?: number\n}\n\nexport type ProviderAvailabilityEntry = ProviderAvailability & {\n providerId: EmbeddingProviderId\n}\n\nexport type EmbeddingProviderProbe = {\n checkAvailability(providerId: EmbeddingProviderId, options?: { force?: boolean }): Promise<ProviderAvailability>\n}\n\nconst ALL_PROVIDERS: EmbeddingProviderId[] = ['openai', 'google', 'mistral', 'cohere', 'bedrock', 'ollama']\n\nexport async function checkAllProviders(\n probe: EmbeddingProviderProbe,\n options?: { force?: boolean },\n): Promise<ProviderAvailabilityEntry[]> {\n return Promise.all(\n ALL_PROVIDERS.map(async (providerId) => ({\n providerId,\n ...(await probe.checkAvailability(providerId, options)),\n })),\n )\n}\n\nconst cacheKey = (providerId: string) => `embedding-provider-probe:${CACHE_VERSION}:${providerId}`\n\nconst resolveCache = (container: AppContainer): CacheStrategy | null => {\n try {\n return container.resolve('cache') as CacheStrategy\n } catch {\n return null\n }\n}\n\nconst ollamaBaseUrl = () => process.env.OLLAMA_BASE_URL ?? 'http://localhost:11434'\n\nconst keyPresence = (providerId: EmbeddingProviderId): ProviderAvailability => {\n const info = EMBEDDING_PROVIDERS[providerId]\n const envKey = info?.envKeyRequired\n switch (providerId) {\n case 'openai':\n return process.env.OPENAI_API_KEY?.trim()\n ? { available: true }\n : { available: false, reason: `Set ${envKey} to enable ${info?.name ?? providerId}` }\n case 'google':\n return process.env.GOOGLE_GENERATIVE_AI_API_KEY?.trim()\n ? { available: true }\n : { available: false, reason: `Set ${envKey} to enable ${info?.name ?? providerId}` }\n case 'mistral':\n return process.env.MISTRAL_API_KEY?.trim()\n ? { available: true }\n : { available: false, reason: `Set ${envKey} to enable ${info?.name ?? providerId}` }\n case 'cohere':\n return process.env.COHERE_API_KEY?.trim()\n ? { available: true }\n : { available: false, reason: `Set ${envKey} to enable ${info?.name ?? providerId}` }\n case 'bedrock':\n return process.env.AWS_ACCESS_KEY_ID?.trim() && process.env.AWS_SECRET_ACCESS_KEY?.trim()\n ? { available: true }\n : { available: false, reason: 'Set AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY to enable Bedrock' }\n default:\n return { available: false, reason: `Unknown provider: ${providerId}` }\n }\n}\n\nexport async function probeOllama(baseUrl: string): Promise<ProviderAvailability> {\n const controller = new AbortController()\n const timer = setTimeout(() => controller.abort(), OLLAMA_PROBE_TIMEOUT_MS)\n try {\n const response = await fetch(`${baseUrl.replace(/\\/$/, '')}/api/tags`, {\n method: 'GET',\n signal: controller.signal,\n })\n if (!response.ok) {\n return { available: false, reason: `Ollama responded ${response.status} at ${baseUrl}` }\n }\n let models: number | undefined\n try {\n const payload = (await response.json()) as { models?: unknown[] }\n if (Array.isArray(payload?.models)) models = payload.models.length\n } catch {}\n return { available: true, models }\n } catch (error) {\n const aborted = error instanceof Error && error.name === 'AbortError'\n return {\n available: false,\n reason: aborted ? `Ollama not reachable at ${baseUrl} (timed out)` : `Ollama not reachable at ${baseUrl}`,\n }\n } finally {\n clearTimeout(timer)\n }\n}\n\nasync function computeAvailability(providerId: EmbeddingProviderId): Promise<ProviderAvailability> {\n try {\n if (providerId === 'ollama') {\n return await probeOllama(ollamaBaseUrl())\n }\n return keyPresence(providerId)\n } catch (error) {\n // Fail closed: any unexpected error means the provider is treated as unavailable.\n return { available: false, reason: error instanceof Error ? error.message : 'Availability check failed' }\n }\n}\n\nexport function createEmbeddingProviderProbe(container: AppContainer): EmbeddingProviderProbe {\n const checkAvailability = async (\n providerId: EmbeddingProviderId,\n options?: { force?: boolean },\n ): Promise<ProviderAvailability> => {\n const cache = resolveCache(container)\n const key = cacheKey(providerId)\n if (!options?.force && cache) {\n try {\n const cached = await cache.get(key)\n if (cached && typeof cached === 'object' && 'available' in cached) {\n return cached as ProviderAvailability\n }\n } catch {}\n }\n const result = await computeAvailability(providerId)\n if (cache) {\n try {\n await cache.set(key, result, { ttl: CACHE_TTL_MS })\n } catch {}\n }\n return result\n }\n return { checkAvailability }\n}\n"],
5
+ "mappings": "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",
6
+ "names": []
7
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@open-mercato/search",
3
- "version": "0.6.6-develop.6296.1.5e8bdfee17",
3
+ "version": "0.6.6-develop.6299.1.29224e2d86",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -127,9 +127,9 @@
127
127
  "zod": "^4.4.3"
128
128
  },
129
129
  "peerDependencies": {
130
- "@open-mercato/core": "0.6.6-develop.6296.1.5e8bdfee17",
131
- "@open-mercato/queue": "0.6.6-develop.6296.1.5e8bdfee17",
132
- "@open-mercato/shared": "0.6.6-develop.6296.1.5e8bdfee17"
130
+ "@open-mercato/core": "0.6.6-develop.6299.1.29224e2d86",
131
+ "@open-mercato/queue": "0.6.6-develop.6299.1.29224e2d86",
132
+ "@open-mercato/shared": "0.6.6-develop.6299.1.29224e2d86"
133
133
  },
134
134
  "devDependencies": {
135
135
  "@types/jest": "^30.0.0",
@@ -0,0 +1,74 @@
1
+ import { expect, test } from '@playwright/test'
2
+ import { apiRequest, getAuthToken } from '@open-mercato/core/helpers/integration/api'
3
+ import { readJsonSafe } from '@open-mercato/core/helpers/integration/generalFixtures'
4
+
5
+ type GlobalSearchSettings = {
6
+ enabledStrategies?: string[]
7
+ source?: 'tenant' | 'instance' | 'env'
8
+ }
9
+ type EmbeddingsSettings = {
10
+ settings?: {
11
+ embeddingConfig?: unknown
12
+ embeddingConfigSource?: 'tenant' | 'instance' | 'env'
13
+ }
14
+ }
15
+
16
+ const DEFAULT_STRATEGIES = ['fulltext', 'vector', 'tokens']
17
+
18
+ /**
19
+ * TC-SEARCH-010: search settings carry a tenant-scope source discriminator.
20
+ * Source: .ai/specs/2026-06-15-tenant-scoped-search-settings.md (Phase 2).
21
+ *
22
+ * The settings APIs are tenant-scoped: GET reports a `source` of tenant | instance
23
+ * | env, and a tenant admin's POST writes only its own scoped row (subsequent GET
24
+ * reports `source: 'tenant'`). This spec is self-contained — it restores the
25
+ * tenant's original global-search strategies in `finally`.
26
+ */
27
+ test.describe('TC-SEARCH-010: tenant-scoped search settings expose a source', () => {
28
+ test('global-search GET exposes source and POST round-trips as a tenant override', async ({ request }) => {
29
+ let token: string | null = null
30
+ let original: string[] | null = null
31
+
32
+ try {
33
+ token = await getAuthToken(request, 'admin')
34
+
35
+ const before = await apiRequest(request, 'GET', '/api/search/settings/global-search', { token })
36
+ expect(before.ok(), 'admin should read global-search settings').toBeTruthy()
37
+ const beforeBody = (await readJsonSafe<GlobalSearchSettings>(before)) ?? {}
38
+ expect(Array.isArray(beforeBody.enabledStrategies)).toBeTruthy()
39
+ original = beforeBody.enabledStrategies ?? DEFAULT_STRATEGIES
40
+ expect(['tenant', 'instance', 'env']).toContain(beforeBody.source)
41
+
42
+ const next = original.includes('vector')
43
+ ? original.filter((strategy) => strategy !== 'vector')
44
+ : [...original, 'vector']
45
+ const safeNext = next.length > 0 ? next : ['tokens']
46
+
47
+ const update = await apiRequest(request, 'POST', '/api/search/settings/global-search', {
48
+ token,
49
+ data: { enabledStrategies: safeNext },
50
+ })
51
+ expect(update.ok(), 'admin should update global-search settings').toBeTruthy()
52
+
53
+ const after = await apiRequest(request, 'GET', '/api/search/settings/global-search', { token })
54
+ const afterBody = (await readJsonSafe<GlobalSearchSettings>(after)) ?? {}
55
+ expect(afterBody.enabledStrategies?.sort()).toEqual([...safeNext].sort())
56
+ expect(afterBody.source, 'after a tenant save the source is the tenant row').toBe('tenant')
57
+ } finally {
58
+ if (token && original) {
59
+ await apiRequest(request, 'POST', '/api/search/settings/global-search', {
60
+ token,
61
+ data: { enabledStrategies: original },
62
+ }).catch(() => undefined)
63
+ }
64
+ }
65
+ })
66
+
67
+ test('embeddings GET exposes the embedding config source', async ({ request }) => {
68
+ const token = await getAuthToken(request, 'admin')
69
+ const response = await apiRequest(request, 'GET', '/api/search/embeddings', { token })
70
+ expect(response.ok(), 'admin should read embedding settings').toBeTruthy()
71
+ const body = (await readJsonSafe<EmbeddingsSettings>(response)) ?? {}
72
+ expect(['tenant', 'instance', 'env']).toContain(body.settings?.embeddingConfigSource)
73
+ })
74
+ })
@@ -0,0 +1,46 @@
1
+ import { expect, test } from '@playwright/test'
2
+ import { apiRequest, getAuthToken } from '@open-mercato/core/helpers/integration/api'
3
+ import { readJsonSafe } from '@open-mercato/core/helpers/integration/generalFixtures'
4
+
5
+ type ProviderAvailabilityEntry = { providerId: string; available: boolean; reason?: string }
6
+ type EmbeddingsSettings = {
7
+ settings?: {
8
+ providerAvailability?: ProviderAvailabilityEntry[]
9
+ }
10
+ }
11
+ type SaveError = { error?: string; reason?: string }
12
+
13
+ /**
14
+ * TC-SEARCH-011: the embeddings save guard rejects an unreachable provider.
15
+ * Source: .ai/specs/2026-06-15-tenant-scoped-search-settings.md (Phase 3/4).
16
+ *
17
+ * POST /api/search/embeddings must reject selecting a provider the availability
18
+ * probe reports unreachable with a 409 + structured reason, so a dead provider
19
+ * (e.g. Ollama with nothing listening) can never be persisted. This is read-only
20
+ * on the failure path (no state changes), so it is self-contained.
21
+ */
22
+ test.describe('TC-SEARCH-011: save guard rejects an unavailable provider', () => {
23
+ test('selecting Ollama while it is unreachable is rejected with 409 + reason', async ({ request }) => {
24
+ const token = await getAuthToken(request, 'admin')
25
+
26
+ const settingsResp = await apiRequest(request, 'GET', '/api/search/embeddings', { token })
27
+ expect(settingsResp.ok(), 'admin should read embedding settings').toBeTruthy()
28
+ const settings = (await readJsonSafe<EmbeddingsSettings>(settingsResp)) ?? {}
29
+ const availability = settings.settings?.providerAvailability ?? []
30
+ expect(Array.isArray(availability)).toBeTruthy()
31
+
32
+ const ollama = availability.find((entry) => entry.providerId === 'ollama')
33
+ test.skip(!ollama, 'provider availability not exposed by this build')
34
+ test.skip(ollama?.available === true, 'Ollama is reachable in this environment; cannot exercise the rejection path')
35
+
36
+ const response = await apiRequest(request, 'POST', '/api/search/embeddings', {
37
+ token,
38
+ data: { embeddingConfig: { providerId: 'ollama', model: 'nomic-embed-text', dimension: 768 } },
39
+ })
40
+
41
+ expect(response.status(), 'an unavailable provider must be rejected').toBe(409)
42
+ const body = (await readJsonSafe<SaveError>(response)) ?? {}
43
+ expect(typeof body.error, 'the rejection carries an error message').toBe('string')
44
+ expect(body, 'the rejection exposes a structured reason').toHaveProperty('reason')
45
+ })
46
+ })
@@ -15,12 +15,14 @@ jest.mock('@open-mercato/shared/lib/i18n/server', () => ({
15
15
  }))
16
16
 
17
17
  const mockResolveEmbeddingConfig = jest.fn()
18
+ const mockResolveEmbeddingConfigResult = jest.fn()
18
19
  const mockSaveEmbeddingConfig = jest.fn()
19
20
  const mockGetConfiguredProviders = jest.fn()
20
21
  const mockDetectConfigChange = jest.fn()
21
22
  const mockGetEffectiveDimension = jest.fn()
22
23
  jest.mock('../../../lib/embedding-config', () => ({
23
24
  resolveEmbeddingConfig: (...args: unknown[]) => mockResolveEmbeddingConfig(...args),
25
+ resolveEmbeddingConfigResult: (...args: unknown[]) => mockResolveEmbeddingConfigResult(...args),
24
26
  saveEmbeddingConfig: (...args: unknown[]) => mockSaveEmbeddingConfig(...args),
25
27
  getConfiguredProviders: (...args: unknown[]) => mockGetConfiguredProviders(...args),
26
28
  detectConfigChange: (...args: unknown[]) => mockDetectConfigChange(...args),
@@ -71,6 +73,7 @@ describe('POST /api/search/embeddings — Ollama baseUrl SSRF guard', () => {
71
73
  })
72
74
 
73
75
  mockResolveEmbeddingConfig.mockResolvedValue(null)
76
+ mockResolveEmbeddingConfigResult.mockResolvedValue({ config: null, source: 'env' })
74
77
  mockGetConfiguredProviders.mockReturnValue(['ollama', 'openai'])
75
78
  mockGetEffectiveDimension.mockReturnValue(768)
76
79
  mockDetectConfigChange.mockImplementation((_existing: unknown, next: unknown) => ({
@@ -6,11 +6,15 @@ import type { ModuleConfigService } from '@open-mercato/core/modules/configs/lib
6
6
  import { envDisablesAutoIndexing, resolveAutoIndexingEnabled, SEARCH_AUTO_INDEX_CONFIG_KEY } from '../../lib/auto-indexing'
7
7
  import {
8
8
  resolveEmbeddingConfig,
9
+ resolveEmbeddingConfigResult,
9
10
  saveEmbeddingConfig,
10
11
  getConfiguredProviders,
11
12
  detectConfigChange,
12
13
  getEffectiveDimension,
13
14
  } from '../../lib/embedding-config'
15
+ import type { EmbeddingConfigSource } from '../../lib/embedding-config'
16
+ import { checkAllProviders } from '../../lib/provider-probe'
17
+ import type { EmbeddingProviderProbe, ProviderAvailabilityEntry } from '../../lib/provider-probe'
14
18
  import { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'
15
19
  import type { EmbeddingProviderConfig, EmbeddingProviderId, VectorDriver } from '../../../../vector'
16
20
  import { EMBEDDING_PROVIDERS, DEFAULT_EMBEDDING_CONFIG, EmbeddingService } from '../../../../vector'
@@ -46,7 +50,9 @@ type SettingsResponse = {
46
50
  autoIndexingLocked: boolean
47
51
  lockReason: string | null
48
52
  embeddingConfig: EmbeddingProviderConfig | null
53
+ embeddingConfigSource: EmbeddingConfigSource
49
54
  configuredProviders: EmbeddingProviderId[]
55
+ providerAvailability: ProviderAvailabilityEntry[]
50
56
  indexedDimension: number | null
51
57
  reindexRequired: boolean
52
58
  documentCount: number | null
@@ -67,6 +73,26 @@ const configUnavailable = async () => {
67
73
  return NextResponse.json({ error: t('search.api.errors.configUnavailable', 'Configuration service unavailable') }, { status: 503 })
68
74
  }
69
75
 
76
+ function resolveProbe(container: { resolve: <T = unknown>(name: string) => T }): EmbeddingProviderProbe | null {
77
+ try {
78
+ return container.resolve<EmbeddingProviderProbe>('embeddingProviderProbe')
79
+ } catch {
80
+ return null
81
+ }
82
+ }
83
+
84
+ async function resolveProviderAvailability(
85
+ container: { resolve: <T = unknown>(name: string) => T },
86
+ ): Promise<ProviderAvailabilityEntry[]> {
87
+ const probe = resolveProbe(container)
88
+ if (!probe) return []
89
+ try {
90
+ return await checkAllProviders(probe)
91
+ } catch {
92
+ return []
93
+ }
94
+ }
95
+
70
96
  async function getIndexedDimension(container: { resolve: <T = unknown>(name: string) => T }): Promise<number | null> {
71
97
  try {
72
98
  const drivers = container.resolve<VectorDriver[]>('vectorDrivers')
@@ -107,14 +133,17 @@ export async function GET(req: Request) {
107
133
  let autoIndexingEnabled = !lockedByEnv
108
134
  if (!lockedByEnv) {
109
135
  try {
110
- autoIndexingEnabled = await resolveAutoIndexingEnabled(container, { defaultValue: true })
136
+ autoIndexingEnabled = await resolveAutoIndexingEnabled(container, { defaultValue: true, scope: { tenantId: auth.tenantId } })
111
137
  } catch {
112
138
  autoIndexingEnabled = true
113
139
  }
114
140
  }
115
141
 
116
- const embeddingConfig = await resolveEmbeddingConfig(container, { defaultValue: null })
142
+ const { config: embeddingConfig, source: embeddingConfigSource } = await resolveEmbeddingConfigResult(container, {
143
+ scope: { tenantId: auth.tenantId },
144
+ })
117
145
  const configuredProviders = getConfiguredProviders()
146
+ const providerAvailability = await resolveProviderAvailability(container)
118
147
  const indexedDimension = await getIndexedDimension(container)
119
148
 
120
149
  const effectiveDimension = embeddingConfig
@@ -139,7 +168,9 @@ export async function GET(req: Request) {
139
168
  autoIndexingLocked: lockedByEnv,
140
169
  lockReason: lockedByEnv ? 'env' : null,
141
170
  embeddingConfig,
171
+ embeddingConfigSource,
142
172
  configuredProviders,
173
+ providerAvailability,
143
174
  indexedDimension,
144
175
  reindexRequired,
145
176
  documentCount,
@@ -190,10 +221,10 @@ export async function POST(req: Request) {
190
221
  { status: 409 },
191
222
  )
192
223
  }
193
- await service.setValue('vector', SEARCH_AUTO_INDEX_CONFIG_KEY, parsed.data.autoIndexingEnabled)
224
+ await service.setValue('vector', SEARCH_AUTO_INDEX_CONFIG_KEY, parsed.data.autoIndexingEnabled, { tenantId: auth.tenantId })
194
225
  }
195
226
 
196
- let embeddingConfig = await resolveEmbeddingConfig(container, { defaultValue: null })
227
+ let embeddingConfig = await resolveEmbeddingConfig(container, { defaultValue: null, scope: { tenantId: auth.tenantId } })
197
228
  let reindexRequired = false
198
229
  let indexedDimension = await getIndexedDimension(container)
199
230
 
@@ -216,6 +247,7 @@ export async function POST(req: Request) {
216
247
  )
217
248
  }
218
249
 
250
+ // Reject an unsafe user-supplied Ollama base URL before doing anything with it (SSRF guard).
219
251
  if (newConfig.providerId === 'ollama' && newConfig.baseUrl != null) {
220
252
  try {
221
253
  assertSafeOllamaBaseUrl(newConfig.baseUrl)
@@ -236,6 +268,24 @@ export async function POST(req: Request) {
236
268
  }
237
269
  }
238
270
 
271
+ // Save-time availability guard: never persist a provider the probe reports unreachable.
272
+ const probe = resolveProbe(container)
273
+ if (probe) {
274
+ const availability = await probe.checkAvailability(newConfig.providerId)
275
+ if (!availability.available) {
276
+ return NextResponse.json(
277
+ {
278
+ error: t(
279
+ 'search.api.errors.providerUnavailable',
280
+ `Provider ${providerInfo.name} is not available: ${availability.reason ?? 'unreachable'}`,
281
+ ),
282
+ reason: availability.reason ?? null,
283
+ },
284
+ { status: 409 },
285
+ )
286
+ }
287
+ }
288
+
239
289
  const change = detectConfigChange(
240
290
  embeddingConfig,
241
291
  {
@@ -279,7 +329,7 @@ export async function POST(req: Request) {
279
329
  }
280
330
  }
281
331
 
282
- await saveEmbeddingConfig(container, change.newConfig)
332
+ await saveEmbeddingConfig(container, change.newConfig, { scope: { tenantId: auth.tenantId } })
283
333
  embeddingConfig = change.newConfig
284
334
 
285
335
  try {
@@ -296,7 +346,7 @@ export async function POST(req: Request) {
296
346
  let autoIndexingEnabled = !lockedByEnv
297
347
  if (!lockedByEnv) {
298
348
  try {
299
- autoIndexingEnabled = await resolveAutoIndexingEnabled(container, { defaultValue: true })
349
+ autoIndexingEnabled = await resolveAutoIndexingEnabled(container, { defaultValue: true, scope: { tenantId: auth.tenantId } })
300
350
  } catch {
301
351
  autoIndexingEnabled = true
302
352
  }
@@ -307,6 +357,10 @@ export async function POST(req: Request) {
307
357
  ? await getVectorDocumentCount(container, auth.tenantId, auth.orgId)
308
358
  : null
309
359
 
360
+ const { source: embeddingConfigSource } = await resolveEmbeddingConfigResult(container, {
361
+ scope: { tenantId: auth.tenantId },
362
+ })
363
+
310
364
  return toJson({
311
365
  settings: {
312
366
  openaiConfigured: openAiConfigured(),
@@ -314,7 +368,9 @@ export async function POST(req: Request) {
314
368
  autoIndexingLocked: lockedByEnv,
315
369
  lockReason: lockedByEnv ? 'env' : null,
316
370
  embeddingConfig,
371
+ embeddingConfigSource,
317
372
  configuredProviders: getConfiguredProviders(),
373
+ providerAvailability: await resolveProviderAvailability(container),
318
374
  indexedDimension,
319
375
  reindexRequired,
320
376
  documentCount: updatedDocumentCount,
@@ -64,8 +64,8 @@ export async function GET(req: Request) {
64
64
  )
65
65
  }
66
66
 
67
- // Fetch saved global search strategies
68
- const strategies = await resolveGlobalSearchStrategies(container)
67
+ // Fetch saved global search strategies (per-tenant; falls back to the instance default)
68
+ const strategies = await resolveGlobalSearchStrategies(container, { scope: { tenantId: auth.tenantId } })
69
69
 
70
70
  // Load embedding config for vector strategy (only if vector is enabled)
71
71
  if (strategies.includes('vector')) {
@@ -4,10 +4,11 @@ import { createRequestContainer } from '@open-mercato/shared/lib/di/container'
4
4
  import { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'
5
5
  import { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'
6
6
  import {
7
- resolveGlobalSearchStrategies,
7
+ resolveGlobalSearchStrategiesResult,
8
8
  saveGlobalSearchStrategies,
9
9
  DEFAULT_GLOBAL_SEARCH_STRATEGIES,
10
10
  } from '../../../lib/global-search-config'
11
+ import type { GlobalSearchSource } from '../../../lib/global-search-config'
11
12
  import type { SearchStrategyId } from '@open-mercato/shared/modules/search'
12
13
  import { globalSearchSettingsOpenApi } from '../../openapi'
13
14
 
@@ -22,6 +23,7 @@ export const metadata = {
22
23
 
23
24
  type SettingsResponse = {
24
25
  enabledStrategies: SearchStrategyId[]
26
+ source?: GlobalSearchSource
25
27
  }
26
28
 
27
29
  const toJson = (payload: SettingsResponse, init?: ResponseInit) => NextResponse.json(payload, init)
@@ -37,11 +39,12 @@ export async function GET(req: Request) {
37
39
 
38
40
  const container = await createRequestContainer()
39
41
  try {
40
- const enabledStrategies = await resolveGlobalSearchStrategies(container, {
42
+ const { strategies: enabledStrategies, source } = await resolveGlobalSearchStrategiesResult(container, {
41
43
  defaultValue: DEFAULT_GLOBAL_SEARCH_STRATEGIES,
44
+ scope: { tenantId: auth.tenantId },
42
45
  })
43
46
 
44
- return toJson({ enabledStrategies })
47
+ return toJson({ enabledStrategies, source })
45
48
  } finally {
46
49
  const disposable = container as unknown as { dispose?: () => Promise<void> }
47
50
  if (typeof disposable.dispose === 'function') {
@@ -75,7 +78,9 @@ export async function POST(req: Request) {
75
78
 
76
79
  const container = await createRequestContainer()
77
80
  try {
78
- await saveGlobalSearchStrategies(container, parsed.data.enabledStrategies)
81
+ await saveGlobalSearchStrategies(container, parsed.data.enabledStrategies, {
82
+ scope: { tenantId: auth.tenantId },
83
+ })
79
84
 
80
85
  return NextResponse.json({ ok: true, enabledStrategies: parsed.data.enabledStrategies })
81
86
  } catch (error) {
@@ -4,6 +4,7 @@ import { getRedisUrlOrThrow } from '@open-mercato/shared/lib/redis/connection'
4
4
  import { EmbeddingService, createPgVectorDriver, createChromaDbDriver, createQdrantDriver } from '../../vector'
5
5
  import { createVectorIndexingQueue, type VectorIndexJobPayload } from '../../queue/vector-indexing'
6
6
  import { createFulltextIndexingQueue, type FulltextIndexJobPayload } from '../../queue/fulltext-indexing'
7
+ import { createEmbeddingProviderProbe } from './lib/provider-probe'
7
8
  import type { Queue } from '@open-mercato/queue'
8
9
 
9
10
  /**
@@ -47,5 +48,6 @@ export function register(container: AppContainer) {
47
48
  vectorDrivers: asValue(drivers),
48
49
  vectorIndexQueue: asValue(vectorIndexQueue),
49
50
  fulltextIndexQueue: asValue(fulltextIndexQueue),
51
+ embeddingProviderProbe: asValue(createEmbeddingProviderProbe(container)),
50
52
  })
51
53
  }