@columenlabs/lumen-ai 0.1.229 → 0.1.231

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
@@ -72,6 +72,7 @@ const assistant = createAssistant({
72
72
  name: 'Helper',
73
73
  system: 'You help people with their notes. Use the notes tools for facts.',
74
74
  hideModel: true, // never names its model, provider or tier; asked, it is just "Helper"
75
+ // pickTier: false, // the app picks each turn's tier (agentTurn input.tier); no tier menu in the panel
75
76
  // Smallest first. The app names them; a person picks each tier's model and effort.
76
77
  tiers: [
77
78
  { id: 'fast', label: 'Fast', about: 'Quick lookups', model: 'claude-haiku-5-5' },
@@ -86,7 +87,15 @@ const assistant = createAssistant({
86
87
  sealer, // the app's own sealing: keys and chats are stored sealed
87
88
  // A provider's key when none is set in Settings.
88
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',
89
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' });
90
99
 
91
100
  const routes = assistantRoutes(assistant, {
92
101
  base: '/api/assistant',
@@ -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';
@@ -142,6 +142,12 @@ export interface AssistantOptions {
142
142
  * it, and answers "which model are you?" with its own `name`. Off by default (it names them).
143
143
  */
144
144
  hideModel?: boolean;
145
+ /**
146
+ * Who picks the tier a chat turn runs on. False: the app does, each turn, through
147
+ * `AgentTurnInput.tier` (any of its tiers; left out, the chat task's tier). The person's saved
148
+ * tier and task picks are ignored and the panel offers no tier menu. True by default.
149
+ */
150
+ pickTier?: boolean;
145
151
  /**
146
152
  * The app's slash commands, listed in the composer when the person types `/`. A message
147
153
  * `/name words` is sent to the model as `prompt` with `{args}` replaced by the words (or what the
@@ -188,6 +194,23 @@ export interface AssistantOptions {
188
194
  keepDays?: number;
189
195
  /** The fetch the model providers and ScrapeQL calls go through. */
190
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';
191
214
  onStep?: (s: StepRecord) => void | Promise<void>;
192
215
  }
193
216
  /** A turn's failure the person can act on. */
@@ -244,6 +267,12 @@ export declare class Assistant {
244
267
  private mainTier;
245
268
  private access;
246
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;
247
276
  /**
248
277
  * One turn as a Think agent's plan (`LumenAgent` in `@columenlabs/lumen-ai/agent`): the person's
249
278
  * tier and its model on whoever pays, the tool groups at their access, the kit's rules, the
@@ -343,6 +372,11 @@ export declare class Assistant {
343
372
  * task does more than one kind of work (a first try, a repair). Left out, it is the task.
344
373
  */
345
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;
346
380
  }): Promise<{
347
381
  text: string;
348
382
  usd: number;
@@ -216,7 +216,7 @@ export class Assistant {
216
216
  }
217
217
  /** The tier the person's chat runs on: their pick, else the chat task's start tier. */
218
218
  async mainTier(caller, task = 'chat') {
219
- const p = await this.state.person(caller.id);
219
+ const p = this.o.pickTier === false ? {} : await this.state.person(caller.id);
220
220
  const startId = p.tasks?.[task] ?? this.tasks.find((t) => t.id === task)?.tier;
221
221
  return (this.tierById(task === 'chat' ? (p.tier ?? startId) : startId) ?? this.tiers[0]);
222
222
  }
@@ -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
@@ -240,11 +257,14 @@ export class Assistant {
240
257
  async agentTurn(caller, input) {
241
258
  const message = input.message.trim();
242
259
  const picked = await this.mainTier(caller);
243
- const lower = input.tier && !this.above(picked).some((t) => t.id === input.tier)
260
+ // The app picks the tier (pickTier: false): any of its tiers, never a hand off.
261
+ const byApp = this.o.pickTier === false;
262
+ const lower = input.tier && (byApp || !this.above(picked).some((t) => t.id === input.tier))
244
263
  ? this.tierById(input.tier)
245
264
  : undefined;
246
265
  const main = await this.session(caller, lower ?? picked);
247
- 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);
248
268
  if (!paying) {
249
269
  const code = wait ?? 'not_connected';
250
270
  throw new AssistantError(waitText(code, main.model.provider, !!this.o.allowance), code, 402);
@@ -254,17 +274,30 @@ export class Assistant {
254
274
  return apiKey ? { apiKey, ...(paying.plans[provider] ? { plan: true } : {}) } : null;
255
275
  };
256
276
  const key = keyOf(main.model.provider) ?? { apiKey: paying.apiKey };
257
- // A ChatGPT plan used up for now: the app's first Claude (its smallest) answers instead, on
258
- // the same payer's Anthropic key, rather than the turn ending in the limit's error.
259
- const claude = key.plan ? this.models.find((m) => m.provider === 'anthropic') : undefined;
260
- const claudeKey = claude ? keyOf('anthropic') : null;
261
- const fallback = claude && claudeKey
262
- ? { model: claude, key: claudeKey, label: `${main.tier.label} · ${claude.label}` }
263
- : 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
+ }
264
297
  const p = await this.state.person(caller.id);
265
298
  const policy = p.hand_off ?? 'next';
266
299
  const advisor = await this.advisorFor(caller, p.advice, main);
267
- const handTo = input.tier ? this.above(main.tier).find((t) => t.id === input.tier) : undefined;
300
+ const handTo = input.tier && !byApp ? this.above(main.tier).find((t) => t.id === input.tier) : undefined;
268
301
  const appSystem = typeof this.o.system === 'string' ? this.o.system : await this.o.system(caller);
269
302
  const asked = await this.expand(caller, message);
270
303
  const told = await bearings({
@@ -339,9 +372,16 @@ export class Assistant {
339
372
  : {},
340
373
  };
341
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;
342
378
  await this.state
343
- .addSpend(caller.id, paying.payer, end.parts, { task: 'chat', ms: end.ms })
379
+ .addSpend(caller.id, paying.payer, onPayer, { task: 'chat', ms: end.ms })
344
380
  .catch(() => undefined);
381
+ if (onShared.length)
382
+ await this.state
383
+ .addSpend(caller.id, 'app', onShared, { task: 'chat' })
384
+ .catch(() => undefined);
345
385
  if (input.chat)
346
386
  await this.state
347
387
  .addUsage(caller.id, { id: `chat:${input.chat}`, kind: 'chat', title: message.split('\n')[0] || 'Chat' }, end.parts)
@@ -493,6 +533,7 @@ export class Assistant {
493
533
  const mainModel = (await this.pick(caller, main)).model;
494
534
  // The binding's last test has no token doc to sit in; it is read beside the rest.
495
535
  const linkTest = this.o.web?.scrapeql?.fetch ? this.state.scrapeqlLink() : null;
536
+ const ownWays = await this.ownWaysOf(caller);
496
537
  const [p, providers, scrapeql, spend, appCap, ownCap, { paying, wait }] = await Promise.all([
497
538
  this.state.person(caller.id),
498
539
  Promise.all(this.providers.map(async (id) => {
@@ -518,13 +559,14 @@ export class Assistant {
518
559
  ? { plan: keyState(plan?.doc ?? null, (d) => d.api_key) }
519
560
  : {}),
520
561
  ...(use ? { use } : {}),
562
+ ...(ownWays ? { own_ways: ownWays[id] ?? [] } : {}),
521
563
  };
522
564
  })),
523
565
  this.o.web?.scrapeql ? this.state.scrapeql() : Promise.resolve(null),
524
566
  this.state.spend(caller.id),
525
567
  this.state.appCap(),
526
568
  this.state.ownCap(caller.id),
527
- this.state.paying(caller.id, mainModel.provider),
569
+ this.payingRules(caller).then((r) => this.state.paying(caller.id, mainModel.provider, r)),
528
570
  ]);
529
571
  const link = await linkTest;
530
572
  const tiers = await Promise.all(this.tiers.map(async (t) => {
@@ -551,6 +593,7 @@ export class Assistant {
551
593
  admin: caller.admin,
552
594
  tier: main.id,
553
595
  tiers,
596
+ ...(this.o.pickTier === false ? { pick_tier: false } : {}),
554
597
  models: this.models.map((m) => ({
555
598
  id: m.id,
556
599
  label: m.label,
@@ -922,7 +965,7 @@ export class Assistant {
922
965
  async complete(caller, o) {
923
966
  const began = Date.now();
924
967
  const s = await this.session(caller, await this.mainTier(caller, o.task));
925
- 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));
926
969
  if (!paying) {
927
970
  const code = wait ?? 'not_connected';
928
971
  throw new AssistantError(waitText(code, s.model.provider, !!this.o.allowance), code, 402);
@@ -1009,7 +1052,7 @@ export class Assistant {
1009
1052
  */
1010
1053
  async image(caller, o) {
1011
1054
  const began = Date.now();
1012
- 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));
1013
1056
  if (!paying) {
1014
1057
  const code = wait ?? 'not_connected';
1015
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.229",
3
+ "version": "0.1.231",
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.229",
56
- "@columenlabs/lumen-ui-types": "0.1.229",
55
+ "@columenlabs/lumen-ops": "0.1.231",
56
+ "@columenlabs/lumen-ui-types": "0.1.231",
57
57
  "agents": "0.32.0",
58
58
  "ai": "7.0.162",
59
59
  "just-bash": "3.6.0",