@columenlabs/lumen-ai 0.1.77 → 0.1.78

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.
@@ -8,15 +8,20 @@
8
8
  *
9
9
  * Hand off: an `escalate` tool hands one task to a higher tier with the same tools, in its own
10
10
  * section of the chat, and its memo goes back. Advice: Anthropic's advisor tool lets a higher tier
11
- * read the chat and send a plan (it runs nothing), at most `ADVICE_MAX` an answer.
11
+ * read the chat and send a plan (it runs nothing), at most `ADVICE_MAX` an answer (Claude only).
12
+ *
13
+ * Providers: a tier runs a Claude model (Anthropic's Messages API) or an OpenAI model (the
14
+ * Responses API), each on its payer's key of that provider. A hand-off may cross providers.
12
15
  *
13
16
  * Spend: every call is priced at list price as it runs; past what its payer has left this month the
14
17
  * model must answer from what it has.
15
18
  */
16
19
  import Anthropic from '@anthropic-ai/sdk';
17
20
  import { HAND_OFF_WHYS } from '@columenlabs/lumen-ui-types/assistant';
18
- import { CLAUDE_MODELS, DEFAULT_OUT, Meter, effortParams, } from './models.js';
19
- import { DEFAULT_CAP, State, WAIT_TEXT, keyState } from './state.js';
21
+ import OpenAI from 'openai';
22
+ import { CHATGPT_REDIRECT, SigninError, finishChatgpt, startChatgpt, testChatgpt, } from './chatgpt.js';
23
+ import { ALL_MODELS, DEFAULT_OUT, Meter, PROVIDERS, effortParams, } from './models.js';
24
+ import { DEFAULT_CAP, State, keyDocName, keyState, waitText, } from './state.js';
20
25
  import { Docs } from './store.js';
21
26
  import { ToolError, rowCount, toolName, withDeadline, } from './tools.js';
22
27
  import { scrapeqlTools, webFetchTools } from './web.js';
@@ -100,23 +105,27 @@ function draftsOf(items) {
100
105
  function shownError(err) {
101
106
  if (err instanceof AssistantError)
102
107
  return { message: err.message, code: err.code };
103
- if (err instanceof Anthropic.APIError) {
104
- if (err.status === 401)
105
- return { message: 'The Claude key was refused.', code: 'key_refused' };
106
- if (err.status === 429)
108
+ const api = err instanceof Anthropic.APIError
109
+ ? { who: 'Claude', status: err.status }
110
+ : err instanceof OpenAI.APIError
111
+ ? { who: 'OpenAI', status: err.status }
112
+ : null;
113
+ if (api) {
114
+ const { who, status } = api;
115
+ if (status === 401)
116
+ return { message: `The ${who} key was refused.`, code: 'key_refused' };
117
+ if (status === 429)
107
118
  return {
108
- message: 'Claude is busy or the key hit its rate limit. Try again shortly.',
119
+ message: `${who} is busy or the key hit its rate limit. Try again shortly.`,
109
120
  code: 'rate_limited',
110
121
  };
111
- if (err.status === 529 || (err.status ?? 0) >= 500)
112
- return { message: 'Claude is overloaded. Try again shortly.', code: 'overloaded' };
113
- return {
114
- message: `Claude refused the request (${err.status ?? 'error'}).`,
115
- code: 'model_error',
116
- };
122
+ if (status === 529 || (status ?? 0) >= 500)
123
+ return { message: `${who} is overloaded. Try again shortly.`, code: 'overloaded' };
124
+ return { message: `${who} refused the request (${status ?? 'error'}).`, code: 'model_error' };
117
125
  }
118
126
  return { message: 'Something went wrong while answering.', code: 'internal' };
119
127
  }
128
+ const isApiError = (err) => err instanceof Anthropic.APIError || err instanceof OpenAI.APIError;
120
129
  export class Assistant {
121
130
  o;
122
131
  name;
@@ -131,7 +140,7 @@ export class Assistant {
131
140
  throw new Error('An assistant needs at least one tier');
132
141
  this.name = o.name;
133
142
  this.tiers = o.tiers;
134
- this.models = o.models ?? CLAUDE_MODELS;
143
+ this.models = o.models ?? ALL_MODELS;
135
144
  this.tasks = o.tasks ?? [
136
145
  { id: 'chat', label: 'Chat', about: 'The side panel', tier: o.tiers[0].id },
137
146
  ];
@@ -140,12 +149,31 @@ export class Assistant {
140
149
  timeZone: o.timeZone ?? 'UTC',
141
150
  defaultCap: o.defaultCap ?? DEFAULT_CAP,
142
151
  ...(o.appKey ? { appKey: o.appKey } : {}),
152
+ ...(o.fetch ? { fetch: o.fetch } : {}),
143
153
  });
144
154
  }
145
- client(apiKey) {
155
+ anthropic(apiKey) {
146
156
  const f = this.o.fetch;
147
157
  return new Anthropic({ apiKey, maxRetries: 1, ...(f ? { fetch: f } : {}) });
148
158
  }
159
+ openai(apiKey, plan = false) {
160
+ const f = this.o.fetch;
161
+ // A plan sign-in's calls are not retried: a usage limit is final for now.
162
+ return new OpenAI({ apiKey, maxRetries: plan ? 0 : 1, ...(f ? { fetch: f } : {}) });
163
+ }
164
+ clients(paying) {
165
+ const { keys, plans } = paying;
166
+ return {
167
+ ...(keys.anthropic ? { anthropic: this.anthropic(keys.anthropic) } : {}),
168
+ ...(keys.openai
169
+ ? { openai: this.openai(keys.openai, !!plans.openai), openaiPlan: !!plans.openai }
170
+ : {}),
171
+ };
172
+ }
173
+ /** The providers the app's models run on, in the order the models list them. */
174
+ get providers() {
175
+ return [...new Set(this.models.map((m) => m.provider))];
176
+ }
149
177
  tierById(id) {
150
178
  return this.tiers.find((t) => t.id === id);
151
179
  }
@@ -181,16 +209,34 @@ export class Assistant {
181
209
  }
182
210
  // ---------------------------------------------------------------- status and settings
183
211
  async status(caller) {
184
- const [p, appDoc, appKey, own, scrapeql, spend, appCap, { paying, wait }, main] = await Promise.all([
212
+ const main = await this.mainTier(caller);
213
+ const mainModel = (await this.pick(caller, main)).model;
214
+ const [p, providers, scrapeql, spend, appCap, ownCap, { paying, wait }] = await Promise.all([
185
215
  this.state.person(caller.id),
186
- this.state.appKey(),
187
- this.state.appApiKey(),
188
- this.state.ownKey(caller.id),
216
+ Promise.all(this.providers.map(async (id) => {
217
+ const [doc, key, own] = await Promise.all([
218
+ this.state.appKey(id),
219
+ this.state.appApiKey(id),
220
+ this.state.ownKey(caller.id, id),
221
+ ]);
222
+ const app = caller.admin
223
+ ? keyState(doc, (d) => d.api_key)
224
+ : { set: !!key };
225
+ return {
226
+ id,
227
+ label: PROVIDERS[id].label,
228
+ prefix: PROVIDERS[id].prefix,
229
+ keys_url: PROVIDERS[id].keysUrl,
230
+ ...(PROVIDERS[id].signin ? { signin: PROVIDERS[id].signin } : {}),
231
+ app: { ...app, ...(key?.fromEnv ? { from_env: true } : {}) },
232
+ own: keyState(own, (d) => d.api_key),
233
+ };
234
+ })),
189
235
  this.o.web?.scrapeql ? this.state.scrapeql() : Promise.resolve(null),
190
236
  this.state.spend(caller.id),
191
237
  this.state.appCap(),
192
- this.state.paying(caller.id),
193
- this.mainTier(caller),
238
+ this.state.ownCap(caller.id),
239
+ this.state.paying(caller.id, mainModel.provider),
194
240
  ]);
195
241
  const tiers = await Promise.all(this.tiers.map(async (t) => {
196
242
  const { model, effort } = await this.pick(caller, t);
@@ -200,19 +246,23 @@ export class Assistant {
200
246
  about: t.about,
201
247
  model: model.id,
202
248
  model_label: model.label,
249
+ provider: model.provider,
203
250
  effort,
204
251
  };
205
252
  }));
206
- const appState = caller.admin
207
- ? keyState(appDoc, (d) => d.api_key)
208
- : { set: !!appKey };
209
253
  return {
210
254
  name: this.name,
211
255
  wait,
256
+ ...(wait ? { wait_provider: mainModel.provider } : {}),
212
257
  admin: caller.admin,
213
258
  tier: main.id,
214
259
  tiers,
215
- models: this.models.map((m) => ({ id: m.id, label: m.label, group: m.group })),
260
+ models: this.models.map((m) => ({
261
+ id: m.id,
262
+ label: m.label,
263
+ group: m.group,
264
+ provider: m.provider,
265
+ })),
216
266
  tasks: this.tasks.map((t) => ({
217
267
  id: t.id,
218
268
  label: t.label,
@@ -236,13 +286,8 @@ export class Assistant {
236
286
  : { set: !!scrapeql }
237
287
  : null,
238
288
  },
239
- keys: {
240
- app: { ...appState, cap: appCap, ...(appKey?.fromEnv ? { from_env: true } : {}) },
241
- own: {
242
- ...keyState(own, (d) => d.api_key),
243
- cap: own?.cap_usd ?? this.o.defaultCap ?? DEFAULT_CAP,
244
- },
245
- },
289
+ providers,
290
+ caps: { app: appCap, own: ownCap },
246
291
  pay: p.pay ?? 'app',
247
292
  spend: {
248
293
  month: this.state.month(),
@@ -402,20 +447,21 @@ export class Assistant {
402
447
  });
403
448
  const start = doc.items.length;
404
449
  emit('chat', { id: chatId, title: doc.title });
405
- const { paying, wait } = await this.state.paying(caller.id);
450
+ const main = await this.session(caller, await this.mainTier(caller));
451
+ const { paying, wait } = await this.state.paying(caller.id, main.model.provider);
406
452
  if (!paying) {
407
- const text = WAIT_TEXT[wait ?? 'not_connected'];
408
- doc.items.push({ kind: 'error', text, code: wait ?? 'not_connected' });
453
+ const code = wait ?? 'not_connected';
454
+ const text = waitText(code, main.model.provider);
455
+ doc.items.push({ kind: 'error', text, code });
409
456
  await this.writeChat(caller, chatId, doc);
410
- emit('error', { message: text, code: wait });
457
+ emit('error', { message: text, code });
411
458
  emit('done', { id: chatId, usage: doc.usage ?? NO_USAGE });
412
459
  return;
413
460
  }
414
461
  const p = await this.state.person(caller.id);
415
- const main = await this.session(caller, await this.mainTier(caller));
416
462
  const policy = p.hand_off ?? 'next';
417
463
  const advisor = await this.advisorFor(caller, p.advice, main);
418
- const client = this.client(paying.apiKey);
464
+ const clients = this.clients(paying);
419
465
  const live = [main.meter, ...(advisor ? [advisor.session.meter] : [])];
420
466
  const before = doc.usage ?? NO_USAGE;
421
467
  let context = before.context;
@@ -451,7 +497,7 @@ export class Assistant {
451
497
  },
452
498
  ];
453
499
  if (compactOnly || (before.context ?? 0) > COMPACT_AT) {
454
- const did = await this.compact(client, main, doc, emit);
500
+ const did = await this.compact(clients, main, doc, emit);
455
501
  if (did)
456
502
  context = undefined;
457
503
  if (compactOnly) {
@@ -484,7 +530,7 @@ export class Assistant {
484
530
  ];
485
531
  const escalateTool = this.escalateTool(main.tier);
486
532
  let answer = await this.loop({
487
- client,
533
+ clients,
488
534
  session: main,
489
535
  system,
490
536
  tools: [...this.toolsFor(tools.fns), ...(escalateTool ? [escalateTool] : [])],
@@ -502,7 +548,7 @@ export class Assistant {
502
548
  tool: (use) => use.name === 'escalate'
503
549
  ? this.handOff(use, {
504
550
  caller,
505
- client,
551
+ clients,
506
552
  message,
507
553
  handTo,
508
554
  page,
@@ -551,7 +597,7 @@ export class Assistant {
551
597
  else {
552
598
  outcome = 'error';
553
599
  const shown = shownError(err);
554
- if (!(err instanceof AssistantError) && !(err instanceof Anthropic.APIError))
600
+ if (!(err instanceof AssistantError) && !isApiError(err))
555
601
  console.error(JSON.stringify({
556
602
  event: 'assistant_error',
557
603
  error: String(err?.stack ?? err).slice(0, 2000),
@@ -615,10 +661,13 @@ export class Assistant {
615
661
  /** The advisor for this answer, or null when off. Never weaker than the model it advises. */
616
662
  async advisorFor(caller, setting, main) {
617
663
  const tier = setting && setting !== 'off' ? this.tierById(setting) : undefined;
618
- if (!tier)
664
+ if (!tier || main.model.provider !== 'anthropic')
619
665
  return null;
620
666
  const rank = (m) => this.models.indexOf(m);
621
667
  let session = await this.session(caller, tier);
668
+ // Anthropic's advisor tool runs a Claude model: another provider's tier advises nothing.
669
+ if (session.model.provider !== 'anthropic')
670
+ session = { ...main };
622
671
  if (rank(session.model) < rank(main.model))
623
672
  session = { ...main, meter: new Meter(main.model, '') };
624
673
  session.meter = new Meter(session.model, `${session.tier.label} · advice`);
@@ -682,6 +731,8 @@ export class Assistant {
682
731
  if (c.over())
683
732
  return reply('No money left for a higher tier this answer: answer yourself.', true);
684
733
  const sub = await this.session(c.caller, tier);
734
+ if (!c.clients[sub.model.provider])
735
+ return reply(`${tier.label} runs on ${PROVIDERS[sub.model.provider].label} and no ${PROVIDERS[sub.model.provider].label} key is set for whoever pays this answer: answer yourself.`, true);
685
736
  c.live.push(sub.meter);
686
737
  c.counts.handoffs += 1;
687
738
  if (this.tiers.indexOf(tier) > this.tiers.indexOf(c.counts.end))
@@ -707,7 +758,7 @@ export class Assistant {
707
758
  let failed;
708
759
  try {
709
760
  memo = await this.loop({
710
- client: c.client,
761
+ clients: c.clients,
711
762
  session: sub,
712
763
  system: c.system,
713
764
  tools: this.toolsFor(c.tools),
@@ -816,7 +867,156 @@ export class Assistant {
816
867
  return reply(cut, !ok);
817
868
  }
818
869
  /** Model calls and tool rounds until the model answers; returns its words. */
819
- async loop(o) {
870
+ loop(o) {
871
+ const { provider } = o.session.model;
872
+ const label = PROVIDERS[provider].label;
873
+ if (provider === 'openai') {
874
+ if (!o.clients.openai)
875
+ throw new AssistantError(`No ${label} key is set for this answer.`, 'not_connected');
876
+ return this.loopOpenAI({ ...o, client: o.clients.openai, plan: !!o.clients.openaiPlan });
877
+ }
878
+ if (!o.clients.anthropic)
879
+ throw new AssistantError(`No ${label} key is set for this answer.`, 'not_connected');
880
+ return this.loopClaude({ ...o, client: o.clients.anthropic });
881
+ }
882
+ /** The loop on OpenAI's Responses API: the same tools, Stop, caps and rounds as Claude's. */
883
+ async loopOpenAI(o) {
884
+ const { client, session, tools, sink, emit, signal, plan } = o;
885
+ const instructions = o.system.map((b) => b.text).join('\n\n');
886
+ const fnTools = tools.map((t) => ({
887
+ type: 'function',
888
+ name: t.name,
889
+ description: t.description ?? null,
890
+ parameters: t.input_schema,
891
+ strict: false,
892
+ }));
893
+ // The chat so far is plain text turns; this answer's calls are appended as OpenAI items.
894
+ const input = o.messages.map((m) => ({
895
+ role: m.role,
896
+ content: typeof m.content === 'string'
897
+ ? m.content
898
+ : m.content
899
+ .map((b) => (b.type === 'text' ? b.text : ''))
900
+ .filter(Boolean)
901
+ .join('\n'),
902
+ }));
903
+ let answer = '';
904
+ let force = o.first?.type === 'tool' ? { type: 'function', name: o.first.name } : undefined;
905
+ // A plan sign-in takes function tools only inside a namespace and no output cap.
906
+ const sent = (only) => {
907
+ const fns = only ? fnTools.filter((t) => t.name === only) : fnTools;
908
+ if (!plan)
909
+ return { tools: fns, max_output_tokens: session.tier.out ?? DEFAULT_OUT };
910
+ return {
911
+ tools: [
912
+ {
913
+ type: 'namespace',
914
+ name: 'app',
915
+ description: `${this.name}'s tools`,
916
+ tools: fns.map(({ strict: _, ...t }) => t),
917
+ },
918
+ ],
919
+ };
920
+ };
921
+ for (let round = 0; round < MAX_ROUNDS; round++) {
922
+ signal?.throwIfAborted();
923
+ const effort = session.effort === 'default' ? undefined : session.effort;
924
+ // On a plan a forced call is the one tool offered, with any call required.
925
+ const only = plan && typeof force === 'object' ? force.name : undefined;
926
+ const choice = only ? 'required' : force;
927
+ const stream = client.responses.stream({
928
+ model: session.model.id,
929
+ instructions,
930
+ input,
931
+ ...sent(only),
932
+ store: false,
933
+ stream: true,
934
+ include: ['reasoning.encrypted_content'],
935
+ ...(effort ? { reasoning: { effort } } : {}),
936
+ ...(choice ? { tool_choice: choice } : {}),
937
+ }, signal ? { signal } : {});
938
+ let partial = '';
939
+ stream.on('response.output_text.delta', (e) => {
940
+ partial += e.delta;
941
+ emit('text', { delta: e.delta });
942
+ });
943
+ const res = await stream.finalResponse().catch((err) => {
944
+ if (signal?.aborted && partial)
945
+ sink.push({ kind: 'text', text: partial });
946
+ throw err;
947
+ });
948
+ if (res.status === 'failed') {
949
+ if (partial)
950
+ sink.push({ kind: 'text', text: partial });
951
+ const code = res.error?.code ?? '';
952
+ throw new AssistantError(/usage_limit/.test(code)
953
+ ? 'Your ChatGPT plan reached its usage limit for now: try again later, or pick another model.'
954
+ : 'OpenAI could not finish the answer.', 'upstream');
955
+ }
956
+ const u = res.usage;
957
+ if (u) {
958
+ const read = u.input_tokens_details?.cached_tokens ?? 0;
959
+ const written = u.input_tokens_details?.cache_write_tokens ?? 0;
960
+ session.meter.add({
961
+ input_tokens: Math.max(0, u.input_tokens - read - written),
962
+ output_tokens: u.output_tokens,
963
+ cache_read_input_tokens: read,
964
+ cache_creation_input_tokens: written,
965
+ }, plan);
966
+ o.context?.(u.input_tokens + u.output_tokens);
967
+ }
968
+ o.called();
969
+ const cut = res.incomplete_details?.reason === 'max_output_tokens' ? ' …(cut)' : '';
970
+ if (cut)
971
+ emit('text', { delta: cut });
972
+ const text = (res.output_text ?? '') + cut;
973
+ if (text) {
974
+ answer += (answer ? '\n\n' : '') + text;
975
+ sink.push({ kind: 'text', text });
976
+ emit('text_end', {});
977
+ }
978
+ // Reasoning, words and calls go back as they came, so the model keeps its own thread.
979
+ input.push(...res.output);
980
+ const calls = res.output.filter((b) => b.type === 'function_call');
981
+ if (calls.length === 0)
982
+ break;
983
+ const results = await Promise.all(calls.map((c) => {
984
+ let args = {};
985
+ try {
986
+ args = c.arguments ? JSON.parse(c.arguments) : {};
987
+ }
988
+ catch { }
989
+ const use = {
990
+ type: 'tool_use',
991
+ id: c.call_id,
992
+ name: c.name,
993
+ input: args,
994
+ };
995
+ return untilStopped(o.tool(use), signal);
996
+ }));
997
+ for (const r of results) {
998
+ const body = typeof r.content === 'string' ? r.content : JSON.stringify(r.content ?? '');
999
+ input.push({
1000
+ type: 'function_call_output',
1001
+ call_id: r.tool_use_id,
1002
+ output: r.is_error ? `Error: ${body}` : body,
1003
+ });
1004
+ }
1005
+ force = undefined;
1006
+ const limit = o.over();
1007
+ const last = round === MAX_ROUNDS - 2;
1008
+ if (limit || last) {
1009
+ input.push({
1010
+ role: 'user',
1011
+ content: limit ?? 'No more tool calls: answer now from what you have.',
1012
+ });
1013
+ force = 'none';
1014
+ }
1015
+ }
1016
+ return answer;
1017
+ }
1018
+ /** The loop on Anthropic's Messages API, with the advisor and prompt caching. */
1019
+ async loopClaude(o) {
820
1020
  const { client, session, system, tools, messages, sink, emit, signal } = o;
821
1021
  let answer = '';
822
1022
  let force = o.first;
@@ -934,27 +1134,46 @@ export class Assistant {
934
1134
  return answer;
935
1135
  }
936
1136
  /** Swaps the history for a short summary. False with nothing to compact. */
937
- async compact(client, s, doc, emit) {
1137
+ async compact(clients, s, doc, emit) {
938
1138
  if (doc.history.length < 2)
939
1139
  return false;
940
- const msg = await client.messages.create({
941
- model: s.model.id,
942
- max_tokens: 1500,
943
- system: `Summarise this chat between a person and ${this.name}, their assistant, so ${this.name} can carry on from the summary alone. Keep what the person wants, the facts and numbers found, decisions made and cards drafted or saved, and anything still open. Plain bullets, at most 400 words. No preamble.`,
944
- messages: [
945
- {
946
- role: 'user',
947
- content: doc.history
948
- .map((t) => `${t.role === 'user' ? 'Person' : this.name}: ${t.content}`)
949
- .join('\n\n'),
950
- },
951
- ],
952
- });
953
- s.meter.add(msg.usage);
954
- const summary = msg.content
955
- .filter((b) => b.type === 'text')
956
- .map((b) => b.text)
957
- .join('');
1140
+ const system = `Summarise this chat between a person and ${this.name}, their assistant, so ${this.name} can carry on from the summary alone. Keep what the person wants, the facts and numbers found, decisions made and cards drafted or saved, and anything still open. Plain bullets, at most 400 words. No preamble.`;
1141
+ const chat = doc.history
1142
+ .map((t) => `${t.role === 'user' ? 'Person' : this.name}: ${t.content}`)
1143
+ .join('\n\n');
1144
+ let summary = '';
1145
+ if (s.model.provider === 'openai' && clients.openai) {
1146
+ const res = await clients.openai.responses.create({
1147
+ model: s.model.id,
1148
+ instructions: system,
1149
+ input: chat,
1150
+ store: false,
1151
+ max_output_tokens: 1500,
1152
+ });
1153
+ const u = res.usage;
1154
+ if (u)
1155
+ s.meter.add({
1156
+ input_tokens: u.input_tokens - (u.input_tokens_details?.cached_tokens ?? 0),
1157
+ output_tokens: u.output_tokens,
1158
+ cache_read_input_tokens: u.input_tokens_details?.cached_tokens ?? 0,
1159
+ });
1160
+ summary = res.output_text ?? '';
1161
+ }
1162
+ else if (clients.anthropic) {
1163
+ const msg = await clients.anthropic.messages.create({
1164
+ model: s.model.id,
1165
+ max_tokens: 1500,
1166
+ system,
1167
+ messages: [{ role: 'user', content: chat }],
1168
+ });
1169
+ s.meter.add(msg.usage);
1170
+ summary = msg.content
1171
+ .filter((b) => b.type === 'text')
1172
+ .map((b) => b.text)
1173
+ .join('');
1174
+ }
1175
+ else
1176
+ return false;
958
1177
  doc.history = [
959
1178
  { role: 'user', content: `Summary of our chat so far (compacted):\n${summary}` },
960
1179
  { role: 'assistant', content: 'Noted. I carry on from this summary.' },
@@ -990,13 +1209,61 @@ export class Assistant {
990
1209
  return `${answer}\n\n[Tools I used and what they returned]\n${lines.join('\n')}`;
991
1210
  }
992
1211
  // ---------------------------------------------------------------- keys
993
- /** One free call (reading a model) that says whether Claude takes the key. */
994
- async testKey(apiKey) {
1212
+ /** Starts a person's ChatGPT plan sign-in: the address to open in a new tab. */
1213
+ async startSignin(caller, provider) {
1214
+ if (PROVIDERS[provider].signin !== 'chatgpt')
1215
+ throw new AssistantError(`${PROVIDERS[provider].label} has no sign-in.`, 'no_signin', 404);
1216
+ const name = `signin:${provider}`;
1217
+ const { url, reg } = await startChatgpt(await this.state.docs.read(caller.id, name), this.name);
1218
+ await this.state.docs.write(caller.id, name, reg);
1219
+ return { url, redirect: CHATGPT_REDIRECT };
1220
+ }
1221
+ /** Finishes it from the address it ended on; the plan then stands in for the person's own key. */
1222
+ async finishSignin(caller, provider, pasted) {
1223
+ const name = `signin:${provider}`;
1224
+ try {
1225
+ const { tokens, reg } = await finishChatgpt(await this.state.docs.read(caller.id, name), pasted, this.o.fetch ?? fetch);
1226
+ await this.state.docs.write(caller.id, name, reg);
1227
+ const test = await testChatgpt(tokens, this.o.fetch ?? fetch);
1228
+ await this.state.docs.write(caller.id, keyDocName(provider), {
1229
+ api_key: tokens.access_token,
1230
+ saved_at: Date.now(),
1231
+ test,
1232
+ chatgpt: tokens,
1233
+ });
1234
+ }
1235
+ catch (err) {
1236
+ if (err instanceof SigninError)
1237
+ throw new AssistantError(err.message, 'signin_failed');
1238
+ throw err;
1239
+ }
1240
+ }
1241
+ /** Tests a person's own key, refreshing a plan sign-in first. */
1242
+ async testOwn(caller, provider) {
1243
+ const d = await this.state.ownKey(caller.id, provider);
1244
+ if (!d)
1245
+ return null;
1246
+ if (!d.chatgpt)
1247
+ return this.testKey(provider, d.api_key);
1248
+ const fresh = await this.state.ownApiKey(caller.id, provider);
1249
+ const now = await this.state.ownKey(caller.id, provider);
1250
+ if (!fresh || !now?.chatgpt)
1251
+ return now?.test ?? { ok: false, detail: 'Sign in again.', at: Date.now() };
1252
+ return testChatgpt(now.chatgpt, this.o.fetch ?? fetch);
1253
+ }
1254
+ /** One free call (reading a model) that says whether the provider takes the key. */
1255
+ async testKey(provider, apiKey) {
995
1256
  const at = Date.now();
996
- const model = this.modelById(this.tiers[0].model) ?? this.models[0];
1257
+ const model = this.models.find((m) => m.provider === provider);
1258
+ const label = PROVIDERS[provider].label;
1259
+ if (!model)
1260
+ return { ok: false, detail: `This app runs no ${label} models.`, at };
997
1261
  try {
998
- await this.client(apiKey).models.retrieve(model.id);
999
- return { ok: true, detail: 'Claude took the key.', at };
1262
+ if (provider === 'openai')
1263
+ await this.openai(apiKey).models.retrieve(model.id);
1264
+ else
1265
+ await this.anthropic(apiKey).models.retrieve(model.id);
1266
+ return { ok: true, detail: `${label} took the key.`, at };
1000
1267
  }
1001
1268
  catch (err) {
1002
1269
  return { ok: false, detail: shownError(err).message, at };
@@ -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. */