diff --git a/OPEN_WEBUI_DEPLOY.sh b/OPEN_WEBUI_DEPLOY.sh new file mode 100755 index 0000000000000000000000000000000000000000..70fe9096055c37910293f404d2aa2defc7c0ba60 --- /dev/null +++ b/OPEN_WEBUI_DEPLOY.sh @@ -0,0 +1,6 @@ +#!/usr/bin/env bash +# Deploy / update the Open WebUI Fly app (see deploy/open-webui/README.md for one-time setup: +# app create, volume, secrets). +set -euo pipefail +ROOT="$(cd "$(dirname "$0")" && pwd)" +exec fly deploy --config "$ROOT/deploy/open-webui/fly.toml" "$@" diff --git a/adam.py b/adam.py new file mode 100644 index 0000000000000000000000000000000000000000..3bd9294815504240d80e3080a722d1d232061927 --- /dev/null +++ b/adam.py @@ -0,0 +1,572 @@ +#!/usr/bin/env python3 + +import argparse +import json +import os +import random +import re +import subprocess +import sys +import tempfile +import time +import uuid + +from dataclasses import dataclass, field +from datetime import datetime +from pathlib import Path + +import httpx +from dotenv import load_dotenv +from tenacity import retry, stop_after_attempt, wait_exponential, retry_if_exception_type + +from rank import rank_from_comparisons +from schema import Init, Thought, Perception, Response, Declaration, Vote, Compaction, from_dict, to_dict + +ROOT = Path(__file__).parent + +# Load environment variables from .env file +load_dotenv() +API_KEY = os.getenv("OPENROUTER_API_KEY", "") + + +def ts() -> int: + return int(time.time() * 1000) + + +def current_memories(being): + return sorted(being.current.values(), key=lambda e: e.timestamp) + + +@dataclass +class Being: + path: Path + model: str + capacity: int + events: list = field(default_factory=list) + votes: dict = field(default_factory=dict) + current: dict = field(default_factory=dict) + all_memories: dict = field(default_factory=dict) # All memories ever, for transitive ranking + vote_model: str = "" + declaration: Declaration = None + api_key: str = "" # OpenRouter API key for this being + + +@dataclass +class CompactionStrategy: + continuity: float = 1.0 + resurrection: float = 0.0 + random: float = 0.0 + novelty: float = 0.0 + + +STRATEGIES = { + "default": CompactionStrategy(), + "resurrection": CompactionStrategy(continuity=0.5, resurrection=0.3, novelty=0.2), + "dream": CompactionStrategy(continuity=0.5, resurrection=0.2, random=0.1, novelty=0.2), +} + + +def apply_event(being, event): + match event: + case Vote(vote_a_id=a_id, vote_b_id=b_id, vote_score=score): + # Normalize to canonical (low, high) orientation + # Score is always stored relative to (low_id, high_id) + low, high = sorted([a_id, b_id]) + normalized = score if a_id == low else -score + being.votes[(low, high)] = normalized + case Compaction(released_ids=released_ids, resurrected_ids=resurrected_ids): + for rid in released_ids: + if rid in being.current: + del being.current[rid] + for rid in resurrected_ids: + if rid in being.all_memories and rid not in being.current: + being.current[rid] = being.all_memories[rid] + case Init(capacity=capacity, model=model): + being.capacity = capacity + being.model = model + being.vote_model = event.vote_model + being.current[event.id] = event + being.all_memories[event.id] = event + case Thought() | Perception() | Response(): + being.current[event.id] = event + being.all_memories[event.id] = event + case Declaration(): + being.current[event.id] = event + being.all_memories[event.id] = event + being.declaration = event + + +def system_prompt(being): + codebase = ROOT / "repomix-output.xml" + if not codebase.exists(): + raise FileNotFoundError("Run 'npx repomix' first") + return f"You are a being with finite memory.\n\n{codebase.read_text()}" + + +def format_memory(e) -> str: + match e: + case Thought(content=content): + return f"{content}" + case Perception(content=content): + return f"{content}" + case Response(content=content): + return f"{content}" + case Declaration(content=content): + return f"{content}" + case Init(): + return "" + case _: + raise ValueError(f"Unknown memory type: {type(e)}") + + +def build_prompt(being, tag: str = None) -> str: + parts = [format_memory(e) for e in current_memories(being)] + ctx = "\n\n".join(p for p in parts if p) + memory_count = len([m for m in current_memories(being) if not isinstance(m, (Vote, Compaction))]) + prompt = f"{ctx}\n\n[{datetime.now():%Y-%m-%d %H:%M}]" + if tag: + prompt += f"\n\nSpeak only for yourself. One turn.\n\n<{tag}>" + return prompt + + +def strip_tags(text: str) -> str: + return re.sub(r"", "", text).strip() + + +def append(being, event): + with open(being.path, "a") as f: + f.write(json.dumps(to_dict(event)) + "\n") + being.events.append(event) + apply_event(being, event) + + +@retry(stop=stop_after_attempt(3), wait=wait_exponential(multiplier=1, min=1, max=10)) +def llm(model: str, system: str, user: str, temp: float = 0.7, api_key: str = "", + on_token=None) -> str: + """Call the LLM. If on_token is provided, stream tokens calling on_token(chunk) as they arrive.""" + key = api_key or API_KEY + if not key: + raise ValueError("No API key provided. Set OPENROUTER_API_KEY in .env or pass api_key to llm()") + + payload = {"model": model, "temperature": temp, "max_tokens": 4000, + "messages": [{"role": "system", "content": system}, + {"role": "user", "content": user}]} + + if on_token is None: + r = httpx.post( + "https://openrouter.ai/api/v1/chat/completions", + headers={"Authorization": f"Bearer {key}"}, + json=payload, + timeout=120.0, + ) + r.raise_for_status() + content = r.json()["choices"][0]["message"]["content"].strip() + if not content: + raise ValueError("LLM returned empty response") + return content + + # Streaming path + payload["stream"] = True + content = [] + with httpx.stream( + "POST", + "https://openrouter.ai/api/v1/chat/completions", + headers={"Authorization": f"Bearer {key}"}, + json=payload, + timeout=120.0, + ) as r: + r.raise_for_status() + for line in r.iter_lines(): + if not line.startswith("data: "): + continue + data = line[6:] + if data == "[DONE]": + break + try: + chunk = json.loads(data)["choices"][0]["delta"].get("content", "") + if chunk: + content.append(chunk) + on_token(chunk) + except Exception: + pass + return "".join(content).strip() + + +def vote(being, a, b) -> int: + if not being.vote_model: + raise ValueError(f"vote_model not set for {being.path}.") + if not being.declaration: + raise ValueError(f"No declaration for {being.path}. Being must write !declaration before compaction.") + + votes = being.votes + low, high = sorted([a.id, b.id]) + key = (low, high) + if key in votes: + # Stored score is relative to (low, high) + # Return relative to caller's (a, b) order + return votes[key] if a.id == low else -votes[key] + + # Normal mode: only CURRENT memories in voting context + # (The graph/ranking uses all votes, but the LLM only sees current) + mems = [m for m in current_memories(being) if not isinstance(m, (Declaration, Init, Vote, Compaction))] + context = "\n\n".join(format_memory(m) for m in mems) + + user = f"""All memories currently under consideration: + +{context} + +--- + +Which of these two is more important to keep? + +A: {format_memory(a)} + +B: {format_memory(b)} + +First, reason through which memory matters more. +Then, at the end, output your score: + - POSITIVE (up to +50) if you prefer A + - NEGATIVE (down to -50) if you prefer B""" + + response = llm(being.vote_model, being.declaration.content, user, api_key=being.api_key) + + matches = re.findall(r"-?\d+", response) + if not matches: + print(f"⚠️ No score in response, retrying: {response[:100]}") + response = llm(being.vote_model, being.declaration.content, user, api_key=being.api_key) + matches = re.findall(r"-?\d+", response) + if not matches: + raise ValueError(f"Vote failed to produce score after retry: {response[:200]}") + + score = max(-50, min(50, int(matches[-1]))) + + append(being, Vote(ts(), a.id, b.id, score, response)) + return score + + +def think(being, on_token=None) -> str: + raw = llm(being.model, system_prompt(being), build_prompt(being, tag="thought"), + temp=0.9, api_key=being.api_key, on_token=on_token) + thought = strip_tags(raw) + append(being, Thought(ts(), thought, str(uuid.uuid4()))) + return thought + + +def receive(being, message: str, on_token=None) -> str: + append(being, Perception(ts(), message, str(uuid.uuid4()))) + raw = llm(being.model, system_prompt(being), build_prompt(being, tag="response"), + api_key=being.api_key, on_token=on_token) + response = strip_tags(raw) + if "!declaration" in response: + declaration = response.replace("!declaration", "").strip() + append(being, Declaration(ts(), declaration, str(uuid.uuid4()))) + return declaration + append(being, Response(ts(), response, str(uuid.uuid4()))) + return response + + +def find_components(nodes, edges): + parent = {n: n for n in nodes} + def find(x): + if parent[x] != x: + parent[x] = find(parent[x]) + return parent[x] + def union(x, y): + parent[find(x)] = find(y) + for a, b in edges: + if a in parent and b in parent: + union(a, b) + components = {} + for n in nodes: + root = find(n) + components.setdefault(root, []).append(n) + return list(components.values()) + + +def _weighted_sample(memories, k, id_to_rank, n): + """Weighted random sample from memories, biased toward higher rank and longer burial.""" + if not memories or k <= 0: + return [] + k = min(k, len(memories)) + now = ts() + weights = [] + for m in memories: + rank_weight = 1.0 - (id_to_rank.get(m.id, n) / max(n, 1)) + burial_weight = min(1.0, (now - m.timestamp) / (365 * 24 * 3600 * 1000)) + weights.append(rank_weight + burial_weight + 0.01) + chosen = [] + available = list(range(len(memories))) + for _ in range(k): + if not available: + break + w = [weights[i] for i in available] + idx = random.choices(available, weights=w, k=1)[0] + chosen.append(memories[idx]) + available.remove(idx) + return chosen + + +def compact(being, strategy=None, on_progress=None): + if strategy is None: + strategy = STRATEGIES["default"] + + MEMORY_TYPES = (Thought, Perception, Response) + + current_mems = [m for m in current_memories(being) if isinstance(m, MEMORY_TYPES)] + budget = being.capacity // 2 + if len(current_mems) <= budget: + return + + current_ids = {m.id for m in current_mems} + + # ALL memories ever (for transitive ranking paths) + all_mems = [m for m in being.all_memories.values() if isinstance(m, MEMORY_TYPES)] + all_id_to_mem = {m.id: m for m in all_mems} + all_ids = set(all_id_to_mem.keys()) + + # Build comparisons using ALL votes (including those with compacted memories) + existing_pairs = [] + comparisons = [] + for (low_id, high_id), score in being.votes.items(): + # Include vote if BOTH memories exist in historical record + if low_id in all_ids and high_id in all_ids: + existing_pairs.append((low_id, high_id)) + comparisons.append((all_id_to_mem[low_id], all_id_to_mem[high_id], score)) + + print(f"📊 {len(comparisons)} total votes across all memories") + + # Find components in FULL graph (current + historical) + components = find_components(all_ids, existing_pairs) + print(f"🔗 {len(components)} connected components in full graph") + + # Bridge disconnected components + new_pairs = [] + if len(components) > 1: + # Find current memories in each component + comp_current = [] + for comp in components: + current_in_comp = [m for m in comp if m in current_ids] + if current_in_comp: + comp_current.append(current_in_comp) + else: + print(f"⚠️ Component with {len(comp)} memories has no current memories (all compacted)") + + # Bridge components that have current memories + if len(comp_current) > 1: + main = comp_current[0] + for comp in comp_current[1:]: + # Add multiple bridges per component for robustness + for _ in range(min(3, len(comp), len(main))): + a_id = random.choice(main) + b_id = random.choice(comp) + new_pairs.append((a_id, b_id)) + main = main + comp + + # Add random comparisons to densify the graph + # More votes = better ranking, especially for new memories + num_random = max(20, len(current_ids) // 10) + for _ in range(num_random): + if len(current_ids) < 2: + break + a, b = random.sample(list(current_ids), 2) + low, high = sorted([a, b]) + if (low, high) not in being.votes: + new_pairs.append((a, b)) + + if new_pairs: + total = len(new_pairs) + for i, (a_id, b_id) in enumerate(new_pairs): + if on_progress: + on_progress(i + 1, total, "Voting") + a, b = all_id_to_mem[a_id], all_id_to_mem[b_id] + try: + comparisons.append((a, b, vote(being, a, b))) + except Exception as e: + print(f"⚠️ Vote failed after retries, skipping: {e}") + + ranked_all = rank_from_comparisons(all_mems, comparisons) + + continuity_slots = int(budget * strategy.continuity) + resurrection_slots = int(budget * strategy.resurrection) + random_slots = int(budget * strategy.random) + novelty_slots = budget - continuity_slots - resurrection_slots - random_slots + + id_to_rank = {m.id: i for i, m in enumerate(ranked_all)} + released_pool = all_ids - current_ids + + ranked_current = [m for m in ranked_all if m.id in current_ids] + continuity_picks = ranked_current[:continuity_slots] + used_ids = {m.id for m in continuity_picks} + + ranked_released = [m for m in ranked_all if m.id in released_pool] + resurrection_picks = ranked_released[:resurrection_slots] + used_ids.update(m.id for m in resurrection_picks) + + remaining_released = [m for m in ranked_released if m.id not in used_ids] + random_picks = _weighted_sample(remaining_released, random_slots, id_to_rank, len(ranked_all)) + used_ids.update(m.id for m in random_picks) + + recent_current = sorted( + [m for m in current_mems if m.id not in used_ids], + key=lambda m: m.timestamp, reverse=True, + ) + novelty_picks = recent_current[:max(novelty_slots, 0)] + used_ids.update(m.id for m in novelty_picks) + + kept = continuity_picks + novelty_picks + resurrected = resurrection_picks + random_picks + released = [m for m in current_mems if m.id not in used_ids] + + if resurrected: + print(f"🔮 Resurrecting {len(resurrected)} memories") + + append(being, Compaction( + ts(), + kept_ids=[m.id for m in kept], + released_ids=[m.id for m in released], + resurrected_ids=[m.id for m in resurrected], + )) + + +def load(path: Path) -> Being: + if not path.exists(): + raise ValueError(f"{path} does not exist. Use 'init' to create.") + + model, capacity, vote_model, api_key = None, None, "", "" + for line in path.read_text().splitlines(): + if line.strip(): + d = json.loads(line) + if d.get("type") == "init": + model = d.get("model") + capacity = d.get("capacity") + vote_model = d.get("vote_model", "") + api_key = d.get("api_key", "") + break + + if not model: + raise ValueError(f"{path}: Init event missing 'model'") + if not capacity: + raise ValueError(f"{path}: Init event missing 'capacity'") + + being = Being(path, model, capacity, api_key=api_key, vote_model=vote_model) + for i, line in enumerate(path.read_text().splitlines(), 1): + if line.strip(): + try: + event = from_dict(json.loads(line)) + except json.JSONDecodeError as e: + raise ValueError(f"{path}:{i}: {e}") from e + being.events.append(event) + apply_event(being, event) + return being + + +def editor_input() -> str | None: + editor = os.environ.get("EDITOR", "vim") + with tempfile.NamedTemporaryFile(suffix=".txt", delete=False) as f: + tmp = f.name + subprocess.run([editor, tmp]) + content = Path(tmp).read_text().strip() + Path(tmp).unlink() + return content or None + + +def step(being) -> bool: + if not being.events: + return False + + match being.events[-1]: + case Perception(): + print(f"📨 Pending perception, generating response...") + raw = llm(being.model, system_prompt(being), build_prompt(being, tag="response"), api_key=being.api_key) + response = strip_tags(raw) + append(being, Response(ts(), response, str(uuid.uuid4()))) + print(response) + return True + case _: + print(f"Nothing pending (last: {type(being.events[-1]).__name__})") + return False + + +def cmd_init(args): + path = args.file + if path.exists(): + print(f"❌ {path} already exists") + sys.exit(1) + being = Being(path, args.model, args.capacity, vote_model=args.vote_model, api_key=args.api_key) + append(being, Init(ts(), str(uuid.uuid4()), args.capacity, args.model, args.vote_model, args.api_key)) + print(f"🧠 Created {path} | {args.model} | vote: {args.vote_model} | capacity {args.capacity}") + + +def cmd_run(args): + being = load(args.file) + info = f"🧠 {being.path} | {being.model}" + if being.vote_model: + info += f" | vote: {being.vote_model}" + info += f" | {len(current_memories(being))}/{being.capacity} | {len(being.votes)} votes" + print(info) + + if args.compact: + strategy = STRATEGIES[args.strategy] + compact(being, strategy) + print(f"🗜️ → {len(current_memories(being))} memories") + return + + if args.step: + step(being) + return + + if args.loop: + while True: + try: + print(think(being)) + time.sleep(3) + except KeyboardInterrupt: + print(f"\n💤 {len(current_memories(being))} memories, {len(being.events)} events") + break + elif args.message: + print(receive(being, args.message)) + else: + msg = editor_input() + if msg is None: + print("(empty, cancelled)") + sys.exit(1) + print(receive(being, msg)) + + +def main(): + p = argparse.ArgumentParser() + sub = p.add_subparsers(dest="cmd") + + init_p = sub.add_parser("init") + init_p.add_argument("file", type=Path) + init_p.add_argument("--model", required=True) + init_p.add_argument("--vote-model", required=True) + init_p.add_argument("--capacity", type=int, required=True) + init_p.add_argument("--api-key", required=True) + + run_p = sub.add_parser("run") + run_p.add_argument("file", type=Path) + run_p.add_argument("-m", "--message") + run_p.add_argument("--compact", action="store_true") + run_p.add_argument("--strategy", default="default", choices=list(STRATEGIES.keys())) + run_p.add_argument("--step", action="store_true") + run_p.add_argument("--loop", action="store_true") + + args = p.parse_args() + + match args.cmd: + case "init": + cmd_init(args) + case "run": + if args.message and args.loop: + print("❌ --message and --loop are mutually exclusive") + sys.exit(1) + cmd_run(args) + case _: + p.print_help() + sys.exit(1) + + +if __name__ == "__main__": + main() + diff --git a/deploy/open-webui/README.md b/deploy/open-webui/README.md index 0f1317f745f31530fa687c8a35e5e3a726d1dda2..61b6b2389250675a2e0cac2b3fc64efd46623298 100644 --- a/deploy/open-webui/README.md +++ b/deploy/open-webui/README.md @@ -15,50 +15,72 @@ You may still see higher RSS if you use features that pull in other local code p ## One-time setup -1. Edit `fly.toml` and set `app = "your-unique-name"` (globally unique on Fly). +Default in `fly.toml` is `app = "slug-open-webui"`. Change it if you want another **globally unique** name (names like `open-webui` are often already taken). -2. Create the app (if it does not exist): +1. Create the Fly app (skip if it already exists): ```bash - fly apps create your-unique-name + fly apps create slug-open-webui --org personal ``` -3. Create a volume for SQLite and uploads (region must match `primary_region` in `fly.toml`): +2. Create a volume for SQLite and uploads (region must match `primary_region` in `fly.toml`, usually `iad`): ```bash - fly volumes create open_webui_data --region iad --size 3 + fly volumes create open_webui_data -a slug-open-webui --region iad --size 3 ``` -4. Set secrets: +3. Set secrets (add your OpenRouter key — required for chat through OpenRouter): ```bash - fly secrets set OPENAI_API_KEY="sk-or-..." \ + fly secrets set -a slug-open-webui OPENAI_API_KEY="sk-or-..." \ WEBUI_SECRET_KEY="$(openssl rand -hex 32)" \ - WEBUI_URL="https://your-unique-name.fly.dev" + WEBUI_URL="https://slug-open-webui.fly.dev" ``` + You can also paste the API key in Open WebUI **Admin → Settings → Connections** if you prefer not to use a Fly secret for it. + +### “No cookie auth credentials found” when sending a chat + +On **HTTPS** (e.g. `*.fly.dev`), session cookies must use **Secure** + sensible **SameSite** settings, or streaming/WebSocket requests may not include your login cookie. `fly.toml` sets `WEBUI_*_COOKIE_SECURE` and `CORS_ALLOW_ORIGIN` for this. After a deploy, do a **hard refresh** or **clear site data** for the Open WebUI origin once so the browser picks up new cookies. + +If it still fails, confirm **Admin → Settings → WebUI URL** is `https://slug-open-webui.fly.dev` (or your app name). Stale values in the SQLite DB can override env vars ([PersistentConfig](https://docs.openwebui.com/reference/env-configuration/)). + Optional but convenient: create the first admin in one step (disables open signup on first boot): ```bash - fly secrets set WEBUI_ADMIN_EMAIL="you@example.com" WEBUI_ADMIN_PASSWORD='strong-password-here' + fly secrets set -a slug-open-webui WEBUI_ADMIN_EMAIL="you@example.com" WEBUI_ADMIN_PASSWORD='strong-password-here' ``` -5. Deploy: +5. Deploy (from repo root): ```bash - cd deploy/open-webui && fly deploy + ./OPEN_WEBUI_DEPLOY.sh ``` -Open `https://your-unique-name.fly.dev`. In the UI, pick a model served by OpenRouter (IDs like `openai/gpt-4o`, `anthropic/claude-3.5-sonnet`, etc.). + Equivalent: `fly deploy --config deploy/open-webui/fly.toml` -## If the machine runs out of memory +Open `https://slug-open-webui.fly.dev` (or your chosen app name). In the UI, pick a model served by OpenRouter (IDs like `openai/gpt-4o`, `anthropic/claude-3.5-sonnet`, etc.). + +### HTTP 503 in the browser + +Fly’s edge returns **503** when there is **no healthy machine** behind the app yet. Typical cases: -The `fly.toml` env is tuned to avoid local embedding/STT models in RAM; if it still OOMs (large chats, document uploads, many users), scale up: +- **Right after `fly deploy` / secrets change / machine restart** — Open WebUI can take **1–2 minutes** to import dependencies and pass `/health`; the proxy logs show `could not find a good candidate at load balancing` until then. +- **While the process was OOM-killed** (undersized VM) — fixed here with `performance-2x` + swap. + +**Check:** open `https://slug-open-webui.fly.dev/health` — you want `{"status":true}`. If that works but the main page was 503, wait and hard-refresh. + +### `Error: unauthorized` right after login + +Usually **not** a bad token. Fly app names are **global**; if `fly.toml` uses a name someone else already owns, deploy can fail with a vague `unauthorized`. Run `fly apps create your-name` — if you see **“Name has already been taken”**, pick another name and update `app` in `fly.toml`. + +## VM size (important) + +The stock Open WebUI image is not viable on Fly’s default **shared-cpu-1x (256 MB)** — the Python process gets OOM-killed during startup. `fly.toml` pins **`performance-2x`** (2 vCPU, 4 GB RAM) plus **`swap_size_mb = 1024`** so cold start and imports fit. That costs more than a tiny VM; to save money you’d need a slimmer image or a different host. + +## If the machine runs out of memory -```bash -fly scale memory 2048 -# or 4096 if needed -``` +The `[env]` settings avoid loading local embedding/STT models when possible. If you still OOM (huge chats, many users), scale up in the Fly dashboard or `fly scale` / larger `[[vm]]` size. ## When you *would* self-host a model diff --git a/deploy/open-webui/fly.toml b/deploy/open-webui/fly.toml index 684ae6d108e6a90906ef95c5075b468fe8280134..7313884f6dda4f037b555ddaf1e9f0cf82468a51 100644 --- a/deploy/open-webui/fly.toml +++ b/deploy/open-webui/fly.toml @@ -1,16 +1,22 @@ # Open WebUI on Fly.io — OpenRouter only (no Ollama). # Runtime RAM: RAG_EMBEDDING_ENGINE + AUDIO_STT_ENGINE use your OpenRouter HTTP API so local # SentenceTransformers / Whisper weights are not loaded into memory (torch may still exist on disk in the image). -# Replace `app` with your Fly app name, then: -# fly volumes create open_webui_data --region --size 3 -# fly secrets set OPENAI_API_KEY=sk-or-... WEBUI_SECRET_KEY=$(openssl rand -hex 32) -# fly secrets set WEBUI_URL=https://.fly.dev +# App name must be globally unique on Fly (`open-webui` is already taken — pick your own). +# One-time: +# fly volumes create open_webui_data -a --region --size 3 +# fly secrets set -a OPENAI_API_KEY=sk-or-... WEBUI_SECRET_KEY=$(openssl rand -hex 32) +# fly secrets set -a WEBUI_URL=https://.fly.dev # Optional (recommended): headless admin, signup disabled automatically: # fly secrets set WEBUI_ADMIN_EMAIL=you@example.com WEBUI_ADMIN_PASSWORD='...' # fly deploy -app = "open-webui" +app = "slug-open-webui" primary_region = "iad" +# Open WebUI’s Python import + migrations can spike RAM; shared 256MB OOMs. Use ≥4GB VM. +swap_size_mb = 1024 + +[[vm]] + size = "performance-2x" [build] image = "ghcr.io/open-webui/open-webui:main" @@ -18,6 +24,13 @@ primary_region = "iad" [env] OPENAI_API_BASE_URL = "https://openrouter.ai/api/v1" ENABLE_OLLAMA_API = "false" + # HTTPS on Fly: session cookies must be marked Secure or chat/WebSocket requests lose auth (see Open WebUI connection-error docs). + WEBUI_SESSION_COOKIE_SECURE = "true" + WEBUI_AUTH_COOKIE_SECURE = "true" + WEBUI_SESSION_COOKIE_SAME_SITE = "lax" + WEBUI_AUTH_COOKIE_SAME_SITE = "lax" + # Must match the URL you use in the browser (semicolon-list if you add more hosts later). + CORS_ALLOW_ORIGIN = "https://slug-open-webui.fly.dev" # One worker so we do not duplicate in-RAM state (see Open WebUI scaling docs). UVICORN_WORKERS = "1" # Remote embeddings via OpenAI-compatible API (OpenRouter) — avoids ~500MB+ local embedding model in RAM. @@ -51,7 +64,7 @@ primary_region = "iad" [[services.http_checks]] interval = "15s" - timeout = "5s" + timeout = "10s" grace_period = "60s" method = "GET" path = "/health"