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.
- package/README.md +203 -0
- package/dist/agent.d.ts +64 -0
- package/dist/agent.js +160 -0
- package/dist/agent.js.map +1 -0
- package/dist/config.d.ts +43 -0
- package/dist/config.js +91 -0
- package/dist/config.js.map +1 -0
- package/dist/externalChat.d.ts +46 -0
- package/dist/externalChat.js +86 -0
- package/dist/externalChat.js.map +1 -0
- package/dist/externalChatFavorite.d.ts +20 -0
- package/dist/externalChatFavorite.js +33 -0
- package/dist/externalChatFavorite.js.map +1 -0
- package/dist/features.d.ts +30 -0
- package/dist/features.js +28 -0
- package/dist/features.js.map +1 -0
- package/dist/http.d.ts +29 -0
- package/dist/http.js +91 -0
- package/dist/http.js.map +1 -0
- package/dist/index.d.ts +22 -0
- package/dist/index.js +18 -0
- package/dist/index.js.map +1 -0
- package/dist/messages.d.ts +26 -0
- package/dist/messages.js +41 -0
- package/dist/messages.js.map +1 -0
- package/dist/providers/anthropic.d.ts +10 -0
- package/dist/providers/anthropic.js +64 -0
- package/dist/providers/anthropic.js.map +1 -0
- package/dist/providers/gemini.d.ts +8 -0
- package/dist/providers/gemini.js +66 -0
- package/dist/providers/gemini.js.map +1 -0
- package/dist/providers/ollama.d.ts +10 -0
- package/dist/providers/ollama.js +61 -0
- package/dist/providers/ollama.js.map +1 -0
- package/dist/providers/openaiCompatible.d.ts +9 -0
- package/dist/providers/openaiCompatible.js +64 -0
- package/dist/providers/openaiCompatible.js.map +1 -0
- package/dist/registry.d.ts +18 -0
- package/dist/registry.js +77 -0
- package/dist/registry.js.map +1 -0
- package/dist/types.d.ts +97 -0
- package/dist/types.js +8 -0
- package/dist/types.js.map +1 -0
- package/package.json +40 -0
- package/src/agent.ts +206 -0
- package/src/config.ts +117 -0
- package/src/externalChat.ts +115 -0
- package/src/externalChatFavorite.ts +43 -0
- package/src/features.ts +40 -0
- package/src/http.ts +100 -0
- package/src/index.ts +52 -0
- package/src/messages.ts +61 -0
- package/src/providers/anthropic.ts +86 -0
- package/src/providers/gemini.ts +95 -0
- package/src/providers/ollama.ts +81 -0
- package/src/providers/openaiCompatible.ts +89 -0
- package/src/registry.ts +82 -0
- 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.
|
package/dist/agent.d.ts
ADDED
|
@@ -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":"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"}
|
package/dist/config.d.ts
ADDED
|
@@ -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>;
|