route.go gains Route.Affinity/Queue with PerRoute(), Queues() and affinity validation (checkRoutes moved here; config.go calls it once). limiter.Track counts a request without holding or refusing it; a release hands the slot to a waiter only while in flight <= parallel. The proxy leases a PerRoute() route under an empty fingerprint (the row keeps the real one) and uses Track when Queues() is false. Implemented by Ornith (OpenCode); owner review removed a release-on-first-flush workaround for a race in the owner's given test (see implementer log). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
130 lines
4.2 KiB
Go
130 lines
4.2 KiB
Go
package config
|
|
|
|
import (
|
|
"fmt"
|
|
"regexp"
|
|
"sort"
|
|
"strings"
|
|
)
|
|
|
|
// templateName matches a route template: a valid route name ending in "-*".
|
|
var templateName = regexp.MustCompile(`^[a-z0-9][a-z0-9-]*-\*$`)
|
|
|
|
// routeName matches a route (or template) name: the pattern a concrete or template route key must
|
|
// match, so a name with '*' or an invalid prefix never resolves.
|
|
var routeName = regexp.MustCompile(`^[a-z0-9][a-z0-9-]*$`)
|
|
|
|
// Route is an ordered list of hosts to try, with an optional default model, the peers allowed to
|
|
// reach it, how its requests are placed (affinity), and whether crossbar queues them.
|
|
type Route struct {
|
|
Hosts []string `toml:"hosts"`
|
|
DefaultModel string `toml:"default_model"`
|
|
Peers []string `toml:"peers"`
|
|
Affinity string `toml:"affinity"` // "" or "conversation" (the default), or "route"
|
|
Queue *bool `toml:"queue"` // nil means true
|
|
}
|
|
|
|
// PerRoute reports affinity = "route": every request on the route (chat or control) shares one
|
|
// lease per model, so the route lives on one host.
|
|
func (r Route) PerRoute() bool {
|
|
return r.Affinity == "route"
|
|
}
|
|
|
|
// Queues reports whether the route's requests wait in (and can be refused by) crossbar's per-(host,
|
|
// model) queue; false only for queue = false, which leaves queueing to the client's own slot.
|
|
func (r Route) Queues() bool {
|
|
return r.Queue == nil || *r.Queue
|
|
}
|
|
|
|
// Route resolves a request route name: an exact entry wins; else the longest template
|
|
// "<prefix>-*" whose prefix (including the dash) starts name with a non-empty remainder;
|
|
// else ok is false. key is the config key that matched (the template's name for a template).
|
|
// A name that is not a valid route name (the pattern below) or contains '*' never matches.
|
|
func (c *Config) Route(name string) (r Route, key string, ok bool) {
|
|
if !routeName.MatchString(name) {
|
|
return Route{}, "", false
|
|
}
|
|
if rt, found := c.Routes[name]; found {
|
|
return rt, name, true
|
|
}
|
|
var (
|
|
best Route
|
|
bestKey string
|
|
bestLen int
|
|
)
|
|
for tmpl, rt := range c.Routes {
|
|
if !templateName.MatchString(tmpl) {
|
|
continue
|
|
}
|
|
prefix := tmpl[:len(tmpl)-1] // drop the trailing '*', keeping the dash
|
|
if len(prefix) > bestLen && strings.HasPrefix(name, prefix) && len(name) > len(prefix) {
|
|
best, bestKey, bestLen = rt, tmpl, len(prefix)
|
|
}
|
|
}
|
|
if bestKey == "" {
|
|
return Route{}, "", false
|
|
}
|
|
return best, bestKey, true
|
|
}
|
|
|
|
// checkRoutes validates and defaults one route's hosts, model, affinity and peers in a fixed order.
|
|
// A name that is neither a valid route nor a template, a missing or unknown host, a default model no
|
|
// host serves, an unrecognised affinity, or a peers list that breaks the identity contract each
|
|
// wins as the first error.
|
|
func (c *Config) checkRoutes(peersDefined map[string]bool, identityDefined bool) *Error {
|
|
if len(c.Routes) == 0 {
|
|
return &Error{Field: "routes", Msg: "at least one required"}
|
|
}
|
|
names := make([]string, 0, len(c.Routes))
|
|
for name := range c.Routes {
|
|
names = append(names, name)
|
|
}
|
|
sort.Strings(names)
|
|
for _, name := range names {
|
|
r := c.Routes[name]
|
|
|
|
if !routeName.MatchString(name) && !templateName.MatchString(name) {
|
|
return &Error{Field: fmt.Sprintf("routes.%s", name), Msg: "must match [a-z0-9][a-z0-9-]*"}
|
|
}
|
|
|
|
hostsField := fmt.Sprintf("routes.%s.hosts", name)
|
|
if len(r.Hosts) == 0 {
|
|
return &Error{Field: hostsField, Msg: "at least one required"}
|
|
}
|
|
seen := make(map[string]bool, len(r.Hosts))
|
|
for _, h := range r.Hosts {
|
|
if seen[h] {
|
|
return &Error{Field: hostsField, Msg: "host listed twice"}
|
|
}
|
|
seen[h] = true
|
|
if _, ok := c.Hosts[h]; !ok {
|
|
return &Error{Field: hostsField, Msg: "unknown host"}
|
|
}
|
|
}
|
|
|
|
if r.DefaultModel != "" {
|
|
served := false
|
|
for _, h := range r.Hosts {
|
|
if _, ok := c.Hosts[h].Models[r.DefaultModel]; ok {
|
|
served = true
|
|
break
|
|
}
|
|
}
|
|
if !served {
|
|
return &Error{Field: fmt.Sprintf("routes.%s.default_model", name), Msg: "not served by any host in route"}
|
|
}
|
|
}
|
|
|
|
switch r.Affinity {
|
|
case "", "conversation", "route":
|
|
default:
|
|
return &Error{Field: fmt.Sprintf("routes.%s.affinity", name), Msg: `must be "conversation" or "route"`}
|
|
}
|
|
|
|
if e := checkPeers(name, r.Peers, peersDefined[name], identityDefined, c.Identity); e != nil {
|
|
return e
|
|
}
|
|
}
|
|
return nil
|
|
}
|