@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.
- package/dist/agent/agent.js +1 -0
- package/dist/agent/backoff.d.ts +4 -0
- package/dist/agent/backoff.js +63 -0
- package/dist/agent/models.js +6 -8
- package/dist/assistant/engine.js +55 -11
- package/package.json +3 -3
package/dist/agent/agent.js
CHANGED
|
@@ -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
|
+
}
|
package/dist/agent/models.js
CHANGED
|
@@ -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,
|
|
140
|
+
return createAnthropic({ apiKey: key.apiKey, fetch: f })(model.id);
|
|
138
141
|
if (model.provider === 'xai')
|
|
139
|
-
return createOpenAI({
|
|
140
|
-
|
|
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
|
package/dist/assistant/engine.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
206
|
-
|
|
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
|
-
|
|
212
|
-
|
|
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.
|
|
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.
|
|
60
|
-
"@columenlabs/lumen-ui-types": "0.1.
|
|
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",
|