@columenlabs/lumen-ai 0.1.71 → 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.
@@ -0,0 +1,138 @@
1
+ import { keyHint } from './models.js';
2
+ import { APP_SCOPE } from './store.js';
3
+ /** USD a month when nobody set a cap. */
4
+ export const DEFAULT_CAP = 10;
5
+ /** `2026-10` in the app's time zone. */
6
+ export function monthIn(timeZone, at = new Date()) {
7
+ return new Intl.DateTimeFormat('en-CA', { timeZone, year: 'numeric', month: '2-digit' })
8
+ .format(at)
9
+ .slice(0, 7);
10
+ }
11
+ export function keyState(d, secret) {
12
+ if (!d)
13
+ return { set: false };
14
+ return {
15
+ set: true,
16
+ hint: keyHint(secret(d)),
17
+ saved_at: d.saved_at,
18
+ ...(d.test ? { test: d.test } : {}),
19
+ };
20
+ }
21
+ export class State {
22
+ docs;
23
+ o;
24
+ constructor(docs, o) {
25
+ this.docs = docs;
26
+ this.o = o;
27
+ }
28
+ /** USD a month when nobody set a cap. */
29
+ get defaultCap() {
30
+ return this.o.defaultCap;
31
+ }
32
+ month() {
33
+ return monthIn(this.o.timeZone);
34
+ }
35
+ person(id) {
36
+ return this.docs.read(id, 'settings').then((d) => d ?? {});
37
+ }
38
+ setPerson(id, change) {
39
+ return this.docs.update(id, 'settings', (d) => change(d ?? {}));
40
+ }
41
+ app() {
42
+ return this.docs.read(APP_SCOPE, 'app').then((d) => d ?? {});
43
+ }
44
+ setApp(change) {
45
+ return this.docs.update(APP_SCOPE, 'app', (d) => change(d ?? {}));
46
+ }
47
+ appKey() {
48
+ return this.docs.read(APP_SCOPE, 'key');
49
+ }
50
+ /** The app's key: the stored one, else the app's fallback (a Worker secret). */
51
+ async appApiKey() {
52
+ const d = await this.appKey();
53
+ if (d)
54
+ return { key: d.api_key, fromEnv: false };
55
+ const env = await this.o.appKey?.();
56
+ return env ? { key: env, fromEnv: true } : null;
57
+ }
58
+ ownKey(id) {
59
+ return this.docs.read(id, 'key');
60
+ }
61
+ scrapeql() {
62
+ return this.docs.read(APP_SCOPE, 'scrapeql');
63
+ }
64
+ async appCap() {
65
+ return (await this.app()).cap_usd ?? this.o.defaultCap;
66
+ }
67
+ spend(id, month = this.month()) {
68
+ return this.docs.read(id, `spend:${month}`).then((d) => d ?? { rows: [] });
69
+ }
70
+ /** Adds an answer's use to this month's spend, by label and payer. */
71
+ async addSpend(id, payer, parts) {
72
+ if (!parts.some((p) => p.calls))
73
+ return;
74
+ await this.docs.update(id, `spend:${this.month()}`, (d) => {
75
+ const rows = [...(d?.rows ?? [])];
76
+ for (const p of parts) {
77
+ if (!p.calls)
78
+ continue;
79
+ const i = rows.findIndex((r) => r.label === p.label && r.payer === payer);
80
+ const prev = i >= 0 ? rows[i] : undefined;
81
+ const row = prev
82
+ ? {
83
+ ...prev,
84
+ calls: prev.calls + p.calls,
85
+ in: prev.in + p.in,
86
+ read: prev.read + p.read,
87
+ out: prev.out + p.out,
88
+ usd: prev.usd + p.usd,
89
+ }
90
+ : { ...p, payer };
91
+ if (i >= 0)
92
+ rows[i] = row;
93
+ else
94
+ rows.push(row);
95
+ }
96
+ return { rows };
97
+ });
98
+ }
99
+ /** Who pays the person's next answer, or why nobody can. */
100
+ async paying(id) {
101
+ const [person, app, own, spend, appCap] = await Promise.all([
102
+ this.person(id),
103
+ this.appApiKey(),
104
+ this.ownKey(id),
105
+ this.spend(id),
106
+ this.appCap(),
107
+ ]);
108
+ const spent = (p) => spend.rows.filter((r) => r.payer === p).reduce((a, r) => a + r.usd, 0);
109
+ const sources = {
110
+ app: app ? { payer: 'app', apiKey: app.key, cap: appCap, left: appCap - spent('app') } : null,
111
+ own: own
112
+ ? {
113
+ payer: 'own',
114
+ apiKey: own.api_key,
115
+ cap: own.cap_usd ?? this.o.defaultCap,
116
+ left: (own.cap_usd ?? this.o.defaultCap) - spent('own'),
117
+ }
118
+ : null,
119
+ };
120
+ const first = person.pay ?? 'app';
121
+ const order = first === 'own' ? ['own', 'app'] : ['app', 'own'];
122
+ for (const p of order) {
123
+ const s = sources[p];
124
+ if (s && s.left > 0)
125
+ return { paying: s, wait: null };
126
+ }
127
+ if (!sources.app && !sources.own)
128
+ return { paying: null, wait: 'not_connected' };
129
+ // Both at their caps, or only one set and at its cap: name the one the person pays with first.
130
+ const capped = sources[first] ? first : first === 'own' ? 'app' : 'own';
131
+ return { paying: null, wait: capped === 'own' ? 'own_cap' : 'app_cap' };
132
+ }
133
+ }
134
+ export const WAIT_TEXT = {
135
+ not_connected: 'No Claude key is set: an admin adds one in Settings ▸ Model providers.',
136
+ app_cap: "You reached this month's cap on the app's key. Add your own key, or wait for next month.",
137
+ own_cap: 'Your own key reached its monthly cap: raise it in Settings ▸ Spend.',
138
+ };
@@ -0,0 +1,78 @@
1
+ /**
2
+ * Where the assistant keeps things: chats, each person's settings, keys and spend, and the app's own
3
+ * settings (its key, the cap per person, the ScrapeQL token). One table of documents over any SQLite,
4
+ * a Durable Object's own (`doSql`) or D1 (`d1Sql`); an app with another store implements
5
+ * `AssistantStore` itself.
6
+ *
7
+ * Every value and title goes through the app's `Sealer` before it is stored, so chats and keys are
8
+ * as private as the app's other sealed data. Without a sealer they are stored as written.
9
+ */
10
+ /** Seals text before it is stored and opens it after. The app's own (the hub's USERS_KEK sealing). */
11
+ export interface Sealer {
12
+ seal(plain: string): Promise<string>;
13
+ open(sealed: string): Promise<string>;
14
+ }
15
+ /** One stored document: `scope` is a person's id or `APP_SCOPE`, `key` names it. */
16
+ export interface StoredDoc {
17
+ key: string;
18
+ /** A short line kept apart from the value, so a list never reads every value (a chat's title). */
19
+ meta: string | null;
20
+ value: string;
21
+ /** Last written, epoch ms. */
22
+ at: number;
23
+ }
24
+ export interface AssistantStore {
25
+ /** Creates the table. Safe to call often. */
26
+ init(): Promise<void>;
27
+ get(scope: string, key: string): Promise<StoredDoc | null>;
28
+ put(scope: string, key: string, value: string, meta?: string | null): Promise<void>;
29
+ remove(scope: string, key: string): Promise<void>;
30
+ /** Keys starting with `prefix`, newest first, without their values. */
31
+ list(scope: string, prefix: string, limit: number): Promise<Omit<StoredDoc, 'value'>[]>;
32
+ /** Drops documents starting with `prefix` (any scope) last written before `before`. */
33
+ purge(prefix: string, before: number): Promise<number>;
34
+ }
35
+ /** The scope of what belongs to the app, not one person. */
36
+ export declare const APP_SCOPE = "_app";
37
+ type Row = Record<string, unknown>;
38
+ export interface SqlRunner {
39
+ all(sql: string, ...args: unknown[]): Promise<Row[]>;
40
+ run(sql: string, ...args: unknown[]): Promise<void>;
41
+ }
42
+ /** A Durable Object's SQLite (`ctx.storage.sql`). */
43
+ export declare function doSql(sql: {
44
+ exec(q: string, ...args: unknown[]): {
45
+ toArray(): unknown[];
46
+ };
47
+ }): SqlRunner;
48
+ /** A D1 database binding. */
49
+ export declare function d1Sql(db: {
50
+ prepare(q: string): {
51
+ bind(...a: unknown[]): {
52
+ all(): Promise<{
53
+ results: unknown[];
54
+ }>;
55
+ run(): Promise<unknown>;
56
+ };
57
+ };
58
+ }): SqlRunner;
59
+ export declare function sqlAssistantStore(sql: SqlRunner): AssistantStore;
60
+ /** JSON documents over a store, sealed when the app gives a sealer. */
61
+ export declare class Docs {
62
+ private readonly store;
63
+ private readonly sealer?;
64
+ constructor(store: AssistantStore, sealer?: Sealer | undefined);
65
+ private seal;
66
+ private open;
67
+ read<T>(scope: string, key: string): Promise<T | null>;
68
+ write<T>(scope: string, key: string, value: T, meta?: string): Promise<void>;
69
+ /** Read, change and write back; `change` returning null removes it. */
70
+ update<T>(scope: string, key: string, change: (prev: T | null) => T | null): Promise<T | null>;
71
+ remove(scope: string, key: string): Promise<void>;
72
+ list(scope: string, prefix: string, limit: number): Promise<{
73
+ key: string;
74
+ meta: string | null;
75
+ at: number;
76
+ }[]>;
77
+ }
78
+ export {};
@@ -0,0 +1,120 @@
1
+ /**
2
+ * Where the assistant keeps things: chats, each person's settings, keys and spend, and the app's own
3
+ * settings (its key, the cap per person, the ScrapeQL token). One table of documents over any SQLite,
4
+ * a Durable Object's own (`doSql`) or D1 (`d1Sql`); an app with another store implements
5
+ * `AssistantStore` itself.
6
+ *
7
+ * Every value and title goes through the app's `Sealer` before it is stored, so chats and keys are
8
+ * as private as the app's other sealed data. Without a sealer they are stored as written.
9
+ */
10
+ /** The scope of what belongs to the app, not one person. */
11
+ export const APP_SCOPE = '_app';
12
+ /** A Durable Object's SQLite (`ctx.storage.sql`). */
13
+ export function doSql(sql) {
14
+ return {
15
+ all: async (q, ...a) => sql.exec(q, ...a).toArray(),
16
+ run: async (q, ...a) => {
17
+ sql.exec(q, ...a);
18
+ },
19
+ };
20
+ }
21
+ /** A D1 database binding. */
22
+ export function d1Sql(db) {
23
+ return {
24
+ all: async (q, ...a) => (await db
25
+ .prepare(q)
26
+ .bind(...a)
27
+ .all()).results,
28
+ run: async (q, ...a) => {
29
+ await db
30
+ .prepare(q)
31
+ .bind(...a)
32
+ .run();
33
+ },
34
+ };
35
+ }
36
+ /** The table name, so an app's own tables never clash with it. */
37
+ const TABLE = 'assistant_docs';
38
+ export function sqlAssistantStore(sql) {
39
+ let ready = null;
40
+ const init = () => {
41
+ ready ??= sql.run(`CREATE TABLE IF NOT EXISTS ${TABLE} (scope TEXT NOT NULL, key TEXT NOT NULL, meta TEXT, value TEXT NOT NULL, at INTEGER NOT NULL, PRIMARY KEY (scope, key))`);
42
+ return ready;
43
+ };
44
+ return {
45
+ init,
46
+ async get(scope, key) {
47
+ await init();
48
+ const [r] = await sql.all(`SELECT key, meta, value, at FROM ${TABLE} WHERE scope = ? AND key = ?`, scope, key);
49
+ return r
50
+ ? {
51
+ key: String(r.key),
52
+ meta: r.meta == null ? null : String(r.meta),
53
+ value: String(r.value),
54
+ at: Number(r.at),
55
+ }
56
+ : null;
57
+ },
58
+ async put(scope, key, value, meta = null) {
59
+ await init();
60
+ await sql.run(`INSERT INTO ${TABLE} (scope, key, meta, value, at) VALUES (?, ?, ?, ?, ?) ON CONFLICT (scope, key) DO UPDATE SET meta = excluded.meta, value = excluded.value, at = excluded.at`, scope, key, meta, value, Date.now());
61
+ },
62
+ async remove(scope, key) {
63
+ await init();
64
+ await sql.run(`DELETE FROM ${TABLE} WHERE scope = ? AND key = ?`, scope, key);
65
+ },
66
+ async list(scope, prefix, limit) {
67
+ await init();
68
+ const rows = await sql.all(`SELECT key, meta, at FROM ${TABLE} WHERE scope = ? AND substr(key, 1, ?) = ? ORDER BY at DESC LIMIT ?`, scope, prefix.length, prefix, limit);
69
+ return rows.map((r) => ({
70
+ key: String(r.key),
71
+ meta: r.meta == null ? null : String(r.meta),
72
+ at: Number(r.at),
73
+ }));
74
+ },
75
+ async purge(prefix, before) {
76
+ await init();
77
+ const [r] = await sql.all(`SELECT count(*) AS n FROM ${TABLE} WHERE substr(key, 1, ?) = ? AND at < ?`, prefix.length, prefix, before);
78
+ await sql.run(`DELETE FROM ${TABLE} WHERE substr(key, 1, ?) = ? AND at < ?`, prefix.length, prefix, before);
79
+ return Number(r?.n ?? 0);
80
+ },
81
+ };
82
+ }
83
+ /** JSON documents over a store, sealed when the app gives a sealer. */
84
+ export class Docs {
85
+ store;
86
+ sealer;
87
+ constructor(store, sealer) {
88
+ this.store = store;
89
+ this.sealer = sealer;
90
+ }
91
+ seal = (s) => (this.sealer ? this.sealer.seal(s) : Promise.resolve(s));
92
+ open = (s) => (this.sealer ? this.sealer.open(s) : Promise.resolve(s));
93
+ async read(scope, key) {
94
+ const d = await this.store.get(scope, key);
95
+ return d ? JSON.parse(await this.open(d.value)) : null;
96
+ }
97
+ async write(scope, key, value, meta) {
98
+ await this.store.put(scope, key, await this.seal(JSON.stringify(value)), meta === undefined ? null : await this.seal(meta));
99
+ }
100
+ /** Read, change and write back; `change` returning null removes it. */
101
+ async update(scope, key, change) {
102
+ const next = change(await this.read(scope, key));
103
+ if (next === null)
104
+ await this.store.remove(scope, key);
105
+ else
106
+ await this.write(scope, key, next);
107
+ return next;
108
+ }
109
+ remove(scope, key) {
110
+ return this.store.remove(scope, key);
111
+ }
112
+ async list(scope, prefix, limit) {
113
+ const rows = await this.store.list(scope, prefix, limit);
114
+ return Promise.all(rows.map(async (r) => ({
115
+ key: r.key,
116
+ meta: r.meta == null ? null : await this.open(r.meta),
117
+ at: r.at,
118
+ })));
119
+ }
120
+ }
@@ -0,0 +1,49 @@
1
+ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
+ /** One tool as the model sees it. `read` false is a write, which only ever drafts. */
3
+ export interface ToolDef {
4
+ name: string;
5
+ title?: string;
6
+ description?: string;
7
+ /** JSON Schema of its input (an object). */
8
+ inputSchema: Record<string, unknown>;
9
+ read: boolean;
10
+ }
11
+ /** Tools opened for one answer: what they are, how to call one, and how to let them go. */
12
+ export interface OpenTools {
13
+ tools: ToolDef[];
14
+ call(name: string, args: unknown): Promise<unknown>;
15
+ close(): Promise<void>;
16
+ }
17
+ /** Opens a set of tools for one answer (or one save). */
18
+ export type ToolSource = () => Promise<OpenTools>;
19
+ /** A tool that answered with an error: its text is what the model reads. */
20
+ export declare class ToolError extends Error {
21
+ }
22
+ /** Waits for `work`, or fails after `ms` saying what timed out. */
23
+ export declare function withDeadline<T>(what: string, ms: number, work: Promise<T>): Promise<T>;
24
+ /**
25
+ * An MCP server built for the caller and connected in process. A server connects once, so pass a
26
+ * function that builds a new one. A tool is a read when its `readOnlyHint` is set.
27
+ */
28
+ export declare function mcpServerTools(build: () => McpServer | Promise<McpServer>): ToolSource;
29
+ /**
30
+ * An MCP server the app serves as a fetch handler (`(Request) => Response`), called in process
31
+ * without a network hop. Use it for a server built on another MCP SDK.
32
+ */
33
+ export declare function mcpFetchTools(handler: (req: Request) => Promise<Response>, url?: string): ToolSource;
34
+ /** A remote MCP server over Streamable HTTP, with the headers it needs (an Access service token). */
35
+ export declare function remoteMcpTools(o: {
36
+ url: string;
37
+ headers?: Record<string, string>;
38
+ fetch?: typeof fetch;
39
+ }): ToolSource;
40
+ /** A tool written as a plain function. */
41
+ export interface PlainTool extends ToolDef {
42
+ run(args: unknown): Promise<unknown>;
43
+ }
44
+ /** Plain functions as tools, no MCP server needed. */
45
+ export declare function plainTools(tools: PlainTool[]): ToolSource;
46
+ /** A tool name the model may use: letters, digits, `_` and `-`, at most 64. */
47
+ export declare function toolName(group: string, name: string): string;
48
+ /** Rows in a list result: an array, or the first array field of an object. */
49
+ export declare function rowCount(v: unknown): number | undefined;
@@ -0,0 +1,110 @@
1
+ /**
2
+ * The assistant's tools: the app's own, from any MCP server (in process, served as a fetch handler,
3
+ * or remote) or plain functions. Each tool is a read or a write; the person sets each group of
4
+ * tools Off, Read or Read + draft, and a write called while its group allows drafts only makes a
5
+ * card in the chat. It runs when the person presses Save.
6
+ */
7
+ import { Client } from '@modelcontextprotocol/sdk/client/index.js';
8
+ import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
9
+ import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js';
10
+ /** A tool that answered with an error: its text is what the model reads. */
11
+ export class ToolError extends Error {
12
+ }
13
+ /** Waits for `work`, or fails after `ms` saying what timed out. */
14
+ export function withDeadline(what, ms, work) {
15
+ let timer;
16
+ const late = new Promise((_, fail) => {
17
+ timer = setTimeout(() => fail(new Error(`${what} took over ${ms / 1000} s`)), ms);
18
+ });
19
+ return Promise.race([work, late]).finally(() => clearTimeout(timer));
20
+ }
21
+ /** An MCP client over `transport`: its tools, called as JSON. */
22
+ async function clientTools(connect, done = async () => undefined) {
23
+ const client = new Client({ name: 'lumen-assistant', version: '1.0.0' });
24
+ await withDeadline('connecting to tools', 20_000, connect(client));
25
+ const { tools } = await withDeadline('listing tools', 20_000, client.listTools());
26
+ return {
27
+ tools: tools.map((t) => ({
28
+ name: t.name,
29
+ ...(t.title ? { title: t.title } : {}),
30
+ ...(t.description ? { description: t.description } : {}),
31
+ inputSchema: t.inputSchema,
32
+ read: t.annotations?.readOnlyHint === true,
33
+ })),
34
+ async call(name, args) {
35
+ const r = await client.callTool({
36
+ name,
37
+ arguments: (args ?? {}),
38
+ });
39
+ const first = r.content?.find((c) => c.type === 'text');
40
+ const text = first?.text ?? '';
41
+ if (r.isError)
42
+ throw new ToolError(text.slice(0, 500) || `${name} failed`);
43
+ try {
44
+ return JSON.parse(text);
45
+ }
46
+ catch {
47
+ return text;
48
+ }
49
+ },
50
+ async close() {
51
+ await client.close().catch(() => undefined);
52
+ await done().catch(() => undefined);
53
+ },
54
+ };
55
+ }
56
+ /**
57
+ * An MCP server built for the caller and connected in process. A server connects once, so pass a
58
+ * function that builds a new one. A tool is a read when its `readOnlyHint` is set.
59
+ */
60
+ export function mcpServerTools(build) {
61
+ return async () => {
62
+ const server = await build();
63
+ const [a, b] = InMemoryTransport.createLinkedPair();
64
+ await server.connect(a);
65
+ return clientTools((c) => c.connect(b), () => server.close());
66
+ };
67
+ }
68
+ /**
69
+ * An MCP server the app serves as a fetch handler (`(Request) => Response`), called in process
70
+ * without a network hop. Use it for a server built on another MCP SDK.
71
+ */
72
+ export function mcpFetchTools(handler, url = 'https://assistant.local/mcp') {
73
+ return () => clientTools((c) => c.connect(new StreamableHTTPClientTransport(new URL(url), {
74
+ fetch: (input, init) => handler(new Request(input, init)),
75
+ })));
76
+ }
77
+ /** A remote MCP server over Streamable HTTP, with the headers it needs (an Access service token). */
78
+ export function remoteMcpTools(o) {
79
+ return () => clientTools((c) => c.connect(new StreamableHTTPClientTransport(new URL(o.url), {
80
+ requestInit: { headers: o.headers ?? {} },
81
+ ...(o.fetch ? { fetch: o.fetch } : {}),
82
+ })));
83
+ }
84
+ /** Plain functions as tools, no MCP server needed. */
85
+ export function plainTools(tools) {
86
+ return async () => ({
87
+ tools: tools.map(({ run: _run, ...t }) => t),
88
+ async call(name, args) {
89
+ const t = tools.find((x) => x.name === name);
90
+ if (!t)
91
+ throw new ToolError(`No tool ${name}`);
92
+ return t.run(args);
93
+ },
94
+ close: async () => undefined,
95
+ });
96
+ }
97
+ /** A tool name the model may use: letters, digits, `_` and `-`, at most 64. */
98
+ export function toolName(group, name) {
99
+ return `${group}__${name}`.replace(/[^a-zA-Z0-9_-]/g, '_').slice(0, 64);
100
+ }
101
+ /** Rows in a list result: an array, or the first array field of an object. */
102
+ export function rowCount(v) {
103
+ if (Array.isArray(v))
104
+ return v.length;
105
+ if (v && typeof v === 'object')
106
+ for (const x of Object.values(v))
107
+ if (Array.isArray(x))
108
+ return x.length;
109
+ return undefined;
110
+ }
@@ -0,0 +1,45 @@
1
+ /**
2
+ * The web: `fetch`, a plain GET from the Worker (no browser, 10 s, 2 MB), tried first for APIs, JSON
3
+ * and plain pages; and ScrapeQL's own MCP tools (`search`, `scrape`, `extract`, `ask`) over its API
4
+ * for pages that need a real browser or after a fetch fails. ScrapeQL sits behind Cloudflare Access,
5
+ * so the app reaches it with one Access service token an admin pastes in Settings.
6
+ */
7
+ import { type ToolSource } from './tools.js';
8
+ export interface WebFetchOptions {
9
+ /** Hosts never fetched (the app's own, behind sign-in), each with its subdomains. */
10
+ blockHosts?: string[];
11
+ userAgent?: string;
12
+ fetch?: typeof fetch;
13
+ }
14
+ /** One GET, never thrown: what it got, or why not. */
15
+ export declare function webFetch(raw: string, headers?: Record<string, string>, o?: WebFetchOptions): Promise<{
16
+ ok: boolean;
17
+ status: number;
18
+ url: string;
19
+ error: string;
20
+ } | {
21
+ ok: boolean;
22
+ status: number;
23
+ url: string;
24
+ content_type: string;
25
+ json: {} | null;
26
+ truncated: boolean;
27
+ error?: string | undefined;
28
+ } | {
29
+ ok: boolean;
30
+ status: number;
31
+ url: string;
32
+ content_type: string;
33
+ text: string;
34
+ truncated: boolean;
35
+ error?: string | undefined;
36
+ }>;
37
+ /** `fetch` as a tool, at most `PARALLEL` at once per answer. */
38
+ export declare function webFetchTools(o?: WebFetchOptions): ToolSource;
39
+ /** A Cloudflare Access service token: its Client ID and Client Secret. */
40
+ export interface AccessToken {
41
+ client_id: string;
42
+ client_secret: string;
43
+ }
44
+ /** ScrapeQL's MCP tools over its API, with an Access service token. Every one of them reads. */
45
+ export declare function scrapeqlTools(url: string, token: AccessToken, f?: typeof fetch): ToolSource;