@columenlabs/lumen-ai 0.1.268 → 0.1.270

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.
@@ -633,6 +633,7 @@ export class LumenAgent extends Think {
633
633
  let meter = new UsageMeter(plan.model, `${plan.label} · compact`, !!plan.key.plan);
634
634
  this.#owed.push(meter);
635
635
  const r = await generateText({
636
+ maxRetries: 0,
636
637
  model: this.#model(plan, () => {
637
638
  const fb = plan.fallback;
638
639
  if (!fb)
@@ -0,0 +1,4 @@
1
+ /** The wait a refused answer asks for, in ms, or undefined when it names none. */
2
+ export declare function retryAfterMs(h: Headers): number | undefined;
3
+ /** `base` with the retry policy above. */
4
+ export declare function backoffFetch(base?: typeof fetch): typeof fetch;
@@ -0,0 +1,63 @@
1
+ /**
2
+ * One retry policy for every model provider call. A call refused for load or a limit (408, 409,
3
+ * 429, 5xx, 529) is sent again after the wait the provider asks for (`retry-after-ms`,
4
+ * `retry-after` in seconds or as a date), else an exponential wait with jitter (1, 2, 4, 8 s).
5
+ * It stops at 4 retries or 30 s of waiting in all, at once on the caller's abort, and never retries a
6
+ * body it cannot send twice (a stream). Its last answer goes back as it came, so the caller still
7
+ * sees the provider's status and message. The SDKs' own retries are off where this runs, so a call
8
+ * is never retried twice over.
9
+ */
10
+ const RETRY = new Set([408, 409, 429, 500, 502, 503, 504, 529]);
11
+ const TRIES = 4;
12
+ const BUDGET_MS = 30_000;
13
+ const BASE_MS = 1_000;
14
+ /** The wait a refused answer asks for, in ms, or undefined when it names none. */
15
+ export function retryAfterMs(h) {
16
+ const ms = Number(h.get('retry-after-ms'));
17
+ if (Number.isFinite(ms) && ms > 0)
18
+ return ms;
19
+ const v = h.get('retry-after');
20
+ if (!v)
21
+ return undefined;
22
+ const s = Number(v);
23
+ if (Number.isFinite(s))
24
+ return s >= 0 ? s * 1000 : undefined;
25
+ const at = Date.parse(v);
26
+ return Number.isFinite(at) ? Math.max(0, at - Date.now()) : undefined;
27
+ }
28
+ const sleep = (ms, signal) => new Promise((resolve, reject) => {
29
+ if (signal?.aborted)
30
+ return reject(signal.reason);
31
+ const t = setTimeout(resolve, ms);
32
+ signal?.addEventListener('abort', () => {
33
+ clearTimeout(t);
34
+ reject(signal.reason);
35
+ }, { once: true });
36
+ });
37
+ /** `base` with the retry policy above. */
38
+ export function backoffFetch(base = fetch) {
39
+ return async (input, init) => {
40
+ const body = init?.body;
41
+ const resendable = body == null ||
42
+ typeof body === 'string' ||
43
+ body instanceof ArrayBuffer ||
44
+ ArrayBuffer.isView(body) ||
45
+ body instanceof URLSearchParams ||
46
+ body instanceof Blob;
47
+ if (!resendable || input instanceof Request)
48
+ return base(input, init);
49
+ let waited = 0;
50
+ for (let n = 0;; n += 1) {
51
+ const res = await base(input, init);
52
+ if (!RETRY.has(res.status) || n >= TRIES)
53
+ return res;
54
+ const asked = retryAfterMs(res.headers);
55
+ const wait = asked ?? BASE_MS * 2 ** n * (0.75 + Math.random() * 0.5);
56
+ if (waited + wait > BUDGET_MS)
57
+ return res;
58
+ await res.body?.cancel().catch(() => undefined);
59
+ await sleep(wait, init?.signal);
60
+ waited += wait;
61
+ }
62
+ };
63
+ }
@@ -7,6 +7,7 @@ import { createAnthropic } from '@ai-sdk/anthropic';
7
7
  import { createOpenAI } from '@ai-sdk/openai';
8
8
  import { Stream } from 'openai/core/streaming';
9
9
  import { XAI_BASE_URL } from '../assistant/models.js';
10
+ import { backoffFetch } from './backoff.js';
10
11
  /**
11
12
  * A ChatGPT plan sign-in takes function tools only inside one namespace and no output cap, and a
12
13
  * forced call is the one tool offered with any call required (developers.openai.com/siwc, Preview
@@ -133,16 +134,13 @@ function textOf(content) {
133
134
  }
134
135
  /** The AI SDK model a tier runs: its provider's client on the payer's key. */
135
136
  export function aiModel(model, key, o = {}) {
137
+ // Every provider call backs off the same way on a limit or overload (backoff.ts).
138
+ const f = backoffFetch(o.fetch ?? fetch);
136
139
  if (model.provider === 'anthropic')
137
- return createAnthropic({ apiKey: key.apiKey, ...(o.fetch ? { fetch: o.fetch } : {}) })(model.id);
140
+ return createAnthropic({ apiKey: key.apiKey, fetch: f })(model.id);
138
141
  if (model.provider === 'xai')
139
- return createOpenAI({
140
- apiKey: key.apiKey,
141
- baseURL: XAI_BASE_URL,
142
- ...(o.fetch ? { fetch: o.fetch } : {}),
143
- }).responses(model.id);
144
- const f = key.plan ? planFetch(o.fetch, o.name) : o.fetch;
145
- return createOpenAI({ apiKey: key.apiKey, ...(f ? { fetch: f } : {}) }).responses(model.id);
142
+ return createOpenAI({ apiKey: key.apiKey, baseURL: XAI_BASE_URL, fetch: f }).responses(model.id);
143
+ return createOpenAI({ apiKey: key.apiKey, fetch: key.plan ? planFetch(f, o.name) : f }).responses(model.id);
146
144
  }
147
145
  /**
148
146
  * Claude's prompt cache. The request's own breakpoint follows the newest block, so a turn's later
@@ -11,6 +11,7 @@ import Anthropic from '@anthropic-ai/sdk';
11
11
  import { HAND_OFF_WHYS } from '@columenlabs/lumen-ui-types/assistant';
12
12
  import { generateText, jsonSchema, stepCountIs, tool } from 'ai';
13
13
  import OpenAI, { toFile } from 'openai';
14
+ import { backoffFetch } from '../agent/backoff.js';
14
15
  import { aiModel, turnSettings } from '../agent/models.js';
15
16
  import { webLookupTools } from '../subagents/web.js';
16
17
  import { bearings } from './bearings.js';
@@ -96,8 +97,50 @@ function shownError(err) {
96
97
  return { message: `${who} is overloaded. Try again shortly.`, code: 'overloaded' };
97
98
  return { message: `${who} refused the request (${status ?? 'error'}).`, code: 'model_error' };
98
99
  }
100
+ // The AI SDK's errors (APICallError, and RetryError around its last one) carry the HTTP status as
101
+ // statusCode: say what that status means, never the body; the body goes to the log only.
102
+ const call = sdkCall(err);
103
+ if (call) {
104
+ console.error(JSON.stringify({
105
+ event: 'assistant_upstream_error',
106
+ provider: 'ai-sdk',
107
+ status: call.status ?? null,
108
+ message: call.message.slice(0, 500),
109
+ }));
110
+ if (call.status === 401 || call.status === 403)
111
+ return { message: `The model key was refused (${call.status}).`, code: 'key_refused' };
112
+ if (call.status === 429)
113
+ return {
114
+ message: 'The model is busy or hit a rate limit (429). Try again shortly.',
115
+ code: 'rate_limited',
116
+ };
117
+ if ((call.status ?? 0) >= 500)
118
+ return {
119
+ message: `The model is overloaded (${call.status}). Try again shortly.`,
120
+ code: 'overloaded',
121
+ };
122
+ return {
123
+ message: `The model refused the request (${call.status ?? 'error'}).`,
124
+ code: 'model_error',
125
+ };
126
+ }
99
127
  return { message: 'Something went wrong while answering.', code: 'internal' };
100
128
  }
129
+ /** An AI SDK call error's status and message, through RetryError's lastError and `cause`. */
130
+ function sdkCall(err, depth = 0) {
131
+ if (!err || typeof err !== 'object' || depth > 3)
132
+ return null;
133
+ const e = err;
134
+ const name = typeof e.name === 'string' ? e.name : '';
135
+ if (name === 'AI_RetryError' || name === 'RetryError')
136
+ return sdkCall(e.lastError, depth + 1);
137
+ if (name === 'AI_APICallError' || name === 'APICallError' || typeof e.statusCode === 'number')
138
+ return {
139
+ ...(typeof e.statusCode === 'number' && { status: e.statusCode }),
140
+ message: typeof e.message === 'string' ? e.message : '',
141
+ };
142
+ return e.cause ? sdkCall(e.cause, depth + 1) : null;
143
+ }
101
144
  /**
102
145
  * A plain sentence for an error OpenAI sends inside the answer's stream (an `error` event, or a
103
146
  * failed response): what happened and what to do, by its code, with OpenAI's code in brackets so
@@ -198,26 +241,26 @@ export class Assistant {
198
241
  Object.defineProperty(view, 'state', { value: this.state.withDocs(this.state.docs.cached()) });
199
242
  return view;
200
243
  }
244
+ // The SDKs' own retries are off: every call backs off once, the same way (agent/backoff.ts).
201
245
  anthropic(apiKey) {
202
- const f = this.o.fetch;
203
- return new Anthropic({ apiKey, maxRetries: 1, ...(f ? { fetch: f } : {}) });
246
+ return new Anthropic({ apiKey, maxRetries: 0, fetch: backoffFetch(this.o.fetch ?? fetch) });
204
247
  }
205
- openai(apiKey, plan = false) {
206
- const f = this.o.fetch;
207
- // A plan sign-in's calls are not retried: a usage limit is final for now.
208
- return new OpenAI({ apiKey, maxRetries: plan ? 0 : 1, ...(f ? { fetch: f } : {}) });
248
+ openai(apiKey) {
249
+ return new OpenAI({ apiKey, maxRetries: 0, fetch: backoffFetch(this.o.fetch ?? fetch) });
209
250
  }
210
251
  xai(apiKey) {
211
- const f = this.o.fetch;
212
- return new OpenAI({ apiKey, baseURL: XAI_BASE_URL, maxRetries: 1, ...(f ? { fetch: f } : {}) });
252
+ return new OpenAI({
253
+ apiKey,
254
+ baseURL: XAI_BASE_URL,
255
+ maxRetries: 0,
256
+ fetch: backoffFetch(this.o.fetch ?? fetch),
257
+ });
213
258
  }
214
259
  clients(paying) {
215
260
  const { keys, plans } = paying;
216
261
  return {
217
262
  ...(keys.anthropic ? { anthropic: this.anthropic(keys.anthropic) } : {}),
218
- ...(keys.openai
219
- ? { openai: this.openai(keys.openai, !!plans.openai), openaiPlan: !!plans.openai }
220
- : {}),
263
+ ...(keys.openai ? { openai: this.openai(keys.openai), openaiPlan: !!plans.openai } : {}),
221
264
  ...(keys.xai ? { xai: this.xai(keys.xai) } : {}),
222
265
  };
223
266
  }
@@ -554,6 +597,7 @@ export class Assistant {
554
597
  const t0 = Date.now();
555
598
  try {
556
599
  const r = await generateText({
600
+ maxRetries: 0,
557
601
  model: aiModel(sub.model, key, {
558
602
  ...(this.o.fetch ? { fetch: this.o.fetch } : {}),
559
603
  name: this.name,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@columenlabs/lumen-ai",
3
- "version": "0.1.268",
3
+ "version": "0.1.270",
4
4
  "description": "Lumen's assistant and agent parts: a chat assistant's server (Claude tiers, tools over MCP, the web, hand off, spend caps), serving an MCP server from a Worker, read-only tool sets and metered tool calls.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -56,8 +56,8 @@
56
56
  "@ai-sdk/openai": "4.0.108",
57
57
  "@anthropic-ai/sdk": "0.133.0",
58
58
  "@cloudflare/think": "0.23.0",
59
- "@columenlabs/lumen-ops": "0.1.268",
60
- "@columenlabs/lumen-ui-types": "0.1.268",
59
+ "@columenlabs/lumen-ops": "0.1.270",
60
+ "@columenlabs/lumen-ui-types": "0.1.270",
61
61
  "agents": "0.32.0",
62
62
  "ai": "7.0.162",
63
63
  "just-bash": "3.6.0",