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.
package/README.md CHANGED
@@ -1,203 +1,203 @@
1
- # modeldispatcher-browser-agent
2
-
3
- A browser-native, bring-your-own-key AI agent core. The client-side sibling
4
- to `model-dispatcher` for apps with **no backend** to run the Python gateway
5
- on: talks directly to Gemini, OpenAI, Anthropic, Groq, or a local Ollama from
6
- the browser, using whatever key(s) the end user supplies. No vendor SDK, no
7
- server, nothing sent anywhere but the provider itself.
8
-
9
- `AgentConfig` is a **fallback list**, not a single selection: configure more
10
- than one provider (and more than one pooled key per provider), and
11
- `complete`/`streamComplete`/`streamChat` try each candidate in order,
12
- falling through to the next key or provider on failure instead of failing
13
- the whole request — the same idea as the Python core's fallback chain,
14
- running client-side.
15
-
16
- Extracted from four apps (StepByLearn, JobFlowTracker, KanDOne, HighFive)
17
- that had each independently built the same thing — one canonical provider
18
- registry, request/response handling, and streaming parser instead of four
19
- near-duplicate copies.
20
-
21
- ## Install
22
-
23
- ```bash
24
- npm install modeldispatcher-browser-agent
25
- ```
26
-
27
- (Published to the public npm registry — no `.npmrc` or token needed.)
28
-
29
- ## Usage
30
-
31
- ```ts
32
- import { loadConfig, complete, streamChat, PROVIDERS, MODEL_OPTIONS } from "modeldispatcher-browser-agent";
33
-
34
- // Reads the whole fallback list from localStorage by default (one JSON
35
- // blob under "aiConfig"); pass a ConfigStorage + ConfigKeys to use your
36
- // app's own key name.
37
- const cfg = loadConfig();
38
- // cfg.providers: [{ provider: "gemini", model: "gemini-2.0-flash", apiKeys: ["AIza-..."] }, ...]
39
- // cfg.ollamaUrl: "http://localhost:11434"
40
-
41
- // One-shot, full text back — tries cfg.providers in order, and each
42
- // provider's own pooled keys in order, until one succeeds:
43
- const answer = await complete(cfg, "Summarise this in one sentence: ...");
44
-
45
- // One-shot, incremental text back:
46
- await streamComplete(cfg, "Write a haiku", { onChunk: (text) => render(text) });
47
-
48
- // Multi-turn chat — normalises a raw UI message list (role validation,
49
- // user-first ordering, merging) before dispatching:
50
- await streamChat(cfg, uiMessages, {
51
- systemInstruction: "You are a helpful assistant.",
52
- onChunk: (text) => render(text),
53
- });
54
- ```
55
-
56
- `PROVIDERS` is the canonical registry (name, default model, key placeholder,
57
- where to get a key) and `MODEL_OPTIONS`/`modelOptionsFor(id)` a curated
58
- shortlist of current models per vendor — together enough to render a
59
- provider `<select>` and a model `<select>`, the same shape every app's
60
- `APIKeySettings`/`Settings` screen already builds by hand.
61
-
62
- ### Fallback errors
63
-
64
- ```ts
65
- import { NoProviderConfiguredError, AllProvidersExhaustedError } from "modeldispatcher-browser-agent";
66
-
67
- try {
68
- await complete(cfg, prompt);
69
- } catch (err) {
70
- if (err instanceof NoProviderConfiguredError) {
71
- // cfg.providers has nothing usable — send the visitor to AI settings.
72
- } else if (err instanceof AllProvidersExhaustedError) {
73
- // Every configured provider/key was tried. err.attempts is
74
- // [{ provider, error }, ...] for a detailed failure message.
75
- } else {
76
- throw err;
77
- }
78
- }
79
- ```
80
-
81
- A streaming call (`streamComplete`/`streamChat`) only falls back **before**
82
- the first chunk of a given candidate reaches `onChunk`. Once any text has
83
- streamed, a later failure on that same candidate is thrown immediately
84
- instead of silently retried on the next provider — the caller has already
85
- rendered a partial answer, and switching providers mid-stream would
86
- duplicate or contradict it rather than complete it.
87
-
88
- ## No-API-key escape hatch: `openExternalChat`
89
-
90
- For a user with no API key configured (or who just doesn't want to set one
91
- up), `openExternalChat` hands their question off to a free, public AI chat
92
- product instead — ChatGPT, Claude, Gemini (via Google Search's AI Mode), or
93
- Groq — opening it in a new tab with the question pre-filled where the
94
- product supports that, and always copying the question to the clipboard too
95
- as a fallback:
96
-
97
- ```ts
98
- import { openExternalChat, EXTERNAL_CHAT_PROVIDERS } from "modeldispatcher-browser-agent";
99
-
100
- const result = await openExternalChat("claude", "Summarise this in one sentence: ...");
101
- // result.prefilled — true if the question actually made it into the URL
102
- // result.copiedToClipboard — true if it's also on the clipboard, for pasting
103
- ```
104
-
105
- `EXTERNAL_CHAT_PROVIDERS` lists the four supported products for rendering a
106
- picker, the same way `PROVIDERS` does for the BYOK providers above.
107
-
108
- **Why this needs a clipboard fallback at all:** the URL query parameters that
109
- pre-fill a provider's chat box (`claude.ai/new?q=...`, `chatgpt.com/?q=...`,
110
- Google Search's `udm=50` AI Mode) are undocumented, reverse-engineered
111
- conventions — not a stable API any vendor promises to keep working. Groq has
112
- no known one at all, so it just opens the plain homepage. `openExternalChat`
113
- always copies the question to the clipboard regardless, so a broken or
114
- removed parameter never means the user's question is lost, just that they
115
- paste instead of finding it already typed in.
116
-
117
- ## Saving a favorite for the escape hatch: `loadExternalChatFavorite`
118
-
119
- `openExternalChat` is an *action* — it navigates the instant it's called, so
120
- it belongs on a button next to wherever the user is actually about to ask
121
- something, never inside a settings screen (clicking a settings option should
122
- never itself open a new tab). What *does* belong in settings is letting the
123
- visitor pick and save **which** free product they'd reach for, ahead of
124
- time — that's what this is for:
125
-
126
- ```ts
127
- import {
128
- loadExternalChatFavorite,
129
- saveExternalChatFavorite,
130
- } from "modeldispatcher-browser-agent";
131
-
132
- // Settings screen: save the pick. No network call, no navigation.
133
- saveExternalChatFavorite("claude");
134
-
135
- // Wherever the user composes a question: read it back to decide what
136
- // the "ask externally" button should say/do.
137
- const favorite = loadExternalChatFavorite(); // "claude" | null
138
- if (favorite) {
139
- await openExternalChat(favorite, currentQuestion);
140
- }
141
- ```
142
-
143
- Deliberately separate storage from `loadConfig`/`saveConfig` (BYOK
144
- provider/key/model) — picking a favorite here has nothing to do with which
145
- BYOK provider is configured, and a settings screen commonly offers both as
146
- independent, unrelated choices.
147
-
148
- ## Making it optional per app: `resolveDispatcherFeatures`
149
-
150
- Adopting this doesn't have to be all-or-nothing on day one. Two independent,
151
- developer-set flags — never a switch an end user sees — decide what an app
152
- actually turns on:
153
-
154
- ```ts
155
- // modeldispatcher.config.ts — your app's own config module, not exported to users
156
- import { resolveDispatcherFeatures } from "modeldispatcher-browser-agent";
157
-
158
- export const dispatcherFeatures = resolveDispatcherFeatures({
159
- ui: import.meta.env.VITE_MODEL_DISPATCHER_UI !== "false",
160
- dispatch: import.meta.env.VITE_MODEL_DISPATCHER_DISPATCH !== "false",
161
- });
162
- ```
163
-
164
- ```tsx
165
- function AiSettingsScreen() {
166
- // .ui: render <ModelPicker>/<AskExternallyButton>, or this app's own settings screen.
167
- return dispatcherFeatures.ui ? <ModelPicker {...props} /> : <LegacyAiSettings />;
168
- }
169
-
170
- async function askAi(question: string) {
171
- // .dispatch: route through complete()'s fallback dispatch, or this app's own call path.
172
- return dispatcherFeatures.dispatch ? complete(loadConfig(), question) : legacyAskAi(question);
173
- }
174
- ```
175
-
176
- Both default to `true` — resolving with no overrides at all turns everything
177
- on — so an app opts *out* per feature during a gradual rollout rather than
178
- opting in from a cold start. This package never reads environment variables
179
- itself (every bundler exposes them differently); your app's config module
180
- resolves its own env var and passes the boolean in. Full pattern, including
181
- the non-Vite env var forms: [`docs/USAGE.md`](../../docs/USAGE.md).
182
-
183
- ## What's in scope, what isn't
184
-
185
- This package owns the generic "talk to a provider" plumbing: provider
186
- configs, request/response translation, SSE/NDJSON stream parsing, retry on
187
- 429/5xx, cross-provider/cross-key fallback dispatch, timeout/abort handling,
188
- Ollama URL validation, and chat-history normalisation. It deliberately does
189
- **not** know about any app's actual
190
- prompts or business logic (job-search coaching, trip parsing, whatever) —
191
- that stays in each app, calling through this the same way
192
- `services/llm_model_dispatcher.py` in AppMyTrip calls through the Python
193
- gateway without the gateway knowing what a "trip" is.
194
-
195
- ## Testing
196
-
197
- ```bash
198
- npm run typecheck
199
- npm test
200
- ```
201
-
202
- All 81 tests run against mocked `fetch`/`ReadableStream`/`window.open`/
203
- clipboard — no real network, no real API key needed.
1
+ # modeldispatcher-browser-agent
2
+
3
+ A browser-native, bring-your-own-key AI agent core. The client-side sibling
4
+ to `model-dispatcher` for apps with **no backend** to run the Python gateway
5
+ on: talks directly to Gemini, OpenAI, Anthropic, Groq, or a local Ollama from
6
+ the browser, using whatever key(s) the end user supplies. No vendor SDK, no
7
+ server, nothing sent anywhere but the provider itself.
8
+
9
+ `AgentConfig` is a **fallback list**, not a single selection: configure more
10
+ than one provider (and more than one pooled key per provider), and
11
+ `complete`/`streamComplete`/`streamChat` try each candidate in order,
12
+ falling through to the next key or provider on failure instead of failing
13
+ the whole request — the same idea as the Python core's fallback chain,
14
+ running client-side.
15
+
16
+ Extracted from four apps (StepByLearn, JobFlowTracker, KanDOne, HighFive)
17
+ that had each independently built the same thing — one canonical provider
18
+ registry, request/response handling, and streaming parser instead of four
19
+ near-duplicate copies.
20
+
21
+ ## Install
22
+
23
+ ```bash
24
+ npm install modeldispatcher-browser-agent
25
+ ```
26
+
27
+ (Published to the public npm registry — no `.npmrc` or token needed.)
28
+
29
+ ## Usage
30
+
31
+ ```ts
32
+ import { loadConfig, complete, streamChat, PROVIDERS, MODEL_OPTIONS } from "modeldispatcher-browser-agent";
33
+
34
+ // Reads the whole fallback list from localStorage by default (one JSON
35
+ // blob under "aiConfig"); pass a ConfigStorage + ConfigKeys to use your
36
+ // app's own key name.
37
+ const cfg = loadConfig();
38
+ // cfg.providers: [{ provider: "gemini", model: "gemini-2.0-flash", apiKeys: ["AIza-..."] }, ...]
39
+ // cfg.ollamaUrl: "http://localhost:11434"
40
+
41
+ // One-shot, full text back — tries cfg.providers in order, and each
42
+ // provider's own pooled keys in order, until one succeeds:
43
+ const answer = await complete(cfg, "Summarise this in one sentence: ...");
44
+
45
+ // One-shot, incremental text back:
46
+ await streamComplete(cfg, "Write a haiku", { onChunk: (text) => render(text) });
47
+
48
+ // Multi-turn chat — normalises a raw UI message list (role validation,
49
+ // user-first ordering, merging) before dispatching:
50
+ await streamChat(cfg, uiMessages, {
51
+ systemInstruction: "You are a helpful assistant.",
52
+ onChunk: (text) => render(text),
53
+ });
54
+ ```
55
+
56
+ `PROVIDERS` is the canonical registry (name, default model, key placeholder,
57
+ where to get a key) and `MODEL_OPTIONS`/`modelOptionsFor(id)` a curated
58
+ shortlist of current models per vendor — together enough to render a
59
+ provider `<select>` and a model `<select>`, the same shape every app's
60
+ `APIKeySettings`/`Settings` screen already builds by hand.
61
+
62
+ ### Fallback errors
63
+
64
+ ```ts
65
+ import { NoProviderConfiguredError, AllProvidersExhaustedError } from "modeldispatcher-browser-agent";
66
+
67
+ try {
68
+ await complete(cfg, prompt);
69
+ } catch (err) {
70
+ if (err instanceof NoProviderConfiguredError) {
71
+ // cfg.providers has nothing usable — send the visitor to AI settings.
72
+ } else if (err instanceof AllProvidersExhaustedError) {
73
+ // Every configured provider/key was tried. err.attempts is
74
+ // [{ provider, error }, ...] for a detailed failure message.
75
+ } else {
76
+ throw err;
77
+ }
78
+ }
79
+ ```
80
+
81
+ A streaming call (`streamComplete`/`streamChat`) only falls back **before**
82
+ the first chunk of a given candidate reaches `onChunk`. Once any text has
83
+ streamed, a later failure on that same candidate is thrown immediately
84
+ instead of silently retried on the next provider — the caller has already
85
+ rendered a partial answer, and switching providers mid-stream would
86
+ duplicate or contradict it rather than complete it.
87
+
88
+ ## No-API-key escape hatch: `openExternalChat`
89
+
90
+ For a user with no API key configured (or who just doesn't want to set one
91
+ up), `openExternalChat` hands their question off to a free, public AI chat
92
+ product instead — ChatGPT, Claude, Gemini (via Google Search's AI Mode), or
93
+ Groq — opening it in a new tab with the question pre-filled where the
94
+ product supports that, and always copying the question to the clipboard too
95
+ as a fallback:
96
+
97
+ ```ts
98
+ import { openExternalChat, EXTERNAL_CHAT_PROVIDERS } from "modeldispatcher-browser-agent";
99
+
100
+ const result = await openExternalChat("claude", "Summarise this in one sentence: ...");
101
+ // result.prefilled — true if the question actually made it into the URL
102
+ // result.copiedToClipboard — true if it's also on the clipboard, for pasting
103
+ ```
104
+
105
+ `EXTERNAL_CHAT_PROVIDERS` lists the four supported products for rendering a
106
+ picker, the same way `PROVIDERS` does for the BYOK providers above.
107
+
108
+ **Why this needs a clipboard fallback at all:** the URL query parameters that
109
+ pre-fill a provider's chat box (`claude.ai/new?q=...`, `chatgpt.com/?q=...`,
110
+ Google Search's `udm=50` AI Mode) are undocumented, reverse-engineered
111
+ conventions — not a stable API any vendor promises to keep working. Groq has
112
+ no known one at all, so it just opens the plain homepage. `openExternalChat`
113
+ always copies the question to the clipboard regardless, so a broken or
114
+ removed parameter never means the user's question is lost, just that they
115
+ paste instead of finding it already typed in.
116
+
117
+ ## Saving a favorite for the escape hatch: `loadExternalChatFavorite`
118
+
119
+ `openExternalChat` is an *action* — it navigates the instant it's called, so
120
+ it belongs on a button next to wherever the user is actually about to ask
121
+ something, never inside a settings screen (clicking a settings option should
122
+ never itself open a new tab). What *does* belong in settings is letting the
123
+ visitor pick and save **which** free product they'd reach for, ahead of
124
+ time — that's what this is for:
125
+
126
+ ```ts
127
+ import {
128
+ loadExternalChatFavorite,
129
+ saveExternalChatFavorite,
130
+ } from "modeldispatcher-browser-agent";
131
+
132
+ // Settings screen: save the pick. No network call, no navigation.
133
+ saveExternalChatFavorite("claude");
134
+
135
+ // Wherever the user composes a question: read it back to decide what
136
+ // the "ask externally" button should say/do.
137
+ const favorite = loadExternalChatFavorite(); // "claude" | null
138
+ if (favorite) {
139
+ await openExternalChat(favorite, currentQuestion);
140
+ }
141
+ ```
142
+
143
+ Deliberately separate storage from `loadConfig`/`saveConfig` (BYOK
144
+ provider/key/model) — picking a favorite here has nothing to do with which
145
+ BYOK provider is configured, and a settings screen commonly offers both as
146
+ independent, unrelated choices.
147
+
148
+ ## Making it optional per app: `resolveDispatcherFeatures`
149
+
150
+ Adopting this doesn't have to be all-or-nothing on day one. Two independent,
151
+ developer-set flags — never a switch an end user sees — decide what an app
152
+ actually turns on:
153
+
154
+ ```ts
155
+ // modeldispatcher.config.ts — your app's own config module, not exported to users
156
+ import { resolveDispatcherFeatures } from "modeldispatcher-browser-agent";
157
+
158
+ export const dispatcherFeatures = resolveDispatcherFeatures({
159
+ ui: import.meta.env.VITE_MODEL_DISPATCHER_UI !== "false",
160
+ dispatch: import.meta.env.VITE_MODEL_DISPATCHER_DISPATCH !== "false",
161
+ });
162
+ ```
163
+
164
+ ```tsx
165
+ function AiSettingsScreen() {
166
+ // .ui: render <ModelPicker>/<AskExternallyButton>, or this app's own settings screen.
167
+ return dispatcherFeatures.ui ? <ModelPicker {...props} /> : <LegacyAiSettings />;
168
+ }
169
+
170
+ async function askAi(question: string) {
171
+ // .dispatch: route through complete()'s fallback dispatch, or this app's own call path.
172
+ return dispatcherFeatures.dispatch ? complete(loadConfig(), question) : legacyAskAi(question);
173
+ }
174
+ ```
175
+
176
+ Both default to `true` — resolving with no overrides at all turns everything
177
+ on — so an app opts *out* per feature during a gradual rollout rather than
178
+ opting in from a cold start. This package never reads environment variables
179
+ itself (every bundler exposes them differently); your app's config module
180
+ resolves its own env var and passes the boolean in. Full pattern, including
181
+ the non-Vite env var forms: [`docs/USAGE.md`](../../docs/USAGE.md).
182
+
183
+ ## What's in scope, what isn't
184
+
185
+ This package owns the generic "talk to a provider" plumbing: provider
186
+ configs, request/response translation, SSE/NDJSON stream parsing, retry on
187
+ 429/5xx, cross-provider/cross-key fallback dispatch, timeout/abort handling,
188
+ Ollama URL validation, and chat-history normalisation. It deliberately does
189
+ **not** know about any app's actual
190
+ prompts or business logic (job-search coaching, trip parsing, whatever) —
191
+ that stays in each app, calling through this the same way
192
+ `services/llm_model_dispatcher.py` in AppMyTrip calls through the Python
193
+ gateway without the gateway knowing what a "trip" is.
194
+
195
+ ## Testing
196
+
197
+ ```bash
198
+ npm run typecheck
199
+ npm test
200
+ ```
201
+
202
+ All 81 tests run against mocked `fetch`/`ReadableStream`/`window.open`/
203
+ clipboard — no real network, no real API key needed.
package/package.json CHANGED
@@ -1,40 +1,45 @@
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
- }
1
+ {
2
+ "name": "modeldispatcher-browser-agent",
3
+ "version": "0.6.6",
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
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/joka-7/ModelDispatcher.git",
9
+ "directory": "clients/browser-agent"
10
+ },
11
+ "type": "module",
12
+ "types": "./dist/index.d.ts",
13
+ "module": "./dist/index.js",
14
+ "exports": {
15
+ ".": {
16
+ "types": "./dist/index.d.ts",
17
+ "import": "./dist/index.js"
18
+ }
19
+ },
20
+ "files": [
21
+ "dist",
22
+ "src"
23
+ ],
24
+ "sideEffects": false,
25
+ "scripts": {
26
+ "build": "tsc -p tsconfig.build.json",
27
+ "typecheck": "tsc --noEmit",
28
+ "test": "vitest run"
29
+ },
30
+ "keywords": [
31
+ "llm",
32
+ "ai-agent",
33
+ "browser",
34
+ "gemini",
35
+ "openai",
36
+ "anthropic",
37
+ "groq",
38
+ "ollama",
39
+ "streaming"
40
+ ],
41
+ "devDependencies": {
42
+ "typescript": "^5.5.0",
43
+ "vitest": "^2.0.0"
44
+ }
45
+ }