index.ts 8.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202
  1. /**
  2. * Service Definition for the web access capability seam (`ctx.web`): registries and provider-selecting execution for search and
  3. * fetch. Duplicate ids are rejected. At execution time, a configured provider must exist and
  4. * be usable; without one, exactly one usable provider is required, so selection never depends
  5. * on registration order.
  6. * @module @deepseek-ai/dsh-web
  7. */
  8. import { Context, Service } from '@deepseek-ai/cordis'
  9. import z from '@deepseek-ai/schemastery'
  10. import type {
  11. WebFetchProvider,
  12. WebFetchRequest,
  13. WebFetchResult,
  14. WebSearchProvider,
  15. WebSearchRequest,
  16. WebSearchResult,
  17. } from './types.ts'
  18. import { WebError } from './types.ts'
  19. export {
  20. WebError,
  21. } from './types.ts'
  22. export type {
  23. WebFetchBody,
  24. WebFetchProvider,
  25. WebFetchRequest,
  26. WebFetchResult,
  27. WebSearchProvider,
  28. WebSearchRequest,
  29. WebSearchResult,
  30. WebSearchSource,
  31. } from './types.ts'
  32. declare module '@deepseek-ai/cordis' {
  33. interface Context {
  34. web: WebRuntime
  35. }
  36. }
  37. /** Selection inputs for execution-time provider resolution. */
  38. interface Selection<P> {
  39. /** The configured provider id for this capability, if any. */
  40. readonly configuredId?: string
  41. /** Providers registered for this capability kind. */
  42. readonly providers: ReadonlyMap<string, P>
  43. }
  44. /**
  45. * Config for the web seam. `searchProvider` / `fetchProvider` pin which provider
  46. * wins for each capability; both are optional (a single registered usable
  47. * provider auto-selects). Operational overrides such as environment variables
  48. * must feed these same fields rather than introduce a hidden priority chain.
  49. */
  50. export interface WebRuntimeConfig {
  51. /** Explicit search provider id. Omitted = auto-select when exactly one usable. */
  52. readonly searchProvider?: string
  53. /** Explicit fetch provider id. Omitted = auto-select when exactly one usable. */
  54. readonly fetchProvider?: string
  55. }
  56. /**
  57. * The web access service. Registered as `ctx.web` (one instance per context).
  58. *
  59. * Selection semantics (resolved at execution time, never order-dependent):
  60. * - A configured id that is registered and `available()` → that provider.
  61. * - A configured id not registered → `WEB_PROVIDER_CONFIGURED_MISSING`.
  62. * - A configured id registered but unavailable →
  63. * `WEB_PROVIDER_CONFIGURED_UNAVAILABLE`.
  64. * - No id configured, exactly one registered usable provider → that provider.
  65. * - No id configured, multiple usable providers → `WEB_PROVIDER_AMBIGUOUS`.
  66. * - No id configured, no usable provider → `WEB_PROVIDER_UNAVAILABLE`.
  67. */
  68. export class WebRuntime extends Service {
  69. /**
  70. * Provider selection config. Operational env overrides feed the SAME fields:
  71. * `$DSH_WEB_SEARCH_PROVIDER` / `$DSH_WEB_FETCH_PROVIDER` are equivalent to
  72. * `searchProvider` / `fetchProvider` and are NOT a hidden priority chain.
  73. */
  74. static Config: z<WebRuntimeConfig> = z.object({
  75. searchProvider: z.string(),
  76. fetchProvider: z.string(),
  77. })
  78. private searchProviders = new Map<string, WebSearchProvider>()
  79. private fetchProviders = new Map<string, WebFetchProvider>()
  80. private readonly searchProviderId: string | undefined
  81. private readonly fetchProviderId: string | undefined
  82. constructor(ctx: Context, config: WebRuntimeConfig = {}) {
  83. super(ctx, 'web')
  84. this.searchProviderId = config.searchProvider ?? process.env.DSH_WEB_SEARCH_PROVIDER
  85. this.fetchProviderId = config.fetchProvider ?? process.env.DSH_WEB_FETCH_PROVIDER
  86. }
  87. /**
  88. * Register a search provider. Throws {@link WebError} `WEB_DUPLICATE_PROVIDER`
  89. * if its id is already registered for search. Returns a disposer; disposed
  90. * with the calling fiber.
  91. * @param provider - the provider; its `id` is the registry key.
  92. * @returns the disposer that unregisters the provider.
  93. */
  94. registerSearchProvider(provider: WebSearchProvider): () => void {
  95. return this.registerProvider(this.searchProviders, provider)
  96. }
  97. /**
  98. * Register a fetch provider. Throws {@link WebError} `WEB_DUPLICATE_PROVIDER`
  99. * if its id is already registered for fetch. Returns a disposer; disposed
  100. * with the calling fiber.
  101. * @param provider - the provider; its `id` is the registry key.
  102. * @returns the disposer that unregisters the provider.
  103. */
  104. registerFetchProvider(provider: WebFetchProvider): () => void {
  105. return this.registerProvider(this.fetchProviders, provider)
  106. }
  107. private registerProvider<P extends { readonly id: string }>(store: Map<string, P>, provider: P): () => void {
  108. if (store.has(provider.id)) {
  109. throw new WebError(`a web provider with id "${provider.id}" is already registered`, 'WEB_DUPLICATE_PROVIDER')
  110. }
  111. const dispose = this.ctx.effect(function* () {
  112. store.set(provider.id, provider)
  113. yield () => store.delete(provider.id)
  114. }, 'web.registerProvider()')
  115. // ctx.effect's disposer returns Promise<void>; our disposer API is
  116. // synchronous fire-and-forget — discard the (always-resolved) promise.
  117. return () => void dispose()
  118. }
  119. /**
  120. * Run one search through the selected provider. Resolves the provider at call
  121. * time with the selection rules above; throws {@link WebError} when the
  122. * capability cannot run. The seam enforces `request.maxResults` on the result:
  123. * if the provider over-returns, `sources[]` is truncated and `truncated` set.
  124. * @param request - the query and optional result limit.
  125. * @param signal - optional cancellation signal forwarded to the provider.
  126. * @returns the provider's results, capped to `request.maxResults`.
  127. */
  128. async search(request: WebSearchRequest, signal?: AbortSignal): Promise<WebSearchResult> {
  129. const provider = resolveProvider({
  130. providers: this.searchProviders,
  131. ...this.searchProviderId !== undefined ? { configuredId: this.searchProviderId } : {},
  132. })
  133. const result = await provider.search(request, signal)
  134. return capSources(result, request.maxResults)
  135. }
  136. /**
  137. * Retrieve one URL through the selected provider. Resolves the provider at
  138. * call time with the selection rules above; throws {@link WebError} when the
  139. * capability cannot run. A non-2xx response is a result, not a throw.
  140. * @param request - the URL plus retrieval options.
  141. * @param signal - optional cancellation signal forwarded to the provider.
  142. * @returns the retrieval outcome; non-2xx responses resolve descriptively.
  143. */
  144. async fetch(request: WebFetchRequest, signal?: AbortSignal): Promise<WebFetchResult> {
  145. const provider = resolveProvider({
  146. providers: this.fetchProviders,
  147. ...this.fetchProviderId !== undefined ? { configuredId: this.fetchProviderId } : {},
  148. })
  149. return provider.fetch(request, signal)
  150. }
  151. }
  152. interface ResolvableProvider {
  153. readonly id: string
  154. available(): boolean
  155. }
  156. /** Resolve the selected provider or throw the matching {@link WebError}. */
  157. function resolveProvider<P extends ResolvableProvider>(selection: Selection<P>): P {
  158. const { configuredId, providers } = selection
  159. if (configuredId !== undefined) {
  160. const provider = providers.get(configuredId)
  161. if (!provider) {
  162. throw new WebError(`configured web provider "${configuredId}" is not registered`, 'WEB_PROVIDER_CONFIGURED_MISSING')
  163. }
  164. if (!provider.available()) {
  165. throw new WebError(`configured web provider "${configuredId}" is registered but unavailable`, 'WEB_PROVIDER_CONFIGURED_UNAVAILABLE')
  166. }
  167. return provider
  168. }
  169. const usable = [...providers.values()].filter(provider => provider.available())
  170. const [single] = usable
  171. if (single === undefined) {
  172. throw new WebError('no usable web provider is registered', 'WEB_PROVIDER_UNAVAILABLE')
  173. }
  174. if (usable.length > 1) {
  175. const ids = usable.map(provider => provider.id).join(', ')
  176. throw new WebError(`multiple usable web providers are registered (${ids}); configure one explicitly`, 'WEB_PROVIDER_AMBIGUOUS')
  177. }
  178. return single
  179. }
  180. /** Enforce `maxResults` on a search result: truncate `sources[]` and flag it. */
  181. function capSources(result: WebSearchResult, maxResults: number | undefined): WebSearchResult {
  182. if (maxResults === undefined || result.sources.length <= maxResults) return result
  183. return { ...result, sources: result.sources.slice(0, maxResults), truncated: true }
  184. }
  185. export default WebRuntime