Build-vs-buy, run side by side

AI: Foundation vs. Open-Weight Models

The daily-update feature runs the same task — write commentary on the day's tournament action — through two genuinely different kinds of model: a hosted foundation model (Claude) and a self-hosted open-weight model (running on private hardware over a private network). An admin can generate from either, back to back, and compare.

01Two kinds of "AI" in this system

"Using AI" usually means one specific choice: call a vendor's hosted API. This app makes that choice visible by also running the alternative — an "open-weight" model, meaning one whose underlying parameters are published and can be downloaded and run on your own hardware instead of only through the vendor's servers — for the exact same job, so the tradeoff is something you can actually see rather than take on faith.

 Foundation modelOpen-weight model
WhatClaude, via the Anthropic APIgpt-oss:20b, served locally by Ollama
Where it runsAnthropic's servers"the Spark" — a machine on the developer's own private network
Cost modelPay per token, no hardware to runNo per-token cost, but ties up local hardware and needs the network work described below
Code pathdaily_update_stream() — direct Anthropic API call, streamed to the browserdaily_update_spark_direct_stream() / the queue path below — HTTP call to Ollama's /api/chat, streamed

Both paths are called from app/blueprints/admin.py, and both are wired to render on the same admin page from the same prompt and leaderboard data — so a genuine side-by-side comparison is possible, not just a claim that one would work.

Note on history: an earlier version of this feature went through a Model Context Protocol (MCP) server (mcp_client.py talking to a separate mcp_server.py process) as a way of giving Claude structured, read-only tool access to the database. That MCP hop has since been removed — both the Claude path and the Spark paths now call their model directly. mcp_client.py still exists in the codebase but nothing imports it today; it's inactive, kept as reference rather than deleted mid-tournament.

02Evaluations — three generators, one comparison

The Daily Update admin page has three buttons that all produce the same kind of output from the same inputs. That's not redundancy — it's the evaluation harness. Running all three on the same day's data is how "is the open-weight model good enough, and how much slower is it" gets answered with real numbers instead of a guess.

1. Generate with Claude

A direct, streamed call to the Anthropic API. First measured run: ~3.6s to first token, ~8.3s total. The baseline the other two are judged against.

2. Spark — direct call

A real-time streamed call straight to Ollama on the Spark, no queue in between — the fastest path to the open-weight model, used when the Spark is already known to be reachable.

3. Spark — via queue

Writes a pending job row instead of calling Ollama directly; a separate poller running on the Spark itself claims the job and does the work. Nothing in the web app connects to the Spark for this path — see §3 for why that matters. Measured steady-state: ~10s per job.

The queue path is also the substrate for where this is headed next: an agentic loop that runs jobs and tool calls entirely on the Spark, reusing the same job_events-style tables and worker API rather than building new plumbing for it.

An open, tracked evaluation question: gpt-oss:20b runs at roughly 35 tokens/second on the Spark (~13s for a typical commentary). A smaller 7–8B parameter model should run roughly 3× faster — that's an open item on the build backlog: swap the model, re-run the same comparison, and see whether commentary quality holds up at the faster speed. It's a config change, not new code.

03The private network that makes self-hosting possible

Running your own model only works if it's reachable from the app but not from the open internet. That's a real, solved networking problem, not a footnote — done with Tailscale, a private network overlay (a "tailnet") that only devices you've explicitly added can join.

  1. Ollama itself isn't exposed at all. It's bound to 127.0.0.1:11434 on the Spark — not reachable from any other machine, tailnet or not, by default.
  2. The app reaches it only over the tailnet. The web app and the Spark are both devices on the same private Tailscale network, so the app can address the Spark by its tailnet IP the way it would a normal LAN host — without opening anything to the public internet.
  3. A Tailscale ACL further restricts who can even try. An access-control rule scopes which devices on the tailnet are allowed to reach the Spark's Ollama port at all — so being on the tailnet isn't automatically enough.
  4. The database grant is host-locked to that same tailnet IP. The worker's MySQL account (used by earlier queue designs) is only permitted to connect from the Spark's specific Tailscale address — not from "anywhere on the tailnet," and not from the internet.

Getting this working surfaced a real, debugged infrastructure problem: WSL2 (Windows Subsystem for Linux, used in development) by default only binds published ports to Windows' own localhost, not to other network interfaces — which silently broke reachability over the tailnet. The fix was switching WSL2 to mirrored networking mode (a `.wslconfig` setting) plus a Windows Firewall rule scoped specifically to Tailscale's private address range, after which the tailnet interface became visible and the connection succeeded. That's the kind of infrastructure debugging that doesn't show up in a feature list but is exactly what makes "just self-host a model" actually work in practice.