@demigodmode/pi-web-agent 1.11.0 → 1.13.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.
Files changed (82) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/README.md +2 -0
  3. package/dist/backends/config.d.ts +13 -0
  4. package/dist/backends/config.js +44 -1
  5. package/dist/backends/doctor.js +27 -29
  6. package/dist/backends/factory.d.ts +18 -7
  7. package/dist/backends/factory.js +126 -99
  8. package/dist/backends/failure.d.ts +11 -0
  9. package/dist/backends/failure.js +34 -0
  10. package/dist/backends/fallback-policy.d.ts +31 -0
  11. package/dist/backends/fallback-policy.js +239 -0
  12. package/dist/backends/provider-failure.d.ts +21 -0
  13. package/dist/backends/provider-failure.js +110 -0
  14. package/dist/backends/provider-health.d.ts +29 -0
  15. package/dist/backends/provider-health.js +49 -0
  16. package/dist/commands/web-agent-config.d.ts +14 -1
  17. package/dist/commands/web-agent-config.js +75 -3
  18. package/dist/extension.js +47 -3
  19. package/dist/extract/bot-check.d.ts +1 -0
  20. package/dist/extract/bot-check.js +10 -0
  21. package/dist/extract/readability.d.ts +4 -0
  22. package/dist/extract/readability.js +67 -3
  23. package/dist/extract/section-selector.d.ts +14 -0
  24. package/dist/extract/section-selector.js +233 -0
  25. package/dist/fetch/destination-policy.d.ts +32 -0
  26. package/dist/fetch/destination-policy.js +24 -0
  27. package/dist/fetch/firecrawl-fetch.d.ts +1 -1
  28. package/dist/fetch/firecrawl-fetch.js +74 -46
  29. package/dist/fetch/guard-proxy-fetch.d.ts +17 -0
  30. package/dist/fetch/guard-proxy-fetch.js +82 -0
  31. package/dist/fetch/guard-proxy.d.ts +58 -0
  32. package/dist/fetch/guard-proxy.js +420 -0
  33. package/dist/fetch/guarded-fetch.d.ts +7 -0
  34. package/dist/fetch/guarded-fetch.js +75 -0
  35. package/dist/fetch/headless-fetch.d.ts +11 -2
  36. package/dist/fetch/headless-fetch.js +190 -13
  37. package/dist/fetch/http-fetch.d.ts +1 -1
  38. package/dist/fetch/http-fetch.js +29 -8
  39. package/dist/fetch/network-guard.d.ts +82 -0
  40. package/dist/fetch/network-guard.js +275 -0
  41. package/dist/orchestration/answer-synthesizer.js +2 -0
  42. package/dist/orchestration/evidence-quality.d.ts +3 -2
  43. package/dist/orchestration/evidence-quality.js +2 -1
  44. package/dist/orchestration/index.d.ts +26 -7
  45. package/dist/orchestration/index.js +9 -2
  46. package/dist/orchestration/research-orchestrator.d.ts +23 -7
  47. package/dist/orchestration/research-orchestrator.js +60 -26
  48. package/dist/orchestration/research-types.d.ts +13 -1
  49. package/dist/orchestration/research-worker.d.ts +2 -4
  50. package/dist/orchestration/research-worker.js +51 -15
  51. package/dist/orchestration/stop-decider.js +3 -1
  52. package/dist/presentation/config-store.js +6 -0
  53. package/dist/presentation/explore-presentation.js +3 -1
  54. package/dist/presentation/fetch-presentation.js +16 -9
  55. package/dist/presentation/search-presentation.d.ts +2 -1
  56. package/dist/presentation/search-presentation.js +13 -1
  57. package/dist/readers/resolver.d.ts +3 -7
  58. package/dist/search/brave.d.ts +1 -2
  59. package/dist/search/brave.js +23 -80
  60. package/dist/search/duckduckgo.d.ts +7 -3
  61. package/dist/search/duckduckgo.js +17 -18
  62. package/dist/search/exa.d.ts +1 -2
  63. package/dist/search/exa.js +15 -76
  64. package/dist/search/fanout.d.ts +12 -0
  65. package/dist/search/fanout.js +86 -47
  66. package/dist/search/json-provider.d.ts +32 -0
  67. package/dist/search/json-provider.js +76 -0
  68. package/dist/search/searxng.d.ts +1 -2
  69. package/dist/search/searxng.js +15 -57
  70. package/dist/search/tavily.d.ts +1 -2
  71. package/dist/search/tavily.js +17 -74
  72. package/dist/search/youcom.d.ts +4 -2
  73. package/dist/search/youcom.js +49 -75
  74. package/dist/tools/web-explore.d.ts +9 -0
  75. package/dist/tools/web-explore.js +16 -2
  76. package/dist/tools/web-fetch-headless.d.ts +3 -5
  77. package/dist/tools/web-fetch-headless.js +3 -3
  78. package/dist/tools/web-fetch.d.ts +3 -5
  79. package/dist/tools/web-fetch.js +3 -3
  80. package/dist/tools/web-search.js +41 -103
  81. package/dist/types.d.ts +48 -0
  82. package/package.json +3 -3
@@ -0,0 +1,11 @@
1
+ import type { FailureInfo, FailureKind, ToolError } from '../types.js';
2
+ /** Terminal failures never retry, never fall back, never escalate to headless (#55). */
3
+ export declare function isTerminalFailure(failure: FailureInfo | undefined): boolean;
4
+ export declare function shouldFallBack(kind: FailureKind): boolean;
5
+ /** An error result's failure. Unclassified errors are treated as bad_response: fall back, no retry, no state. */
6
+ export declare function failureOf(result: {
7
+ status: string;
8
+ error?: ToolError;
9
+ }): FailureInfo | undefined;
10
+ /** RFC 9110 Retry-After: delay-seconds (non-negative integer) or an HTTP-date. */
11
+ export declare function parseRetryAfter(value: string | null | undefined, now: number): number | undefined;
@@ -0,0 +1,34 @@
1
+ const TERMINAL_KINDS = new Set(['config_global', 'guard_refused']);
2
+ const NO_FALLBACK_KINDS = new Set(['bad_request', 'config_global', 'guard_refused']);
3
+ /** Terminal failures never retry, never fall back, never escalate to headless (#55). */
4
+ export function isTerminalFailure(failure) {
5
+ return failure !== undefined && TERMINAL_KINDS.has(failure.kind);
6
+ }
7
+ export function shouldFallBack(kind) {
8
+ return !NO_FALLBACK_KINDS.has(kind);
9
+ }
10
+ /** An error result's failure. Unclassified errors are treated as bad_response: fall back, no retry, no state. */
11
+ export function failureOf(result) {
12
+ if (result.status !== 'error')
13
+ return undefined;
14
+ return result.error?.failure ?? { kind: 'bad_response' };
15
+ }
16
+ /** RFC 9110 Retry-After: delay-seconds (non-negative integer) or an HTTP-date. */
17
+ export function parseRetryAfter(value, now) {
18
+ if (!value)
19
+ return undefined;
20
+ const trimmed = value.trim();
21
+ if (/^\d+$/.test(trimmed)) {
22
+ const ms = Number(trimmed) * 1000;
23
+ // A huge digit string overflows to Infinity; keep it finite (and JSON-safe) so the
24
+ // cooldown clamps to the maximum instead of falling back to the default.
25
+ return Number.isFinite(ms) ? Math.min(ms, Number.MAX_SAFE_INTEGER) : Number.MAX_SAFE_INTEGER;
26
+ }
27
+ // IMF-fixdate only (RFC 9110 preferred form). V8's Date.parse accepts things like "-5" or "abc 2099".
28
+ if (!/^[A-Za-z]{3}, \d{2} [A-Za-z]{3} \d{4} \d{2}:\d{2}:\d{2} GMT$/.test(trimmed))
29
+ return undefined;
30
+ const at = Date.parse(trimmed);
31
+ if (Number.isNaN(at) || at <= now)
32
+ return undefined;
33
+ return at - now;
34
+ }
@@ -0,0 +1,31 @@
1
+ import type { ResearchFetchInput, SearchProviderName, WebFetchResponse, WebSearchResponse } from '../types.js';
2
+ import type { ProviderHealth } from './provider-health.js';
3
+ export declare const RETRY_BASE_MS = 500;
4
+ export declare const RETRY_JITTER_MS = 250;
5
+ export type PolicyDeps = {
6
+ health: ProviderHealth;
7
+ now?: () => number;
8
+ sleep?: (ms: number) => Promise<void>;
9
+ random?: () => number;
10
+ };
11
+ type Search = (input: {
12
+ query: string;
13
+ }) => Promise<WebSearchResponse>;
14
+ type FetchPage = (input: ResearchFetchInput) => Promise<WebFetchResponse>;
15
+ /**
16
+ * One provider under the #55 policy: skip when cooling down or disabled,
17
+ * retry exactly once on transient, record state. Never falls back itself.
18
+ */
19
+ export declare function withSearchPolicy(name: SearchProviderName, search: Search, deps: PolicyDeps, healthKey?: string): Search;
20
+ /**
21
+ * Tries providers in order under the precedence in the #55 spec: terminal and
22
+ * bad_request failures stop the chain; a result or a valid empty response is
23
+ * returned; everything else falls back.
24
+ */
25
+ export declare function chainSearch(providers: Search[], deps: PolicyDeps): Search;
26
+ /**
27
+ * Firecrawl under the policy, with the optional HTTP fallback. The HTTP fetcher
28
+ * talks to the model-chosen site, not a service, so it has no provider health.
29
+ */
30
+ export declare function withFetchPolicy(primary: FetchPage, fallback: FetchPage | undefined, deps: PolicyDeps, healthKey?: string): FetchPage;
31
+ export {};
@@ -0,0 +1,239 @@
1
+ import { buildFetchPresentation } from '../presentation/fetch-presentation.js';
2
+ import { buildSearchPresentation } from '../presentation/search-presentation.js';
3
+ import { failureOf, shouldFallBack } from './failure.js';
4
+ export const RETRY_BASE_MS = 500;
5
+ export const RETRY_JITTER_MS = 250;
6
+ const USER_FIXABLE_KINDS = new Set(['not_configured', 'auth_failed', 'quota_exhausted']);
7
+ /** Only user-fixable failures keep the provider's message; everything else stays message-free. */
8
+ function detailFor(failure, message) {
9
+ return message && USER_FIXABLE_KINDS.has(failure.kind) ? message : undefined;
10
+ }
11
+ const defaultSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
12
+ function retryDelay(deps) {
13
+ return RETRY_BASE_MS + Math.floor((deps.random ?? Math.random)() * RETRY_JITTER_MS);
14
+ }
15
+ function skipAttempt(backend, state) {
16
+ return {
17
+ backend,
18
+ outcome: 'skipped',
19
+ failure: state.failure,
20
+ skipReason: state.state,
21
+ ...(state.state === 'cooling_down' ? { cooldownUntil: state.until } : {}),
22
+ ...(state.detail ? { detail: state.detail } : {})
23
+ };
24
+ }
25
+ function skipMessage(backend, state) {
26
+ const base = state.state === 'cooling_down'
27
+ ? `${backend} is cooling down after ${state.failure.kind} until ${new Date(state.until).toISOString()}.`
28
+ : `${backend} is disabled for this session after ${state.failure.kind}.`;
29
+ return state.detail ? `${base} ${state.detail}` : base;
30
+ }
31
+ function failedAttempt(backend, failure, state, detail) {
32
+ return {
33
+ backend,
34
+ outcome: 'failed',
35
+ failure,
36
+ ...(state.state === 'cooling_down' ? { cooldownUntil: state.until } : {}),
37
+ ...(detail ? { detail } : {})
38
+ };
39
+ }
40
+ /**
41
+ * One provider under the #55 policy: skip when cooling down or disabled,
42
+ * retry exactly once on transient, record state. Never falls back itself.
43
+ */
44
+ export function withSearchPolicy(name, search, deps, healthKey = name) {
45
+ return async (input) => {
46
+ const state = deps.health.get(healthKey);
47
+ if (state.state !== 'available') {
48
+ return {
49
+ status: 'error',
50
+ results: [],
51
+ metadata: { backend: name, cacheHit: false, attempts: [skipAttempt(name, state)] },
52
+ error: { code: 'BACKEND_UNAVAILABLE', message: skipMessage(name, state), failure: state.failure }
53
+ };
54
+ }
55
+ const attempts = [];
56
+ let result = await search(input);
57
+ let failure = failureOf(result);
58
+ if (failure?.kind === 'transient') {
59
+ attempts.push({ backend: name, outcome: 'retried', failure });
60
+ await (deps.sleep ?? defaultSleep)(retryDelay(deps));
61
+ result = await search(input);
62
+ failure = failureOf(result);
63
+ }
64
+ if (failure) {
65
+ const detail = detailFor(failure, result.error?.message);
66
+ attempts.push(failedAttempt(name, failure, deps.health.record(healthKey, failure, detail), detail));
67
+ }
68
+ else {
69
+ attempts.push({ backend: name, outcome: result.results.length > 0 ? 'results' : 'empty' });
70
+ }
71
+ return { ...result, metadata: { ...result.metadata, attempts: [...(result.metadata.attempts ?? []), ...attempts] } };
72
+ };
73
+ }
74
+ function unavailableMessage(attempts, messages = new Map()) {
75
+ const byProvider = new Map();
76
+ for (const attempt of attempts) {
77
+ if (attempt.outcome === 'failed' || attempt.outcome === 'skipped')
78
+ byProvider.set(attempt.backend, attempt);
79
+ }
80
+ const entries = [...byProvider.values()].sort((a, b) => {
81
+ const aUntil = a.cooldownUntil ?? Number.POSITIVE_INFINITY;
82
+ const bUntil = b.cooldownUntil ?? Number.POSITIVE_INFINITY;
83
+ return aUntil - bUntil;
84
+ });
85
+ const parts = entries.map((attempt) => {
86
+ const kind = attempt.failure?.kind ?? 'bad_response';
87
+ if (attempt.cooldownUntil !== undefined) {
88
+ return `${attempt.backend} ${kind} (available again at ${new Date(attempt.cooldownUntil).toISOString()})`;
89
+ }
90
+ // Keep the provider's own hint for problems the user has to fix, e.g. a missing key or base URL.
91
+ const hint = USER_FIXABLE_KINDS.has(kind)
92
+ ? attempt.detail ?? (attempt.outcome === 'failed' ? messages.get(attempt.backend) : undefined)
93
+ : undefined;
94
+ return hint ? `${attempt.backend} ${kind} (${hint})` : `${attempt.backend} ${kind}`;
95
+ });
96
+ return `No search backend is available: ${parts.join(', ')}.`;
97
+ }
98
+ /**
99
+ * A failed link's unavailable providers, from its own attempts: the last failed or skipped
100
+ * attempt per backend with that attempt's kind. A fanout link reports each provider, not the
101
+ * aggregate. Falls back to the aggregate only when the link carries no failed or skipped attempts.
102
+ */
103
+ function unavailableFor(result, failure) {
104
+ const message = result.error?.message ?? failure.kind;
105
+ const byProvider = new Map();
106
+ for (const entry of result.metadata.coverage?.unavailable ?? []) {
107
+ byProvider.set(entry.provider, { ...entry, message });
108
+ }
109
+ const attempts = result.metadata.attempts ?? [];
110
+ const relevant = attempts.filter((a) => a.outcome === 'failed' || a.outcome === 'skipped');
111
+ if (relevant.length === 0) {
112
+ byProvider.set(result.metadata.backend, { provider: result.metadata.backend, kind: failure.kind, message });
113
+ }
114
+ for (const attempt of relevant) {
115
+ byProvider.delete(attempt.backend); // keep insertion order at the latest attempt
116
+ byProvider.set(attempt.backend, {
117
+ provider: attempt.backend,
118
+ kind: attempt.failure?.kind ?? failure.kind,
119
+ message: attempt.detail ?? message
120
+ });
121
+ }
122
+ return [...byProvider.values()];
123
+ }
124
+ /**
125
+ * Tries providers in order under the precedence in the #55 spec: terminal and
126
+ * bad_request failures stop the chain; a result or a valid empty response is
127
+ * returned; everything else falls back.
128
+ */
129
+ export function chainSearch(providers, deps) {
130
+ return async (input) => {
131
+ const attempts = [];
132
+ const unavailable = [];
133
+ let lastFailure;
134
+ let firstBackend;
135
+ // Keep an earlier fanout's provenance (which providers were tried) when a later link answers.
136
+ let fanout;
137
+ for (const provider of providers) {
138
+ const result = await provider(input);
139
+ firstBackend ??= result.metadata.backend;
140
+ attempts.push(...(result.metadata.attempts ?? []));
141
+ fanout ??= result.metadata.fanout;
142
+ const failure = failureOf(result);
143
+ if (!failure) {
144
+ const first = unavailable[0];
145
+ const merged = {
146
+ ...result,
147
+ metadata: {
148
+ ...result.metadata,
149
+ ...(fanout && !result.metadata.fanout ? { fanout } : {}),
150
+ attempts,
151
+ ...(first
152
+ ? {
153
+ fallbackFrom: first.provider,
154
+ fallbackReason: first.message,
155
+ coverage: { partial: true, unavailable: unavailable.map(({ provider, kind }) => ({ provider, kind })) }
156
+ }
157
+ : {})
158
+ }
159
+ };
160
+ return { ...merged, presentation: buildSearchPresentation(merged) };
161
+ }
162
+ if (!shouldFallBack(failure.kind)) {
163
+ const stopped = { ...result, metadata: { ...result.metadata, attempts } };
164
+ return { ...stopped, presentation: buildSearchPresentation(stopped) };
165
+ }
166
+ for (const entry of unavailableFor(result, failure)) {
167
+ const existing = unavailable.findIndex((u) => u.provider === entry.provider);
168
+ if (existing >= 0)
169
+ unavailable.splice(existing, 1);
170
+ unavailable.push(entry);
171
+ }
172
+ lastFailure = failure;
173
+ }
174
+ const exhausted = {
175
+ status: 'error',
176
+ results: [],
177
+ metadata: { backend: firstBackend ?? 'duckduckgo', cacheHit: false, attempts },
178
+ error: {
179
+ code: 'SEARCH_BACKENDS_UNAVAILABLE',
180
+ message: unavailableMessage(attempts, new Map(unavailable.map((entry) => [entry.provider, entry.message]))),
181
+ failure: lastFailure ?? { kind: 'bad_response' }
182
+ }
183
+ };
184
+ return { ...exhausted, presentation: buildSearchPresentation(exhausted) };
185
+ };
186
+ }
187
+ /**
188
+ * Firecrawl under the policy, with the optional HTTP fallback. The HTTP fetcher
189
+ * talks to the model-chosen site, not a service, so it has no provider health.
190
+ */
191
+ export function withFetchPolicy(primary, fallback, deps, healthKey = 'firecrawl') {
192
+ const finish = (result) => ({ ...result, presentation: buildFetchPresentation(result) });
193
+ return async (input) => {
194
+ const attempts = [];
195
+ const state = deps.health.get(healthKey);
196
+ let first;
197
+ if (state.state !== 'available') {
198
+ attempts.push(skipAttempt('firecrawl', state));
199
+ first = {
200
+ status: 'error',
201
+ url: input.url,
202
+ metadata: { method: 'firecrawl', cacheHit: false },
203
+ error: { code: 'BACKEND_UNAVAILABLE', message: skipMessage('firecrawl', state), failure: state.failure }
204
+ };
205
+ }
206
+ else {
207
+ first = await primary(input);
208
+ let failure = failureOf(first);
209
+ if (failure?.kind === 'transient') {
210
+ attempts.push({ backend: 'firecrawl', outcome: 'retried', failure });
211
+ await (deps.sleep ?? defaultSleep)(retryDelay(deps));
212
+ first = await primary(input);
213
+ failure = failureOf(first);
214
+ }
215
+ if (failure) {
216
+ const detail = detailFor(failure, first.error?.message);
217
+ attempts.push(failedAttempt('firecrawl', failure, deps.health.record(healthKey, failure, detail), detail));
218
+ }
219
+ else
220
+ attempts.push({ backend: 'firecrawl', outcome: first.status === 'ok' ? 'results' : 'empty' });
221
+ }
222
+ const failure = failureOf(first);
223
+ const fallBack = first.status === 'needs_headless' || (failure !== undefined && shouldFallBack(failure.kind));
224
+ if (!fallback || !fallBack) {
225
+ return finish({ ...first, metadata: { ...first.metadata, attempts } });
226
+ }
227
+ const second = await fallback(input);
228
+ attempts.push({ backend: 'http', outcome: second.status === 'ok' ? 'results' : 'failed', ...(second.error?.failure ? { failure: second.error.failure } : {}) });
229
+ return finish({
230
+ ...second,
231
+ metadata: {
232
+ ...second.metadata,
233
+ attempts,
234
+ fallbackFrom: 'firecrawl',
235
+ fallbackReason: first.error?.message ?? 'Firecrawl fetch failed.'
236
+ }
237
+ });
238
+ };
239
+ }
@@ -0,0 +1,21 @@
1
+ import type { FailureInfo, SearchProviderName } from '../types.js';
2
+ export type ClassifiedProvider = SearchProviderName | 'firecrawl';
3
+ export type ResponseParts = {
4
+ status: number;
5
+ headers: Headers;
6
+ /** Parsed JSON body, or undefined when the body was empty or not JSON. */
7
+ json?: unknown;
8
+ };
9
+ export declare class BodyReadError extends Error {
10
+ readonly cause: unknown;
11
+ constructor(cause: unknown);
12
+ }
13
+ /**
14
+ * Reads a response body once, keeping status and headers alongside the parsed JSON.
15
+ * A body that can't be read (connection dropped mid-stream) throws BodyReadError: that's
16
+ * a transport failure, not a malformed response.
17
+ */
18
+ export declare function readResponseParts(response: Response): Promise<ResponseParts & {
19
+ text: string;
20
+ }>;
21
+ export declare function classifyHttpFailure(provider: ClassifiedProvider, parts: ResponseParts, now?: number): FailureInfo;
@@ -0,0 +1,110 @@
1
+ import { parseRetryAfter } from './failure.js';
2
+ export class BodyReadError extends Error {
3
+ cause;
4
+ constructor(cause) {
5
+ super(cause instanceof Error ? cause.message : String(cause));
6
+ this.cause = cause;
7
+ this.name = 'BodyReadError';
8
+ }
9
+ }
10
+ /**
11
+ * Reads a response body once, keeping status and headers alongside the parsed JSON.
12
+ * A body that can't be read (connection dropped mid-stream) throws BodyReadError: that's
13
+ * a transport failure, not a malformed response.
14
+ */
15
+ export async function readResponseParts(response) {
16
+ let text;
17
+ try {
18
+ text = await response.text();
19
+ }
20
+ catch (error) {
21
+ throw new BodyReadError(error);
22
+ }
23
+ let json;
24
+ try {
25
+ json = text ? JSON.parse(text) : undefined;
26
+ }
27
+ catch {
28
+ json = undefined;
29
+ }
30
+ return { status: response.status, headers: response.headers, json, text };
31
+ }
32
+ // Source: https://exa.ai/docs/reference/error-codes
33
+ const EXA_TAGS = {
34
+ RATE_LIMIT_EXCEEDED: 'rate_limited',
35
+ NO_MORE_CREDITS: 'quota_exhausted',
36
+ API_KEY_BUDGET_EXCEEDED: 'quota_exhausted',
37
+ TEAM_BUDGET_EXCEEDED: 'quota_exhausted',
38
+ INVALID_API_KEY: 'auth_failed',
39
+ FEATURE_DISABLED: 'auth_failed',
40
+ PROHIBITED_CONTENT: 'bad_request',
41
+ CONTENT_FILTER_ERROR: 'bad_request',
42
+ INVALID_REQUEST_BODY: 'bad_request',
43
+ INVALID_REQUEST: 'bad_request',
44
+ INVALID_NUM_RESULTS: 'bad_request'
45
+ };
46
+ /**
47
+ * Conservative defaults for undocumented responses. A 401 disables the provider
48
+ * (low risk: resets on config change); an undocumented 403 is treated as a bot
49
+ * wall; an undocumented 402 is not assumed to mean quota.
50
+ */
51
+ function defaultKind(status) {
52
+ if (status === 429)
53
+ return 'rate_limited';
54
+ if (status === 401)
55
+ return 'auth_failed';
56
+ if (status === 403)
57
+ return 'blocked';
58
+ if (status === 400 || status === 422)
59
+ return 'bad_request';
60
+ if (status === 408 || status >= 500)
61
+ return 'transient';
62
+ return 'bad_response';
63
+ }
64
+ function stringField(json, field) {
65
+ if (!json || typeof json !== 'object')
66
+ return undefined;
67
+ const value = json[field];
68
+ return typeof value === 'string' ? value : undefined;
69
+ }
70
+ export function classifyHttpFailure(provider, parts, now = Date.now()) {
71
+ const { status } = parts;
72
+ let kind;
73
+ let providerCode;
74
+ if (provider === 'exa') {
75
+ const tag = stringField(parts.json, 'tag');
76
+ if (tag && EXA_TAGS[tag]) {
77
+ kind = EXA_TAGS[tag];
78
+ providerCode = tag;
79
+ }
80
+ else if (status === 402) {
81
+ kind = 'quota_exhausted'; // documented 402 meaning for Exa
82
+ }
83
+ }
84
+ else if (provider === 'firecrawl') {
85
+ // Source: https://docs.firecrawl.dev/api-reference/errors
86
+ if (status === 402)
87
+ kind = 'quota_exhausted';
88
+ }
89
+ else if (provider === 'youcom') {
90
+ // Source: https://you.com/docs/api-reference/search/v1-search (429 UNVERIFIED -> default)
91
+ if (status === 402)
92
+ kind = 'quota_exhausted';
93
+ else if (status === 403)
94
+ kind = 'auth_failed';
95
+ }
96
+ else if (provider === 'searxng') {
97
+ // Source: https://docs.searxng.org/dev/search_api.html (403 = format=json disabled in settings)
98
+ if (status === 403)
99
+ kind = 'auth_failed';
100
+ }
101
+ // brave, tavily, duckduckgo: defaults only (UNVERIFIED beyond 429; see research gate).
102
+ const resolved = kind ?? defaultKind(status);
103
+ const info = { kind: resolved, httpStatus: status, ...(providerCode ? { providerCode } : {}) };
104
+ if (resolved === 'rate_limited') {
105
+ const retryAfter = parseRetryAfter(parts.headers.get('retry-after'), now);
106
+ if (retryAfter !== undefined)
107
+ info.providerRetryAfterMs = retryAfter;
108
+ }
109
+ return info;
110
+ }
@@ -0,0 +1,29 @@
1
+ import type { FailureInfo } from '../types.js';
2
+ export declare const DEFAULT_COOLDOWN_MS = 60000;
3
+ export declare const MIN_COOLDOWN_MS = 1000;
4
+ export declare const MAX_COOLDOWN_MS: number;
5
+ export type ProviderHealthState = {
6
+ state: 'available';
7
+ } | {
8
+ state: 'cooling_down';
9
+ until: number;
10
+ failure: FailureInfo;
11
+ detail?: string;
12
+ } | {
13
+ state: 'disabled';
14
+ failure: FailureInfo;
15
+ detail?: string;
16
+ };
17
+ export type ProviderHealth = {
18
+ get(key: string): ProviderHealthState;
19
+ record(key: string, failure: FailureInfo, detail?: string): ProviderHealthState;
20
+ };
21
+ /** The applied cooldown. The provider's own value stays in failure.providerRetryAfterMs. */
22
+ export declare function cooldownFor(failure: FailureInfo): number;
23
+ /**
24
+ * Per backend set. The set is rebuilt on any effective config change, so fixed
25
+ * credentials or new endpoints take effect without restarting Pi.
26
+ */
27
+ export declare function createProviderHealth({ now }?: {
28
+ now?: () => number;
29
+ }): ProviderHealth;
@@ -0,0 +1,49 @@
1
+ export const DEFAULT_COOLDOWN_MS = 60_000;
2
+ export const MIN_COOLDOWN_MS = 1000;
3
+ export const MAX_COOLDOWN_MS = 15 * 60_000;
4
+ /** The applied cooldown. The provider's own value stays in failure.providerRetryAfterMs. */
5
+ export function cooldownFor(failure) {
6
+ const raw = failure.providerRetryAfterMs;
7
+ if (raw === undefined || Number.isNaN(raw))
8
+ return DEFAULT_COOLDOWN_MS;
9
+ return Math.min(MAX_COOLDOWN_MS, Math.max(MIN_COOLDOWN_MS, raw));
10
+ }
11
+ /**
12
+ * Per backend set. The set is rebuilt on any effective config change, so fixed
13
+ * credentials or new endpoints take effect without restarting Pi.
14
+ */
15
+ export function createProviderHealth({ now = Date.now } = {}) {
16
+ const states = new Map();
17
+ function get(key) {
18
+ const current = states.get(key);
19
+ if (!current)
20
+ return { state: 'available' };
21
+ if (current.state === 'cooling_down' && current.until <= now()) {
22
+ states.delete(key);
23
+ return { state: 'available' };
24
+ }
25
+ return current;
26
+ }
27
+ function record(key, failure, detail) {
28
+ const current = get(key);
29
+ if (current.state === 'disabled')
30
+ return current;
31
+ switch (failure.kind) {
32
+ case 'rate_limited': {
33
+ const next = { state: 'cooling_down', until: now() + cooldownFor(failure), failure };
34
+ states.set(key, next);
35
+ return next;
36
+ }
37
+ case 'quota_exhausted':
38
+ case 'auth_failed':
39
+ case 'not_configured': {
40
+ const next = { state: 'disabled', failure, ...(detail ? { detail } : {}) };
41
+ states.set(key, next);
42
+ return next;
43
+ }
44
+ default:
45
+ return current;
46
+ }
47
+ }
48
+ return { get, record };
49
+ }
@@ -36,7 +36,20 @@ export declare function validateBackendUrl(value: string): {
36
36
  ok: false;
37
37
  message: string;
38
38
  };
39
- export declare function createBackendUrlEditor(theme: any, label: string, placeholderUrl: string, onOpenChange?: (open: boolean) => void): (currentValue: string, done: (selectedValue?: string) => void) => Component;
39
+ export declare function validateAllowRanges(value: string): {
40
+ ok: true;
41
+ value: string;
42
+ } | {
43
+ ok: false;
44
+ message: string;
45
+ };
46
+ export declare function createBackendUrlEditor(theme: any, label: string, placeholderUrl: string, onOpenChange?: (open: boolean) => void, validate?: (value: string) => {
47
+ ok: true;
48
+ value: string;
49
+ } | {
50
+ ok: false;
51
+ message: string;
52
+ }): (currentValue: string, done: (selectedValue?: string) => void) => Component;
40
53
  export declare function getInheritedConfigForScope(loaded: Awaited<LoadedPresentationConfig>, scope: PresentationScope): PresentationConfig;
41
54
  export declare function getScopeDisplayConfig(loaded: Awaited<LoadedPresentationConfig>, scope: PresentationScope): PresentationConfig;
42
55
  export declare function getInheritedBackendsForScope(loaded: Awaited<LoadedPresentationConfig>, scope: PresentationScope): BackendConfig;