@betterlu/agentlu 1.5.0 → 1.7.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.
package/README.md CHANGED
@@ -10,7 +10,7 @@ const client = new OpenAI({ baseURL: "http://localhost:8912/v1", apiKey });
10
10
  const r = await client.responses.create({ model: "fast", input: "hi" });
11
11
  ```
12
12
 
13
- This client adds get-or-create registration, typed `runJson`, and auto-retry on `409 thread_busy`.
13
+ This client adds get-or-create registration, typed `runJson` / `runText`, use-case catalogs, background `wait`, client-side `timeoutMs`, and auto-retry on `409 thread_busy`.
14
14
 
15
15
  ## Install
16
16
 
@@ -75,6 +75,48 @@ const { data } = await mealEnricher.runJson<{ meal: string }>({ input: "two eggs
75
75
 
76
76
  Set a field on `run` or `runJson` to override its registered default. `strict: false` still requires parsed JSON but does not require that value to match the schema.
77
77
 
78
+ Do **not** put `maxOutputTokens` on client defaults — the server ignores it. Pass `max_output_tokens` (or `maxOutputTokens`) on the run.
79
+
80
+ Several use-cases sharing one client:
81
+
82
+ ```ts
83
+ const ai = alu.useCases({
84
+ extract: {
85
+ name: "myapp-extract",
86
+ defaults: { agent: "api", store: false, timeoutMs: 45_000, instructions: "Parse into JSON." },
87
+ },
88
+ tag: { defaults: { agent: "api", store: false } },
89
+ });
90
+ await ai.ensure(); // boot
91
+ const { data } = await ai.extract.runJson<{ items: string[] }>({ input: "two milk" });
92
+ ```
93
+
94
+ Plain text (throws unless `status === "completed"` with non-empty `output_text`):
95
+
96
+ ```ts
97
+ const { text } = await ai.tag.runText({ input: "summarize this" });
98
+ ```
99
+
100
+ `timeoutMs` aborts the HTTP attempt and, when `timeout_ms` is omitted, is also sent as the server-side run budget. Constructor `new AgentLu({ timeoutMs: 30_000 })` is the default for calls that omit it.
101
+
102
+ Upload-and-run:
103
+
104
+ ```ts
105
+ await extract.runJson({
106
+ input: "what's in this photo",
107
+ files: [{ data: bytes, filename: "shelf.jpg", mime: "image/jpeg" }],
108
+ });
109
+ ```
110
+
111
+ Background poll:
112
+
113
+ ```ts
114
+ const queued = await extract.run({ input, schema, background: true, store: false });
115
+ const done = await alu.responses.wait(queued.response.id, { intervalMs: 1000, timeoutMs: 120_000 });
116
+ ```
117
+
118
+ `runJson({ attempts: 2 })` re-asks once after `schema_validation_failed` / `incomplete` / `missing_output_parsed`. It never scrapes `output_text`.
119
+
78
120
  Env: `AGENTLU_URL` (default `http://localhost:8912/v1`), `AGENTLU_API_KEY`.
79
121
 
80
122
  ## Decisions (System One / Jev)
@@ -113,10 +155,12 @@ if (answers.team.type === "choice") return lookupOrder(answers.team.choice);
113
155
  | `choice` | `choice`, `confidence`, `probabilities` per option |
114
156
  | `score` | `score`, `confidence`, `legend`, `probabilities` per step |
115
157
 
116
- `decisions.ask()` is the same call returning only the answers map. Discover the
158
+ `decisions.ask()` is the same call returning only the answers map.
159
+ `decisions.askOrNull()` is fail-open (`null` on unconfigured/error). Discover the
117
160
  model in `GET /v1/models` (`harness: "systemone"`); it is only callable on
118
161
  `POST /v1/decisions`, sync, no streaming. A missing key surfaces as HTTP 503
119
- `systemone_unconfigured` (not retried by the SDK).
162
+ `systemone_unconfigured` (not retried by the SDK). Use-case runners expose the
163
+ same `decisions` helpers on their own key.
120
164
 
121
165
  Decision calls accept three client-side options beyond `state` / `questions` /
122
166
  `model`:
package/dist/index.d.ts CHANGED
@@ -6,20 +6,36 @@
6
6
  * new OpenAI({ baseURL: "http://localhost:8912/v1", apiKey })
7
7
  *
8
8
  * This client exists for the ergonomic bits OpenAI's SDK does not know about:
9
- * get-or-create registration, typed `runJson`, and auto-retry on
10
- * `409 thread_busy`.
9
+ * get-or-create registration, typed `runJson` / `runText`, use-case catalogs,
10
+ * background `wait`, and auto-retry on `409 thread_busy`.
11
11
  */
12
12
  export declare const DEFAULT_BASE_URL = "http://localhost:8912/v1";
13
+ /**
14
+ * Accept an origin (`http://host:8912`) or a `/v1` base and normalize to the
15
+ * `/v1` base the API actually serves, so `new AgentLu({ baseUrl: "http://host:8912" })`
16
+ * works the same as the documented default.
17
+ */
18
+ export declare function normalizeBaseUrl(raw: string): string;
13
19
  export type Backend = "pi";
14
20
  export type ThinkingLevel = "off" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max";
15
21
  export type ApiToolFamily = "web_search" | "fetch_content" | "hublu" | "memory" | "image" | "browser" | "android";
22
+ export type ApiToolChoice = "auto" | "none" | "required";
16
23
  /** Closed tool-family request accepted by `/v1/responses`. */
17
24
  export interface ApiToolSpec {
18
25
  type: ApiToolFamily;
19
26
  /** Optional family-specific operation allowlist (HubLu, BrowserLu, or AndroidLu). */
20
27
  allowed_tools?: string[];
21
28
  }
22
- export type ApiToolInput = ApiToolFamily | ApiToolSpec;
29
+ /**
30
+ * Native MCP server for the machine-only `api` agent. Mounted per run;
31
+ * `allowed_tools` pre-scopes the names the server lists.
32
+ */
33
+ export interface ApiMcpServerSpec {
34
+ type: "mcp";
35
+ url: string;
36
+ allowed_tools?: string[];
37
+ }
38
+ export type ApiToolInput = ApiToolFamily | ApiToolSpec | ApiMcpServerSpec;
23
39
  export type BrowserStatus = "active" | "needs_human" | "human_control" | "idle" | "closed" | "interrupted";
24
40
  export interface BrowserScreenshot {
25
41
  id: string;
@@ -87,6 +103,14 @@ export type TextFormat = TextFormatText | TextFormatJsonSchema;
87
103
  export interface TextConfig {
88
104
  format?: TextFormat;
89
105
  }
106
+ export type FileKind = "image" | "video" | "text" | "document";
107
+ /** Local file to upload and attach on the same `run` / `stream`. */
108
+ export interface RunFile {
109
+ data: Uint8Array | Blob;
110
+ filename: string;
111
+ mime?: string;
112
+ kind?: FileKind;
113
+ }
90
114
  /** Yes/no, answered as the probability that the answer is yes. */
91
115
  export interface NoulQuestion {
92
116
  type: "noul";
@@ -137,7 +161,7 @@ export interface DecisionOptions {
137
161
  /**
138
162
  * Per-attempt client deadline in milliseconds. A timeout aborts the request
139
163
  * and is never retried; without it the request is bounded only by the
140
- * caller's fetch implementation.
164
+ * caller's fetch implementation (or the AgentLu constructor `timeoutMs`).
141
165
  */
142
166
  timeoutMs?: number;
143
167
  /** Total attempts including the first. Defaults to the shared 5-attempt policy. */
@@ -158,6 +182,12 @@ export interface DecisionObject {
158
182
  } | null;
159
183
  [k: string]: unknown;
160
184
  }
185
+ export interface DecisionApi {
186
+ create: (body: DecisionOptions) => Promise<DecisionObject>;
187
+ ask: (body: DecisionOptions) => Promise<Record<string, DecisionAnswer>>;
188
+ /** Fail-open: returns `null` when the model is unconfigured or the call fails. */
189
+ askOrNull: (body: DecisionOptions) => Promise<Record<string, DecisionAnswer> | null>;
190
+ }
161
191
  /** Question builders; sugar over the wire shape. */
162
192
  export declare const noul: (instructions: string, criteria?: {
163
193
  true: string;
@@ -184,6 +214,12 @@ export interface AgentLuOptions {
184
214
  baseUrl?: string;
185
215
  apiKey?: string;
186
216
  fetch?: typeof fetch;
217
+ /**
218
+ * Default per-attempt client deadline for `run` / `stream` / `upload` /
219
+ * `decisions` when the call omits `timeoutMs`. Also fills `timeout_ms` on
220
+ * `/v1/responses` when the call omits that field.
221
+ */
222
+ timeoutMs?: number;
187
223
  }
188
224
  export interface ClientDefaults {
189
225
  backend?: Backend;
@@ -196,6 +232,11 @@ export interface ClientDefaults {
196
232
  store?: boolean;
197
233
  reasoning?: ClientReasoningOptions;
198
234
  text?: TextConfig;
235
+ /**
236
+ * Retired on client defaults (server ignores it). Pass `max_output_tokens`
237
+ * or `maxOutputTokens` on `run` / `runJson` / `stream` instead.
238
+ */
239
+ maxOutputTokens?: never;
199
240
  }
200
241
  /** High-level use-case declaration for model routing and priority tier. */
201
242
  export interface UseCaseProfile {
@@ -204,18 +245,33 @@ export interface UseCaseProfile {
204
245
  /** MiniMax priority admission consent (priority costs 1.5x standard). */
205
246
  priorityTier?: "never" | "retry" | "first" | "always";
206
247
  }
248
+ export interface UseCaseDeclaration {
249
+ /** Registered client name. Defaults to the catalog key. */
250
+ name?: string;
251
+ person?: string | null;
252
+ defaults?: ClientDefaults;
253
+ use_case_profile?: UseCaseProfile;
254
+ }
255
+ export type UseCaseCatalog<T extends Record<string, UseCaseDeclaration>> = {
256
+ [K in keyof T]: UseCaseRunner;
257
+ } & {
258
+ /** Idempotent get-or-create for every use-case. Safe to call on boot. */
259
+ ensure: () => Promise<void>;
260
+ };
207
261
  export interface RunOptions {
208
262
  backend?: Backend;
209
263
  agent?: string;
210
264
  model?: string;
211
265
  input?: string;
212
266
  /**
213
- * Attachments previously uploaded via `upload()`. Prepended to the user
214
- * turn using each file's `kind` (`input_image` / `input_video` / `input_file`).
215
- */
267
+ * Attachments previously uploaded via `upload()`. Prepended to the user
268
+ * turn using each file's `kind` (`input_image` / `input_video` / `input_file`).
269
+ */
216
270
  fileIds?: string[];
217
271
  /** Optional kinds aligned with `fileIds` (from `upload()`). Defaults to image. */
218
- fileKinds?: Array<"image" | "video" | "text" | "document">;
272
+ fileKinds?: FileKind[];
273
+ /** Upload-and-attach in one call. Combined with `fileIds` when both are set. */
274
+ files?: RunFile[];
219
275
  /** Structured input messages (OpenAI shape) - overrides `input`. */
220
276
  inputMessages?: Array<{
221
277
  role: string;
@@ -223,18 +279,32 @@ export interface RunOptions {
223
279
  }>;
224
280
  instructions?: string;
225
281
  tools?: ApiToolInput[];
282
+ tool_choice?: ApiToolChoice;
226
283
  reasoning?: ReasoningOptions;
227
284
  text?: TextConfig;
228
285
  strict?: boolean;
229
286
  conversation?: string;
287
+ previous_response_id?: string;
230
288
  store?: boolean;
231
289
  person?: string | null;
232
290
  owner?: "client" | "person";
233
291
  title?: string;
292
+ /** Server-side run budget (wire name). */
234
293
  timeout_ms?: number;
294
+ /**
295
+ * Per-attempt client deadline. When `timeout_ms` is omitted, the same value
296
+ * is also sent as the server-side budget.
297
+ */
298
+ timeoutMs?: number;
299
+ /** Caller cancellation, combined with `timeoutMs` when both are set. */
300
+ signal?: AbortSignal;
301
+ /** Total HTTP attempts including the first. Defaults to the shared 5-attempt policy. */
302
+ maxAttempts?: number;
235
303
  include?: string[];
236
304
  temperature?: number;
237
305
  max_output_tokens?: number;
306
+ /** Alias for `max_output_tokens` on this run (not a client default). */
307
+ maxOutputTokens?: number;
238
308
  /** Caller metadata stored on the response row (console visibility). */
239
309
  metadata?: Record<string, string>;
240
310
  /** JSON Schema. When set, `data` on the result is the validated object. */
@@ -242,6 +312,36 @@ export interface RunOptions {
242
312
  schemaName?: string;
243
313
  /** Queue a background run instead of waiting. Returns the queued response. */
244
314
  background?: boolean;
315
+ /**
316
+ * Explicit idempotency key. Non-streaming runs always send one (generated
317
+ * when omitted) so SDK-level retries cannot double-run; set it yourself to
318
+ * make two separate `run` calls idempotent.
319
+ */
320
+ idempotencyKey?: string;
321
+ /**
322
+ * `runJson` only: extra model attempts after `schema_validation_failed`,
323
+ * `missing_output_parsed`, or `incomplete`. Defaults to 1 (no extra tries).
324
+ */
325
+ attempts?: number;
326
+ /** `run`/`runJson`/`runText` only: throw instead of returning when the run is `incomplete`. */
327
+ throwOnIncomplete?: boolean;
328
+ }
329
+ export interface WaitOptions {
330
+ /** Delay between polls. Defaults to 1000ms. */
331
+ intervalMs?: number;
332
+ /**
333
+ * Overall wait deadline (default 10 minutes). The in-flight poll is aborted
334
+ * when it fires. Pass `0` to wait indefinitely.
335
+ */
336
+ timeoutMs?: number;
337
+ signal?: AbortSignal;
338
+ }
339
+ /** Default overall deadline for `responses.wait`. */
340
+ export declare const DEFAULT_WAIT_TIMEOUT_MS: number;
341
+ export interface UploadOptions {
342
+ timeoutMs?: number;
343
+ signal?: AbortSignal;
344
+ maxAttempts?: number;
245
345
  }
246
346
  export interface ResponseObject {
247
347
  id: string;
@@ -273,10 +373,36 @@ export interface ResponseObject {
273
373
  };
274
374
  [k: string]: unknown;
275
375
  }
376
+ export declare function isTerminalStatus(status: string): boolean;
377
+ /**
378
+ * One SSE event from `/v1/responses` with `stream: true`. The event names and
379
+ * shapes mirror the OpenAI Responses stream; `delta` is the incremental text
380
+ * for `response.output_text.delta`.
381
+ */
382
+ export interface StreamEvent {
383
+ type: string;
384
+ sequence_number?: number;
385
+ /** Incremental text on `response.output_text.delta`. */
386
+ delta?: string;
387
+ [k: string]: unknown;
388
+ }
389
+ /**
390
+ * Accumulate a raw stream into typed events and the final text. `events()`
391
+ * yields every event; `finalText()` resolves after the stream ends with the
392
+ * concatenated `response.output_text.delta` payloads.
393
+ *
394
+ * const stream = alu.typedStream({ input: "hi" });
395
+ * for await (const ev of stream) { ... }
396
+ * const text = stream.finalText();
397
+ */
398
+ export declare function typedStream(source: AsyncIterable<Record<string, unknown>>): AsyncIterable<StreamEvent> & {
399
+ finalText: () => Promise<string>;
400
+ };
276
401
  export declare class AgentLu {
277
402
  readonly baseUrl: string;
278
403
  apiKey: string | undefined;
279
404
  private readonly fetchImpl;
405
+ private readonly defaultTimeoutMs;
280
406
  constructor(opts?: AgentLuOptions);
281
407
  private postClient;
282
408
  /** Get-or-create a client by name. Caches the returned key on this instance. */
@@ -295,12 +421,21 @@ export declare class AgentLu {
295
421
  * The use-case's defaults (system prompt, tools, temperature, ...) live
296
422
  * server-side on the client, so `run()` only needs the input. Registering a
297
423
  * use-case is registering a client - `alu.useCase("mealu-enrich", {...})`.
298
- */
424
+ */
299
425
  useCase(name: string, opts?: {
300
426
  person?: string | null;
301
427
  defaults?: ClientDefaults;
302
428
  use_case_profile?: UseCaseProfile;
303
429
  }): UseCaseRunner;
430
+ /**
431
+ * Several named use-case runners that share this client. Call `ensure()` on
432
+ * boot, then `catalog.extract.runJson({ input })`.
433
+ *
434
+ * const ai = alu.useCases({
435
+ * extract: { name: "deallu-extract", defaults: { agent: "api", store: false } },
436
+ * });
437
+ */
438
+ useCases<T extends Record<string, UseCaseDeclaration>>(specs: T): UseCaseCatalog<T>;
304
439
  /**
305
440
  * Register a use-case client and resolve to its api key. The key is cached
306
441
  * on this instance (same semantics as `register`), so a following
@@ -316,7 +451,7 @@ export declare class AgentLu {
316
451
  * Upload an image, video, or document for use with `run({ fileIds })`.
317
452
  * Returns the `file_id` and stored `kind`.
318
453
  */
319
- upload(data: Uint8Array | Blob, filename: string, mime?: string): Promise<{
454
+ upload(data: Uint8Array | Blob, filename: string, mime?: string, opts?: UploadOptions): Promise<{
320
455
  id: string;
321
456
  kind?: string;
322
457
  }>;
@@ -335,9 +470,33 @@ export declare class AgentLu {
335
470
  data: T;
336
471
  response: ResponseObject;
337
472
  }>;
473
+ runText(opts: RunOptions): Promise<{
474
+ text: string;
475
+ response: ResponseObject;
476
+ }>;
477
+ /**
478
+ * `runJson` that returns `null` instead of throwing on any failure. For
479
+ * best-effort enrichment where a missing result is a normal outcome.
480
+ */
481
+ runJsonOrNull<T = unknown>(opts: RunOptions): Promise<T | null>;
482
+ /** `runText` that returns `null` instead of throwing on any failure. */
483
+ runTextOrNull(opts: RunOptions): Promise<string | null>;
484
+ stream(opts: RunOptions): AsyncGenerator<Record<string, unknown>>;
485
+ /**
486
+ * Typed stream: yields `StreamEvent`s and exposes `finalText()` with the
487
+ * concatenated output text after the stream ends.
488
+ */
489
+ typedStream(opts: RunOptions): AsyncIterable<StreamEvent> & {
490
+ finalText: () => Promise<string>;
491
+ };
338
492
  private runForApiKey;
339
493
  private runJsonForApiKey;
494
+ private runJsonOnce;
495
+ private runTextForApiKey;
340
496
  private createRunResponse;
497
+ private buildRunBody;
498
+ private uploadRunFiles;
499
+ private transportFromRun;
341
500
  readonly responses: {
342
501
  create: (body: Record<string, unknown>) => Promise<ResponseObject>;
343
502
  retrieve: (id: string) => Promise<ResponseObject>;
@@ -350,8 +509,11 @@ export declare class AgentLu {
350
509
  data: ResponseObject[];
351
510
  }>;
352
511
  stream: (body: Record<string, unknown>) => AsyncGenerator<Record<string, unknown>, any, any>;
512
+ wait: (id: string, opts?: WaitOptions) => Promise<ResponseObject>;
353
513
  };
354
- private streamForApiKey;
514
+ private waitForApiKey;
515
+ private streamRunForApiKey;
516
+ private streamRawForApiKey;
355
517
  readonly threads: {
356
518
  create: (body?: Record<string, unknown>) => Promise<unknown>;
357
519
  retrieve: (id: string) => Promise<unknown>;
@@ -375,17 +537,14 @@ export declare class AgentLu {
375
537
  * `timeoutMs` bounds each attempt (timeouts are never retried), `maxAttempts`
376
538
  * overrides the shared 5-attempt policy, and `signal` cancels the call.
377
539
  */
378
- readonly decisions: {
379
- create: (body: DecisionOptions) => Promise<DecisionObject>;
380
- /** Same call, returning only the answers map. */
381
- ask: (body: DecisionOptions) => Promise<Record<string, DecisionAnswer>>;
382
- };
540
+ readonly decisions: DecisionApi;
383
541
  agents(): Promise<unknown>;
384
542
  models(): Promise<unknown>;
385
543
  tools(): Promise<unknown>;
386
544
  health(): Promise<unknown>;
387
545
  private request;
388
546
  private raw;
547
+ private rawOnce;
389
548
  }
390
549
  /**
391
550
  * A runner bound to one registered use-case (one service client + its
@@ -404,13 +563,28 @@ export declare class UseCaseRunner {
404
563
  private readonly operations;
405
564
  private apiKey;
406
565
  private ready;
566
+ /** In-flight/short-lived guard so a burst of runs does not stampede registration. */
567
+ private registerFailures;
568
+ private lastRegisterError;
569
+ private registerFailedAt;
570
+ /** Cooldown is short and only suppresses *repeated* failures in one burst. */
571
+ private static readonly REGISTER_COOLDOWN_MS;
407
572
  constructor(parent: AgentLu, name: string, opts?: {
408
573
  person?: string | null;
409
574
  defaults?: ClientDefaults;
410
575
  use_case_profile?: UseCaseProfile;
411
576
  });
412
- /** Idempotent get-or-create; safe to call before every run. */
413
- private ensure;
577
+ /**
578
+ * Idempotent get-or-create; safe to call on boot or before every run.
579
+ * A failed registration is remembered for a short cooldown so an app that
580
+ * calls `ensure()` in a loop does not hammer the server; runs always retry
581
+ * registration transparently (pass `force`).
582
+ */
583
+ ensure(opts?: {
584
+ force?: boolean;
585
+ }): Promise<void>;
586
+ private invalidate;
587
+ private withAuthRetry;
414
588
  /**
415
589
  * Run with this use-case's key. Request fields that are set override the
416
590
  * use-case's registered defaults; unset fields inherit them server-side.
@@ -423,13 +597,33 @@ export declare class UseCaseRunner {
423
597
  data: T;
424
598
  response: ResponseObject;
425
599
  }>;
426
- stream(body: Record<string, unknown>): Promise<AsyncGenerator<Record<string, unknown>, any, any>>;
600
+ runText(opts: RunOptions): Promise<{
601
+ text: string;
602
+ response: ResponseObject;
603
+ }>;
604
+ /** `runJson` that returns `null` instead of throwing on any failure. */
605
+ runJsonOrNull<T = unknown>(opts: RunOptions): Promise<T | null>;
606
+ /** `runText` that returns `null` instead of throwing on any failure. */
607
+ runTextOrNull(opts: RunOptions): Promise<string | null>;
608
+ stream(opts: RunOptions): AsyncGenerator<Record<string, unknown>>;
609
+ /** Typed stream for this use-case: events + `finalText()`. */
610
+ typedStream(opts: RunOptions): AsyncIterable<StreamEvent> & {
611
+ finalText: () => Promise<string>;
612
+ };
427
613
  /**
428
614
  * Upload with this use-case's key, so the file is owned by the same
429
615
  * service client that will reference it via `run({ fileIds })`.
430
616
  */
431
- upload(data: Uint8Array | Blob, filename: string, mime?: string): Promise<{
617
+ upload(data: Uint8Array | Blob, filename: string, mime?: string, opts?: UploadOptions): Promise<{
432
618
  id: string;
433
619
  kind?: string;
434
620
  }>;
621
+ readonly responses: {
622
+ retrieve: (id: string) => Promise<ResponseObject>;
623
+ cancel: (id: string) => Promise<ResponseObject>;
624
+ wait: (id: string, opts?: WaitOptions) => Promise<ResponseObject>;
625
+ };
626
+ readonly decisions: DecisionApi;
435
627
  }
628
+ /** True when an error should be retried by the shared policy. */
629
+ export declare function isRetryableError(err: unknown): boolean;
package/dist/index.js CHANGED
@@ -6,10 +6,21 @@
6
6
  * new OpenAI({ baseURL: "http://localhost:8912/v1", apiKey })
7
7
  *
8
8
  * This client exists for the ergonomic bits OpenAI's SDK does not know about:
9
- * get-or-create registration, typed `runJson`, and auto-retry on
10
- * `409 thread_busy`.
9
+ * get-or-create registration, typed `runJson` / `runText`, use-case catalogs,
10
+ * background `wait`, and auto-retry on `409 thread_busy`.
11
11
  */
12
12
  export const DEFAULT_BASE_URL = "http://localhost:8912/v1";
13
+ /**
14
+ * Accept an origin (`http://host:8912`) or a `/v1` base and normalize to the
15
+ * `/v1` base the API actually serves, so `new AgentLu({ baseUrl: "http://host:8912" })`
16
+ * works the same as the documented default.
17
+ */
18
+ export function normalizeBaseUrl(raw) {
19
+ const trimmed = raw.replace(/\/+$/, "");
20
+ if (/\/v1$/.test(trimmed))
21
+ return trimmed;
22
+ return `${trimmed}/v1`;
23
+ }
13
24
  /** Strip client-only decision options before the body reaches `/v1/decisions`. */
14
25
  function decisionWire(body) {
15
26
  const wire = { state: body.state, questions: body.questions };
@@ -17,8 +28,12 @@ function decisionWire(body) {
17
28
  wire.model = body.model;
18
29
  return wire;
19
30
  }
20
- function decisionRequestOptions(body) {
21
- return { timeoutMs: body.timeoutMs, maxAttempts: body.maxAttempts, signal: body.signal };
31
+ function decisionRequestOptions(body, defaultTimeoutMs) {
32
+ return {
33
+ timeoutMs: body.timeoutMs ?? defaultTimeoutMs,
34
+ maxAttempts: body.maxAttempts,
35
+ signal: body.signal,
36
+ };
22
37
  }
23
38
  /** Question builders; sugar over the wire shape. */
24
39
  export const noul = (instructions, criteria) => ({
@@ -65,6 +80,49 @@ export class AgentLuResponseError extends AgentLuError {
65
80
  this.responseStatus = responseStatus;
66
81
  }
67
82
  }
83
+ /** Default overall deadline for `responses.wait`. */
84
+ export const DEFAULT_WAIT_TIMEOUT_MS = 10 * 60_000;
85
+ export function isTerminalStatus(status) {
86
+ return status === "completed" || status === "failed" || status === "incomplete" || status === "cancelled";
87
+ }
88
+ /**
89
+ * Accumulate a raw stream into typed events and the final text. `events()`
90
+ * yields every event; `finalText()` resolves after the stream ends with the
91
+ * concatenated `response.output_text.delta` payloads.
92
+ *
93
+ * const stream = alu.typedStream({ input: "hi" });
94
+ * for await (const ev of stream) { ... }
95
+ * const text = stream.finalText();
96
+ */
97
+ export function typedStream(source) {
98
+ let text = "";
99
+ let done = false;
100
+ const it = (async function* () {
101
+ try {
102
+ for await (const raw of source) {
103
+ const ev = raw;
104
+ if (ev.type === "response.output_text.delta" && typeof ev.delta === "string") {
105
+ text += ev.delta;
106
+ }
107
+ yield ev;
108
+ }
109
+ }
110
+ finally {
111
+ done = true;
112
+ }
113
+ })();
114
+ return Object.assign(it, {
115
+ finalText: async () => {
116
+ // Drain if the caller never iterated to the end.
117
+ if (!done) {
118
+ for await (const _ of it) {
119
+ void _;
120
+ }
121
+ }
122
+ return text;
123
+ },
124
+ });
125
+ }
68
126
  function env(name) {
69
127
  return typeof process !== "undefined" ? process.env[name] : undefined;
70
128
  }
@@ -73,20 +131,37 @@ export class AgentLu {
73
131
  baseUrl;
74
132
  apiKey;
75
133
  fetchImpl;
134
+ defaultTimeoutMs;
76
135
  constructor(opts = {}) {
77
- this.baseUrl = (opts.baseUrl ?? env("AGENTLU_URL") ?? DEFAULT_BASE_URL).replace(/\/$/, "");
136
+ this.baseUrl = normalizeBaseUrl(opts.baseUrl ?? env("AGENTLU_URL") ?? DEFAULT_BASE_URL);
78
137
  this.apiKey = opts.apiKey ?? env("AGENTLU_API_KEY");
79
138
  this.fetchImpl = opts.fetch ?? globalThis.fetch.bind(globalThis);
139
+ this.defaultTimeoutMs = opts.timeoutMs;
80
140
  scopedUseCaseOperations.set(this, {
81
141
  register: (name, registerOpts) => this.registerForUseCase(name, registerOpts),
82
142
  run: (apiKey, runOpts) => this.runForApiKey(apiKey, runOpts),
83
143
  runJson: (apiKey, runOpts) => this.runJsonForApiKey(apiKey, runOpts),
84
- stream: (apiKey, body) => this.streamForApiKey(body, apiKey),
85
- upload: (apiKey, data, filename, mime) => this.uploadForApiKey(data, filename, mime, apiKey),
144
+ runText: (apiKey, runOpts) => this.runTextForApiKey(apiKey, runOpts),
145
+ stream: (apiKey, runOpts) => this.streamRunForApiKey(runOpts, apiKey),
146
+ upload: (apiKey, data, filename, mime, uploadOpts) => this.uploadForApiKey(data, filename, mime, apiKey, uploadOpts),
147
+ wait: (apiKey, id, waitOpts) => this.waitForApiKey(id, apiKey, waitOpts),
148
+ retrieve: (apiKey, id, reqOpts) => this.request("GET", `/responses/${encodeURIComponent(id)}`, undefined, {
149
+ ...reqOpts,
150
+ apiKey,
151
+ }),
152
+ cancel: (apiKey, id, reqOpts) => this.request("POST", `/responses/${encodeURIComponent(id)}/cancel`, undefined, {
153
+ ...reqOpts,
154
+ apiKey,
155
+ }),
156
+ decideCreate: (apiKey, body) => this.request("POST", "/decisions", decisionWire(body), { ...decisionRequestOptions(body, this.defaultTimeoutMs), apiKey }),
157
+ decideAsk: async (apiKey, body) => (await this.request("POST", "/decisions", decisionWire(body), { ...decisionRequestOptions(body, this.defaultTimeoutMs), apiKey })).answers,
86
158
  });
87
159
  }
88
160
  postClient(name, opts) {
89
- return this.request("POST", "/clients", { name, ...opts }, { auth: false });
161
+ // Registration is get-or-create and callers gate it behind `ensure()`'s
162
+ // cooldown; two attempts smooth a blip without stacking long backoffs
163
+ // before the cooldown can do its job.
164
+ return this.request("POST", "/clients", { name, ...opts }, { auth: false, maxAttempts: 2 });
90
165
  }
91
166
  /** Get-or-create a client by name. Caches the returned key on this instance. */
92
167
  async register(name, opts) {
@@ -101,10 +176,30 @@ export class AgentLu {
101
176
  * The use-case's defaults (system prompt, tools, temperature, ...) live
102
177
  * server-side on the client, so `run()` only needs the input. Registering a
103
178
  * use-case is registering a client - `alu.useCase("mealu-enrich", {...})`.
104
- */
179
+ */
105
180
  useCase(name, opts) {
106
181
  return new UseCaseRunner(this, name, opts);
107
182
  }
183
+ /**
184
+ * Several named use-case runners that share this client. Call `ensure()` on
185
+ * boot, then `catalog.extract.runJson({ input })`.
186
+ *
187
+ * const ai = alu.useCases({
188
+ * extract: { name: "deallu-extract", defaults: { agent: "api", store: false } },
189
+ * });
190
+ */
191
+ useCases(specs) {
192
+ const runners = {};
193
+ for (const key of Object.keys(specs)) {
194
+ const spec = specs[key];
195
+ runners[key] = this.useCase(spec.name ?? String(key), spec);
196
+ }
197
+ const catalog = { ...runners };
198
+ catalog.ensure = async () => {
199
+ await Promise.all(Object.values(runners).map((runner) => runner.ensure()));
200
+ };
201
+ return catalog;
202
+ }
108
203
  /**
109
204
  * Register a use-case client and resolve to its api key. The key is cached
110
205
  * on this instance (same semantics as `register`), so a following
@@ -127,14 +222,19 @@ export class AgentLu {
127
222
  * Upload an image, video, or document for use with `run({ fileIds })`.
128
223
  * Returns the `file_id` and stored `kind`.
129
224
  */
130
- async upload(data, filename, mime) {
131
- return this.uploadForApiKey(data, filename, mime, this.apiKey);
225
+ async upload(data, filename, mime, opts) {
226
+ return this.uploadForApiKey(data, filename, mime, this.apiKey, opts);
132
227
  }
133
- async uploadForApiKey(data, filename, mime, apiKey) {
228
+ async uploadForApiKey(data, filename, mime, apiKey, opts) {
134
229
  const form = new FormData();
135
230
  const blob = data instanceof Blob ? data : new Blob([new Uint8Array(data)], { type: mime ?? "application/octet-stream" });
136
231
  form.append("file", blob, filename);
137
- const res = await this.raw("POST", "/files", form, { apiKey });
232
+ const res = await this.raw("POST", "/files", form, {
233
+ apiKey,
234
+ timeoutMs: opts?.timeoutMs,
235
+ signal: opts?.signal,
236
+ maxAttempts: opts?.maxAttempts,
237
+ });
138
238
  return (await res.json());
139
239
  }
140
240
  async me() {
@@ -152,14 +252,68 @@ export class AgentLu {
152
252
  assertJsonRunOptions(opts);
153
253
  return this.runJsonForApiKey(this.apiKey, opts);
154
254
  }
255
+ async runText(opts) {
256
+ return this.runTextForApiKey(this.apiKey, opts);
257
+ }
258
+ /**
259
+ * `runJson` that returns `null` instead of throwing on any failure. For
260
+ * best-effort enrichment where a missing result is a normal outcome.
261
+ */
262
+ async runJsonOrNull(opts) {
263
+ try {
264
+ const { data } = await this.runJson(opts);
265
+ return data;
266
+ }
267
+ catch {
268
+ return null;
269
+ }
270
+ }
271
+ /** `runText` that returns `null` instead of throwing on any failure. */
272
+ async runTextOrNull(opts) {
273
+ try {
274
+ const { text } = await this.runText(opts);
275
+ return text;
276
+ }
277
+ catch {
278
+ return null;
279
+ }
280
+ }
281
+ async *stream(opts) {
282
+ yield* this.streamRunForApiKey(opts, this.apiKey);
283
+ }
284
+ /**
285
+ * Typed stream: yields `StreamEvent`s and exposes `finalText()` with the
286
+ * concatenated output text after the stream ends.
287
+ */
288
+ typedStream(opts) {
289
+ return typedStream(this.stream(opts));
290
+ }
155
291
  async runForApiKey(apiKey, opts) {
156
292
  const response = await this.createRunResponse(opts, apiKey);
157
293
  if (response.status === "failed" && response.error) {
158
294
  throw new AgentLuError(200, response.error.code, response.error.message, response.error.retryable);
159
295
  }
296
+ if (opts.throwOnIncomplete && response.status === "incomplete") {
297
+ throw new AgentLuResponseError(response);
298
+ }
160
299
  return { data: response.output_parsed, response };
161
300
  }
162
301
  async runJsonForApiKey(apiKey, opts) {
302
+ const attempts = jsonAttempts(opts);
303
+ let last;
304
+ for (let attempt = 0; attempt < attempts; attempt++) {
305
+ try {
306
+ return await this.runJsonOnce(apiKey, opts);
307
+ }
308
+ catch (e) {
309
+ last = e;
310
+ if (!isJsonRetryable(e) || attempt >= attempts - 1)
311
+ throw e;
312
+ }
313
+ }
314
+ throw last;
315
+ }
316
+ async runJsonOnce(apiKey, opts) {
163
317
  const response = await this.createRunResponse(opts, apiKey);
164
318
  if (response.status !== "completed")
165
319
  throw new AgentLuResponseError(response);
@@ -168,15 +322,44 @@ export class AgentLu {
168
322
  }
169
323
  return { data: response.output_parsed, response };
170
324
  }
325
+ async runTextForApiKey(apiKey, opts) {
326
+ const response = await this.createRunResponse(opts, apiKey);
327
+ if (response.status !== "completed")
328
+ throw new AgentLuResponseError(response);
329
+ const text = response.output_text ?? "";
330
+ if (!text.trim()) {
331
+ throw new AgentLuError(200, "empty_output", "Completed response did not include output_text.", false);
332
+ }
333
+ return { text, response };
334
+ }
171
335
  async createRunResponse(opts, apiKey) {
336
+ const body = await this.buildRunBody(opts, apiKey);
337
+ const transport = this.transportFromRun(opts, apiKey);
338
+ // A non-streaming run is retried on transient faults; an auto key makes
339
+ // those retries safe (the server replays the first outcome instead of
340
+ // starting a second run). `createRunResponse` is only used by the sync
341
+ // paths; streaming has its own method and never sends this header.
342
+ const headers = {};
343
+ if (opts.idempotencyKey) {
344
+ headers["Idempotency-Key"] = opts.idempotencyKey;
345
+ }
346
+ else {
347
+ headers["Idempotency-Key"] = randomIdempotencyKey();
348
+ }
349
+ return this.request("POST", "/responses", body, { ...transport, headers });
350
+ }
351
+ async buildRunBody(opts, apiKey) {
352
+ const uploaded = await this.uploadRunFiles(opts, apiKey);
353
+ const fileIds = [...uploaded.fileIds, ...(opts.fileIds ?? [])];
354
+ const fileKinds = [...uploaded.fileKinds, ...(opts.fileKinds ?? [])];
172
355
  let input = opts.inputMessages ?? opts.input;
173
- if (opts.fileIds?.length) {
356
+ if (fileIds.length) {
174
357
  input = [
175
358
  {
176
359
  role: "user",
177
360
  content: [
178
- ...opts.fileIds.map((file_id, i) => {
179
- const kind = opts.fileKinds?.[i];
361
+ ...fileIds.map((file_id, i) => {
362
+ const kind = fileKinds[i];
180
363
  if (kind === "video")
181
364
  return { type: "input_video", file_id };
182
365
  if (kind === "text" || kind === "document")
@@ -195,16 +378,18 @@ export class AgentLu {
195
378
  model: opts.model,
196
379
  instructions: opts.instructions,
197
380
  tools: opts.tools,
381
+ tool_choice: opts.tool_choice,
198
382
  reasoning: opts.reasoning,
199
383
  conversation: opts.conversation,
384
+ previous_response_id: opts.previous_response_id,
200
385
  store: opts.store,
201
386
  person: opts.person,
202
387
  owner: opts.owner,
203
388
  title: opts.title,
204
- timeout_ms: opts.timeout_ms,
389
+ timeout_ms: opts.timeout_ms ?? opts.timeoutMs ?? this.defaultTimeoutMs,
205
390
  include: opts.include,
206
391
  temperature: opts.temperature,
207
- max_output_tokens: opts.max_output_tokens,
392
+ max_output_tokens: opts.max_output_tokens ?? opts.maxOutputTokens,
208
393
  background: opts.background,
209
394
  metadata: opts.metadata,
210
395
  };
@@ -221,7 +406,36 @@ export class AgentLu {
221
406
  },
222
407
  };
223
408
  }
224
- return this.request("POST", "/responses", body, { apiKey });
409
+ return body;
410
+ }
411
+ async uploadRunFiles(opts, apiKey) {
412
+ const files = opts.files ?? [];
413
+ if (!files.length)
414
+ return { fileIds: [], fileKinds: [] };
415
+ const transport = this.transportFromRun(opts, apiKey);
416
+ const fileIds = [];
417
+ const fileKinds = [];
418
+ for (const file of files) {
419
+ const uploaded = await this.uploadForApiKey(file.data, file.filename, file.mime, apiKey, {
420
+ timeoutMs: transport.timeoutMs,
421
+ signal: transport.signal,
422
+ maxAttempts: transport.maxAttempts,
423
+ });
424
+ fileIds.push(uploaded.id);
425
+ fileKinds.push(file.kind ?? kindFromMime(file.mime, uploaded.kind));
426
+ }
427
+ return { fileIds, fileKinds };
428
+ }
429
+ transportFromRun(opts, apiKey) {
430
+ // The server budget and the client deadline must not race: give the client
431
+ // 5s of slack so the server's own timeout/error envelope arrives first.
432
+ const serverBudgetMs = opts.timeout_ms ?? opts.timeoutMs ?? this.defaultTimeoutMs;
433
+ return {
434
+ apiKey,
435
+ timeoutMs: serverBudgetMs !== undefined ? serverBudgetMs + 5_000 : this.defaultTimeoutMs,
436
+ maxAttempts: opts.maxAttempts,
437
+ signal: opts.signal,
438
+ };
225
439
  }
226
440
  responses = {
227
441
  create: (body) => this.request("POST", "/responses", body),
@@ -236,10 +450,47 @@ export class AgentLu {
236
450
  const qs = q.toString();
237
451
  return this.request("GET", `/responses${qs ? `?${qs}` : ""}`);
238
452
  },
239
- stream: (body) => this.streamForApiKey(body),
453
+ stream: (body) => this.streamRawForApiKey(body, this.apiKey),
454
+ wait: (id, opts) => this.waitForApiKey(id, this.apiKey, opts),
240
455
  };
241
- async *streamForApiKey(body, apiKey) {
242
- const res = await this.raw("POST", "/responses", { ...body, stream: true }, { apiKey });
456
+ async waitForApiKey(id, apiKey, opts) {
457
+ const intervalMs = Math.max(opts?.intervalMs ?? 1_000, 50);
458
+ // A background run may sit behind a queue; without a default deadline a
459
+ // caller that forgets timeoutMs polls forever.
460
+ const deadlineMs = opts?.timeoutMs === 0
461
+ ? undefined
462
+ : (opts?.timeoutMs ?? DEFAULT_WAIT_TIMEOUT_MS);
463
+ const started = Date.now();
464
+ for (;;) {
465
+ if (opts?.signal?.aborted) {
466
+ throw opts.signal.reason instanceof Error
467
+ ? opts.signal.reason
468
+ : new AgentLuError(0, "aborted", "wait aborted", false);
469
+ }
470
+ const remaining = deadlineMs !== undefined ? Math.max(deadlineMs - (Date.now() - started), 1) : undefined;
471
+ const row = await this.request("GET", `/responses/${encodeURIComponent(id)}`, undefined, {
472
+ apiKey,
473
+ signal: opts?.signal,
474
+ timeoutMs: remaining ?? this.defaultTimeoutMs,
475
+ maxAttempts: 1,
476
+ });
477
+ if (isTerminalStatus(row.status))
478
+ return row;
479
+ if (deadlineMs !== undefined && Date.now() - started + intervalMs >= deadlineMs) {
480
+ throw new AgentLuError(408, "timeout", `Timed out waiting for response ${id}`, true);
481
+ }
482
+ await sleep(intervalMs);
483
+ }
484
+ }
485
+ async *streamRunForApiKey(opts, apiKey) {
486
+ if (opts.background === true) {
487
+ throw new AgentLuError(400, "background_not_supported", "stream cannot use background.", false);
488
+ }
489
+ const body = await this.buildRunBody(opts, apiKey);
490
+ yield* this.streamRawForApiKey(body, apiKey, this.transportFromRun(opts, apiKey));
491
+ }
492
+ async *streamRawForApiKey(body, apiKey, transport) {
493
+ const res = await this.raw("POST", "/responses", { ...body, stream: true }, { ...transport, apiKey: transport?.apiKey ?? apiKey });
243
494
  if (!res.body)
244
495
  throw new AgentLuError(0, "internal_error", "empty stream body", true);
245
496
  const reader = res.body.getReader();
@@ -251,13 +502,13 @@ export class AgentLu {
251
502
  if (done)
252
503
  break;
253
504
  buf += decoder.decode(value, { stream: true });
254
- const lines = buf.split("\n");
505
+ const lines = buf.split(/\r?\n/);
255
506
  buf = lines.pop() ?? "";
256
507
  for (const line of lines) {
257
508
  const trimmed = line.trim();
258
- if (!trimmed.startsWith("data: "))
509
+ if (!trimmed.startsWith("data:"))
259
510
  continue;
260
- const payload = trimmed.slice(6);
511
+ const payload = trimmed.slice(5).trim();
261
512
  if (payload === "[DONE]")
262
513
  return;
263
514
  try {
@@ -270,6 +521,7 @@ export class AgentLu {
270
521
  }
271
522
  }
272
523
  finally {
524
+ await reader.cancel().catch(() => { });
273
525
  reader.releaseLock();
274
526
  }
275
527
  }
@@ -297,9 +549,16 @@ export class AgentLu {
297
549
  * overrides the shared 5-attempt policy, and `signal` cancels the call.
298
550
  */
299
551
  decisions = {
300
- create: (body) => this.request("POST", "/decisions", decisionWire(body), decisionRequestOptions(body)),
301
- /** Same call, returning only the answers map. */
302
- ask: async (body) => (await this.request("POST", "/decisions", decisionWire(body), decisionRequestOptions(body))).answers,
552
+ create: (body) => this.request("POST", "/decisions", decisionWire(body), decisionRequestOptions(body, this.defaultTimeoutMs)),
553
+ ask: async (body) => (await this.request("POST", "/decisions", decisionWire(body), decisionRequestOptions(body, this.defaultTimeoutMs))).answers,
554
+ askOrNull: async (body) => {
555
+ try {
556
+ return await this.decisions.ask(body);
557
+ }
558
+ catch {
559
+ return null;
560
+ }
561
+ },
303
562
  };
304
563
  async agents() {
305
564
  return this.request("GET", "/agents", undefined, { auth: false });
@@ -314,31 +573,29 @@ export class AgentLu {
314
573
  return this.request("GET", "/health", undefined, { auth: false });
315
574
  }
316
575
  async request(method, path, body, opts) {
317
- // `409 thread_busy` and `503 too_many_active_runs` are correctness bounds,
318
- // not rate limits. Retrying with backoff is almost always the right thing,
576
+ const res = await this.raw(method, path, body, opts);
577
+ if (res.status === 204)
578
+ return undefined;
579
+ return (await res.json());
580
+ }
581
+ async raw(method, path, body, opts) {
582
+ // `409 thread_busy`, `503 too_many_active_runs`, network faults, 502/504
583
+ // and any server-marked retryable error are correctness/transient bounds,
584
+ // not rate limits. Retrying with jittered backoff is almost always right,
319
585
  // so the SDK does it - honoring Retry-After when the server sends one.
320
586
  // A 503 can also signal a config boundary (no decision model key); those
321
587
  // are not retryable and must surface immediately. Callers can cap attempts
322
588
  // per request (`maxAttempts`); a timeout/caller abort is never retried.
323
- const nonRetryable503 = new Set(["systemone_unconfigured"]);
324
589
  const maxAttempts = opts?.maxAttempts !== undefined && Number.isFinite(opts.maxAttempts) && opts.maxAttempts >= 1
325
590
  ? Math.floor(opts.maxAttempts)
326
591
  : 5;
327
592
  let last;
328
593
  for (let attempt = 0; attempt < maxAttempts; attempt++) {
329
594
  try {
330
- const res = await this.raw(method, path, body, opts);
331
- if (res.status === 204)
332
- return undefined;
333
- const json = (await res.json());
334
- return json;
595
+ return await this.rawOnce(method, path, body, opts);
335
596
  }
336
597
  catch (e) {
337
- if (e instanceof AgentLuError &&
338
- (e.code === "thread_busy" ||
339
- e.code === "too_many_active_runs" ||
340
- (e.status === 503 && !nonRetryable503.has(e.code))) &&
341
- attempt < maxAttempts - 1) {
598
+ if (isRetryableError(e) && attempt < maxAttempts - 1) {
342
599
  last = e;
343
600
  await sleep(retryDelayMs(e, attempt));
344
601
  continue;
@@ -348,7 +605,7 @@ export class AgentLu {
348
605
  }
349
606
  throw last ?? new AgentLuError(503, "too_many_active_runs", "too many retries", true);
350
607
  }
351
- async raw(method, path, body, opts) {
608
+ async rawOnce(method, path, body, opts) {
352
609
  const headers = {};
353
610
  const isForm = typeof FormData !== "undefined" && body instanceof FormData;
354
611
  if (body !== undefined && !isForm) {
@@ -357,13 +614,21 @@ export class AgentLu {
357
614
  const apiKey = opts?.apiKey ?? this.apiKey;
358
615
  if (opts?.auth !== false && apiKey)
359
616
  headers.Authorization = `Bearer ${apiKey}`;
360
- const signal = requestSignal(opts?.signal, opts?.timeoutMs);
361
- const res = await this.fetchImpl(`${this.baseUrl}${path}`, {
362
- method,
363
- headers,
364
- ...(signal ? { signal } : {}),
365
- body: body === undefined ? undefined : isForm ? body : JSON.stringify(body),
366
- });
617
+ if (opts?.headers)
618
+ Object.assign(headers, opts.headers);
619
+ const signal = requestSignal(opts?.signal, opts?.timeoutMs ?? this.defaultTimeoutMs);
620
+ let res;
621
+ try {
622
+ res = await this.fetchImpl(`${this.baseUrl}${path}`, {
623
+ method,
624
+ headers,
625
+ ...(signal ? { signal } : {}),
626
+ body: body === undefined ? undefined : isForm ? body : JSON.stringify(body),
627
+ });
628
+ }
629
+ catch (e) {
630
+ throw mapFetchError(e, opts?.signal);
631
+ }
367
632
  if (!res.ok) {
368
633
  let code = `http_${res.status}`;
369
634
  let message = `HTTP ${res.status}`;
@@ -402,58 +667,191 @@ export class UseCaseRunner {
402
667
  operations;
403
668
  apiKey = null;
404
669
  ready = null;
670
+ /** In-flight/short-lived guard so a burst of runs does not stampede registration. */
671
+ registerFailures = 0;
672
+ lastRegisterError = null;
673
+ registerFailedAt = null;
674
+ /** Cooldown is short and only suppresses *repeated* failures in one burst. */
675
+ static REGISTER_COOLDOWN_MS = 1_000;
405
676
  constructor(parent, name, opts) {
406
677
  this.name = name;
407
678
  this.registerOpts = opts ?? {};
408
679
  this.operations = scopedUseCaseOperations.get(parent);
409
680
  }
410
- /** Idempotent get-or-create; safe to call before every run. */
411
- ensure() {
681
+ /**
682
+ * Idempotent get-or-create; safe to call on boot or before every run.
683
+ * A failed registration is remembered for a short cooldown so an app that
684
+ * calls `ensure()` in a loop does not hammer the server; runs always retry
685
+ * registration transparently (pass `force`).
686
+ */
687
+ ensure(opts) {
412
688
  if (this.apiKey)
413
689
  return Promise.resolve();
414
690
  if (!this.ready) {
691
+ if (!opts?.force &&
692
+ this.registerFailedAt !== null &&
693
+ Date.now() - this.registerFailedAt < UseCaseRunner.REGISTER_COOLDOWN_MS) {
694
+ return Promise.reject(this.lastRegisterError ??
695
+ new AgentLuError(503, "register_cooldown", "use-case registration is cooling down", true));
696
+ }
415
697
  this.ready = this.operations.register(this.name, this.registerOpts)
416
698
  .then((key) => {
417
699
  this.apiKey = key;
700
+ this.registerFailedAt = null;
701
+ this.registerFailures = 0;
702
+ this.lastRegisterError = null;
418
703
  })
419
704
  .catch((e) => {
420
705
  this.ready = null;
706
+ this.registerFailedAt = Date.now();
707
+ this.registerFailures += 1;
708
+ this.lastRegisterError = e;
421
709
  throw e;
422
710
  });
423
711
  }
424
712
  return this.ready;
425
713
  }
714
+ invalidate() {
715
+ this.apiKey = null;
716
+ this.ready = null;
717
+ }
718
+ async withAuthRetry(fn) {
719
+ await this.ensure({ force: true });
720
+ try {
721
+ return await fn(this.apiKey);
722
+ }
723
+ catch (e) {
724
+ if (e instanceof AgentLuError && e.status === 401) {
725
+ this.invalidate();
726
+ await this.ensure({ force: true });
727
+ return await fn(this.apiKey);
728
+ }
729
+ throw e;
730
+ }
731
+ }
426
732
  /**
427
733
  * Run with this use-case's key. Request fields that are set override the
428
734
  * use-case's registered defaults; unset fields inherit them server-side.
429
735
  */
430
736
  async run(opts) {
431
- await this.ensure();
432
- return (await this.operations.run(this.apiKey, opts));
737
+ return (await this.withAuthRetry((apiKey) => this.operations.run(apiKey, opts)));
433
738
  }
434
739
  async runJson(opts) {
435
740
  assertJsonRunOptions(opts);
436
- await this.ensure();
437
- return (await this.operations.runJson(this.apiKey, opts));
741
+ return (await this.withAuthRetry((apiKey) => this.operations.runJson(apiKey, opts)));
742
+ }
743
+ async runText(opts) {
744
+ return this.withAuthRetry((apiKey) => this.operations.runText(apiKey, opts));
745
+ }
746
+ /** `runJson` that returns `null` instead of throwing on any failure. */
747
+ async runJsonOrNull(opts) {
748
+ try {
749
+ const { data } = await this.runJson(opts);
750
+ return data;
751
+ }
752
+ catch {
753
+ return null;
754
+ }
755
+ }
756
+ /** `runText` that returns `null` instead of throwing on any failure. */
757
+ async runTextOrNull(opts) {
758
+ try {
759
+ const { text } = await this.runText(opts);
760
+ return text;
761
+ }
762
+ catch {
763
+ return null;
764
+ }
765
+ }
766
+ async *stream(opts) {
767
+ await this.ensure({ force: true });
768
+ try {
769
+ yield* this.operations.stream(this.apiKey, opts);
770
+ }
771
+ catch (e) {
772
+ if (!(e instanceof AgentLuError && e.status === 401))
773
+ throw e;
774
+ this.invalidate();
775
+ await this.ensure({ force: true });
776
+ yield* this.operations.stream(this.apiKey, opts);
777
+ }
438
778
  }
439
- async stream(body) {
440
- await this.ensure();
441
- return this.operations.stream(this.apiKey, body);
779
+ /** Typed stream for this use-case: events + `finalText()`. */
780
+ typedStream(opts) {
781
+ return typedStream(this.stream(opts));
442
782
  }
443
783
  /**
444
784
  * Upload with this use-case's key, so the file is owned by the same
445
785
  * service client that will reference it via `run({ fileIds })`.
446
786
  */
447
- async upload(data, filename, mime) {
448
- await this.ensure();
449
- return this.operations.upload(this.apiKey, data, filename, mime);
787
+ async upload(data, filename, mime, opts) {
788
+ return this.withAuthRetry((apiKey) => this.operations.upload(apiKey, data, filename, mime, opts));
450
789
  }
790
+ responses = {
791
+ retrieve: (id) => this.withAuthRetry((apiKey) => this.operations.retrieve(apiKey, id)),
792
+ cancel: (id) => this.withAuthRetry((apiKey) => this.operations.cancel(apiKey, id)),
793
+ wait: (id, opts) => this.withAuthRetry((apiKey) => this.operations.wait(apiKey, id, opts)),
794
+ };
795
+ decisions = {
796
+ create: (body) => this.withAuthRetry((apiKey) => this.operations.decideCreate(apiKey, body)),
797
+ ask: (body) => this.withAuthRetry((apiKey) => this.operations.decideAsk(apiKey, body)),
798
+ askOrNull: async (body) => {
799
+ try {
800
+ return await this.decisions.ask(body);
801
+ }
802
+ catch {
803
+ return null;
804
+ }
805
+ },
806
+ };
451
807
  }
452
808
  function assertJsonRunOptions(opts) {
453
809
  if (opts.background === true) {
454
810
  throw new AgentLuError(400, "background_not_supported", "runJson requires a completed response and cannot use background.", false);
455
811
  }
456
812
  }
813
+ function jsonAttempts(opts) {
814
+ if (opts.attempts !== undefined && Number.isFinite(opts.attempts) && opts.attempts >= 1) {
815
+ return Math.floor(opts.attempts);
816
+ }
817
+ return 1;
818
+ }
819
+ function isJsonRetryable(error) {
820
+ if (error instanceof AgentLuError && error.code === "missing_output_parsed")
821
+ return true;
822
+ if (error instanceof AgentLuResponseError) {
823
+ return error.code === "schema_validation_failed" || error.responseStatus === "incomplete";
824
+ }
825
+ return false;
826
+ }
827
+ function kindFromMime(mime, stored) {
828
+ if (stored === "video" || stored === "text" || stored === "document" || stored === "image")
829
+ return stored;
830
+ if (mime?.startsWith("video/"))
831
+ return "video";
832
+ if (mime?.startsWith("text/"))
833
+ return "text";
834
+ if (mime === "application/pdf")
835
+ return "document";
836
+ return "image";
837
+ }
838
+ function mapFetchError(error, callerSignal) {
839
+ if (error instanceof AgentLuError)
840
+ return error;
841
+ if (callerSignal?.aborted) {
842
+ const reason = callerSignal.reason;
843
+ if (reason instanceof AgentLuError)
844
+ return reason;
845
+ const message = reason instanceof Error ? reason.message : "Request aborted";
846
+ return new AgentLuError(0, "aborted", message, false);
847
+ }
848
+ const name = error instanceof Error ? error.name : "";
849
+ const message = error instanceof Error ? error.message : String(error);
850
+ if (name === "TimeoutError" || name === "AbortError" || /timed out/i.test(message)) {
851
+ return new AgentLuError(408, "timeout", message || "Request timed out", true);
852
+ }
853
+ return new AgentLuError(0, "network_error", message || "fetch failed", true);
854
+ }
457
855
  function parseRetryAfter(res) {
458
856
  const header = res.headers.get("retry-after");
459
857
  if (!header)
@@ -497,11 +895,43 @@ function requestSignal(signal, timeoutMs) {
497
895
  return signals.length ? mergeSignals(signals) : undefined;
498
896
  }
499
897
  function retryDelayMs(err, attempt) {
898
+ // Jitter keeps a fleet of clients from retrying in lockstep after a blip.
899
+ const jitter = 0.75 + Math.random() * 0.5;
500
900
  if (err.retryAfterMs !== undefined) {
501
- return Math.min(10_000, Math.max(250, err.retryAfterMs));
901
+ return Math.min(10_000, Math.max(250, err.retryAfterMs * jitter));
502
902
  }
503
- return Math.min(10_000, 500 * 2 ** attempt);
903
+ return Math.min(10_000, 500 * 2 ** attempt * jitter);
904
+ }
905
+ /** Statuses worth a retry even without a server `retryable` flag. */
906
+ const RETRYABLE_STATUSES = new Set([502, 503, 504]);
907
+ /** True when an error should be retried by the shared policy. */
908
+ export function isRetryableError(err) {
909
+ if (!(err instanceof AgentLuError))
910
+ return false;
911
+ if (err.code === "systemone_unconfigured")
912
+ return false;
913
+ if (err.code === "thread_busy" || err.code === "too_many_active_runs")
914
+ return true;
915
+ // Network faults are retried; a client timeout is not (the deadline was hit
916
+ // and a retry would start a second run past the caller's budget).
917
+ if (err.code === "network_error")
918
+ return true;
919
+ if (RETRYABLE_STATUSES.has(err.status))
920
+ return true;
921
+ // The server marks quota exhaustion retryable:false; anything it marks
922
+ // retryable:true is safe to retry even on an otherwise-unremarkable status.
923
+ return err.retryable === true && err.status >= 500;
504
924
  }
505
925
  function sleep(ms) {
506
926
  return new Promise((r) => setTimeout(r, ms));
507
927
  }
928
+ /**
929
+ * Unique-enough idempotency key without assuming `crypto.randomUUID` exists
930
+ * (browser SDK consumers on older runtimes).
931
+ */
932
+ function randomIdempotencyKey() {
933
+ const g = globalThis;
934
+ if (g.crypto?.randomUUID)
935
+ return `sdk_${g.crypto.randomUUID()}`;
936
+ return `sdk_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 12)}`;
937
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@betterlu/agentlu",
3
- "version": "1.5.0",
3
+ "version": "1.7.0",
4
4
  "description": "Client for the agentlu service API (/v1). Or just use the openai SDK pointed at http://localhost:8912/v1.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",