@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.
- package/README.md +7 -4
- package/dist/assistant/chatgpt.d.ts +61 -0
- package/dist/assistant/chatgpt.js +165 -0
- package/dist/assistant/engine.d.ts +25 -8
- package/dist/assistant/engine.js +337 -70
- package/dist/assistant/index.d.ts +3 -3
- package/dist/assistant/index.js +3 -3
- package/dist/assistant/models.d.ts +16 -2
- package/dist/assistant/models.js +46 -3
- package/dist/assistant/routes.js +47 -33
- package/dist/assistant/state.d.ts +45 -14
- package/dist/assistant/state.js +93 -23
- package/package.json +6 -5
package/dist/assistant/engine.js
CHANGED
|
@@ -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
|
|
19
|
-
import {
|
|
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
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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:
|
|
119
|
+
message: `${who} is busy or the key hit its rate limit. Try again shortly.`,
|
|
109
120
|
code: 'rate_limited',
|
|
110
121
|
};
|
|
111
|
-
if (
|
|
112
|
-
return { message:
|
|
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 ??
|
|
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
|
-
|
|
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
|
|
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.
|
|
187
|
-
|
|
188
|
-
|
|
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.
|
|
193
|
-
this.
|
|
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) => ({
|
|
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
|
-
|
|
240
|
-
|
|
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
|
|
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
|
|
408
|
-
|
|
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
|
|
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
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
1137
|
+
async compact(clients, s, doc, emit) {
|
|
938
1138
|
if (doc.history.length < 2)
|
|
939
1139
|
return false;
|
|
940
|
-
const
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
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
|
-
/**
|
|
994
|
-
async
|
|
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.
|
|
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
|
-
|
|
999
|
-
|
|
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
|
|
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';
|
package/dist/assistant/index.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* An assistant for any app: a chat side panel's server. Claude on the app's tiers
|
|
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
|
-
|
|
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. */
|