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 +203 -203
- package/package.json +45 -40
- package/src/agent.ts +206 -206
- package/src/config.ts +117 -117
- package/src/externalChat.ts +115 -115
- package/src/externalChatFavorite.ts +43 -43
- package/src/features.ts +40 -40
- package/src/http.ts +100 -100
- package/src/index.ts +52 -52
- package/src/messages.ts +61 -61
- package/src/providers/anthropic.ts +86 -86
- package/src/providers/gemini.ts +95 -95
- package/src/providers/ollama.ts +81 -81
- package/src/providers/openaiCompatible.ts +89 -89
- package/src/registry.ts +82 -82
- package/src/types.ts +108 -108
package/src/agent.ts
CHANGED
|
@@ -1,206 +1,206 @@
|
|
|
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
|
-
|
|
17
|
-
import { buildMessages, type BuildMessagesOptions } from "./messages.js";
|
|
18
|
-
import { completeAnthropic, streamAnthropic } from "./providers/anthropic.js";
|
|
19
|
-
import { completeGemini, streamGemini } from "./providers/gemini.js";
|
|
20
|
-
import { completeOllama, streamOllama } from "./providers/ollama.js";
|
|
21
|
-
import { completeOpenAICompatible, streamOpenAICompatible } from "./providers/openaiCompatible.js";
|
|
22
|
-
import type {
|
|
23
|
-
AgentConfig,
|
|
24
|
-
ChatMessage,
|
|
25
|
-
ProviderCallConfig,
|
|
26
|
-
RequestOptions,
|
|
27
|
-
StreamOptions,
|
|
28
|
-
} from "./types.js";
|
|
29
|
-
|
|
30
|
-
/** One candidate that failed, kept only to compose
|
|
31
|
-
* {@link AllProvidersExhaustedError}'s message. */
|
|
32
|
-
interface FailedAttempt {
|
|
33
|
-
readonly provider: string;
|
|
34
|
-
readonly error: string;
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
/** `cfg.providers` has nothing usable at all — no vendor has a pooled key,
|
|
38
|
-
* and Ollama isn't configured either. Raised before any network call, the
|
|
39
|
-
* same way the Python core's `NoProviderAvailableError` is. */
|
|
40
|
-
export class NoProviderConfiguredError extends Error {
|
|
41
|
-
constructor() {
|
|
42
|
-
super("No AI provider is configured — add a provider and key (or Ollama) in AI settings.");
|
|
43
|
-
this.name = "NoProviderConfiguredError";
|
|
44
|
-
}
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
/** Every configured provider/key was tried and every one failed. */
|
|
48
|
-
export class AllProvidersExhaustedError extends Error {
|
|
49
|
-
readonly attempts: readonly FailedAttempt[];
|
|
50
|
-
|
|
51
|
-
constructor(attempts: readonly FailedAttempt[]) {
|
|
52
|
-
super(`All configured AI providers failed: ${attempts.map((a) => `${a.provider} (${a.error})`).join("; ")}`);
|
|
53
|
-
this.name = "AllProvidersExhaustedError";
|
|
54
|
-
this.attempts = attempts;
|
|
55
|
-
}
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
/**
|
|
59
|
-
* Flatten the configured provider list into concrete, single-key attempts,
|
|
60
|
-
* in fallback order: each provider's own pooled keys are tried in the order
|
|
61
|
-
* they were added before moving to the next configured provider. Ollama
|
|
62
|
-
* contributes at most one candidate (it authenticates via `cfg.ollamaUrl`,
|
|
63
|
-
* not a key) and only when its own entry is present in the list — a
|
|
64
|
-
* provider with no usable credential is never offered as a candidate at
|
|
65
|
-
* all, the same rule the Python core's registry-building applies.
|
|
66
|
-
*/
|
|
67
|
-
function candidatesOf(cfg: AgentConfig): ProviderCallConfig[] {
|
|
68
|
-
const candidates: ProviderCallConfig[] = [];
|
|
69
|
-
for (const cred of cfg.providers) {
|
|
70
|
-
if (cred.provider === "ollama") {
|
|
71
|
-
candidates.push({ provider: "ollama", model: cred.model, apiKey: "", ollamaUrl: cfg.ollamaUrl });
|
|
72
|
-
continue;
|
|
73
|
-
}
|
|
74
|
-
for (const apiKey of cred.apiKeys) {
|
|
75
|
-
if (apiKey) candidates.push({ provider: cred.provider, model: cred.model, apiKey, ollamaUrl: cfg.ollamaUrl });
|
|
76
|
-
}
|
|
77
|
-
}
|
|
78
|
-
return candidates;
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
function messagesOf(promptOrMessages: string | readonly ChatMessage[]): readonly ChatMessage[] {
|
|
82
|
-
return typeof promptOrMessages === "string" ? [{ role: "user", content: promptOrMessages }] : promptOrMessages;
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
function completeOne(
|
|
86
|
-
candidate: ProviderCallConfig,
|
|
87
|
-
messages: readonly ChatMessage[],
|
|
88
|
-
options: RequestOptions,
|
|
89
|
-
): Promise<string> {
|
|
90
|
-
switch (candidate.provider) {
|
|
91
|
-
case "gemini":
|
|
92
|
-
return completeGemini(candidate, messages, options);
|
|
93
|
-
case "anthropic":
|
|
94
|
-
return completeAnthropic(candidate, messages, options);
|
|
95
|
-
case "ollama":
|
|
96
|
-
return completeOllama(candidate, messages, options);
|
|
97
|
-
case "openai":
|
|
98
|
-
case "groq":
|
|
99
|
-
return completeOpenAICompatible(candidate.provider, candidate, messages, options);
|
|
100
|
-
}
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
/** Non-streaming completion, tried across every configured provider/key in
|
|
104
|
-
* order until one succeeds. `prompt` is wrapped as a single user turn; pass
|
|
105
|
-
* `messages` instead for an already-built multi-turn history.
|
|
106
|
-
*
|
|
107
|
-
* Raises:
|
|
108
|
-
* NoProviderConfiguredError: `cfg.providers` has no usable credential at all.
|
|
109
|
-
* AllProvidersExhaustedError: every candidate was tried and failed;
|
|
110
|
-
* `.attempts` lists each provider and its error.
|
|
111
|
-
*/
|
|
112
|
-
export async function complete(
|
|
113
|
-
cfg: AgentConfig,
|
|
114
|
-
promptOrMessages: string | readonly ChatMessage[],
|
|
115
|
-
options: RequestOptions = {},
|
|
116
|
-
): Promise<string> {
|
|
117
|
-
const candidates = candidatesOf(cfg);
|
|
118
|
-
if (candidates.length === 0) throw new NoProviderConfiguredError();
|
|
119
|
-
const messages = messagesOf(promptOrMessages);
|
|
120
|
-
|
|
121
|
-
const attempts: FailedAttempt[] = [];
|
|
122
|
-
for (const candidate of candidates) {
|
|
123
|
-
try {
|
|
124
|
-
return await completeOne(candidate, messages, options);
|
|
125
|
-
} catch (err) {
|
|
126
|
-
attempts.push({ provider: candidate.provider, error: (err as Error).message });
|
|
127
|
-
}
|
|
128
|
-
}
|
|
129
|
-
throw new AllProvidersExhaustedError(attempts);
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
function streamOne(
|
|
133
|
-
candidate: ProviderCallConfig,
|
|
134
|
-
messages: readonly ChatMessage[],
|
|
135
|
-
onChunk: (fullTextSoFar: string) => void,
|
|
136
|
-
options: RequestOptions,
|
|
137
|
-
): Promise<string> {
|
|
138
|
-
switch (candidate.provider) {
|
|
139
|
-
case "gemini":
|
|
140
|
-
return streamGemini(candidate, messages, onChunk, options);
|
|
141
|
-
case "anthropic":
|
|
142
|
-
return streamAnthropic(candidate, messages, onChunk, options);
|
|
143
|
-
case "ollama":
|
|
144
|
-
return streamOllama(candidate, messages, onChunk, options);
|
|
145
|
-
case "openai":
|
|
146
|
-
case "groq":
|
|
147
|
-
return streamOpenAICompatible(candidate.provider, candidate, messages, onChunk, options);
|
|
148
|
-
}
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
/**
|
|
152
|
-
* Streaming completion, tried across every configured provider/key in
|
|
153
|
-
* order — but only up to the first chunk actually streamed. Once any text
|
|
154
|
-
* has reached `onChunk` for a candidate, a later failure on that same
|
|
155
|
-
* candidate is surfaced immediately rather than silently retried on the
|
|
156
|
-
* next provider: the caller has already rendered a partial answer, and
|
|
157
|
-
* switching providers mid-stream would duplicate or contradict it rather
|
|
158
|
-
* than complete it.
|
|
159
|
-
*
|
|
160
|
-
* Raises the same errors as {@link complete}.
|
|
161
|
-
*/
|
|
162
|
-
export async function streamComplete(
|
|
163
|
-
cfg: AgentConfig,
|
|
164
|
-
promptOrMessages: string | readonly ChatMessage[],
|
|
165
|
-
options: StreamOptions,
|
|
166
|
-
): Promise<string> {
|
|
167
|
-
const candidates = candidatesOf(cfg);
|
|
168
|
-
if (candidates.length === 0) throw new NoProviderConfiguredError();
|
|
169
|
-
const messages = messagesOf(promptOrMessages);
|
|
170
|
-
const { onChunk, ...rest } = options;
|
|
171
|
-
|
|
172
|
-
const attempts: FailedAttempt[] = [];
|
|
173
|
-
for (const candidate of candidates) {
|
|
174
|
-
let startedStreaming = false;
|
|
175
|
-
try {
|
|
176
|
-
return await streamOne(
|
|
177
|
-
candidate,
|
|
178
|
-
messages,
|
|
179
|
-
(text) => {
|
|
180
|
-
startedStreaming = true;
|
|
181
|
-
onChunk(text);
|
|
182
|
-
},
|
|
183
|
-
rest,
|
|
184
|
-
);
|
|
185
|
-
} catch (err) {
|
|
186
|
-
if (startedStreaming) throw err;
|
|
187
|
-
attempts.push({ provider: candidate.provider, error: (err as Error).message });
|
|
188
|
-
}
|
|
189
|
-
}
|
|
190
|
-
throw new AllProvidersExhaustedError(attempts);
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
/**
|
|
194
|
-
* Streaming multi-turn chat: normalises a raw UI message list via
|
|
195
|
-
* {@link buildMessages} (role validation, alternation, sentinel-dropping)
|
|
196
|
-
* before dispatching — the one thing `streamComplete()` deliberately
|
|
197
|
-
* doesn't do, since its `messages` input is assumed already well-formed.
|
|
198
|
-
*/
|
|
199
|
-
export async function streamChat(
|
|
200
|
-
cfg: AgentConfig,
|
|
201
|
-
uiMessages: readonly ChatMessage[],
|
|
202
|
-
options: StreamOptions & BuildMessagesOptions,
|
|
203
|
-
): Promise<string> {
|
|
204
|
-
const normalised = buildMessages(uiMessages, options);
|
|
205
|
-
return streamComplete(cfg, normalised, options);
|
|
206
|
-
}
|
|
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
|
+
|
|
17
|
+
import { buildMessages, type BuildMessagesOptions } from "./messages.js";
|
|
18
|
+
import { completeAnthropic, streamAnthropic } from "./providers/anthropic.js";
|
|
19
|
+
import { completeGemini, streamGemini } from "./providers/gemini.js";
|
|
20
|
+
import { completeOllama, streamOllama } from "./providers/ollama.js";
|
|
21
|
+
import { completeOpenAICompatible, streamOpenAICompatible } from "./providers/openaiCompatible.js";
|
|
22
|
+
import type {
|
|
23
|
+
AgentConfig,
|
|
24
|
+
ChatMessage,
|
|
25
|
+
ProviderCallConfig,
|
|
26
|
+
RequestOptions,
|
|
27
|
+
StreamOptions,
|
|
28
|
+
} from "./types.js";
|
|
29
|
+
|
|
30
|
+
/** One candidate that failed, kept only to compose
|
|
31
|
+
* {@link AllProvidersExhaustedError}'s message. */
|
|
32
|
+
interface FailedAttempt {
|
|
33
|
+
readonly provider: string;
|
|
34
|
+
readonly error: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** `cfg.providers` has nothing usable at all — no vendor has a pooled key,
|
|
38
|
+
* and Ollama isn't configured either. Raised before any network call, the
|
|
39
|
+
* same way the Python core's `NoProviderAvailableError` is. */
|
|
40
|
+
export class NoProviderConfiguredError extends Error {
|
|
41
|
+
constructor() {
|
|
42
|
+
super("No AI provider is configured — add a provider and key (or Ollama) in AI settings.");
|
|
43
|
+
this.name = "NoProviderConfiguredError";
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Every configured provider/key was tried and every one failed. */
|
|
48
|
+
export class AllProvidersExhaustedError extends Error {
|
|
49
|
+
readonly attempts: readonly FailedAttempt[];
|
|
50
|
+
|
|
51
|
+
constructor(attempts: readonly FailedAttempt[]) {
|
|
52
|
+
super(`All configured AI providers failed: ${attempts.map((a) => `${a.provider} (${a.error})`).join("; ")}`);
|
|
53
|
+
this.name = "AllProvidersExhaustedError";
|
|
54
|
+
this.attempts = attempts;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Flatten the configured provider list into concrete, single-key attempts,
|
|
60
|
+
* in fallback order: each provider's own pooled keys are tried in the order
|
|
61
|
+
* they were added before moving to the next configured provider. Ollama
|
|
62
|
+
* contributes at most one candidate (it authenticates via `cfg.ollamaUrl`,
|
|
63
|
+
* not a key) and only when its own entry is present in the list — a
|
|
64
|
+
* provider with no usable credential is never offered as a candidate at
|
|
65
|
+
* all, the same rule the Python core's registry-building applies.
|
|
66
|
+
*/
|
|
67
|
+
function candidatesOf(cfg: AgentConfig): ProviderCallConfig[] {
|
|
68
|
+
const candidates: ProviderCallConfig[] = [];
|
|
69
|
+
for (const cred of cfg.providers) {
|
|
70
|
+
if (cred.provider === "ollama") {
|
|
71
|
+
candidates.push({ provider: "ollama", model: cred.model, apiKey: "", ollamaUrl: cfg.ollamaUrl });
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
for (const apiKey of cred.apiKeys) {
|
|
75
|
+
if (apiKey) candidates.push({ provider: cred.provider, model: cred.model, apiKey, ollamaUrl: cfg.ollamaUrl });
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return candidates;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function messagesOf(promptOrMessages: string | readonly ChatMessage[]): readonly ChatMessage[] {
|
|
82
|
+
return typeof promptOrMessages === "string" ? [{ role: "user", content: promptOrMessages }] : promptOrMessages;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function completeOne(
|
|
86
|
+
candidate: ProviderCallConfig,
|
|
87
|
+
messages: readonly ChatMessage[],
|
|
88
|
+
options: RequestOptions,
|
|
89
|
+
): Promise<string> {
|
|
90
|
+
switch (candidate.provider) {
|
|
91
|
+
case "gemini":
|
|
92
|
+
return completeGemini(candidate, messages, options);
|
|
93
|
+
case "anthropic":
|
|
94
|
+
return completeAnthropic(candidate, messages, options);
|
|
95
|
+
case "ollama":
|
|
96
|
+
return completeOllama(candidate, messages, options);
|
|
97
|
+
case "openai":
|
|
98
|
+
case "groq":
|
|
99
|
+
return completeOpenAICompatible(candidate.provider, candidate, messages, options);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Non-streaming completion, tried across every configured provider/key in
|
|
104
|
+
* order until one succeeds. `prompt` is wrapped as a single user turn; pass
|
|
105
|
+
* `messages` instead for an already-built multi-turn history.
|
|
106
|
+
*
|
|
107
|
+
* Raises:
|
|
108
|
+
* NoProviderConfiguredError: `cfg.providers` has no usable credential at all.
|
|
109
|
+
* AllProvidersExhaustedError: every candidate was tried and failed;
|
|
110
|
+
* `.attempts` lists each provider and its error.
|
|
111
|
+
*/
|
|
112
|
+
export async function complete(
|
|
113
|
+
cfg: AgentConfig,
|
|
114
|
+
promptOrMessages: string | readonly ChatMessage[],
|
|
115
|
+
options: RequestOptions = {},
|
|
116
|
+
): Promise<string> {
|
|
117
|
+
const candidates = candidatesOf(cfg);
|
|
118
|
+
if (candidates.length === 0) throw new NoProviderConfiguredError();
|
|
119
|
+
const messages = messagesOf(promptOrMessages);
|
|
120
|
+
|
|
121
|
+
const attempts: FailedAttempt[] = [];
|
|
122
|
+
for (const candidate of candidates) {
|
|
123
|
+
try {
|
|
124
|
+
return await completeOne(candidate, messages, options);
|
|
125
|
+
} catch (err) {
|
|
126
|
+
attempts.push({ provider: candidate.provider, error: (err as Error).message });
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
throw new AllProvidersExhaustedError(attempts);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function streamOne(
|
|
133
|
+
candidate: ProviderCallConfig,
|
|
134
|
+
messages: readonly ChatMessage[],
|
|
135
|
+
onChunk: (fullTextSoFar: string) => void,
|
|
136
|
+
options: RequestOptions,
|
|
137
|
+
): Promise<string> {
|
|
138
|
+
switch (candidate.provider) {
|
|
139
|
+
case "gemini":
|
|
140
|
+
return streamGemini(candidate, messages, onChunk, options);
|
|
141
|
+
case "anthropic":
|
|
142
|
+
return streamAnthropic(candidate, messages, onChunk, options);
|
|
143
|
+
case "ollama":
|
|
144
|
+
return streamOllama(candidate, messages, onChunk, options);
|
|
145
|
+
case "openai":
|
|
146
|
+
case "groq":
|
|
147
|
+
return streamOpenAICompatible(candidate.provider, candidate, messages, onChunk, options);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Streaming completion, tried across every configured provider/key in
|
|
153
|
+
* order — but only up to the first chunk actually streamed. Once any text
|
|
154
|
+
* has reached `onChunk` for a candidate, a later failure on that same
|
|
155
|
+
* candidate is surfaced immediately rather than silently retried on the
|
|
156
|
+
* next provider: the caller has already rendered a partial answer, and
|
|
157
|
+
* switching providers mid-stream would duplicate or contradict it rather
|
|
158
|
+
* than complete it.
|
|
159
|
+
*
|
|
160
|
+
* Raises the same errors as {@link complete}.
|
|
161
|
+
*/
|
|
162
|
+
export async function streamComplete(
|
|
163
|
+
cfg: AgentConfig,
|
|
164
|
+
promptOrMessages: string | readonly ChatMessage[],
|
|
165
|
+
options: StreamOptions,
|
|
166
|
+
): Promise<string> {
|
|
167
|
+
const candidates = candidatesOf(cfg);
|
|
168
|
+
if (candidates.length === 0) throw new NoProviderConfiguredError();
|
|
169
|
+
const messages = messagesOf(promptOrMessages);
|
|
170
|
+
const { onChunk, ...rest } = options;
|
|
171
|
+
|
|
172
|
+
const attempts: FailedAttempt[] = [];
|
|
173
|
+
for (const candidate of candidates) {
|
|
174
|
+
let startedStreaming = false;
|
|
175
|
+
try {
|
|
176
|
+
return await streamOne(
|
|
177
|
+
candidate,
|
|
178
|
+
messages,
|
|
179
|
+
(text) => {
|
|
180
|
+
startedStreaming = true;
|
|
181
|
+
onChunk(text);
|
|
182
|
+
},
|
|
183
|
+
rest,
|
|
184
|
+
);
|
|
185
|
+
} catch (err) {
|
|
186
|
+
if (startedStreaming) throw err;
|
|
187
|
+
attempts.push({ provider: candidate.provider, error: (err as Error).message });
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
throw new AllProvidersExhaustedError(attempts);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Streaming multi-turn chat: normalises a raw UI message list via
|
|
195
|
+
* {@link buildMessages} (role validation, alternation, sentinel-dropping)
|
|
196
|
+
* before dispatching — the one thing `streamComplete()` deliberately
|
|
197
|
+
* doesn't do, since its `messages` input is assumed already well-formed.
|
|
198
|
+
*/
|
|
199
|
+
export async function streamChat(
|
|
200
|
+
cfg: AgentConfig,
|
|
201
|
+
uiMessages: readonly ChatMessage[],
|
|
202
|
+
options: StreamOptions & BuildMessagesOptions,
|
|
203
|
+
): Promise<string> {
|
|
204
|
+
const normalised = buildMessages(uiMessages, options);
|
|
205
|
+
return streamComplete(cfg, normalised, options);
|
|
206
|
+
}
|
package/src/config.ts
CHANGED
|
@@ -1,117 +1,117 @@
|
|
|
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
|
-
|
|
10
|
-
import { PROVIDERS, isKnownProvider } from "./registry.js";
|
|
11
|
-
import type { AgentConfig, ProviderCredential, ProviderId } from "./types.js";
|
|
12
|
-
|
|
13
|
-
/** Minimal Web Storage shape — matches `localStorage`, injectable for tests
|
|
14
|
-
* or for a non-browser host (e.g. a React Native AsyncStorage shim). */
|
|
15
|
-
export interface ConfigStorage {
|
|
16
|
-
getItem(key: string): string | null;
|
|
17
|
-
setItem(key: string, value: string): void;
|
|
18
|
-
removeItem(key: string): void;
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
export interface ConfigKeys {
|
|
22
|
-
/** Single key holding the whole config as JSON. */
|
|
23
|
-
config: string;
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
export const DEFAULT_CONFIG_KEYS: ConfigKeys = {
|
|
27
|
-
config: "aiConfig",
|
|
28
|
-
};
|
|
29
|
-
|
|
30
|
-
const DEFAULT_OLLAMA_URL = "http://localhost:11434";
|
|
31
|
-
|
|
32
|
-
/** Resolve an explicit storage, or fall back to `localStorage` in a browser.
|
|
33
|
-
* Shared with {@link ./externalChatFavorite.js} so both persistence modules
|
|
34
|
-
* agree on where "no storage available" should throw. */
|
|
35
|
-
export function resolveStorage(storage?: ConfigStorage): ConfigStorage {
|
|
36
|
-
if (storage) return storage;
|
|
37
|
-
if (typeof localStorage !== "undefined") return localStorage;
|
|
38
|
-
throw new Error(
|
|
39
|
-
"No storage available: pass a ConfigStorage explicitly outside a browser.",
|
|
40
|
-
);
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
function sanitizeCredential(raw: unknown): ProviderCredential | null {
|
|
44
|
-
if (typeof raw !== "object" || raw === null) return null;
|
|
45
|
-
const { provider, model, apiKeys } = raw as Record<string, unknown>;
|
|
46
|
-
if (typeof provider !== "string" || !isKnownProvider(provider)) return null;
|
|
47
|
-
|
|
48
|
-
const keys = Array.isArray(apiKeys)
|
|
49
|
-
? apiKeys.filter((k): k is string => typeof k === "string" && k.trim().length > 0).map((k) => k.trim())
|
|
50
|
-
: [];
|
|
51
|
-
const resolvedModel = typeof model === "string" && model.trim() ? model.trim() : PROVIDERS[provider].defaultModel;
|
|
52
|
-
|
|
53
|
-
return { provider, model: resolvedModel, apiKeys: keys };
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
/** Read the configured provider fallback list + Ollama URL from storage.
|
|
57
|
-
* A missing, empty, or corrupt blob resolves to an empty provider list
|
|
58
|
-
* (nothing configured yet) rather than throwing — the same "still runs,
|
|
59
|
-
* just answers no-credential" posture the rest of this package uses. */
|
|
60
|
-
export function loadConfig(
|
|
61
|
-
storage?: ConfigStorage,
|
|
62
|
-
keys: ConfigKeys = DEFAULT_CONFIG_KEYS,
|
|
63
|
-
): AgentConfig {
|
|
64
|
-
const store = resolveStorage(storage);
|
|
65
|
-
const raw = store.getItem(keys.config);
|
|
66
|
-
if (!raw) return { providers: [], ollamaUrl: DEFAULT_OLLAMA_URL };
|
|
67
|
-
|
|
68
|
-
let parsed: unknown;
|
|
69
|
-
try {
|
|
70
|
-
parsed = JSON.parse(raw);
|
|
71
|
-
} catch {
|
|
72
|
-
return { providers: [], ollamaUrl: DEFAULT_OLLAMA_URL };
|
|
73
|
-
}
|
|
74
|
-
if (typeof parsed !== "object" || parsed === null) return { providers: [], ollamaUrl: DEFAULT_OLLAMA_URL };
|
|
75
|
-
|
|
76
|
-
const { providers, ollamaUrl } = parsed as Record<string, unknown>;
|
|
77
|
-
const sanitized = Array.isArray(providers)
|
|
78
|
-
? providers.map(sanitizeCredential).filter((c): c is ProviderCredential => c !== null)
|
|
79
|
-
: [];
|
|
80
|
-
const resolvedOllamaUrl = typeof ollamaUrl === "string" && ollamaUrl.trim() ? ollamaUrl.trim() : DEFAULT_OLLAMA_URL;
|
|
81
|
-
|
|
82
|
-
return { providers: sanitized, ollamaUrl: resolvedOllamaUrl };
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
/** Persist the full config, replacing whatever was stored before — callers
|
|
86
|
-
* (e.g. `ModelPicker`) hold the authoritative in-memory list and pass the
|
|
87
|
-
* whole thing on every change, the same controlled-component pattern the
|
|
88
|
-
* React layer uses. */
|
|
89
|
-
export function saveConfig(config: AgentConfig, storage?: ConfigStorage, keys: ConfigKeys = DEFAULT_CONFIG_KEYS): void {
|
|
90
|
-
const store = resolveStorage(storage);
|
|
91
|
-
const cleaned: AgentConfig = {
|
|
92
|
-
providers: config.providers.map((cred) => ({
|
|
93
|
-
provider: cred.provider,
|
|
94
|
-
model: cred.model.trim(),
|
|
95
|
-
apiKeys: cred.apiKeys.map((k) => k.trim()).filter((k) => k.length > 0),
|
|
96
|
-
})),
|
|
97
|
-
ollamaUrl: config.ollamaUrl.trim() || DEFAULT_OLLAMA_URL,
|
|
98
|
-
};
|
|
99
|
-
store.setItem(keys.config, JSON.stringify(cleaned));
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
export function clearConfig(storage?: ConfigStorage, keys: ConfigKeys = DEFAULT_CONFIG_KEYS): void {
|
|
103
|
-
resolveStorage(storage).removeItem(keys.config);
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
/** Whether `cfg` has at least one usable candidate to dispatch to — Ollama
|
|
107
|
-
* (no key needed) or any vendor with at least one pooled key. Mirrors
|
|
108
|
-
* `candidatesOf` in `agent.ts` without needing to import its internals. */
|
|
109
|
-
export function isConfigReady(cfg: AgentConfig): boolean {
|
|
110
|
-
return cfg.providers.some((cred) => cred.provider === "ollama" || cred.apiKeys.length > 0);
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
/** True when `provider` isn't already in `cfg.providers` — for an "Add
|
|
114
|
-
* provider" control to only offer vendors not yet configured. */
|
|
115
|
-
export function isProviderConfigured(cfg: AgentConfig, provider: ProviderId): boolean {
|
|
116
|
-
return cfg.providers.some((cred) => cred.provider === provider);
|
|
117
|
-
}
|
|
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
|
+
|
|
10
|
+
import { PROVIDERS, isKnownProvider } from "./registry.js";
|
|
11
|
+
import type { AgentConfig, ProviderCredential, ProviderId } from "./types.js";
|
|
12
|
+
|
|
13
|
+
/** Minimal Web Storage shape — matches `localStorage`, injectable for tests
|
|
14
|
+
* or for a non-browser host (e.g. a React Native AsyncStorage shim). */
|
|
15
|
+
export interface ConfigStorage {
|
|
16
|
+
getItem(key: string): string | null;
|
|
17
|
+
setItem(key: string, value: string): void;
|
|
18
|
+
removeItem(key: string): void;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface ConfigKeys {
|
|
22
|
+
/** Single key holding the whole config as JSON. */
|
|
23
|
+
config: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export const DEFAULT_CONFIG_KEYS: ConfigKeys = {
|
|
27
|
+
config: "aiConfig",
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
const DEFAULT_OLLAMA_URL = "http://localhost:11434";
|
|
31
|
+
|
|
32
|
+
/** Resolve an explicit storage, or fall back to `localStorage` in a browser.
|
|
33
|
+
* Shared with {@link ./externalChatFavorite.js} so both persistence modules
|
|
34
|
+
* agree on where "no storage available" should throw. */
|
|
35
|
+
export function resolveStorage(storage?: ConfigStorage): ConfigStorage {
|
|
36
|
+
if (storage) return storage;
|
|
37
|
+
if (typeof localStorage !== "undefined") return localStorage;
|
|
38
|
+
throw new Error(
|
|
39
|
+
"No storage available: pass a ConfigStorage explicitly outside a browser.",
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function sanitizeCredential(raw: unknown): ProviderCredential | null {
|
|
44
|
+
if (typeof raw !== "object" || raw === null) return null;
|
|
45
|
+
const { provider, model, apiKeys } = raw as Record<string, unknown>;
|
|
46
|
+
if (typeof provider !== "string" || !isKnownProvider(provider)) return null;
|
|
47
|
+
|
|
48
|
+
const keys = Array.isArray(apiKeys)
|
|
49
|
+
? apiKeys.filter((k): k is string => typeof k === "string" && k.trim().length > 0).map((k) => k.trim())
|
|
50
|
+
: [];
|
|
51
|
+
const resolvedModel = typeof model === "string" && model.trim() ? model.trim() : PROVIDERS[provider].defaultModel;
|
|
52
|
+
|
|
53
|
+
return { provider, model: resolvedModel, apiKeys: keys };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Read the configured provider fallback list + Ollama URL from storage.
|
|
57
|
+
* A missing, empty, or corrupt blob resolves to an empty provider list
|
|
58
|
+
* (nothing configured yet) rather than throwing — the same "still runs,
|
|
59
|
+
* just answers no-credential" posture the rest of this package uses. */
|
|
60
|
+
export function loadConfig(
|
|
61
|
+
storage?: ConfigStorage,
|
|
62
|
+
keys: ConfigKeys = DEFAULT_CONFIG_KEYS,
|
|
63
|
+
): AgentConfig {
|
|
64
|
+
const store = resolveStorage(storage);
|
|
65
|
+
const raw = store.getItem(keys.config);
|
|
66
|
+
if (!raw) return { providers: [], ollamaUrl: DEFAULT_OLLAMA_URL };
|
|
67
|
+
|
|
68
|
+
let parsed: unknown;
|
|
69
|
+
try {
|
|
70
|
+
parsed = JSON.parse(raw);
|
|
71
|
+
} catch {
|
|
72
|
+
return { providers: [], ollamaUrl: DEFAULT_OLLAMA_URL };
|
|
73
|
+
}
|
|
74
|
+
if (typeof parsed !== "object" || parsed === null) return { providers: [], ollamaUrl: DEFAULT_OLLAMA_URL };
|
|
75
|
+
|
|
76
|
+
const { providers, ollamaUrl } = parsed as Record<string, unknown>;
|
|
77
|
+
const sanitized = Array.isArray(providers)
|
|
78
|
+
? providers.map(sanitizeCredential).filter((c): c is ProviderCredential => c !== null)
|
|
79
|
+
: [];
|
|
80
|
+
const resolvedOllamaUrl = typeof ollamaUrl === "string" && ollamaUrl.trim() ? ollamaUrl.trim() : DEFAULT_OLLAMA_URL;
|
|
81
|
+
|
|
82
|
+
return { providers: sanitized, ollamaUrl: resolvedOllamaUrl };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Persist the full config, replacing whatever was stored before — callers
|
|
86
|
+
* (e.g. `ModelPicker`) hold the authoritative in-memory list and pass the
|
|
87
|
+
* whole thing on every change, the same controlled-component pattern the
|
|
88
|
+
* React layer uses. */
|
|
89
|
+
export function saveConfig(config: AgentConfig, storage?: ConfigStorage, keys: ConfigKeys = DEFAULT_CONFIG_KEYS): void {
|
|
90
|
+
const store = resolveStorage(storage);
|
|
91
|
+
const cleaned: AgentConfig = {
|
|
92
|
+
providers: config.providers.map((cred) => ({
|
|
93
|
+
provider: cred.provider,
|
|
94
|
+
model: cred.model.trim(),
|
|
95
|
+
apiKeys: cred.apiKeys.map((k) => k.trim()).filter((k) => k.length > 0),
|
|
96
|
+
})),
|
|
97
|
+
ollamaUrl: config.ollamaUrl.trim() || DEFAULT_OLLAMA_URL,
|
|
98
|
+
};
|
|
99
|
+
store.setItem(keys.config, JSON.stringify(cleaned));
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function clearConfig(storage?: ConfigStorage, keys: ConfigKeys = DEFAULT_CONFIG_KEYS): void {
|
|
103
|
+
resolveStorage(storage).removeItem(keys.config);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Whether `cfg` has at least one usable candidate to dispatch to — Ollama
|
|
107
|
+
* (no key needed) or any vendor with at least one pooled key. Mirrors
|
|
108
|
+
* `candidatesOf` in `agent.ts` without needing to import its internals. */
|
|
109
|
+
export function isConfigReady(cfg: AgentConfig): boolean {
|
|
110
|
+
return cfg.providers.some((cred) => cred.provider === "ollama" || cred.apiKeys.length > 0);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** True when `provider` isn't already in `cfg.providers` — for an "Add
|
|
114
|
+
* provider" control to only offer vendors not yet configured. */
|
|
115
|
+
export function isProviderConfigured(cfg: AgentConfig, provider: ProviderId): boolean {
|
|
116
|
+
return cfg.providers.some((cred) => cred.provider === provider);
|
|
117
|
+
}
|