wizards 0.2.7 → 0.2.8

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 (67) hide show
  1. package/apps/runtime/dist/env.js +4 -0
  2. package/apps/runtime/dist/media/generate.js +2 -2
  3. package/apps/runtime/dist/media/transcribe.js +18 -6
  4. package/apps/runtime/dist/models.js +566 -218
  5. package/apps/runtime/dist/routes/studio.js +152 -10
  6. package/apps/web/dist/assets/Account-YJEQEo5k.js +1 -0
  7. package/apps/web/dist/assets/{ChangelogDialog-DEfZRDK3.js → ChangelogDialog-BNQ9yup8.js} +1 -1
  8. package/apps/web/dist/assets/{ChatPanel-CNiFyu_w.js → ChatPanel-CtE1wcUD.js} +1 -1
  9. package/apps/web/dist/assets/{CreditsDialog-CUvc2iPM.js → CreditsDialog-DSjsgLoo.js} +1 -1
  10. package/apps/web/dist/assets/EditorPage-CAxamSak.js +2 -0
  11. package/apps/web/dist/assets/{FlowPreview-BC25OUH0.js → FlowPreview-B3IxmikV.js} +1 -1
  12. package/apps/web/dist/assets/Harness-Bo-kwm1H.js +16 -0
  13. package/apps/web/dist/assets/HomePage-BdIa2zcg.js +1 -0
  14. package/apps/web/dist/assets/ImportWizard-DMea5ZH1.js +1 -0
  15. package/apps/web/dist/assets/LocalRuntime-DFswQjxc.js +1 -0
  16. package/apps/web/dist/assets/NewWizardPage-Cnb6UYme.js +2 -0
  17. package/apps/web/dist/assets/ProjectPage-CR_FIUya.js +2 -0
  18. package/apps/web/dist/assets/{PublicRunner-BCrmXPkr.js → PublicRunner-CIHEMLlN.js} +1 -1
  19. package/apps/web/dist/assets/{ReadOnly-CvmN9XW4.js → ReadOnly-B0-ReU_0.js} +1 -1
  20. package/apps/web/dist/assets/RunnerView-COKGppwq.js +2 -0
  21. package/apps/web/dist/assets/SettingsPage-BqAogRSI.js +1 -0
  22. package/apps/web/dist/assets/SetupPage-Bk4qXYZV.js +1 -0
  23. package/apps/web/dist/assets/{SharePage-BqwqI3Ga.js → SharePage-ChEtCkhn.js} +1 -1
  24. package/apps/web/dist/assets/{api-Bf-l9Sd8.js → api-u3nydnnp.js} +1 -1
  25. package/apps/web/dist/assets/changelog-C03GWYid.js +1 -0
  26. package/apps/web/dist/assets/circle-alert-B76z9TWZ.js +1 -0
  27. package/apps/web/dist/assets/definition-Co2Mg1qo.js +55 -0
  28. package/apps/web/dist/assets/esm-Rtjq9ReB.js +7 -0
  29. package/apps/web/dist/assets/image-BShbErBm.js +1 -0
  30. package/apps/web/dist/assets/index-BAQ2e4is.js +10 -0
  31. package/apps/web/dist/assets/index-CQQD8QNR.css +1 -0
  32. package/apps/web/dist/assets/meta-MVWkLr9H.js +1 -0
  33. package/apps/web/dist/assets/oss-credits-CIxnNZor.js +1 -0
  34. package/apps/web/dist/assets/{outputs-CsESg-Eg.js → outputs-s8BpXH6V.js} +1 -1
  35. package/apps/web/dist/assets/{video-CjgmvRDk.js → pen-line-NK46C_nG.js} +1 -1
  36. package/apps/web/dist/assets/refresh-cw-Cmk7VCe0.js +1 -0
  37. package/apps/web/dist/assets/ui-gGAdXx-P.js +1 -0
  38. package/apps/web/dist/assets/video-DBmwF1Jm.js +1 -0
  39. package/apps/web/dist/assets/volume-2-Bp2tyaKE.js +1 -0
  40. package/apps/web/dist/index.html +4 -7
  41. package/apps/web/dist/mcp/flow.html +2 -2
  42. package/node_modules/@engenty-wizards/shared/dist/providers.d.ts +75 -0
  43. package/node_modules/@engenty-wizards/shared/dist/providers.js +339 -0
  44. package/package.json +5 -1
  45. package/apps/web/dist/assets/Account-DLLeEwJL.js +0 -1
  46. package/apps/web/dist/assets/EditorPage-CZuCz7lB.js +0 -2
  47. package/apps/web/dist/assets/HomePage-DQKuhMFZ.js +0 -1
  48. package/apps/web/dist/assets/ImportWizard-BockXJXm.js +0 -1
  49. package/apps/web/dist/assets/LocalRuntime-C199bg_B.js +0 -16
  50. package/apps/web/dist/assets/NewWizardPage-C4cde8FO.js +0 -2
  51. package/apps/web/dist/assets/ProjectPage-DJC1K4w_.js +0 -2
  52. package/apps/web/dist/assets/RunnerView-CCe8-k9Q.js +0 -2
  53. package/apps/web/dist/assets/SettingsPage-BuocMkLt.js +0 -1
  54. package/apps/web/dist/assets/SetupPage-o6Jim2Pu.js +0 -1
  55. package/apps/web/dist/assets/changelog-CFeEzKqb.js +0 -1
  56. package/apps/web/dist/assets/cloud-off-D--dZLDw.js +0 -1
  57. package/apps/web/dist/assets/definition-DndaH5Ow.js +0 -55
  58. package/apps/web/dist/assets/esm-rJY59Ixx.js +0 -7
  59. package/apps/web/dist/assets/globe-ffVj8XwO.js +0 -1
  60. package/apps/web/dist/assets/index-B8n2AYO0.css +0 -1
  61. package/apps/web/dist/assets/index-BR5ELbeZ.js +0 -10
  62. package/apps/web/dist/assets/meta-CKE9QceT.js +0 -1
  63. package/apps/web/dist/assets/oss-credits-C-U2Tlpn.js +0 -1
  64. package/apps/web/dist/assets/react-dom-DenslgJm.js +0 -1
  65. package/apps/web/dist/assets/session-C7LDDcj7.js +0 -1
  66. package/apps/web/dist/assets/ui-BT0Y_Hci.js +0 -1
  67. /package/apps/web/dist/assets/{LocalRuntime-BrP-ENHg.css → Harness-BrP-ENHg.css} +0 -0
@@ -1,34 +1,50 @@
1
1
  import { createAnthropic } from "@ai-sdk/anthropic";
2
+ import { createElevenLabs } from "@ai-sdk/elevenlabs";
3
+ import { createFal } from "@ai-sdk/fal";
2
4
  import { createGateway } from "@ai-sdk/gateway";
5
+ import { createGoogle } from "@ai-sdk/google";
3
6
  import { createOpenAI } from "@ai-sdk/openai";
7
+ import { createReplicate } from "@ai-sdk/replicate";
4
8
  import { MODEL_CLASSES, TEXT_CLASSES, } from "@engenty-wizards/shared/definition";
5
- import { accountToken } from "./auth/account.js";
9
+ import { creditsModelOf, isCreditsRef, PROVIDER_IDS, providerInfo, providerOfRef, } from "@engenty-wizards/shared/providers";
10
+ import { accountToken, linkedAccount } from "./auth/account.js";
6
11
  import { env } from "./env.js";
7
12
  import { detectHarness, HarnessModel, harness, isHarnessVendor, notInstalled, signedOut, } from "./harness/index.js";
8
13
  import { managed } from "./manage.js";
9
14
  import { ModelUnavailableError } from "./model-errors.js";
15
+ import { seal, unseal } from "./secrets/crypto.js";
10
16
  import { vaultDelete, vaultGet, vaultSet } from "./secrets/vault.js";
11
17
  import { readSetting, writeSetting } from "./settings.js";
12
- import { currentTenantOrNull } from "./tenants/tenant.js";
18
+ import { currentTenant, currentTenantOrNull } from "./tenants/tenant.js";
13
19
  export { isHarnessVendor } from "./harness/index.js";
14
20
  /**
15
- * A step names a model class, never a model. Where the class runs is decided here:
16
- * - a runtime of a Manage-App sends `wizards/<class>` to the model-gateway, which binds it,
17
- * meters the call and books the tenant's credits;
18
- * - a runtime that runs alone uses the linked account's credits through the same gateway, or
19
- * resolves the class itself: an AI client installed on the machine (Claude Code, Codex,
20
- * Gemini CLI, Cursor Agent — on its subscription), own keys (AI Gateway, OpenAI, Anthropic)
21
- * or a local model.
22
- * A local binding is `[provider:]vendor/model`, e.g. `anthropic/claude-haiku-4.5`,
23
- * `openai:gpt-5.4-mini`, `ollama:qwen3`, `claude/sonnet`, `codex/default`.
21
+ * A step names a model class, never a model. Where the class runs is decided here, in this order:
22
+ * 1. its own way: an AI client installed on the machine (Claude Code, Codex, Gemini CLI, Cursor
23
+ * Agent — on its subscription), an API key the person brought (OpenAI, Anthropic, Google,
24
+ * fal.ai, ElevenLabs, Replicate, AI Gateway) or a local model;
25
+ * 2. credits: the model-gateway binds `wizards/<class>`, meters the call and books it — on the
26
+ * tenant of a Manage-App's runtime, or on the account a runtime that runs alone is linked to.
27
+ * A runtime of a Manage-App keeps a tenant's own keys (sealed) and runs on its credits where it
28
+ * has none. A binding is `[provider:]vendor/model`, e.g. `anthropic/claude-haiku-4.5` (AI
29
+ * Gateway), `openai:gpt-5.4-mini`, `fal:fal-ai/flux/schnell`, `ollama:qwen3`, `claude/sonnet`
30
+ * (an installed client), or `credits`.
24
31
  */
25
32
  export { ModelUnavailableError } from "./model-errors.js";
26
- // --- local configuration -------------------------------------------------------
27
- export const LOCAL_KEYS = ["gateway", "openai", "anthropic"];
28
- const local = {
29
- settings: { source: "own", bindings: {} },
30
- keys: {},
31
- };
33
+ // --- configuration -----------------------------------------------------------------
34
+ export const LOCAL_KEYS = PROVIDER_IDS;
35
+ const local = { settings: { source: "own", bindings: {} }, keys: {} };
36
+ function envKey(name) {
37
+ const value = {
38
+ gateway: env.aiGatewayKey,
39
+ openai: env.openaiKey,
40
+ anthropic: env.anthropicKey,
41
+ google: env.googleKey,
42
+ fal: env.falKey,
43
+ elevenlabs: env.elevenlabsKey,
44
+ replicate: env.replicateKey,
45
+ }[name];
46
+ return value || undefined;
47
+ }
32
48
  /** Reads the model settings and keys of a runtime that runs alone; called at start and after a change. */
33
49
  export async function loadLocalModels() {
34
50
  if (managed) {
@@ -38,23 +54,41 @@ export async function loadLocalModels() {
38
54
  source: "own",
39
55
  bindings: {},
40
56
  };
41
- local.keys = {
42
- gateway: (await vaultGet("key.gateway")) ?? env.aiGatewayKey,
43
- openai: (await vaultGet("key.openai")) ?? env.openaiKey,
44
- anthropic: (await vaultGet("key.anthropic")) ?? env.anthropicKey,
45
- };
57
+ const keys = {};
58
+ for (const name of LOCAL_KEYS) {
59
+ keys[name] = (await vaultGet(`key.${name}`)) ?? envKey(name);
60
+ }
61
+ local.keys = keys;
46
62
  }
47
- export function localModelSettings() {
48
- return {
49
- ...local.settings,
50
- bindings: Object.fromEntries(MODEL_CLASSES.map((c) => [c, localRef(c)])),
51
- ollamaUrl: local.settings.ollamaUrl ?? env.ollamaUrl,
52
- keys: Object.fromEntries(LOCAL_KEYS.map((k) => [k, Boolean(local.keys[k])])),
63
+ const tenantConfigs = new Map();
64
+ const tenantSetting = (tenant) => `models:${tenant}`;
65
+ /** A team thinks on its credits unless it brought a key: `own`, with the credits behind it. */
66
+ function teamSettings(bindings) {
67
+ return { source: "own", bindings, creditFallback: true };
68
+ }
69
+ async function tenantConfig(tenant) {
70
+ const hit = tenantConfigs.get(tenant);
71
+ if (hit) {
72
+ return hit;
73
+ }
74
+ const stored = await readSetting(tenantSetting(tenant));
75
+ const value = {
76
+ settings: teamSettings(stored?.bindings ?? {}),
77
+ keys: (stored?.keys ? unseal(stored.keys) : null) ?? {},
53
78
  };
79
+ tenantConfigs.set(tenant, value);
80
+ return value;
54
81
  }
55
- export async function saveLocalModels(input) {
56
- const bindings = { ...local.settings.bindings };
57
- for (const [cls, ref] of Object.entries(input.bindings ?? {})) {
82
+ async function config() {
83
+ if (!managed) {
84
+ return local;
85
+ }
86
+ const tenant = currentTenantOrNull();
87
+ return tenant ? tenantConfig(tenant) : { settings: teamSettings({}), keys: {} };
88
+ }
89
+ function mergeBindings(current, changes = {}) {
90
+ const bindings = { ...current };
91
+ for (const [cls, ref] of Object.entries(changes)) {
58
92
  if (ref?.trim()) {
59
93
  bindings[cls] = ref.trim();
60
94
  }
@@ -62,10 +96,23 @@ export async function saveLocalModels(input) {
62
96
  delete bindings[cls];
63
97
  }
64
98
  }
99
+ return bindings;
100
+ }
101
+ export function localModelSettings() {
102
+ return {
103
+ ...local.settings,
104
+ creditFallback: local.settings.creditFallback !== false,
105
+ bindings: Object.fromEntries(MODEL_CLASSES.map((c) => [c, ownRef(local, c) || local.settings.bindings[c] || ""])),
106
+ ollamaUrl: local.settings.ollamaUrl ?? env.ollamaUrl,
107
+ keys: Object.fromEntries(LOCAL_KEYS.map((k) => [k, Boolean(local.keys[k])])),
108
+ };
109
+ }
110
+ export async function saveLocalModels(input) {
65
111
  await writeSetting("models", {
66
112
  source: input.source ?? local.settings.source,
67
- bindings,
113
+ bindings: mergeBindings(local.settings.bindings, input.bindings),
68
114
  ollamaUrl: input.ollamaUrl?.trim() || local.settings.ollamaUrl,
115
+ creditFallback: input.creditFallback ?? local.settings.creditFallback,
69
116
  });
70
117
  for (const [name, value] of Object.entries(input.keys ?? {})) {
71
118
  if (value?.trim()) {
@@ -77,7 +124,128 @@ export async function saveLocalModels(input) {
77
124
  }
78
125
  await loadLocalModels();
79
126
  }
80
- async function gatewayAccess() {
127
+ /** A team's keys and bindings on a runtime of a Manage-App. No client and no local model there. */
128
+ async function saveTenantModels(tenant, input) {
129
+ const current = await tenantConfig(tenant);
130
+ const keys = { ...current.keys };
131
+ for (const [name, value] of Object.entries(input.keys ?? {})) {
132
+ if (value?.trim()) {
133
+ keys[name] = value.trim();
134
+ }
135
+ else {
136
+ delete keys[name];
137
+ }
138
+ }
139
+ await writeSetting(tenantSetting(tenant), {
140
+ bindings: mergeBindings(current.settings.bindings, input.bindings),
141
+ keys: Object.keys(keys).length ? seal(keys) : null,
142
+ });
143
+ tenantConfigs.delete(tenant);
144
+ }
145
+ /** Saves for this machine, or for the current tenant of a Manage-App's runtime. */
146
+ export async function saveModels(input) {
147
+ if (managed) {
148
+ await saveTenantModels(currentTenant(), input);
149
+ }
150
+ else {
151
+ await saveLocalModels(input);
152
+ }
153
+ }
154
+ /** A stored key, for a check of the key itself; never sent to the studio. */
155
+ export async function storedKey(name) {
156
+ return (await config()).keys[name];
157
+ }
158
+ // --- where a class runs -------------------------------------------------------------
159
+ const isText = (cls) => TEXT_CLASSES.includes(cls);
160
+ /**
161
+ * `fal:fal-ai/flux/schnell` → provider fal, id `fal-ai/flux/schnell`. Without a provider the
162
+ * binding is an AI Gateway id (`google/veo-…`) or an installed client (`codex/default`).
163
+ */
164
+ function parse(ref) {
165
+ const match = /^([a-z]+):(.*)$/s.exec(ref);
166
+ if (match) {
167
+ const [, provider, id] = match;
168
+ // Ollama and the gateway name a model as the gateway does; the others are their own vendor.
169
+ const vendor = provider === "ollama" || provider === "gateway" ? id.split("/")[0] : provider;
170
+ return { provider, id, vendor };
171
+ }
172
+ return { provider: null, id: ref, vendor: ref.split("/")[0] };
173
+ }
174
+ const vendorOf = (ref) => parse(ref).vendor;
175
+ /** The part of `codex/default` after the client. */
176
+ const aliasOf = (id) => id.split("/").slice(1).join("/");
177
+ /** Whether this configuration can reach a binding without credits. */
178
+ function reachable(cfg, ref) {
179
+ if (!ref || isCreditsRef(ref)) {
180
+ return false;
181
+ }
182
+ const { provider, vendor } = parse(ref);
183
+ if ((provider === null && isHarnessVendor(vendor)) || provider === "ollama") {
184
+ // An installed client and a local model are of this machine.
185
+ return !managed;
186
+ }
187
+ if (provider === null || provider === "gateway") {
188
+ return (Boolean(cfg.keys.gateway) ||
189
+ (provider === null &&
190
+ (vendor === "openai" || vendor === "anthropic") &&
191
+ Boolean(cfg.keys[vendor])));
192
+ }
193
+ return Boolean(cfg.keys[provider]);
194
+ }
195
+ /** What a class runs on without credits: a client, an own key or a local model. "" = no way of its own. */
196
+ function ownRef(cfg, cls) {
197
+ const bound = cfg.settings.bindings[cls];
198
+ const client = managed ? null : harness(cfg.settings.source);
199
+ if (isText(cls) && client) {
200
+ // Text thinks on the installed client; a binding counts only where it names that client.
201
+ return bound?.startsWith(`${client.id}/`)
202
+ ? bound
203
+ : `${client.id}/${client.classes[cls]}`;
204
+ }
205
+ // Text on the account's credits: every text class goes there.
206
+ if (isText(cls) && cfg.settings.source === "account") {
207
+ return "";
208
+ }
209
+ if (isCreditsRef(bound)) {
210
+ return "";
211
+ }
212
+ if (bound) {
213
+ return reachable(cfg, bound) ? bound : "";
214
+ }
215
+ // A client that makes images on the sign-in makes them too, before any key is paid for.
216
+ if (cls === "image" && client?.image) {
217
+ return `${client.id}/${client.image.alias}`;
218
+ }
219
+ // The defaults name gateway models; without a gateway key nothing reaches them.
220
+ return reachable(cfg, env.models[cls]) ? env.models[cls] : "";
221
+ }
222
+ const NOTHING = {
223
+ classifier: "Für Text ist kein Modell eingerichtet.",
224
+ standard: "Für Text ist kein Modell eingerichtet.",
225
+ high: "Für Text ist kein Modell eingerichtet.",
226
+ highest: "Für Text ist kein Modell eingerichtet.",
227
+ image: "Für Bilder ist kein Modell eingerichtet.",
228
+ video: "Für Videos ist kein Modell eingerichtet.",
229
+ speech: "Für Sprachausgabe ist kein Modell eingerichtet.",
230
+ audio: "Für Sprachnotizen ist kein Modell eingerichtet.",
231
+ };
232
+ /** Why a class has no way: the provider picked for it lacks its key, or nothing is set up. */
233
+ function missingText(cfg, cls) {
234
+ const bound = cfg.settings.bindings[cls];
235
+ if (isCreditsRef(bound)) {
236
+ return "Dafür ist das Guthaben gewählt, aber kein Konto angemeldet.";
237
+ }
238
+ const provider = bound ? providerInfo(providerOfRef(bound) ?? "") : null;
239
+ if (provider) {
240
+ return `${NOTHING[cls].replace(/ ist kein Modell eingerichtet\.$/, "")} ist ${provider.name} gewählt, aber kein API Key hinterlegt.`;
241
+ }
242
+ return NOTHING[cls];
243
+ }
244
+ /**
245
+ * The way to the credits. On a Manage-App's runtime always; alone, through the linked account —
246
+ * where text runs on it (`chosen`), or as the fallback for what has no way of its own.
247
+ */
248
+ async function creditAccess(cfg, chosen) {
81
249
  if (managed) {
82
250
  return {
83
251
  baseUrl: env.manage.gatewayUrl,
@@ -85,15 +253,43 @@ async function gatewayAccess() {
85
253
  tenant: currentTenantOrNull(),
86
254
  };
87
255
  }
88
- if (local.settings.source !== "account") {
256
+ if (!chosen && (cfg.settings.creditFallback === false || !(await linkedAccount()))) {
89
257
  return null;
90
258
  }
91
259
  const token = await accountToken();
92
260
  if (!token) {
93
- throw new ModelUnavailableError("Das Konto ist nicht mehr angemeldet. Bitte in den Einstellungen neu anmelden.");
261
+ if (chosen) {
262
+ throw new ModelUnavailableError("Das Konto ist nicht mehr angemeldet. Bitte in den Einstellungen neu anmelden.");
263
+ }
264
+ return null;
94
265
  }
95
266
  return { baseUrl: env.local.gatewayUrl, token, tenant: null };
96
267
  }
268
+ /** A class goes to the credits on purpose: text on the account, or a binding that says so. */
269
+ function creditsChosen(cfg, cls) {
270
+ return (isCreditsRef(cfg.settings.bindings[cls]) ||
271
+ (isText(cls) && cfg.settings.source === "account" && !managed));
272
+ }
273
+ async function routeOf(cls) {
274
+ const cfg = await config();
275
+ const ref = ownRef(cfg, cls);
276
+ if (ref) {
277
+ return { kind: "own", cfg, ref };
278
+ }
279
+ const access = await creditAccess(cfg, creditsChosen(cfg, cls));
280
+ if (access) {
281
+ return { kind: "credits", access, model: creditsModelOf(cfg.settings.bindings[cls]) };
282
+ }
283
+ throw new ModelUnavailableError(missingText(cfg, cls));
284
+ }
285
+ /** The key of a provider, or why the call cannot be made. */
286
+ function need(cfg, name) {
287
+ const key = cfg.keys[name];
288
+ if (!key) {
289
+ throw new ModelUnavailableError(`Für ${providerInfo(name)?.name ?? name} ist kein API Key hinterlegt.`);
290
+ }
291
+ return key;
292
+ }
97
293
  function gatewayClient(access, meta) {
98
294
  return createGateway({
99
295
  baseURL: `${access.baseUrl}/v4/ai`,
@@ -109,7 +305,8 @@ function gatewayClient(access, meta) {
109
305
  let gatewayCatalog = null;
110
306
  /** What the gateway binds each class to, with prices in credits. Cached for five minutes. */
111
307
  export async function classCatalog() {
112
- const access = await gatewayAccess().catch(() => null);
308
+ const cfg = await config();
309
+ const access = await creditAccess(cfg, cfg.settings.source === "account").catch(() => null);
113
310
  if (!access) {
114
311
  return null;
115
312
  }
@@ -137,99 +334,72 @@ export async function classCatalog() {
137
334
  return gatewayCatalog?.value ?? null;
138
335
  }
139
336
  }
140
- // --- resolving a class -------------------------------------------------------------
141
- function split(ref) {
142
- const [maybeProvider, rest] = ref.includes(":") ? ref.split(/:(.*)/s, 2) : [null, ref];
143
- const [vendor, ...model] = (rest ?? ref).split("/");
144
- return { provider: maybeProvider, vendor, model: model.join("/") };
145
- }
146
- const vendorOf = (ref) => split(ref).vendor;
147
- function localRef(cls) {
148
- const bound = local.settings.bindings[cls];
149
- const client = harness(local.settings.source);
150
- if (client && TEXT_CLASSES.includes(cls)) {
151
- // Text thinks on the installed client; a binding counts only where it names that client.
152
- return bound?.startsWith(`${client.id}/`)
153
- ? bound
154
- : `${client.id}/${client.classes[cls]}`;
155
- }
156
- if (bound) {
157
- return bound;
158
- }
159
- // A client that makes images on the sign-in makes them too, before any key is paid for.
160
- if (cls === "image" && client?.image) {
161
- return `${client.id}/${client.image.alias}`;
162
- }
163
- // The defaults for images, video and sound name gateway models; without a gateway key nothing
164
- // reaches them, so the class stays empty instead of showing a model that cannot run.
165
- if (!TEXT_CLASSES.includes(cls) && !local.keys.gateway) {
166
- return "";
167
- }
168
- return env.models[cls];
169
- }
170
- function ownGateway() {
171
- return local.keys.gateway ? createGateway({ apiKey: local.keys.gateway }) : null;
172
- }
173
- async function localLanguageModel(ref, meta) {
174
- if (!ref) {
175
- throw new ModelUnavailableError("Für diese Klasse ist kein Modell eingerichtet.");
176
- }
177
- const { provider, vendor, model } = split(ref);
178
- const client = harness(vendor);
179
- if (client) {
180
- if (!(await detectHarness(client.id))?.version) {
181
- throw new ModelUnavailableError(notInstalled(client));
182
- }
183
- return { model: client.model(model, meta.effort), gateway: false };
184
- }
185
- if (provider === "ollama") {
186
- const ollama = createOpenAI({
187
- baseURL: local.settings.ollamaUrl ?? env.ollamaUrl,
188
- apiKey: "ollama",
189
- });
190
- return { model: ollama.chat(model ? `${vendor}/${model}` : vendor), gateway: false };
191
- }
192
- const gateway = ownGateway();
193
- if ((provider === null || provider === "gateway") && gateway) {
194
- return { model: gateway(`${vendor}/${model}`), gateway: true };
195
- }
196
- if ((provider === "openai" || vendor === "openai") && local.keys.openai) {
197
- return {
198
- model: createOpenAI({ apiKey: local.keys.openai })(model || vendor),
199
- gateway: false,
200
- };
201
- }
202
- if ((provider === "anthropic" || vendor === "anthropic") && local.keys.anthropic) {
203
- return {
204
- model: createAnthropic({ apiKey: local.keys.anthropic })(model || vendor),
205
- gateway: false,
206
- };
207
- }
208
- throw new ModelUnavailableError(`Für das Modell "${ref}" ist kein Zugang eingerichtet. Bitte in den Einstellungen einen Schlüssel hinterlegen oder ein Konto anmelden.`);
209
- }
210
- async function viaGateway(cls, meta, pick) {
211
- const access = await gatewayAccess();
212
- if (!access) {
213
- return null;
214
- }
215
- const bound = (await classCatalog())?.classes[cls]?.model ?? "";
337
+ /** A call on the credits: the model a binding names, or the gateway's model of the class. */
338
+ async function viaCredits(route, cls, meta, pick) {
339
+ const bound = route.model ?? (await classCatalog())?.classes[cls]?.model ?? "";
216
340
  return {
217
- model: pick(gatewayClient(access, meta), `wizards/${cls}`),
341
+ model: pick(gatewayClient(route.access, meta), route.model ?? `wizards/${cls}`),
218
342
  ref: bound,
219
343
  vendor: vendorOf(bound.replace(/^[a-z]+:/, "")),
220
344
  gateway: true,
221
345
  metered: true,
222
346
  };
223
347
  }
348
+ /** The installed client a binding names, checked to be there. */
349
+ async function installedClient(ref) {
350
+ const { provider, vendor } = parse(ref);
351
+ const client = provider === null ? harness(vendor) : null;
352
+ if (client && !(await detectHarness(client.id))?.version) {
353
+ throw new ModelUnavailableError(notInstalled(client));
354
+ }
355
+ return client;
356
+ }
357
+ async function ownLanguageModel(cfg, ref, meta) {
358
+ const { provider, id, vendor } = parse(ref);
359
+ const client = await installedClient(ref);
360
+ if (client) {
361
+ return { model: client.model(aliasOf(id), meta.effort), gateway: false };
362
+ }
363
+ switch (provider) {
364
+ case "ollama":
365
+ return {
366
+ model: createOpenAI({
367
+ baseURL: cfg.settings.ollamaUrl ?? env.ollamaUrl,
368
+ apiKey: "ollama",
369
+ }).chat(id),
370
+ gateway: false,
371
+ };
372
+ case "openai":
373
+ return { model: createOpenAI({ apiKey: need(cfg, "openai") })(id), gateway: false };
374
+ case "anthropic":
375
+ return { model: createAnthropic({ apiKey: need(cfg, "anthropic") })(id), gateway: false };
376
+ case "google":
377
+ return { model: createGoogle({ apiKey: need(cfg, "google") })(id), gateway: false };
378
+ case null:
379
+ case "gateway":
380
+ if (cfg.keys.gateway) {
381
+ return { model: createGateway({ apiKey: cfg.keys.gateway })(id), gateway: true };
382
+ }
383
+ if (provider === null && vendor === "openai" && cfg.keys.openai) {
384
+ return { model: createOpenAI({ apiKey: cfg.keys.openai })(aliasOf(id)), gateway: false };
385
+ }
386
+ if (provider === null && vendor === "anthropic" && cfg.keys.anthropic) {
387
+ return {
388
+ model: createAnthropic({ apiKey: cfg.keys.anthropic })(aliasOf(id)),
389
+ gateway: false,
390
+ };
391
+ }
392
+ }
393
+ throw new ModelUnavailableError(`Für das Modell "${ref}" ist kein Zugang eingerichtet. Bitte in den Einstellungen einen API Key hinterlegen oder ein Konto anmelden.`);
394
+ }
224
395
  /** The language model a text class runs on. */
225
396
  export async function textModel(cls, meta = {}) {
226
- const remote = await viaGateway(cls, meta, (client, id) => client(id));
227
- if (remote) {
228
- return remote;
397
+ const route = await routeOf(cls);
398
+ if (route.kind === "credits") {
399
+ return viaCredits(route, cls, meta, (client, id) => client(id));
229
400
  }
230
- const ref = localRef(cls);
231
- const { model, gateway } = await localLanguageModel(ref, meta);
232
- return { model, ref, vendor: vendorOf(ref), gateway, metered: false };
401
+ const { model, gateway } = await ownLanguageModel(route.cfg, route.ref, meta);
402
+ return { model, ref: route.ref, vendor: vendorOf(route.ref), gateway, metered: false };
233
403
  }
234
404
  /** Hands the tools an agent will call to a model that runs them itself (an installed AI client). */
235
405
  export function attachTools(resolved, tools) {
@@ -237,98 +407,159 @@ export function attachTools(resolved, tools) {
237
407
  resolved.model.attach(tools);
238
408
  }
239
409
  }
410
+ /** Gemini image models of the gateway are chat models answering with image files. */
411
+ export function isChatImageModel(ref) {
412
+ return ref.includes("gemini") && ref.includes("image");
413
+ }
414
+ /** `chat`: the model answers through chat with an image file (`textModel("image")`). */
240
415
  export async function imageModel(meta = {}) {
241
- const remote = await viaGateway("image", meta, (client, id) => client.imageModel(id));
242
- if (remote) {
243
- return remote;
244
- }
245
- const ref = localRef("image");
246
- const { vendor, model } = split(ref);
247
- const client = harness(vendor);
416
+ const route = await routeOf("image");
417
+ if (route.kind === "credits") {
418
+ const resolved = await viaCredits(route, "image", meta, (c, id) => c.imageModel(id));
419
+ return { ...resolved, chat: isChatImageModel(resolved.ref) };
420
+ }
421
+ const { cfg, ref } = route;
422
+ const { provider, id, vendor } = parse(ref);
423
+ const own = (model, gateway = false) => ({
424
+ model,
425
+ ref,
426
+ vendor,
427
+ gateway,
428
+ metered: false,
429
+ chat: false,
430
+ });
431
+ const client = await installedClient(ref);
248
432
  if (client?.image) {
249
- if (!(await detectHarness(client.id))?.version) {
250
- throw new ModelUnavailableError(notInstalled(client));
251
- }
252
- return { model: client.image.model(model), ref, vendor, gateway: false, metered: false };
253
- }
254
- const gateway = ownGateway();
255
- if (gateway) {
256
- return {
257
- model: gateway.imageModel(`${vendor}/${model}`),
258
- ref,
259
- vendor,
260
- gateway: true,
261
- metered: false,
262
- };
263
- }
264
- if (local.keys.openai && vendor === "openai") {
265
- return {
266
- model: createOpenAI({ apiKey: local.keys.openai }).image(model),
267
- ref,
268
- vendor,
269
- gateway: false,
270
- metered: false,
271
- };
433
+ return own(client.image.model(aliasOf(id)));
434
+ }
435
+ switch (provider) {
436
+ case "openai":
437
+ return own(createOpenAI({ apiKey: need(cfg, "openai") }).image(id));
438
+ case "google":
439
+ return own(createGoogle({ apiKey: need(cfg, "google") }).image(id));
440
+ case "fal":
441
+ return own(createFal({ apiKey: need(cfg, "fal") }).image(id));
442
+ case "replicate":
443
+ return own(createReplicate({ apiToken: need(cfg, "replicate") }).image(id));
444
+ case null:
445
+ case "gateway":
446
+ if (cfg.keys.gateway) {
447
+ return {
448
+ ...own(createGateway({ apiKey: cfg.keys.gateway }).imageModel(id), true),
449
+ chat: isChatImageModel(id),
450
+ };
451
+ }
452
+ if (provider === null && vendor === "openai" && cfg.keys.openai) {
453
+ return own(createOpenAI({ apiKey: cfg.keys.openai }).image(aliasOf(id)));
454
+ }
272
455
  }
273
456
  throw new ModelUnavailableError("Für Bilder ist kein Modell eingerichtet.");
274
457
  }
275
458
  export async function videoModel(meta = {}) {
276
- const remote = await viaGateway("video", meta, (client, id) => client.videoModel(id));
277
- if (remote) {
278
- return remote;
279
- }
280
- const ref = localRef("video");
281
- const { vendor, model } = split(ref);
282
- const gateway = ownGateway();
283
- if (gateway) {
284
- return {
285
- model: gateway.videoModel(`${vendor}/${model}`),
286
- ref,
287
- vendor,
288
- gateway: true,
289
- metered: false,
290
- };
459
+ const route = await routeOf("video");
460
+ if (route.kind === "credits") {
461
+ return viaCredits(route, "video", meta, (c, id) => c.videoModel(id));
462
+ }
463
+ const { cfg, ref } = route;
464
+ const { provider, id, vendor } = parse(ref);
465
+ const own = (model, gateway = false) => ({
466
+ model,
467
+ ref,
468
+ vendor,
469
+ gateway,
470
+ metered: false,
471
+ });
472
+ switch (provider) {
473
+ case "google":
474
+ return own(createGoogle({ apiKey: need(cfg, "google") }).video(id));
475
+ case "fal":
476
+ return own(createFal({ apiKey: need(cfg, "fal") }).video(id));
477
+ case "replicate":
478
+ return own(createReplicate({ apiToken: need(cfg, "replicate") }).video(id));
479
+ case null:
480
+ case "gateway":
481
+ if (cfg.keys.gateway) {
482
+ return own(createGateway({ apiKey: cfg.keys.gateway }).videoModel(id), true);
483
+ }
291
484
  }
292
485
  throw new ModelUnavailableError("Für Videos ist kein Modell eingerichtet.");
293
486
  }
294
487
  /** The model that reads a text aloud. */
295
488
  export async function speechModel(meta = {}) {
296
- const access = await gatewayAccess();
297
- if (access) {
298
- const bound = (await classCatalog())?.classes.speech?.model;
299
- if (!bound) {
489
+ const route = await routeOf("speech");
490
+ if (route.kind === "credits") {
491
+ const resolved = await viaCredits(route, "speech", meta, (c, id) => c.speechModel(id));
492
+ if (!resolved.ref) {
300
493
  throw new ModelUnavailableError("Für Sprachausgabe ist kein Modell eingerichtet.");
301
494
  }
302
- return {
303
- model: gatewayClient(access, meta).speechModel("wizards/speech"),
304
- ref: bound,
305
- vendor: vendorOf(bound.replace(/^[a-z]+:/, "")),
306
- gateway: true,
307
- metered: true,
308
- };
495
+ return resolved;
496
+ }
497
+ const { cfg, ref } = route;
498
+ const { provider, id, vendor } = parse(ref);
499
+ const own = (model, gateway = false) => ({
500
+ model,
501
+ ref,
502
+ vendor,
503
+ gateway,
504
+ metered: false,
505
+ });
506
+ switch (provider) {
507
+ case "openai":
508
+ return own(createOpenAI({ apiKey: need(cfg, "openai") }).speech(id));
509
+ case "google":
510
+ return own(createGoogle({ apiKey: need(cfg, "google") }).speech(id));
511
+ case "fal":
512
+ return own(createFal({ apiKey: need(cfg, "fal") }).speech(id));
513
+ case "elevenlabs":
514
+ return own(createElevenLabs({ apiKey: need(cfg, "elevenlabs") }).speech(id));
515
+ case null:
516
+ case "gateway":
517
+ if (cfg.keys.gateway) {
518
+ return own(createGateway({ apiKey: cfg.keys.gateway }).speechModel(id), true);
519
+ }
520
+ if (provider === null && vendor === "openai" && cfg.keys.openai) {
521
+ return own(createOpenAI({ apiKey: cfg.keys.openai }).speech(aliasOf(id)));
522
+ }
309
523
  }
310
- const ref = localRef("speech");
311
- const { vendor, model } = split(ref);
312
- const gateway = ownGateway();
313
- if (gateway) {
314
- return {
315
- model: gateway.speechModel(`${vendor}/${model}`),
316
- ref,
317
- vendor,
318
- gateway: true,
319
- metered: false,
320
- };
524
+ throw new ModelUnavailableError("Für Sprachausgabe ist kein Modell eingerichtet.");
525
+ }
526
+ /**
527
+ * What turns a voice note into text: a transcription model (ElevenLabs Scribe, Whisper), or a
528
+ * chat model that takes audio files (Gemini) — that one also on the credits.
529
+ */
530
+ export async function listenerModel(meta = {}) {
531
+ const route = await routeOf("audio");
532
+ if (route.kind === "credits") {
533
+ // On the credits a transcription model is called as one: named so, or bound to the class.
534
+ const catalog = await classCatalog();
535
+ const transcribes = route.model
536
+ ? catalog?.models?.find((m) => m.id === route.model)?.kind === "transcription"
537
+ : Boolean(catalog?.classes.audio?.transcribes);
538
+ if (transcribes) {
539
+ return {
540
+ kind: "transcription",
541
+ resolved: await viaCredits(route, "audio", meta, (c, id) => c.transcriptionModel(id)),
542
+ };
543
+ }
321
544
  }
322
- if (local.keys.openai && vendor === "openai") {
323
- return {
324
- model: createOpenAI({ apiKey: local.keys.openai }).speech(model),
325
- ref,
326
- vendor,
327
- gateway: false,
328
- metered: false,
329
- };
545
+ if (route.kind === "own") {
546
+ const { cfg, ref } = route;
547
+ const { provider, id, vendor } = parse(ref);
548
+ const transcriber = provider === "elevenlabs"
549
+ ? createElevenLabs({ apiKey: need(cfg, "elevenlabs") }).transcription(id)
550
+ : provider === "fal"
551
+ ? createFal({ apiKey: need(cfg, "fal") }).transcription(id)
552
+ : provider === "openai" && /transcribe|whisper/.test(id)
553
+ ? createOpenAI({ apiKey: need(cfg, "openai") }).transcription(id)
554
+ : null;
555
+ if (transcriber) {
556
+ return {
557
+ kind: "transcription",
558
+ resolved: { model: transcriber, ref, vendor, gateway: false, metered: false },
559
+ };
560
+ }
330
561
  }
331
- throw new ModelUnavailableError("Für Sprachausgabe ist kein Modell eingerichtet.");
562
+ return { kind: "chat", resolved: await textModel("audio", meta) };
332
563
  }
333
564
  /**
334
565
  * The model that turns passages and questions into vectors for a project's document index.
@@ -336,38 +567,41 @@ export async function speechModel(meta = {}) {
336
567
  * works on keywords alone.
337
568
  */
338
569
  export async function embeddingModel() {
339
- const access = await gatewayAccess().catch(() => null);
340
- if (access) {
341
- const bound = (await classCatalog())?.classes.embedding;
342
- return bound
570
+ const cfg = await config();
571
+ if (managed) {
572
+ const access = await creditAccess(cfg, true).catch(() => null);
573
+ const bound = access ? (await classCatalog())?.classes.embedding : null;
574
+ return access && bound
343
575
  ? { model: gatewayClient(access, {}).embeddingModel("wizards/embedding"), ref: bound.model }
344
576
  : null;
345
577
  }
346
578
  const ref = env.models.embedding;
347
- const { provider, vendor, model } = split(ref);
579
+ const { provider, id, vendor } = parse(ref);
348
580
  if (provider === "ollama") {
349
581
  const ollama = createOpenAI({
350
- baseURL: local.settings.ollamaUrl ?? env.ollamaUrl,
582
+ baseURL: cfg.settings.ollamaUrl ?? env.ollamaUrl,
351
583
  apiKey: "ollama",
352
584
  });
353
- return { model: ollama.embedding(model ? `${vendor}/${model}` : vendor), ref };
585
+ return { model: ollama.embedding(id), ref };
354
586
  }
355
- const gateway = ownGateway();
356
- if ((provider === null || provider === "gateway") && gateway) {
357
- return { model: gateway.embeddingModel(`${vendor}/${model}`), ref };
587
+ if ((provider === null || provider === "gateway") && cfg.keys.gateway) {
588
+ return { model: createGateway({ apiKey: cfg.keys.gateway }).embeddingModel(id), ref };
358
589
  }
359
- if ((provider === "openai" || vendor === "openai") && local.keys.openai) {
360
- return { model: createOpenAI({ apiKey: local.keys.openai }).embedding(model || vendor), ref };
590
+ if ((provider === "openai" || vendor === "openai") && cfg.keys.openai) {
591
+ return {
592
+ model: createOpenAI({ apiKey: cfg.keys.openai }).embedding(provider ? id : aliasOf(id)),
593
+ ref,
594
+ };
361
595
  }
362
- return null;
363
- }
364
- /** Gemini image models are chat models answering with image files. */
365
- export function isChatImageModel(ref) {
366
- return ref.includes("gemini") && ref.includes("image");
596
+ // No key of its own: the linked account's gateway, where its catalog has the class.
597
+ const access = await creditAccess(cfg, false).catch(() => null);
598
+ const bound = access ? (await classCatalog())?.classes.embedding : null;
599
+ return access && bound
600
+ ? { model: gatewayClient(access, {}).embeddingModel("wizards/embedding"), ref: bound.model }
601
+ : null;
367
602
  }
368
603
  /** Search tools an AI Gateway runs itself; they travel in the request, so any gateway client names them. */
369
604
  export const gatewayTools = createGateway({ apiKey: "unused" }).tools;
370
- /** Whether any text model can answer at all: the studio says so before the first chat turn. */
371
605
  /**
372
606
  * Whether a class can run here, found out without calling it: null, or what the person reads.
373
607
  * A class the gateway serves counts as there unless its catalog leaves it unbound.
@@ -380,10 +614,20 @@ export async function classProblem(cls) {
380
614
  ? await videoModel()
381
615
  : cls === "speech"
382
616
  ? await speechModel()
383
- : await textModel(cls);
617
+ : cls === "audio"
618
+ ? (await listenerModel()).resolved
619
+ : await textModel(cls);
384
620
  if (resolved.metered) {
385
- const bound = (await classCatalog())?.classes;
386
- return bound && !bound[cls] ? "Dafür ist beim Konto kein Modell eingerichtet." : null;
621
+ const catalog = await classCatalog();
622
+ const named = creditsModelOf((await config()).settings.bindings[cls]);
623
+ if (named) {
624
+ return catalog?.models && !catalog.models.some((m) => m.id === named)
625
+ ? "Dieses Modell gibt es beim Konto nicht."
626
+ : null;
627
+ }
628
+ return catalog?.classes && !catalog.classes[cls]
629
+ ? "Dafür ist beim Konto kein Modell eingerichtet."
630
+ : null;
387
631
  }
388
632
  const client = harness(resolved.vendor);
389
633
  if (client && (await detectHarness(client.id))?.auth === "none") {
@@ -398,6 +642,7 @@ export async function classProblem(cls) {
398
642
  throw err;
399
643
  }
400
644
  }
645
+ /** Whether any text model can answer at all: the studio says so before the first chat turn. */
401
646
  export async function hasTextModel() {
402
647
  try {
403
648
  await textModel("standard");
@@ -407,6 +652,103 @@ export async function hasTextModel() {
407
652
  return false;
408
653
  }
409
654
  }
655
+ export async function classWays() {
656
+ const cfg = await config();
657
+ const ways = {};
658
+ for (const cls of MODEL_CLASSES) {
659
+ const ref = ownRef(cfg, cls);
660
+ if (ref) {
661
+ const { provider, vendor } = parse(ref);
662
+ const client = provider === null && isHarnessVendor(vendor);
663
+ ways[cls] = {
664
+ kind: client ? "client" : provider === "ollama" ? "local" : "key",
665
+ by: client
666
+ ? vendor
667
+ : providerOfRef(ref) === "gateway" && !cfg.keys.gateway
668
+ ? vendor
669
+ : providerOfRef(ref),
670
+ ref,
671
+ problem: null,
672
+ };
673
+ continue;
674
+ }
675
+ let problem = null;
676
+ const access = await creditAccess(cfg, creditsChosen(cfg, cls)).catch((err) => {
677
+ problem = err.message;
678
+ return null;
679
+ });
680
+ ways[cls] = access
681
+ ? {
682
+ kind: "credits",
683
+ by: null,
684
+ ref: creditsModelOf(cfg.settings.bindings[cls]) ??
685
+ (await classCatalog())?.classes[cls]?.model ??
686
+ "",
687
+ problem: null,
688
+ }
689
+ : { kind: "none", by: null, ref: "", problem: problem ?? missingText(cfg, cls) };
690
+ }
691
+ return ways;
692
+ }
693
+ /** What the settings page shows: the choices, which keys are there (never the keys), and the ways. */
694
+ export async function modelSettings() {
695
+ const cfg = await config();
696
+ return {
697
+ source: cfg.settings.source,
698
+ bindings: cfg.settings.bindings,
699
+ ollamaUrl: cfg.settings.ollamaUrl ?? env.ollamaUrl,
700
+ creditFallback: cfg.settings.creditFallback !== false,
701
+ keys: Object.fromEntries(LOCAL_KEYS.map((k) => [k, Boolean(cfg.keys[k])])),
702
+ ways: await classWays(),
703
+ /** What a class costs on the credits, where they are at hand. */
704
+ prices: (await classCatalog())?.classes ?? null,
705
+ /** The media models the credits pay for, which a binding may name. */
706
+ creditModels: (await classCatalog())?.models ?? [],
707
+ };
708
+ }
709
+ // --- Checking a key ------------------------------------------------------------------
710
+ /** A cheap authenticated call: whether the provider takes the key. Nothing is generated. */
711
+ export async function checkKey(name, key) {
712
+ const requests = {
713
+ openai: ["https://api.openai.com/v1/models", { authorization: `Bearer ${key}` }],
714
+ anthropic: [
715
+ "https://api.anthropic.com/v1/models?limit=1",
716
+ { "x-api-key": key, "anthropic-version": "2023-06-01" },
717
+ ],
718
+ google: [
719
+ "https://generativelanguage.googleapis.com/v1beta/models?pageSize=1",
720
+ { "x-goog-api-key": key },
721
+ ],
722
+ fal: [
723
+ "https://api.fal.ai/v1/models?endpoint_id=fal-ai/flux/schnell",
724
+ { authorization: `Key ${key}` },
725
+ ],
726
+ elevenlabs: ["https://api.elevenlabs.io/v1/models", { "xi-api-key": key }],
727
+ replicate: ["https://api.replicate.com/v1/account", { authorization: `Bearer ${key}` }],
728
+ gateway: ["https://ai-gateway.vercel.sh/v1/credits", { authorization: `Bearer ${key}` }],
729
+ };
730
+ const [url, headers] = requests[name];
731
+ try {
732
+ const res = await fetch(url, { headers, signal: AbortSignal.timeout(10_000) });
733
+ if (res.ok) {
734
+ return { ok: true, message: null };
735
+ }
736
+ const body = await res.text().catch(() => "");
737
+ // A key restricted to some permissions is still a key the provider knows.
738
+ if (res.status === 403 || /missing_permissions/.test(body)) {
739
+ return { ok: true, message: null };
740
+ }
741
+ return {
742
+ ok: false,
743
+ message: res.status === 401
744
+ ? "Der Anbieter kennt diesen Key nicht."
745
+ : `Der Anbieter antwortet mit ${res.status}.`,
746
+ };
747
+ }
748
+ catch {
749
+ return { ok: false, message: "Der Anbieter ist gerade nicht erreichbar." };
750
+ }
751
+ }
410
752
  let catalog = new Map();
411
753
  let catalogLoadedAt = 0;
412
754
  /** Public prices of the AI Gateway: what a runtime that runs alone shows as the cost of a run. */
@@ -426,9 +768,11 @@ export async function loadCatalog(force = false) {
426
768
  // Prices fall back to conservative defaults below.
427
769
  }
428
770
  }
771
+ /** The AI Gateway's prices, by its id of the model: what an own key of the same model costs too. */
429
772
  function priceOf(ref) {
430
- const { vendor, model } = split(ref);
431
- return catalog.get(`${vendor}/${model}`)?.pricing ?? {};
773
+ const { provider, id } = parse(ref);
774
+ return (catalog.get(provider === null || provider === "gateway" ? id : `${provider}/${id}`)?.pricing ??
775
+ {});
432
776
  }
433
777
  const n = (v, fallback) => {
434
778
  const x = Number(v);
@@ -487,3 +831,7 @@ export function speechCostUsd(ref, characters) {
487
831
  return n(p.audio_output_token_cost, 0.00001) * Math.ceil((characters / 15) * 25);
488
832
  }
489
833
  export const WEB_SEARCH_COST_USD = 0.01;
834
+ /** A transcript, priced by the minute; the gateway's catalog has no transcription prices. */
835
+ export function transcriptionCostUsd(seconds) {
836
+ return (seconds / 60) * 0.006;
837
+ }