@columenlabs/lumen-ai 0.1.207 → 0.1.209

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 CHANGED
@@ -94,8 +94,8 @@ const routes = assistantRoutes(assistant, {
94
94
  app.all('/api/assistant/*', (c) => routes(c.req.raw));
95
95
  ```
96
96
 
97
- - **Providers.** A tier runs any model in `models` (Claude's and OpenAI's by default, `CLAUDE_MODELS`, `OPENAI_MODELS`): Claude through Anthropic's Messages API, OpenAI through the Responses API, with the same tools, drafts, Stop and caps. A hand-off may cross providers. The advisor and prompt caching marks are Claude's only.
98
- - **Keys.** Each provider has its own keys (`?provider=anthropic|openai` on the key routes). An admin pastes the shared key everyone uses (`PUT /key`); it is tested with one free call before it is kept, then sealed, and no route returns it. A person may add their own (`PUT /my-key`), with one monthly cap on their own keys (`PUT /my-key/cap`).
97
+ - **Providers.** A tier runs any model in `models` (Claude's and OpenAI's by default, `CLAUDE_MODELS`, `OPENAI_MODELS`): Claude through Anthropic's Messages API, OpenAI through the Responses API, with the same tools, drafts, Stop and caps. xAI's Grok models (`XAI_MODELS`) are opt in: an app that lists them gets an xAI row in Model providers (an API key, `xai-…`); they run on the same Responses client at `XAI_BASE_URL` (`https://api.x.ai/v1`). xAI has no public sign-in for other apps, so Grok takes a key only. A hand-off may cross providers. The advisor and prompt caching marks are Claude's only.
98
+ - **Keys.** Each provider has its own keys (`?provider=anthropic|openai|xai` on the key routes). An admin pastes the shared key everyone uses (`PUT /key`); it is tested with one free call before it is kept, then sealed, and no route returns it. A person may add their own (`PUT /my-key`), with one monthly cap on their own keys (`PUT /my-key/cap`).
99
99
  - **ChatGPT sign-in.** In place of their own OpenAI key a person may sign in with their ChatGPT plan (`POST /my-signin`, then `POST /my-signin/finish {url}`): the sign-in opens in a new tab and ends on `http://127.0.0.1:1455/auth/callback`, a page that does not load, whose address the person pastes back. The tokens are sealed as their own key and refreshed before they run out; answers on the plan count tokens and no dollars.
100
100
  - **Who pays.** The person's pick (the shared keys by default) while it has a key for the tier's provider and money left this month under its cap (an admin sets the shared keys' cap per person, `PUT /app`), else the other; with neither the panel says why.
101
101
  - **Tools.** Each group is Off, Read, Read + draft or Read + write per person. A tool is a read when its MCP `readOnlyHint` is set. On Read + draft a write only drafts a card, and runs as the person when they press Save, after access is checked again. A group with `readOnly: true` (read tools only) is offered Off or Read alone. Read + write is offered only for a group with `writes: true`: its writes run at once, and with `undo` set each one's line in the chat has Undo, which calls `undo(caller, { tool, args, result })`; the model is told what was undone. Cards drafted one after another show as one stack with Save all. Use `mcpServerTools` (an SDK `McpServer`), `mcpFetchTools` (a server served as a fetch handler, any SDK), `remoteMcpTools` or `plainTools`.
@@ -103,7 +103,7 @@ app.all('/api/assistant/*', (c) => routes(c.req.raw));
103
103
  - **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.
104
104
  - **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.
105
105
  - **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.
106
- - **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. It throws `AssistantError` `not_connected`, `app_cap` or `own_cap` when nobody can pay, so the caller waits and tries later.
106
+ - **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.
107
107
 
108
108
  ### Commands, `@` and `#`
109
109
 
@@ -1,6 +1,6 @@
1
1
  import type { AssistantEffort } from '@columenlabs/lumen-ui-types/assistant';
2
2
  import type { JSONValue, LanguageModel, ModelMessage, SystemModelMessage } from 'ai';
3
- import type { ModelDef } from '../assistant/models.js';
3
+ import { type ModelDef } from '../assistant/models.js';
4
4
  /** The key a model runs on. `plan`: an OpenAI access token from a ChatGPT plan sign-in. */
5
5
  export interface ModelKey {
6
6
  apiKey: string;
@@ -5,6 +5,7 @@
5
5
  */
6
6
  import { createAnthropic } from '@ai-sdk/anthropic';
7
7
  import { createOpenAI } from '@ai-sdk/openai';
8
+ import { XAI_BASE_URL } from '../assistant/models.js';
8
9
  /**
9
10
  * A ChatGPT plan sign-in takes function tools only inside one namespace and no output cap, and a
10
11
  * forced call is the one tool offered with any call required (developers.openai.com/siwc, Preview
@@ -84,6 +85,12 @@ function textOf(content) {
84
85
  export function aiModel(model, key, o = {}) {
85
86
  if (model.provider === 'anthropic')
86
87
  return createAnthropic({ apiKey: key.apiKey, ...(o.fetch ? { fetch: o.fetch } : {}) })(model.id);
88
+ if (model.provider === 'xai')
89
+ return createOpenAI({
90
+ apiKey: key.apiKey,
91
+ baseURL: XAI_BASE_URL,
92
+ ...(o.fetch ? { fetch: o.fetch } : {}),
93
+ }).responses(model.id);
87
94
  const f = key.plan ? planFetch(o.fetch, o.name) : o.fetch;
88
95
  return createOpenAI({ apiKey: key.apiKey, ...(f ? { fetch: f } : {}) }).responses(model.id);
89
96
  }
@@ -56,6 +56,8 @@ export interface AllowanceNotice {
56
56
  request: AllowanceRequest;
57
57
  unit: AllowanceUnit;
58
58
  }
59
+ /** The largest answer `complete` asks for, tokens (thinking included). Streamed, so minutes are fine. */
60
+ export declare const COMPLETE_MAX_TOKENS = 32000;
59
61
  /** A change a write tool made at once, as `undo` gets it. */
60
62
  export interface AssistantWrite {
61
63
  /** The tool's own name (not the `group__tool` the model calls). */
@@ -202,6 +204,7 @@ export declare class Assistant {
202
204
  forRequest(): Assistant;
203
205
  private anthropic;
204
206
  private openai;
207
+ private xai;
205
208
  private clients;
206
209
  /** The providers the app's models run on, in the order the models list them. */
207
210
  get providers(): AssistantProvider[];
@@ -274,7 +277,7 @@ export declare class Assistant {
274
277
  private askedFor;
275
278
  /** Swaps the history for a short summary. False with nothing to compact. */
276
279
  /**
277
- * One plain answer from OpenAI, metered on `s`: no tools, no chat. Streamed and read from the raw
280
+ * One plain answer from OpenAI (or xAI, on the same API), metered on `s`: no tools, no chat. Streamed and read from the raw
278
281
  * events like the chat loop, because a ChatGPT plan sign-in answers only streamed calls, takes no
279
282
  * output cap and may send its words only as deltas.
280
283
  */
@@ -291,12 +294,14 @@ export declare class Assistant {
291
294
  prompt: string;
292
295
  /** Photos read with the prompt (a scanned page, a screenshot). */
293
296
  images?: CompleteImage[];
297
+ /** The answer's token cap, thinking included (default 1,000, at most COMPLETE_MAX_TOKENS). */
294
298
  maxTokens?: number;
295
299
  signal?: AbortSignal;
296
300
  }): Promise<{
297
301
  text: string;
298
302
  usd: number;
299
303
  model: string;
304
+ cut: boolean;
300
305
  }>;
301
306
  /** Starts a person's ChatGPT plan sign-in: the address to open in a new tab. */
302
307
  startSignin(caller: Caller, provider: AssistantProvider): Promise<{
@@ -14,7 +14,7 @@ import OpenAI from 'openai';
14
14
  import { aiModel, turnSettings } from '../agent/models.js';
15
15
  import { bearings } from './bearings.js';
16
16
  import { CHATGPT_REDIRECT, SigninError, finishChatgpt, startChatgpt, testChatgpt, } from './chatgpt.js';
17
- import { ALL_MODELS, DEFAULT_OUT, Meter, PROVIDERS, effortParams, } from './models.js';
17
+ import { ALL_MODELS, DEFAULT_OUT, Meter, PROVIDERS, XAI_BASE_URL, effortParams, } from './models.js';
18
18
  import { DEFAULT_CAP, State, keyDocName, keyState, planDocName, waitText, } from './state.js';
19
19
  import { Docs } from './store.js';
20
20
  import { toolName, withDeadline } from './tools.js';
@@ -23,6 +23,8 @@ const DOLLARS = { one: 'dollar', many: 'dollars', usd: 1 };
23
23
  const round2 = (n) => Math.round(n * 100) / 100;
24
24
  /** A request may ask for at most this many times the starter or monthly cap. */
25
25
  const REQUEST_MAX_TIMES = 100;
26
+ /** The largest answer `complete` asks for, tokens (thinking included). Streamed, so minutes are fine. */
27
+ export const COMPLETE_MAX_TOKENS = 32_000;
26
28
  /** A turn's failure the person can act on. */
27
29
  export class AssistantError extends Error {
28
30
  code;
@@ -162,6 +164,10 @@ export class Assistant {
162
164
  // A plan sign-in's calls are not retried: a usage limit is final for now.
163
165
  return new OpenAI({ apiKey, maxRetries: plan ? 0 : 1, ...(f ? { fetch: f } : {}) });
164
166
  }
167
+ xai(apiKey) {
168
+ const f = this.o.fetch;
169
+ return new OpenAI({ apiKey, baseURL: XAI_BASE_URL, maxRetries: 1, ...(f ? { fetch: f } : {}) });
170
+ }
165
171
  clients(paying) {
166
172
  const { keys, plans } = paying;
167
173
  return {
@@ -169,6 +175,7 @@ export class Assistant {
169
175
  ...(keys.openai
170
176
  ? { openai: this.openai(keys.openai, !!plans.openai), openaiPlan: !!plans.openai }
171
177
  : {}),
178
+ ...(keys.xai ? { xai: this.xai(keys.xai) } : {}),
172
179
  };
173
180
  }
174
181
  /** The providers the app's models run on, in the order the models list them. */
@@ -815,13 +822,15 @@ export class Assistant {
815
822
  }
816
823
  /** Swaps the history for a short summary. False with nothing to compact. */
817
824
  /**
818
- * One plain answer from OpenAI, metered on `s`: no tools, no chat. Streamed and read from the raw
825
+ * One plain answer from OpenAI (or xAI, on the same API), metered on `s`: no tools, no chat. Streamed and read from the raw
819
826
  * events like the chat loop, because a ChatGPT plan sign-in answers only streamed calls, takes no
820
827
  * output cap and may send its words only as deltas.
821
828
  */
822
829
  async openaiOnce(clients, s, instructions, input, max, signal, images) {
823
- const client = clients.openai;
824
- const plan = !!clients.openaiPlan;
830
+ // xAI's API is OpenAI's Responses API at another address.
831
+ const xai = s.model.provider === 'xai';
832
+ const client = (xai ? clients.xai : clients.openai);
833
+ const plan = !xai && !!clients.openaiPlan;
825
834
  let text = '';
826
835
  let done;
827
836
  try {
@@ -880,7 +889,10 @@ export class Assistant {
880
889
  cache_read_input_tokens: read,
881
890
  }, plan);
882
891
  }
883
- return text || done?.output_text || '';
892
+ return {
893
+ text: text || done?.output_text || '',
894
+ cut: done?.status === 'incomplete' && done.incomplete_details?.reason === 'max_output_tokens',
895
+ };
884
896
  }
885
897
  // ---------------------------------------------------------------- one-shot work
886
898
  /**
@@ -897,14 +909,20 @@ export class Assistant {
897
909
  throw new AssistantError(waitText(code, s.model.provider, !!this.o.allowance), code, 402);
898
910
  }
899
911
  const clients = this.clients(paying);
900
- const max = Math.min(o.maxTokens ?? 1000, 8000);
912
+ const max = Math.min(o.maxTokens ?? 1000, COMPLETE_MAX_TOKENS);
901
913
  let text = '';
914
+ // The answer stopped at `max` before it ended (its thinking counts toward the cap too).
915
+ let cut = false;
902
916
  try {
903
- if (s.model.provider === 'openai' && clients.openai) {
904
- text = await this.openaiOnce(clients, s, o.system, o.prompt, max, o.signal, o.images);
917
+ if ((s.model.provider === 'openai' && clients.openai) ||
918
+ (s.model.provider === 'xai' && clients.xai)) {
919
+ ({ text, cut } = await this.openaiOnce(clients, s, o.system, o.prompt, max, o.signal, o.images));
905
920
  }
906
921
  else if (clients.anthropic) {
907
- const msg = await clients.anthropic.messages.create({
922
+ // Streamed and read whole: a long answer (minutes at a high cap) keeps its connection alive,
923
+ // and the SDK refuses a long answer as one unstreamed request.
924
+ const msg = await clients.anthropic.messages
925
+ .stream({
908
926
  model: s.model.id,
909
927
  max_tokens: max,
910
928
  system: o.system,
@@ -927,8 +945,10 @@ export class Assistant {
927
945
  },
928
946
  ],
929
947
  ...effortParams(s.effort),
930
- }, o.signal ? { signal: o.signal } : undefined);
948
+ }, o.signal ? { signal: o.signal } : undefined)
949
+ .finalMessage();
931
950
  s.meter.add(msg.usage);
951
+ cut = msg.stop_reason === 'max_tokens';
932
952
  text = msg.content
933
953
  .filter((b) => b.type === 'text')
934
954
  .map((b) => b.text)
@@ -945,7 +965,7 @@ export class Assistant {
945
965
  finally {
946
966
  await this.state.addSpend(caller.id, paying.payer, [s.meter.part()]);
947
967
  }
948
- return { text, usd: s.meter.usd, model: s.model.id };
968
+ return { text, usd: s.meter.usd, model: s.model.id, cut };
949
969
  }
950
970
  // ---------------------------------------------------------------- keys
951
971
  /** Starts a person's ChatGPT plan sign-in: the address to open in a new tab. */
@@ -1025,6 +1045,8 @@ export class Assistant {
1025
1045
  try {
1026
1046
  if (provider === 'openai')
1027
1047
  await this.openai(apiKey).models.retrieve(model.id);
1048
+ else if (provider === 'xai')
1049
+ await this.xai(apiKey).models.retrieve(model.id);
1028
1050
  else
1029
1051
  await this.anthropic(apiKey).models.retrieve(model.id);
1030
1052
  return { ok: true, detail: `${label} took the key.`, at };
@@ -20,6 +20,14 @@ export interface ModelDef {
20
20
  export declare const CLAUDE_MODELS: ModelDef[];
21
21
  /** OpenAI models, smallest first (list prices from OpenAI's pricing page, October 2026). */
22
22
  export declare const OPENAI_MODELS: ModelDef[];
23
+ /**
24
+ * xAI's Grok models, smallest first (list prices from docs.x.ai/developers/pricing, October 2026,
25
+ * under 200k tokens of context). xAI's API is OpenAI-compatible (`XAI_BASE_URL`), so they run on the
26
+ * same Responses client as OpenAI's. Not in `ALL_MODELS`: an app opts in by listing them.
27
+ */
28
+ export declare const XAI_MODELS: ModelDef[];
29
+ /** xAI's OpenAI-compatible API. */
30
+ export declare const XAI_BASE_URL = "https://api.x.ai/v1";
23
31
  /** Every model the kit knows: what a tier may run unless the app lists its own. */
24
32
  export declare const ALL_MODELS: ModelDef[];
25
33
  /** How Settings names each provider, how its keys start and where a person makes one. */
@@ -43,6 +43,26 @@ export const OPENAI_MODELS = [
43
43
  out: o,
44
44
  read: r,
45
45
  }));
46
+ /**
47
+ * xAI's Grok models, smallest first (list prices from docs.x.ai/developers/pricing, October 2026,
48
+ * under 200k tokens of context). xAI's API is OpenAI-compatible (`XAI_BASE_URL`), so they run on the
49
+ * same Responses client as OpenAI's. Not in `ALL_MODELS`: an app opts in by listing them.
50
+ */
51
+ export const XAI_MODELS = [
52
+ ['grok-4.3', 'Grok 4.3', 1.25, 2.5, 0.2],
53
+ ['grok-4.6', 'Grok 4.6', 2, 6, 0.5],
54
+ ['grok-4.7', 'Grok 4.7', 2, 6, 0.5],
55
+ ].map(([id, label, i, o, r]) => ({
56
+ id: id,
57
+ label: label,
58
+ provider: 'xai',
59
+ group: 'xAI',
60
+ in: i,
61
+ out: o,
62
+ read: r,
63
+ }));
64
+ /** xAI's OpenAI-compatible API. */
65
+ export const XAI_BASE_URL = 'https://api.x.ai/v1';
46
66
  /** Every model the kit knows: what a tier may run unless the app lists its own. */
47
67
  export const ALL_MODELS = [...CLAUDE_MODELS, ...OPENAI_MODELS];
48
68
  /** How Settings names each provider, how its keys start and where a person makes one. */
@@ -58,6 +78,11 @@ export const PROVIDERS = {
58
78
  keysUrl: 'https://platform.openai.com/api-keys',
59
79
  signin: 'chatgpt',
60
80
  },
81
+ xai: {
82
+ label: 'xAI',
83
+ prefix: 'xai-',
84
+ keysUrl: 'https://console.x.ai/team/default/api-keys',
85
+ },
61
86
  };
62
87
  /** Output tokens per model call when a tier sets none. */
63
88
  export const DEFAULT_OUT = 16_000;
@@ -193,8 +193,8 @@ export declare class State {
193
193
  addSpend(id: string, payer: AssistantPayer, parts: UsagePart[]): Promise<void>;
194
194
  /** The payer's key of each provider, those set, and which are plan sign-ins. */
195
195
  keysOf(id: string, payer: AssistantPayer): Promise<{
196
- keys: Partial<Record<"anthropic" | "openai", string>>;
197
- plans: Partial<Record<"anthropic" | "openai", boolean>>;
196
+ keys: Partial<Record<"anthropic" | "openai" | "xai", string>>;
197
+ plans: Partial<Record<"anthropic" | "openai" | "xai", boolean>>;
198
198
  }>;
199
199
  /** Who pays the person's next answer on `provider`'s model, or why nobody can. */
200
200
  paying(id: string, provider: AssistantProvider): Promise<{
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@columenlabs/lumen-ai",
3
- "version": "0.1.207",
3
+ "version": "0.1.209",
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",
@@ -52,8 +52,8 @@
52
52
  "@ai-sdk/openai": "4.0.108",
53
53
  "@anthropic-ai/sdk": "0.133.0",
54
54
  "@cloudflare/think": "0.23.0",
55
- "@columenlabs/lumen-ops": "0.1.207",
56
- "@columenlabs/lumen-ui-types": "0.1.207",
55
+ "@columenlabs/lumen-ops": "0.1.209",
56
+ "@columenlabs/lumen-ui-types": "0.1.209",
57
57
  "agents": "0.32.0",
58
58
  "ai": "7.0.162",
59
59
  "just-bash": "3.6.0",