@columenlabs/lumen-ai 0.1.185 → 0.1.187

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
@@ -220,11 +220,12 @@ ctx.waitUntil(meaningStep({ def: notesFind, sql: d1Sql(env.DB), embed, index: en
220
220
  | `LumenAgent` | An abstract Think agent. The app answers `plan(turn)` (model, key, effort, instructions, tool sources, money left) and hears `settle(end)` (outcome, usage parts, context size, time) when the turn ends |
221
221
  | `aiModel(model, key, opts?)` | The AI SDK model for a tier on an API key, or on a ChatGPT plan sign-in (`key.plan`, through `planFetch`) |
222
222
  | `planFetch(fetch?, name?)` | Rewrites Responses calls to a plan sign-in's rules: tools in one namespace, no output cap, a forced call as the only tool |
223
+ | `planFallback(primary, fallback, onSwitch)` | A plan's model that hands the call, and the rest of the turn, to `fallback` when the plan reaches its usage limit (a 429 or a usage code, before any output) |
223
224
  | `turnSettings(model, opts)` | Output cap and effort as the provider takes them |
224
225
  | `openAiTools(sources, opts)` | The app's tool groups as AI SDK tools: reads always, drafts and writes by access, results cut at 16k characters |
225
226
  | `UsageMeter`, `tokenUsage(u)` | Prices each step's tokens (cache reads and writes, reasoning) into one usage part |
226
227
 
227
- A ChatGPT plan sign-in is free to the app (`priced` with `free`). Nothing here holds a key or a token: the app passes them per turn.
228
+ A ChatGPT plan sign-in is free to the app (`priced` with `free`). When the plan is used up for now, a turn with `fallback` (`agentTurn` sets the app's first Claude on the same payer's Anthropic key) answers on Claude instead of ending in the limit's error, its use its own part. Nothing here holds a key or a token: the app passes them per turn.
228
229
 
229
230
  An app with an `Assistant` gets the whole plan from it: `assistant.agentTurn(caller, { message, tier?, context?, refs?, time_zone? })` returns `{ plan, settle }` with the person's tier and model on whoever pays, the tool groups at their access, the kit's rules, the bearings and the page before the message, `escalate` to a higher tier (its own usage part, inside the same cap) and Claude's advisor. `settle` adds the spend to the payer's month and reports the turn through `onStep`. It throws `AssistantError` when nobody can pay.
230
231
 
@@ -61,6 +61,15 @@ export interface AgentTurnPlan {
61
61
  maxUses: number;
62
62
  maxTokens: number;
63
63
  };
64
+ /**
65
+ * Who answers when the ChatGPT plan `key` signs in with reaches its usage limit: a Claude model
66
+ * on the payer's own key, for the rest of the turn. Its use is its own part (`label`).
67
+ */
68
+ fallback?: {
69
+ model: ModelDef;
70
+ key: ModelKey;
71
+ label: string;
72
+ };
64
73
  /** Dollars the payer has left; at or past it the model gets no more tools. */
65
74
  left?: number;
66
75
  /** Most model calls in one answer; 8. */
@@ -16,7 +16,7 @@ import { Think, } from '@cloudflare/think';
16
16
  import { createCompactFunction } from 'agents/sessions';
17
17
  import { generateText } from 'ai';
18
18
  import { CONNECTOR_GROUP, checkConnectorUrl, connectorGroup, connectorId, connectorSource, } from './connectors.js';
19
- import { aiModel, turnSettings } from './models.js';
19
+ import { aiModel, planFallback, turnSettings } from './models.js';
20
20
  import { LAZY_AT, TOOL_SEARCH, lazyTools } from './search.js';
21
21
  import { chunkEvents, itemsOf } from './stream.js';
22
22
  import { openAiTools, } from './tools.js';
@@ -348,12 +348,15 @@ export class LumenAgent extends Think {
348
348
  if (!last)
349
349
  throw new Error('No model to compact with until the next answer.');
350
350
  const { plan } = last;
351
- const meter = new UsageMeter(plan.model, `${plan.label} · compact`, !!plan.key.plan);
351
+ let meter = new UsageMeter(plan.model, `${plan.label} · compact`, !!plan.key.plan);
352
352
  this.#owed.push(meter);
353
353
  const r = await generateText({
354
- model: aiModel(plan.model, plan.key, {
355
- ...(plan.fetch ? { fetch: plan.fetch } : {}),
356
- name: this.constructor.name,
354
+ model: this.#model(plan, () => {
355
+ const fb = plan.fallback;
356
+ if (!fb)
357
+ return;
358
+ meter = new UsageMeter(fb.model, `${fb.label} · compact`, false);
359
+ this.#owed.push(meter);
357
360
  }),
358
361
  prompt,
359
362
  ...turnSettings(plan.model, { effort: 'default', out: 4000, plan: !!plan.key.plan }),
@@ -361,6 +364,14 @@ export class LumenAgent extends Think {
361
364
  meter.add(r.usage);
362
365
  return r.text;
363
366
  }
367
+ /** The turn's model; on a ChatGPT plan with a fallback, Claude once the plan's limit is reached. */
368
+ #model(plan, onSwitch) {
369
+ const o = { ...(plan.fetch ? { fetch: plan.fetch } : {}), name: this.constructor.name };
370
+ const model = aiModel(plan.model, plan.key, o);
371
+ if (!plan.key.plan || !plan.fallback)
372
+ return model;
373
+ return planFallback(model, aiModel(plan.fallback.model, plan.fallback.key, o), onSwitch);
374
+ }
364
375
  /** Never read: `beforeTurn` gives every turn its own model. */
365
376
  getModel() {
366
377
  return '@cf/none';
@@ -442,10 +453,15 @@ export class LumenAgent extends Think {
442
453
  this.#turn.system = system;
443
454
  this.#turn.lazy = lazy;
444
455
  }
456
+ const turn = this.#turn;
445
457
  return {
446
- model: aiModel(plan.model, plan.key, {
447
- ...(plan.fetch ? { fetch: plan.fetch } : {}),
448
- name: this.constructor.name,
458
+ model: this.#model(plan, () => {
459
+ const fb = plan.fallback;
460
+ if (!fb)
461
+ return;
462
+ // Claude answers the rest of the turn on the payer's key: its use is its own part.
463
+ turn.main = new UsageMeter(fb.model, fb.label, false);
464
+ turn.meters.push(turn.main);
449
465
  }),
450
466
  instructions: system,
451
467
  messages: withLead(ctx.messages, `${this.#news()}${plan.lead ?? ''}`),
@@ -15,8 +15,11 @@ export interface AiModelOptions {
15
15
  /**
16
16
  * A ChatGPT plan sign-in takes function tools only inside one namespace and no output cap, and a
17
17
  * forced call is the one tool offered with any call required (developers.openai.com/siwc, Preview
18
- * limitations). This fetch rewrites each Responses request to those rules, so the AI SDK's OpenAI
19
- * provider runs on a plan unchanged.
18
+ * limitations). It also keeps nothing (`store: false`, so earlier reasoning rides along encrypted)
19
+ * and takes the system prompt as `instructions`, as the assistant's own OpenAI loop sends it (the
20
+ * AI SDK's request without these came back 400 Bad Request on a plan, 2026-11-04). This fetch
21
+ * rewrites each Responses request to those rules, so the AI SDK's OpenAI provider runs on a plan
22
+ * unchanged.
20
23
  */
21
24
  export declare function planFetch(base?: typeof fetch, name?: string): typeof fetch;
22
25
  /** The AI SDK model a tier runs: its provider's client on the payer's key. */
@@ -30,3 +33,12 @@ export declare function turnSettings(model: ModelDef, o: {
30
33
  maxOutputTokens?: number;
31
34
  providerOptions?: Record<string, Record<string, JSONValue>>;
32
35
  };
36
+ /** A ChatGPT plan used up for now: a 429 or a usage code, thrown or sent before any output. */
37
+ export declare function planLimited(err: unknown): boolean;
38
+ /**
39
+ * `primary` until its ChatGPT plan reaches its usage limit, then `fallback` for that call and
40
+ * every later one on this model (the rest of the turn). The limit shows either as a thrown error
41
+ * or as an error part before any output; once output has started nothing is swapped. `onSwitch`
42
+ * runs once, at the swap.
43
+ */
44
+ export declare function planFallback(primary: LanguageModel, fallback: LanguageModel, onSwitch: () => void): LanguageModel;
@@ -8,8 +8,11 @@ import { createOpenAI } from '@ai-sdk/openai';
8
8
  /**
9
9
  * A ChatGPT plan sign-in takes function tools only inside one namespace and no output cap, and a
10
10
  * forced call is the one tool offered with any call required (developers.openai.com/siwc, Preview
11
- * limitations). This fetch rewrites each Responses request to those rules, so the AI SDK's OpenAI
12
- * provider runs on a plan unchanged.
11
+ * limitations). It also keeps nothing (`store: false`, so earlier reasoning rides along encrypted)
12
+ * and takes the system prompt as `instructions`, as the assistant's own OpenAI loop sends it (the
13
+ * AI SDK's request without these came back 400 Bad Request on a plan, 2026-11-04). This fetch
14
+ * rewrites each Responses request to those rules, so the AI SDK's OpenAI provider runs on a plan
15
+ * unchanged.
13
16
  */
14
17
  export function planFetch(base = fetch, name = 'Assistant') {
15
18
  return async (input, init) => {
@@ -17,22 +20,66 @@ export function planFetch(base = fetch, name = 'Assistant') {
17
20
  if (!url.endsWith('/responses') || typeof init?.body !== 'string')
18
21
  return base(input, init);
19
22
  const { max_output_tokens: _cap, ...body } = JSON.parse(init.body);
23
+ body.store = false;
24
+ const include = Array.isArray(body.include) ? body.include : [];
25
+ if (!include.includes('reasoning.encrypted_content'))
26
+ body.include = [...include, 'reasoning.encrypted_content'];
27
+ // System and developer messages become the request's instructions.
28
+ const items = Array.isArray(body.input) ? body.input : [];
29
+ const lead = items.filter((m) => m.role === 'system' || m.role === 'developer');
30
+ if (lead.length) {
31
+ const said = lead.map((m) => textOf(m.content)).filter(Boolean);
32
+ if (typeof body.instructions === 'string' && body.instructions)
33
+ said.unshift(body.instructions);
34
+ body.instructions = said.join('\n\n');
35
+ body.input = items.filter((m) => !lead.includes(m));
36
+ }
20
37
  let tools = body.tools ?? [];
21
38
  const choice = body.tool_choice;
22
39
  if (choice && typeof choice === 'object' && choice.type === 'function' && choice.name) {
23
40
  tools = tools.filter((t) => t.name === choice.name);
24
41
  body.tool_choice = 'required';
25
42
  }
26
- const fns = tools.filter((t) => t.type === 'function').map(({ strict: _, ...t }) => t);
43
+ // Each function as the assistant's own loop sends it: no `strict`, no JSON Schema dialect tag.
44
+ const fns = tools
45
+ .filter((t) => t.type === 'function')
46
+ .map(({ strict: _, ...t }) => {
47
+ const p = t.parameters;
48
+ if (!p || !('$schema' in p))
49
+ return t;
50
+ const { $schema: _s, ...parameters } = p;
51
+ return { ...t, parameters };
52
+ });
27
53
  const rest = tools.filter((t) => t.type !== 'function');
28
54
  if (fns.length)
29
55
  body.tools = [
30
56
  ...rest,
31
57
  { type: 'namespace', name: 'app', description: `${name}'s tools`, tools: fns },
32
58
  ];
33
- return base(input, { ...init, body: JSON.stringify(body) });
59
+ const res = await base(input, { ...init, body: JSON.stringify(body) });
60
+ // A refusal is logged with OpenAI's own reason; the person sees a plain sentence.
61
+ if (!res.ok)
62
+ console.error(JSON.stringify({
63
+ event: 'assistant_plan_refused',
64
+ status: res.status,
65
+ body: (await res.clone().text()).slice(0, 500),
66
+ }));
67
+ return res;
34
68
  };
35
69
  }
70
+ /** A message's text, whether a string or a list of text parts. */
71
+ function textOf(content) {
72
+ if (typeof content === 'string')
73
+ return content;
74
+ if (!Array.isArray(content))
75
+ return '';
76
+ return content
77
+ .map((p) => p && typeof p === 'object' && typeof p.text === 'string'
78
+ ? p.text
79
+ : '')
80
+ .filter(Boolean)
81
+ .join('\n');
82
+ }
36
83
  /** The AI SDK model a tier runs: its provider's client on the payer's key. */
37
84
  export function aiModel(model, key, o = {}) {
38
85
  if (model.provider === 'anthropic')
@@ -43,9 +90,112 @@ export function aiModel(model, key, o = {}) {
43
90
  /** A tier's effort and output cap as a turn's settings; nothing at the model's own default. */
44
91
  export function turnSettings(model, o) {
45
92
  const out = o.plan || !o.out ? {} : { maxOutputTokens: o.out };
46
- if (o.effort === 'default')
47
- return out;
48
- return model.provider === 'anthropic'
49
- ? { ...out, providerOptions: { anthropic: { effort: o.effort } } }
50
- : { ...out, providerOptions: { openai: { reasoningEffort: o.effort } } };
93
+ // A plan keeps nothing, so earlier turns go back as items, not references to stored ones.
94
+ const openai = o.plan ? { store: false } : {};
95
+ if (o.effort !== 'default') {
96
+ if (model.provider === 'anthropic')
97
+ return { ...out, providerOptions: { anthropic: { effort: o.effort } } };
98
+ openai.reasoningEffort = o.effort;
99
+ }
100
+ return model.provider === 'openai' && Object.keys(openai).length
101
+ ? { ...out, providerOptions: { openai } }
102
+ : out;
103
+ }
104
+ /** A ChatGPT plan used up for now: a 429 or a usage code, thrown or sent before any output. */
105
+ export function planLimited(err) {
106
+ const e = err;
107
+ if (!e)
108
+ return false;
109
+ if (e.statusCode === 429 || e.status === 429)
110
+ return true;
111
+ const text = `${e.code ?? ''} ${e.message ?? ''} ${e.responseBody ?? ''} ${typeof err === 'string' ? err : ''}`;
112
+ return /usage_limit|usage limit|usage_unavailable|insufficient_quota/i.test(text);
113
+ }
114
+ /** Parts that come before an answer starts; an error among them means nothing was said yet. */
115
+ const PREAMBLE = new Set(['stream-start', 'response-metadata']);
116
+ /**
117
+ * `primary` until its ChatGPT plan reaches its usage limit, then `fallback` for that call and
118
+ * every later one on this model (the rest of the turn). The limit shows either as a thrown error
119
+ * or as an error part before any output; once output has started nothing is swapped. `onSwitch`
120
+ * runs once, at the swap.
121
+ */
122
+ export function planFallback(primary, fallback, onSwitch) {
123
+ const p = primary;
124
+ const f = fallback;
125
+ let on = p;
126
+ const swap = () => {
127
+ if (on === f)
128
+ return;
129
+ on = f;
130
+ onSwitch();
131
+ };
132
+ const model = {
133
+ specificationVersion: p.specificationVersion,
134
+ provider: p.provider,
135
+ modelId: p.modelId,
136
+ get supportedUrls() {
137
+ return on.supportedUrls;
138
+ },
139
+ async doGenerate(o) {
140
+ if (on === f)
141
+ return f.doGenerate(o);
142
+ try {
143
+ return await p.doGenerate(o);
144
+ }
145
+ catch (err) {
146
+ if (!planLimited(err))
147
+ throw err;
148
+ swap();
149
+ return f.doGenerate(o);
150
+ }
151
+ },
152
+ async doStream(o) {
153
+ if (on === f)
154
+ return f.doStream(o);
155
+ let r;
156
+ try {
157
+ r = await p.doStream(o);
158
+ }
159
+ catch (err) {
160
+ if (!planLimited(err))
161
+ throw err;
162
+ swap();
163
+ return f.doStream(o);
164
+ }
165
+ // Hold the preamble until the first real part shows whether the plan answered.
166
+ const reader = r.stream.getReader();
167
+ const held = [];
168
+ for (;;) {
169
+ const { done, value } = await reader.read();
170
+ if (done)
171
+ break;
172
+ if (value.type === 'error' && planLimited(value.error)) {
173
+ await reader.cancel().catch(() => undefined);
174
+ swap();
175
+ return f.doStream(o);
176
+ }
177
+ held.push(value);
178
+ if (!PREAMBLE.has(value.type))
179
+ break;
180
+ }
181
+ const stream = new ReadableStream({
182
+ start(c) {
183
+ for (const v of held)
184
+ c.enqueue(v);
185
+ },
186
+ async pull(c) {
187
+ const { done, value } = await reader.read();
188
+ if (done)
189
+ c.close();
190
+ else
191
+ c.enqueue(value);
192
+ },
193
+ cancel(reason) {
194
+ return reader.cancel(reason);
195
+ },
196
+ });
197
+ return { ...r, stream };
198
+ },
199
+ };
200
+ return model;
51
201
  }
@@ -388,6 +388,13 @@ export class Assistant {
388
388
  return apiKey ? { apiKey, ...(paying.plans[provider] ? { plan: true } : {}) } : null;
389
389
  };
390
390
  const key = keyOf(main.model.provider) ?? { apiKey: paying.apiKey };
391
+ // A ChatGPT plan used up for now: the app's first Claude (its smallest) answers instead, on
392
+ // the same payer's Anthropic key, rather than the turn ending in the limit's error.
393
+ const claude = key.plan ? this.models.find((m) => m.provider === 'anthropic') : undefined;
394
+ const claudeKey = claude ? keyOf('anthropic') : null;
395
+ const fallback = claude && claudeKey
396
+ ? { model: claude, key: claudeKey, label: `${main.tier.label} · ${claude.label}` }
397
+ : undefined;
391
398
  const p = await this.state.person(caller.id);
392
399
  const policy = p.hand_off ?? 'next';
393
400
  const advisor = await this.advisorFor(caller, p.advice, main);
@@ -423,6 +430,7 @@ export class Assistant {
423
430
  ? `The person handed this message to ${handTo.label}: call escalate {tier: "${handTo.id}", why: "asked"} now, its task the message with every fact from this chat it needs, then answer from its memo.\n\n`
424
431
  : ''}${command}`,
425
432
  sources: await this.sourcesOf(caller, p),
433
+ ...(fallback ? { fallback } : {}),
426
434
  left: paying.left,
427
435
  maxSteps: MAX_ROUNDS,
428
436
  ...(this.o.fetch ? { fetch: this.o.fetch } : {}),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@columenlabs/lumen-ai",
3
- "version": "0.1.185",
3
+ "version": "0.1.187",
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",
@@ -48,8 +48,8 @@
48
48
  "@ai-sdk/openai": "4.0.108",
49
49
  "@anthropic-ai/sdk": "0.133.0",
50
50
  "@cloudflare/think": "0.23.0",
51
- "@columenlabs/lumen-ops": "0.1.185",
52
- "@columenlabs/lumen-ui-types": "0.1.185",
51
+ "@columenlabs/lumen-ops": "0.1.187",
52
+ "@columenlabs/lumen-ui-types": "0.1.187",
53
53
  "agents": "0.32.0",
54
54
  "ai": "7.0.162",
55
55
  "just-bash": "3.6.0",