Route templates: a route named x-* serves any request route x-<something>
Implemented-By: OpenCode session (model recorded in docs/implementer-log.md)
This commit is contained in:
@@ -144,13 +144,13 @@ func (p *Handler) route(r *http.Request) (route, rest string, code int, msg stri
|
||||
if hdr != "" {
|
||||
rest := r.URL.Path
|
||||
// A path that also carries a (different) route name is a client mistake: the header is the
|
||||
// operator's intent, but the path disagrees.
|
||||
// operator's intent, but the path disagrees. Compare concrete names.
|
||||
if rname, _, ok := SplitRoute(rest); ok {
|
||||
if _, known := p.cfg.Routes[rname]; known && rname != hdr {
|
||||
if _, _, rok := p.cfg.Route(rname); rok && rname != hdr {
|
||||
return "", "", http.StatusBadRequest, "conflicting route"
|
||||
}
|
||||
}
|
||||
if _, known := p.cfg.Routes[hdr]; !known {
|
||||
if _, _, ok := p.cfg.Route(hdr); !ok {
|
||||
return "", "", http.StatusNotFound, "unknown route"
|
||||
}
|
||||
if !allowedPath(rest) {
|
||||
@@ -162,7 +162,7 @@ func (p *Handler) route(r *http.Request) (route, rest string, code int, msg stri
|
||||
if !ok {
|
||||
return "", "", http.StatusBadRequest, "missing route"
|
||||
}
|
||||
if _, known := p.cfg.Routes[route]; !known {
|
||||
if _, _, ok := p.cfg.Route(route); !ok {
|
||||
return "", "", http.StatusNotFound, "unknown route"
|
||||
}
|
||||
if !allowedPath(rest) {
|
||||
@@ -207,7 +207,7 @@ func (p *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
p.writeError(w, code, msg)
|
||||
return
|
||||
}
|
||||
routeCfg := p.cfg.Routes[route]
|
||||
routeCfg, _, _ := p.cfg.Route(route)
|
||||
|
||||
model, body, err := peekModel(r)
|
||||
if err != nil {
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
package proxy_test
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.wntrmute.dev/kyle/crossbar/internal/proxy"
|
||||
"git.wntrmute.dev/kyle/crossbar/internal/store"
|
||||
)
|
||||
|
||||
const templateHosts = `
|
||||
listen = "127.0.0.1:1"
|
||||
[hosts.alpha]
|
||||
base_url = %q
|
||||
models = { "shared" = { parallel = 4 } }
|
||||
[routes."opencode-*"]
|
||||
hosts = ["alpha"]
|
||||
default_model = "shared"
|
||||
[routes.opencode-fixed]
|
||||
hosts = ["alpha"]
|
||||
default_model = "shared"
|
||||
`
|
||||
|
||||
// One OpenCode instance per route, without listing every instance in the config: a route named
|
||||
// "opencode-*" serves any request route "opencode-<something>". Leases and accounting are keyed
|
||||
// by the concrete route name, so two instances never share a lease and each gets its own usage
|
||||
// row. The literal template name is never a request route.
|
||||
func TestRouteTemplateServesConcreteRoutes(t *testing.T) {
|
||||
alpha := newUpstream(t, "alpha")
|
||||
r := newRig(t, templateHosts, alpha)
|
||||
|
||||
resp := r.post("/opencode-projecta-4242/v1/chat/completions", conversation(1, 1))
|
||||
drain(resp)
|
||||
if resp.StatusCode != 200 || resp.Header.Get(proxy.HostHeader) != "alpha" || resp.Header.Get(proxy.LeaseHeader) != "new" {
|
||||
t.Fatalf("first turn on a templated route: %d %q %q, want 200 alpha new", resp.StatusCode, resp.Header.Get(proxy.HostHeader), resp.Header.Get(proxy.LeaseHeader))
|
||||
}
|
||||
resp = r.post("/opencode-projecta-4242/v1/chat/completions", conversation(1, 2))
|
||||
drain(resp)
|
||||
if resp.Header.Get(proxy.LeaseHeader) != "reused" {
|
||||
t.Errorf("second turn should reuse the lease, got %q", resp.Header.Get(proxy.LeaseHeader))
|
||||
}
|
||||
// A second instance with the same conversation shape is a different route: its own lease.
|
||||
resp = r.post("/opencode-projectb-7/v1/chat/completions", conversation(1, 1))
|
||||
drain(resp)
|
||||
if resp.StatusCode != 200 || resp.Header.Get(proxy.LeaseHeader) != "new" {
|
||||
t.Errorf("another instance must get its own lease: %d %q", resp.StatusCode, resp.Header.Get(proxy.LeaseHeader))
|
||||
}
|
||||
// The header form resolves templates too.
|
||||
resp = r.post("/v1/chat/completions", conversation(2, 1), proxy.RouteHeader, "opencode-projectc-1")
|
||||
drain(resp)
|
||||
if resp.StatusCode != 200 {
|
||||
t.Errorf("X-Crossbar-Route with a templated name: %d, want 200", resp.StatusCode)
|
||||
}
|
||||
// An exact route still works and is not shadowed by the template.
|
||||
resp = r.post("/opencode-fixed/v1/chat/completions", conversation(3, 1))
|
||||
drain(resp)
|
||||
if resp.StatusCode != 200 {
|
||||
t.Errorf("exact route: %d, want 200", resp.StatusCode)
|
||||
}
|
||||
|
||||
for _, path := range []string{"/opencode-*/v1/models", "/opencode-/v1/models", "/opencode/v1/models", "/opencodex/v1/models"} {
|
||||
req, _ := http.NewRequest(http.MethodGet, r.front.URL+path, nil)
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
drain(resp)
|
||||
if resp.StatusCode != 404 {
|
||||
t.Errorf("%s: %d, want 404 unknown route", path, resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
rows, _ := r.store.Usage(time.Time{}, store.ByRoute)
|
||||
keys := map[string]int64{}
|
||||
for _, row := range rows {
|
||||
keys[row.Key] = row.Requests
|
||||
}
|
||||
if keys["opencode-projecta-4242"] != 2 || keys["opencode-projectb-7"] != 1 || keys["opencode-projectc-1"] != 1 || keys["opencode-fixed"] != 1 {
|
||||
t.Errorf("usage by route = %v, want rows per concrete route", keys)
|
||||
}
|
||||
if _, present := keys["opencode-*"]; present {
|
||||
t.Errorf("the template name must never be an accounting key: %v", keys)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user