@jini-ai/http-kit 0.2.1

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 (227) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +107 -0
  3. package/dist/active-context.d.ts +99 -0
  4. package/dist/active-context.d.ts.map +1 -0
  5. package/dist/active-context.js +77 -0
  6. package/dist/active-context.js.map +1 -0
  7. package/dist/adapter.d.ts +36 -0
  8. package/dist/adapter.d.ts.map +1 -0
  9. package/dist/adapter.js +92 -0
  10. package/dist/adapter.js.map +1 -0
  11. package/dist/agents.d.ts +55 -0
  12. package/dist/agents.d.ts.map +1 -0
  13. package/dist/agents.js +23 -0
  14. package/dist/agents.js.map +1 -0
  15. package/dist/api-security-middleware.d.ts +146 -0
  16. package/dist/api-security-middleware.d.ts.map +1 -0
  17. package/dist/api-security-middleware.js +276 -0
  18. package/dist/api-security-middleware.js.map +1 -0
  19. package/dist/attachments.d.ts +317 -0
  20. package/dist/attachments.d.ts.map +1 -0
  21. package/dist/attachments.js +664 -0
  22. package/dist/attachments.js.map +1 -0
  23. package/dist/cancel-owned-runs.d.ts +44 -0
  24. package/dist/cancel-owned-runs.d.ts.map +1 -0
  25. package/dist/cancel-owned-runs.js +35 -0
  26. package/dist/cancel-owned-runs.js.map +1 -0
  27. package/dist/compat.d.ts +22 -0
  28. package/dist/compat.d.ts.map +1 -0
  29. package/dist/compat.js +17 -0
  30. package/dist/compat.js.map +1 -0
  31. package/dist/component-catalog.d.ts +74 -0
  32. package/dist/component-catalog.d.ts.map +1 -0
  33. package/dist/component-catalog.js +74 -0
  34. package/dist/component-catalog.js.map +1 -0
  35. package/dist/connectors.d.ts +165 -0
  36. package/dist/connectors.d.ts.map +1 -0
  37. package/dist/connectors.js +474 -0
  38. package/dist/connectors.js.map +1 -0
  39. package/dist/daemon-status.d.ts +72 -0
  40. package/dist/daemon-status.d.ts.map +1 -0
  41. package/dist/daemon-status.js +60 -0
  42. package/dist/daemon-status.js.map +1 -0
  43. package/dist/db-ops.d.ts +116 -0
  44. package/dist/db-ops.d.ts.map +1 -0
  45. package/dist/db-ops.js +191 -0
  46. package/dist/db-ops.js.map +1 -0
  47. package/dist/delegated-tools.d.ts +46 -0
  48. package/dist/delegated-tools.d.ts.map +1 -0
  49. package/dist/delegated-tools.js +155 -0
  50. package/dist/delegated-tools.js.map +1 -0
  51. package/dist/express/run-stream.d.ts +15 -0
  52. package/dist/express/run-stream.d.ts.map +1 -0
  53. package/dist/express/run-stream.js +11 -0
  54. package/dist/express/run-stream.js.map +1 -0
  55. package/dist/express-index.d.ts +32 -0
  56. package/dist/express-index.d.ts.map +1 -0
  57. package/dist/express-index.js +14 -0
  58. package/dist/express-index.js.map +1 -0
  59. package/dist/fastify/adapter.d.ts +31 -0
  60. package/dist/fastify/adapter.d.ts.map +1 -0
  61. package/dist/fastify/adapter.js +64 -0
  62. package/dist/fastify/adapter.js.map +1 -0
  63. package/dist/fastify/agents.d.ts +13 -0
  64. package/dist/fastify/agents.d.ts.map +1 -0
  65. package/dist/fastify/agents.js +7 -0
  66. package/dist/fastify/agents.js.map +1 -0
  67. package/dist/fastify/api-security-middleware.d.ts +64 -0
  68. package/dist/fastify/api-security-middleware.d.ts.map +1 -0
  69. package/dist/fastify/api-security-middleware.js +139 -0
  70. package/dist/fastify/api-security-middleware.js.map +1 -0
  71. package/dist/fastify/compat.d.ts +22 -0
  72. package/dist/fastify/compat.d.ts.map +1 -0
  73. package/dist/fastify/compat.js +16 -0
  74. package/dist/fastify/compat.js.map +1 -0
  75. package/dist/fastify/daemon-status.d.ts +22 -0
  76. package/dist/fastify/daemon-status.d.ts.map +1 -0
  77. package/dist/fastify/daemon-status.js +9 -0
  78. package/dist/fastify/daemon-status.js.map +1 -0
  79. package/dist/fastify/host-tools.d.ts +13 -0
  80. package/dist/fastify/host-tools.d.ts.map +1 -0
  81. package/dist/fastify/host-tools.js +8 -0
  82. package/dist/fastify/host-tools.js.map +1 -0
  83. package/dist/fastify/index.d.ts +36 -0
  84. package/dist/fastify/index.d.ts.map +1 -0
  85. package/dist/fastify/index.js +18 -0
  86. package/dist/fastify/index.js.map +1 -0
  87. package/dist/fastify/local-daemon-request.d.ts +43 -0
  88. package/dist/fastify/local-daemon-request.d.ts.map +1 -0
  89. package/dist/fastify/local-daemon-request.js +155 -0
  90. package/dist/fastify/local-daemon-request.js.map +1 -0
  91. package/dist/fastify/origin.d.ts +21 -0
  92. package/dist/fastify/origin.d.ts.map +1 -0
  93. package/dist/fastify/origin.js +14 -0
  94. package/dist/fastify/origin.js.map +1 -0
  95. package/dist/fastify/request.d.ts +20 -0
  96. package/dist/fastify/request.d.ts.map +1 -0
  97. package/dist/fastify/request.js +25 -0
  98. package/dist/fastify/request.js.map +1 -0
  99. package/dist/fastify/response.d.ts +20 -0
  100. package/dist/fastify/response.d.ts.map +1 -0
  101. package/dist/fastify/response.js +41 -0
  102. package/dist/fastify/response.js.map +1 -0
  103. package/dist/fastify/route-registration-guard.d.ts +70 -0
  104. package/dist/fastify/route-registration-guard.d.ts.map +1 -0
  105. package/dist/fastify/route-registration-guard.js +69 -0
  106. package/dist/fastify/route-registration-guard.js.map +1 -0
  107. package/dist/fastify/run-stream.d.ts +18 -0
  108. package/dist/fastify/run-stream.d.ts.map +1 -0
  109. package/dist/fastify/run-stream.js +10 -0
  110. package/dist/fastify/run-stream.js.map +1 -0
  111. package/dist/fastify/runs.d.ts +17 -0
  112. package/dist/fastify/runs.d.ts.map +1 -0
  113. package/dist/fastify/runs.js +33 -0
  114. package/dist/fastify/runs.js.map +1 -0
  115. package/dist/frontend-control.d.ts +131 -0
  116. package/dist/frontend-control.d.ts.map +1 -0
  117. package/dist/frontend-control.js +77 -0
  118. package/dist/frontend-control.js.map +1 -0
  119. package/dist/frontend-sessions.d.ts +100 -0
  120. package/dist/frontend-sessions.d.ts.map +1 -0
  121. package/dist/frontend-sessions.js +180 -0
  122. package/dist/frontend-sessions.js.map +1 -0
  123. package/dist/health.d.ts +91 -0
  124. package/dist/health.d.ts.map +1 -0
  125. package/dist/health.js +53 -0
  126. package/dist/health.js.map +1 -0
  127. package/dist/host-tools.d.ts +174 -0
  128. package/dist/host-tools.d.ts.map +1 -0
  129. package/dist/host-tools.js +338 -0
  130. package/dist/host-tools.js.map +1 -0
  131. package/dist/index.d.ts +86 -0
  132. package/dist/index.d.ts.map +1 -0
  133. package/dist/index.js +40 -0
  134. package/dist/index.js.map +1 -0
  135. package/dist/local-daemon-request.d.ts +44 -0
  136. package/dist/local-daemon-request.d.ts.map +1 -0
  137. package/dist/local-daemon-request.js +145 -0
  138. package/dist/local-daemon-request.js.map +1 -0
  139. package/dist/media.d.ts +131 -0
  140. package/dist/media.d.ts.map +1 -0
  141. package/dist/media.js +352 -0
  142. package/dist/media.js.map +1 -0
  143. package/dist/memory.d.ts +230 -0
  144. package/dist/memory.d.ts.map +1 -0
  145. package/dist/memory.js +331 -0
  146. package/dist/memory.js.map +1 -0
  147. package/dist/model-proxy.d.ts +45 -0
  148. package/dist/model-proxy.d.ts.map +1 -0
  149. package/dist/model-proxy.js +619 -0
  150. package/dist/model-proxy.js.map +1 -0
  151. package/dist/origin-validation.d.ts +64 -0
  152. package/dist/origin-validation.d.ts.map +1 -0
  153. package/dist/origin-validation.js +226 -0
  154. package/dist/origin-validation.js.map +1 -0
  155. package/dist/origin.d.ts +32 -0
  156. package/dist/origin.d.ts.map +1 -0
  157. package/dist/origin.js +14 -0
  158. package/dist/origin.js.map +1 -0
  159. package/dist/pack-http.d.ts +30 -0
  160. package/dist/pack-http.d.ts.map +1 -0
  161. package/dist/pack-http.js +12 -0
  162. package/dist/pack-http.js.map +1 -0
  163. package/dist/raw-sse.d.ts +44 -0
  164. package/dist/raw-sse.d.ts.map +1 -0
  165. package/dist/raw-sse.js +93 -0
  166. package/dist/raw-sse.js.map +1 -0
  167. package/dist/remote-run-events.d.ts +66 -0
  168. package/dist/remote-run-events.d.ts.map +1 -0
  169. package/dist/remote-run-events.js +230 -0
  170. package/dist/remote-run-events.js.map +1 -0
  171. package/dist/request.d.ts +19 -0
  172. package/dist/request.d.ts.map +1 -0
  173. package/dist/request.js +25 -0
  174. package/dist/request.js.map +1 -0
  175. package/dist/research.d.ts +42 -0
  176. package/dist/research.d.ts.map +1 -0
  177. package/dist/research.js +316 -0
  178. package/dist/research.js.map +1 -0
  179. package/dist/response.d.ts +14 -0
  180. package/dist/response.d.ts.map +1 -0
  181. package/dist/response.js +45 -0
  182. package/dist/response.js.map +1 -0
  183. package/dist/route-manifest.d.ts +31 -0
  184. package/dist/route-manifest.d.ts.map +1 -0
  185. package/dist/route-manifest.js +103 -0
  186. package/dist/route-manifest.js.map +1 -0
  187. package/dist/route-registration-guard.d.ts +65 -0
  188. package/dist/route-registration-guard.d.ts.map +1 -0
  189. package/dist/route-registration-guard.js +71 -0
  190. package/dist/route-registration-guard.js.map +1 -0
  191. package/dist/routines.d.ts +114 -0
  192. package/dist/routines.d.ts.map +1 -0
  193. package/dist/routines.js +320 -0
  194. package/dist/routines.js.map +1 -0
  195. package/dist/run-stream.d.ts +60 -0
  196. package/dist/run-stream.d.ts.map +1 -0
  197. package/dist/run-stream.js +108 -0
  198. package/dist/run-stream.js.map +1 -0
  199. package/dist/runs.d.ts +79 -0
  200. package/dist/runs.d.ts.map +1 -0
  201. package/dist/runs.js +232 -0
  202. package/dist/runs.js.map +1 -0
  203. package/dist/sse.d.ts +139 -0
  204. package/dist/sse.d.ts.map +1 -0
  205. package/dist/sse.js +157 -0
  206. package/dist/sse.js.map +1 -0
  207. package/dist/terminals.d.ts +125 -0
  208. package/dist/terminals.d.ts.map +1 -0
  209. package/dist/terminals.js +349 -0
  210. package/dist/terminals.js.map +1 -0
  211. package/dist/tool-catalog.d.ts +68 -0
  212. package/dist/tool-catalog.d.ts.map +1 -0
  213. package/dist/tool-catalog.js +68 -0
  214. package/dist/tool-catalog.js.map +1 -0
  215. package/dist/types.d.ts +58 -0
  216. package/dist/types.d.ts.map +1 -0
  217. package/dist/types.js +5 -0
  218. package/dist/types.js.map +1 -0
  219. package/dist/workspace-root.d.ts +69 -0
  220. package/dist/workspace-root.d.ts.map +1 -0
  221. package/dist/workspace-root.js +63 -0
  222. package/dist/workspace-root.js.map +1 -0
  223. package/dist/xai.d.ts +86 -0
  224. package/dist/xai.d.ts.map +1 -0
  225. package/dist/xai.js +668 -0
  226. package/dist/xai.js.map +1 -0
  227. package/package.json +58 -0
package/dist/xai.js ADDED
@@ -0,0 +1,668 @@
1
+ /**
2
+ * @module xai
3
+ *
4
+ * `POST /api/xai/oauth/start`, `POST /api/xai/oauth/complete`, `POST /api/xai/oauth/cancel`,
5
+ * `GET /api/xai/auth/status`, `POST /api/xai/oauth/disconnect`, `POST /api/xai/search` — ported
6
+ * from OD's real `apps/daemon/src/routes/xai.ts` (read directly in the sibling
7
+ * `/Users/la/Desktop/Programming/OSS-Repos/open-design` checkout on this machine, per this repo's
8
+ * "verify against the real source, don't guess" convention), and from that same file's own
9
+ * `packages/http/source-map.md` routes-classification row #27 ("MIXED, OD-leaning... the OAuth
10
+ * start/complete/status/cancel/disconnect shape is a recognizable generic pattern, but this file is
11
+ * fused to a transitional xAI PoC arrangement"). Deliberately left out of the 2026-07-22
12
+ * azure/google/ollama/connectors/health/research route-parity pass pending a product decision on
13
+ * whether to build xAI OAuth support at all — approved after that pass landed; this is that build.
14
+ *
15
+ * **Reused `@jini-ai/agent-runtime`'s existing generic OAuth+PKCE machinery instead of building a
16
+ * second OAuth stack.** `packages/agent-runtime/src/providers/{pkce,oauth-provider,oauth-callback-
17
+ * server,oauth-tokens,oauth-credentials}.ts` (all ported 2026-07-18, see that package's
18
+ * `source-map.md` "providers/ — LLM-provider integrations" section) already generalized this exact
19
+ * origin file's OAuth sibling files — OD's `integrations/xai-oauth.ts` → `oauth-provider.ts`
20
+ * (`beginOAuthPkce`/`completeOAuthPkce`/`refreshOAuthPkceToken`, config-driven via
21
+ * `OAuthPkceProviderConfig`, with `XAI_OAUTH_PROVIDER_CONFIG` kept as the concrete xAI preset the
22
+ * origin actually shipped), `integrations/xai-oauth-server.ts` → `oauth-callback-server.ts`
23
+ * (`startOAuthCallbackListener`, de-branded, `host`/`port`/`path` now caller-supplied instead of
24
+ * xAI-hardcoded constants), `integrations/xai-tokens.ts` → `oauth-tokens.ts`
25
+ * (`getStoredOAuthToken`/`setStoredOAuthToken`/`clearStoredOAuthToken`, filename now a parameter),
26
+ * `integrations/xai-credentials.ts` → `oauth-credentials.ts` (`resolveOAuthBearer`, refresh-on-read).
27
+ * That prior port had **no HTTP route consumer anywhere** — this file is the first one. Nothing new
28
+ * was built for PKCE/callback-listener/token-storage/refresh; this module supplies only the pieces
29
+ * that machinery doesn't already have an opinion on: xAI's concrete `providerConfig`/callback
30
+ * host-port-path defaults (still overridable — see `XaiHttpDeps`), the HTTP wire shapes
31
+ * (`RouteInputContext` parsing, `Result`/`ApiError` responses, `requireSameOrigin`), and the
32
+ * `x_search` Responses-API call itself (genuinely xAI-specific, no existing home in
33
+ * `@jini-ai/agent-runtime`).
34
+ *
35
+ * **The one genuinely xAI-specific quirk carried over verbatim: the fixed loopback port.** xAI's
36
+ * PoC `client_id` (`XAI_OAUTH_PROVIDER_CONFIG.clientId`, reused from NousResearch/hermes-agent —
37
+ * xAI does not yet publish a public client-registration flow) has its `redirect_uri` locked to
38
+ * `http://127.0.0.1:56121/callback` server-side; a daemon can't register its own. `oauth-callback-
39
+ * server.ts` already made `host`/`port`/`path` generic parameters rather than hardcoded constants,
40
+ * so this file just supplies xAI's fixed values as `XaiHttpDeps`' (overridable, for tests) default
41
+ * (`XAI_OAUTH_REDIRECT_HOST`/`_PORT`/`_PATH`, all from `@jini-ai/agent-runtime`) — no new
42
+ * listener-mechanics code needed.
43
+ *
44
+ * **Dropped: OD's "SuperGrok subscription" gate.** The origin's `POST /api/xai/search` resolved
45
+ * credentials through OD's own multi-source cascade (`resolveProviderConfig(..., 'grok')`:
46
+ * OD-native OAuth token → a separate "Hermes" tool's `auth.json` → `OD_GROK_API_KEY` env var →
47
+ * `XAI_API_KEY` env var) and returned a 401 with product-specific copy
48
+ * ("sign in with your SuperGrok subscription...") when nothing resolved. Both are OD product/billing
49
+ * decisions, not generic engine concerns — a SuperGrok subscription is an OD-specific entitlement
50
+ * concept `@jini-ai/http-kit` has no business modeling. This port checks only "is an xAI OAuth account
51
+ * connected" (via `resolveOAuthBearer` against this file's own token store) and returns a clean
52
+ * `NOT_CONFIGURED` (503) when it isn't; if a connected account isn't actually entitled to
53
+ * `x_search`, xAI's own `/responses` endpoint returns its own real error, which this route surfaces
54
+ * through the same SEC-005 `INTERNAL_ERROR` path as any other upstream failure — no hardcoded
55
+ * pre-flight entitlement check is layered on top of xAI's own answer.
56
+ *
57
+ * **Wire shapes changed from OD's snake_case request body to this package's established camelCase
58
+ * convention** (`allowedXHandles`/`excludedXHandles`/`fromDate`/`toDate`/
59
+ * `enableImageUnderstanding`/`enableVideoUnderstanding`, matching `media.ts`/`connectors.ts`'s own
60
+ * camelCase JSON surfaces) — converted to xAI's real snake_case (`allowed_x_handles`, ...) only when
61
+ * building the actual upstream `x_search` tool payload, the same "camelCase API surface, vendor
62
+ * snake_case is an internal implementation detail" split `research.ts` already applies to Tavily's
63
+ * `search_depth`/`max_results`.
64
+ *
65
+ * **State sharing across requests, resolved once, not per-call.** Unlike `research.ts`'s
66
+ * `resolveCredentials` (stateless — a fresh default is harmless every call) or `connectors.ts`'s
67
+ * per-capability `deps.auth`/`deps.storage` (genuinely-optional-forever, not defaulted at all), this
68
+ * file's `pending` (`PendingAuthCache`) and `listenerRef` (the in-flight loopback listener slot) are
69
+ * mutable state that must be the *same instance* across `oauth/start` → `oauth/complete`/
70
+ * `oauth/cancel`/`oauth/disconnect` calls for the dance to work at all — a fresh `PendingAuthCache`
71
+ * minted independently on every request would mean `start` and `complete` never see each other's
72
+ * state. `registerXaiRoutes` resolves every optional default exactly once (`resolveXaiHttpDeps`)
73
+ * and hands that single resolved object to every mounted route, so a zero-config
74
+ * `registerXaiRoutes(app, {}, adapter)` call still shares one cache/listener-slot across the whole
75
+ * mounted lifetime. Each route's own `handle` also calls `resolveXaiHttpDeps` at its top (a
76
+ * defensive, idempotent pass-through when deps are already concrete) purely so individual routes
77
+ * stay directly unit-testable without going through `registerXaiRoutes` first, matching every other
78
+ * route pack in this package's own test convention — a test exercising a multi-call flow (e.g.
79
+ * start then complete) must pass the same concrete `pending`/`listenerRef` across both calls, the
80
+ * same way a real mounted server does.
81
+ *
82
+ * **SEC-005 throughout**: every thrown error (listener bind failure, token-exchange/refresh
83
+ * failure, token read/write failure, the `x_search` upstream call) is converted to a redacted,
84
+ * correlation-id-bearing generic `INTERNAL_ERROR` before reaching the caller — the HTTP *response*
85
+ * never carries the raw error regardless of source. For the two paths that actually touch an
86
+ * untrusted upstream response body — token exchange/refresh (`handleListenerCallback`/
87
+ * `xaiOauthCompleteRoute`) and `callXaiSearch` (which sends this file's own just-issued bearer
88
+ * token as a header, the highest-risk path) — `redactSecrets` (reused from `@jini-ai/agent-runtime`'s
89
+ * `connection-guard.ts`, already this package's `research.ts` precedent) additionally strips any
90
+ * Bearer/api-key-shaped text out of the message before it is even logged to the host's own sink
91
+ * (`redactError`/`callXaiSearch`'s own inline redaction), belt-and-braces beyond the generic-message
92
+ * substitution the caller already gets. Purely local failures (a listener-bind `EADDRINUSE`, an
93
+ * `fs` read/write error) carry no upstream text and are passed through to the sink unredacted — the
94
+ * generic response substitution alone already satisfies SEC-005 for those. The one deliberate
95
+ * exception: `completeOAuthPkce`'s own "state not found or expired" / "state mismatch" validation
96
+ * errors (thrown before any network call, carrying no secret) are surfaced verbatim as
97
+ * `BAD_REQUEST` — a legitimate client-correctable error, not an internal failure to redact.
98
+ *
99
+ * **Two known, deliberate, non-functional divergences from OD's exact wire text, both confirmed
100
+ * against the real OD daemon (2026-07-22 live-parity pass) and left as-is:**
101
+ * 1. `GET /api/xai/auth/status` when disconnected: OD's real handler (`routes/xai.ts:205-207`)
102
+ * returns only `{ connected: false, listening }` — `expiresAt`/`scope`/`savedAt` are *omitted*
103
+ * entirely, confirmed live (`{"connected":false,"listening":false}`, no other keys). This file's
104
+ * `XaiAuthStatusResponse` always includes those three keys, set to `null` when disconnected —
105
+ * a deliberate stable-typed-envelope choice (avoids an `expiresAt?: number` optional-vs-null split
106
+ * for callers) consistent with this package's established camelCase/typed-shape departures from
107
+ * OD elsewhere in this same file; not changed to avoid weakening the typed response contract.
108
+ * 2. The "state not found or expired" / "state mismatch" `BAD_REQUEST` text
109
+ * (`@jini-ai/agent-runtime`'s `oauth-provider.ts`) reads `xai OAuth state ...` (lowercase, from the
110
+ * generic `providerId: 'xai'` config field shared across providers), where OD's real, hardcoded
111
+ * string (`integrations/xai-oauth.ts:131,134-136`) reads `xAI OAuth state ...` (mixed-case, a
112
+ * literal specific to that one file). Confirmed live (`{"error":"xAI OAuth state not found or
113
+ * expired"}` from the real daemon). Purely cosmetic — same meaning, same `BAD_REQUEST` classifi-
114
+ * cation either way (`xaiOauthCompleteRoute`'s own prefix check uses the same `providerId` for
115
+ * both generation and matching, so it's internally self-consistent) — and the casing lives in
116
+ * `@jini-ai/agent-runtime`'s already-shipped generic module, outside this file's own scope to alter
117
+ * without special-casing one provider's capitalization inside otherwise-generic code.
118
+ */
119
+ import { randomUUID } from 'node:crypto';
120
+ import { beginOAuthPkce, clearStoredOAuthToken, completeOAuthPkce, getStoredOAuthToken, PendingAuthCache, redactSecrets, resolveOAuthBearer, setStoredOAuthToken, startOAuthCallbackListener, validateBaseUrl, XAI_OAUTH_PROVIDER_CONFIG, XAI_OAUTH_REDIRECT_HOST, XAI_OAUTH_REDIRECT_PATH, XAI_OAUTH_REDIRECT_PORT, } from '@jini-ai/agent-runtime';
121
+ import { createApiError } from '@jini-ai/protocol';
122
+ import { defineJsonRoute, mountJsonRoute } from './adapter.js';
123
+ import { validationError } from './request.js';
124
+ import { err, ok } from './types.js';
125
+ const DEFAULT_TOKEN_FILE_NAME = 'xai-oauth-token.json';
126
+ /** Matches the loopback listener's own 30-minute self-close timeout (OD's real comment: keeps the
127
+ * PKCE state, the open socket, and any paste-back UI expiring together). */
128
+ const DEFAULT_PENDING_AUTH_TTL_MS = 30 * 60 * 1000;
129
+ const DEFAULT_SEARCH_BASE_URL = 'https://api.x.ai/v1';
130
+ /** xAI's real Responses-API model id for `x_search`, per OD's origin `X_SEARCH_DEFAULT_MODEL`. */
131
+ const DEFAULT_SEARCH_MODEL = 'grok-4.20-reasoning';
132
+ /** Deadline for one xAI search request, covering the response body as well as the request. Matches `research.ts`'s `DEFAULT_TAVILY_TIMEOUT_MS` — the two outbound-provider paths in this package should not disagree about how long a stalled upstream may pin a handler. */
133
+ const DEFAULT_SEARCH_TIMEOUT_MS = 30_000;
134
+ function defaultInternalErrorSink(context) {
135
+ // eslint-disable-next-line no-console
136
+ console.error(`[@jini-ai/http-kit] internal error (xai/${context.source}, correlationId=${context.correlationId})`, context.error);
137
+ }
138
+ /** Resolves every optional `XaiHttpDeps` field to a concrete value. Idempotent — calling this again on an already-resolved object (e.g. `registerXaiRoutes`'s own single resolution, passed through to each route's `handle`) is a harmless pass-through. See module doc on why stateful fields (`pending`/`listenerRef`) must be resolved once per registration, not once per request. */
139
+ function resolveXaiHttpDeps(deps) {
140
+ return {
141
+ dataDir: deps.dataDir ?? process.cwd(),
142
+ tokenFileName: deps.tokenFileName ?? DEFAULT_TOKEN_FILE_NAME,
143
+ providerConfig: deps.providerConfig ?? XAI_OAUTH_PROVIDER_CONFIG,
144
+ callbackHost: deps.callbackHost ?? XAI_OAUTH_REDIRECT_HOST,
145
+ callbackPort: deps.callbackPort ?? XAI_OAUTH_REDIRECT_PORT,
146
+ callbackPath: deps.callbackPath ?? XAI_OAUTH_REDIRECT_PATH,
147
+ pending: deps.pending ?? new PendingAuthCache(DEFAULT_PENDING_AUTH_TTL_MS),
148
+ listenerRef: deps.listenerRef ?? { current: null },
149
+ startCallbackListener: deps.startCallbackListener ?? startOAuthCallbackListener,
150
+ searchBaseUrl: deps.searchBaseUrl ?? DEFAULT_SEARCH_BASE_URL,
151
+ searchDefaultModel: deps.searchDefaultModel ?? DEFAULT_SEARCH_MODEL,
152
+ fetchImpl: deps.fetchImpl ?? fetch,
153
+ searchTimeoutMs: deps.searchTimeoutMs ?? DEFAULT_SEARCH_TIMEOUT_MS,
154
+ onInternalError: deps.onInternalError ?? defaultInternalErrorSink,
155
+ };
156
+ }
157
+ function xaiRedirectUri(resolved) {
158
+ return `http://${resolved.callbackHost}:${resolved.callbackPort}${resolved.callbackPath}`;
159
+ }
160
+ /** Stops (best-effort) and clears the in-flight loopback listener, if any. Never throws — the listener self-closes on completion/timeout anyway, matching OD's origin `stopActiveListener`. */
161
+ async function stopListener(listenerRef) {
162
+ const current = listenerRef.current;
163
+ listenerRef.current = null;
164
+ if (!current)
165
+ return;
166
+ try {
167
+ await current.stop();
168
+ }
169
+ catch {
170
+ // Best-effort only — see this function's own doc.
171
+ }
172
+ }
173
+ /**
174
+ * Serializes OAuth starts that share one `listenerRef`, keyed by the ref object itself (a
175
+ * `WeakMap`, so a host that discards a registration's deps does not leak an entry here).
176
+ *
177
+ * The loopback callback port is a singleton, and `oauth/start` both checks and claims it
178
+ * (`listenerRef.current`) across two `await`s (`beginOAuthPkce` then `startCallbackListener`).
179
+ * Without serializing, two concurrent starts could both observe `listenerRef.current === null`
180
+ * before either claimed it, both proceed to open a listener, and the second's write would
181
+ * silently clobber the first's — the first caller would already have been handed an
182
+ * `authorizeUrl` whose callback endpoint just died, with no error ever surfaced to it. That is
183
+ * the exact race `xaiOauthStartRoute`'s own `OAUTH_FLOW_IN_PROGRESS` conflict check exists to
184
+ * reject instead of silently losing. Serializing makes "check the slot, then claim it" the single
185
+ * indivisible step it always needed to be, so that check can never race with itself.
186
+ */
187
+ const oauthStartQueues = new WeakMap();
188
+ function serializeOauthStart(key, task) {
189
+ const previous = oauthStartQueues.get(key) ?? Promise.resolve();
190
+ // `then(task, task)` so a rejected predecessor still lets the next start run — this is a mutex,
191
+ // not a dependency chain.
192
+ const next = previous.then(task, task);
193
+ // The stored link must never reject, or an unhandled rejection escapes and every later start
194
+ // inherits it. Only the returned promise carries the outcome.
195
+ oauthStartQueues.set(key, next.then(() => undefined, () => undefined));
196
+ return next;
197
+ }
198
+ /** Redacts any Bearer/api-key/`key=`-shaped secret out of a thrown value's message before it reaches the internal-error sink — belt-and-braces even on paths (token exchange/refresh) that don't send a bearer header of our own, since the upstream error body is otherwise passed through verbatim. Matches `callXaiSearch`'s own redaction; kept as a separate helper since those two paths have no exact-secret value in common to pass as `redactSecrets`' second argument. */
199
+ function redactError(error) {
200
+ const message = error instanceof Error ? error.message : String(error);
201
+ return new Error(redactSecrets(message));
202
+ }
203
+ async function persistToken(resolved, tokenResp) {
204
+ const stored = {
205
+ accessToken: tokenResp.access_token,
206
+ tokenType: tokenResp.token_type ?? 'Bearer',
207
+ savedAt: Date.now(),
208
+ };
209
+ if (tokenResp.refresh_token)
210
+ stored.refreshToken = tokenResp.refresh_token;
211
+ if (tokenResp.scope)
212
+ stored.scope = tokenResp.scope;
213
+ if (typeof tokenResp.expires_in === 'number') {
214
+ stored.expiresAt = Date.now() + tokenResp.expires_in * 1000;
215
+ }
216
+ await setStoredOAuthToken(resolved.dataDir, resolved.tokenFileName, stored);
217
+ }
218
+ /** Invoked when the loopback listener actually receives xAI's redirect (the real-browser path, as opposed to `oauth/complete`'s manual paste-back path — xAI's own docs note the loopback redirect commonly shows the user a code to paste rather than following the redirect). No HTTP response to write here; failures are only reported through the internal-error sink. */
219
+ async function handleListenerCallback(resolved, outcome) {
220
+ resolved.listenerRef.current = null;
221
+ if (outcome.kind !== 'ok') {
222
+ resolved.onInternalError({
223
+ source: 'oauth-start',
224
+ correlationId: randomUUID(),
225
+ error: new Error(`xAI OAuth callback failed: ${outcome.error}`),
226
+ });
227
+ return;
228
+ }
229
+ try {
230
+ const tokenResp = await completeOAuthPkce({
231
+ config: resolved.providerConfig,
232
+ pending: resolved.pending,
233
+ state: outcome.state,
234
+ code: outcome.code,
235
+ fetchImpl: resolved.fetchImpl,
236
+ });
237
+ await persistToken(resolved, tokenResp);
238
+ }
239
+ catch (error) {
240
+ resolved.onInternalError({ source: 'oauth-start', correlationId: randomUUID(), error: redactError(error) });
241
+ }
242
+ }
243
+ function isRecord(value) {
244
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
245
+ }
246
+ /** Trims before checking/returning — matches OD's real route handlers, which trim `state`/`code`
247
+ * (`routes/xai.ts:154-157`) and `query` (`routes/xai.ts:258`) before ever using them, so a
248
+ * paste-back value with incidental leading/trailing whitespace (the common case for xAI's own
249
+ * loopback-redirect UX, which shows the user a code to copy rather than following a redirect — see
250
+ * this file's own `xaiOauthCompleteRoute` doc) still matches the exact state string the pending-auth
251
+ * cache stored it under. Live-verified against the real OD daemon (2026-07-22 live-parity pass):
252
+ * `POST /oauth/complete` with a state padded in extra spaces around a real, still-pending state
253
+ * proceeded straight to the token exchange (reaching `auth.x.ai`'s real "invalid_grant" response)
254
+ * instead of failing with "state not found or expired" — proving OD's server-side trim, not just its
255
+ * request-body parsing, is load-bearing. This file's own three `nonEmptyString` call sites
256
+ * (`state`/`code` in `parseXaiOauthComplete`, `query` in `parseXaiSearch`) are exactly OD's three
257
+ * trimmed fields — `fromDate`/`toDate`/`model` deliberately go through `parseOptionalString` instead,
258
+ * which does not trim, matching OD's own untrimmed `from_date`/`to_date`/`model` handling. */
259
+ function nonEmptyString(value) {
260
+ if (typeof value !== 'string')
261
+ return null;
262
+ const trimmed = value.trim();
263
+ return trimmed.length > 0 ? trimmed : null;
264
+ }
265
+ function parseOptionalString(body, key) {
266
+ const value = body[key];
267
+ if (value === undefined)
268
+ return ok(undefined);
269
+ if (typeof value !== 'string')
270
+ return err(validationError(`${key} must be a string when provided`));
271
+ return ok(value);
272
+ }
273
+ function parseOptionalBoolean(body, key) {
274
+ const value = body[key];
275
+ if (value === undefined)
276
+ return ok(undefined);
277
+ if (typeof value !== 'boolean')
278
+ return err(validationError(`${key} must be a boolean when provided`));
279
+ return ok(value);
280
+ }
281
+ function parseOptionalStringArray(body, key) {
282
+ const value = body[key];
283
+ if (value === undefined)
284
+ return ok(undefined);
285
+ if (!Array.isArray(value) || !value.every((v) => typeof v === 'string')) {
286
+ return err(validationError(`${key} must be an array of strings when provided`));
287
+ }
288
+ return ok(value);
289
+ }
290
+ export const xaiOauthStartRoute = defineJsonRoute({
291
+ method: 'post',
292
+ path: '/api/xai/oauth/start',
293
+ requireSameOrigin: true,
294
+ parse: () => ok(undefined),
295
+ handle: async (_input, deps) => {
296
+ const resolved = resolveXaiHttpDeps(deps);
297
+ // Only one OAuth dance can be in flight at a time — the loopback port is a singleton. A
298
+ // second `start` arriving while a listener from a prior `start` is still bound/waiting used
299
+ // to silently stop that listener and open a new one: the first caller's already-returned
300
+ // `authorizeUrl` would point at a now-dead callback endpoint with no error ever surfaced to
301
+ // it. Fail loud instead: reject with a conflict and require the caller to finish
302
+ // (`oauth/complete`) or explicitly cancel (`POST /api/xai/oauth/cancel`) the in-flight flow
303
+ // first. The check-then-claim runs under `serializeOauthStart` so two concurrent starts can't
304
+ // both observe an empty slot — see that function's doc.
305
+ return serializeOauthStart(resolved.listenerRef, async () => {
306
+ if (resolved.listenerRef.current !== null) {
307
+ return err(createApiError('OAUTH_FLOW_IN_PROGRESS', 'an xAI OAuth flow is already in progress — complete it, or cancel it via POST /api/xai/oauth/cancel, before starting a new one'));
308
+ }
309
+ try {
310
+ const { authorizeUrl, state } = beginOAuthPkce({
311
+ config: resolved.providerConfig,
312
+ pending: resolved.pending,
313
+ redirectUri: xaiRedirectUri(resolved),
314
+ });
315
+ const listener = await resolved.startCallbackListener({
316
+ host: resolved.callbackHost,
317
+ port: resolved.callbackPort,
318
+ path: resolved.callbackPath,
319
+ expectedState: state,
320
+ onCallback: (outcome) => handleListenerCallback(resolved, outcome),
321
+ });
322
+ resolved.listenerRef.current = listener;
323
+ return ok({
324
+ authorizeUrl,
325
+ state,
326
+ callback: { host: listener.address.host, port: listener.address.port },
327
+ });
328
+ }
329
+ catch (error) {
330
+ await stopListener(resolved.listenerRef);
331
+ const correlationId = randomUUID();
332
+ resolved.onInternalError({ source: 'oauth-start', correlationId, error });
333
+ return err(createApiError('INTERNAL_ERROR', 'an internal error occurred', { requestId: correlationId }));
334
+ }
335
+ });
336
+ },
337
+ });
338
+ function parseXaiOauthComplete(input) {
339
+ if (!isRecord(input.body))
340
+ return err(validationError('body must be a JSON object'));
341
+ const state = nonEmptyString(input.body.state);
342
+ if (!state)
343
+ return err(validationError('state must be a non-empty string', [{ path: 'state', message: 'required non-empty string' }]));
344
+ const code = nonEmptyString(input.body.code);
345
+ if (!code)
346
+ return err(validationError('code must be a non-empty string', [{ path: 'code', message: 'required non-empty string' }]));
347
+ return ok({ state, code });
348
+ }
349
+ export const xaiOauthCompleteRoute = defineJsonRoute({
350
+ method: 'post',
351
+ path: '/api/xai/oauth/complete',
352
+ requireSameOrigin: true,
353
+ parse: parseXaiOauthComplete,
354
+ handle: async ({ state, code }, deps) => {
355
+ const resolved = resolveXaiHttpDeps(deps);
356
+ try {
357
+ const tokenResp = await completeOAuthPkce({
358
+ config: resolved.providerConfig,
359
+ pending: resolved.pending,
360
+ state,
361
+ code,
362
+ fetchImpl: resolved.fetchImpl,
363
+ });
364
+ await persistToken(resolved, tokenResp);
365
+ // We won the race against the loopback listener (or it was never going to resolve in the
366
+ // first place); shut it down so the next `oauth/start` has a clean slate — matching OD's
367
+ // origin behavior on a successful manual paste-back.
368
+ await stopListener(resolved.listenerRef);
369
+ return ok({ ok: true });
370
+ }
371
+ catch (error) {
372
+ const message = error instanceof Error ? error.message : String(error);
373
+ // `completeOAuthPkce`'s own pre-network validation errors ("state not found or expired",
374
+ // "state mismatch...") carry no secret and are a legitimate client-correctable failure —
375
+ // surfaced verbatim as BAD_REQUEST rather than folded into the SEC-005 INTERNAL_ERROR path
376
+ // below (see module doc's "SEC-005 throughout" section). Exact-prefix match, not substring:
377
+ // pkce.ts#tokenRequest's upstream-error message ("token endpoint rejected request: ...")
378
+ // can legitimately contain the substring "OAuth state" inside an attacker-influenced token
379
+ // endpoint error body, which must never be reflected raw to the caller.
380
+ if (message.startsWith(`${resolved.providerConfig.providerId} OAuth state`)) {
381
+ return err(validationError(message));
382
+ }
383
+ const correlationId = randomUUID();
384
+ resolved.onInternalError({ source: 'oauth-complete', correlationId, error: redactError(error) });
385
+ return err(createApiError('INTERNAL_ERROR', 'an internal error occurred', { requestId: correlationId }));
386
+ }
387
+ },
388
+ });
389
+ // ---------------------------------------------------------------------------
390
+ // POST /api/xai/oauth/cancel
391
+ // ---------------------------------------------------------------------------
392
+ export const xaiOauthCancelRoute = defineJsonRoute({
393
+ method: 'post',
394
+ path: '/api/xai/oauth/cancel',
395
+ requireSameOrigin: true,
396
+ parse: () => ok(undefined),
397
+ handle: async (_input, deps) => {
398
+ const resolved = resolveXaiHttpDeps(deps);
399
+ // Cancel only stops the in-flight loopback listener; it must never wipe a stored token — a
400
+ // user clicking Cancel mid-reconnect would otherwise lose their existing grant. Disconnect
401
+ // (below) is the destructive path. Matches OD's origin `oauth/cancel` handler exactly.
402
+ await stopListener(resolved.listenerRef);
403
+ return ok({ ok: true });
404
+ },
405
+ });
406
+ export const xaiAuthStatusRoute = defineJsonRoute({
407
+ method: 'get',
408
+ path: '/api/xai/auth/status',
409
+ requireSameOrigin: true,
410
+ parse: () => ok(undefined),
411
+ handle: async (_input, deps) => {
412
+ const resolved = resolveXaiHttpDeps(deps);
413
+ try {
414
+ const token = await getStoredOAuthToken(resolved.dataDir, resolved.tokenFileName);
415
+ const listening = resolved.listenerRef.current !== null;
416
+ if (!token)
417
+ return ok({ connected: false, expiresAt: null, scope: null, savedAt: null, listening });
418
+ return ok({
419
+ connected: true,
420
+ expiresAt: token.expiresAt ?? null,
421
+ scope: token.scope ?? null,
422
+ savedAt: token.savedAt,
423
+ listening,
424
+ });
425
+ }
426
+ catch (error) {
427
+ const correlationId = randomUUID();
428
+ resolved.onInternalError({ source: 'auth-status', correlationId, error });
429
+ return err(createApiError('INTERNAL_ERROR', 'an internal error occurred', { requestId: correlationId }));
430
+ }
431
+ },
432
+ });
433
+ // ---------------------------------------------------------------------------
434
+ // POST /api/xai/oauth/disconnect
435
+ // ---------------------------------------------------------------------------
436
+ export const xaiOauthDisconnectRoute = defineJsonRoute({
437
+ method: 'post',
438
+ path: '/api/xai/oauth/disconnect',
439
+ requireSameOrigin: true,
440
+ parse: () => ok(undefined),
441
+ handle: async (_input, deps) => {
442
+ const resolved = resolveXaiHttpDeps(deps);
443
+ try {
444
+ await stopListener(resolved.listenerRef);
445
+ await clearStoredOAuthToken(resolved.dataDir, resolved.tokenFileName);
446
+ return ok({ ok: true });
447
+ }
448
+ catch (error) {
449
+ const correlationId = randomUUID();
450
+ resolved.onInternalError({ source: 'oauth-disconnect', correlationId, error });
451
+ return err(createApiError('INTERNAL_ERROR', 'an internal error occurred', { requestId: correlationId }));
452
+ }
453
+ },
454
+ });
455
+ function parseXaiSearch(input) {
456
+ if (!isRecord(input.body))
457
+ return err(validationError('body must be a JSON object'));
458
+ const body = input.body;
459
+ const query = nonEmptyString(body.query);
460
+ if (!query)
461
+ return err(validationError('query must be a non-empty string', [{ path: 'query', message: 'required non-empty string' }]));
462
+ const allowedXHandles = parseOptionalStringArray(body, 'allowedXHandles');
463
+ if (!allowedXHandles.ok)
464
+ return allowedXHandles;
465
+ const excludedXHandles = parseOptionalStringArray(body, 'excludedXHandles');
466
+ if (!excludedXHandles.ok)
467
+ return excludedXHandles;
468
+ const fromDate = parseOptionalString(body, 'fromDate');
469
+ if (!fromDate.ok)
470
+ return fromDate;
471
+ const toDate = parseOptionalString(body, 'toDate');
472
+ if (!toDate.ok)
473
+ return toDate;
474
+ const enableImageUnderstanding = parseOptionalBoolean(body, 'enableImageUnderstanding');
475
+ if (!enableImageUnderstanding.ok)
476
+ return enableImageUnderstanding;
477
+ const enableVideoUnderstanding = parseOptionalBoolean(body, 'enableVideoUnderstanding');
478
+ if (!enableVideoUnderstanding.ok)
479
+ return enableVideoUnderstanding;
480
+ const model = parseOptionalString(body, 'model');
481
+ if (!model.ok)
482
+ return model;
483
+ return ok({
484
+ query,
485
+ ...(allowedXHandles.value?.length ? { allowedXHandles: allowedXHandles.value } : {}),
486
+ ...(excludedXHandles.value?.length ? { excludedXHandles: excludedXHandles.value } : {}),
487
+ ...(fromDate.value ? { fromDate: fromDate.value } : {}),
488
+ ...(toDate.value ? { toDate: toDate.value } : {}),
489
+ ...(enableImageUnderstanding.value !== undefined ? { enableImageUnderstanding: enableImageUnderstanding.value } : {}),
490
+ ...(enableVideoUnderstanding.value !== undefined ? { enableVideoUnderstanding: enableVideoUnderstanding.value } : {}),
491
+ ...(model.value ? { model: model.value } : {}),
492
+ });
493
+ }
494
+ /** Pulls the assistant's main text out of an xAI Responses-API payload — the convenience `output_text` field, or the structured `output[].content[].text` form. Ported verbatim from OD's `extractAnswerText`. */
495
+ function extractAnswerText(data) {
496
+ if (!isRecord(data))
497
+ return '';
498
+ const direct = data.output_text;
499
+ if (typeof direct === 'string' && direct.trim())
500
+ return direct;
501
+ const output = data.output;
502
+ if (!Array.isArray(output))
503
+ return '';
504
+ const chunks = [];
505
+ for (const item of output) {
506
+ if (!isRecord(item))
507
+ continue;
508
+ const content = item.content;
509
+ if (!Array.isArray(content))
510
+ continue;
511
+ for (const block of content) {
512
+ if (!isRecord(block))
513
+ continue;
514
+ const t = block.text;
515
+ if (typeof t === 'string' && t)
516
+ chunks.push(t);
517
+ }
518
+ }
519
+ return chunks.join('\n').trim();
520
+ }
521
+ /** Walks every annotation in the Responses-API output and returns the unique URL citations (`type: 'url_citation'`). Ported verbatim from OD's `extractUrlCitations`. */
522
+ function extractUrlCitations(data) {
523
+ if (!isRecord(data))
524
+ return [];
525
+ const output = data.output;
526
+ if (!Array.isArray(output))
527
+ return [];
528
+ const urls = new Set();
529
+ for (const item of output) {
530
+ if (!isRecord(item))
531
+ continue;
532
+ const content = item.content;
533
+ if (!Array.isArray(content))
534
+ continue;
535
+ for (const block of content) {
536
+ if (!isRecord(block))
537
+ continue;
538
+ const annotations = block.annotations;
539
+ if (!Array.isArray(annotations))
540
+ continue;
541
+ for (const ann of annotations) {
542
+ if (!isRecord(ann))
543
+ continue;
544
+ if (ann.type !== 'url_citation')
545
+ continue;
546
+ const url = typeof ann.url === 'string' ? ann.url.trim() : '';
547
+ if (url)
548
+ urls.add(url);
549
+ }
550
+ }
551
+ }
552
+ return [...urls];
553
+ }
554
+ /** Calls xAI's real `POST {searchBaseUrl}/responses` endpoint with the `x_search` native tool, using `accessToken` as the bearer credential. Throws (never a `Result`) on any failure — `xaiSearchRoute.handle` is the one place that converts a thrown error into this package's SEC-005 response shape. Any thrown message is pre-redacted of `accessToken` here so the sink never even logs it unredacted. */
555
+ async function callXaiSearch(resolved, accessToken, request) {
556
+ const baseUrlCheck = validateBaseUrl(resolved.searchBaseUrl);
557
+ if (baseUrlCheck.error)
558
+ throw new Error(baseUrlCheck.error);
559
+ const base = resolved.searchBaseUrl.replace(/\/+$/, '');
560
+ const model = request.model ?? resolved.searchDefaultModel;
561
+ const xSearchTool = { type: 'x_search' };
562
+ if (request.allowedXHandles?.length)
563
+ xSearchTool.allowed_x_handles = request.allowedXHandles;
564
+ if (request.excludedXHandles?.length)
565
+ xSearchTool.excluded_x_handles = request.excludedXHandles;
566
+ if (request.fromDate)
567
+ xSearchTool.from_date = request.fromDate;
568
+ if (request.toDate)
569
+ xSearchTool.to_date = request.toDate;
570
+ if (request.enableImageUnderstanding === true)
571
+ xSearchTool.enable_image_understanding = true;
572
+ if (request.enableVideoUnderstanding === true)
573
+ xSearchTool.enable_video_understanding = true;
574
+ const requestBody = {
575
+ model,
576
+ input: [{ role: 'user', content: request.query }],
577
+ tools: [xSearchTool],
578
+ store: false,
579
+ };
580
+ // One deadline covering the request *and* the body read, mirroring `research.ts`'s Tavily call.
581
+ // Arming it only around `fetch` would leave the far more common stall unbounded: an upstream that
582
+ // returns headers promptly and then never finishes the body still pins this handler, its socket
583
+ // and the caller's connection forever. The timer is cleared in a `finally` so a fast response
584
+ // does not leave one armed.
585
+ const timeoutController = new AbortController();
586
+ const timeoutHandle = setTimeout(() => timeoutController.abort(), resolved.searchTimeoutMs);
587
+ const timedOutMessage = `xAI request timed out after ${resolved.searchTimeoutMs}ms`;
588
+ try {
589
+ let response;
590
+ try {
591
+ response = await resolved.fetchImpl(`${base}/responses`, {
592
+ method: 'POST',
593
+ headers: { authorization: `Bearer ${accessToken}`, 'content-type': 'application/json' },
594
+ body: JSON.stringify(requestBody),
595
+ signal: timeoutController.signal,
596
+ });
597
+ }
598
+ catch (error) {
599
+ if (timeoutController.signal.aborted)
600
+ throw new Error(timedOutMessage);
601
+ const message = error instanceof Error ? error.message : String(error);
602
+ throw new Error(redactSecrets(`xAI request failed: ${message}`, [accessToken]));
603
+ }
604
+ if (!response.ok) {
605
+ const text = await response.text().catch(() => '');
606
+ throw new Error(redactSecrets(`xAI ${response.status}: ${text.slice(0, 240) || 'no body'}`, [accessToken]));
607
+ }
608
+ let data;
609
+ try {
610
+ data = await response.json();
611
+ }
612
+ catch (error) {
613
+ if (timeoutController.signal.aborted)
614
+ throw new Error(timedOutMessage);
615
+ const message = error instanceof Error ? error.message : String(error);
616
+ throw new Error(redactSecrets(`xAI response could not be read: ${message}`, [accessToken]));
617
+ }
618
+ return { answer: extractAnswerText(data), citations: extractUrlCitations(data), model };
619
+ }
620
+ finally {
621
+ clearTimeout(timeoutHandle);
622
+ }
623
+ }
624
+ export const xaiSearchRoute = defineJsonRoute({
625
+ method: 'post',
626
+ path: '/api/xai/search',
627
+ requireSameOrigin: true,
628
+ parse: parseXaiSearch,
629
+ handle: async (request, deps) => {
630
+ const resolved = resolveXaiHttpDeps(deps);
631
+ let credential;
632
+ try {
633
+ credential = await resolveOAuthBearer(resolved.providerConfig, resolved.tokenFileName, resolved.dataDir, resolved.fetchImpl);
634
+ }
635
+ catch (error) {
636
+ const correlationId = randomUUID();
637
+ resolved.onInternalError({ source: 'search', correlationId, error });
638
+ return err(createApiError('INTERNAL_ERROR', 'an internal error occurred', { requestId: correlationId }));
639
+ }
640
+ if (!credential) {
641
+ // No entitlement gate here — see module doc's "Dropped: OD's SuperGrok subscription gate"
642
+ // section. This is only "no connected/refreshable xAI account," an honest, non-exceptional
643
+ // NOT_CONFIGURED (503), matching `research.ts`'s "capability exists, reachable, inert until
644
+ // configured" shape.
645
+ return err(createApiError('NOT_CONFIGURED', 'no xAI account connected — sign in via /api/xai/oauth/start first'));
646
+ }
647
+ try {
648
+ const result = await callXaiSearch(resolved, credential.accessToken, request);
649
+ return ok(result);
650
+ }
651
+ catch (error) {
652
+ const correlationId = randomUUID();
653
+ resolved.onInternalError({ source: 'search', correlationId, error });
654
+ return err(createApiError('INTERNAL_ERROR', 'an internal error occurred', { requestId: correlationId }));
655
+ }
656
+ },
657
+ });
658
+ /** Mounts every xAI route on `app`. A pack's `http(app, services)` calls this directly. */
659
+ export function registerXaiRoutes(app, deps, adapter) {
660
+ const resolved = resolveXaiHttpDeps(deps);
661
+ mountJsonRoute(app, xaiOauthStartRoute, resolved, adapter);
662
+ mountJsonRoute(app, xaiOauthCompleteRoute, resolved, adapter);
663
+ mountJsonRoute(app, xaiOauthCancelRoute, resolved, adapter);
664
+ mountJsonRoute(app, xaiAuthStatusRoute, resolved, adapter);
665
+ mountJsonRoute(app, xaiOauthDisconnectRoute, resolved, adapter);
666
+ mountJsonRoute(app, xaiSearchRoute, resolved, adapter);
667
+ }
668
+ //# sourceMappingURL=xai.js.map