time: a shared inference Worker, so venture pages call Venice without a key in the page
cloudflare/inference.js: one Worker (time-inference) at inference.<TIME_VENTURE_DOMAIN> holding VENICE_API_KEY as a secret; venture pages send the same bodies they would send Venice, with an x-app header. Allowlisted routes (chat, music quote/queue/retrieve/complete, image generate), allowlisted models, size caps, and per-person / per-app daily quotas in D1 (the same counter pattern as worker.js). inference.py deploys it; idempotent. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01K6897PWcUEwHiqZMcVnNF6
This commit is contained in:
1 parent
bd0bbbc9ac
commit
5f61220fdc
2 files changed
+211
No files matched your search
@@ -0,0 +1,125 @@
|
||||
// Front-end inference for the yolopreneur ventures, without a key in any page.
|
||||
// One Worker (script time-inference) at inference.<TIME_VENTURE_DOMAIN> holds the Venice key as a secret and lets
|
||||
// venture pages call an allowlisted slice of the Venice API under per-person, per-app and per-day limits.
|
||||
// Deployed by .github/time/inference.py; the pages talk to it with the same bodies they would send Venice.
|
||||
//
|
||||
// GET /health -> { ok, rev, apps }
|
||||
// POST /v1/chat/completions model in CHAT_MODELS, no streaming, max_tokens <= MAX_TOKENS
|
||||
// POST /v1/audio/quote | queue | retrieve | complete model in AUDIO_MODELS, duration_seconds <= AUDIO_SECONDS
|
||||
// POST /v1/image/generate model in IMAGE_MODELS
|
||||
//
|
||||
// Every call names its app with an `x-app` header (one of APPS) and comes from an allowed origin (ORIGINS, a comma
|
||||
// list; `*.yolopreneur.lol` style wildcards allowed). Quotas are counted per (day, app, kind, person) and per
|
||||
// (day, app, kind): CHAT_DAILY/RENDER_DAILY per person, CHAT_TOTAL/RENDER_TOTAL per app. A render is an audio queue
|
||||
// or an image generate; retrieve polls, quotes and completes are free. Over a limit answers 429 with a plain reason.
|
||||
//
|
||||
// Bindings: DB (D1). Secret: VENICE_API_KEY. Vars: REV, APPS, ORIGINS, CHAT_MODELS, AUDIO_MODELS, IMAGE_MODELS,
|
||||
// AUDIO_SECONDS, MAX_TOKENS, CHAT_DAILY, CHAT_TOTAL, RENDER_DAILY, RENDER_TOTAL.
|
||||
|
||||
const VENICE = "https://api.venice.ai/api/v1";
|
||||
const SCHEMA = ["CREATE TABLE IF NOT EXISTS quota (k TEXT PRIMARY KEY, n INTEGER NOT NULL)"];
|
||||
const MAX_BODY = 32768;
|
||||
let ready = null;
|
||||
|
||||
const json = (body, status = 200, headers = {}) =>
|
||||
new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json; charset=utf-8", ...headers } });
|
||||
const list = (s) => String(s || "").split(",").map((x) => x.trim()).filter(Boolean);
|
||||
const num = (s, d) => { const n = Number(s); return Number.isFinite(n) && n > 0 ? n : d; };
|
||||
|
||||
function originOk(origin, env) {
|
||||
if (!origin) return false;
|
||||
let host;
|
||||
try { host = new URL(origin).hostname; } catch { return false; }
|
||||
return list(env.ORIGINS).some((p) => p.startsWith("*.") ? host === p.slice(2) || host.endsWith(p.slice(1)) : host === p);
|
||||
}
|
||||
function cors(req, env) {
|
||||
const origin = req.headers.get("origin");
|
||||
if (!originOk(origin, env)) return null;
|
||||
return { "access-control-allow-origin": origin, "access-control-allow-methods": "GET, POST, OPTIONS",
|
||||
"access-control-allow-headers": "content-type, x-app", "access-control-max-age": "600", "vary": "origin" };
|
||||
}
|
||||
|
||||
async function setup(env) {
|
||||
ready ??= env.DB.batch(SCHEMA.map((s) => env.DB.prepare(s))).catch((e) => { ready = null; throw e; });
|
||||
return ready;
|
||||
}
|
||||
async function who(req, app) {
|
||||
const ip = req.headers.get("cf-connecting-ip") || "?";
|
||||
const raw = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(`${app}:${ip}`));
|
||||
return [...new Uint8Array(raw).slice(0, 8)].map((b) => b.toString(16).padStart(2, "0")).join("");
|
||||
}
|
||||
// One counter per (day, app, kind, person) and one per (day, app, kind): over either limit means no.
|
||||
async function spend(env, app, kind, person, perPerson, total) {
|
||||
const day = new Date().toISOString().slice(0, 10);
|
||||
const bump = (k) => env.DB.prepare("INSERT INTO quota (k, n) VALUES (?, 1) ON CONFLICT(k) DO UPDATE SET n = n + 1 RETURNING n").bind(k);
|
||||
const [mine, all] = await env.DB.batch([bump(`${day}:${app}:${kind}:${person}`), bump(`${day}:${app}:${kind}:*`)]);
|
||||
if (mine.results[0].n > perPerson) return "you're over today's limit for this app; add your own Venice key or come back tomorrow";
|
||||
if (all.results[0].n > total) return "this app is over its daily budget; add your own Venice key or come back tomorrow";
|
||||
return "";
|
||||
}
|
||||
|
||||
// What goes to Venice: the page's body, checked and trimmed, never the page's headers.
|
||||
async function forward(path, body, env) {
|
||||
const res = await fetch(VENICE + path, { method: "POST", headers: { Authorization: "Bearer " + env.VENICE_API_KEY, "Content-Type": "application/json", Accept: "*/*" },
|
||||
body: JSON.stringify(body) });
|
||||
const headers = new Headers();
|
||||
for (const k of ["content-type", "content-length", "x-venice-is-blurred"]) if (res.headers.get(k)) headers.set(k, res.headers.get(k));
|
||||
return new Response(res.body, { status: res.status, headers });
|
||||
}
|
||||
const withCors = (res, h) => { for (const [k, v] of Object.entries(h)) res.headers.set(k, v); return res; };
|
||||
|
||||
async function api(req, env, url, h) {
|
||||
const path = url.pathname.replace(/\/+$/, "");
|
||||
if (path === "/health") return json({ ok: true, rev: env.REV, apps: list(env.APPS) }, 200, h);
|
||||
if (req.method !== "POST") return json({ error: "POST only" }, 405, h);
|
||||
if (!env.VENICE_API_KEY) return json({ error: "no key on this worker yet" }, 503, h);
|
||||
const app = String(req.headers.get("x-app") || "").toLowerCase();
|
||||
if (!list(env.APPS).includes(app)) return json({ error: "unknown app: send an x-app header this worker knows" }, 403, h);
|
||||
const text = await req.text();
|
||||
if (text.length > MAX_BODY) return json({ error: "too big" }, 413, h);
|
||||
let body;
|
||||
try { body = JSON.parse(text); if (!body || typeof body !== "object" || Array.isArray(body)) throw 0; } catch { return json({ error: "send a JSON object" }, 400, h); }
|
||||
await setup(env);
|
||||
const person = await who(req, app);
|
||||
const model = String(body.model || "");
|
||||
|
||||
if (path === "/v1/chat/completions") {
|
||||
if (!list(env.CHAT_MODELS).includes(model)) return json({ error: `model not allowed here: ${model.slice(0, 40)}` }, 403, h);
|
||||
if (!Array.isArray(body.messages) || !body.messages.length) return json({ error: "messages is empty" }, 400, h);
|
||||
body.stream = false;
|
||||
body.max_tokens = Math.min(num(body.max_tokens, 1024), num(env.MAX_TOKENS, 2000));
|
||||
const no = await spend(env, app, "chat", person, num(env.CHAT_DAILY, 60), num(env.CHAT_TOTAL, 2000));
|
||||
if (no) return json({ error: no }, 429, h);
|
||||
return withCors(await forward("/chat/completions", body, env), h);
|
||||
}
|
||||
const audio = path.match(/^\/v1\/audio\/(quote|queue|retrieve|complete)$/);
|
||||
if (audio) {
|
||||
if (!list(env.AUDIO_MODELS).includes(model)) return json({ error: `model not allowed here: ${model.slice(0, 40)}` }, 403, h);
|
||||
if (audio[1] === "queue") {
|
||||
body.duration_seconds = Math.min(num(body.duration_seconds, 60), num(env.AUDIO_SECONDS, 60));
|
||||
const no = await spend(env, app, "render", person, num(env.RENDER_DAILY, 20), num(env.RENDER_TOTAL, 400));
|
||||
if (no) return json({ error: no }, 429, h);
|
||||
}
|
||||
if (audio[1] === "retrieve") body.delete_media_on_completion = true; // nothing of theirs stays at Venice
|
||||
return withCors(await forward("/audio/" + audio[1], body, env), h);
|
||||
}
|
||||
if (path === "/v1/image/generate") {
|
||||
if (!list(env.IMAGE_MODELS).includes(model)) return json({ error: `model not allowed here: ${model.slice(0, 40)}` }, 403, h);
|
||||
body.safe_mode = true;
|
||||
const no = await spend(env, app, "render", person, num(env.RENDER_DAILY, 20), num(env.RENDER_TOTAL, 400));
|
||||
if (no) return json({ error: no }, 429, h);
|
||||
return withCors(await forward("/image/generate", body, env), h);
|
||||
}
|
||||
return json({ error: "no such endpoint" }, 404, h);
|
||||
}
|
||||
|
||||
export default {
|
||||
async fetch(req, env) {
|
||||
const url = new URL(req.url);
|
||||
const h = cors(req, env);
|
||||
if (req.method === "OPTIONS") return new Response(null, { status: h ? 204 : 403, headers: h || {} });
|
||||
if (!h && url.pathname !== "/health") return json({ error: "this worker only serves its own ventures' pages" }, 403);
|
||||
try { return await api(req, env, url, h || {}); }
|
||||
catch (e) { return json({ error: "backend error", detail: String(e).slice(0, 200) }, 500, h || {}); }
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,86 @@
|
||||
"""The shared inference Worker: Venice behind a key the pages never see (cloudflare/inference.js).
|
||||
|
||||
python3 .github/time/inference.py deploy # script time-inference at inference.<TIME_VENTURE_DOMAIN>
|
||||
python3 .github/time/inference.py check # /health of the deployed worker
|
||||
|
||||
Needs CLOUDFLARE_API_TOKEN, CLOUDFLARE_ACCOUNT_ID, TIME_VENTURE_DOMAIN and VENICE_API_KEY (bound as a Worker secret).
|
||||
Settings (all optional, TIME_INFERENCE_*): APPS, ORIGINS, CHAT_MODELS, AUDIO_MODELS, IMAGE_MODELS, AUDIO_SECONDS,
|
||||
MAX_TOKENS, CHAT_DAILY, CHAT_TOTAL, RENDER_DAILY, RENDER_TOTAL. Idempotent: deploying again updates in place.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
from cloudflare import COMPAT, account, attach, cf, env, multipart # noqa: E402
|
||||
|
||||
HERE = Path(__file__).resolve().parent
|
||||
WORKER = HERE / "cloudflare" / "inference.js"
|
||||
NAME = "time-inference"
|
||||
DEFAULTS = {
|
||||
"APPS": "gandora,havre",
|
||||
"ORIGINS": "*.yolopreneur.lol",
|
||||
"CHAT_MODELS": "claude-haiku-5-5,google-gemma-4-26b-a4b-it,mistral-small-3-2-24b-instruct,deepseek-v4-flash,qwen3-235b-a22b-instruct-2507",
|
||||
"AUDIO_MODELS": "ace-step-15",
|
||||
"IMAGE_MODELS": "z-image-turbo",
|
||||
"AUDIO_SECONDS": "60",
|
||||
"MAX_TOKENS": "2000",
|
||||
"CHAT_DAILY": "60", "CHAT_TOTAL": "2000",
|
||||
"RENDER_DAILY": "20", "RENDER_TOTAL": "400",
|
||||
}
|
||||
|
||||
|
||||
def settings() -> dict:
|
||||
return {k: env("TIME_INFERENCE_" + k, v) for k, v in DEFAULTS.items()}
|
||||
|
||||
|
||||
def hostname() -> str:
|
||||
zone = env("TIME_VENTURE_DOMAIN").lower().strip(".")
|
||||
if not zone:
|
||||
raise SystemExit("set TIME_VENTURE_DOMAIN (the zone the worker lives under)")
|
||||
return f"inference.{zone}"
|
||||
|
||||
|
||||
def database() -> str:
|
||||
for d in cf("GET", f"{account()}/d1/database?name={NAME}") or []:
|
||||
if d.get("name") == NAME:
|
||||
return d["uuid"]
|
||||
return cf("POST", f"{account()}/d1/database", {"name": NAME})["uuid"]
|
||||
|
||||
|
||||
def deploy() -> dict:
|
||||
key = env("VENICE_API_KEY")
|
||||
if not key:
|
||||
raise SystemExit("set VENICE_API_KEY: it becomes the worker's secret")
|
||||
vars_ = settings()
|
||||
h = hashlib.sha256(WORKER.read_bytes())
|
||||
h.update(json.dumps({**vars_, "host": hostname(), "compat": COMPAT}, sort_keys=True).encode())
|
||||
vars_["REV"] = rev = h.hexdigest()[:12]
|
||||
db = database()
|
||||
meta = {"main_module": "inference.js", "compatibility_date": COMPAT,
|
||||
"bindings": [{"type": "d1", "name": "DB", "id": db}, {"type": "secret_text", "name": "VENICE_API_KEY", "text": key}]
|
||||
+ [{"type": "plain_text", "name": k, "text": str(v)} for k, v in vars_.items()]}
|
||||
data, ctype = multipart([("metadata", "application/json", json.dumps(meta).encode()),
|
||||
("inference.js", "application/javascript+module", WORKER.read_bytes())])
|
||||
cf("PUT", f"{account()}/workers/scripts/{NAME}", data=data, ctype=ctype)
|
||||
url = attach(NAME, hostname())
|
||||
return {"url": url, "worker": NAME, "db": db, "rev": rev}
|
||||
|
||||
|
||||
def check() -> dict:
|
||||
with urllib.request.urlopen(f"https://{hostname()}/health", timeout=20) as r:
|
||||
return json.loads(r.read())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
if sys.argv[1:2] == ["deploy"]:
|
||||
print(json.dumps(deploy(), indent=1))
|
||||
elif sys.argv[1:2] == ["check"]:
|
||||
print(json.dumps(check(), indent=1))
|
||||
else:
|
||||
sys.exit(__doc__)
|
||||
Reference in new issue
Block a user