@columenlabs/lumen-ai 0.1.76 → 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 CHANGED
@@ -57,7 +57,7 @@ Every merge to `main` publishes the next patch of every Lumen package at once.
57
57
 
58
58
  ## A chat assistant
59
59
 
60
- `@columenlabs/lumen-ai/assistant` is the server behind a chat side panel: Claude on the app's own tiers, the app's tools from any MCP server, the web, drafts the person saves, hand off to a higher tier, advice, and a monthly spend cap per person. The panel and Settings sections are `@columenlabs/lumen-ui`'s; the shared types are `@columenlabs/lumen-ui-types/assistant`.
60
+ `@columenlabs/lumen-ai/assistant` is the server behind a chat side panel: Claude or OpenAI models on the app's own tiers, the app's tools from any MCP server, the web, drafts the person saves, hand off to a higher tier, advice, and a monthly spend cap per person. The panel and Settings sections are `@columenlabs/lumen-ui`'s; the shared types are `@columenlabs/lumen-ui-types/assistant`.
61
61
 
62
62
  ```ts
63
63
  import {
@@ -83,7 +83,8 @@ const assistant = createAssistant({
83
83
  web: { fetch: { blockHosts: ['app.example.com'] }, scrapeql: { url: 'https://scrapeql.example.com/mcp' } },
84
84
  store: sqlAssistantStore(doSql(ctx.storage.sql)), // or d1Sql(env.DB)
85
85
  sealer, // the app's own sealing: keys and chats are stored sealed
86
- appKey: async () => env.ANTHROPIC_API_KEY, // when no key is set in Settings
86
+ // A provider's key when none is set in Settings.
87
+ appKey: async (provider) => (provider === 'openai' ? env.OPENAI_API_KEY : env.ANTHROPIC_API_KEY),
87
88
  });
88
89
 
89
90
  const routes = assistantRoutes(assistant, {
@@ -93,8 +94,10 @@ const routes = assistantRoutes(assistant, {
93
94
  app.all('/api/assistant/*', (c) => routes(c.req.raw));
94
95
  ```
95
96
 
96
- - **Keys.** An admin pastes the app's Claude key (`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 its own monthly cap.
97
- - **Who pays.** The person's pick (the app's key by default) while it has money left this month under its cap (an admin sets the app key's cap per person, `PUT /app`), else the other; with neither the panel says why.
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`).
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
+ - **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.
98
101
  - **Tools.** Each group is Off, Read or Read + draft per person. A tool is a read when its MCP `readOnlyHint` is set; a write only drafts a card, and runs as the person when they press Save, after access is checked again. Use `mcpServerTools` (an SDK `McpServer`), `mcpFetchTools` (a server served as a fetch handler, any SDK), `remoteMcpTools` or `plainTools`.
99
102
  - **Web.** `fetch` is a plain GET from the Worker (10 s, 2 MB, never the app's own hosts or private addresses). ScrapeQL's own tools join over its MCP API once an admin pastes a Cloudflare Access service token (`PUT /web/scrapeql`).
100
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.
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Sign in with ChatGPT: a person runs OpenAI models on their own ChatGPT plan instead of an API key
3
+ * (OpenAI's "token sharing" flow, developers.openai.com/siwc). The sign-in opens in a new tab and
4
+ * ends on a 127.0.0.1 address that loads nothing, since the app is not on the person's machine; the
5
+ * person pastes that address back and the server finishes it. PKCE, a fresh state and nonce each
6
+ * time, no client secret. The first sign-in registers the app (`dynamic_agent_client`) and OpenAI
7
+ * issues a client id kept for later sign-ins.
8
+ */
9
+ export declare const CHATGPT_RESOURCE = "https://api.openai.com/v1";
10
+ /** Only the port may change between sign-ins; `localhost` is refused. */
11
+ export declare const CHATGPT_REDIRECT = "http://127.0.0.1:1455/auth/callback";
12
+ /** The app's registration with OpenAI for one person, kept across sign-outs. */
13
+ export interface ChatgptRegistration {
14
+ /** An opaque id of this app for this person, the same on every sign-in. */
15
+ host_id: string;
16
+ /** The client id OpenAI issued on the first sign-in. */
17
+ client_id?: string;
18
+ /** A sign-in started and not yet finished. */
19
+ pending?: {
20
+ state: string;
21
+ nonce: string;
22
+ verifier: string;
23
+ at: number;
24
+ };
25
+ }
26
+ /** A finished sign-in: kept sealed as the person's own OpenAI key. */
27
+ export interface ChatgptTokens {
28
+ client_id: string;
29
+ access_token: string;
30
+ refresh_token: string;
31
+ /** ms */
32
+ expires_at: number;
33
+ scope: string;
34
+ email?: string;
35
+ }
36
+ export declare class SigninError extends Error {
37
+ }
38
+ /** The sign-in address to open, and the registration with its new pending sign-in. */
39
+ export declare function startChatgpt(reg: ChatgptRegistration | null, appName: string): Promise<{
40
+ url: string;
41
+ reg: ChatgptRegistration;
42
+ }>;
43
+ /**
44
+ * Finishes a sign-in from the address it ended on: checks the state, trades the code for tokens and
45
+ * checks they may run models on the plan. The ID token comes straight from OpenAI's token endpoint
46
+ * over TLS, so its nonce and audience are checked and its signature is not (OIDC Core 3.1.3.7).
47
+ */
48
+ export declare function finishChatgpt(reg: ChatgptRegistration | null, pasted: string, fetchFn: typeof fetch): Promise<{
49
+ tokens: ChatgptTokens;
50
+ reg: ChatgptRegistration;
51
+ }>;
52
+ /** Whether the access token needs a refresh before the next call. */
53
+ export declare const staleChatgpt: (t: ChatgptTokens) => boolean;
54
+ /** A new access token and the rotated refresh token that replaces the old one. */
55
+ export declare function refreshChatgpt(t: ChatgptTokens, fetchFn: typeof fetch): Promise<ChatgptTokens>;
56
+ /** One free read (the account's models) that says whether the plan sign-in still works. */
57
+ export declare function testChatgpt(t: ChatgptTokens, fetchFn: typeof fetch): Promise<{
58
+ ok: boolean;
59
+ detail: string;
60
+ at: number;
61
+ }>;
@@ -0,0 +1,165 @@
1
+ /**
2
+ * Sign in with ChatGPT: a person runs OpenAI models on their own ChatGPT plan instead of an API key
3
+ * (OpenAI's "token sharing" flow, developers.openai.com/siwc). The sign-in opens in a new tab and
4
+ * ends on a 127.0.0.1 address that loads nothing, since the app is not on the person's machine; the
5
+ * person pastes that address back and the server finishes it. PKCE, a fresh state and nonce each
6
+ * time, no client secret. The first sign-in registers the app (`dynamic_agent_client`) and OpenAI
7
+ * issues a client id kept for later sign-ins.
8
+ */
9
+ const AUTH = 'https://auth.openai.com/api/accounts';
10
+ export const CHATGPT_RESOURCE = 'https://api.openai.com/v1';
11
+ /** Only the port may change between sign-ins; `localhost` is refused. */
12
+ export const CHATGPT_REDIRECT = 'http://127.0.0.1:1455/auth/callback';
13
+ const SCOPE = 'openid profile email offline_access resource.invoke chatgpt.tokens.use.direct';
14
+ /** The scope that lets the tokens run models on the plan. */
15
+ const PLAN_SCOPE = 'chatgpt.tokens.use.direct';
16
+ /** Refresh this long before the access token runs out. */
17
+ const EARLY_MS = 5 * 60_000;
18
+ /** A started sign-in is finished within this. */
19
+ const PENDING_MS = 15 * 60_000;
20
+ const b64url = (bytes) => btoa(String.fromCharCode(...new Uint8Array(bytes)))
21
+ .replace(/\+/g, '-')
22
+ .replace(/\//g, '_')
23
+ .replace(/=+$/, '');
24
+ const random = (n = 32) => b64url(crypto.getRandomValues(new Uint8Array(n)));
25
+ export class SigninError extends Error {
26
+ }
27
+ /** The sign-in address to open, and the registration with its new pending sign-in. */
28
+ export async function startChatgpt(reg, appName) {
29
+ const pending = { state: random(), nonce: random(), verifier: random(48), at: Date.now() };
30
+ const challenge = b64url(await crypto.subtle.digest('SHA-256', new TextEncoder().encode(pending.verifier)));
31
+ const next = { host_id: reg?.host_id ?? random(18), ...reg, pending };
32
+ const q = new URLSearchParams({
33
+ client_id: next.client_id ?? 'dynamic_agent_client',
34
+ ...(next.client_id ? {} : { agent_name_hint: appName }),
35
+ ext_agent_host_id: next.host_id,
36
+ response_type: 'code',
37
+ redirect_uri: CHATGPT_REDIRECT,
38
+ scope: SCOPE,
39
+ resource: CHATGPT_RESOURCE,
40
+ state: pending.state,
41
+ nonce: pending.nonce,
42
+ code_challenge_method: 'S256',
43
+ code_challenge: challenge,
44
+ });
45
+ return { url: `${AUTH}/authorize?${q}`, reg: next };
46
+ }
47
+ function claims(idToken) {
48
+ try {
49
+ const part = idToken.split('.')[1] ?? '';
50
+ const pad = part.replace(/-/g, '+').replace(/_/g, '/');
51
+ return JSON.parse(atob(pad + '='.repeat((4 - (pad.length % 4)) % 4)));
52
+ }
53
+ catch {
54
+ return {};
55
+ }
56
+ }
57
+ async function token(fetchFn, form) {
58
+ const res = await fetchFn(`${AUTH}/oauth/token`, {
59
+ method: 'POST',
60
+ headers: { 'content-type': 'application/x-www-form-urlencoded' },
61
+ body: new URLSearchParams(form),
62
+ });
63
+ const b = (await res.json().catch(() => ({})));
64
+ if (!res.ok || typeof b.access_token !== 'string' || typeof b.refresh_token !== 'string') {
65
+ const code = typeof b.error === 'string' ? b.error : `HTTP ${res.status}`;
66
+ throw new SigninError(code === 'invalid_grant'
67
+ ? 'That sign-in has expired or was used already: sign in again.'
68
+ : `OpenAI refused the sign-in (${code}).`);
69
+ }
70
+ return {
71
+ access_token: b.access_token,
72
+ refresh_token: b.refresh_token,
73
+ expires_at: Date.now() + (typeof b.expires_in === 'number' ? b.expires_in : 3600) * 1000,
74
+ scope: typeof b.scope === 'string' ? b.scope : '',
75
+ id_token: typeof b.id_token === 'string' ? b.id_token : '',
76
+ };
77
+ }
78
+ /**
79
+ * Finishes a sign-in from the address it ended on: checks the state, trades the code for tokens and
80
+ * checks they may run models on the plan. The ID token comes straight from OpenAI's token endpoint
81
+ * over TLS, so its nonce and audience are checked and its signature is not (OIDC Core 3.1.3.7).
82
+ */
83
+ export async function finishChatgpt(reg, pasted, fetchFn) {
84
+ const pending = reg?.pending;
85
+ if (!reg || !pending || Date.now() - pending.at > PENDING_MS)
86
+ throw new SigninError('Start the sign-in again: this one is too old.');
87
+ let q;
88
+ try {
89
+ q = new URL(pasted.trim()).searchParams;
90
+ }
91
+ catch {
92
+ throw new SigninError('Paste the whole address from the tab the sign-in ended on.');
93
+ }
94
+ if (q.get('state') !== pending.state)
95
+ throw new SigninError('That address is from another sign-in: start again.');
96
+ if (q.get('error') === 'access_denied')
97
+ throw new SigninError('ChatGPT said no: plan use may be off for this account.');
98
+ if (q.get('error'))
99
+ throw new SigninError(`ChatGPT stopped the sign-in (${q.get('error')}).`);
100
+ const code = q.get('code');
101
+ if (!code)
102
+ throw new SigninError('That address has no sign-in code.');
103
+ const returned = q.get('client_id');
104
+ if (reg.client_id && returned && returned !== reg.client_id)
105
+ throw new SigninError('That sign-in belongs to another registration: start again.');
106
+ const clientId = reg.client_id ?? returned;
107
+ if (!clientId)
108
+ throw new SigninError('OpenAI sent no client id: start again.');
109
+ const t = await token(fetchFn, {
110
+ grant_type: 'authorization_code',
111
+ client_id: clientId,
112
+ code,
113
+ code_verifier: pending.verifier,
114
+ redirect_uri: CHATGPT_REDIRECT,
115
+ resource: CHATGPT_RESOURCE,
116
+ });
117
+ const c = claims(t.id_token);
118
+ const aud = Array.isArray(c.aud) ? c.aud : [c.aud];
119
+ if (c.nonce !== pending.nonce || !aud.includes(clientId))
120
+ throw new SigninError('The sign-in did not match: start again.');
121
+ if (!t.scope.split(' ').includes(PLAN_SCOPE))
122
+ throw new SigninError('This ChatGPT account may not run models on its plan.');
123
+ const { pending: _, ...kept } = reg;
124
+ return {
125
+ reg: { ...kept, client_id: clientId },
126
+ tokens: {
127
+ client_id: clientId,
128
+ access_token: t.access_token,
129
+ refresh_token: t.refresh_token,
130
+ expires_at: t.expires_at,
131
+ scope: t.scope,
132
+ ...(typeof c.email === 'string' ? { email: c.email } : {}),
133
+ },
134
+ };
135
+ }
136
+ /** Whether the access token needs a refresh before the next call. */
137
+ export const staleChatgpt = (t) => t.expires_at - EARLY_MS < Date.now();
138
+ /** A new access token and the rotated refresh token that replaces the old one. */
139
+ export async function refreshChatgpt(t, fetchFn) {
140
+ const n = await token(fetchFn, {
141
+ grant_type: 'refresh_token',
142
+ client_id: t.client_id,
143
+ refresh_token: t.refresh_token,
144
+ resource: CHATGPT_RESOURCE,
145
+ });
146
+ return {
147
+ ...t,
148
+ access_token: n.access_token,
149
+ refresh_token: n.refresh_token,
150
+ expires_at: n.expires_at,
151
+ ...(n.scope ? { scope: n.scope } : {}),
152
+ };
153
+ }
154
+ /** One free read (the account's models) that says whether the plan sign-in still works. */
155
+ export async function testChatgpt(t, fetchFn) {
156
+ const at = Date.now();
157
+ const res = await fetchFn(`${CHATGPT_RESOURCE}/models`, {
158
+ headers: { authorization: `Bearer ${t.access_token}` },
159
+ }).catch(() => null);
160
+ if (!res)
161
+ return { ok: false, detail: 'OpenAI could not be reached.', at };
162
+ if (!res.ok)
163
+ return { ok: false, detail: `OpenAI refused the sign-in (HTTP ${res.status}).`, at };
164
+ return { ok: true, detail: 'ChatGPT plan sign-in works.', at };
165
+ }
@@ -1,4 +1,4 @@
1
- import type { AssistantAccess, AssistantChat, AssistantChatRow, AssistantMessage, AssistantStatus, DraftItem } from '@columenlabs/lumen-ui-types/assistant';
1
+ import type { AssistantAccess, AssistantChat, AssistantChatRow, AssistantMessage, AssistantProvider, AssistantStatus, DraftItem } from '@columenlabs/lumen-ui-types/assistant';
2
2
  import { type ModelDef, type TaskDef, type TierDef } from './models.js';
3
3
  import { State } from './state.js';
4
4
  import { type AssistantStore, type Sealer } from './store.js';
@@ -56,7 +56,7 @@ export interface AssistantOptions {
56
56
  tiers: TierDef[];
57
57
  /** Tasks a person picks a starting tier for; the chat on the smallest tier by default. */
58
58
  tasks?: TaskDef[];
59
- /** Models a tier may run; Claude's by default. */
59
+ /** Models a tier may run; every model the kit knows (Claude's and OpenAI's) by default. */
60
60
  models?: ModelDef[];
61
61
  groups?: ToolGroup[];
62
62
  web?: {
@@ -69,15 +69,15 @@ export interface AssistantOptions {
69
69
  };
70
70
  store: AssistantStore;
71
71
  sealer?: Sealer;
72
- /** The app's key when none is stored in Settings (a Worker secret). */
73
- appKey?: () => Promise<string | null | undefined>;
72
+ /** The app's key of a provider when none is stored in Settings (a Worker secret). */
73
+ appKey?: (provider: AssistantProvider) => Promise<string | null | undefined>;
74
74
  /** USD a person may spend a month when nobody set a cap; 10. */
75
75
  defaultCap?: number;
76
76
  /** Months and dates in this zone; UTC by default. */
77
77
  timeZone?: string;
78
78
  /** Chats are deleted this many days after their last message (`purge`); 30. */
79
79
  keepDays?: number;
80
- /** The fetch Anthropic and ScrapeQL calls go through. */
80
+ /** The fetch the model providers and ScrapeQL calls go through. */
81
81
  fetch?: typeof fetch;
82
82
  onStep?: (s: StepRecord) => void | Promise<void>;
83
83
  }
@@ -97,7 +97,11 @@ export declare class Assistant {
97
97
  readonly state: State;
98
98
  private readonly groups;
99
99
  constructor(o: AssistantOptions);
100
- private client;
100
+ private anthropic;
101
+ private openai;
102
+ private clients;
103
+ /** The providers the app's models run on, in the order the models list them. */
104
+ get providers(): AssistantProvider[];
101
105
  private tierById;
102
106
  private above;
103
107
  private modelById;
@@ -132,14 +136,27 @@ export declare class Assistant {
132
136
  private runTool;
133
137
  /** Model calls and tool rounds until the model answers; returns its words. */
134
138
  private loop;
139
+ /** The loop on OpenAI's Responses API: the same tools, Stop, caps and rounds as Claude's. */
140
+ private loopOpenAI;
141
+ /** The loop on Anthropic's Messages API, with the advisor and prompt caching. */
142
+ private loopClaude;
135
143
  /** Swaps the history for a short summary. False with nothing to compact. */
136
144
  private compact;
137
145
  /** How the person's cards ended since the last answer, once each. */
138
146
  private cardNews;
139
147
  /** The answer as later turns remember it: with what its tools returned, cut. */
140
148
  private remember;
141
- /** One free call (reading a model) that says whether Claude takes the key. */
142
- testKey(apiKey: string): Promise<{
149
+ /** Starts a person's ChatGPT plan sign-in: the address to open in a new tab. */
150
+ startSignin(caller: Caller, provider: AssistantProvider): Promise<{
151
+ url: string;
152
+ redirect: string;
153
+ }>;
154
+ /** Finishes it from the address it ended on; the plan then stands in for the person's own key. */
155
+ finishSignin(caller: Caller, provider: AssistantProvider, pasted: string): Promise<void>;
156
+ /** Tests a person's own key, refreshing a plan sign-in first. */
157
+ testOwn(caller: Caller, provider: AssistantProvider): Promise<import("./state.js").KeyTest | null>;
158
+ /** One free call (reading a model) that says whether the provider takes the key. */
159
+ testKey(provider: AssistantProvider, apiKey: string): Promise<{
143
160
  ok: boolean;
144
161
  detail: string;
145
162
  at: number;