@betterlu/agentlu 1.5.0 → 1.6.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,30 @@
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
13
  export type Backend = "pi";
14
14
  export type ThinkingLevel = "off" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max";
15
15
  export type ApiToolFamily = "web_search" | "fetch_content" | "hublu" | "memory" | "image" | "browser" | "android";
16
+ export type ApiToolChoice = "auto" | "none" | "required";
16
17
  /** Closed tool-family request accepted by `/v1/responses`. */
17
18
  export interface ApiToolSpec {
18
19
  type: ApiToolFamily;
19
20
  /** Optional family-specific operation allowlist (HubLu, BrowserLu, or AndroidLu). */
20
21
  allowed_tools?: string[];
21
22
  }
22
- export type ApiToolInput = ApiToolFamily | ApiToolSpec;
23
+ /**
24
+ * Native MCP server for the machine-only `api` agent. Mounted per run;
25
+ * `allowed_tools` pre-scopes the names the server lists.
26
+ */
27
+ export interface ApiMcpServerSpec {
28
+ type: "mcp";
29
+ url: string;
30
+ allowed_tools?: string[];
31
+ }
32
+ export type ApiToolInput = ApiToolFamily | ApiToolSpec | ApiMcpServerSpec;
23
33
  export type BrowserStatus = "active" | "needs_human" | "human_control" | "idle" | "closed" | "interrupted";
24
34
  export interface BrowserScreenshot {
25
35
  id: string;
@@ -87,6 +97,14 @@ export type TextFormat = TextFormatText | TextFormatJsonSchema;
87
97
  export interface TextConfig {
88
98
  format?: TextFormat;
89
99
  }
100
+ export type FileKind = "image" | "video" | "text" | "document";
101
+ /** Local file to upload and attach on the same `run` / `stream`. */
102
+ export interface RunFile {
103
+ data: Uint8Array | Blob;
104
+ filename: string;
105
+ mime?: string;
106
+ kind?: FileKind;
107
+ }
90
108
  /** Yes/no, answered as the probability that the answer is yes. */
91
109
  export interface NoulQuestion {
92
110
  type: "noul";
@@ -137,7 +155,7 @@ export interface DecisionOptions {
137
155
  /**
138
156
  * Per-attempt client deadline in milliseconds. A timeout aborts the request
139
157
  * and is never retried; without it the request is bounded only by the
140
- * caller's fetch implementation.
158
+ * caller's fetch implementation (or the AgentLu constructor `timeoutMs`).
141
159
  */
142
160
  timeoutMs?: number;
143
161
  /** Total attempts including the first. Defaults to the shared 5-attempt policy. */
@@ -158,6 +176,12 @@ export interface DecisionObject {
158
176
  } | null;
159
177
  [k: string]: unknown;
160
178
  }
179
+ export interface DecisionApi {
180
+ create: (body: DecisionOptions) => Promise<DecisionObject>;
181
+ ask: (body: DecisionOptions) => Promise<Record<string, DecisionAnswer>>;
182
+ /** Fail-open: returns `null` when the model is unconfigured or the call fails. */
183
+ askOrNull: (body: DecisionOptions) => Promise<Record<string, DecisionAnswer> | null>;
184
+ }
161
185
  /** Question builders; sugar over the wire shape. */
162
186
  export declare const noul: (instructions: string, criteria?: {
163
187
  true: string;
@@ -184,6 +208,12 @@ export interface AgentLuOptions {
184
208
  baseUrl?: string;
185
209
  apiKey?: string;
186
210
  fetch?: typeof fetch;
211
+ /**
212
+ * Default per-attempt client deadline for `run` / `stream` / `upload` /
213
+ * `decisions` when the call omits `timeoutMs`. Also fills `timeout_ms` on
214
+ * `/v1/responses` when the call omits that field.
215
+ */
216
+ timeoutMs?: number;
187
217
  }
188
218
  export interface ClientDefaults {
189
219
  backend?: Backend;
@@ -196,6 +226,11 @@ export interface ClientDefaults {
196
226
  store?: boolean;
197
227
  reasoning?: ClientReasoningOptions;
198
228
  text?: TextConfig;
229
+ /**
230
+ * Retired on client defaults (server ignores it). Pass `max_output_tokens`
231
+ * or `maxOutputTokens` on `run` / `runJson` / `stream` instead.
232
+ */
233
+ maxOutputTokens?: never;
199
234
  }
200
235
  /** High-level use-case declaration for model routing and priority tier. */
201
236
  export interface UseCaseProfile {
@@ -204,18 +239,33 @@ export interface UseCaseProfile {
204
239
  /** MiniMax priority admission consent (priority costs 1.5x standard). */
205
240
  priorityTier?: "never" | "retry" | "first" | "always";
206
241
  }
242
+ export interface UseCaseDeclaration {
243
+ /** Registered client name. Defaults to the catalog key. */
244
+ name?: string;
245
+ person?: string | null;
246
+ defaults?: ClientDefaults;
247
+ use_case_profile?: UseCaseProfile;
248
+ }
249
+ export type UseCaseCatalog<T extends Record<string, UseCaseDeclaration>> = {
250
+ [K in keyof T]: UseCaseRunner;
251
+ } & {
252
+ /** Idempotent get-or-create for every use-case. Safe to call on boot. */
253
+ ensure: () => Promise<void>;
254
+ };
207
255
  export interface RunOptions {
208
256
  backend?: Backend;
209
257
  agent?: string;
210
258
  model?: string;
211
259
  input?: string;
212
260
  /**
213
- * Attachments previously uploaded via `upload()`. Prepended to the user
214
- * turn using each file's `kind` (`input_image` / `input_video` / `input_file`).
215
- */
261
+ * Attachments previously uploaded via `upload()`. Prepended to the user
262
+ * turn using each file's `kind` (`input_image` / `input_video` / `input_file`).
263
+ */
216
264
  fileIds?: string[];
217
265
  /** Optional kinds aligned with `fileIds` (from `upload()`). Defaults to image. */
218
- fileKinds?: Array<"image" | "video" | "text" | "document">;
266
+ fileKinds?: FileKind[];
267
+ /** Upload-and-attach in one call. Combined with `fileIds` when both are set. */
268
+ files?: RunFile[];
219
269
  /** Structured input messages (OpenAI shape) - overrides `input`. */
220
270
  inputMessages?: Array<{
221
271
  role: string;
@@ -223,18 +273,32 @@ export interface RunOptions {
223
273
  }>;
224
274
  instructions?: string;
225
275
  tools?: ApiToolInput[];
276
+ tool_choice?: ApiToolChoice;
226
277
  reasoning?: ReasoningOptions;
227
278
  text?: TextConfig;
228
279
  strict?: boolean;
229
280
  conversation?: string;
281
+ previous_response_id?: string;
230
282
  store?: boolean;
231
283
  person?: string | null;
232
284
  owner?: "client" | "person";
233
285
  title?: string;
286
+ /** Server-side run budget (wire name). */
234
287
  timeout_ms?: number;
288
+ /**
289
+ * Per-attempt client deadline. When `timeout_ms` is omitted, the same value
290
+ * is also sent as the server-side budget.
291
+ */
292
+ timeoutMs?: number;
293
+ /** Caller cancellation, combined with `timeoutMs` when both are set. */
294
+ signal?: AbortSignal;
295
+ /** Total HTTP attempts including the first. Defaults to the shared 5-attempt policy. */
296
+ maxAttempts?: number;
235
297
  include?: string[];
236
298
  temperature?: number;
237
299
  max_output_tokens?: number;
300
+ /** Alias for `max_output_tokens` on this run (not a client default). */
301
+ maxOutputTokens?: number;
238
302
  /** Caller metadata stored on the response row (console visibility). */
239
303
  metadata?: Record<string, string>;
240
304
  /** JSON Schema. When set, `data` on the result is the validated object. */
@@ -242,6 +306,23 @@ export interface RunOptions {
242
306
  schemaName?: string;
243
307
  /** Queue a background run instead of waiting. Returns the queued response. */
244
308
  background?: boolean;
309
+ /**
310
+ * `runJson` only: extra model attempts after `schema_validation_failed`,
311
+ * `missing_output_parsed`, or `incomplete`. Defaults to 1 (no extra tries).
312
+ */
313
+ attempts?: number;
314
+ }
315
+ export interface WaitOptions {
316
+ /** Delay between polls. Defaults to 1000ms. */
317
+ intervalMs?: number;
318
+ /** Overall wait deadline. The in-flight poll is aborted when it fires. */
319
+ timeoutMs?: number;
320
+ signal?: AbortSignal;
321
+ }
322
+ export interface UploadOptions {
323
+ timeoutMs?: number;
324
+ signal?: AbortSignal;
325
+ maxAttempts?: number;
245
326
  }
246
327
  export interface ResponseObject {
247
328
  id: string;
@@ -273,10 +354,12 @@ export interface ResponseObject {
273
354
  };
274
355
  [k: string]: unknown;
275
356
  }
357
+ export declare function isTerminalStatus(status: string): boolean;
276
358
  export declare class AgentLu {
277
359
  readonly baseUrl: string;
278
360
  apiKey: string | undefined;
279
361
  private readonly fetchImpl;
362
+ private readonly defaultTimeoutMs;
280
363
  constructor(opts?: AgentLuOptions);
281
364
  private postClient;
282
365
  /** Get-or-create a client by name. Caches the returned key on this instance. */
@@ -295,12 +378,21 @@ export declare class AgentLu {
295
378
  * The use-case's defaults (system prompt, tools, temperature, ...) live
296
379
  * server-side on the client, so `run()` only needs the input. Registering a
297
380
  * use-case is registering a client - `alu.useCase("mealu-enrich", {...})`.
298
- */
381
+ */
299
382
  useCase(name: string, opts?: {
300
383
  person?: string | null;
301
384
  defaults?: ClientDefaults;
302
385
  use_case_profile?: UseCaseProfile;
303
386
  }): UseCaseRunner;
387
+ /**
388
+ * Several named use-case runners that share this client. Call `ensure()` on
389
+ * boot, then `catalog.extract.runJson({ input })`.
390
+ *
391
+ * const ai = alu.useCases({
392
+ * extract: { name: "deallu-extract", defaults: { agent: "api", store: false } },
393
+ * });
394
+ */
395
+ useCases<T extends Record<string, UseCaseDeclaration>>(specs: T): UseCaseCatalog<T>;
304
396
  /**
305
397
  * Register a use-case client and resolve to its api key. The key is cached
306
398
  * on this instance (same semantics as `register`), so a following
@@ -316,7 +408,7 @@ export declare class AgentLu {
316
408
  * Upload an image, video, or document for use with `run({ fileIds })`.
317
409
  * Returns the `file_id` and stored `kind`.
318
410
  */
319
- upload(data: Uint8Array | Blob, filename: string, mime?: string): Promise<{
411
+ upload(data: Uint8Array | Blob, filename: string, mime?: string, opts?: UploadOptions): Promise<{
320
412
  id: string;
321
413
  kind?: string;
322
414
  }>;
@@ -335,9 +427,19 @@ export declare class AgentLu {
335
427
  data: T;
336
428
  response: ResponseObject;
337
429
  }>;
430
+ runText(opts: RunOptions): Promise<{
431
+ text: string;
432
+ response: ResponseObject;
433
+ }>;
434
+ stream(opts: RunOptions): AsyncGenerator<Record<string, unknown>>;
338
435
  private runForApiKey;
339
436
  private runJsonForApiKey;
437
+ private runJsonOnce;
438
+ private runTextForApiKey;
340
439
  private createRunResponse;
440
+ private buildRunBody;
441
+ private uploadRunFiles;
442
+ private transportFromRun;
341
443
  readonly responses: {
342
444
  create: (body: Record<string, unknown>) => Promise<ResponseObject>;
343
445
  retrieve: (id: string) => Promise<ResponseObject>;
@@ -350,8 +452,11 @@ export declare class AgentLu {
350
452
  data: ResponseObject[];
351
453
  }>;
352
454
  stream: (body: Record<string, unknown>) => AsyncGenerator<Record<string, unknown>, any, any>;
455
+ wait: (id: string, opts?: WaitOptions) => Promise<ResponseObject>;
353
456
  };
354
- private streamForApiKey;
457
+ private waitForApiKey;
458
+ private streamRunForApiKey;
459
+ private streamRawForApiKey;
355
460
  readonly threads: {
356
461
  create: (body?: Record<string, unknown>) => Promise<unknown>;
357
462
  retrieve: (id: string) => Promise<unknown>;
@@ -375,17 +480,14 @@ export declare class AgentLu {
375
480
  * `timeoutMs` bounds each attempt (timeouts are never retried), `maxAttempts`
376
481
  * overrides the shared 5-attempt policy, and `signal` cancels the call.
377
482
  */
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
- };
483
+ readonly decisions: DecisionApi;
383
484
  agents(): Promise<unknown>;
384
485
  models(): Promise<unknown>;
385
486
  tools(): Promise<unknown>;
386
487
  health(): Promise<unknown>;
387
488
  private request;
388
489
  private raw;
490
+ private rawOnce;
389
491
  }
390
492
  /**
391
493
  * A runner bound to one registered use-case (one service client + its
@@ -409,8 +511,10 @@ export declare class UseCaseRunner {
409
511
  defaults?: ClientDefaults;
410
512
  use_case_profile?: UseCaseProfile;
411
513
  });
412
- /** Idempotent get-or-create; safe to call before every run. */
413
- private ensure;
514
+ /** Idempotent get-or-create; safe to call on boot or before every run. */
515
+ ensure(): Promise<void>;
516
+ private invalidate;
517
+ private withAuthRetry;
414
518
  /**
415
519
  * Run with this use-case's key. Request fields that are set override the
416
520
  * use-case's registered defaults; unset fields inherit them server-side.
@@ -423,13 +527,23 @@ export declare class UseCaseRunner {
423
527
  data: T;
424
528
  response: ResponseObject;
425
529
  }>;
426
- stream(body: Record<string, unknown>): Promise<AsyncGenerator<Record<string, unknown>, any, any>>;
530
+ runText(opts: RunOptions): Promise<{
531
+ text: string;
532
+ response: ResponseObject;
533
+ }>;
534
+ stream(opts: RunOptions): AsyncGenerator<Record<string, unknown>>;
427
535
  /**
428
536
  * Upload with this use-case's key, so the file is owned by the same
429
537
  * service client that will reference it via `run({ fileIds })`.
430
538
  */
431
- upload(data: Uint8Array | Blob, filename: string, mime?: string): Promise<{
539
+ upload(data: Uint8Array | Blob, filename: string, mime?: string, opts?: UploadOptions): Promise<{
432
540
  id: string;
433
541
  kind?: string;
434
542
  }>;
543
+ readonly responses: {
544
+ retrieve: (id: string) => Promise<ResponseObject>;
545
+ cancel: (id: string) => Promise<ResponseObject>;
546
+ wait: (id: string, opts?: WaitOptions) => Promise<ResponseObject>;
547
+ };
548
+ readonly decisions: DecisionApi;
435
549
  }
package/dist/index.js CHANGED
@@ -6,8 +6,8 @@
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
13
  /** Strip client-only decision options before the body reaches `/v1/decisions`. */
@@ -17,8 +17,12 @@ function decisionWire(body) {
17
17
  wire.model = body.model;
18
18
  return wire;
19
19
  }
20
- function decisionRequestOptions(body) {
21
- return { timeoutMs: body.timeoutMs, maxAttempts: body.maxAttempts, signal: body.signal };
20
+ function decisionRequestOptions(body, defaultTimeoutMs) {
21
+ return {
22
+ timeoutMs: body.timeoutMs ?? defaultTimeoutMs,
23
+ maxAttempts: body.maxAttempts,
24
+ signal: body.signal,
25
+ };
22
26
  }
23
27
  /** Question builders; sugar over the wire shape. */
24
28
  export const noul = (instructions, criteria) => ({
@@ -65,6 +69,9 @@ export class AgentLuResponseError extends AgentLuError {
65
69
  this.responseStatus = responseStatus;
66
70
  }
67
71
  }
72
+ export function isTerminalStatus(status) {
73
+ return status === "completed" || status === "failed" || status === "incomplete" || status === "cancelled";
74
+ }
68
75
  function env(name) {
69
76
  return typeof process !== "undefined" ? process.env[name] : undefined;
70
77
  }
@@ -73,16 +80,30 @@ export class AgentLu {
73
80
  baseUrl;
74
81
  apiKey;
75
82
  fetchImpl;
83
+ defaultTimeoutMs;
76
84
  constructor(opts = {}) {
77
85
  this.baseUrl = (opts.baseUrl ?? env("AGENTLU_URL") ?? DEFAULT_BASE_URL).replace(/\/$/, "");
78
86
  this.apiKey = opts.apiKey ?? env("AGENTLU_API_KEY");
79
87
  this.fetchImpl = opts.fetch ?? globalThis.fetch.bind(globalThis);
88
+ this.defaultTimeoutMs = opts.timeoutMs;
80
89
  scopedUseCaseOperations.set(this, {
81
90
  register: (name, registerOpts) => this.registerForUseCase(name, registerOpts),
82
91
  run: (apiKey, runOpts) => this.runForApiKey(apiKey, runOpts),
83
92
  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),
93
+ runText: (apiKey, runOpts) => this.runTextForApiKey(apiKey, runOpts),
94
+ stream: (apiKey, runOpts) => this.streamRunForApiKey(runOpts, apiKey),
95
+ upload: (apiKey, data, filename, mime, uploadOpts) => this.uploadForApiKey(data, filename, mime, apiKey, uploadOpts),
96
+ wait: (apiKey, id, waitOpts) => this.waitForApiKey(id, apiKey, waitOpts),
97
+ retrieve: (apiKey, id, reqOpts) => this.request("GET", `/responses/${encodeURIComponent(id)}`, undefined, {
98
+ ...reqOpts,
99
+ apiKey,
100
+ }),
101
+ cancel: (apiKey, id, reqOpts) => this.request("POST", `/responses/${encodeURIComponent(id)}/cancel`, undefined, {
102
+ ...reqOpts,
103
+ apiKey,
104
+ }),
105
+ decideCreate: (apiKey, body) => this.request("POST", "/decisions", decisionWire(body), { ...decisionRequestOptions(body, this.defaultTimeoutMs), apiKey }),
106
+ decideAsk: async (apiKey, body) => (await this.request("POST", "/decisions", decisionWire(body), { ...decisionRequestOptions(body, this.defaultTimeoutMs), apiKey })).answers,
86
107
  });
87
108
  }
88
109
  postClient(name, opts) {
@@ -101,10 +122,30 @@ export class AgentLu {
101
122
  * The use-case's defaults (system prompt, tools, temperature, ...) live
102
123
  * server-side on the client, so `run()` only needs the input. Registering a
103
124
  * use-case is registering a client - `alu.useCase("mealu-enrich", {...})`.
104
- */
125
+ */
105
126
  useCase(name, opts) {
106
127
  return new UseCaseRunner(this, name, opts);
107
128
  }
129
+ /**
130
+ * Several named use-case runners that share this client. Call `ensure()` on
131
+ * boot, then `catalog.extract.runJson({ input })`.
132
+ *
133
+ * const ai = alu.useCases({
134
+ * extract: { name: "deallu-extract", defaults: { agent: "api", store: false } },
135
+ * });
136
+ */
137
+ useCases(specs) {
138
+ const runners = {};
139
+ for (const key of Object.keys(specs)) {
140
+ const spec = specs[key];
141
+ runners[key] = this.useCase(spec.name ?? String(key), spec);
142
+ }
143
+ const catalog = { ...runners };
144
+ catalog.ensure = async () => {
145
+ await Promise.all(Object.values(runners).map((runner) => runner.ensure()));
146
+ };
147
+ return catalog;
148
+ }
108
149
  /**
109
150
  * Register a use-case client and resolve to its api key. The key is cached
110
151
  * on this instance (same semantics as `register`), so a following
@@ -127,14 +168,19 @@ export class AgentLu {
127
168
  * Upload an image, video, or document for use with `run({ fileIds })`.
128
169
  * Returns the `file_id` and stored `kind`.
129
170
  */
130
- async upload(data, filename, mime) {
131
- return this.uploadForApiKey(data, filename, mime, this.apiKey);
171
+ async upload(data, filename, mime, opts) {
172
+ return this.uploadForApiKey(data, filename, mime, this.apiKey, opts);
132
173
  }
133
- async uploadForApiKey(data, filename, mime, apiKey) {
174
+ async uploadForApiKey(data, filename, mime, apiKey, opts) {
134
175
  const form = new FormData();
135
176
  const blob = data instanceof Blob ? data : new Blob([new Uint8Array(data)], { type: mime ?? "application/octet-stream" });
136
177
  form.append("file", blob, filename);
137
- const res = await this.raw("POST", "/files", form, { apiKey });
178
+ const res = await this.raw("POST", "/files", form, {
179
+ apiKey,
180
+ timeoutMs: opts?.timeoutMs,
181
+ signal: opts?.signal,
182
+ maxAttempts: opts?.maxAttempts,
183
+ });
138
184
  return (await res.json());
139
185
  }
140
186
  async me() {
@@ -152,6 +198,12 @@ export class AgentLu {
152
198
  assertJsonRunOptions(opts);
153
199
  return this.runJsonForApiKey(this.apiKey, opts);
154
200
  }
201
+ async runText(opts) {
202
+ return this.runTextForApiKey(this.apiKey, opts);
203
+ }
204
+ async *stream(opts) {
205
+ yield* this.streamRunForApiKey(opts, this.apiKey);
206
+ }
155
207
  async runForApiKey(apiKey, opts) {
156
208
  const response = await this.createRunResponse(opts, apiKey);
157
209
  if (response.status === "failed" && response.error) {
@@ -160,6 +212,21 @@ export class AgentLu {
160
212
  return { data: response.output_parsed, response };
161
213
  }
162
214
  async runJsonForApiKey(apiKey, opts) {
215
+ const attempts = jsonAttempts(opts);
216
+ let last;
217
+ for (let attempt = 0; attempt < attempts; attempt++) {
218
+ try {
219
+ return await this.runJsonOnce(apiKey, opts);
220
+ }
221
+ catch (e) {
222
+ last = e;
223
+ if (!isJsonRetryable(e) || attempt >= attempts - 1)
224
+ throw e;
225
+ }
226
+ }
227
+ throw last;
228
+ }
229
+ async runJsonOnce(apiKey, opts) {
163
230
  const response = await this.createRunResponse(opts, apiKey);
164
231
  if (response.status !== "completed")
165
232
  throw new AgentLuResponseError(response);
@@ -168,15 +235,32 @@ export class AgentLu {
168
235
  }
169
236
  return { data: response.output_parsed, response };
170
237
  }
238
+ async runTextForApiKey(apiKey, opts) {
239
+ const response = await this.createRunResponse(opts, apiKey);
240
+ if (response.status !== "completed")
241
+ throw new AgentLuResponseError(response);
242
+ const text = response.output_text ?? "";
243
+ if (!text.trim()) {
244
+ throw new AgentLuError(200, "empty_output", "Completed response did not include output_text.", false);
245
+ }
246
+ return { text, response };
247
+ }
171
248
  async createRunResponse(opts, apiKey) {
249
+ const body = await this.buildRunBody(opts, apiKey);
250
+ return this.request("POST", "/responses", body, this.transportFromRun(opts, apiKey));
251
+ }
252
+ async buildRunBody(opts, apiKey) {
253
+ const uploaded = await this.uploadRunFiles(opts, apiKey);
254
+ const fileIds = [...uploaded.fileIds, ...(opts.fileIds ?? [])];
255
+ const fileKinds = [...uploaded.fileKinds, ...(opts.fileKinds ?? [])];
172
256
  let input = opts.inputMessages ?? opts.input;
173
- if (opts.fileIds?.length) {
257
+ if (fileIds.length) {
174
258
  input = [
175
259
  {
176
260
  role: "user",
177
261
  content: [
178
- ...opts.fileIds.map((file_id, i) => {
179
- const kind = opts.fileKinds?.[i];
262
+ ...fileIds.map((file_id, i) => {
263
+ const kind = fileKinds[i];
180
264
  if (kind === "video")
181
265
  return { type: "input_video", file_id };
182
266
  if (kind === "text" || kind === "document")
@@ -195,16 +279,18 @@ export class AgentLu {
195
279
  model: opts.model,
196
280
  instructions: opts.instructions,
197
281
  tools: opts.tools,
282
+ tool_choice: opts.tool_choice,
198
283
  reasoning: opts.reasoning,
199
284
  conversation: opts.conversation,
285
+ previous_response_id: opts.previous_response_id,
200
286
  store: opts.store,
201
287
  person: opts.person,
202
288
  owner: opts.owner,
203
289
  title: opts.title,
204
- timeout_ms: opts.timeout_ms,
290
+ timeout_ms: opts.timeout_ms ?? opts.timeoutMs ?? this.defaultTimeoutMs,
205
291
  include: opts.include,
206
292
  temperature: opts.temperature,
207
- max_output_tokens: opts.max_output_tokens,
293
+ max_output_tokens: opts.max_output_tokens ?? opts.maxOutputTokens,
208
294
  background: opts.background,
209
295
  metadata: opts.metadata,
210
296
  };
@@ -221,7 +307,33 @@ export class AgentLu {
221
307
  },
222
308
  };
223
309
  }
224
- return this.request("POST", "/responses", body, { apiKey });
310
+ return body;
311
+ }
312
+ async uploadRunFiles(opts, apiKey) {
313
+ const files = opts.files ?? [];
314
+ if (!files.length)
315
+ return { fileIds: [], fileKinds: [] };
316
+ const transport = this.transportFromRun(opts, apiKey);
317
+ const fileIds = [];
318
+ const fileKinds = [];
319
+ for (const file of files) {
320
+ const uploaded = await this.uploadForApiKey(file.data, file.filename, file.mime, apiKey, {
321
+ timeoutMs: transport.timeoutMs,
322
+ signal: transport.signal,
323
+ maxAttempts: transport.maxAttempts,
324
+ });
325
+ fileIds.push(uploaded.id);
326
+ fileKinds.push(file.kind ?? kindFromMime(file.mime, uploaded.kind));
327
+ }
328
+ return { fileIds, fileKinds };
329
+ }
330
+ transportFromRun(opts, apiKey) {
331
+ return {
332
+ apiKey,
333
+ timeoutMs: opts.timeoutMs ?? this.defaultTimeoutMs,
334
+ maxAttempts: opts.maxAttempts,
335
+ signal: opts.signal,
336
+ };
225
337
  }
226
338
  responses = {
227
339
  create: (body) => this.request("POST", "/responses", body),
@@ -236,10 +348,42 @@ export class AgentLu {
236
348
  const qs = q.toString();
237
349
  return this.request("GET", `/responses${qs ? `?${qs}` : ""}`);
238
350
  },
239
- stream: (body) => this.streamForApiKey(body),
351
+ stream: (body) => this.streamRawForApiKey(body, this.apiKey),
352
+ wait: (id, opts) => this.waitForApiKey(id, this.apiKey, opts),
240
353
  };
241
- async *streamForApiKey(body, apiKey) {
242
- const res = await this.raw("POST", "/responses", { ...body, stream: true }, { apiKey });
354
+ async waitForApiKey(id, apiKey, opts) {
355
+ const intervalMs = Math.max(opts?.intervalMs ?? 1_000, 50);
356
+ const started = Date.now();
357
+ for (;;) {
358
+ if (opts?.signal?.aborted) {
359
+ throw opts.signal.reason instanceof Error
360
+ ? opts.signal.reason
361
+ : new AgentLuError(0, "aborted", "wait aborted", false);
362
+ }
363
+ const remaining = opts?.timeoutMs !== undefined ? Math.max(opts.timeoutMs - (Date.now() - started), 1) : undefined;
364
+ const row = await this.request("GET", `/responses/${encodeURIComponent(id)}`, undefined, {
365
+ apiKey,
366
+ signal: opts?.signal,
367
+ timeoutMs: remaining ?? this.defaultTimeoutMs,
368
+ maxAttempts: 1,
369
+ });
370
+ if (isTerminalStatus(row.status))
371
+ return row;
372
+ if (opts?.timeoutMs !== undefined && Date.now() - started + intervalMs >= opts.timeoutMs) {
373
+ throw new AgentLuError(408, "timeout", `Timed out waiting for response ${id}`, true);
374
+ }
375
+ await sleep(intervalMs);
376
+ }
377
+ }
378
+ async *streamRunForApiKey(opts, apiKey) {
379
+ if (opts.background === true) {
380
+ throw new AgentLuError(400, "background_not_supported", "stream cannot use background.", false);
381
+ }
382
+ const body = await this.buildRunBody(opts, apiKey);
383
+ yield* this.streamRawForApiKey(body, apiKey, this.transportFromRun(opts, apiKey));
384
+ }
385
+ async *streamRawForApiKey(body, apiKey, transport) {
386
+ const res = await this.raw("POST", "/responses", { ...body, stream: true }, { ...transport, apiKey: transport?.apiKey ?? apiKey });
243
387
  if (!res.body)
244
388
  throw new AgentLuError(0, "internal_error", "empty stream body", true);
245
389
  const reader = res.body.getReader();
@@ -251,13 +395,13 @@ export class AgentLu {
251
395
  if (done)
252
396
  break;
253
397
  buf += decoder.decode(value, { stream: true });
254
- const lines = buf.split("\n");
398
+ const lines = buf.split(/\r?\n/);
255
399
  buf = lines.pop() ?? "";
256
400
  for (const line of lines) {
257
401
  const trimmed = line.trim();
258
- if (!trimmed.startsWith("data: "))
402
+ if (!trimmed.startsWith("data:"))
259
403
  continue;
260
- const payload = trimmed.slice(6);
404
+ const payload = trimmed.slice(5).trim();
261
405
  if (payload === "[DONE]")
262
406
  return;
263
407
  try {
@@ -270,6 +414,7 @@ export class AgentLu {
270
414
  }
271
415
  }
272
416
  finally {
417
+ await reader.cancel().catch(() => { });
273
418
  reader.releaseLock();
274
419
  }
275
420
  }
@@ -297,9 +442,16 @@ export class AgentLu {
297
442
  * overrides the shared 5-attempt policy, and `signal` cancels the call.
298
443
  */
299
444
  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,
445
+ create: (body) => this.request("POST", "/decisions", decisionWire(body), decisionRequestOptions(body, this.defaultTimeoutMs)),
446
+ ask: async (body) => (await this.request("POST", "/decisions", decisionWire(body), decisionRequestOptions(body, this.defaultTimeoutMs))).answers,
447
+ askOrNull: async (body) => {
448
+ try {
449
+ return await this.decisions.ask(body);
450
+ }
451
+ catch {
452
+ return null;
453
+ }
454
+ },
303
455
  };
304
456
  async agents() {
305
457
  return this.request("GET", "/agents", undefined, { auth: false });
@@ -314,6 +466,12 @@ export class AgentLu {
314
466
  return this.request("GET", "/health", undefined, { auth: false });
315
467
  }
316
468
  async request(method, path, body, opts) {
469
+ const res = await this.raw(method, path, body, opts);
470
+ if (res.status === 204)
471
+ return undefined;
472
+ return (await res.json());
473
+ }
474
+ async raw(method, path, body, opts) {
317
475
  // `409 thread_busy` and `503 too_many_active_runs` are correctness bounds,
318
476
  // not rate limits. Retrying with backoff is almost always the right thing,
319
477
  // so the SDK does it - honoring Retry-After when the server sends one.
@@ -327,11 +485,7 @@ export class AgentLu {
327
485
  let last;
328
486
  for (let attempt = 0; attempt < maxAttempts; attempt++) {
329
487
  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;
488
+ return await this.rawOnce(method, path, body, opts);
335
489
  }
336
490
  catch (e) {
337
491
  if (e instanceof AgentLuError &&
@@ -348,7 +502,7 @@ export class AgentLu {
348
502
  }
349
503
  throw last ?? new AgentLuError(503, "too_many_active_runs", "too many retries", true);
350
504
  }
351
- async raw(method, path, body, opts) {
505
+ async rawOnce(method, path, body, opts) {
352
506
  const headers = {};
353
507
  const isForm = typeof FormData !== "undefined" && body instanceof FormData;
354
508
  if (body !== undefined && !isForm) {
@@ -357,13 +511,19 @@ export class AgentLu {
357
511
  const apiKey = opts?.apiKey ?? this.apiKey;
358
512
  if (opts?.auth !== false && apiKey)
359
513
  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
- });
514
+ const signal = requestSignal(opts?.signal, opts?.timeoutMs ?? this.defaultTimeoutMs);
515
+ let res;
516
+ try {
517
+ res = await this.fetchImpl(`${this.baseUrl}${path}`, {
518
+ method,
519
+ headers,
520
+ ...(signal ? { signal } : {}),
521
+ body: body === undefined ? undefined : isForm ? body : JSON.stringify(body),
522
+ });
523
+ }
524
+ catch (e) {
525
+ throw mapFetchError(e, opts?.signal);
526
+ }
367
527
  if (!res.ok) {
368
528
  let code = `http_${res.status}`;
369
529
  let message = `HTTP ${res.status}`;
@@ -407,7 +567,7 @@ export class UseCaseRunner {
407
567
  this.registerOpts = opts ?? {};
408
568
  this.operations = scopedUseCaseOperations.get(parent);
409
569
  }
410
- /** Idempotent get-or-create; safe to call before every run. */
570
+ /** Idempotent get-or-create; safe to call on boot or before every run. */
411
571
  ensure() {
412
572
  if (this.apiKey)
413
573
  return Promise.resolve();
@@ -423,37 +583,123 @@ export class UseCaseRunner {
423
583
  }
424
584
  return this.ready;
425
585
  }
586
+ invalidate() {
587
+ this.apiKey = null;
588
+ this.ready = null;
589
+ }
590
+ async withAuthRetry(fn) {
591
+ await this.ensure();
592
+ try {
593
+ return await fn(this.apiKey);
594
+ }
595
+ catch (e) {
596
+ if (e instanceof AgentLuError && e.status === 401) {
597
+ this.invalidate();
598
+ await this.ensure();
599
+ return await fn(this.apiKey);
600
+ }
601
+ throw e;
602
+ }
603
+ }
426
604
  /**
427
605
  * Run with this use-case's key. Request fields that are set override the
428
606
  * use-case's registered defaults; unset fields inherit them server-side.
429
607
  */
430
608
  async run(opts) {
431
- await this.ensure();
432
- return (await this.operations.run(this.apiKey, opts));
609
+ return (await this.withAuthRetry((apiKey) => this.operations.run(apiKey, opts)));
433
610
  }
434
611
  async runJson(opts) {
435
612
  assertJsonRunOptions(opts);
436
- await this.ensure();
437
- return (await this.operations.runJson(this.apiKey, opts));
613
+ return (await this.withAuthRetry((apiKey) => this.operations.runJson(apiKey, opts)));
614
+ }
615
+ async runText(opts) {
616
+ return this.withAuthRetry((apiKey) => this.operations.runText(apiKey, opts));
438
617
  }
439
- async stream(body) {
618
+ async *stream(opts) {
440
619
  await this.ensure();
441
- return this.operations.stream(this.apiKey, body);
620
+ try {
621
+ yield* this.operations.stream(this.apiKey, opts);
622
+ }
623
+ catch (e) {
624
+ if (!(e instanceof AgentLuError && e.status === 401))
625
+ throw e;
626
+ this.invalidate();
627
+ await this.ensure();
628
+ yield* this.operations.stream(this.apiKey, opts);
629
+ }
442
630
  }
443
631
  /**
444
632
  * Upload with this use-case's key, so the file is owned by the same
445
633
  * service client that will reference it via `run({ fileIds })`.
446
634
  */
447
- async upload(data, filename, mime) {
448
- await this.ensure();
449
- return this.operations.upload(this.apiKey, data, filename, mime);
635
+ async upload(data, filename, mime, opts) {
636
+ return this.withAuthRetry((apiKey) => this.operations.upload(apiKey, data, filename, mime, opts));
450
637
  }
638
+ responses = {
639
+ retrieve: (id) => this.withAuthRetry((apiKey) => this.operations.retrieve(apiKey, id)),
640
+ cancel: (id) => this.withAuthRetry((apiKey) => this.operations.cancel(apiKey, id)),
641
+ wait: (id, opts) => this.withAuthRetry((apiKey) => this.operations.wait(apiKey, id, opts)),
642
+ };
643
+ decisions = {
644
+ create: (body) => this.withAuthRetry((apiKey) => this.operations.decideCreate(apiKey, body)),
645
+ ask: (body) => this.withAuthRetry((apiKey) => this.operations.decideAsk(apiKey, body)),
646
+ askOrNull: async (body) => {
647
+ try {
648
+ return await this.decisions.ask(body);
649
+ }
650
+ catch {
651
+ return null;
652
+ }
653
+ },
654
+ };
451
655
  }
452
656
  function assertJsonRunOptions(opts) {
453
657
  if (opts.background === true) {
454
658
  throw new AgentLuError(400, "background_not_supported", "runJson requires a completed response and cannot use background.", false);
455
659
  }
456
660
  }
661
+ function jsonAttempts(opts) {
662
+ if (opts.attempts !== undefined && Number.isFinite(opts.attempts) && opts.attempts >= 1) {
663
+ return Math.floor(opts.attempts);
664
+ }
665
+ return 1;
666
+ }
667
+ function isJsonRetryable(error) {
668
+ if (error instanceof AgentLuError && error.code === "missing_output_parsed")
669
+ return true;
670
+ if (error instanceof AgentLuResponseError) {
671
+ return error.code === "schema_validation_failed" || error.responseStatus === "incomplete";
672
+ }
673
+ return false;
674
+ }
675
+ function kindFromMime(mime, stored) {
676
+ if (stored === "video" || stored === "text" || stored === "document" || stored === "image")
677
+ return stored;
678
+ if (mime?.startsWith("video/"))
679
+ return "video";
680
+ if (mime?.startsWith("text/"))
681
+ return "text";
682
+ if (mime === "application/pdf")
683
+ return "document";
684
+ return "image";
685
+ }
686
+ function mapFetchError(error, callerSignal) {
687
+ if (error instanceof AgentLuError)
688
+ return error;
689
+ if (callerSignal?.aborted) {
690
+ const reason = callerSignal.reason;
691
+ if (reason instanceof AgentLuError)
692
+ return reason;
693
+ const message = reason instanceof Error ? reason.message : "Request aborted";
694
+ return new AgentLuError(0, "aborted", message, false);
695
+ }
696
+ const name = error instanceof Error ? error.name : "";
697
+ const message = error instanceof Error ? error.message : String(error);
698
+ if (name === "TimeoutError" || name === "AbortError" || /timed out/i.test(message)) {
699
+ return new AgentLuError(408, "timeout", message || "Request timed out", true);
700
+ }
701
+ return new AgentLuError(0, "network_error", message || "fetch failed", true);
702
+ }
457
703
  function parseRetryAfter(res) {
458
704
  const header = res.headers.get("retry-after");
459
705
  if (!header)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@betterlu/agentlu",
3
- "version": "1.5.0",
3
+ "version": "1.6.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",