mirror of
https://github.com/lukaszraczylo/traefik-llmgateway.git
synced 2026-10-20 05:56:56 +00:00
feat(admin): add events feed, fleet-wide rejections and span rankings
GET /admin/api/events returns recent failures, newest first. It covers rate limits, budget and unpriced-model refusals, upstream errors, timeouts and capacity rejections. Events go into an in-process ring of 200 entries and are copied to a Redis list (llmgw:events, LPUSH plus LTRIM to 200). The copy happens only on the error path, is throttled to 50 per second per replica, and never blocks a request. When Redis is absent or down, the feed falls back to this replica's ring. Messages are fixed strings or scrubbed provider errors. Fields are assigned one at a time, because Yaegi drops values from a multi-value assignment that contains a call. Rejections per day are counted per scope and in total, in the same store call that already rolls back a rejected request. /admin/api/usage returns them as rejectionsPerDay. /admin/api/usage/models accepts span, which sums that many buckets, and prefix, which filters model ids before the limit applies. The overview reports the replica id, the instance name and the warnings logged while the middleware was built. The failover and retry fan-out warning is now logged at WARN. Claude-Session: https://claude.ai/code/session_01V5jYJNxpDFdJkyyExpADrv
This commit is contained in:
@@ -17,6 +17,35 @@ import (
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// TestPipelineMutates_LPUSH proves LPUSH is registered as a mutating
|
||||
// command (F3, v0.3 dashboard task: events.go's eventLog.push sends it) —
|
||||
// pipelineEncoded's own retry-safety gate must never blindly resend a
|
||||
// pipeline containing it after a reply was lost mid-read, or a lost-ACK
|
||||
// event push could be double-applied. Table-driven alongside the
|
||||
// pre-existing INCRBY/INCRBYFLOAT/GET cases so a future change to any of
|
||||
// the four can't silently drift the others.
|
||||
func TestPipelineMutates_LPUSH(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
cmds [][]string
|
||||
want bool
|
||||
}{
|
||||
{name: "LPUSH alone", cmds: [][]string{{"LPUSH", "llmgw:events", "{}"}}, want: true},
|
||||
{name: "LPUSH+LTRIM (events.go's own pipeline)", cmds: [][]string{{"LPUSH", "k", "v"}, {"LTRIM", "k", "0", "199"}}, want: true},
|
||||
{name: "INCRBY still mutates", cmds: [][]string{{"INCRBY", "k", "1"}}, want: true},
|
||||
{name: "INCRBYFLOAT still mutates", cmds: [][]string{{"INCRBYFLOAT", "k", "1.5"}}, want: true},
|
||||
{name: "read-only GET/LRANGE does not mutate", cmds: [][]string{{"GET", "k"}, {"LRANGE", "llmgw:events", "0", "49"}}, want: false},
|
||||
{name: "empty pipeline does not mutate", cmds: nil, want: false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
if got := pipelineMutates(c.cmds); got != c.want {
|
||||
t.Errorf("pipelineMutates(%v) = %v, want %v", c.cmds, got, c.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestEncodeCommand pins the exact RESP2 wire format every command must
|
||||
// use: "*N\r\n$len\r\narg\r\n..." — a fake or real server parses this
|
||||
// framing byte-for-byte, so a drift here breaks every other test silently.
|
||||
|
||||
Reference in New Issue
Block a user