@ixo/oracle-runtime-workers 0.2.0

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 (215) hide show
  1. package/README.md +110 -0
  2. package/package.json +77 -0
  3. package/src/attachments/classify.ts +114 -0
  4. package/src/attachments/content-blocks.ts +51 -0
  5. package/src/attachments/download.ts +235 -0
  6. package/src/attachments/extract.ts +241 -0
  7. package/src/attachments/index.ts +84 -0
  8. package/src/attachments/magic.ts +133 -0
  9. package/src/attachments/pipeline.ts +409 -0
  10. package/src/attachments/retention.ts +205 -0
  11. package/src/attachments/route.ts +41 -0
  12. package/src/attachments/sandbox-archive.ts +90 -0
  13. package/src/attachments/sanitize.ts +35 -0
  14. package/src/attachments/types.ts +86 -0
  15. package/src/attachments/view.ts +107 -0
  16. package/src/core/env.ts +200 -0
  17. package/src/core/index.ts +513 -0
  18. package/src/core/llm.ts +842 -0
  19. package/src/core/main-agent-types.ts +123 -0
  20. package/src/core/main-agent.ts +399 -0
  21. package/src/core/manifest.ts +365 -0
  22. package/src/core/meta-tools.ts +232 -0
  23. package/src/core/middlewares/byo-history-sanitizer.ts +165 -0
  24. package/src/core/middlewares/capability-gate.ts +76 -0
  25. package/src/core/middlewares/index.ts +18 -0
  26. package/src/core/middlewares/page-context.ts +98 -0
  27. package/src/core/middlewares/safety-guardrail.ts +117 -0
  28. package/src/core/middlewares/summarization.ts +99 -0
  29. package/src/core/middlewares/tool-repetition-guard.ts +125 -0
  30. package/src/core/middlewares/tool-validation.ts +83 -0
  31. package/src/core/middlewares/work-status.ts +57 -0
  32. package/src/core/openrouter-pricing.ts +124 -0
  33. package/src/core/plugin-loader.ts +192 -0
  34. package/src/core/plugins/index.ts +6 -0
  35. package/src/core/plugins/skills/index.ts +8 -0
  36. package/src/core/plugins/skills/skills-tools.ts +356 -0
  37. package/src/core/plugins/skills/skills.plugin.ts +113 -0
  38. package/src/core/plugins/weather/index.ts +8 -0
  39. package/src/core/plugins/weather/weather-client.ts +153 -0
  40. package/src/core/plugins/weather/weather-middleware.ts +27 -0
  41. package/src/core/plugins/weather/weather-sub-agent.ts +65 -0
  42. package/src/core/plugins/weather/weather-tools.ts +103 -0
  43. package/src/core/plugins/weather/weather.plugin.ts +227 -0
  44. package/src/core/prompt-composer.ts +469 -0
  45. package/src/core/registries.ts +642 -0
  46. package/src/core/runtime-context.ts +619 -0
  47. package/src/core/state.ts +108 -0
  48. package/src/core/sub-agent-fallback.ts +187 -0
  49. package/src/core/subagent-as-tool.ts +322 -0
  50. package/src/core/template.ts +245 -0
  51. package/src/core/utils.ts +246 -0
  52. package/src/core/wrap-plugin-tool.ts +100 -0
  53. package/src/do/ambient.ts +266 -0
  54. package/src/do/contracts.ts +562 -0
  55. package/src/do/debug-timers.ts +120 -0
  56. package/src/do/fresh-stub.ts +32 -0
  57. package/src/do/gateway-retry.ts +73 -0
  58. package/src/do/secrets-adapter.ts +32 -0
  59. package/src/do/size-alerts.ts +76 -0
  60. package/src/do/sse-stream.ts +472 -0
  61. package/src/do/transcript.ts +192 -0
  62. package/src/do/turn-body.ts +84 -0
  63. package/src/do/turn-metadata.ts +146 -0
  64. package/src/do/ucan-service.ts +339 -0
  65. package/src/do/user-oracle-do.ts +3035 -0
  66. package/src/gateway-worker.ts +82 -0
  67. package/src/index.ts +237 -0
  68. package/src/llm/byo-adapter.ts +59 -0
  69. package/src/llm/byo-catalog.ts +520 -0
  70. package/src/llm/byo-client.ts +280 -0
  71. package/src/llm/byo-routes.ts +255 -0
  72. package/src/llm/byo-service.ts +836 -0
  73. package/src/llm/chatgpt-oauth.ts +430 -0
  74. package/src/llm/provider-error.ts +283 -0
  75. package/src/matrix/gateway-do.ts +918 -0
  76. package/src/matrix/ingest.ts +209 -0
  77. package/src/matrix/replay-format.ts +34 -0
  78. package/src/matrix/reply-chain.ts +109 -0
  79. package/src/matrix/room-state-codec.ts +115 -0
  80. package/src/matrix/user-homeserver.ts +106 -0
  81. package/src/matrix/work-status.ts +294 -0
  82. package/src/memory/session-history-indexer.ts +328 -0
  83. package/src/owner-store/ixo-vfs-store.ts +570 -0
  84. package/src/owner-store/matrix-media-store.ts +136 -0
  85. package/src/owner-store/migrating-store.ts +185 -0
  86. package/src/owner-store/owner-copy-errors.ts +175 -0
  87. package/src/owner-store/retry.ts +45 -0
  88. package/src/owner-store/tus-upload.ts +333 -0
  89. package/src/owner-store/types.ts +0 -0
  90. package/src/plugin-api/define-plugin.ts +45 -0
  91. package/src/plugin-api/index.ts +56 -0
  92. package/src/plugin-api/oracle-plugin.ts +145 -0
  93. package/src/plugin-api/tool-helper.ts +82 -0
  94. package/src/plugin-api/types.ts +816 -0
  95. package/src/plugin-api/ucan-errors.ts +16 -0
  96. package/src/plugins/agui/agui-agent.ts +115 -0
  97. package/src/plugins/agui/agui.plugin.ts +148 -0
  98. package/src/plugins/agui/index.ts +2 -0
  99. package/src/plugins/attachments/attachments.plugin.ts +54 -0
  100. package/src/plugins/attachments/index.ts +6 -0
  101. package/src/plugins/attachments/view-attachment-tool.ts +91 -0
  102. package/src/plugins/base64.ts +26 -0
  103. package/src/plugins/composio/composio-tools.ts +365 -0
  104. package/src/plugins/composio/composio-ucan.ts +43 -0
  105. package/src/plugins/composio/composio.plugin.ts +214 -0
  106. package/src/plugins/composio/index.ts +15 -0
  107. package/src/plugins/domain-indexer/domain-indexer-agent.ts +57 -0
  108. package/src/plugins/domain-indexer/domain-indexer-tools.ts +253 -0
  109. package/src/plugins/domain-indexer/domain-indexer.plugin.ts +100 -0
  110. package/src/plugins/domain-indexer/index.ts +3 -0
  111. package/src/plugins/editor/blocknote-bridge.ts +120 -0
  112. package/src/plugins/editor/content-session.ts +331 -0
  113. package/src/plugins/editor/content-tools.ts +686 -0
  114. package/src/plugins/editor/document-model.ts +0 -0
  115. package/src/plugins/editor/editor-access-denied-tool.ts +58 -0
  116. package/src/plugins/editor/editor-agent.ts +34 -0
  117. package/src/plugins/editor/editor-config.ts +64 -0
  118. package/src/plugins/editor/editor-mx.ts +128 -0
  119. package/src/plugins/editor/editor.plugin.ts +143 -0
  120. package/src/plugins/editor/failures.ts +154 -0
  121. package/src/plugins/editor/index.ts +31 -0
  122. package/src/plugins/editor/jsdom-shim.ts +55 -0
  123. package/src/plugins/editor/prompts.ts +59 -0
  124. package/src/plugins/editor/prop-policy.ts +147 -0
  125. package/src/plugins/editor/provider.ts +386 -0
  126. package/src/plugins/editor/room-membership.ts +95 -0
  127. package/src/plugins/editor/standalone-editor-tool.ts +200 -0
  128. package/src/plugins/firecrawl/firecrawl-agent.ts +93 -0
  129. package/src/plugins/firecrawl/firecrawl-tools.ts +220 -0
  130. package/src/plugins/firecrawl/firecrawl.plugin.ts +105 -0
  131. package/src/plugins/firecrawl/index.ts +15 -0
  132. package/src/plugins/flows/action-metadata.ts +3505 -0
  133. package/src/plugins/flows/actions.ts +190 -0
  134. package/src/plugins/flows/edit.ts +314 -0
  135. package/src/plugins/flows/errors.ts +38 -0
  136. package/src/plugins/flows/explain.ts +43 -0
  137. package/src/plugins/flows/flow-doc.ts +111 -0
  138. package/src/plugins/flows/flows.plugin.ts +129 -0
  139. package/src/plugins/flows/forms.ts +330 -0
  140. package/src/plugins/flows/index.ts +2 -0
  141. package/src/plugins/flows/linkage.ts +147 -0
  142. package/src/plugins/flows/port-types.ts +53 -0
  143. package/src/plugins/flows/prompts.ts +65 -0
  144. package/src/plugins/flows/read.ts +392 -0
  145. package/src/plugins/flows/references.ts +66 -0
  146. package/src/plugins/flows/test-support.ts +165 -0
  147. package/src/plugins/flows/tools/authoring.ts +516 -0
  148. package/src/plugins/flows/tools/discovery.ts +119 -0
  149. package/src/plugins/flows/tools/forms.ts +142 -0
  150. package/src/plugins/flows/tools/inspect.ts +165 -0
  151. package/src/plugins/flows/tools/linkage.ts +117 -0
  152. package/src/plugins/flows/tools/settings.ts +184 -0
  153. package/src/plugins/flows/translator.ts +328 -0
  154. package/src/plugins/flows/types.ts +210 -0
  155. package/src/plugins/flows/ydoc-helpers.ts +543 -0
  156. package/src/plugins/index.ts +70 -0
  157. package/src/plugins/mcp-call-timeout.ts +38 -0
  158. package/src/plugins/mcp-tool-adapter.ts +87 -0
  159. package/src/plugins/memory/index.ts +17 -0
  160. package/src/plugins/memory/memory-tools.ts +463 -0
  161. package/src/plugins/memory/memory-ucan.ts +51 -0
  162. package/src/plugins/memory/memory.plugin.ts +122 -0
  163. package/src/plugins/memory/types.ts +147 -0
  164. package/src/plugins/portal/action-log.ts +34 -0
  165. package/src/plugins/portal/index.ts +2 -0
  166. package/src/plugins/portal/portal.plugin.ts +203 -0
  167. package/src/plugins/sandbox/index.ts +34 -0
  168. package/src/plugins/sandbox/sandbox-bridge.ts +295 -0
  169. package/src/plugins/sandbox/sandbox-mcp.ts +127 -0
  170. package/src/plugins/sandbox/sandbox-write-blob.ts +103 -0
  171. package/src/plugins/sandbox/sandbox.plugin.ts +599 -0
  172. package/src/plugins/tasks/index.ts +17 -0
  173. package/src/plugins/tasks/manifest.ts +81 -0
  174. package/src/plugins/tasks/middleware.ts +219 -0
  175. package/src/plugins/tasks/tasks-tools.ts +414 -0
  176. package/src/plugins/tasks/tasks.plugin.ts +74 -0
  177. package/src/plugins/ucan-failure.ts +133 -0
  178. package/src/plugins/user-preferences/index.ts +22 -0
  179. package/src/plugins/user-preferences/schema.ts +31 -0
  180. package/src/plugins/user-preferences/user-preferences-store.ts +172 -0
  181. package/src/plugins/user-preferences/user-preferences-tool.ts +131 -0
  182. package/src/plugins/user-preferences/user-preferences.plugin.ts +137 -0
  183. package/src/plugins/vfs/index.ts +40 -0
  184. package/src/plugins/vfs/vfs-auth.ts +56 -0
  185. package/src/plugins/vfs/vfs-client.ts +655 -0
  186. package/src/plugins/vfs/vfs-content.ts +181 -0
  187. package/src/plugins/vfs/vfs-errors.ts +163 -0
  188. package/src/plugins/vfs/vfs-sandbox-tools.ts +272 -0
  189. package/src/plugins/vfs/vfs-tools.ts +635 -0
  190. package/src/plugins/vfs/vfs.plugin.ts +201 -0
  191. package/src/realtime/frontend-call-registry.ts +163 -0
  192. package/src/realtime/realtime-endpoint.ts +619 -0
  193. package/src/realtime/session-socket-hub.ts +249 -0
  194. package/src/realtime/socket-io-codec.ts +187 -0
  195. package/src/secrets/jwe.ts +74 -0
  196. package/src/secrets/pin-cipher.ts +43 -0
  197. package/src/secrets/secrets-service.ts +319 -0
  198. package/src/shell/app.ts +564 -0
  199. package/src/shell/auth.ts +290 -0
  200. package/src/sqlite/blob-codec.ts +100 -0
  201. package/src/sqlite/blob-compactor.ts +146 -0
  202. package/src/sqlite/cache-config.ts +45 -0
  203. package/src/sqlite/database.ts +643 -0
  204. package/src/sqlite/do-vfs.ts +1485 -0
  205. package/src/sqlite/index.ts +61 -0
  206. package/src/sqlite/serialization.ts +370 -0
  207. package/src/sqlite/sessions-store.ts +311 -0
  208. package/src/sqlite/sqlite-saver.ts +919 -0
  209. package/src/sqlite/vacuum-policy.ts +77 -0
  210. package/src/sqlite/wa-sqlite-loader.ts +123 -0
  211. package/src/sqlite/wasm.d.ts +10 -0
  212. package/src/tasks/schedule.ts +174 -0
  213. package/src/tasks/scheduler.ts +753 -0
  214. package/src/tasks/spec.ts +125 -0
  215. package/src/tasks/store.ts +351 -0
@@ -0,0 +1,842 @@
1
+ import type { BaseChatModel } from '@langchain/core/language_models/chat_models';
2
+ import { LangChainTracer } from '@langchain/core/tracers/tracer_langchain';
3
+ import { ChatOpenAI } from '@langchain/openai';
4
+ import { Client } from 'langsmith';
5
+ import type { ChatOpenAIFields, Logger, ModelRole } from '../plugin-api/types';
6
+ import { fetchOpenRouterPrices } from './openrouter-pricing';
7
+ import type { LlmAdapter } from './runtime-context';
8
+ import { NOOP_LOGGER } from './utils';
9
+
10
+ // ── Catalog ─────────────────────────────────────────────────────────────────
11
+
12
+ /** Coarse capability/price tier. Drives the `$`/`$$`/`$$$` + badge shown to users. */
13
+ export type ModelTier = 'everyday' | 'balanced' | 'top';
14
+
15
+ /** Provider family — lets a picker group models or show a logo. */
16
+ export type ModelFamily =
17
+ | 'openai'
18
+ | 'google'
19
+ | 'anthropic'
20
+ | 'moonshotai'
21
+ | 'z-ai'
22
+ | 'deepseek';
23
+
24
+ /**
25
+ * Native (non-text) input types a model accepts. Drives attachment routing:
26
+ * an attachment is sent to the model directly only when the model accepts
27
+ * that modality.
28
+ */
29
+ export interface ModelInputCapabilities {
30
+ image: boolean;
31
+ file: boolean;
32
+ audio: boolean;
33
+ video: boolean;
34
+ }
35
+
36
+ /** A single hand-curated model. */
37
+ export interface ModelCatalogEntry {
38
+ /** OpenRouter slug — sent to the provider and used as the allow-list key. */
39
+ id: string;
40
+ /** Friendly display name. */
41
+ label: string;
42
+ family: ModelFamily;
43
+ tier: ModelTier;
44
+ /** One plain-language sentence: when should someone pick this model? */
45
+ blurb: string;
46
+ /** Whether the model accepts image input. */
47
+ vision: boolean;
48
+ /**
49
+ * Baseline list price, used when live OpenRouter pricing is unavailable
50
+ * (see `openrouter-pricing.ts`). Raw provider price; markup applied later.
51
+ */
52
+ baselinePrice: ModelPrice;
53
+ }
54
+
55
+ /** Raw list price, USD per million tokens. */
56
+ export interface ModelPrice {
57
+ inputPerMillion: number;
58
+ outputPerMillion: number;
59
+ }
60
+
61
+ /**
62
+ * The default model for a fresh chat. An oracle operator can override this
63
+ * per deployment via the `DEFAULT_MODEL` env var (see `getDefaultModelId`).
64
+ */
65
+ export const DEFAULT_MODEL_ID = 'openai/gpt-5.6-luna';
66
+
67
+ /** Per-tier presentation, defined once so the wording stays consistent. */
68
+ export const TIER_DISPLAY: Record<
69
+ ModelTier,
70
+ { costLabel: string; badge: string; order: number }
71
+ > = {
72
+ everyday: { costLabel: '$', badge: 'Fast', order: 0 },
73
+ balanced: { costLabel: '$$', badge: 'Balanced', order: 1 },
74
+ top: { costLabel: '$$$', badge: 'Smartest', order: 2 },
75
+ };
76
+
77
+ /**
78
+ * The curated, user-facing model catalog — also the **allow-list**: a
79
+ * per-request model override is only honoured if its id appears here.
80
+ * `baselinePrice` is the fallback when live OpenRouter pricing is unavailable.
81
+ */
82
+ export const MODEL_CATALOG: readonly ModelCatalogEntry[] = [
83
+ // ── $ Everyday — fast and cheap ─────────────────────────────────────────
84
+ {
85
+ id: DEFAULT_MODEL_ID,
86
+ label: 'GPT-5.4 Nano',
87
+ family: 'openai',
88
+ tier: 'everyday',
89
+ blurb: 'Fast and low-cost — great for everyday questions and quick help.',
90
+ vision: true,
91
+ baselinePrice: { inputPerMillion: 0.2, outputPerMillion: 1.2 },
92
+ },
93
+ {
94
+ id: 'google/gemini-3.1-flash-lite',
95
+ label: 'Gemini 3.1 Flash Lite',
96
+ family: 'google',
97
+ tier: 'everyday',
98
+ blurb: 'Speedy and inexpensive, and it can read images too.',
99
+ vision: true,
100
+ baselinePrice: { inputPerMillion: 0.25, outputPerMillion: 1.5 },
101
+ },
102
+ {
103
+ id: 'z-ai/glm-5.2',
104
+ label: 'GLM 5.2',
105
+ family: 'z-ai',
106
+ tier: 'everyday',
107
+ blurb: 'Budget-friendly open model that handles general chat well.',
108
+ vision: false,
109
+ baselinePrice: { inputPerMillion: 0.2968, outputPerMillion: 0.9328 },
110
+ },
111
+ {
112
+ id: 'moonshotai/kimi-k2.5',
113
+ label: 'Kimi K2.5',
114
+ family: 'moonshotai',
115
+ tier: 'everyday',
116
+ blurb: 'Low-cost open model that can also look at images.',
117
+ vision: true,
118
+ baselinePrice: { inputPerMillion: 0.57, outputPerMillion: 2.85 },
119
+ },
120
+
121
+ // ── $$ Balanced — smarter, still affordable ─────────────────────────────
122
+ {
123
+ id: 'openai/gpt-5.6-luna',
124
+ label: 'GPT-5.6 Luna',
125
+ family: 'openai',
126
+ tier: 'balanced',
127
+ blurb: 'A smart all-rounder that balances speed and reasoning.',
128
+ vision: true,
129
+ baselinePrice: { inputPerMillion: 1.0, outputPerMillion: 6.0 },
130
+ },
131
+ {
132
+ id: 'google/gemini-3.5-flash',
133
+ label: 'Gemini 3.5 Flash',
134
+ family: 'google',
135
+ tier: 'balanced',
136
+ blurb: 'Capable multimodal model for tougher, more detailed tasks.',
137
+ vision: true,
138
+ baselinePrice: { inputPerMillion: 1.5, outputPerMillion: 9.0 },
139
+ },
140
+ {
141
+ id: 'anthropic/claude-sonnet-5',
142
+ label: 'Claude Sonnet 5',
143
+ family: 'anthropic',
144
+ tier: 'balanced',
145
+ blurb: 'Great at careful writing, reasoning and coding.',
146
+ vision: true,
147
+ baselinePrice: { inputPerMillion: 2.0, outputPerMillion: 10.0 },
148
+ },
149
+
150
+ // ── $$$ Top-tier — most capable ─────────────────────────────────────────
151
+ {
152
+ id: 'moonshotai/kimi-k3',
153
+ label: 'Kimi K3',
154
+ family: 'moonshotai',
155
+ tier: 'top',
156
+ blurb: 'Powerful open model for complex, long-running tasks.',
157
+ vision: true,
158
+ baselinePrice: { inputPerMillion: 3.0, outputPerMillion: 15.0 },
159
+ },
160
+ {
161
+ id: 'anthropic/claude-opus-4.8',
162
+ label: 'Claude Opus 4.8',
163
+ family: 'anthropic',
164
+ tier: 'top',
165
+ blurb: "Anthropic's most capable model for hard problems.",
166
+ vision: true,
167
+ baselinePrice: { inputPerMillion: 5.0, outputPerMillion: 25.0 },
168
+ },
169
+ {
170
+ id: 'openai/gpt-5.6-sol',
171
+ label: 'GPT-5.6 Sol',
172
+ family: 'openai',
173
+ tier: 'top',
174
+ blurb: "OpenAI's flagship for the most complex work.",
175
+ vision: true,
176
+ baselinePrice: { inputPerMillion: 5.0, outputPerMillion: 30.0 },
177
+ },
178
+ ];
179
+
180
+ /** The public, per-model shape returned by a `GET /models` route. */
181
+ export interface ModelListItem {
182
+ id: string;
183
+ label: string;
184
+ family: ModelFamily;
185
+ tier: ModelTier;
186
+ /** `$` / `$$` / `$$$` — the at-a-glance cost cue. */
187
+ costLabel: string;
188
+ /** `Fast` / `Balanced` / `Smartest`. */
189
+ badge: string;
190
+ blurb: string;
191
+ vision: boolean;
192
+ /**
193
+ * The price the user pays, already including the platform markup. The raw
194
+ * provider price and the markup multiplier are deliberately NOT included.
195
+ */
196
+ pricing: {
197
+ inputPerMillion: number;
198
+ outputPerMillion: number;
199
+ currency: 'USD';
200
+ unit: 'per_million_tokens';
201
+ };
202
+ /** True for the model a fresh chat uses when the user hasn't picked one. */
203
+ isDefault: boolean;
204
+ }
205
+
206
+ /** The full `GET /models` response — the Node runtime's `ModelListing`. */
207
+ export interface ModelListing {
208
+ models: ModelListItem[];
209
+ /** Id of the default model (also flagged via `isDefault` on the item). */
210
+ default: string;
211
+ }
212
+
213
+ const catalogById = new Map(MODEL_CATALOG.map((m) => [m.id, m]));
214
+
215
+ /** Look up a catalog entry by its OpenRouter id. */
216
+ export function getCatalogEntry(id: string): ModelCatalogEntry | undefined {
217
+ return catalogById.get(id);
218
+ }
219
+
220
+ /**
221
+ * Whether `id` is a selectable model. This is the allow-list guard for the
222
+ * per-request override — an unknown id is rejected and the turn falls back to
223
+ * the default model.
224
+ */
225
+ export function isAllowedModel(id: string | undefined | null): id is string {
226
+ return typeof id === 'string' && catalogById.has(id);
227
+ }
228
+
229
+ /**
230
+ * The effective default model id for this deployment: the `DEFAULT_MODEL`
231
+ * config value when an operator has set one, otherwise {@link DEFAULT_MODEL_ID}.
232
+ * Reads the validated config, never `process.env`.
233
+ */
234
+ export function getDefaultModelId(
235
+ config: { DEFAULT_MODEL?: unknown } | undefined,
236
+ ): string {
237
+ const raw = config?.DEFAULT_MODEL;
238
+ const override = typeof raw === 'string' ? raw.trim() : '';
239
+ return override.length > 0 ? override : DEFAULT_MODEL_ID;
240
+ }
241
+
242
+ const MODEL_INPUT_CAPS: Record<string, ModelInputCapabilities> = {
243
+ 'openai/gpt-5.4-nano': {
244
+ image: true,
245
+ file: true,
246
+ audio: false,
247
+ video: false,
248
+ },
249
+ 'google/gemini-3.1-flash-lite': {
250
+ image: true,
251
+ file: true,
252
+ audio: false,
253
+ video: false,
254
+ },
255
+ 'z-ai/glm-5.2': { image: false, file: false, audio: false, video: false },
256
+ 'moonshotai/kimi-k2.5': {
257
+ image: true,
258
+ file: false,
259
+ audio: false,
260
+ video: false,
261
+ },
262
+ 'openai/gpt-5.6-luna': {
263
+ image: true,
264
+ file: true,
265
+ audio: false,
266
+ video: false,
267
+ },
268
+ 'google/gemini-3.5-flash': {
269
+ image: true,
270
+ file: true,
271
+ audio: false,
272
+ video: false,
273
+ },
274
+ 'anthropic/claude-sonnet-5': {
275
+ image: true,
276
+ file: true,
277
+ audio: false,
278
+ video: false,
279
+ },
280
+ 'moonshotai/kimi-k3': {
281
+ image: true,
282
+ file: false,
283
+ audio: false,
284
+ video: false,
285
+ },
286
+ 'anthropic/claude-opus-4.8': {
287
+ image: true,
288
+ file: true,
289
+ audio: false,
290
+ video: false,
291
+ },
292
+ 'openai/gpt-5.6-sol': { image: true, file: true, audio: false, video: false },
293
+ };
294
+
295
+ const TEXT_ONLY_CAPS: ModelInputCapabilities = {
296
+ image: false,
297
+ file: false,
298
+ audio: false,
299
+ video: false,
300
+ };
301
+
302
+ /** Native input capabilities for a model id; text-only for unknown ids. */
303
+ export function getModelCapabilities(modelId: string): ModelInputCapabilities {
304
+ return MODEL_INPUT_CAPS[modelId] ?? TEXT_ONLY_CAPS;
305
+ }
306
+
307
+ /** The display markup applied to raw provider prices when none is configured. */
308
+ export const DEFAULT_MODEL_PRICE_MARKUP = 1.6;
309
+
310
+ /**
311
+ * The display markup: `MODEL_PRICE_MARKUP` when it is a positive finite number
312
+ * (validated config or the raw Worker `env` string), else the default.
313
+ */
314
+ export function getModelPriceMarkup(
315
+ config: { MODEL_PRICE_MARKUP?: unknown } | undefined,
316
+ ): number {
317
+ const markup = Number(config?.MODEL_PRICE_MARKUP);
318
+ return Number.isFinite(markup) && markup > 0
319
+ ? markup
320
+ : DEFAULT_MODEL_PRICE_MARKUP;
321
+ }
322
+
323
+ function roundPrice(value: number): number {
324
+ return Math.round(value * 10_000) / 10_000;
325
+ }
326
+
327
+ /**
328
+ * Build the public listing from the catalog, applying `markup` to each price
329
+ * (live price when available in `livePrices`, else the entry's baseline).
330
+ * Pure — same as the Node runtime's `buildModelListing`; the caller supplies
331
+ * the live price map, markup, and default id.
332
+ */
333
+ export function buildModelListing(params: {
334
+ livePrices?: ReadonlyMap<string, ModelPrice>;
335
+ markup: number;
336
+ defaultModelId: string;
337
+ }): ModelListing {
338
+ const { livePrices, markup, defaultModelId } = params;
339
+
340
+ const models: ModelListItem[] = MODEL_CATALOG.map((entry): ModelListItem => {
341
+ const raw = livePrices?.get(entry.id) ?? entry.baselinePrice;
342
+ const display = TIER_DISPLAY[entry.tier];
343
+ return {
344
+ id: entry.id,
345
+ label: entry.label,
346
+ family: entry.family,
347
+ tier: entry.tier,
348
+ costLabel: display.costLabel,
349
+ badge: display.badge,
350
+ blurb: entry.blurb,
351
+ vision: entry.vision,
352
+ pricing: {
353
+ inputPerMillion: roundPrice(raw.inputPerMillion * markup),
354
+ outputPerMillion: roundPrice(raw.outputPerMillion * markup),
355
+ currency: 'USD',
356
+ unit: 'per_million_tokens',
357
+ },
358
+ isDefault: entry.id === defaultModelId,
359
+ };
360
+ }).sort(
361
+ (a, b) =>
362
+ TIER_DISPLAY[a.tier].order - TIER_DISPLAY[b.tier].order ||
363
+ a.pricing.inputPerMillion - b.pricing.inputPerMillion,
364
+ );
365
+
366
+ return { models, default: defaultModelId };
367
+ }
368
+
369
+ /**
370
+ * The `GET /models` payload without a network call: the curated catalog priced
371
+ * from `livePrices` (else baselines) with the configured markup and the
372
+ * deployment default flagged. `env` is the Worker `env` (or validated config)
373
+ * — only `DEFAULT_MODEL` and `MODEL_PRICE_MARKUP` are read.
374
+ */
375
+ export function listModelCatalog(
376
+ env: { DEFAULT_MODEL?: unknown; MODEL_PRICE_MARKUP?: unknown } | undefined,
377
+ livePrices?: ReadonlyMap<string, ModelPrice>,
378
+ ): ModelListing {
379
+ return buildModelListing({
380
+ livePrices,
381
+ markup: getModelPriceMarkup(env),
382
+ defaultModelId: getDefaultModelId(env),
383
+ });
384
+ }
385
+
386
+ /**
387
+ * The `GET /models` payload with live OpenRouter prices (cached per isolate
388
+ * for an hour; baseline prices when the fetch fails) — what the Node runtime's
389
+ * `ModelsService.listModels` returns.
390
+ */
391
+ export async function listModels(
392
+ env:
393
+ | {
394
+ DEFAULT_MODEL?: unknown;
395
+ MODEL_PRICE_MARKUP?: unknown;
396
+ OPEN_ROUTER_API_KEY?: unknown;
397
+ }
398
+ | undefined,
399
+ ): Promise<ModelListing> {
400
+ const apiKey =
401
+ typeof env?.OPEN_ROUTER_API_KEY === 'string' && env.OPEN_ROUTER_API_KEY
402
+ ? env.OPEN_ROUTER_API_KEY
403
+ : undefined;
404
+ const livePrices = await fetchOpenRouterPrices({ apiKey });
405
+ return listModelCatalog(env, livePrices);
406
+ }
407
+
408
+ // ── Role → model map (OpenRouter only) ──────────────────────────────────────
409
+
410
+ /**
411
+ * Every role the provider maps to a model id. The plugin-API exposes a lean
412
+ * `ModelRole` (`'main' | 'subagent' | 'utility' | string`); the adapter falls
413
+ * back to `subagent` for unrecognized strings.
414
+ */
415
+ export type ProviderModelRole =
416
+ | 'main'
417
+ | 'skills'
418
+ | 'subagent'
419
+ | 'vision'
420
+ | 'guard'
421
+ | 'routing'
422
+ | 'session-title'
423
+ | 'embedding'
424
+ | 'custom_medium'
425
+ | 'custom_low';
426
+
427
+ /** OpenRouter role map. `main` is resolved through `getDefaultModelId`. */
428
+ export const OPENROUTER_MODEL_MAP: Record<ProviderModelRole, string> = {
429
+ main: DEFAULT_MODEL_ID,
430
+ skills: 'openai/gpt-5.6-luna',
431
+ subagent: 'openai/gpt-5.6-luna',
432
+ vision: 'google/gemini-3.1-flash-lite',
433
+ guard: 'meta-llama/llama-3.1-8b-instruct',
434
+ routing: 'openai/gpt-oss-120b',
435
+ custom_low: 'openai/gpt-oss-120b',
436
+ custom_medium: 'google/gemini-3.1-flash-lite',
437
+ 'session-title': 'meta-llama/llama-3.1-8b-instruct',
438
+ embedding: 'text-embedding-3-small',
439
+ };
440
+
441
+ /** OpenRouter fallback models for the 'main' role (via `models`, sorted by latency). */
442
+ export const OPENROUTER_MAIN_FALLBACKS: readonly string[] = [
443
+ 'qwen/qwen3-235b-a22b-thinking-2507',
444
+ 'google/gemini-2.5-flash-lite',
445
+ ];
446
+
447
+ export const OPENROUTER_BASE_URL = 'https://openrouter.ai/api/v1';
448
+
449
+ // ── Nebius (self-hosted deployments) ────────────────────────────────────────
450
+
451
+ export const NEBIUS_BASE_URL = 'https://api.tokenfactory.nebius.com/v1/';
452
+
453
+ /**
454
+ * Nebius role map — a fixed map (mirrors the Node provider): only OpenRouter
455
+ * participates in `DEFAULT_MODEL` selection, so self-hosted deployments are
456
+ * unaffected by catalog changes.
457
+ */
458
+ export const NEBIUS_MODEL_MAP: Record<ProviderModelRole, string> = {
459
+ main: 'Qwen/Qwen3-235B-A22B-Thinking-2507',
460
+ skills: 'Qwen/Qwen3-235B-A22B-Thinking-2507',
461
+ subagent: 'Qwen/Qwen3-235B-A22B-Instruct-2507',
462
+ vision: 'Qwen/Qwen2.5-VL-72B-Instruct',
463
+ guard: 'meta-llama/Llama-Guard-3-8B',
464
+ routing: 'Qwen/Qwen3-30B-A3B-Instruct-2507',
465
+ custom_low: '',
466
+ custom_medium: '',
467
+ 'session-title': 'meta-llama/Meta-Llama-3.1-8B-Instruct',
468
+ embedding: 'Qwen/Qwen3-Embedding-8B',
469
+ };
470
+
471
+ /** Provider families the platform adapter can be built for. */
472
+ export type LlmProvider = 'openrouter' | 'nebius';
473
+
474
+ /** The slice of validated config the adapter reads. */
475
+ export interface LlmEnv {
476
+ OPEN_ROUTER_API_KEY: string;
477
+ DEFAULT_MODEL?: string;
478
+ MAIN_REASONING_EFFORT?: 'low' | 'medium' | 'high';
479
+ ORACLE_NAME?: string;
480
+ /** Platform provider — `openrouter` (default) or `nebius`. */
481
+ LLM_PROVIDER?: LlmProvider;
482
+ /** Required when `LLM_PROVIDER=nebius`. */
483
+ NEBIUS_API_KEY?: string;
484
+ }
485
+
486
+ /**
487
+ * Narrow the raw Worker `env` bindings to the adapter's env slice. Lets the
488
+ * host build a provider-selected adapter from `OracleWorkerEnv` directly
489
+ * (`LLM_PROVIDER` / `NEBIUS_API_KEY` are not part of the validated base
490
+ * schema); non-string / empty values read as unset.
491
+ */
492
+ export function llmEnvFromWorkerEnv(env: Record<string, unknown>): LlmEnv {
493
+ const str = (key: string): string | undefined => {
494
+ const value = env[key];
495
+ return typeof value === 'string' && value.length > 0 ? value : undefined;
496
+ };
497
+ const effort = str('MAIN_REASONING_EFFORT');
498
+ return {
499
+ OPEN_ROUTER_API_KEY: str('OPEN_ROUTER_API_KEY') ?? '',
500
+ DEFAULT_MODEL: str('DEFAULT_MODEL'),
501
+ MAIN_REASONING_EFFORT:
502
+ effort === 'low' || effort === 'medium' || effort === 'high'
503
+ ? effort
504
+ : undefined,
505
+ ORACLE_NAME: str('ORACLE_NAME'),
506
+ LLM_PROVIDER: str('LLM_PROVIDER') === 'nebius' ? 'nebius' : 'openrouter',
507
+ NEBIUS_API_KEY: str('NEBIUS_API_KEY'),
508
+ };
509
+ }
510
+
511
+ /**
512
+ * Adapter surface — the ambient `llm` plus the role/model resolvers. The name
513
+ * predates provider selection; a Nebius-backed adapter satisfies the same
514
+ * interface (`providerConfig` then carries the Nebius base URL/key).
515
+ */
516
+ export interface OpenRouterLlmAdapter extends LlmAdapter {
517
+ /** Model id a role resolves to (honours `DEFAULT_MODEL` for `main`). */
518
+ modelForRole(role: ModelRole): string;
519
+ /** The deployment's default `main` model id. */
520
+ readonly defaultModelId: string;
521
+ /** Provider config for raw fetch calls the host may make itself. */
522
+ readonly providerConfig: {
523
+ baseURL: string;
524
+ apiKey: string;
525
+ headers: Record<string, string>;
526
+ };
527
+ }
528
+
529
+ function asRecord(value: unknown): Record<string, unknown> {
530
+ return value && typeof value === 'object' && !Array.isArray(value)
531
+ ? (value as Record<string, unknown>)
532
+ : {};
533
+ }
534
+
535
+ /**
536
+ * Build the platform LLM adapter from validated config, selecting the
537
+ * provider by `LLM_PROVIDER` (`openrouter` default | `nebius`). `ChatOpenAI`
538
+ * speaks to both OpenAI-compatible endpoints over `fetch`, so it runs
539
+ * unchanged on Workers. Every model is created with `maxRetries: 2`.
540
+ *
541
+ * OpenRouter role handling mirrors the Node provider:
542
+ * - `main` → `DEFAULT_MODEL` (or the catalog default), with the OpenRouter
543
+ * `models` fallback array and `provider.sort: 'latency'`; reasoning
544
+ * effort from `MAIN_REASONING_EFFORT`.
545
+ * - every other role → the fixed map; unknown roles fall back to `subagent`.
546
+ * - `params.model` always wins over the role default.
547
+ *
548
+ * Reasoning goes through `modelKwargs` (spread verbatim into the request
549
+ * body), never ChatOpenAI's top-level `reasoning` field: that field is
550
+ * dropped for OpenRouter-prefixed ids, and a top-level `reasoning.summary`
551
+ * silently reroutes the call to the Responses API.
552
+ *
553
+ * Nebius mirrors the Node provider's second branch: the fixed
554
+ * {@link NEBIUS_MODEL_MAP} (no `DEFAULT_MODEL` participation, no fallback
555
+ * array, no reasoning kwargs), `temperature` 0 for `guard` / 0.8 otherwise.
556
+ */
557
+ export function createLlmAdapter(
558
+ env: LlmEnv,
559
+ logger: Logger = NOOP_LOGGER,
560
+ ): OpenRouterLlmAdapter {
561
+ if (env.LLM_PROVIDER === 'nebius') return createNebiusAdapter(env, logger);
562
+ const defaultModelId = getDefaultModelId(env);
563
+ const mainEffort = env.MAIN_REASONING_EFFORT ?? 'medium';
564
+ const headers: Record<string, string> = {
565
+ 'HTTP-Referer': 'oracle-app.com',
566
+ 'X-Title': env.ORACLE_NAME ?? 'Oracle App',
567
+ };
568
+
569
+ const modelForRole = (role: ModelRole): string => {
570
+ if (role === 'main') return defaultModelId;
571
+ return (
572
+ OPENROUTER_MODEL_MAP[role as ProviderModelRole] ??
573
+ OPENROUTER_MODEL_MAP.subagent
574
+ );
575
+ };
576
+
577
+ const get = (role: ModelRole, params?: ChatOpenAIFields): BaseChatModel => {
578
+ const {
579
+ model: paramsModel,
580
+ reasoning: paramsReasoning,
581
+ modelKwargs: paramsModelKwargs,
582
+ configuration: paramsConfiguration,
583
+ ...rest
584
+ } = params ?? {};
585
+ const model =
586
+ typeof paramsModel === 'string' && paramsModel.length > 0
587
+ ? paramsModel
588
+ : modelForRole(role);
589
+
590
+ const fallbackKwargs: Record<string, unknown> =
591
+ role === 'main'
592
+ ? {
593
+ models: [...OPENROUTER_MAIN_FALLBACKS],
594
+ provider: { sort: 'latency' },
595
+ }
596
+ : {};
597
+
598
+ const reasoningKwargs = {
599
+ effort: role === 'main' ? mainEffort : 'medium',
600
+ summary: 'auto',
601
+ ...asRecord(paramsReasoning),
602
+ };
603
+
604
+ logger.debug?.(
605
+ `[llm] creating model — role=${String(role)}, model=${model}, reasoning=${JSON.stringify(reasoningKwargs)}`,
606
+ );
607
+
608
+ const configuration = asRecord(paramsConfiguration);
609
+ const overrideHeaders: Record<string, string> = {};
610
+ for (const [key, value] of Object.entries(
611
+ asRecord(configuration.defaultHeaders),
612
+ )) {
613
+ if (typeof value === 'string') overrideHeaders[key] = value;
614
+ }
615
+ return new ChatOpenAI({
616
+ temperature: 0.8,
617
+ maxRetries: 2,
618
+ apiKey: env.OPEN_ROUTER_API_KEY,
619
+ ...rest,
620
+ model,
621
+ __includeRawResponse: true,
622
+ configuration: {
623
+ baseURL: OPENROUTER_BASE_URL,
624
+ ...configuration,
625
+ defaultHeaders: { ...headers, ...overrideHeaders },
626
+ },
627
+ modelKwargs: {
628
+ require_parameters: true,
629
+ include_reasoning: true,
630
+ ...fallbackKwargs,
631
+ reasoning: reasoningKwargs,
632
+ ...asRecord(paramsModelKwargs),
633
+ },
634
+ });
635
+ };
636
+
637
+ return {
638
+ get,
639
+ modelForRole,
640
+ defaultModelId,
641
+ providerConfig: {
642
+ baseURL: OPENROUTER_BASE_URL,
643
+ apiKey: env.OPEN_ROUTER_API_KEY,
644
+ headers,
645
+ },
646
+ };
647
+ }
648
+
649
+ /**
650
+ * Nebius Token Factory adapter — the Node provider's `nebius` branch:
651
+ * a plain OpenAI-compatible endpoint, the fixed role map, `temperature` 0
652
+ * for the `guard` role and 0.8 otherwise (callers override via `params`).
653
+ * No OpenRouter wire extras (`require_parameters`, fallback `models`,
654
+ * `reasoning` kwargs) — those break non-OpenRouter APIs.
655
+ */
656
+ function createNebiusAdapter(
657
+ env: LlmEnv,
658
+ logger: Logger,
659
+ ): OpenRouterLlmAdapter {
660
+ const apiKey = env.NEBIUS_API_KEY ?? '';
661
+ if (!apiKey) {
662
+ logger.warn(
663
+ '[llm] LLM_PROVIDER=nebius but NEBIUS_API_KEY is not set — model calls will fail',
664
+ );
665
+ }
666
+
667
+ const modelForRole = (role: ModelRole): string =>
668
+ NEBIUS_MODEL_MAP[role as ProviderModelRole] ?? NEBIUS_MODEL_MAP.subagent;
669
+
670
+ const get = (role: ModelRole, params?: ChatOpenAIFields): BaseChatModel => {
671
+ const {
672
+ model: paramsModel,
673
+ configuration: paramsConfiguration,
674
+ ...rest
675
+ } = params ?? {};
676
+ const model =
677
+ typeof paramsModel === 'string' && paramsModel.length > 0
678
+ ? paramsModel
679
+ : modelForRole(role);
680
+ logger.debug?.(
681
+ `[llm] creating model — provider=nebius, role=${String(role)}, model=${model}`,
682
+ );
683
+ return new ChatOpenAI({
684
+ // Cold for classification (guard), platform default otherwise.
685
+ temperature: role === 'guard' ? 0 : 0.8,
686
+ maxRetries: 2,
687
+ apiKey,
688
+ __includeRawResponse: true,
689
+ ...rest,
690
+ model,
691
+ configuration: {
692
+ baseURL: NEBIUS_BASE_URL,
693
+ ...asRecord(paramsConfiguration),
694
+ },
695
+ });
696
+ };
697
+
698
+ return {
699
+ get,
700
+ modelForRole,
701
+ defaultModelId: NEBIUS_MODEL_MAP.main,
702
+ providerConfig: { baseURL: NEBIUS_BASE_URL, apiKey, headers: {} },
703
+ };
704
+ }
705
+
706
+ // ── LangSmith tracing ───────────────────────────────────────────────────────
707
+
708
+ /**
709
+ * Per-turn LangSmith tracing decision — the Workers port of the Node
710
+ * runtime's `modules/messages/langsmith-tracing.ts`.
711
+ *
712
+ * One deliberate difference: on Node, `LANGSMITH_TRACING=true` relies on
713
+ * LangChain's env-driven auto-attached tracer. Workers has no `process.env`,
714
+ * so the callback manager never auto-attaches anything — here BOTH modes
715
+ * attach an explicit `LangChainTracer` whose `Client` is constructed from the
716
+ * Worker env (`LANGSMITH_API_KEY` / `LANGSMITH_ENDPOINT`) and memoised per
717
+ * isolate, so every tracer shares one batched upload queue:
718
+ *
719
+ * - **Global** — `LANGSMITH_TRACING === 'true'`: every turn is traced.
720
+ * - **Selective** — `LANGSMITH_TRACED_DIDS` allowlist (`*` = everyone):
721
+ * only turns whose UCAN-authenticated user DID matches are traced.
722
+ *
723
+ * Fail-closed: no API key ⇒ no tracer, nothing leaves the isolate. The
724
+ * `metadata` is returned unconditionally — attached to the run config, it is
725
+ * inert without a tracer and filterable per user in LangSmith with one.
726
+ */
727
+ export interface LangsmithTracingEnv {
728
+ /** Raw `LANGSMITH_TRACING`. `'true'` (LangChain's exact check) = trace everyone. */
729
+ tracing?: string;
730
+ /** Raw `LANGSMITH_API_KEY`. Nothing is traced without it. */
731
+ apiKey?: string;
732
+ /** Raw `LANGSMITH_PROJECT`. Forwarded to the tracer when set. */
733
+ project?: string;
734
+ /** Raw `LANGSMITH_ENDPOINT` (self-hosted / EU). */
735
+ endpoint?: string;
736
+ /** Raw `LANGSMITH_TRACED_DIDS` comma-separated allowlist (`*` = everyone). */
737
+ tracedDids?: string;
738
+ }
739
+
740
+ /** Narrow the raw Worker `env` bindings to the tracing env slice. */
741
+ export function langsmithEnvFromWorkerEnv(
742
+ env: Record<string, unknown>,
743
+ ): LangsmithTracingEnv {
744
+ const str = (key: string): string | undefined => {
745
+ const value = env[key];
746
+ return typeof value === 'string' && value.length > 0 ? value : undefined;
747
+ };
748
+ return {
749
+ tracing: str('LANGSMITH_TRACING'),
750
+ apiKey: str('LANGSMITH_API_KEY'),
751
+ project: str('LANGSMITH_PROJECT'),
752
+ endpoint: str('LANGSMITH_ENDPOINT'),
753
+ tracedDids: str('LANGSMITH_TRACED_DIDS'),
754
+ };
755
+ }
756
+
757
+ export interface ResolveLangsmithTracingArgs {
758
+ /** The turn's user DID — from the authenticated payload, never client-set metadata. */
759
+ userDid: string;
760
+ /** Ingress surface, so latency can be sliced per client in LangSmith. */
761
+ client: 'portal' | 'matrix' | 'slack';
762
+ env: LangsmithTracingEnv;
763
+ }
764
+
765
+ export interface LangsmithTracingDecision {
766
+ /**
767
+ * Attached to the run config unconditionally — LangGraph forwards it to
768
+ * the tracer when one is attached, and it is inert otherwise.
769
+ */
770
+ metadata: Record<string, string | number>;
771
+ /** Present only when this turn should be traced. */
772
+ callbacks?: [LangChainTracer];
773
+ }
774
+
775
+ /**
776
+ * One `langsmith` Client per (apiKey, endpoint) per isolate. Tracer instances
777
+ * are per-turn and cheap; the client owns the batched upload queue, so
778
+ * sharing it is what keeps tracing off the hot path.
779
+ */
780
+ const langsmithClients = new Map<string, Client>();
781
+
782
+ function langsmithClientFor(apiKey: string, endpoint?: string): Client {
783
+ const key = `${apiKey}\n${endpoint ?? ''}`;
784
+ let client = langsmithClients.get(key);
785
+ if (!client) {
786
+ client = new Client({ apiKey, ...(endpoint && { apiUrl: endpoint }) });
787
+ langsmithClients.set(key, client);
788
+ }
789
+ return client;
790
+ }
791
+
792
+ /** Single-slot memo for the parsed allowlist (raw string is deploy-stable). */
793
+ let tracedDidsMemoRaw: string | undefined;
794
+ let tracedDidsMemoSet: ReadonlySet<string> = new Set();
795
+
796
+ function parseTracedDids(raw: string | undefined): ReadonlySet<string> {
797
+ if (raw === tracedDidsMemoRaw) return tracedDidsMemoSet;
798
+ tracedDidsMemoRaw = raw;
799
+ tracedDidsMemoSet = new Set(
800
+ (raw ?? '')
801
+ .split(',')
802
+ .map((entry) => entry.trim())
803
+ .filter((entry) => entry.length > 0),
804
+ );
805
+ return tracedDidsMemoSet;
806
+ }
807
+
808
+ export function resolveLangsmithTracing(
809
+ args: ResolveLangsmithTracingArgs,
810
+ ): LangsmithTracingDecision {
811
+ const { userDid, client, env } = args;
812
+
813
+ // `user_id` duplicates the DID under the key LangSmith examples use for
814
+ // user-scoped filtering; `user_did` is the self-describing key.
815
+ const metadata: Record<string, string | number> = {
816
+ user_did: userDid,
817
+ user_id: userDid,
818
+ client,
819
+ };
820
+
821
+ // Fail-closed: a tracer that cannot authenticate is never attached.
822
+ if (!env.apiKey) {
823
+ return { metadata };
824
+ }
825
+
826
+ const globalOn = env.tracing?.trim() === 'true';
827
+ if (!globalOn) {
828
+ const allowlist = parseTracedDids(env.tracedDids);
829
+ const traced = allowlist.has('*') || allowlist.has(userDid);
830
+ if (!traced) return { metadata };
831
+ }
832
+
833
+ return {
834
+ metadata,
835
+ callbacks: [
836
+ new LangChainTracer({
837
+ client: langsmithClientFor(env.apiKey, env.endpoint),
838
+ ...(env.project && { projectName: env.project }),
839
+ }),
840
+ ],
841
+ };
842
+ }