webpeel 0.21.2 → 0.21.3

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,63 @@
1
+ /**
2
+ * WebPeel Deep Research
3
+ *
4
+ * Multi-step search agent that turns one question into a comprehensive,
5
+ * cited research report. Orchestrates:
6
+ *
7
+ * 1. Query Decomposition — LLM breaks question into 3-5 sub-queries
8
+ * 2. Parallel Multi-Search — All sub-queries across DDG + Stealth
9
+ * 3. Source Fetching — peel() on top results per sub-query
10
+ * 4. Relevance Scoring — BM25 against the original question
11
+ * 5. Gap Detection — LLM: "Is there enough info? What's missing?"
12
+ * 6. Re-Search Loop — Generate new queries if gaps found (max N rounds)
13
+ * 7. Synthesis — LLM generates final cited report
14
+ */
15
+ import { type LLMConfig } from './llm-provider.js';
16
+ export type ProgressEventType = 'decomposing' | 'searching' | 'fetching' | 'scoring' | 'gap_check' | 'researching' | 'synthesizing' | 'done' | 'error';
17
+ export interface DeepResearchProgressEvent {
18
+ type: ProgressEventType;
19
+ message: string;
20
+ round?: number;
21
+ data?: Record<string, unknown>;
22
+ }
23
+ export interface Citation {
24
+ index: number;
25
+ title: string;
26
+ url: string;
27
+ snippet: string;
28
+ relevanceScore: number;
29
+ }
30
+ export interface DeepResearchRequest {
31
+ question: string;
32
+ llm?: LLMConfig;
33
+ /** Maximum research rounds (default: 3) */
34
+ maxRounds?: number;
35
+ /** Maximum sources to consider (default: 20) */
36
+ maxSources?: number;
37
+ stream?: boolean;
38
+ /** Called with incremental report text when stream=true */
39
+ onChunk?: (text: string) => void;
40
+ /** Called with progress updates */
41
+ onProgress?: (event: DeepResearchProgressEvent) => void;
42
+ signal?: AbortSignal;
43
+ }
44
+ export interface DeepResearchResponse {
45
+ report: string;
46
+ citations: Citation[];
47
+ sourcesUsed: number;
48
+ roundsCompleted: number;
49
+ totalSearchQueries: number;
50
+ llmProvider: string;
51
+ tokensUsed: {
52
+ input: number;
53
+ output: number;
54
+ };
55
+ elapsed: number;
56
+ }
57
+ /**
58
+ * Run a deep research session.
59
+ *
60
+ * Orchestrates query decomposition → multi-search → source fetching →
61
+ * relevance scoring → gap detection → re-search loop → synthesis.
62
+ */
63
+ export declare function runDeepResearch(req: DeepResearchRequest): Promise<DeepResearchResponse>;
@@ -0,0 +1,487 @@
1
+ /**
2
+ * WebPeel Deep Research
3
+ *
4
+ * Multi-step search agent that turns one question into a comprehensive,
5
+ * cited research report. Orchestrates:
6
+ *
7
+ * 1. Query Decomposition — LLM breaks question into 3-5 sub-queries
8
+ * 2. Parallel Multi-Search — All sub-queries across DDG + Stealth
9
+ * 3. Source Fetching — peel() on top results per sub-query
10
+ * 4. Relevance Scoring — BM25 against the original question
11
+ * 5. Gap Detection — LLM: "Is there enough info? What's missing?"
12
+ * 6. Re-Search Loop — Generate new queries if gaps found (max N rounds)
13
+ * 7. Synthesis — LLM generates final cited report
14
+ */
15
+ import { peel } from '../index.js';
16
+ import { getSearchProvider } from './search-provider.js';
17
+ import { scoreBM25, splitIntoBlocks } from './bm25-filter.js';
18
+ import { callLLM, getDefaultLLMConfig, isFreeTierLimitError, } from './llm-provider.js';
19
+ import { sanitizeForLLM } from './prompt-guard.js';
20
+ // ---------------------------------------------------------------------------
21
+ // Helpers
22
+ // ---------------------------------------------------------------------------
23
+ function clamp(n, min, max) {
24
+ return Math.min(Math.max(n, min), max);
25
+ }
26
+ function truncate(text, maxChars) {
27
+ if (text.length <= maxChars)
28
+ return text;
29
+ return text.slice(0, maxChars) + '\n\n[Truncated]';
30
+ }
31
+ function normalizeUrl(url) {
32
+ try {
33
+ const u = new URL(url);
34
+ const host = u.hostname.toLowerCase().replace(/^www\./, '');
35
+ const path = (u.pathname || '/').replace(/\/+$/, '');
36
+ return `${host}${path}`;
37
+ }
38
+ catch {
39
+ return url.toLowerCase().replace(/^https?:\/\/(www\.)?/, '').replace(/\/$/, '');
40
+ }
41
+ }
42
+ // ---------------------------------------------------------------------------
43
+ // LLM call with merged token tracking
44
+ // ---------------------------------------------------------------------------
45
+ async function callWithTracking(config, messages, tokenAccumulator, opts = {}) {
46
+ const result = await callLLM(config, {
47
+ messages,
48
+ stream: opts.stream,
49
+ onChunk: opts.onChunk,
50
+ signal: opts.signal,
51
+ maxTokens: opts.maxTokens ?? 4096,
52
+ temperature: 0.3,
53
+ });
54
+ tokenAccumulator.input += result.usage.input;
55
+ tokenAccumulator.output += result.usage.output;
56
+ return result.text;
57
+ }
58
+ // ---------------------------------------------------------------------------
59
+ // Step 1: Query Decomposition
60
+ // ---------------------------------------------------------------------------
61
+ async function decomposeQuery(question, config, tokens, signal) {
62
+ const messages = [
63
+ {
64
+ role: 'system',
65
+ content: [
66
+ 'You are a research assistant that helps decompose complex questions.',
67
+ 'Given a research question, generate 3-5 specific search sub-queries that together would provide comprehensive coverage of the topic.',
68
+ 'Each sub-query should target a different aspect of the question.',
69
+ 'Output ONLY the sub-queries, one per line, no numbering, no explanation.',
70
+ ].join('\n'),
71
+ },
72
+ {
73
+ role: 'user',
74
+ content: `Research question: "${question}"\n\nGenerate 3-5 focused search sub-queries:`,
75
+ },
76
+ ];
77
+ const text = await callWithTracking(config, messages, tokens, {
78
+ signal,
79
+ maxTokens: 500,
80
+ });
81
+ // Parse lines, filter empties and numbering
82
+ const queries = text
83
+ .split('\n')
84
+ .map((line) => line
85
+ .trim()
86
+ .replace(/^\d+[.)]\s*/, '')
87
+ .replace(/^[-*•]\s*/, '')
88
+ .trim())
89
+ .filter((line) => line.length > 5 && line.length < 300);
90
+ // Ensure the original question is always in the mix
91
+ const all = [question, ...queries];
92
+ // Deduplicate (case-insensitive)
93
+ const seen = new Set();
94
+ const deduped = [];
95
+ for (const q of all) {
96
+ const key = q.toLowerCase();
97
+ if (!seen.has(key)) {
98
+ seen.add(key);
99
+ deduped.push(q);
100
+ }
101
+ }
102
+ // Return at most 6 queries (1 original + up to 5 generated)
103
+ return deduped.slice(0, 6);
104
+ }
105
+ // ---------------------------------------------------------------------------
106
+ // Step 2: Parallel Multi-Search
107
+ // ---------------------------------------------------------------------------
108
+ async function searchAll(queries, signal) {
109
+ const resultsMap = new Map();
110
+ const searchWithDDG = async (query) => {
111
+ try {
112
+ const provider = getSearchProvider('duckduckgo');
113
+ return await provider.searchWeb(query, {
114
+ count: 5,
115
+ signal,
116
+ });
117
+ }
118
+ catch {
119
+ return [];
120
+ }
121
+ };
122
+ // Run all queries in parallel
123
+ const settled = await Promise.allSettled(queries.map(async (query) => {
124
+ const results = await searchWithDDG(query);
125
+ return { query, results };
126
+ }));
127
+ for (const outcome of settled) {
128
+ if (outcome.status === 'fulfilled') {
129
+ resultsMap.set(outcome.value.query, outcome.value.results);
130
+ }
131
+ }
132
+ return resultsMap;
133
+ }
134
+ // ---------------------------------------------------------------------------
135
+ // Step 3: Source Fetching
136
+ // ---------------------------------------------------------------------------
137
+ async function fetchSources(searchResults, maxSources, signal) {
138
+ // Collect top 3 per sub-query, deduplicated by URL
139
+ const seen = new Set();
140
+ const toFetch = [];
141
+ for (const [subQuery, results] of searchResults) {
142
+ let count = 0;
143
+ for (const result of results) {
144
+ if (count >= 3)
145
+ break;
146
+ const key = normalizeUrl(result.url);
147
+ if (seen.has(key))
148
+ continue;
149
+ seen.add(key);
150
+ toFetch.push({ result, subQuery });
151
+ count++;
152
+ if (toFetch.length >= maxSources)
153
+ break;
154
+ }
155
+ if (toFetch.length >= maxSources)
156
+ break;
157
+ }
158
+ // Fetch in parallel batches of 5
159
+ const BATCH_SIZE = 5;
160
+ const fetched = [];
161
+ for (let i = 0; i < toFetch.length; i += BATCH_SIZE) {
162
+ if (signal?.aborted)
163
+ break;
164
+ const batch = toFetch.slice(i, i + BATCH_SIZE);
165
+ const settled = await Promise.allSettled(batch.map(async ({ result, subQuery }) => {
166
+ try {
167
+ const pr = await peel(result.url, {
168
+ format: 'markdown',
169
+ maxTokens: 2000,
170
+ timeout: 25_000,
171
+ render: false,
172
+ });
173
+ return { result, content: pr.content || '', subQuery };
174
+ }
175
+ catch (err) {
176
+ return {
177
+ result,
178
+ content: result.snippet || '',
179
+ subQuery,
180
+ };
181
+ }
182
+ }));
183
+ for (const outcome of settled) {
184
+ if (outcome.status === 'fulfilled') {
185
+ fetched.push({
186
+ ...outcome.value,
187
+ relevanceScore: 0, // filled in step 4
188
+ });
189
+ }
190
+ }
191
+ }
192
+ return fetched;
193
+ }
194
+ // ---------------------------------------------------------------------------
195
+ // Step 4: Relevance Scoring
196
+ // ---------------------------------------------------------------------------
197
+ function scoreSources(sources, question) {
198
+ const queryTerms = question
199
+ .toLowerCase()
200
+ .replace(/[^\w\s]/g, ' ')
201
+ .split(/\s+/)
202
+ .filter((t) => t.length > 2);
203
+ return sources.map((source) => {
204
+ const content = source.content;
205
+ if (!content || queryTerms.length === 0) {
206
+ return { ...source, relevanceScore: 0 };
207
+ }
208
+ const blocks = splitIntoBlocks(content);
209
+ if (blocks.length === 0) {
210
+ return { ...source, relevanceScore: 0 };
211
+ }
212
+ const scores = scoreBM25(blocks, queryTerms);
213
+ // Weighted average by block length
214
+ const blockLens = blocks.map((b) => b.raw.length);
215
+ const totalLen = blockLens.reduce((s, l) => s + l, 0) || 1;
216
+ let weightedSum = 0;
217
+ for (let i = 0; i < scores.length; i++) {
218
+ weightedSum += scores[i] * (blockLens[i] / totalLen);
219
+ }
220
+ // Normalize to 0-1 using sigmoid
221
+ const perTerm = weightedSum / (queryTerms.length || 1);
222
+ const normalized = Math.max(0, Math.min(1, 2 / (1 + Math.exp(-perTerm * 8)) - 1));
223
+ return { ...source, relevanceScore: normalized };
224
+ });
225
+ }
226
+ async function detectGaps(question, sources, config, tokens, signal) {
227
+ // Build summary of what we have
228
+ const topSources = sources
229
+ .sort((a, b) => b.relevanceScore - a.relevanceScore)
230
+ .slice(0, 8);
231
+ const contextSummary = topSources
232
+ .map((s, i) => {
233
+ const snippet = truncate(s.content || s.result.snippet || '', 800);
234
+ return `[${i + 1}] ${s.result.title}\nURL: ${s.result.url}\n${snippet}`;
235
+ })
236
+ .join('\n\n---\n\n');
237
+ const messages = [
238
+ {
239
+ role: 'system',
240
+ content: [
241
+ 'You are a research quality assessor. Given a question and the sources collected so far,',
242
+ 'determine if there is sufficient information to write a comprehensive answer.',
243
+ '',
244
+ 'Respond in this EXACT JSON format (no markdown, no code blocks):',
245
+ '{',
246
+ ' "hasEnoughInfo": boolean,',
247
+ ' "gaps": ["gap1", "gap2"],',
248
+ ' "additionalQueries": ["query1", "query2"]',
249
+ '}',
250
+ '',
251
+ '"gaps" should be 0-3 specific aspects not covered by the sources.',
252
+ '"additionalQueries" should be 0-3 new search queries to fill those gaps.',
253
+ 'If hasEnoughInfo is true, set gaps and additionalQueries to empty arrays.',
254
+ ].join('\n'),
255
+ },
256
+ {
257
+ role: 'user',
258
+ content: `Question: "${question}"\n\nSources collected:\n\n${contextSummary}\n\nAnalyze coverage and gaps:`,
259
+ },
260
+ ];
261
+ let text;
262
+ try {
263
+ text = await callWithTracking(config, messages, tokens, {
264
+ signal,
265
+ maxTokens: 600,
266
+ });
267
+ }
268
+ catch (err) {
269
+ if (isFreeTierLimitError(err))
270
+ throw err;
271
+ // On LLM failure, assume we have enough info
272
+ return { hasEnoughInfo: true, gaps: [], additionalQueries: [] };
273
+ }
274
+ // Parse JSON response
275
+ try {
276
+ // Strip markdown code fences if present
277
+ const cleaned = text
278
+ .replace(/```json\s*/gi, '')
279
+ .replace(/```\s*/g, '')
280
+ .trim();
281
+ const json = JSON.parse(cleaned);
282
+ return {
283
+ hasEnoughInfo: Boolean(json.hasEnoughInfo),
284
+ gaps: Array.isArray(json.gaps) ? json.gaps.slice(0, 3) : [],
285
+ additionalQueries: Array.isArray(json.additionalQueries)
286
+ ? json.additionalQueries.slice(0, 3)
287
+ : [],
288
+ };
289
+ }
290
+ catch {
291
+ // Couldn't parse JSON — assume enough info
292
+ return { hasEnoughInfo: true, gaps: [], additionalQueries: [] };
293
+ }
294
+ }
295
+ // ---------------------------------------------------------------------------
296
+ // Step 7: Synthesis
297
+ // ---------------------------------------------------------------------------
298
+ async function synthesizeReport(question, sources, config, tokens, opts) {
299
+ // Sort by relevance, take best sources (max 15 for context)
300
+ const topSources = sources
301
+ .sort((a, b) => b.relevanceScore - a.relevanceScore)
302
+ .slice(0, 15);
303
+ // Build context
304
+ const contextParts = [];
305
+ const citations = [];
306
+ topSources.forEach((source, i) => {
307
+ const idx = i + 1;
308
+ const sanitized = sanitizeForLLM(truncate(source.content || source.result.snippet || '', 3000));
309
+ contextParts.push(`SOURCE [${idx}]\nTitle: ${source.result.title}\nURL: ${source.result.url}\n\n${sanitized.content}`);
310
+ citations.push({
311
+ index: idx,
312
+ title: source.result.title,
313
+ url: source.result.url,
314
+ snippet: source.result.snippet || '',
315
+ relevanceScore: source.relevanceScore,
316
+ });
317
+ });
318
+ const context = contextParts.join('\n\n---\n\n');
319
+ const messages = [
320
+ {
321
+ role: 'system',
322
+ content: [
323
+ 'You are a research analyst that writes comprehensive, well-cited reports.',
324
+ 'Use ONLY the provided sources to answer the question.',
325
+ 'Cite sources using bracketed numbers like [1], [2], [3].',
326
+ 'Structure your report with:',
327
+ ' - A brief executive summary',
328
+ ' - Key findings (with citations)',
329
+ ' - Detailed analysis',
330
+ ' - Conclusion',
331
+ 'Do not fabricate URLs or citations. Do not include information not found in the sources.',
332
+ ].join('\n'),
333
+ },
334
+ {
335
+ role: 'user',
336
+ content: `Research question: "${question}"\n\nSources:\n\n${context}\n\nWrite a comprehensive research report with citations:`,
337
+ },
338
+ ];
339
+ const report = await callWithTracking(config, messages, tokens, {
340
+ stream: opts.stream,
341
+ onChunk: opts.onChunk,
342
+ signal: opts.signal,
343
+ maxTokens: 4096,
344
+ });
345
+ return { report, citations };
346
+ }
347
+ // ---------------------------------------------------------------------------
348
+ // Main: runDeepResearch
349
+ // ---------------------------------------------------------------------------
350
+ /**
351
+ * Run a deep research session.
352
+ *
353
+ * Orchestrates query decomposition → multi-search → source fetching →
354
+ * relevance scoring → gap detection → re-search loop → synthesis.
355
+ */
356
+ export async function runDeepResearch(req) {
357
+ const startTime = Date.now();
358
+ const question = (req.question || '').trim();
359
+ if (!question)
360
+ throw new Error('Missing or invalid "question"');
361
+ if (question.length > 5000)
362
+ throw new Error('Question too long (max 5000 characters)');
363
+ const maxRounds = clamp(req.maxRounds ?? 3, 1, 5);
364
+ const maxSources = clamp(req.maxSources ?? 20, 5, 30);
365
+ const config = req.llm ?? getDefaultLLMConfig();
366
+ const tokens = { input: 0, output: 0 };
367
+ let totalSearchQueries = 0;
368
+ let roundsCompleted = 0;
369
+ const progress = (event) => {
370
+ req.onProgress?.(event);
371
+ };
372
+ // ── Round tracking ────────────────────────────────────────────────────────
373
+ // All fetched sources across all rounds, deduplicated by URL
374
+ const allSources = [];
375
+ const seenUrls = new Set();
376
+ let usedQueries = new Set();
377
+ // ── Round 0..maxRounds ────────────────────────────────────────────────────
378
+ let currentQueries = [];
379
+ for (let round = 0; round < maxRounds; round++) {
380
+ if (req.signal?.aborted)
381
+ break;
382
+ if (round === 0) {
383
+ // Step 1: Query Decomposition
384
+ progress({ type: 'decomposing', message: 'Decomposing question into sub-queries…', round });
385
+ try {
386
+ currentQueries = await decomposeQuery(question, config, tokens, req.signal);
387
+ }
388
+ catch (err) {
389
+ if (isFreeTierLimitError(err))
390
+ throw err;
391
+ // Fallback: just use the original question
392
+ currentQueries = [question];
393
+ }
394
+ }
395
+ // Filter out already-used queries
396
+ const newQueries = currentQueries.filter((q) => !usedQueries.has(q.toLowerCase()));
397
+ if (newQueries.length === 0)
398
+ break;
399
+ for (const q of newQueries) {
400
+ usedQueries.add(q.toLowerCase());
401
+ }
402
+ totalSearchQueries += newQueries.length;
403
+ // Step 2: Multi-Search
404
+ progress({
405
+ type: 'searching',
406
+ message: `Searching ${newQueries.length} queries (round ${round + 1})…`,
407
+ round,
408
+ data: { queries: newQueries },
409
+ });
410
+ const searchResults = await searchAll(newQueries, req.signal);
411
+ // Step 3: Source Fetching
412
+ const newResultCount = [...searchResults.values()].reduce((s, r) => s + r.length, 0);
413
+ progress({
414
+ type: 'fetching',
415
+ message: `Fetching content from up to ${Math.min(newResultCount, maxSources)} sources…`,
416
+ round,
417
+ });
418
+ const roundSources = await fetchSources(searchResults, maxSources, req.signal);
419
+ // Deduplicate against already-fetched sources
420
+ const newSources = roundSources.filter((s) => {
421
+ const key = normalizeUrl(s.result.url);
422
+ if (seenUrls.has(key))
423
+ return false;
424
+ seenUrls.add(key);
425
+ return true;
426
+ });
427
+ // Step 4: Relevance Scoring
428
+ progress({ type: 'scoring', message: 'Scoring source relevance…', round });
429
+ const scored = scoreSources(newSources, question);
430
+ allSources.push(...scored);
431
+ roundsCompleted = round + 1;
432
+ // Don't do gap detection after the last round
433
+ if (round >= maxRounds - 1)
434
+ break;
435
+ // Step 5: Gap Detection
436
+ progress({
437
+ type: 'gap_check',
438
+ message: 'Checking research coverage for gaps…',
439
+ round,
440
+ });
441
+ let gapResult;
442
+ try {
443
+ gapResult = await detectGaps(question, allSources, config, tokens, req.signal);
444
+ }
445
+ catch (err) {
446
+ if (isFreeTierLimitError(err))
447
+ throw err;
448
+ break;
449
+ }
450
+ if (gapResult.hasEnoughInfo || gapResult.additionalQueries.length === 0) {
451
+ break;
452
+ }
453
+ // Step 6: Re-Search Loop
454
+ progress({
455
+ type: 'researching',
456
+ message: `Found ${gapResult.additionalQueries.length} gaps — searching more…`,
457
+ round,
458
+ data: { additionalQueries: gapResult.additionalQueries },
459
+ });
460
+ currentQueries = gapResult.additionalQueries;
461
+ }
462
+ // Step 7: Synthesis
463
+ progress({ type: 'synthesizing', message: 'Synthesizing research report…' });
464
+ // Sort all sources by relevance for synthesis
465
+ const sortedSources = allSources.sort((a, b) => b.relevanceScore - a.relevanceScore);
466
+ const { report, citations } = await synthesizeReport(question, sortedSources, config, tokens, {
467
+ stream: req.stream,
468
+ onChunk: req.onChunk,
469
+ signal: req.signal,
470
+ });
471
+ const elapsed = Date.now() - startTime;
472
+ progress({
473
+ type: 'done',
474
+ message: `Research complete in ${(elapsed / 1000).toFixed(1)}s`,
475
+ data: { sourcesUsed: citations.length, roundsCompleted, totalSearchQueries },
476
+ });
477
+ return {
478
+ report,
479
+ citations,
480
+ sourcesUsed: citations.length,
481
+ roundsCompleted,
482
+ totalSearchQueries,
483
+ llmProvider: config.provider,
484
+ tokensUsed: tokens,
485
+ elapsed,
486
+ };
487
+ }
@@ -0,0 +1,71 @@
1
+ /**
2
+ * Unified LLM Provider Abstraction for Deep Research
3
+ *
4
+ * Supports 5 providers:
5
+ * 1. Cloudflare Workers AI (free default, with daily neuron cap)
6
+ * 2. OpenAI (BYOK)
7
+ * 3. Anthropic (BYOK)
8
+ * 4. Google Gemini (BYOK)
9
+ * 5. Ollama (local, OpenAI-compatible)
10
+ */
11
+ export type DeepResearchLLMProvider = 'cloudflare' | 'openai' | 'anthropic' | 'google' | 'ollama';
12
+ export interface LLMConfig {
13
+ provider: DeepResearchLLMProvider;
14
+ apiKey?: string;
15
+ model?: string;
16
+ /** For Ollama: base endpoint URL. Default: http://localhost:11434 */
17
+ endpoint?: string;
18
+ }
19
+ export interface LLMMessage {
20
+ role: 'system' | 'user' | 'assistant';
21
+ content: string;
22
+ }
23
+ export interface LLMCallOptions {
24
+ messages: LLMMessage[];
25
+ stream?: boolean;
26
+ onChunk?: (text: string) => void;
27
+ signal?: AbortSignal;
28
+ maxTokens?: number;
29
+ temperature?: number;
30
+ }
31
+ export interface LLMCallResult {
32
+ text: string;
33
+ usage: {
34
+ input: number;
35
+ output: number;
36
+ };
37
+ }
38
+ export interface FreeTierLimitError {
39
+ error: 'free_tier_limit';
40
+ message: string;
41
+ }
42
+ /**
43
+ * Estimate neuron cost for a Cloudflare Workers AI call.
44
+ * Token count: split by whitespace * 1.3
45
+ */
46
+ export declare function estimateNeurons(inputText: string, outputText: string): number;
47
+ /** Get current neuron usage for today */
48
+ export declare function getNeuronUsage(): {
49
+ date: string;
50
+ neurons: number;
51
+ cap: number;
52
+ remaining: number;
53
+ };
54
+ /** Add neurons to today's usage (for testing / external tracking) */
55
+ export declare function addNeuronUsage(neurons: number): void;
56
+ /** Reset neuron usage (for testing) */
57
+ export declare function resetNeuronUsage(): void;
58
+ /**
59
+ * Call an LLM using the unified provider abstraction.
60
+ *
61
+ * @throws {FreeTierLimitError} if Cloudflare free tier cap is exceeded
62
+ * @throws {Error} for other failures
63
+ */
64
+ export declare function callLLM(config: LLMConfig, options: LLMCallOptions): Promise<LLMCallResult>;
65
+ /**
66
+ * Get the default LLM config based on available environment variables.
67
+ * Falls back to Cloudflare if nothing else is configured.
68
+ */
69
+ export declare function getDefaultLLMConfig(): LLMConfig;
70
+ /** Type guard: check if a thrown value is a FreeTierLimitError */
71
+ export declare function isFreeTierLimitError(err: unknown): err is FreeTierLimitError;