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,95 @@
1
+ /**
2
+ * Google Gemini — direct browser call, no SDK. The key travels in a header
3
+ * (`x-goog-api-key`) rather than the `?key=` query string: a URL ends up in
4
+ * proxy/server access logs and browser history, a header does not.
5
+ */
6
+
7
+ import { consumeLines, describeHttpError, fetchWithRetry } from "../http.js";
8
+ import type { ChatMessage, ProviderCallConfig, RequestOptions } from "../types.js";
9
+
10
+ const DEFAULT_MAX_TOKENS = 1024;
11
+
12
+ interface GeminiPart {
13
+ text: string;
14
+ }
15
+ interface GeminiContent {
16
+ role: "user" | "model";
17
+ parts: GeminiPart[];
18
+ }
19
+
20
+ function toContents(messages: readonly ChatMessage[]): GeminiContent[] {
21
+ return messages
22
+ .filter((m) => m.role !== "system")
23
+ .map((m) => ({ role: m.role === "assistant" ? "model" : "user", parts: [{ text: m.content }] }));
24
+ }
25
+
26
+ function systemInstructionOf(messages: readonly ChatMessage[], explicit?: string): string | undefined {
27
+ return explicit ?? messages.find((m) => m.role === "system")?.content;
28
+ }
29
+
30
+ function buildBody(messages: readonly ChatMessage[], options: RequestOptions) {
31
+ const system = systemInstructionOf(messages, options.systemInstruction);
32
+ return {
33
+ contents: toContents(messages),
34
+ generationConfig: {
35
+ maxOutputTokens: options.maxTokens ?? DEFAULT_MAX_TOKENS,
36
+ ...(options.jsonMode ? { responseMimeType: "application/json" } : {}),
37
+ },
38
+ ...(system ? { systemInstruction: { parts: [{ text: system }] } } : {}),
39
+ };
40
+ }
41
+
42
+ export async function completeGemini(
43
+ cfg: ProviderCallConfig,
44
+ messages: readonly ChatMessage[],
45
+ options: RequestOptions = {},
46
+ ): Promise<string> {
47
+ const url = `https://generativelanguage.googleapis.com/v1beta/models/${encodeURIComponent(cfg.model)}:generateContent`;
48
+ const res = await fetchWithRetry(
49
+ url,
50
+ {
51
+ method: "POST",
52
+ headers: { "Content-Type": "application/json", "x-goog-api-key": cfg.apiKey },
53
+ body: JSON.stringify(buildBody(messages, options)),
54
+ },
55
+ options.signal,
56
+ );
57
+ if (!res.ok) throw new Error(await describeHttpError(res));
58
+ const data = await res.json();
59
+ return data.candidates?.[0]?.content?.parts?.[0]?.text ?? "";
60
+ }
61
+
62
+ export async function streamGemini(
63
+ cfg: ProviderCallConfig,
64
+ messages: readonly ChatMessage[],
65
+ onChunk: (fullTextSoFar: string) => void,
66
+ options: RequestOptions = {},
67
+ ): Promise<string> {
68
+ const url = `https://generativelanguage.googleapis.com/v1beta/models/${encodeURIComponent(cfg.model)}:streamGenerateContent?alt=sse`;
69
+ const res = await fetchWithRetry(
70
+ url,
71
+ {
72
+ method: "POST",
73
+ headers: { "Content-Type": "application/json", "x-goog-api-key": cfg.apiKey },
74
+ body: JSON.stringify(buildBody(messages, options)),
75
+ },
76
+ options.signal,
77
+ );
78
+ if (!res.ok) throw new Error(await describeHttpError(res));
79
+
80
+ let full = "";
81
+ await consumeLines(res, (line) => {
82
+ if (!line.startsWith("data: ")) return;
83
+ const raw = line.slice(6).trim();
84
+ try {
85
+ const text = JSON.parse(raw).candidates?.[0]?.content?.parts?.[0]?.text;
86
+ if (text) {
87
+ full += text;
88
+ onChunk(full);
89
+ }
90
+ } catch {
91
+ // Skip a malformed/partial SSE frame — the next line may complete it.
92
+ }
93
+ });
94
+ return full;
95
+ }
@@ -0,0 +1,81 @@
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
+
9
+ import { consumeLines, fetchWithRetry, validateOllamaUrl } from "../http.js";
10
+ import type { ChatMessage, ProviderCallConfig, RequestOptions } from "../types.js";
11
+
12
+ const DEFAULT_MAX_TOKENS = 1024;
13
+
14
+ function toApiMessages(messages: readonly ChatMessage[], systemInstruction?: string) {
15
+ const hasSystemTurn = messages.some((m) => m.role === "system");
16
+ const system = !hasSystemTurn && systemInstruction ? [{ role: "system", content: systemInstruction }] : [];
17
+ return [...system, ...messages.map((m) => ({ role: m.role, content: m.content }))];
18
+ }
19
+
20
+ function buildBody(cfg: ProviderCallConfig, messages: readonly ChatMessage[], options: RequestOptions, stream: boolean) {
21
+ return {
22
+ model: cfg.model,
23
+ messages: toApiMessages(messages, options.systemInstruction),
24
+ stream,
25
+ options: { num_predict: options.maxTokens ?? DEFAULT_MAX_TOKENS },
26
+ };
27
+ }
28
+
29
+ export async function completeOllama(
30
+ cfg: ProviderCallConfig,
31
+ messages: readonly ChatMessage[],
32
+ options: RequestOptions = {},
33
+ ): Promise<string> {
34
+ const baseUrl = validateOllamaUrl(cfg.ollamaUrl);
35
+ const res = await fetchWithRetry(
36
+ `${baseUrl}/api/chat`,
37
+ {
38
+ method: "POST",
39
+ headers: { "Content-Type": "application/json" },
40
+ body: JSON.stringify(buildBody(cfg, messages, options, false)),
41
+ },
42
+ options.signal,
43
+ );
44
+ if (!res.ok) throw new Error(`Ollama error: HTTP ${res.status}. Is Ollama running?`);
45
+ const data = await res.json();
46
+ return data.message?.content ?? "";
47
+ }
48
+
49
+ export async function streamOllama(
50
+ cfg: ProviderCallConfig,
51
+ messages: readonly ChatMessage[],
52
+ onChunk: (fullTextSoFar: string) => void,
53
+ options: RequestOptions = {},
54
+ ): Promise<string> {
55
+ const baseUrl = validateOllamaUrl(cfg.ollamaUrl);
56
+ const res = await fetchWithRetry(
57
+ `${baseUrl}/api/chat`,
58
+ {
59
+ method: "POST",
60
+ headers: { "Content-Type": "application/json" },
61
+ body: JSON.stringify(buildBody(cfg, messages, options, true)),
62
+ },
63
+ options.signal,
64
+ );
65
+ if (!res.ok) throw new Error(`Ollama error: HTTP ${res.status}. Is Ollama running?`);
66
+
67
+ let full = "";
68
+ await consumeLines(res, (line) => {
69
+ if (!line.trim()) return;
70
+ try {
71
+ const text = JSON.parse(line).message?.content;
72
+ if (text) {
73
+ full += text;
74
+ onChunk(full);
75
+ }
76
+ } catch {
77
+ // Skip a malformed/partial NDJSON line.
78
+ }
79
+ });
80
+ return full;
81
+ }
@@ -0,0 +1,89 @@
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
+
8
+ import { consumeLines, describeHttpError, fetchWithRetry } from "../http.js";
9
+ import type { ChatMessage, ProviderCallConfig, RequestOptions } from "../types.js";
10
+
11
+ const DEFAULT_MAX_TOKENS = 1024;
12
+
13
+ const BASE_URLS: Record<"openai" | "groq", string> = {
14
+ openai: "https://api.openai.com/v1/chat/completions",
15
+ groq: "https://api.groq.com/openai/v1/chat/completions",
16
+ };
17
+
18
+ function toApiMessages(messages: readonly ChatMessage[], systemInstruction?: string) {
19
+ const hasSystemTurn = messages.some((m) => m.role === "system");
20
+ const system = !hasSystemTurn && systemInstruction ? [{ role: "system", content: systemInstruction }] : [];
21
+ return [...system, ...messages.map((m) => ({ role: m.role, content: m.content }))];
22
+ }
23
+
24
+ function buildBody(cfg: ProviderCallConfig, messages: readonly ChatMessage[], options: RequestOptions, stream: boolean) {
25
+ return {
26
+ model: cfg.model,
27
+ max_tokens: options.maxTokens ?? DEFAULT_MAX_TOKENS,
28
+ messages: toApiMessages(messages, options.systemInstruction),
29
+ ...(options.jsonMode ? { response_format: { type: "json_object" } } : {}),
30
+ ...(stream ? { stream: true } : {}),
31
+ };
32
+ }
33
+
34
+ async function request(
35
+ provider: "openai" | "groq",
36
+ cfg: ProviderCallConfig,
37
+ messages: readonly ChatMessage[],
38
+ options: RequestOptions,
39
+ stream: boolean,
40
+ ): Promise<Response> {
41
+ const res = await fetchWithRetry(
42
+ BASE_URLS[provider],
43
+ {
44
+ method: "POST",
45
+ headers: { "Content-Type": "application/json", Authorization: `Bearer ${cfg.apiKey}` },
46
+ body: JSON.stringify(buildBody(cfg, messages, options, stream)),
47
+ },
48
+ options.signal,
49
+ );
50
+ if (!res.ok) throw new Error(await describeHttpError(res));
51
+ return res;
52
+ }
53
+
54
+ export async function completeOpenAICompatible(
55
+ provider: "openai" | "groq",
56
+ cfg: ProviderCallConfig,
57
+ messages: readonly ChatMessage[],
58
+ options: RequestOptions = {},
59
+ ): Promise<string> {
60
+ const res = await request(provider, cfg, messages, options, false);
61
+ const data = await res.json();
62
+ return data.choices?.[0]?.message?.content ?? "";
63
+ }
64
+
65
+ export async function streamOpenAICompatible(
66
+ provider: "openai" | "groq",
67
+ cfg: ProviderCallConfig,
68
+ messages: readonly ChatMessage[],
69
+ onChunk: (fullTextSoFar: string) => void,
70
+ options: RequestOptions = {},
71
+ ): Promise<string> {
72
+ const res = await request(provider, cfg, messages, options, true);
73
+ let full = "";
74
+ await consumeLines(res, (line) => {
75
+ if (!line.startsWith("data: ")) return;
76
+ const raw = line.slice(6).trim();
77
+ if (raw === "[DONE]") return;
78
+ try {
79
+ const delta = JSON.parse(raw).choices?.[0]?.delta?.content;
80
+ if (delta) {
81
+ full += delta;
82
+ onChunk(full);
83
+ }
84
+ } catch {
85
+ // Skip a malformed/partial SSE frame.
86
+ }
87
+ });
88
+ return full;
89
+ }
@@ -0,0 +1,82 @@
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
+
7
+ import type { ProviderId, ProviderInfo } from "./types.js";
8
+
9
+ export const PROVIDERS: Record<ProviderId, ProviderInfo> = {
10
+ gemini: {
11
+ id: "gemini",
12
+ name: "Google Gemini",
13
+ free: false,
14
+ defaultModel: "gemini-2.0-flash",
15
+ placeholder: "AIza...",
16
+ infoUrl: "https://aistudio.google.com/app/apikey",
17
+ infoText: "Get a free key from Google AI Studio",
18
+ },
19
+ groq: {
20
+ id: "groq",
21
+ name: "Groq",
22
+ free: true,
23
+ defaultModel: "llama-3.1-8b-instant",
24
+ placeholder: "gsk_...",
25
+ infoUrl: "https://console.groq.com/keys",
26
+ infoText: "Get a free key from Groq Console",
27
+ },
28
+ ollama: {
29
+ id: "ollama",
30
+ name: "Ollama (local)",
31
+ free: true,
32
+ noKey: true,
33
+ defaultModel: "llama3.2",
34
+ placeholder: "http://localhost:11434",
35
+ infoUrl: "https://ollama.ai",
36
+ infoText: "Install Ollama on your machine",
37
+ },
38
+ anthropic: {
39
+ id: "anthropic",
40
+ name: "Anthropic Claude",
41
+ free: false,
42
+ defaultModel: "claude-haiku-4-5-20251001",
43
+ placeholder: "sk-ant-...",
44
+ infoUrl: "https://console.anthropic.com/settings/keys",
45
+ infoText: "Get a key from the Anthropic Console",
46
+ },
47
+ openai: {
48
+ id: "openai",
49
+ name: "OpenAI",
50
+ free: false,
51
+ defaultModel: "gpt-4o-mini",
52
+ placeholder: "sk-...",
53
+ infoUrl: "https://platform.openai.com/api-keys",
54
+ infoText: "Get a key from the OpenAI Platform",
55
+ },
56
+ };
57
+
58
+ export function isKnownProvider(id: string): id is ProviderId {
59
+ return Object.hasOwn(PROVIDERS, id);
60
+ }
61
+
62
+ /**
63
+ * A curated, hand-maintained shortlist of current models per vendor, for
64
+ * rendering a model `<select>` instead of a free-text field. Necessarily
65
+ * goes stale as vendors ship new models — update this list by hand when
66
+ * that happens; it isn't fetched from anywhere. `PROVIDERS[id].defaultModel`
67
+ * is always included (first) even if it's ever edited out of this list.
68
+ */
69
+ export const MODEL_OPTIONS: Record<ProviderId, readonly string[]> = {
70
+ gemini: ["gemini-2.0-flash", "gemini-2.5-flash", "gemini-2.5-pro"],
71
+ groq: ["llama-3.1-8b-instant", "llama-3.3-70b-versatile", "openai/gpt-oss-120b"],
72
+ ollama: ["llama3.2", "llama3.1", "mistral", "qwen2.5"],
73
+ anthropic: ["claude-haiku-4-5-20251001", "claude-sonnet-4-5", "claude-opus-4-8"],
74
+ openai: ["gpt-4o-mini", "gpt-4o", "gpt-4.1-mini"],
75
+ };
76
+
77
+ /** `MODEL_OPTIONS[provider]` with `defaultModel` guaranteed present and first. */
78
+ export function modelOptionsFor(provider: ProviderId): readonly string[] {
79
+ const options = MODEL_OPTIONS[provider];
80
+ const defaultModel = PROVIDERS[provider].defaultModel;
81
+ return options.includes(defaultModel) ? options : [defaultModel, ...options];
82
+ }
package/src/types.ts ADDED
@@ -0,0 +1,108 @@
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
+
8
+ export type ProviderId = "gemini" | "openai" | "anthropic" | "groq" | "ollama";
9
+
10
+ /** Static metadata about a provider — enough to render a settings picker. */
11
+ export interface ProviderInfo {
12
+ readonly id: ProviderId;
13
+ readonly name: string;
14
+ /** Whether this provider has a genuinely free tier (not just "has a trial"). */
15
+ readonly free: boolean;
16
+ /** True for providers that need no API key at all (e.g. a local Ollama). */
17
+ readonly noKey?: boolean;
18
+ readonly defaultModel: string;
19
+ /** UI placeholder text for the key input (e.g. "sk-ant-..."). */
20
+ readonly placeholder: string;
21
+ readonly infoUrl: string;
22
+ readonly infoText: string;
23
+ }
24
+
25
+ /**
26
+ * One configured vendor: its model, and the key(s) pooled for it. More than
27
+ * one key is tried in order (e.g. a personal key and a team key) before
28
+ * this vendor is considered exhausted and the dispatcher moves to the next
29
+ * configured provider. Ignored for `provider: "ollama"`, which authenticates
30
+ * via `AgentConfig.ollamaUrl` instead of a key.
31
+ */
32
+ export interface ProviderCredential {
33
+ readonly provider: ProviderId;
34
+ readonly model: string;
35
+ readonly apiKeys: readonly string[];
36
+ }
37
+
38
+ /**
39
+ * The full BYOK configuration: an ordered fallback list of vendors (each
40
+ * with its own pooled keys) to try in turn, plus Ollama's local server URL
41
+ * (shared across the whole config — there's only one local Ollama, not one
42
+ * per fallback slot). `dispatch`/`complete`/`streamComplete` walk this list
43
+ * candidate by candidate, mirroring the Python core's fallback chain.
44
+ */
45
+ export interface AgentConfig {
46
+ readonly providers: readonly ProviderCredential[];
47
+ readonly ollamaUrl: string;
48
+ }
49
+
50
+ /**
51
+ * One concrete attempt: a specific vendor, model, and (for keyed vendors) a
52
+ * single already-resolved key — what an individual `providers/*` module
53
+ * needs to make one call. Distinct from `AgentConfig`, which holds the whole
54
+ * configured fallback list before the dispatch loop has picked a candidate.
55
+ */
56
+ export interface ProviderCallConfig {
57
+ readonly provider: ProviderId;
58
+ readonly model: string;
59
+ readonly apiKey: string;
60
+ readonly ollamaUrl: string;
61
+ }
62
+
63
+ export type ChatRole = "system" | "user" | "assistant";
64
+
65
+ export interface ChatMessage {
66
+ readonly role: ChatRole;
67
+ readonly content: string;
68
+ }
69
+
70
+ /** Options shared by both complete() and streamComplete()/streamChat(). */
71
+ export interface RequestOptions {
72
+ /** System prompt / instruction, applied via each provider's native mechanism. */
73
+ systemInstruction?: string;
74
+ /** Request the provider's structured-JSON response mode where it has one
75
+ * (OpenAI-compatible providers only — Gemini's `responseMimeType` is
76
+ * always requested by callers that need it via a raw prompt instruction,
77
+ * Anthropic and Ollama have no such mode). */
78
+ jsonMode?: boolean;
79
+ maxTokens?: number;
80
+ /** Cancel the request (e.g. component unmount). Independent of, and
81
+ * merged with, the library's own request timeout. */
82
+ signal?: AbortSignal;
83
+ }
84
+
85
+ export interface StreamOptions extends RequestOptions {
86
+ /** Called with the *cumulative* text so far after every chunk, matching
87
+ * every existing app's streaming callback shape. */
88
+ onChunk: (fullTextSoFar: string) => void;
89
+ }
90
+
91
+ /** A free, public AI chat web product `openExternalChat` can hand a question
92
+ * off to — the no-API-key escape hatch, distinct from the `ProviderId`s above
93
+ * (which all require a BYOK credential). */
94
+ export type ExternalChatProviderId = "chatgpt" | "claude" | "gemini" | "groq";
95
+
96
+ /** Static metadata about one external chat provider. */
97
+ export interface ExternalChatProviderInfo {
98
+ readonly id: ExternalChatProviderId;
99
+ readonly name: string;
100
+ /** Where `openExternalChat` sends the user when there's no known
101
+ * query-prefill parameter for this provider (or as the base URL the
102
+ * parameter is appended to). */
103
+ readonly homeUrl: string;
104
+ /** Build the deep-link URL for a given question, when this provider has a
105
+ * known (unofficial, reverse-engineered) prefill parameter. `null` when it
106
+ * doesn't — `openExternalChat` falls back to `homeUrl` plus the clipboard. */
107
+ readonly buildUrl: ((question: string) => string) | null;
108
+ }