modeldispatcher-browser-agent 0.6.0 → 0.6.6

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,81 +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
- }
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
+ }
@@ -1,89 +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
- }
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
+ }
package/src/registry.ts CHANGED
@@ -1,82 +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
- }
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
+ }