@betterlu/agentlu 1.6.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/dist/index.d.ts CHANGED
@@ -10,6 +10,12 @@
10
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";
@@ -306,19 +312,32 @@ export interface RunOptions {
306
312
  schemaName?: string;
307
313
  /** Queue a background run instead of waiting. Returns the queued response. */
308
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;
309
321
  /**
310
322
  * `runJson` only: extra model attempts after `schema_validation_failed`,
311
323
  * `missing_output_parsed`, or `incomplete`. Defaults to 1 (no extra tries).
312
324
  */
313
325
  attempts?: number;
326
+ /** `run`/`runJson`/`runText` only: throw instead of returning when the run is `incomplete`. */
327
+ throwOnIncomplete?: boolean;
314
328
  }
315
329
  export interface WaitOptions {
316
330
  /** Delay between polls. Defaults to 1000ms. */
317
331
  intervalMs?: number;
318
- /** Overall wait deadline. The in-flight poll is aborted when it fires. */
332
+ /**
333
+ * Overall wait deadline (default 10 minutes). The in-flight poll is aborted
334
+ * when it fires. Pass `0` to wait indefinitely.
335
+ */
319
336
  timeoutMs?: number;
320
337
  signal?: AbortSignal;
321
338
  }
339
+ /** Default overall deadline for `responses.wait`. */
340
+ export declare const DEFAULT_WAIT_TIMEOUT_MS: number;
322
341
  export interface UploadOptions {
323
342
  timeoutMs?: number;
324
343
  signal?: AbortSignal;
@@ -355,6 +374,30 @@ export interface ResponseObject {
355
374
  [k: string]: unknown;
356
375
  }
357
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
+ };
358
401
  export declare class AgentLu {
359
402
  readonly baseUrl: string;
360
403
  apiKey: string | undefined;
@@ -431,7 +474,21 @@ export declare class AgentLu {
431
474
  text: string;
432
475
  response: ResponseObject;
433
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>;
434
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
+ };
435
492
  private runForApiKey;
436
493
  private runJsonForApiKey;
437
494
  private runJsonOnce;
@@ -506,13 +563,26 @@ export declare class UseCaseRunner {
506
563
  private readonly operations;
507
564
  private apiKey;
508
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;
509
572
  constructor(parent: AgentLu, name: string, opts?: {
510
573
  person?: string | null;
511
574
  defaults?: ClientDefaults;
512
575
  use_case_profile?: UseCaseProfile;
513
576
  });
514
- /** Idempotent get-or-create; safe to call on boot or before every run. */
515
- ensure(): Promise<void>;
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>;
516
586
  private invalidate;
517
587
  private withAuthRetry;
518
588
  /**
@@ -531,7 +601,15 @@ export declare class UseCaseRunner {
531
601
  text: string;
532
602
  response: ResponseObject;
533
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>;
534
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
+ };
535
613
  /**
536
614
  * Upload with this use-case's key, so the file is owned by the same
537
615
  * service client that will reference it via `run({ fileIds })`.
@@ -547,3 +625,5 @@ export declare class UseCaseRunner {
547
625
  };
548
626
  readonly decisions: DecisionApi;
549
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
@@ -10,6 +10,17 @@
10
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 };
@@ -69,9 +80,49 @@ export class AgentLuResponseError extends AgentLuError {
69
80
  this.responseStatus = responseStatus;
70
81
  }
71
82
  }
83
+ /** Default overall deadline for `responses.wait`. */
84
+ export const DEFAULT_WAIT_TIMEOUT_MS = 10 * 60_000;
72
85
  export function isTerminalStatus(status) {
73
86
  return status === "completed" || status === "failed" || status === "incomplete" || status === "cancelled";
74
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
+ }
75
126
  function env(name) {
76
127
  return typeof process !== "undefined" ? process.env[name] : undefined;
77
128
  }
@@ -82,7 +133,7 @@ export class AgentLu {
82
133
  fetchImpl;
83
134
  defaultTimeoutMs;
84
135
  constructor(opts = {}) {
85
- this.baseUrl = (opts.baseUrl ?? env("AGENTLU_URL") ?? DEFAULT_BASE_URL).replace(/\/$/, "");
136
+ this.baseUrl = normalizeBaseUrl(opts.baseUrl ?? env("AGENTLU_URL") ?? DEFAULT_BASE_URL);
86
137
  this.apiKey = opts.apiKey ?? env("AGENTLU_API_KEY");
87
138
  this.fetchImpl = opts.fetch ?? globalThis.fetch.bind(globalThis);
88
139
  this.defaultTimeoutMs = opts.timeoutMs;
@@ -107,7 +158,10 @@ export class AgentLu {
107
158
  });
108
159
  }
109
160
  postClient(name, opts) {
110
- 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 });
111
165
  }
112
166
  /** Get-or-create a client by name. Caches the returned key on this instance. */
113
167
  async register(name, opts) {
@@ -201,14 +255,47 @@ export class AgentLu {
201
255
  async runText(opts) {
202
256
  return this.runTextForApiKey(this.apiKey, opts);
203
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
+ }
204
281
  async *stream(opts) {
205
282
  yield* this.streamRunForApiKey(opts, this.apiKey);
206
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
+ }
207
291
  async runForApiKey(apiKey, opts) {
208
292
  const response = await this.createRunResponse(opts, apiKey);
209
293
  if (response.status === "failed" && response.error) {
210
294
  throw new AgentLuError(200, response.error.code, response.error.message, response.error.retryable);
211
295
  }
296
+ if (opts.throwOnIncomplete && response.status === "incomplete") {
297
+ throw new AgentLuResponseError(response);
298
+ }
212
299
  return { data: response.output_parsed, response };
213
300
  }
214
301
  async runJsonForApiKey(apiKey, opts) {
@@ -247,7 +334,19 @@ export class AgentLu {
247
334
  }
248
335
  async createRunResponse(opts, apiKey) {
249
336
  const body = await this.buildRunBody(opts, apiKey);
250
- return this.request("POST", "/responses", body, this.transportFromRun(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 });
251
350
  }
252
351
  async buildRunBody(opts, apiKey) {
253
352
  const uploaded = await this.uploadRunFiles(opts, apiKey);
@@ -328,9 +427,12 @@ export class AgentLu {
328
427
  return { fileIds, fileKinds };
329
428
  }
330
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;
331
433
  return {
332
434
  apiKey,
333
- timeoutMs: opts.timeoutMs ?? this.defaultTimeoutMs,
435
+ timeoutMs: serverBudgetMs !== undefined ? serverBudgetMs + 5_000 : this.defaultTimeoutMs,
334
436
  maxAttempts: opts.maxAttempts,
335
437
  signal: opts.signal,
336
438
  };
@@ -353,6 +455,11 @@ export class AgentLu {
353
455
  };
354
456
  async waitForApiKey(id, apiKey, opts) {
355
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);
356
463
  const started = Date.now();
357
464
  for (;;) {
358
465
  if (opts?.signal?.aborted) {
@@ -360,7 +467,7 @@ export class AgentLu {
360
467
  ? opts.signal.reason
361
468
  : new AgentLuError(0, "aborted", "wait aborted", false);
362
469
  }
363
- const remaining = opts?.timeoutMs !== undefined ? Math.max(opts.timeoutMs - (Date.now() - started), 1) : undefined;
470
+ const remaining = deadlineMs !== undefined ? Math.max(deadlineMs - (Date.now() - started), 1) : undefined;
364
471
  const row = await this.request("GET", `/responses/${encodeURIComponent(id)}`, undefined, {
365
472
  apiKey,
366
473
  signal: opts?.signal,
@@ -369,7 +476,7 @@ export class AgentLu {
369
476
  });
370
477
  if (isTerminalStatus(row.status))
371
478
  return row;
372
- if (opts?.timeoutMs !== undefined && Date.now() - started + intervalMs >= opts.timeoutMs) {
479
+ if (deadlineMs !== undefined && Date.now() - started + intervalMs >= deadlineMs) {
373
480
  throw new AgentLuError(408, "timeout", `Timed out waiting for response ${id}`, true);
374
481
  }
375
482
  await sleep(intervalMs);
@@ -472,13 +579,13 @@ export class AgentLu {
472
579
  return (await res.json());
473
580
  }
474
581
  async raw(method, path, body, opts) {
475
- // `409 thread_busy` and `503 too_many_active_runs` are correctness bounds,
476
- // not rate limits. Retrying with backoff is almost always the right thing,
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,
477
585
  // so the SDK does it - honoring Retry-After when the server sends one.
478
586
  // A 503 can also signal a config boundary (no decision model key); those
479
587
  // are not retryable and must surface immediately. Callers can cap attempts
480
588
  // per request (`maxAttempts`); a timeout/caller abort is never retried.
481
- const nonRetryable503 = new Set(["systemone_unconfigured"]);
482
589
  const maxAttempts = opts?.maxAttempts !== undefined && Number.isFinite(opts.maxAttempts) && opts.maxAttempts >= 1
483
590
  ? Math.floor(opts.maxAttempts)
484
591
  : 5;
@@ -488,11 +595,7 @@ export class AgentLu {
488
595
  return await this.rawOnce(method, path, body, opts);
489
596
  }
490
597
  catch (e) {
491
- if (e instanceof AgentLuError &&
492
- (e.code === "thread_busy" ||
493
- e.code === "too_many_active_runs" ||
494
- (e.status === 503 && !nonRetryable503.has(e.code))) &&
495
- attempt < maxAttempts - 1) {
598
+ if (isRetryableError(e) && attempt < maxAttempts - 1) {
496
599
  last = e;
497
600
  await sleep(retryDelayMs(e, attempt));
498
601
  continue;
@@ -511,6 +614,8 @@ export class AgentLu {
511
614
  const apiKey = opts?.apiKey ?? this.apiKey;
512
615
  if (opts?.auth !== false && apiKey)
513
616
  headers.Authorization = `Bearer ${apiKey}`;
617
+ if (opts?.headers)
618
+ Object.assign(headers, opts.headers);
514
619
  const signal = requestSignal(opts?.signal, opts?.timeoutMs ?? this.defaultTimeoutMs);
515
620
  let res;
516
621
  try {
@@ -562,22 +667,45 @@ export class UseCaseRunner {
562
667
  operations;
563
668
  apiKey = null;
564
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;
565
676
  constructor(parent, name, opts) {
566
677
  this.name = name;
567
678
  this.registerOpts = opts ?? {};
568
679
  this.operations = scopedUseCaseOperations.get(parent);
569
680
  }
570
- /** Idempotent get-or-create; safe to call on boot or before every run. */
571
- 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) {
572
688
  if (this.apiKey)
573
689
  return Promise.resolve();
574
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
+ }
575
697
  this.ready = this.operations.register(this.name, this.registerOpts)
576
698
  .then((key) => {
577
699
  this.apiKey = key;
700
+ this.registerFailedAt = null;
701
+ this.registerFailures = 0;
702
+ this.lastRegisterError = null;
578
703
  })
579
704
  .catch((e) => {
580
705
  this.ready = null;
706
+ this.registerFailedAt = Date.now();
707
+ this.registerFailures += 1;
708
+ this.lastRegisterError = e;
581
709
  throw e;
582
710
  });
583
711
  }
@@ -588,14 +716,14 @@ export class UseCaseRunner {
588
716
  this.ready = null;
589
717
  }
590
718
  async withAuthRetry(fn) {
591
- await this.ensure();
719
+ await this.ensure({ force: true });
592
720
  try {
593
721
  return await fn(this.apiKey);
594
722
  }
595
723
  catch (e) {
596
724
  if (e instanceof AgentLuError && e.status === 401) {
597
725
  this.invalidate();
598
- await this.ensure();
726
+ await this.ensure({ force: true });
599
727
  return await fn(this.apiKey);
600
728
  }
601
729
  throw e;
@@ -615,8 +743,28 @@ export class UseCaseRunner {
615
743
  async runText(opts) {
616
744
  return this.withAuthRetry((apiKey) => this.operations.runText(apiKey, opts));
617
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
+ }
618
766
  async *stream(opts) {
619
- await this.ensure();
767
+ await this.ensure({ force: true });
620
768
  try {
621
769
  yield* this.operations.stream(this.apiKey, opts);
622
770
  }
@@ -624,10 +772,14 @@ export class UseCaseRunner {
624
772
  if (!(e instanceof AgentLuError && e.status === 401))
625
773
  throw e;
626
774
  this.invalidate();
627
- await this.ensure();
775
+ await this.ensure({ force: true });
628
776
  yield* this.operations.stream(this.apiKey, opts);
629
777
  }
630
778
  }
779
+ /** Typed stream for this use-case: events + `finalText()`. */
780
+ typedStream(opts) {
781
+ return typedStream(this.stream(opts));
782
+ }
631
783
  /**
632
784
  * Upload with this use-case's key, so the file is owned by the same
633
785
  * service client that will reference it via `run({ fileIds })`.
@@ -743,11 +895,43 @@ function requestSignal(signal, timeoutMs) {
743
895
  return signals.length ? mergeSignals(signals) : undefined;
744
896
  }
745
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;
746
900
  if (err.retryAfterMs !== undefined) {
747
- return Math.min(10_000, Math.max(250, err.retryAfterMs));
901
+ return Math.min(10_000, Math.max(250, err.retryAfterMs * jitter));
748
902
  }
749
- 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;
750
924
  }
751
925
  function sleep(ms) {
752
926
  return new Promise((r) => setTimeout(r, ms));
753
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.6.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",