@columenlabs/lumen-ai 0.1.77 → 0.1.79

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,6 +1,6 @@
1
1
  /**
2
- * An assistant for any app: a chat side panel's server. Claude on the app's tiers (named by the
3
- * app), the app's tools from any MCP server, the web (a plain fetch and ScrapeQL over its API),
2
+ * An assistant for any app: a chat side panel's server. Claude or OpenAI models on the app's tiers
3
+ * (named by the app), the app's tools from any MCP server, the web (a plain fetch and ScrapeQL over its API),
4
4
  * hand off to a higher tier, advice, drafts the person saves, and a monthly spend cap per person.
5
5
  *
6
6
  * const assistant = createAssistant({ name, system, tiers, groups, store, sealer, appKey });
@@ -12,7 +12,7 @@
12
12
  export * from './engine.js';
13
13
  export * from './models.js';
14
14
  export * from './routes.js';
15
- export { DEFAULT_CAP, type KeyDoc, type KeyTest, monthIn, type Paying, type PersonDoc, type SpendDoc, State, type TokenDoc, } from './state.js';
15
+ export { DEFAULT_CAP, type KeyDoc, keyDocName, type KeyTest, monthIn, type Paying, type PersonDoc, type SpendDoc, State, type TokenDoc, waitText, } from './state.js';
16
16
  export * from './store.js';
17
17
  export * from './tools.js';
18
18
  export * from './web.js';
@@ -1,6 +1,6 @@
1
1
  /**
2
- * An assistant for any app: a chat side panel's server. Claude on the app's tiers (named by the
3
- * app), the app's tools from any MCP server, the web (a plain fetch and ScrapeQL over its API),
2
+ * An assistant for any app: a chat side panel's server. Claude or OpenAI models on the app's tiers
3
+ * (named by the app), the app's tools from any MCP server, the web (a plain fetch and ScrapeQL over its API),
4
4
  * hand off to a higher tier, advice, drafts the person saves, and a monthly spend cap per person.
5
5
  *
6
6
  * const assistant = createAssistant({ name, system, tiers, groups, store, sealer, appKey });
@@ -12,7 +12,7 @@
12
12
  export * from './engine.js';
13
13
  export * from './models.js';
14
14
  export * from './routes.js';
15
- export { DEFAULT_CAP, monthIn, State, } from './state.js';
15
+ export { DEFAULT_CAP, keyDocName, monthIn, State, waitText, } from './state.js';
16
16
  export * from './store.js';
17
17
  export * from './tools.js';
18
18
  export * from './web.js';
@@ -3,11 +3,13 @@
3
3
  * person may pick another model and effort per tier. Prices are the providers' list prices per
4
4
  * million tokens, so every answer's cost is known as it runs.
5
5
  */
6
- import type { AssistantEffort, UsagePart } from '@columenlabs/lumen-ui-types/assistant';
6
+ import type { AssistantEffort, AssistantProvider, UsagePart } from '@columenlabs/lumen-ui-types/assistant';
7
7
  /** A model a tier can run, with its list price per million tokens (input, output, cache reads). */
8
8
  export interface ModelDef {
9
9
  id: string;
10
10
  label: string;
11
+ /** Whose key runs it. */
12
+ provider: AssistantProvider;
11
13
  /** The heading pickers list it under (its provider). */
12
14
  group: string;
13
15
  in: number;
@@ -16,6 +18,17 @@ export interface ModelDef {
16
18
  }
17
19
  /** Claude models, smallest first (an advisor is never weaker than the model it advises). */
18
20
  export declare const CLAUDE_MODELS: ModelDef[];
21
+ /** OpenAI models, smallest first (list prices from OpenAI's pricing page, October 2026). */
22
+ export declare const OPENAI_MODELS: ModelDef[];
23
+ /** Every model the kit knows: what a tier may run unless the app lists its own. */
24
+ export declare const ALL_MODELS: ModelDef[];
25
+ /** How Settings names each provider, how its keys start and where a person makes one. */
26
+ export declare const PROVIDERS: Record<AssistantProvider, {
27
+ label: string;
28
+ prefix: string;
29
+ keysUrl: string;
30
+ signin?: 'chatgpt';
31
+ }>;
19
32
  /** A tier as the app defines it. `out` caps each model call's output tokens. */
20
33
  export interface TierDef {
21
34
  id: string;
@@ -57,7 +70,8 @@ export declare class Meter {
57
70
  out: number;
58
71
  usd: number;
59
72
  constructor(model: ModelDef, label: string);
60
- add(usage: TokenUsage): void;
73
+ /** `free`: the call ran on a plan sign-in, so it counts tokens and no dollars. */
74
+ add(usage: TokenUsage, free?: boolean): void;
61
75
  part(): UsagePart;
62
76
  }
63
77
  /** The request's effort setting: nothing at the model's own default. */
@@ -3,6 +3,7 @@ export const CLAUDE_MODELS = [
3
3
  {
4
4
  id: 'claude-haiku-5-5',
5
5
  label: 'Claude Haiku 5.5',
6
+ provider: 'anthropic',
6
7
  group: 'Claude',
7
8
  in: 0.1,
8
9
  out: 0.5,
@@ -11,13 +12,53 @@ export const CLAUDE_MODELS = [
11
12
  {
12
13
  id: 'claude-sonnet-5-5',
13
14
  label: 'Claude Sonnet 5.5',
15
+ provider: 'anthropic',
14
16
  group: 'Claude',
15
17
  in: 2,
16
18
  out: 10,
17
19
  read: 0.2,
18
20
  },
19
- { id: 'claude-opus-5-5', label: 'Claude Opus 5.5', group: 'Claude', in: 4, out: 20, read: 0.2 },
21
+ {
22
+ id: 'claude-opus-5-5',
23
+ label: 'Claude Opus 5.5',
24
+ provider: 'anthropic',
25
+ group: 'Claude',
26
+ in: 4,
27
+ out: 20,
28
+ read: 0.2,
29
+ },
20
30
  ];
31
+ /** OpenAI models, smallest first (list prices from OpenAI's pricing page, October 2026). */
32
+ export const OPENAI_MODELS = [
33
+ ['gpt-6-luna', 'GPT-6 Luna', 0.1, 0.5, 0.01],
34
+ ['gpt-6.1-terra', 'GPT-6.1 Terra', 1, 5, 0.05],
35
+ ['gpt-6.1-sol', 'GPT-6.1 Sol', 2, 10, 0.1],
36
+ ['gpt-6-astra', 'GPT-6 Astra', 10, 50, 1],
37
+ ].map(([id, label, i, o, r]) => ({
38
+ id: id,
39
+ label: label,
40
+ provider: 'openai',
41
+ group: 'OpenAI',
42
+ in: i,
43
+ out: o,
44
+ read: r,
45
+ }));
46
+ /** Every model the kit knows: what a tier may run unless the app lists its own. */
47
+ export const ALL_MODELS = [...CLAUDE_MODELS, ...OPENAI_MODELS];
48
+ /** How Settings names each provider, how its keys start and where a person makes one. */
49
+ export const PROVIDERS = {
50
+ anthropic: {
51
+ label: 'Anthropic',
52
+ prefix: 'sk-ant-',
53
+ keysUrl: 'https://console.anthropic.com/settings/keys',
54
+ },
55
+ openai: {
56
+ label: 'OpenAI',
57
+ prefix: 'sk-',
58
+ keysUrl: 'https://platform.openai.com/api-keys',
59
+ signin: 'chatgpt',
60
+ },
61
+ };
21
62
  /** Output tokens per model call when a tier sets none. */
22
63
  export const DEFAULT_OUT = 16_000;
23
64
  /** Tokens in (cache reads and writes included) and out of one reply, and its cost at list price. */
@@ -48,13 +89,15 @@ export class Meter {
48
89
  this.model = model;
49
90
  this.label = label;
50
91
  }
51
- add(usage) {
92
+ /** `free`: the call ran on a plan sign-in, so it counts tokens and no dollars. */
93
+ add(usage, free = false) {
52
94
  const p = priced(this.model, usage);
53
95
  this.calls += 1;
54
96
  this.in += p.in;
55
97
  this.read += p.read;
56
98
  this.out += p.out;
57
- this.usd += p.usd;
99
+ if (!free)
100
+ this.usd += p.usd;
58
101
  }
59
102
  part() {
60
103
  const { label, calls, read, out, usd } = this;
@@ -6,14 +6,19 @@
6
6
  *
7
7
  * GET /status what the panel and Settings show
8
8
  * PUT /settings the person's tiers, tasks, hand off, advice, tool access, web, payer
9
- * PUT /app {cap_usd} admin: what each person may spend a month on the app's key
10
- * PUT /key {api_key} admin: the app's Claude key, tested before it is kept
11
- * DELETE /key, POST /key/test admin
12
- * PUT /my-key {api_key, cap_usd?}, PUT /my-key/cap {cap_usd}, DELETE /my-key, POST /my-key/test
9
+ * PUT /app {cap_usd} admin: what each person may spend a month on the app's keys
10
+ * PUT /key {api_key} admin: the app's key of `?provider=` (anthropic by default,
11
+ * or openai), tested before it is kept
12
+ * DELETE /key, POST /key/test admin, `?provider=` the same
13
+ * PUT /my-key {api_key}, DELETE /my-key, POST /my-key/test the person's own, `?provider=`
14
+ * PUT /my-key/cap {cap_usd} what the person may spend a month on their own keys
15
+ * POST /my-signin starts a ChatGPT plan sign-in (`?provider=openai`): {url, redirect}
16
+ * POST /my-signin/finish {url} finishes it from the 127.0.0.1 address the sign-in ended on
13
17
  * PUT /web/scrapeql {client_id, client_secret}, DELETE /web/scrapeql, POST /web/test admin
14
18
  * POST /chat one answer as server-sent events; closing the request stops it
15
19
  * GET /chats, GET|DELETE /chats/:id
16
20
  * POST /chats/:id/drafts/:draft {action: save | dismiss}
21
+ * POST /chats/:id/writes/:step/undo
17
22
  *
18
23
  * Writes must be same-origin JSON: a signed-in browser session is a cookie, so a foreign page's
19
24
  * form or fetch is refused before it reaches a rule. Keys and tokens are write-only: no route
@@ -21,6 +26,8 @@
21
26
  */
22
27
  import { ASSISTANT_ACCESS, ASSISTANT_EFFORTS, ASSISTANT_PAYERS, HAND_OFF_POLICIES, } from '@columenlabs/lumen-ui-types/assistant';
23
28
  import { AssistantError } from './engine.js';
29
+ import { PROVIDERS } from './models.js';
30
+ import { keyDocName } from './state.js';
24
31
  import { APP_SCOPE } from './store.js';
25
32
  const MUTATING = new Set(['POST', 'PUT', 'PATCH', 'DELETE']);
26
33
  const MAX_MESSAGE = 20_000;
@@ -137,6 +144,13 @@ export function assistantRoutes(a, o) {
137
144
  return fail('Sign in first.', 403, 'forbidden');
138
145
  const admin = () => (caller.admin ? null : fail('Admins only.', 403, 'forbidden'));
139
146
  const status = async () => json(await a.status(caller));
147
+ const asked = url.searchParams.get('provider') ?? 'anthropic';
148
+ const provider = a.providers.find((p) => p === asked);
149
+ const keyRoute = /^(PUT|DELETE|POST) \/(my-)?(key|signin)(\/test|\/finish)?$/.test(route);
150
+ if (keyRoute && !provider)
151
+ return fail(`This app runs no ${asked} models.`, 404);
152
+ const prov = provider ?? 'anthropic';
153
+ const docName = keyDocName(prov);
140
154
  try {
141
155
  switch (route) {
142
156
  case 'GET /status':
@@ -169,31 +183,21 @@ export function assistantRoutes(a, o) {
169
183
  }
170
184
  const b = await body(req);
171
185
  const key = str(b.api_key, 400);
172
- if (!key)
173
- return fail('Paste the key (sk-ant-…).');
174
- const c = b.cap_usd === undefined ? undefined : cap(b.cap_usd);
175
- if (c === null)
176
- return fail(`cap_usd is a number of dollars, 0 to ${MAX_CAP}.`);
177
- const test = await a.testKey(key);
186
+ const { prefix, label } = PROVIDERS[prov];
187
+ if (!key?.startsWith(prefix))
188
+ return fail(`Paste the ${label} key (${prefix}…).`);
189
+ const test = await a.testKey(prov, key);
178
190
  if (!test.ok)
179
191
  return fail(test.detail, 400, 'key_refused');
180
192
  const scope = mine ? caller.id : APP_SCOPE;
181
- await docs.update(scope, 'key', (d) => ({
182
- api_key: key,
183
- saved_at: Date.now(),
184
- test,
185
- ...(mine ? { cap_usd: c ?? d?.cap_usd ?? a.state.defaultCap } : {}),
186
- }));
193
+ await docs.write(scope, docName, { api_key: key, saved_at: Date.now(), test });
187
194
  return status();
188
195
  }
189
196
  case 'PUT /my-key/cap': {
190
197
  const c = cap((await body(req)).cap_usd);
191
198
  if (c === null)
192
199
  return fail(`cap_usd is a number of dollars, 0 to ${MAX_CAP}.`);
193
- const d = await a.state.ownKey(caller.id);
194
- if (!d)
195
- return fail('Add your key first.', 404);
196
- await docs.write(caller.id, 'key', { ...d, cap_usd: c });
200
+ await a.state.setPerson(caller.id, (d) => ({ ...d, own_cap: c }));
197
201
  return status();
198
202
  }
199
203
  case 'DELETE /key':
@@ -204,25 +208,36 @@ export function assistantRoutes(a, o) {
204
208
  if (no)
205
209
  return no;
206
210
  }
207
- await docs.remove(mine ? caller.id : APP_SCOPE, 'key');
211
+ await docs.remove(mine ? caller.id : APP_SCOPE, docName);
212
+ return status();
213
+ }
214
+ case 'POST /my-signin':
215
+ return json(await a.startSignin(caller, prov));
216
+ case 'POST /my-signin/finish': {
217
+ const pasted = str((await body(req)).url, 4000);
218
+ if (!pasted)
219
+ return fail('Paste the address the sign-in ended on.');
220
+ await a.finishSignin(caller, prov, pasted);
208
221
  return status();
209
222
  }
210
- case 'POST /key/test':
211
223
  case 'POST /my-key/test': {
212
- const mine = route === 'POST /my-key/test';
213
- if (!mine) {
214
- const no = admin();
215
- if (no)
216
- return no;
217
- }
218
- const scope = mine ? caller.id : APP_SCOPE;
219
- const d = await docs.read(scope, 'key');
220
- const key = d?.api_key ?? (mine ? null : (await a.state.appApiKey())?.key);
224
+ const test = await a.testOwn(caller, prov);
225
+ if (!test)
226
+ return fail('No key is set.', 404);
227
+ await docs.update(caller.id, docName, (d) => (d ? { ...d, test } : null));
228
+ return json(test);
229
+ }
230
+ case 'POST /key/test': {
231
+ const no = admin();
232
+ if (no)
233
+ return no;
234
+ const d = await docs.read(APP_SCOPE, docName);
235
+ const key = d?.api_key ?? (await a.state.appApiKey(prov))?.key;
221
236
  if (!key)
222
237
  return fail('No key is set.', 404);
223
- const test = await a.testKey(key);
238
+ const test = await a.testKey(prov, key);
224
239
  if (d)
225
- await docs.write(scope, 'key', { ...d, test });
240
+ await docs.write(APP_SCOPE, docName, { ...d, test });
226
241
  return json(test);
227
242
  }
228
243
  case 'PUT /web/scrapeql': {
@@ -302,6 +317,8 @@ export function assistantRoutes(a, o) {
302
317
  return fail('action is save or dismiss.');
303
318
  return json(await a.settleDraft(caller, id, parts[3], action));
304
319
  }
320
+ if (parts.length === 5 && parts[2] === 'writes' && parts[4] === 'undo' && m === 'POST')
321
+ return json(await a.undoWrite(caller, id, parts[3]));
305
322
  }
306
323
  return fail('Not found.', 404);
307
324
  }
@@ -1,17 +1,23 @@
1
1
  /**
2
2
  * The assistant's settings, keys and spend, as stored documents.
3
3
  *
4
- * app (the app's scope) the cap per person on the app's key
4
+ * app (the app's scope) the cap per person on the app's keys
5
5
  * key (the app's scope) the app's Anthropic key, tested before it is kept, sealed
6
+ * key:<p> (the app's scope) the app's key of another provider (`key:openai`), the same
6
7
  * scrapeql (the app's scope) ScrapeQL's Access service token, tested before it is kept, sealed
7
- * settings (a person's scope) their tiers, start tiers, hand off, advice, tool access, web, payer
8
- * key (a person's scope) their own Anthropic key and its monthly cap, sealed
8
+ * settings (a person's scope) their tiers, start tiers, hand off, advice, tool access, web, payer,
9
+ * and the monthly cap on their own keys
10
+ * key, key:<p> (a person's) their own key of each provider, sealed; OpenAI's may be a ChatGPT
11
+ * plan sign-in, refreshed before it runs out
12
+ * signin:<p> (a person's) the app's sign-in registration for them, kept across sign-outs
9
13
  * spend:YYYY-MM what each tier cost them this month, by payer
10
14
  *
11
- * Who pays: the person's pick (the app's key by default) while it has money left this month, else
12
- * the other; with neither the assistant waits and says why.
15
+ * Who pays: the person's pick (the app's keys by default) while it has a key for the provider the
16
+ * tier runs on and money left this month, else the other; with neither the assistant waits and says
17
+ * why. A cap covers every provider of its payer.
13
18
  */
14
- import type { AssistantAccess, AssistantEffort, AssistantPayer, AssistantWait, HandOffPolicy, UsagePart } from '@columenlabs/lumen-ui-types/assistant';
19
+ import type { AssistantAccess, AssistantEffort, AssistantPayer, AssistantProvider, AssistantWait, HandOffPolicy, UsagePart } from '@columenlabs/lumen-ui-types/assistant';
20
+ import { type ChatgptTokens } from './chatgpt.js';
15
21
  import { type Docs } from './store.js';
16
22
  export interface KeyTest {
17
23
  at: number;
@@ -22,9 +28,13 @@ export interface KeyDoc {
22
28
  api_key: string;
23
29
  saved_at: number;
24
30
  test: KeyTest | null;
25
- /** USD a month: the person's own key only. */
31
+ /** USD a month: a person's own Anthropic key before the cap moved to their settings. */
26
32
  cap_usd?: number;
33
+ /** A ChatGPT plan sign-in in place of an API key (`api_key` is then its access token). */
34
+ chatgpt?: ChatgptTokens;
27
35
  }
36
+ /** Where a provider's key is kept: `key` for Anthropic, `key:<provider>` for the rest. */
37
+ export declare const keyDocName: (provider: AssistantProvider) => string;
28
38
  export interface TokenDoc {
29
39
  client_id: string;
30
40
  client_secret: string;
@@ -47,6 +57,8 @@ export interface PersonDoc {
47
57
  access?: Record<string, AssistantAccess>;
48
58
  web?: boolean;
49
59
  pay?: AssistantPayer;
60
+ /** USD a month on their own keys. */
61
+ own_cap?: number;
50
62
  }
51
63
  export type SpendRow = UsagePart & {
52
64
  payer: AssistantPayer;
@@ -63,12 +75,18 @@ export declare function keyState(d: KeyDoc | TokenDoc | null, secret: (d: KeyDoc
63
75
  } | {
64
76
  set: true;
65
77
  hint: string;
78
+ via?: "chatgpt" | undefined;
66
79
  saved_at: number;
67
80
  test?: KeyTest | undefined;
68
81
  };
69
82
  export interface Paying {
70
83
  payer: AssistantPayer;
84
+ /** The key of the provider asked for. */
71
85
  apiKey: string;
86
+ /** The payer's key of every provider it has one for (a hand-off may run on another). */
87
+ keys: Partial<Record<AssistantProvider, string>>;
88
+ /** Providers whose key is a plan sign-in: their use costs the payer nothing. */
89
+ plans: Partial<Record<AssistantProvider, boolean>>;
72
90
  /** USD left this month under the payer's cap. */
73
91
  left: number;
74
92
  cap: number;
@@ -79,7 +97,8 @@ export declare class State {
79
97
  constructor(docs: Docs, o: {
80
98
  timeZone: string;
81
99
  defaultCap: number;
82
- appKey?: () => Promise<string | null | undefined>;
100
+ appKey?: (provider: AssistantProvider) => Promise<string | null | undefined>;
101
+ fetch?: typeof fetch;
83
102
  });
84
103
  /** USD a month when nobody set a cap. */
85
104
  get defaultCap(): number;
@@ -88,22 +107,34 @@ export declare class State {
88
107
  setPerson(id: string, change: (d: PersonDoc) => PersonDoc): Promise<PersonDoc | null>;
89
108
  app(): Promise<AppDoc>;
90
109
  setApp(change: (d: AppDoc) => AppDoc): Promise<AppDoc | null>;
91
- appKey(): Promise<KeyDoc | null>;
110
+ appKey(provider: AssistantProvider): Promise<KeyDoc | null>;
92
111
  /** The app's key: the stored one, else the app's fallback (a Worker secret). */
93
- appApiKey(): Promise<{
112
+ appApiKey(provider: AssistantProvider): Promise<{
94
113
  key: string;
95
114
  fromEnv: boolean;
96
115
  } | null>;
97
- ownKey(id: string): Promise<KeyDoc | null>;
116
+ ownKey(id: string, provider: AssistantProvider): Promise<KeyDoc | null>;
117
+ /** The person's own key, a plan sign-in refreshed first when it is about to run out. */
118
+ ownApiKey(id: string, provider: AssistantProvider): Promise<{
119
+ key: string;
120
+ plan: boolean;
121
+ } | null>;
122
+ /** USD a month on the person's own keys. */
123
+ ownCap(id: string): Promise<number>;
98
124
  scrapeql(): Promise<TokenDoc | null>;
99
125
  appCap(): Promise<number>;
100
126
  spend(id: string, month?: string): Promise<SpendDoc>;
101
127
  /** Adds an answer's use to this month's spend, by label and payer. */
102
128
  addSpend(id: string, payer: AssistantPayer, parts: UsagePart[]): Promise<void>;
103
- /** Who pays the person's next answer, or why nobody can. */
104
- paying(id: string): Promise<{
129
+ /** The payer's key of each provider, those set, and which are plan sign-ins. */
130
+ keysOf(id: string, payer: AssistantPayer): Promise<{
131
+ keys: Partial<Record<"anthropic" | "openai", string>>;
132
+ plans: Partial<Record<"anthropic" | "openai", boolean>>;
133
+ }>;
134
+ /** Who pays the person's next answer on `provider`'s model, or why nobody can. */
135
+ paying(id: string, provider: AssistantProvider): Promise<{
105
136
  paying: Paying | null;
106
137
  wait: AssistantWait | null;
107
138
  }>;
108
139
  }
109
- export declare const WAIT_TEXT: Record<AssistantWait, string>;
140
+ export declare function waitText(wait: AssistantWait, provider: AssistantProvider): string;
@@ -1,5 +1,8 @@
1
- import { keyHint } from './models.js';
1
+ import { refreshChatgpt, staleChatgpt } from './chatgpt.js';
2
+ import { PROVIDERS, keyHint } from './models.js';
2
3
  import { APP_SCOPE } from './store.js';
4
+ /** Where a provider's key is kept: `key` for Anthropic, `key:<provider>` for the rest. */
5
+ export const keyDocName = (provider) => provider === 'anthropic' ? 'key' : `key:${provider}`;
3
6
  /** USD a month when nobody set a cap. */
4
7
  export const DEFAULT_CAP = 10;
5
8
  /** `2026-10` in the app's time zone. */
@@ -11,9 +14,11 @@ export function monthIn(timeZone, at = new Date()) {
11
14
  export function keyState(d, secret) {
12
15
  if (!d)
13
16
  return { set: false };
17
+ const plan = 'chatgpt' in d ? d.chatgpt : undefined;
14
18
  return {
15
19
  set: true,
16
- hint: keyHint(secret(d)),
20
+ hint: plan ? (plan.email ?? 'ChatGPT plan') : keyHint(secret(d)),
21
+ ...(plan ? { via: 'chatgpt' } : {}),
17
22
  saved_at: d.saved_at,
18
23
  ...(d.test ? { test: d.test } : {}),
19
24
  };
@@ -44,19 +49,52 @@ export class State {
44
49
  setApp(change) {
45
50
  return this.docs.update(APP_SCOPE, 'app', (d) => change(d ?? {}));
46
51
  }
47
- appKey() {
48
- return this.docs.read(APP_SCOPE, 'key');
52
+ appKey(provider) {
53
+ return this.docs.read(APP_SCOPE, keyDocName(provider));
49
54
  }
50
55
  /** The app's key: the stored one, else the app's fallback (a Worker secret). */
51
- async appApiKey() {
52
- const d = await this.appKey();
56
+ async appApiKey(provider) {
57
+ const d = await this.appKey(provider);
53
58
  if (d)
54
59
  return { key: d.api_key, fromEnv: false };
55
- const env = await this.o.appKey?.();
60
+ const env = await this.o.appKey?.(provider);
56
61
  return env ? { key: env, fromEnv: true } : null;
57
62
  }
58
- ownKey(id) {
59
- return this.docs.read(id, 'key');
63
+ ownKey(id, provider) {
64
+ return this.docs.read(id, keyDocName(provider));
65
+ }
66
+ /** The person's own key, a plan sign-in refreshed first when it is about to run out. */
67
+ async ownApiKey(id, provider) {
68
+ const d = await this.ownKey(id, provider);
69
+ if (!d)
70
+ return null;
71
+ if (!d.chatgpt)
72
+ return { key: d.api_key, plan: false };
73
+ if (!staleChatgpt(d.chatgpt))
74
+ return { key: d.chatgpt.access_token, plan: true };
75
+ try {
76
+ const t = await refreshChatgpt(d.chatgpt, this.o.fetch ?? fetch);
77
+ await this.docs.write(id, keyDocName(provider), {
78
+ ...d,
79
+ api_key: t.access_token,
80
+ chatgpt: t,
81
+ });
82
+ return { key: t.access_token, plan: true };
83
+ }
84
+ catch (err) {
85
+ await this.docs.write(id, keyDocName(provider), {
86
+ ...d,
87
+ test: { at: Date.now(), ok: false, detail: err.message },
88
+ });
89
+ return null;
90
+ }
91
+ }
92
+ /** USD a month on the person's own keys. */
93
+ async ownCap(id) {
94
+ const p = await this.person(id);
95
+ if (p.own_cap !== undefined)
96
+ return p.own_cap;
97
+ return (await this.ownKey(id, 'anthropic'))?.cap_usd ?? this.o.defaultCap;
60
98
  }
61
99
  scrapeql() {
62
100
  return this.docs.read(APP_SCOPE, 'scrapeql');
@@ -96,24 +134,53 @@ export class State {
96
134
  return { rows };
97
135
  });
98
136
  }
99
- /** Who pays the person's next answer, or why nobody can. */
100
- async paying(id) {
101
- const [person, app, own, spend, appCap] = await Promise.all([
137
+ /** The payer's key of each provider, those set, and which are plan sign-ins. */
138
+ async keysOf(id, payer) {
139
+ const ids = Object.keys(PROVIDERS);
140
+ const found = await Promise.all(ids.map((p) => payer === 'app'
141
+ ? this.appApiKey(p).then((k) => (k ? { key: k.key, plan: false } : null))
142
+ : this.ownApiKey(id, p)));
143
+ const keys = {};
144
+ const plans = {};
145
+ ids.forEach((p, i) => {
146
+ const k = found[i];
147
+ if (k)
148
+ keys[p] = k.key;
149
+ if (k?.plan)
150
+ plans[p] = true;
151
+ });
152
+ return { keys, plans };
153
+ }
154
+ /** Who pays the person's next answer on `provider`'s model, or why nobody can. */
155
+ async paying(id, provider) {
156
+ const [person, appKeys, ownKeys, spend, appCap, ownCap] = await Promise.all([
102
157
  this.person(id),
103
- this.appApiKey(),
104
- this.ownKey(id),
158
+ this.keysOf(id, 'app'),
159
+ this.keysOf(id, 'own'),
105
160
  this.spend(id),
106
161
  this.appCap(),
162
+ this.ownCap(id),
107
163
  ]);
108
164
  const spent = (p) => spend.rows.filter((r) => r.payer === p).reduce((a, r) => a + r.usd, 0);
165
+ const app = appKeys.keys[provider];
166
+ const own = ownKeys.keys[provider];
109
167
  const sources = {
110
- app: app ? { payer: 'app', apiKey: app.key, cap: appCap, left: appCap - spent('app') } : null,
168
+ app: app
169
+ ? {
170
+ payer: 'app',
171
+ apiKey: app,
172
+ ...appKeys,
173
+ cap: appCap,
174
+ left: appCap - spent('app'),
175
+ }
176
+ : null,
111
177
  own: own
112
178
  ? {
113
179
  payer: 'own',
114
- apiKey: own.api_key,
115
- cap: own.cap_usd ?? this.o.defaultCap,
116
- left: (own.cap_usd ?? this.o.defaultCap) - spent('own'),
180
+ apiKey: own,
181
+ ...ownKeys,
182
+ cap: ownCap,
183
+ left: ownCap - spent('own'),
117
184
  }
118
185
  : null,
119
186
  };
@@ -131,8 +198,11 @@ export class State {
131
198
  return { paying: null, wait: capped === 'own' ? 'own_cap' : 'app_cap' };
132
199
  }
133
200
  }
134
- export const WAIT_TEXT = {
135
- not_connected: 'No Claude key is set: an admin adds one in Settings ▸ Model providers.',
136
- app_cap: "You reached this month's cap on the app's key. Add your own key, or wait for next month.",
137
- own_cap: 'Your own key reached its monthly cap: raise it in Settings ▸ Spend.',
138
- };
201
+ export function waitText(wait, provider) {
202
+ const label = PROVIDERS[provider].label;
203
+ if (wait === 'not_connected')
204
+ return `No ${label} key is set: add one in Settings ▸ Model providers, or pick another model in Settings ▸ Models.`;
205
+ if (wait === 'app_cap')
206
+ return "You reached this month's cap on the shared keys. Add your own key, or wait for next month.";
207
+ return 'Your own keys reached their monthly cap: raise it in Settings ▸ Model providers.';
208
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@columenlabs/lumen-ai",
3
- "version": "0.1.77",
3
+ "version": "0.1.79",
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",
@@ -40,10 +40,11 @@
40
40
  "registry": "https://registry.npmjs.org/"
41
41
  },
42
42
  "dependencies": {
43
- "@columenlabs/lumen-ops": "0.1.77",
44
- "@columenlabs/lumen-ui-types": "0.1.77",
45
- "zod": "^4.3.6",
46
- "@anthropic-ai/sdk": "0.133.0"
43
+ "@anthropic-ai/sdk": "0.133.0",
44
+ "@columenlabs/lumen-ops": "0.1.79",
45
+ "@columenlabs/lumen-ui-types": "0.1.79",
46
+ "openai": "7.32.0",
47
+ "zod": "^4.3.6"
47
48
  },
48
49
  "peerDependencies": {
49
50
  "@modelcontextprotocol/sdk": "^1.29.0"