1// One call to Jev from the page, through this site's relay (RELAY; 2// worker/src/relay.ts), which adds the site's TypeSafe key and forwards it 3// to api.typesafe.ai. TypeSafe's API refuses browser origins (CORS), so a 4// page cannot call it directly, and visitors never need a key of their own. 5// The relay forwards only the question shapes the page asks; widen it there 6// first when a new one is needed. 7export const JEV_MODEL = 'jev-1.13.0'; 8export const RELAY = '/jev/v1/systemone'; 9 10// The page's own timing, for the relay's log (worker/src/relay.ts): how many 11// seconds before its section begins each request went out, and which 12// attempt it was. Headers, not body fields, so the body the relay forwards 13// is exactly what TypeSafe reads; the relay sends TypeSafe none of the 14// page's headers. Listeners heard answers land after their section began 15// while headless runs could not make one, and these say which side is slow. 16export const LEAD_HEADER = 'Jev-Section-In'; 17export const ATTEMPT_HEADER = 'Jev-Attempt'; 18 19// On every answer the relay gives: `hit` when it came from the relay's 20// answer cache (the same request, byte for byte, answered within the hour; 21// TypeSafe not asked), `miss` when TypeSafe answered it 22// (worker/src/answer-cache.ts). The network panel shows it per call. 23export const CACHE_HEADER = 'Jev-Cache'; 24 25// On answers to a signed-in listener (worker/src/relay.ts): their daily 26// Jev budget as `used/limit` on each call it charged, and `spent` on the 27// 429 that refuses one once it is used up (with Retry-After until 00:00 28// UTC, which the back-off below honours, so songs play their fallbacks 29// until then). account.mjs listens, to keep the header's count current. 30export const BUDGET_HEADER = 'Jev-Budget'; 31const budgetListeners = new Set(); 32export function onBudget(listener) { 33 budgetListeners.add(listener); 34 return () => budgetListeners.delete(listener); 35} 36// A budget header from a call made elsewhere (sandbox.mjs, for code the 37// listener's own AI played), told to the same listeners. 38export function noteBudgetHeader(value) { 39 if (value) for (const listener of budgetListeners) listener(value); 40} 41 42// Signing in or out changes whose budget and which rate limit a call is 43// charged to, so a back-off earned by the other one no longer applies. 44export function resetBackoff() { 45 blockedUntil = 0; 46 budgetSpent = false; 47 failures = 0; 48} 49 50// Backing off as TypeSafe's docs ask: after a 429 or 529 (or a 503 that 51// says when to come back), every call on this page waits: for the 52// response's retry-after-ms or Retry-After when it has one (as the SDK 53// honours them), else exponentially from 0.5 s to 5 s with a quarter of 54// jitter (the SDK's defaults). Until then a call fails at once, without a 55// request, so songs play their fallbacks and the mood picker, radio and art 56// check say why. 57let blockedUntil = 0; 58let budgetSpent = false; // the back-off in force is the daily budget's 59let failures = 0; 60const BACKOFF = { initialMs: 500, maxMs: 5000, jitter: 0.25 }; 61 62function waitMs(res) { 63 const ms = Number(res.headers.get('retry-after-ms')); 64 if (Number.isFinite(ms) && ms >= 0 && res.headers.has('retry-after-ms')) return ms; 65 const value = res.headers.get('Retry-After'); 66 if (value !== null) { 67 const seconds = Number(value); 68 if (Number.isFinite(seconds)) return seconds * 1000; 69 const date = Date.parse(value); // an HTTP date 70 if (Number.isFinite(date)) return Math.max(0, date - Date.now()); 71 } 72 const exp = Math.min(BACKOFF.initialMs * 2 ** failures, BACKOFF.maxMs); 73 return exp * (1 - Math.random() * BACKOFF.jitter); 74} 75 76const backsOff = (res) => 77 res.status === 429 || res.status === 529 || (res.status === 503 && res.headers.has('Retry-After')); 78 79// A call that fails in transit (no connection, or a 5xx that is not 80// TypeSafe asking us to back off: the relay restarting, a dropped upstream) 81// is asked again, twice, 1 s then 2 s later. A Jev call has no side effects, 82// so asking twice is safe, and a section is seconds away. 83const RETRY_MS = [1000, 2000]; 84const transient = (res) => res.status >= 500 && !backsOff(res); 85const pause = (ms) => new Promise((r) => setTimeout(r, ms)); 86 87// How a call reaches the relay: the page's fetch, or, inside the sandbox a 88// listener's song plays in (sandbox/player.mjs), a message to the page, 89// which makes the call without the visitor's session (sandbox.mjs). 90let relayFetch = (url, init) => fetch(url, init); 91export function setRelayFetch(f) { 92 relayFetch = f; 93} 94 95// `secondsToSection()` is read as each attempt goes out: a retry is later. 96async function post(body, secondsToSection) { 97 for (let attempt = 0; ; attempt++) { 98 const headers = { 'Content-Type': 'application/json', [ATTEMPT_HEADER]: String(attempt) }; 99 const lead = secondsToSection?.(); 100 if (Number.isFinite(lead)) headers[LEAD_HEADER] = lead.toFixed(2); 101 try { 102 const res = await relayFetch(RELAY, { method: 'POST', headers, body }); 103 if (!transient(res) || attempt >= RETRY_MS.length) return res; 104 } catch (e) { 105 if (attempt >= RETRY_MS.length) throw e; 106 } 107 await pause(RETRY_MS[attempt]); 108 } 109} 110 111// Resolves to the answers, keyed like `questions`. `secondsToSection`, when 112// given, returns how long until the section asked about begins (or null 113// when the song is not playing), for the relay's log. 114export async function askJev(questions, state, { secondsToSection } = {}) { 115 const wait = blockedUntil - Date.now(); 116 if (wait > 0) { 117 if (budgetSpent) throw new Error('your daily Jev budget is spent; it resets at 00:00 UTC'); 118 throw new Error(`backing off; Jev is asked again in ${Math.ceil(wait / 1000)} s`); 119 } 120 const res = await post(JSON.stringify({ model: JEV_MODEL, questions, state }), secondsToSection); 121 const budget = res.headers.get(BUDGET_HEADER); 122 noteBudgetHeader(budget); 123 if (backsOff(res)) { 124 const ms = waitMs(res); 125 failures += 1; 126 blockedUntil = Date.now() + ms; 127 budgetSpent = budget === 'spent'; 128 if (budgetSpent) throw new Error('your daily Jev budget is spent; it resets at 00:00 UTC'); 129 const why = res.status === 429 ? 'rate limited' : 'overloaded'; 130 throw new Error(`${why}; Jev is asked again in ${Math.ceil(ms / 1000)} s`); 131 } 132 failures = 0; 133 if (!res.ok) throw new Error(`relay answered ${res.status}: ${(await res.text()).slice(0, 120)}`); 134 const body = await res.json(); 135 return body.answers ?? {}; 136}