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/README.md ADDED
@@ -0,0 +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.
@@ -0,0 +1,64 @@
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
+ import { type BuildMessagesOptions } from "./messages.js";
17
+ import type { AgentConfig, ChatMessage, RequestOptions, StreamOptions } from "./types.js";
18
+ /** One candidate that failed, kept only to compose
19
+ * {@link AllProvidersExhaustedError}'s message. */
20
+ interface FailedAttempt {
21
+ readonly provider: string;
22
+ readonly error: string;
23
+ }
24
+ /** `cfg.providers` has nothing usable at all — no vendor has a pooled key,
25
+ * and Ollama isn't configured either. Raised before any network call, the
26
+ * same way the Python core's `NoProviderAvailableError` is. */
27
+ export declare class NoProviderConfiguredError extends Error {
28
+ constructor();
29
+ }
30
+ /** Every configured provider/key was tried and every one failed. */
31
+ export declare class AllProvidersExhaustedError extends Error {
32
+ readonly attempts: readonly FailedAttempt[];
33
+ constructor(attempts: readonly FailedAttempt[]);
34
+ }
35
+ /** Non-streaming completion, tried across every configured provider/key in
36
+ * order until one succeeds. `prompt` is wrapped as a single user turn; pass
37
+ * `messages` instead for an already-built multi-turn history.
38
+ *
39
+ * Raises:
40
+ * NoProviderConfiguredError: `cfg.providers` has no usable credential at all.
41
+ * AllProvidersExhaustedError: every candidate was tried and failed;
42
+ * `.attempts` lists each provider and its error.
43
+ */
44
+ export declare function complete(cfg: AgentConfig, promptOrMessages: string | readonly ChatMessage[], options?: RequestOptions): Promise<string>;
45
+ /**
46
+ * Streaming completion, tried across every configured provider/key in
47
+ * order — but only up to the first chunk actually streamed. Once any text
48
+ * has reached `onChunk` for a candidate, a later failure on that same
49
+ * candidate is surfaced immediately rather than silently retried on the
50
+ * next provider: the caller has already rendered a partial answer, and
51
+ * switching providers mid-stream would duplicate or contradict it rather
52
+ * than complete it.
53
+ *
54
+ * Raises the same errors as {@link complete}.
55
+ */
56
+ export declare function streamComplete(cfg: AgentConfig, promptOrMessages: string | readonly ChatMessage[], options: StreamOptions): Promise<string>;
57
+ /**
58
+ * Streaming multi-turn chat: normalises a raw UI message list via
59
+ * {@link buildMessages} (role validation, alternation, sentinel-dropping)
60
+ * before dispatching — the one thing `streamComplete()` deliberately
61
+ * doesn't do, since its `messages` input is assumed already well-formed.
62
+ */
63
+ export declare function streamChat(cfg: AgentConfig, uiMessages: readonly ChatMessage[], options: StreamOptions & BuildMessagesOptions): Promise<string>;
64
+ export {};
package/dist/agent.js ADDED
@@ -0,0 +1,160 @@
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
+ import { buildMessages } from "./messages.js";
17
+ import { completeAnthropic, streamAnthropic } from "./providers/anthropic.js";
18
+ import { completeGemini, streamGemini } from "./providers/gemini.js";
19
+ import { completeOllama, streamOllama } from "./providers/ollama.js";
20
+ import { completeOpenAICompatible, streamOpenAICompatible } from "./providers/openaiCompatible.js";
21
+ /** `cfg.providers` has nothing usable at all — no vendor has a pooled key,
22
+ * and Ollama isn't configured either. Raised before any network call, the
23
+ * same way the Python core's `NoProviderAvailableError` is. */
24
+ export class NoProviderConfiguredError extends Error {
25
+ constructor() {
26
+ super("No AI provider is configured — add a provider and key (or Ollama) in AI settings.");
27
+ this.name = "NoProviderConfiguredError";
28
+ }
29
+ }
30
+ /** Every configured provider/key was tried and every one failed. */
31
+ export class AllProvidersExhaustedError extends Error {
32
+ attempts;
33
+ constructor(attempts) {
34
+ super(`All configured AI providers failed: ${attempts.map((a) => `${a.provider} (${a.error})`).join("; ")}`);
35
+ this.name = "AllProvidersExhaustedError";
36
+ this.attempts = attempts;
37
+ }
38
+ }
39
+ /**
40
+ * Flatten the configured provider list into concrete, single-key attempts,
41
+ * in fallback order: each provider's own pooled keys are tried in the order
42
+ * they were added before moving to the next configured provider. Ollama
43
+ * contributes at most one candidate (it authenticates via `cfg.ollamaUrl`,
44
+ * not a key) and only when its own entry is present in the list — a
45
+ * provider with no usable credential is never offered as a candidate at
46
+ * all, the same rule the Python core's registry-building applies.
47
+ */
48
+ function candidatesOf(cfg) {
49
+ const candidates = [];
50
+ for (const cred of cfg.providers) {
51
+ if (cred.provider === "ollama") {
52
+ candidates.push({ provider: "ollama", model: cred.model, apiKey: "", ollamaUrl: cfg.ollamaUrl });
53
+ continue;
54
+ }
55
+ for (const apiKey of cred.apiKeys) {
56
+ if (apiKey)
57
+ candidates.push({ provider: cred.provider, model: cred.model, apiKey, ollamaUrl: cfg.ollamaUrl });
58
+ }
59
+ }
60
+ return candidates;
61
+ }
62
+ function messagesOf(promptOrMessages) {
63
+ return typeof promptOrMessages === "string" ? [{ role: "user", content: promptOrMessages }] : promptOrMessages;
64
+ }
65
+ function completeOne(candidate, messages, options) {
66
+ switch (candidate.provider) {
67
+ case "gemini":
68
+ return completeGemini(candidate, messages, options);
69
+ case "anthropic":
70
+ return completeAnthropic(candidate, messages, options);
71
+ case "ollama":
72
+ return completeOllama(candidate, messages, options);
73
+ case "openai":
74
+ case "groq":
75
+ return completeOpenAICompatible(candidate.provider, candidate, messages, options);
76
+ }
77
+ }
78
+ /** Non-streaming completion, tried across every configured provider/key in
79
+ * order until one succeeds. `prompt` is wrapped as a single user turn; pass
80
+ * `messages` instead for an already-built multi-turn history.
81
+ *
82
+ * Raises:
83
+ * NoProviderConfiguredError: `cfg.providers` has no usable credential at all.
84
+ * AllProvidersExhaustedError: every candidate was tried and failed;
85
+ * `.attempts` lists each provider and its error.
86
+ */
87
+ export async function complete(cfg, promptOrMessages, options = {}) {
88
+ const candidates = candidatesOf(cfg);
89
+ if (candidates.length === 0)
90
+ throw new NoProviderConfiguredError();
91
+ const messages = messagesOf(promptOrMessages);
92
+ const attempts = [];
93
+ for (const candidate of candidates) {
94
+ try {
95
+ return await completeOne(candidate, messages, options);
96
+ }
97
+ catch (err) {
98
+ attempts.push({ provider: candidate.provider, error: err.message });
99
+ }
100
+ }
101
+ throw new AllProvidersExhaustedError(attempts);
102
+ }
103
+ function streamOne(candidate, messages, onChunk, options) {
104
+ switch (candidate.provider) {
105
+ case "gemini":
106
+ return streamGemini(candidate, messages, onChunk, options);
107
+ case "anthropic":
108
+ return streamAnthropic(candidate, messages, onChunk, options);
109
+ case "ollama":
110
+ return streamOllama(candidate, messages, onChunk, options);
111
+ case "openai":
112
+ case "groq":
113
+ return streamOpenAICompatible(candidate.provider, candidate, messages, onChunk, options);
114
+ }
115
+ }
116
+ /**
117
+ * Streaming completion, tried across every configured provider/key in
118
+ * order — but only up to the first chunk actually streamed. Once any text
119
+ * has reached `onChunk` for a candidate, a later failure on that same
120
+ * candidate is surfaced immediately rather than silently retried on the
121
+ * next provider: the caller has already rendered a partial answer, and
122
+ * switching providers mid-stream would duplicate or contradict it rather
123
+ * than complete it.
124
+ *
125
+ * Raises the same errors as {@link complete}.
126
+ */
127
+ export async function streamComplete(cfg, promptOrMessages, options) {
128
+ const candidates = candidatesOf(cfg);
129
+ if (candidates.length === 0)
130
+ throw new NoProviderConfiguredError();
131
+ const messages = messagesOf(promptOrMessages);
132
+ const { onChunk, ...rest } = options;
133
+ const attempts = [];
134
+ for (const candidate of candidates) {
135
+ let startedStreaming = false;
136
+ try {
137
+ return await streamOne(candidate, messages, (text) => {
138
+ startedStreaming = true;
139
+ onChunk(text);
140
+ }, rest);
141
+ }
142
+ catch (err) {
143
+ if (startedStreaming)
144
+ throw err;
145
+ attempts.push({ provider: candidate.provider, error: err.message });
146
+ }
147
+ }
148
+ throw new AllProvidersExhaustedError(attempts);
149
+ }
150
+ /**
151
+ * Streaming multi-turn chat: normalises a raw UI message list via
152
+ * {@link buildMessages} (role validation, alternation, sentinel-dropping)
153
+ * before dispatching — the one thing `streamComplete()` deliberately
154
+ * doesn't do, since its `messages` input is assumed already well-formed.
155
+ */
156
+ export async function streamChat(cfg, uiMessages, options) {
157
+ const normalised = buildMessages(uiMessages, options);
158
+ return streamComplete(cfg, normalised, options);
159
+ }
160
+ //# sourceMappingURL=agent.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"agent.js","sourceRoot":"","sources":["../src/agent.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AAEH,OAAO,EAAE,aAAa,EAA6B,MAAM,eAAe,CAAC;AACzE,OAAO,EAAE,iBAAiB,EAAE,eAAe,EAAE,MAAM,0BAA0B,CAAC;AAC9E,OAAO,EAAE,cAAc,EAAE,YAAY,EAAE,MAAM,uBAAuB,CAAC;AACrE,OAAO,EAAE,cAAc,EAAE,YAAY,EAAE,MAAM,uBAAuB,CAAC;AACrE,OAAO,EAAE,wBAAwB,EAAE,sBAAsB,EAAE,MAAM,iCAAiC,CAAC;AAgBnG;;+DAE+D;AAC/D,MAAM,OAAO,yBAA0B,SAAQ,KAAK;IAClD;QACE,KAAK,CAAC,mFAAmF,CAAC,CAAC;QAC3F,IAAI,CAAC,IAAI,GAAG,2BAA2B,CAAC;IAC1C,CAAC;CACF;AAED,oEAAoE;AACpE,MAAM,OAAO,0BAA2B,SAAQ,KAAK;IAC1C,QAAQ,CAA2B;IAE5C,YAAY,QAAkC;QAC5C,KAAK,CAAC,uCAAuC,QAAQ,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,GAAG,CAAC,CAAC,QAAQ,KAAK,CAAC,CAAC,KAAK,GAAG,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,EAAE,CAAC,CAAC;QAC7G,IAAI,CAAC,IAAI,GAAG,4BAA4B,CAAC;QACzC,IAAI,CAAC,QAAQ,GAAG,QAAQ,CAAC;IAC3B,CAAC;CACF;AAED;;;;;;;;GAQG;AACH,SAAS,YAAY,CAAC,GAAgB;IACpC,MAAM,UAAU,GAAyB,EAAE,CAAC;IAC5C,KAAK,MAAM,IAAI,IAAI,GAAG,CAAC,SAAS,EAAE,CAAC;QACjC,IAAI,IAAI,CAAC,QAAQ,KAAK,QAAQ,EAAE,CAAC;YAC/B,UAAU,CAAC,IAAI,CAAC,EAAE,QAAQ,EAAE,QAAQ,EAAE,KAAK,EAAE,IAAI,CAAC,KAAK,EAAE,MAAM,EAAE,EAAE,EAAE,SAAS,EAAE,GAAG,CAAC,SAAS,EAAE,CAAC,CAAC;YACjG,SAAS;QACX,CAAC;QACD,KAAK,MAAM,MAAM,IAAI,IAAI,CAAC,OAAO,EAAE,CAAC;YAClC,IAAI,MAAM;gBAAE,UAAU,CAAC,IAAI,CAAC,EAAE,QAAQ,EAAE,IAAI,CAAC,QAAQ,EAAE,KAAK,EAAE,IAAI,CAAC,KAAK,EAAE,MAAM,EAAE,SAAS,EAAE,GAAG,CAAC,SAAS,EAAE,CAAC,CAAC;QAChH,CAAC;IACH,CAAC;IACD,OAAO,UAAU,CAAC;AACpB,CAAC;AAED,SAAS,UAAU,CAAC,gBAAiD;IACnE,OAAO,OAAO,gBAAgB,KAAK,QAAQ,CAAC,CAAC,CAAC,CAAC,EAAE,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,gBAAgB,EAAE,CAAC,CAAC,CAAC,CAAC,gBAAgB,CAAC;AACjH,CAAC;AAED,SAAS,WAAW,CAClB,SAA6B,EAC7B,QAAgC,EAChC,OAAuB;IAEvB,QAAQ,SAAS,CAAC,QAAQ,EAAE,CAAC;QAC3B,KAAK,QAAQ;YACX,OAAO,cAAc,CAAC,SAAS,EAAE,QAAQ,EAAE,OAAO,CAAC,CAAC;QACtD,KAAK,WAAW;YACd,OAAO,iBAAiB,CAAC,SAAS,EAAE,QAAQ,EAAE,OAAO,CAAC,CAAC;QACzD,KAAK,QAAQ;YACX,OAAO,cAAc,CAAC,SAAS,EAAE,QAAQ,EAAE,OAAO,CAAC,CAAC;QACtD,KAAK,QAAQ,CAAC;QACd,KAAK,MAAM;YACT,OAAO,wBAAwB,CAAC,SAAS,CAAC,QAAQ,EAAE,SAAS,EAAE,QAAQ,EAAE,OAAO,CAAC,CAAC;IACtF,CAAC;AACH,CAAC;AAED;;;;;;;;GAQG;AACH,MAAM,CAAC,KAAK,UAAU,QAAQ,CAC5B,GAAgB,EAChB,gBAAiD,EACjD,UAA0B,EAAE;IAE5B,MAAM,UAAU,GAAG,YAAY,CAAC,GAAG,CAAC,CAAC;IACrC,IAAI,UAAU,CAAC,MAAM,KAAK,CAAC;QAAE,MAAM,IAAI,yBAAyB,EAAE,CAAC;IACnE,MAAM,QAAQ,GAAG,UAAU,CAAC,gBAAgB,CAAC,CAAC;IAE9C,MAAM,QAAQ,GAAoB,EAAE,CAAC;IACrC,KAAK,MAAM,SAAS,IAAI,UAAU,EAAE,CAAC;QACnC,IAAI,CAAC;YACH,OAAO,MAAM,WAAW,CAAC,SAAS,EAAE,QAAQ,EAAE,OAAO,CAAC,CAAC;QACzD,CAAC;QAAC,OAAO,GAAG,EAAE,CAAC;YACb,QAAQ,CAAC,IAAI,CAAC,EAAE,QAAQ,EAAE,SAAS,CAAC,QAAQ,EAAE,KAAK,EAAG,GAAa,CAAC,OAAO,EAAE,CAAC,CAAC;QACjF,CAAC;IACH,CAAC;IACD,MAAM,IAAI,0BAA0B,CAAC,QAAQ,CAAC,CAAC;AACjD,CAAC;AAED,SAAS,SAAS,CAChB,SAA6B,EAC7B,QAAgC,EAChC,OAAwC,EACxC,OAAuB;IAEvB,QAAQ,SAAS,CAAC,QAAQ,EAAE,CAAC;QAC3B,KAAK,QAAQ;YACX,OAAO,YAAY,CAAC,SAAS,EAAE,QAAQ,EAAE,OAAO,EAAE,OAAO,CAAC,CAAC;QAC7D,KAAK,WAAW;YACd,OAAO,eAAe,CAAC,SAAS,EAAE,QAAQ,EAAE,OAAO,EAAE,OAAO,CAAC,CAAC;QAChE,KAAK,QAAQ;YACX,OAAO,YAAY,CAAC,SAAS,EAAE,QAAQ,EAAE,OAAO,EAAE,OAAO,CAAC,CAAC;QAC7D,KAAK,QAAQ,CAAC;QACd,KAAK,MAAM;YACT,OAAO,sBAAsB,CAAC,SAAS,CAAC,QAAQ,EAAE,SAAS,EAAE,QAAQ,EAAE,OAAO,EAAE,OAAO,CAAC,CAAC;IAC7F,CAAC;AACH,CAAC;AAED;;;;;;;;;;GAUG;AACH,MAAM,CAAC,KAAK,UAAU,cAAc,CAClC,GAAgB,EAChB,gBAAiD,EACjD,OAAsB;IAEtB,MAAM,UAAU,GAAG,YAAY,CAAC,GAAG,CAAC,CAAC;IACrC,IAAI,UAAU,CAAC,MAAM,KAAK,CAAC;QAAE,MAAM,IAAI,yBAAyB,EAAE,CAAC;IACnE,MAAM,QAAQ,GAAG,UAAU,CAAC,gBAAgB,CAAC,CAAC;IAC9C,MAAM,EAAE,OAAO,EAAE,GAAG,IAAI,EAAE,GAAG,OAAO,CAAC;IAErC,MAAM,QAAQ,GAAoB,EAAE,CAAC;IACrC,KAAK,MAAM,SAAS,IAAI,UAAU,EAAE,CAAC;QACnC,IAAI,gBAAgB,GAAG,KAAK,CAAC;QAC7B,IAAI,CAAC;YACH,OAAO,MAAM,SAAS,CACpB,SAAS,EACT,QAAQ,EACR,CAAC,IAAI,EAAE,EAAE;gBACP,gBAAgB,GAAG,IAAI,CAAC;gBACxB,OAAO,CAAC,IAAI,CAAC,CAAC;YAChB,CAAC,EACD,IAAI,CACL,CAAC;QACJ,CAAC;QAAC,OAAO,GAAG,EAAE,CAAC;YACb,IAAI,gBAAgB;gBAAE,MAAM,GAAG,CAAC;YAChC,QAAQ,CAAC,IAAI,CAAC,EAAE,QAAQ,EAAE,SAAS,CAAC,QAAQ,EAAE,KAAK,EAAG,GAAa,CAAC,OAAO,EAAE,CAAC,CAAC;QACjF,CAAC;IACH,CAAC;IACD,MAAM,IAAI,0BAA0B,CAAC,QAAQ,CAAC,CAAC;AACjD,CAAC;AAED;;;;;GAKG;AACH,MAAM,CAAC,KAAK,UAAU,UAAU,CAC9B,GAAgB,EAChB,UAAkC,EAClC,OAA6C;IAE7C,MAAM,UAAU,GAAG,aAAa,CAAC,UAAU,EAAE,OAAO,CAAC,CAAC;IACtD,OAAO,cAAc,CAAC,GAAG,EAAE,UAAU,EAAE,OAAO,CAAC,CAAC;AAClD,CAAC"}
@@ -0,0 +1,43 @@
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
+ import type { AgentConfig, ProviderId } from "./types.js";
10
+ /** Minimal Web Storage shape — matches `localStorage`, injectable for tests
11
+ * or for a non-browser host (e.g. a React Native AsyncStorage shim). */
12
+ export interface ConfigStorage {
13
+ getItem(key: string): string | null;
14
+ setItem(key: string, value: string): void;
15
+ removeItem(key: string): void;
16
+ }
17
+ export interface ConfigKeys {
18
+ /** Single key holding the whole config as JSON. */
19
+ config: string;
20
+ }
21
+ export declare const DEFAULT_CONFIG_KEYS: ConfigKeys;
22
+ /** Resolve an explicit storage, or fall back to `localStorage` in a browser.
23
+ * Shared with {@link ./externalChatFavorite.js} so both persistence modules
24
+ * agree on where "no storage available" should throw. */
25
+ export declare function resolveStorage(storage?: ConfigStorage): ConfigStorage;
26
+ /** Read the configured provider fallback list + Ollama URL from storage.
27
+ * A missing, empty, or corrupt blob resolves to an empty provider list
28
+ * (nothing configured yet) rather than throwing — the same "still runs,
29
+ * just answers no-credential" posture the rest of this package uses. */
30
+ export declare function loadConfig(storage?: ConfigStorage, keys?: ConfigKeys): AgentConfig;
31
+ /** Persist the full config, replacing whatever was stored before — callers
32
+ * (e.g. `ModelPicker`) hold the authoritative in-memory list and pass the
33
+ * whole thing on every change, the same controlled-component pattern the
34
+ * React layer uses. */
35
+ export declare function saveConfig(config: AgentConfig, storage?: ConfigStorage, keys?: ConfigKeys): void;
36
+ export declare function clearConfig(storage?: ConfigStorage, keys?: ConfigKeys): void;
37
+ /** Whether `cfg` has at least one usable candidate to dispatch to — Ollama
38
+ * (no key needed) or any vendor with at least one pooled key. Mirrors
39
+ * `candidatesOf` in `agent.ts` without needing to import its internals. */
40
+ export declare function isConfigReady(cfg: AgentConfig): boolean;
41
+ /** True when `provider` isn't already in `cfg.providers` — for an "Add
42
+ * provider" control to only offer vendors not yet configured. */
43
+ export declare function isProviderConfigured(cfg: AgentConfig, provider: ProviderId): boolean;
package/dist/config.js ADDED
@@ -0,0 +1,91 @@
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
+ import { PROVIDERS, isKnownProvider } from "./registry.js";
10
+ export const DEFAULT_CONFIG_KEYS = {
11
+ config: "aiConfig",
12
+ };
13
+ const DEFAULT_OLLAMA_URL = "http://localhost:11434";
14
+ /** Resolve an explicit storage, or fall back to `localStorage` in a browser.
15
+ * Shared with {@link ./externalChatFavorite.js} so both persistence modules
16
+ * agree on where "no storage available" should throw. */
17
+ export function resolveStorage(storage) {
18
+ if (storage)
19
+ return storage;
20
+ if (typeof localStorage !== "undefined")
21
+ return localStorage;
22
+ throw new Error("No storage available: pass a ConfigStorage explicitly outside a browser.");
23
+ }
24
+ function sanitizeCredential(raw) {
25
+ if (typeof raw !== "object" || raw === null)
26
+ return null;
27
+ const { provider, model, apiKeys } = raw;
28
+ if (typeof provider !== "string" || !isKnownProvider(provider))
29
+ return null;
30
+ const keys = Array.isArray(apiKeys)
31
+ ? apiKeys.filter((k) => typeof k === "string" && k.trim().length > 0).map((k) => k.trim())
32
+ : [];
33
+ const resolvedModel = typeof model === "string" && model.trim() ? model.trim() : PROVIDERS[provider].defaultModel;
34
+ return { provider, model: resolvedModel, apiKeys: keys };
35
+ }
36
+ /** Read the configured provider fallback list + Ollama URL from storage.
37
+ * A missing, empty, or corrupt blob resolves to an empty provider list
38
+ * (nothing configured yet) rather than throwing — the same "still runs,
39
+ * just answers no-credential" posture the rest of this package uses. */
40
+ export function loadConfig(storage, keys = DEFAULT_CONFIG_KEYS) {
41
+ const store = resolveStorage(storage);
42
+ const raw = store.getItem(keys.config);
43
+ if (!raw)
44
+ return { providers: [], ollamaUrl: DEFAULT_OLLAMA_URL };
45
+ let parsed;
46
+ try {
47
+ parsed = JSON.parse(raw);
48
+ }
49
+ catch {
50
+ return { providers: [], ollamaUrl: DEFAULT_OLLAMA_URL };
51
+ }
52
+ if (typeof parsed !== "object" || parsed === null)
53
+ return { providers: [], ollamaUrl: DEFAULT_OLLAMA_URL };
54
+ const { providers, ollamaUrl } = parsed;
55
+ const sanitized = Array.isArray(providers)
56
+ ? providers.map(sanitizeCredential).filter((c) => c !== null)
57
+ : [];
58
+ const resolvedOllamaUrl = typeof ollamaUrl === "string" && ollamaUrl.trim() ? ollamaUrl.trim() : DEFAULT_OLLAMA_URL;
59
+ return { providers: sanitized, ollamaUrl: resolvedOllamaUrl };
60
+ }
61
+ /** Persist the full config, replacing whatever was stored before — callers
62
+ * (e.g. `ModelPicker`) hold the authoritative in-memory list and pass the
63
+ * whole thing on every change, the same controlled-component pattern the
64
+ * React layer uses. */
65
+ export function saveConfig(config, storage, keys = DEFAULT_CONFIG_KEYS) {
66
+ const store = resolveStorage(storage);
67
+ const cleaned = {
68
+ providers: config.providers.map((cred) => ({
69
+ provider: cred.provider,
70
+ model: cred.model.trim(),
71
+ apiKeys: cred.apiKeys.map((k) => k.trim()).filter((k) => k.length > 0),
72
+ })),
73
+ ollamaUrl: config.ollamaUrl.trim() || DEFAULT_OLLAMA_URL,
74
+ };
75
+ store.setItem(keys.config, JSON.stringify(cleaned));
76
+ }
77
+ export function clearConfig(storage, keys = DEFAULT_CONFIG_KEYS) {
78
+ resolveStorage(storage).removeItem(keys.config);
79
+ }
80
+ /** Whether `cfg` has at least one usable candidate to dispatch to — Ollama
81
+ * (no key needed) or any vendor with at least one pooled key. Mirrors
82
+ * `candidatesOf` in `agent.ts` without needing to import its internals. */
83
+ export function isConfigReady(cfg) {
84
+ return cfg.providers.some((cred) => cred.provider === "ollama" || cred.apiKeys.length > 0);
85
+ }
86
+ /** True when `provider` isn't already in `cfg.providers` — for an "Add
87
+ * provider" control to only offer vendors not yet configured. */
88
+ export function isProviderConfigured(cfg, provider) {
89
+ return cfg.providers.some((cred) => cred.provider === provider);
90
+ }
91
+ //# sourceMappingURL=config.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"config.js","sourceRoot":"","sources":["../src/config.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,46 @@
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
+ import type { ExternalChatProviderId, ExternalChatProviderInfo } from "./types.js";
18
+ export declare const EXTERNAL_CHAT_PROVIDERS: Record<ExternalChatProviderId, ExternalChatProviderInfo>;
19
+ /** Minimal shape of the two browser APIs this needs — injectable so this is
20
+ * testable outside a real browser and swappable for a non-browser host. */
21
+ export interface OpenExternalChatDeps {
22
+ open?: (url: string) => unknown;
23
+ clipboard?: {
24
+ writeText(text: string): Promise<void>;
25
+ };
26
+ }
27
+ export interface OpenExternalChatResult {
28
+ readonly provider: ExternalChatProviderId;
29
+ readonly url: string;
30
+ /** True if this provider has a known prefill parameter and the question
31
+ * was actually embedded in `url`. False means `url` is just `homeUrl`. */
32
+ readonly prefilled: boolean;
33
+ /** True if the question was successfully copied to the clipboard — the
34
+ * fallback path a user needs whenever `prefilled` is false, and a useful
35
+ * safety net even when it's true. */
36
+ readonly copiedToClipboard: boolean;
37
+ }
38
+ /**
39
+ * Open `provider`'s chat with `question`, pre-filled where a prefill
40
+ * parameter is known, and always copy `question` to the clipboard as well.
41
+ *
42
+ * Never throws: a clipboard failure (permissions, a non-secure context, a
43
+ * non-browser host) is reported via `copiedToClipboard: false` rather than
44
+ * rejecting, since the tab still opens either way.
45
+ */
46
+ export declare function openExternalChat(provider: ExternalChatProviderId, question: string, deps?: OpenExternalChatDeps): Promise<OpenExternalChatResult>;