v0 plan: AGENTS.md, gate, five task files with given tests, run-plan driver

Tests were run against a private reference implementation: gate ok after every
task in order, smoke ok (stream spread ~1000 ms). The reference is not in the
repository.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
2026-09-25 01:51:53 -07:00
co-authored by Claude Fable 5.1
parent b52126ba0b
commit 89e47d83f8
25 changed files with 1847 additions and 0 deletions
+121
View File
@@ -0,0 +1,121 @@
# v0 task 03: the routing reverse proxy
**Branch:** `v0` (run `git switch v0`; `git status --short` must be empty, otherwise stop)
**Commit subject:** `Add the routing reverse proxy`
## Goal
Write `internal/proxy`: an `http.Handler` that takes `/{route}/v1/…`, picks a host from the
route's list using the health table, forwards the request with `httputil.ReverseProxy`, streams
the answer back **as it arrives**, and tells the health table when a host fails.
## Context
Clients (OpenCode, Hermes) only know a base URL, so the route is the first path segment:
`http://crossbar:7777/opencode-a/v1/chat/completions`. The upstream must see `/v1/chat/completions`
with the query string kept. Answers are often server-sent-event streams of hundreds of small
chunks over minutes; a proxy that buffers them makes the client look frozen, so **`FlushInterval`
is `-1`** (flush after every write) and anything wrapping the `ResponseWriter` must still
implement `http.Flusher`. Request bodies are looked at once, for a top-level `"model"` field, so
that a request for a model only one host has loaded goes there; the body is then handed to the
upstream unchanged. Bodies are never logged.
## Files
- Copy: `internal/proxy/proxy_test.go`
- Create: `internal/proxy/proxy.go`
- Modify: `docs/implementer-log.md`
## Interfaces
Produces, in `internal/proxy/proxy.go`, package `proxy`:
```go
const MaxBody = 16 << 20 // largest request body we look at
const HostHeader = "X-Crossbar-Host" // set on every proxied response: the host that answered
// Health is what the proxy needs from the health table (internal/health satisfies it).
type Health interface {
Get(name string) (health.Status, bool)
MarkDown(name, reason string)
}
type Handler struct { /* private: *config.Config, Health, *slog.Logger */ }
func New(cfg *config.Config, h Health, log *slog.Logger) *Handler // nil log -> slog.Default()
func (p *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request)
// SplitRoute takes the first path segment as the route.
// "/a/v1/x" -> ("a", "/v1/x", true) "/a" and "/a/" -> ("a", "/", true)
// "/", "//x", "", "noslash/v1" -> ("", "", false)
func SplitRoute(path string) (route, rest string, ok bool)
// Choose: the first host in order that is healthy and lists model in Loaded; failing that, the
// first healthy host; ok == false if none. model may be "".
func Choose(hosts []string, model string, h Health) (string, bool)
```
Rules the tests check, in the order `ServeHTTP` applies them. Every error answer is JSON
`{"error":"<msg>"}` with `Content-Type: application/json`:
1. `SplitRoute(r.URL.Path)` not ok → **400** `missing route`.
2. Route not in `cfg.Routes` → **404** `unknown route`.
3. `rest` must start with `/v1/` or be exactly `/health` or `/props`; else → **404** `not found`.
(`/_crossbar/…` through a route is therefore 404 too.)
4. **Model peek.** For requests other than GET/HEAD with a body: read up to `MaxBody + 1` bytes
(`io.LimitReader`); more than `MaxBody` → **413** `body too large`. Put the bytes back
(`r.Body = io.NopCloser(bytes.NewReader(body))`, `r.ContentLength = len(body)`). Then try to
decode `{"model": "…"}`; a body that is not JSON, or has no model, simply gives `""` — that is
not an error. If the model is `""`, use the route's `DefaultModel`.
5. `Choose(route.Hosts, model, h)` not ok → **503** `no healthy host`. Nothing is marked down by
rules 1–5.
6. **Forward** with a `httputil.ReverseProxy`:
- `Rewrite`: `pr.SetURL(target)` where `target` is the host's `BaseURL` parsed once;
`pr.Out.URL.Path = target.Path + rest`; `pr.Out.URL.RawPath = ""`; `pr.Out.Host =
target.Host`; `pr.SetXForwarded()`. The query string is kept (the tests check
`/v1/models?x=1` arrives as `/v1/models?x=1`).
- `FlushInterval: -1`.
- `ModifyResponse`: set `HostHeader` to the host's name.
- `ErrorHandler`: if `errors.Is(err, context.Canceled)`, do nothing (the client left);
otherwise `h.MarkDown(name, err.Error())` and answer **502**
`{"error":"upstream failed","host":"<name>"}`.
7. **One log line per proxied request**, after it finishes, through the logger:
`p.log.Info("request", "route", …, "host", …, "method", …, "path", rest, "status", …, "ms", …)`.
To know the status, wrap the `ResponseWriter` in a small recorder that implements
`WriteHeader` **and `Flush`** (forwarding to the underlying `http.Flusher`). Without `Flush`
the reverse proxy cannot stream and `TestStreamingIsNotBuffered` fails.
8. Never log, print or keep a request or response body. Never panic on a request.
## Steps
- [ ] **1. Copy.**
```sh
git switch v0
cp docs/plans/v0/files/internal/proxy/proxy_test.go internal/proxy/
```
Read the test. `fakeHealth` stands in for the table; `newUpstream` records what arrived.
`TestStreamingIsNotBuffered` is rule 6/7: the upstream sends one chunk and then *waits until the
test has read it*; a buffering proxy hangs there.
- [ ] **2. See the test fail.** `go test ./internal/proxy/`. Expected: it does not compile.
- [ ] **3. Write `internal/proxy/proxy.go`.** `gofmt -w internal/proxy/`.
- [ ] **4. See the test pass.** `go test -race -count=1 ./internal/proxy/`. Expected: `ok`.
- [ ] **5. Run the gate.** `make gate`. Expected last line: `gate: ok`.
- [ ] **6. Log and commit.** Row `v0/03-proxy`.
```sh
git add internal/proxy docs/implementer-log.md
git commit
```
## Done when
- `go test -race -count=1 ./internal/proxy/` is `ok`; `make gate` prints `gate: ok`.
- `cmp internal/proxy/proxy_test.go docs/plans/v0/files/internal/proxy/proxy_test.go` prints nothing.
## Stop and report if
- `TestStreamingIsNotBuffered` still fails with `FlushInterval: -1` and a recorder that
implements `Flush`: stop and describe exactly what you wrote.