best-email-builder 0.5.1 → 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 (32) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/README.md +36 -7
  3. package/dist/ai-server.js +3 -1
  4. package/dist/ai.js +163 -35
  5. package/dist/ai.js.map +1 -1
  6. package/dist/builder.js +4 -4
  7. package/dist/{chunk-2Y7CDXZM.js → chunk-5W4TUUPD.js} +3 -3
  8. package/dist/{chunk-N7DEVAIP.js → chunk-JU3OUO6X.js} +2 -2
  9. package/dist/{chunk-7QQH6MTV.js → chunk-NC2YDZSP.js} +4 -4
  10. package/dist/{chunk-BAA5XMWY.js → chunk-NZIQF7IW.js} +77 -22
  11. package/dist/{chunk-BAA5XMWY.js.map → chunk-NZIQF7IW.js.map} +1 -1
  12. package/dist/{chunk-H4W65GBX.js → chunk-QPAZLCPO.js} +2 -2
  13. package/dist/{chunk-H4W65GBX.js.map → chunk-QPAZLCPO.js.map} +1 -1
  14. package/dist/{chunk-2RKVJQSD.js → chunk-XEFBPP5Y.js} +3 -3
  15. package/dist/index.js +5 -5
  16. package/dist/library.js +4 -4
  17. package/dist/server.js +1 -1
  18. package/dist/styles.css +1 -1
  19. package/dist/types/ai/ConnectAi.d.ts +4 -0
  20. package/dist/types/ai/claude.d.ts +30 -5
  21. package/dist/types/ai/createAi.d.ts +5 -3
  22. package/dist/types/ai/index.d.ts +2 -2
  23. package/dist/types/ai/keys.d.ts +15 -26
  24. package/dist/types/ai/providers.d.ts +15 -1
  25. package/dist/types/ai/server.d.ts +1 -1
  26. package/dist/types/core/constants.d.ts +1 -1
  27. package/dist/types/engine/NativeAdapter.d.ts +1 -1
  28. package/package.json +1 -1
  29. /package/dist/{chunk-2Y7CDXZM.js.map → chunk-5W4TUUPD.js.map} +0 -0
  30. /package/dist/{chunk-N7DEVAIP.js.map → chunk-JU3OUO6X.js.map} +0 -0
  31. /package/dist/{chunk-7QQH6MTV.js.map → chunk-NC2YDZSP.js.map} +0 -0
  32. /package/dist/{chunk-2RKVJQSD.js.map → chunk-XEFBPP5Y.js.map} +0 -0
package/CHANGELOG.md CHANGED
@@ -6,6 +6,32 @@ All notable changes are documented here. The format follows
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.6.0] — 2026-10-15
10
+
11
+ ### Added
12
+ - **The user chooses the provider.** `createAi({ provider: [...], askKey: true })`
13
+ takes a list: the Connect dialog shows the providers as cards (name, model,
14
+ free or not, a dot on those whose key this browser keeps; arrows move
15
+ through them). The choice and one key per provider are remembered, and a
16
+ provider whose key is kept needs nothing pasted. `ai.key` gains `provider`,
17
+ `providers`, `choose(id)` and `get(id)`; `ai.key.request()` opens the dialog
18
+ to change provider.
19
+ - **`claudeProvider({ client })`** — Claude as one of those providers, with
20
+ the user's own Anthropic key; the application's SDK is loaded when Claude
21
+ is chosen.
22
+ - **`failureCode(status, message)`** — the rule that turns a provider's
23
+ failure into the interface's error, exported for a model of your own.
24
+ - The live demo offers OpenAI, Claude, Mistral, Gemini and OpenRouter, says
25
+ which one is connected, and has a *Change provider* link.
26
+
27
+ ### Fixed
28
+ - **An exhausted quota said "busy, try again".** OpenAI answers a quota
29
+ spent with a 429 (`insufficient_quota`), Anthropic an empty credit balance
30
+ with a 400: both are now `MODEL_CREDITS`.
31
+ - **Claude's failures were all "could not answer".** The SDK's errors are now
32
+ read like every provider's: a refused key is `KEY_REFUSED` (asked again with
33
+ `askKey`), no credits `MODEL_CREDITS`, overloaded (529) `MODEL_BUSY`.
34
+
9
35
  ## [0.5.1] — 2026-10-15
10
36
 
11
37
  ### Fixed
package/README.md CHANGED
@@ -61,7 +61,8 @@ Best Email Builder is the second option done once, properly, and given away:
61
61
  model's MJML is cleaned and checked like any template before it gets there.
62
62
  OpenRouter, Gemini, Groq, Mistral, OpenAI, DeepSeek, Cerebras, Ollama or any
63
63
  OpenAI-compatible API by its key — free tiers included — and Claude through
64
- its SDK; on your server, or in the page with each user's own key.
64
+ its SDK; on your server, or in the page with each user's own key — and the
65
+ provider they choose.
65
66
  - **Any text rewritten in one click** — select a text or a button: *Shorter*,
66
67
  *Longer*, *Fix mistakes*, *Friendlier*, *More formal*, *Translate* into 13
67
68
  languages, or say what to change in your own words. The text changes in
@@ -363,9 +364,19 @@ the provider refuses is forgotten and asked again. Off by default:
363
364
  const ai = createAi({ provider: 'openrouter', askKey: true });
364
365
  ```
365
366
 
366
- `ai.key` reads and changes it: `get()`, `set(key)`, `forget()`,
367
- `request()`, `subscribe(fn)`, `state()` (`{ asking, refused, connected }`).
368
- This is what the live demo does.
367
+ Give a list and each user chooses: the dialog shows the providers as cards
368
+ (name, model, free or not, a dot on those whose key this browser keeps). The
369
+ choice and one key per provider are remembered; going back to a provider whose
370
+ key is kept needs nothing pasted.
371
+
372
+ ```jsx
373
+ const ai = createAi({ provider: ['openai', 'mistral', 'gemini', 'openrouter'], askKey: true });
374
+ ```
375
+
376
+ `ai.key` reads and changes it: `provider` (the one in use), `providers`,
377
+ `choose(id)`, `get(id?)`, `set(key)`, `forget()`, `request()` (opens the
378
+ dialog — to change provider, for one), `subscribe(fn)`, `state()`
379
+ (`{ asking, refused, connected }`). This is what the live demo does.
369
380
 
370
381
  **In the page, with one key for everyone** — prototypes and internal tools
371
382
  only, the key is in the page's code: `createAi({ provider: 'groq', apiKey })`.
@@ -411,7 +422,10 @@ providerEmailModel({ provider: { baseUrl: 'http://localhost:1234/v1', model: 'qw
411
422
  quotes of HTML) can be told to skip it: `provider: { …, format: 'text' }`.
412
423
  - The provider's failures become errors the interface explains, in its
413
424
  language: `KEY_MISSING`, `KEY_REFUSED`, `MODEL_CREDITS`, `MODEL_BUSY`,
414
- `MODEL_ERROR` (with the provider's own text in `detail`). None is a 401:
425
+ `MODEL_ERROR` (with the provider's own text in `detail`). An exhausted
426
+ quota is `MODEL_CREDITS`, not `MODEL_BUSY`, even when it comes as a 429
427
+ (OpenAI's `insufficient_quota`) or a 400 (Anthropic's credit balance);
428
+ `failureCode(status, message)` is that rule, for a model of your own. None is a 401:
415
429
  the key is the AI's, not the user's session. A 403 about the model (not
416
430
  available here, in this region) or the content is not `KEY_REFUSED`: with
417
431
  `askKey`, the user's key is not thrown away for it.
@@ -419,7 +433,21 @@ providerEmailModel({ provider: { baseUrl: 'http://localhost:1234/v1', model: 'qw
419
433
  Anthropic SDK (`npm install @anthropic-ai/sdk`) — `claude-opus-5-5` by
420
434
  default, `effort: 'medium'`, structured output, streamed so a long email
421
435
  does not hit a request timeout — or through OpenRouter with an
422
- `anthropic/…` model and one key for every model.
436
+ `anthropic/…` model and one key for every model. With `askKey`, Claude is
437
+ one more provider to choose, with the user's own Anthropic key and the SDK
438
+ loaded only when it is chosen:
439
+
440
+ ```js
441
+ import { claudeProvider, createAi } from 'best-email-builder/ai';
442
+
443
+ const claude = claudeProvider({
444
+ client: async (apiKey) => {
445
+ const { default: Anthropic } = await import('@anthropic-ai/sdk');
446
+ return new Anthropic({ apiKey, dangerouslyAllowBrowser: true });
447
+ },
448
+ });
449
+ createAi({ provider: ['openai', claude, 'gemini'], askKey: true });
450
+ ```
423
451
  - **Anything else** is a function `({ system, messages, schema, signal, task })
424
452
  => Promise<string>` returning the JSON text — `createEmailGenerator({ model })`
425
453
  on the server, `createAi({ model })` in the page.
@@ -474,7 +502,8 @@ another click another one, the first row brings the user's subject back.
474
502
  are exported.
475
503
 
476
504
  The live demo has no server: there, the AI runs in the page with the
477
- visitor's own OpenRouter key (`createAi({ provider: 'openrouter', askKey: true })`).
505
+ visitor's own key, for the provider they choose — OpenAI, Claude, Mistral,
506
+ Gemini or OpenRouter (`createAi({ provider: ['openai', claude, 'mistral', 'gemini', 'openrouter'], askKey: true })`).
478
507
 
479
508
  ### 5. Your brand (optional)
480
509
 
package/dist/ai-server.js CHANGED
@@ -20,6 +20,7 @@ import {
20
20
  createRewriter,
21
21
  createSubjectSuggester,
22
22
  emailWords,
23
+ failureCode,
23
24
  normalizeGeneratedMjml,
24
25
  parseDraft,
25
26
  providerEmailModel,
@@ -29,7 +30,7 @@ import {
29
30
  reviewDraft,
30
31
  reviewRewrite,
31
32
  reviewSubjects
32
- } from "./chunk-BAA5XMWY.js";
33
+ } from "./chunk-NZIQF7IW.js";
33
34
  import "./chunk-OWVXKMLI.js";
34
35
  import "./chunk-W424KPCR.js";
35
36
  import "./chunk-JI36FNWE.js";
@@ -55,6 +56,7 @@ export {
55
56
  createRewriter,
56
57
  createSubjectSuggester,
57
58
  emailWords,
59
+ failureCode,
58
60
  normalizeGeneratedMjml,
59
61
  parseDraft,
60
62
  providerEmailModel,
package/dist/ai.js CHANGED
@@ -23,8 +23,8 @@ import {
23
23
  createHttpCaller,
24
24
  useSearchShortcut,
25
25
  useUi
26
- } from "./chunk-N7DEVAIP.js";
27
- import "./chunk-H4W65GBX.js";
26
+ } from "./chunk-JU3OUO6X.js";
27
+ import "./chunk-QPAZLCPO.js";
28
28
  import {
29
29
  AI_MESSAGES,
30
30
  AI_PROVIDERS,
@@ -38,12 +38,14 @@ import {
38
38
  REWRITE_ACTIONS,
39
39
  REWRITE_LANGUAGES,
40
40
  claudeEmailModel,
41
+ claudeProvider,
41
42
  createEmailGenerator,
42
43
  emailWords,
44
+ failureCode,
43
45
  providerEmailModel,
44
46
  purposesByCategory,
45
47
  resolveProvider
46
- } from "./chunk-BAA5XMWY.js";
48
+ } from "./chunk-NZIQF7IW.js";
47
49
  import "./chunk-OWVXKMLI.js";
48
50
  import "./chunk-W424KPCR.js";
49
51
  import {
@@ -53,6 +55,7 @@ import {
53
55
 
54
56
  // src/ai/keys.js
55
57
  var STORAGE_PREFIX = "best-email-builder:ai-key:";
58
+ var CHOICE_SLOT = "best-email-builder:ai-provider";
56
59
  function browserStorage() {
57
60
  try {
58
61
  const storage = globalThis.localStorage;
@@ -61,21 +64,31 @@ function browserStorage() {
61
64
  return null;
62
65
  }
63
66
  }
64
- function createKeyStore(provider) {
65
- const slot = STORAGE_PREFIX + provider.id;
66
- let memory = "";
67
+ var choiceOf = (provider) => Object.freeze({
68
+ id: provider.id,
69
+ name: provider.name,
70
+ model: provider.model ?? null,
71
+ keyUrl: provider.keyUrl,
72
+ keyHint: provider.keyHint,
73
+ free: provider.free
74
+ });
75
+ function createKeyStore(providerOrList) {
76
+ const providers = Object.freeze((Array.isArray(providerOrList) ? providerOrList : [providerOrList]).map(choiceOf));
77
+ if (!providers.length) throw new TypeError("createKeyStore: at least one provider");
78
+ const known = (id) => providers.some((provider) => provider.id === id);
79
+ const memory = /* @__PURE__ */ new Map();
67
80
  let refused = false;
68
81
  let pending = null;
69
82
  const listeners = /* @__PURE__ */ new Set();
70
83
  let snapshot;
71
- const read = () => {
84
+ const read = (slot) => {
72
85
  try {
73
86
  return browserStorage()?.getItem(slot) || "";
74
87
  } catch {
75
88
  return "";
76
89
  }
77
90
  };
78
- const write = (value) => {
91
+ const write = (slot, value) => {
79
92
  try {
80
93
  const storage = browserStorage();
81
94
  if (value) storage?.setItem(slot, value);
@@ -83,7 +96,14 @@ function createKeyStore(provider) {
83
96
  } catch {
84
97
  }
85
98
  };
86
- const get = () => memory || read();
99
+ const keyOf = (id) => memory.has(id) ? memory.get(id) : read(STORAGE_PREFIX + id);
100
+ const keep = (id, value) => {
101
+ memory.set(id, value);
102
+ write(STORAGE_PREFIX + id, value);
103
+ };
104
+ const kept = read(CHOICE_SLOT);
105
+ let current = known(kept) ? kept : (providers.find((provider) => keyOf(provider.id)) || providers[0]).id;
106
+ const get = (id) => keyOf(id === void 0 ? current : String(id));
87
107
  const changed = () => {
88
108
  snapshot = Object.freeze({ asking: Boolean(pending), refused, connected: Boolean(get()) });
89
109
  for (const listener of [...listeners]) listener();
@@ -92,9 +112,8 @@ function createKeyStore(provider) {
92
112
  const answer = (key) => {
93
113
  const value = String(key || "").trim();
94
114
  if (value) {
95
- memory = value;
115
+ keep(current, value);
96
116
  refused = false;
97
- write(value);
98
117
  }
99
118
  const waiting = pending;
100
119
  pending = null;
@@ -102,15 +121,28 @@ function createKeyStore(provider) {
102
121
  waiting?.resolve(value);
103
122
  };
104
123
  return Object.freeze({
105
- provider: Object.freeze({ id: provider.id, name: provider.name, keyUrl: provider.keyUrl, keyHint: provider.keyHint, free: provider.free }),
124
+ get provider() {
125
+ return (
126
+ /** @type {ProviderChoice} */
127
+ providers.find((provider) => provider.id === current)
128
+ );
129
+ },
130
+ providers,
106
131
  get,
107
132
  set: (key) => {
108
133
  if (typeof key !== "string" || !key.trim()) throw new TypeError("ai.key.set: a non-empty key");
109
134
  answer(key);
110
135
  },
111
136
  forget: () => {
112
- memory = "";
113
- write("");
137
+ keep(current, "");
138
+ changed();
139
+ },
140
+ choose: (id) => {
141
+ if (!known(id)) throw new TypeError(`ai.key.choose: one of ${providers.map((provider) => provider.id).join(", ")}`);
142
+ if (id === current) return;
143
+ current = id;
144
+ refused = false;
145
+ write(CHOICE_SLOT, id);
114
146
  changed();
115
147
  },
116
148
  request: () => {
@@ -130,8 +162,7 @@ function createKeyStore(provider) {
130
162
  state: () => snapshot,
131
163
  answer: (key) => answer(key || ""),
132
164
  refuse: () => {
133
- memory = "";
134
- write("");
165
+ keep(current, "");
135
166
  refused = true;
136
167
  changed();
137
168
  }
@@ -147,28 +178,54 @@ function ConnectAi({ aiKey }) {
147
178
  const state = useSyncExternalStore(aiKey.subscribe, aiKey.state, aiKey.state);
148
179
  const [value, setValue] = useState("");
149
180
  const [shown, setShown] = useState(false);
181
+ const [chosenId, setChosenId] = useState(aiKey.provider.id);
150
182
  const inputRef = useRef(
151
183
  /** @type {HTMLInputElement|null} */
152
184
  null
153
185
  );
186
+ const choicesRef = useRef(
187
+ /** @type {HTMLDivElement|null} */
188
+ null
189
+ );
154
190
  const inputId = useId();
155
191
  const hintId = useId();
156
- const { name, keyUrl, keyHint, free } = aiKey.provider;
192
+ const choices = aiKey.providers;
193
+ const chosen = choices.find((provider) => provider.id === chosenId) || aiKey.provider;
194
+ const { name, keyUrl, keyHint, free } = chosen;
157
195
  useEffect(() => {
158
196
  if (!state.asking) return;
197
+ setChosenId(aiKey.provider.id);
159
198
  setValue("");
160
199
  setShown(false);
161
- }, [state.asking]);
200
+ }, [state.asking, aiKey]);
162
201
  const key = value.trim();
202
+ const saved = aiKey.get(chosen.id);
163
203
  const unusual = Boolean(key && keyHint && !key.startsWith(keyHint));
204
+ const refused = state.refused && chosen.id === aiKey.provider.id;
164
205
  const submit = (event) => {
165
206
  event.preventDefault();
166
- if (key) aiKey.answer(key);
207
+ const answer = key || saved;
208
+ if (!answer) return;
209
+ if (chosen.id !== aiKey.provider.id) aiKey.choose(chosen.id);
210
+ aiKey.answer(answer);
211
+ };
212
+ const choose = (id) => {
213
+ setChosenId(id);
214
+ setValue("");
215
+ };
216
+ const onChoicesKeyDown = (event) => {
217
+ const at = choices.findIndex((provider) => provider.id === chosen.id);
218
+ const step = { ArrowDown: 1, ArrowRight: 1, ArrowUp: -1, ArrowLeft: -1 }[event.key];
219
+ const to = step ? (at + step + choices.length) % choices.length : event.key === "Home" ? 0 : event.key === "End" ? choices.length - 1 : -1;
220
+ if (to === -1) return;
221
+ event.preventDefault();
222
+ choose(choices[to].id);
223
+ choicesRef.current?.querySelectorAll('[role="radio"]')[to]?.focus();
167
224
  };
168
225
  return /* @__PURE__ */ jsx(Dialog, { open: state.asking, onOpenChange: (open) => !open && aiKey.answer(null), children: /* @__PURE__ */ jsx(
169
226
  DialogContent,
170
227
  {
171
- className: "eb:gap-0 eb:overflow-hidden eb:p-0 eb:sm:max-w-md",
228
+ className: cn("eb:gap-0 eb:overflow-hidden eb:p-0", choices.length > 1 ? "eb:sm:max-w-xl" : "eb:sm:max-w-md"),
172
229
  "data-testid": "eb-ai-connect",
173
230
  onOpenAutoFocus: (event) => {
174
231
  event.preventDefault();
@@ -183,7 +240,44 @@ function ConnectAi({ aiKey }) {
183
240
  /* @__PURE__ */ jsx(DialogDescription, { className: "eb:text-[13px] eb:leading-relaxed eb:text-foreground-lighter", children: t("The AI writes with your own {provider} key. Connect it once: this browser remembers it.", { provider: name }) })
184
241
  ] })
185
242
  ] }),
186
- state.refused && /* @__PURE__ */ jsxs("p", { role: "alert", className: "eb:flex eb:items-start eb:gap-2 eb:rounded-md eb:border eb:border-destructive/30 eb:bg-destructive/10 eb:px-3 eb:py-2 eb:text-[12.5px] eb:text-destructive", children: [
243
+ choices.length > 1 && /* @__PURE__ */ jsx(
244
+ "div",
245
+ {
246
+ ref: choicesRef,
247
+ role: "radiogroup",
248
+ "aria-label": t("AI provider"),
249
+ className: "eb:grid eb:grid-cols-2 eb:gap-1.5 eb:sm:grid-cols-3",
250
+ onKeyDown: onChoicesKeyDown,
251
+ "data-testid": "eb-ai-connect-providers",
252
+ children: choices.map((provider) => {
253
+ const checked = provider.id === chosen.id;
254
+ return /* @__PURE__ */ jsxs(
255
+ "button",
256
+ {
257
+ type: "button",
258
+ role: "radio",
259
+ "aria-checked": checked,
260
+ tabIndex: checked ? 0 : -1,
261
+ onClick: () => choose(provider.id),
262
+ className: cn(
263
+ "eb:flex eb:min-w-0 eb:flex-col eb:gap-0.5 eb:rounded-md eb:border eb:px-2.5 eb:py-2 eb:text-left eb:transition-colors eb:outline-none eb:focus-visible:ring-[3px] eb:focus-visible:ring-ring/50",
264
+ checked ? "eb:border-primary eb:bg-primary/10" : "eb:border-border eb:hover:bg-muted"
265
+ ),
266
+ children: [
267
+ /* @__PURE__ */ jsxs("span", { className: "eb:flex eb:items-center eb:gap-1.5 eb:text-[13px] eb:leading-tight eb:font-medium eb:text-foreground", children: [
268
+ /* @__PURE__ */ jsx("span", { className: "eb:truncate", children: provider.name }),
269
+ provider.free && /* @__PURE__ */ jsx(Gift, { className: "eb:size-3 eb:shrink-0 eb:text-success", "aria-label": t(provider.free) }),
270
+ aiKey.get(provider.id) && /* @__PURE__ */ jsx("span", { className: "eb:ml-auto eb:size-1.5 eb:shrink-0 eb:rounded-full eb:bg-success", title: t("Key saved"), "aria-label": t("Key saved") })
271
+ ] }),
272
+ provider.model && /* @__PURE__ */ jsx("span", { className: "eb:truncate eb:font-mono eb:text-[10.5px] eb:text-foreground-muted", title: provider.model, children: provider.model })
273
+ ]
274
+ },
275
+ provider.id
276
+ );
277
+ })
278
+ }
279
+ ),
280
+ refused && /* @__PURE__ */ jsxs("p", { role: "alert", className: "eb:flex eb:items-start eb:gap-2 eb:rounded-md eb:border eb:border-destructive/30 eb:bg-destructive/10 eb:px-3 eb:py-2 eb:text-[12.5px] eb:text-destructive", children: [
187
281
  /* @__PURE__ */ jsx(TriangleAlert, { className: "eb:mt-px eb:size-3.5 eb:shrink-0", "aria-hidden": "true" }),
188
282
  t("{provider} refused the last key. Paste another one.", { provider: name })
189
283
  ] }),
@@ -230,7 +324,7 @@ function ConnectAi({ aiKey }) {
230
324
  }
231
325
  )
232
326
  ] }),
233
- /* @__PURE__ */ jsx("p", { id: hintId, className: "eb:min-h-4 eb:text-[11.5px] eb:text-foreground-muted", children: unusual ? /* @__PURE__ */ jsx("span", { className: "eb:text-warning", children: t("{provider} keys usually start with {prefix}.", { provider: name, prefix: keyHint }) }) : keyHint ? t("{provider} keys start with {prefix}.", { provider: name, prefix: keyHint }) : null })
327
+ /* @__PURE__ */ jsx("p", { id: hintId, className: "eb:min-h-4 eb:text-[11.5px] eb:text-foreground-muted", children: unusual ? /* @__PURE__ */ jsx("span", { className: "eb:text-warning", children: t("{provider} keys usually start with {prefix}.", { provider: name, prefix: keyHint }) }) : saved && !key ? t("A key for {provider} is saved in this browser: Connect uses it, or paste another one.", { provider: name }) : keyHint ? t("{provider} keys start with {prefix}.", { provider: name, prefix: keyHint }) : null })
234
328
  ] })
235
329
  ] }),
236
330
  /* @__PURE__ */ jsxs("div", { className: "eb:flex eb:items-start eb:gap-2 eb:border-t eb:border-border eb:bg-surface-75 eb:px-6 eb:py-3 eb:text-[11.5px] eb:leading-relaxed eb:text-foreground-lighter", children: [
@@ -239,7 +333,7 @@ function ConnectAi({ aiKey }) {
239
333
  ] }),
240
334
  /* @__PURE__ */ jsxs(DialogFooter, { className: "eb:border-t eb:border-border eb:px-6 eb:py-4", children: [
241
335
  /* @__PURE__ */ jsx(Button, { type: "button", variant: "ghost", onClick: () => aiKey.answer(null), children: t("Cancel") }),
242
- /* @__PURE__ */ jsx(Button, { type: "submit", disabled: !key, "data-testid": "eb-ai-connect-submit", children: t("Connect") })
336
+ /* @__PURE__ */ jsx(Button, { type: "submit", disabled: !key && !saved, "data-testid": "eb-ai-connect-submit", children: t("Connect") })
243
337
  ] })
244
338
  ] })
245
339
  }
@@ -753,7 +847,13 @@ function createAi(options = {}) {
753
847
  }
754
848
  if (typeof askKey !== "boolean") throw new TypeError("createAi: `askKey` must be true or false");
755
849
  if (apiKey && askKey) throw new TypeError("createAi: `apiKey` (one key for all) or `askKey` (each user's own), not both");
756
- const provider = usesProvider ? resolveProvider(providerOption) : null;
850
+ const choice = Array.isArray(providerOption);
851
+ if (choice && (!providerOption.length || !askKey)) {
852
+ throw new TypeError("createAi: a list of providers is for `askKey: true` \u2014 each user chooses one and connects their own key");
853
+ }
854
+ const providers = usesProvider ? (choice ? providerOption : [providerOption]).map(providerOf) : [];
855
+ if (new Set(providers.map((provider2) => provider2.id)).size !== providers.length) throw new TypeError("createAi: each provider once");
856
+ const [provider = null] = providers;
757
857
  if (provider && !apiKey && !askKey && !provider.keyless) {
758
858
  throw new TypeError(`createAi: ${provider.name} needs a key \u2014 \`apiKey\`, or \`askKey: true\` to let each user connect their own`);
759
859
  }
@@ -765,18 +865,31 @@ function createAi(options = {}) {
765
865
  }
766
866
  const ownOnly = !model && !http && PARTS.some((part) => typeof options[part] === "function");
767
867
  let transport = {};
768
- const key = provider && askKey ? createKeyStore(provider) : null;
868
+ const key = provider && askKey ? createKeyStore(providers) : null;
769
869
  if (provider) {
770
- const generator = createEmailGenerator({
771
- model: providerEmailModel({
772
- provider,
773
- fetch: fetchImpl,
774
- // The user's key, asked when there is none ('' when they cancel: KEY_MISSING).
775
- apiKey: key ? async () => key.get() || await key.request() : apiKey
776
- }),
777
- maxAttempts,
778
- compile
779
- });
870
+ const models = /* @__PURE__ */ new Map();
871
+ const keyFor = () => key ? key.get() : apiKey || "";
872
+ const modelOf = (id) => {
873
+ if (!models.has(id)) {
874
+ const chosen = (
875
+ /** @type {import('./providers.js').AiProvider} */
876
+ providers.find((item) => item.id === id)
877
+ );
878
+ models.set(
879
+ id,
880
+ chosen.createModel ? chosen.createModel({ apiKey: keyFor, fetch: fetchImpl }) : providerEmailModel({ provider: chosen, fetch: fetchImpl, apiKey: keyFor })
881
+ );
882
+ }
883
+ return (
884
+ /** @type {import('./generator.js').EmailModel} */
885
+ models.get(id)
886
+ );
887
+ };
888
+ const model2 = async (request) => {
889
+ if (key && !key.get() && !providers.find((item) => item.id === key.provider.id)?.keyless) await key.request();
890
+ return modelOf(key ? key.provider.id : provider.id)(request);
891
+ };
892
+ const generator = createEmailGenerator({ model: model2, maxAttempts, compile });
780
893
  for (const part of PARTS) transport[part] = key ? askedAgain(generator[part], key) : generator[part];
781
894
  } else if (model) {
782
895
  transport = createEmailGenerator({ model, maxAttempts, compile });
@@ -810,6 +923,19 @@ function createAi(options = {}) {
810
923
  components: Object.freeze({ Generate: GenerateButton, Rewrite: RewriteText, Subjects: SubjectIdeas, Connect: key ? ConnectAi : null })
811
924
  });
812
925
  }
926
+ function providerOf(option) {
927
+ if (option && typeof option === "object" && typeof option.createModel === "function") {
928
+ if (!option.id || !option.name) throw new TypeError("createAi: a provider with `createModel` needs an `id` and a `name`");
929
+ return (
930
+ /** @type {import('./providers.js').AiProvider} */
931
+ option
932
+ );
933
+ }
934
+ return resolveProvider(
935
+ /** @type {any} */
936
+ option
937
+ );
938
+ }
813
939
  function askedAgain(run, key) {
814
940
  return async (input, options) => {
815
941
  try {
@@ -843,9 +969,11 @@ export {
843
969
  REWRITE_ACTIONS,
844
970
  REWRITE_LANGUAGES,
845
971
  claudeEmailModel,
972
+ claudeProvider,
846
973
  createAi,
847
974
  createEmailGenerator,
848
975
  emailWords,
976
+ failureCode,
849
977
  providerEmailModel,
850
978
  resolveProvider
851
979
  };