@librechat/agents 4.0.1 → 4.0.2

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 (138) hide show
  1. package/dist/cjs/decisions/deadline.cjs +52 -0
  2. package/dist/cjs/decisions/deadline.cjs.map +1 -0
  3. package/dist/cjs/decisions/dialect.cjs +88 -0
  4. package/dist/cjs/decisions/dialect.cjs.map +1 -0
  5. package/dist/cjs/decisions/http.cjs +77 -0
  6. package/dist/cjs/decisions/http.cjs.map +1 -0
  7. package/dist/cjs/decisions/index.cjs +7 -0
  8. package/dist/cjs/decisions/presets.cjs +82 -0
  9. package/dist/cjs/decisions/presets.cjs.map +1 -0
  10. package/dist/cjs/decisions/questions.cjs +70 -0
  11. package/dist/cjs/decisions/questions.cjs.map +1 -0
  12. package/dist/cjs/decisions/structuredChat.cjs +178 -0
  13. package/dist/cjs/decisions/structuredChat.cjs.map +1 -0
  14. package/dist/cjs/decisions/traceMarker.cjs +6 -0
  15. package/dist/cjs/decisions/traceMarker.cjs.map +1 -0
  16. package/dist/cjs/decisions/transport.cjs +166 -0
  17. package/dist/cjs/decisions/transport.cjs.map +1 -0
  18. package/dist/cjs/decisions/types.cjs +44 -0
  19. package/dist/cjs/decisions/types.cjs.map +1 -0
  20. package/dist/cjs/graphs/Graph.cjs +4 -1
  21. package/dist/cjs/graphs/Graph.cjs.map +1 -1
  22. package/dist/cjs/langfuseToolOutputTracing.cjs +6 -1
  23. package/dist/cjs/langfuseToolOutputTracing.cjs.map +1 -1
  24. package/dist/cjs/main.cjs +39 -0
  25. package/dist/cjs/rerank/index.cjs +2 -0
  26. package/dist/cjs/rerank/search.cjs +45 -0
  27. package/dist/cjs/rerank/search.cjs.map +1 -0
  28. package/dist/cjs/rerank/systemone.cjs +70 -0
  29. package/dist/cjs/rerank/systemone.cjs.map +1 -0
  30. package/dist/cjs/run.cjs +4 -0
  31. package/dist/cjs/run.cjs.map +1 -1
  32. package/dist/cjs/tools/search/rerankers.cjs +1 -0
  33. package/dist/cjs/tools/search/search.cjs +3 -3
  34. package/dist/cjs/tools/search/search.cjs.map +1 -1
  35. package/dist/cjs/tools/search/tool.cjs +4 -3
  36. package/dist/cjs/tools/search/tool.cjs.map +1 -1
  37. package/dist/cjs/tools/subagent/SubagentExecutionRegistry.cjs +11 -0
  38. package/dist/cjs/tools/subagent/SubagentExecutionRegistry.cjs.map +1 -1
  39. package/dist/cjs/tools/subagent/SubagentExecutor.cjs +87 -14
  40. package/dist/cjs/tools/subagent/SubagentExecutor.cjs.map +1 -1
  41. package/dist/cjs/tools/subagent/diagnostics.cjs +45 -2
  42. package/dist/cjs/tools/subagent/diagnostics.cjs.map +1 -1
  43. package/dist/cjs/tools/subagent/index.cjs +1 -0
  44. package/dist/esm/decisions/deadline.mjs +52 -0
  45. package/dist/esm/decisions/deadline.mjs.map +1 -0
  46. package/dist/esm/decisions/dialect.mjs +87 -0
  47. package/dist/esm/decisions/dialect.mjs.map +1 -0
  48. package/dist/esm/decisions/http.mjs +75 -0
  49. package/dist/esm/decisions/http.mjs.map +1 -0
  50. package/dist/esm/decisions/index.mjs +8 -0
  51. package/dist/esm/decisions/presets.mjs +77 -0
  52. package/dist/esm/decisions/presets.mjs.map +1 -0
  53. package/dist/esm/decisions/questions.mjs +67 -0
  54. package/dist/esm/decisions/questions.mjs.map +1 -0
  55. package/dist/esm/decisions/structuredChat.mjs +178 -0
  56. package/dist/esm/decisions/structuredChat.mjs.map +1 -0
  57. package/dist/esm/decisions/traceMarker.mjs +6 -0
  58. package/dist/esm/decisions/traceMarker.mjs.map +1 -0
  59. package/dist/esm/decisions/transport.mjs +164 -0
  60. package/dist/esm/decisions/transport.mjs.map +1 -0
  61. package/dist/esm/decisions/types.mjs +39 -0
  62. package/dist/esm/decisions/types.mjs.map +1 -0
  63. package/dist/esm/graphs/Graph.mjs +4 -1
  64. package/dist/esm/graphs/Graph.mjs.map +1 -1
  65. package/dist/esm/langfuseToolOutputTracing.mjs +6 -1
  66. package/dist/esm/langfuseToolOutputTracing.mjs.map +1 -1
  67. package/dist/esm/main.mjs +13 -1
  68. package/dist/esm/rerank/index.mjs +3 -0
  69. package/dist/esm/rerank/search.mjs +45 -0
  70. package/dist/esm/rerank/search.mjs.map +1 -0
  71. package/dist/esm/rerank/systemone.mjs +70 -0
  72. package/dist/esm/rerank/systemone.mjs.map +1 -0
  73. package/dist/esm/run.mjs +4 -0
  74. package/dist/esm/run.mjs.map +1 -1
  75. package/dist/esm/tools/search/rerankers.mjs +1 -1
  76. package/dist/esm/tools/search/search.mjs +3 -3
  77. package/dist/esm/tools/search/search.mjs.map +1 -1
  78. package/dist/esm/tools/search/tool.mjs +4 -3
  79. package/dist/esm/tools/search/tool.mjs.map +1 -1
  80. package/dist/esm/tools/subagent/SubagentExecutionRegistry.mjs +11 -0
  81. package/dist/esm/tools/subagent/SubagentExecutionRegistry.mjs.map +1 -1
  82. package/dist/esm/tools/subagent/SubagentExecutor.mjs +88 -15
  83. package/dist/esm/tools/subagent/SubagentExecutor.mjs.map +1 -1
  84. package/dist/esm/tools/subagent/diagnostics.mjs +44 -3
  85. package/dist/esm/tools/subagent/diagnostics.mjs.map +1 -1
  86. package/dist/esm/tools/subagent/index.mjs +1 -0
  87. package/dist/types/decisions/deadline.d.ts +3 -0
  88. package/dist/types/decisions/dialect.d.ts +11 -0
  89. package/dist/types/decisions/http.d.ts +22 -0
  90. package/dist/types/decisions/index.d.ts +10 -0
  91. package/dist/types/decisions/presets.d.ts +17 -0
  92. package/dist/types/decisions/questions.d.ts +8 -0
  93. package/dist/types/decisions/structuredChat.d.ts +16 -0
  94. package/dist/types/decisions/traceMarker.d.ts +2 -0
  95. package/dist/types/decisions/transport.d.ts +29 -0
  96. package/dist/types/decisions/types.d.ts +119 -0
  97. package/dist/types/graphs/Graph.d.ts +2 -1
  98. package/dist/types/index.d.ts +2 -0
  99. package/dist/types/rerank/index.d.ts +4 -0
  100. package/dist/types/rerank/search.d.ts +7 -0
  101. package/dist/types/rerank/systemone.d.ts +13 -0
  102. package/dist/types/rerank/types.d.ts +24 -0
  103. package/dist/types/run.d.ts +1 -0
  104. package/dist/types/tools/search/types.d.ts +3 -1
  105. package/dist/types/tools/subagent/SubagentExecutionRegistry.d.ts +4 -0
  106. package/dist/types/tools/subagent/SubagentExecutor.d.ts +13 -0
  107. package/dist/types/tools/subagent/diagnostics.d.ts +32 -8
  108. package/dist/types/tools/subagent/index.d.ts +2 -0
  109. package/dist/types/types/graph.d.ts +3 -0
  110. package/dist/types/types/run.d.ts +2 -0
  111. package/package.json +1 -1
  112. package/src/decisions/deadline.ts +92 -0
  113. package/src/decisions/dialect.ts +200 -0
  114. package/src/decisions/http.ts +175 -0
  115. package/src/decisions/index.ts +14 -0
  116. package/src/decisions/presets.ts +111 -0
  117. package/src/decisions/questions.ts +128 -0
  118. package/src/decisions/structuredChat.ts +364 -0
  119. package/src/decisions/traceMarker.ts +2 -0
  120. package/src/decisions/transport.ts +335 -0
  121. package/src/decisions/types.ts +193 -0
  122. package/src/graphs/Graph.ts +4 -0
  123. package/src/index.ts +2 -0
  124. package/src/langfuseToolOutputTracing.ts +8 -1
  125. package/src/rerank/index.ts +4 -0
  126. package/src/rerank/search.ts +85 -0
  127. package/src/rerank/systemone.ts +140 -0
  128. package/src/rerank/types.ts +28 -0
  129. package/src/run.ts +4 -0
  130. package/src/tools/search/search.ts +4 -4
  131. package/src/tools/search/tool.ts +27 -25
  132. package/src/tools/search/types.ts +4 -1
  133. package/src/tools/subagent/SubagentExecutionRegistry.ts +21 -0
  134. package/src/tools/subagent/SubagentExecutor.ts +190 -22
  135. package/src/tools/subagent/diagnostics.ts +88 -9
  136. package/src/tools/subagent/index.ts +11 -0
  137. package/src/types/graph.ts +3 -0
  138. package/src/types/run.ts +2 -0
@@ -0,0 +1,364 @@
1
+ import { HumanMessage, SystemMessage } from '@langchain/core/messages';
2
+ import type { BaseChatModel } from '@langchain/core/language_models/chat_models';
3
+ import type {
4
+ DecisionModel,
5
+ DecisionQuestion,
6
+ DecisionResult,
7
+ DecisionRequest,
8
+ } from './types';
9
+ import { DecisionError, isDecisionObject, readDecisionUsage } from './types';
10
+ import { failureForStatus, retryAfterMs } from './transport';
11
+ import { validateDecisionQuestions } from './questions';
12
+ import { DECISION_PROMPT_PREFIX } from './traceMarker';
13
+ import { withDecisionDeadline } from './deadline';
14
+
15
+ const MAX_QUESTIONS = 32;
16
+ const MAX_CHOICE_OPTIONS = 128;
17
+ const DEFAULT_TIMEOUT_MS = 20_000;
18
+ const PROVIDER_ID = 'structured-chat';
19
+
20
+ export function readChatUsage(raw: unknown): DecisionResult['usage'] {
21
+ if (!isDecisionObject(raw)) {
22
+ return null;
23
+ }
24
+ const usage = readDecisionUsage(raw.usage_metadata);
25
+ const response = isDecisionObject(raw.response_metadata)
26
+ ? raw.response_metadata
27
+ : null;
28
+ const nested = isDecisionObject(response?.metadata)
29
+ ? response.metadata
30
+ : null;
31
+ const bedrock = isDecisionObject(nested?.usage)
32
+ ? nested.usage
33
+ : response?.usage;
34
+ if (
35
+ usage?.inputTokens === undefined ||
36
+ !isDecisionObject(bedrock) ||
37
+ usage.inputTokens !== bedrock.inputTokens
38
+ ) {
39
+ return usage;
40
+ }
41
+ const read = bedrock.cacheReadInputTokens;
42
+ const write = bedrock.cacheWriteInputTokens;
43
+ if (
44
+ (read != null &&
45
+ (typeof read !== 'number' || !Number.isSafeInteger(read) || read < 0)) ||
46
+ (write != null &&
47
+ (typeof write !== 'number' || !Number.isSafeInteger(write) || write < 0))
48
+ ) {
49
+ return usage;
50
+ }
51
+ const inputTokens =
52
+ usage.inputTokens +
53
+ (typeof read === 'number' ? read : 0) +
54
+ (typeof write === 'number' ? write : 0);
55
+ return Number.isSafeInteger(inputTokens) ? { ...usage, inputTokens } : usage;
56
+ }
57
+
58
+ function providerFailure(error: unknown, provider: string): DecisionError {
59
+ const details = isDecisionObject(error) ? error : null;
60
+ const metadata = isDecisionObject(details?.$metadata)
61
+ ? details.$metadata
62
+ : null;
63
+ const rawStatus =
64
+ details?.status ?? details?.statusCode ?? metadata?.httpStatusCode;
65
+ const status =
66
+ typeof rawStatus === 'number' &&
67
+ Number.isInteger(rawStatus) &&
68
+ rawStatus >= 100 &&
69
+ rawStatus <= 599
70
+ ? rawStatus
71
+ : undefined;
72
+ const failure = new DecisionError(
73
+ status == null ? 'network' : failureForStatus(status),
74
+ 'structured decision model request failed',
75
+ { provider, status }
76
+ );
77
+ const headers = details?.headers;
78
+ if (headers instanceof Headers) {
79
+ failure.retryAfterMs = retryAfterMs(headers.get('retry-after'));
80
+ }
81
+ return failure;
82
+ }
83
+
84
+ export interface StructuredChatDecisionModelOptions {
85
+ /** An already configured chat model whose chosen method enforces strict schemas. */
86
+ model: BaseChatModel;
87
+ modelId: string;
88
+ providerId?: string;
89
+ /** Verified OpenAI strict modes or Anthropic strict tool calling; other adapters fail closed. */
90
+ method: 'jsonSchema' | 'functionCalling';
91
+ timeoutMs?: number;
92
+ maxQuestions?: number;
93
+ onAnswered?: (label: string, ms: number) => void;
94
+ }
95
+
96
+ function decisionSchema(
97
+ entries: Array<[string, DecisionQuestion]>
98
+ ): Record<string, unknown> {
99
+ const properties: Record<string, object> = Object.create(null);
100
+ for (const [id, question] of entries) {
101
+ if (question.type === 'boolean') {
102
+ properties[id] = {
103
+ type: 'object',
104
+ properties: { decision: { type: 'boolean' } },
105
+ required: ['decision'],
106
+ additionalProperties: false,
107
+ };
108
+ continue;
109
+ }
110
+ properties[id] = {
111
+ type: 'object',
112
+ properties: {
113
+ choice: { type: 'string', enum: Object.keys(question.criteria) },
114
+ },
115
+ required: ['choice'],
116
+ additionalProperties: false,
117
+ };
118
+ }
119
+ return {
120
+ type: 'object',
121
+ properties: {
122
+ answers: {
123
+ type: 'object',
124
+ properties,
125
+ required: entries.map(([id]) => id),
126
+ additionalProperties: false,
127
+ },
128
+ },
129
+ required: ['answers'],
130
+ additionalProperties: false,
131
+ };
132
+ }
133
+
134
+ /** Do not trust even a provider-parsed response: check every required id and choice locally. */
135
+ function readDecisions(
136
+ parsed: unknown,
137
+ entries: Array<[string, DecisionQuestion]>,
138
+ provider: string
139
+ ): DecisionResult['answers'] {
140
+ const invalid = (): DecisionError =>
141
+ new DecisionError(
142
+ 'malformed_response',
143
+ 'invalid structured decision model answer',
144
+ {
145
+ provider,
146
+ }
147
+ );
148
+ if (
149
+ !isDecisionObject(parsed) ||
150
+ Object.keys(parsed).length !== 1 ||
151
+ !isDecisionObject(parsed.answers) ||
152
+ Object.keys(parsed.answers).length !== entries.length
153
+ ) {
154
+ throw invalid();
155
+ }
156
+ const answers: DecisionResult['answers'] = Object.create(null);
157
+ for (const [id, question] of entries) {
158
+ if (!Object.hasOwn(parsed.answers, id)) {
159
+ throw invalid();
160
+ }
161
+ const raw = parsed.answers[id];
162
+ if (!isDecisionObject(raw) || Object.keys(raw).length !== 1) {
163
+ throw invalid();
164
+ }
165
+ if (question.type === 'boolean') {
166
+ if (typeof raw.decision !== 'boolean') {
167
+ throw invalid();
168
+ }
169
+ answers[id] = {
170
+ type: 'boolean',
171
+ decision: raw.decision,
172
+ probability: null,
173
+ };
174
+ continue;
175
+ }
176
+ if (
177
+ question.type !== 'choice' ||
178
+ typeof raw.choice !== 'string' ||
179
+ !Object.hasOwn(question.criteria, raw.choice)
180
+ ) {
181
+ throw invalid();
182
+ }
183
+ answers[id] = {
184
+ type: 'choice',
185
+ choice: raw.choice,
186
+ confidence: null,
187
+ probabilities: null,
188
+ };
189
+ }
190
+ return answers;
191
+ }
192
+
193
+ function questionsForChat(
194
+ questions: DecisionRequest['questions'],
195
+ provider: string,
196
+ maxQuestions: number
197
+ ): Array<[string, DecisionQuestion]> {
198
+ const entries = validateDecisionQuestions(questions, provider);
199
+ let choices = 0;
200
+ for (const [, question] of entries) {
201
+ if (question.type === 'score') {
202
+ throw new DecisionError(
203
+ 'unsupported_question',
204
+ 'chat decision cannot score expected values',
205
+ { provider }
206
+ );
207
+ }
208
+ if (question.type === 'choice') {
209
+ choices += Object.keys(question.criteria).length;
210
+ }
211
+ }
212
+ if (entries.length > maxQuestions || choices > MAX_CHOICE_OPTIONS) {
213
+ throw new DecisionError(
214
+ 'unsupported_question',
215
+ 'decision model question batch too large',
216
+ { provider }
217
+ );
218
+ }
219
+ return entries;
220
+ }
221
+
222
+ /** Strict provider schema or strict tool calling, without an agent loop or fabricated probabilities. */
223
+ export function createStructuredChatDecisionModel(
224
+ options: StructuredChatDecisionModelOptions
225
+ ): DecisionModel {
226
+ const provider = options.providerId ?? PROVIDER_ID;
227
+ if (!['jsonSchema', 'functionCalling'].includes(options.method)) {
228
+ throw new DecisionError(
229
+ 'unsupported_mode',
230
+ 'unsupported structured decision model mode',
231
+ {
232
+ provider,
233
+ }
234
+ );
235
+ }
236
+ if (!options.modelId.trim()) {
237
+ throw new DecisionError(
238
+ 'bad_request',
239
+ 'decision model requires a model id',
240
+ {
241
+ provider,
242
+ }
243
+ );
244
+ }
245
+ const maxQuestions = options.maxQuestions ?? MAX_QUESTIONS;
246
+ if (
247
+ !Number.isSafeInteger(maxQuestions) ||
248
+ maxQuestions < 1 ||
249
+ maxQuestions > MAX_QUESTIONS
250
+ ) {
251
+ throw new DecisionError(
252
+ 'bad_request',
253
+ 'invalid decision model question limit',
254
+ {
255
+ provider,
256
+ }
257
+ );
258
+ }
259
+ const { model, method } = options;
260
+ const modelId = options.modelId.trim();
261
+ const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
262
+ const onAnswered = options.onAnswered;
263
+
264
+ return {
265
+ id: provider,
266
+ model: modelId,
267
+ async decide(request: DecisionRequest): Promise<DecisionResult> {
268
+ const started = performance.now();
269
+ return withDecisionDeadline(
270
+ provider,
271
+ request.timeoutMs ?? timeoutMs,
272
+ request.signal,
273
+ async (signal, waitFor) => {
274
+ let entries: Array<[string, DecisionQuestion]>;
275
+ let input: string;
276
+ try {
277
+ entries = questionsForChat(
278
+ request.questions,
279
+ provider,
280
+ maxQuestions
281
+ );
282
+ input =
283
+ DECISION_PROMPT_PREFIX +
284
+ JSON.stringify({
285
+ state: request.state,
286
+ questions: Object.fromEntries(entries),
287
+ });
288
+ } catch (error) {
289
+ if (error instanceof DecisionError) {
290
+ throw error;
291
+ }
292
+ throw new DecisionError(
293
+ 'bad_request',
294
+ 'invalid decision model request',
295
+ {
296
+ provider,
297
+ }
298
+ );
299
+ }
300
+ let structured: ReturnType<typeof model.withStructuredOutput>;
301
+ try {
302
+ const adapter = model._llmType();
303
+ if (
304
+ adapter !== 'openai' &&
305
+ (adapter !== 'anthropic' || method !== 'functionCalling')
306
+ ) {
307
+ throw new Error('unverified strict decision model adapter');
308
+ }
309
+ structured = model.withStructuredOutput(decisionSchema(entries), {
310
+ name: 'DecideQuestions',
311
+ method,
312
+ strict: true,
313
+ includeRaw: true,
314
+ });
315
+ } catch {
316
+ throw new DecisionError(
317
+ 'unsupported_mode',
318
+ 'model cannot enforce the requested strict mode',
319
+ {
320
+ provider,
321
+ }
322
+ );
323
+ }
324
+ await waitFor(Promise.resolve());
325
+ let output: Awaited<ReturnType<typeof structured.invoke>>;
326
+ try {
327
+ output = await waitFor(
328
+ structured.invoke(
329
+ [
330
+ new SystemMessage(
331
+ 'Classify the state using the questions. Return only the required decisions. Do not estimate probabilities, confidence, or token usage.'
332
+ ),
333
+ new HumanMessage(input),
334
+ ],
335
+ { signal }
336
+ )
337
+ );
338
+ } catch (error) {
339
+ if (error instanceof DecisionError) {
340
+ throw error;
341
+ }
342
+ throw providerFailure(error, provider);
343
+ }
344
+ const answers = readDecisions(output.parsed, entries, provider);
345
+ const result: DecisionResult = {
346
+ model: modelId,
347
+ answers,
348
+ usage: readChatUsage(output.raw),
349
+ };
350
+ await waitFor(Promise.resolve());
351
+ try {
352
+ onAnswered?.(
353
+ request.label ?? 'decide',
354
+ performance.now() - started
355
+ );
356
+ } catch {
357
+ // A callback error must not make a valid decision fail.
358
+ }
359
+ return result;
360
+ }
361
+ );
362
+ },
363
+ };
364
+ }
@@ -0,0 +1,2 @@
1
+ /** Marks a JSON decision prompt so trace redaction can safely inspect its nested state. */
2
+ export const DECISION_PROMPT_PREFIX = 'librechat-decision-state:';
@@ -0,0 +1,335 @@
1
+ import type { AwaitWithinDeadline } from './deadline';
2
+ import type { DecisionCredential } from './types';
3
+ import { withDecisionDeadline } from './deadline';
4
+ import { DecisionError } from './types';
5
+
6
+ const DEFAULT_TIMEOUT_MS = 4_000;
7
+ const DEFAULT_MAX_RETRIES = 2;
8
+ const MAX_RESPONSE_BYTES = 256 * 1024;
9
+ const BACKOFF_MS = [250, 750, 1_500, 3_000, 6_000];
10
+ const MAX_RETRY_AFTER_MS = 10_000;
11
+
12
+ export type DecisionFetch = (
13
+ input: string,
14
+ init: {
15
+ method: string;
16
+ headers: Record<string, string>;
17
+ body: string;
18
+ signal: AbortSignal;
19
+ redirect: 'error';
20
+ }
21
+ ) => Promise<Pick<Response, 'ok' | 'status' | 'headers' | 'body'>>;
22
+
23
+ export interface TransportOptions {
24
+ providerId: string;
25
+ apiKey?: DecisionCredential;
26
+ requiresAuth?: boolean;
27
+ /** Full URL, not a base path. */
28
+ endpoint: string;
29
+ timeoutMs?: number;
30
+ maxRetries?: number;
31
+ fetch?: DecisionFetch;
32
+ sleep?: (ms: number, signal?: AbortSignal) => Promise<void>;
33
+ /** Called only after the response parses, with no state, answers, or credentials. */
34
+ onAnswered?: (label: string, ms: number) => void;
35
+ }
36
+
37
+ export type Transport = <T>(
38
+ prepare: () => { payload: string; parse: (body: string) => T },
39
+ signal: AbortSignal | undefined,
40
+ label: string,
41
+ timeoutOverrideMs?: number
42
+ ) => Promise<T>;
43
+
44
+ export function failureForStatus(
45
+ status: number
46
+ ): 'unauthorized' | 'rate_limited' | 'server_error' | 'bad_request' {
47
+ if (status === 401 || status === 403) {
48
+ return 'unauthorized';
49
+ }
50
+ if (status === 429) {
51
+ return 'rate_limited';
52
+ }
53
+ if (status >= 500) {
54
+ return 'server_error';
55
+ }
56
+ return 'bad_request';
57
+ }
58
+
59
+ export function retryAfterMs(header: string | null): number | undefined {
60
+ if (header == null || header === '') {
61
+ return undefined;
62
+ }
63
+ const seconds = Number(header);
64
+ if (Number.isFinite(seconds) && seconds >= 0) {
65
+ return Math.min(seconds * 1_000, MAX_RETRY_AFTER_MS);
66
+ }
67
+ const at = Date.parse(header);
68
+ return Number.isFinite(at)
69
+ ? Math.min(Math.max(at - Date.now(), 0), MAX_RETRY_AFTER_MS)
70
+ : undefined;
71
+ }
72
+
73
+ function defaultSleep(ms: number, signal?: AbortSignal): Promise<void> {
74
+ if (signal?.aborted === true) {
75
+ return Promise.resolve();
76
+ }
77
+ return new Promise((resolve) => {
78
+ const done = (): void => {
79
+ clearTimeout(timer);
80
+ signal?.removeEventListener('abort', done);
81
+ resolve();
82
+ };
83
+ const timer = setTimeout(done, ms);
84
+ signal?.addEventListener('abort', done, { once: true });
85
+ });
86
+ }
87
+
88
+ /** Stream successful bodies only, so neither errors nor oversized replies are ever buffered. */
89
+ async function readResponse(
90
+ body: ReadableStream<Uint8Array> | null,
91
+ waitFor: AwaitWithinDeadline,
92
+ provider: string
93
+ ): Promise<string> {
94
+ if (!body) {
95
+ return '';
96
+ }
97
+ const reader = body.getReader();
98
+ const decoder = new TextDecoder();
99
+ let bytes = 0;
100
+ let text = '';
101
+ try {
102
+ for (;;) {
103
+ const chunk = await waitFor(reader.read());
104
+ if (chunk.done) {
105
+ return text + decoder.decode();
106
+ }
107
+ bytes += chunk.value.byteLength;
108
+ if (bytes > MAX_RESPONSE_BYTES) {
109
+ throw new DecisionError(
110
+ 'malformed_response',
111
+ 'decision model response too large',
112
+ {
113
+ provider,
114
+ }
115
+ );
116
+ }
117
+ text += decoder.decode(chunk.value, { stream: true });
118
+ }
119
+ } catch (error) {
120
+ void reader.cancel().catch(() => {});
121
+ throw error;
122
+ } finally {
123
+ try {
124
+ reader.releaseLock();
125
+ } catch {
126
+ // A timed-out read still holds the lock until cancellation settles.
127
+ }
128
+ }
129
+ }
130
+
131
+ /** One immutable transport; every call owns its signal, deadline, and credentials. */
132
+ export function createTransport(options: TransportOptions): Transport {
133
+ const { providerId } = options;
134
+ const credential = options.apiKey;
135
+ if (
136
+ options.requiresAuth !== false &&
137
+ (credential == null ||
138
+ (typeof credential === 'string' && credential.trim() === ''))
139
+ ) {
140
+ throw new DecisionError(
141
+ 'unauthorized',
142
+ 'decision model requires an API key',
143
+ {
144
+ provider: providerId,
145
+ }
146
+ );
147
+ }
148
+ const endpoint = options.endpoint.trim();
149
+ if (!endpoint) {
150
+ throw new DecisionError(
151
+ 'bad_request',
152
+ 'decision model requires an endpoint',
153
+ {
154
+ provider: providerId,
155
+ }
156
+ );
157
+ }
158
+ const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
159
+ const maxRetries = options.maxRetries ?? DEFAULT_MAX_RETRIES;
160
+ const sleep = options.sleep ?? defaultSleep;
161
+ const onAnswered = options.onAnswered;
162
+ if (!Number.isSafeInteger(maxRetries) || maxRetries < 0 || maxRetries > 5) {
163
+ throw new DecisionError(
164
+ 'bad_request',
165
+ 'invalid decision model retry limit',
166
+ {
167
+ provider: providerId,
168
+ }
169
+ );
170
+ }
171
+ const fetchImpl: DecisionFetch = options.fetch ?? globalThis.fetch;
172
+ if (typeof fetchImpl !== 'function') {
173
+ throw new DecisionError('network', 'no fetch implementation available', {
174
+ provider: providerId,
175
+ });
176
+ }
177
+
178
+ async function resolveKey(
179
+ refresh: boolean,
180
+ signal: AbortSignal
181
+ ): Promise<string | null> {
182
+ if (typeof credential === 'string') {
183
+ return credential.trim() || null;
184
+ }
185
+ if (!credential) {
186
+ return null;
187
+ }
188
+ const minted: string = await credential({ refresh, signal });
189
+ const token = typeof minted === 'string' ? minted.trim() : '';
190
+ if (!token) {
191
+ throw new DecisionError(
192
+ 'unauthorized',
193
+ 'credential function returned no token',
194
+ {
195
+ provider: providerId,
196
+ }
197
+ );
198
+ }
199
+ return token;
200
+ }
201
+
202
+ return async function send(prepare, callerSignal, label, timeoutOverrideMs) {
203
+ const started = performance.now();
204
+ return withDecisionDeadline(
205
+ providerId,
206
+ timeoutOverrideMs ?? timeoutMs,
207
+ callerSignal,
208
+ async (signal, waitFor) => {
209
+ let prepared: ReturnType<typeof prepare>;
210
+ try {
211
+ prepared = prepare();
212
+ } catch (error) {
213
+ if (error instanceof DecisionError) {
214
+ throw error;
215
+ }
216
+ throw new DecisionError(
217
+ 'bad_request',
218
+ 'invalid decision model request',
219
+ {
220
+ provider: providerId,
221
+ }
222
+ );
223
+ }
224
+ const { payload, parse } = prepared;
225
+ await waitFor(Promise.resolve());
226
+ const wasAborted = (): boolean => signal.aborted;
227
+ const interrupted = (): DecisionError =>
228
+ new DecisionError(
229
+ callerSignal?.aborted === true ? 'aborted' : 'timeout',
230
+ 'decision model request interrupted',
231
+ { provider: providerId }
232
+ );
233
+ let refreshed = false;
234
+ let key: string | null | undefined;
235
+ let lastError: DecisionError | undefined;
236
+ for (let attemptNo = 0; attemptNo <= maxRetries; attemptNo++) {
237
+ if (wasAborted()) {
238
+ throw interrupted();
239
+ }
240
+ try {
241
+ if (key === undefined) {
242
+ key = await waitFor(resolveKey(refreshed, signal));
243
+ }
244
+ const response = await waitFor(
245
+ fetchImpl(endpoint, {
246
+ method: 'POST',
247
+ headers: {
248
+ ...(key !== null ? { Authorization: `Bearer ${key}` } : {}),
249
+ 'Content-Type': 'application/json',
250
+ },
251
+ body: payload,
252
+ signal,
253
+ redirect: 'error',
254
+ })
255
+ );
256
+ if (!response.ok) {
257
+ void response.body?.cancel().catch(() => {});
258
+ const error = new DecisionError(
259
+ failureForStatus(response.status),
260
+ `decision model returned HTTP ${response.status}`,
261
+ { provider: providerId, status: response.status }
262
+ );
263
+ error.retryAfterMs = retryAfterMs(
264
+ response.headers.get('retry-after')
265
+ );
266
+ throw error;
267
+ }
268
+ const body = await readResponse(response.body, waitFor, providerId);
269
+ const result = await waitFor(Promise.resolve(parse(body)));
270
+ try {
271
+ onAnswered?.(label, performance.now() - started);
272
+ } catch {
273
+ // Observability must not turn a valid answer into a retried request.
274
+ }
275
+ return result;
276
+ } catch (error) {
277
+ if (wasAborted()) {
278
+ throw interrupted();
279
+ }
280
+ if (error instanceof DecisionError) {
281
+ lastError = error;
282
+ } else {
283
+ lastError = new DecisionError(
284
+ 'network',
285
+ 'decision model request failed',
286
+ { provider: providerId }
287
+ );
288
+ }
289
+ if (
290
+ lastError.status === 401 &&
291
+ typeof credential === 'function' &&
292
+ !refreshed
293
+ ) {
294
+ refreshed = true;
295
+ key = undefined;
296
+ attemptNo -= 1;
297
+ continue;
298
+ }
299
+ if (
300
+ attemptNo === maxRetries ||
301
+ !['rate_limited', 'server_error', 'network'].includes(
302
+ lastError.failure
303
+ )
304
+ ) {
305
+ break;
306
+ }
307
+ const wait =
308
+ lastError.retryAfterMs ??
309
+ BACKOFF_MS[Math.min(attemptNo, BACKOFF_MS.length - 1)];
310
+ try {
311
+ await waitFor(sleep(wait, signal));
312
+ } catch (error) {
313
+ if (error instanceof DecisionError) {
314
+ throw error;
315
+ }
316
+ throw new DecisionError(
317
+ 'network',
318
+ 'decision model backoff failed',
319
+ {
320
+ provider: providerId,
321
+ }
322
+ );
323
+ }
324
+ }
325
+ }
326
+ throw (
327
+ lastError ??
328
+ new DecisionError('network', 'decision model request failed', {
329
+ provider: providerId,
330
+ })
331
+ );
332
+ }
333
+ );
334
+ };
335
+ }