modeldispatcher-browser-agent 0.6.0 → 0.6.7

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.
@@ -1,115 +1,124 @@
1
- /**
2
- * "Free agent" escape hatch: hand a question off to a free, public AI chat
3
- * web product instead of calling any API. For a BYOK app, this is what a
4
- * user with no API key configured (or who just doesn't want to set one up)
5
- * falls back to — open one of the well-known consumer chat products in a new
6
- * tab, with the question pre-filled where the product supports it.
7
- *
8
- * The query-prefill URL parameters below are undocumented, reverse-engineered
9
- * conventions (the kind of thing browser address-bar integrations and
10
- * "Ask X" share links rely on) — not a stable public API any vendor promises
11
- * to keep working, and they can change or disappear without notice. Treat
12
- * them as best-effort: `openExternalChat` always copies the question to the
13
- * clipboard too, regardless of whether a prefill parameter exists, so a
14
- * broken/removed parameter still leaves the user able to paste it in by hand
15
- * instead of silently losing their question.
16
- */
17
-
18
- import type { ExternalChatProviderId, ExternalChatProviderInfo } from "./types.js";
19
-
20
- export const EXTERNAL_CHAT_PROVIDERS: Record<ExternalChatProviderId, ExternalChatProviderInfo> = {
21
- chatgpt: {
22
- id: "chatgpt",
23
- name: "ChatGPT",
24
- homeUrl: "https://chatgpt.com/",
25
- buildUrl: (question) => `https://chatgpt.com/?${new URLSearchParams({ q: question, hints: "search" })}`,
26
- },
27
- claude: {
28
- id: "claude",
29
- name: "Claude",
30
- homeUrl: "https://claude.ai/new",
31
- buildUrl: (question) => `https://claude.ai/new?${new URLSearchParams({ q: question })}`,
32
- },
33
- gemini: {
34
- id: "gemini",
35
- name: "Gemini (Google AI Mode)",
36
- homeUrl: "https://www.google.com/",
37
- // gemini.google.com itself has no known prefill parameter. Google
38
- // Search's AI Mode does (`udm=50`), and is the more reliable target for
39
- // "ask Google's AI this" — that's what this actually opens.
40
- buildUrl: (question) => `https://www.google.com/search?${new URLSearchParams({ q: question, udm: "50" })}`,
41
- },
42
- groq: {
43
- id: "groq",
44
- name: "Groq",
45
- homeUrl: "https://groq.com/",
46
- // No known query-prefill parameter for Groq's consumer chat product —
47
- // opens the plain homepage; the question still goes to the clipboard.
48
- buildUrl: null,
49
- },
50
- };
51
-
52
- /** Minimal shape of the two browser APIs this needs — injectable so this is
53
- * testable outside a real browser and swappable for a non-browser host. */
54
- export interface OpenExternalChatDeps {
55
- open?: (url: string) => unknown;
56
- clipboard?: { writeText(text: string): Promise<void> };
57
- }
58
-
59
- export interface OpenExternalChatResult {
60
- readonly provider: ExternalChatProviderId;
61
- readonly url: string;
62
- /** True if this provider has a known prefill parameter and the question
63
- * was actually embedded in `url`. False means `url` is just `homeUrl`. */
64
- readonly prefilled: boolean;
65
- /** True if the question was successfully copied to the clipboard — the
66
- * fallback path a user needs whenever `prefilled` is false, and a useful
67
- * safety net even when it's true. */
68
- readonly copiedToClipboard: boolean;
69
- }
70
-
71
- function defaultOpen(url: string): void {
72
- if (typeof window === "undefined") return;
73
- window.open(url, "_blank", "noopener,noreferrer");
74
- }
75
-
76
- function resolveClipboard(
77
- clipboard?: OpenExternalChatDeps["clipboard"],
78
- ): OpenExternalChatDeps["clipboard"] | undefined {
79
- if (clipboard) return clipboard;
80
- if (typeof navigator !== "undefined" && navigator.clipboard) return navigator.clipboard;
81
- return undefined;
82
- }
83
-
84
- /**
85
- * Open `provider`'s chat with `question`, pre-filled where a prefill
86
- * parameter is known, and always copy `question` to the clipboard as well.
87
- *
88
- * Never throws: a clipboard failure (permissions, a non-secure context, a
89
- * non-browser host) is reported via `copiedToClipboard: false` rather than
90
- * rejecting, since the tab still opens either way.
91
- */
92
- export async function openExternalChat(
93
- provider: ExternalChatProviderId,
94
- question: string,
95
- deps: OpenExternalChatDeps = {},
96
- ): Promise<OpenExternalChatResult> {
97
- const info = EXTERNAL_CHAT_PROVIDERS[provider];
98
- const prefilled = info.buildUrl !== null;
99
- const url = info.buildUrl ? info.buildUrl(question) : info.homeUrl;
100
-
101
- const clipboard = resolveClipboard(deps.clipboard);
102
- let copiedToClipboard = false;
103
- if (clipboard) {
104
- try {
105
- await clipboard.writeText(question);
106
- copiedToClipboard = true;
107
- } catch {
108
- copiedToClipboard = false;
109
- }
110
- }
111
-
112
- (deps.open ?? defaultOpen)(url);
113
-
114
- return { provider, url, prefilled, copiedToClipboard };
115
- }
1
+ /**
2
+ * "Free agent" escape hatch: hand a question off to a free, public AI chat
3
+ * web product instead of calling any API. For a BYOK app, this is what a
4
+ * user with no API key configured (or who just doesn't want to set one up)
5
+ * falls back to — open one of the well-known consumer chat products in a new
6
+ * tab, with the question pre-filled where the product supports it.
7
+ *
8
+ * The query-prefill URL parameters below are undocumented, reverse-engineered
9
+ * conventions (the kind of thing browser address-bar integrations and
10
+ * "Ask X" share links rely on) — not a stable public API any vendor promises
11
+ * to keep working, and they can change or disappear without notice. Treat
12
+ * them as best-effort: `openExternalChat` always copies the question to the
13
+ * clipboard too, regardless of whether a prefill parameter exists, so a
14
+ * broken/removed parameter still leaves the user able to paste it in by hand
15
+ * instead of silently losing their question.
16
+ */
17
+
18
+ import type { ExternalChatProviderId, ExternalChatProviderInfo } from "./types.js";
19
+
20
+ export const EXTERNAL_CHAT_PROVIDERS: Record<ExternalChatProviderId, ExternalChatProviderInfo> = {
21
+ chatgpt: {
22
+ id: "chatgpt",
23
+ name: "ChatGPT",
24
+ homeUrl: "https://chatgpt.com/",
25
+ buildUrl: (question) => `https://chatgpt.com/?${new URLSearchParams({ q: question, hints: "search" })}`,
26
+ },
27
+ claude: {
28
+ id: "claude",
29
+ name: "Claude",
30
+ homeUrl: "https://claude.ai/new",
31
+ buildUrl: (question) => `https://claude.ai/new?${new URLSearchParams({ q: question })}`,
32
+ },
33
+ gemini: {
34
+ id: "gemini",
35
+ name: "Gemini (Google AI Mode)",
36
+ homeUrl: "https://www.google.com/",
37
+ // gemini.google.com itself has no known prefill parameter. Google
38
+ // Search's AI Mode does (`udm=50`), and is the more reliable target for
39
+ // "ask Google's AI this" — that's what this actually opens.
40
+ buildUrl: (question) => `https://www.google.com/search?${new URLSearchParams({ q: question, udm: "50" })}`,
41
+ },
42
+ geminiApp: {
43
+ id: "geminiApp",
44
+ name: "Gemini",
45
+ homeUrl: "https://gemini.google.com/app",
46
+ // The real Gemini app, distinct from the AI-Mode entry above. It has no
47
+ // known query-prefill parameter — opens the plain app; the question
48
+ // still goes to the clipboard.
49
+ buildUrl: null,
50
+ },
51
+ groq: {
52
+ id: "groq",
53
+ name: "Groq",
54
+ homeUrl: "https://groq.com/",
55
+ // No known query-prefill parameter for Groq's consumer chat product —
56
+ // opens the plain homepage; the question still goes to the clipboard.
57
+ buildUrl: null,
58
+ },
59
+ };
60
+
61
+ /** Minimal shape of the two browser APIs this needs — injectable so this is
62
+ * testable outside a real browser and swappable for a non-browser host. */
63
+ export interface OpenExternalChatDeps {
64
+ open?: (url: string) => unknown;
65
+ clipboard?: { writeText(text: string): Promise<void> };
66
+ }
67
+
68
+ export interface OpenExternalChatResult {
69
+ readonly provider: ExternalChatProviderId;
70
+ readonly url: string;
71
+ /** True if this provider has a known prefill parameter and the question
72
+ * was actually embedded in `url`. False means `url` is just `homeUrl`. */
73
+ readonly prefilled: boolean;
74
+ /** True if the question was successfully copied to the clipboard — the
75
+ * fallback path a user needs whenever `prefilled` is false, and a useful
76
+ * safety net even when it's true. */
77
+ readonly copiedToClipboard: boolean;
78
+ }
79
+
80
+ function defaultOpen(url: string): void {
81
+ if (typeof window === "undefined") return;
82
+ window.open(url, "_blank", "noopener,noreferrer");
83
+ }
84
+
85
+ function resolveClipboard(
86
+ clipboard?: OpenExternalChatDeps["clipboard"],
87
+ ): OpenExternalChatDeps["clipboard"] | undefined {
88
+ if (clipboard) return clipboard;
89
+ if (typeof navigator !== "undefined" && navigator.clipboard) return navigator.clipboard;
90
+ return undefined;
91
+ }
92
+
93
+ /**
94
+ * Open `provider`'s chat with `question`, pre-filled where a prefill
95
+ * parameter is known, and always copy `question` to the clipboard as well.
96
+ *
97
+ * Never throws: a clipboard failure (permissions, a non-secure context, a
98
+ * non-browser host) is reported via `copiedToClipboard: false` rather than
99
+ * rejecting, since the tab still opens either way.
100
+ */
101
+ export async function openExternalChat(
102
+ provider: ExternalChatProviderId,
103
+ question: string,
104
+ deps: OpenExternalChatDeps = {},
105
+ ): Promise<OpenExternalChatResult> {
106
+ const info = EXTERNAL_CHAT_PROVIDERS[provider];
107
+ const prefilled = info.buildUrl !== null;
108
+ const url = info.buildUrl ? info.buildUrl(question) : info.homeUrl;
109
+
110
+ const clipboard = resolveClipboard(deps.clipboard);
111
+ let copiedToClipboard = false;
112
+ if (clipboard) {
113
+ try {
114
+ await clipboard.writeText(question);
115
+ copiedToClipboard = true;
116
+ } catch {
117
+ copiedToClipboard = false;
118
+ }
119
+ }
120
+
121
+ (deps.open ?? defaultOpen)(url);
122
+
123
+ return { provider, url, prefilled, copiedToClipboard };
124
+ }
@@ -1,43 +1,43 @@
1
- /**
2
- * Persistence for a visitor's saved "ask externally" favorite.
3
- *
4
- * Deliberately separate from {@link ./config.js}'s `AgentConfig`
5
- * (provider/key/model for BYOK dispatch): picking a favorite here means the
6
- * visitor wants the app to offer opening this free chat product instead of
7
- * calling any API, so it has nothing to do with which provider/key is
8
- * configured for BYOK and must never be conflated with it. A settings screen
9
- * showing both is choosing between two independent, saveable preferences —
10
- * selecting a favorite here never itself opens anything (see
11
- * {@link ./externalChat.js}'s `openExternalChat` for the action that does).
12
- */
13
-
14
- import { EXTERNAL_CHAT_PROVIDERS } from "./externalChat.js";
15
- import { resolveStorage, type ConfigStorage } from "./config.js";
16
- import type { ExternalChatProviderId } from "./types.js";
17
-
18
- export const DEFAULT_EXTERNAL_CHAT_FAVORITE_KEY = "aiExternalChatFavorite";
19
-
20
- function isKnownExternalChatProvider(id: string): id is ExternalChatProviderId {
21
- return Object.hasOwn(EXTERNAL_CHAT_PROVIDERS, id);
22
- }
23
-
24
- /** Read the saved favorite, or `null` if none is set (or the stored value is
25
- * no longer a recognised provider). */
26
- export function loadExternalChatFavorite(
27
- storage?: ConfigStorage,
28
- key: string = DEFAULT_EXTERNAL_CHAT_FAVORITE_KEY,
29
- ): ExternalChatProviderId | null {
30
- const raw = resolveStorage(storage).getItem(key);
31
- return raw !== null && isKnownExternalChatProvider(raw) ? raw : null;
32
- }
33
-
34
- /** Persist the favorite; pass `null` to clear it. */
35
- export function saveExternalChatFavorite(
36
- favorite: ExternalChatProviderId | null,
37
- storage?: ConfigStorage,
38
- key: string = DEFAULT_EXTERNAL_CHAT_FAVORITE_KEY,
39
- ): void {
40
- const store = resolveStorage(storage);
41
- if (favorite === null) store.removeItem(key);
42
- else store.setItem(key, favorite);
43
- }
1
+ /**
2
+ * Persistence for a visitor's saved "ask externally" favorite.
3
+ *
4
+ * Deliberately separate from {@link ./config.js}'s `AgentConfig`
5
+ * (provider/key/model for BYOK dispatch): picking a favorite here means the
6
+ * visitor wants the app to offer opening this free chat product instead of
7
+ * calling any API, so it has nothing to do with which provider/key is
8
+ * configured for BYOK and must never be conflated with it. A settings screen
9
+ * showing both is choosing between two independent, saveable preferences —
10
+ * selecting a favorite here never itself opens anything (see
11
+ * {@link ./externalChat.js}'s `openExternalChat` for the action that does).
12
+ */
13
+
14
+ import { EXTERNAL_CHAT_PROVIDERS } from "./externalChat.js";
15
+ import { resolveStorage, type ConfigStorage } from "./config.js";
16
+ import type { ExternalChatProviderId } from "./types.js";
17
+
18
+ export const DEFAULT_EXTERNAL_CHAT_FAVORITE_KEY = "aiExternalChatFavorite";
19
+
20
+ function isKnownExternalChatProvider(id: string): id is ExternalChatProviderId {
21
+ return Object.hasOwn(EXTERNAL_CHAT_PROVIDERS, id);
22
+ }
23
+
24
+ /** Read the saved favorite, or `null` if none is set (or the stored value is
25
+ * no longer a recognised provider). */
26
+ export function loadExternalChatFavorite(
27
+ storage?: ConfigStorage,
28
+ key: string = DEFAULT_EXTERNAL_CHAT_FAVORITE_KEY,
29
+ ): ExternalChatProviderId | null {
30
+ const raw = resolveStorage(storage).getItem(key);
31
+ return raw !== null && isKnownExternalChatProvider(raw) ? raw : null;
32
+ }
33
+
34
+ /** Persist the favorite; pass `null` to clear it. */
35
+ export function saveExternalChatFavorite(
36
+ favorite: ExternalChatProviderId | null,
37
+ storage?: ConfigStorage,
38
+ key: string = DEFAULT_EXTERNAL_CHAT_FAVORITE_KEY,
39
+ ): void {
40
+ const store = resolveStorage(storage);
41
+ if (favorite === null) store.removeItem(key);
42
+ else store.setItem(key, favorite);
43
+ }
package/src/features.ts CHANGED
@@ -1,40 +1,40 @@
1
- /**
2
- * Per-app opt-in/opt-out for the two things this package supplies. This is a
3
- * developer/deploy-time decision made once when an app is wired up — never a
4
- * switch an end user sees or controls. Both flags default to enabled, so
5
- * adopting the shared framework is opt-OUT per app, not opt-in-everywhere.
6
- *
7
- * This package never reads environment variables itself: every bundler
8
- * exposes them differently (`import.meta.env` for Vite, `process.env` for
9
- * Next.js/CRA), so resolving env vars is left to each app's own config
10
- * module, which passes the result in as `overrides`.
11
- */
12
-
13
- /** Which parts of the shared framework this app has enabled. */
14
- export interface DispatcherFeatureFlags {
15
- /** Render `<ModelPicker>`/`<AskExternallyButton>`, or this app's own settings UI. */
16
- readonly ui: boolean;
17
- /** Route AI calls through `complete()`/`streamComplete()`'s fallback dispatch,
18
- * or this app's own existing call path. */
19
- readonly dispatch: boolean;
20
- }
21
-
22
- /** Both features enabled — the framework's out-of-the-box behavior. */
23
- export const DEFAULT_DISPATCHER_FEATURES: DispatcherFeatureFlags = Object.freeze({
24
- ui: true,
25
- dispatch: true,
26
- });
27
-
28
- /**
29
- * Resolves the effective feature flags for this app: enabled-by-default,
30
- * merged with whatever this app's own config module decided to override.
31
- *
32
- * Call this once at your app's own composition root and pass the result down
33
- * to wherever the UI/dispatch branching actually happens — see
34
- * `docs/USAGE.md` for the full pattern.
35
- */
36
- export function resolveDispatcherFeatures(
37
- overrides?: Partial<DispatcherFeatureFlags>,
38
- ): DispatcherFeatureFlags {
39
- return { ...DEFAULT_DISPATCHER_FEATURES, ...overrides };
40
- }
1
+ /**
2
+ * Per-app opt-in/opt-out for the two things this package supplies. This is a
3
+ * developer/deploy-time decision made once when an app is wired up — never a
4
+ * switch an end user sees or controls. Both flags default to enabled, so
5
+ * adopting the shared framework is opt-OUT per app, not opt-in-everywhere.
6
+ *
7
+ * This package never reads environment variables itself: every bundler
8
+ * exposes them differently (`import.meta.env` for Vite, `process.env` for
9
+ * Next.js/CRA), so resolving env vars is left to each app's own config
10
+ * module, which passes the result in as `overrides`.
11
+ */
12
+
13
+ /** Which parts of the shared framework this app has enabled. */
14
+ export interface DispatcherFeatureFlags {
15
+ /** Render `<ModelPicker>`/`<AskExternallyButton>`, or this app's own settings UI. */
16
+ readonly ui: boolean;
17
+ /** Route AI calls through `complete()`/`streamComplete()`'s fallback dispatch,
18
+ * or this app's own existing call path. */
19
+ readonly dispatch: boolean;
20
+ }
21
+
22
+ /** Both features enabled — the framework's out-of-the-box behavior. */
23
+ export const DEFAULT_DISPATCHER_FEATURES: DispatcherFeatureFlags = Object.freeze({
24
+ ui: true,
25
+ dispatch: true,
26
+ });
27
+
28
+ /**
29
+ * Resolves the effective feature flags for this app: enabled-by-default,
30
+ * merged with whatever this app's own config module decided to override.
31
+ *
32
+ * Call this once at your app's own composition root and pass the result down
33
+ * to wherever the UI/dispatch branching actually happens — see
34
+ * `docs/USAGE.md` for the full pattern.
35
+ */
36
+ export function resolveDispatcherFeatures(
37
+ overrides?: Partial<DispatcherFeatureFlags>,
38
+ ): DispatcherFeatureFlags {
39
+ return { ...DEFAULT_DISPATCHER_FEATURES, ...overrides };
40
+ }
package/src/http.ts CHANGED
@@ -1,100 +1,100 @@
1
- /**
2
- * Shared HTTP plumbing every provider call needs: a request timeout merged
3
- * with the caller's own AbortSignal, one retry on a transient (429/5xx)
4
- * failure, Ollama URL validation, and a line-buffered stream reader so each
5
- * provider's stream parser only has to know its own line format.
6
- */
7
-
8
- export const DEFAULT_TIMEOUT_MS = 60_000;
9
- const RETRY_DELAY_MS = 600;
10
-
11
- /** A hung provider (dropped connection, stalled proxy) would otherwise hang
12
- * forever — fetch() has no default timeout. Merges an optional caller signal
13
- * (e.g. "abort because the component unmounted") with a hard ceiling. */
14
- export function requestSignal(
15
- signal?: AbortSignal,
16
- timeoutMs: number = DEFAULT_TIMEOUT_MS,
17
- ): AbortSignal {
18
- const timeoutSignal = AbortSignal.timeout(timeoutMs);
19
- return signal ? AbortSignal.any([signal, timeoutSignal]) : timeoutSignal;
20
- }
21
-
22
- function isRetryableStatus(status: number): boolean {
23
- return status === 429 || (status >= 500 && status < 600);
24
- }
25
-
26
- /**
27
- * fetch() with one retry on a 429/5xx or a network failure. An
28
- * externally-aborted request (unmount, a newer request superseding this
29
- * one) is never retried — that's a deliberate cancellation, not a
30
- * transient failure, and retrying it would just start a request nobody
31
- * wants the result of anymore.
32
- */
33
- export async function fetchWithRetry(
34
- url: string,
35
- init: RequestInit,
36
- externalSignal?: AbortSignal,
37
- timeoutMs: number = DEFAULT_TIMEOUT_MS,
38
- ): Promise<Response> {
39
- for (let attempt = 0; ; attempt++) {
40
- try {
41
- const res = await fetch(url, { ...init, signal: requestSignal(externalSignal, timeoutMs) });
42
- if (attempt === 0 && isRetryableStatus(res.status)) {
43
- await new Promise((resolve) => setTimeout(resolve, RETRY_DELAY_MS));
44
- continue;
45
- }
46
- return res;
47
- } catch (err) {
48
- if (externalSignal?.aborted || attempt > 0) throw err;
49
- await new Promise((resolve) => setTimeout(resolve, RETRY_DELAY_MS));
50
- }
51
- }
52
- }
53
-
54
- /** Reject a remote Ollama URL that isn't HTTPS (localhost is exempt — that's
55
- * the whole point of running it locally). Returns the normalised origin. */
56
- export function validateOllamaUrl(url: string): string {
57
- let parsed: URL;
58
- try {
59
- parsed = new URL(url);
60
- } catch (err) {
61
- throw new Error(`Invalid Ollama URL: ${(err as Error).message}`, { cause: err });
62
- }
63
- const isLocalhost = ["localhost", "127.0.0.1", "::1"].includes(parsed.hostname);
64
- if (!isLocalhost && parsed.protocol !== "https:") {
65
- throw new Error("Remote Ollama must use HTTPS. For local testing, use http://localhost:11434");
66
- }
67
- return parsed.origin;
68
- }
69
-
70
- /** A 200 with no body (adblocker, misbehaving proxy) would otherwise throw a
71
- * raw TypeError on getReader() deep inside the parser. */
72
- function getBodyReader(res: Response): ReadableStreamDefaultReader<Uint8Array> {
73
- if (!res.body) throw new Error("Empty response body from AI provider.");
74
- return res.body.getReader();
75
- }
76
-
77
- /** Feeds each complete line of a streamed response body to `onLine`, so
78
- * every provider's stream parser only needs to know its own line format
79
- * (SSE "data: " frames vs. bare NDJSON). */
80
- export async function consumeLines(res: Response, onLine: (line: string) => void): Promise<void> {
81
- const reader = getBodyReader(res);
82
- const decoder = new TextDecoder();
83
- let buffer = "";
84
- for (;;) {
85
- const { done, value } = await reader.read();
86
- if (done) break;
87
- buffer += decoder.decode(value, { stream: true });
88
- const lines = buffer.split("\n");
89
- buffer = lines.pop() ?? "";
90
- for (const line of lines) onLine(line);
91
- }
92
- }
93
-
94
- /** Extract a provider's error message from a JSON error body, falling back
95
- * to the raw HTTP status when the body isn't shaped as expected. */
96
- export async function describeHttpError(res: Response): Promise<string> {
97
- const body: unknown = await res.json().catch(() => ({}));
98
- const message = (body as { error?: { message?: string } })?.error?.message;
99
- return message ?? `HTTP ${res.status}`;
100
- }
1
+ /**
2
+ * Shared HTTP plumbing every provider call needs: a request timeout merged
3
+ * with the caller's own AbortSignal, one retry on a transient (429/5xx)
4
+ * failure, Ollama URL validation, and a line-buffered stream reader so each
5
+ * provider's stream parser only has to know its own line format.
6
+ */
7
+
8
+ export const DEFAULT_TIMEOUT_MS = 60_000;
9
+ const RETRY_DELAY_MS = 600;
10
+
11
+ /** A hung provider (dropped connection, stalled proxy) would otherwise hang
12
+ * forever — fetch() has no default timeout. Merges an optional caller signal
13
+ * (e.g. "abort because the component unmounted") with a hard ceiling. */
14
+ export function requestSignal(
15
+ signal?: AbortSignal,
16
+ timeoutMs: number = DEFAULT_TIMEOUT_MS,
17
+ ): AbortSignal {
18
+ const timeoutSignal = AbortSignal.timeout(timeoutMs);
19
+ return signal ? AbortSignal.any([signal, timeoutSignal]) : timeoutSignal;
20
+ }
21
+
22
+ function isRetryableStatus(status: number): boolean {
23
+ return status === 429 || (status >= 500 && status < 600);
24
+ }
25
+
26
+ /**
27
+ * fetch() with one retry on a 429/5xx or a network failure. An
28
+ * externally-aborted request (unmount, a newer request superseding this
29
+ * one) is never retried — that's a deliberate cancellation, not a
30
+ * transient failure, and retrying it would just start a request nobody
31
+ * wants the result of anymore.
32
+ */
33
+ export async function fetchWithRetry(
34
+ url: string,
35
+ init: RequestInit,
36
+ externalSignal?: AbortSignal,
37
+ timeoutMs: number = DEFAULT_TIMEOUT_MS,
38
+ ): Promise<Response> {
39
+ for (let attempt = 0; ; attempt++) {
40
+ try {
41
+ const res = await fetch(url, { ...init, signal: requestSignal(externalSignal, timeoutMs) });
42
+ if (attempt === 0 && isRetryableStatus(res.status)) {
43
+ await new Promise((resolve) => setTimeout(resolve, RETRY_DELAY_MS));
44
+ continue;
45
+ }
46
+ return res;
47
+ } catch (err) {
48
+ if (externalSignal?.aborted || attempt > 0) throw err;
49
+ await new Promise((resolve) => setTimeout(resolve, RETRY_DELAY_MS));
50
+ }
51
+ }
52
+ }
53
+
54
+ /** Reject a remote Ollama URL that isn't HTTPS (localhost is exempt — that's
55
+ * the whole point of running it locally). Returns the normalised origin. */
56
+ export function validateOllamaUrl(url: string): string {
57
+ let parsed: URL;
58
+ try {
59
+ parsed = new URL(url);
60
+ } catch (err) {
61
+ throw new Error(`Invalid Ollama URL: ${(err as Error).message}`, { cause: err });
62
+ }
63
+ const isLocalhost = ["localhost", "127.0.0.1", "::1"].includes(parsed.hostname);
64
+ if (!isLocalhost && parsed.protocol !== "https:") {
65
+ throw new Error("Remote Ollama must use HTTPS. For local testing, use http://localhost:11434");
66
+ }
67
+ return parsed.origin;
68
+ }
69
+
70
+ /** A 200 with no body (adblocker, misbehaving proxy) would otherwise throw a
71
+ * raw TypeError on getReader() deep inside the parser. */
72
+ function getBodyReader(res: Response): ReadableStreamDefaultReader<Uint8Array> {
73
+ if (!res.body) throw new Error("Empty response body from AI provider.");
74
+ return res.body.getReader();
75
+ }
76
+
77
+ /** Feeds each complete line of a streamed response body to `onLine`, so
78
+ * every provider's stream parser only needs to know its own line format
79
+ * (SSE "data: " frames vs. bare NDJSON). */
80
+ export async function consumeLines(res: Response, onLine: (line: string) => void): Promise<void> {
81
+ const reader = getBodyReader(res);
82
+ const decoder = new TextDecoder();
83
+ let buffer = "";
84
+ for (;;) {
85
+ const { done, value } = await reader.read();
86
+ if (done) break;
87
+ buffer += decoder.decode(value, { stream: true });
88
+ const lines = buffer.split("\n");
89
+ buffer = lines.pop() ?? "";
90
+ for (const line of lines) onLine(line);
91
+ }
92
+ }
93
+
94
+ /** Extract a provider's error message from a JSON error body, falling back
95
+ * to the raw HTTP status when the body isn't shaped as expected. */
96
+ export async function describeHttpError(res: Response): Promise<string> {
97
+ const body: unknown = await res.json().catch(() => ({}));
98
+ const message = (body as { error?: { message?: string } })?.error?.message;
99
+ return message ?? `HTTP ${res.status}`;
100
+ }