@columenlabs/lumen-ai 0.1.274 → 0.1.276
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 +4 -2
- package/dist/agent/agent.d.ts +23 -0
- package/dist/agent/agent.js +76 -4
- package/dist/agent/usage.d.ts +3 -1
- package/dist/agent/usage.js +4 -1
- package/dist/assistant/engine.d.ts +21 -0
- package/dist/assistant/engine.js +49 -4
- package/dist/assistant/models.d.ts +31 -0
- package/dist/assistant/models.js +33 -0
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -117,7 +117,9 @@ app.all('/api/assistant/*', (c) => routes(c.req.raw));
|
|
|
117
117
|
- **Hand off and advice.** The `escalate` tool hands one task to a higher tier (the person sets next, any or only when asked); Anthropic's advisor tool lets a higher tier send a plan, at most two an answer. A hand-off gives up after `HAND_OFF_MS` (4 minutes) and the turn goes on without it.
|
|
118
118
|
- **Chats** run on the agent (`./agent`, `LumenAgent`, below): the app routes `POST /chat` and each chat's reads, cards and Undo to its own agent, and `assistantRoutes` answers `POST /chat` with 410 `moved`. Chats saved by the old turn loop stay readable (`GET /chats`, `GET|DELETE /chats/:id`) until `assistant.purge()` drops them 30 days after their last message.
|
|
119
119
|
- **Screen moves**: while the person's Settings ▸ Screen is Move (the default), the panel sends its page map (`AssistantMessage.screen`: routes, nav pages, Settings sections, cards on screen, rows to open) and `LumenAgent` gives the model a `screen` tool (`./agent` `screen.ts`). Each call's moves (go, settings, chart, open, point) are checked against the map, kept in the chat and streamed as a `screen` event; the panel runs them through `AssistantScreenProvider` (mounted by `LumenShell`) with an Undo chip, and their outcomes go back with the next message (`outcomes`). Nothing is saved by a move.
|
|
120
|
-
- **Background work** an app runs for a person (a score, a summary) is `assistant.complete(caller, { task, system, prompt, images?, maxTokens })`: one answer on the task's tier (`tasks`, so the person picks its model in Settings ▸ Models), no chat and no tools (`images`, base64 with their media type, are read with the prompt: a scanned page, a screenshot), billed to their payer and counted in their spend and allowance like a chat turn. `maxTokens` (default 1,000) goes up to `COMPLETE_MAX_TOKENS` (32,000, thinking included); the answer is streamed, so a long one keeps its connection, and `cut: true` says it stopped at the cap before it ended (the app can ask again, shorter, or continue). It throws `AssistantError` `not_connected`, `app_cap` or `own_cap` when nobody can pay, so the caller waits and tries later. It returns the answer's `usd`, `model`, `usage` (tokens in, cache reads, out, per model) and `payer` (`app` or `own`: who paid, so the app can show a cost on the person's own key as money and the rest as credits). `effort` sets how hard the model thinks on that one call (`low` to `max`; on an OpenAI or xAI model it is the request's reasoning effort), over what the person saved for the tier. `run: { id, title }` makes the calls that share an `id` one line of the person's usage history (every round of one rebuild); without it a task's calls that day share a line. The month's spend also adds up per task and model (`spend:YYYY-MM` `tasks`: answers, calls, tokens, cost and time, a chat answer as `chat`), so an app's admin page can show what each kind of work costs; `tag` counts one call under another name when a task does more than one kind of work (a first try, a repair). **Prompt cache:**
|
|
120
|
+
- **Background work** an app runs for a person (a score, a summary) is `assistant.complete(caller, { task, system, prompt, images?, maxTokens })`: one answer on the task's tier (`tasks`, so the person picks its model in Settings ▸ Models), no chat and no tools (`images`, base64 with their media type, are read with the prompt: a scanned page, a screenshot), billed to their payer and counted in their spend and allowance like a chat turn. `maxTokens` (default 1,000) goes up to `COMPLETE_MAX_TOKENS` (32,000, thinking included); the answer is streamed, so a long one keeps its connection, and `cut: true` says it stopped at the cap before it ended (the app can ask again, shorter, or continue). It throws `AssistantError` `not_connected`, `app_cap` or `own_cap` when nobody can pay, so the caller waits and tries later. It returns the answer's `usd`, `model`, `usage` (tokens in, cache reads, out, per model) and `payer` (`app` or `own`: who paid, so the app can show a cost on the person's own key as money and the rest as credits). `effort` sets how hard the model thinks on that one call (`low` to `max`; on an OpenAI or xAI model it is the request's reasoning effort), over what the person saved for the tier. `run: { id, title }` makes the calls that share an `id` one line of the person's usage history (every round of one rebuild); without it a task's calls that day share a line. The month's spend also adds up per task and model (`spend:YYYY-MM` `tasks`: answers, calls, tokens, cost and time, a chat answer as `chat`), so an app's admin page can show what each kind of work costs; `tag` counts one call under another name when a task does more than one kind of work (a first try, a repair). **Prompt cache:** a provider's cache reuses whole earlier messages, not text added inside the same message (OpenAI read 0% of a prompt that only gained a part, and 99.7% when the same words came as a new message). So a job of several steps (a study that asks for more pictures, then a build) passes `history`: the conversation so far as `{ role, text, images? }` messages, oldest first, ending with the model's answer, and `prompt` and `images` as the newest message; each call then pays only for what is new. `prompt` may also be a list of parts (Claude marks every part but the last), which helps only an exact repeat on OpenAI. OpenAI calls carry a cache key per person, task and run (a ChatGPT plan sign-in caches nothing without one).
|
|
121
|
+
|
|
122
|
+
**Call log:** every model call (chat steps, hand-offs, compaction, `complete`) writes one JSON line on the console, `{ event: 'ai.call', model, label, task, run, in, read, written, out, hit, usd, free }`: tokens in (cache included), cache reads and writes, out, the share read from cache (`hit`), the cost at list price, and `free` for a plan sign-in. Workers Logs keeps it, so an app sees each call's cache use with no wiring. `setCallLog(fn)` sends the records elsewhere; `setCallLog(null)` turns it off.
|
|
121
123
|
- **Images** an app makes for a person are `assistant.image(caller, { task, prompt, images?, size?, quality?, run? })`: one image on the payer's OpenAI key (`IMAGE_MODEL`, gpt-image-2; a ChatGPT plan sign-in cannot make one), from `images` when given (a reference, a sketch), billed and capped like `complete` at the model's list price per token. It returns the PNG as base64 with its `usd`. A chat tool that returns `image: { url, alt?, cost? }` shows as an image card in the panel (`StepItem.image`); `LumenAssistant` `imageTool` names that tool (its "Making the image" box while it runs, Make another) and `modes` adds composer toggles (Make an image) whose id goes with the message as `use`.
|
|
122
124
|
- **Usage history.** Each chat (`agentTurn` with `chat`) and each run of background work is one line of the person's month (`usage:YYYY-MM`, the newest 300): its title, first and last use, tokens, cost and its parts per tier and model. `GET /usage?month=YYYY-MM` returns it beside that month's spend and the months that have any; `LumenAssistant`'s footer shows this month's total and opens it (`UsageHistoryView`).
|
|
123
125
|
|
|
@@ -162,7 +164,7 @@ The panel's composer lists what can follow three keys as they are typed. The kit
|
|
|
162
164
|
|
|
163
165
|
### Bearings
|
|
164
166
|
|
|
165
|
-
Each message the model gets starts with a few lines of bearings: the time now in the person's zone (the panel sends the browser's), UTC, how long since the chat's last message and the app's name. They go in the message, not the system prompt, so the prompt cache holds. A turn's lead (bearings, `plan.lead`, news)
|
|
167
|
+
Each message the model gets starts with a few lines of bearings: the time now in the person's zone (the panel sends the browser's), UTC, how long since the chat's last message and the app's name. They go in the message, not the system prompt, so the prompt cache holds. A turn's lead (bearings, `plan.lead`, news) stays on the message it went with, so every earlier message is sent exactly as before and read from the prompt cache (a lead taken off the last message changed it, and that message and its tool steps were paid again: 0% read before, 97 to 99% after, on OpenAI). The newest lead says only what changed since the last (`stickyLeads`, `leadDelta`): one line when nothing did, the new and gone lines when some did, and the whole lead the first time, after compaction, or when the change is most of it. A chat's OpenAI calls carry the chat's id as their cache key. `/compact` in the panel summarizes the chat's older part at once (as past `compactAt()`), without asking the model. An app adds its own lines with `bearings`; each call gets 300 ms, and when it is late or throws its lines are left out.
|
|
166
168
|
|
|
167
169
|
```ts
|
|
168
170
|
createAssistant({
|
package/dist/agent/agent.d.ts
CHANGED
|
@@ -132,6 +132,29 @@ export declare function cardNews(cards: AgentCard[]): string;
|
|
|
132
132
|
export declare const COMPACT_COMMAND = "/compact";
|
|
133
133
|
/** The messages with `lead` put before the newest message the person wrote. */
|
|
134
134
|
export declare function withLead(messages: ModelMessage[], lead: string | undefined): ModelMessage[];
|
|
135
|
+
/** The leads a chat has sent, kept so each earlier message is sent again exactly as it was. */
|
|
136
|
+
export interface SentLeads {
|
|
137
|
+
/** Each person's message (`leadKey`) and the lead it went with. */
|
|
138
|
+
sent: Record<string, string>;
|
|
139
|
+
/** The last whole lead, which the next one is told against. */
|
|
140
|
+
last?: string;
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* The lead for the newest message, told against the last one: the whole lead the first time, a line
|
|
144
|
+
* when nothing changed, else only the lines that are new and the lines that went (the whole lead
|
|
145
|
+
* when that is not much shorter).
|
|
146
|
+
*/
|
|
147
|
+
export declare function leadDelta(last: string | undefined, next: string): string;
|
|
148
|
+
/**
|
|
149
|
+
* The chat's messages with every lead where it was first sent, and the newest message's lead told
|
|
150
|
+
* against the last. A provider's prompt cache reuses only earlier messages sent exactly as before:
|
|
151
|
+
* a lead moved off the last message changed it, and that message and every step after it were
|
|
152
|
+
* paid again on the next turn. Unchanged context costs one line, changed context only what changed.
|
|
153
|
+
*/
|
|
154
|
+
export declare function stickyLeads(messages: ModelMessage[], kept: SentLeads, lead: string): {
|
|
155
|
+
messages: ModelMessage[];
|
|
156
|
+
kept: SentLeads;
|
|
157
|
+
};
|
|
135
158
|
/** One message the person sent, with the page, refs and tier the composer added. */
|
|
136
159
|
export interface AskInput {
|
|
137
160
|
message: string;
|
package/dist/agent/agent.js
CHANGED
|
@@ -70,6 +70,68 @@ export function withLead(messages, lead) {
|
|
|
70
70
|
i -= 1;
|
|
71
71
|
return i < 0 ? messages : leadAt(messages, i, lead);
|
|
72
72
|
}
|
|
73
|
+
/** Leads kept per chat; the oldest go first (a compacted chat no longer has their messages). */
|
|
74
|
+
const LEADS_KEPT = 60;
|
|
75
|
+
const LEAD_SAME = 'Context: as in my last message.\n\n';
|
|
76
|
+
const LEAD_CHANGED = 'Context: what changed since my last message (the rest is as it was).';
|
|
77
|
+
/** A person's message's place among theirs and a hash of its words: the same message turn after turn. */
|
|
78
|
+
function leadKey(n, m) {
|
|
79
|
+
const text = typeof m.content === 'string'
|
|
80
|
+
? m.content
|
|
81
|
+
: m.content.map((p) => (p.type === 'text' ? p.text : p.type)).join('\n');
|
|
82
|
+
let h = 0x811c9dc5;
|
|
83
|
+
for (let i = 0; i < text.length; i++)
|
|
84
|
+
h = Math.imul(h ^ text.charCodeAt(i), 0x01000193);
|
|
85
|
+
return `${n}:${(h >>> 0).toString(36)}`;
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* The lead for the newest message, told against the last one: the whole lead the first time, a line
|
|
89
|
+
* when nothing changed, else only the lines that are new and the lines that went (the whole lead
|
|
90
|
+
* when that is not much shorter).
|
|
91
|
+
*/
|
|
92
|
+
export function leadDelta(last, next) {
|
|
93
|
+
if (!next || last === undefined)
|
|
94
|
+
return next;
|
|
95
|
+
if (last === next)
|
|
96
|
+
return LEAD_SAME;
|
|
97
|
+
const was = new Set(last.split('\n'));
|
|
98
|
+
const is = new Set(next.split('\n'));
|
|
99
|
+
const added = next.split('\n').filter((l) => l.trim() && !was.has(l));
|
|
100
|
+
const gone = last.split('\n').filter((l) => l.trim() && !is.has(l));
|
|
101
|
+
const delta = `${LEAD_CHANGED}\n${added.length ? `Now:\n${added.join('\n')}\n` : ''}${gone.length ? `No longer:\n${gone.join('\n')}\n` : ''}\n`;
|
|
102
|
+
return delta.length < next.length * 0.6 ? delta : next;
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* The chat's messages with every lead where it was first sent, and the newest message's lead told
|
|
106
|
+
* against the last. A provider's prompt cache reuses only earlier messages sent exactly as before:
|
|
107
|
+
* a lead moved off the last message changed it, and that message and every step after it were
|
|
108
|
+
* paid again on the next turn. Unchanged context costs one line, changed context only what changed.
|
|
109
|
+
*/
|
|
110
|
+
export function stickyLeads(messages, kept, lead) {
|
|
111
|
+
const users = messages.flatMap((m, i) => (m.role === 'user' ? [i] : []));
|
|
112
|
+
const newest = users.at(-1);
|
|
113
|
+
const sent = { ...kept.sent };
|
|
114
|
+
// Told against the last lead only when an earlier message still carries one (not after compaction).
|
|
115
|
+
let last = users.some((i, n) => i !== newest && sent[leadKey(n, messages[i])])
|
|
116
|
+
? kept.last
|
|
117
|
+
: undefined;
|
|
118
|
+
let out = messages;
|
|
119
|
+
users.forEach((i, n) => {
|
|
120
|
+
const m = messages[i];
|
|
121
|
+
const key = leadKey(n, m);
|
|
122
|
+
if (i === newest && sent[key] === undefined && lead) {
|
|
123
|
+
sent[key] = leadDelta(last, lead);
|
|
124
|
+
last = lead;
|
|
125
|
+
}
|
|
126
|
+
const l = sent[key];
|
|
127
|
+
if (l)
|
|
128
|
+
out = leadAt(out, i, l);
|
|
129
|
+
});
|
|
130
|
+
const keys = Object.keys(sent);
|
|
131
|
+
for (const k of keys.slice(0, Math.max(0, keys.length - LEADS_KEPT)))
|
|
132
|
+
delete sent[k];
|
|
133
|
+
return { messages: out, kept: { sent, ...(last !== undefined && { last }) } };
|
|
134
|
+
}
|
|
73
135
|
/** The message at `i` (one the person wrote) with `lead` put before what it says. */
|
|
74
136
|
function leadAt(messages, i, lead) {
|
|
75
137
|
const m = messages[i];
|
|
@@ -632,6 +694,7 @@ export class LumenAgent extends Think {
|
|
|
632
694
|
throw new Error('No model to compact with until the next answer.');
|
|
633
695
|
const { plan } = last;
|
|
634
696
|
let meter = new UsageMeter(plan.model, `${plan.label} · compact`, !!plan.key.plan);
|
|
697
|
+
meter.ctx = { task: 'chat', run: this.name };
|
|
635
698
|
this.#owed.push(meter);
|
|
636
699
|
const r = await generateText({
|
|
637
700
|
maxRetries: 0,
|
|
@@ -640,6 +703,7 @@ export class LumenAgent extends Think {
|
|
|
640
703
|
if (!fb)
|
|
641
704
|
return;
|
|
642
705
|
meter = new UsageMeter(fb.model, `${fb.label} · compact`, false);
|
|
706
|
+
meter.ctx = { task: 'chat', run: this.name };
|
|
643
707
|
this.#owed.push(meter);
|
|
644
708
|
}),
|
|
645
709
|
prompt,
|
|
@@ -684,14 +748,18 @@ export class LumenAgent extends Think {
|
|
|
684
748
|
...plan.tools,
|
|
685
749
|
});
|
|
686
750
|
const free = !!plan.key.plan;
|
|
751
|
+
const call = { task: 'chat', run: this.name };
|
|
687
752
|
const main = new UsageMeter(plan.model, plan.label, free);
|
|
753
|
+
main.ctx = call;
|
|
688
754
|
const meters = [main, ...this.#owed];
|
|
689
755
|
this.#owed = [];
|
|
690
756
|
const advice = plan.advisor
|
|
691
757
|
? new UsageMeter(plan.advisor.model, plan.advisor.label, free)
|
|
692
758
|
: undefined;
|
|
693
|
-
if (advice)
|
|
759
|
+
if (advice) {
|
|
760
|
+
advice.ctx = call;
|
|
694
761
|
meters.push(advice);
|
|
762
|
+
}
|
|
695
763
|
let system = typeof plan.system === 'string' ? plan.system : plan.system(opened);
|
|
696
764
|
this.#keepTitles(opened.info);
|
|
697
765
|
this.#turn = {
|
|
@@ -711,6 +779,7 @@ export class LumenAgent extends Think {
|
|
|
711
779
|
over: () => plan.left !== undefined && this.#spent() >= plan.left,
|
|
712
780
|
meter: (model, label, f = free) => {
|
|
713
781
|
const m = new UsageMeter(model, label, f);
|
|
782
|
+
m.ctx = call;
|
|
714
783
|
meters.push(m);
|
|
715
784
|
return m;
|
|
716
785
|
},
|
|
@@ -759,9 +828,11 @@ export class LumenAgent extends Think {
|
|
|
759
828
|
this.#turn.lazy = lazy;
|
|
760
829
|
}
|
|
761
830
|
const turn = this.#turn;
|
|
762
|
-
//
|
|
763
|
-
//
|
|
764
|
-
const
|
|
831
|
+
// Each lead stays on the message it went with, so earlier messages are sent as before and read
|
|
832
|
+
// from the prompt cache; the newest says only what changed since the last (`stickyLeads`).
|
|
833
|
+
const led = stickyLeads(ctx.messages, this.#get('leads') ?? { sent: {} }, `${this.#news()}${this.#screenNews()}${plan.lead ?? ''}`);
|
|
834
|
+
this.#put('leads', led.kept);
|
|
835
|
+
const cached = cachedTurn(system, led.messages);
|
|
765
836
|
return {
|
|
766
837
|
model: this.#model(plan, () => {
|
|
767
838
|
const fb = plan.fallback;
|
|
@@ -769,6 +840,7 @@ export class LumenAgent extends Think {
|
|
|
769
840
|
return;
|
|
770
841
|
// Claude answers the rest of the turn on the payer's key: its use is its own part.
|
|
771
842
|
turn.main = new UsageMeter(fb.model, fb.label, false);
|
|
843
|
+
turn.main.ctx = { task: 'chat', run: this.name };
|
|
772
844
|
turn.meters.push(turn.main);
|
|
773
845
|
}),
|
|
774
846
|
// Think types instructions as a string; the SDK it hands them to takes a system message too.
|
package/dist/agent/usage.d.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/** An AI SDK step's usage as the kit's tokens and list-price cost. */
|
|
2
2
|
import type { UsagePart } from '@columenlabs/lumen-ui-types/assistant';
|
|
3
3
|
import type { LanguageModelUsage } from 'ai';
|
|
4
|
-
import { type ModelDef, type TokenUsage } from '../assistant/models.js';
|
|
4
|
+
import { type CallContext, type ModelDef, type TokenUsage } from '../assistant/models.js';
|
|
5
5
|
/**
|
|
6
6
|
* One call's usage in the kit's terms. Input splits into plain, cache writes and cache reads where
|
|
7
7
|
* the provider says (Claude reports all three; OpenAI reports reads, its writes cost nothing extra).
|
|
@@ -18,6 +18,8 @@ export declare class UsageMeter {
|
|
|
18
18
|
readonly model: ModelDef;
|
|
19
19
|
private readonly free;
|
|
20
20
|
private p;
|
|
21
|
+
/** What the calls are for, on each call's log line (`logCall`). */
|
|
22
|
+
ctx?: CallContext;
|
|
21
23
|
constructor(model: ModelDef, label: string, free?: boolean);
|
|
22
24
|
add(u: LanguageModelUsage): {
|
|
23
25
|
in: number;
|
package/dist/agent/usage.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { priced } from '../assistant/models.js';
|
|
1
|
+
import { logCall, priced, } from '../assistant/models.js';
|
|
2
2
|
/**
|
|
3
3
|
* One call's usage in the kit's terms. Input splits into plain, cache writes and cache reads where
|
|
4
4
|
* the provider says (Claude reports all three; OpenAI reports reads, its writes cost nothing extra).
|
|
@@ -21,6 +21,8 @@ export class UsageMeter {
|
|
|
21
21
|
model;
|
|
22
22
|
free;
|
|
23
23
|
p;
|
|
24
|
+
/** What the calls are for, on each call's log line (`logCall`). */
|
|
25
|
+
ctx;
|
|
24
26
|
constructor(model, label, free = false) {
|
|
25
27
|
this.model = model;
|
|
26
28
|
this.free = free;
|
|
@@ -32,6 +34,7 @@ export class UsageMeter {
|
|
|
32
34
|
/** Tokens a provider reports apart from the step's usage (Claude's advisor calls). */
|
|
33
35
|
addTokens(u) {
|
|
34
36
|
const t = priced(this.model, u);
|
|
37
|
+
logCall(this.model, this.p.label, u, t, this.free, this.ctx);
|
|
35
38
|
this.p.calls += 1;
|
|
36
39
|
this.p.in += t.in;
|
|
37
40
|
this.p.read += t.read;
|
|
@@ -36,6 +36,20 @@ export interface CompleteImage {
|
|
|
36
36
|
mediaType: string;
|
|
37
37
|
data: string;
|
|
38
38
|
}
|
|
39
|
+
/**
|
|
40
|
+
* One earlier message of a `complete` conversation: what was asked (with its own photos) or what the
|
|
41
|
+
* model answered. Sent as real messages, in order, before the newest prompt: a provider's prompt
|
|
42
|
+
* cache reuses whole earlier messages, so a conversation that only grows at its end is read from
|
|
43
|
+
* the cache at a tenth of the price. Text added inside one message is not (OpenAI, checked
|
|
44
|
+
* 2026-11-14: 0 of 3,889 tokens read when a part was added to the same message, 9,563 of 9,592
|
|
45
|
+
* when the same words came as a new message).
|
|
46
|
+
*/
|
|
47
|
+
export interface CompleteTurn {
|
|
48
|
+
role: 'user' | 'assistant';
|
|
49
|
+
text: string;
|
|
50
|
+
/** A user message's photos, sent before its text. */
|
|
51
|
+
images?: CompleteImage[];
|
|
52
|
+
}
|
|
39
53
|
export interface Caller {
|
|
40
54
|
id: string;
|
|
41
55
|
admin: boolean;
|
|
@@ -407,6 +421,13 @@ export declare class Assistant {
|
|
|
407
421
|
prompt: string | string[];
|
|
408
422
|
/** Photos read with the prompt (a scanned page, a screenshot), sent before it and cached. */
|
|
409
423
|
images?: CompleteImage[];
|
|
424
|
+
/**
|
|
425
|
+
* The conversation so far, oldest first, alternating user and assistant and ending with the
|
|
426
|
+
* model's answer; `prompt` and `images` are the newest message. Each earlier message is read
|
|
427
|
+
* from the prompt cache on the next call, so a multi-step job (a study that asks for more
|
|
428
|
+
* pictures, then a build) re-pays only what is new.
|
|
429
|
+
*/
|
|
430
|
+
history?: CompleteTurn[];
|
|
410
431
|
/** The answer's token cap, thinking included (default 1,000, at most COMPLETE_MAX_TOKENS). */
|
|
411
432
|
maxTokens?: number;
|
|
412
433
|
/**
|
package/dist/assistant/engine.js
CHANGED
|
@@ -38,6 +38,34 @@ export const COMPLETE_MAX_TOKENS = 32_000;
|
|
|
38
38
|
/** A turn's failure the person can act on. */
|
|
39
39
|
/** Claude's prompt cache mark on a block: the prompt up to it is kept for the next call. */
|
|
40
40
|
const CACHE_MARK = { type: 'ephemeral' };
|
|
41
|
+
/**
|
|
42
|
+
* A `complete` conversation's earlier messages for Claude, the last block marked so the next call
|
|
43
|
+
* reads the whole conversation so far from the cache.
|
|
44
|
+
*/
|
|
45
|
+
function claudeHistory(history) {
|
|
46
|
+
return history.map((t, n) => {
|
|
47
|
+
const last = n === history.length - 1;
|
|
48
|
+
if (t.role === 'assistant')
|
|
49
|
+
return {
|
|
50
|
+
role: 'assistant',
|
|
51
|
+
content: [{ type: 'text', text: t.text, ...(last ? { cache_control: CACHE_MARK } : {}) }],
|
|
52
|
+
};
|
|
53
|
+
return {
|
|
54
|
+
role: 'user',
|
|
55
|
+
content: [
|
|
56
|
+
...(t.images ?? []).map((i) => ({
|
|
57
|
+
type: 'image',
|
|
58
|
+
source: {
|
|
59
|
+
type: 'base64',
|
|
60
|
+
media_type: i.mediaType,
|
|
61
|
+
data: i.data,
|
|
62
|
+
},
|
|
63
|
+
})),
|
|
64
|
+
{ type: 'text', text: t.text, ...(last ? { cache_control: CACHE_MARK } : {}) },
|
|
65
|
+
],
|
|
66
|
+
};
|
|
67
|
+
});
|
|
68
|
+
}
|
|
41
69
|
/** OpenAI's prompt cache key for one person's task or run: a short, stable hash of `of`. */
|
|
42
70
|
function cacheKey(of) {
|
|
43
71
|
let h = 0x811c9dc5;
|
|
@@ -1132,7 +1160,7 @@ export class Assistant {
|
|
|
1132
1160
|
* events like the chat loop, because a ChatGPT plan sign-in answers only streamed calls, takes no
|
|
1133
1161
|
* output cap and may send its words only as deltas.
|
|
1134
1162
|
*/
|
|
1135
|
-
async openaiOnce(clients, s, instructions, input, max, signal, images, effort, cacheKey) {
|
|
1163
|
+
async openaiOnce(clients, s, instructions, input, max, signal, images, effort, cacheKey, history = []) {
|
|
1136
1164
|
// xAI's API is OpenAI's Responses API at another address.
|
|
1137
1165
|
const xai = s.model.provider === 'xai';
|
|
1138
1166
|
const client = (xai ? clients.xai : clients.openai);
|
|
@@ -1144,8 +1172,24 @@ export class Assistant {
|
|
|
1144
1172
|
model: s.model.id,
|
|
1145
1173
|
instructions,
|
|
1146
1174
|
// A plan sign-in takes only a list of items, never bare text ("Input must be a list").
|
|
1147
|
-
//
|
|
1175
|
+
// The earlier messages as they were, then the newest: pictures first, then its text parts.
|
|
1148
1176
|
input: [
|
|
1177
|
+
...history.map((t) => t.role === 'assistant'
|
|
1178
|
+
? {
|
|
1179
|
+
role: 'assistant',
|
|
1180
|
+
content: [{ type: 'output_text', text: t.text }],
|
|
1181
|
+
}
|
|
1182
|
+
: {
|
|
1183
|
+
role: 'user',
|
|
1184
|
+
content: [
|
|
1185
|
+
...(t.images ?? []).map((i) => ({
|
|
1186
|
+
type: 'input_image',
|
|
1187
|
+
image_url: `data:${i.mediaType};base64,${i.data}`,
|
|
1188
|
+
detail: 'auto',
|
|
1189
|
+
})),
|
|
1190
|
+
{ type: 'input_text', text: t.text },
|
|
1191
|
+
],
|
|
1192
|
+
}),
|
|
1149
1193
|
{
|
|
1150
1194
|
role: 'user',
|
|
1151
1195
|
content: [
|
|
@@ -1217,6 +1261,7 @@ export class Assistant {
|
|
|
1217
1261
|
const began = Date.now();
|
|
1218
1262
|
const s = await this.session(caller, await this.mainTier(caller, o.task));
|
|
1219
1263
|
const { rules, free } = await this.rulesFor(caller, s.tier.id, o.payer);
|
|
1264
|
+
s.meter.ctx = { task: o.task, ...(o.run && { run: o.run.id }) };
|
|
1220
1265
|
const { paying, wait } = await this.state.paying(caller.id, s.model.provider, rules);
|
|
1221
1266
|
if (!paying) {
|
|
1222
1267
|
const code = wait ?? 'not_connected';
|
|
@@ -1231,7 +1276,7 @@ export class Assistant {
|
|
|
1231
1276
|
try {
|
|
1232
1277
|
if ((s.model.provider === 'openai' && clients.openai) ||
|
|
1233
1278
|
(s.model.provider === 'xai' && clients.xai)) {
|
|
1234
|
-
({ text, cut } = await this.openaiOnce(clients, s, o.system, parts, max, o.signal, o.images, o.effort, cacheKey(`${caller.id}:${o.task}:${o.run?.id ?? ''}`)));
|
|
1279
|
+
({ text, cut } = await this.openaiOnce(clients, s, o.system, parts, max, o.signal, o.images, o.effort, cacheKey(`${caller.id}:${o.task}:${o.run?.id ?? ''}`), o.history));
|
|
1235
1280
|
}
|
|
1236
1281
|
else if (clients.anthropic) {
|
|
1237
1282
|
// Streamed and read whole: a long answer (minutes at a high cap) keeps its connection alive,
|
|
@@ -1260,7 +1305,7 @@ export class Assistant {
|
|
|
1260
1305
|
model: s.model.id,
|
|
1261
1306
|
max_tokens: max,
|
|
1262
1307
|
system: [{ type: 'text', text: o.system, cache_control: CACHE_MARK }],
|
|
1263
|
-
messages: [{ role: 'user', content }],
|
|
1308
|
+
messages: [...claudeHistory(o.history ?? []), { role: 'user', content }],
|
|
1264
1309
|
...effortParams(o.effort ?? s.effort),
|
|
1265
1310
|
}, o.signal ? { signal: o.signal } : undefined)
|
|
1266
1311
|
.finalMessage();
|
|
@@ -73,6 +73,35 @@ export declare function priced(model: ModelDef, usage: TokenUsage): {
|
|
|
73
73
|
read: number;
|
|
74
74
|
usd: number;
|
|
75
75
|
};
|
|
76
|
+
/** What a model call was for, as far as the code that made it knows: its task and its run (a chat, a rebuild). */
|
|
77
|
+
export interface CallContext {
|
|
78
|
+
task?: string;
|
|
79
|
+
run?: string;
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* One model call, as every app logs it: the model, the part's label (`Tier · Model`), tokens in (cache
|
|
83
|
+
* reads and writes included), cache reads, cache writes and tokens out, the share of the input read
|
|
84
|
+
* from cache (`hit`, 0 to 1), the cost at list price and whether a plan sign-in ran it (`free`).
|
|
85
|
+
*/
|
|
86
|
+
export interface CallRecord extends CallContext {
|
|
87
|
+
event: 'ai.call';
|
|
88
|
+
model: string;
|
|
89
|
+
label: string;
|
|
90
|
+
in: number;
|
|
91
|
+
read: number;
|
|
92
|
+
written: number;
|
|
93
|
+
out: number;
|
|
94
|
+
hit: number;
|
|
95
|
+
usd: number;
|
|
96
|
+
free: boolean;
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Where each model call's record goes. By default one JSON line on the console (Workers Logs keeps
|
|
100
|
+
* it), so every app sees each call's tokens and cache reads with no wiring. `null` turns it off.
|
|
101
|
+
*/
|
|
102
|
+
export declare function setCallLog(fn: ((r: CallRecord) => void) | null): void;
|
|
103
|
+
/** Logs one priced call. Never throws: a log that fails never fails the call. */
|
|
104
|
+
export declare function logCall(model: ModelDef, label: string, usage: TokenUsage, p: ReturnType<typeof priced>, free: boolean, ctx?: CallContext): void;
|
|
76
105
|
/** The running use of one tier in one answer. */
|
|
77
106
|
export declare class Meter {
|
|
78
107
|
readonly model: ModelDef;
|
|
@@ -82,6 +111,8 @@ export declare class Meter {
|
|
|
82
111
|
read: number;
|
|
83
112
|
out: number;
|
|
84
113
|
usd: number;
|
|
114
|
+
/** What the calls are for, on each call's log line. */
|
|
115
|
+
ctx?: CallContext;
|
|
85
116
|
constructor(model: ModelDef, label: string);
|
|
86
117
|
/** `free`: the call ran on a plan sign-in, so it counts tokens and no dollars. */
|
|
87
118
|
add(usage: TokenUsage, free?: boolean): void;
|
package/dist/assistant/models.js
CHANGED
|
@@ -101,6 +101,36 @@ export function priced(model, usage) {
|
|
|
101
101
|
1e6,
|
|
102
102
|
};
|
|
103
103
|
}
|
|
104
|
+
let callLog = (r) => console.log(JSON.stringify(r));
|
|
105
|
+
/**
|
|
106
|
+
* Where each model call's record goes. By default one JSON line on the console (Workers Logs keeps
|
|
107
|
+
* it), so every app sees each call's tokens and cache reads with no wiring. `null` turns it off.
|
|
108
|
+
*/
|
|
109
|
+
export function setCallLog(fn) {
|
|
110
|
+
callLog = fn;
|
|
111
|
+
}
|
|
112
|
+
/** Logs one priced call. Never throws: a log that fails never fails the call. */
|
|
113
|
+
export function logCall(model, label, usage, p, free, ctx) {
|
|
114
|
+
try {
|
|
115
|
+
callLog?.({
|
|
116
|
+
event: 'ai.call',
|
|
117
|
+
model: model.id,
|
|
118
|
+
label,
|
|
119
|
+
...(ctx?.task && { task: ctx.task }),
|
|
120
|
+
...(ctx?.run && { run: ctx.run }),
|
|
121
|
+
in: p.in,
|
|
122
|
+
read: p.read,
|
|
123
|
+
written: usage.cache_creation_input_tokens ?? 0,
|
|
124
|
+
out: p.out,
|
|
125
|
+
hit: p.in ? Math.round((p.read / p.in) * 1000) / 1000 : 0,
|
|
126
|
+
usd: free ? 0 : Math.round(p.usd * 1e6) / 1e6,
|
|
127
|
+
free,
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
catch {
|
|
131
|
+
// A log is never worth a failed call.
|
|
132
|
+
}
|
|
133
|
+
}
|
|
104
134
|
/** The running use of one tier in one answer. */
|
|
105
135
|
export class Meter {
|
|
106
136
|
model;
|
|
@@ -110,6 +140,8 @@ export class Meter {
|
|
|
110
140
|
read = 0;
|
|
111
141
|
out = 0;
|
|
112
142
|
usd = 0;
|
|
143
|
+
/** What the calls are for, on each call's log line. */
|
|
144
|
+
ctx;
|
|
113
145
|
constructor(model, label) {
|
|
114
146
|
this.model = model;
|
|
115
147
|
this.label = label;
|
|
@@ -117,6 +149,7 @@ export class Meter {
|
|
|
117
149
|
/** `free`: the call ran on a plan sign-in, so it counts tokens and no dollars. */
|
|
118
150
|
add(usage, free = false) {
|
|
119
151
|
const p = priced(this.model, usage);
|
|
152
|
+
logCall(this.model, this.label, usage, p, free, this.ctx);
|
|
120
153
|
this.calls += 1;
|
|
121
154
|
this.in += p.in;
|
|
122
155
|
this.read += p.read;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@columenlabs/lumen-ai",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.276",
|
|
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",
|
|
@@ -57,8 +57,8 @@
|
|
|
57
57
|
"@anthropic-ai/sdk": "0.133.0",
|
|
58
58
|
"@cloudflare/computer": "0.8.0",
|
|
59
59
|
"@cloudflare/think": "0.23.0",
|
|
60
|
-
"@columenlabs/lumen-ops": "0.1.
|
|
61
|
-
"@columenlabs/lumen-ui-types": "0.1.
|
|
60
|
+
"@columenlabs/lumen-ops": "0.1.276",
|
|
61
|
+
"@columenlabs/lumen-ui-types": "0.1.276",
|
|
62
62
|
"agents": "0.32.0",
|
|
63
63
|
"ai": "7.0.162",
|
|
64
64
|
"just-bash": "3.6.0",
|