@ixo/oracle-runtime-workers 0.16.0 → 0.17.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 (40) hide show
  1. package/README.md +3 -0
  2. package/package.json +5 -3
  3. package/src/core/budgeted-llm.ts +115 -6
  4. package/src/core/context-budget.ts +24 -26
  5. package/src/core/context-window.ts +39 -12
  6. package/src/core/domain-context/document.ts +100 -0
  7. package/src/core/domain-context/findings.ts +25 -0
  8. package/src/core/domain-context/index.ts +28 -0
  9. package/src/core/domain-context/integrity.ts +60 -0
  10. package/src/core/domain-context/lru.ts +63 -0
  11. package/src/core/domain-context/resolver.ts +663 -0
  12. package/src/core/domain-context/transport.ts +382 -0
  13. package/src/core/domain-context/turn-context.ts +443 -0
  14. package/src/core/domain-context/types.ts +119 -0
  15. package/src/core/domain-context/validator.ts +41 -0
  16. package/src/core/env.ts +60 -6
  17. package/src/core/index.ts +2 -0
  18. package/src/core/llm.ts +225 -4
  19. package/src/core/main-agent-types.ts +28 -0
  20. package/src/core/main-agent.ts +61 -4
  21. package/src/core/middlewares/tool-execution.ts +19 -2
  22. package/src/core/registries.ts +14 -3
  23. package/src/core/state.ts +2 -1
  24. package/src/core/sub-agent-fallback.ts +14 -2
  25. package/src/core/turn-budget.ts +19 -22
  26. package/src/do/contracts.ts +23 -2
  27. package/src/do/domain-context-store.ts +123 -0
  28. package/src/do/run-request.ts +6 -0
  29. package/src/do/sse-stream.ts +1 -1
  30. package/src/do/transcript.ts +9 -0
  31. package/src/do/turn-body.ts +2 -1
  32. package/src/do/turn-metadata.ts +13 -6
  33. package/src/do/user-oracle-do.ts +111 -42
  34. package/src/index.ts +16 -0
  35. package/src/llm/byo-adapter.ts +6 -0
  36. package/src/llm/byo-catalog.ts +7 -4
  37. package/src/llm/byo-client.ts +42 -7
  38. package/src/llm/provider-error.ts +21 -3
  39. package/src/llm/stream-liveness.ts +498 -0
  40. package/src/plugins/firecrawl/firecrawl-tools.ts +6 -0
@@ -69,10 +69,10 @@ import {
69
69
  langsmithEnvFromWorkerEnv,
70
70
  llmEnvFromWorkerEnv,
71
71
  resolveLangsmithTracing,
72
+ streamLivenessFromEnv,
72
73
  type LangsmithTracingDecision,
73
74
  type OpenRouterLlmAdapter,
74
75
  DEFAULT_MODEL_ID,
75
- OPENROUTER_MODEL_MAP,
76
76
  } from '../core/llm';
77
77
  import { renderTurnTimeNote } from '../core/prompt-composer';
78
78
  import { TURN_TIME_NOTE_KWARG, withTurnTimeNote } from '../core/turn-time-note';
@@ -97,7 +97,7 @@ import type { BaseChatModel } from '@langchain/core/language_models/chat_models'
97
97
  import type { AmbientServices, LlmAdapter } from '../core/runtime-context';
98
98
  import { chatGptBackendFromEnv } from '../llm/byo-client';
99
99
  import { createByoLlmAdapter } from '../llm/byo-adapter';
100
- import { byoModelForRole, isByoModelId } from '../llm/byo-catalog';
100
+ import { isByoModelId } from '../llm/byo-catalog';
101
101
  import { handleByoRequest } from '../llm/byo-routes';
102
102
  import {
103
103
  WorkersByoService,
@@ -192,6 +192,7 @@ import {
192
192
  type TurnIdentity,
193
193
  type TurnRequest,
194
194
  type TurnResult,
195
+ type DomainContextRouterUpdate,
195
196
  type MemorySchemaDebug,
196
197
  TURN_INTERRUPTED_MARKER,
197
198
  type JsonString,
@@ -275,6 +276,14 @@ import {
275
276
  } from '../core/middlewares/context-guard';
276
277
  import { fetchOpenRouterContextLengths } from '../core/openrouter-pricing';
277
278
  import { ResultStore, resultStoreKnobs } from './result-store';
279
+ import {
280
+ DomainContextResolver,
281
+ type DomainContextOptions,
282
+ type DomainContextPins,
283
+ type DomainContextProvenance,
284
+ } from '../core/domain-context';
285
+ import { DomainContextStore } from './domain-context-store';
286
+ import { EVENT_NAMES } from '../core/runtime-context';
278
287
  import { artifactStorageConfig } from '../artifacts/config';
279
288
  import {
280
289
  ArtifactStore,
@@ -333,6 +342,8 @@ interface TurnBuildRun {
333
342
  resumed: boolean;
334
343
  /** The reply text the user already received (resumed attempts). */
335
344
  continuation: string | null;
345
+ /** Domain anchors the run started with (resumed attempts keep them). */
346
+ domainPins?: DomainContextPins;
336
347
  }
337
348
 
338
349
  /**
@@ -536,6 +547,11 @@ export interface OracleWorkerHooks {
536
547
  export interface UserOracleDOOptions {
537
548
  core: (env: OracleWorkerEnv) => RuntimeCore;
538
549
  hooks?: OracleWorkerHooks;
550
+ /**
551
+ * Observe-only domain context (docs/configuration.md). Absent or `off`:
552
+ * no lookups, no prompt block, no tools.
553
+ */
554
+ domainContext?: DomainContextOptions;
539
555
  }
540
556
 
541
557
  interface StoredDelegation {
@@ -619,7 +635,11 @@ function lastAiText(messages: BaseMessage[]): string {
619
635
  }
620
636
 
621
637
  /** Id of the final assistant message (what `POST /messages` reports as `message.id`). */
622
- /** `turn_runs.usage` for `/debug/runs`; a row written before the column existed, or an unreadable one, is null. */
638
+ /**
639
+ * `turn_runs.usage` for `/debug/runs`; a row written before the column
640
+ * existed, or an unreadable one, is null. A `limits.tokens` a row from an
641
+ * older runtime still carries is ignored: tokens are no longer limited.
642
+ */
623
643
  function parseUsage(raw: string | null): TurnUsage | null {
624
644
  if (!raw) return null;
625
645
  try {
@@ -642,7 +662,6 @@ function parseUsage(raw: string | null): TurnUsage | null {
642
662
  toolAttempts: num('toolAttempts'),
643
663
  elapsedMs: num('elapsedMs'),
644
664
  limits: {
645
- tokens: limit('tokens'),
646
665
  tools: limit('tools'),
647
666
  durationMs: limit('durationMs'),
648
667
  },
@@ -653,7 +672,6 @@ function parseUsage(raw: string | null): TurnUsage | null {
653
672
  usage.modelCalls === undefined ||
654
673
  usage.toolAttempts === undefined ||
655
674
  usage.elapsedMs === undefined ||
656
- usage.limits.tokens === undefined ||
657
675
  usage.limits.tools === undefined ||
658
676
  usage.limits.durationMs === undefined
659
677
  )
@@ -665,7 +683,6 @@ function parseUsage(raw: string | null): TurnUsage | null {
665
683
  toolAttempts: usage.toolAttempts,
666
684
  elapsedMs: usage.elapsedMs,
667
685
  limits: {
668
- tokens: usage.limits.tokens,
669
686
  tools: usage.limits.tools,
670
687
  durationMs: usage.limits.durationMs,
671
688
  },
@@ -741,6 +758,15 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
741
758
  * bounded lanes. Lives as long as the object.
742
759
  */
743
760
  private readonly toolScheduler = new ToolScheduler();
761
+ /**
762
+ * Domain-context anchors and documents, cached for every session of this
763
+ * object; `refresh_domain_context` invalidates entries here.
764
+ */
765
+ private readonly domainResolver = new DomainContextResolver({
766
+ logger: console,
767
+ });
768
+ /** Per-turn domain-context provenance (`domain_context_runs`). */
769
+ private domainContextStore: DomainContextStore | null = null;
744
770
  private runs: RunCoordinator | null = null;
745
771
  private readonly runConfig: RunDurabilityConfig = runDurabilityConfig(
746
772
  this.env,
@@ -1641,6 +1667,10 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
1641
1667
  });
1642
1668
  await this.resultStore.setup();
1643
1669
  this.attachmentTextCache = new AttachmentTextCacheStore(liveDb, console);
1670
+ // Constructing it touches no SQL. Only an observe turn records (and so
1671
+ // creates `domain_context_runs`); a session delete never creates it but
1672
+ // still removes rows an earlier observe deployment left behind.
1673
+ this.domainContextStore = new DomainContextStore(liveDb, console);
1644
1674
  // The store exists whenever the bucket is bound, so artefacts made
1645
1675
  // earlier stay readable, revocable and deleted with their session after
1646
1676
  // the public origin is removed; only new ones need it.
@@ -1894,6 +1924,7 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
1894
1924
  this.resultStore = null;
1895
1925
  this.artifacts = null;
1896
1926
  this.attachmentTextCache = null;
1927
+ this.domainContextStore = null;
1897
1928
  // Its store is bound to the database just dropped: a caller that sees
1898
1929
  // no scheduler boots again instead of reading a closed connection.
1899
1930
  this.taskScheduler = null;
@@ -2760,6 +2791,13 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
2760
2791
  `[user-do] could not delete the artefacts of ${sessionId}; their links expire on schedule: ${err instanceof Error ? err.message : String(err)}`,
2761
2792
  );
2762
2793
  });
2794
+ await this.domainContextStore
2795
+ ?.deleteForSession(sessionId)
2796
+ .catch((err: unknown) => {
2797
+ console.warn(
2798
+ `[domain-context] could not delete the provenance of ${sessionId}: ${err instanceof Error ? err.message : String(err)}`,
2799
+ );
2800
+ });
2763
2801
  await this.ctx.storage.delete(contextStatsKey(sessionId));
2764
2802
  await this.feedbackMarkers!.forgetSession(sessionId);
2765
2803
  this.markDirty();
@@ -4283,6 +4321,7 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
4283
4321
  toolOutputCapChars,
4284
4322
  delivery,
4285
4323
  usage,
4324
+ domainContext,
4286
4325
  } = await this.prepareTurn(
4287
4326
  req,
4288
4327
  {
@@ -4298,9 +4337,28 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
4298
4337
  abortController: live.abort,
4299
4338
  resumed: !freshInput,
4300
4339
  continuation: freshInput ? null : live.continuation,
4340
+ ...(stored.domainPins ? { domainPins: stored.domainPins } : {}),
4301
4341
  },
4302
4342
  );
4303
4343
  const sessionId = req.sessionId;
4344
+ // The anchors this run read are pinned on its row, so a resumed
4345
+ // attempt reads the same document revisions.
4346
+ if (
4347
+ domainContext &&
4348
+ JSON.stringify(domainContext.pins) !==
4349
+ JSON.stringify(stored.domainPins ?? {})
4350
+ ) {
4351
+ stored.domainPins = domainContext.pins;
4352
+ const request = JSON.stringify(stored);
4353
+ try {
4354
+ await this.runStore!.update(live.runId, { request });
4355
+ live.record.request = request;
4356
+ } catch (error) {
4357
+ console.warn(
4358
+ `[domain-context] could not pin the anchors of run ${live.runId}: ${error instanceof Error ? error.message : String(error)}`,
4359
+ );
4360
+ }
4361
+ }
4304
4362
  const sink = {
4305
4363
  emit: (eventName: string, payload: Record<string, unknown>) => {
4306
4364
  if (live.buffer.isClosed) return;
@@ -4313,6 +4371,8 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
4313
4371
  // (the turn's deadline timer among them) run however the attempt ends.
4314
4372
  try {
4315
4373
  this.events.register(sessionId, sink);
4374
+ if (domainContext)
4375
+ await this.reportDomainContext(req, domainContext.provenance);
4316
4376
  if (byoNotice)
4317
4377
  sink.emit('error', {
4318
4378
  ...byoNotice,
@@ -4641,15 +4701,7 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
4641
4701
  const window = await this.contextWindows.resolve(modelId, {
4642
4702
  ...(byoProvider ? { byoProvider } : {}),
4643
4703
  });
4644
- const budget = contextBudgetFor(
4645
- window,
4646
- contextKnobs(this.env, console),
4647
- await this.contextWindows.resolve(
4648
- llm && isProviderAdapter(llm)
4649
- ? llm.modelForRole('routing')
4650
- : OPENROUTER_MODEL_MAP.routing,
4651
- ),
4652
- );
4704
+ const budget = contextBudgetFor(window, contextKnobs(this.env, console));
4653
4705
  const stats = (await this.resultStore?.stats()) ?? {
4654
4706
  rows: 0,
4655
4707
  sqliteBytes: 0,
@@ -5007,27 +5059,6 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
5007
5059
  };
5008
5060
  }
5009
5061
 
5010
- /**
5011
- * The model that summarises the turn's history (the `routing` role): its
5012
- * window bounds what the summariser is handed.
5013
- */
5014
- private summaryModelId(
5015
- platform: LlmAdapter,
5016
- byoTurn: ByoTurnState | null,
5017
- ): string {
5018
- if (byoTurn)
5019
- return (
5020
- byoModelForRole(
5021
- byoTurn.credential.provider,
5022
- 'routing',
5023
- byoTurn.mainModelId,
5024
- ) ?? OPENROUTER_MODEL_MAP.routing
5025
- );
5026
- return isProviderAdapter(platform)
5027
- ? platform.modelForRole('routing')
5028
- : OPENROUTER_MODEL_MAP.routing;
5029
- }
5030
-
5031
5062
  /**
5032
5063
  * Build one attempt's agent and input (`buildTurn`). A build that fails
5033
5064
  * part-way leaves nothing behind: the cleanups it registered run (the
@@ -5139,6 +5170,7 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
5139
5170
  credential: byoTurn.credential,
5140
5171
  mainModelId: byoTurn.mainModelId,
5141
5172
  chatGptBackend: byoTurn.chatGptBackend,
5173
+ streamLiveness: streamLivenessFromEnv(core.validatedEnv, 'byo'),
5142
5174
  },
5143
5175
  console,
5144
5176
  ),
@@ -5157,13 +5189,11 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
5157
5189
  const windowResolution = await this.contextWindows.resolve(mainModelId, {
5158
5190
  ...(byoTurn ? { byoProvider: byoTurn.provider } : {}),
5159
5191
  });
5192
+ // The summary is written by the main model too (the `summarizer`
5193
+ // role), so the main model's window is the only one that counts.
5160
5194
  const contextBudget = contextBudgetFor(
5161
5195
  windowResolution,
5162
5196
  contextKnobs(this.env, console),
5163
- await this.contextWindows.resolve(
5164
- this.summaryModelId(baseAmbient.llm, byoTurn),
5165
- byoTurn ? { byoProvider: byoTurn.provider } : {},
5166
- ),
5167
5197
  );
5168
5198
  if (
5169
5199
  req.client === 'matrix' &&
@@ -5393,8 +5423,9 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
5393
5423
  continuation: run.continuation,
5394
5424
  logger: console,
5395
5425
  });
5396
- // The turn's budget (turn-budget.ts): model calls are charged by the
5397
- // metered adapter every model of this turn comes from, tool calls by
5426
+ // The turn's budget (turn-budget.ts): model tokens are accounted (for
5427
+ // the usage line below, never limited) by the metered adapter every
5428
+ // model of this turn comes from, tool calls are charged by
5398
5429
  // the execution middleware below, and the deadline aborts the run
5399
5430
  // with the limit as the reason (the stream reports it as a terminal
5400
5431
  // error, not as a user abort).
@@ -5404,6 +5435,7 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
5404
5435
  budget,
5405
5436
  outputReserveTokens: contextBudget.outputReserveTokens,
5406
5437
  signal: abortController.signal,
5438
+ logger: console,
5407
5439
  });
5408
5440
  const deadline = setTimeout(() => {
5409
5441
  if (abortController.signal.aborted) return;
@@ -5420,6 +5452,8 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
5420
5452
  }, turnLimits.durationMs);
5421
5453
  turnDisposables.add(async () => {
5422
5454
  clearTimeout(deadline);
5455
+ // Heartbeats of model calls that never reported an end.
5456
+ meteredLlm.dispose();
5423
5457
  const usage = budget.snapshot();
5424
5458
  console.log(
5425
5459
  `[harness] turn ${req.requestId} usage: ~${usage.tokens} tokens (${usage.reportedTokens} reported over ${usage.modelCalls} model calls), ${usage.toolAttempts} tool attempts, ${usage.elapsedMs} ms`,
@@ -5489,6 +5523,18 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
5489
5523
  contextBudget,
5490
5524
  turnBudget: budget,
5491
5525
  delivery,
5526
+ // Observe-only domain context, on the object's shared resolver (the
5527
+ // one `refresh_domain_context` invalidates); a resumed run reads the
5528
+ // anchors it pinned.
5529
+ ...(opts.domainContext
5530
+ ? {
5531
+ domainContext: opts.domainContext,
5532
+ domainResolver: this.domainResolver,
5533
+ onDomainProvenance: (provenance: DomainContextProvenance[]) =>
5534
+ void this.reportDomainContext(req, provenance),
5535
+ ...(run.domainPins ? { domainPins: run.domainPins } : {}),
5536
+ }
5537
+ : {}),
5492
5538
  ambient: {
5493
5539
  ...ambient,
5494
5540
  llm: meteredLlm,
@@ -5657,7 +5703,30 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
5657
5703
  /** The budget's usage so far (JSON), stored with the run's end. */
5658
5704
  usage: (): string => JSON.stringify(budget.snapshot()),
5659
5705
  delivery,
5706
+ domainContext: built.domainContext,
5707
+ };
5708
+ }
5709
+
5710
+ /**
5711
+ * A turn's domain-context provenance: a `router_update` frame for the
5712
+ * client and the session's sockets, and a `domain_context_runs` row.
5713
+ * Diagnostics only — nothing here can fail the turn.
5714
+ */
5715
+ private async reportDomainContext(
5716
+ req: TurnRequest,
5717
+ provenance: DomainContextProvenance[],
5718
+ ): Promise<void> {
5719
+ const update: DomainContextRouterUpdate = {
5720
+ sessionId: req.sessionId,
5721
+ requestId: req.requestId,
5722
+ domainContext: provenance,
5660
5723
  };
5724
+ this.events.emit(EVENT_NAMES.router, { ...update });
5725
+ await this.domainContextStore?.record(
5726
+ req.sessionId,
5727
+ req.requestId,
5728
+ provenance,
5729
+ );
5661
5730
  }
5662
5731
 
5663
5732
  /**
package/src/index.ts CHANGED
@@ -39,6 +39,7 @@ import { artifactSweepIntervalMs } from './artifacts/config';
39
39
  import { sweepExpiredArtifacts } from './artifacts/sweep';
40
40
  import { feedbackConfigFromEnv } from './feedback/config';
41
41
  import { LinearFeedbackSink } from './feedback/linear-sink';
42
+ import type { DomainContextOptions } from './core/domain-context';
42
43
 
43
44
  export * from './plugin-api';
44
45
  export * from './plugins';
@@ -56,6 +57,14 @@ export {
56
57
  createUserOracleDO,
57
58
  type OracleWorkerHooks,
58
59
  } from './do/user-oracle-do';
60
+ export type {
61
+ DocumentRequest,
62
+ DomainCapsuleInspection,
63
+ DomainContextOptions,
64
+ DomainContextPins,
65
+ DomainContextProvenance,
66
+ DomainDocumentRead,
67
+ } from './core/domain-context';
59
68
  export { createShell, type ListModelsOptions } from './shell/app';
60
69
  export { authenticate } from './shell/auth';
61
70
  export {
@@ -265,6 +274,12 @@ export interface CreateOracleWorkerOptions {
265
274
  * user object's ambient services.
266
275
  */
267
276
  hooks?: OracleWorkerHooks;
277
+ /**
278
+ * Observe-only domain context: the oracle's and the selected subject's
279
+ * anchored domain documents, verified by CID and handed to the model as
280
+ * retrieved data. Default off. See docs/configuration.md.
281
+ */
282
+ domainContext?: DomainContextOptions;
268
283
  }
269
284
 
270
285
  export interface OracleWorker {
@@ -341,6 +356,7 @@ export function createOracleWorker(
341
356
  const UserOracleDO = createUserOracleDO({
342
357
  core: coreFor,
343
358
  ...(opts.hooks ? { hooks: opts.hooks } : {}),
359
+ ...(opts.domainContext ? { domainContext: opts.domainContext } : {}),
344
360
  });
345
361
 
346
362
  return {
@@ -28,6 +28,7 @@ import {
28
28
  ByoModelFallbackChatModel,
29
29
  ByoModelFallbackState,
30
30
  } from './byo-model-fallback';
31
+ import type { StreamLivenessSettings } from './stream-liveness';
31
32
 
32
33
  export interface ByoTurnResolution {
33
34
  credential: ByoCredential;
@@ -35,6 +36,8 @@ export interface ByoTurnResolution {
35
36
  mainModelId: string;
36
37
  /** ChatGPT lane only: backend/proxy the requests go to. */
37
38
  chatGptBackend?: ChatGptBackendConfig;
39
+ /** The BYO lane's liveness budgets (`streamLivenessFromEnv(env, 'byo')`). */
40
+ streamLiveness?: StreamLivenessSettings;
38
41
  }
39
42
 
40
43
  export function createByoLlmAdapter(
@@ -68,6 +71,9 @@ export function createByoLlmAdapter(
68
71
  modelId,
69
72
  role,
70
73
  chatGptBackend: turn.chatGptBackend,
74
+ ...(turn.streamLiveness
75
+ ? { streamLiveness: turn.streamLiveness }
76
+ : {}),
71
77
  params: rest,
72
78
  logger,
73
79
  }),
@@ -382,6 +382,8 @@ export const BYO_DEFAULT_MODEL: Record<ByoProvider, string> = {
382
382
  * A role absent here falls back to the platform provider (deliberately:
383
383
  * `embedding` everywhere — none of these providers is wired for the runtime's
384
384
  * embedding path — and `vision` on DeepSeek, whose chat models are text-only).
385
+ * `main` and `summarizer` are not listed: both are the turn's selected model
386
+ * (`byoModelForRole`), so the history is summarized on the user's provider.
385
387
  */
386
388
  export const BYO_ROLE_MODELS: Record<
387
389
  ByoProvider,
@@ -447,6 +449,7 @@ export const BYO_ROLE_MODELS: Record<
447
449
  */
448
450
  const KNOWN_MODEL_ROLES: ReadonlySet<string> = new Set([
449
451
  'main',
452
+ 'summarizer',
450
453
  'skills',
451
454
  'subagent',
452
455
  'vision',
@@ -460,16 +463,16 @@ const KNOWN_MODEL_ROLES: ReadonlySet<string> = new Set([
460
463
 
461
464
  /**
462
465
  * Resolve the provider-native model id for a role on a BYO turn.
463
- * `main` uses the turn's selected model; other roles use the translation
464
- * table. `null` → the role is not served by this provider and the caller
465
- * must fall back to the platform adapter.
466
+ * `main` and `summarizer` use the turn's selected model; other roles use the
467
+ * translation table. `null` → the role is not served by this provider and
468
+ * the caller must fall back to the platform adapter.
466
469
  */
467
470
  export function byoModelForRole(
468
471
  provider: ByoProvider,
469
472
  role: ProviderModelRole | string,
470
473
  mainModelId: string,
471
474
  ): string | null {
472
- if (role === 'main') return mainModelId;
475
+ if (role === 'main' || role === 'summarizer') return mainModelId;
473
476
  const map = BYO_ROLE_MODELS[provider];
474
477
  const direct = map[role as ProviderModelRole];
475
478
  if (direct) return direct;
@@ -17,8 +17,13 @@
17
17
  import type { BaseChatModel } from '@langchain/core/language_models/chat_models';
18
18
  import { ChatOpenAI } from '@langchain/openai';
19
19
  import type { ChatOpenAIFields, Logger } from '../plugin-api/types';
20
- import type { ProviderModelRole } from '../core/llm';
20
+ import { streamLivenessFromEnv, type ProviderModelRole } from '../core/llm';
21
21
  import type { ByoCredential } from './byo-catalog';
22
+ import {
23
+ livenessFetchFor,
24
+ livenessModelFields,
25
+ type StreamLivenessSettings,
26
+ } from './stream-liveness';
22
27
 
23
28
  /** OpenAI-compatible base URLs for the API-key providers that need one. */
24
29
  export const DEEPSEEK_BASE_URL = 'https://api.deepseek.com/v1';
@@ -133,6 +138,11 @@ export interface CreateByoChatModelArgs {
133
138
  logger?: Logger;
134
139
  /** ChatGPT lane only: where requests go (default: the real backend). */
135
140
  chatGptBackend?: ChatGptBackendConfig;
141
+ /**
142
+ * Liveness budgets for this model's calls (default: the BYO lane's
143
+ * defaults, see `streamLivenessFromEnv`).
144
+ */
145
+ streamLiveness?: StreamLivenessSettings;
136
146
  }
137
147
 
138
148
  const NOOP: Logger = {
@@ -164,17 +174,35 @@ export function createByoChatModel(
164
174
  // "Thinking..." for a minute before any error surfaces. Two attempts keep
165
175
  // transient-blip resilience while failing fast enough to report.
166
176
  const maxRetries = 2;
177
+ // Every call runs behind the stream liveness guard with the BYO lane's
178
+ // budgets: a direct provider may stay silent while a model reasons, so
179
+ // its idle budget is longer than the platform lane's.
180
+ const liveness = args.streamLiveness ?? streamLivenessFromEnv({}, 'byo');
181
+ const callerConfiguration = asRecord(params?.configuration);
182
+ const guardedFetch = (base?: typeof fetch): typeof fetch =>
183
+ livenessFetchFor({
184
+ settings: liveness,
185
+ label: `byo:${credential.provider} ${String(role)} ${modelId}`,
186
+ logger,
187
+ callerFetch: callerConfiguration.fetch,
188
+ ...(base ? { base } : {}),
189
+ });
167
190
 
168
191
  switch (credential.provider) {
169
192
  case 'openai':
170
193
  return new ChatOpenAI({
171
194
  __includeRawResponse: true,
172
195
  maxRetries,
196
+ ...livenessModelFields(liveness),
173
197
  ...params,
174
198
  model: modelId,
175
199
  apiKey: credential.apiKey,
176
200
  temperature: undefined,
177
201
  topP: undefined,
202
+ configuration: {
203
+ ...callerConfiguration,
204
+ fetch: guardedFetch(),
205
+ },
178
206
  });
179
207
 
180
208
  case 'deepseek':
@@ -182,12 +210,14 @@ export function createByoChatModel(
182
210
  temperature,
183
211
  __includeRawResponse: true,
184
212
  maxRetries,
213
+ ...livenessModelFields(liveness),
185
214
  ...params,
186
215
  model: modelId,
187
216
  apiKey: credential.apiKey,
188
217
  configuration: {
189
218
  baseURL: DEEPSEEK_BASE_URL,
190
- ...asRecord(params?.configuration),
219
+ ...callerConfiguration,
220
+ fetch: guardedFetch(),
191
221
  },
192
222
  });
193
223
 
@@ -196,12 +226,14 @@ export function createByoChatModel(
196
226
  temperature,
197
227
  __includeRawResponse: true,
198
228
  maxRetries,
229
+ ...livenessModelFields(liveness),
199
230
  ...params,
200
231
  model: modelId,
201
232
  apiKey: credential.apiKey,
202
233
  configuration: {
203
234
  baseURL: GEMINI_OPENAI_COMPAT_BASE_URL,
204
- ...asRecord(params?.configuration),
235
+ ...callerConfiguration,
236
+ fetch: guardedFetch(),
205
237
  },
206
238
  });
207
239
 
@@ -211,6 +243,7 @@ export function createByoChatModel(
211
243
  return new ChatOpenAI({
212
244
  __includeRawResponse: true,
213
245
  maxRetries,
246
+ ...livenessModelFields(liveness),
214
247
  ...params,
215
248
  model: modelId,
216
249
  apiKey: credential.apiKey,
@@ -218,7 +251,8 @@ export function createByoChatModel(
218
251
  topP: undefined,
219
252
  configuration: {
220
253
  baseURL: ANTHROPIC_OPENAI_COMPAT_BASE_URL,
221
- ...asRecord(params?.configuration),
254
+ ...callerConfiguration,
255
+ fetch: guardedFetch(),
222
256
  },
223
257
  });
224
258
 
@@ -232,7 +266,7 @@ export function createByoChatModel(
232
266
  // distinct headers upstream — send both. The backend rejects sampling
233
267
  // params (`temperature`/`top_p`) the way the standard API does for
234
268
  // reasoning models, and reports request errors with an EMPTY body —
235
- // hence the diagnostic fetch.
269
+ // hence the diagnostic fetch, inside the liveness guard.
236
270
  const sessionId = crypto.randomUUID();
237
271
  return new ChatOpenAI({
238
272
  __includeRawResponse: true,
@@ -249,6 +283,7 @@ export function createByoChatModel(
249
283
  // Effort + human-readable reasoning summaries, exactly the pair the
250
284
  // Codex clients send — summaries feed the portal's thinking stream.
251
285
  reasoning: { effort: 'medium', summary: 'auto' },
286
+ ...livenessModelFields(liveness),
252
287
  ...params,
253
288
  model: modelId,
254
289
  apiKey: credential.oauth.accessToken,
@@ -257,8 +292,8 @@ export function createByoChatModel(
257
292
  topP: undefined,
258
293
  configuration: {
259
294
  baseURL: backend.baseUrl,
260
- fetch: chatGptDiagnosticFetch(logger),
261
- ...asRecord(params?.configuration),
295
+ ...callerConfiguration,
296
+ fetch: guardedFetch(chatGptDiagnosticFetch(logger)),
262
297
  defaultHeaders: {
263
298
  ...chatGptBackendHeaders(backend),
264
299
  'ChatGPT-Account-ID': credential.oauth.accountId,
@@ -14,6 +14,7 @@ import {
14
14
  isByoProvider,
15
15
  type ByoProvider,
16
16
  } from './byo-catalog';
17
+ import { findProviderStall, type ProviderStallError } from './stream-liveness';
17
18
 
18
19
  /** `kind` value for the BYO degradation notice. */
19
20
  export const BYO_FALLBACK_KIND = 'byo_fallback';
@@ -283,12 +284,27 @@ export interface ClassifyLlmErrorContext {
283
284
  byoProvider?: ByoProvider | string | null;
284
285
  }
285
286
 
287
+ /**
288
+ * What the user is told about a stalled call (`ProviderStallError`): a stall
289
+ * mid-reply reads differently from a model that never started.
290
+ */
291
+ function stallMessage(stall: ProviderStallError): string {
292
+ return stall.phase === 'stream'
293
+ ? 'The model stopped responding mid-reply. Please try again.'
294
+ : 'The model did not start responding. Please try again.';
295
+ }
296
+
286
297
  export function classifyLlmError(
287
298
  error: unknown,
288
299
  ctx?: ClassifyLlmErrorContext,
289
300
  ): ClassifiedLlmError {
290
- const parts = extractErrorParts(error);
291
- const kind = detectKind(parts);
301
+ // A stall is found through the wrappers (the OpenAI SDK's
302
+ // `APIConnectionError`, LangChain's middleware errors) by its `cause`.
303
+ const stall = findProviderStall(error);
304
+ const parts: ErrorParts = stall
305
+ ? { text: stall.message }
306
+ : extractErrorParts(error);
307
+ const kind: LlmErrorKind = stall ? 'timeout' : detectKind(parts);
292
308
  const provider =
293
309
  typeof ctx?.byoProvider === 'string' &&
294
310
  isByoProvider(ctx.byoProvider) &&
@@ -305,7 +321,9 @@ export function classifyLlmError(
305
321
  ...(providerLabel && { providerLabel }),
306
322
  ...(parts.status !== undefined && { status: parts.status }),
307
323
  retryable: RETRYABLE_KINDS.has(kind),
308
- message: fallbackMessage(kind, provider, providerLabel),
324
+ message: stall
325
+ ? stallMessage(stall)
326
+ : fallbackMessage(kind, provider, providerLabel),
309
327
  detail: parts.text,
310
328
  };
311
329
  }