@columenlabs/lumen-ai 0.1.230 → 0.1.232

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
@@ -87,7 +87,15 @@ const assistant = createAssistant({
87
87
  sealer, // the app's own sealing: keys and chats are stored sealed
88
88
  // A provider's key when none is set in Settings.
89
89
  appKey: async (provider) => (provider === 'openai' ? env.OPENAI_API_KEY : env.ANTHROPIC_API_KEY),
90
+ // Bring your own AI, per caller (a feature flag, a person's plan): which own ways Settings offers
91
+ // and the routes accept, whether their own key falls back to the shared one, and who finishes a
92
+ // turn when a ChatGPT plan runs out ('shared': the same model on the app's key, shared spend).
93
+ // ownWays: async (caller) => ({ openai: ['key', 'plan'] }),
94
+ // ownFallback: async (caller) => true,
95
+ // planFallback: 'shared',
90
96
  });
97
+ // A check the app always runs on its own key, whoever pays the person's answers:
98
+ // await assistant.complete(caller, { task: 'scope', system, prompt, payer: 'app' });
91
99
 
92
100
  const routes = assistantRoutes(assistant, {
93
101
  base: '/api/assistant',
@@ -144,6 +144,8 @@ export interface AskInput {
144
144
  screen?: AssistantScreenMap;
145
145
  /** How earlier screen calls ended in the browser. */
146
146
  outcomes?: AssistantScreenOutcome[];
147
+ /** The person kept this message in this chat after a fresh chat was offered (`offerFresh`). */
148
+ stay?: boolean;
147
149
  }
148
150
  /** A quote as sent, checked: its text cut to `ASSISTANT_QUOTE_MAX`, and whose message it came from. */
149
151
  export declare function quoteOf(q: unknown): AssistantQuote | undefined;
@@ -182,6 +184,13 @@ export declare abstract class LumenAgent<Env = unknown> extends Think<Env> {
182
184
  includeMcpTools: boolean;
183
185
  /** What the person sent with the message this turn answers (page, refs, tier, zone). */
184
186
  protected get input(): AskInput | null;
187
+ /**
188
+ * Whether to ask the person to take `input` to a fresh chat before answering it: `true` when it
189
+ * starts new work and this chat is long (the app decides both, e.g. from `usage().context`).
190
+ * The panel then shows a card: + sends the message in a new chat, × sends it here again with
191
+ * `stay`. Off (`false`) unless the app overrides it; a throw counts as `false`.
192
+ */
193
+ protected offerFresh(_input: AskInput): Promise<boolean>;
185
194
  /** The chat's running use: every answer's parts added up, and the last context size. */
186
195
  usage(): ChatUsage;
187
196
  /** The chat as the panel reads it: title, last message, items and use. */
@@ -184,6 +184,15 @@ export class LumenAgent extends Think {
184
184
  this.#meta();
185
185
  this.sql `INSERT OR REPLACE INTO lumen_chat (k, v) VALUES (${k}, ${JSON.stringify(v)})`;
186
186
  }
187
+ /**
188
+ * Whether to ask the person to take `input` to a fresh chat before answering it: `true` when it
189
+ * starts new work and this chat is long (the app decides both, e.g. from `usage().context`).
190
+ * The panel then shows a card: + sends the message in a new chat, × sends it here again with
191
+ * `stay`. Off (`false`) unless the app overrides it; a throw counts as `false`.
192
+ */
193
+ async offerFresh(_input) {
194
+ return false;
195
+ }
187
196
  /** The chat's running use: every answer's parts added up, and the last context size. */
188
197
  usage() {
189
198
  return this.#get('usage') ?? chatUsage([]);
@@ -229,6 +238,12 @@ export class LumenAgent extends Think {
229
238
  }
230
239
  this.#put('at', at);
231
240
  await sink.event({ event: 'chat', data: { id: this.name, title } });
241
+ // New work in a long chat: the panel asks first, and the message is not kept or answered here.
242
+ if (!input.stay && (await this.offerFresh(input).catch(() => false))) {
243
+ await sink.event({ event: 'fresh', data: {} });
244
+ await sink.event({ event: 'done', data: { id: this.name, usage: this.usage() } });
245
+ return;
246
+ }
232
247
  this.#outcomes(screenOutcomesOf(input.outcomes));
233
248
  const quote = quoteOf(input.quote);
234
249
  const meta = {
@@ -1,8 +1,8 @@
1
- import type { AllowanceRequest, AllowanceState, AllowanceUnit, AssistantAccess, AssistantChat, AssistantChatRow, AssistantEffort, AssistantMessage, AssistantOwnWay, AssistantProvider, AssistantStatus, UsagePart } from '@columenlabs/lumen-ui-types/assistant';
1
+ import type { AllowanceRequest, AllowanceState, AllowanceUnit, AssistantAccess, AssistantChat, AssistantChatRow, AssistantEffort, AssistantMessage, AssistantOwnWay, AssistantPayer, AssistantProvider, AssistantStatus, UsagePart } from '@columenlabs/lumen-ui-types/assistant';
2
2
  import type { AgentTurnEnd, AgentTurnPlan } from '../agent/agent.js';
3
3
  import { type BearingsExtra, type BearingsNow } from './bearings.js';
4
4
  import { type ModelDef, type TaskDef, type TierDef } from './models.js';
5
- import { State } from './state.js';
5
+ import { type OwnWays, State } from './state.js';
6
6
  import { type AssistantStore, type Sealer } from './store.js';
7
7
  import { type ToolSource } from './tools.js';
8
8
  import { type AccessToken, type WebFetchOptions } from './web.js';
@@ -194,6 +194,23 @@ export interface AssistantOptions {
194
194
  keepDays?: number;
195
195
  /** The fetch the model providers and ScrapeQL calls go through. */
196
196
  fetch?: typeof fetch;
197
+ /**
198
+ * Which of their own ways (an API key, a plan sign-in) a person may pay with, per provider: a
199
+ * provider left out has none, and Settings shows only these. Left out, every way of every
200
+ * provider (feature flags or a person's plan decide it per caller).
201
+ */
202
+ ownWays?: (caller: Caller) => OwnWays | Promise<OwnWays>;
203
+ /**
204
+ * Whether a person who pays with their own key first moves to the shared key when theirs is at
205
+ * its cap or their plan is used up. True by default.
206
+ */
207
+ ownFallback?: (caller: Caller) => boolean | Promise<boolean>;
208
+ /**
209
+ * Who answers when a ChatGPT plan reaches its usage limit mid-turn. 'claude' (default): the
210
+ * app's smallest Claude on the same payer's Anthropic key. 'shared': the same model on the app's
211
+ * shared key, counted as shared spend (only when `ownFallback` allows it).
212
+ */
213
+ planFallback?: 'claude' | 'shared';
197
214
  onStep?: (s: StepRecord) => void | Promise<void>;
198
215
  }
199
216
  /** A turn's failure the person can act on. */
@@ -250,6 +267,12 @@ export declare class Assistant {
250
267
  private mainTier;
251
268
  private access;
252
269
  private groupById;
270
+ /** The own ways the app lets this person pay with (`ownWays`); undefined is every way. */
271
+ ownWaysOf(caller: Caller): Promise<OwnWays | undefined>;
272
+ /** Whether this person may use `way` of their own on `provider`. */
273
+ mayOwn(caller: Caller, provider: AssistantProvider, way: AssistantOwnWay): Promise<boolean>;
274
+ /** The app's rules for who pays this person's answers (own ways, fallback). */
275
+ private payingRules;
253
276
  /**
254
277
  * One turn as a Think agent's plan (`LumenAgent` in `@columenlabs/lumen-ai/agent`): the person's
255
278
  * tier and its model on whoever pays, the tool groups at their access, the kit's rules, the
@@ -349,6 +372,11 @@ export declare class Assistant {
349
372
  * task does more than one kind of work (a first try, a repair). Left out, it is the task.
350
373
  */
351
374
  tag?: string;
375
+ /**
376
+ * Only this payer: 'app' runs the call on the shared key whatever the person pays with (a
377
+ * check the app always runs on its own key). Left out, whoever pays the person's answers.
378
+ */
379
+ payer?: AssistantPayer;
352
380
  }): Promise<{
353
381
  text: string;
354
382
  usd: number;
@@ -229,6 +229,23 @@ export class Assistant {
229
229
  groupById(id) {
230
230
  return this.groups.find((g) => g.id === id);
231
231
  }
232
+ /** The own ways the app lets this person pay with (`ownWays`); undefined is every way. */
233
+ async ownWaysOf(caller) {
234
+ return this.o.ownWays ? await this.o.ownWays(caller) : undefined;
235
+ }
236
+ /** Whether this person may use `way` of their own on `provider`. */
237
+ async mayOwn(caller, provider, way) {
238
+ const ways = await this.ownWaysOf(caller);
239
+ return !ways || !!ways[provider]?.includes(way);
240
+ }
241
+ /** The app's rules for who pays this person's answers (own ways, fallback). */
242
+ async payingRules(caller, only) {
243
+ const [ways, fallback] = await Promise.all([
244
+ this.ownWaysOf(caller),
245
+ this.o.ownFallback ? this.o.ownFallback(caller) : true,
246
+ ]);
247
+ return { ...(ways ? { ways } : {}), fallback, ...(only ? { only } : {}) };
248
+ }
232
249
  // ---------------------------------------------------------------- a turn on Think
233
250
  /**
234
251
  * One turn as a Think agent's plan (`LumenAgent` in `@columenlabs/lumen-ai/agent`): the person's
@@ -246,7 +263,8 @@ export class Assistant {
246
263
  ? this.tierById(input.tier)
247
264
  : undefined;
248
265
  const main = await this.session(caller, lower ?? picked);
249
- const { paying, wait } = await this.state.paying(caller.id, main.model.provider);
266
+ const rules = await this.payingRules(caller);
267
+ const { paying, wait } = await this.state.paying(caller.id, main.model.provider, rules);
250
268
  if (!paying) {
251
269
  const code = wait ?? 'not_connected';
252
270
  throw new AssistantError(waitText(code, main.model.provider, !!this.o.allowance), code, 402);
@@ -256,13 +274,26 @@ export class Assistant {
256
274
  return apiKey ? { apiKey, ...(paying.plans[provider] ? { plan: true } : {}) } : null;
257
275
  };
258
276
  const key = keyOf(main.model.provider) ?? { apiKey: paying.apiKey };
259
- // A ChatGPT plan used up for now: the app's first Claude (its smallest) answers instead, on
260
- // the same payer's Anthropic key, rather than the turn ending in the limit's error.
261
- const claude = key.plan ? this.models.find((m) => m.provider === 'anthropic') : undefined;
262
- const claudeKey = claude ? keyOf('anthropic') : null;
263
- const fallback = claude && claudeKey
264
- ? { model: claude, key: claudeKey, label: `${main.tier.label} · ${claude.label}` }
265
- : undefined;
277
+ // A ChatGPT plan used up for now: rather than the turn ending in the limit's error, the same
278
+ // model on the app's shared key ('shared'), or the app's first Claude (its smallest) on the
279
+ // same payer's Anthropic key.
280
+ let fallback;
281
+ let sharedLabel;
282
+ if (key.plan && this.o.planFallback === 'shared') {
283
+ const shared = rules.fallback !== false
284
+ ? (await this.state.paying(caller.id, main.model.provider, { only: 'app' })).paying
285
+ : null;
286
+ if (shared) {
287
+ sharedLabel = `${main.meter.label} · shared key`;
288
+ fallback = { model: main.model, key: { apiKey: shared.apiKey }, label: sharedLabel };
289
+ }
290
+ }
291
+ else if (key.plan) {
292
+ const claude = this.models.find((m) => m.provider === 'anthropic');
293
+ const claudeKey = claude ? keyOf('anthropic') : null;
294
+ if (claude && claudeKey)
295
+ fallback = { model: claude, key: claudeKey, label: `${main.tier.label} · ${claude.label}` };
296
+ }
266
297
  const p = await this.state.person(caller.id);
267
298
  const policy = p.hand_off ?? 'next';
268
299
  const advisor = await this.advisorFor(caller, p.advice, main);
@@ -341,9 +372,16 @@ export class Assistant {
341
372
  : {},
342
373
  };
343
374
  const settle = async (end) => {
375
+ // What the shared key answered after a plan ran out is the app's spend, not the person's.
376
+ const onShared = sharedLabel ? end.parts.filter((x) => x.label === sharedLabel) : [];
377
+ const onPayer = onShared.length ? end.parts.filter((x) => !onShared.includes(x)) : end.parts;
344
378
  await this.state
345
- .addSpend(caller.id, paying.payer, end.parts, { task: 'chat', ms: end.ms })
379
+ .addSpend(caller.id, paying.payer, onPayer, { task: 'chat', ms: end.ms })
346
380
  .catch(() => undefined);
381
+ if (onShared.length)
382
+ await this.state
383
+ .addSpend(caller.id, 'app', onShared, { task: 'chat' })
384
+ .catch(() => undefined);
347
385
  if (input.chat)
348
386
  await this.state
349
387
  .addUsage(caller.id, { id: `chat:${input.chat}`, kind: 'chat', title: message.split('\n')[0] || 'Chat' }, end.parts)
@@ -495,6 +533,7 @@ export class Assistant {
495
533
  const mainModel = (await this.pick(caller, main)).model;
496
534
  // The binding's last test has no token doc to sit in; it is read beside the rest.
497
535
  const linkTest = this.o.web?.scrapeql?.fetch ? this.state.scrapeqlLink() : null;
536
+ const ownWays = await this.ownWaysOf(caller);
498
537
  const [p, providers, scrapeql, spend, appCap, ownCap, { paying, wait }] = await Promise.all([
499
538
  this.state.person(caller.id),
500
539
  Promise.all(this.providers.map(async (id) => {
@@ -520,13 +559,14 @@ export class Assistant {
520
559
  ? { plan: keyState(plan?.doc ?? null, (d) => d.api_key) }
521
560
  : {}),
522
561
  ...(use ? { use } : {}),
562
+ ...(ownWays ? { own_ways: ownWays[id] ?? [] } : {}),
523
563
  };
524
564
  })),
525
565
  this.o.web?.scrapeql ? this.state.scrapeql() : Promise.resolve(null),
526
566
  this.state.spend(caller.id),
527
567
  this.state.appCap(),
528
568
  this.state.ownCap(caller.id),
529
- this.state.paying(caller.id, mainModel.provider),
569
+ this.payingRules(caller).then((r) => this.state.paying(caller.id, mainModel.provider, r)),
530
570
  ]);
531
571
  const link = await linkTest;
532
572
  const tiers = await Promise.all(this.tiers.map(async (t) => {
@@ -925,7 +965,7 @@ export class Assistant {
925
965
  async complete(caller, o) {
926
966
  const began = Date.now();
927
967
  const s = await this.session(caller, await this.mainTier(caller, o.task));
928
- const { paying, wait } = await this.state.paying(caller.id, s.model.provider);
968
+ const { paying, wait } = await this.state.paying(caller.id, s.model.provider, await this.payingRules(caller, o.payer));
929
969
  if (!paying) {
930
970
  const code = wait ?? 'not_connected';
931
971
  throw new AssistantError(waitText(code, s.model.provider, !!this.o.allowance), code, 402);
@@ -1012,7 +1052,7 @@ export class Assistant {
1012
1052
  */
1013
1053
  async image(caller, o) {
1014
1054
  const began = Date.now();
1015
- const { paying, wait } = await this.state.paying(caller.id, 'openai');
1055
+ const { paying, wait } = await this.state.paying(caller.id, 'openai', await this.payingRules(caller));
1016
1056
  if (!paying) {
1017
1057
  const code = wait ?? 'not_connected';
1018
1058
  throw new AssistantError(waitText(code, 'openai', !!this.o.allowance), code, 402);
@@ -173,6 +173,15 @@ export function assistantRoutes(app, o) {
173
173
  return fail(`This app runs no ${asked} models.`, 404);
174
174
  const prov = provider ?? 'anthropic';
175
175
  const docName = keyDocName(prov);
176
+ // Adding a way of their own the app does not let this person use (`ownWays`); removing one
177
+ // always works.
178
+ const addsOwn = route === 'PUT /my-key'
179
+ ? 'key'
180
+ : route === 'POST /my-signin' || route === 'POST /my-signin/finish'
181
+ ? 'plan'
182
+ : null;
183
+ if (addsOwn && !(await a.mayOwn(caller, prov, addsOwn)))
184
+ return fail("That isn't available on your account.", 403, 'forbidden');
176
185
  try {
177
186
  switch (route) {
178
187
  case 'GET /status':
@@ -97,6 +97,17 @@ export interface RequestsDoc {
97
97
  export type SpendRow = UsagePart & {
98
98
  payer: AssistantPayer;
99
99
  };
100
+ /** The ways of their own a person may pay with, per provider; a provider left out has none. */
101
+ export type OwnWays = Partial<Record<AssistantProvider, AssistantOwnWay[]>>;
102
+ /** How `paying` may choose: the app's limits on a person's own keys, and one payer only. */
103
+ export interface PayingRules {
104
+ /** The own ways this person may use (`AssistantOptions.ownWays`); left out, every way. */
105
+ ways?: OwnWays;
106
+ /** False: a person who pays with their own key first never moves to the shared key. */
107
+ fallback?: boolean;
108
+ /** Only this payer: an app's own check always runs on the shared key (`complete({ payer })`). */
109
+ only?: AssistantPayer;
110
+ }
100
111
  /**
101
112
  * One task's use of one model this month: what a kind of work cost (a chat answer, a background
102
113
  * task), beside `rows`, which stay per tier and payer.
@@ -219,7 +230,7 @@ export declare class State {
219
230
  /** The plan's access token, refreshed first when it is about to run out; null once it fails. */
220
231
  planToken(id: string, provider: AssistantProvider): Promise<string | null>;
221
232
  /** The person's own key or plan, the one in use first, the other when that one fails. */
222
- ownApiKey(id: string, provider: AssistantProvider): Promise<{
233
+ ownApiKey(id: string, provider: AssistantProvider, ways?: OwnWays): Promise<{
223
234
  key: string;
224
235
  plan: boolean;
225
236
  } | null>;
@@ -253,12 +264,12 @@ export declare class State {
253
264
  /** One month's history (this month by default) beside its spend, and the months that have one. */
254
265
  usage(id: string, month?: string): Promise<UsageHistory>;
255
266
  /** The payer's key of each provider, those set, and which are plan sign-ins. */
256
- keysOf(id: string, payer: AssistantPayer): Promise<{
267
+ keysOf(id: string, payer: AssistantPayer, ways?: OwnWays): Promise<{
257
268
  keys: Partial<Record<"anthropic" | "openai" | "xai", string>>;
258
269
  plans: Partial<Record<"anthropic" | "openai" | "xai", boolean>>;
259
270
  }>;
260
271
  /** Who pays the person's next answer on `provider`'s model, or why nobody can. */
261
- paying(id: string, provider: AssistantProvider): Promise<{
272
+ paying(id: string, provider: AssistantProvider, rules?: PayingRules): Promise<{
262
273
  paying: Paying | null;
263
274
  wait: AssistantWait | null;
264
275
  }>;
@@ -193,7 +193,10 @@ export class State {
193
193
  }
194
194
  }
195
195
  /** The person's own key or plan, the one in use first, the other when that one fails. */
196
- async ownApiKey(id, provider) {
196
+ async ownApiKey(id, provider, ways) {
197
+ const allowed = ways ? (ways[provider] ?? []) : null;
198
+ if (allowed && !allowed.length)
199
+ return null;
197
200
  const use = await this.ownUse(id, provider);
198
201
  if (!use)
199
202
  return null;
@@ -205,7 +208,12 @@ export class State {
205
208
  const t = await this.planToken(id, provider);
206
209
  return t ? { key: t, plan: true } : null;
207
210
  };
208
- return use === 'key' ? ((await key()) ?? (await plan())) : ((await plan()) ?? (await key()));
211
+ const may = (w) => !allowed || allowed.includes(w);
212
+ const tryKey = async () => (may('key') ? key() : null);
213
+ const tryPlan = async () => (may('plan') ? plan() : null);
214
+ return use === 'key'
215
+ ? ((await tryKey()) ?? (await tryPlan()))
216
+ : ((await tryPlan()) ?? (await tryKey()));
209
217
  }
210
218
  /** USD a month on the person's own keys. */
211
219
  async ownCap(id) {
@@ -340,11 +348,11 @@ export class State {
340
348
  };
341
349
  }
342
350
  /** The payer's key of each provider, those set, and which are plan sign-ins. */
343
- async keysOf(id, payer) {
351
+ async keysOf(id, payer, ways) {
344
352
  const ids = Object.keys(PROVIDERS);
345
353
  const found = await Promise.all(ids.map((p) => payer === 'app'
346
354
  ? this.appApiKey(p).then((k) => (k ? { key: k.key, plan: false } : null))
347
- : this.ownApiKey(id, p)));
355
+ : this.ownApiKey(id, p, ways)));
348
356
  const keys = {};
349
357
  const plans = {};
350
358
  ids.forEach((p, i) => {
@@ -357,11 +365,11 @@ export class State {
357
365
  return { keys, plans };
358
366
  }
359
367
  /** Who pays the person's next answer on `provider`'s model, or why nobody can. */
360
- async paying(id, provider) {
368
+ async paying(id, provider, rules = {}) {
361
369
  const [person, appKeys, ownKeys, spend, ownCap] = await Promise.all([
362
370
  this.person(id),
363
371
  this.keysOf(id, 'app'),
364
- this.keysOf(id, 'own'),
372
+ this.keysOf(id, 'own', rules.ways),
365
373
  this.spend(id),
366
374
  this.ownCap(id),
367
375
  ]);
@@ -389,8 +397,16 @@ export class State {
389
397
  }
390
398
  : null,
391
399
  };
392
- const first = person.pay ?? 'app';
393
- const order = first === 'own' ? ['own', 'app'] : ['app', 'own'];
400
+ const first = rules.only ?? person.pay ?? 'app';
401
+ const order = rules.only
402
+ ? [rules.only]
403
+ : first === 'own'
404
+ ? rules.fallback === false && sources.own
405
+ ? ['own']
406
+ : ['own', 'app']
407
+ : ['app', 'own'];
408
+ if (rules.only)
409
+ sources[rules.only === 'app' ? 'own' : 'app'] = null;
394
410
  for (const p of order) {
395
411
  const s = sources[p];
396
412
  if (s && s.left > 0)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@columenlabs/lumen-ai",
3
- "version": "0.1.230",
3
+ "version": "0.1.232",
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",
@@ -52,8 +52,8 @@
52
52
  "@ai-sdk/openai": "4.0.108",
53
53
  "@anthropic-ai/sdk": "0.133.0",
54
54
  "@cloudflare/think": "0.23.0",
55
- "@columenlabs/lumen-ops": "0.1.230",
56
- "@columenlabs/lumen-ui-types": "0.1.230",
55
+ "@columenlabs/lumen-ops": "0.1.232",
56
+ "@columenlabs/lumen-ui-types": "0.1.232",
57
57
  "agents": "0.32.0",
58
58
  "ai": "7.0.162",
59
59
  "just-bash": "3.6.0",