@columenlabs/lumen-ai 0.1.91 → 0.1.93

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.
@@ -1,4 +1,4 @@
1
- import type { AllowanceRequest, AllowanceState, AllowanceUnit, AssistantAccess, AssistantChat, AssistantChatRow, AssistantMessage, AssistantProvider, AssistantStatus, DraftItem, StepItem } from '@columenlabs/lumen-ui-types/assistant';
1
+ import type { AllowanceRequest, AllowanceState, AllowanceUnit, AssistantAccess, AssistantChat, AssistantChatRow, AssistantMessage, AssistantOwnWay, AssistantProvider, AssistantStatus, DraftItem, StepItem } from '@columenlabs/lumen-ui-types/assistant';
2
2
  import { type ModelDef, type TaskDef, type TierDef } from './models.js';
3
3
  import { State } from './state.js';
4
4
  import { type AssistantStore, type Sealer } from './store.js';
@@ -234,10 +234,18 @@ export declare class Assistant {
234
234
  url: string;
235
235
  redirect: string;
236
236
  }>;
237
- /** Finishes it from the address it ended on; the plan then stands in for the person's own key. */
237
+ /**
238
+ * Finishes it from the address it ended on. The plan is kept beside the person's key; a person who
239
+ * already has a key keeps running on it until they pick the plan.
240
+ */
238
241
  finishSignin(caller: Caller, provider: AssistantProvider, pasted: string): Promise<void>;
239
- /** Tests a person's own key, refreshing a plan sign-in first. */
240
- testOwn(caller: Caller, provider: AssistantProvider): Promise<import("./state.js").KeyTest | null>;
242
+ /** Signs the person out of their plan; their key, if any, runs from then on. */
243
+ signOut(caller: Caller, provider: AssistantProvider): Promise<void>;
244
+ /**
245
+ * Tests one of the person's own (`way`, else the one in use), refreshing a plan first, and keeps
246
+ * the result on it.
247
+ */
248
+ testOwn(caller: Caller, provider: AssistantProvider, way?: AssistantOwnWay): Promise<import("./state.js").KeyTest | null>;
241
249
  /** One free call (reading a model) that says whether the provider takes the key. */
242
250
  testKey(provider: AssistantProvider, apiKey: string): Promise<{
243
251
  ok: boolean;
@@ -21,7 +21,7 @@ import { HAND_OFF_WHYS } from '@columenlabs/lumen-ui-types/assistant';
21
21
  import OpenAI from 'openai';
22
22
  import { CHATGPT_REDIRECT, SigninError, finishChatgpt, startChatgpt, testChatgpt, } from './chatgpt.js';
23
23
  import { ALL_MODELS, DEFAULT_OUT, Meter, PROVIDERS, effortParams, } from './models.js';
24
- import { DEFAULT_CAP, State, keyDocName, keyState, waitText, } from './state.js';
24
+ import { DEFAULT_CAP, State, keyDocName, keyState, planDocName, waitText, } from './state.js';
25
25
  import { Docs } from './store.js';
26
26
  import { ToolError, rowCount, toolName, withDeadline, } from './tools.js';
27
27
  import { scrapeqlTools, webFetchTools } from './web.js';
@@ -248,10 +248,12 @@ export class Assistant {
248
248
  const [p, providers, scrapeql, spend, appCap, ownCap, { paying, wait }] = await Promise.all([
249
249
  this.state.person(caller.id),
250
250
  Promise.all(this.providers.map(async (id) => {
251
- const [doc, key, own] = await Promise.all([
251
+ const [doc, key, own, plan, use] = await Promise.all([
252
252
  this.state.appKey(id),
253
253
  this.state.appApiKey(id),
254
254
  this.state.ownKey(caller.id, id),
255
+ PROVIDERS[id].signin ? this.state.ownPlan(caller.id, id) : Promise.resolve(null),
256
+ this.state.ownUse(caller.id, id),
255
257
  ]);
256
258
  const app = caller.admin
257
259
  ? keyState(doc, (d) => d.api_key)
@@ -264,6 +266,10 @@ export class Assistant {
264
266
  ...(PROVIDERS[id].signin ? { signin: PROVIDERS[id].signin } : {}),
265
267
  app: { ...app, ...(key?.fromEnv ? { from_env: true } : {}) },
266
268
  own: keyState(own, (d) => d.api_key),
269
+ ...(PROVIDERS[id].signin
270
+ ? { plan: keyState(plan?.doc ?? null, (d) => d.api_key) }
271
+ : {}),
272
+ ...(use ? { use } : {}),
267
273
  };
268
274
  })),
269
275
  this.o.web?.scrapeql ? this.state.scrapeql() : Promise.resolve(null),
@@ -1421,19 +1427,27 @@ export class Assistant {
1421
1427
  await this.state.docs.write(caller.id, name, reg);
1422
1428
  return { url, redirect: CHATGPT_REDIRECT };
1423
1429
  }
1424
- /** Finishes it from the address it ended on; the plan then stands in for the person's own key. */
1430
+ /**
1431
+ * Finishes it from the address it ended on. The plan is kept beside the person's key; a person who
1432
+ * already has a key keeps running on it until they pick the plan.
1433
+ */
1425
1434
  async finishSignin(caller, provider, pasted) {
1426
1435
  const name = `signin:${provider}`;
1427
1436
  try {
1428
1437
  const { tokens, reg } = await finishChatgpt(await this.state.docs.read(caller.id, name), pasted, this.o.fetch ?? fetch);
1429
1438
  await this.state.docs.write(caller.id, name, reg);
1430
1439
  const test = await testChatgpt(tokens, this.o.fetch ?? fetch);
1431
- await this.state.docs.write(caller.id, keyDocName(provider), {
1440
+ const legacy = await this.state.ownPlan(caller.id, provider);
1441
+ await this.state.docs.write(caller.id, planDocName(provider), {
1432
1442
  api_key: tokens.access_token,
1433
1443
  saved_at: Date.now(),
1434
1444
  test,
1435
1445
  chatgpt: tokens,
1436
1446
  });
1447
+ if (legacy && legacy.name !== planDocName(provider))
1448
+ await this.state.docs.remove(caller.id, legacy.name);
1449
+ if (await this.state.ownKey(caller.id, provider))
1450
+ await this.state.setPerson(caller.id, (d) => d.own_use?.[provider] ? d : { ...d, own_use: { ...d.own_use, [provider]: 'key' } });
1437
1451
  }
1438
1452
  catch (err) {
1439
1453
  if (err instanceof SigninError)
@@ -1441,18 +1455,35 @@ export class Assistant {
1441
1455
  throw err;
1442
1456
  }
1443
1457
  }
1444
- /** Tests a person's own key, refreshing a plan sign-in first. */
1445
- async testOwn(caller, provider) {
1446
- const d = await this.state.ownKey(caller.id, provider);
1447
- if (!d)
1458
+ /** Signs the person out of their plan; their key, if any, runs from then on. */
1459
+ async signOut(caller, provider) {
1460
+ const found = await this.state.ownPlan(caller.id, provider);
1461
+ if (found)
1462
+ await this.state.docs.remove(caller.id, found.name);
1463
+ }
1464
+ /**
1465
+ * Tests one of the person's own (`way`, else the one in use), refreshing a plan first, and keeps
1466
+ * the result on it.
1467
+ */
1468
+ async testOwn(caller, provider, way) {
1469
+ const w = way ?? (await this.state.ownUse(caller.id, provider));
1470
+ if (w === 'key') {
1471
+ const d = await this.state.ownKey(caller.id, provider);
1472
+ if (!d)
1473
+ return null;
1474
+ const test = await this.testKey(provider, d.api_key);
1475
+ await this.state.docs.write(caller.id, keyDocName(provider), { ...d, test });
1476
+ return test;
1477
+ }
1478
+ if (w !== 'plan' || !(await this.state.ownPlan(caller.id, provider)))
1448
1479
  return null;
1449
- if (!d.chatgpt)
1450
- return this.testKey(provider, d.api_key);
1451
- const fresh = await this.state.ownApiKey(caller.id, provider);
1452
- const now = await this.state.ownKey(caller.id, provider);
1453
- if (!fresh || !now?.chatgpt)
1454
- return now?.test ?? { ok: false, detail: 'Sign in again.', at: Date.now() };
1455
- return testChatgpt(now.chatgpt, this.o.fetch ?? fetch);
1480
+ const token = await this.state.planToken(caller.id, provider);
1481
+ const now = await this.state.ownPlan(caller.id, provider);
1482
+ if (!token || !now)
1483
+ return now?.doc.test ?? { ok: false, detail: 'Sign in again.', at: Date.now() };
1484
+ const test = await testChatgpt(now.doc.chatgpt, this.o.fetch ?? fetch);
1485
+ await this.state.docs.write(caller.id, now.name, { ...now.doc, test });
1486
+ return test;
1456
1487
  }
1457
1488
  /** One free call (reading a model) that says whether the provider takes the key. */
1458
1489
  async testKey(provider, apiKey) {
@@ -14,6 +14,9 @@
14
14
  * PUT /my-key/cap {cap_usd} what the person may spend a month on their own keys
15
15
  * POST /my-signin starts a ChatGPT plan sign-in (`?provider=openai`): {url, redirect}
16
16
  * POST /my-signin/finish {url} finishes it from the 127.0.0.1 address the sign-in ended on
17
+ * DELETE /my-signin signs out of the plan (the person's key, if set, runs on)
18
+ * (`POST /my-key/test?way=key|plan` tests one; the plan and the
19
+ * key are kept side by side, `own_use` in /settings picks)
17
20
  * PUT /web/scrapeql {client_id, client_secret}, DELETE /web/scrapeql, POST /web/test admin
18
21
  * POST /chat one answer as server-sent events; closing the request stops it
19
22
  * GET /chats, GET|DELETE /chats/:id
@@ -27,10 +30,10 @@
27
30
  * form or fetch is refused before it reaches a rule. Keys and tokens are write-only: no route
28
31
  * returns one, only a hint (`sk-ant-…abcd`).
29
32
  */
30
- import { ASSISTANT_ACCESS, ASSISTANT_EFFORTS, ASSISTANT_PAYERS, HAND_OFF_POLICIES, } from '@columenlabs/lumen-ui-types/assistant';
33
+ import { ASSISTANT_ACCESS, ASSISTANT_EFFORTS, ASSISTANT_OWN_WAYS, ASSISTANT_PAYERS, HAND_OFF_POLICIES, } from '@columenlabs/lumen-ui-types/assistant';
31
34
  import { AssistantError } from './engine.js';
32
35
  import { PROVIDERS } from './models.js';
33
- import { keyDocName } from './state.js';
36
+ import { keyDocName, planDocName } from './state.js';
34
37
  import { APP_SCOPE } from './store.js';
35
38
  const MUTATING = new Set(['POST', 'PUT', 'PATCH', 'DELETE']);
36
39
  const MAX_MESSAGE = 20_000;
@@ -101,6 +104,16 @@ function settingsChange(a, groups, b) {
101
104
  return 'web is {fetch: true | false}.';
102
105
  if (p.pay !== undefined && !oneOf(ASSISTANT_PAYERS, p.pay))
103
106
  return 'pay is app or own.';
107
+ if (p.own_use !== undefined) {
108
+ if (!p.own_use || typeof p.own_use !== 'object')
109
+ return 'own_use is {provider: key | plan}.';
110
+ for (const [prov, w] of Object.entries(p.own_use)) {
111
+ if (!a.providers.includes(prov))
112
+ return `No provider ${prov}.`;
113
+ if (!oneOf(ASSISTANT_OWN_WAYS, w))
114
+ return `No way ${w}.`;
115
+ }
116
+ }
104
117
  return (d) => {
105
118
  const tiers = { ...(d.tiers ?? {}) };
106
119
  for (const [id, t] of Object.entries(p.tiers ?? {})) {
@@ -118,6 +131,7 @@ function settingsChange(a, groups, b) {
118
131
  ...(p.access !== undefined ? { access: { ...(d.access ?? {}), ...p.access } } : {}),
119
132
  ...(p.web !== undefined ? { web: p.web.fetch } : {}),
120
133
  ...(p.pay !== undefined ? { pay: p.pay } : {}),
134
+ ...(p.own_use !== undefined ? { own_use: { ...(d.own_use ?? {}), ...p.own_use } } : {}),
121
135
  };
122
136
  };
123
137
  }
@@ -195,6 +209,10 @@ export function assistantRoutes(app, o) {
195
209
  if (!test.ok)
196
210
  return fail(test.detail, 400, 'key_refused');
197
211
  const scope = mine ? caller.id : APP_SCOPE;
212
+ // A plan sign-in from before 2026-10-27 sits in the key's doc: move it beside the key.
213
+ const legacy = mine ? await a.state.ownPlan(caller.id, prov) : null;
214
+ if (legacy?.name === docName)
215
+ await docs.write(scope, planDocName(prov), legacy.doc);
198
216
  await docs.write(scope, docName, { api_key: key, saved_at: Date.now(), test });
199
217
  return status();
200
218
  }
@@ -218,6 +236,9 @@ export function assistantRoutes(app, o) {
218
236
  }
219
237
  case 'POST /my-signin':
220
238
  return json(await a.startSignin(caller, prov));
239
+ case 'DELETE /my-signin':
240
+ await a.signOut(caller, prov);
241
+ return status();
221
242
  case 'POST /my-signin/finish': {
222
243
  const pasted = str((await body(req)).url, 4000);
223
244
  if (!pasted)
@@ -226,10 +247,13 @@ export function assistantRoutes(app, o) {
226
247
  return status();
227
248
  }
228
249
  case 'POST /my-key/test': {
229
- const test = await a.testOwn(caller, prov);
250
+ const askedWay = url.searchParams.get('way');
251
+ const way = oneOf(ASSISTANT_OWN_WAYS, askedWay);
252
+ if (askedWay !== null && !way)
253
+ return fail('way is key or plan.');
254
+ const test = await a.testOwn(caller, prov, way);
230
255
  if (!test)
231
- return fail('No key is set.', 404);
232
- await docs.update(caller.id, docName, (d) => (d ? { ...d, test } : null));
256
+ return fail(way === 'plan' ? 'Not signed in.' : 'No key is set.', 404);
233
257
  return json(test);
234
258
  }
235
259
  case 'POST /key/test': {
@@ -19,7 +19,7 @@
19
19
  * tier runs on and money left this month, else the other; with neither the assistant waits and says
20
20
  * why. A cap covers every provider of its payer.
21
21
  */
22
- import type { AllowanceRequest, AssistantAccess, AssistantEffort, AssistantPayer, AssistantProvider, AssistantWait, HandOffPolicy, UsagePart } from '@columenlabs/lumen-ui-types/assistant';
22
+ import type { AllowanceRequest, AssistantAccess, AssistantEffort, AssistantOwnWay, AssistantPayer, AssistantProvider, AssistantWait, HandOffPolicy, UsagePart } from '@columenlabs/lumen-ui-types/assistant';
23
23
  import { type ChatgptTokens } from './chatgpt.js';
24
24
  import { type Docs } from './store.js';
25
25
  export interface KeyTest {
@@ -38,6 +38,8 @@ export interface KeyDoc {
38
38
  }
39
39
  /** Where a provider's key is kept: `key` for Anthropic, `key:<provider>` for the rest. */
40
40
  export declare const keyDocName: (provider: AssistantProvider) => string;
41
+ /** Where a person's plan sign-in is kept, beside their key (before 2026-10-27 it replaced the key). */
42
+ export declare const planDocName: (provider: AssistantProvider) => string;
41
43
  export interface TokenDoc {
42
44
  client_id: string;
43
45
  client_secret: string;
@@ -62,6 +64,8 @@ export interface PersonDoc {
62
64
  pay?: AssistantPayer;
63
65
  /** USD a month on their own keys. */
64
66
  own_cap?: number;
67
+ /** Which of their own runs on a provider when both a key and a plan are set. */
68
+ own_use?: Partial<Record<AssistantProvider, AssistantOwnWay>>;
65
69
  }
66
70
  /** How the app counts allowance on its shared keys (`createAssistant` `allowance`). */
67
71
  export interface AllowanceConfig {
@@ -156,8 +160,20 @@ export declare class State {
156
160
  key: string;
157
161
  fromEnv: boolean;
158
162
  } | null>;
163
+ /** The person's own API key (a key doc that holds a plan sign-in is their plan). */
159
164
  ownKey(id: string, provider: AssistantProvider): Promise<KeyDoc | null>;
160
- /** The person's own key, a plan sign-in refreshed first when it is about to run out. */
165
+ /** The person's plan sign-in and the doc it is kept in (the key's, for a sign-in from before). */
166
+ ownPlan(id: string, provider: AssistantProvider): Promise<{
167
+ doc: KeyDoc & {
168
+ chatgpt: ChatgptTokens;
169
+ };
170
+ name: string;
171
+ } | null>;
172
+ /** Which of their own runs: the one they picked while it is set, else the plan, else the key. */
173
+ ownUse(id: string, provider: AssistantProvider): Promise<AssistantOwnWay | null>;
174
+ /** The plan's access token, refreshed first when it is about to run out; null once it fails. */
175
+ planToken(id: string, provider: AssistantProvider): Promise<string | null>;
176
+ /** The person's own key or plan, the one in use first, the other when that one fails. */
161
177
  ownApiKey(id: string, provider: AssistantProvider): Promise<{
162
178
  key: string;
163
179
  plan: boolean;
@@ -3,6 +3,8 @@ import { PROVIDERS, 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}`;
6
+ /** Where a person's plan sign-in is kept, beside their key (before 2026-10-27 it replaced the key). */
7
+ export const planDocName = (provider) => `plan:${provider}`;
6
8
  /** Requests kept for approvers: the newest this many. */
7
9
  const REQUESTS_KEPT = 300;
8
10
  /** USD a month when nobody set a cap. */
@@ -107,35 +109,70 @@ export class State {
107
109
  const env = await this.o.appKey?.(provider);
108
110
  return env ? { key: env, fromEnv: true } : null;
109
111
  }
110
- ownKey(id, provider) {
111
- return this.docs.read(id, keyDocName(provider));
112
+ /** The person's own API key (a key doc that holds a plan sign-in is their plan). */
113
+ async ownKey(id, provider) {
114
+ const d = await this.docs.read(id, keyDocName(provider));
115
+ return d && !d.chatgpt ? d : null;
112
116
  }
113
- /** The person's own key, a plan sign-in refreshed first when it is about to run out. */
114
- async ownApiKey(id, provider) {
115
- const d = await this.ownKey(id, provider);
116
- if (!d)
117
+ /** The person's plan sign-in and the doc it is kept in (the key's, for a sign-in from before). */
118
+ async ownPlan(id, provider) {
119
+ for (const name of [planDocName(provider), keyDocName(provider)]) {
120
+ const doc = await this.docs.read(id, name);
121
+ if (doc?.chatgpt)
122
+ return { doc: doc, name };
123
+ }
124
+ return null;
125
+ }
126
+ /** Which of their own runs: the one they picked while it is set, else the plan, else the key. */
127
+ async ownUse(id, provider) {
128
+ const [p, key, plan] = await Promise.all([
129
+ this.person(id),
130
+ this.ownKey(id, provider),
131
+ this.ownPlan(id, provider),
132
+ ]);
133
+ const use = p.own_use?.[provider];
134
+ if (use === 'key' && key)
135
+ return 'key';
136
+ if (use === 'plan' && plan)
137
+ return 'plan';
138
+ return plan ? 'plan' : key ? 'key' : null;
139
+ }
140
+ /** The plan's access token, refreshed first when it is about to run out; null once it fails. */
141
+ async planToken(id, provider) {
142
+ const found = await this.ownPlan(id, provider);
143
+ if (!found)
117
144
  return null;
118
- if (!d.chatgpt)
119
- return { key: d.api_key, plan: false };
145
+ const { doc: d, name } = found;
120
146
  if (!staleChatgpt(d.chatgpt))
121
- return { key: d.chatgpt.access_token, plan: true };
147
+ return d.chatgpt.access_token;
122
148
  try {
123
149
  const t = await refreshChatgpt(d.chatgpt, this.o.fetch ?? fetch);
124
- await this.docs.write(id, keyDocName(provider), {
125
- ...d,
126
- api_key: t.access_token,
127
- chatgpt: t,
128
- });
129
- return { key: t.access_token, plan: true };
150
+ await this.docs.write(id, name, { ...d, api_key: t.access_token, chatgpt: t });
151
+ return t.access_token;
130
152
  }
131
153
  catch (err) {
132
- await this.docs.write(id, keyDocName(provider), {
154
+ await this.docs.write(id, name, {
133
155
  ...d,
134
156
  test: { at: Date.now(), ok: false, detail: err.message },
135
157
  });
136
158
  return null;
137
159
  }
138
160
  }
161
+ /** The person's own key or plan, the one in use first, the other when that one fails. */
162
+ async ownApiKey(id, provider) {
163
+ const use = await this.ownUse(id, provider);
164
+ if (!use)
165
+ return null;
166
+ const key = async () => {
167
+ const d = await this.ownKey(id, provider);
168
+ return d ? { key: d.api_key, plan: false } : null;
169
+ };
170
+ const plan = async () => {
171
+ const t = await this.planToken(id, provider);
172
+ return t ? { key: t, plan: true } : null;
173
+ };
174
+ return use === 'key' ? ((await key()) ?? (await plan())) : ((await plan()) ?? (await key()));
175
+ }
139
176
  /** USD a month on the person's own keys. */
140
177
  async ownCap(id) {
141
178
  const p = await this.person(id);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@columenlabs/lumen-ai",
3
- "version": "0.1.91",
3
+ "version": "0.1.93",
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",
@@ -41,8 +41,8 @@
41
41
  },
42
42
  "dependencies": {
43
43
  "@anthropic-ai/sdk": "0.133.0",
44
- "@columenlabs/lumen-ops": "0.1.91",
45
- "@columenlabs/lumen-ui-types": "0.1.91",
44
+ "@columenlabs/lumen-ops": "0.1.93",
45
+ "@columenlabs/lumen-ui-types": "0.1.93",
46
46
  "openai": "7.32.0",
47
47
  "zod": "^4.3.6"
48
48
  },