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
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Ollama — a local (or self-hosted) model server, no API key. Always uses
3
+ * `/api/chat` (message-list based) rather than switching between `/generate`
4
+ * and `/chat` per call shape — a single-turn prompt is just a one-message
5
+ * chat, so one code path covers both without the caller needing to know
6
+ * which Ollama endpoint that implies.
7
+ */
8
+ import type { ChatMessage, ProviderCallConfig, RequestOptions } from "../types.js";
9
+ export declare function completeOllama(cfg: ProviderCallConfig, messages: readonly ChatMessage[], options?: RequestOptions): Promise<string>;
10
+ export declare function streamOllama(cfg: ProviderCallConfig, messages: readonly ChatMessage[], onChunk: (fullTextSoFar: string) => void, options?: RequestOptions): Promise<string>;
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Ollama — a local (or self-hosted) model server, no API key. Always uses
3
+ * `/api/chat` (message-list based) rather than switching between `/generate`
4
+ * and `/chat` per call shape — a single-turn prompt is just a one-message
5
+ * chat, so one code path covers both without the caller needing to know
6
+ * which Ollama endpoint that implies.
7
+ */
8
+ import { consumeLines, fetchWithRetry, validateOllamaUrl } from "../http.js";
9
+ const DEFAULT_MAX_TOKENS = 1024;
10
+ function toApiMessages(messages, systemInstruction) {
11
+ const hasSystemTurn = messages.some((m) => m.role === "system");
12
+ const system = !hasSystemTurn && systemInstruction ? [{ role: "system", content: systemInstruction }] : [];
13
+ return [...system, ...messages.map((m) => ({ role: m.role, content: m.content }))];
14
+ }
15
+ function buildBody(cfg, messages, options, stream) {
16
+ return {
17
+ model: cfg.model,
18
+ messages: toApiMessages(messages, options.systemInstruction),
19
+ stream,
20
+ options: { num_predict: options.maxTokens ?? DEFAULT_MAX_TOKENS },
21
+ };
22
+ }
23
+ export async function completeOllama(cfg, messages, options = {}) {
24
+ const baseUrl = validateOllamaUrl(cfg.ollamaUrl);
25
+ const res = await fetchWithRetry(`${baseUrl}/api/chat`, {
26
+ method: "POST",
27
+ headers: { "Content-Type": "application/json" },
28
+ body: JSON.stringify(buildBody(cfg, messages, options, false)),
29
+ }, options.signal);
30
+ if (!res.ok)
31
+ throw new Error(`Ollama error: HTTP ${res.status}. Is Ollama running?`);
32
+ const data = await res.json();
33
+ return data.message?.content ?? "";
34
+ }
35
+ export async function streamOllama(cfg, messages, onChunk, options = {}) {
36
+ const baseUrl = validateOllamaUrl(cfg.ollamaUrl);
37
+ const res = await fetchWithRetry(`${baseUrl}/api/chat`, {
38
+ method: "POST",
39
+ headers: { "Content-Type": "application/json" },
40
+ body: JSON.stringify(buildBody(cfg, messages, options, true)),
41
+ }, options.signal);
42
+ if (!res.ok)
43
+ throw new Error(`Ollama error: HTTP ${res.status}. Is Ollama running?`);
44
+ let full = "";
45
+ await consumeLines(res, (line) => {
46
+ if (!line.trim())
47
+ return;
48
+ try {
49
+ const text = JSON.parse(line).message?.content;
50
+ if (text) {
51
+ full += text;
52
+ onChunk(full);
53
+ }
54
+ }
55
+ catch {
56
+ // Skip a malformed/partial NDJSON line.
57
+ }
58
+ });
59
+ return full;
60
+ }
61
+ //# sourceMappingURL=ollama.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"ollama.js","sourceRoot":"","sources":["../../src/providers/ollama.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,9 @@
1
+ /**
2
+ * OpenAI and Groq — both speak the same chat-completions REST shape, direct
3
+ * from the browser, no SDK. Groq is just the same code pointed at its own
4
+ * base URL (matches how the Python core's GroqProvider relates to
5
+ * OpenAIProvider — same idea, browser side).
6
+ */
7
+ import type { ChatMessage, ProviderCallConfig, RequestOptions } from "../types.js";
8
+ export declare function completeOpenAICompatible(provider: "openai" | "groq", cfg: ProviderCallConfig, messages: readonly ChatMessage[], options?: RequestOptions): Promise<string>;
9
+ export declare function streamOpenAICompatible(provider: "openai" | "groq", cfg: ProviderCallConfig, messages: readonly ChatMessage[], onChunk: (fullTextSoFar: string) => void, options?: RequestOptions): Promise<string>;
@@ -0,0 +1,64 @@
1
+ /**
2
+ * OpenAI and Groq — both speak the same chat-completions REST shape, direct
3
+ * from the browser, no SDK. Groq is just the same code pointed at its own
4
+ * base URL (matches how the Python core's GroqProvider relates to
5
+ * OpenAIProvider — same idea, browser side).
6
+ */
7
+ import { consumeLines, describeHttpError, fetchWithRetry } from "../http.js";
8
+ const DEFAULT_MAX_TOKENS = 1024;
9
+ const BASE_URLS = {
10
+ openai: "https://api.openai.com/v1/chat/completions",
11
+ groq: "https://api.groq.com/openai/v1/chat/completions",
12
+ };
13
+ function toApiMessages(messages, systemInstruction) {
14
+ const hasSystemTurn = messages.some((m) => m.role === "system");
15
+ const system = !hasSystemTurn && systemInstruction ? [{ role: "system", content: systemInstruction }] : [];
16
+ return [...system, ...messages.map((m) => ({ role: m.role, content: m.content }))];
17
+ }
18
+ function buildBody(cfg, messages, options, stream) {
19
+ return {
20
+ model: cfg.model,
21
+ max_tokens: options.maxTokens ?? DEFAULT_MAX_TOKENS,
22
+ messages: toApiMessages(messages, options.systemInstruction),
23
+ ...(options.jsonMode ? { response_format: { type: "json_object" } } : {}),
24
+ ...(stream ? { stream: true } : {}),
25
+ };
26
+ }
27
+ async function request(provider, cfg, messages, options, stream) {
28
+ const res = await fetchWithRetry(BASE_URLS[provider], {
29
+ method: "POST",
30
+ headers: { "Content-Type": "application/json", Authorization: `Bearer ${cfg.apiKey}` },
31
+ body: JSON.stringify(buildBody(cfg, messages, options, stream)),
32
+ }, options.signal);
33
+ if (!res.ok)
34
+ throw new Error(await describeHttpError(res));
35
+ return res;
36
+ }
37
+ export async function completeOpenAICompatible(provider, cfg, messages, options = {}) {
38
+ const res = await request(provider, cfg, messages, options, false);
39
+ const data = await res.json();
40
+ return data.choices?.[0]?.message?.content ?? "";
41
+ }
42
+ export async function streamOpenAICompatible(provider, cfg, messages, onChunk, options = {}) {
43
+ const res = await request(provider, cfg, messages, options, true);
44
+ let full = "";
45
+ await consumeLines(res, (line) => {
46
+ if (!line.startsWith("data: "))
47
+ return;
48
+ const raw = line.slice(6).trim();
49
+ if (raw === "[DONE]")
50
+ return;
51
+ try {
52
+ const delta = JSON.parse(raw).choices?.[0]?.delta?.content;
53
+ if (delta) {
54
+ full += delta;
55
+ onChunk(full);
56
+ }
57
+ }
58
+ catch {
59
+ // Skip a malformed/partial SSE frame.
60
+ }
61
+ });
62
+ return full;
63
+ }
64
+ //# sourceMappingURL=openaiCompatible.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"openaiCompatible.js","sourceRoot":"","sources":["../../src/providers/openaiCompatible.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,18 @@
1
+ /**
2
+ * The provider registry — one canonical source for the metadata every app's
3
+ * settings screen was independently duplicating (name, default model, key
4
+ * placeholder, where to get a key).
5
+ */
6
+ import type { ProviderId, ProviderInfo } from "./types.js";
7
+ export declare const PROVIDERS: Record<ProviderId, ProviderInfo>;
8
+ export declare function isKnownProvider(id: string): id is ProviderId;
9
+ /**
10
+ * A curated, hand-maintained shortlist of current models per vendor, for
11
+ * rendering a model `<select>` instead of a free-text field. Necessarily
12
+ * goes stale as vendors ship new models — update this list by hand when
13
+ * that happens; it isn't fetched from anywhere. `PROVIDERS[id].defaultModel`
14
+ * is always included (first) even if it's ever edited out of this list.
15
+ */
16
+ export declare const MODEL_OPTIONS: Record<ProviderId, readonly string[]>;
17
+ /** `MODEL_OPTIONS[provider]` with `defaultModel` guaranteed present and first. */
18
+ export declare function modelOptionsFor(provider: ProviderId): readonly string[];
@@ -0,0 +1,77 @@
1
+ /**
2
+ * The provider registry — one canonical source for the metadata every app's
3
+ * settings screen was independently duplicating (name, default model, key
4
+ * placeholder, where to get a key).
5
+ */
6
+ export const PROVIDERS = {
7
+ gemini: {
8
+ id: "gemini",
9
+ name: "Google Gemini",
10
+ free: false,
11
+ defaultModel: "gemini-2.0-flash",
12
+ placeholder: "AIza...",
13
+ infoUrl: "https://aistudio.google.com/app/apikey",
14
+ infoText: "Get a free key from Google AI Studio",
15
+ },
16
+ groq: {
17
+ id: "groq",
18
+ name: "Groq",
19
+ free: true,
20
+ defaultModel: "llama-3.1-8b-instant",
21
+ placeholder: "gsk_...",
22
+ infoUrl: "https://console.groq.com/keys",
23
+ infoText: "Get a free key from Groq Console",
24
+ },
25
+ ollama: {
26
+ id: "ollama",
27
+ name: "Ollama (local)",
28
+ free: true,
29
+ noKey: true,
30
+ defaultModel: "llama3.2",
31
+ placeholder: "http://localhost:11434",
32
+ infoUrl: "https://ollama.ai",
33
+ infoText: "Install Ollama on your machine",
34
+ },
35
+ anthropic: {
36
+ id: "anthropic",
37
+ name: "Anthropic Claude",
38
+ free: false,
39
+ defaultModel: "claude-haiku-4-5-20251001",
40
+ placeholder: "sk-ant-...",
41
+ infoUrl: "https://console.anthropic.com/settings/keys",
42
+ infoText: "Get a key from the Anthropic Console",
43
+ },
44
+ openai: {
45
+ id: "openai",
46
+ name: "OpenAI",
47
+ free: false,
48
+ defaultModel: "gpt-4o-mini",
49
+ placeholder: "sk-...",
50
+ infoUrl: "https://platform.openai.com/api-keys",
51
+ infoText: "Get a key from the OpenAI Platform",
52
+ },
53
+ };
54
+ export function isKnownProvider(id) {
55
+ return Object.hasOwn(PROVIDERS, id);
56
+ }
57
+ /**
58
+ * A curated, hand-maintained shortlist of current models per vendor, for
59
+ * rendering a model `<select>` instead of a free-text field. Necessarily
60
+ * goes stale as vendors ship new models — update this list by hand when
61
+ * that happens; it isn't fetched from anywhere. `PROVIDERS[id].defaultModel`
62
+ * is always included (first) even if it's ever edited out of this list.
63
+ */
64
+ export const MODEL_OPTIONS = {
65
+ gemini: ["gemini-2.0-flash", "gemini-2.5-flash", "gemini-2.5-pro"],
66
+ groq: ["llama-3.1-8b-instant", "llama-3.3-70b-versatile", "openai/gpt-oss-120b"],
67
+ ollama: ["llama3.2", "llama3.1", "mistral", "qwen2.5"],
68
+ anthropic: ["claude-haiku-4-5-20251001", "claude-sonnet-4-5", "claude-opus-4-8"],
69
+ openai: ["gpt-4o-mini", "gpt-4o", "gpt-4.1-mini"],
70
+ };
71
+ /** `MODEL_OPTIONS[provider]` with `defaultModel` guaranteed present and first. */
72
+ export function modelOptionsFor(provider) {
73
+ const options = MODEL_OPTIONS[provider];
74
+ const defaultModel = PROVIDERS[provider].defaultModel;
75
+ return options.includes(defaultModel) ? options : [defaultModel, ...options];
76
+ }
77
+ //# sourceMappingURL=registry.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"registry.js","sourceRoot":"","sources":["../src/registry.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAIH,MAAM,CAAC,MAAM,SAAS,GAAqC;IACzD,MAAM,EAAE;QACN,EAAE,EAAE,QAAQ;QACZ,IAAI,EAAE,eAAe;QACrB,IAAI,EAAE,KAAK;QACX,YAAY,EAAE,kBAAkB;QAChC,WAAW,EAAE,SAAS;QACtB,OAAO,EAAE,wCAAwC;QACjD,QAAQ,EAAE,sCAAsC;KACjD;IACD,IAAI,EAAE;QACJ,EAAE,EAAE,MAAM;QACV,IAAI,EAAE,MAAM;QACZ,IAAI,EAAE,IAAI;QACV,YAAY,EAAE,sBAAsB;QACpC,WAAW,EAAE,SAAS;QACtB,OAAO,EAAE,+BAA+B;QACxC,QAAQ,EAAE,kCAAkC;KAC7C;IACD,MAAM,EAAE;QACN,EAAE,EAAE,QAAQ;QACZ,IAAI,EAAE,gBAAgB;QACtB,IAAI,EAAE,IAAI;QACV,KAAK,EAAE,IAAI;QACX,YAAY,EAAE,UAAU;QACxB,WAAW,EAAE,wBAAwB;QACrC,OAAO,EAAE,mBAAmB;QAC5B,QAAQ,EAAE,gCAAgC;KAC3C;IACD,SAAS,EAAE;QACT,EAAE,EAAE,WAAW;QACf,IAAI,EAAE,kBAAkB;QACxB,IAAI,EAAE,KAAK;QACX,YAAY,EAAE,2BAA2B;QACzC,WAAW,EAAE,YAAY;QACzB,OAAO,EAAE,6CAA6C;QACtD,QAAQ,EAAE,sCAAsC;KACjD;IACD,MAAM,EAAE;QACN,EAAE,EAAE,QAAQ;QACZ,IAAI,EAAE,QAAQ;QACd,IAAI,EAAE,KAAK;QACX,YAAY,EAAE,aAAa;QAC3B,WAAW,EAAE,QAAQ;QACrB,OAAO,EAAE,sCAAsC;QAC/C,QAAQ,EAAE,oCAAoC;KAC/C;CACF,CAAC;AAEF,MAAM,UAAU,eAAe,CAAC,EAAU;IACxC,OAAO,MAAM,CAAC,MAAM,CAAC,SAAS,EAAE,EAAE,CAAC,CAAC;AACtC,CAAC;AAED;;;;;;GAMG;AACH,MAAM,CAAC,MAAM,aAAa,GAA0C;IAClE,MAAM,EAAE,CAAC,kBAAkB,EAAE,kBAAkB,EAAE,gBAAgB,CAAC;IAClE,IAAI,EAAE,CAAC,sBAAsB,EAAE,yBAAyB,EAAE,qBAAqB,CAAC;IAChF,MAAM,EAAE,CAAC,UAAU,EAAE,UAAU,EAAE,SAAS,EAAE,SAAS,CAAC;IACtD,SAAS,EAAE,CAAC,2BAA2B,EAAE,mBAAmB,EAAE,iBAAiB,CAAC;IAChF,MAAM,EAAE,CAAC,aAAa,EAAE,QAAQ,EAAE,cAAc,CAAC;CAClD,CAAC;AAEF,kFAAkF;AAClF,MAAM,UAAU,eAAe,CAAC,QAAoB;IAClD,MAAM,OAAO,GAAG,aAAa,CAAC,QAAQ,CAAC,CAAC;IACxC,MAAM,YAAY,GAAG,SAAS,CAAC,QAAQ,CAAC,CAAC,YAAY,CAAC;IACtD,OAAO,OAAO,CAAC,QAAQ,CAAC,YAAY,CAAC,CAAC,CAAC,CAAC,OAAO,CAAC,CAAC,CAAC,CAAC,YAAY,EAAE,GAAG,OAAO,CAAC,CAAC;AAC/E,CAAC"}
@@ -0,0 +1,97 @@
1
+ /**
2
+ * Shared vocabulary for the browser agent.
3
+ *
4
+ * Mirrors the split model-dispatcher's Python core uses: plain, dependency-free
5
+ * value types here, behaviour in `providers/*` and `agent.ts`.
6
+ */
7
+ export type ProviderId = "gemini" | "openai" | "anthropic" | "groq" | "ollama";
8
+ /** Static metadata about a provider — enough to render a settings picker. */
9
+ export interface ProviderInfo {
10
+ readonly id: ProviderId;
11
+ readonly name: string;
12
+ /** Whether this provider has a genuinely free tier (not just "has a trial"). */
13
+ readonly free: boolean;
14
+ /** True for providers that need no API key at all (e.g. a local Ollama). */
15
+ readonly noKey?: boolean;
16
+ readonly defaultModel: string;
17
+ /** UI placeholder text for the key input (e.g. "sk-ant-..."). */
18
+ readonly placeholder: string;
19
+ readonly infoUrl: string;
20
+ readonly infoText: string;
21
+ }
22
+ /**
23
+ * One configured vendor: its model, and the key(s) pooled for it. More than
24
+ * one key is tried in order (e.g. a personal key and a team key) before
25
+ * this vendor is considered exhausted and the dispatcher moves to the next
26
+ * configured provider. Ignored for `provider: "ollama"`, which authenticates
27
+ * via `AgentConfig.ollamaUrl` instead of a key.
28
+ */
29
+ export interface ProviderCredential {
30
+ readonly provider: ProviderId;
31
+ readonly model: string;
32
+ readonly apiKeys: readonly string[];
33
+ }
34
+ /**
35
+ * The full BYOK configuration: an ordered fallback list of vendors (each
36
+ * with its own pooled keys) to try in turn, plus Ollama's local server URL
37
+ * (shared across the whole config — there's only one local Ollama, not one
38
+ * per fallback slot). `dispatch`/`complete`/`streamComplete` walk this list
39
+ * candidate by candidate, mirroring the Python core's fallback chain.
40
+ */
41
+ export interface AgentConfig {
42
+ readonly providers: readonly ProviderCredential[];
43
+ readonly ollamaUrl: string;
44
+ }
45
+ /**
46
+ * One concrete attempt: a specific vendor, model, and (for keyed vendors) a
47
+ * single already-resolved key — what an individual `providers/*` module
48
+ * needs to make one call. Distinct from `AgentConfig`, which holds the whole
49
+ * configured fallback list before the dispatch loop has picked a candidate.
50
+ */
51
+ export interface ProviderCallConfig {
52
+ readonly provider: ProviderId;
53
+ readonly model: string;
54
+ readonly apiKey: string;
55
+ readonly ollamaUrl: string;
56
+ }
57
+ export type ChatRole = "system" | "user" | "assistant";
58
+ export interface ChatMessage {
59
+ readonly role: ChatRole;
60
+ readonly content: string;
61
+ }
62
+ /** Options shared by both complete() and streamComplete()/streamChat(). */
63
+ export interface RequestOptions {
64
+ /** System prompt / instruction, applied via each provider's native mechanism. */
65
+ systemInstruction?: string;
66
+ /** Request the provider's structured-JSON response mode where it has one
67
+ * (OpenAI-compatible providers only — Gemini's `responseMimeType` is
68
+ * always requested by callers that need it via a raw prompt instruction,
69
+ * Anthropic and Ollama have no such mode). */
70
+ jsonMode?: boolean;
71
+ maxTokens?: number;
72
+ /** Cancel the request (e.g. component unmount). Independent of, and
73
+ * merged with, the library's own request timeout. */
74
+ signal?: AbortSignal;
75
+ }
76
+ export interface StreamOptions extends RequestOptions {
77
+ /** Called with the *cumulative* text so far after every chunk, matching
78
+ * every existing app's streaming callback shape. */
79
+ onChunk: (fullTextSoFar: string) => void;
80
+ }
81
+ /** A free, public AI chat web product `openExternalChat` can hand a question
82
+ * off to — the no-API-key escape hatch, distinct from the `ProviderId`s above
83
+ * (which all require a BYOK credential). */
84
+ export type ExternalChatProviderId = "chatgpt" | "claude" | "gemini" | "groq";
85
+ /** Static metadata about one external chat provider. */
86
+ export interface ExternalChatProviderInfo {
87
+ readonly id: ExternalChatProviderId;
88
+ readonly name: string;
89
+ /** Where `openExternalChat` sends the user when there's no known
90
+ * query-prefill parameter for this provider (or as the base URL the
91
+ * parameter is appended to). */
92
+ readonly homeUrl: string;
93
+ /** Build the deep-link URL for a given question, when this provider has a
94
+ * known (unofficial, reverse-engineered) prefill parameter. `null` when it
95
+ * doesn't — `openExternalChat` falls back to `homeUrl` plus the clipboard. */
96
+ readonly buildUrl: ((question: string) => string) | null;
97
+ }
package/dist/types.js ADDED
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Shared vocabulary for the browser agent.
3
+ *
4
+ * Mirrors the split model-dispatcher's Python core uses: plain, dependency-free
5
+ * value types here, behaviour in `providers/*` and `agent.ts`.
6
+ */
7
+ export {};
8
+ //# sourceMappingURL=types.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"types.js","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA;;;;;GAKG"}
package/package.json ADDED
@@ -0,0 +1,40 @@
1
+ {
2
+ "name": "modeldispatcher-browser-agent",
3
+ "version": "0.6.0",
4
+ "description": "Browser-native, bring-your-own-key AI agent core with cross-provider/cross-key fallback dispatch: multi-provider (Gemini/OpenAI/Anthropic/Groq/Ollama), streaming and non-streaming, no backend and no vendor SDK required. The client-side sibling to model-dispatcher for apps with no server to run it on.",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "types": "./dist/index.d.ts",
8
+ "module": "./dist/index.js",
9
+ "exports": {
10
+ ".": {
11
+ "types": "./dist/index.d.ts",
12
+ "import": "./dist/index.js"
13
+ }
14
+ },
15
+ "files": [
16
+ "dist",
17
+ "src"
18
+ ],
19
+ "sideEffects": false,
20
+ "scripts": {
21
+ "build": "tsc -p tsconfig.build.json",
22
+ "typecheck": "tsc --noEmit",
23
+ "test": "vitest run"
24
+ },
25
+ "keywords": [
26
+ "llm",
27
+ "ai-agent",
28
+ "browser",
29
+ "gemini",
30
+ "openai",
31
+ "anthropic",
32
+ "groq",
33
+ "ollama",
34
+ "streaming"
35
+ ],
36
+ "devDependencies": {
37
+ "typescript": "^5.5.0",
38
+ "vitest": "^2.0.0"
39
+ }
40
+ }
package/src/agent.ts ADDED
@@ -0,0 +1,206 @@
1
+ /**
2
+ * The public entry points — now multi-provider: `cfg.providers` is an
3
+ * ordered fallback list (each vendor with one or more pooled keys) instead
4
+ * of a single vendor+key, so a rate limit, bad key, or outage on the first
5
+ * configured provider falls through to the next instead of failing the
6
+ * whole request. Mirrors the Python core's fallback chain, simplified for a
7
+ * browser client with no shared quota/routing state to manage.
8
+ *
9
+ * - complete() — one-shot prompt in, full text back (HighFive, StepByLearn)
10
+ * - streamComplete() — one-shot prompt in, incremental text via onChunk (JobFlowTracker's runStream)
11
+ * - streamChat() — a UI chat history in (auto-normalised via buildMessages), incremental text back (JobFlowTracker's/KanDOne's streamChat)
12
+ *
13
+ * All three dispatch on each candidate's `provider` to the matching
14
+ * provider module; callers never need their own provider switch statement.
15
+ */
16
+
17
+ import { buildMessages, type BuildMessagesOptions } from "./messages.js";
18
+ import { completeAnthropic, streamAnthropic } from "./providers/anthropic.js";
19
+ import { completeGemini, streamGemini } from "./providers/gemini.js";
20
+ import { completeOllama, streamOllama } from "./providers/ollama.js";
21
+ import { completeOpenAICompatible, streamOpenAICompatible } from "./providers/openaiCompatible.js";
22
+ import type {
23
+ AgentConfig,
24
+ ChatMessage,
25
+ ProviderCallConfig,
26
+ RequestOptions,
27
+ StreamOptions,
28
+ } from "./types.js";
29
+
30
+ /** One candidate that failed, kept only to compose
31
+ * {@link AllProvidersExhaustedError}'s message. */
32
+ interface FailedAttempt {
33
+ readonly provider: string;
34
+ readonly error: string;
35
+ }
36
+
37
+ /** `cfg.providers` has nothing usable at all — no vendor has a pooled key,
38
+ * and Ollama isn't configured either. Raised before any network call, the
39
+ * same way the Python core's `NoProviderAvailableError` is. */
40
+ export class NoProviderConfiguredError extends Error {
41
+ constructor() {
42
+ super("No AI provider is configured — add a provider and key (or Ollama) in AI settings.");
43
+ this.name = "NoProviderConfiguredError";
44
+ }
45
+ }
46
+
47
+ /** Every configured provider/key was tried and every one failed. */
48
+ export class AllProvidersExhaustedError extends Error {
49
+ readonly attempts: readonly FailedAttempt[];
50
+
51
+ constructor(attempts: readonly FailedAttempt[]) {
52
+ super(`All configured AI providers failed: ${attempts.map((a) => `${a.provider} (${a.error})`).join("; ")}`);
53
+ this.name = "AllProvidersExhaustedError";
54
+ this.attempts = attempts;
55
+ }
56
+ }
57
+
58
+ /**
59
+ * Flatten the configured provider list into concrete, single-key attempts,
60
+ * in fallback order: each provider's own pooled keys are tried in the order
61
+ * they were added before moving to the next configured provider. Ollama
62
+ * contributes at most one candidate (it authenticates via `cfg.ollamaUrl`,
63
+ * not a key) and only when its own entry is present in the list — a
64
+ * provider with no usable credential is never offered as a candidate at
65
+ * all, the same rule the Python core's registry-building applies.
66
+ */
67
+ function candidatesOf(cfg: AgentConfig): ProviderCallConfig[] {
68
+ const candidates: ProviderCallConfig[] = [];
69
+ for (const cred of cfg.providers) {
70
+ if (cred.provider === "ollama") {
71
+ candidates.push({ provider: "ollama", model: cred.model, apiKey: "", ollamaUrl: cfg.ollamaUrl });
72
+ continue;
73
+ }
74
+ for (const apiKey of cred.apiKeys) {
75
+ if (apiKey) candidates.push({ provider: cred.provider, model: cred.model, apiKey, ollamaUrl: cfg.ollamaUrl });
76
+ }
77
+ }
78
+ return candidates;
79
+ }
80
+
81
+ function messagesOf(promptOrMessages: string | readonly ChatMessage[]): readonly ChatMessage[] {
82
+ return typeof promptOrMessages === "string" ? [{ role: "user", content: promptOrMessages }] : promptOrMessages;
83
+ }
84
+
85
+ function completeOne(
86
+ candidate: ProviderCallConfig,
87
+ messages: readonly ChatMessage[],
88
+ options: RequestOptions,
89
+ ): Promise<string> {
90
+ switch (candidate.provider) {
91
+ case "gemini":
92
+ return completeGemini(candidate, messages, options);
93
+ case "anthropic":
94
+ return completeAnthropic(candidate, messages, options);
95
+ case "ollama":
96
+ return completeOllama(candidate, messages, options);
97
+ case "openai":
98
+ case "groq":
99
+ return completeOpenAICompatible(candidate.provider, candidate, messages, options);
100
+ }
101
+ }
102
+
103
+ /** Non-streaming completion, tried across every configured provider/key in
104
+ * order until one succeeds. `prompt` is wrapped as a single user turn; pass
105
+ * `messages` instead for an already-built multi-turn history.
106
+ *
107
+ * Raises:
108
+ * NoProviderConfiguredError: `cfg.providers` has no usable credential at all.
109
+ * AllProvidersExhaustedError: every candidate was tried and failed;
110
+ * `.attempts` lists each provider and its error.
111
+ */
112
+ export async function complete(
113
+ cfg: AgentConfig,
114
+ promptOrMessages: string | readonly ChatMessage[],
115
+ options: RequestOptions = {},
116
+ ): Promise<string> {
117
+ const candidates = candidatesOf(cfg);
118
+ if (candidates.length === 0) throw new NoProviderConfiguredError();
119
+ const messages = messagesOf(promptOrMessages);
120
+
121
+ const attempts: FailedAttempt[] = [];
122
+ for (const candidate of candidates) {
123
+ try {
124
+ return await completeOne(candidate, messages, options);
125
+ } catch (err) {
126
+ attempts.push({ provider: candidate.provider, error: (err as Error).message });
127
+ }
128
+ }
129
+ throw new AllProvidersExhaustedError(attempts);
130
+ }
131
+
132
+ function streamOne(
133
+ candidate: ProviderCallConfig,
134
+ messages: readonly ChatMessage[],
135
+ onChunk: (fullTextSoFar: string) => void,
136
+ options: RequestOptions,
137
+ ): Promise<string> {
138
+ switch (candidate.provider) {
139
+ case "gemini":
140
+ return streamGemini(candidate, messages, onChunk, options);
141
+ case "anthropic":
142
+ return streamAnthropic(candidate, messages, onChunk, options);
143
+ case "ollama":
144
+ return streamOllama(candidate, messages, onChunk, options);
145
+ case "openai":
146
+ case "groq":
147
+ return streamOpenAICompatible(candidate.provider, candidate, messages, onChunk, options);
148
+ }
149
+ }
150
+
151
+ /**
152
+ * Streaming completion, tried across every configured provider/key in
153
+ * order — but only up to the first chunk actually streamed. Once any text
154
+ * has reached `onChunk` for a candidate, a later failure on that same
155
+ * candidate is surfaced immediately rather than silently retried on the
156
+ * next provider: the caller has already rendered a partial answer, and
157
+ * switching providers mid-stream would duplicate or contradict it rather
158
+ * than complete it.
159
+ *
160
+ * Raises the same errors as {@link complete}.
161
+ */
162
+ export async function streamComplete(
163
+ cfg: AgentConfig,
164
+ promptOrMessages: string | readonly ChatMessage[],
165
+ options: StreamOptions,
166
+ ): Promise<string> {
167
+ const candidates = candidatesOf(cfg);
168
+ if (candidates.length === 0) throw new NoProviderConfiguredError();
169
+ const messages = messagesOf(promptOrMessages);
170
+ const { onChunk, ...rest } = options;
171
+
172
+ const attempts: FailedAttempt[] = [];
173
+ for (const candidate of candidates) {
174
+ let startedStreaming = false;
175
+ try {
176
+ return await streamOne(
177
+ candidate,
178
+ messages,
179
+ (text) => {
180
+ startedStreaming = true;
181
+ onChunk(text);
182
+ },
183
+ rest,
184
+ );
185
+ } catch (err) {
186
+ if (startedStreaming) throw err;
187
+ attempts.push({ provider: candidate.provider, error: (err as Error).message });
188
+ }
189
+ }
190
+ throw new AllProvidersExhaustedError(attempts);
191
+ }
192
+
193
+ /**
194
+ * Streaming multi-turn chat: normalises a raw UI message list via
195
+ * {@link buildMessages} (role validation, alternation, sentinel-dropping)
196
+ * before dispatching — the one thing `streamComplete()` deliberately
197
+ * doesn't do, since its `messages` input is assumed already well-formed.
198
+ */
199
+ export async function streamChat(
200
+ cfg: AgentConfig,
201
+ uiMessages: readonly ChatMessage[],
202
+ options: StreamOptions & BuildMessagesOptions,
203
+ ): Promise<string> {
204
+ const normalised = buildMessages(uiMessages, options);
205
+ return streamComplete(cfg, normalised, options);
206
+ }