@columenlabs/lumen-ai 0.1.72 → 0.1.73
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 +46 -1
- package/dist/assistant/engine.d.ts +157 -0
- package/dist/assistant/engine.js +1064 -0
- package/dist/assistant/index.d.ts +18 -0
- package/dist/assistant/index.js +18 -0
- package/dist/assistant/models.d.ts +72 -0
- package/dist/assistant/models.js +69 -0
- package/dist/assistant/routes.d.ts +10 -0
- package/dist/assistant/routes.js +319 -0
- package/dist/assistant/state.d.ts +109 -0
- package/dist/assistant/state.js +138 -0
- package/dist/assistant/store.d.ts +78 -0
- package/dist/assistant/store.js +120 -0
- package/dist/assistant/tools.d.ts +49 -0
- package/dist/assistant/tools.js +110 -0
- package/dist/assistant/web.d.ts +45 -0
- package/dist/assistant/web.js +172 -0
- package/package.json +10 -3
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* An assistant for any app: a chat side panel's server. Claude on the app's tiers (named by the
|
|
3
|
+
* app), the app's tools from any MCP server, the web (a plain fetch and ScrapeQL over its API),
|
|
4
|
+
* hand off to a higher tier, advice, drafts the person saves, and a monthly spend cap per person.
|
|
5
|
+
*
|
|
6
|
+
* const assistant = createAssistant({ name, system, tiers, groups, store, sealer, appKey });
|
|
7
|
+
* const routes = assistantRoutes(assistant, { base: '/api/assistant', caller });
|
|
8
|
+
*
|
|
9
|
+
* The panel and the Settings sections are `@columenlabs/lumen-ui`'s; the types they share are
|
|
10
|
+
* `@columenlabs/lumen-ui-types/assistant`.
|
|
11
|
+
*/
|
|
12
|
+
export * from './engine.js';
|
|
13
|
+
export * from './models.js';
|
|
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';
|
|
16
|
+
export * from './store.js';
|
|
17
|
+
export * from './tools.js';
|
|
18
|
+
export * from './web.js';
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* An assistant for any app: a chat side panel's server. Claude on the app's tiers (named by the
|
|
3
|
+
* app), the app's tools from any MCP server, the web (a plain fetch and ScrapeQL over its API),
|
|
4
|
+
* hand off to a higher tier, advice, drafts the person saves, and a monthly spend cap per person.
|
|
5
|
+
*
|
|
6
|
+
* const assistant = createAssistant({ name, system, tiers, groups, store, sealer, appKey });
|
|
7
|
+
* const routes = assistantRoutes(assistant, { base: '/api/assistant', caller });
|
|
8
|
+
*
|
|
9
|
+
* The panel and the Settings sections are `@columenlabs/lumen-ui`'s; the types they share are
|
|
10
|
+
* `@columenlabs/lumen-ui-types/assistant`.
|
|
11
|
+
*/
|
|
12
|
+
export * from './engine.js';
|
|
13
|
+
export * from './models.js';
|
|
14
|
+
export * from './routes.js';
|
|
15
|
+
export { DEFAULT_CAP, monthIn, State, } from './state.js';
|
|
16
|
+
export * from './store.js';
|
|
17
|
+
export * from './tools.js';
|
|
18
|
+
export * from './web.js';
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Models and tiers. The app names its tiers (smallest first) and the model each runs by default; a
|
|
3
|
+
* person may pick another model and effort per tier. Prices are the providers' list prices per
|
|
4
|
+
* million tokens, so every answer's cost is known as it runs.
|
|
5
|
+
*/
|
|
6
|
+
import type { AssistantEffort, UsagePart } from '@columenlabs/lumen-ui-types/assistant';
|
|
7
|
+
/** A model a tier can run, with its list price per million tokens (input, output, cache reads). */
|
|
8
|
+
export interface ModelDef {
|
|
9
|
+
id: string;
|
|
10
|
+
label: string;
|
|
11
|
+
/** The heading pickers list it under (its provider). */
|
|
12
|
+
group: string;
|
|
13
|
+
in: number;
|
|
14
|
+
out: number;
|
|
15
|
+
read: number;
|
|
16
|
+
}
|
|
17
|
+
/** Claude models, smallest first (an advisor is never weaker than the model it advises). */
|
|
18
|
+
export declare const CLAUDE_MODELS: ModelDef[];
|
|
19
|
+
/** A tier as the app defines it. `out` caps each model call's output tokens. */
|
|
20
|
+
export interface TierDef {
|
|
21
|
+
id: string;
|
|
22
|
+
label: string;
|
|
23
|
+
about: string;
|
|
24
|
+
model: string;
|
|
25
|
+
out?: number;
|
|
26
|
+
}
|
|
27
|
+
/** A task a person picks a starting tier for (the chat panel, a drafting job, a schedule). */
|
|
28
|
+
export interface TaskDef {
|
|
29
|
+
id: string;
|
|
30
|
+
label: string;
|
|
31
|
+
about: string;
|
|
32
|
+
tier: string;
|
|
33
|
+
}
|
|
34
|
+
/** Output tokens per model call when a tier sets none. */
|
|
35
|
+
export declare const DEFAULT_OUT = 16000;
|
|
36
|
+
/** Token use of one reply, as the provider reports it. */
|
|
37
|
+
export interface TokenUsage {
|
|
38
|
+
input_tokens: number;
|
|
39
|
+
output_tokens: number;
|
|
40
|
+
cache_creation_input_tokens?: number | null;
|
|
41
|
+
cache_read_input_tokens?: number | null;
|
|
42
|
+
}
|
|
43
|
+
/** Tokens in (cache reads and writes included) and out of one reply, and its cost at list price. */
|
|
44
|
+
export declare function priced(model: ModelDef, usage: TokenUsage): {
|
|
45
|
+
in: number;
|
|
46
|
+
out: number;
|
|
47
|
+
read: number;
|
|
48
|
+
usd: number;
|
|
49
|
+
};
|
|
50
|
+
/** The running use of one tier in one answer. */
|
|
51
|
+
export declare class Meter {
|
|
52
|
+
readonly model: ModelDef;
|
|
53
|
+
readonly label: string;
|
|
54
|
+
calls: number;
|
|
55
|
+
in: number;
|
|
56
|
+
read: number;
|
|
57
|
+
out: number;
|
|
58
|
+
usd: number;
|
|
59
|
+
constructor(model: ModelDef, label: string);
|
|
60
|
+
add(usage: TokenUsage): void;
|
|
61
|
+
part(): UsagePart;
|
|
62
|
+
}
|
|
63
|
+
/** The request's effort setting: nothing at the model's own default. */
|
|
64
|
+
export declare function effortParams(effort: AssistantEffort): {
|
|
65
|
+
output_config?: undefined;
|
|
66
|
+
} | {
|
|
67
|
+
output_config: {
|
|
68
|
+
effort: "high" | "low" | "max" | "medium";
|
|
69
|
+
};
|
|
70
|
+
};
|
|
71
|
+
/** `sk-ant-api03-…7Qx2`: never more of a key than its start and last four. */
|
|
72
|
+
export declare const keyHint: (key: string) => string;
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/** Claude models, smallest first (an advisor is never weaker than the model it advises). */
|
|
2
|
+
export const CLAUDE_MODELS = [
|
|
3
|
+
{
|
|
4
|
+
id: 'claude-haiku-5-5',
|
|
5
|
+
label: 'Claude Haiku 5.5',
|
|
6
|
+
group: 'Claude',
|
|
7
|
+
in: 0.1,
|
|
8
|
+
out: 0.5,
|
|
9
|
+
read: 0.01,
|
|
10
|
+
},
|
|
11
|
+
{
|
|
12
|
+
id: 'claude-sonnet-5-5',
|
|
13
|
+
label: 'Claude Sonnet 5.5',
|
|
14
|
+
group: 'Claude',
|
|
15
|
+
in: 2,
|
|
16
|
+
out: 10,
|
|
17
|
+
read: 0.2,
|
|
18
|
+
},
|
|
19
|
+
{ id: 'claude-opus-5-5', label: 'Claude Opus 5.5', group: 'Claude', in: 4, out: 20, read: 0.2 },
|
|
20
|
+
];
|
|
21
|
+
/** Output tokens per model call when a tier sets none. */
|
|
22
|
+
export const DEFAULT_OUT = 16_000;
|
|
23
|
+
/** Tokens in (cache reads and writes included) and out of one reply, and its cost at list price. */
|
|
24
|
+
export function priced(model, usage) {
|
|
25
|
+
const written = usage.cache_creation_input_tokens ?? 0;
|
|
26
|
+
const read = usage.cache_read_input_tokens ?? 0;
|
|
27
|
+
return {
|
|
28
|
+
in: usage.input_tokens + written + read,
|
|
29
|
+
out: usage.output_tokens,
|
|
30
|
+
read,
|
|
31
|
+
usd: (usage.input_tokens * model.in +
|
|
32
|
+
written * model.in * 1.25 +
|
|
33
|
+
read * model.read +
|
|
34
|
+
usage.output_tokens * model.out) /
|
|
35
|
+
1e6,
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
/** The running use of one tier in one answer. */
|
|
39
|
+
export class Meter {
|
|
40
|
+
model;
|
|
41
|
+
label;
|
|
42
|
+
calls = 0;
|
|
43
|
+
in = 0;
|
|
44
|
+
read = 0;
|
|
45
|
+
out = 0;
|
|
46
|
+
usd = 0;
|
|
47
|
+
constructor(model, label) {
|
|
48
|
+
this.model = model;
|
|
49
|
+
this.label = label;
|
|
50
|
+
}
|
|
51
|
+
add(usage) {
|
|
52
|
+
const p = priced(this.model, usage);
|
|
53
|
+
this.calls += 1;
|
|
54
|
+
this.in += p.in;
|
|
55
|
+
this.read += p.read;
|
|
56
|
+
this.out += p.out;
|
|
57
|
+
this.usd += p.usd;
|
|
58
|
+
}
|
|
59
|
+
part() {
|
|
60
|
+
const { label, calls, read, out, usd } = this;
|
|
61
|
+
return { label, calls, in: this.in, read, out, usd };
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
/** The request's effort setting: nothing at the model's own default. */
|
|
65
|
+
export function effortParams(effort) {
|
|
66
|
+
return effort === 'default' ? {} : { output_config: { effort } };
|
|
67
|
+
}
|
|
68
|
+
/** `sk-ant-api03-…7Qx2`: never more of a key than its start and last four. */
|
|
69
|
+
export const keyHint = (key) => `${key.slice(0, 7)}…${key.slice(-4)}`;
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { type Assistant, type Caller } from './engine.js';
|
|
2
|
+
type Awaitable<T> = T | Promise<T>;
|
|
3
|
+
export interface AssistantRoutesOptions {
|
|
4
|
+
/** Where the routes are mounted (`/api/assistant`). */
|
|
5
|
+
base: string;
|
|
6
|
+
/** The verified caller, or null (403). The app decides who is an admin. */
|
|
7
|
+
caller: (req: Request) => Awaitable<Caller | null>;
|
|
8
|
+
}
|
|
9
|
+
export declare function assistantRoutes(a: Assistant, o: AssistantRoutesOptions): (req: Request) => Promise<Response>;
|
|
10
|
+
export {};
|
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The HTTP routes the assistant's panel and Settings call, as one fetch handler on Web
|
|
3
|
+
* `Request`/`Response`, so it mounts in any router:
|
|
4
|
+
*
|
|
5
|
+
* app.all('/api/assistant/*', (c) => assistant(c.req.raw)); // Hono
|
|
6
|
+
*
|
|
7
|
+
* GET /status what the panel and Settings show
|
|
8
|
+
* PUT /settings the person's tiers, tasks, hand off, advice, tool access, web, payer
|
|
9
|
+
* PUT /app {cap_usd} admin: what each person may spend a month on the app's key
|
|
10
|
+
* PUT /key {api_key} admin: the app's Claude key, tested before it is kept
|
|
11
|
+
* DELETE /key, POST /key/test admin
|
|
12
|
+
* PUT /my-key {api_key, cap_usd?}, PUT /my-key/cap {cap_usd}, DELETE /my-key, POST /my-key/test
|
|
13
|
+
* PUT /web/scrapeql {client_id, client_secret}, DELETE /web/scrapeql, POST /web/test admin
|
|
14
|
+
* POST /chat one answer as server-sent events; closing the request stops it
|
|
15
|
+
* GET /chats, GET|DELETE /chats/:id
|
|
16
|
+
* POST /chats/:id/drafts/:draft {action: save | dismiss}
|
|
17
|
+
*
|
|
18
|
+
* Writes must be same-origin JSON: a signed-in browser session is a cookie, so a foreign page's
|
|
19
|
+
* form or fetch is refused before it reaches a rule. Keys and tokens are write-only: no route
|
|
20
|
+
* returns one, only a hint (`sk-ant-…abcd`).
|
|
21
|
+
*/
|
|
22
|
+
import { ASSISTANT_ACCESS, ASSISTANT_EFFORTS, ASSISTANT_PAYERS, HAND_OFF_POLICIES, } from '@columenlabs/lumen-ui-types/assistant';
|
|
23
|
+
import { AssistantError } from './engine.js';
|
|
24
|
+
import { APP_SCOPE } from './store.js';
|
|
25
|
+
const MUTATING = new Set(['POST', 'PUT', 'PATCH', 'DELETE']);
|
|
26
|
+
const MAX_MESSAGE = 20_000;
|
|
27
|
+
const MAX_CAP = 10_000;
|
|
28
|
+
const json = (body, status = 200) => new Response(JSON.stringify(body), {
|
|
29
|
+
status,
|
|
30
|
+
headers: { 'content-type': 'application/json', 'cache-control': 'no-store' },
|
|
31
|
+
});
|
|
32
|
+
const fail = (error, status = 400, code) => json({ error, ...(code ? { code } : {}) }, status);
|
|
33
|
+
async function body(req) {
|
|
34
|
+
try {
|
|
35
|
+
const b = await req.json();
|
|
36
|
+
return b && typeof b === 'object' && !Array.isArray(b) ? b : {};
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
return {};
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
const str = (v, max = 500) => typeof v === 'string' && v.trim() && v.length <= max ? v.trim() : null;
|
|
43
|
+
const cap = (v) => typeof v === 'number' && Number.isFinite(v) && v >= 0 && v <= MAX_CAP
|
|
44
|
+
? Math.round(v * 100) / 100
|
|
45
|
+
: null;
|
|
46
|
+
const oneOf = (list, v) => list.find((x) => x === v);
|
|
47
|
+
/** Checks a settings change against the app's tiers, tasks, models and tool groups. */
|
|
48
|
+
function settingsChange(a, groups, b) {
|
|
49
|
+
const p = b;
|
|
50
|
+
const tierIds = new Set(a.tiers.map((t) => t.id));
|
|
51
|
+
const modelIds = new Set(a.models.map((m) => m.id));
|
|
52
|
+
if (p.tier !== undefined && !tierIds.has(p.tier))
|
|
53
|
+
return 'No such tier.';
|
|
54
|
+
if (p.tiers !== undefined) {
|
|
55
|
+
if (!p.tiers || typeof p.tiers !== 'object')
|
|
56
|
+
return 'tiers is {tier: {model, effort}}.';
|
|
57
|
+
for (const [id, t] of Object.entries(p.tiers)) {
|
|
58
|
+
if (!tierIds.has(id))
|
|
59
|
+
return `No tier ${id}.`;
|
|
60
|
+
if (t.model !== undefined && !modelIds.has(t.model))
|
|
61
|
+
return `No model ${t.model}.`;
|
|
62
|
+
if (t.effort !== undefined && !oneOf(ASSISTANT_EFFORTS, t.effort))
|
|
63
|
+
return `No effort ${t.effort}.`;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
if (p.tasks !== undefined) {
|
|
67
|
+
if (!p.tasks || typeof p.tasks !== 'object')
|
|
68
|
+
return 'tasks is {task: tier}.';
|
|
69
|
+
for (const [id, tier] of Object.entries(p.tasks)) {
|
|
70
|
+
if (!a.tasks.some((t) => t.id === id))
|
|
71
|
+
return `No task ${id}.`;
|
|
72
|
+
if (!tierIds.has(tier))
|
|
73
|
+
return `No tier ${tier}.`;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
if (p.hand_off !== undefined && !oneOf(HAND_OFF_POLICIES, p.hand_off))
|
|
77
|
+
return 'No such hand off.';
|
|
78
|
+
if (p.advice !== undefined && p.advice !== 'off' && !tierIds.has(p.advice))
|
|
79
|
+
return 'Advice is off or a tier.';
|
|
80
|
+
if (p.access !== undefined) {
|
|
81
|
+
if (!p.access || typeof p.access !== 'object')
|
|
82
|
+
return 'access is {group: off | read | draft}.';
|
|
83
|
+
for (const [g, v] of Object.entries(p.access)) {
|
|
84
|
+
if (!groups.includes(g))
|
|
85
|
+
return `No tool group ${g}.`;
|
|
86
|
+
if (!oneOf(ASSISTANT_ACCESS, v))
|
|
87
|
+
return `No access ${v}.`;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
if (p.web !== undefined && (typeof p.web !== 'object' || typeof p.web?.fetch !== 'boolean'))
|
|
91
|
+
return 'web is {fetch: true | false}.';
|
|
92
|
+
if (p.pay !== undefined && !oneOf(ASSISTANT_PAYERS, p.pay))
|
|
93
|
+
return 'pay is app or own.';
|
|
94
|
+
return (d) => {
|
|
95
|
+
const tiers = { ...(d.tiers ?? {}) };
|
|
96
|
+
for (const [id, t] of Object.entries(p.tiers ?? {})) {
|
|
97
|
+
const tier = a.tiers.find((x) => x.id === id);
|
|
98
|
+
const prev = tiers[id] ?? { model: tier?.model ?? '', effort: 'default' };
|
|
99
|
+
tiers[id] = { model: t.model ?? prev.model, effort: t.effort ?? prev.effort };
|
|
100
|
+
}
|
|
101
|
+
return {
|
|
102
|
+
...d,
|
|
103
|
+
...(p.tier !== undefined ? { tier: p.tier } : {}),
|
|
104
|
+
...(p.tiers !== undefined ? { tiers } : {}),
|
|
105
|
+
...(p.tasks !== undefined ? { tasks: { ...(d.tasks ?? {}), ...p.tasks } } : {}),
|
|
106
|
+
...(p.hand_off !== undefined ? { hand_off: p.hand_off } : {}),
|
|
107
|
+
...(p.advice !== undefined ? { advice: p.advice } : {}),
|
|
108
|
+
...(p.access !== undefined ? { access: { ...(d.access ?? {}), ...p.access } } : {}),
|
|
109
|
+
...(p.web !== undefined ? { web: p.web.fetch } : {}),
|
|
110
|
+
...(p.pay !== undefined ? { pay: p.pay } : {}),
|
|
111
|
+
};
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
export function assistantRoutes(a, o) {
|
|
115
|
+
const base = o.base.replace(/\/+$/, '');
|
|
116
|
+
const docs = a.state.docs;
|
|
117
|
+
return async (req) => {
|
|
118
|
+
const url = new URL(req.url);
|
|
119
|
+
if (!url.pathname.startsWith(base))
|
|
120
|
+
return fail('Not found.', 404);
|
|
121
|
+
const parts = url.pathname
|
|
122
|
+
.slice(base.length)
|
|
123
|
+
.split('/')
|
|
124
|
+
.filter(Boolean)
|
|
125
|
+
.map(decodeURIComponent);
|
|
126
|
+
const m = req.method;
|
|
127
|
+
const route = `${m} /${parts.join('/')}`;
|
|
128
|
+
if (MUTATING.has(m)) {
|
|
129
|
+
const origin = req.headers.get('origin');
|
|
130
|
+
if (origin && origin !== url.origin)
|
|
131
|
+
return fail('Not from this site.', 403, 'forbidden_origin');
|
|
132
|
+
if (!(req.headers.get('content-type') ?? '').toLowerCase().startsWith('application/json'))
|
|
133
|
+
return fail('Use Content-Type: application/json.', 415);
|
|
134
|
+
}
|
|
135
|
+
const caller = await o.caller(req);
|
|
136
|
+
if (!caller)
|
|
137
|
+
return fail('Sign in first.', 403, 'forbidden');
|
|
138
|
+
const admin = () => (caller.admin ? null : fail('Admins only.', 403, 'forbidden'));
|
|
139
|
+
const status = async () => json(await a.status(caller));
|
|
140
|
+
try {
|
|
141
|
+
switch (route) {
|
|
142
|
+
case 'GET /status':
|
|
143
|
+
return status();
|
|
144
|
+
case 'PUT /settings': {
|
|
145
|
+
const groups = (await a.status(caller)).groups.map((g) => g.id);
|
|
146
|
+
const change = settingsChange(a, groups, await body(req));
|
|
147
|
+
if (typeof change === 'string')
|
|
148
|
+
return fail(change);
|
|
149
|
+
await a.state.setPerson(caller.id, change);
|
|
150
|
+
return status();
|
|
151
|
+
}
|
|
152
|
+
case 'PUT /app': {
|
|
153
|
+
const no = admin();
|
|
154
|
+
if (no)
|
|
155
|
+
return no;
|
|
156
|
+
const c = cap((await body(req)).cap_usd);
|
|
157
|
+
if (c === null)
|
|
158
|
+
return fail(`cap_usd is a number of dollars, 0 to ${MAX_CAP}.`);
|
|
159
|
+
await a.state.setApp((d) => ({ ...d, cap_usd: c }));
|
|
160
|
+
return status();
|
|
161
|
+
}
|
|
162
|
+
case 'PUT /key':
|
|
163
|
+
case 'PUT /my-key': {
|
|
164
|
+
const mine = route === 'PUT /my-key';
|
|
165
|
+
if (!mine) {
|
|
166
|
+
const no = admin();
|
|
167
|
+
if (no)
|
|
168
|
+
return no;
|
|
169
|
+
}
|
|
170
|
+
const b = await body(req);
|
|
171
|
+
const key = str(b.api_key, 400);
|
|
172
|
+
if (!key)
|
|
173
|
+
return fail('Paste the key (sk-ant-…).');
|
|
174
|
+
const c = b.cap_usd === undefined ? undefined : cap(b.cap_usd);
|
|
175
|
+
if (c === null)
|
|
176
|
+
return fail(`cap_usd is a number of dollars, 0 to ${MAX_CAP}.`);
|
|
177
|
+
const test = await a.testKey(key);
|
|
178
|
+
if (!test.ok)
|
|
179
|
+
return fail(test.detail, 400, 'key_refused');
|
|
180
|
+
const scope = mine ? caller.id : APP_SCOPE;
|
|
181
|
+
await docs.update(scope, 'key', (d) => ({
|
|
182
|
+
api_key: key,
|
|
183
|
+
saved_at: Date.now(),
|
|
184
|
+
test,
|
|
185
|
+
...(mine ? { cap_usd: c ?? d?.cap_usd ?? a.state.defaultCap } : {}),
|
|
186
|
+
}));
|
|
187
|
+
return status();
|
|
188
|
+
}
|
|
189
|
+
case 'PUT /my-key/cap': {
|
|
190
|
+
const c = cap((await body(req)).cap_usd);
|
|
191
|
+
if (c === null)
|
|
192
|
+
return fail(`cap_usd is a number of dollars, 0 to ${MAX_CAP}.`);
|
|
193
|
+
const d = await a.state.ownKey(caller.id);
|
|
194
|
+
if (!d)
|
|
195
|
+
return fail('Add your key first.', 404);
|
|
196
|
+
await docs.write(caller.id, 'key', { ...d, cap_usd: c });
|
|
197
|
+
return status();
|
|
198
|
+
}
|
|
199
|
+
case 'DELETE /key':
|
|
200
|
+
case 'DELETE /my-key': {
|
|
201
|
+
const mine = route === 'DELETE /my-key';
|
|
202
|
+
if (!mine) {
|
|
203
|
+
const no = admin();
|
|
204
|
+
if (no)
|
|
205
|
+
return no;
|
|
206
|
+
}
|
|
207
|
+
await docs.remove(mine ? caller.id : APP_SCOPE, 'key');
|
|
208
|
+
return status();
|
|
209
|
+
}
|
|
210
|
+
case 'POST /key/test':
|
|
211
|
+
case 'POST /my-key/test': {
|
|
212
|
+
const mine = route === 'POST /my-key/test';
|
|
213
|
+
if (!mine) {
|
|
214
|
+
const no = admin();
|
|
215
|
+
if (no)
|
|
216
|
+
return no;
|
|
217
|
+
}
|
|
218
|
+
const scope = mine ? caller.id : APP_SCOPE;
|
|
219
|
+
const d = await docs.read(scope, 'key');
|
|
220
|
+
const key = d?.api_key ?? (mine ? null : (await a.state.appApiKey())?.key);
|
|
221
|
+
if (!key)
|
|
222
|
+
return fail('No key is set.', 404);
|
|
223
|
+
const test = await a.testKey(key);
|
|
224
|
+
if (d)
|
|
225
|
+
await docs.write(scope, 'key', { ...d, test });
|
|
226
|
+
return json(test);
|
|
227
|
+
}
|
|
228
|
+
case 'PUT /web/scrapeql': {
|
|
229
|
+
const no = admin();
|
|
230
|
+
if (no)
|
|
231
|
+
return no;
|
|
232
|
+
const b = await body(req);
|
|
233
|
+
const client_id = str(b.client_id, 200);
|
|
234
|
+
const client_secret = str(b.client_secret, 400);
|
|
235
|
+
if (!client_id || !client_secret)
|
|
236
|
+
return fail('Paste the Client ID and Client Secret.');
|
|
237
|
+
const test = await a.testScrapeql({ client_id, client_secret });
|
|
238
|
+
if (!test.ok)
|
|
239
|
+
return fail(test.detail, 400, 'token_refused');
|
|
240
|
+
await docs.write(APP_SCOPE, 'scrapeql', {
|
|
241
|
+
client_id,
|
|
242
|
+
client_secret,
|
|
243
|
+
saved_at: Date.now(),
|
|
244
|
+
test,
|
|
245
|
+
});
|
|
246
|
+
return status();
|
|
247
|
+
}
|
|
248
|
+
case 'DELETE /web/scrapeql': {
|
|
249
|
+
const no = admin();
|
|
250
|
+
if (no)
|
|
251
|
+
return no;
|
|
252
|
+
await docs.remove(APP_SCOPE, 'scrapeql');
|
|
253
|
+
return status();
|
|
254
|
+
}
|
|
255
|
+
case 'POST /web/test': {
|
|
256
|
+
const no = admin();
|
|
257
|
+
if (no)
|
|
258
|
+
return no;
|
|
259
|
+
const d = await a.state.scrapeql();
|
|
260
|
+
if (!d)
|
|
261
|
+
return fail('No ScrapeQL token is set.', 404);
|
|
262
|
+
const test = await a.testScrapeql(d);
|
|
263
|
+
await docs.write(APP_SCOPE, 'scrapeql', { ...d, test });
|
|
264
|
+
return json(test);
|
|
265
|
+
}
|
|
266
|
+
case 'POST /chat': {
|
|
267
|
+
const b = (await body(req));
|
|
268
|
+
const message = typeof b.message === 'string' ? b.message.trim() : '';
|
|
269
|
+
if (!message || message.length > MAX_MESSAGE)
|
|
270
|
+
return fail(`Write a message of up to ${MAX_MESSAGE} characters.`);
|
|
271
|
+
const msg = {
|
|
272
|
+
message,
|
|
273
|
+
...(typeof b.chat_id === 'string' ? { chat_id: b.chat_id } : {}),
|
|
274
|
+
...(b.context && typeof b.context === 'object' ? { context: b.context } : {}),
|
|
275
|
+
...(Array.isArray(b.refs) ? { refs: b.refs.slice(0, 20) } : {}),
|
|
276
|
+
...(typeof b.tier === 'string' ? { tier: b.tier } : {}),
|
|
277
|
+
};
|
|
278
|
+
return new Response(a.turnStream(caller, msg, req.signal), {
|
|
279
|
+
headers: {
|
|
280
|
+
'content-type': 'text/event-stream',
|
|
281
|
+
'cache-control': 'no-store',
|
|
282
|
+
'x-accel-buffering': 'no',
|
|
283
|
+
},
|
|
284
|
+
});
|
|
285
|
+
}
|
|
286
|
+
case 'GET /chats':
|
|
287
|
+
return json(await a.listChats(caller));
|
|
288
|
+
}
|
|
289
|
+
if (parts[0] === 'chats' && parts[1]) {
|
|
290
|
+
const id = parts[1];
|
|
291
|
+
if (parts.length === 2 && m === 'GET') {
|
|
292
|
+
const chat = await a.readChat(caller, id);
|
|
293
|
+
return chat ? json(chat) : fail('That chat is gone.', 404, 'not_found');
|
|
294
|
+
}
|
|
295
|
+
if (parts.length === 2 && m === 'DELETE') {
|
|
296
|
+
await a.removeChat(caller, id);
|
|
297
|
+
return json({ ok: true });
|
|
298
|
+
}
|
|
299
|
+
if (parts.length === 4 && parts[2] === 'drafts' && m === 'POST') {
|
|
300
|
+
const action = (await body(req)).action;
|
|
301
|
+
if (action !== 'save' && action !== 'dismiss')
|
|
302
|
+
return fail('action is save or dismiss.');
|
|
303
|
+
return json(await a.settleDraft(caller, id, parts[3], action));
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
return fail('Not found.', 404);
|
|
307
|
+
}
|
|
308
|
+
catch (err) {
|
|
309
|
+
if (err instanceof AssistantError)
|
|
310
|
+
return fail(err.message, err.status, err.code);
|
|
311
|
+
console.error(JSON.stringify({
|
|
312
|
+
event: 'assistant_route_error',
|
|
313
|
+
route,
|
|
314
|
+
error: String(err?.stack ?? err).slice(0, 2000),
|
|
315
|
+
}));
|
|
316
|
+
return fail('Something went wrong.', 500);
|
|
317
|
+
}
|
|
318
|
+
};
|
|
319
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The assistant's settings, keys and spend, as stored documents.
|
|
3
|
+
*
|
|
4
|
+
* app (the app's scope) the cap per person on the app's key
|
|
5
|
+
* key (the app's scope) the app's Anthropic key, tested before it is kept, sealed
|
|
6
|
+
* scrapeql (the app's scope) ScrapeQL's Access service token, tested before it is kept, sealed
|
|
7
|
+
* settings (a person's scope) their tiers, start tiers, hand off, advice, tool access, web, payer
|
|
8
|
+
* key (a person's scope) their own Anthropic key and its monthly cap, sealed
|
|
9
|
+
* spend:YYYY-MM what each tier cost them this month, by payer
|
|
10
|
+
*
|
|
11
|
+
* Who pays: the person's pick (the app's key by default) while it has money left this month, else
|
|
12
|
+
* the other; with neither the assistant waits and says why.
|
|
13
|
+
*/
|
|
14
|
+
import type { AssistantAccess, AssistantEffort, AssistantPayer, AssistantWait, HandOffPolicy, UsagePart } from '@columenlabs/lumen-ui-types/assistant';
|
|
15
|
+
import { type Docs } from './store.js';
|
|
16
|
+
export interface KeyTest {
|
|
17
|
+
at: number;
|
|
18
|
+
ok: boolean;
|
|
19
|
+
detail: string;
|
|
20
|
+
}
|
|
21
|
+
export interface KeyDoc {
|
|
22
|
+
api_key: string;
|
|
23
|
+
saved_at: number;
|
|
24
|
+
test: KeyTest | null;
|
|
25
|
+
/** USD a month: the person's own key only. */
|
|
26
|
+
cap_usd?: number;
|
|
27
|
+
}
|
|
28
|
+
export interface TokenDoc {
|
|
29
|
+
client_id: string;
|
|
30
|
+
client_secret: string;
|
|
31
|
+
saved_at: number;
|
|
32
|
+
test: KeyTest | null;
|
|
33
|
+
}
|
|
34
|
+
export interface AppDoc {
|
|
35
|
+
/** USD each person may spend a month on the app's key. */
|
|
36
|
+
cap_usd?: number;
|
|
37
|
+
}
|
|
38
|
+
export interface PersonDoc {
|
|
39
|
+
tier?: string;
|
|
40
|
+
tiers?: Record<string, {
|
|
41
|
+
model: string;
|
|
42
|
+
effort: AssistantEffort;
|
|
43
|
+
}>;
|
|
44
|
+
tasks?: Record<string, string>;
|
|
45
|
+
hand_off?: HandOffPolicy;
|
|
46
|
+
advice?: string;
|
|
47
|
+
access?: Record<string, AssistantAccess>;
|
|
48
|
+
web?: boolean;
|
|
49
|
+
pay?: AssistantPayer;
|
|
50
|
+
}
|
|
51
|
+
export type SpendRow = UsagePart & {
|
|
52
|
+
payer: AssistantPayer;
|
|
53
|
+
};
|
|
54
|
+
export interface SpendDoc {
|
|
55
|
+
rows: SpendRow[];
|
|
56
|
+
}
|
|
57
|
+
/** USD a month when nobody set a cap. */
|
|
58
|
+
export declare const DEFAULT_CAP = 10;
|
|
59
|
+
/** `2026-10` in the app's time zone. */
|
|
60
|
+
export declare function monthIn(timeZone: string, at?: Date): string;
|
|
61
|
+
export declare function keyState(d: KeyDoc | TokenDoc | null, secret: (d: KeyDoc | TokenDoc) => string): {
|
|
62
|
+
set: false;
|
|
63
|
+
} | {
|
|
64
|
+
set: true;
|
|
65
|
+
hint: string;
|
|
66
|
+
saved_at: number;
|
|
67
|
+
test?: KeyTest | undefined;
|
|
68
|
+
};
|
|
69
|
+
export interface Paying {
|
|
70
|
+
payer: AssistantPayer;
|
|
71
|
+
apiKey: string;
|
|
72
|
+
/** USD left this month under the payer's cap. */
|
|
73
|
+
left: number;
|
|
74
|
+
cap: number;
|
|
75
|
+
}
|
|
76
|
+
export declare class State {
|
|
77
|
+
readonly docs: Docs;
|
|
78
|
+
private readonly o;
|
|
79
|
+
constructor(docs: Docs, o: {
|
|
80
|
+
timeZone: string;
|
|
81
|
+
defaultCap: number;
|
|
82
|
+
appKey?: () => Promise<string | null | undefined>;
|
|
83
|
+
});
|
|
84
|
+
/** USD a month when nobody set a cap. */
|
|
85
|
+
get defaultCap(): number;
|
|
86
|
+
month(): string;
|
|
87
|
+
person(id: string): Promise<PersonDoc>;
|
|
88
|
+
setPerson(id: string, change: (d: PersonDoc) => PersonDoc): Promise<PersonDoc | null>;
|
|
89
|
+
app(): Promise<AppDoc>;
|
|
90
|
+
setApp(change: (d: AppDoc) => AppDoc): Promise<AppDoc | null>;
|
|
91
|
+
appKey(): Promise<KeyDoc | null>;
|
|
92
|
+
/** The app's key: the stored one, else the app's fallback (a Worker secret). */
|
|
93
|
+
appApiKey(): Promise<{
|
|
94
|
+
key: string;
|
|
95
|
+
fromEnv: boolean;
|
|
96
|
+
} | null>;
|
|
97
|
+
ownKey(id: string): Promise<KeyDoc | null>;
|
|
98
|
+
scrapeql(): Promise<TokenDoc | null>;
|
|
99
|
+
appCap(): Promise<number>;
|
|
100
|
+
spend(id: string, month?: string): Promise<SpendDoc>;
|
|
101
|
+
/** Adds an answer's use to this month's spend, by label and payer. */
|
|
102
|
+
addSpend(id: string, payer: AssistantPayer, parts: UsagePart[]): Promise<void>;
|
|
103
|
+
/** Who pays the person's next answer, or why nobody can. */
|
|
104
|
+
paying(id: string): Promise<{
|
|
105
|
+
paying: Paying | null;
|
|
106
|
+
wait: AssistantWait | null;
|
|
107
|
+
}>;
|
|
108
|
+
}
|
|
109
|
+
export declare const WAIT_TEXT: Record<AssistantWait, string>;
|