@columenlabs/lumen-ai 0.1.246 → 0.1.247

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
@@ -106,7 +106,7 @@ app.all('/api/assistant/*', (c) => routes(c.req.raw));
106
106
  ```
107
107
 
108
108
  - **Providers.** A tier runs any model in `models` (Claude's and OpenAI's by default, `CLAUDE_MODELS`, `OPENAI_MODELS`): Claude through Anthropic's Messages API, OpenAI through the Responses API, with the same tools, drafts, Stop and caps. xAI's Grok models (`XAI_MODELS`) are opt in: an app that lists them gets an xAI row in Model providers (an API key, `xai-…`); they run on the same Responses client at `XAI_BASE_URL` (`https://api.x.ai/v1`). xAI has no public sign-in for other apps, so Grok takes a key only. A hand-off may cross providers. The advisor and prompt caching marks are Claude's only.
109
- - **Keys.** Each provider has its own keys (`?provider=anthropic|openai|xai` on the key routes). An admin pastes the shared key everyone uses (`PUT /key`); it is tested with one free call before it is kept, then sealed, and no route returns it. A person may add their own (`PUT /my-key`), with one monthly cap on their own keys (`PUT /my-key/cap`).
109
+ - **Keys.** Each provider has its own keys (`?provider=anthropic|openai|xai` on the key routes). An admin pastes the shared key everyone uses (`PUT /key`); it is tested with one free call before it is kept, then sealed, and no route returns it. A person may add their own (`PUT /my-key`), with one monthly cap on their own keys (`PUT /my-key/cap`). The cap counts what their own API keys spent this month on providers they can still pay with; a plan sign-in (ChatGPT) costs them nothing per call, so its calls count $0 (each own spend row records its `way`). Status gives that count as `spend.own_used`.
110
110
  - **ChatGPT sign-in.** In place of their own OpenAI key a person may sign in with their ChatGPT plan (`POST /my-signin`, then `POST /my-signin/finish {url}`): the sign-in opens in a new tab and ends on `http://127.0.0.1:1455/auth/callback`, a page that does not load, whose address the person pastes back. The tokens are sealed as their own key and refreshed before they run out; answers on the plan count tokens and no dollars.
111
111
  - **Who pays.** The person's pick (the shared keys by default) while it has a key for the tier's provider and money left this month under its cap (an admin sets the shared keys' cap per person, `PUT /app`), else the other; with neither the panel says why.
112
112
  - **Tools.** Each group is Off, Read, Read + draft or Read + write per person. A tool is a read when its MCP `readOnlyHint` is set. On Read + draft a write only drafts a card, and runs as the person when they press Save, after access is checked again. A group with `readOnly: true` (read tools only) is offered Off or Read alone. Read + write is offered only for a group with `writes: true`: its writes run at once, and with `undo` set each one's line in the chat has Undo, which calls `undo(caller, { tool, args, result })`; the model is told what was undone. Cards drafted one after another show as one stack with Save all. Use `mcpServerTools` (an SDK `McpServer`), `mcpFetchTools` (a server served as a fetch handler, any SDK), `remoteMcpTools` or `plainTools`.
@@ -253,6 +253,8 @@ Drafts and writes are cards in the chat's own storage (`lumen_cards` in the agen
253
253
 
254
254
  **The panel.** `LumenAssistant` reads a Think chat with no change. `ask(input, sink)` runs one answer and sends the panel's own events (`AssistantEvent`: the chat, the text, each tool line, cards, hand-off sections, advice, the use) to `sink`. The app's `POST /chat` returns `askResponse((sink) => chat.ask(input, sink), { signal, stop: () => chat.stop() })`, the same server-sent events `assistantRoutes` sends. `view()` gives a stored chat as the panel's items, and `usage()` gives the chat's use so far. A card's id is its tool call's, so the panel's Save, Dismiss and Undo go to `saveCard`, `dismissCard` and `undoCard`. The app keeps the list of chats, and each chat is its own agent, such as a facet of the person's store. `chunkEvents` and `itemsOf` are the mapping, for an app that streams some other way.
255
255
 
256
+ **A write in progress.** A tool line starts (`step_start`) as soon as the model starts the call, while it is still writing the arguments. For a Read + write tool it carries `write: true`, and `useAssistantChat` marks that running step `writing` until its result lands or the answer ends, so an app can show what the tool changes as being changed now (a pending edit is not ready to accept while the model rewrites it).
257
+
256
258
  **Steps.** One answer makes at most `plan.maxSteps` model calls (`MAX_STEPS`, 8). The last one runs with no tools and `LAST_STEP_NOTE`, so an answer always ends in words: what it found, and what is left when the task did not fit.
257
259
 
258
260
  **Steer and queue.** While an answer runs, the panel's box stays open: Enter steers the message into the answer (`steer(message)` on the agent; the app routes `POST /chats/:id/steer` `{ message }` to it and answers `{ steered }`), Alt+Enter queues it for after. A steer joins at the answer's next step, after the tool it is on, with `STEER_NOTE` before it for the model; the panel shows it there marked Steered (event `steered`), and `view()` keeps it in place (`SteerNote`, under the message the answer is to). A steer the answer ends before reading comes back as `unsteered` and goes first in the queue. `plan.steer: false` turns it off for a model; an app without the route (404) shows Queue only. Queued messages each go as their own message when the answer before them ends; each has Steer now, Edit and Remove.
@@ -108,7 +108,15 @@ export function chunkEvents(o = {}) {
108
108
  }
109
109
  if (name === 'escalate' || name === 'screen' || o.kindOf?.(name) === 'draft')
110
110
  return [];
111
- return [{ event: 'step_start', data: { id, tool: name, title: stepTitle(name) } }];
111
+ // A tool that makes a change at once says so as it starts, so an app can show that what it
112
+ // changes is being changed now, before the model has finished writing the call.
113
+ const write = o.kindOf?.(name) === 'write';
114
+ return [
115
+ {
116
+ event: 'step_start',
117
+ data: { id, tool: name, title: stepTitle(name), ...(write ? { write } : {}) },
118
+ },
119
+ ];
112
120
  }
113
121
  case 'tool-input-available': {
114
122
  const c = calls.get(id) ?? { name: String(chunk.toolName ?? ''), t0: Date.now() };
@@ -15,7 +15,7 @@ import { aiModel, turnSettings } from '../agent/models.js';
15
15
  import { bearings } from './bearings.js';
16
16
  import { CHATGPT_REDIRECT, SigninError, finishChatgpt, startChatgpt, testChatgpt, } from './chatgpt.js';
17
17
  import { ALL_MODELS, DEFAULT_OUT, Meter, PROVIDERS, XAI_BASE_URL, effortParams, } from './models.js';
18
- import { DEFAULT_CAP, State, keyDocName, keyState, planDocName, waitText, } from './state.js';
18
+ import { DEFAULT_CAP, State, keyDocName, keyState, ownKeyUsd, planDocName, waitText, withProvider, } from './state.js';
19
19
  import { Docs } from './store.js';
20
20
  import { toolName, withDeadline } from './tools.js';
21
21
  import { scrapeqlTools, webFetchTools } from './web.js';
@@ -388,7 +388,7 @@ export class Assistant {
388
388
  const onShared = sharedLabel ? end.parts.filter((x) => x.label === sharedLabel) : [];
389
389
  const onPayer = onShared.length ? end.parts.filter((x) => !onShared.includes(x)) : end.parts;
390
390
  await this.state
391
- .addSpend(caller.id, paying.payer, onPayer, { task: 'chat', ms: end.ms }, free)
391
+ .addSpend(caller.id, paying.payer, onPayer, { task: 'chat', ms: end.ms }, free, paying.plans)
392
392
  .catch(() => undefined);
393
393
  if (onShared.length)
394
394
  await this.state
@@ -400,7 +400,7 @@ export class Assistant {
400
400
  id: `chat:${input.chat}`,
401
401
  kind: 'chat',
402
402
  title: message.split('\n')[0] || 'Chat',
403
- own_usd: paying.payer === 'own' ? onPayer.reduce((a, x) => a + x.usd, 0) : 0,
403
+ own_usd: ownKeyUsd(paying, onPayer),
404
404
  }, end.parts)
405
405
  .catch(() => undefined);
406
406
  const total = end.parts.reduce((a, m) => ({
@@ -551,7 +551,7 @@ export class Assistant {
551
551
  // The binding's last test has no token doc to sit in; it is read beside the rest.
552
552
  const linkTest = this.o.web?.scrapeql?.fetch ? this.state.scrapeqlLink() : null;
553
553
  const ownWays = await this.ownWaysOf(caller);
554
- const [p, providers, scrapeql, spend, appCap, ownCap, { paying, wait }] = await Promise.all([
554
+ const [p, providers, scrapeql, spend, appCap, ownCap, { paying, wait }, ownUsed] = await Promise.all([
555
555
  this.state.person(caller.id),
556
556
  Promise.all(this.providers.map(async (id) => {
557
557
  const [doc, key, own, plan, use] = await Promise.all([
@@ -584,6 +584,7 @@ export class Assistant {
584
584
  this.state.appCap(),
585
585
  this.state.ownCap(caller.id),
586
586
  this.rulesFor(caller, main.id).then(({ rules }) => this.state.paying(caller.id, mainModel.provider, rules)),
587
+ this.state.ownUsed(caller.id, ownWays),
587
588
  ]);
588
589
  const link = await linkTest;
589
590
  const tiers = await Promise.all(this.tiers.map(async (t) => {
@@ -656,7 +657,8 @@ export class Assistant {
656
657
  usd: spend.rows.reduce((a, r) => a + r.usd, 0),
657
658
  cap: paying?.cap ?? null,
658
659
  payer: paying?.payer ?? null,
659
- rows: spend.rows,
660
+ rows: withProvider(spend.rows),
661
+ own_used: ownUsed,
660
662
  },
661
663
  ...(this.o.allowance ? { allowance: await this.allowanceState(caller) } : {}),
662
664
  };
@@ -1044,7 +1046,7 @@ export class Assistant {
1044
1046
  }
1045
1047
  finally {
1046
1048
  const part = s.meter.part();
1047
- await this.state.addSpend(caller.id, paying.payer, [part], { task: o.tag ?? o.task, ms: Date.now() - began }, free);
1049
+ await this.state.addSpend(caller.id, paying.payer, [part], { task: o.tag ?? o.task, ms: Date.now() - began }, free, paying.plans);
1048
1050
  const task = this.tasks.find((t) => t.id === o.task);
1049
1051
  await this.state
1050
1052
  .addUsage(caller.id, {
@@ -1052,7 +1054,7 @@ export class Assistant {
1052
1054
  kind: 'task',
1053
1055
  task: o.task,
1054
1056
  title: o.run?.title ?? task?.label ?? o.task,
1055
- own_usd: paying.payer === 'own' ? part.usd : 0,
1057
+ own_usd: ownKeyUsd(paying, [part]),
1056
1058
  }, [part])
1057
1059
  .catch(() => undefined);
1058
1060
  }
@@ -1132,10 +1134,7 @@ export class Assistant {
1132
1134
  usd,
1133
1135
  model: m.id,
1134
1136
  };
1135
- await this.state.addSpend(caller.id, paying.payer, [part], {
1136
- task: o.task,
1137
- ms: Date.now() - began,
1138
- });
1137
+ await this.state.addSpend(caller.id, paying.payer, [part], { task: o.task, ms: Date.now() - began }, false, paying.plans);
1139
1138
  const task = this.tasks.find((t) => t.id === o.task);
1140
1139
  await this.state
1141
1140
  .addUsage(caller.id, {
@@ -1143,7 +1142,7 @@ export class Assistant {
1143
1142
  kind: 'task',
1144
1143
  task: o.task,
1145
1144
  title: o.run?.title ?? task?.label ?? o.task,
1146
- own_usd: paying.payer === 'own' ? part.usd : 0,
1145
+ own_usd: ownKeyUsd(paying, [part]),
1147
1146
  }, [part])
1148
1147
  .catch(() => undefined);
1149
1148
  }
@@ -99,7 +99,24 @@ export interface RequestsDoc {
99
99
  export type SpendRow = UsagePart & {
100
100
  payer: AssistantPayer;
101
101
  free?: true;
102
+ /** Of an own row: paid by the person's API key or their plan sign-in (a plan costs them $0). */
103
+ way?: AssistantOwnWay;
102
104
  };
105
+ /** The provider a part ran on: by its model id, else by the model's name at the end of its label. */
106
+ export declare function providerOfPart(p: UsagePart): AssistantProvider | undefined;
107
+ /** Spend rows with the provider each ran on, so a page can tell a removed key's spend apart. */
108
+ export declare function withProvider<R extends UsagePart>(rows: R[]): R[];
109
+ /** USD of `parts` the person's own API keys paid: a provider signed in with a plan costs $0. */
110
+ export declare function ownKeyUsd(paying: {
111
+ payer: AssistantPayer;
112
+ plans: Paying['plans'];
113
+ }, parts: UsagePart[]): number;
114
+ /**
115
+ * What counts toward the person's own cap: own rows paid by an API key (a plan sign-in costs
116
+ * nothing per call), on a provider they can still pay with. A row from before ways were recorded
117
+ * counts unless its provider has no own way left (an old key since removed).
118
+ */
119
+ export declare function ownCapUsed(rows: SpendRow[], usable: Partial<Record<AssistantProvider, unknown>>): number;
103
120
  /** The ways of their own a person may pay with, per provider; a provider left out has none. */
104
121
  export type OwnWays = Partial<Record<AssistantProvider, AssistantOwnWay[]>>;
105
122
  /** How `paying` may choose: the app's limits on a person's own keys, and one payer only. */
@@ -255,7 +272,7 @@ export declare class State {
255
272
  addSpend(id: string, payer: AssistantPayer, parts: UsagePart[], at?: {
256
273
  task: string;
257
274
  ms?: number;
258
- }, free?: boolean): Promise<void>;
275
+ }, free?: boolean, plans?: Partial<Record<AssistantProvider, boolean>>): Promise<void>;
259
276
  /**
260
277
  * Adds one answer's use to a chat or run in this month's history (`usage:YYYY-MM`): one entry a
261
278
  * chat or run, its parts merged, the newest `USAGE_KEPT` kept. `title` names a new entry only;
@@ -275,6 +292,8 @@ export declare class State {
275
292
  keys: Partial<Record<"anthropic" | "openai" | "xai", string>>;
276
293
  plans: Partial<Record<"anthropic" | "openai" | "xai", boolean>>;
277
294
  }>;
295
+ /** USD of this month's own spend that counts toward the person's own cap (`ownCapUsed`). */
296
+ ownUsed(id: string, ways?: OwnWays): Promise<number>;
278
297
  /** Who pays the person's next answer on `provider`'s model, or why nobody can. */
279
298
  paying(id: string, provider: AssistantProvider, rules?: PayingRules): Promise<{
280
299
  paying: Paying | null;
@@ -1,5 +1,5 @@
1
1
  import { refreshChatgpt, staleChatgpt } from './chatgpt.js';
2
- import { PROVIDERS, keyHint } from './models.js';
2
+ import { ALL_MODELS, PROVIDERS, XAI_MODELS, keyHint } from './models.js';
3
3
  import { APP_SCOPE } from './store.js';
4
4
  /** Where a provider's key is kept: `key` for Anthropic, `key:<provider>` for the rest. */
5
5
  export const keyDocName = (provider) => provider === 'anthropic' ? 'key' : `key:${provider}`;
@@ -7,6 +7,45 @@ export const keyDocName = (provider) => provider === 'anthropic' ? 'key' : `key:
7
7
  export const planDocName = (provider) => `plan:${provider}`;
8
8
  /** Requests kept for approvers: the newest this many. */
9
9
  const REQUESTS_KEPT = 300;
10
+ const KNOWN_MODELS = [...ALL_MODELS, ...XAI_MODELS];
11
+ /** The provider a part ran on: by its model id, else by the model's name at the end of its label. */
12
+ export function providerOfPart(p) {
13
+ const m = KNOWN_MODELS.find((x) => x.id === p.model) ??
14
+ KNOWN_MODELS.find((x) => p.label === x.label || p.label.endsWith(` · ${x.label}`));
15
+ return m?.provider;
16
+ }
17
+ /** Spend rows with the provider each ran on, so a page can tell a removed key's spend apart. */
18
+ export function withProvider(rows) {
19
+ return rows.map((r) => {
20
+ const provider = providerOfPart(r);
21
+ return provider ? { ...r, provider } : r;
22
+ });
23
+ }
24
+ /** USD of `parts` the person's own API keys paid: a provider signed in with a plan costs $0. */
25
+ export function ownKeyUsd(paying, parts) {
26
+ if (paying.payer !== 'own')
27
+ return 0;
28
+ return parts
29
+ .filter((p) => {
30
+ const prov = providerOfPart(p);
31
+ return !(prov && paying.plans[prov]);
32
+ })
33
+ .reduce((a, p) => a + p.usd, 0);
34
+ }
35
+ /**
36
+ * What counts toward the person's own cap: own rows paid by an API key (a plan sign-in costs
37
+ * nothing per call), on a provider they can still pay with. A row from before ways were recorded
38
+ * counts unless its provider has no own way left (an old key since removed).
39
+ */
40
+ export function ownCapUsed(rows, usable) {
41
+ return rows
42
+ .filter((r) => r.payer === 'own' && !r.free && r.way !== 'plan')
43
+ .filter((r) => {
44
+ const prov = providerOfPart(r);
45
+ return !prov || !!usable[prov];
46
+ })
47
+ .reduce((a, r) => a + r.usd, 0);
48
+ }
10
49
  /** Chats and runs kept in one month's usage history: the newest this many. */
11
50
  export const USAGE_KEPT = 300;
12
51
  /** Months of usage history offered (`usage:YYYY-MM` documents are listed this far back). */
@@ -241,9 +280,16 @@ export class State {
241
280
  * Adds an answer's use to this month's spend, by label and payer. `at` names the task the answer
242
281
  * was for and how long it took, so the month also adds up per task and model (`tasks`).
243
282
  */
244
- async addSpend(id, payer, parts, at, free = false) {
283
+ async addSpend(id, payer, parts, at, free = false, plans) {
245
284
  if (!parts.some((p) => p.calls))
246
285
  return;
286
+ // Which own way paid each part: a plan sign-in or a key, by the part's provider.
287
+ const wayOf = (p) => {
288
+ if (payer !== 'own' || !plans)
289
+ return undefined;
290
+ const prov = providerOfPart(p);
291
+ return prov && plans[prov] ? 'plan' : 'key';
292
+ };
247
293
  await this.docs.update(id, `spend:${this.month()}`, (d) => {
248
294
  const rows = [...(d?.rows ?? [])];
249
295
  const tasks = [...(d?.tasks ?? [])];
@@ -277,7 +323,8 @@ export class State {
277
323
  for (const p of parts) {
278
324
  if (!p.calls)
279
325
  continue;
280
- const i = rows.findIndex((r) => r.label === p.label && r.payer === payer && !!r.free === free);
326
+ const way = wayOf(p);
327
+ const i = rows.findIndex((r) => r.label === p.label && r.payer === payer && !!r.free === free && r.way === way);
281
328
  const prev = i >= 0 ? rows[i] : undefined;
282
329
  const row = prev
283
330
  ? {
@@ -288,7 +335,7 @@ export class State {
288
335
  out: prev.out + p.out,
289
336
  usd: prev.usd + p.usd,
290
337
  }
291
- : { ...p, payer, ...(free ? { free: true } : {}) };
338
+ : { ...p, payer, ...(free ? { free: true } : {}), ...(way ? { way } : {}) };
292
339
  if (i >= 0)
293
340
  rows[i] = row;
294
341
  else
@@ -348,7 +395,7 @@ export class State {
348
395
  month,
349
396
  months,
350
397
  usd: spend.rows.reduce((a, r) => a + r.usd, 0),
351
- rows: spend.rows,
398
+ rows: withProvider(spend.rows),
352
399
  entries: doc?.entries ?? [],
353
400
  };
354
401
  }
@@ -369,6 +416,11 @@ export class State {
369
416
  });
370
417
  return { keys, plans };
371
418
  }
419
+ /** USD of this month's own spend that counts toward the person's own cap (`ownCapUsed`). */
420
+ async ownUsed(id, ways) {
421
+ const [{ keys }, spend] = await Promise.all([this.keysOf(id, 'own', ways), this.spend(id)]);
422
+ return ownCapUsed(spend.rows, keys);
423
+ }
372
424
  /** Who pays the person's next answer on `provider`'s model, or why nobody can. */
373
425
  async paying(id, provider, rules = {}) {
374
426
  const [person, appKeys, ownKeys, spend, ownCap] = await Promise.all([
@@ -399,7 +451,7 @@ export class State {
399
451
  apiKey: own,
400
452
  ...ownKeys,
401
453
  cap: ownCap,
402
- left: ownCap - spent('own'),
454
+ left: ownCap - ownCapUsed(spend.rows, ownKeys.keys),
403
455
  }
404
456
  : null,
405
457
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@columenlabs/lumen-ai",
3
- "version": "0.1.246",
3
+ "version": "0.1.247",
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.246",
56
- "@columenlabs/lumen-ui-types": "0.1.246",
55
+ "@columenlabs/lumen-ops": "0.1.247",
56
+ "@columenlabs/lumen-ui-types": "0.1.247",
57
57
  "agents": "0.32.0",
58
58
  "ai": "7.0.162",
59
59
  "just-bash": "3.6.0",