modeldispatcher-browser-agent 0.6.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 (58) hide show
  1. package/README.md +203 -0
  2. package/dist/agent.d.ts +64 -0
  3. package/dist/agent.js +160 -0
  4. package/dist/agent.js.map +1 -0
  5. package/dist/config.d.ts +43 -0
  6. package/dist/config.js +91 -0
  7. package/dist/config.js.map +1 -0
  8. package/dist/externalChat.d.ts +46 -0
  9. package/dist/externalChat.js +86 -0
  10. package/dist/externalChat.js.map +1 -0
  11. package/dist/externalChatFavorite.d.ts +20 -0
  12. package/dist/externalChatFavorite.js +33 -0
  13. package/dist/externalChatFavorite.js.map +1 -0
  14. package/dist/features.d.ts +30 -0
  15. package/dist/features.js +28 -0
  16. package/dist/features.js.map +1 -0
  17. package/dist/http.d.ts +29 -0
  18. package/dist/http.js +91 -0
  19. package/dist/http.js.map +1 -0
  20. package/dist/index.d.ts +22 -0
  21. package/dist/index.js +18 -0
  22. package/dist/index.js.map +1 -0
  23. package/dist/messages.d.ts +26 -0
  24. package/dist/messages.js +41 -0
  25. package/dist/messages.js.map +1 -0
  26. package/dist/providers/anthropic.d.ts +10 -0
  27. package/dist/providers/anthropic.js +64 -0
  28. package/dist/providers/anthropic.js.map +1 -0
  29. package/dist/providers/gemini.d.ts +8 -0
  30. package/dist/providers/gemini.js +66 -0
  31. package/dist/providers/gemini.js.map +1 -0
  32. package/dist/providers/ollama.d.ts +10 -0
  33. package/dist/providers/ollama.js +61 -0
  34. package/dist/providers/ollama.js.map +1 -0
  35. package/dist/providers/openaiCompatible.d.ts +9 -0
  36. package/dist/providers/openaiCompatible.js +64 -0
  37. package/dist/providers/openaiCompatible.js.map +1 -0
  38. package/dist/registry.d.ts +18 -0
  39. package/dist/registry.js +77 -0
  40. package/dist/registry.js.map +1 -0
  41. package/dist/types.d.ts +97 -0
  42. package/dist/types.js +8 -0
  43. package/dist/types.js.map +1 -0
  44. package/package.json +40 -0
  45. package/src/agent.ts +206 -0
  46. package/src/config.ts +117 -0
  47. package/src/externalChat.ts +115 -0
  48. package/src/externalChatFavorite.ts +43 -0
  49. package/src/features.ts +40 -0
  50. package/src/http.ts +100 -0
  51. package/src/index.ts +52 -0
  52. package/src/messages.ts +61 -0
  53. package/src/providers/anthropic.ts +86 -0
  54. package/src/providers/gemini.ts +95 -0
  55. package/src/providers/ollama.ts +81 -0
  56. package/src/providers/openaiCompatible.ts +89 -0
  57. package/src/registry.ts +82 -0
  58. package/src/types.ts +108 -0
package/src/config.ts ADDED
@@ -0,0 +1,117 @@
1
+ /**
2
+ * BYOK config persistence: the whole `AgentConfig` (an ordered fallback list
3
+ * of providers, each with its own pooled keys, plus the shared Ollama URL)
4
+ * as one JSON blob under one storage key — simpler than one key per field
5
+ * now that the config is a list rather than a single flat selection, and
6
+ * the key name/storage itself stay overridable so an app can keep its own
7
+ * naming without forking the loader logic.
8
+ */
9
+
10
+ import { PROVIDERS, isKnownProvider } from "./registry.js";
11
+ import type { AgentConfig, ProviderCredential, ProviderId } from "./types.js";
12
+
13
+ /** Minimal Web Storage shape — matches `localStorage`, injectable for tests
14
+ * or for a non-browser host (e.g. a React Native AsyncStorage shim). */
15
+ export interface ConfigStorage {
16
+ getItem(key: string): string | null;
17
+ setItem(key: string, value: string): void;
18
+ removeItem(key: string): void;
19
+ }
20
+
21
+ export interface ConfigKeys {
22
+ /** Single key holding the whole config as JSON. */
23
+ config: string;
24
+ }
25
+
26
+ export const DEFAULT_CONFIG_KEYS: ConfigKeys = {
27
+ config: "aiConfig",
28
+ };
29
+
30
+ const DEFAULT_OLLAMA_URL = "http://localhost:11434";
31
+
32
+ /** Resolve an explicit storage, or fall back to `localStorage` in a browser.
33
+ * Shared with {@link ./externalChatFavorite.js} so both persistence modules
34
+ * agree on where "no storage available" should throw. */
35
+ export function resolveStorage(storage?: ConfigStorage): ConfigStorage {
36
+ if (storage) return storage;
37
+ if (typeof localStorage !== "undefined") return localStorage;
38
+ throw new Error(
39
+ "No storage available: pass a ConfigStorage explicitly outside a browser.",
40
+ );
41
+ }
42
+
43
+ function sanitizeCredential(raw: unknown): ProviderCredential | null {
44
+ if (typeof raw !== "object" || raw === null) return null;
45
+ const { provider, model, apiKeys } = raw as Record<string, unknown>;
46
+ if (typeof provider !== "string" || !isKnownProvider(provider)) return null;
47
+
48
+ const keys = Array.isArray(apiKeys)
49
+ ? apiKeys.filter((k): k is string => typeof k === "string" && k.trim().length > 0).map((k) => k.trim())
50
+ : [];
51
+ const resolvedModel = typeof model === "string" && model.trim() ? model.trim() : PROVIDERS[provider].defaultModel;
52
+
53
+ return { provider, model: resolvedModel, apiKeys: keys };
54
+ }
55
+
56
+ /** Read the configured provider fallback list + Ollama URL from storage.
57
+ * A missing, empty, or corrupt blob resolves to an empty provider list
58
+ * (nothing configured yet) rather than throwing — the same "still runs,
59
+ * just answers no-credential" posture the rest of this package uses. */
60
+ export function loadConfig(
61
+ storage?: ConfigStorage,
62
+ keys: ConfigKeys = DEFAULT_CONFIG_KEYS,
63
+ ): AgentConfig {
64
+ const store = resolveStorage(storage);
65
+ const raw = store.getItem(keys.config);
66
+ if (!raw) return { providers: [], ollamaUrl: DEFAULT_OLLAMA_URL };
67
+
68
+ let parsed: unknown;
69
+ try {
70
+ parsed = JSON.parse(raw);
71
+ } catch {
72
+ return { providers: [], ollamaUrl: DEFAULT_OLLAMA_URL };
73
+ }
74
+ if (typeof parsed !== "object" || parsed === null) return { providers: [], ollamaUrl: DEFAULT_OLLAMA_URL };
75
+
76
+ const { providers, ollamaUrl } = parsed as Record<string, unknown>;
77
+ const sanitized = Array.isArray(providers)
78
+ ? providers.map(sanitizeCredential).filter((c): c is ProviderCredential => c !== null)
79
+ : [];
80
+ const resolvedOllamaUrl = typeof ollamaUrl === "string" && ollamaUrl.trim() ? ollamaUrl.trim() : DEFAULT_OLLAMA_URL;
81
+
82
+ return { providers: sanitized, ollamaUrl: resolvedOllamaUrl };
83
+ }
84
+
85
+ /** Persist the full config, replacing whatever was stored before — callers
86
+ * (e.g. `ModelPicker`) hold the authoritative in-memory list and pass the
87
+ * whole thing on every change, the same controlled-component pattern the
88
+ * React layer uses. */
89
+ export function saveConfig(config: AgentConfig, storage?: ConfigStorage, keys: ConfigKeys = DEFAULT_CONFIG_KEYS): void {
90
+ const store = resolveStorage(storage);
91
+ const cleaned: AgentConfig = {
92
+ providers: config.providers.map((cred) => ({
93
+ provider: cred.provider,
94
+ model: cred.model.trim(),
95
+ apiKeys: cred.apiKeys.map((k) => k.trim()).filter((k) => k.length > 0),
96
+ })),
97
+ ollamaUrl: config.ollamaUrl.trim() || DEFAULT_OLLAMA_URL,
98
+ };
99
+ store.setItem(keys.config, JSON.stringify(cleaned));
100
+ }
101
+
102
+ export function clearConfig(storage?: ConfigStorage, keys: ConfigKeys = DEFAULT_CONFIG_KEYS): void {
103
+ resolveStorage(storage).removeItem(keys.config);
104
+ }
105
+
106
+ /** Whether `cfg` has at least one usable candidate to dispatch to — Ollama
107
+ * (no key needed) or any vendor with at least one pooled key. Mirrors
108
+ * `candidatesOf` in `agent.ts` without needing to import its internals. */
109
+ export function isConfigReady(cfg: AgentConfig): boolean {
110
+ return cfg.providers.some((cred) => cred.provider === "ollama" || cred.apiKeys.length > 0);
111
+ }
112
+
113
+ /** True when `provider` isn't already in `cfg.providers` — for an "Add
114
+ * provider" control to only offer vendors not yet configured. */
115
+ export function isProviderConfigured(cfg: AgentConfig, provider: ProviderId): boolean {
116
+ return cfg.providers.some((cred) => cred.provider === provider);
117
+ }
@@ -0,0 +1,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
+ 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
+ }
@@ -0,0 +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
+ }
@@ -0,0 +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
+ }
package/src/http.ts ADDED
@@ -0,0 +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
+ }
package/src/index.ts ADDED
@@ -0,0 +1,52 @@
1
+ /**
2
+ * modeldispatcher-browser-agent — public API surface.
3
+ *
4
+ * A browser-native, bring-your-own-key AI agent core: the client-side
5
+ * sibling to `model-dispatcher` for apps with no backend to run the Python
6
+ * gateway on. Talks directly to Gemini/OpenAI/Anthropic/Groq/Ollama from the
7
+ * browser, using whatever key the end user supplies — no vendor SDK, no
8
+ * server, nothing sent anywhere but the provider itself.
9
+ */
10
+
11
+ export {
12
+ AllProvidersExhaustedError,
13
+ complete,
14
+ NoProviderConfiguredError,
15
+ streamChat,
16
+ streamComplete,
17
+ } from "./agent.js";
18
+ export {
19
+ clearConfig,
20
+ DEFAULT_CONFIG_KEYS,
21
+ isConfigReady,
22
+ isProviderConfigured,
23
+ loadConfig,
24
+ resolveStorage,
25
+ saveConfig,
26
+ } from "./config.js";
27
+ export type { ConfigKeys, ConfigStorage } from "./config.js";
28
+ export { DEFAULT_TIMEOUT_MS, describeHttpError, validateOllamaUrl } from "./http.js";
29
+ export { EXTERNAL_CHAT_PROVIDERS, openExternalChat } from "./externalChat.js";
30
+ export type { OpenExternalChatDeps, OpenExternalChatResult } from "./externalChat.js";
31
+ export {
32
+ DEFAULT_EXTERNAL_CHAT_FAVORITE_KEY,
33
+ loadExternalChatFavorite,
34
+ saveExternalChatFavorite,
35
+ } from "./externalChatFavorite.js";
36
+ export { DEFAULT_DISPATCHER_FEATURES, resolveDispatcherFeatures } from "./features.js";
37
+ export type { DispatcherFeatureFlags } from "./features.js";
38
+ export { buildMessages } from "./messages.js";
39
+ export type { BuildMessagesOptions } from "./messages.js";
40
+ export { isKnownProvider, modelOptionsFor, MODEL_OPTIONS, PROVIDERS } from "./registry.js";
41
+ export type {
42
+ AgentConfig,
43
+ ChatMessage,
44
+ ChatRole,
45
+ ExternalChatProviderId,
46
+ ExternalChatProviderInfo,
47
+ ProviderCredential,
48
+ ProviderId,
49
+ ProviderInfo,
50
+ RequestOptions,
51
+ StreamOptions,
52
+ } from "./types.js";
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Normalise a UI's chat history into a provider-safe message list: strict
3
+ * role validation, length capping, and merging consecutive same-role
4
+ * messages — Anthropic and Gemini both require strict user/assistant
5
+ * alternation starting with "user", which raw UI state doesn't guarantee.
6
+ */
7
+
8
+ import type { ChatMessage, ChatRole } from "./types.js";
9
+
10
+ const VALID_TURN_ROLES = new Set<ChatRole>(["user", "assistant"]);
11
+ const MAX_MESSAGE_LENGTH = 4000;
12
+
13
+ export interface BuildMessagesOptions {
14
+ /** If true, always append a trailing filler user turn (e.g. to kick off a
15
+ * greeting flow) instead of only when the history would otherwise start
16
+ * with "assistant". */
17
+ forceTrailingFiller?: boolean;
18
+ /** The filler turn's content when one is needed to satisfy user-first
19
+ * ordering. Defaults to "begin". */
20
+ fillerContent?: string;
21
+ /** Marker content to drop entirely rather than treat as a real turn
22
+ * (e.g. a UI-internal "simulate opening" sentinel). */
23
+ dropContent?: string;
24
+ }
25
+
26
+ /**
27
+ * Build a provider-safe chat history from UI messages: validates/coerces
28
+ * roles, caps message length, drops sentinel content, ensures user-first
29
+ * ordering, and merges consecutive same-role turns (required by
30
+ * Anthropic/Gemini's strict alternation).
31
+ */
32
+ export function buildMessages(
33
+ uiMessages: readonly ChatMessage[],
34
+ options: BuildMessagesOptions = {},
35
+ ): ChatMessage[] {
36
+ const { forceTrailingFiller = false, fillerContent = "begin", dropContent } = options;
37
+
38
+ let messages = uiMessages
39
+ .map(({ role, content }): ChatMessage => ({
40
+ role: VALID_TURN_ROLES.has(role) ? role : "user",
41
+ content: String(content ?? "").trim().slice(0, MAX_MESSAGE_LENGTH),
42
+ }))
43
+ .filter((m) => m.content.length > 0 && (dropContent === undefined || m.content !== dropContent));
44
+
45
+ if (forceTrailingFiller) {
46
+ messages = [...messages, { role: "user", content: fillerContent }];
47
+ } else if (messages.length > 0 && messages[0]?.role === "assistant") {
48
+ messages = [{ role: "user", content: fillerContent }, ...messages];
49
+ }
50
+
51
+ const merged: ChatMessage[] = [];
52
+ for (const message of messages) {
53
+ const last = merged.at(-1);
54
+ if (last && last.role === message.role) {
55
+ merged[merged.length - 1] = { role: last.role, content: `${last.content}\n\n${message.content}` };
56
+ } else {
57
+ merged.push(message);
58
+ }
59
+ }
60
+ return merged.length > 0 ? merged : [{ role: "user", content: fillerContent }];
61
+ }
@@ -0,0 +1,86 @@
1
+ /**
2
+ * Anthropic Claude — direct browser call via raw fetch, no SDK dependency.
3
+ * `anthropic-dangerous-direct-browser-access` is the header the official SDK
4
+ * sends under `dangerouslyAllowBrowser` — acceptable here because the user
5
+ * supplies their own key on their own machine; there is no backend to hide
6
+ * it behind in the first place.
7
+ */
8
+
9
+ import { consumeLines, describeHttpError, fetchWithRetry } from "../http.js";
10
+ import type { ChatMessage, ProviderCallConfig, RequestOptions } from "../types.js";
11
+
12
+ const DEFAULT_MAX_TOKENS = 1024;
13
+ const ANTHROPIC_URL = "https://api.anthropic.com/v1/messages";
14
+
15
+ function toApiMessages(messages: readonly ChatMessage[]) {
16
+ return messages.filter((m) => m.role !== "system").map((m) => ({ role: m.role, content: m.content }));
17
+ }
18
+
19
+ function systemOf(messages: readonly ChatMessage[], explicit?: string): string | undefined {
20
+ return explicit ?? messages.find((m) => m.role === "system")?.content;
21
+ }
22
+
23
+ function buildBody(cfg: ProviderCallConfig, messages: readonly ChatMessage[], options: RequestOptions, stream: boolean) {
24
+ const system = systemOf(messages, options.systemInstruction);
25
+ return {
26
+ model: cfg.model,
27
+ max_tokens: options.maxTokens ?? DEFAULT_MAX_TOKENS,
28
+ ...(system ? { system } : {}),
29
+ messages: toApiMessages(messages),
30
+ ...(stream ? { stream: true } : {}),
31
+ };
32
+ }
33
+
34
+ function headers(cfg: ProviderCallConfig): HeadersInit {
35
+ return {
36
+ "Content-Type": "application/json",
37
+ "x-api-key": cfg.apiKey,
38
+ "anthropic-version": "2023-06-01",
39
+ "anthropic-dangerous-direct-browser-access": "true",
40
+ };
41
+ }
42
+
43
+ export async function completeAnthropic(
44
+ cfg: ProviderCallConfig,
45
+ messages: readonly ChatMessage[],
46
+ options: RequestOptions = {},
47
+ ): Promise<string> {
48
+ const res = await fetchWithRetry(
49
+ ANTHROPIC_URL,
50
+ { method: "POST", headers: headers(cfg), body: JSON.stringify(buildBody(cfg, messages, options, false)) },
51
+ options.signal,
52
+ );
53
+ if (!res.ok) throw new Error(await describeHttpError(res));
54
+ const data = await res.json();
55
+ return data.content?.[0]?.text ?? "";
56
+ }
57
+
58
+ export async function streamAnthropic(
59
+ cfg: ProviderCallConfig,
60
+ messages: readonly ChatMessage[],
61
+ onChunk: (fullTextSoFar: string) => void,
62
+ options: RequestOptions = {},
63
+ ): Promise<string> {
64
+ const res = await fetchWithRetry(
65
+ ANTHROPIC_URL,
66
+ { method: "POST", headers: headers(cfg), body: JSON.stringify(buildBody(cfg, messages, options, true)) },
67
+ options.signal,
68
+ );
69
+ if (!res.ok) throw new Error(await describeHttpError(res));
70
+
71
+ let full = "";
72
+ await consumeLines(res, (line) => {
73
+ if (!line.startsWith("data: ")) return;
74
+ const raw = line.slice(6).trim();
75
+ try {
76
+ const event = JSON.parse(raw);
77
+ if (event.type === "content_block_delta" && event.delta?.type === "text_delta") {
78
+ full += event.delta.text;
79
+ onChunk(full);
80
+ }
81
+ } catch {
82
+ // Skip a malformed/partial SSE frame.
83
+ }
84
+ });
85
+ return full;
86
+ }