@columenlabs/lumen-ai 0.1.276 → 0.1.277

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.
@@ -18,7 +18,7 @@ import { ASSISTANT_IMAGES_MAX, ASSISTANT_IMAGE_CHARS, ASSISTANT_IMAGE_TYPES, ASS
18
18
  import { createCompactFunction } from 'agents/sessions';
19
19
  import { generateText } from 'ai';
20
20
  import { CONNECTOR_GROUP, checkConnectorUrl, connectorGroup, connectorId, connectorSource, } from './connectors.js';
21
- import { aiModel, cachedTurn, planFallback, turnSettings } from './models.js';
21
+ import { aiModel, cachedTurn, planFallback, prefixCacheKey, turnSettings, } from './models.js';
22
22
  import { screenMapOf, screenNews, screenOutcomesOf, screenRules, screenTool, } from './screen.js';
23
23
  import { LAZY_AT, TOOL_SEARCH, lazyTools, usedTools } from './search.js';
24
24
  import { chunkEvents, itemsOf, shownTitle, } from './stream.js';
@@ -854,7 +854,10 @@ export class LumenAgent extends Think {
854
854
  effort: plan.effort ?? 'default',
855
855
  ...(plan.out ? { out: plan.out } : {}),
856
856
  plan: free,
857
- cacheKey: this.name,
857
+ // Named for how the prompt starts, so a new chat reads its instructions and tools from the cache.
858
+ cacheKey: prefixCacheKey(plan.model.id, system, Object.keys(tools)
859
+ .filter((n) => !lazy || lazy.active().includes(n))
860
+ .join(',')),
858
861
  }),
859
862
  };
860
863
  }
@@ -35,14 +35,21 @@ export declare function cachedTurn(instructions: string, messages: ModelMessage[
35
35
  messages: ModelMessage[];
36
36
  };
37
37
  /**
38
- * OpenAI's prompt cache key for one chat or run: calls with the same key go to the same cache, so
39
- * a long prompt's start is read from it instead of paid again. A ChatGPT plan sign-in caches
40
- * nothing without one. At most 64 characters.
38
+ * OpenAI's prompt cache key: calls with the same key go to the same cache, so a long prompt's start
39
+ * is read from it instead of paid again. A ChatGPT plan sign-in caches nothing without one. At most
40
+ * 64 characters.
41
41
  */
42
42
  export declare const promptCacheKey: (key: string) => string;
43
+ /**
44
+ * A cache key named for what a prompt starts with (its model, instructions and tools), not for
45
+ * one chat or run: a new chat or run that starts the same way reads that start from the cache
46
+ * (a key per chat read 0 of a new chat's 5.9k-token start; one key for the start read 99.7%, our
47
+ * key, 2026-11-14). The key only routes; what is read is still only the exact same start.
48
+ */
49
+ export declare function prefixCacheKey(...parts: (string | undefined)[]): string;
43
50
  /**
44
51
  * A tier's effort and output cap as a turn's settings, with the prompt cache on: Claude's
45
- * breakpoint, and OpenAI's cache key when `cacheKey` names the chat.
52
+ * breakpoint, and OpenAI's cache key (`prefixCacheKey`) when `cacheKey` is given.
46
53
  */
47
54
  export declare function turnSettings(model: ModelDef, o: {
48
55
  effort: AssistantEffort;
@@ -71,9 +71,51 @@ export function planFetch(base = fetch, name = 'Assistant') {
71
71
  status: res.status,
72
72
  body: (await res.clone().text()).slice(0, 500),
73
73
  }));
74
- return res.ok && !streamed ? whole(res, init?.signal) : res;
74
+ if (!res.ok || !res.body)
75
+ return res;
76
+ // The plan's own usage numbers, as it sent them (cache reads included), one line a call.
77
+ const [mine, theirs] = res.body.tee();
78
+ void logPlanUsage(theirs, String(body.model ?? ''), typeof body.prompt_cache_key === 'string');
79
+ const seen = new Response(mine, {
80
+ status: res.status,
81
+ statusText: res.statusText,
82
+ headers: res.headers,
83
+ });
84
+ return streamed ? seen : whole(seen, init?.signal);
75
85
  };
76
86
  }
87
+ /**
88
+ * Reads a plan's streamed answer for its final usage and logs it as sent (`assistant_plan_usage`):
89
+ * whether a plan reads its prompt from the cache shows in its own numbers. Never throws.
90
+ */
91
+ async function logPlanUsage(stream, model, keyed) {
92
+ try {
93
+ const reader = stream.getReader();
94
+ const text = new TextDecoder();
95
+ let buf = '';
96
+ let usage;
97
+ for (;;) {
98
+ const { done, value } = await reader.read();
99
+ if (done)
100
+ break;
101
+ buf += text.decode(value, { stream: true });
102
+ const lines = buf.split('\n');
103
+ buf = lines.pop() ?? '';
104
+ for (const line of lines) {
105
+ if (!line.startsWith('data:') || !line.includes('"usage"'))
106
+ continue;
107
+ const e = JSON.parse(line.slice(5));
108
+ if (e.type === 'response.completed' || e.type === 'response.incomplete')
109
+ usage = e.response?.usage;
110
+ }
111
+ }
112
+ if (usage)
113
+ console.log(JSON.stringify({ event: 'assistant_plan_usage', model, keyed, usage }));
114
+ }
115
+ catch {
116
+ // A log line is never worth a failed answer.
117
+ }
118
+ }
77
119
  /**
78
120
  * A streamed Responses answer as the one JSON response a request that was not streamed expects:
79
121
  * the final `response.completed` (or `response.incomplete`), with the items from each
@@ -178,14 +220,30 @@ export function cachedTurn(instructions, messages) {
178
220
  };
179
221
  }
180
222
  /**
181
- * OpenAI's prompt cache key for one chat or run: calls with the same key go to the same cache, so
182
- * a long prompt's start is read from it instead of paid again. A ChatGPT plan sign-in caches
183
- * nothing without one. At most 64 characters.
223
+ * OpenAI's prompt cache key: calls with the same key go to the same cache, so a long prompt's start
224
+ * is read from it instead of paid again. A ChatGPT plan sign-in caches nothing without one. At most
225
+ * 64 characters.
184
226
  */
185
227
  export const promptCacheKey = (key) => key.slice(0, 64);
228
+ /**
229
+ * A cache key named for what a prompt starts with (its model, instructions and tools), not for
230
+ * one chat or run: a new chat or run that starts the same way reads that start from the cache
231
+ * (a key per chat read 0 of a new chat's 5.9k-token start; one key for the start read 99.7%, our
232
+ * key, 2026-11-14). The key only routes; what is read is still only the exact same start.
233
+ */
234
+ export function prefixCacheKey(...parts) {
235
+ const of = parts.filter(Boolean).join('\u0000');
236
+ let h = 0x811c9dc5;
237
+ let g = 0x01000193;
238
+ for (let i = 0; i < of.length; i++) {
239
+ h = Math.imul(h ^ of.charCodeAt(i), 0x01000193);
240
+ g = Math.imul(g ^ of.charCodeAt(i), 0x85ebca6b);
241
+ }
242
+ return `lumen-${(h >>> 0).toString(36)}${(g >>> 0).toString(36)}`;
243
+ }
186
244
  /**
187
245
  * A tier's effort and output cap as a turn's settings, with the prompt cache on: Claude's
188
- * breakpoint, and OpenAI's cache key when `cacheKey` names the chat.
246
+ * breakpoint, and OpenAI's cache key (`prefixCacheKey`) when `cacheKey` is given.
189
247
  */
190
248
  export function turnSettings(model, o) {
191
249
  const out = o.plan || !o.out ? {} : { maxOutputTokens: o.out };
@@ -66,7 +66,7 @@ function claudeHistory(history) {
66
66
  };
67
67
  });
68
68
  }
69
- /** OpenAI's prompt cache key for one person's task or run: a short, stable hash of `of`. */
69
+ /** OpenAI's prompt cache key: a short, stable hash of `of` (how the prompt starts). */
70
70
  function cacheKey(of) {
71
71
  let h = 0x811c9dc5;
72
72
  let g = 0x01000193;
@@ -1276,7 +1276,10 @@ export class Assistant {
1276
1276
  try {
1277
1277
  if ((s.model.provider === 'openai' && clients.openai) ||
1278
1278
  (s.model.provider === 'xai' && clients.xai)) {
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));
1279
+ ({ text, cut } = await this.openaiOnce(clients, s, o.system, parts, max, o.signal, o.images, o.effort,
1280
+ // Named for how the prompt starts (the person, task and instructions), not the run, so
1281
+ // the next run of the same task reads that start from the cache.
1282
+ cacheKey(`${caller.id}:${o.task}:${s.model.id}:${o.system ?? ''}`), o.history));
1280
1283
  }
1281
1284
  else if (clients.anthropic) {
1282
1285
  // Streamed and read whole: a long answer (minutes at a high cap) keeps its connection alive,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@columenlabs/lumen-ai",
3
- "version": "0.1.276",
3
+ "version": "0.1.277",
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.276",
61
- "@columenlabs/lumen-ui-types": "0.1.276",
60
+ "@columenlabs/lumen-ops": "0.1.277",
61
+ "@columenlabs/lumen-ui-types": "0.1.277",
62
62
  "agents": "0.32.0",
63
63
  "ai": "7.0.162",
64
64
  "just-bash": "3.6.0",