Oregami
Repositories/oxedyne/daimond

oxedyne/daimond/www/js/models.js

122 KiB, 5 runs

created by r2519314175:1401, which is this file's identity for as long as the history lasts, whatever it is later renamed to

download · who wrote it · its history

1/* models.js — the models Daimond can reach, across every provider you have a key for.
2 *
3 * Daimond used to hold ONE provider: a base URL, a key, a model. That is the shape of a
4 * demo, not of a working setup — the model you want for a cheap classification is not the
5 * one you want for a hard refactor, and they rarely live behind the same key. So a key is
6 * held per provider, every provider's models are listed together, and exactly one model is
7 * the default a new chat starts with.
8 *
9 * The store, in localStorage:
10 *
11 * {
12 * v: 2,
13 * def: { provider, model }, the default a new chat or Diamond starts with
14 * providers: {
15 * <id>: { name, url, key|keyEnc, models: [id…], fetched }
16 * }
17 * }
18 *
19 * A key is written to storage ENCRYPTED (`keyEnc`) whenever there is a passphrase identity
20 * to encrypt it under, exactly as the single key was; the plaintext exists only in memory,
21 * and only after the user has unlocked. `key` is the plaintext-at-rest fallback for the
22 * skippable, browser-only path, and it is the same trade the app already made.
23 *
24 * ACROSS DEVICES. The store travels in the sync parcel — see `exportSync`/`applySync` at the
25 * end of this file — because a second device that has to be told about six providers again,
26 * and have six keys pasted into it again, is not the same account: it is a second setup. What
27 * travels is the SEALED key and never a readable one, which is safe for exactly one reason:
28 * both devices hold the same identity (the salt travels in the pairing bundle), so `keyEnc`
29 * opens on both and the gateway carrying it holds no key for either.
30 *
31 * One provider is not like the others. `credits` is the models a Daimond balance buys, and
32 * its key is MINTED by the gateway rather than typed by the user — see `mint()`. Everything
33 * else about it is ordinary: it is a row in the same store, its models come from the same
34 * `fetchModels`, and the browser calls it directly with no relay in the middle. That last
35 * part is the whole product, and it is why credits could not simply be proxied.
36 */
37(function () {
38 'use strict';
39
40 /// What the app says. The table lives in i18n/en.js.
41 function t(k, v) { return window.DaimondI18n ? DaimondI18n.t(k, v) : k; }
42 function tn(k, n, v) { return window.DaimondI18n ? DaimondI18n.tn(k, n, v) : k; }
43
44 /// A string from the table, or the English written here when the table has no
45 /// entry for it yet. The twin of `tOr` in daimond.js, and for the same reason:
46 /// a control reading "copy.what_model" is worse than one reading English while
47 /// the key is on its way. `vars` fills `{name}` placeholders in either.
48 function tOr(key, fallback, vars) {
49 var s = t(key, vars);
50 if (s !== key) return s;
51 if (!vars) return fallback;
52 return String(fallback).replace(/\{(\w+)\}/g, function (whole, k) {
53 return vars[k] != null ? String(vars[k]) : whole;
54 });
55 }
56
57 /// The app's copy button, or nothing when daimond.js has not published it yet.
58 /// A missing button is a row without a convenience; a thrown error here would
59 /// be a Models panel that does not draw.
60 function copyBtn(what, get) {
61 if (!(window.DaimondUI && DaimondUI.copyBtn)) return null;
62 return DaimondUI.copyBtn(what, get);
63 }
64
65 // ── The pause, refused where the money is committed ─────────────
66 // A pause the widget respects and the network does not is decoration, so it
67 // is checked HERE, in front of the mint, rather than trusted to whatever
68 // asked. `isPaused` is a set lookup, which is why it can sit in front of
69 // every one.
70
71 /// The worker pump's leaf. Every slot key (>=1) is that pump spending,
72 /// whichever Diamond asked for it, so one node gates the lot.
73 function workersNode() {
74 return window.DaimondPause ? DaimondPause.id(DaimondPause.ROOT, 'workers') : 'root/workers';
75 }
76
77 /// Is this node paused? Absent module means nothing is, which is the state
78 /// the app was in before the tree existed.
79 function held(node) {
80 return !!(node && window.DaimondPause && DaimondPause.isPaused(node));
81 }
82
83 /// The refusal a pause produces: an Error naming the node and how to start
84 /// it again. `paused` marks it so a caller can show a held spend calmly
85 /// rather than as a fault, and `pauseNode` says which control to point at.
86 function pauseError(node) {
87 var s = t('pause.refused.turn', { node: node });
88 if (s === 'pause.refused.turn') {
89 // A byte-for-byte copy of the catalogue entry, `{node}` filled in here.
90 // Assembling a second wording is how this drifted from `en.js` unnoticed.
91 s = ('{node} is paused. No turn started, nothing spent. '
92 + 'Press play on it to resume.').replace(/\{node\}/g, node);
93 }
94 var e = new Error(s);
95 e.paused = true;
96 e.pauseNode = node;
97 return e;
98 }
99
100 var KEY = 'daimond-models-v2';
101 var OLD_KEY = 'daimond-byok'; // the single-provider config this replaces
102 // Providers deleted on purpose, by id. The sync merge is a UNION, so a row
103 // this device removed is simply handed back by the device that still has it,
104 // key and all — absence in a parcel means "that device never had it", never
105 // "it is gone". A tombstone is what tells the two apart, and it is the same
106 // mechanism, TTL and merge rule the chats and the Diamonds use.
107 var TOMBS = 'daimond-provider-tombs';
108 var deps = null; // { onChange, onTopUp }
109
110 /// The provider whose key Daimond mints, rather than the user pasting one.
111 var CREDITS = 'credits';
112 /// Where a key is minted from. Session-authed, empty body; see `mint()`.
113 var MINT_URL = '/api/inference-key';
114
115 /// The providers Daimond knows how to talk to. Every one was verified to allow a direct
116 /// browser call, so a key works with no relay in the middle. `model` is a sensible default
117 /// where the provider has a stable id worth starting on.
118 ///
119 /// `credits` carries no URL: the gateway names the host when it mints the key, so the one
120 /// provider Daimond runs itself is also the one it does not hardcode an endpoint for.
121 /// `anthropic` is the one row that is NOT OpenAI-compatible. Its endpoint is the Messages
122 /// API, and the wasm client picks the wire dialect off that path — see `Dialect` in
123 /// src/llm.rs. It is here because Claude was reachable only through a router, which meant
124 /// every turn paid a middleman and no turn could ask for extended thinking at all.
125 var KNOWN = {
126 credits: { name: 'Daimond credits', url: '', model: '' },
127 anthropic: { name: 'Anthropic', url: 'https://api.anthropic.com/v1/messages', model: 'claude-opus-5' },
128 fireworks: { name: 'Fireworks AI', url: 'https://api.fireworks.ai/inference/v1/chat/completions', model: 'accounts/fireworks/models/glm-5p2' },
129 openrouter: { name: 'OpenRouter', url: 'https://openrouter.ai/api/v1/chat/completions', model: '' },
130 together: { name: 'Together AI', url: 'https://api.together.xyz/v1/chat/completions', model: '' },
131 groq: { name: 'Groq', url: 'https://api.groq.com/openai/v1/chat/completions', model: '' },
132 deepinfra: { name: 'DeepInfra', url: 'https://api.deepinfra.com/v1/openai/chat/completions', model: '' },
133 };
134
135 /// The Anthropic API version this app is written against.
136 ///
137 /// Pinned rather than tracking latest, and the same constant the wasm client sends
138 /// (`ANTHROPIC_VERSION` in src/llm.rs): the version header is what stops a breaking change
139 /// to the wire shape arriving without a code change.
140 var ANTHROPIC_VERSION = '2023-06-01';
141
142 var store = { v: 2, def: { provider: '', model: '' }, providers: {} };
143
144 /// Provider id -> plaintext key, memory only.
145 ///
146 /// For a key the user typed this is a cache: the durable copy is in `store`, sealed. For
147 /// `credits` it is the ONLY copy, and deliberately so. A minted key is a bearer credential
148 /// for money — whoever holds it can spend the balance behind it — and it is worth strictly
149 /// less to Daimond at rest than it costs to keep: another one is one authenticated request
150 /// away. So it is never written to `store`, never to localStorage, never sealed, never
151 /// exported, never synced. There is no at-rest story for this key because there is nothing
152 /// at rest. `lock()` empties this map, which is the whole of forgetting it.
153 var plain = {};
154
155 /// What the last mint said, and what the row says about itself. Memory only, for the same
156 /// reason the key is: a balance drawn from disk after a reload is a number that was true
157 /// once, and money the user cannot trust is worse than money they cannot see.
158 var credits = {
159 state: '', // '' | 'minting' | 'ready' | 'nocredits' | 'offline' | 'failed'
160 bal: 0, // minor units behind the key
161 cur: 'usd',
162 limit: 0, // minor units the minted key may itself spend
163 via: '', // who actually runs the models, per the gateway
164 why: '', // what went wrong, when something did
165 };
166
167 /// Which mint the live key came from, counting up. The gateway keeps at most ONE live key
168 /// per account, so a caller holding a key from an earlier generation is holding a revoked
169 /// one — and needs the current key, not another mint. See `remint()`.
170 var mintGen = 0;
171 /// The mint in flight, so simultaneous callers make one request between them.
172 var minting = null;
173
174 // ── The store ───────────────────────────────────────────────────
175
176 function load() {
177 var raw = null;
178 try { raw = JSON.parse(localStorage.getItem(KEY) || 'null'); } catch (e) { raw = null; }
179 if (raw && raw.v === 2 && raw.providers) {
180 store = raw;
181 if (!store.def) store.def = { provider: '', model: '' };
182 return;
183 }
184 migrate();
185 }
186
187 /// Carry the single provider the app used to hold into the store that holds many.
188 ///
189 /// The user has a key in there and a model they chose; losing either because the shape
190 /// changed underneath them would be the app forgetting something they told it. The old
191 /// record is left where it is — it still carries `maxTokens` and `tools`, which are not
192 /// per-provider and are still read from it.
193 function migrate() {
194 var old = null;
195 try { old = JSON.parse(localStorage.getItem(OLD_KEY) || 'null'); } catch (e) { old = null; }
196 store = { v: 2, def: { provider: '', model: '' }, providers: {} };
197 if (!old || !old.baseUrl) { save(); return; }
198
199 var id = idForUrl(old.baseUrl) || 'custom';
200 var models = [];
201 try { models = JSON.parse(localStorage.getItem('daimond-models') || '[]'); } catch (e) { models = []; }
202
203 store.providers[id] = {
204 name: (KNOWN[id] && KNOWN[id].name) || 'Custom provider',
205 url: old.baseUrl,
206 key: old.apiKey || '',
207 keyEnc: old.apiKeyEnc || '',
208 models: Array.isArray(models) ? models : [],
209 fetched: 0,
210 };
211 if (old.model) store.def = { provider: id, model: old.model };
212 save();
213 }
214
215 function save() {
216 try { localStorage.setItem(KEY, JSON.stringify(store)); } catch (e) { /* quota */ }
217 if (deps && deps.onChange) deps.onChange();
218 }
219
220 /// Stamp a provider row as configured just now.
221 ///
222 /// The merge across devices needs one number per row to compare, and it has to be bumped by
223 /// every change a user would be cross to lose — a key, a name, a URL, a manual balance. The
224 /// model list carries its own stamp (`fetched`) and is merged on that instead: "who asked
225 /// the provider more recently" and "who configured the row more recently" are two different
226 /// questions and a single stamp answers neither well.
227 function touch(id) {
228 var p = store.providers[id];
229 if (p) p.touched = Date.now();
230 }
231
232 /// Which known provider a base URL belongs to, or '' when it is nobody's.
233 function idForUrl(url) {
234 for (var id in KNOWN) { if (KNOWN[id].url === url) return id; }
235 return '';
236 }
237
238 function providerUrl(id) {
239 var p = store.providers[id];
240 return (p && p.url) || (KNOWN[id] && KNOWN[id].url) || '';
241 }
242 /// A provider's name for the screen.
243 ///
244 /// A vendor's name is a proper noun and is never translated; the two names
245 /// this app made up for itself -- the credits row and the catch-all for a
246 /// provider it does not know -- are phrases, and are. They are matched on
247 /// the stored English because that is what a store written before the
248 /// interface spoke anything else holds.
249 function providerName(id) {
250 var p = store.providers[id];
251 var n = (p && p.name) || (KNOWN[id] && KNOWN[id].name) || id;
252 if (id === CREDITS || n === 'Daimond credits') return t('models.credits_row');
253 if (n === 'Custom provider') return t('models.custom_provider_name');
254 return n;
255 }
256
257 /// Whether an endpoint speaks Anthropic's Messages API rather than OpenAI chat completions.
258 ///
259 /// The same two signals the wasm client uses (`Dialect::for_endpoint` in src/llm.rs), and
260 /// deliberately the same shape of test, because a browser that lists models one way and a
261 /// turn that posts them another is a provider that half works.
262 function isAnthropic(url) {
263 var s = String(url || '').toLowerCase().replace(/[?#].*$/, '').replace(/\/+$/, '');
264 if (!s) return false;
265 if (/^https?:\/\/([^/:]*\.)?anthropic\.com([:/]|$)/.test(s)) return true;
266 return s.slice(-'/v1/messages'.length) === '/v1/messages';
267 }
268
269 /// Derive the model-listing endpoint from a turn endpoint.
270 ///
271 /// Anthropic's listing sits at `/v1/models`, a SIBLING of `/v1/messages` — appending
272 /// `/models` to the turn endpoint (which is what every other provider needs) would ask
273 /// `/v1/messages/models`, which is nobody's endpoint and 404s.
274 function modelsUrl(base) {
275 if (isAnthropic(base)) return String(base).replace(/\/+$/, '').replace(/\/messages$/, '/models');
276 if (base.indexOf('/chat/completions') !== -1) return base.replace('/chat/completions', '/models');
277 return base.replace(/\/+$/, '') + '/models';
278 }
279
280 /// The headers a listing or turn request needs for `url`.
281 ///
282 /// Anthropic refuses a bearer token: it wants `x-api-key`, a pinned version, and — for a
283 /// call made from a page rather than a server — the header that makes its edge answer a
284 /// cross-origin request at all. Without that last one the browser never sees a reply, only
285 /// a CORS failure, which is why a provider that works from curl can still look broken here.
286 function authHeaders(url, key) {
287 if (!isAnthropic(url)) return { authorization: 'Bearer ' + key };
288 return {
289 'x-api-key': key,
290 'anthropic-version': ANTHROPIC_VERSION,
291 'anthropic-dangerous-direct-browser-access': 'true',
292 };
293 }
294
295 // ── Keys ────────────────────────────────────────────────────────
296
297 /// Decrypt every stored key into memory. Called once the user unlocks: a sealed key is
298 /// unreadable until then, which is the point of sealing it.
299 async function unseal() {
300 if (!window.DaimondIdentity || !DaimondIdentity.isUnlocked()) return;
301 for (var id in store.providers) {
302 var p = store.providers[id];
303 if (p.keyEnc) {
304 try { plain[id] = await DaimondIdentity.unwrap(p.keyEnc); }
305 catch (e) { plain[id] = ''; }
306 } else if (p.key) {
307 plain[id] = p.key;
308 }
309 }
310 if (deps && deps.onChange) deps.onChange();
311 refreshCredits();
312 }
313
314 /// Re-seal every provider key under the passphrase that has just replaced the old one.
315 ///
316 /// Called AFTER `DaimondIdentity.changePassphrase`. No read-out phase is needed, unlike
317 /// mail's: a key is already decrypted in `plain` for the length of an unlocked session, so
318 /// only the wrapping has to be redone.
319 ///
320 /// **This was missing until 2026-08-14 and the failure was silent and total**: nothing
321 /// re-wrapped `keyEnc`, so after a passphrase change every provider key was unreadable,
322 /// `ready()` went false and the app lost its model connection — while the notice on screen
323 /// said the saved key HAD been re-encrypted. A message that says the opposite of what
324 /// happened is worse than no message.
325 ///
326 /// A provider whose key cannot be re-sealed is NAMED, because "OpenRouter needs its key
327 /// again" can be acted on and "something went wrong" cannot.
328 /// A provider named so the user can tell two of them apart.
329 ///
330 /// `p.name` alone is not enough: `addProvider` defaults it to "Custom provider"
331 /// for any unknown id, so somebody with two custom endpoints would be told
332 /// "Custom provider needs its key again" and have no way to know which. The URL
333 /// is what actually distinguishes them, so it is appended whenever the name is
334 /// not unique across the store.
335 function labelOf(id) {
336 var p = store.providers[id];
337 if (!p) return id;
338 var nm = p.name || id, seen = 0;
339 for (var other in store.providers) {
340 if ((store.providers[other].name || other) === nm) seen++;
341 }
342 return seen > 1 && p.url ? nm + ' (' + p.url + ')' : nm;
343 }
344
345 async function resealAfterRekey() {
346 var failed = [], unread = [];
347 for (var id in store.providers) {
348 var p = store.providers[id];
349 var key = plain[id];
350 if (!key) {
351 // Sealed, and `unseal` could not open it — it turns a failed unwrap
352 // into an empty string, so a key that was ALREADY unreadable arrives
353 // here indistinguishable from a provider with no key at all. Told
354 // apart by the ciphertext still being there, and reported: this is
355 // the one moment the app holds both the fact and the user's
356 // attention, and saying nothing is how a dead key stays dead.
357 if (p.keyEnc) unread.push(labelOf(id));
358 continue;
359 }
360 try {
361 p.keyEnc = await DaimondIdentity.wrap(key);
362 p.key = ''; // never leave a plaintext copy behind
363 } catch (e) { failed.push(labelOf(id)); }
364 }
365 save();
366 return { ok: !failed.length && !unread.length, failed: failed, unread: unread };
367 }
368
369 /// Take part in a passphrase change, registered HERE beside the seal.
370 ///
371 /// NO READ-OUT PHASE, deliberately: a key is already decrypted in `plain` for
372 /// the length of an unlocked session, so there is nothing to read out under
373 /// the old key and nothing held afterwards that was not held before. `mail.js`
374 /// is the other shape and needs both phases; the registry expresses both
375 /// rather than bending either.
376 if (window.DaimondRekey) {
377 DaimondRekey.register({
378 name: 'models',
379 reseal: resealAfterRekey,
380 sentence: function (kind, list) {
381 return t(kind === 'unread' ? 'changepass.models_not_unsealed'
382 : 'changepass.models_not_resealed', { list: list.join(', ') });
383 },
384 });
385 }
386
387 /// Store a key for a provider, sealed under the passphrase where there is one.
388 async function setKey(id, key) {
389 var p = store.providers[id];
390 if (!p) return;
391 touch(id); // a configuration change the other device must see
392 plain[id] = key;
393 p.key = '';
394 p.keyEnc = '';
395 if (window.DaimondIdentity && DaimondIdentity.isUnlocked()) {
396 try { p.keyEnc = await DaimondIdentity.wrap(key); }
397 catch (e) { p.key = key; } // no identity to seal under: the old trade
398 } else {
399 p.key = key;
400 }
401 // A key that was just pasted is a key whose balance nobody has asked about. A stale
402 // figure from the PREVIOUS key would be worse than none, so any credit record goes —
403 // and with it the gate's memory of the OLD key's probes, whose floor and whose backoff
404 // were about a credential this row no longer holds.
405 delete p.credit;
406 delete probes[id];
407 // And the catalogue gate's memory, for the same reason: its floor and its failure count
408 // were earned by a credential this row no longer holds, and a key pasted to replace a
409 // revoked one should not wait out the backoff the revoked one collected.
410 delete lists[id];
411 save();
412 if (canProbeCredit(providerUrl(id))) {
413 fetchCredit(id).then(function (got) {
414 if (got && document.getElementById('models-list')) render();
415 }).catch(function () { /* no balance is better than a wrong one */ });
416 }
417 }
418
419 /// The plaintext key for a provider, or '' when it is sealed and the app is locked.
420 function keyFor(id) {
421 if (plain[id]) return plain[id];
422 var p = store.providers[id];
423 return (p && p.key) || '';
424 }
425
426 /// Whether a provider holds a key at all, sealed or not. A provider with no key is
427 /// listed but cannot be used, and says so.
428 ///
429 /// A live key in memory counts, and must: `credits` holds its minted key there and nowhere
430 /// else, so a predicate reading only the stored copy called the one provider that was
431 /// working keyless. `keyFor()` has always answered from `plain` first, so the two now agree
432 /// — which is the actual bug. For a provider whose key the user typed nothing changes:
433 /// `plain[id]` is only ever filled from `key` or `keyEnc`, so it can add no new truth.
434 function hasKey(id) {
435 var p = store.providers[id];
436 return !!(p && (plain[id] || p.key || p.keyEnc));
437 }
438
439 /// Whether the key is present but unreadable because the app is locked.
440 ///
441 /// A minted key is never sealed — it is not stored to be sealed — so a locked `credits` row
442 /// is not "sealed", it is simply keyless until the next mint. Unlocking is still what fixes
443 /// it, because minting needs the device signature that unlocking makes available.
444 function isSealed(id) {
445 var p = store.providers[id];
446 return !!(p && p.keyEnc && !plain[id]);
447 }
448
449 /// Whether this provider can be used right now: a key, and one we can read.
450 function canRun(id) {
451 return hasKey(id) && !isSealed(id);
452 }
453
454 // ── Credits: the key Daimond mints ──────────────────────────────
455 // Credits used to buy everything except the thing the app is for. A user with money in
456 // their account could fetch a page, send mail and sync with it, and the model picker still
457 // said "no model connected" — the two halves of the product had no seam between them. This
458 // is the seam, and it is deliberately a small one: credits are a provider row like any
459 // other, and the only difference is who produces the key.
460
461 /// The chat-completions endpoint a minted key is spent at.
462 ///
463 /// The gateway names a BASE url — `https://openrouter.ai/api/v1` — because that is what its
464 /// operator configures and what the host documents. A provider row wants the endpoint a turn
465 /// is POSTed to verbatim, which is that string plus `/chat/completions`. Both forms are
466 /// accepted, since an operator who configures the whole endpoint is not wrong either. The two
467 /// are reconciled HERE rather than left to the caller, because a row built on the base URL
468 /// looks perfectly well until the first turn, which is much too late to discover it.
469 function chatUrl(base) {
470 var s = String(base || '').replace(/\/+$/, '');
471 if (!s || s.indexOf('/chat/completions') !== -1) return s;
472 return s + '/chat/completions';
473 }
474
475 /// Who actually runs a minted key's models, for saying so on the row.
476 ///
477 /// The user bought Daimond credits, so the row is named for that — but the request leaves
478 /// their browser for somebody else's machine, and an app whose whole claim is that nothing
479 /// happens behind the user's back cannot leave that out. The host is read from the URL the
480 /// gateway hands back, so the row names whoever it actually minted against rather than
481 /// whoever this file was written expecting.
482 function hostOf(url) {
483 var id = idForUrl(url);
484 if (id && KNOWN[id]) return providerName(id);
485 try { return new URL(url).hostname.replace(/^www\./, ''); } catch (e) { return ''; }
486 }
487
488 function money(minor, cur) {
489 if (window.DaimondGateway && DaimondGateway.fmtMoney) return DaimondGateway.fmtMoney(minor, cur);
490 return '$' + ((minor || 0) / 100).toFixed(2);
491 }
492
493 /// Mint a fresh inference key, and hold it in memory only.
494 ///
495 /// The gateway authenticates the session, reconciles what the last key drew, and returns a
496 /// key capped at the smaller of its float and the balance behind it — so the cap is usually
497 /// well UNDER the balance, and a key runs out long before the credits do. That is what
498 /// `remint()` is for. No amount is sent: what a key may spend is the account's business, and
499 /// a browser that could ask for a number could ask for the wrong one.
500 ///
501 /// Minting also REVOKES the account's previous key: the gateway keeps at most one live per
502 /// account. So this is not free to call twice, and a second tab minting will quietly spend
503 /// this one's key — which the 401 retry then heals, one turn at a time.
504 ///
505 /// The contract version rides on this call like every other, and is READ from gateway.js
506 /// rather than copied: two constants that must match are two constants that will one day
507 /// not. A tab too old to serve is answered 426, which is turned into the reload the updater
508 /// exists for.
509 /// POST the mint endpoint for one slot, returning the parsed reply or throwing.
510 ///
511 /// Factored out of `mint` so the chat (slot 0) and each parallel worker (its
512 /// own slot) share exactly one request path. The slot rides in the body, and
513 /// the gateway gives each slot its OWN capped key -- so parallel workers never
514 /// share a key, and their concurrent requests cannot race a shared cap into an
515 /// overspend. A body naming no slot is slot 0, which is the chat's own key.
516 ///
517 /// `node` is the pause-tree leaf the turn belongs to, when the caller knows
518 /// it — a chat's leaf, a Diamond's `self`. A mint is where a turn commits, so
519 /// a paused leaf is refused here and no request goes out. A slot of 1 or more
520 /// is the worker pump spending, and that leaf is checked whether the caller
521 /// names one or not: the pump is the same pump however the work reached it.
522 async function mintRequest(slot, node) {
523 var stop = held(node) ? node
524 : (((slot | 0) >= 1 && held(workersNode())) ? workersNode() : '');
525 if (stop) throw pauseError(stop);
526 var head = { 'content-type': 'application/json' };
527 if (window.DaimondGateway && DaimondGateway.clientApi) {
528 head['x-daimond-api'] = String(DaimondGateway.clientApi());
529 }
530 var r = await fetch(MINT_URL, {
531 method: 'POST',
532 headers: head,
533 credentials: 'same-origin',
534 body: JSON.stringify({ slot: slot | 0 }),
535 });
536 if (r.status === 426) { try { window.dispatchEvent(new Event('daimond:stale')); } catch (e) {} }
537 var j = null;
538 try { j = await r.json(); } catch (e) { j = null; }
539 // The gateway reconciles the account before it answers, so this reply carries the one
540 // balance in an ordinary chat session that has actually moved -- on the refusal as much as
541 // on the mint, which is the moment a nearly empty account most needs the figure to be
542 // right. `credits.bal` below is this file's own copy for the models panel; the account
543 // figure in the rail belongs to gateway.js and has to be told, and was not.
544 if (window.DaimondGateway && DaimondGateway.noteBalance) DaimondGateway.noteBalance(j);
545 if (!r.ok || !j || j.ok === false) {
546 var err = new Error((j && (j.error || j.message))
547 || t('models.err_refused', { status: r.status }));
548 // An empty account is not a fault, it is a thing to do, and the row offers the doing
549 // of it rather than reporting an error at somebody who has done nothing wrong. `402
550 // Payment Required` is the gateway saying exactly that; the balance is checked too,
551 // so this holds whichever way it chooses to say it.
552 err.noCredits = r.status === 402 || !!(j && j.credits_minor === 0);
553 throw err;
554 }
555 if (!j.key || !j.url) throw new Error(t('models.err_bad_key'));
556 return j;
557 }
558
559 /// `node` is the leaf the mint is for, when there is one. The mint at unlock
560 /// belongs to no leaf and passes none: gating it would leave a paused chat's
561 /// pause holding the whole account keyless.
562 async function mint(node) {
563 var j = await mintRequest(0, node);
564 var url = chatUrl(j.url);
565 plain[CREDITS] = j.key; // memory, and nowhere else — see `plain`.
566 mintGen++; // this key's generation; the last one is revoked.
567 credits.bal = typeof j.credits_minor === 'number' ? j.credits_minor : 0;
568 credits.cur = j.currency || 'usd';
569 // What the key itself may draw, which is NOT the balance: the gateway caps a minted key
570 // at a float, so a key can be spent while the account still holds credits. That is why
571 // a refusal mid-session is answered with another key rather than reported as an error.
572 credits.limit = typeof j.limit_minor === 'number' ? j.limit_minor : 0;
573 credits.via = hostOf(url);
574 credits.state = 'ready';
575 credits.why = '';
576
577 // The row itself is ordinary and IS stored: its name, its host and the models behind it
578 // are not secrets, and keeping them means a returning user sees their models while the
579 // mint is still in flight rather than an empty panel. `key` and `keyEnc` stay empty for
580 // this row, always.
581 var p = store.providers[CREDITS];
582 store.providers[CREDITS] = {
583 name: KNOWN[CREDITS].name,
584 url: url,
585 key: '',
586 keyEnc: '',
587 models: (p && p.models) || [],
588 fetched: (p && p.fetched) || 0,
589 };
590 save();
591 return credits;
592 }
593
594 /// Stand the credits row down: no key, and a reason the panel can show.
595 ///
596 /// The row is left in place when it is already there. A user who has been running on
597 /// credits and has just run out needs to see that that is what happened, beside the models
598 /// they were using; removing the row would leave them looking for something that had
599 /// silently gone. A user who never had credits never gets a row at all, so nothing new
600 /// appears in the panel of somebody who only ever wanted their own key.
601 function standDown(state, why) {
602 delete plain[CREDITS];
603 credits.state = state;
604 credits.why = why || '';
605 if (state !== 'ready') credits.limit = 0;
606 if (deps && deps.onChange) deps.onChange();
607 }
608
609 /// Make the credits row reflect the account: mint while there is a balance to spend, stand
610 /// down when there is not, and list what the key can run.
611 ///
612 /// `acct` is what the caller has just read from the gateway — `{ authed, credits, currency,
613 /// offline }`. It is passed in rather than fetched here so the gateway's contract stays in
614 /// gateway.js and this file stays about models.
615 async function syncCredits(acct) {
616 acct = acct || {};
617 if (!acct.authed) { standDown(acct.offline ? 'offline' : ''); return false; }
618 if (typeof acct.credits === 'number' && acct.credits <= 0) { standDown('nocredits'); return false; }
619
620 credits.state = 'minting';
621 if (deps && deps.onChange) deps.onChange();
622 try {
623 await mint();
624 } catch (e) {
625 standDown(e && e.noCredits ? 'nocredits' : 'failed', e && e.message ? e.message : String(e));
626 return false;
627 }
628 // The catalogue behind a minted key is large and changes without us, so it is asked for
629 // rather than assumed. A refusal here leaves the key good and the row usable on whatever
630 // was already listed.
631 try { await fetchModels(CREDITS); }
632 catch (e) { /* the key still works; the list is just older than we hoped. */ }
633 if (deps && deps.onChange) deps.onChange();
634 return true;
635 }
636
637 /// A fresh key for a spent one, mid-session.
638 ///
639 /// A minted key is capped at a float well under the balance, so it is refused the moment
640 /// that cap is reached while the account behind it still holds credits. That is not a key
641 /// the user can check and not a failure to report: it is a key to replace. Callers get one
642 /// shot at this per turn.
643 ///
644 /// **Coalesced, and that is the whole of this function.** The gateway keeps at most one live
645 /// key per account: minting revokes the last one. So several agents that hit a spent key
646 /// together must not mint several keys, or each would revoke the one before it and they
647 /// would chase each other round — the two-tab race, but automated, at machine speed, and
648 /// spending real money on every lap. Two things close it:
649 ///
650 /// * `gen` — the mint generation the caller's key came from. A caller whose key has
651 /// ALREADY been replaced by somebody else's mint does not mint: it takes the live key,
652 /// which is the very thing it was about to ask for.
653 /// * `minting` — callers arriving together wait on the one request in flight rather than
654 /// racing it.
655 ///
656 /// Between them, N agents holding one spent key produce exactly ONE mint, whether they fail
657 /// at the same instant or one after another.
658 ///
659 /// `node` is the leaf whose turn wants the key. Checked BEFORE the coalescing
660 /// guards, so a paused chat neither mints nor takes the key another caller is
661 /// minting: joining a mint in flight is how a paused leaf would go on running
662 /// on somebody else's key.
663 async function remint(gen, node) {
664 if (held(node)) throw pauseError(node);
665 if (typeof gen === 'number' && gen < mintGen && plain[CREDITS]) return plain[CREDITS];
666 if (minting) return await minting;
667 minting = (async function () {
668 delete plain[CREDITS];
669 credits.state = 'minting';
670 try {
671 await mint(node);
672 } catch (e) {
673 standDown(e && e.noCredits ? 'nocredits' : 'failed', e && e.message ? e.message : String(e));
674 throw e;
675 }
676 if (deps && deps.onChange) deps.onChange();
677 return keyFor(CREDITS);
678 })();
679 try { return await minting; }
680 finally { minting = null; }
681 }
682
683 /// Which mint the live key came from. A caller records this when it builds something around
684 /// the key, and hands it back to `remint()`, which uses it to tell "my key is spent" from
685 /// "my key is merely old" — only the first needs a new one.
686 function creditsGen() { return mintGen; }
687
688 // ── Worker slots: a key per parallel worker ─────────────────────
689 // The chat spends slot 0 — the key `mint`/`plain[CREDITS]` above hold. Each
690 // parallel worker spends its OWN slot (>=1), so no two share a key: a shared
691 // key is exactly what lets concurrent requests race the host's stale cap check
692 // and overspend it. A slot is owned by one worker at a time (daimond.js hands
693 // them out from a free-list), so nothing coalesces here — the single worker on
694 // a slot mints and re-mints it in sequence.
695 var slots = {}; // slot(>=1) -> { key, url, gen }
696
697 /// Mint (or replace) the key for a worker slot, returning `{ key, url, gen }`.
698 ///
699 /// `node` is the leaf that asked for the worker — the Diamond's `self`, or a
700 /// triggered action. The worker pump's own leaf is checked regardless; see
701 /// `mintRequest`.
702 async function mintSlot(slot, node) {
703 var j = await mintRequest(slot, node);
704 var s = slots[slot] || (slots[slot] = { key: '', url: '', gen: 0 });
705 s.key = j.key;
706 s.url = chatUrl(j.url);
707 s.gen += 1;
708 // The balance is account-wide, so a worker's mint keeps the shared row as
709 // current as the chat's own mint does.
710 if (typeof j.credits_minor === 'number') credits.bal = j.credits_minor;
711 return s;
712 }
713
714 /// A fresh key for a slot whose key was refused — unless another mint has
715 /// already replaced it, the same generation guard `remint` uses, per slot.
716 async function remintSlot(slot, gen, node) {
717 // Before the generation guard, for the reason `remint` checks before its
718 // own: handing back a key somebody else minted is still the paused node
719 // carrying on.
720 if (held(node)) throw pauseError(node);
721 if (held(workersNode())) throw pauseError(workersNode());
722 var s = slots[slot];
723 if (s && typeof gen === 'number' && gen < s.gen && s.key) return s;
724 return await mintSlot(slot, node);
725 }
726
727 /// A slot's live `{ key, url, gen }`, or null if it holds none yet.
728 function slotConfig(slot) { return slots[slot] || null; }
729
730 /// Forget one slot's key (its worker has finished with it). The key at the
731 /// host is left for the next mint on that slot to rotate out, or the sweep.
732 function forgetSlot(slot) { delete slots[slot]; }
733
734 /// What the credits row currently is, for a caller that must explain it.
735 function creditsState() {
736 return {
737 state: credits.state,
738 credits: credits.bal,
739 currency: credits.cur,
740 limit: credits.limit,
741 via: credits.via,
742 why: credits.why,
743 hasRow: !!store.providers[CREDITS],
744 ready: canRun(CREDITS),
745 };
746 }
747
748 // ── The models ──────────────────────────────────────────────────
749
750 /// A per-token price as USD per 1,000,000 tokens, or null when the figure cannot be
751 /// trusted.
752 ///
753 /// The units are the whole risk here. An OpenAI-compatible `/models` reply gives PER-TOKEN
754 /// prices, usually as strings ("0.0000006153"); read as per-million they would understate
755 /// spend by a factor of a million, which is worse than not knowing. So anything above
756 /// 0.001/token — $1,000 per million, well above any model that exists — is refused rather
757 /// than guessed at, and a provider using a different unit simply contributes nothing and
758 /// leaves the table to answer.
759 function perM(v) {
760 if (v === null || v === undefined || v === '') return null;
761 var n = (typeof v === 'number') ? v : parseFloat(v);
762 if (!isFinite(n) || n < 0) return null;
763 if (n === 0) return 0; // a free model is priced, at nothing
764 if (n > 1e-3) return null; // not per-token; refuse to convert
765 return n * 1e6;
766 }
767
768 /// The rates and context window one entry of a `/models` reply publishes, or null.
769 function ratesOf(m) {
770 if (!m || typeof m !== 'object') return null;
771 var p = m.pricing || {};
772 var inPerM = perM(p.prompt !== undefined ? p.prompt : p.input);
773 var outPerM = perM(p.completion !== undefined ? p.completion : p.output);
774 if (inPerM === null || outPerM === null) return null;
775 var cached = perM(p.input_cache_read !== undefined ? p.input_cache_read : p.cached_input);
776 var ctx = (typeof m.context_length === 'number') ? m.context_length : null;
777 var out = { in: inPerM, out: outPerM };
778 if (cached !== null) out.cached = cached;
779 if (ctx !== null) out.ctx = ctx;
780 return out;
781 }
782
783 /// Ask a provider what it can run. The list is cached, because a chat's model can be
784 /// switched from its header and re-asking on every switch would be rude to the provider
785 /// and slow for the user.
786 ///
787 /// The reply's prices and context windows are KEPT. They were being thrown away and the
788 /// app then estimated a turn's cost from a hand-maintained table months out of date — while
789 /// the provider had just told it, in the same request, exactly what it charges.
790 async function fetchModels(id) {
791 var url = providerUrl(id);
792 var key = keyFor(id);
793 if (!url || !key) throw new Error(t('models.err_no_key'));
794 var r = await fetch(modelsUrl(url), { headers: authHeaders(url, key) });
795 if (!r.ok) throw new Error(t('models.err_key_refused', { status: r.status }));
796 var j = await r.json();
797 var list = j.data || j.models || [];
798 var ids = list
799 .map(function (m) { return typeof m === 'string' ? m : (m.id || m.name); })
800 .filter(Boolean)
801 .sort();
802 var rates = {};
803 list.forEach(function (m) {
804 if (typeof m === 'string') return;
805 var mid = m.id || m.name;
806 var rr = ratesOf(m);
807 if (mid && rr) rates[mid] = rr;
808 });
809 store.providers[id].models = ids;
810 store.providers[id].rates = rates;
811 store.providers[id].fetched = Date.now();
812 save();
813 return ids;
814 }
815
816 /// What `provider` says it charges for `model`, as `{ inPerM, outPerM, cachedPerM, ctx }`,
817 /// or null when it never said.
818 ///
819 /// `DaimondPricing` asks this FIRST and falls back to its own table only when the answer is
820 /// null, so a live quote always beats a surveyed figure. `cachedPerM` is null when the
821 /// provider publishes no separate cache-read rate; it is NOT filled in with the input rate
822 /// here, because "no discount published" and "no discount" are different claims and only
823 /// the pricing code should decide what to do about it.
824 function rateFor(provider, model) {
825 var p = store.providers[provider];
826 if (!p || !p.rates) return null;
827 var r = p.rates[model];
828 if (!r || typeof r.in !== 'number' || typeof r.out !== 'number') return null;
829 return {
830 inPerM: r.in,
831 outPerM: r.out,
832 cachedPerM: (typeof r.cached === 'number') ? r.cached : null,
833 ctx: (typeof r.ctx === 'number') ? r.ctx : null,
834 };
835 }
836
837 // ── What is left on a key ───────────────────────────────────────
838
839 /// Sibling endpoints of a chat-completions URL, for the two credit probes.
840 function siblingUrl(base, leaf) {
841 if (base.indexOf('/chat/completions') !== -1) {
842 return base.replace('/chat/completions', '/' + leaf);
843 }
844 return base.replace(/\/+$/, '') + '/' + leaf;
845 }
846
847 /// Whether a provider's endpoint is one whose remaining balance can be ASKED for.
848 ///
849 /// Only OpenRouter is known to answer, and only OpenRouter has been verified to answer a
850 /// browser (both probes send CORS headers). Every other provider gets the manual tally
851 /// instead — which is not a lesser feature, it is the honest one for a host that will not
852 /// say.
853 function canProbeCredit(url) {
854 return String(url || '').indexOf('openrouter.ai') !== -1;
855 }
856
857 // ── When the figure is asked for again ──────────────────────────
858 // A displayed balance goes wrong two ways, and the two want opposite treatments.
859 //
860 // The user SPENT. Daimond watched them do it: the turn and its cost are already in the
861 // ledger, so the figure is walked down locally with no request at all — see `creditFor`.
862 // That is the frequent case and it costs nothing.
863 //
864 // The user TOPPED UP. Nothing in the browser can know that; only a probe finds it. So the
865 // probe happens at the moments a person would expect it to — unlocking, pasting a key,
866 // coming back to the tab, opening this panel, and a heartbeat while the tab is in front —
867 // and every one of them passes through the SAME gate, so three arriving together are still
868 // one request.
869
870 /// The shortest gap between two automatic probes of one key.
871 ///
872 /// It is the user's own key and their own rate limit, which is what makes a modest poll
873 /// cheap — but it is not free, and no limit is published for OpenRouter's `/key` or
874 /// `/credits` endpoints. The way to find one out is not to hammer it, so the floor is set
875 /// far under any plausible limit (twelve requests an hour at the very worst) while staying
876 /// well inside the "I added money and came back" window this figure exists for. Every
877 /// automatic trigger shares this one floor, which is what stops them stacking.
878 var PROBE_FLOOR_MS = 5 * 60 * 1000;
879
880 /// How often a VISIBLE tab asks the gate whether the floor has passed.
881 ///
882 /// Not the cadence — the floor is the cadence. This only decides how promptly the floor is
883 /// noticed once it has gone by, and it is what moves the age line on. A hidden tab does not
884 /// beat at all: browsers throttle background timers anyway, and a request nobody is there
885 /// to read is money and rate limit spent on nothing.
886 var PROBE_BEAT_MS = 60 * 1000;
887
888 /// The longest the gate will hold back a key whose probes keep failing.
889 ///
890 /// A failure doubles the wait rather than retrying on the next beat: whatever is refusing —
891 /// a rate limit, a revoked key, an outage — is not fixed by asking faster, and a tab left
892 /// open overnight would otherwise spend the night retrying. A success clears it, and so
893 /// does the user asking by hand.
894 var PROBE_BACKOFF_MAX_MS = 30 * 60 * 1000;
895
896 /// When a reading is old enough for its age to be said emphatically rather than quietly.
897 ///
898 /// Six floors. Inside that, a figure is between beats or has been asked for and refused
899 /// once, and neither is worth raising the voice about; past it, something has stopped
900 /// working and the age is no longer a footnote on the figure but the point of it.
901 var CREDIT_STALE_MS = 30 * 60 * 1000;
902
903 /// When a catalogue is old enough to be asked for again without anybody asking.
904 ///
905 /// A provider publishes a model when it publishes one and tells nobody; a list a day old
906 /// has very likely missed something, and a list an hour old very likely has not. That is
907 /// the whole of the reasoning, and it is also why this is compiled in rather than offered:
908 /// every figure between an hour and a day produces the same experience for the same person,
909 /// so a control over it would be a choice with nothing on either side of it.
910 ///
911 /// Two orders of magnitude above `CREDIT_STALE_MS` above, and the gap is the point. A
912 /// balance moves whenever the user spends or tops up; a catalogue moves when a company
913 /// ships. The two figures are next to each other so that neither is ever copied from the
914 /// other by somebody in a hurry.
915 var LIST_STALE_MS = 24 * 60 * 60 * 1000;
916
917 /// Per provider: `{ at, busy, ok, fails }` — when it was last ASKED, whether an ask is in
918 /// flight, whether the last completed one answered, and how many have failed in a row.
919 ///
920 /// Memory only, deliberately: an attempt stamp restored from disk would hold back the one
921 /// probe a freshly loaded tab most needs, which is the exact case this feature is for.
922 var probes = {};
923
924 /// How long the gate holds this key: the floor, doubled once per consecutive failure.
925 function probeWait(id) {
926 var st = probes[id];
927 var n = (st && st.fails) || 0;
928 return Math.min(PROBE_FLOOR_MS * Math.pow(2, n), PROBE_BACKOFF_MAX_MS);
929 }
930
931 /// Whether an AUTOMATIC probe of this key is allowed right now. The user asking by hand is
932 /// not automatic and does not come through here.
933 function probeDue(id) {
934 var st = probes[id];
935 if (!st) return true;
936 if (st.busy) return false; // one in flight is one request already
937 return (Date.now() - st.at) >= probeWait(id);
938 }
939
940 /// Ask every key that will answer, wherever the gate allows it.
941 ///
942 /// Fire-and-forget on purpose: this is a nicety on a panel, and nothing may wait on somebody
943 /// else's server. A probe that fails writes nothing, so the row keeps the figure it had —
944 /// and the age line beside it goes on ageing, which is the only thing that tells a fresh
945 /// figure from a frozen one.
946 function refreshCredits() {
947 for (var id in store.providers) {
948 if (id === CREDITS) continue;
949 if (!canProbeCredit(providerUrl(id)) || !keyFor(id)) continue;
950 if (!probeDue(id)) continue;
951 (function (pid) {
952 fetchCredit(pid).then(function (got) {
953 if (got && document.getElementById('models-list')) render();
954 else ageLines(); // a refusal still moves the line that says so
955 }).catch(function () { ageLines(); });
956 })(id);
957 }
958 }
959
960 // ── When the catalogue is asked for again ───────────────────────
961 // A balance goes wrong because the user spent or topped up. A LIST goes wrong for a reason
962 // nothing in this browser can see: the provider published something. The button beside the
963 // list is the deliberate way to find out, and it was the ONLY way — so a catalogue asked for
964 // on the day a key was pasted stayed that way until somebody thought to press it, which for
965 // the models a person actually uses is never. So it is also asked for by itself, at the two
966 // moments a person would expect it to be current: the panel coming up, and the app starting.
967 //
968 // THE GATE IS THE WHOLE OF THIS. `fetchModels` ends in `save()`, `save()` tells daimond.js
969 // the store changed, and daimond.js redraws this panel — so an ask made BY a redraw makes a
970 // redraw that makes an ask. It is the loop the comment above `refreshCredits` measured at
971 // four thousand requests, with two differences, both bad: this one runs against a third
972 // party rather than the same one twice, and it carries the user's own API key while it does
973 // it. Three things stop it, and all three are needed:
974 //
975 // * BUSY — the redraw from `save()` happens while the first ask is still in flight, and so
976 // does every redraw for a row expanded or a language changed in the meantime. Without
977 // this, a tight run of redraws is a request each, because none of them has an answer
978 // yet and the list is still as stale as it was.
979 // * THE FLOOR — a FAILED ask writes nothing, so `fetched` stays old and staleness alone
980 // would wave the next redraw straight through. The floor is what makes a refusal cost
981 // one request rather than one per redraw.
982 // * THE FAILURE COUNT — a provider that is refusing is not persuaded by being asked
983 // faster, and a tab left open overnight would otherwise spend the night asking.
984 //
985 // A SEPARATE RECORD from `probes`, deliberately. "When the balance was last asked" and "when
986 // the list was last asked" are different facts about different endpoints, and one record
987 // answering both would have each of them lying about the other.
988
989 /// The shortest gap between two automatic asks for one provider's catalogue.
990 ///
991 /// It governs the failing case and only that: a successful ask stamps `fetched`, after which
992 /// `LIST_STALE_MS` holds the next one off for a day. So it is set at the length of a network
993 /// hiccup rather than at anything to do with catalogues — a provider unreachable at boot
994 /// should get another chance when the panel is opened, and should not have spent a whole day
995 /// on one refusal.
996 var LIST_FLOOR_MS = 10 * 60 * 1000;
997
998 /// Per provider: `{ at, busy, ok, fails }`, the same shape and the same job as `probes`.
999 ///
1000 /// Memory only, and for the same reason: an attempt stamp restored from disk would hold back
1001 /// the one ask a freshly loaded tab most needs.
1002 var lists = {};
1003
1004 /// How long the gate holds this catalogue: the floor, doubled once per consecutive failure,
1005 /// and never longer than the staleness it exists to notice. A provider refusing all day is
1006 /// still asked once a day, which is what a provider answering perfectly gets anyway.
1007 function listWait(id) {
1008 var st = lists[id];
1009 var n = (st && st.fails) || 0;
1010 return Math.min(LIST_FLOOR_MS * Math.pow(2, n), LIST_STALE_MS);
1011 }
1012
1013 /// Whether an AUTOMATIC ask for this provider's catalogue is allowed right now. The button
1014 /// under the list is the user asking, is not automatic, and does not come through here.
1015 function listDue(id) {
1016 var p = store.providers[id];
1017 if (!p) return false;
1018 // The minted row asks for its own list after every mint (`syncCredits`), which is a
1019 // better moment than either of ours: the key it would be asked with did not exist a
1020 // second earlier. Asking again here would be asking for work already done.
1021 if (id === CREDITS) return false;
1022 // No key, or a key sealed under a passphrase nobody has typed yet. Either way there is
1023 // nothing to ask with, and an ask would spend a request to be told so.
1024 if (!canRun(id)) return false;
1025 var st = lists[id];
1026 if (st) {
1027 if (st.busy) return false; // one in flight is one request already
1028 if ((Date.now() - st.at) < listWait(id)) return false;
1029 }
1030 return (Date.now() - ms(p.fetched)) >= LIST_STALE_MS;
1031 }
1032
1033 /// Ask one provider for its catalogue, silently.
1034 ///
1035 /// The button reports what the provider said, because somebody pressed it and is waiting for
1036 /// an answer. This did not ask, so it says nothing: a panel that opens with a provider's
1037 /// error across it is blaming the user for arriving. The old list stands, and the line under
1038 /// it goes on saying how old it is, which is the honest account of what happened. The
1039 /// failure is counted, because that is the one thing that has to happen — see `listWait`.
1040 async function askList(id) {
1041 var st = lists[id] = {
1042 at: Date.now(), // stamped BEFORE the request, so a slow one still counts
1043 busy: true,
1044 ok: lists[id] ? lists[id].ok : null,
1045 fails: (lists[id] && lists[id].fails) || 0,
1046 };
1047 var got = false;
1048 try {
1049 await fetchModels(id);
1050 got = true;
1051 } catch (e) {
1052 /* a revoked key, a typo in a URL, a rate limit: none of it was asked for */
1053 } finally {
1054 st.busy = false;
1055 st.done = Date.now();
1056 st.ok = got;
1057 st.fails = got ? 0 : st.fails + 1;
1058 }
1059 }
1060
1061 /// Ask every provider whose catalogue has gone stale, wherever the gate allows it.
1062 ///
1063 /// Fire-and-forget, like `refreshCredits` beside it: nothing on screen may wait on somebody
1064 /// else's server. An ask that answers redraws the panel through `save()`, which is how a
1065 /// model published this morning arrives without anybody pressing anything.
1066 function refreshLists() {
1067 // Offline is not a refusal to hold against a provider — there is nothing to ask — so it
1068 // returns before anything is stamped and nothing is counted against anybody.
1069 if (typeof navigator !== 'undefined' && navigator.onLine === false) return;
1070 for (var id in store.providers) {
1071 if (!listDue(id)) continue;
1072 askList(id);
1073 }
1074 }
1075
1076 /// Whether a check is already waiting for the panel to appear.
1077 var showCheck = false;
1078
1079 /// Try the panel again one task after a redraw that found nothing on screen.
1080 ///
1081 /// `settings()` in daimond.js draws this list and THEN reveals the drawer holding it, so at
1082 /// the instant of that draw the list has no box and `onScreen` is quite correctly false —
1083 /// which means the redraw the trigger was written for, the panel being OPENED, is the one
1084 /// redraw the trigger misses. Measured: a click on Models asks nothing, and the ask waits
1085 /// for whatever the user does next inside the panel. A task later the drawer is up and the
1086 /// box is real, so the same test is asked again then.
1087 ///
1088 /// The credit probe above has the same blind spot and is deliberately left with it: it has a
1089 /// heartbeat behind it that catches the panel within the minute, this has none, and rewiring
1090 /// somebody else's trigger is not what this change is.
1091 ///
1092 /// One timer at a time. A run of redraws must not become a run of timers — that is the same
1093 /// loop again wearing a different hat — and a single check is all any of them wanted.
1094 function askWhenShown() {
1095 if (showCheck || typeof setTimeout !== 'function') return;
1096 showCheck = true;
1097 setTimeout(function () {
1098 showCheck = false;
1099 // Still nothing on screen: the redraw was a sync pull or a change of language, and
1100 // nobody is looking. The panel will be drawn again when somebody is.
1101 if (!onScreen(document.getElementById('models-list'))) return;
1102 refreshLists();
1103 }, 0);
1104 }
1105
1106 /// Ask a provider what is left on its key.
1107 ///
1108 /// Two questions, because a key answers only one of them. `/key` describes THIS key: its
1109 /// spend cap and what remains of it. A key with `limit: null` has no cap, and its
1110 /// `limit_remaining` is null too — which is not zero, and showing $0 to somebody holding a
1111 /// hundred dollars of credit would be the worst kind of wrong. So the account-wide figure
1112 /// from `/credits` answers for an uncapped key.
1113 ///
1114 /// A failed probe writes NOTHING and returns null: the row then shows no balance at all,
1115 /// rather than a zero it cannot stand behind. A figure is never stored without the moment it
1116 /// was true (`asOf`), because a balance drawn from disk with no timestamp is a number that
1117 /// was true once and is now a claim.
1118 ///
1119 /// Returns `{ remainingUsd, asOf, capped }` or null.
1120 ///
1121 /// Every route to a probe comes through here — unlock, a pasted key, the tab returning, the
1122 /// beat, the panel, the button — so this is where the attempt is stamped and its outcome
1123 /// recorded. One place, so the floor and the backoff cannot be walked round by adding a
1124 /// caller. A key that cannot be probed at all is not an attempt and is not stamped.
1125 async function fetchCredit(id) {
1126 if (!store.providers[id] || !keyFor(id) || !canProbeCredit(providerUrl(id))) return null;
1127 var st = probes[id] = {
1128 at: Date.now(), // stamped BEFORE the request, so a slow one still counts
1129 busy: true,
1130 ok: probes[id] ? probes[id].ok : null,
1131 fails: (probes[id] && probes[id].fails) || 0,
1132 };
1133 var got = null;
1134 try {
1135 got = await askCredit(id);
1136 return got;
1137 } finally {
1138 st.busy = false;
1139 st.done = Date.now();
1140 st.ok = !!got;
1141 st.fails = got ? 0 : st.fails + 1;
1142 }
1143 }
1144
1145 /// The two requests themselves. See `fetchCredit`, which is what everything calls.
1146 async function askCredit(id) {
1147 var p = store.providers[id];
1148 var url = providerUrl(id);
1149 var key = keyFor(id);
1150 if (!p || !url || !key || !canProbeCredit(url)) return null;
1151 var auth = { authorization: 'Bearer ' + key };
1152 var keyData = null;
1153 try {
1154 var rk = await fetch(siblingUrl(url, 'key'), { headers: auth });
1155 if (!rk.ok) return null;
1156 var jk = await rk.json();
1157 keyData = jk.data || jk;
1158 } catch (e) { return null; }
1159 if (!keyData) return null;
1160
1161 var remaining = null, capped = false;
1162 if (typeof keyData.limit === 'number' && typeof keyData.limit_remaining === 'number') {
1163 remaining = keyData.limit_remaining;
1164 capped = true;
1165 } else {
1166 // Uncapped: the account's own credit is what this key can spend.
1167 try {
1168 var rc = await fetch(siblingUrl(url, 'credits'), { headers: auth });
1169 if (!rc.ok) return null;
1170 var jc = await rc.json();
1171 var cd = jc.data || jc;
1172 if (typeof cd.total_credits === 'number' && typeof cd.total_usage === 'number') {
1173 remaining = cd.total_credits - cd.total_usage;
1174 }
1175 } catch (e) { return null; }
1176 }
1177 if (typeof remaining !== 'number' || !isFinite(remaining)) return null;
1178
1179 var asOf = Date.now();
1180 p.credit = {
1181 mode: 'auto',
1182 remainingUsd: remaining,
1183 asOf: asOf,
1184 // A manual base the user may have set earlier is kept: an auto probe that starts
1185 // failing tomorrow should fall back to their figure, not forget it.
1186 baseUsd: (p.credit && typeof p.credit.baseUsd === 'number') ? p.credit.baseUsd : null,
1187 baseAt: (p.credit && typeof p.credit.baseAt === 'number') ? p.credit.baseAt : null,
1188 };
1189 save();
1190 return { remainingUsd: remaining, asOf: asOf, capped: capped };
1191 }
1192
1193 /// Record the user's own figure: "I have $X on this key, as of now".
1194 ///
1195 /// What is displayed afterwards is that figure counted down by the ledger's estimate of what
1196 /// has been spent on this provider since — and it is labelled as exactly that, because it is
1197 /// their number minus a guess, not a balance.
1198 function setCreditBase(id, usd) {
1199 var p = store.providers[id];
1200 if (!p) return;
1201 var n = (typeof usd === 'number') ? usd : parseFloat(usd);
1202 if (!isFinite(n) || n < 0) return;
1203 var prev = p.credit || {};
1204 p.credit = {
1205 mode: 'manual',
1206 // The probed figure is dropped: the user has just said what is true, and holding a
1207 // stale automatic number beside it invites the row to show two different balances.
1208 remainingUsd: null,
1209 asOf: null,
1210 baseUsd: n,
1211 baseAt: Date.now(),
1212 };
1213 if (prev.mode === 'auto') p.credit.mode = 'manual';
1214 touch(id);
1215 save();
1216 }
1217
1218 /// What the ledger says has gone on this provider's key since `since`.
1219 ///
1220 /// The ledger is where a turn's cost is ALREADY recorded — one entry per metered turn,
1221 /// carrying the provider it was billed to — so this is not a second set of books, it is the
1222 /// same entries read per key. It is this device's ledger and only this device's: a second
1223 /// device spending the same key is drift, and the next probe replaces the figure outright
1224 /// rather than correcting it, so the drift cannot accumulate.
1225 function spentSince(id, since) {
1226 if (typeof since !== 'number') return 0;
1227 if (!(window.DaimondLedger && typeof DaimondLedger.perProvider === 'function')) return 0;
1228 var spent = 0;
1229 try {
1230 DaimondLedger.perProvider(since).forEach(function (row) {
1231 if (row.provider === id) spent += row.usd || 0;
1232 });
1233 } catch (e) { spent = 0; }
1234 return spent;
1235 }
1236
1237 /// What this provider's key has left, and how that is known.
1238 ///
1239 /// `{ mode: 'auto', usd, probedUsd, asOf, spentUsd }` — the provider said so, less what has
1240 /// been spent on it here since it said it.
1241 /// `{ mode: 'manual', usd, baseUsd, baseAt, spentUsd }` — the user said so, less the
1242 /// ledger's estimate of what has gone since.
1243 /// `null` — nothing is known, and the row must show nothing at all.
1244 function creditFor(id) {
1245 var p = store.providers[id];
1246 var c = p && p.credit;
1247 if (!c) return null;
1248 if (c.mode === 'auto' && typeof c.remainingUsd === 'number' && typeof c.asOf === 'number') {
1249 // Spending is the one movement Daimond can see for itself, so it is applied with no
1250 // request at all: a figure that sat still through a morning's work was telling the
1251 // user something it had every means to know was false. The probed number is kept
1252 // beside it (`probedUsd`) because the sentence has to be able to say which is which.
1253 var gone = spentSince(id, c.asOf);
1254 return {
1255 mode: 'auto',
1256 // Never below zero: no key holds negative money, and an estimate that ran past
1257 // the balance would be asserting something no provider could confirm.
1258 usd: Math.max(0, c.remainingUsd - gone),
1259 probedUsd: c.remainingUsd,
1260 spentUsd: gone,
1261 asOf: c.asOf,
1262 };
1263 }
1264 if (typeof c.baseUsd === 'number' && typeof c.baseAt === 'number') {
1265 var spent = spentSince(id, c.baseAt);
1266 return {
1267 mode: 'manual',
1268 usd: c.baseUsd - spent,
1269 baseUsd: c.baseUsd,
1270 baseAt: c.baseAt,
1271 spentUsd: spent,
1272 };
1273 }
1274 return null;
1275 }
1276
1277 /// A balance as money, from a USD figure rather than the gateway's minor units.
1278 function usd(v) {
1279 if (window.DaimondI18n && typeof DaimondI18n.money === 'function') {
1280 return DaimondI18n.money(v, 'fine');
1281 }
1282 return '$' + (v || 0).toFixed(2);
1283 }
1284
1285 /// Every model Daimond can reach, across every provider with a key.
1286 function all() {
1287 var out = [];
1288 for (var id in store.providers) {
1289 (store.providers[id].models || []).forEach(function (m) {
1290 out.push({ provider: id, name: providerName(id), model: m });
1291 });
1292 }
1293 return out;
1294 }
1295
1296 function count() {
1297 var n = 0;
1298 for (var id in store.providers) n += (store.providers[id].models || []).length;
1299 return n;
1300 }
1301
1302 function providers() {
1303 return Object.keys(store.providers).map(function (id) {
1304 var p = store.providers[id];
1305 var mine = id === CREDITS;
1306 // What is left on the user's OWN key, when anything knows. The credits row's
1307 // balance is minted money and comes from `credits.bal` instead; the two are
1308 // different accounts and are never mixed.
1309 var cr = mine ? null : creditFor(id);
1310 return {
1311 id: id,
1312 name: providerName(id),
1313 url: providerUrl(id),
1314 models: p.models || [],
1315 count: (p.models || []).length,
1316 // When the catalogue was last asked for, so the panel can say. A list has
1317 // no other age: it is not re-asked on a beat the way a balance is, and a
1318 // count with no date behind it cannot tell you it was taken a month ago.
1319 fetched: ms(p.fetched),
1320 hasKey: hasKey(id),
1321 sealed: isSealed(id),
1322 ready: canRun(id),
1323 // Two economies sit in one list. `paid` marks the rows that draw down the balance
1324 // the user is holding with Daimond; every other row is billed by the provider the
1325 // user holds an account with, and cannot touch that balance at all. Which of the
1326 // two a model belongs to is the difference between spending money here and
1327 // spending it elsewhere, so nothing may show a model without showing this.
1328 paid: mine,
1329 minted: mine,
1330 why: mine ? credits.why : '',
1331 via: mine ? credits.via : '',
1332 balance: mine
1333 ? (credits.state === 'ready' ? money(credits.bal, credits.cur) : '')
1334 : (cr ? usd(cr.usd) : ''),
1335 state: mine ? credits.state : '',
1336 // How the balance beside the name is known, so the row can say. Empty when
1337 // there is no balance to explain.
1338 creditMode: cr ? cr.mode : '',
1339 credit: cr,
1340 // Whether this provider will answer the question at all, which decides
1341 // between an "ask again" affordance and a "tell me" one.
1342 canProbeCredit: !mine && canProbeCredit(providerUrl(id)),
1343 };
1344 });
1345 }
1346
1347 /// The bare name a model id ends in, for spotting one model behind two providers.
1348 ///
1349 /// Providers prefix ids differently — `accounts/fireworks/models/deepseek-v3` and
1350 /// `deepseek/deepseek-v3` are one model wearing two names — so only the last segment is
1351 /// compared. It is a shallow test and deliberately so: a false match marks two rows that did
1352 /// not need marking, which costs a few characters, where a missed one leaves the user unable
1353 /// to tell whose money a model spends.
1354 function baseName(m) {
1355 var s = String(m || '');
1356 var cut = s.lastIndexOf('/');
1357 return (cut === -1 ? s : s.slice(cut + 1)).toLowerCase();
1358 }
1359
1360 /// The model names more than one provider serves.
1361 ///
1362 /// Llama, DeepSeek and Qwen are on the credits row AND on half the BYOK providers, so the
1363 /// picker shows the same name twice with different economics behind each. They are NOT
1364 /// deduped: which of the two is picked decides who gets paid, and that is the user's
1365 /// decision to make, not ours to make quietly on their behalf. So both are shown, and both
1366 /// are labelled.
1367 function dupes() {
1368 var seen = {}, dup = {};
1369 for (var id in store.providers) {
1370 var names = {};
1371 (store.providers[id].models || []).forEach(function (m) { names[baseName(m)] = 1; });
1372 for (var n in names) {
1373 if (seen[n]) dup[n] = 1;
1374 seen[n] = 1;
1375 }
1376 }
1377 return dup;
1378 }
1379
1380 // ── The default, and resolving a chat's model ───────────────────
1381
1382 function getDefault() {
1383 return { provider: store.def.provider || '', model: store.def.model || '' };
1384 }
1385 function setDefault(provider, model) {
1386 store.def = { provider: provider, model: model };
1387 store.defAt = Date.now(); // which device chose last, for the merge
1388 save();
1389 }
1390
1391 // ── The drafting model ──────────────────────────────────────────
1392 // The note→proposal drafting in triage.js runs on the CHAT model unless a model
1393 // is chosen HERE. This is the one global task routed to a model of its own, the
1394 // way a Diamond's vision and worker already are per Diamond. UNSET means "same as
1395 // chat", so the whole feature is exactly as it was until somebody chooses.
1396
1397 function getDraft() {
1398 var dr = store.draft || {};
1399 return { provider: dr.provider || '', model: dr.model || '' };
1400 }
1401 /// Empty model clears the setting: drafting falls back to the chat default. The
1402 /// provider is dropped with it, since a provider with no model names nothing.
1403 function setDraft(provider, model) {
1404 store.draft = { provider: model ? (provider || '') : '', model: model || '' };
1405 store.draftAt = Date.now(); // which device chose last, for the merge
1406 save();
1407 }
1408
1409 /// What the note→proposal drafting runs on: the drafting model when one is set,
1410 /// the chat model when it is not. The SAME shape `resolve` answers, so a caller
1411 /// cannot tell a defaulted run from a chosen one and the cost line prices
1412 /// whichever it actually is.
1413 function resolveDraft() {
1414 var dr = getDraft();
1415 if (dr.model) return resolve(dr.provider, dr.model);
1416 return resolve('', '');
1417 }
1418
1419 /// What a chat needs to actually run: the endpoint, the key and the model.
1420 ///
1421 /// A chat records the provider it was started on, so switching the default later does not
1422 /// silently move a running conversation to another model. A chat from before providers
1423 /// existed carries only a model id, and falls back to the default provider.
1424 function resolve(provider, model) {
1425 var d = getDefault();
1426 var id = provider || d.provider;
1427 var m = model || (provider ? '' : d.model);
1428 if (!id || !store.providers[id]) return null;
1429 var key = keyFor(id);
1430 if (!key || !m) return null;
1431 return { provider: id, baseUrl: providerUrl(id), apiKey: key, model: m };
1432 }
1433
1434 /// Whether anything can run at all: one provider, with a readable key, and a default model.
1435 function ready() {
1436 return !!resolve('', '');
1437 }
1438
1439 function addProvider(id, opts) {
1440 opts = opts || {};
1441 store.providers[id] = {
1442 name: opts.name || (KNOWN[id] && KNOWN[id].name) || 'Custom provider',
1443 url: opts.url || (KNOWN[id] && KNOWN[id].url) || '',
1444 key: '',
1445 keyEnc: '',
1446 models: [],
1447 fetched: 0,
1448 touched: stampNow(id),
1449 };
1450 save();
1451 }
1452
1453 /// A stamp for a configuration written NOW, which must also beat any tombstone
1454 /// this id already carries.
1455 ///
1456 /// Removing a provider and adding it straight back is one action to the user
1457 /// and two to the store, and the merge decides on strictly-later: a re-add
1458 /// stamped in the same millisecond as its own deletion would lose to it and
1459 /// vanish again on the next pull. A person cannot type that fast; a script,
1460 /// and a test, can.
1461 function stampNow(id) {
1462 return Math.max(Date.now(), ms(tombs()[id]) + 1);
1463 }
1464
1465 function removeProvider(id) {
1466 delete store.providers[id];
1467 delete plain[id];
1468 delete probes[id]; // no floor to hold back a key that is gone
1469 if (store.def.provider === id) store.def = { provider: '', model: '' };
1470 // A drafting model on the removed provider falls back to the chat model, the
1471 // same way the default does rather than pointing at nothing.
1472 if (store.draft && store.draft.provider === id) store.draft = { provider: '', model: '' };
1473 // Before the store is written, so the very next push carries the deletion:
1474 // there is one way into this function and every delete in the panel comes
1475 // through it, which is what keeps the tombstone from being forgotten at one
1476 // of several call sites.
1477 tombstone(id);
1478 save();
1479 }
1480
1481 /// The providers deleted on purpose, by id, with anything past its TTL pruned.
1482 /// The map, the TTL and the union rule are DaimondCore's — one deletion policy
1483 /// for chats, Diamonds, providers and mailboxes rather than four. A page
1484 /// without the core module cannot sync at all, so an empty map there is the
1485 /// truth rather than a fallback.
1486 function tombs() {
1487 return (window.DaimondCore && DaimondCore.tombs) ? DaimondCore.tombs(TOMBS) : {};
1488 }
1489 function tombstone(id) {
1490 if (window.DaimondCore && DaimondCore.tombstone) DaimondCore.tombstone(TOMBS, id);
1491 }
1492 function mergeTombs(incoming) {
1493 return (window.DaimondCore && DaimondCore.mergeTombs)
1494 ? DaimondCore.mergeTombs(TOMBS, incoming) : tombs();
1495 }
1496
1497 /// Forget every key. The lock does this: a locked Daimond holds no readable key.
1498 ///
1499 /// This is the whole of forgetting the minted key — it was never anywhere else — and it also
1500 /// stands the credits row down, because a balance is nobody's business while the app is
1501 /// locked and a stale one is worse than none.
1502 function lock() {
1503 plain = {};
1504 slots = {}; // the workers' per-slot keys are memory-only too, and go with the rest.
1505 credits.state = '';
1506 credits.bal = 0;
1507 credits.limit = 0;
1508 credits.why = '';
1509 // `mintGen` is NOT reset: it only ever counts up, and a caller holding a key from before
1510 // the lock must still be told its key is old rather than matching a rewound counter.
1511 if (deps && deps.onChange) deps.onChange();
1512 }
1513
1514 // ── Travelling in the sync parcel ───────────────────────────────
1515 // A user who has linked two devices has one account, and an account that knows about six
1516 // providers on one machine and none on the other is an account only by name. So the store
1517 // rides in the parcel beside the chats, the workspace and the Diamonds.
1518 //
1519 // WHAT TRAVELS, and why each thing was decided:
1520 //
1521 // * `keyEnc`, always — and the plaintext `key`, NEVER. The sealed key is the whole reason
1522 // this is safe to do: both devices derive the same wrapping key from the shared salt, so
1523 // the ciphertext opens on both and the gateway in the middle can open neither. A
1524 // plaintext key exists only on the browser-only path where there is no identity to seal
1525 // under, and that path cannot sync at all (sync needs the identity for the parcel), so
1526 // carrying it would be shipping a readable credential purely to satisfy a case that
1527 // cannot arise.
1528 // * The model list and its published rates, stamped with `fetched`. Two devices asked at
1529 // different times, and the later answer is the better one.
1530 // * The row's configuration — name, URL, sealed key — stamped with `touched`.
1531 // * A MANUAL credit base (`baseUsd` + `baseAt`), which is the user telling the app what is
1532 // on a key; that is a fact about the key, not about the device, and belongs on both.
1533 // * NOT the PROBED balance (`remainingUsd`/`asOf`). It is left behind deliberately. It was
1534 // true on the other machine at a moment, and a figure copied here would arrive already
1535 // ageing, with the ledger that is supposed to count it down holding this device's spend
1536 // rather than that one's. A balance nobody can stand behind is worse than none, which is
1537 // the same rule `fetchCredit` follows when a probe fails. This device probes for itself.
1538 // * NOT the `credits` row. Its key is minted per device and never stored; its URL and model
1539 // list are whatever the gateway last minted against. A device that holds the account will
1540 // mint its own on unlock, so carrying the row would only put a keyless one in front of
1541 // somebody a second before their own arrives.
1542 //
1543 // DETERMINISM IS A REQUIREMENT, not a nicety. sync.js skips a push when the parcel
1544 // stringifies to what it last sent, so anything whose serialisation depends on enumeration
1545 // order makes the app push for ever. Provider ids are sorted, model lists are sorted, rate
1546 // tables are rebuilt with sorted keys, and every row is assembled in a fixed field order.
1547
1548 /// A millisecond stamp, or 0 when there is none to be had.
1549 ///
1550 /// NOT `n | 0`. A bitwise operator coerces to a 32-bit int, and an epoch-ms value is far
1551 /// past that: `1785419676021 | 0` is -1286719115. Every comparison in the merge below is
1552 /// against another stamp, so the truncation is not merely wrong, it is inconsistently wrong
1553 /// — a fresher stamp can truncate to a smaller number than an older one, and the freshest
1554 /// side then loses. This cost the models merge two of its own tests before it was found.
1555 function ms(v) {
1556 return (typeof v === 'number' && isFinite(v) && v > 0) ? Math.floor(v) : 0;
1557 }
1558
1559 /// One provider's published rates, rebuilt with sorted keys, or null when there are none.
1560 function sortedRates(rates) {
1561 if (!rates || typeof rates !== 'object') return null;
1562 var out = {}, n = 0;
1563 Object.keys(rates).sort().forEach(function (mid) {
1564 var r = rates[mid];
1565 if (!r || typeof r.in !== 'number' || typeof r.out !== 'number') return;
1566 var row = { in: r.in, out: r.out };
1567 if (typeof r.cached === 'number') row.cached = r.cached;
1568 if (typeof r.ctx === 'number') row.ctx = r.ctx;
1569 out[mid] = row;
1570 n++;
1571 });
1572 return n ? out : null;
1573 }
1574
1575 /// The tombstone map, rebuilt with sorted keys for the same reason every other
1576 /// map here is: enumeration order must never reach the wire.
1577 function sortedTombs() {
1578 var t = tombs(), out = {};
1579 Object.keys(t).sort().forEach(function (id) { out[id] = ms(t[id]); });
1580 return out;
1581 }
1582
1583 /// The store as it should travel: JSON-safe, deterministic, and holding no readable key.
1584 function exportSync() {
1585 var out = {
1586 v: 2,
1587 def: { provider: store.def.provider || '', model: store.def.model || '' },
1588 defAt: ms(store.defAt),
1589 draft: { provider: (store.draft && store.draft.provider) || '',
1590 model: (store.draft && store.draft.model) || '' },
1591 draftAt: ms(store.draftAt),
1592 providers: {},
1593 // What was deleted here, so the other device deletes it too rather than
1594 // handing it back on the next pull.
1595 tombs: sortedTombs(),
1596 };
1597 Object.keys(store.providers).sort().forEach(function (id) {
1598 if (id === CREDITS) return; // minted per device; see above
1599 var p = store.providers[id] || {};
1600 var row = {
1601 name: String(p.name || ''),
1602 url: String(p.url || ''),
1603 models: (Array.isArray(p.models) ? p.models.slice() : []).sort(),
1604 fetched: ms(p.fetched),
1605 touched: ms(p.touched),
1606 };
1607 if (p.keyEnc) row.keyEnc = p.keyEnc; // sealed only, and only when there is one
1608 var rates = sortedRates(p.rates);
1609 if (rates) row.rates = rates;
1610 if (p.credit && typeof p.credit.baseUsd === 'number' && typeof p.credit.baseAt === 'number') {
1611 row.credit = { baseUsd: p.credit.baseUsd, baseAt: p.credit.baseAt };
1612 }
1613 out.providers[id] = row;
1614 });
1615 return out;
1616 }
1617
1618 /// Merge another device's store into this one.
1619 ///
1620 /// A union, never a replacement: a provider only this device has is untouched, and a
1621 /// provider only the other device has arrives whole. Where both have one, the freshest side
1622 /// wins per FACT rather than per row — the later `touched` decides the configuration, the
1623 /// later `fetched` decides the model list — so a device that merely re-asked a provider for
1624 /// its catalogue does not thereby win an argument about the key.
1625 ///
1626 /// A DELETION does travel, and it travels as a tombstone. An absence still means "that
1627 /// device never had it"; a tombstone means "it is gone", and the two are decided on the
1628 /// stamp — a provider whose `touched` is later than the tombstone is a deliberate re-add
1629 /// after the deletion and survives, one whose stamp is older is the deleted row coming
1630 /// round again and is dropped on both sides.
1631 ///
1632 /// One thing is deliberately left alone: the in-memory plaintext cache is never
1633 /// overwritten — a device mid-turn goes on running with the key it holds, and an adopted
1634 /// key is read at the next unlock. A gap in the cache IS filled, since a key that arrives
1635 /// and cannot be used until a reload is a key the user will assume did not arrive.
1636 ///
1637 /// A parcel with no `models` section (a v1 or early-v2 device) is a no-op, so an old device
1638 /// and a new one sync happily in both directions.
1639 async function applySync(remote) {
1640 if (!remote || typeof remote !== 'object' || !remote.providers
1641 || typeof remote.providers !== 'object') return { added: 0, updated: 0 };
1642 var added = 0, updated = 0, adopt = [];
1643 // The tombstones first, unioned both ways: this device learns what the other
1644 // deleted, and keeps its own so the next push still carries them.
1645 var dead = mergeTombs(remote.tombs);
1646 Object.keys(dead).forEach(function (id) {
1647 if (id === CREDITS) return; // not the user's to delete
1648 var p = store.providers[id];
1649 if (!p) return;
1650 if (ms(p.touched) > ms(dead[id])) return; // re-added here since: the re-add wins
1651 delete store.providers[id];
1652 delete plain[id];
1653 if (store.def.provider === id) store.def = { provider: '', model: '' };
1654 if (store.draft && store.draft.provider === id) store.draft = { provider: '', model: '' };
1655 updated++;
1656 });
1657 Object.keys(remote.providers).sort().forEach(function (id) {
1658 if (id === CREDITS) return; // never carried, never adopted
1659 var r = remote.providers[id];
1660 if (!r || typeof r !== 'object') return;
1661 // A row the other device still holds but this one has buried: it comes
1662 // back only if it was re-added after the deletion.
1663 if (dead[id] && !(ms(r.touched) > ms(dead[id]))) return;
1664 var models = (Array.isArray(r.models) ? r.models.slice() : []).sort();
1665 var rates = sortedRates(r.rates);
1666 var fetched = ms(r.fetched);
1667 var stamp = ms(r.touched);
1668 var mine = store.providers[id];
1669 if (!mine) {
1670 mine = store.providers[id] = {
1671 name: String(r.name || (KNOWN[id] && KNOWN[id].name) || 'Custom provider'),
1672 url: String(r.url || (KNOWN[id] && KNOWN[id].url) || ''),
1673 key: '',
1674 keyEnc: r.keyEnc || '',
1675 models: models,
1676 fetched: fetched,
1677 touched: stamp,
1678 };
1679 if (rates) mine.rates = rates;
1680 added++;
1681 if (mine.keyEnc) adopt.push(id);
1682 } else {
1683 if (stamp > ms(mine.touched)) {
1684 mine.name = String(r.name || mine.name || '');
1685 mine.url = String(r.url || mine.url || '');
1686 // An empty `keyEnc` on the other side is not an instruction to forget this
1687 // device's key: it means that device never had one.
1688 if (r.keyEnc && r.keyEnc !== mine.keyEnc) {
1689 mine.keyEnc = r.keyEnc;
1690 mine.key = '';
1691 adopt.push(id);
1692 }
1693 mine.touched = stamp;
1694 updated++;
1695 }
1696 if (fetched > ms(mine.fetched)) {
1697 mine.models = models;
1698 if (rates) mine.rates = rates;
1699 mine.fetched = fetched;
1700 updated++;
1701 }
1702 }
1703 // The manual base carries its own stamp, so it is merged on that and on nothing
1704 // else: a user typing "$20 is on this key" on their laptop said something true
1705 // about the key, whichever device happens to have been configured more recently.
1706 if (r.credit && typeof r.credit.baseUsd === 'number' && typeof r.credit.baseAt === 'number') {
1707 var c = mine.credit || {};
1708 if (!(typeof c.baseAt === 'number') || r.credit.baseAt > c.baseAt) {
1709 mine.credit = {
1710 mode: c.mode === 'auto' ? 'auto' : 'manual',
1711 remainingUsd: (typeof c.remainingUsd === 'number') ? c.remainingUsd : null,
1712 asOf: (typeof c.asOf === 'number') ? c.asOf : null,
1713 baseUsd: r.credit.baseUsd,
1714 baseAt: r.credit.baseAt,
1715 };
1716 updated++;
1717 }
1718 }
1719 });
1720 // The default follows the freshest side — but only to a provider that exists here after
1721 // the merge. A default pointing at nothing is worse than an older default that works,
1722 // and the stamp is NOT advanced when the choice is refused, so the device that does hold
1723 // that provider can still win with it later.
1724 var rAt = ms(remote.defAt);
1725 if (rAt > ms(store.defAt) && remote.def && remote.def.provider
1726 && store.providers[remote.def.provider]) {
1727 store.def = { provider: remote.def.provider, model: remote.def.model || '' };
1728 store.defAt = rAt;
1729 updated++;
1730 }
1731 // The drafting model travels on the same rule as the default, with one added
1732 // case: an UNSET draft (empty model, empty provider) is a real choice — "use
1733 // the chat model" — and adopts freely, since it points at no provider to be
1734 // missing after the merge.
1735 var drAt = ms(remote.draftAt);
1736 if (drAt > ms(store.draftAt) && remote.draft
1737 && (!remote.draft.provider || store.providers[remote.draft.provider])) {
1738 store.draft = { provider: remote.draft.provider || '', model: remote.draft.model || '' };
1739 store.draftAt = drAt;
1740 updated++;
1741 }
1742 // Fill the gaps in the plaintext cache, never overwrite it.
1743 if (window.DaimondIdentity && DaimondIdentity.isUnlocked()) {
1744 for (var i = 0; i < adopt.length; i++) {
1745 var pid = adopt[i];
1746 if (plain[pid]) continue; // this session's key stays this session's
1747 try { plain[pid] = await DaimondIdentity.unwrap(store.providers[pid].keyEnc); }
1748 catch (e) { /* sealed under something this device cannot open; leave it keyless */ }
1749 }
1750 }
1751 if (added || updated) {
1752 save();
1753 if (document.getElementById('models-list')) render();
1754 }
1755 return { added: added, updated: updated };
1756 }
1757
1758 // ── The panel ───────────────────────────────────────────────────
1759
1760 function esc(s) {
1761 return String(s == null ? '' : s).replace(/[&<>"']/g, function (c) {
1762 return { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c];
1763 });
1764 }
1765 function html(s) {
1766 var d = document.createElement('div');
1767 d.innerHTML = s;
1768 return d.firstElementChild || d;
1769 }
1770
1771 /// Whether an element is actually being shown. The panel stays mounted whether or not it is
1772 /// open, so its mere existence says nothing about whether anybody is looking at it.
1773 function onScreen(el) {
1774 return !!(el && (el.offsetParent || el.getClientRects().length));
1775 }
1776
1777 var open = {}; // provider id -> is its model list expanded
1778
1779 /// What a row says about its key, in the row's own terms.
1780 ///
1781 /// A credits row is never "sealed" and never has a key the user could add, so the three
1782 /// words the other rows use are all wrong for it. It answers a different question anyway —
1783 /// not "is there a key" but "is there money" — so it answers that one, and the balance is
1784 /// the answer when there is one.
1785 function keyLabel(p) {
1786 if (!p.minted) {
1787 return p.sealed ? '🔒 ' + t('models.sealed')
1788 : p.hasKey ? '🔑 ' + t('models.key_set') : '⚠ ' + t('models.no_key');
1789 }
1790 switch (p.state) {
1791 case 'ready': return ''; // the balance says it better
1792 case 'minting': return '✦ ' + t('models.connecting');
1793 case 'nocredits': return '⚠ ' + t('models.no_credits');
1794 case 'offline': return '⚠ ' + t('models.offline');
1795 case 'failed': return '⚠ ' + t('models.could_not_connect');
1796 default: return '🔒 ' + t('models.unlock_to_use');
1797 }
1798 }
1799
1800 /// The credit block inside an expanded provider: what is left, how that is known, and the
1801 /// one affordance for changing the answer.
1802 ///
1803 /// Three states, and each says which it is. An automatic figure names the provider as its
1804 /// source and when it was asked. A manual figure is the user's own number counted down by
1805 /// the ledger's ESTIMATE of what has been spent since, and says so in those words — it is
1806 /// not a balance and must not read like one. Nothing known shows the invitation to say.
1807 function creditBlock(p) {
1808 var wrap = document.createElement('div');
1809 wrap.className = 'models-credit';
1810 wrap.dataset.prov = p.id;
1811 var c = p.credit;
1812
1813 wrap.appendChild(html('<div class="models-credit-line"></div>'));
1814 wrap.appendChild(html('<div class="models-credit-age"></div>'));
1815 paintCredit(wrap);
1816
1817 // Ask the provider, where it will answer.
1818 if (p.canProbeCredit) {
1819 var ask = document.createElement('button');
1820 ask.className = 'models-refetch';
1821 ask.textContent = t(c && c.mode === 'auto' ? 'models.credit_recheck' : 'models.credit_check');
1822 ask.addEventListener('click', async function () {
1823 ask.disabled = true;
1824 ask.textContent = t('models.asking');
1825 note(''); // this ask answers for itself
1826 var got = null;
1827 try { got = await fetchCredit(p.id); } catch (e) { got = null; }
1828 if (!got) note(t('models.credit_probe_failed', { provider: p.name }));
1829 render();
1830 });
1831 wrap.appendChild(ask);
1832 }
1833
1834 // And the user's own figure, always available: it is the only thing that works for a
1835 // provider that will not answer, and the thing to fall back on when a probe fails.
1836 // A div and a button rather than a form: nothing here needs submit semantics, and a
1837 // form inside a settings panel is one stray Enter away from navigating the page.
1838 var row = document.createElement('div');
1839 row.className = 'models-credit-form';
1840 var input = document.createElement('input');
1841 input.type = 'text';
1842 input.className = 'models-credit-input';
1843 input.inputMode = 'decimal';
1844 input.placeholder = t('models.credit_base_ph');
1845 input.setAttribute('aria-label', t('models.credit_base_label'));
1846 var set = document.createElement('button');
1847 set.type = 'button';
1848 set.className = 'models-refetch';
1849 set.textContent = t(c && c.mode === 'manual' ? 'models.credit_base_update' : 'models.credit_base_set');
1850 var commit = function () {
1851 var v = parseFloat(String(input.value || '').replace(/[^0-9.]/g, ''));
1852 // The refusal, said out loud. This used to be a bare early return: the field simply
1853 // did not take, with no message, and the user was left to guess what was wrong with
1854 // what they had typed. `note()` survives the `render()` below, so the success path
1855 // clears it rather than relying on the redraw to do it.
1856 if (!isFinite(v) || v < 0) { note(t('models.credit_base_bad')); return; }
1857 note('');
1858 setCreditBase(p.id, v);
1859 render();
1860 };
1861 set.addEventListener('click', commit);
1862 input.addEventListener('keydown', function (ev) {
1863 if (ev.key === 'Enter') { ev.preventDefault(); commit(); }
1864 });
1865 row.appendChild(input);
1866 row.appendChild(set);
1867 wrap.appendChild(row);
1868 return wrap;
1869 }
1870
1871 /// The sentence that says what is left and how it is known.
1872 ///
1873 /// An automatic figure that has been walked down by this device's own spending is no longer
1874 /// the number the provider said, so it does not go on claiming to be: it names the probed
1875 /// figure, the moment it was read, and the spending applied since, in the same shape the
1876 /// manual sentence has always used.
1877 function creditSentence(c) {
1878 if (c && c.mode === 'auto') {
1879 if (c.spentUsd > 0) {
1880 return t('models.credit_auto_spent', {
1881 amount: usd(c.usd),
1882 base: usd(c.probedUsd),
1883 spent: usd(c.spentUsd),
1884 when: whenShort(c.asOf),
1885 });
1886 }
1887 return t('models.credit_auto', { amount: usd(c.usd), when: whenShort(c.asOf) });
1888 }
1889 if (c && c.mode === 'manual') {
1890 return t('models.credit_manual', {
1891 amount: usd(c.usd),
1892 base: usd(c.baseUsd),
1893 spent: usd(c.spentUsd),
1894 when: whenShort(c.baseAt),
1895 });
1896 }
1897 return t('models.credit_unknown');
1898 }
1899
1900 /// How old the reading is, and whether the last attempt to renew it answered.
1901 ///
1902 /// This is the line that makes a failed probe VISIBLE. A probe that fails writes nothing and
1903 /// keeps the old number, which is right — no balance beats a wrong one — but silence and
1904 /// freshness look identical on screen. An age that goes on climbing is the difference, and
1905 /// it is why the operator console stamps every reading it shows with the instant it was
1906 /// read. Empty when there is nothing that was read at a moment: a figure the user typed
1907 /// carries its own date in the sentence above.
1908 function ageSentence(id, c) {
1909 var out = [];
1910 if (c && c.mode === 'auto' && typeof c.asOf === 'number') out.push(agoWords(c.asOf));
1911 var st = probes[id];
1912 if (st && st.ok === false) out.push(t('models.age_failed'));
1913 return out.join(' ');
1914 }
1915
1916 /// Whether the figure is old enough to be said so in the loud colour.
1917 function creditStale(id, c) {
1918 var st = probes[id];
1919 if (st && st.ok === false) return true;
1920 if (!c || c.mode !== 'auto' || typeof c.asOf !== 'number') return false;
1921 return (Date.now() - c.asOf) > CREDIT_STALE_MS;
1922 }
1923
1924 /// How long ago, in the coarsest unit that still says something.
1925 ///
1926 /// Rounded rather than truncated, and "just now" holds for a minute and a half: the point of
1927 /// this line is whether the number is minutes or hours old, and a reader who has to work out
1928 /// which from a timestamp is being asked to do the app's job.
1929 function agoWords(ts) {
1930 var secs = Math.max(0, Date.now() - ts) / 1000;
1931 if (secs < 90) return t('models.age_now');
1932 var mins = Math.round(secs / 60);
1933 if (mins < 60) return tn('models.age_mins', mins);
1934 var hrs = Math.round(mins / 60);
1935 if (hrs < 24) return tn('models.age_hours', hrs);
1936 return tn('models.age_days', Math.round(hrs / 24));
1937 }
1938
1939 /// Write both sentences into a credit block that is already on screen.
1940 function paintCredit(wrap) {
1941 var id = wrap.dataset.prov;
1942 var c = creditFor(id);
1943 var line = wrap.querySelector('.models-credit-line');
1944 var age = wrap.querySelector('.models-credit-age');
1945 if (line) line.textContent = creditSentence(c);
1946 if (!age) return;
1947 var words = ageSentence(id, c);
1948 age.textContent = words;
1949 age.style.display = words ? '' : 'none';
1950 age.classList.toggle('stale', creditStale(id, c));
1951 }
1952
1953 /// Move every visible age on, and with it every figure the ledger has walked down.
1954 ///
1955 /// The closed row's mark is refreshed with the open row's sentence, because the two say the
1956 /// same thing to different readers and a mark that only moved when the panel was redrawn
1957 /// would be a staleness warning that had itself gone stale.
1958 ///
1959 /// Text and attributes only, never a `render()`: redrawing the panel would wipe whatever the
1960 /// user has half-typed into the "I have this much" field, and a clock is not a good enough
1961 /// reason to take somebody's typing away from them.
1962 function ageLines() {
1963 // Nothing is being read, so nothing needs moving on: the panel is redrawn from scratch
1964 // when it is next opened, which is sooner than anybody could notice.
1965 if (!onScreen(document.getElementById('models-list'))) return;
1966 var rows = document.querySelectorAll('.models-prov[data-prov]');
1967 for (var i = 0; i < rows.length; i++) {
1968 var id = rows[i].dataset.prov;
1969 var blk = rows[i].querySelector('.models-credit');
1970 if (blk) paintCredit(blk);
1971 var bal = rows[i].querySelector('.models-bal');
1972 if (bal) paintBal(bal, id, creditFor(id));
1973 }
1974 }
1975
1976 /// The figure and its age on the closed row, which is all a passer-by sees.
1977 ///
1978 /// The AMOUNT is rewritten here too, not only the mark. It is drawn from the same
1979 /// `creditFor` as the sentence inside the row, so leaving it to the next `render()` put two
1980 /// different balances on screen at once — the head still saying what the provider said while
1981 /// the block below it had already counted the morning's turns off. The minted credits row is
1982 /// left alone: its balance is the gateway's, not a probe's, and it has no age to carry.
1983 function paintBal(bal, id, c) {
1984 if (id === CREDITS) return;
1985 if (c) bal.textContent = t('models.balance_left', { amount: usd(c.usd) });
1986 var words = ageSentence(id, c);
1987 if (words) bal.setAttribute('title', words); else bal.removeAttribute('title');
1988 if (words && creditStale(id, c)) bal.setAttribute('data-stale', '1');
1989 else bal.removeAttribute('data-stale');
1990 }
1991
1992 /// A short local date and time for a figure that was true at a moment.
1993 function whenShort(ts) {
1994 try {
1995 var d = new Date(ts);
1996 return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' })
1997 + ' ' + d.toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit' });
1998 } catch (e) { return ''; }
1999 }
2000
2001 /// Draw the providers, each one expandable to the models it can run.
2002 ///
2003 /// The default is a star ON the model, not a separate dropdown somewhere else: the thing a
2004 /// new chat starts on is a model belonging to a provider, and showing it anywhere other
2005 /// than beside that model invites the two to disagree.
2006 ///
2007 /// The credits row is named for what the user bought — their credits — and not for the
2008 /// company that ends up running the request, because "OpenRouter" is not a thing they
2009 /// bought, chose or have an account with. That company is named anyway, quietly, beside it:
2010 /// a user is entitled to know whose machine their words land on, and burying it would be
2011 /// the sort of thing this app exists not to do.
2012 function render() {
2013 var el = document.getElementById('models-list');
2014 if (!el) return;
2015 // Opening this panel is the "I am looking at it now" moment, and the one moment a person
2016 // most expects the figure to be current. Only when the panel is actually on screen: this
2017 // same function redraws for a sync pull and a change of language, and neither is somebody
2018 // looking.
2019 //
2020 // The gate is doing two jobs here and the second is load-bearing. It stops the triggers
2021 // stacking, and it TERMINATES this: a probe that answers redraws the panel, and a redraw
2022 // asks again. Measured with the gate taken out, that loop reached four thousand requests
2023 // in the seconds it took to hide and show the tab five times.
2024 //
2025 // The catalogue joins on exactly those terms, and needed its own gate to do it: an ask
2026 // that answers ends in `save()`, and `save()` is a redraw. Same shape, same trap, and
2027 // see the paragraph above `refreshLists` for what the second gate holds back that the
2028 // first one could not have.
2029 //
2030 // The `else` is not decoration; see `askWhenShown`. The one redraw that matters most —
2031 // the panel being opened — happens while the panel is still hidden, so the test above is
2032 // false at exactly the moment it was written for.
2033 if (onScreen(el)) { refreshCredits(); refreshLists(); }
2034 else askWhenShown();
2035 el.innerHTML = '';
2036
2037 var list = providers();
2038 if (!list.length) {
2039 el.appendChild(html('<div class="models-empty">' + esc(t('models.empty')) + '</div>'));
2040 return;
2041 }
2042
2043 var d = getDefault();
2044 var dup = dupes();
2045 list.forEach(function (p) {
2046 var row = document.createElement('div');
2047 row.className = 'models-prov' + (p.paid ? ' paid' : '');
2048 // Which key this row is about, so the age can be moved on later without redrawing
2049 // the panel out from under whatever the user is typing into it.
2050 row.dataset.prov = p.id;
2051
2052 var head = document.createElement('button');
2053 head.className = 'models-prov-head';
2054 // The name, the balance and the host are three units, each kept whole: the rail is
2055 // narrow enough that all three will not fit on one line, and a line broken through
2056 // "$8.40 left" leaves a number on one row and its meaning on the next.
2057 head.innerHTML =
2058 '<span class="models-caret">' + (open[p.id] ? '▾' : '▸') + '</span>'
2059 + '<span class="models-prov-name">'
2060 + '<span class="models-nm">' + esc(p.name) + '</span>'
2061 + (p.balance ? '<span class="models-bal">'
2062 + esc(t('models.balance_left', { amount: p.balance })) + '</span>' : '')
2063 + (p.via ? '<span class="models-via">'
2064 + esc(t('models.via', { provider: p.via })) + '</span>' : '')
2065 + '</span>'
2066 + '<span class="models-prov-key">' + esc(keyLabel(p)) + '</span>'
2067 + '<span class="models-prov-count">' + esc(tn('models.count', p.count)) + '</span>';
2068 head.title = p.paid
2069 ? t('models.row_paid_help', { provider: p.via || t('models.the_provider') })
2070 : t('models.row_own_help', { provider: p.name });
2071 head.addEventListener('click', function () { open[p.id] = !open[p.id]; render(); });
2072 // How old the figure on the head is, for somebody who has not opened the row. The
2073 // full account lives in the block below; this is the one fact that cannot wait for a
2074 // click, because a number with no age cannot tell you it has stopped moving.
2075 var bal = head.querySelector('.models-bal');
2076 if (bal) paintBal(bal, p.id, p.credit);
2077 row.appendChild(head);
2078
2079 if (open[p.id]) {
2080 var body = document.createElement('div');
2081 body.className = 'models-prov-body';
2082
2083 // The gateway says why in words meant for the user — an operator who has not
2084 // configured a management key gets "bring your own model key to keep working",
2085 // which is better advice than anything this file knows to give. So it is shown,
2086 // rather than flattened into the row's one-word state and thrown away.
2087 if (p.paid && p.why) body.appendChild(html('<div class="models-why">' + esc(p.why) + '</div>'));
2088
2089 // Out of credits is not an error to read, it is a thing to do: the row says so, and
2090 // then offers the doing of it. Nothing else in the panel can be fixed with a button.
2091 if (p.paid && p.state === 'nocredits') {
2092 var top = document.createElement('button');
2093 top.className = 'models-refetch';
2094 top.textContent = t('models.top_up');
2095 top.addEventListener('click', function () { if (deps && deps.onTopUp) deps.onTopUp(); });
2096 body.appendChild(top);
2097 }
2098
2099 // What is left on this key, and where that figure came from. A row that
2100 // shows a balance without saying how it knows is asking to be trusted
2101 // about money; a row that shows nothing when it cannot know is the same
2102 // promise kept the other way.
2103 if (!p.minted && p.hasKey && !p.sealed) body.appendChild(creditBlock(p));
2104
2105 // A catalogue is asked for, never delivered: a provider that answered once
2106 // can answer again, and a model released this morning appears on nobody's
2107 // screen until somebody asks. This used to be drawn only for a provider
2108 // listing NOTHING, so the one user who needed a new model had to call
2109 // `DaimondModels.fetchModels` from the browser console — the app had built
2110 // the route and then shown it to nobody.
2111 //
2112 // The minted row is the exception, and only that one: it re-asks itself
2113 // after every mint, and Top up above is the single deliberate thing to do
2114 // on it. A second button there would offer work that has already happened.
2115 if (!p.minted) {
2116 var refetch = document.createElement('button');
2117 refetch.className = 'models-refetch';
2118 // The LABEL carries the state; the button's existence never does. A
2119 // control that is simply absent is indistinguishable from one that is
2120 // working, which is how the defect above survived — so a row that
2121 // cannot ask yet still draws the button, disabled, saying why.
2122 refetch.textContent = !p.hasKey ? t('models.add_key_first')
2123 : p.count ? t('models.ask_provider_again')
2124 : t('models.ask_provider');
2125 refetch.disabled = !p.ready;
2126 refetch.addEventListener('click', async function () {
2127 refetch.disabled = true;
2128 refetch.textContent = t('models.asking');
2129 note(''); // this ask answers for itself
2130 // The provider's own words. A key that has been revoked, a base URL
2131 // with a typo in it and a rate limit all fail differently, and only
2132 // the provider knows which.
2133 try { await fetchModels(p.id); }
2134 catch (e) { note(e && e.message ? e.message : String(e)); }
2135 render();
2136 });
2137 body.appendChild(refetch);
2138
2139 // When the list was last asked for, under the button that asks again.
2140 // A date and not the `age_*` family: the credit block a few lines above
2141 // already says "Checked twenty minutes ago" about a BALANCE, and two
2142 // sentences of that shape in one row read as one fact repeated rather
2143 // than two facts of different kinds. No stale colour either — a
2144 // catalogue does not move the way a balance does, and a red one here
2145 // would teach the eye to skip the red that means something.
2146 var age = document.createElement('div');
2147 age.className = 'models-list-age';
2148 age.textContent = p.fetched
2149 ? t('models.list_asked', { when: whenShort(p.fetched) })
2150 : t('models.list_never');
2151 body.appendChild(age);
2152 }
2153
2154 p.models.forEach(function (m) {
2155 var isDef = d.provider === p.id && d.model === m;
2156 var twin = !!dup[baseName(m)];
2157 var mr = document.createElement('button');
2158 mr.className = 'models-model' + (isDef ? ' on' : '');
2159 // A model on the credits row is marked wherever it appears, because it is the one
2160 // that moves money the user is holding here. A model with a twin on another row is
2161 // marked too, on both rows: two identical names doing different things to a
2162 // person's wallet is precisely the case a picker must not stay quiet about.
2163 mr.innerHTML = '<span class="models-star">' + (isDef ? '★' : '☆') + '</span>'
2164 + '<span class="models-id">' + esc(m) + '</span>'
2165 + (p.paid ? '<span class="models-econ paid">' + esc(t('models.econ_credits')) + '</span>'
2166 : twin ? '<span class="models-econ">' + esc(t('models.econ_own')) + '</span>' : '')
2167 + (isDef ? '<span class="models-def">' + esc(t('models.is_default')) + '</span>' : '');
2168 mr.title = t(isDef ? 'models.model_is_default' : 'models.model_make_default') + '\n'
2169 + (p.paid ? t('models.model_paid', { provider: p.via || t('models.the_provider') })
2170 : t('models.model_own', { provider: p.name }))
2171 + (twin ? '\n' + t('models.model_twin', { provider: p.name }) : '');
2172 mr.addEventListener('click', function () { setDefault(p.id, m); render(); });
2173 // A model id is a string people paste -- into a config, into a support
2174 // message, into another provider's console -- and clicking the row here
2175 // picks a default rather than selecting the text. The copy sits OUTSIDE
2176 // the row, because the row is a button and a button cannot hold one.
2177 // Named by the id itself: a long catalogue of buttons all called "Copy
2178 // model id" tells a listener nothing about which model each one is.
2179 var cb = copyBtn(tOr('copy.what_model', 'model id {id}', { id: m }), m);
2180 if (cb) {
2181 var mrow = document.createElement('div');
2182 mrow.className = 'models-modelrow';
2183 mrow.appendChild(mr);
2184 mrow.appendChild(cb);
2185 body.appendChild(mrow);
2186 } else {
2187 body.appendChild(mr);
2188 }
2189 });
2190
2191 // The credits row is not the user's to remove. It is their balance: taking it out of
2192 // the panel would neither refund it nor stop it existing, and the next mint would put
2193 // it straight back. Spending it to zero is the only thing that stands it down.
2194 if (!p.minted) {
2195 var rm = document.createElement('button');
2196 rm.className = 'models-remove';
2197 rm.textContent = t('models.remove', { provider: p.name });
2198 rm.addEventListener('click', function () {
2199 removeProvider(p.id);
2200 render();
2201 });
2202 body.appendChild(rm);
2203 }
2204 row.appendChild(body);
2205 }
2206 el.appendChild(row);
2207 });
2208
2209 var foot = document.createElement('div');
2210 foot.className = 'models-default';
2211 foot.textContent = d.provider && d.model
2212 ? t('models.starts_on', { model: providerName(d.provider) + ' · ' + d.model })
2213 : t('models.no_default');
2214 // The one model id worth copying without opening a provider first. It copies
2215 // the BARE id, not the sentence around it: the sentence names the provider
2216 // for a reader, and nothing takes it as input.
2217 if (d.provider && d.model) {
2218 var fc = copyBtn(tOr('copy.what_default_model', 'the default model id'), d.model);
2219 if (fc) foot.appendChild(fc);
2220 }
2221 el.appendChild(foot);
2222
2223 // The drafting model, once there is at least one model to draft with. Below
2224 // the default because it is a refinement of it: unset, it IS the default.
2225 if (list.some(function (p) { return p.count > 0; })) el.appendChild(draftFoot(d));
2226 }
2227
2228 /// The drafting-model row under the default: the model triage.js uses to turn
2229 /// notes into proposals. One `<select>` whose first option is "same as chat" —
2230 /// picking it clears the setting, so the row can always be put back to the
2231 /// zero-config state it starts in — and everything below it is the ordinary model
2232 /// list, so a drafting model is chosen exactly the way a chat's is.
2233 function draftFoot(d) {
2234 var box = document.createElement('div');
2235 box.className = 'models-draft';
2236 var dr = getDraft();
2237
2238 var lab = document.createElement('label');
2239 lab.className = 'models-draft-lab';
2240 lab.textContent = t('models.drafting_label');
2241 lab.title = t('models.drafting_help');
2242
2243 var sel = document.createElement('select');
2244 sel.className = 'models-draft-sel';
2245 lab.appendChild(sel);
2246 fillSelect(sel, dr.provider, dr.model);
2247 // The sentinel first, and selected when nothing is set, so drafting reads as
2248 // what it is -- the chat model, named rather than left blank.
2249 var same = document.createElement('option');
2250 same.value = '';
2251 same.textContent = (d.provider && d.model)
2252 ? t('models.drafting_same_on', { model: d.model })
2253 : t('models.drafting_same');
2254 sel.insertBefore(same, sel.firstChild);
2255 if (!dr.model) same.selected = true;
2256
2257 // A sentinel value of '' clears the setting through `setDraft`, so choosing it
2258 // is the one gesture that puts drafting back on the chat model.
2259 sel.addEventListener('change', function () {
2260 var g = pick(sel);
2261 setDraft(g.provider, g.model);
2262 render();
2263 });
2264 box.appendChild(lab);
2265 return box;
2266 }
2267
2268 /// The panel's one message line.
2269 ///
2270 /// THE TRAP: this lookup was the ONLY line in the whole tree that mentioned `models-note`. No
2271 /// markup, no JS that built one, not even a CSS rule -- so it returned null every time and
2272 /// `if (n)` read like a correct guard. A user could type `abc` into the credit field, press
2273 /// Set, and get an early return with nothing on screen at all. An element that does not exist
2274 /// reports itself to a browser automation locator as HIDDEN, which is indistinguishable from a
2275 /// guard doing its job, so no check that asserted "no error is shown" would have caught it.
2276 /// Every check over this line asserts the element EXISTS and says what it holds.
2277 ///
2278 /// The element lives in `index.html` beside `#models-list` and NOT inside it, because
2279 /// `render()` rewrites that list wholesale: a message written just before a redraw would go
2280 /// with it. The price of that is that it does not expire by itself -- each action that can
2281 /// produce a message clears it first, rather than `render()` clearing it, so that a background
2282 /// redraw (a sync pull, a change of language) cannot take a refusal off the screen unasked.
2283 function note(msg) {
2284 var n = document.getElementById('models-note');
2285 if (!n) return;
2286 n.textContent = msg || '';
2287 }
2288
2289 // ── The picker ──────────────────────────────────────────────────
2290
2291 /// Fill a `<select>` with every model, grouped under the provider that runs it.
2292 ///
2293 /// The provider is carried on the option (`dataset.provider`) rather than baked into the
2294 /// value: two providers can serve a model of the same name -- llama-3.3-70b is on four of
2295 /// them -- so a value alone does not say which key to use. `pick()` reads both back.
2296 ///
2297 /// A provider whose key cannot be read is shown, and its models are disabled. Hiding it would
2298 /// leave a user who has locked the app wondering where their models went; saying "sealed"
2299 /// tells them the answer is to unlock.
2300 ///
2301 /// Two economies share this list, and that is the thing it has to get right. Most rows spend
2302 /// money the user holds with somebody else; the credits row spends money they handed to
2303 /// Daimond, and drawing that down is a surprise if it happens to someone who was only
2304 /// curious what Claude would say. So the group says which it is and the option says it
2305 /// again — the group heading is gone the moment the pulldown is closed, and by then the
2306 /// choice is made.
2307 // ── Favourites ──────────────────────────────────────────────
2308 //
2309 // A working setup reaches a dozen models across four providers, and the two or
2310 // three a person actually works with are scattered through the list in provider
2311 // order. The pulldown is a native `<select>`, so finding one means scrolling a
2312 // list whose order is about where a model is BILLED rather than about how often
2313 // it is wanted.
2314 //
2315 // So the most-used float to the top, in a group of their own. Nothing is starred
2316 // by hand: what a person uses is already the answer, and a second kind of star
2317 // beside the default's would make both mean less.
2318 //
2319 // USE, not selection. A model chosen in a pulldown and never run is not a model
2320 // anybody uses, so the count is incremented where a turn actually commits to
2321 // one — a chat freezing its model, a Diamond's daimon, a dispatched worker.
2322 //
2323 // NOT carried in the sync parcel, deliberately. Merging two devices' counters is
2324 // either wrong (summing double-counts on every round trip) or pointless (taking
2325 // the larger throws one device's history away), and the list earns itself again
2326 // on a new device within a few turns. If it ever travels, it should travel as
2327 // the ordered KEYS with one stamp, not as the counters.
2328
2329 var USE_KEY = 'daimond-model-use'; // per account; accounts.js namespaces daimond-*
2330
2331 /// How many float to the top. Five is about a screen's worth on a phone, and
2332 /// small enough that the group stays a shortlist and not a second copy of the list.
2333 var FAV_MAX = 5;
2334 /// Below this there is nothing to scroll, so the group would be clutter.
2335 var FAV_MIN_MODELS = 8;
2336 /// And a shortlist of one is not a shortlist.
2337 var FAV_MIN = 2;
2338 /// The most entries kept; beyond it the least recently used are dropped, so a
2339 /// long-lived account cannot grow this without bound.
2340 var USE_MAX = 60;
2341
2342 function useKey(provider, model) { return (provider || '') + ' ' + (model || ''); }
2343
2344 function readUse() {
2345 try {
2346 var o = JSON.parse(localStorage.getItem(USE_KEY) || 'null');
2347 return (o && typeof o === 'object') ? o : {};
2348 } catch (e) { return {}; }
2349 }
2350
2351 /// Record that a turn is about to run on this model.
2352 function noteUse(provider, model) {
2353 if (!model) return;
2354 var use = readUse();
2355 var k = useKey(provider, model);
2356 var e = use[k] || { n: 0, t: 0 };
2357 use[k] = { n: (e.n || 0) + 1, t: Date.now() };
2358 var keys = Object.keys(use);
2359 if (keys.length > USE_MAX) {
2360 keys.sort(function (a, b) { return (use[a].t || 0) - (use[b].t || 0); });
2361 for (var i = 0; i < keys.length - USE_MAX; i++) delete use[keys[i]];
2362 }
2363 try { localStorage.setItem(USE_KEY, JSON.stringify(use)); } catch (e2) { /* quota */ }
2364 }
2365
2366 /// The favourites, most used first, filtered to models that still exist on a
2367 /// provider that is still listed — a model whose provider was removed must not
2368 /// go on being offered from the top.
2369 function favourites(list) {
2370 var use = readUse();
2371 var live = {};
2372 list.forEach(function (p) {
2373 p.models.forEach(function (m) { live[useKey(p.id, m)] = { p: p, m: m }; });
2374 });
2375 return Object.keys(use)
2376 .filter(function (k) { return live[k]; })
2377 .sort(function (a, b) {
2378 var d = (use[b].n || 0) - (use[a].n || 0);
2379 return d !== 0 ? d : (use[b].t || 0) - (use[a].t || 0);
2380 })
2381 .slice(0, FAV_MAX)
2382 .map(function (k) { return { provider: live[k].p, model: live[k].m }; });
2383 }
2384
2385 function fillSelect(sel, provider, model) {
2386 sel.innerHTML = '';
2387 var list = providers().filter(function (p) { return p.count > 0; });
2388
2389 if (!list.length) {
2390 var o = document.createElement('option');
2391 o.value = '';
2392 o.textContent = t('models.none_yet');
2393 sel.appendChild(o);
2394 sel.disabled = true;
2395 return;
2396 }
2397 sel.disabled = false;
2398
2399 var d = getDefault();
2400 var dup = dupes();
2401
2402 /// One option, wherever it is drawn. The favourites group holds a SECOND
2403 /// element for the same model, and the two must carry the same meaning — a
2404 /// shortcut that read differently from the row it stands for would be worse
2405 /// than no shortcut, because the economy marking is the part that matters.
2406 ///
2407 /// `inFav` adds the provider's name, and that is not an inconsistency but
2408 /// the opposite. A row's full meaning includes the group heading above it;
2409 /// under "Favourites" that heading is gone, so reproducing only the row's
2410 /// own text would LOSE information — and two providers offering the same
2411 /// model under the user's own key would then draw two identical shortcuts.
2412 function optionFor(p, m, inFav) {
2413 var twin = !!dup[baseName(m)];
2414 var o = document.createElement('option');
2415 o.value = m;
2416 o.dataset.provider = p.id;
2417 o.dataset.paid = p.paid ? '1' : '';
2418 o.textContent = m
2419 + (p.paid ? ' · ' + t('models.econ_credits') : twin ? ' · ' + t('models.econ_own') : '')
2420 + (inFav ? ' · ' + p.name : '')
2421 + (d.provider === p.id && d.model === m ? ' ★' : '');
2422 o.title = p.name + ' · ' + m + ' — '
2423 + (p.paid ? t('models.model_paid', { provider: p.via || t('models.the_provider') })
2424 : t('models.model_own', { provider: p.name }));
2425 o.disabled = !p.ready;
2426 return o;
2427 }
2428
2429 // The shortlist first, when there is a list worth shortening. Every model
2430 // here appears again under its own provider: this is a shortcut to a row,
2431 // not a category of its own, and somebody looking for a model by who bills
2432 // for it must still find it where they expect.
2433 var total = list.reduce(function (n, p) { return n + p.models.length; }, 0);
2434 var favs = total >= FAV_MIN_MODELS ? favourites(list) : [];
2435 if (favs.length >= FAV_MIN) {
2436 var fg = document.createElement('optgroup');
2437 fg.label = t('models.favourites');
2438 favs.forEach(function (f) {
2439 var o = optionFor(f.provider, f.model, true);
2440 o.dataset.fav = '1';
2441 fg.appendChild(o);
2442 });
2443 sel.appendChild(fg);
2444 }
2445
2446 list.forEach(function (p) {
2447 var g = document.createElement('optgroup');
2448 // Only the credits group is relabelled. A row that spends the user's own provider
2449 // account is the case this picker has always described, and describing it twice —
2450 // once here and once on every option — would make the marking mean less, not more:
2451 // the mark has to be the exception to read as one.
2452 g.label = p.paid
2453 ? p.name
2454 + (p.balance ? ' · ' + t('models.balance_left', { amount: p.balance }) : '')
2455 + (p.via ? ' — ' + t('models.via', { provider: p.via }) : '')
2456 + (p.ready ? '' : ' (' + t(p.state === 'nocredits'
2457 ? 'models.top_up_to_use' : 'models.connecting') + ')')
2458 : p.name + (p.sealed ? ' (' + t('models.sealed_unlock') + ')'
2459 : p.hasKey ? '' : ' (' + t('models.no_key') + ')');
2460 p.models.forEach(function (m) { g.appendChild(optionFor(p, m)); });
2461 sel.appendChild(g);
2462 });
2463
2464 // Select what was asked for; failing that, the starred default; failing that, the first
2465 // model anything can actually run.
2466 if (!select(sel, provider, model) && !select(sel, d.provider, d.model)) {
2467 var firstUsable = sel.querySelector('option:not([disabled])');
2468 if (firstUsable) firstUsable.selected = true;
2469 }
2470 }
2471
2472 /// Select the option for one provider's model. True when it was there to select.
2473 function select(sel, provider, model) {
2474 if (!model) return false;
2475 var opts = sel.querySelectorAll('option');
2476 for (var i = 0; i < opts.length; i++) {
2477 if (opts[i].value === model && (!provider || opts[i].dataset.provider === provider)) {
2478 opts[i].selected = true;
2479 return true;
2480 }
2481 }
2482 return false;
2483 }
2484
2485 /// What a `<select>` filled by `fillSelect` is currently pointing at.
2486 function pick(sel) {
2487 var o = sel && sel.selectedOptions && sel.selectedOptions[0];
2488 if (!o || !o.value) return { provider: '', model: '' };
2489 return { provider: o.dataset.provider || '', model: o.value };
2490 }
2491
2492 /// The consequence of pointing a daimon (or a chat) at a different model.
2493 ///
2494 /// `before` and `after` are `{ provider, model }`; `used` is the context the
2495 /// conversation already holds, in tokens. `changed` is whether the pick really
2496 /// differs from what runs now — the daimon settings button reads "Change" only
2497 /// when it does. `window` is the new model's context window in tokens, or 0
2498 /// where nobody publishes one, so the meter can be redrawn against it. The
2499 /// switch REUSES the existing conversation: the thread belongs to the record,
2500 /// not to the model, so nothing here ends it. `needsFresh` is the single case
2501 /// it cannot carry over — a published window the held context already exceeds
2502 /// outright — and even then it is the engine's fold on the next turn that acts;
2503 /// this only names the state so a caller need not compute it twice.
2504 function planModelSwitch(before, after, used) {
2505 before = before || {}; after = after || {};
2506 var changed = String(after.model || '') !== String(before.model || '')
2507 || String(after.provider || '') !== String(before.provider || '');
2508 var win = window.DaimondPricing
2509 ? (DaimondPricing.contextWindow(after.model || '', after.provider || '') || 0) : 0;
2510 return {
2511 changed: changed,
2512 window: win,
2513 needsFresh: changed && win > 0 && (used || 0) > win,
2514 };
2515 }
2516
2517 function init(d) {
2518 deps = d || {};
2519 load();
2520 }
2521
2522 // The panel stays mounted, so a language change redraws it where it stands.
2523 if (window.DaimondI18n) {
2524 DaimondI18n.onChange(function () {
2525 if (document.getElementById('models-list')) render();
2526 });
2527 }
2528
2529 // ── The tab coming back, and the beat while it is here ──────────
2530 // The author's own case: money added to the provider's account on another screen, this tab
2531 // left alone for hours, and the old figure still on it when he came back. Nothing in the
2532 // browser can be told about a top-up, so the moment the tab is looked at again is the moment
2533 // to ask. The beat covers the other half of it — a tab that is looked at all day and never
2534 // hidden — and it beats only while the tab is in front.
2535
2536 /// The heartbeat, or null while nothing is watching.
2537 var beat = null;
2538
2539 function beatOn() {
2540 if (beat || typeof setInterval !== 'function') return;
2541 beat = setInterval(function () {
2542 refreshCredits(); // the gate decides whether this becomes a request
2543 ageLines(); // the age moves on whether it did or not
2544 }, PROBE_BEAT_MS);
2545 }
2546
2547 function beatOff() {
2548 if (beat) { clearInterval(beat); beat = null; }
2549 }
2550
2551 if (typeof document !== 'undefined' && document.addEventListener) {
2552 document.addEventListener('visibilitychange', function () {
2553 if (document.visibilityState === 'hidden') { beatOff(); return; }
2554 beatOn();
2555 // Back in front after who knows how long. The gate is what stops a user who flicks
2556 // between two tabs from spending a probe on every flick.
2557 refreshCredits();
2558 ageLines();
2559 });
2560 if (document.visibilityState !== 'hidden') beatOn();
2561 }
2562
2563 // A turn has just finished, so the ledger has just gained what it cost. Repaint, and NOTHING
2564 // else: the figure moves from books this device already keeps, with no request and no floor
2565 // to spend, which is the whole reason spending is treated differently from a top-up. The
2566 // event already exists and already carries this meaning — daimond.js fires it from the one
2567 // place every exit from a turn passes through, and the Daimond balance in daimond.js has
2568 // hung off it for the same reason since before this did.
2569 if (typeof window !== 'undefined' && window.addEventListener) {
2570 window.addEventListener('daimond:idle', function () { ageLines(); });
2571 }
2572
2573 window.DaimondModels = {
2574 render: render,
2575 noteUse: noteUse,
2576 favourites: favourites,
2577 fillSelect: fillSelect,
2578 pick: pick,
2579 // Whether a picked model differs from the one in force, the new model's window,
2580 // and whether the held context cannot fit it at all. Drives the daimon "Change".
2581 planModelSwitch: planModelSwitch,
2582 init: init,
2583 unseal: unseal,
2584 /// Re-wrap every key after a passphrase change. The keys never leave this module.
2585 resealAfterRekey: resealAfterRekey,
2586 lock: lock,
2587 known: function () { return KNOWN; },
2588 // Which wire dialect an endpoint speaks, and the headers it wants. Exported because
2589 // the settings form in daimond.js lists a provider's models with its own fetch, and a
2590 // bearer token is refused by the one provider that is not OpenAI-compatible.
2591 isAnthropic: isAnthropic,
2592 authHeaders: authHeaders,
2593 modelsUrl: modelsUrl,
2594 providers: providers,
2595 addProvider: addProvider,
2596 removeProvider: removeProvider,
2597 setKey: setKey,
2598 keyFor: keyFor,
2599 hasKey: hasKey,
2600 isSealed: isSealed,
2601 fetchModels: fetchModels,
2602 // The live rates a provider published, which `DaimondPricing` asks before its table.
2603 rateFor: rateFor,
2604 // What is left on a provider's key: asked for where it can be, told to us otherwise.
2605 fetchCredit: fetchCredit,
2606 // When each key was last asked, and how that went. A snapshot, so nothing outside this
2607 // file can move the floor the probes are held behind.
2608 creditProbes: function () { return JSON.parse(JSON.stringify(probes)); },
2609 // Ask every provider whose catalogue has gone stale, if the gate allows it. Exported for
2610 // the one caller that is not this file: daimond.js, at the moment the app finishes
2611 // starting, which is the other occasion a list ought to be current.
2612 refreshLists: refreshLists,
2613 // When each catalogue was last asked for, and how that went. A snapshot, like
2614 // `creditProbes`, so nothing outside this file can move the floor the asks are held
2615 // behind by writing to the record that holds them.
2616 listAsks: function () { return JSON.parse(JSON.stringify(lists)); },
2617 setCreditBase: setCreditBase,
2618 creditFor: creditFor,
2619 all: all,
2620 count: count,
2621 getDefault: getDefault,
2622 setDefault: setDefault,
2623 resolve: resolve,
2624 // The drafting model: the note→proposal task's own model, defaulting to chat.
2625 getDraft: getDraft,
2626 setDraft: setDraft,
2627 resolveDraft: resolveDraft,
2628 ready: ready,
2629 providerName: providerName,
2630 // The store as it travels between devices, and the merge on arrival.
2631 exportSync: exportSync,
2632 applySync: applySync,
2633 // Credits: the provider Daimond mints the key for.
2634 CREDITS: CREDITS,
2635 syncCredits: syncCredits,
2636 remint: remint,
2637 creditsGen: creditsGen,
2638 creditsState: creditsState,
2639 // Per-slot worker keys, so parallel workers never share one.
2640 mintSlot: mintSlot,
2641 remintSlot: remintSlot,
2642 slotConfig: slotConfig,
2643 forgetSlot: forgetSlot,
2644 };
2645})();