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"?(?:thought|response|message|declaration)>", "", 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"