@ixo/oracle-runtime-workers 0.13.0 → 0.15.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 (205) hide show
  1. package/README.md +32 -10
  2. package/package.json +13 -8
  3. package/src/artifacts/config.ts +158 -0
  4. package/src/artifacts/crypto.ts +94 -0
  5. package/src/artifacts/routes.ts +73 -0
  6. package/src/artifacts/store.ts +238 -0
  7. package/src/artifacts/sweep.ts +200 -0
  8. package/src/artifacts/tool.ts +103 -0
  9. package/src/artifacts/viewer-page.ts +614 -0
  10. package/src/attachments/download.ts +168 -45
  11. package/src/attachments/index.ts +9 -0
  12. package/src/attachments/magic.ts +33 -0
  13. package/src/attachments/pipeline.ts +105 -49
  14. package/src/attachments/sandbox-archive.ts +9 -8
  15. package/src/attachments/view-cache.ts +142 -0
  16. package/src/attachments/view.ts +53 -12
  17. package/src/channels/auth.ts +267 -0
  18. package/src/channels/contract.ts +137 -0
  19. package/src/channels/turns.ts +270 -0
  20. package/src/core/budgeted-llm.ts +223 -0
  21. package/src/core/capability-router.ts +273 -0
  22. package/src/core/context-budget.ts +97 -10
  23. package/src/core/env.ts +111 -0
  24. package/src/core/execution-profile.ts +17 -0
  25. package/src/core/index.ts +267 -9
  26. package/src/core/llm.ts +30 -9
  27. package/src/core/main-agent-types.ts +41 -1
  28. package/src/core/main-agent.ts +367 -65
  29. package/src/core/manifest.ts +34 -0
  30. package/src/core/meta-tools.ts +128 -18
  31. package/src/core/middlewares/capability-gate.ts +130 -12
  32. package/src/core/middlewares/context-guard.ts +55 -24
  33. package/src/core/middlewares/index.ts +20 -2
  34. package/src/core/middlewares/page-context.ts +46 -2
  35. package/src/core/middlewares/return-direct-first.ts +47 -0
  36. package/src/core/middlewares/summarization.ts +152 -10
  37. package/src/core/middlewares/tool-execution.ts +323 -0
  38. package/src/core/middlewares/tool-repetition-guard.ts +202 -35
  39. package/src/core/middlewares/turn-boundary.ts +162 -0
  40. package/src/core/openrouter-pricing.ts +155 -22
  41. package/src/core/prompt-composer.ts +112 -7
  42. package/src/core/registries.ts +253 -48
  43. package/src/core/request-admission.ts +159 -0
  44. package/src/core/runtime-context.ts +107 -8
  45. package/src/core/state.ts +1 -1
  46. package/src/core/sub-agent-fallback.ts +21 -2
  47. package/src/core/subagent-as-tool.ts +78 -43
  48. package/src/core/tool-access.ts +189 -0
  49. package/src/core/tool-scheduler.ts +125 -0
  50. package/src/core/turn-budget.ts +205 -0
  51. package/src/core/turn-time-note.ts +43 -0
  52. package/src/core/user-kv.ts +446 -0
  53. package/src/core/wrap-plugin-tool.ts +20 -0
  54. package/src/delivery/plan.ts +278 -0
  55. package/src/delivery/profile.ts +161 -0
  56. package/src/delivery/prompt.ts +27 -0
  57. package/src/delivery/schema.ts +61 -0
  58. package/src/delivery/shaper.ts +389 -0
  59. package/src/delivery/types.ts +84 -0
  60. package/src/do/ambient.ts +51 -15
  61. package/src/do/contracts.ts +170 -6
  62. package/src/do/idle-eviction.ts +38 -0
  63. package/src/do/matrix-turn-ledger.ts +24 -5
  64. package/src/do/result-store-test-do.ts +137 -3
  65. package/src/do/result-store.ts +239 -58
  66. package/src/do/room-mirror.ts +29 -0
  67. package/src/do/run-buffer.ts +64 -9
  68. package/src/do/run-coordinator.ts +451 -76
  69. package/src/do/run-request.ts +2 -0
  70. package/src/do/run-store-test-do.ts +301 -1
  71. package/src/do/run-store.ts +385 -43
  72. package/src/do/secrets-adapter.ts +22 -3
  73. package/src/do/sse-stream.ts +60 -9
  74. package/src/do/transcript.ts +48 -5
  75. package/src/do/turn-body.ts +105 -5
  76. package/src/do/ucan-service.ts +204 -34
  77. package/src/do/user-oracle-do.ts +1883 -264
  78. package/src/feedback/config.ts +127 -0
  79. package/src/feedback/contract.ts +148 -0
  80. package/src/feedback/linear-sink.ts +285 -0
  81. package/src/feedback/privacy.ts +150 -0
  82. package/src/feedback/reservation.ts +298 -0
  83. package/src/feedback/submit.ts +247 -0
  84. package/src/index.ts +138 -6
  85. package/src/llm/byo-adapter.ts +27 -6
  86. package/src/llm/byo-catalog.ts +13 -0
  87. package/src/llm/byo-model-fallback.ts +248 -0
  88. package/src/llm/byo-service.ts +45 -8
  89. package/src/llm/provider-error.ts +139 -2
  90. package/src/matrix/gateway-do.ts +504 -108
  91. package/src/matrix/group-chat.ts +66 -23
  92. package/src/matrix/inbox-store.ts +16 -1
  93. package/src/matrix/ingest.ts +79 -14
  94. package/src/matrix/media-deadline.ts +130 -0
  95. package/src/matrix/replay-format.ts +5 -3
  96. package/src/matrix/reply-parts.ts +92 -0
  97. package/src/matrix/room-alias.ts +96 -0
  98. package/src/matrix/user-homeserver.ts +166 -7
  99. package/src/owner-store/ixo-vfs-store.ts +170 -43
  100. package/src/owner-store/migrating-store.ts +31 -15
  101. package/src/owner-store/types.ts +18 -3
  102. package/src/plugin-api/index.ts +43 -2
  103. package/src/plugin-api/oracle-plugin.ts +35 -0
  104. package/src/plugin-api/request-admission.ts +41 -0
  105. package/src/plugin-api/tool-helper.ts +25 -1
  106. package/src/plugin-api/tool-plane.ts +92 -0
  107. package/src/plugin-api/types.ts +194 -6
  108. package/src/plugins/agui/agui.plugin.ts +31 -16
  109. package/src/plugins/composio/composio-tools.ts +57 -6
  110. package/src/plugins/composio/composio-ucan.ts +3 -2
  111. package/src/plugins/composio/composio.plugin.ts +2 -0
  112. package/src/plugins/delegated-capabilities.ts +25 -0
  113. package/src/plugins/editor/content-session.ts +173 -83
  114. package/src/plugins/editor/content-tools.ts +76 -17
  115. package/src/plugins/editor/editor-mx.ts +28 -9
  116. package/src/plugins/editor/editor.plugin.ts +3 -1
  117. package/src/plugins/editor/failures.ts +28 -7
  118. package/src/plugins/editor/prompts.ts +2 -1
  119. package/src/plugins/editor/provider.ts +553 -40
  120. package/src/plugins/editor/standalone-editor-tool.ts +61 -18
  121. package/src/plugins/firecrawl/firecrawl-tools.ts +13 -5
  122. package/src/plugins/flows/action-metadata.ts +111 -11
  123. package/src/plugins/flows/actions.ts +18 -0
  124. package/src/plugins/flows/decision-context.ts +31 -0
  125. package/src/plugins/flows/edit.ts +223 -52
  126. package/src/plugins/flows/errors.ts +22 -1
  127. package/src/plugins/flows/flow-doc.ts +92 -9
  128. package/src/plugins/flows/flows.plugin.ts +11 -5
  129. package/src/plugins/flows/forms.ts +34 -0
  130. package/src/plugins/flows/index.ts +2 -0
  131. package/src/plugins/flows/input-policy.ts +140 -0
  132. package/src/plugins/flows/prompts.ts +14 -11
  133. package/src/plugins/flows/read.ts +49 -9
  134. package/src/plugins/flows/ref-syntax.ts +129 -0
  135. package/src/plugins/flows/test-homeserver.ts +245 -0
  136. package/src/plugins/flows/tools/authoring.ts +216 -112
  137. package/src/plugins/flows/tools/discovery.ts +4 -1
  138. package/src/plugins/flows/tools/forms.ts +21 -2
  139. package/src/plugins/flows/tools/settings.ts +34 -13
  140. package/src/plugins/flows/translator.ts +13 -10
  141. package/src/plugins/flows/types.ts +167 -42
  142. package/src/plugins/index.ts +11 -1
  143. package/src/plugins/ixo-transaction/chain-ids.ts +43 -0
  144. package/src/plugins/ixo-transaction/index.ts +2 -0
  145. package/src/plugins/ixo-transaction/ixo-transaction.plugin.ts +119 -0
  146. package/src/plugins/ixo-transaction/receipts.ts +99 -0
  147. package/src/plugins/ixo-transaction/tools.ts +329 -0
  148. package/src/plugins/ixo-transaction/wallet-signing.ts +202 -0
  149. package/src/plugins/mcp-tool-adapter.ts +23 -2
  150. package/src/plugins/memory/memory-tools.ts +7 -2
  151. package/src/plugins/memory/memory-ucan.ts +3 -2
  152. package/src/plugins/memory/memory.plugin.ts +2 -0
  153. package/src/plugins/pod-creator/blueprint-store.ts +163 -0
  154. package/src/plugins/pod-creator/blueprint-types.ts +61 -0
  155. package/src/plugins/pod-creator/bounded-map.ts +87 -0
  156. package/src/plugins/pod-creator/capsule-content-client.ts +285 -0
  157. package/src/plugins/pod-creator/chain-gateway.ts +110 -0
  158. package/src/plugins/pod-creator/config.ts +85 -0
  159. package/src/plugins/pod-creator/create-session-store.ts +180 -0
  160. package/src/plugins/pod-creator/create-tools.ts +538 -0
  161. package/src/plugins/pod-creator/design-pod-roles.ts +111 -0
  162. package/src/plugins/pod-creator/index.ts +65 -0
  163. package/src/plugins/pod-creator/orchestration-tools.ts +172 -0
  164. package/src/plugins/pod-creator/pod-creator.plugin.ts +190 -0
  165. package/src/plugins/pod-creator/stage.ts +201 -0
  166. package/src/plugins/pod-creator/sub-agents.ts +189 -0
  167. package/src/plugins/portal/action-log.ts +21 -1
  168. package/src/plugins/portal/declared-tools.ts +90 -0
  169. package/src/plugins/portal/portal.plugin.ts +42 -16
  170. package/src/plugins/sandbox/sandbox-bridge.ts +79 -10
  171. package/src/plugins/sandbox/sandbox-mcp.ts +3 -2
  172. package/src/plugins/sandbox/sandbox-write-blob.ts +11 -1
  173. package/src/plugins/sandbox/sandbox.plugin.ts +8 -16
  174. package/src/plugins/tasks/manifest.ts +1 -1
  175. package/src/plugins/tasks/middleware.ts +20 -7
  176. package/src/plugins/tasks/tasks-tools.ts +30 -3
  177. package/src/plugins/tasks/tasks.plugin.ts +64 -11
  178. package/src/plugins/user-preferences/schema.ts +12 -1
  179. package/src/plugins/user-preferences/user-preferences-store.ts +38 -13
  180. package/src/plugins/user-preferences/user-preferences-tool.ts +3 -2
  181. package/src/plugins/vfs/vfs-client.ts +250 -71
  182. package/src/plugins/vfs/vfs-content.ts +30 -11
  183. package/src/plugins/vfs/vfs-errors.ts +22 -0
  184. package/src/plugins/vfs/vfs-sandbox-tools.ts +12 -5
  185. package/src/plugins/vfs/vfs-tools.ts +60 -20
  186. package/src/realtime/frontend-call-registry.ts +228 -81
  187. package/src/realtime/realtime-endpoint.ts +215 -44
  188. package/src/realtime/session-socket-hub.ts +47 -2
  189. package/src/secrets/secrets-service.ts +9 -3
  190. package/src/shell/app.ts +529 -82
  191. package/src/shell/auth.ts +131 -37
  192. package/src/shell/turn-body-cap.ts +11 -13
  193. package/src/sqlite/blob-compactor.ts +61 -28
  194. package/src/sqlite/database.ts +156 -90
  195. package/src/sqlite/do-vfs.ts +416 -129
  196. package/src/sqlite/page-tier.ts +133 -40
  197. package/src/sqlite/serialization.ts +40 -0
  198. package/src/sqlite/sessions-store.ts +10 -2
  199. package/src/sqlite/sqlite-saver.ts +139 -19
  200. package/src/sqlite/user-kv-store.ts +317 -0
  201. package/src/tasks/schedule.ts +201 -12
  202. package/src/tasks/scheduler.ts +586 -98
  203. package/src/tasks/spec.ts +67 -3
  204. package/src/tasks/store.ts +292 -27
  205. package/src/tasks/topic-deliverables.ts +70 -0
package/src/index.ts CHANGED
@@ -16,6 +16,7 @@
16
16
  import {
17
17
  createRuntimeCore,
18
18
  listModels,
19
+ type DecisionAdapter,
19
20
  type RuntimeCoreOptions,
20
21
  type RuntimeCore,
21
22
  } from './core';
@@ -27,8 +28,17 @@ import {
27
28
  type OracleWorkerHooks,
28
29
  } from './do/user-oracle-do';
29
30
  import { MatrixGatewayDO } from './matrix/gateway-do';
30
- import { createShell, gateway, type PluginRoute } from './shell/app';
31
+ import {
32
+ createShell,
33
+ gateway,
34
+ type ListModelsOptions,
35
+ type PluginRoute,
36
+ } from './shell/app';
31
37
  import type { RouteExclusion } from './shell/auth';
38
+ import { artifactSweepIntervalMs } from './artifacts/config';
39
+ import { sweepExpiredArtifacts } from './artifacts/sweep';
40
+ import { feedbackConfigFromEnv } from './feedback/config';
41
+ import { LinearFeedbackSink } from './feedback/linear-sink';
32
42
 
33
43
  export * from './plugin-api';
34
44
  export * from './plugins';
@@ -46,7 +56,7 @@ export {
46
56
  createUserOracleDO,
47
57
  type OracleWorkerHooks,
48
58
  } from './do/user-oracle-do';
49
- export { createShell } from './shell/app';
59
+ export { createShell, type ListModelsOptions } from './shell/app';
50
60
  export { authenticate } from './shell/auth';
51
61
  export {
52
62
  createRuntimeCore,
@@ -62,6 +72,64 @@ export {
62
72
  type RuntimeCore,
63
73
  type RuntimeCoreOptions,
64
74
  } from './core';
75
+ // --- bounded semantic decisions --------------------------------------------
76
+ // `defineDecision` and the plugin-facing types come with `./plugin-api` above.
77
+ export {
78
+ AmbiguousDecisionProviderError,
79
+ CloudflareJevDecisionAdapter,
80
+ DECISION_PROVIDERS,
81
+ DecisionNotApplicableError,
82
+ DecisionProviderNotFoundError,
83
+ DecisionProviderRegistry,
84
+ DecisionProviderRouter,
85
+ DecisionProviderUnavailableError,
86
+ DecisionRegistry,
87
+ DecisionRuntime,
88
+ HOST_DECISION_PROVIDER_ID,
89
+ JevDecisionError,
90
+ OpenRouterJevDecisionAdapter,
91
+ StaleDecisionSubjectError,
92
+ UNAVAILABLE_DECISION_EVALUATOR,
93
+ WorkersAiJevDecisionAdapter,
94
+ assertFinalDecisionSubjectUnchanged,
95
+ canonicalizeFinalDecisionSubject,
96
+ createDecisionAuthorityReceipt,
97
+ createDecisionExecutionReceipt,
98
+ decisionProviderEnvShape,
99
+ digestFinalDecisionSubject,
100
+ measureDecisionQuestionIsolation,
101
+ resolveDecisionAdapter,
102
+ type CloudflareJevAdapterOptions,
103
+ type DecisionApplicability,
104
+ type DecisionAuthorityReceipt,
105
+ type DecisionCalibrationProvenance,
106
+ type DecisionEvaluator,
107
+ type DecisionExecution,
108
+ type DecisionExecutionReceipt,
109
+ type DecisionJudgmentMethod,
110
+ type DecisionJudgmentProvenance,
111
+ type DecisionLookup,
112
+ type DecisionProviderConfigIssue,
113
+ type DecisionProviderName,
114
+ type DecisionProviderPolicy,
115
+ type DecisionProviderProvenance,
116
+ type DecisionProviderRegistration,
117
+ type DecisionProviderResolution,
118
+ type DecisionProviderSelection,
119
+ type DecisionQuestionIsolationObservation,
120
+ type DecisionQuestionIsolationReport,
121
+ type DecisionRuntimeLogger,
122
+ type FinalDecisionSubject,
123
+ type FinalDecisionSubjectBinding,
124
+ type FinalDecisionSubjectValue,
125
+ type JevProviderName,
126
+ type OpenRouterJevAdapterOptions,
127
+ type RegisteredDecision,
128
+ type ResolveDecisionAdapterOptions,
129
+ type ResolveDecisionAdapterResult,
130
+ type WorkersAiBinding,
131
+ type WorkersAiJevAdapterOptions,
132
+ } from './core';
65
133
  export {
66
134
  NEBIUS_BASE_URL,
67
135
  NEBIUS_MODEL_MAP,
@@ -74,6 +142,12 @@ export {
74
142
  type ResolveLangsmithTracingArgs,
75
143
  } from './core/llm';
76
144
 
145
+ // --- task adapters -----------------------------------------------------------
146
+ export {
147
+ agentWakeFromTaskRecord,
148
+ portableWorkFromTaskRecord,
149
+ } from './tasks/spec';
150
+
77
151
  // --- per-room JWE secrets ---------------------------------------------------
78
152
  export { decryptJwe, encryptJwe, parseJwk, type JWK } from './secrets/jwe';
79
153
  export { decryptWithPin } from './secrets/pin-cipher';
@@ -107,9 +181,16 @@ export {
107
181
  type WorkersByoServiceOptions,
108
182
  } from './llm/byo-service';
109
183
  export { handleByoRequest } from './llm/byo-routes';
184
+ export {
185
+ ByoModelFallbackChatModel,
186
+ ByoModelFallbackState,
187
+ type ByoModelFallbackFields,
188
+ } from './llm/byo-model-fallback';
110
189
  export {
111
190
  BYO_FALLBACK_KIND,
112
191
  buildByoFallbackNotice,
192
+ isModelUnavailableError,
193
+ type ByoFallbackNoticeOptions,
113
194
  type ByoFallbackNoticePayload,
114
195
  type ByoFallbackReason,
115
196
  } from './llm/provider-error';
@@ -145,15 +226,39 @@ export interface CreateOracleWorkerOptions {
145
226
  * the merged manifest is validated like an authored one.
146
227
  */
147
228
  manifestOverrides?: RuntimeCoreOptions['manifestOverrides'];
229
+ /**
230
+ * Single host-supplied Decision adapter, registered as provider `host`.
231
+ * Wins over the `DECISION_PROVIDER` env configuration; without any
232
+ * provider, every `ctx.decisions` call rejects as unavailable. Mutually
233
+ * exclusive with `decisionProviders`.
234
+ */
235
+ decisionAdapter?: DecisionAdapter;
236
+ /**
237
+ * Host-configured Decision providers, registered beside the env-selected
238
+ * one (id = the `DECISION_PROVIDER` value). Adapters are built once per
239
+ * oracle, outside any `env`; a provider that needs a Worker binding comes
240
+ * from `DECISION_PROVIDER`.
241
+ */
242
+ decisionProviders?: RuntimeCoreOptions['decisionProviders'];
243
+ /**
244
+ * Default and exact per-Decision routes over the registered providers. The
245
+ * env-selected provider is the default unless this names another.
246
+ */
247
+ decisionProviderPolicy?: RuntimeCoreOptions['decisionProviderPolicy'];
148
248
  /** Extra host routes mounted on the shell. */
149
249
  routes?: PluginRoute[];
150
250
  /** Host routes exempt from UCAN auth. */
151
251
  authExcludedRoutes?: RouteExclusion[];
152
252
  /**
153
253
  * Overrides for the model catalog exposed on `GET /models`. Default: the
154
- * curated catalog with live OpenRouter prices (`listModels`).
254
+ * curated catalog with live OpenRouter prices (`listModels`). The second
255
+ * argument carries the request's `waitUntil` (for background work such as
256
+ * a price refresh); an override may ignore it.
155
257
  */
156
- listModels?: (env: OracleWorkerEnv) => unknown | Promise<unknown>;
258
+ listModels?: (
259
+ env: OracleWorkerEnv,
260
+ options?: ListModelsOptions,
261
+ ) => unknown | Promise<unknown>;
157
262
  /**
158
263
  * Host hooks for the per-turn agent build — the Node runtime's
159
264
  * `opts.hooks.getRoomTitle` / `safetyModel`, resolved per turn against the
@@ -195,6 +300,9 @@ export function createOracleWorker(
195
300
  features: opts.features,
196
301
  manifestOverrides: opts.manifestOverrides,
197
302
  env,
303
+ decisionAdapter: opts.decisionAdapter,
304
+ decisionProviders: opts.decisionProviders,
305
+ decisionProviderPolicy: opts.decisionProviderPolicy,
198
306
  logger: console,
199
307
  });
200
308
  cores.set(env, core);
@@ -207,17 +315,23 @@ export function createOracleWorker(
207
315
  let app = apps.get(env);
208
316
  if (!app) {
209
317
  const core = coreFor(env);
318
+ // Validated with the core: half-configured feedback fails the boot.
319
+ const feedback = feedbackConfigFromEnv(env);
210
320
  app = createShell({
211
321
  routes: [...core.pluginRoutes, ...(opts.routes ?? [])],
212
322
  authExcludedRoutes: [
213
323
  ...core.authExcludedRoutes,
214
324
  ...(opts.authExcludedRoutes ?? []),
215
325
  ],
216
- listModels: opts.listModels ?? ((e) => listModels(e)),
326
+ listModels: opts.listModels ?? ((e, options) => listModels(e, options)),
217
327
  banner: {
218
328
  name: opts.config.name,
219
329
  description: opts.config.description,
220
330
  },
331
+ feedback: feedback && {
332
+ config: feedback,
333
+ sink: new LinearFeedbackSink(feedback.linear),
334
+ },
221
335
  });
222
336
  apps.set(env, app);
223
337
  }
@@ -246,7 +360,25 @@ export function createOracleWorker(
246
360
  scheduled: async (_event, env) => {
247
361
  // Cron safety net: if the gateway's keep-alive loop ever died, this
248
362
  // restarts it. Declare a `triggers.crons` entry (e.g. every 5 minutes).
249
- await gateway(env).ensureStarted();
363
+ // A failed keep-alive must not stop the sweep, and is rethrown after it
364
+ // so the cron invocation still reports the failure.
365
+ let keepAlive: { error: unknown } | undefined;
366
+ try {
367
+ await gateway(env).ensureStarted();
368
+ } catch (error) {
369
+ keepAlive = { error };
370
+ }
371
+ // The sweep throttles itself to one R2 read per tick between sweeps and
372
+ // contains its own errors (it logs them and never throws).
373
+ if (env.ARTIFACT_BUCKET)
374
+ await sweepExpiredArtifacts({
375
+ bucket: env.ARTIFACT_BUCKET,
376
+ intervalMs: artifactSweepIntervalMs(env, (message) =>
377
+ console.warn(message),
378
+ ),
379
+ log: console,
380
+ });
381
+ if (keepAlive) throw keepAlive.error;
250
382
  },
251
383
  };
252
384
  }
@@ -11,13 +11,23 @@
11
11
  *
12
12
  * The credential is captured in this closure only; it never appears on the
13
13
  * request context, graph state, or trace metadata.
14
+ *
15
+ * Every BYO model it hands out is wrapped in `ByoModelFallbackChatModel`:
16
+ * a model the provider refuses (`isModelUnavailableError`) is answered by
17
+ * the platform model for the rest of the turn, behind a `byo_fallback`
18
+ * notice. The refusal state lives in this adapter, so it is per turn.
14
19
  */
15
20
 
16
21
  import type { BaseChatModel } from '@langchain/core/language_models/chat_models';
17
22
  import type { ChatOpenAIFields, Logger, ModelRole } from '../plugin-api/types';
18
23
  import type { LlmAdapter } from '../core/runtime-context';
24
+ import { NOOP_LOGGER } from '../core/utils';
19
25
  import { byoModelForRole, type ByoCredential } from './byo-catalog';
20
26
  import { createByoChatModel, type ChatGptBackendConfig } from './byo-client';
27
+ import {
28
+ ByoModelFallbackChatModel,
29
+ ByoModelFallbackState,
30
+ } from './byo-model-fallback';
21
31
 
22
32
  export interface ByoTurnResolution {
23
33
  credential: ByoCredential;
@@ -32,6 +42,7 @@ export function createByoLlmAdapter(
32
42
  turn: ByoTurnResolution,
33
43
  logger?: Logger,
34
44
  ): LlmAdapter {
45
+ const fallbackState = new ByoModelFallbackState();
35
46
  return {
36
47
  get(role: ModelRole, params?: ChatOpenAIFields): BaseChatModel {
37
48
  const modelId = byoModelForRole(
@@ -46,13 +57,23 @@ export function createByoLlmAdapter(
46
57
  // Strip a caller-supplied `model` (for `main` it carries the `byo:` id,
47
58
  // which is not a wire id) — the translated id wins.
48
59
  const { model: _model, ...rest } = params ?? {};
49
- return createByoChatModel({
50
- credential: turn.credential,
60
+ // Wrapped even after a refusal this turn: the wrapper then goes straight
61
+ // to the platform model and attributes that model's failures to the
62
+ // platform, not to the user's account.
63
+ return new ByoModelFallbackChatModel({
64
+ provider: turn.credential.provider,
51
65
  modelId,
52
- role,
53
- chatGptBackend: turn.chatGptBackend,
54
- params: rest,
55
- logger,
66
+ byo: createByoChatModel({
67
+ credential: turn.credential,
68
+ modelId,
69
+ role,
70
+ chatGptBackend: turn.chatGptBackend,
71
+ params: rest,
72
+ logger,
73
+ }),
74
+ platform: () => platform.get(role, rest),
75
+ state: fallbackState,
76
+ logger: logger ?? NOOP_LOGGER,
56
77
  });
57
78
  },
58
79
  };
@@ -98,6 +98,19 @@ export const BYO_PROVIDER_INFO: Record<ByoProvider, ByoProviderInfo> = {
98
98
  * after the token exchange. Stored in the canonical user↔oracle room, so the
99
99
  * credential is account-level for this oracle, not per-session.
100
100
  */
101
+ /**
102
+ * The room-secret namespace reserved for the runtime's own LLM credentials:
103
+ * every `BYO_SECRET_NAMES` entry, and any provider added later. Plugins never
104
+ * see a secret under it (`createSecretsAdapter`) — above all the sandbox,
105
+ * which forwards what it sees to code the model writes.
106
+ */
107
+ export const BYO_SECRET_PREFIX = 'BYO_LLM_';
108
+
109
+ /** A room secret only the runtime itself may read (a BYO LLM credential). */
110
+ export function isRuntimeOnlySecret(name: string): boolean {
111
+ return name.startsWith(BYO_SECRET_PREFIX);
112
+ }
113
+
101
114
  export const BYO_SECRET_NAMES: Record<ByoProvider, string> = {
102
115
  chatgpt: 'BYO_LLM_CHATGPT_OAUTH',
103
116
  openai: 'BYO_LLM_OPENAI_API_KEY',
@@ -0,0 +1,248 @@
1
+ /**
2
+ * Call-time fallback for a BYO model the user's provider refuses.
3
+ *
4
+ * The pre-turn checks (`WorkersByoService.resolveForTurn`) cannot know
5
+ * whether the provider serves the selected model: the ChatGPT backend answers
6
+ * an immediate `400` with an empty body for a model id the subscription does
7
+ * not offer, and only the first real request finds out. This wrapper stands
8
+ * in for the BYO chat model: when its call fails that way before the
9
+ * provider produced any output, it announces the fallback once (an
10
+ * `on_custom_event` named `byo_fallback`, which the SSE producer writes as
11
+ * the same `error`-channel notice the pre-turn fallbacks send) and answers
12
+ * the very same call on the platform model. The refusal is remembered for
13
+ * the rest of the turn (`ByoModelFallbackState`, one per adapter, i.e. per
14
+ * turn): every later call to that model id goes to the platform model
15
+ * directly. Other BYO model ids of the turn (the helper roles' models) stay
16
+ * on the user's account. Nothing is persisted across turns. A failure of the
17
+ * answering platform model is marked (`markPlatformFallbackFailure`) so the
18
+ * SSE classification blames — and redacts — the platform, not the user's
19
+ * account.
20
+ *
21
+ * Delegation runs at the `_streamResponseChunks` / `_generate` level, inside
22
+ * this wrapper's own run, so callbacks, metering and the stream events see
23
+ * one model call whichever model answered it.
24
+ */
25
+
26
+ import type { CallbackManagerForLLMRun } from '@langchain/core/callbacks/manager';
27
+ import type {
28
+ BaseLanguageModelInput,
29
+ ToolDefinition,
30
+ } from '@langchain/core/language_models/base';
31
+ import {
32
+ BaseChatModel,
33
+ type BaseChatModelCallOptions,
34
+ type BindToolsInput,
35
+ } from '@langchain/core/language_models/chat_models';
36
+ import type { ModelProfile } from '@langchain/core/language_models/profile';
37
+ import type { AIMessageChunk, BaseMessage } from '@langchain/core/messages';
38
+ import type { ChatGenerationChunk, ChatResult } from '@langchain/core/outputs';
39
+ import type { Runnable } from '@langchain/core/runnables';
40
+ import { convertToOpenAITool } from '@langchain/core/utils/function_calling';
41
+ import type { Logger } from '../plugin-api/types';
42
+ import type { ByoProvider } from './byo-catalog';
43
+ import {
44
+ BYO_FALLBACK_KIND,
45
+ buildByoFallbackNotice,
46
+ classifyLlmError,
47
+ isModelUnavailableError,
48
+ markPlatformFallbackFailure,
49
+ } from './provider-error';
50
+
51
+ /** Turn-scoped record of the BYO model ids the provider refused. */
52
+ export class ByoModelFallbackState {
53
+ private readonly refused = new Set<string>();
54
+
55
+ isRefused(modelId: string): boolean {
56
+ return this.refused.has(modelId);
57
+ }
58
+
59
+ /** Records a refusal; `true` only the first time for this model id. */
60
+ markRefused(modelId: string): boolean {
61
+ if (this.refused.has(modelId)) return false;
62
+ this.refused.add(modelId);
63
+ return true;
64
+ }
65
+ }
66
+
67
+ export interface ByoModelFallbackCallOptions extends BaseChatModelCallOptions {
68
+ /** Tools in OpenAI function format (see `bindTools`). */
69
+ tools?: ToolDefinition[] | BindToolsInput[];
70
+ strict?: boolean;
71
+ }
72
+
73
+ export interface ByoModelFallbackFields {
74
+ provider: ByoProvider;
75
+ /** Provider-native id of the wrapped BYO model. */
76
+ modelId: string;
77
+ /** The user's BYO model for this role. */
78
+ byo: BaseChatModel;
79
+ /** Builds the platform model for the same role (called at most once). */
80
+ platform: () => BaseChatModel;
81
+ state: ByoModelFallbackState;
82
+ logger: Logger;
83
+ }
84
+
85
+ export class ByoModelFallbackChatModel extends BaseChatModel<ByoModelFallbackCallOptions> {
86
+ private readonly fields: ByoModelFallbackFields;
87
+ private platformModel: BaseChatModel | undefined;
88
+
89
+ static lc_name(): string {
90
+ return 'ByoModelFallbackChatModel';
91
+ }
92
+
93
+ constructor(fields: ByoModelFallbackFields) {
94
+ super({});
95
+ this.fields = fields;
96
+ }
97
+
98
+ /** The provider-native id this wrapper stands in for. */
99
+ get modelId(): string {
100
+ return this.fields.modelId;
101
+ }
102
+
103
+ /** The user's own model this wrapper calls first. */
104
+ get byoModel(): BaseChatModel {
105
+ return this.fields.byo;
106
+ }
107
+
108
+ private platform(): BaseChatModel {
109
+ this.platformModel ??= this.fields.platform();
110
+ return this.platformModel;
111
+ }
112
+
113
+ /** The model a call starting now goes to. */
114
+ private active(): BaseChatModel {
115
+ return this.fields.state.isRefused(this.fields.modelId)
116
+ ? this.platform()
117
+ : this.fields.byo;
118
+ }
119
+
120
+ /** Read by the base constructor, before the fields exist — a constant. */
121
+ _llmType(): string {
122
+ return 'byo-model-fallback';
123
+ }
124
+
125
+ override get profile(): ModelProfile {
126
+ return this.active().profile;
127
+ }
128
+
129
+ override invocationParams(options?: this['ParsedCallOptions']): unknown {
130
+ return this.active().invocationParams(options);
131
+ }
132
+
133
+ override getLsParams(options: this['ParsedCallOptions']) {
134
+ return this.active().getLsParams(options);
135
+ }
136
+
137
+ /**
138
+ * Tools travel as call options in OpenAI function format — the format
139
+ * `ChatOpenAI.bindTools` produces and both of its request converters
140
+ * (Chat Completions and Responses) accept — so the same bound call can be
141
+ * answered by either model. `strict` stays a call option, applied by the
142
+ * answering model exactly as its own `bindTools` would. Provider-specific
143
+ * tool kinds that only `ChatOpenAI.bindTools` special-cases on a LangChain
144
+ * tool (`metadata.customTool`, `extras.providerToolDefinition`,
145
+ * `extras.defer_loading`) become plain functions here; the runtime binds
146
+ * none of them. Plain OpenAI tool objects pass through unchanged.
147
+ */
148
+ override bindTools(
149
+ tools: BindToolsInput[],
150
+ kwargs?: Partial<ByoModelFallbackCallOptions>,
151
+ ): Runnable<
152
+ BaseLanguageModelInput,
153
+ AIMessageChunk,
154
+ ByoModelFallbackCallOptions
155
+ > {
156
+ return this.withConfig({
157
+ tools: tools.map((tool) => convertToOpenAITool(tool)),
158
+ ...kwargs,
159
+ });
160
+ }
161
+
162
+ /**
163
+ * A refusal: announce it once per model id per turn, log it, and remember
164
+ * it so every later call goes to the platform model.
165
+ */
166
+ private async fallBack(
167
+ error: unknown,
168
+ runManager: CallbackManagerForLLMRun | undefined,
169
+ ): Promise<void> {
170
+ const { provider, modelId, state, logger } = this.fields;
171
+ if (!state.markRefused(modelId)) return;
172
+ const status = classifyLlmError(error).status;
173
+ logger.warn(
174
+ `[byo] ${provider} refused model "${modelId}" (HTTP ${status ?? 'n/a'}): ${error instanceof Error ? error.message : String(error)} — this turn uses the platform model for it`,
175
+ );
176
+ await runManager?.handleCustomEvent(
177
+ BYO_FALLBACK_KIND,
178
+ buildByoFallbackNotice('model_unavailable', provider, { modelId }),
179
+ );
180
+ }
181
+
182
+ override async *_streamResponseChunks(
183
+ messages: BaseMessage[],
184
+ options: this['ParsedCallOptions'],
185
+ runManager?: CallbackManagerForLLMRun,
186
+ ): AsyncGenerator<ChatGenerationChunk> {
187
+ if (!this.fields.state.isRefused(this.fields.modelId)) {
188
+ let produced = false;
189
+ try {
190
+ for await (const chunk of this.fields.byo._streamResponseChunks(
191
+ messages,
192
+ options,
193
+ runManager,
194
+ )) {
195
+ produced = true;
196
+ yield chunk;
197
+ }
198
+ return;
199
+ } catch (error) {
200
+ if (
201
+ options.signal?.aborted ||
202
+ !isModelUnavailableError(error, { afterOutput: produced })
203
+ ) {
204
+ throw error;
205
+ }
206
+ await this.fallBack(error, runManager);
207
+ }
208
+ }
209
+ try {
210
+ yield* this.platform()._streamResponseChunks(
211
+ messages,
212
+ options,
213
+ runManager,
214
+ );
215
+ } catch (error) {
216
+ markPlatformFallbackFailure(error);
217
+ throw error;
218
+ }
219
+ }
220
+
221
+ /**
222
+ * The non-streamed path (no streaming handler on the call). A model that
223
+ * streams internally here reports tokens to handlers that do not forward
224
+ * them anywhere, so a refusal is always answered by the platform model.
225
+ */
226
+ async _generate(
227
+ messages: BaseMessage[],
228
+ options: this['ParsedCallOptions'],
229
+ runManager?: CallbackManagerForLLMRun,
230
+ ): Promise<ChatResult> {
231
+ if (!this.fields.state.isRefused(this.fields.modelId)) {
232
+ try {
233
+ return await this.fields.byo._generate(messages, options, runManager);
234
+ } catch (error) {
235
+ if (options.signal?.aborted || !isModelUnavailableError(error)) {
236
+ throw error;
237
+ }
238
+ await this.fallBack(error, runManager);
239
+ }
240
+ }
241
+ try {
242
+ return await this.platform()._generate(messages, options, runManager);
243
+ } catch (error) {
244
+ markPlatformFallbackFailure(error);
245
+ throw error;
246
+ }
247
+ }
248
+ }
@@ -106,6 +106,15 @@ export interface ByoProviderStatus {
106
106
  export interface ByoSecretsBackend {
107
107
  getIndex(roomId: string): Promise<Array<{ name: string; eventId: string }>>;
108
108
  getValues(roomId: string, names: string[]): Promise<Record<string, string>>;
109
+ /**
110
+ * Values for index entries already read with `getIndex`, without reading
111
+ * the index again. Optional: without it the service asks `getValues` for
112
+ * the entries' names.
113
+ */
114
+ getValuesFor?(
115
+ roomId: string,
116
+ index: ReadonlyArray<{ name: string; eventId: string }>,
117
+ ): Promise<Record<string, string>>;
109
118
  putSecret(roomId: string, name: string, value: string): Promise<void>;
110
119
  deleteSecret(roomId: string, name: string): Promise<void>;
111
120
  }
@@ -132,12 +141,23 @@ export interface WorkersByoServiceOptions {
132
141
  logger?: Logger;
133
142
  /** Fetch used for the ChatGPT-backend reachability probe (tests). */
134
143
  probeFetch?: typeof fetch;
144
+ /** Bound on the reachability probe (default `CHATGPT_PROBE_TIMEOUT_MS`). */
145
+ probeTimeoutMs?: number;
146
+ /** Bound on a key-validation call (default `KEY_CHECK_TIMEOUT_MS`). */
147
+ keyCheckTimeoutMs?: number;
135
148
  /** Where ChatGPT-subscription requests go (default: the real backend). */
136
149
  chatGptBackend?: ChatGptBackendConfig;
137
150
  }
138
151
 
139
152
  /** How long a ChatGPT-backend reachability verdict is trusted. */
140
153
  const CHATGPT_PROBE_TTL_MS = 10 * 60 * 1000;
154
+ /**
155
+ * The probe runs at the start of a ChatGPT turn: a backend that does not
156
+ * answer within this is treated as reachable rather than holding the turn.
157
+ */
158
+ const CHATGPT_PROBE_TIMEOUT_MS = 5_000;
159
+ /** How long a "check my key" call waits on the provider. */
160
+ const KEY_CHECK_TIMEOUT_MS = 10_000;
141
161
 
142
162
  const NOOP: Logger = {
143
163
  log: () => undefined,
@@ -173,6 +193,8 @@ export class WorkersByoService {
173
193
  /** Per-user refresh-failure cooldown (epoch-ms deadline). */
174
194
  private readonly refreshCooldownUntil = new Map<string, number>();
175
195
  private readonly probeFetch: typeof fetch;
196
+ private readonly probeTimeoutMs: number;
197
+ private readonly keyCheckTimeoutMs: number;
176
198
  readonly chatGptBackend: ChatGptBackendConfig;
177
199
  /** Last ChatGPT-backend reachability verdict (deployment-wide, not per user). */
178
200
  private chatGptProbe: { blocked: boolean; at: number } | null = null;
@@ -189,6 +211,8 @@ export class WorkersByoService {
189
211
  // method. Resolve the global at call time so the probe really runs.
190
212
  this.probeFetch =
191
213
  opts.probeFetch ?? ((input, init) => globalThis.fetch(input, init));
214
+ this.probeTimeoutMs = opts.probeTimeoutMs ?? CHATGPT_PROBE_TIMEOUT_MS;
215
+ this.keyCheckTimeoutMs = opts.keyCheckTimeoutMs ?? KEY_CHECK_TIMEOUT_MS;
192
216
  this.chatGptBackend = opts.chatGptBackend ?? DEFAULT_CHATGPT_BACKEND;
193
217
  }
194
218
 
@@ -201,8 +225,9 @@ export class WorkersByoService {
201
225
  * a failed turn with no reply, so probe once (a cheap GET the backend
202
226
  * answers with a JSON 404/405 when reachable) and trust the verdict for
203
227
  * `CHATGPT_PROBE_TTL_MS`. Only an HTML 403 counts as blocked — a network
204
- * error or any JSON status is "reachable", so a probe hiccup never hides
205
- * the user's own provider errors.
228
+ * error, a probe that times out (`CHATGPT_PROBE_TIMEOUT_MS`) or any JSON
229
+ * status is "reachable", so a probe hiccup never hides the user's own
230
+ * provider errors.
206
231
  */
207
232
  private async chatGptBackendBlocked(accessToken: string): Promise<boolean> {
208
233
  if (
@@ -222,6 +247,7 @@ export class WorkersByoService {
222
247
  Authorization: `Bearer ${accessToken}`,
223
248
  originator: 'codex_cli_rs',
224
249
  },
250
+ signal: AbortSignal.timeout(this.probeTimeoutMs),
225
251
  },
226
252
  );
227
253
  const type = res.headers.get('content-type') ?? '';
@@ -340,12 +366,20 @@ export class WorkersByoService {
340
366
  const roomId = await this.resolveRoomIdFn(userDid);
341
367
  if (!roomId) return {};
342
368
 
343
- const index = await this.secrets.getIndex(roomId);
344
- const byoNames = index
345
- .map((entry) => entry.name)
346
- .filter((name) => providerForSecretName(name) !== undefined);
369
+ // One read of the room's secret index when the backend can decrypt the
370
+ // entries already read; `getValues` would read the index again.
371
+ const byoEntries = (await this.secrets.getIndex(roomId)).filter(
372
+ (entry) => providerForSecretName(entry.name) !== undefined,
373
+ );
347
374
  const values =
348
- byoNames.length > 0 ? await this.secrets.getValues(roomId, byoNames) : {};
375
+ byoEntries.length === 0
376
+ ? {}
377
+ : this.secrets.getValuesFor
378
+ ? await this.secrets.getValuesFor(roomId, byoEntries)
379
+ : await this.secrets.getValues(
380
+ roomId,
381
+ byoEntries.map((entry) => entry.name),
382
+ );
349
383
 
350
384
  const creds: ByoCredentialMap = {};
351
385
  for (const [name, value] of Object.entries(values)) {
@@ -789,7 +823,10 @@ export class WorkersByoService {
789
823
  credential.provider,
790
824
  credential.apiKey,
791
825
  );
792
- const res = await fetch(url, { headers });
826
+ const res = await fetch(url, {
827
+ headers,
828
+ signal: AbortSignal.timeout(this.keyCheckTimeoutMs),
829
+ });
793
830
  if (res.ok) return { valid: true };
794
831
  return {
795
832
  valid: false,