feat: serve a backup model when the requested model fails
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful

Any model can name another model as its backup. The gateway tries the
backup when the model is offline, rate limited, or its upstream fails
before the client has received anything. The response reports the
requested model, so the client sees no switch. The request log records
each failed attempt and the address that served the request, and the
cost uses the backup's prices.
This commit is contained in:
2026-10-15 09:56:01 +02:00
parent 599b5542c6
commit 8bb62a1c20
22 changed files with 949 additions and 231 deletions

View File

@@ -260,9 +260,15 @@ timer and cached in memory.
serving that model and picks one by the configured **routing strategy** serving that model and picks one by the configured **routing strategy**
(`round_robin` | `lowest_cost`, set from the dashboard Routing page). (`round_robin` | `lowest_cost`, set from the dashboard Routing page).
- **Admin overrides** — per-model `enabled`/`blocked`, parameter overrides, - **Admin overrides** — per-model `enabled`/`blocked`, parameter overrides,
pricing, context window, and rate limits live alongside the discovered data pricing, context window, rate limits, and a backup model live alongside the
and survive syncs. Clearing an override in the dashboard falls back to the discovered data and survive syncs. Clearing an override in the dashboard
provider-reported value. falls back to the provider-reported value.
- **Backup models** — any model can name another model as its backup. When
the model is offline, rate limited, or its upstream fails before the client
has received anything, the gateway sends the same request to the backup and
returns the answer under the requested model's name. The client sees no
difference. The request log records each failed attempt, the address that
served the request, and the backup's cost.
- **Curated aliases** — the schema has `models` / `model_targets` tables for - **Curated aliases** — the schema has `models` / `model_targets` tables for
friendly names with priority/weight failover. No code uses them yet. friendly names with priority/weight failover. No code uses them yet.

View File

@@ -45,6 +45,9 @@ export interface AdminModel {
price_cached_per_mtok: number | null price_cached_per_mtok: number | null
rpm_limit: number | null rpm_limit: number | null
tpm_limit: number | null tpm_limit: number | null
/** Backup model served when this one is down or fails. */
fallback_model_id: string | null
fallback_address: string | null
} }
export interface ChatMessage { role: string; content: unknown } export interface ChatMessage { role: string; content: unknown }
@@ -55,6 +58,8 @@ export interface RequestLog {
RequestID: string RequestID: string
KeyPrefix: string KeyPrefix: string
ModelName: string ModelName: string
/** Set when a backup model served the request instead of ModelName. */
ServedModel: string
ProviderName: string ProviderName: string
StatusCode: number StatusCode: number
Streamed: boolean Streamed: boolean
@@ -146,6 +151,7 @@ export interface ModelOverride {
rpm_limit?: number | null rpm_limit?: number | null
tpm_limit?: number | null tpm_limit?: number | null
context_length?: number | null context_length?: number | null
fallback_model_id?: string | null
} }
export type Settings = { export type Settings = {

View File

@@ -43,6 +43,7 @@ function splitThink(s: string): { reasoning: string; answer: string } {
function Detail({ log, content, loading }: { log: RequestLog; content?: LogContent; loading: boolean }) { function Detail({ log, content, loading }: { log: RequestLog; content?: LogContent; loading: boolean }) {
const meta = [ const meta = [
['Request ID', log.RequestID || '—'], ['Request ID', log.RequestID || '—'],
['Served by', log.ServedModel ? `${log.ServedModel} (backup)` : log.ModelName],
['Streamed', log.Streamed ? 'yes' : 'no'], ['Streamed', log.Streamed ? 'yes' : 'no'],
['Prompt tokens', log.PromptTokens.toLocaleString()], ['Prompt tokens', log.PromptTokens.toLocaleString()],
['Cached tokens', log.CachedTokens.toLocaleString()], ['Cached tokens', log.CachedTokens.toLocaleString()],
@@ -204,7 +205,10 @@ export default function Logs() {
onKeyDown={e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); toggle(l.ID) } }} onKeyDown={e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); toggle(l.ID) } }}
className="row row-hover cursor-pointer" style={{ gridTemplateColumns: COLS, borderBottom: 'none' }}> className="row row-hover cursor-pointer" style={{ gridTemplateColumns: COLS, borderBottom: 'none' }}>
<span className="num text-[11px] text-ink-3" title={fmtDateTime(l.CreatedAt)}>{fmtTime(l.CreatedAt)}</span> <span className="num text-[11px] text-ink-3" title={fmtDateTime(l.CreatedAt)}>{fmtTime(l.CreatedAt)}</span>
<span className="mono text-[11.5px] text-accent-3 ellip pr-2" title={l.ModelName}>{l.ModelName}</span> <span className="mono text-[11.5px] text-accent-3 ellip pr-2 flex items-center gap-1.5 min-w-0" title={l.ServedModel ? `${l.ModelName} — served by backup ${l.ServedModel}` : l.ModelName}>
<span className="ellip">{l.ModelName}</span>
{l.ServedModel && <Badge tone="warn" title={`Served by backup ${l.ServedModel}`}>↪ backup</Badge>}
</span>
<span className="text-xs text-ink-2 ellip">{l.ProviderName}</span> <span className="text-xs text-ink-2 ellip">{l.ProviderName}</span>
<span className="mono text-[11px] text-ink-3 ellip">{l.KeyPrefix || '—'}</span> <span className="mono text-[11px] text-ink-3 ellip">{l.KeyPrefix || '—'}</span>
<span className="num text-[11px] text-ink-3" title={`${l.PromptTokens.toLocaleString()} in · ${l.CompletionTokens.toLocaleString()} out`}>{fmtTokens(l.TotalTokens)}</span> <span className="num text-[11px] text-ink-3" title={`${l.PromptTokens.toLocaleString()} in · ${l.CompletionTokens.toLocaleString()} out`}>{fmtTokens(l.TotalTokens)}</span>

View File

@@ -8,7 +8,7 @@ import { Badge, Button, Card, Checkbox, CodeLine, CopyIconButton, Empty, ErrorNo
type Filter = 'all' | 'live' | 'offline' | 'blocked' | 'overridden' type Filter = 'all' | 'live' | 'offline' | 'blocked' | 'overridden'
function hasOverride(m: AdminModel) { function hasOverride(m: AdminModel) {
return m.price_input_per_mtok != null || m.price_output_per_mtok != null || m.price_cached_per_mtok != null || m.rpm_limit != null || m.tpm_limit != null || m.context_length_override != null return m.price_input_per_mtok != null || m.price_output_per_mtok != null || m.price_cached_per_mtok != null || m.rpm_limit != null || m.tpm_limit != null || m.context_length_override != null || m.fallback_model_id != null
} }
function price(v: number | null | undefined) { function price(v: number | null | undefined) {
@@ -24,13 +24,14 @@ function parseField(raw: string, int = false): number | null | 'invalid' {
return n return n
} }
function OverrideModal({ model, onClose, onSaved }: { model: AdminModel; onClose: () => void; onSaved: () => void }) { function OverrideModal({ model, models, onClose, onSaved }: { model: AdminModel; models: AdminModel[]; onClose: () => void; onSaved: () => void }) {
const [inPrice, setInPrice] = useState(price(model.price_input_per_mtok)) const [inPrice, setInPrice] = useState(price(model.price_input_per_mtok))
const [outPrice, setOutPrice] = useState(price(model.price_output_per_mtok)) const [outPrice, setOutPrice] = useState(price(model.price_output_per_mtok))
const [cachedPrice, setCachedPrice] = useState(price(model.price_cached_per_mtok)) const [cachedPrice, setCachedPrice] = useState(price(model.price_cached_per_mtok))
const [rpm, setRpm] = useState(price(model.rpm_limit)) const [rpm, setRpm] = useState(price(model.rpm_limit))
const [tpm, setTpm] = useState(price(model.tpm_limit)) const [tpm, setTpm] = useState(price(model.tpm_limit))
const [ctx, setCtx] = useState(price(model.context_length_override)) const [ctx, setCtx] = useState(price(model.context_length_override))
const [fallback, setFallback] = useState(model.fallback_model_id ?? '')
const [enabled, setEnabled] = useState(model.enabled) const [enabled, setEnabled] = useState(model.enabled)
const [blocked, setBlocked] = useState(model.blocked) const [blocked, setBlocked] = useState(model.blocked)
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
@@ -57,7 +58,7 @@ function OverrideModal({ model, onClose, onSaved }: { model: AdminModel; onClose
} }
setLoading(true) setLoading(true)
try { try {
await api.models.update(model.id, { enabled, blocked, ...(fields as Partial<ModelOverride>) }) await api.models.update(model.id, { enabled, blocked, fallback_model_id: fallback || null, ...(fields as Partial<ModelOverride>) })
onSaved() onSaved()
onClose() onClose()
} catch (e) { } catch (e) {
@@ -68,6 +69,7 @@ function OverrideModal({ model, onClose, onSaved }: { model: AdminModel; onClose
} }
const discIn = model.disc_input_per_mtok, discOut = model.disc_output_per_mtok const discIn = model.disc_input_per_mtok, discOut = model.disc_output_per_mtok
const backups = models.filter(m => m.id !== model.id && !m.blocked && m.enabled)
return ( return (
<Modal title={model.display_name || model.slug} onClose={onClose}> <Modal title={model.display_name || model.slug} onClose={onClose}>
@@ -89,6 +91,12 @@ function OverrideModal({ model, onClose, onSaved }: { model: AdminModel; onClose
<Input mono inputMode="numeric" value={ctx} onChange={e => setCtx(e.target.value)} placeholder={model.context_length != null ? String(model.context_length) : 'unknown'} /> <Input mono inputMode="numeric" value={ctx} onChange={e => setCtx(e.target.value)} placeholder={model.context_length != null ? String(model.context_length) : 'unknown'} />
</Field> </Field>
</div> </div>
<Field label="Backup model" hint="Serves requests for this model when it is offline, rate limited, or its provider fails. The client sees the response under this model's name. The log shows the backup and its cost.">
<Select value={fallback} onChange={e => setFallback(e.target.value)}>
<option value="">None</option>
{backups.map(m => <option key={m.id} value={m.id}>{m.provider_name}/{m.slug}{m.available ? '' : ' (offline)'}</option>)}
</Select>
</Field>
<div className="flex gap-5"> <div className="flex gap-5">
<Checkbox label="Enabled" checked={enabled} onChange={setEnabled} /> <Checkbox label="Enabled" checked={enabled} onChange={setEnabled} />
<Checkbox label="Blocked" tone="bad" checked={blocked} onChange={setBlocked} /> <Checkbox label="Blocked" tone="bad" checked={blocked} onChange={setBlocked} />
@@ -213,7 +221,10 @@ export default function Models() {
<span className="mono text-[12px] text-accent-3 ellip" title={address}>{address}</span> <span className="mono text-[12px] text-accent-3 ellip" title={address}>{address}</span>
<CopyIconButton text={address} title="Copy model address" /> <CopyIconButton text={address} title="Copy model address" />
</div> </div>
<span className="text-xs text-ink-2 ellip" title={m.display_name}>{m.display_name || <span className="text-ink-4">—</span>}</span> <span className="text-xs text-ink-2 ellip flex items-center gap-1.5 min-w-0" title={m.display_name}>
<span className="ellip">{m.display_name || <span className="text-ink-4">—</span>}</span>
{m.fallback_address && <Badge tone="neutral" title={`Backup: ${m.fallback_address}`}>↪ backup</Badge>}
</span>
<span className="num text-[11px] text-ink-3" title={m.context_length_override != null && m.context_length ? `Override. Provider reports ${fmtContext(m.context_length)}` : undefined}> <span className="num text-[11px] text-ink-3" title={m.context_length_override != null && m.context_length ? `Override. Provider reports ${fmtContext(m.context_length)}` : undefined}>
{(m.context_length_override ?? m.context_length) ? fmtContext(m.context_length_override ?? m.context_length!).replace(' ctx', '') : '—'}{m.context_length_override != null && <span className="text-warn">*</span>} {(m.context_length_override ?? m.context_length) ? fmtContext(m.context_length_override ?? m.context_length!).replace(' ctx', '') : '—'}{m.context_length_override != null && <span className="text-warn">*</span>}
</span> </span>
@@ -232,7 +243,7 @@ export default function Models() {
</div> </div>
</Card> </Card>
{selected && <OverrideModal model={selected} onClose={() => setSelected(null)} onSaved={() => { toast('Model updated', 'good'); load() }} />} {selected && <OverrideModal model={selected} models={models} onClose={() => setSelected(null)} onSaved={() => { toast('Model updated', 'good'); load() }} />}
</div> </div>
) )
} }

View File

@@ -5,6 +5,7 @@ import (
"crypto/sha256" "crypto/sha256"
"encoding/hex" "encoding/hex"
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"log/slog" "log/slog"
"net/http" "net/http"
@@ -15,6 +16,7 @@ import (
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"github.com/google/uuid" "github.com/google/uuid"
"github.com/jackc/pgx/v5/pgconn"
"git.govekar.net/LovroG05/localmodelroutex/internal/catalog" "git.govekar.net/LovroG05/localmodelroutex/internal/catalog"
"git.govekar.net/LovroG05/localmodelroutex/internal/providers" "git.govekar.net/LovroG05/localmodelroutex/internal/providers"
@@ -436,6 +438,14 @@ func (a *API) adminUpdateModel(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusBadRequest, "context_length must be 1 or more") writeError(w, http.StatusBadRequest, "context_length must be 1 or more")
return return
} }
if o.FallbackModelID, err = patchField[uuid.UUID](fields, "fallback_model_id"); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
if o.FallbackModelID.Value != nil && *o.FallbackModelID.Value == id {
writeError(w, http.StatusBadRequest, "a model cannot be its own backup")
return
}
for _, p := range []store.Patch[float64]{o.InputPrice, o.OutputPrice, o.CachedPrice} { for _, p := range []store.Patch[float64]{o.InputPrice, o.OutputPrice, o.CachedPrice} {
if p.Value != nil && *p.Value < 0 { if p.Value != nil && *p.Value < 0 {
writeError(w, http.StatusBadRequest, "prices must be 0 or more") writeError(w, http.StatusBadRequest, "prices must be 0 or more")
@@ -450,6 +460,11 @@ func (a *API) adminUpdateModel(w http.ResponseWriter, r *http.Request) {
} }
if err := a.Store.UpdateModelOverride(r.Context(), id, o); err != nil { if err := a.Store.UpdateModelOverride(r.Context(), id, o); err != nil {
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) && pgErr.Code == "23503" /* foreign_key_violation */ {
writeError(w, http.StatusBadRequest, "backup model not found")
return
}
writeError(w, http.StatusInternalServerError, err.Error()) writeError(w, http.StatusInternalServerError, err.Error())
return return
} }

View File

@@ -43,101 +43,123 @@ func (a *API) chatCompletions(w http.ResponseWriter, r *http.Request) {
key := keyFrom(r.Context()) key := keyFrom(r.Context())
address := req.Model address := req.Model
entry, err := a.Cache.Resolve(address) route, err := a.Cache.Route(address)
if err != nil { if err != nil {
writeError(w, http.StatusNotFound, err.Error()) writeError(w, http.StatusNotFound, err.Error())
return return
} }
if !allowed(key, entry.Address) { if !allowed(key, route.Primary) {
writeError(w, http.StatusForbidden, fmt.Sprintf("model %q is not allowed for this key", address)) writeError(w, http.StatusForbidden, fmt.Sprintf("model %q is not allowed for this key", address))
return return
} }
if !a.Limiter.AllowRequest(entry.Address, ptrInt(entry.RPMLimit)) {
writeError(w, http.StatusTooManyRequests, "rate limit exceeded (RPM)")
return
}
if !a.Limiter.AllowTokens(entry.Address, ptrInt(entry.TPMLimit)) {
writeError(w, http.StatusTooManyRequests, "rate limit exceeded (TPM)")
return
}
prov, ok := a.Manager.Get(entry.ProviderName)
if !ok {
writeError(w, http.StatusServiceUnavailable, "provider unavailable")
return
}
cc, ok := prov.(providers.ChatCompleter)
if !ok {
writeError(w, http.StatusNotImplemented, "provider does not support chat completions")
return
}
// Snapshot the messages before we overwrite req.Model below.
messages := marshalMessages(req.Payload["messages"]) messages := marshalMessages(req.Payload["messages"])
req.Model = entry.Slug var last *failure
applyOverrides(req.Payload, entry.ParamOverrides) for i, entry := range route.Targets {
if i > 0 && r.Context().Err() != nil {
start := time.Now() break // the client is gone; a backup cannot help
if req.Stream { }
a.streamChat(w, r, cc, req, entry, key, address, messages, start) prov, fail := a.admit(entry, 1)
return if fail != nil {
last = fail
continue
}
cc, ok := prov.(providers.ChatCompleter)
if !ok {
last = &failure{status: http.StatusNotImplemented, msg: "provider does not support chat completions"}
continue
}
attempt := &providers.ChatRequest{Model: entry.Slug, Stream: req.Stream, Payload: targetPayload(req.Payload, entry)}
next := nextTarget(route, i)
start := time.Now()
if req.Stream {
fail = a.streamChat(w, r, cc, attempt, entry, route, next, key, messages, start)
} else {
fail = a.completeChat(w, r, cc, attempt, entry, route, next, key, messages, start)
}
if fail == nil {
return
}
last = fail
} }
last.write(w)
}
// completeChat serves a non-streaming completion from one target. It returns
// nil once the client has an answer, or the failure when the request should
// move on to next. An upstream error with no backup behind it is relayed.
func (a *API) completeChat(w http.ResponseWriter, r *http.Request, cc providers.ChatCompleter, req *providers.ChatRequest, entry *catalog.Entry, route *catalog.Route, next *catalog.Entry, key *store.VirtualKey, messages json.RawMessage, start time.Time) *failure {
resp, err := cc.ChatCompletion(r.Context(), req) resp, err := cc.ChatCompletion(r.Context(), req)
if err != nil { if err != nil {
writeError(w, http.StatusBadGateway, err.Error()) a.logRequest(r, key, entry, route, http.StatusBadGateway, false, providers.Usage{}, err.Error()+switchNote(next), start, nil, messages, "")
a.logRequest(r, key, entry, address, http.StatusBadGateway, false, providers.Usage{}, err.Error(), start, nil, messages, "") return &failure{status: http.StatusBadGateway, msg: err.Error()}
return
} }
a.Limiter.RecordTokens(entry.Address, resp.Usage.TotalTokens) a.Limiter.RecordTokens(entry.Address, resp.Usage.TotalTokens)
respBody := resp.Body
errMsg := ""
if resp.StatusCode >= 400 { if resp.StatusCode >= 400 {
errMsg = upstreamErrorMessage(resp.Body) errMsg := upstreamErrorMessage(resp.Body)
} else { if next != nil && retryable(resp.StatusCode) {
respBody = injectCreated(resp.Body) a.logRequest(r, key, entry, route, resp.StatusCode, false, resp.Usage, errMsg+switchNote(next), start, nil, messages, "")
return &failure{status: resp.StatusCode, body: resp.Body}
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(resp.StatusCode)
_, _ = w.Write(resp.Body)
a.logRequest(r, key, entry, route, resp.StatusCode, false, resp.Usage, errMsg, start, nil, messages, "")
return nil
} }
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
w.WriteHeader(resp.StatusCode) w.WriteHeader(resp.StatusCode)
_, _ = w.Write(respBody) _, _ = w.Write(clientBody(resp.Body, time.Now().Unix(), reportedModel(route, entry)))
completion := extractCompletion(resp.Body) a.logRequest(r, key, entry, route, resp.StatusCode, false, resp.Usage, "", start, nil, messages, extractCompletion(resp.Body))
a.logRequest(r, key, entry, address, resp.StatusCode, false, resp.Usage, errMsg, start, nil, messages, completion) return nil
} }
func (a *API) streamChat(w http.ResponseWriter, r *http.Request, cc providers.ChatCompleter, req *providers.ChatRequest, entry *catalog.Entry, key *store.VirtualKey, address string, messages json.RawMessage, start time.Time) { // streamChat serves a streaming completion from one target. The first chunk is
// read before the status line goes out, so an upstream that fails at once can
// still hand the request to a backup. After the first byte reaches the
// client, a failure can only be recorded in the log.
func (a *API) streamChat(w http.ResponseWriter, r *http.Request, cc providers.ChatCompleter, req *providers.ChatRequest, entry *catalog.Entry, route *catalog.Route, next *catalog.Entry, key *store.VirtualKey, messages json.RawMessage, start time.Time) *failure {
stream, err := cc.ChatCompletionStream(r.Context(), req) stream, err := cc.ChatCompletionStream(r.Context(), req)
if err != nil { if err != nil {
var apiErr *providers.APIError var apiErr *providers.APIError
if errors.As(err, &apiErr) { if errors.As(err, &apiErr) {
errMsg := upstreamErrorMessage(apiErr.Body)
if next != nil && retryable(apiErr.StatusCode) {
a.logRequest(r, key, entry, route, apiErr.StatusCode, true, providers.Usage{}, errMsg+switchNote(next), start, nil, messages, "")
return &failure{status: apiErr.StatusCode, body: apiErr.Body}
}
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
w.WriteHeader(apiErr.StatusCode) w.WriteHeader(apiErr.StatusCode)
_, _ = w.Write(apiErr.Body) _, _ = w.Write(apiErr.Body)
a.logRequest(r, key, entry, address, apiErr.StatusCode, true, providers.Usage{}, upstreamErrorMessage(apiErr.Body), start, nil, messages, "") a.logRequest(r, key, entry, route, apiErr.StatusCode, true, providers.Usage{}, errMsg, start, nil, messages, "")
return return nil
} }
writeError(w, http.StatusBadGateway, err.Error()) a.logRequest(r, key, entry, route, http.StatusBadGateway, true, providers.Usage{}, err.Error()+switchNote(next), start, nil, messages, "")
a.logRequest(r, key, entry, address, http.StatusBadGateway, true, providers.Usage{}, err.Error(), start, nil, messages, "") return &failure{status: http.StatusBadGateway, msg: err.Error()}
return
} }
defer stream.Close() defer stream.Close()
ev, err := stream.Recv()
if err != nil && !errors.Is(err, io.EOF) {
errMsg := "stream interrupted: " + err.Error()
a.logRequest(r, key, entry, route, http.StatusBadGateway, true, providers.Usage{}, errMsg+switchNote(next), start, nil, messages, "")
return &failure{status: http.StatusBadGateway, msg: errMsg}
}
flusher, _ := w.(http.Flusher) flusher, _ := w.(http.Flusher)
w.Header().Set("Content-Type", "text/event-stream") w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache") w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive") w.Header().Set("Connection", "keep-alive")
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
model := reportedModel(route, entry)
var ttft *int var ttft *int
var reasoning, content strings.Builder var reasoning, content strings.Builder
created := time.Now().Unix() created := time.Now().Unix()
errMsg := "" errMsg := ""
for { for !errors.Is(err, io.EOF) {
ev, err := stream.Recv()
if errors.Is(err, io.EOF) {
break
}
if err != nil { if err != nil {
// The status line is already sent, so the error can only be // The status line is already sent, so the error can only be
// recorded in the log row and signalled by closing the stream. // recorded in the log row and signalled by closing the stream.
@@ -154,13 +176,14 @@ func (a *API) streamChat(w http.ResponseWriter, r *http.Request, cc providers.Ch
} }
// Accumulate reasoning and content deltas separately so the recorded // Accumulate reasoning and content deltas separately so the recorded
// completion wraps reasoning in think tags, matching extractCompletion. // completion wraps reasoning in think tags, matching extractCompletion.
r, c := extractDelta(ev.Data) rs, c := extractDelta(ev.Data)
reasoning.WriteString(r) reasoning.WriteString(rs)
content.WriteString(c) content.WriteString(c)
_, _ = fmt.Fprintf(w, "data: %s\n\n", injectCreatedChunk(ev.Data, created)) _, _ = fmt.Fprintf(w, "data: %s\n\n", clientBody(ev.Data, created, model))
if flusher != nil { if flusher != nil {
flusher.Flush() flusher.Flush()
} }
ev, err = stream.Recv()
} }
_, _ = fmt.Fprint(w, "data: [DONE]\n\n") _, _ = fmt.Fprint(w, "data: [DONE]\n\n")
if flusher != nil { if flusher != nil {
@@ -173,21 +196,26 @@ func (a *API) streamChat(w http.ResponseWriter, r *http.Request, cc providers.Ch
} }
usage := stream.Usage() usage := stream.Usage()
a.Limiter.RecordTokens(entry.Address, usage.TotalTokens) a.Limiter.RecordTokens(entry.Address, usage.TotalTokens)
a.logRequest(r, key, entry, address, http.StatusOK, true, usage, errMsg, start, ttft, messages, completion) a.logRequest(r, key, entry, route, http.StatusOK, true, usage, errMsg, start, ttft, messages, completion)
return nil
} }
func applyOverrides(payload, overrides map[string]any) { // logRequest records one attempt. ModelName is the address the client sent;
maps.Copy(payload, overrides) // when a backup served the request, ServedModel names it and Cost uses its
} // prices, so the switch is visible in the log but not to the client.
func (a *API) logRequest(r *http.Request, key *store.VirtualKey, entry *catalog.Entry, route *catalog.Route, status int, streamed bool, usage providers.Usage, errMsg string, start time.Time, ttft *int, messages json.RawMessage, completion string) {
func (a *API) logRequest(r *http.Request, key *store.VirtualKey, entry *catalog.Entry, address string, status int, streamed bool, usage providers.Usage, errMsg string, start time.Time, ttft *int, messages json.RawMessage, completion string) {
entryID := entry.ID entryID := entry.ID
providerID := entry.ProviderID providerID := entry.ProviderID
served := ""
if route.IsFallback(entry) {
served = entry.Address
}
rec := store.RequestLog{ rec := store.RequestLog{
ProviderModelID: &entryID, ProviderModelID: &entryID,
ProviderID: &providerID, ProviderID: &providerID,
ProviderName: entry.ProviderName, ProviderName: entry.ProviderName,
ModelName: address, ModelName: route.Address,
ServedModel: served,
StatusCode: status, StatusCode: status,
Streamed: streamed, Streamed: streamed,
Error: errMsg, Error: errMsg,
@@ -381,34 +409,3 @@ func clientIP(r *http.Request) string {
} }
return host return host
} }
// injectCreated ensures a non-streaming response has a "created" timestamp.
// If the upstream already provided one, it is preserved.
func injectCreated(body []byte) []byte {
var obj map[string]any
if json.Unmarshal(body, &obj) == nil {
if _, ok := obj["created"]; ok {
return body // upstream already set it
}
obj["created"] = time.Now().Unix()
if out, err := json.Marshal(obj); err == nil {
return out
}
}
return body // fall back to raw body on any error
}
// injectCreatedChunk ensures a streaming SSE chunk has a "created" timestamp.
func injectCreatedChunk(data []byte, created int64) []byte {
var obj map[string]any
if json.Unmarshal(data, &obj) == nil {
if _, ok := obj["created"]; ok {
return data // upstream already set it
}
obj["created"] = created
if out, err := json.Marshal(obj); err == nil {
return out
}
}
return data // fall back to raw chunk on any error
}

View File

@@ -28,57 +28,62 @@ func (a *API) embeddings(w http.ResponseWriter, r *http.Request) {
key := keyFrom(r.Context()) key := keyFrom(r.Context())
address := req.Model address := req.Model
entry, err := a.Cache.Resolve(address) route, err := a.Cache.Route(address)
if err != nil { if err != nil {
writeError(w, http.StatusNotFound, err.Error()) writeError(w, http.StatusNotFound, err.Error())
return return
} }
if !allowed(key, entry.Address) { if !allowed(key, route.Primary) {
writeError(w, http.StatusForbidden, fmt.Sprintf("model %q is not allowed for this key", address)) writeError(w, http.StatusForbidden, fmt.Sprintf("model %q is not allowed for this key", address))
return return
} }
if !a.Limiter.AllowRequest(entry.Address, ptrInt(entry.RPMLimit)) {
writeError(w, http.StatusTooManyRequests, "rate limit exceeded (RPM)") var last *failure
for i, entry := range route.Targets {
if i > 0 && r.Context().Err() != nil {
break
}
prov, fail := a.admit(entry, 1)
if fail != nil {
last = fail
continue
}
emb, ok := prov.(providers.Embedder)
if !ok {
last = &failure{status: http.StatusNotImplemented, msg: "provider does not support embeddings"}
continue
}
attempt := &providers.EmbeddingRequest{Model: entry.Slug, Payload: targetPayload(req.Payload, entry)}
next := nextTarget(route, i)
start := time.Now()
resp, err := emb.Embeddings(r.Context(), attempt)
if err != nil {
a.logRequest(r, key, entry, route, http.StatusBadGateway, false, providers.Usage{}, err.Error()+switchNote(next), start, nil, nil, "")
last = &failure{status: http.StatusBadGateway, msg: err.Error()}
continue
}
a.Limiter.RecordTokens(entry.Address, resp.Usage.TotalTokens)
errMsg := ""
body := resp.Body
if resp.StatusCode >= 400 {
errMsg = upstreamErrorMessage(resp.Body)
if next != nil && retryable(resp.StatusCode) {
a.logRequest(r, key, entry, route, resp.StatusCode, false, resp.Usage, errMsg+switchNote(next), start, nil, nil, "")
last = &failure{status: resp.StatusCode, body: resp.Body}
continue
}
} else {
body = withModel(resp.Body, reportedModel(route, entry))
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(resp.StatusCode)
_, _ = w.Write(body)
a.logRequest(r, key, entry, route, resp.StatusCode, false, resp.Usage, errMsg, start, nil, nil, "")
return return
} }
if !a.Limiter.AllowTokens(entry.Address, ptrInt(entry.TPMLimit)) { last.write(w)
writeError(w, http.StatusTooManyRequests, "rate limit exceeded (TPM)")
return
}
prov, ok := a.Manager.Get(entry.ProviderName)
if !ok {
writeError(w, http.StatusServiceUnavailable, "provider unavailable")
return
}
emb, ok := prov.(providers.Embedder)
if !ok {
writeError(w, http.StatusNotImplemented, "provider does not support embeddings")
return
}
req.Model = entry.Slug
applyOverrides(req.Payload, entry.ParamOverrides)
start := time.Now()
resp, err := emb.Embeddings(r.Context(), req)
if err != nil {
writeError(w, http.StatusBadGateway, err.Error())
a.logRequest(r, key, entry, address, http.StatusBadGateway, false, providers.Usage{}, err.Error(), start, nil, nil, "")
return
}
a.Limiter.RecordTokens(entry.Address, resp.Usage.TotalTokens)
errMsg := ""
if resp.StatusCode >= 400 {
errMsg = upstreamErrorMessage(resp.Body)
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(resp.StatusCode)
_, _ = w.Write(resp.Body)
a.logRequest(r, key, entry, address, resp.StatusCode, false, resp.Usage, errMsg, start, nil, nil, "")
} }
// ptrInt dereferences an *int, returning 0 (no limit) when nil. // ptrInt dereferences an *int, returning 0 (no limit) when nil.

View File

@@ -47,69 +47,73 @@ func (a *API) imageGenerations(w http.ResponseWriter, r *http.Request) {
key := keyFrom(r.Context()) key := keyFrom(r.Context())
address := req.Model address := req.Model
entry, err := a.Cache.Resolve(address) route, err := a.Cache.Route(address)
if err != nil { if err != nil {
writeError(w, http.StatusNotFound, err.Error()) writeError(w, http.StatusNotFound, err.Error())
return return
} }
if !allowed(key, entry.Address) { if !allowed(key, route.Primary) {
writeError(w, http.StatusForbidden, fmt.Sprintf("model %q is not allowed for this key", address)) writeError(w, http.StatusForbidden, fmt.Sprintf("model %q is not allowed for this key", address))
return return
} }
if !slices.Contains(entry.Capabilities, string(providers.CapabilityImages)) {
writeError(w, http.StatusBadRequest, fmt.Sprintf("model %q does not generate images", address))
return
}
// Each requested image is one upstream call, so charge all of them
// against the RPM limit up front.
if !a.Limiter.AllowRequests(entry.Address, ptrInt(entry.RPMLimit), max(req.N, 1)) {
writeError(w, http.StatusTooManyRequests, "rate limit exceeded (RPM)")
return
}
if !a.Limiter.AllowTokens(entry.Address, ptrInt(entry.TPMLimit)) {
writeError(w, http.StatusTooManyRequests, "rate limit exceeded (TPM)")
return
}
prov, ok := a.Manager.Get(entry.ProviderName)
if !ok {
writeError(w, http.StatusServiceUnavailable, "provider unavailable")
return
}
gen, ok := prov.(providers.ImageGenerator)
if !ok {
writeError(w, http.StatusNotImplemented, "provider does not support image generation")
return
}
// Log the prompt as a one-message conversation so it shows in the request // Log the prompt as a one-message conversation so it shows in the request
// log like a chat turn. // log like a chat turn.
messages, _ := json.Marshal([]map[string]any{{"role": "user", "content": req.Prompt}}) messages, _ := json.Marshal([]map[string]any{{"role": "user", "content": req.Prompt}})
req.Model = entry.Slug var last *failure
applyOverrides(req.Payload, entry.ParamOverrides) for i, entry := range route.Targets {
if i > 0 && r.Context().Err() != nil {
break
}
if !slices.Contains(entry.Capabilities, string(providers.CapabilityImages)) {
last = &failure{status: http.StatusBadRequest, msg: fmt.Sprintf("model %q does not generate images", address)}
continue
}
// Each requested image is one upstream call, so charge all of them
// against the RPM limit up front.
prov, fail := a.admit(entry, max(req.N, 1))
if fail != nil {
last = fail
continue
}
gen, ok := prov.(providers.ImageGenerator)
if !ok {
last = &failure{status: http.StatusNotImplemented, msg: "provider does not support image generation"}
continue
}
attempt := *req
attempt.Model = entry.Slug
attempt.Payload = targetPayload(req.Payload, entry)
next := nextTarget(route, i)
start := time.Now()
resp, err := gen.ImageGeneration(r.Context(), &attempt)
if err != nil {
a.logRequest(r, key, entry, route, http.StatusBadGateway, false, providers.Usage{}, err.Error()+switchNote(next), start, nil, messages, "")
last = &failure{status: http.StatusBadGateway, msg: err.Error()}
continue
}
a.Limiter.RecordTokens(entry.Address, resp.Usage.TotalTokens)
start := time.Now() errMsg := ""
resp, err := gen.ImageGeneration(r.Context(), req) completion := ""
if err != nil { if resp.StatusCode >= 400 {
writeError(w, http.StatusBadGateway, err.Error()) errMsg = upstreamErrorMessage(resp.Body)
a.logRequest(r, key, entry, address, http.StatusBadGateway, false, providers.Usage{}, err.Error(), start, nil, messages, "") if next != nil && retryable(resp.StatusCode) {
a.logRequest(r, key, entry, route, resp.StatusCode, false, resp.Usage, errMsg+switchNote(next), start, nil, messages, "")
last = &failure{status: resp.StatusCode, body: resp.Body}
continue
}
} else {
completion = imageCompletionSummary(resp.Body)
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(resp.StatusCode)
_, _ = w.Write(resp.Body)
a.logRequest(r, key, entry, route, resp.StatusCode, false, resp.Usage, errMsg, start, nil, messages, completion)
return return
} }
a.Limiter.RecordTokens(entry.Address, resp.Usage.TotalTokens) last.write(w)
errMsg := ""
completion := ""
if resp.StatusCode >= 400 {
errMsg = upstreamErrorMessage(resp.Body)
} else {
completion = imageCompletionSummary(resp.Body)
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(resp.StatusCode)
_, _ = w.Write(resp.Body)
a.logRequest(r, key, entry, address, resp.StatusCode, false, resp.Usage, errMsg, start, nil, messages, completion)
} }
// imageCompletionSummary describes an images response for the content log // imageCompletionSummary describes an images response for the content log

View File

@@ -76,17 +76,27 @@ func (f *fakeLogs) TouchVirtualKey(_ context.Context, id uuid.UUID) error {
return nil return nil
} }
// wait blocks until one request has been fully logged. // waitN blocks until n requests have been logged and returns the rows in the
func (f *fakeLogs) wait(t *testing.T) store.RequestLog { // order they were written.
func (f *fakeLogs) waitN(t *testing.T, n int) []store.RequestLog {
t.Helper() t.Helper()
select { for range n {
case <-f.done: select {
case <-time.After(3 * time.Second): case <-f.done:
t.Fatal("request log was not written") case <-time.After(3 * time.Second):
t.Fatal("request log was not written")
}
} }
f.mu.Lock() f.mu.Lock()
defer f.mu.Unlock() defer f.mu.Unlock()
return f.rows[len(f.rows)-1] return append([]store.RequestLog(nil), f.rows...)
}
// wait blocks until one request has been fully logged.
func (f *fakeLogs) wait(t *testing.T) store.RequestLog {
t.Helper()
rows := f.waitN(t, 1)
return rows[len(rows)-1]
} }
// upstream is a fake OpenRouter that records the last body it received. // upstream is a fake OpenRouter that records the last body it received.
@@ -473,3 +483,187 @@ func TestAllowedPatterns(t *testing.T) {
t.Error("allow-all key must pass") t.Error("allow-all key must pass")
} }
} }
// failPrimary answers gpt-4o with status and lets every other model through
// to chatReply.
func failPrimary(status int) func(http.ResponseWriter, *http.Request, map[string]any) {
return func(w http.ResponseWriter, r *http.Request, body map[string]any) {
if body["model"] == "openai/gpt-4o" {
w.WriteHeader(status)
_, _ = io.WriteString(w, `{"error":{"message":"primary down"}}`)
return
}
chatReply(w, r, body)
}
}
// withBackup makes the image model the backup of gpt-4o, at a different price.
func withBackup(rows []store.CatalogRow) {
id := rows[0].ID
rows[1].FallbackModelID = &id
rows[0].InputPricePerMTok = 1
rows[0].OutputPricePerMTok = 2
}
// rowsByStatus indexes log rows by status code.
func rowsByStatus(rows []store.RequestLog) map[int]store.RequestLog {
out := map[int]store.RequestLog{}
for _, r := range rows {
out[r.StatusCode] = r
}
return out
}
func TestChatCompletionsFallsBackOnUpstreamError(t *testing.T) {
env := newTestEnv(t, failPrimary(http.StatusServiceUnavailable), withBackup)
rec := call(env.api.chatCompletions, allowAllKey(), http.MethodPost,
`{"model":"or/openai/gpt-4o","messages":[{"role":"user","content":"hi"}]}`)
if rec.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", rec.Code, rec.Body)
}
// The client sees the model it asked for.
var out map[string]any
_ = json.Unmarshal(rec.Body.Bytes(), &out)
if out["model"] != "openai/gpt-4o" {
t.Errorf("reported model = %v, want the requested one", out["model"])
}
if calls, body := env.upstream.last(); calls != 2 || body["model"] != "google/gemini-2.5-flash-image" {
t.Errorf("upstream calls=%d last model=%v", calls, body["model"])
}
// The log sees both attempts and the backup's cost.
rows := rowsByStatus(env.logs.waitN(t, 2))
failed, ok := rows[http.StatusServiceUnavailable]
if !ok || !strings.Contains(failed.Error, "primary down") || !strings.Contains(failed.Error, "switched to backup or/google/gemini-2.5-flash-image") {
t.Errorf("failed attempt row = %+v", failed)
}
if failed.ServedModel != "" || failed.ModelName != "or/openai/gpt-4o" {
t.Errorf("failed attempt must be logged against the requested model: %+v", failed)
}
served := rows[http.StatusOK]
if served.ModelName != "or/openai/gpt-4o" || served.ServedModel != "or/google/gemini-2.5-flash-image" {
t.Errorf("served row = %+v", served)
}
want := 100.0/1e6*1 + 50.0/1e6*2
if served.Cost < want-1e-12 || served.Cost > want+1e-12 {
t.Errorf("cost = %v, want the backup's price %v", served.Cost, want)
}
}
func TestChatCompletionsStreamFallsBack(t *testing.T) {
env := newTestEnv(t, failPrimary(http.StatusBadGateway), withBackup)
rec := call(env.api.chatCompletions, allowAllKey(), http.MethodPost,
`{"model":"or/openai/gpt-4o","messages":[],"stream":true}`)
if rec.Code != http.StatusOK || rec.Header().Get("Content-Type") != "text/event-stream" {
t.Fatalf("status=%d type=%q body=%s", rec.Code, rec.Header().Get("Content-Type"), rec.Body)
}
body := rec.Body.String()
if strings.Count(body, "data: ") != 4 || strings.Count(body, `"model":"openai/gpt-4o"`) != 3 {
t.Errorf("stream body = %q", body)
}
rows := rowsByStatus(env.logs.waitN(t, 2))
if rows[http.StatusOK].ServedModel != "or/google/gemini-2.5-flash-image" || !rows[http.StatusOK].Streamed {
t.Errorf("served row = %+v", rows[http.StatusOK])
}
}
func TestChatCompletionsUnavailableModelServedByBackup(t *testing.T) {
env := newTestEnv(t, chatReply, func(rows []store.CatalogRow) {
withBackup(rows)
rows[1].Available = false
})
rec := call(env.api.chatCompletions, allowAllKey(), http.MethodPost,
`{"model":"or/openai/gpt-4o","messages":[]}`)
if rec.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", rec.Code, rec.Body)
}
var out map[string]any
_ = json.Unmarshal(rec.Body.Bytes(), &out)
if out["model"] != "openai/gpt-4o" {
t.Errorf("reported model = %v", out["model"])
}
if calls, _ := env.upstream.last(); calls != 1 {
t.Errorf("upstream calls = %d, want 1", calls)
}
row := env.logs.wait(t)
if row.ModelName != "or/openai/gpt-4o" || row.ServedModel != "or/google/gemini-2.5-flash-image" {
t.Errorf("log row = %+v", row)
}
// The down model stays listed, because clients can still call it.
list := call(env.api.listModels, allowAllKey(), http.MethodGet, "")
if !strings.Contains(list.Body.String(), `"or/openai/gpt-4o"`) {
t.Errorf("models list = %s", list.Body)
}
}
func TestChatCompletionsRateLimitOverflowsToBackup(t *testing.T) {
env := newTestEnv(t, chatReply, func(rows []store.CatalogRow) {
withBackup(rows)
one := 1
rows[1].RPMLimit = &one
})
body := `{"model":"or/openai/gpt-4o","messages":[]}`
if rec := call(env.api.chatCompletions, allowAllKey(), http.MethodPost, body); rec.Code != http.StatusOK {
t.Fatalf("first call status=%d", rec.Code)
}
if row := env.logs.wait(t); row.ServedModel != "" {
t.Errorf("first call must use the requested model: %+v", row)
}
if rec := call(env.api.chatCompletions, allowAllKey(), http.MethodPost, body); rec.Code != http.StatusOK {
t.Fatalf("second call status=%d body=%s", rec.Code, rec.Body)
}
if row := env.logs.wait(t); row.ServedModel != "or/google/gemini-2.5-flash-image" {
t.Errorf("second call must overflow to the backup: %+v", row)
}
}
func TestChatCompletionsClientErrorIsNotRetried(t *testing.T) {
env := newTestEnv(t, failPrimary(http.StatusBadRequest), withBackup)
rec := call(env.api.chatCompletions, allowAllKey(), http.MethodPost,
`{"model":"or/openai/gpt-4o","messages":[]}`)
if rec.Code != http.StatusBadRequest || !strings.Contains(rec.Body.String(), "primary down") {
t.Errorf("status=%d body=%s", rec.Code, rec.Body)
}
if calls, _ := env.upstream.last(); calls != 1 {
t.Errorf("upstream calls = %d, want 1", calls)
}
if row := env.logs.wait(t); strings.Contains(row.Error, "switched") {
t.Errorf("log row = %+v", row)
}
}
func TestChatCompletionsLastFailureIsRelayed(t *testing.T) {
// Both the model and its backup are down: the client gets the last error.
env := newTestEnv(t, func(w http.ResponseWriter, _ *http.Request, _ map[string]any) {
w.WriteHeader(http.StatusServiceUnavailable)
_, _ = io.WriteString(w, `{"error":{"message":"all down"}}`)
}, withBackup)
rec := call(env.api.chatCompletions, allowAllKey(), http.MethodPost,
`{"model":"or/openai/gpt-4o","messages":[]}`)
if rec.Code != http.StatusServiceUnavailable || !strings.Contains(rec.Body.String(), "all down") {
t.Errorf("status=%d body=%s", rec.Code, rec.Body)
}
if calls, _ := env.upstream.last(); calls != 2 {
t.Errorf("upstream calls = %d, want 2", calls)
}
env.logs.waitN(t, 2)
}
func TestEmbeddingsFallBack(t *testing.T) {
env := newTestEnv(t, func(w http.ResponseWriter, _ *http.Request, body map[string]any) {
if body["model"] == "openai/gpt-4o" {
w.WriteHeader(http.StatusInternalServerError)
return
}
_, _ = io.WriteString(w, `{"object":"list","model":"backup","data":[{"embedding":[0.1]}],"usage":{"prompt_tokens":4,"total_tokens":4}}`)
}, withBackup)
rec := call(env.api.embeddings, allowAllKey(), http.MethodPost, `{"model":"or/openai/gpt-4o","input":"x"}`)
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), `"model":"openai/gpt-4o"`) {
t.Errorf("status=%d body=%s", rec.Code, rec.Body)
}
rows := rowsByStatus(env.logs.waitN(t, 2))
if rows[http.StatusOK].ServedModel != "or/google/gemini-2.5-flash-image" {
t.Errorf("served row = %+v", rows[http.StatusOK])
}
}

View File

@@ -0,0 +1,132 @@
package api
import (
"encoding/json"
"maps"
"net/http"
"git.govekar.net/LovroG05/localmodelroutex/internal/catalog"
"git.govekar.net/LovroG05/localmodelroutex/internal/providers"
)
// The proxy handlers walk a route's targets in order. The requested model is
// tried first. When it is rate limited, has no provider, or its upstream
// fails before the client has received anything, the next backup is tried
// with the same request. A backup answers under the requested model's name,
// so the client cannot tell the difference. The request log can: each failed
// attempt gets its own row, and the row for the backup carries both the
// requested address and the address that served it, with the backup's cost.
// failure is one target's refusal. When no target answers, the last failure
// is sent to the client.
type failure struct {
status int
body []byte // upstream error payload to relay; nil writes msg as a gateway error
msg string
}
func (f *failure) write(w http.ResponseWriter) {
if f.body != nil {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(f.status)
_, _ = w.Write(f.body)
return
}
writeError(w, f.status, f.msg)
}
// retryable reports whether an upstream status means the request should move
// on to a backup. Client errors are not retried: a backup would get the same
// request and most likely give the same answer.
func retryable(status int) bool {
return status == http.StatusRequestTimeout || status == http.StatusTooManyRequests || status >= http.StatusInternalServerError
}
// admit charges requests against a target's rate limits and returns its
// provider. The failure says why the target cannot serve.
func (a *API) admit(e *catalog.Entry, requests int) (providers.Provider, *failure) {
if !a.Limiter.AllowRequests(e.Address, ptrInt(e.RPMLimit), requests) {
return nil, &failure{status: http.StatusTooManyRequests, msg: "rate limit exceeded (RPM)"}
}
if !a.Limiter.AllowTokens(e.Address, ptrInt(e.TPMLimit)) {
return nil, &failure{status: http.StatusTooManyRequests, msg: "rate limit exceeded (TPM)"}
}
prov, ok := a.Manager.Get(e.ProviderName)
if !ok {
return nil, &failure{status: http.StatusServiceUnavailable, msg: "provider unavailable"}
}
return prov, nil
}
// targetPayload copies the client payload for one target and applies the
// target's parameter overrides. Each target gets its own copy, so the
// overrides of a failed target do not leak into the request to its backup.
func targetPayload(payload map[string]any, e *catalog.Entry) map[string]any {
out := maps.Clone(payload)
maps.Copy(out, e.ParamOverrides)
return out
}
// switchNote is appended to the log row of a failed attempt when the request
// moves on to a backup. Empty when there is no backup after the attempt.
func switchNote(next *catalog.Entry) string {
if next == nil {
return ""
}
return "; switched to backup " + next.Address
}
// nextTarget returns the target after index i, or nil.
func nextTarget(route *catalog.Route, i int) *catalog.Entry {
if i+1 < len(route.Targets) {
return route.Targets[i+1]
}
return nil
}
// reportedModel is the model id a response from e must carry. A backup
// reports the requested model's id; the requested model keeps what its
// upstream sent.
func reportedModel(route *catalog.Route, e *catalog.Entry) string {
if route.IsFallback(e) {
return route.Slug
}
return ""
}
// editJSON decodes an object body, applies edit, and encodes it again. A body
// that is not a JSON object is returned unchanged.
func editJSON(body []byte, edit func(map[string]any)) []byte {
var obj map[string]any
if json.Unmarshal(body, &obj) != nil || obj == nil {
return body
}
edit(obj)
out, err := json.Marshal(obj)
if err != nil {
return body
}
return out
}
// clientBody prepares an upstream chat body or chunk for the client. A missing
// "created" timestamp is added, and when model is set the reported model is
// replaced with it.
func clientBody(body []byte, created int64, model string) []byte {
return editJSON(body, func(obj map[string]any) {
if _, ok := obj["created"]; !ok {
obj["created"] = created
}
if model != "" {
obj["model"] = model
}
})
}
// withModel replaces the reported model when model is set.
func withModel(body []byte, model string) []byte {
if model == "" {
return body
}
return editJSON(body, func(obj map[string]any) { obj["model"] = model })
}

View File

@@ -8,6 +8,8 @@ import (
"sync" "sync"
"sync/atomic" "sync/atomic"
"github.com/google/uuid"
"git.govekar.net/LovroG05/localmodelroutex/internal/store" "git.govekar.net/LovroG05/localmodelroutex/internal/store"
) )
@@ -23,17 +25,26 @@ type Cache struct {
mu sync.RWMutex mu sync.RWMutex
byAddr map[string]*Entry byAddr map[string]*Entry
byID map[uuid.UUID]*Entry
entries []*Entry entries []*Entry
strategy Strategy strategy Strategy
// standby holds enabled, unblocked models the provider does not serve
// right now. They are not servable, but a request for one can still be
// routed to its backup.
standby map[string]*Entry
rr sync.Map // map[string]*atomic.Uint64, round-robin counters per query rr sync.Map // map[string]*atomic.Uint64, round-robin counters per query
} }
// NewCache returns an empty cache; call Reload to populate it. // NewCache returns an empty cache; call Reload to populate it.
func NewCache(s CatalogStore) *Cache { func NewCache(s CatalogStore) *Cache {
return &Cache{store: s, byAddr: map[string]*Entry{}, strategy: StrategyRoundRobin} return &Cache{store: s, byAddr: map[string]*Entry{}, byID: map[uuid.UUID]*Entry{}, standby: map[string]*Entry{}, strategy: StrategyRoundRobin}
} }
// maxFallbackHops bounds a backup chain. A chain may legitimately loop (two
// models that back each other up), so the walk also tracks visited ids.
const maxFallbackHops = 8
// Reload rebuilds the cache (servable entries only) and routing strategy from // Reload rebuilds the cache (servable entries only) and routing strategy from
// the store. // the store.
func (c *Cache) Reload(ctx context.Context) error { func (c *Cache) Reload(ctx context.Context) error {
@@ -43,9 +54,11 @@ func (c *Cache) Reload(ctx context.Context) error {
} }
byAddr := make(map[string]*Entry, len(rows)) byAddr := make(map[string]*Entry, len(rows))
byID := make(map[uuid.UUID]*Entry, len(rows))
standby := map[string]*Entry{}
entries := make([]*Entry, 0, len(rows)) entries := make([]*Entry, 0, len(rows))
for _, r := range rows { for _, r := range rows {
if !r.Enabled || r.Blocked || !r.Available { if !r.Enabled || r.Blocked {
continue continue
} }
var overrides map[string]any var overrides map[string]any
@@ -69,8 +82,14 @@ func (c *Cache) Reload(ctx context.Context) error {
CachedPricePerMTok: r.CachedPricePerMTok, CachedPricePerMTok: r.CachedPricePerMTok,
RPMLimit: r.RPMLimit, RPMLimit: r.RPMLimit,
TPMLimit: r.TPMLimit, TPMLimit: r.TPMLimit,
FallbackID: r.FallbackModelID,
}
if !r.Available {
standby[e.Address] = e
continue
} }
byAddr[e.Address] = e byAddr[e.Address] = e
byID[e.ID] = e
entries = append(entries, e) entries = append(entries, e)
} }
@@ -82,15 +101,29 @@ func (c *Cache) Reload(ctx context.Context) error {
c.mu.Lock() c.mu.Lock()
c.byAddr = byAddr c.byAddr = byAddr
c.byID = byID
c.standby = standby
c.entries = entries c.entries = entries
c.strategy = strategy c.strategy = strategy
c.mu.Unlock() c.mu.Unlock()
return nil return nil
} }
// Resolve maps a model address to a servable entry. "any/<slug>" selects among // Resolve maps a model address to the servable entry a request would reach
// matching providers using the configured strategy. // first: the model itself, or its first live backup when the model is down.
func (c *Cache) Resolve(address string) (*Entry, error) { func (c *Cache) Resolve(address string) (*Entry, error) {
r, err := c.Route(address)
if err != nil {
return nil, err
}
return r.Targets[0], nil
}
// Route maps a model address to the entries that can serve it, in order.
// "any/<slug>" selects among matching providers using the configured strategy;
// the selected entry's backups follow it. A model that is enabled but not
// available routes straight to its backups.
func (c *Cache) Route(address string) (*Route, error) {
provider, slug, ok := SplitAddress(address) provider, slug, ok := SplitAddress(address)
if !ok { if !ok {
return nil, fmt.Errorf("invalid model address %q (want <provider>/<slug>)", address) return nil, fmt.Errorf("invalid model address %q (want <provider>/<slug>)", address)
@@ -104,14 +137,51 @@ func (c *Cache) Resolve(address string) (*Entry, error) {
if len(cands) == 0 { if len(cands) == 0 {
return nil, fmt.Errorf("no provider serves model %q", slug) return nil, fmt.Errorf("no provider serves model %q", slug)
} }
return c.selectLocked(slug, cands), nil e := c.selectLocked(slug, cands)
return &Route{Address: address, Primary: e.Address, Slug: e.Slug, Targets: c.chainLocked(e)}, nil
} }
e, ok := c.byAddr[address] if e, ok := c.byAddr[address]; ok {
if !ok { return &Route{Address: address, Primary: address, Slug: e.Slug, Targets: c.chainLocked(e)}, nil
return nil, fmt.Errorf("model %q not found", address)
} }
return e, nil if e, ok := c.standby[address]; ok {
if targets := c.chainLocked(e); len(targets) > 0 {
return &Route{Address: address, Primary: address, Slug: e.Slug, Targets: targets}, nil
}
}
return nil, fmt.Errorf("model %q not found", address)
}
// chainLocked returns e, when it is servable, followed by its live backups.
// Backups that are not servable are skipped and the walk continues through
// them, so a down backup does not hide the ones behind it.
func (c *Cache) chainLocked(e *Entry) []*Entry {
var out []*Entry
seen := map[uuid.UUID]bool{}
for hops := 0; e != nil && hops <= maxFallbackHops && !seen[e.ID]; hops++ {
seen[e.ID] = true
if c.byID[e.ID] == e {
out = append(out, e)
}
e = c.lookupLocked(e.FallbackID)
}
return out
}
// lookupLocked finds an enabled, unblocked entry by id, servable or standby.
func (c *Cache) lookupLocked(id *uuid.UUID) *Entry {
if id == nil {
return nil
}
if e, ok := c.byID[*id]; ok {
return e
}
for _, e := range c.standby {
if e.ID == *id {
return e
}
}
return nil
} }
// candidatesLocked returns servable entries whose slug equals slug or ends with // candidatesLocked returns servable entries whose slug equals slug or ends with
@@ -149,12 +219,18 @@ func (c *Cache) selectLocked(key string, cands []*Entry) *Entry {
func cost(e *Entry) float64 { return e.InputPricePerMTok + e.OutputPricePerMTok } func cost(e *Entry) float64 { return e.InputPricePerMTok + e.OutputPricePerMTok }
// List returns a snapshot of servable entries. // List returns a snapshot of the entries clients can call: the servable ones,
// plus unavailable models that have a live backup.
func (c *Cache) List() []*Entry { func (c *Cache) List() []*Entry {
c.mu.RLock() c.mu.RLock()
defer c.mu.RUnlock() defer c.mu.RUnlock()
out := make([]*Entry, len(c.entries)) out := make([]*Entry, len(c.entries), len(c.entries)+len(c.standby))
copy(out, c.entries) copy(out, c.entries)
for _, e := range c.standby {
if len(c.chainLocked(e)) > 0 {
out = append(out, e)
}
}
return out return out
} }

View File

@@ -1,19 +1,139 @@
package catalog package catalog
import "testing" import (
"testing"
"github.com/google/uuid"
)
func newTestCache(strategy Strategy, entries ...*Entry) *Cache { func newTestCache(strategy Strategy, entries ...*Entry) *Cache {
c := &Cache{byAddr: map[string]*Entry{}, strategy: strategy} c := &Cache{byAddr: map[string]*Entry{}, byID: map[uuid.UUID]*Entry{}, standby: map[string]*Entry{}, strategy: strategy}
for _, e := range entries { for _, e := range entries {
if e.Address == "" { if e.Address == "" {
e.Address = e.ProviderName + "/" + e.Slug e.Address = e.ProviderName + "/" + e.Slug
} }
if e.ID == uuid.Nil {
e.ID = uuid.New()
}
c.byAddr[e.Address] = e c.byAddr[e.Address] = e
c.byID[e.ID] = e
c.entries = append(c.entries, e) c.entries = append(c.entries, e)
} }
return c return c
} }
// addStandby registers an enabled model the provider does not serve right now.
func addStandby(c *Cache, e *Entry) *Entry {
if e.Address == "" {
e.Address = e.ProviderName + "/" + e.Slug
}
if e.ID == uuid.Nil {
e.ID = uuid.New()
}
c.standby[e.Address] = e
return e
}
func backup(e *Entry) *uuid.UUID { id := e.ID; return &id }
func addresses(entries []*Entry) []string {
out := make([]string, len(entries))
for i, e := range entries {
out[i] = e.Address
}
return out
}
func TestRouteFollowsBackupChainAndSkipsDownBackups(t *testing.T) {
a := &Entry{ProviderName: "local", Slug: "a"}
b := &Entry{ProviderName: "local", Slug: "b"}
cEntry := &Entry{ProviderName: "cloud", Slug: "c"}
c := newTestCache(StrategyRoundRobin, a, cEntry)
addStandby(c, b)
a.FallbackID = backup(b)
b.FallbackID = backup(cEntry)
r, err := c.Route("local/a")
if err != nil {
t.Fatal(err)
}
if got := addresses(r.Targets); len(got) != 2 || got[0] != "local/a" || got[1] != "cloud/c" {
t.Fatalf("targets = %v, want [local/a cloud/c]", got)
}
if r.Primary != "local/a" || r.Slug != "a" || r.IsFallback(a) || !r.IsFallback(cEntry) {
t.Errorf("route = %+v", r)
}
}
func TestRouteUnavailableModelGoesToBackup(t *testing.T) {
b := &Entry{ProviderName: "cloud", Slug: "org/a"}
c := newTestCache(StrategyRoundRobin, b)
a := addStandby(c, &Entry{ProviderName: "local", Slug: "a"})
a.FallbackID = backup(b)
r, err := c.Route("local/a")
if err != nil {
t.Fatal(err)
}
if got := addresses(r.Targets); len(got) != 1 || got[0] != "cloud/org/a" {
t.Fatalf("targets = %v", got)
}
// The client asked for local/a, so the allowlist and the reported model
// use it, not the backup.
if r.Address != "local/a" || r.Primary != "local/a" || r.Slug != "a" || !r.IsFallback(b) {
t.Errorf("route = %+v", r)
}
if e, err := c.Resolve("local/a"); err != nil || e != b {
t.Errorf("Resolve = %v, %v", e, err)
}
if got := addresses(c.List()); len(got) != 2 {
t.Errorf("List must include the down model with a live backup: %v", got)
}
}
func TestRouteUnavailableModelWithoutBackupIsNotFound(t *testing.T) {
c := newTestCache(StrategyRoundRobin)
addStandby(c, &Entry{ProviderName: "local", Slug: "a"})
if _, err := c.Route("local/a"); err == nil {
t.Fatal("expected not found")
}
if got := c.List(); len(got) != 0 {
t.Errorf("List = %v", addresses(got))
}
}
func TestRouteMutualBackupsDoNotLoop(t *testing.T) {
a := &Entry{ProviderName: "x", Slug: "a"}
b := &Entry{ProviderName: "x", Slug: "b"}
c := newTestCache(StrategyRoundRobin, a, b)
a.FallbackID = backup(b)
b.FallbackID = backup(a)
r, err := c.Route("x/b")
if err != nil {
t.Fatal(err)
}
if got := addresses(r.Targets); len(got) != 2 || got[0] != "x/b" || got[1] != "x/a" {
t.Fatalf("targets = %v", got)
}
}
func TestRouteAnyCarriesBackups(t *testing.T) {
a := &Entry{ProviderName: "openai", Slug: "gpt-4o"}
b := &Entry{ProviderName: "cloud", Slug: "other"}
c := newTestCache(StrategyRoundRobin, a, b)
a.FallbackID = backup(b)
r, err := c.Route("any/gpt-4o")
if err != nil {
t.Fatal(err)
}
if got := addresses(r.Targets); len(got) != 2 || got[1] != "cloud/other" {
t.Fatalf("targets = %v", got)
}
if r.Address != "any/gpt-4o" || r.Primary != "openai/gpt-4o" {
t.Errorf("route = %+v", r)
}
}
func TestSplitAddress(t *testing.T) { func TestSplitAddress(t *testing.T) {
cases := []struct { cases := []struct {
in string in string

View File

@@ -41,4 +41,27 @@ type Entry struct {
CachedPricePerMTok float64 CachedPricePerMTok float64
RPMLimit *int RPMLimit *int
TPMLimit *int TPMLimit *int
// FallbackID is the admin-configured backup model, when set. The cache
// resolves it to servable entries in Route.
FallbackID *uuid.UUID
} }
// Route is the ordered list of servable entries for one requested address:
// the model the client asked for first, then its backups. A backup is used
// when the model before it is unavailable or its upstream fails, and the
// client sees no difference in the response.
type Route struct {
// Address is the address the client sent, for the request log.
Address string
// Primary is the address of the model the client asked for, after any
// "any/<slug>" selection. Key allowlists are checked against it.
Primary string
// Slug is the primary model's upstream id. Responses served by a backup
// report this id, so the switch stays invisible to the client.
Slug string
// Targets holds the servable entries to try, in order. It is never empty.
Targets []*Entry
}
// IsFallback reports whether e is a backup rather than the requested model.
func (r *Route) IsFallback(e *Entry) bool { return e.Address != r.Primary }

View File

@@ -147,6 +147,12 @@ func (e *Exporter) requestPoint(rec store.RequestLog) Point {
"key_prefix": keyPrefix, "key_prefix": keyPrefix,
"status_code": strconv.Itoa(rec.StatusCode), "status_code": strconv.Itoa(rec.StatusCode),
"streamed": strconv.FormatBool(rec.Streamed), "streamed": strconv.FormatBool(rec.Streamed),
// fallback is true when a backup model served the request; then
// served_model names it and cost is the backup's cost.
"fallback": strconv.FormatBool(rec.ServedModel != ""),
}
if rec.ServedModel != "" {
tags["served_model"] = rec.ServedModel
} }
if rec.UserID != nil { if rec.UserID != nil {
tags["user_id"] = rec.UserID.String() tags["user_id"] = rec.UserID.String()

View File

@@ -165,7 +165,7 @@ func TestRecordWritesRequestPoint(t *testing.T) {
if srv.auth != "Bearer secret" { if srv.auth != "Bearer secret" {
t.Fatalf("auth = %q", srv.auth) t.Fatalf("auth = %q", srv.auth)
} }
want := `lmr_request,key_prefix=lmr_abc,model=openai/gpt-4o,provider=openai,status_code=200,streamed=true,user_id=11111111-1111-1111-1111-111111111111 ` + want := `lmr_request,fallback=false,key_prefix=lmr_abc,model=openai/gpt-4o,provider=openai,status_code=200,streamed=true,user_id=11111111-1111-1111-1111-111111111111 ` +
`cached_tokens=2i,completion_tokens=5i,cost=0.00125,error="",errors=0i,finish_reason="",latency_ms=321i,prompt_tokens=10i,request_id="req-1",requests=1i,total_tokens=15i,ttft_ms=42i 100000000000` + "\n" `cached_tokens=2i,completion_tokens=5i,cost=0.00125,error="",errors=0i,finish_reason="",latency_ms=321i,prompt_tokens=10i,request_id="req-1",requests=1i,total_tokens=15i,ttft_ms=42i 100000000000` + "\n"
if len(srv.bodies) != 1 || srv.bodies[0] != want { if len(srv.bodies) != 1 || srv.bodies[0] != want {
t.Fatalf("bodies = %q\nwant %q", srv.bodies, want) t.Fatalf("bodies = %q\nwant %q", srv.bodies, want)
@@ -206,6 +206,21 @@ func TestRequestPointWritesAllFields(t *testing.T) {
if p.Tags["key_prefix"] != "(no key)" { if p.Tags["key_prefix"] != "(no key)" {
t.Errorf("key_prefix = %q", p.Tags["key_prefix"]) t.Errorf("key_prefix = %q", p.Tags["key_prefix"])
} }
if p.Tags["fallback"] != "false" {
t.Errorf("fallback = %q", p.Tags["fallback"])
}
if _, ok := p.Tags["served_model"]; ok {
t.Errorf("served_model must be absent when the requested model served")
}
}
func TestRequestPointTagsBackupModel(t *testing.T) {
srv := newCaptureServer(t)
e := newTestExporter(t, srv, Config{})
p := e.requestPoint(store.RequestLog{StatusCode: 200, ModelName: "local/a", ServedModel: "cloud/b"})
if p.Tags["fallback"] != "true" || p.Tags["served_model"] != "cloud/b" || p.Tags["model"] != "local/a" {
t.Errorf("tags = %v", p.Tags)
}
} }
func TestBatchAndFlushOnClose(t *testing.T) { func TestBatchAndFlushOnClose(t *testing.T) {

View File

@@ -95,6 +95,8 @@ type CatalogRow struct {
CachedPricePerMTok float64 CachedPricePerMTok float64
RPMLimit *int RPMLimit *int
TPMLimit *int TPMLimit *int
// FallbackModelID is the admin-configured backup model, when set.
FallbackModelID *uuid.UUID
} }
// ListCatalog returns every catalog entry belonging to an enabled provider. // ListCatalog returns every catalog entry belonging to an enabled provider.
@@ -109,7 +111,7 @@ func (s *Store) ListCatalog(ctx context.Context) ([]CatalogRow, error) {
COALESCE(pm.price_input_per_mtok, pm.disc_input_price_per_mtok, 0)::double precision, COALESCE(pm.price_input_per_mtok, pm.disc_input_price_per_mtok, 0)::double precision,
COALESCE(pm.price_output_per_mtok, pm.disc_output_price_per_mtok, 0)::double precision, COALESCE(pm.price_output_per_mtok, pm.disc_output_price_per_mtok, 0)::double precision,
COALESCE(pm.price_cached_per_mtok, 0)::double precision, COALESCE(pm.price_cached_per_mtok, 0)::double precision,
pm.rpm_limit, pm.tpm_limit pm.rpm_limit, pm.tpm_limit, pm.fallback_model_id
FROM provider_models pm FROM provider_models pm
JOIN providers p ON p.id = pm.provider_id JOIN providers p ON p.id = pm.provider_id
WHERE p.enabled = true`) WHERE p.enabled = true`)
@@ -126,7 +128,7 @@ func (s *Store) ListCatalog(ctx context.Context) ([]CatalogRow, error) {
&r.DisplayName, &r.Capabilities, &r.ContextLength, &r.DisplayName, &r.Capabilities, &r.ContextLength,
&r.Enabled, &r.Blocked, &r.Available, &r.ParamOverrides, &r.Enabled, &r.Blocked, &r.Available, &r.ParamOverrides,
&r.InputPricePerMTok, &r.OutputPricePerMTok, &r.CachedPricePerMTok, &r.InputPricePerMTok, &r.OutputPricePerMTok, &r.CachedPricePerMTok,
&r.RPMLimit, &r.TPMLimit, &r.RPMLimit, &r.TPMLimit, &r.FallbackModelID,
); err != nil { ); err != nil {
return nil, err return nil, err
} }

View File

@@ -28,6 +28,9 @@ type AdminModelRow struct {
PriceCachedPerMTok *float64 `json:"price_cached_per_mtok"` PriceCachedPerMTok *float64 `json:"price_cached_per_mtok"`
RPMLimit *int `json:"rpm_limit"` RPMLimit *int `json:"rpm_limit"`
TPMLimit *int `json:"tpm_limit"` TPMLimit *int `json:"tpm_limit"`
FallbackModelID *uuid.UUID `json:"fallback_model_id"`
// FallbackAddress is the backup model's "<provider>/<slug>" for display.
FallbackAddress *string `json:"fallback_address"`
} }
// ListModelsAdmin lists every catalog entry (no servability filter) for admin. // ListModelsAdmin lists every catalog entry (no servability filter) for admin.
@@ -37,9 +40,12 @@ func (s *Store) ListModelsAdmin(ctx context.Context) ([]AdminModelRow, error) {
pm.available, pm.enabled, pm.blocked, pm.context_length, pm.context_length_override, pm.last_seen_at, pm.available, pm.enabled, pm.blocked, pm.context_length, pm.context_length_override, pm.last_seen_at,
pm.disc_input_price_per_mtok, pm.disc_output_price_per_mtok, pm.disc_input_price_per_mtok, pm.disc_output_price_per_mtok,
pm.price_input_per_mtok, pm.price_output_per_mtok, pm.price_cached_per_mtok, pm.price_input_per_mtok, pm.price_output_per_mtok, pm.price_cached_per_mtok,
pm.rpm_limit, pm.tpm_limit pm.rpm_limit, pm.tpm_limit,
pm.fallback_model_id, fp.name || '/' || fb.slug
FROM provider_models pm FROM provider_models pm
JOIN providers p ON p.id = pm.provider_id JOIN providers p ON p.id = pm.provider_id
LEFT JOIN provider_models fb ON fb.id = pm.fallback_model_id
LEFT JOIN providers fp ON fp.id = fb.provider_id
ORDER BY p.name, pm.slug`) ORDER BY p.name, pm.slug`)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -52,7 +58,7 @@ func (s *Store) ListModelsAdmin(ctx context.Context) ([]AdminModelRow, error) {
&m.Available, &m.Enabled, &m.Blocked, &m.ContextLength, &m.ContextLengthOvr, &m.LastSeenAt, &m.Available, &m.Enabled, &m.Blocked, &m.ContextLength, &m.ContextLengthOvr, &m.LastSeenAt,
&m.DiscInputPerMTok, &m.DiscOutputPerMTok, &m.DiscInputPerMTok, &m.DiscOutputPerMTok,
&m.PriceInputPerMTok, &m.PriceOutputPerMTok, &m.PriceCachedPerMTok, &m.PriceInputPerMTok, &m.PriceOutputPerMTok, &m.PriceCachedPerMTok,
&m.RPMLimit, &m.TPMLimit); err != nil { &m.RPMLimit, &m.TPMLimit, &m.FallbackModelID, &m.FallbackAddress); err != nil {
return nil, err return nil, err
} }
out = append(out, m) out = append(out, m)
@@ -79,6 +85,8 @@ type ModelOverride struct {
TPMLimit Patch[int] TPMLimit Patch[int]
// ContextLength overrides the provider-reported context window. // ContextLength overrides the provider-reported context window.
ContextLength Patch[int] ContextLength Patch[int]
// FallbackModelID is the backup model served when this one fails.
FallbackModelID Patch[uuid.UUID]
} }
func (s *Store) UpdateModelOverride(ctx context.Context, id uuid.UUID, o ModelOverride) error { func (s *Store) UpdateModelOverride(ctx context.Context, id uuid.UUID, o ModelOverride) error {
@@ -96,7 +104,8 @@ func (s *Store) UpdateModelOverride(ctx context.Context, id uuid.UUID, o ModelOv
price_cached_per_mtok = CASE WHEN $9::boolean THEN $10 ELSE price_cached_per_mtok END, price_cached_per_mtok = CASE WHEN $9::boolean THEN $10 ELSE price_cached_per_mtok END,
rpm_limit = CASE WHEN $11::boolean THEN $12 ELSE rpm_limit END, rpm_limit = CASE WHEN $11::boolean THEN $12 ELSE rpm_limit END,
tpm_limit = CASE WHEN $13::boolean THEN $14 ELSE tpm_limit END, tpm_limit = CASE WHEN $13::boolean THEN $14 ELSE tpm_limit END,
context_length_override = CASE WHEN $15::boolean THEN $16 ELSE context_length_override END context_length_override = CASE WHEN $15::boolean THEN $16 ELSE context_length_override END,
fallback_model_id = CASE WHEN $17::boolean THEN $18 ELSE fallback_model_id END
WHERE id = $1`, WHERE id = $1`,
id, o.Enabled, o.Blocked, params, id, o.Enabled, o.Blocked, params,
o.InputPrice.Set, o.InputPrice.Value, o.InputPrice.Set, o.InputPrice.Value,
@@ -105,6 +114,7 @@ func (s *Store) UpdateModelOverride(ctx context.Context, id uuid.UUID, o ModelOv
o.RPMLimit.Set, o.RPMLimit.Value, o.RPMLimit.Set, o.RPMLimit.Value,
o.TPMLimit.Set, o.TPMLimit.Value, o.TPMLimit.Set, o.TPMLimit.Value,
o.ContextLength.Set, o.ContextLength.Value, o.ContextLength.Set, o.ContextLength.Value,
o.FallbackModelID.Set, o.FallbackModelID.Value,
) )
return err return err
} }

View File

@@ -19,6 +19,7 @@ type RequestLog struct {
ProviderID *uuid.UUID ProviderID *uuid.UUID
KeyPrefix string KeyPrefix string
ModelName string // the address the client requested ModelName string // the address the client requested
ServedModel string // the address that served it, when a backup model did
ProviderName string ProviderName string
StatusCode int StatusCode int
Streamed bool Streamed bool
@@ -43,14 +44,15 @@ func (s *Store) InsertRequestLog(ctx context.Context, e RequestLog) (uuid.UUID,
key_prefix, model_name, provider_name, key_prefix, model_name, provider_name,
status_code, streamed, finish_reason, error, status_code, streamed, finish_reason, error,
prompt_tokens, completion_tokens, cached_tokens, total_tokens, prompt_tokens, completion_tokens, cached_tokens, total_tokens,
cost, latency_ms, ttft_ms, request_id, client_ip cost, latency_ms, ttft_ms, request_id, client_ip, served_model
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20) ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21)
RETURNING id`, RETURNING id`,
e.VirtualKeyID, e.UserID, e.ProviderModelID, e.ProviderID, e.VirtualKeyID, e.UserID, e.ProviderModelID, e.ProviderID,
nullString(e.KeyPrefix), nullString(e.ModelName), nullString(e.ProviderName), nullString(e.KeyPrefix), nullString(e.ModelName), nullString(e.ProviderName),
e.StatusCode, e.Streamed, nullString(e.FinishReason), nullString(e.Error), e.StatusCode, e.Streamed, nullString(e.FinishReason), nullString(e.Error),
e.PromptTokens, e.CompletionTokens, e.CachedTokens, e.TotalTokens, e.PromptTokens, e.CompletionTokens, e.CachedTokens, e.TotalTokens,
e.Cost, e.LatencyMS, e.TTFTMs, nullString(e.RequestID), nullInet(e.ClientIP), e.Cost, e.LatencyMS, e.TTFTMs, nullString(e.RequestID), nullInet(e.ClientIP),
nullString(e.ServedModel),
).Scan(&id) ).Scan(&id)
return id, err return id, err
} }
@@ -76,6 +78,7 @@ type RequestLogListRow struct {
RequestID string RequestID string
KeyPrefix string KeyPrefix string
ModelName string ModelName string
ServedModel string // set when a backup model served the request
ProviderName string ProviderName string
StatusCode int StatusCode int
Streamed bool Streamed bool
@@ -132,7 +135,8 @@ func (s *Store) ListRequestLogs(ctx context.Context, p ListLogsParams) ([]Reques
} }
rows, err := s.pool.Query(ctx, ` rows, err := s.pool.Query(ctx, `
SELECT rl.id::text, rl.created_at, COALESCE(rl.request_id,''), SELECT rl.id::text, rl.created_at, COALESCE(rl.request_id,''),
COALESCE(rl.key_prefix,''), COALESCE(rl.model_name,''), COALESCE(rl.provider_name,''), COALESCE(rl.key_prefix,''), COALESCE(rl.model_name,''), COALESCE(rl.served_model,''),
COALESCE(rl.provider_name,''),
rl.status_code, rl.streamed, COALESCE(rl.error,''), rl.status_code, rl.streamed, COALESCE(rl.error,''),
rl.prompt_tokens, rl.completion_tokens, rl.cached_tokens, rl.total_tokens, rl.prompt_tokens, rl.completion_tokens, rl.cached_tokens, rl.total_tokens,
rl.cost, rl.latency_ms, rl.ttft_ms rl.cost, rl.latency_ms, rl.ttft_ms
@@ -145,6 +149,7 @@ func (s *Store) ListRequestLogs(ctx context.Context, p ListLogsParams) ([]Reques
AND ($6::timestamptz IS NULL OR rl.created_at >= $6) AND ($6::timestamptz IS NULL OR rl.created_at >= $6)
AND ($7::timestamptz IS NULL OR (rl.created_at, rl.id) < ($7, $8)) AND ($7::timestamptz IS NULL OR (rl.created_at, rl.id) < ($7, $8))
AND ($9 = '' OR rl.model_name ILIKE '%' || $9 || '%' AND ($9 = '' OR rl.model_name ILIKE '%' || $9 || '%'
OR rl.served_model ILIKE '%' || $9 || '%'
OR rl.provider_name ILIKE '%' || $9 || '%' OR rl.provider_name ILIKE '%' || $9 || '%'
OR rl.key_prefix ILIKE '%' || $9 || '%' OR rl.key_prefix ILIKE '%' || $9 || '%'
OR rl.request_id ILIKE '%' || $9 || '%') OR rl.request_id ILIKE '%' || $9 || '%')
@@ -161,7 +166,7 @@ func (s *Store) ListRequestLogs(ctx context.Context, p ListLogsParams) ([]Reques
var r RequestLogListRow var r RequestLogListRow
if err := rows.Scan( if err := rows.Scan(
&r.ID, &r.CreatedAt, &r.RequestID, &r.ID, &r.CreatedAt, &r.RequestID,
&r.KeyPrefix, &r.ModelName, &r.ProviderName, &r.KeyPrefix, &r.ModelName, &r.ServedModel, &r.ProviderName,
&r.StatusCode, &r.Streamed, &r.Error, &r.StatusCode, &r.Streamed, &r.Error,
&r.PromptTokens, &r.CompletionTokens, &r.CachedTokens, &r.TotalTokens, &r.PromptTokens, &r.CompletionTokens, &r.CachedTokens, &r.TotalTokens,
&r.Cost, &r.LatencyMS, &r.TTFTMs, &r.Cost, &r.LatencyMS, &r.TTFTMs,

View File

@@ -119,14 +119,17 @@ func TestStoreQueries(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("ListModelsAdmin: %v", err) t.Fatalf("ListModelsAdmin: %v", err)
} }
var m1 uuid.UUID var m1, m2 uuid.UUID
for _, r := range rows { for _, r := range rows {
if r.ProviderName == "local" && r.Slug == "m1" { if r.ProviderName == "local" && r.Slug == "m1" {
m1 = r.ID m1 = r.ID
} }
if r.ProviderName == "local" && r.Slug == "m2" {
m2 = r.ID
}
} }
if m1 == uuid.Nil { if m1 == uuid.Nil || m2 == uuid.Nil {
t.Fatalf("local/m1 missing from %+v", rows) t.Fatalf("local/m1 or local/m2 missing from %+v", rows)
} }
price := 2.5 price := 2.5
rpm := 30 rpm := 30
@@ -157,10 +160,55 @@ func TestStoreQueries(t *testing.T) {
} }
} }
// Backup model: set, read back through the admin list and the catalog,
// reject an unknown target, then clear.
if err := st.UpdateModelOverride(ctx, m1, store.ModelOverride{FallbackModelID: store.Patch[uuid.UUID]{Set: true, Value: &m2}}); err != nil {
t.Fatalf("UpdateModelOverride fallback: %v", err)
}
rows, _ = st.ListModelsAdmin(ctx)
for _, r := range rows {
if r.ID == m1 && (r.FallbackModelID == nil || *r.FallbackModelID != m2 || r.FallbackAddress == nil || *r.FallbackAddress != "local/m2") {
t.Fatalf("fallback not applied: %+v", r)
}
}
on := true
if err := st.UpdateProvider(ctx, provID, store.UpdateProviderParams{Enabled: &on}); err != nil {
t.Fatalf("UpdateProvider enable: %v", err)
}
cat, err := st.ListCatalog(ctx)
if err != nil {
t.Fatalf("ListCatalog: %v", err)
}
var sawM1 bool
for _, r := range cat {
if r.ID == m1 {
sawM1 = true
if r.FallbackModelID == nil || *r.FallbackModelID != m2 {
t.Fatalf("catalog row lacks fallback: %+v", r)
}
}
}
if !sawM1 {
t.Fatalf("local/m1 missing from catalog %+v", cat)
}
unknown := uuid.New()
if err := st.UpdateModelOverride(ctx, m1, store.ModelOverride{FallbackModelID: store.Patch[uuid.UUID]{Set: true, Value: &unknown}}); err == nil {
t.Fatal("UpdateModelOverride must reject an unknown backup model")
}
if err := st.UpdateModelOverride(ctx, m1, store.ModelOverride{FallbackModelID: store.Patch[uuid.UUID]{Set: true}}); err != nil {
t.Fatalf("UpdateModelOverride clear fallback: %v", err)
}
rows, _ = st.ListModelsAdmin(ctx)
for _, r := range rows {
if r.ID == m1 && (r.FallbackModelID != nil || r.FallbackAddress != nil) {
t.Fatalf("fallback not cleared: %+v", r)
}
}
// Logs: insert, then list and summarize. // Logs: insert, then list and summarize.
logID, err := st.InsertRequestLog(ctx, store.RequestLog{ logID, err := st.InsertRequestLog(ctx, store.RequestLog{
VirtualKeyID: &keyID, UserID: &user.ID, ProviderID: &provID, ProviderModelID: &m1, VirtualKeyID: &keyID, UserID: &user.ID, ProviderID: &provID, ProviderModelID: &m2,
KeyPrefix: "sk-00000001", ModelName: "local/m1", ProviderName: "local", KeyPrefix: "sk-00000001", ModelName: "local/m1", ServedModel: "local/m2", ProviderName: "local",
StatusCode: 502, Error: "upstream down", LatencyMS: 12, ClientIP: "127.0.0.1", StatusCode: 502, Error: "upstream down", LatencyMS: 12, ClientIP: "127.0.0.1",
}) })
if err != nil { if err != nil {
@@ -170,9 +218,13 @@ func TestStoreQueries(t *testing.T) {
t.Fatalf("InsertLogContent: %v", err) t.Fatalf("InsertLogContent: %v", err)
} }
list, err := st.ListRequestLogs(ctx, store.ListLogsParams{UserID: &user.ID, Query: "local/m1", StatusGte: 400}) list, err := st.ListRequestLogs(ctx, store.ListLogsParams{UserID: &user.ID, Query: "local/m1", StatusGte: 400})
if err != nil || len(list) != 1 || list[0].Error != "upstream down" { if err != nil || len(list) != 1 || list[0].Error != "upstream down" || list[0].ServedModel != "local/m2" {
t.Fatalf("ListRequestLogs: %v %+v", err, list) t.Fatalf("ListRequestLogs: %v %+v", err, list)
} }
// The search also matches the address that served the request.
if list, err := st.ListRequestLogs(ctx, store.ListLogsParams{UserID: &user.ID, Query: "local/m2"}); err != nil || len(list) != 1 {
t.Fatalf("ListRequestLogs by served model: %v %+v", err, list)
}
sum, err = st.GetLogsSummary(ctx, time.Now().Add(-time.Hour), &user.ID) sum, err = st.GetLogsSummary(ctx, time.Now().Add(-time.Hour), &user.ID)
if err != nil || sum.Requests != 1 || sum.Errors != 1 { if err != nil || sum.Requests != 1 || sum.Errors != 1 {
t.Fatalf("summary = %+v err=%v", sum, err) t.Fatalf("summary = %+v err=%v", sum, err)

View File

@@ -0,0 +1,2 @@
ALTER TABLE request_logs DROP COLUMN IF EXISTS served_model;
ALTER TABLE provider_models DROP COLUMN IF EXISTS fallback_model_id;

View File

@@ -0,0 +1,9 @@
-- Admin-configured backup model. When the model is unavailable or its upstream
-- fails, the gateway serves the request with the backup instead. NULL means
-- there is no backup. Deleting the backup clears the reference.
ALTER TABLE provider_models
ADD COLUMN fallback_model_id uuid REFERENCES provider_models (id) ON DELETE SET NULL;
-- The address that served a request when it differs from model_name, which is
-- the address the client requested. NULL when the requested model served it.
ALTER TABLE request_logs ADD COLUMN served_model text;

View File

@@ -109,13 +109,37 @@ router, and `/upstream/<model>/props` for models llama-swap has loaded. It
never asks llama-swap about a stopped model, because that would start it. never asks llama-swap about a stopped model, because that would start it.
Admin overrides live in separate columns on the same row and survive syncs: Admin overrides live in separate columns on the same row and survive syncs:
enabled, blocked, parameter overrides, prices, context window, and rate enabled, blocked, parameter overrides, prices, context window, rate limits,
limits. A model that disappears from the upstream list is marked unavailable, and a backup model. A model that disappears from the upstream list is marked
not deleted. unavailable, not deleted.
The cache holds every servable row in memory, keyed by address. It is rebuilt The cache holds every servable row in memory, keyed by address. It is rebuilt
after each sync, so the request path never queries the database for routing. after each sync, so the request path never queries the database for routing.
### Backup models
A model's `fallback_model_id` names another catalog row. The cache turns an
address into a route: the requested model first, then its backups in chain
order, with models that are not servable skipped. Two models may back each
other up; the walk tracks visited ids and stops after a fixed number of hops.
A model that is enabled but unavailable still routes, straight to its backups,
and still appears in `/v1/models`.
The proxy handlers walk the route. A target is skipped when the rate limiter
refuses it, its provider is missing, the transport fails, or the upstream
answers 408, 429, or 5xx. Client errors (4xx) are relayed, because the backup
would most likely answer the same. For a stream, the first chunk is read
before the status line goes to the client, so a stream that dies at once can
still move to a backup. After the first byte is sent, a failure can only be
logged.
The switch is invisible to the client and visible in the log. A backup's
response reports the requested model's id. The key allowlist is checked
against the requested address. Every failed attempt gets its own log row, with
the error and a note that names the backup. The row for the backup keeps the
requested address in `model_name`, puts the backup in `served_model`, and
uses the backup's prices for the cost.
## Authentication ## Authentication
Two systems that do not mix: Two systems that do not mix: