surf-skill 2.0.0

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,96 @@
1
+ // Library wrappers for research (sync + async start/poll).
2
+
3
+ import { dispatch } from '../dispatch.mjs';
4
+ import { buildInMemoryState } from '../../env.mjs';
5
+ import { setSilent } from '../progress.mjs';
6
+ import { providerFromRequestId } from '../providers/index.mjs';
7
+
8
+ const SLEEP = ms => new Promise(r => setTimeout(r, ms));
9
+
10
+ /**
11
+ * Start an async deep research job. Returns immediately with a request_id.
12
+ * Use researchPoll(id) to check status / get result.
13
+ *
14
+ * @param {string} input - the research question
15
+ * @param {object} [opts]
16
+ * @param {'mini'|'auto'|'pro'|'ultra'} [opts.model='auto']
17
+ * @returns {Promise<object>} envelope with data.request_id, data.status
18
+ */
19
+ export async function researchStart(input, opts = {}) {
20
+ if (opts.quiet !== false) setSilent(true);
21
+ if (!input || typeof input !== 'string') throw new Error('researchStart: input required');
22
+
23
+ const state = await buildInMemoryState(opts);
24
+ return dispatch(
25
+ 'research-start',
26
+ {
27
+ input,
28
+ model: opts.model || 'auto',
29
+ citationFormat: opts.citationFormat || 'numbered',
30
+ outputSchema: opts.outputSchema,
31
+ processor: opts.processor,
32
+ },
33
+ {
34
+ provider: opts.provider,
35
+ 'no-fallback': opts.noFallback,
36
+ 'confirm-expensive': true,
37
+ },
38
+ { state }
39
+ );
40
+ }
41
+
42
+ /**
43
+ * Poll a research job by request_id. The id encodes the originating provider
44
+ * (e.g. 'tvly:abc' or 'pllx:abc'), so no provider hint is needed.
45
+ *
46
+ * @param {string} requestId
47
+ * @returns {Promise<object>} envelope with data.status + data.content when completed
48
+ */
49
+ export async function researchPoll(requestId, opts = {}) {
50
+ if (opts.quiet !== false) setSilent(true);
51
+ if (!requestId || typeof requestId !== 'string') throw new Error('researchPoll: requestId required');
52
+
53
+ const decoded = providerFromRequestId(requestId);
54
+ if (!decoded) throw new Error(`unknown request_id prefix in '${requestId}'`);
55
+
56
+ const state = await buildInMemoryState(opts);
57
+ return dispatch(
58
+ 'research-poll',
59
+ {},
60
+ { ...opts, __requestId: requestId, 'confirm-expensive': true },
61
+ { state }
62
+ );
63
+ }
64
+
65
+ /**
66
+ * Synchronous research wrapper. Refuses model=pro/ultra (those are too slow).
67
+ * Polls every 5s up to 50s; if not finished, returns the in-progress envelope
68
+ * with request_id so caller can poll later.
69
+ */
70
+ export async function research(input, opts = {}) {
71
+ if (opts.quiet !== false) setSilent(true);
72
+ const model = opts.model || 'mini';
73
+ if (model === 'pro' || model === 'ultra') {
74
+ throw new Error(`research: model=${model} too slow for sync. Use researchStart + researchPoll.`);
75
+ }
76
+
77
+ const start = await researchStart(input, { ...opts, model });
78
+ const requestId = start.data.request_id;
79
+ const deadline = Date.now() + (opts.timeoutMs || 50_000);
80
+
81
+ while (Date.now() < deadline) {
82
+ await SLEEP(5000);
83
+ const poll = await researchPoll(requestId, opts);
84
+ if (poll.data.status === 'completed' || poll.data.status === 'failed') {
85
+ return poll;
86
+ }
87
+ }
88
+ return {
89
+ operation: 'research',
90
+ data: {
91
+ request_id: requestId,
92
+ status: 'pending',
93
+ hint: `Use researchPoll('${requestId}') to continue.`,
94
+ },
95
+ };
96
+ }
@@ -0,0 +1,92 @@
1
+ // Library wrapper for `search`. Wraps dispatch + key discovery.
2
+
3
+ import { dispatch } from '../dispatch.mjs';
4
+ import { buildInMemoryState } from '../../env.mjs';
5
+ import { setSilent } from '../progress.mjs';
6
+
7
+ /**
8
+ * Web search.
9
+ *
10
+ * @param {string|string[]} query - single query or array (batch)
11
+ * @param {object} [opts]
12
+ * @param {string|string[]} [opts.tavilyKey|opts.tavilyKeys]
13
+ * @param {string|string[]} [opts.parallelKey|opts.parallelKeys]
14
+ * @param {'tavily'|'parallel'} [opts.provider] - force a provider (no fallback)
15
+ * @param {'basic'|'advanced'|'fast'} [opts.depth='advanced']
16
+ * @param {number} [opts.max=5]
17
+ * @param {string} [opts.topic] - 'general' | 'news' | 'finance'
18
+ * @param {string} [opts.time] - 'day' | 'week' | 'month' | 'year'
19
+ * @param {string|string[]} [opts.domains]
20
+ * @param {string|string[]} [opts.excludeDomains]
21
+ * @param {string} [opts.country]
22
+ * @param {boolean|string} [opts.answer]
23
+ * @param {boolean|string} [opts.raw]
24
+ * @param {boolean} [opts.noCache=false]
25
+ * @param {boolean} [opts.quiet=true] - silence stderr progress logs (library default)
26
+ * @returns {Promise<object>} normalized envelope { provider, operation, data, usage, latency_ms, raw }
27
+ */
28
+ export async function search(query, opts = {}) {
29
+ if (opts.quiet !== false) setSilent(true);
30
+
31
+ const queries = Array.isArray(query) ? query : [query];
32
+ if (queries.length === 0 || queries.some(q => typeof q !== 'string' || !q.trim())) {
33
+ throw new Error('search: query must be a non-empty string or array of strings');
34
+ }
35
+
36
+ const state = await buildInMemoryState(opts);
37
+
38
+ if (queries.length === 1) {
39
+ return dispatch('search', buildArgs(queries[0], opts), buildFlags(opts), { state });
40
+ }
41
+
42
+ // Batch: run sequentially, return array of envelopes
43
+ const batches = [];
44
+ for (const q of queries) {
45
+ try {
46
+ const env = await dispatch('search', buildArgs(q, opts), buildFlags(opts), { state });
47
+ batches.push({ query: q, ok: true, envelope: env });
48
+ } catch (e) {
49
+ batches.push({ query: q, ok: false, error: { code: e.code || 'Error', message: e.message } });
50
+ }
51
+ }
52
+ return {
53
+ operation: 'search-batch',
54
+ data: { batches },
55
+ summary: {
56
+ total: queries.length,
57
+ succeeded: batches.filter(b => b.ok).length,
58
+ failed: batches.filter(b => !b.ok).length,
59
+ },
60
+ };
61
+ }
62
+
63
+ function buildArgs(query, opts) {
64
+ return {
65
+ query,
66
+ depth: opts.depth || 'advanced',
67
+ max: opts.max,
68
+ topic: opts.topic,
69
+ time: opts.time,
70
+ startDate: opts.startDate,
71
+ endDate: opts.endDate,
72
+ domains: opts.domains,
73
+ excludeDomains: opts.excludeDomains,
74
+ country: opts.country,
75
+ answer: opts.answer,
76
+ raw: opts.raw,
77
+ images: opts.images,
78
+ auto: opts.auto,
79
+ exactMatch: opts.exactMatch,
80
+ processor: opts.processor,
81
+ };
82
+ }
83
+
84
+ function buildFlags(opts) {
85
+ return {
86
+ provider: opts.provider,
87
+ 'no-fallback': opts.noFallback,
88
+ 'no-cache': opts.noCache,
89
+ timeout: opts.timeout,
90
+ 'confirm-expensive': true, // library callers know what they're doing
91
+ };
92
+ }
@@ -0,0 +1,34 @@
1
+ // Append-only audit log and usage ledger. Never logs API keys — only provider
2
+ // name and key index.
3
+
4
+ import { appendFile, readFile } from 'node:fs/promises';
5
+ import { existsSync } from 'node:fs';
6
+ import { join } from 'node:path';
7
+ import { CACHE_DIR, ensureCacheDir } from './state.mjs';
8
+
9
+ export const AUDIT_LOG = join(CACHE_DIR, 'audit.log');
10
+ export const USAGE_LOG = join(CACHE_DIR, 'usage.jsonl');
11
+
12
+ async function appendJsonl(path, entry) {
13
+ await ensureCacheDir();
14
+ await appendFile(path, JSON.stringify({ ts: new Date().toISOString(), ...entry }) + '\n');
15
+ }
16
+
17
+ export async function audit(event) {
18
+ await appendJsonl(AUDIT_LOG, event);
19
+ }
20
+
21
+ export async function recordUsage(event) {
22
+ await appendJsonl(USAGE_LOG, event);
23
+ }
24
+
25
+ export async function readUsage() {
26
+ if (!existsSync(USAGE_LOG)) return [];
27
+ const raw = await readFile(USAGE_LOG, 'utf8');
28
+ return raw
29
+ .split('\n')
30
+ .map(line => line.trim())
31
+ .filter(Boolean)
32
+ .map(line => { try { return JSON.parse(line); } catch { return null; } })
33
+ .filter(Boolean);
34
+ }
@@ -0,0 +1,46 @@
1
+ // Response cache keyed by (provider, endpoint, body).
2
+
3
+ import { readFile, writeFile, readdir, unlink } from 'node:fs/promises';
4
+ import { existsSync } from 'node:fs';
5
+ import { join } from 'node:path';
6
+ import { createHash } from 'node:crypto';
7
+ import { CACHE_DIR, ensureCacheDir } from './state.mjs';
8
+
9
+ export const TTL_MS = (Number(process.env.SURF_CACHE_TTL || process.env.TAVILY_CACHE_TTL) || 21600) * 1000;
10
+
11
+ export function cacheKey(provider, endpoint, body) {
12
+ return createHash('sha256')
13
+ .update(`${provider}:${endpoint}:${JSON.stringify(body || {})}`)
14
+ .digest('hex')
15
+ .slice(0, 24);
16
+ }
17
+
18
+ export async function cacheGet(key) {
19
+ const f = join(CACHE_DIR, key + '.json');
20
+ if (!existsSync(f)) return null;
21
+ try {
22
+ const raw = JSON.parse(await readFile(f, 'utf8'));
23
+ if (Date.now() - raw.ts > TTL_MS) return null;
24
+ return raw.data;
25
+ } catch {
26
+ return null;
27
+ }
28
+ }
29
+
30
+ export async function cacheSet(key, data) {
31
+ await ensureCacheDir();
32
+ await writeFile(join(CACHE_DIR, key + '.json'), JSON.stringify({ ts: Date.now(), data }));
33
+ }
34
+
35
+ export async function cacheClear() {
36
+ if (!existsSync(CACHE_DIR)) return 0;
37
+ const files = await readdir(CACHE_DIR);
38
+ let n = 0;
39
+ for (const f of files) {
40
+ if (f.endsWith('.json')) {
41
+ await unlink(join(CACHE_DIR, f));
42
+ n++;
43
+ }
44
+ }
45
+ return n;
46
+ }
@@ -0,0 +1,90 @@
1
+ // Credit estimation. We model both providers on the same "credit" scale that
2
+ // Tavily uses (since that's the only published one). Parallel costs are
3
+ // estimated coarsely from documented processor tiers.
4
+
5
+ import { clamp, ceilDiv } from './flags.mjs';
6
+
7
+ const EXPENSIVE_OK = process.env.SURF_ALLOW_EXPENSIVE === '1'
8
+ || process.env.TAVILY_ALLOW_EXPENSIVE === '1';
9
+
10
+ // Tavily — matches the public credit table.
11
+ function estimateTavily(op, args) {
12
+ switch (op) {
13
+ case 'search':
14
+ return (args.depth === 'advanced' || args.auto) ? 2 : 1;
15
+ case 'extract': {
16
+ const urls = Array.isArray(args.urls) ? args.urls.length : 1;
17
+ const rate = args.depth === 'advanced' ? 2 : 1;
18
+ return ceilDiv(Math.max(urls, 1), 5) * rate;
19
+ }
20
+ case 'map': {
21
+ const limit = clamp(Number(args.limit) || 50, 1, 500);
22
+ const rate = args.instructions ? 2 : 1;
23
+ return ceilDiv(limit, 10) * rate;
24
+ }
25
+ case 'crawl': {
26
+ const limit = clamp(Number(args.limit) || 50, 1, 200);
27
+ const mapRate = args.instructions ? 2 : 1;
28
+ const exRate = args.extractDepth === 'advanced' ? 2 : 1;
29
+ return ceilDiv(limit, 10) * mapRate + ceilDiv(limit, 5) * exRate;
30
+ }
31
+ case 'research':
32
+ case 'research-start': {
33
+ const model = args.model || (op === 'research' ? 'mini' : 'auto');
34
+ if (model === 'mini') return 15;
35
+ return 50;
36
+ }
37
+ default:
38
+ return 0;
39
+ }
40
+ }
41
+
42
+ // Parallel — approximate, since public per-request pricing is opaque.
43
+ // Tier mapping: lite ≈ 1, base ≈ 2, core/pro ≈ 5, ultra ≈ 25, ultra8x ≈ 200.
44
+ function tierCredits(p) {
45
+ return { lite: 1, base: 2, core: 5, pro: 8, ultra: 25, ultra8x: 200 }[p] || 2;
46
+ }
47
+
48
+ function estimateParallel(op, args) {
49
+ switch (op) {
50
+ case 'search': {
51
+ const proc = args.processor || (args.depth === 'advanced' ? 'base' : 'lite');
52
+ return tierCredits(proc);
53
+ }
54
+ case 'extract': {
55
+ const urls = Array.isArray(args.urls) ? args.urls.length : 1;
56
+ return Math.max(1, ceilDiv(urls, 5));
57
+ }
58
+ case 'research':
59
+ case 'research-start': {
60
+ const proc = args.processor || ({ mini: 'lite', auto: 'base', pro: 'pro', ultra: 'ultra' }[args.model || 'auto']) || 'base';
61
+ return tierCredits(proc);
62
+ }
63
+ case 'crawl':
64
+ case 'map':
65
+ return Infinity; // not supported, won't be chosen
66
+ default:
67
+ return 0;
68
+ }
69
+ }
70
+
71
+ export function estimateCreditsForChain(operation, args, chain) {
72
+ let worst = 0;
73
+ for (const p of chain) {
74
+ const est = p === 'tavily' ? estimateTavily(operation, args) : estimateParallel(operation, args);
75
+ if (Number.isFinite(est) && est > worst) worst = est;
76
+ }
77
+ return worst;
78
+ }
79
+
80
+ export function guardExpensive(operation, args, chain, flags) {
81
+ if (EXPENSIVE_OK || flags['confirm-expensive']) return;
82
+ const estimate = estimateCreditsForChain(operation, args, chain);
83
+ if (estimate > 10) {
84
+ const err = new Error(
85
+ `This '${operation}' is estimated at ~${estimate} credits across providers. Re-run with --confirm-expensive (or set SURF_ALLOW_EXPENSIVE=1) after user approval.`
86
+ );
87
+ err.code = 'EXPENSIVE_BLOCKED';
88
+ throw err;
89
+ }
90
+ }
@@ -0,0 +1,320 @@
1
+ // Central dispatch: provider+key fallback for every operation. The CLI never
2
+ // talks to provider adapters directly — it always goes through dispatch().
3
+
4
+ import {
5
+ loadState, saveStateAtomic, markBurned, providerHasUsableKey,
6
+ nextUsableKeyIndex, PROVIDERS as PROVIDER_NAMES,
7
+ } from './state.mjs';
8
+ import { audit, recordUsage } from './audit.mjs';
9
+ import { cacheKey, cacheGet, cacheSet } from './cache.mjs';
10
+ import { getProvider, capabilityMap, providerFromRequestId } from './providers/index.mjs';
11
+ import { guardExpensive } from './cost.mjs';
12
+ import { sleep } from './flags.mjs';
13
+ import { progress } from './progress.mjs';
14
+
15
+ const CACHEABLE = new Set(['search', 'extract', 'map']);
16
+ const VERSION = '1.0.0';
17
+
18
+ // Detect the agent harness's bash timeout from env vars. The number is the
19
+ // total time (ms) the harness will allow our process to live before SIGTERM.
20
+ // We use this to abort early with an actionable error instead of being killed
21
+ // silently.
22
+ export function detectHarnessBudgetMs() {
23
+ if (process.env.SURF_AGENT_BUDGET_MS) {
24
+ const n = Number(process.env.SURF_AGENT_BUDGET_MS);
25
+ if (Number.isFinite(n) && n > 0) return n;
26
+ }
27
+ if (process.env.BASH_DEFAULT_TIMEOUT_MS) {
28
+ const n = Number(process.env.BASH_DEFAULT_TIMEOUT_MS);
29
+ if (Number.isFinite(n) && n > 0) return n; // Claude Code
30
+ }
31
+ if (process.env.PI_BASH_DEFAULT_TIMEOUT_SECONDS) {
32
+ const n = Number(process.env.PI_BASH_DEFAULT_TIMEOUT_SECONDS);
33
+ if (Number.isFinite(n) && n > 0) return n * 1000; // Pi
34
+ }
35
+ if (process.env.OPENCODE_EXPERIMENTAL_BASH_DEFAULT_TIMEOUT_MS) {
36
+ const n = Number(process.env.OPENCODE_EXPERIMENTAL_BASH_DEFAULT_TIMEOUT_MS);
37
+ if (Number.isFinite(n) && n > 0) return n;
38
+ }
39
+ // Unknown harness — assume worst case (Copilot CLI without per-project hook).
40
+ return 30_000;
41
+ }
42
+
43
+ export function detectHarnessName() {
44
+ if (process.env.SURF_AGENT_BUDGET_MS) return 'override';
45
+ if (process.env.BASH_DEFAULT_TIMEOUT_MS) return 'claude-code';
46
+ if (process.env.PI_BASH_DEFAULT_TIMEOUT_SECONDS) return 'pi';
47
+ if (process.env.OPENCODE_EXPERIMENTAL_BASH_DEFAULT_TIMEOUT_MS) return 'opencode';
48
+ return 'unknown (assuming 30s — likely GH Copilot CLI without hook)';
49
+ }
50
+
51
+ export class DispatchError extends Error {
52
+ constructor(code, message, details = {}) {
53
+ super(message);
54
+ this.name = 'DispatchError';
55
+ this.code = code;
56
+ this.details = details;
57
+ }
58
+ }
59
+
60
+ function buildChain(operation, state, flags) {
61
+ if (operation === 'research-poll') {
62
+ const decoded = providerFromRequestId(flags.__requestId);
63
+ if (!decoded) throw new DispatchError('BAD_REQUEST_ID', `unknown request_id prefix in '${flags.__requestId}'`);
64
+ if (!providerHasUsableKey(state, decoded.provider)) {
65
+ throw new DispatchError(
66
+ 'NoUsableKeyForRequestId',
67
+ `request_id belongs to provider '${decoded.provider}', which has no usable keys; run "surf-skill keys add --provider ${decoded.provider} <key>" and retry`
68
+ );
69
+ }
70
+ return { chain: [decoded.provider], pinned: true, decoded };
71
+ }
72
+
73
+ if (operation === 'usage') {
74
+ const provider = flags.provider;
75
+ if (!provider) throw new DispatchError('UsageNeedsProvider', `'usage' requires --provider tavily|parallel`);
76
+ if (!providerHasUsableKey(state, provider)) {
77
+ throw new DispatchError('NoUsableKey', `provider '${provider}' has no usable keys`);
78
+ }
79
+ return { chain: [provider], pinned: true };
80
+ }
81
+
82
+ const baseChain = capabilityMap[operation];
83
+ if (!Array.isArray(baseChain)) {
84
+ throw new DispatchError('UnknownOperation', `operation '${operation}' is not registered`);
85
+ }
86
+
87
+ let chain = baseChain.filter(p => providerHasUsableKey(state, p));
88
+
89
+ if (flags.provider) {
90
+ if (!baseChain.includes(flags.provider)) {
91
+ throw new DispatchError('NotCapable',
92
+ `provider '${flags.provider}' does not support '${operation}' (supported: ${baseChain.join(', ')})`);
93
+ }
94
+ if (!providerHasUsableKey(state, flags.provider)) {
95
+ throw new DispatchError('NoUsableKey', `provider '${flags.provider}' has no usable keys for '${operation}'`);
96
+ }
97
+ return { chain: [flags.provider], pinned: true };
98
+ }
99
+
100
+ if (chain.length === 0) {
101
+ throw new DispatchError(
102
+ 'NoProviderAvailable',
103
+ `operation '${operation}' requires one of [${baseChain.join(', ')}]; run "surf-skill keys add --provider <name> <key>"`
104
+ );
105
+ }
106
+
107
+ // Promote last_ok_provider to the front when it is still in the filtered chain.
108
+ if (state.last_ok_provider && chain.includes(state.last_ok_provider)) {
109
+ chain = [state.last_ok_provider, ...chain.filter(x => x !== state.last_ok_provider)];
110
+ }
111
+
112
+ if (flags['no-fallback'] || flags.noFallback) {
113
+ return { chain: [chain[0]], pinned: true };
114
+ }
115
+
116
+ return { chain, pinned: false };
117
+ }
118
+
119
+ function backoff(attempt) {
120
+ return Math.min(1500 * (attempt + 1) ** 2, 8000);
121
+ }
122
+
123
+ export async function dispatch(operation, args, flags = {}, runCtx = {}) {
124
+ const startTs = Date.now();
125
+ const harnessBudget = detectHarnessBudgetMs();
126
+ const harnessName = detectHarnessName();
127
+ // Reserve a cushion so we surface the error before the harness kills us.
128
+ const cushion = Math.min(2000, Math.floor(harnessBudget * 0.1));
129
+
130
+ // Library mode: caller can pass an in-memory state object to avoid touching
131
+ // ~/.config/surf/keys.json. State mutations (last_ok_provider, burned) stay
132
+ // in-memory and don't get persisted when runCtx.state._inMemory is true.
133
+ const state = runCtx.state || await loadState();
134
+ const persistState = !state._inMemory;
135
+ let cachedHit = null;
136
+ let cKey = null;
137
+
138
+ // Cache lookup (only for cacheable, only when not forced/raw/no-cache).
139
+ if (CACHEABLE.has(operation) && !flags['no-cache'] && !flags['raw-json'] && !flags.provider) {
140
+ cKey = cacheKey('any', operation, args);
141
+ cachedHit = await cacheGet(cKey);
142
+ if (cachedHit) {
143
+ await audit({ op: operation, cache: 'hit', provider: cachedHit.provider });
144
+ await recordUsage({ op: operation, provider: cachedHit.provider, credits: 0, cached: true });
145
+ progress.success(`${operation} cache hit (${cachedHit.provider})`);
146
+ return cachedHit;
147
+ }
148
+ }
149
+
150
+ const { chain, pinned, decoded } = buildChain(operation, state, flags);
151
+
152
+ // Cost guard runs AFTER chain build, so NoProviderAvailable and
153
+ // bad-input errors surface before users see a misleading credit warning.
154
+ if (operation !== 'research-poll' && operation !== 'usage') {
155
+ guardExpensive(operation, args, chain, flags);
156
+ }
157
+
158
+ const errors = [];
159
+
160
+ for (const providerName of chain) {
161
+ const provider = getProvider(providerName);
162
+ if (!provider) {
163
+ errors.push(`${providerName}: provider not registered`);
164
+ continue;
165
+ }
166
+ if (operation !== 'research-poll' && !provider.supports[operation]) {
167
+ errors.push(`${providerName}: does not support '${operation}'`);
168
+ continue;
169
+ }
170
+
171
+ let attempted = new Set();
172
+ let providerExhausted = false;
173
+
174
+ while (!providerExhausted) {
175
+ const keyIdx = (() => {
176
+ const p = state[providerName];
177
+ if (!p || !p.keys.length) return -1;
178
+ const burnedIdx = new Set(p.burned.map(b => b.index));
179
+ const n = p.keys.length;
180
+ const start = Math.max(0, Math.min(p.current || 0, n - 1));
181
+ for (let off = 0; off < n; off++) {
182
+ const i = (start + off) % n;
183
+ if (attempted.has(i)) continue;
184
+ if (burnedIdx.has(i)) continue;
185
+ return i;
186
+ }
187
+ return -1;
188
+ })();
189
+
190
+ if (keyIdx === -1) { providerExhausted = true; break; }
191
+ attempted.add(keyIdx);
192
+ progress.start(`${operation} → ${providerName} (key #${keyIdx})`);
193
+
194
+ // Self-budget check: abort BEFORE the harness SIGTERMs us.
195
+ const elapsed = Date.now() - startTs;
196
+ const remaining = harnessBudget - elapsed - cushion;
197
+ if (remaining <= 1000) {
198
+ throw new DispatchError(
199
+ 'LikelyAgentTimeout',
200
+ `Operation '${operation}' would likely exceed the agent's bash timeout ` +
201
+ `(~${Math.round(harnessBudget / 1000)}s detected, harness=${harnessName}). ` +
202
+ `Run 'surf-skill project-config' in this project to raise the limit, ` +
203
+ `or use 'research-start' + 'research-poll' for long jobs.`,
204
+ { harness: harnessName, budgetMs: harnessBudget, elapsedMs: elapsed },
205
+ );
206
+ }
207
+
208
+ const ctx = {
209
+ key: state[providerName].keys[keyIdx],
210
+ // Constrain HTTP timeout to whatever's left in our budget so we don't
211
+ // sit waiting beyond what the harness will allow.
212
+ timeout: Math.min(
213
+ flags.timeout ? Number(flags.timeout) : Infinity,
214
+ remaining,
215
+ ),
216
+ version: VERSION,
217
+ };
218
+
219
+ const callArgs = operation === 'research-poll' && decoded
220
+ ? { ...args, providerRunId: decoded.providerRunId }
221
+ : args;
222
+
223
+ let consecutive5xx = 0;
224
+ let consecutive429 = 0;
225
+ let consecutiveNetwork = 0;
226
+ let success = null;
227
+
228
+ for (let attempt = 0; attempt < 3 && !success; attempt++) {
229
+ try {
230
+ const result = await provider[operation](callArgs, ctx);
231
+ success = result;
232
+ break;
233
+ } catch (e) {
234
+ const kind = e.kind || 'caller_4xx';
235
+ await audit({
236
+ op: operation, provider: providerName, key_index: keyIdx,
237
+ kind, status: e.statusCode, message: (e.message || '').slice(0, 200),
238
+ });
239
+
240
+ if (kind === 'caller_4xx' || kind === 'not_supported') {
241
+ // Don't retry, don't fallback. Bad input.
242
+ throw e;
243
+ }
244
+ if (kind === 'rate_limit_429') {
245
+ consecutive429++;
246
+ if (attempt < 2) {
247
+ progress.retry(`${providerName} 429 — backoff ${backoff(attempt)}ms (attempt ${attempt + 1}/3)`);
248
+ await sleep(backoff(attempt)); continue;
249
+ }
250
+ break; // exhausted retries -> next key
251
+ }
252
+ if (kind === 'network') {
253
+ consecutiveNetwork++;
254
+ if (attempt < 2) {
255
+ progress.retry(`${providerName} network error — backoff ${Math.round(backoff(attempt) / 2)}ms`);
256
+ await sleep(backoff(attempt) / 2); continue;
257
+ }
258
+ break; // exhausted retries -> next key
259
+ }
260
+ if (kind === 'auth') {
261
+ progress.warn(`${providerName} key #${keyIdx} burned (${e.statusCode || 'auth'})`);
262
+ markBurned(state, providerName, keyIdx, String(e.statusCode || 'auth'));
263
+ if (persistState) await saveStateAtomic(state);
264
+ break; // next key
265
+ }
266
+ if (kind === 'server_5xx') {
267
+ consecutive5xx++;
268
+ if (consecutive5xx >= 3) {
269
+ progress.warn(`${providerName} key #${keyIdx} burned (5xx x3)`);
270
+ markBurned(state, providerName, keyIdx, '5xx');
271
+ if (persistState) await saveStateAtomic(state);
272
+ break; // next key
273
+ }
274
+ if (attempt < 2) {
275
+ progress.retry(`${providerName} 5xx — backoff ${backoff(attempt)}ms`);
276
+ await sleep(backoff(attempt)); continue;
277
+ }
278
+ break;
279
+ }
280
+ // Unknown kind — treat as caller error to avoid masking bugs.
281
+ throw e;
282
+ }
283
+ }
284
+
285
+ if (success) {
286
+ state.last_ok_provider = providerName;
287
+ state[providerName].current = keyIdx;
288
+ if (persistState) await saveStateAtomic(state);
289
+ await recordUsage({
290
+ op: operation,
291
+ provider: providerName,
292
+ key_index: keyIdx,
293
+ credits: success.usage && success.usage.credits,
294
+ cached: false,
295
+ latency_ms: success.latency_ms,
296
+ });
297
+ if (cKey && CACHEABLE.has(operation)) {
298
+ await cacheSet(cKey, success);
299
+ }
300
+ const credits = success.usage && success.usage.credits;
301
+ progress.success(
302
+ `${operation} ${providerName} ${success.latency_ms}ms` +
303
+ (credits != null ? ` (${credits} credits)` : '')
304
+ );
305
+ return success;
306
+ }
307
+
308
+ // No success on this key; record summary and loop to next key.
309
+ errors.push(`${providerName}#${keyIdx}: ${consecutive5xx ? '5xx' : consecutive429 ? '429' : consecutiveNetwork ? 'network' : 'auth'}`);
310
+ }
311
+
312
+ if (pinned) break;
313
+ }
314
+
315
+ throw new DispatchError(
316
+ 'AllProvidersExhausted',
317
+ `operation '${operation}' failed on every provider/key${errors.length ? ': ' + errors.join('; ') : ''}`,
318
+ { errors },
319
+ );
320
+ }