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 { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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 | })(); |