Spaces:
Running
Running
Deploy 4ed0390343adb220188e58f95ab1a8e7e1dd995e (manual: Actions blocked on billing) (part 2)
1b9ed71 verified | /* The Swan engine interface, and the runtime choice between backends. | |
| * | |
| * Event Horizon asks Swan two things: "run this PyRel" and "turn this question | |
| * into PyRel and run it". Where that happens is a deployment detail: | |
| * | |
| * wasm Pyodide + DuckDB + swan.duckdb_extension, in the tab. Swan itself | |
| * loads and runs (verified: tools/verify_pyrel_wasm.mjs), but PyRel | |
| * compilation is Python, so this backend serves SQL only for now. | |
| * Staged by tools/vendor_swan_wasm.mjs. | |
| * server tools/swan_sidecar.py behind server.js's /api proxy, running the | |
| * native extension from the rai-swan wheel. Works today. | |
| * | |
| * Both satisfy the same interface, so nothing above this module knows which one | |
| * answered: | |
| * | |
| * await engine.ready() -> { engine, swan, duckdb, model, nl } | |
| * await engine.schema() -> { schema, model } | |
| * await engine.pyrel(code) -> { code, rows } | |
| * await engine.ask(question) -> { question, code, rows, attempts, examples } | |
| * await engine.chat(q, history) -> the same, with prior turns as context | |
| * | |
| * `auto` prefers wasm and falls back to server, reporting *why* it fell back | |
| * rather than silently degrading — a browser-only Swan and a proxied one have | |
| * very different privacy and latency characteristics, and which one you got is | |
| * something the page shows the user. | |
| */ | |
| import { createServerEngine } from './server_engine.js'; | |
| import { createWasmEngine } from './wasm_engine.js'; | |
| const BACKENDS = { wasm: createWasmEngine, server: createServerEngine }; | |
| /** Engine order for `auto`. wasm first: it needs no server and no proxy hop. */ | |
| const AUTO_ORDER = ['wasm', 'server']; | |
| export function engineModeFromLocation(search = (typeof location !== 'undefined' ? location.search : '')) { | |
| const mode = new URLSearchParams(search).get('engine'); | |
| return mode && mode in BACKENDS ? mode : (mode === 'auto' ? 'auto' : 'auto'); | |
| } | |
| /* The project's own hosted generator, used when a page says nothing else. | |
| * | |
| * A default is a decision about where a visitor's question travels, so it is a | |
| * named public endpoint this project runs rather than a guess at localhost — and | |
| * the page says where questions go, in `note` below, rather than leaving it to | |
| * be discovered. `?nlq=off` opts out and `?nlq=<url>` points elsewhere. | |
| * | |
| * Everything except producing text already runs in the browser: schema | |
| * rendering, few-shot selection, the sandbox, error translation and retries are | |
| * all in the compiled `pyrel_duckdb.nlq`. Only the prompt leaves. */ | |
| export const DEFAULT_NLQ = [ | |
| /* Unquantized weights on a shared GPU, ~2s a question — reached through a | |
| * proxy rather than directly, and the indirection is the whole point. | |
| * | |
| * ZeroGPU bills GPU time to a Hugging Face account, and the daily allowance | |
| * depends on which one: 2 minutes for an unauthenticated caller, 40 for PRO, | |
| * with the highest queue priority. A request from a visitor's tab names no | |
| * account, so it draws on that 2 minutes — which a public page exhausts almost | |
| * immediately, and every call after that is refused in ~0.4s with | |
| * `event: error` and a null body. No status code, no message, and nothing in | |
| * the Space's own log, so an exhausted quota and a hard block look exactly | |
| * alike from here. black-swan-proxy holds a token and lends the account. | |
| * | |
| * Pointing at the GPU Space directly instead would spend that 2 minutes and | |
| * then be a guaranteed half-second of nothing before every question. */ | |
| 'https://maxdemarzi-black-swan-proxy.hf.space/v1/chat/completions', | |
| /* The same model quantized, on CPU, unmetered. The proxy spends a finite | |
| * daily quota; when it runs out this is what answers, which is why the chain | |
| * outlived the problem that created it. Slower — though 14s on a pruned | |
| * schema, not the ~110s a full one costs. */ | |
| 'https://maxdemarzi-black-swan-pyrel.hf.space/v1/chat/completions', | |
| ]; | |
| export const DEFAULT_NLQ_MODEL = 'maxdemarzi/black-swan-lora'; | |
| /** | |
| * Where the browser engine sends a prompt. | |
| * | |
| * (nothing) the hosted generator above | |
| * ?nlq=off no generation; PyRel still works, `nl: false` | |
| * ?nlq=http://host/... somewhere else | |
| */ | |
| export function nlqEndpointFromLocation(search = (typeof location !== 'undefined' ? location.search : '')) { | |
| const raw = new URLSearchParams(search).get('nlq'); | |
| if (raw === null) return DEFAULT_NLQ; | |
| if (/^(off|none|0|false)$/i.test(raw)) return null; | |
| // Comma-separated, so a caller can supply their own chain rather than being | |
| // stuck with one host. | |
| const urls = raw.split(',').map((u) => u.trim()).filter(Boolean).map((u) => { | |
| try { | |
| // Reject anything that is not an absolute http(s) URL rather than letting | |
| // a typo become a request to this origin's own 404 page, parsed as a model. | |
| const parsed = new URL(u, typeof location !== 'undefined' ? location.href : undefined); | |
| return /^https?:$/.test(parsed.protocol) ? parsed.href : null; | |
| } catch { | |
| return null; | |
| } | |
| }).filter(Boolean); | |
| return urls.length ? urls : null; | |
| } | |
| /** | |
| * Whether to send only the concepts a question needs. `?prune=0` turns it off. | |
| * | |
| * On by default because the alternative is measurably broken: TPC-DS renders to | |
| * 8,203 prompt tokens whole, against the 317-640 the adapter was trained on, and | |
| * the attention that costs peaks at 11 GiB on a 12 GiB card — which then spills | |
| * to system memory and takes ~150s instead of ~4s. Pruning brings it to ~1,850. | |
| * A retry escalates to the full schema, so a question the pruner misjudges costs | |
| * an extra attempt rather than an answer, which is what makes a default safe. | |
| */ | |
| export function nlqPruneFromLocation(search = (typeof location !== 'undefined' ? location.search : '')) { | |
| const v = new URLSearchParams(search).get('prune'); | |
| return !(v === '0' || v === 'false' || v === 'off'); | |
| } | |
| /** | |
| * Which model to name in the request. `?nlqModel=black-swan-sft15-q4:1.5b` | |
| * | |
| * Not optional for every server, which is why it is here rather than assumed: a | |
| * host serving one fine-tune usually ignores the field, but ollama's | |
| * OpenAI-compatible endpoint rejects a request without it. Named `nlqModel` | |
| * rather than `model` because this page already has a *data* model and a `graph` | |
| * parameter, and one of those confusions is enough. | |
| */ | |
| export function nlqModelFromLocation(search = (typeof location !== 'undefined' ? location.search : '')) { | |
| const params = new URLSearchParams(search); | |
| const named = params.get('nlqModel'); | |
| if (named) return named; | |
| // Only default the model name alongside the default endpoint. Sending our | |
| // model's name to someone else's server is at best ignored and at worst a 404 | |
| // naming a model they have never heard of. | |
| return params.get('nlq') === null ? DEFAULT_NLQ_MODEL : null; | |
| } | |
| /** | |
| * @param {object} opts | |
| * @param {'auto'|'wasm'|'server'} opts.mode | |
| * @param {(msg: string) => void} [opts.onProgress] surfaced in the UI while a | |
| * backend boots — the wasm one downloads tens of megabytes, so silence | |
| * for that long reads as a hang. | |
| */ | |
| export function createEngine({ mode = 'auto', onProgress = () => {}, nlq = null, nlqPrune = true, nlqModel = null } = {}) { | |
| const attempts = []; | |
| let chosen = null; | |
| let readyPromise = null; | |
| async function boot() { | |
| const order = mode === 'auto' ? AUTO_ORDER : [mode]; | |
| /* An engine that booted but cannot compile PyRel *yet*. Held back in case a | |
| * later one can answer immediately, and selected if none can — see the end | |
| * of this function for why "none can" is the normal case, not the odd one. */ | |
| let deferred = null; | |
| for (const name of order) { | |
| onProgress(`starting ${name} engine…`); | |
| // `nlq` means something only to the wasm backend; the sidecar holds its | |
| // own generator. Passed to both rather than special-cased, so adding a | |
| // third backend does not need this line changed. | |
| const candidate = BACKENDS[name]({ onProgress, nlq, nlqPrune, nlqModel }); | |
| try { | |
| const info = await candidate.ready(); | |
| /* Booting is not the same as being able to answer. The wasm engine loads | |
| * Swan and runs SQL, but has no PyRel front-end — compiling PyRel needs | |
| * pyrel_duckdb, which is Python. `auto` exists to find an engine that can | |
| * serve this page, so an engine that cannot compile PyRel is passed over | |
| * with a reason rather than selected and then failing on first use. | |
| * `?engine=wasm` still reaches it, for SQL. */ | |
| if (mode === 'auto' && info.pyrel === false && info.pyrelAvailable) { | |
| const note = { | |
| engine: name, | |
| error: 'loaded, but has no PyRel front-end yet (needs Pyodide + pyrel_duckdb)', | |
| }; | |
| attempts.push(note); | |
| deferred ||= { candidate, info: { ...info, engine: name }, note }; | |
| onProgress(`${name} engine cannot compile PyRel — trying the next`); | |
| continue; | |
| } | |
| chosen = candidate; | |
| return { ...info, engine: name, attempts }; | |
| } catch (err) { | |
| attempts.push({ engine: name, error: String(err && err.message || err) }); | |
| // An explicitly requested backend that fails is an error, not a cue to | |
| // try something else — the caller asked for that one specifically. | |
| if (mode !== 'auto') throw err; | |
| onProgress(`${name} engine unavailable — ${attempts[attempts.length - 1].error}`); | |
| } | |
| } | |
| /* Nothing could answer straight away, so take the one that can answer once | |
| * it is asked to. This is the ordinary case wherever there is no sidecar — | |
| * a static host, or anyone who cloned the repo and opened a page without | |
| * starting a second process — and reporting "no Swan engine available" for | |
| * it was wrong: the engine is right there, it just wanted a hundred | |
| * megabytes of Python first, which is a question for the user rather than | |
| * grounds for refusing to run. | |
| * | |
| * Its own passed-over note is dropped on the way out. It is what the page | |
| * ends up running, and listing it under "engine unavailable" would describe | |
| * the thing that is working. A genuinely failed backend keeps its note. */ | |
| if (deferred) { | |
| const i = attempts.indexOf(deferred.note); | |
| if (i >= 0) attempts.splice(i, 1); | |
| chosen = deferred.candidate; | |
| return { ...deferred.info, attempts }; | |
| } | |
| const why = attempts.map((a) => `${a.engine}: ${a.error}`).join('; '); | |
| throw new Error(`no Swan engine available (${why})`); | |
| } | |
| function require() { | |
| if (!chosen) throw new Error('engine not ready — await ready() first'); | |
| return chosen; | |
| } | |
| return { | |
| /** Idempotent: repeated calls share one boot, so two callers can't race two backends. */ | |
| ready() { return (readyPromise ||= boot()); }, | |
| get name() { return chosen ? chosen.name : null; }, | |
| attempts, | |
| schema() { return require().schema(); }, | |
| pyrel(code) { return require().pyrel(code); }, | |
| ask(question) { return require().ask(question); }, | |
| chat(question, history) { | |
| const backend = require(); | |
| if (!backend.chat) throw new Error(`the ${backend.name} engine has no conversational endpoint`); | |
| return backend.chat(question, history); | |
| }, | |
| /* Backend-specific capabilities, forwarded rather than assumed. | |
| * | |
| * These exist only on the wasm engine. Leaving them off the façade meant | |
| * `engine.sql(...)` was `undefined` even when the backend implemented it — | |
| * invisible to every static check, because the façade is an object literal | |
| * and nothing cross-references it against the backends. A browser session | |
| * calling sql() is what surfaced it. */ | |
| sql(text) { | |
| const backend = require(); | |
| if (!backend.sql) throw new Error(`the ${backend.name} engine does not execute raw SQL`); | |
| return backend.sql(text); | |
| }, | |
| startPyrel(opts) { | |
| const backend = require(); | |
| if (!backend.startPyrel) { | |
| throw new Error(`the ${backend.name} engine has no separate PyRel front-end to start`); | |
| } | |
| return backend.startPyrel(opts); | |
| }, | |
| loadModel(source) { | |
| const backend = require(); | |
| if (!backend.loadModel) throw new Error(`the ${backend.name} engine cannot load a model in-process`); | |
| return backend.loadModel(source); | |
| }, | |
| loadDataset() { | |
| const backend = require(); | |
| if (!backend.loadDataset) { | |
| throw new Error(`the ${backend.name} engine already has its data — loadDataset is for the browser`); | |
| } | |
| return backend.loadDataset(); | |
| }, | |
| /* Templates, forwarded for exactly the reason the comment above sql() gives | |
| * — and it caught me anyway. Both were implemented on the wasm backend and | |
| * verified there, the worker contract check passed, and the page still met | |
| * `engine.prepareTemplate is not a function`, because the façade is an | |
| * object literal that nothing cross-references against the backends. */ | |
| prepareTemplate(spec) { | |
| const backend = require(); | |
| if (!backend.prepareTemplate) { | |
| throw new Error(`the ${backend.name} engine cannot run templates — they need the browser engine`); | |
| } | |
| return backend.prepareTemplate(spec); | |
| }, | |
| runTemplateCell(source) { | |
| const backend = require(); | |
| if (!backend.runTemplateCell) { | |
| throw new Error(`the ${backend.name} engine cannot run templates — they need the browser engine`); | |
| } | |
| return backend.runTemplateCell(source); | |
| }, | |
| templateGraph() { | |
| const backend = require(); | |
| if (!backend.templateGraph) { | |
| throw new Error(`the ${backend.name} engine cannot read a template's model — that needs the browser engine`); | |
| } | |
| return backend.templateGraph(); | |
| }, | |
| /* Best-effort and fire-and-forget: this runs while the page is going away, | |
| * so an engine that cannot do it is not worth an error nobody will see. */ | |
| closeTemplate() { | |
| const backend = require(); | |
| return backend.closeTemplate ? backend.closeTemplate() : Promise.resolve(null); | |
| }, | |
| /* The rules tier lands well after the dataset does — see the comment on | |
| * loadDataset in pyrel-worker.js. Engines that have no separate rules phase | |
| * (the sidecar evaluated them at startup) report so immediately, so a caller | |
| * can await this unconditionally. */ | |
| rulesReady() { | |
| const backend = require(); | |
| return backend.rulesReady ? backend.rulesReady() : Promise.resolve(null); | |
| }, | |
| /* The two escape hatches from the cache, forwarded for the same reason as | |
| * sql() above: a capability the façade does not name is a capability no | |
| * caller can reach, and nothing static would notice. Both are browser-only — | |
| * the sidecar has no cache to be stale. */ | |
| reloadModel() { | |
| const backend = require(); | |
| if (!backend.reloadModel) { | |
| throw new Error(`the ${backend.name} engine evaluates its rules at startup — there is nothing cached to rebuild`); | |
| } | |
| return backend.reloadModel(); | |
| }, | |
| reloadData() { | |
| const backend = require(); | |
| if (!backend.reloadData) { | |
| throw new Error(`the ${backend.name} engine reads its data directly — there is no cached copy to discard`); | |
| } | |
| return backend.reloadData(); | |
| }, | |
| }; | |
| } | |