mirror of
https://github.com/lukaszraczylo/traefik-llmgateway.git
synced 2026-10-18 05:47:38 +00:00
fix: v0.2 final review wave
Transcriptions Yaegi coverage via integration mock, flattened admin usage batch, hardened CSP, sanitized provider lastErr, source-archive prefix template, vendor export-ignore warning, release paths.
This commit is contained in:
@@ -3,6 +3,17 @@
|
|||||||
# (.goreleaser.yaml), so these entries are the actual filter for the
|
# (.goreleaser.yaml), so these entries are the actual filter for the
|
||||||
# release source tarball — not the archive block's own file globs, which
|
# release source tarball — not the archive block's own file globs, which
|
||||||
# have no negation syntax and cannot exclude anything.
|
# have no negation syntax and cannot exclude anything.
|
||||||
|
# WARNING (v0.2 final review wave, 2026-08-20): vendor/ is safe to
|
||||||
|
# export-ignore ONLY because every current go.mod dependency
|
||||||
|
# (github.com/stretchr/testify, its go.yaml.in/yaml/v3 indirect) is a
|
||||||
|
# TEST-only dependency, imported solely by *_test.go files — which are
|
||||||
|
# themselves export-ignored below, so the released tarball never contains
|
||||||
|
# code that imports them and never needs vendor/ to build. go.mod itself
|
||||||
|
# is NOT export-ignored and ships in every release tarball as-is. The
|
||||||
|
# moment any non-test .go file (production code, not *_test.go) imports a
|
||||||
|
# third-party package, this line MUST be removed: otherwise the tarball
|
||||||
|
# ships a go.mod requiring a module its own vendor/ was stripped out of,
|
||||||
|
# and the extracted source cannot build standalone.
|
||||||
vendor/ export-ignore
|
vendor/ export-ignore
|
||||||
integration/ export-ignore
|
integration/ export-ignore
|
||||||
tools/ export-ignore
|
tools/ export-ignore
|
||||||
|
|||||||
@@ -8,6 +8,11 @@ on:
|
|||||||
- "**.go"
|
- "**.go"
|
||||||
- "go.mod"
|
- "go.mod"
|
||||||
- "go.sum"
|
- "go.sum"
|
||||||
|
- ".goreleaser.yaml"
|
||||||
|
- ".gitattributes"
|
||||||
|
- ".traefik.yml"
|
||||||
|
- "version.go"
|
||||||
|
- "workflow-prepare.sh"
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
|
|||||||
@@ -20,6 +20,13 @@ source:
|
|||||||
enabled: true
|
enabled: true
|
||||||
format: tar.gz
|
format: tar.gz
|
||||||
name_template: "{{ .ProjectName }}_v{{ .Version }}_source"
|
name_template: "{{ .ProjectName }}_v{{ .Version }}_source"
|
||||||
|
# Extracting the tarball must land everything under one directory, not
|
||||||
|
# loose at the archive root, the same way GitHub's own source tarballs
|
||||||
|
# behave — without this, `tar xzf` scatters the plugin's files straight
|
||||||
|
# into the extracting directory. Verified with `goreleaser release
|
||||||
|
# --snapshot --clean`: the tarball's top-level entry is exactly
|
||||||
|
# traefik-llmgateway_v{version}/ (v0.2 final review wave, 2026-08-20).
|
||||||
|
prefix_template: "{{ .ProjectName }}_v{{ .Version }}/"
|
||||||
|
|
||||||
checksum:
|
checksum:
|
||||||
name_template: "{{ .ProjectName }}_v{{ .Version }}_checksums.txt"
|
name_template: "{{ .ProjectName }}_v{{ .Version }}_checksums.txt"
|
||||||
|
|||||||
@@ -171,7 +171,17 @@ config accepts them as YAML, which decodes to the same JSON shape.
|
|||||||
| `cache` | `CacheConfig` | `{}` (disabled) | Opt-in Redis-backed response cache for unified non-streaming chat/embeddings — see [Caching](#caching). Omitted or `enabled: false` means no caching, byte-identical to a gateway built before this field existed. |
|
| `cache` | `CacheConfig` | `{}` (disabled) | Opt-in Redis-backed response cache for unified non-streaming chat/embeddings — see [Caching](#caching). Omitted or `enabled: false` means no caching, byte-identical to a gateway built before this field existed. |
|
||||||
| `admin` | `*AdminConfig` | `nil` (disabled) | Read-only admin dashboard — see [Admin](#admin). `nil` or `enabled: false` means the `/admin*` routes are not registered at all. |
|
| `admin` | `*AdminConfig` | `nil` (disabled) | Read-only admin dashboard — see [Admin](#admin). `nil` or `enabled: false` means the `/admin*` routes are not registered at all. |
|
||||||
| `modelAliases` | `map[string]string` | `{}` | Operator-defined alias id → target model id — see [Model aliases](#model-aliases). Omitted or empty means no aliases, byte-identical to a gateway built before this field existed. |
|
| `modelAliases` | `map[string]string` | `{}` | Operator-defined alias id → target model id — see [Model aliases](#model-aliases). Omitted or empty means no aliases, byte-identical to a gateway built before this field existed. |
|
||||||
| `passthroughUnknown` | `bool` | `false` | `false`: a request matching none of the plugin's routes gets a 404 JSON envelope. `true`: it falls through to the router's own backing service. |
|
| `passthroughUnknown` | `bool` | `false` | `false`: a request matching none of the plugin's routes gets a 404 JSON envelope. `true`: it falls through to the router's own backing service. **Does not cover the three media routes** — see the upgrade note below. |
|
||||||
|
|
||||||
|
> **Upgrading from v0.1**: `POST /v1/images/generations`, `POST
|
||||||
|
> /v1/audio/speech`, and `POST /v1/audio/transcriptions` are now handled by
|
||||||
|
> the gateway itself, unconditionally — see [Image and audio
|
||||||
|
> endpoints](#image-and-audio-endpoints). They no longer fall through to
|
||||||
|
> `next` even when `passthroughUnknown: true`. A v0.1 deployment that
|
||||||
|
> served its own backend on any of these three exact paths, relying on
|
||||||
|
> passthrough to reach it, must move that backend to a different path
|
||||||
|
> before upgrading to v0.2 — the gateway now intercepts these paths first,
|
||||||
|
> every time.
|
||||||
|
|
||||||
Provider, `mcpServers`, and `agents` map **keys** (names) must match
|
Provider, `mcpServers`, and `agents` map **keys** (names) must match
|
||||||
`^[a-zA-Z0-9._-]+$` and must not be `v1`, `mcp`, or `a2a` — those names are
|
`^[a-zA-Z0-9._-]+$` and must not be `v1`, `mcp`, or `a2a` — those names are
|
||||||
@@ -348,10 +358,15 @@ kept — a bad edit to the file never breaks already-authenticated traffic.
|
|||||||
through the explicit `provider/model` form regardless of who won the
|
through the explicit `provider/model` form regardless of who won the
|
||||||
bare id, and `GET /v1/models` lists both the bare (winner's) form and
|
bare id, and `GET /v1/models` lists both the bare (winner's) form and
|
||||||
every `provider/model` form.
|
every `provider/model` form.
|
||||||
- Authorization always requires both `group.allowsModel` and
|
- **On the unified routes** (`modelRegistry.resolve`, used by
|
||||||
|
`/v1/chat/completions`, `/v1/embeddings`, and the three media
|
||||||
|
endpoints), authorization requires both `group.allowsModel` and
|
||||||
`group.allowsProvider` for the resolved provider — a model that exists
|
`group.allowsProvider` for the resolved provider — a model that exists
|
||||||
but the caller's group cannot reach returns 403, distinct from a model no
|
but the caller's group cannot reach returns 403, distinct from a model no
|
||||||
configured provider knows at all (404).
|
configured provider knows at all (404). Native passthrough
|
||||||
|
(`/{provider}/...`) resolves no model at all and checks
|
||||||
|
`group.allowsProvider` only — see the authorization row in [Unified vs.
|
||||||
|
passthrough](#unified-vs-passthrough).
|
||||||
- **Discovery**: `GET {baseUrl}/v1/models` for an `openai`-type provider,
|
- **Discovery**: `GET {baseUrl}/v1/models` for an `openai`-type provider,
|
||||||
`GET {baseUrl}/v1/models` for `anthropic`, `GET {baseUrl}/v1beta/models`
|
`GET {baseUrl}/v1/models` for `anthropic`, `GET {baseUrl}/v1beta/models`
|
||||||
for `gemini`. Runs once at plugin construction (bounded to 5s per
|
for `gemini`. Runs once at plugin construction (bounded to 5s per
|
||||||
@@ -489,9 +504,11 @@ predates this field keeps making exactly one upstream attempt per
|
|||||||
request.
|
request.
|
||||||
|
|
||||||
- **Scope**: every adapter upstream call — chat, embeddings, model
|
- **Scope**: every adapter upstream call — chat, embeddings, model
|
||||||
listing — across all three provider types. Native passthrough
|
listing, and the three media endpoints (`images/generations`,
|
||||||
(`/{provider}/...`) and the MCP/A2A proxy are raw reverse proxies and
|
`audio/speech`, `audio/transcriptions`) — across all three provider
|
||||||
are never retried; the client owns retry semantics there.
|
types. Native passthrough (`/{provider}/...`) and the MCP/A2A proxy are
|
||||||
|
raw reverse proxies and are never retried; the client owns retry
|
||||||
|
semantics there.
|
||||||
- **Attempts is retries, not tries**: `attempts` counts retries
|
- **Attempts is retries, not tries**: `attempts` counts retries
|
||||||
performed **after** the first try. The default, `1`, allows one retry
|
performed **after** the first try. The default, `1`, allows one retry
|
||||||
— two tries total, not one. The maximum, `3`, allows three retries —
|
— two tries total, not one. The maximum, `3`, allows three retries —
|
||||||
@@ -579,6 +596,7 @@ cached bodies for the same request.
|
|||||||
| | Unified (`/v1/...`) | Native passthrough (`/{provider}/...`) |
|
| | Unified (`/v1/...`) | Native passthrough (`/{provider}/...`) |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| Wire format | Always OpenAI-shaped in, OpenAI-shaped out. | The provider's own native format, untouched. |
|
| Wire format | Always OpenAI-shaped in, OpenAI-shaped out. | The provider's own native format, untouched. |
|
||||||
|
| Authorization | `group.allowsModel` AND `group.allowsProvider`, both checked against the resolved provider (`modelRegistry.resolve` — see [Model routing](#model-routing)). | `group.allowsProvider` only. Passthrough resolves no model at all, so there is nothing for `allowsModel` to gate. |
|
||||||
| Translation | `openai`-type: body forwarded verbatim (model id rewritten). `anthropic`/`gemini`: full bidirectional translation, including streaming, chunk by chunk. | None — a raw reverse proxy. |
|
| Translation | `openai`-type: body forwarded verbatim (model id rewritten). `anthropic`/`gemini`: full bidirectional translation, including streaming, chunk by chunk. | None — a raw reverse proxy. |
|
||||||
| Model alias in the response | Translated providers (`anthropic`, `gemini`) echo back the client's exact requested model string in the response's `model` field, even though the upstream call used the resolved provider model id. An `openai`-type response is a verbatim passthrough of the upstream body, so it carries whatever model id the upstream itself returned — this asymmetry is intentional, not a bug. | The upstream's own `model` field, verbatim — there is no alias to echo. |
|
| Model alias in the response | Translated providers (`anthropic`, `gemini`) echo back the client's exact requested model string in the response's `model` field, even though the upstream call used the resolved provider model id. An `openai`-type response is a verbatim passthrough of the upstream body, so it carries whatever model id the upstream itself returned — this asymmetry is intentional, not a bug. | The upstream's own `model` field, verbatim — there is no alias to echo. |
|
||||||
| Embeddings | `openai`-type: passthrough. `gemini`: mapped to `:embedContent`/`:batchEmbedContents`. `anthropic`: **501** — Anthropic's API has no embeddings endpoint. | Whatever the provider itself supports at that path; the gateway does not gate it. |
|
| Embeddings | `openai`-type: passthrough. `gemini`: mapped to `:embedContent`/`:batchEmbedContents`. `anthropic`: **501** — Anthropic's API has no embeddings endpoint. | Whatever the provider itself supports at that path; the gateway does not gate it. |
|
||||||
@@ -592,6 +610,14 @@ cached bodies for the same request.
|
|||||||
`/v1/chat/completions` — auth, model routing, group authorization, limits,
|
`/v1/chat/completions` — auth, model routing, group authorization, limits,
|
||||||
adapter call — minus response caching and token/cost accounting (below).
|
adapter call — minus response caching and token/cost accounting (below).
|
||||||
|
|
||||||
|
> **Upgrading from v0.1**: these three routes are now handled by the
|
||||||
|
> gateway unconditionally. They are matched and served before
|
||||||
|
> `passthroughUnknown` is ever consulted, so a request to any of them
|
||||||
|
> **never** falls through to `next` — not even with `passthroughUnknown:
|
||||||
|
> true`. If a v0.1 deployment pointed its router's backing service at one
|
||||||
|
> of these exact paths and relied on passthrough to reach it, that backend
|
||||||
|
> must move to a different path before upgrading.
|
||||||
|
|
||||||
| | `openai`-type | `gemini` | `anthropic` |
|
| | `openai`-type | `gemini` | `anthropic` |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| `images/generations` | Native forward. | Translated to Imagen's `:predict` (below). | **501**. |
|
| `images/generations` | Native forward. | Translated to Imagen's `:predict` (below). | **501**. |
|
||||||
@@ -674,7 +700,10 @@ request to any of them falls through to the plugin's existing
|
|||||||
gate: unauthenticated → 401, authenticated non-admin → 403, admin →
|
gate: unauthenticated → 401, authenticated non-admin → 403, admin →
|
||||||
serve. `overview` returns the provider list, groups, the configured
|
serve. `overview` returns the provider list, groups, the configured
|
||||||
model alias table (`alias`/`target` pairs, sorted by alias — see
|
model alias table (`alias`/`target` pairs, sorted by alias — see
|
||||||
[Model aliases](#model-aliases)), redis/cache status, and the plugin
|
[Model aliases](#model-aliases)), redis/cache/retry status (`retry`'s
|
||||||
|
`enabled`/`attempts`/`backoff` are the EFFECTIVE values after
|
||||||
|
[Retry](#retry)'s own defaulting, not the raw config — `attempts` and
|
||||||
|
`backoff` are both omitted when retry is disabled), and the plugin
|
||||||
version string. `usage` returns every user's and every group's
|
version string. `usage` returns every user's and every group's
|
||||||
current-window counter values (req/min, req/day, tok/day, tok/month,
|
current-window counter values (req/min, req/day, tok/day, tok/month,
|
||||||
cost/day, cost/month) alongside their configured limits.
|
cost/day, cost/month) alongside their configured limits.
|
||||||
@@ -793,6 +822,18 @@ request to any of them falls through to the plugin's existing
|
|||||||
move a token counter and always record cost as 0, regardless of what a
|
move a token counter and always record cost as 0, regardless of what a
|
||||||
provider itself would charge for them — a real gap for an operator
|
provider itself would charge for them — a real gap for an operator
|
||||||
billing on these endpoints, not just an accounting nuance.
|
billing on these endpoints, not just an accounting nuance.
|
||||||
|
- **An upstream response over 32MiB (`maxResponseBytes`, `providers.go`)
|
||||||
|
is silently truncated, not rejected.** Every adapter response read — the
|
||||||
|
unified chat/embeddings JSON, and the three media endpoints — is capped
|
||||||
|
at this limit. `audio/speech`'s streaming copy (`openaiAdapter.audioSpeech`,
|
||||||
|
`provider_openai.go`) already committed the upstream's 200 status before
|
||||||
|
it starts copying bytes, so a response over the cap simply ends early: no
|
||||||
|
error, no truncation signal of any kind, a shorter audio file
|
||||||
|
indistinguishable from a genuinely short one. `images/generations` and
|
||||||
|
`audio/transcriptions` (`forwardMediaBody`) read their whole response
|
||||||
|
before writing a status, so they hit the identical cap with the same
|
||||||
|
silent-truncation-into-200 outcome. 32MiB is generous for any of these
|
||||||
|
payloads in practice, but nothing enforces or signals the cutoff.
|
||||||
- **The response cache shares entries across every user and group that
|
- **The response cache shares entries across every user and group that
|
||||||
can reach a model.** `cache`'s key (see [Caching](#caching)) carries no
|
can reach a model.** `cache`'s key (see [Caching](#caching)) carries no
|
||||||
user identity by design, so two different callers sending the same
|
user identity by design, so two different callers sending the same
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -22,8 +23,16 @@ const (
|
|||||||
// folded review item, 2026-08-20 review): no external assets of any
|
// folded review item, 2026-08-20 review): no external assets of any
|
||||||
// kind, only this page's own inline script/style, and fetch calls
|
// kind, only this page's own inline script/style, and fetch calls
|
||||||
// restricted to same-origin — the dashboard works air-gapped and cannot
|
// restricted to same-origin — the dashboard works air-gapped and cannot
|
||||||
// be coerced into loading anything off-host.
|
// be coerced into loading anything off-host. frame-ancestors/base-uri/
|
||||||
const adminCSP = "default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; connect-src 'self'"
|
// form-action are all 'none' (v0.2 final review wave, 2026-08-20): the
|
||||||
|
// page carries a password-type key-entry input, so it must never be
|
||||||
|
// embeddable in another site's frame (clickjacking), never have its
|
||||||
|
// <base> href hijacked to retarget a relative script/fetch URL, and never
|
||||||
|
// have its auth-form submitted anywhere but nowhere at all — the form's
|
||||||
|
// own submit handler already intercepts and cancels the real submit
|
||||||
|
// (adminPageHTML's "submit" listener calls preventDefault()), so
|
||||||
|
// form-action has nothing legitimate to allow.
|
||||||
|
const adminCSP = "default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; connect-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'none'"
|
||||||
|
|
||||||
// adminEnabled reports whether cfg's Admin block is present and enabled —
|
// adminEnabled reports whether cfg's Admin block is present and enabled —
|
||||||
// the single gate ServeHTTP checks before matching any /admin* route at
|
// the single gate ServeHTTP checks before matching any /admin* route at
|
||||||
@@ -139,6 +148,29 @@ func sanitizeBaseURL(raw string) string {
|
|||||||
return u.String()
|
return u.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// sanitizeProviderErr applies sanitizeBaseURL's userinfo/query-stripping
|
||||||
|
// treatment to lastErr before it is ever echoed by GET /admin/api/overview
|
||||||
|
// (v0.2 final review wave, 2026-08-20): a provider's discovery/refresh
|
||||||
|
// error commonly embeds the request URL it failed against (Go's
|
||||||
|
// net/http wraps a *url.Error carrying it verbatim), so a baseUrl
|
||||||
|
// misconfigured with embedded credentials would otherwise leak straight
|
||||||
|
// back out through this field, even though adminProviderView.BaseURL
|
||||||
|
// itself is already sanitized.
|
||||||
|
//
|
||||||
|
// This is best-effort, not a general URL scrubber: it looks only for
|
||||||
|
// rawBaseURL appearing verbatim inside lastErr and replaces every such
|
||||||
|
// occurrence with sanitizeBaseURL's cleaned form. An error naming some
|
||||||
|
// OTHER URL — a redirect target, a differently-formatted variant of the
|
||||||
|
// same host — is not caught; recognizing every URL shape a wrapped error
|
||||||
|
// might embed, without risking mangling ordinary error text, is not
|
||||||
|
// attempted here.
|
||||||
|
func sanitizeProviderErr(lastErr, rawBaseURL string) string {
|
||||||
|
if lastErr == "" || rawBaseURL == "" || !strings.Contains(lastErr, rawBaseURL) {
|
||||||
|
return lastErr
|
||||||
|
}
|
||||||
|
return strings.ReplaceAll(lastErr, rawBaseURL, sanitizeBaseURL(rawBaseURL))
|
||||||
|
}
|
||||||
|
|
||||||
// adminProviderView is one provider's read-only view in GET
|
// adminProviderView is one provider's read-only view in GET
|
||||||
// /admin/api/overview (spec §4, v0.2). baseUrl is not secret — the
|
// /admin/api/overview (spec §4, v0.2). baseUrl is not secret — the
|
||||||
// spec's NEVER-exposed list is API keys (not even digests), provider
|
// spec's NEVER-exposed list is API keys (not even digests), provider
|
||||||
@@ -179,6 +211,21 @@ type adminCacheView struct {
|
|||||||
Enabled bool `json:"enabled"`
|
Enabled bool `json:"enabled"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// adminRetryView is the retry config summary in GET /admin/api/overview
|
||||||
|
// (v0.2 final review wave, 2026-08-20): the EFFECTIVE enabled/attempts/
|
||||||
|
// backoff a `retry: {enabled: true}` block resolves to, after
|
||||||
|
// newRetryPolicy's own zero-value defaulting (retry.go) — not the raw,
|
||||||
|
// possibly-empty RetryConfig fields, so an operator sees what actually
|
||||||
|
// governs upstream calls, not just what they left unset. Attempts and
|
||||||
|
// Backoff are both omitted (zero value / empty string) when retry is
|
||||||
|
// disabled, mirroring adminCacheView's own omitempty convention for a
|
||||||
|
// disabled feature's now-meaningless detail fields.
|
||||||
|
type adminRetryView struct {
|
||||||
|
Backoff string `json:"backoff,omitempty"`
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
Attempts int `json:"attempts,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
// adminGroupView is one group's summary in GET /admin/api/overview.
|
// adminGroupView is one group's summary in GET /admin/api/overview.
|
||||||
type adminGroupView struct {
|
type adminGroupView struct {
|
||||||
Limits *LimitsConfig `json:"limits,omitempty"`
|
Limits *LimitsConfig `json:"limits,omitempty"`
|
||||||
@@ -205,6 +252,7 @@ type adminOverviewResponse struct {
|
|||||||
Aliases []adminAliasView `json:"aliases"`
|
Aliases []adminAliasView `json:"aliases"`
|
||||||
Redis adminRedisView `json:"redis"`
|
Redis adminRedisView `json:"redis"`
|
||||||
Cache adminCacheView `json:"cache"`
|
Cache adminCacheView `json:"cache"`
|
||||||
|
Retry adminRetryView `json:"retry"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// buildAdminOverview assembles adminOverviewResponse from the registry,
|
// buildAdminOverview assembles adminOverviewResponse from the registry,
|
||||||
@@ -220,7 +268,7 @@ func (g *Gateway) buildAdminOverview() adminOverviewResponse {
|
|||||||
BaseURL: sanitizeBaseURL(s.baseURL),
|
BaseURL: sanitizeBaseURL(s.baseURL),
|
||||||
ModelCount: s.modelCount,
|
ModelCount: s.modelCount,
|
||||||
LastRefresh: s.lastRefresh,
|
LastRefresh: s.lastRefresh,
|
||||||
LastErr: s.lastErr,
|
LastErr: sanitizeProviderErr(s.lastErr, s.baseURL),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -232,6 +280,19 @@ func (g *Gateway) buildAdminOverview() adminOverviewResponse {
|
|||||||
ttl = g.cache.ttl.String()
|
ttl = g.cache.ttl.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// newRetryPolicy is pure (no I/O) and already ran once, successfully,
|
||||||
|
// when this Gateway was constructed (providers.go's buildAdapters) —
|
||||||
|
// g.cfg.Retry cannot have changed since a running Gateway never
|
||||||
|
// mutates its own config, so this second call is guaranteed to return
|
||||||
|
// the identical result with a nil error; the error return is
|
||||||
|
// intentionally discarded rather than propagated.
|
||||||
|
rp, _ := newRetryPolicy(g.cfg.Retry)
|
||||||
|
retryView := adminRetryView{Enabled: rp.enabled}
|
||||||
|
if rp.enabled {
|
||||||
|
retryView.Attempts = rp.attempts
|
||||||
|
retryView.Backoff = rp.backoff.String()
|
||||||
|
}
|
||||||
|
|
||||||
_, groupSummaries := g.auth.snapshot()
|
_, groupSummaries := g.auth.snapshot()
|
||||||
groups := make([]adminGroupView, len(groupSummaries))
|
groups := make([]adminGroupView, len(groupSummaries))
|
||||||
for i, gs := range groupSummaries {
|
for i, gs := range groupSummaries {
|
||||||
@@ -248,6 +309,7 @@ func (g *Gateway) buildAdminOverview() adminOverviewResponse {
|
|||||||
Providers: providers,
|
Providers: providers,
|
||||||
Redis: adminRedisView{Configured: configured, LastErr: redisLastErr, LastErrAt: redisLastErrAt},
|
Redis: adminRedisView{Configured: configured, LastErr: redisLastErr, LastErrAt: redisLastErrAt},
|
||||||
Cache: adminCacheView{Enabled: cacheEnabled, TTL: ttl},
|
Cache: adminCacheView{Enabled: cacheEnabled, TTL: ttl},
|
||||||
|
Retry: retryView,
|
||||||
Groups: groups,
|
Groups: groups,
|
||||||
Aliases: aliases,
|
Aliases: aliases,
|
||||||
Version: pluginVersion,
|
Version: pluginVersion,
|
||||||
@@ -309,25 +371,28 @@ func usageEntryView(su scopeUsage, limits *LimitsConfig) adminUsageEntryView {
|
|||||||
// buildAdminUsage assembles adminUsageResponse: authStore.snapshot lists
|
// buildAdminUsage assembles adminUsageResponse: authStore.snapshot lists
|
||||||
// every currently active user and every configured group (names, group
|
// every currently active user and every configured group (names, group
|
||||||
// membership, and limits only — never a key or its digest), and
|
// membership, and limits only — never a key or its digest), and
|
||||||
// limiter.currentUsage reads each one's six current-window counters —
|
// limiter.currentUsage reads every one's six current-window counters in
|
||||||
// one batched round trip per scope (limits.go's storeGetMulti) rather
|
// ONE storeGetMulti round trip total (v0.2 final review wave, 2026-08-20;
|
||||||
// than six separate ones. Unlike buildLimitScopes (routes_unified.go),
|
// see limiter.currentUsage's own doc comment for the tradeoff this
|
||||||
// this never omits an entity for having nil limits — the dashboard shows
|
// supersedes) — users and groups are concatenated into a single scopes
|
||||||
// usage for every user and group, limited or not.
|
// slice before that one call, then the flat result is sliced back into
|
||||||
|
// the two response sections at the same split point, order preserved.
|
||||||
|
// Unlike buildLimitScopes (routes_unified.go), this never omits an
|
||||||
|
// entity for having nil limits — the dashboard shows usage for every
|
||||||
|
// user and group, limited or not.
|
||||||
func (g *Gateway) buildAdminUsage() adminUsageResponse {
|
func (g *Gateway) buildAdminUsage() adminUsageResponse {
|
||||||
userSummaries, groupSummaries := g.auth.snapshot()
|
userSummaries, groupSummaries := g.auth.snapshot()
|
||||||
|
|
||||||
userScopes := make([]limitScope, len(userSummaries))
|
scopes := make([]limitScope, 0, len(userSummaries)+len(groupSummaries))
|
||||||
for i, us := range userSummaries {
|
for _, us := range userSummaries {
|
||||||
userScopes[i] = limitScope{kind: "user", id: us.name, limits: us.limits}
|
scopes = append(scopes, limitScope{kind: "user", id: us.name, limits: us.limits})
|
||||||
}
|
}
|
||||||
groupScopes := make([]limitScope, len(groupSummaries))
|
for _, gs := range groupSummaries {
|
||||||
for i, gs := range groupSummaries {
|
scopes = append(scopes, limitScope{kind: "group", id: gs.name, limits: gs.limits})
|
||||||
groupScopes[i] = limitScope{kind: "group", id: gs.name, limits: gs.limits}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
userUsage := g.limiter.currentUsage(userScopes)
|
allUsage := g.limiter.currentUsage(scopes)
|
||||||
groupUsage := g.limiter.currentUsage(groupScopes)
|
userUsage, groupUsage := allUsage[:len(userSummaries)], allUsage[len(userSummaries):]
|
||||||
|
|
||||||
users := make([]adminUsageEntryView, len(userUsage))
|
users := make([]adminUsageEntryView, len(userUsage))
|
||||||
for i, su := range userUsage {
|
for i, su := range userUsage {
|
||||||
@@ -571,6 +636,8 @@ const adminPageHTML = `<!doctype html>
|
|||||||
infra.appendChild(el("div", redisText, "infra-line"));
|
infra.appendChild(el("div", redisText, "infra-line"));
|
||||||
var cacheText = "Cache: " + (data.cache.enabled ? "enabled (ttl " + data.cache.ttl + ")" : "disabled");
|
var cacheText = "Cache: " + (data.cache.enabled ? "enabled (ttl " + data.cache.ttl + ")" : "disabled");
|
||||||
infra.appendChild(el("div", cacheText, "infra-line"));
|
infra.appendChild(el("div", cacheText, "infra-line"));
|
||||||
|
var retryText = "Retry: " + (data.retry.enabled ? "enabled (attempts " + data.retry.attempts + ", backoff " + data.retry.backoff + ")" : "disabled");
|
||||||
|
infra.appendChild(el("div", retryText, "infra-line"));
|
||||||
infra.appendChild(el("div", "Version: " + data.version, "infra-line muted"));
|
infra.appendChild(el("div", "Version: " + data.version, "infra-line muted"));
|
||||||
|
|
||||||
groupMeta = {};
|
groupMeta = {};
|
||||||
|
|||||||
+46
-7
@@ -263,6 +263,12 @@ func TestAdminOverview_SortedShapeAndVersion(t *testing.T) {
|
|||||||
if got.Redis.Configured {
|
if got.Redis.Configured {
|
||||||
t.Error("redis.configured must be false: cfg.Redis was never configured")
|
t.Error("redis.configured must be false: cfg.Redis was never configured")
|
||||||
}
|
}
|
||||||
|
if got.Retry.Enabled {
|
||||||
|
t.Error("retry.enabled must be false: cfg.Retry was never configured")
|
||||||
|
}
|
||||||
|
if got.Retry.Attempts != 0 || got.Retry.Backoff != "" {
|
||||||
|
t.Errorf("retry.attempts/backoff must be omitted when disabled, got %+v", got.Retry)
|
||||||
|
}
|
||||||
|
|
||||||
wantAliases := []adminAliasView{
|
wantAliases := []adminAliasView{
|
||||||
{Alias: "aaliased/y", Target: "alpha/a-model-1"},
|
{Alias: "aaliased/y", Target: "alpha/a-model-1"},
|
||||||
@@ -278,6 +284,38 @@ func TestAdminOverview_SortedShapeAndVersion(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestAdminOverview_RetryEffectiveValues proves GET /admin/api/overview's
|
||||||
|
// retry block reports the EFFECTIVE, defaulted values newRetryPolicy
|
||||||
|
// (retry.go) resolves an enabled retry block to, not the raw config —
|
||||||
|
// Attempts and Backoff are both left at their zero value here specifically
|
||||||
|
// to exercise that defaulting (v0.2 final review wave, 2026-08-20).
|
||||||
|
func TestAdminOverview_RetryEffectiveValues(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
cfg := newAdminTestConfig()
|
||||||
|
cfg.Retry = RetryConfig{Enabled: true}
|
||||||
|
h, _ := newAdminGatewayHandle(t, cfg)
|
||||||
|
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(rec, adminRequest(http.MethodGet, adminOverviewPath, "sk-admin1"))
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want 200, body=%s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
var got adminOverviewResponse
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
|
||||||
|
t.Fatalf("decode: %v", err)
|
||||||
|
}
|
||||||
|
if !got.Retry.Enabled {
|
||||||
|
t.Fatal("retry.enabled must be true")
|
||||||
|
}
|
||||||
|
if got.Retry.Attempts != defaultRetryAttempts {
|
||||||
|
t.Errorf("retry.attempts = %d, want the default %d (raw config left Attempts at 0)", got.Retry.Attempts, defaultRetryAttempts)
|
||||||
|
}
|
||||||
|
if got.Retry.Backoff != defaultRetryBackoff {
|
||||||
|
t.Errorf("retry.backoff = %q, want the default %q (raw config left Backoff empty)", got.Retry.Backoff, defaultRetryBackoff)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestAdminOverview_ProviderLastErrAfterFailedRefresh(t *testing.T) {
|
func TestAdminOverview_ProviderLastErrAfterFailedRefresh(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -478,7 +516,7 @@ func TestAdminPage_CSPHeaderAndFetchURLs(t *testing.T) {
|
|||||||
if ct := rec.Header().Get("Content-Type"); !strings.Contains(ct, "text/html") {
|
if ct := rec.Header().Get("Content-Type"); !strings.Contains(ct, "text/html") {
|
||||||
t.Errorf("content-type = %q, want text/html", ct)
|
t.Errorf("content-type = %q, want text/html", ct)
|
||||||
}
|
}
|
||||||
wantCSP := "default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; connect-src 'self'"
|
wantCSP := "default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; connect-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'none'"
|
||||||
if got := rec.Header().Get("Content-Security-Policy"); got != wantCSP {
|
if got := rec.Header().Get("Content-Security-Policy"); got != wantCSP {
|
||||||
t.Errorf("CSP = %q, want %q", got, wantCSP)
|
t.Errorf("CSP = %q, want %q", got, wantCSP)
|
||||||
}
|
}
|
||||||
@@ -506,7 +544,7 @@ func TestAdmin_JSONSecurityHeaders(t *testing.T) {
|
|||||||
cfg := newAdminTestConfig()
|
cfg := newAdminTestConfig()
|
||||||
h, _ := newAdminGatewayHandle(t, cfg)
|
h, _ := newAdminGatewayHandle(t, cfg)
|
||||||
|
|
||||||
wantCSP := "default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; connect-src 'self'"
|
wantCSP := "default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; connect-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'none'"
|
||||||
for _, p := range []string{adminOverviewPath, adminUsagePath} {
|
for _, p := range []string{adminOverviewPath, adminUsagePath} {
|
||||||
t.Run(p, func(t *testing.T) {
|
t.Run(p, func(t *testing.T) {
|
||||||
rec := httptest.NewRecorder()
|
rec := httptest.NewRecorder()
|
||||||
@@ -660,8 +698,9 @@ func TestAdminOverview_BaseURLStripsCredentials(t *testing.T) {
|
|||||||
|
|
||||||
// countingMultiStore is a counterStore stub whose getMulti serves a fixed
|
// countingMultiStore is a counterStore stub whose getMulti serves a fixed
|
||||||
// value per key and counts how many times it was called — it proves
|
// value per key and counts how many times it was called — it proves
|
||||||
// limiter.currentUsage batches a scope's six counter reads into one
|
// limiter.currentUsage flattens every scope's six counter reads into ONE
|
||||||
// getMulti call rather than issuing them as six separate get calls.
|
// getMulti call total (v0.2 final review wave, 2026-08-20), rather than
|
||||||
|
// six separate get calls or even one getMulti call per scope.
|
||||||
// incrBy/get are never exercised by currentUsage and just return zero
|
// incrBy/get are never exercised by currentUsage and just return zero
|
||||||
// values.
|
// values.
|
||||||
type countingMultiStore struct {
|
type countingMultiStore struct {
|
||||||
@@ -682,7 +721,7 @@ func (s *countingMultiStore) getMulti(keys []string) ([]int64, error) {
|
|||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestLimiterCurrentUsage_BatchesOneGetMultiCallPerScope(t *testing.T) {
|
func TestLimiterCurrentUsage_BatchesOneGetMultiCallForAllScopes(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
fixedNow := time.Date(2026, 8, 20, 10, 0, 0, 0, time.UTC)
|
fixedNow := time.Date(2026, 8, 20, 10, 0, 0, 0, time.UTC)
|
||||||
store := &countingMultiStore{values: map[string]int64{
|
store := &countingMultiStore{values: map[string]int64{
|
||||||
@@ -703,8 +742,8 @@ func TestLimiterCurrentUsage_BatchesOneGetMultiCallPerScope(t *testing.T) {
|
|||||||
}
|
}
|
||||||
got := l.currentUsage(scopes)
|
got := l.currentUsage(scopes)
|
||||||
|
|
||||||
if store.getMultiCalls != len(scopes) {
|
if store.getMultiCalls != 1 {
|
||||||
t.Errorf("getMultiCalls = %d, want %d (one pipelined getMulti per scope, not six separate get calls)", store.getMultiCalls, len(scopes))
|
t.Errorf("getMultiCalls = %d, want 1 (all scopes' keys flattened into one pipelined getMulti call)", store.getMultiCalls)
|
||||||
}
|
}
|
||||||
if len(got) != 2 {
|
if len(got) != 2 {
|
||||||
t.Fatalf("len(got) = %d, want 2", len(got))
|
t.Fatalf("len(got) = %d, want 2", len(got))
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import (
|
|||||||
"bytes"
|
"bytes"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"io"
|
"io"
|
||||||
|
"mime/multipart"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
@@ -89,6 +90,61 @@ func doJSON(t *testing.T, method, url, apiKey string, body any) (*http.Response,
|
|||||||
return resp, out
|
return resp, out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// buildMultipartAudio builds a multipart/form-data body carrying a dummy
|
||||||
|
// "file" part and a "model" part set to model, mirroring what an
|
||||||
|
// OpenAI-SDK client sends to POST /v1/audio/transcriptions. It returns the
|
||||||
|
// encoded body and its matching Content-Type (including the boundary
|
||||||
|
// parameter multipart.Writer generated).
|
||||||
|
func buildMultipartAudio(t *testing.T, model string) ([]byte, string) {
|
||||||
|
t.Helper()
|
||||||
|
var buf bytes.Buffer
|
||||||
|
mw := multipart.NewWriter(&buf)
|
||||||
|
fw, err := mw.CreateFormFile("file", "audio.wav")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create form file part: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := fw.Write([]byte("mock-audio-bytes")); err != nil {
|
||||||
|
t.Fatalf("write file part: %v", err)
|
||||||
|
}
|
||||||
|
if err := mw.WriteField("model", model); err != nil {
|
||||||
|
t.Fatalf("write model field: %v", err)
|
||||||
|
}
|
||||||
|
if err := mw.Close(); err != nil {
|
||||||
|
t.Fatalf("close multipart writer: %v", err)
|
||||||
|
}
|
||||||
|
return buf.Bytes(), mw.FormDataContentType()
|
||||||
|
}
|
||||||
|
|
||||||
|
// doMultipart issues one multipart/form-data POST, mirroring doJSON's
|
||||||
|
// contract: the returned *http.Response has already had its body read and
|
||||||
|
// closed, and a non-JSON or empty body decodes to a nil map.
|
||||||
|
func doMultipart(t *testing.T, url, apiKey, contentType string, body []byte) (*http.Response, map[string]any) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
req, err := http.NewRequest(http.MethodPost, url, bytes.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("new request POST %s: %v", url, err)
|
||||||
|
}
|
||||||
|
req.Header.Set("Authorization", "Bearer "+apiKey)
|
||||||
|
req.Header.Set("Content-Type", contentType)
|
||||||
|
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("do request POST %s: %v", url, err)
|
||||||
|
}
|
||||||
|
defer func() { _ = resp.Body.Close() }()
|
||||||
|
|
||||||
|
raw, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read response body from POST %s: %v", url, err)
|
||||||
|
}
|
||||||
|
var out map[string]any
|
||||||
|
if len(raw) > 0 {
|
||||||
|
_ = json.Unmarshal(raw, &out)
|
||||||
|
}
|
||||||
|
return resp, out
|
||||||
|
}
|
||||||
|
|
||||||
// firstChoiceContent extracts choices[0].message.content from an
|
// firstChoiceContent extracts choices[0].message.content from an
|
||||||
// OpenAI-shaped chat completion response body, failing the test if the
|
// OpenAI-shaped chat completion response body, failing the test if the
|
||||||
// shape doesn't match.
|
// shape doesn't match.
|
||||||
@@ -827,6 +883,39 @@ func TestAudioSpeech(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestAudioTranscriptions covers the review wave's highest-value Yaegi gap:
|
||||||
|
// POST /v1/audio/transcriptions' multipart handling under real
|
||||||
|
// Traefik+Yaegi. A provider-prefixed model ("openai/gpt-mock") drives
|
||||||
|
// rewriteMultipartModel (routes_media.go), which rebuilds the multipart
|
||||||
|
// body with the "model" part rewritten to the resolved bare upstream id
|
||||||
|
// before it reaches the mock — asserted via mock/main.go's
|
||||||
|
// handleOpenAIAudioTranscriptions echoing "model_received" back. A second,
|
||||||
|
// already-bare request ("gpt-mock") exercises the byte-identical replay
|
||||||
|
// path instead: no rebuild, same bare id reported back verbatim.
|
||||||
|
func TestAudioTranscriptions(t *testing.T) {
|
||||||
|
t.Run("provider-prefixed model is rewritten before upload", func(t *testing.T) {
|
||||||
|
body, contentType := buildMultipartAudio(t, "openai/gpt-mock")
|
||||||
|
resp, decoded := doMultipart(t, traefik1URL+"/v1/audio/transcriptions", aliceKey, contentType, body)
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, body=%#v", resp.StatusCode, decoded)
|
||||||
|
}
|
||||||
|
if got, _ := decoded["model_received"].(string); got != "gpt-mock" {
|
||||||
|
t.Errorf("model_received = %q, want %q (rewritten from the provider-prefixed id)", got, "gpt-mock")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("bare model replays the original body unchanged", func(t *testing.T) {
|
||||||
|
body, contentType := buildMultipartAudio(t, "gpt-mock")
|
||||||
|
resp, decoded := doMultipart(t, traefik1URL+"/v1/audio/transcriptions", aliceKey, contentType, body)
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, body=%#v", resp.StatusCode, decoded)
|
||||||
|
}
|
||||||
|
if got, _ := decoded["model_received"].(string); got != "gpt-mock" {
|
||||||
|
t.Errorf("model_received = %q, want %q (byte-identical replay, no rewrite needed)", got, "gpt-mock")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// TestAdminDashboard covers v0.2 integration coverage (spec §4): the
|
// TestAdminDashboard covers v0.2 integration coverage (spec §4): the
|
||||||
// unauthenticated HTML shell carries no data, the two JSON routes gate
|
// unauthenticated HTML shell carries no data, the two JSON routes gate
|
||||||
// on admin vs. non-admin vs. unauthenticated, and GET /admin/api/usage
|
// on admin vs. non-admin vs. unauthenticated, and GET /admin/api/usage
|
||||||
|
|||||||
@@ -84,6 +84,7 @@ func newServerMux(mode string) (*http.ServeMux, error) {
|
|||||||
mux.HandleFunc("/flaky/reset", handleFlakyReset)
|
mux.HandleFunc("/flaky/reset", handleFlakyReset)
|
||||||
mux.HandleFunc("/v1/images/generations", handleOpenAIImages)
|
mux.HandleFunc("/v1/images/generations", handleOpenAIImages)
|
||||||
mux.HandleFunc("/v1/audio/speech", handleOpenAIAudioSpeech)
|
mux.HandleFunc("/v1/audio/speech", handleOpenAIAudioSpeech)
|
||||||
|
mux.HandleFunc("/v1/audio/transcriptions", handleOpenAIAudioTranscriptions)
|
||||||
case "anthropic":
|
case "anthropic":
|
||||||
mux.HandleFunc("/v1/models", handleAnthropicModels)
|
mux.HandleFunc("/v1/models", handleAnthropicModels)
|
||||||
mux.HandleFunc("/v1/messages", handleAnthropicMessages)
|
mux.HandleFunc("/v1/messages", handleAnthropicMessages)
|
||||||
@@ -309,6 +310,27 @@ func handleOpenAIAudioSpeech(w http.ResponseWriter, r *http.Request) {
|
|||||||
_, _ = w.Write(mockAudioBytes)
|
_, _ = w.Write(mockAudioBytes)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// handleOpenAIAudioTranscriptions answers POST /v1/audio/transcriptions by
|
||||||
|
// parsing the multipart body Traefik forwarded and echoing back the "model"
|
||||||
|
// form field it finds — this is the review wave's highest-value Yaegi
|
||||||
|
// coverage gap closed: the audio-transcriptions integration test sends a
|
||||||
|
// provider-prefixed model ("openai/gpt-mock") and asserts this handler
|
||||||
|
// receives the bare, rewritten form ("gpt-mock"), proving
|
||||||
|
// rewriteMultipartModel (routes_media.go) actually rebuilds the multipart
|
||||||
|
// body under real Traefik+Yaegi, not just in the compiled unit tests. A
|
||||||
|
// second, already-bare request exercises the byte-identical replay path
|
||||||
|
// instead, so this same field still reads back "gpt-mock" with no rebuild.
|
||||||
|
func handleOpenAIAudioTranscriptions(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if err := r.ParseMultipartForm(1 << 20); err != nil { //nolint:gosec // G120: maxMemory is set (1MiB) and the whole handler is behind readCapped's 10MiB gateway-side cap (routes_media.go) before this mock ever sees the body; test-only fixture server, not production code
|
||||||
|
http.Error(w, `{"error":{"message":"invalid multipart body","type":"invalid_request_error"}}`, http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, map[string]any{
|
||||||
|
"text": "mock transcription",
|
||||||
|
"model_received": r.FormValue("model"),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// --- anthropic mode ----------------------------------------------------------
|
// --- anthropic mode ----------------------------------------------------------
|
||||||
|
|
||||||
func handleAnthropicModels(w http.ResponseWriter, _ *http.Request) {
|
func handleAnthropicModels(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
|||||||
@@ -723,42 +723,60 @@ func usageWindowKeys(sc limitScope, now time.Time) []string {
|
|||||||
// does, and it is read-only: unlike checkAndCount, it never increments
|
// does, and it is read-only: unlike checkAndCount, it never increments
|
||||||
// anything.
|
// anything.
|
||||||
//
|
//
|
||||||
// One storeGetMulti call per scope (controller-approved amendment,
|
// ONE storeGetMulti call for the whole scopes slice (v0.2 final review
|
||||||
// 2026-08-20 review): the original per-metric l.getCounter loop issued
|
// wave, 2026-08-20; supersedes the "one call per scope" amendment this
|
||||||
// six separate round trips per scope against the single shared Redis
|
// comment previously described, 2026-08-20 review): every scope's six
|
||||||
// connection — GET /admin/api/usage serialized 6×(users+groups) of them.
|
// keys are flattened into a single round trip against the shared store,
|
||||||
// Batching all scopes' keys into one single storeGetMulti call would cut
|
// rather than N separate ones. The reviewer-confirmed reason for this
|
||||||
// that further, to one round trip total, but was not taken here: it
|
// second amendment: GET /admin/api/usage's per-scope loop was contending
|
||||||
// would need buildAdminUsage to flatten and later re-split two
|
// with request admission on the same store connection as live traffic,
|
||||||
// differently-sized scope lists (users, groups) around one shared call,
|
// and N round trips (even pipelined 6-at-a-time) scales with the number
|
||||||
// for a further win only realized when both lists are non-trivially
|
// of configured users and groups in a way one round trip does not.
|
||||||
// large. One call per scope already turns 6N round trips into N, keeps
|
// buildAdminUsage (admin.go) drives this by concatenating its users and
|
||||||
// currentUsage's per-scope iteration shape identical to
|
// groups into one scopes slice before calling in, then slicing the flat
|
||||||
// checkAndCount/evaluateScope's, and keeps a single scope's store
|
// result back into its two response sections at the same split point.
|
||||||
// failure from forcing every other scope in the same request to look
|
//
|
||||||
// storeDown too. Order is preserved: currentUsage(scopes)[i] corresponds
|
// Tradeoff given up by this second amendment: a transient store error
|
||||||
// to scopes[i].
|
// now marks every scope in the call storeDown together, where the
|
||||||
|
// previous per-scope loop could let one scope's failure land mid-batch
|
||||||
|
// while a later scope's own independent call still succeeded. That
|
||||||
|
// isolation was never a documented guarantee of this method, only an
|
||||||
|
// incidental property of how it was written — cutting N round trips to 1
|
||||||
|
// is the reviewer-confirmed right side of this tradeoff. Order is
|
||||||
|
// preserved: currentUsage(scopes)[i] corresponds to scopes[i].
|
||||||
func (l *limiter) currentUsage(scopes []limitScope) []scopeUsage {
|
func (l *limiter) currentUsage(scopes []limitScope) []scopeUsage {
|
||||||
now := l.now()
|
|
||||||
out := make([]scopeUsage, len(scopes))
|
out := make([]scopeUsage, len(scopes))
|
||||||
for i, sc := range scopes {
|
if len(scopes) == 0 {
|
||||||
keys := usageWindowKeys(sc, now)
|
return out
|
||||||
vals, ok := l.storeGetMulti(keys)
|
}
|
||||||
// len(vals) != len(keys) is reachable only from a non-conforming
|
|
||||||
// counterStore implementation (a real respClient.getMulti and
|
now := l.now()
|
||||||
// memoryStore.getMulti both always return one value per key) —
|
allKeys := make([]string, 0, len(scopes)*6)
|
||||||
// guarded defensively so a future or test-only store's short
|
for _, sc := range scopes {
|
||||||
// slice reports storeDown instead of panicking on an
|
allKeys = append(allKeys, usageWindowKeys(sc, now)...)
|
||||||
// out-of-range index below (review sweep, 2026-08-20).
|
}
|
||||||
if !ok || len(vals) != len(keys) {
|
|
||||||
|
vals, ok := l.storeGetMulti(allKeys)
|
||||||
|
// len(vals) != len(allKeys) is reachable only from a non-conforming
|
||||||
|
// counterStore implementation (a real respClient.getMulti and
|
||||||
|
// memoryStore.getMulti both always return one value per key) —
|
||||||
|
// guarded defensively so a future or test-only store's short slice
|
||||||
|
// reports every scope storeDown instead of panicking on an
|
||||||
|
// out-of-range index below (review sweep, 2026-08-20).
|
||||||
|
if !ok || len(vals) != len(allKeys) {
|
||||||
|
for i, sc := range scopes {
|
||||||
out[i] = scopeUsage{kind: sc.kind, id: sc.id, storeDown: true}
|
out[i] = scopeUsage{kind: sc.kind, id: sc.id, storeDown: true}
|
||||||
continue
|
|
||||||
}
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
for i, sc := range scopes {
|
||||||
|
v := vals[i*6 : i*6+6]
|
||||||
out[i] = scopeUsage{
|
out[i] = scopeUsage{
|
||||||
kind: sc.kind, id: sc.id,
|
kind: sc.kind, id: sc.id,
|
||||||
requestsPerMinute: vals[0], requestsPerDay: vals[1],
|
requestsPerMinute: v[0], requestsPerDay: v[1],
|
||||||
tokensPerDay: vals[2], tokensPerMonth: vals[3],
|
tokensPerDay: v[2], tokensPerMonth: v[3],
|
||||||
costPerDayMicros: vals[4], costPerMonthMicros: vals[5],
|
costPerDayMicros: v[4], costPerMonthMicros: v[5],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
|
|||||||
Reference in New Issue
Block a user