Compare commits

...

21 Commits

Author SHA1 Message Date
2ebc01e03b fix(build): nsis.7z is not a required artifact — Setup exe is the formal Windows product 2026-08-30 13:49:34 +08:00
11776024e9 chore(build): gitignore cmd/gui/.cache (docker electron-builder cache dir) 2026-08-30 10:56:01 +08:00
bf1932c6c5 chore(version): 1.4.0 -> 1.4.1 2026-08-30 10:52:48 +08:00
22ddf5a411 chore(build): drop ELECTRON_BUILDER_CACHE so the wine toolchain cache stays in the volume, not the workspace 2026-08-30 10:50:01 +08:00
3698546d14 fix(build): run Windows NSIS packaging inside docker; add artifact size gate
The Windows installer has been broken since 1.3.0: electron-builder's NSIS step
needs wine to generate the uninstaller, but the host's wine was amd64-only (no
i386 runtime -> empty syswow64 -> `error c0000135`), so electron-builder silently
wrote a 264 KB installer shell with no payload. No check caught it and the broken
exe shipped. 1.4.0 reproduced the same failure this session.

Two fixes:

1. win-builder image gains node + wine32/wine64 (+ i386 arch). dist-win-docker.sh
   now runs BOTH the core cross-build and `npx electron-builder --win nsis`
   inside docker (USE_SYSTEM_WINE=true -> the image's wine). The wine prefix is
   initialized on first run in the shared cache volume, so syswow64/ntdll.dll
   exists - the exact thing the host lacked. The host needs no mingw/wine/node.

2. packaging/verify-dist.sh: a size-floor gate for GUI artifacts (exe >= 5 MB,
   deb/rpm/nsis.7z >= 10 MB). Wired into `make gui-dist` and `make gui-win-docker`
   and the dist-win-docker script, so a degenerate installer fails the build
   instead of reaching a Release. Verified: it rejects the 264 KB exe and passes
   the healthy artifacts.
2026-08-30 10:49:45 +08:00
ed05cccb72 feat(packaging): core distribution packages (deb + rpm + tar.gz) via nfpm
Previously only the Electron GUI had installers; server admins had to build the
core by hand (`make build` -> bare binary). This adds a first-class server
distribution:

- `make core-dist` -> packaging/core-dist.sh -> cmd/build/dist/
    llmsproxy_<ver>_amd64.deb        (systemd unit + adapters + example config)
    llmsproxy-<ver>.x86_64.rpm
    llmsproxy-<ver>-linux-amd64.tar.gz
- nfpm config (packaging/nfpm.yaml): binary to /usr/bin, systemd unit with the
  measured memory tuning (GOGC=50, MALLOC_ARENA_MAX=2) baked in, Lua adapters to
  /usr/share/llmsproxy/adapters, repo's config.yaml as the example.
- postinst seeds /etc/llmsproxy/config.yaml on first install and keeps existing
  runtime state across reinstalls; prerm stops the service on removal.

Gotchas handled: nfpm v2 here does not render `{{ .Env.VERSION }}`, so the script
stamps the version into a throwaway config copy; and under `set -o pipefail` the
idiom `strings | grep -q` is broken (grep -q closes the pipe and strings dies on
SIGPIPE -> spurious 141), so the symbol sanity-gate stages strings in a temp file.

README (zh/en) updated: memory figures replaced with production-measured
32-35 MB settled (was 37-42), and the packaging docs now describe core-dist and
the dockerized Windows build.
2026-08-30 10:49:25 +08:00
c309448414 feat(webui): Mono theme — pure white in light mode, pure black in dark mode
Adds a fourth accent alongside sakura/ocean/violet. Unlike those it is not just a
different hue: the coloured themes are glass surfaces (translucent cards with
backdrop-filter) floating over an animated gradient-mesh background, so setting
--card:#ffffff there still renders as a tinted grey. Mono therefore also switches
off the translucency and hides the blobs, so #ffffff is actually #ffffff and
#000000 is actually #000000, with greys carrying the hierarchy that hue carries
elsewhere. A side effect worth having: no backdrop-filter and no animated blobs
makes it the cheapest theme to render, which helps on weak GPUs and over remote
desktops.

Both light and dark variable blocks are defined, so the existing light/dark
toggle drives it with no extra wiring: light -> white, dark -> black.

Also fixes a latent theme bug found while checking contrast on black: the active
chart's grid baseline assigned the literal string "var(--line)" to
ctx.strokeStyle. Canvas 2D does not resolve CSS custom properties, so that was an
invalid colour the browser ignored, leaving the previous fillStyle (black) — an
invisible baseline on every dark theme. Colours used on a canvas now go through a
cssVar() helper.

Tests: TestUIThemeMatrix asserts every accent defines BOTH a light and a dark
block plus a picker button (a half-defined theme shows up as unreadable text, not
as an error); TestUIMonoThemeIsFlat pins the opaque surfaces and disabled blobs;
TestUICanvasColorsResolveVars fails if any ctx.strokeStyle/fillStyle is handed a
raw var().

Unrelated packaging fix in the same commit: dist:linux only built deb+AppImage
while build.linux.target listed rpm too, so `make gui-dist` silently skipped the
rpm that release builds are expected to produce. Makefile/README wording updated
to match.
2026-08-30 10:04:53 +08:00
5b6d6fc90d fix(gateway): keep the record ring small now that aggregates scan everything
The gateway still built Stats with NewStats(10000), a leftover from when the
ring WAS the source of the dashboard's numbers. With aggregates now computed
from the full audit history, a 10000-entry ring only means 10000 resident Req
structs — the live instance came up at 64 MB instead of ~40 MB, putting back
most of the memory the on-demand paging work removed.

The ring's only jobs are the status page's 5-minute SourceRecent /
SourceAverages windows and the dashboard's first screen, both of which fit in
defaultRingSize (500). Older records are paged from disk.

TestGatewayRingStaysSmall pins it so the constant cannot drift back up.
2026-08-30 09:34:25 +08:00
3ddae41f0c fix(gateway): aggregate the full audit history, repair record paging
Two problems reported after the on-demand log work landed.

1. Dashboard totals were wrong. LoadAudit only replayed the last 4 MB of the
   audit file, so requests/tokens/per-key rows reflected a window instead of all
   time — a regression in reported numbers, not just in presentation.

   The aggregates are now built by streaming EVERY audit file (oldest first, so
   the hourly quota buckets keep their intended trailing window) and keeping
   nothing per record: aggregate maps are keyed by key/model/source, so their
   size is bounded by cardinality. Measured on the production host: 29 MB /
   221k lines / 37k requests in ~260 ms at startup.

   What stays bounded is the RAW-record ring: a fixed-size reqRing keeps only the
   newest maxRecs records, so the ~25 MB that used to be spent appending every
   record into a slice is still saved. auditReplayBytes is gone, and
   replayPartial now means "an audit file could not be read", which is the only
   remaining way for the totals to be incomplete.

2. Scrolling to the bottom stopped loading more records. Two independent causes:

   * paintRecords rebuilt the entire table on every 5s poll whenever the row
     count did not exceed the first screen — the "is a paged view live?" test
     compared row counts and matched exactly on the first refresh — wiping loaded
     pages and resetting scroll position.
   * IntersectionObserver only fires on TRANSITIONS. With a short list, or after a
     page whose rows all duplicated the first screen, the sentinel stayed visible
     and never fired again.

   paintRecords now builds once (recsState.built) and later polls PREPEND only
   genuinely new rows; attachRecsObserver adds a scroll-position fallback;
   fillRecordsViewport loads until the list actually overflows; and
   loadMoreRecords chains (bounded) when a page yields no new rows, since the
   first fetch necessarily overlaps the first screen.

TestUIRecordsPagingWiring pins all four mechanisms structurally, since none of
them is reachable from Go. Test names/comments referring to bounded replay are
updated to describe the bounded RING instead, and both READMEs now state that
totals come from the full history while records are paged.
2026-08-30 09:29:42 +08:00
2378bc00ba docs: replace invented memory figures with measured ones, ship the tuning knobs
The README claimed "~15 MB RSS" and, after the log-loading work, "~10 MB idle /
~19 MB with a 29 MB audit log". Those were TEST-INSTANCE numbers: one mock source
and one adapter. The real production config on this host (16 sources, 13
adapters, 59 models) sits at ~37-42 MB, and sat at ~105 MB before this series.
Quoting the single-source figure as the headline was misleading.

Both READMEs now state that memory scales with the number of configured sources
rather than with uptime, give a three-row measurement table (1 source / 1 source
with a 29 MB audit history / the 16-source production instance), and break the
production RSS down per region (Go heap, thread stacks + LuaJIT, mapped binary,
Go reservations, shared libs) so an operator can tell which part their own
deployment will grow.

Two runtime knobs are documented and now shipped by default in the desktop
build's core spawn (cmd/gui/main.js, overridable by exporting either variable):

  * MALLOC_ARENA_MAX=2 — LuaJIT allocates through cgo into glibc malloc, and
    glibc keeps up to 8*nproc per-thread arenas of ~1 MB that are never returned.
    Measured 8-15 arenas (7-12 MB) -> 0.
  * GOGC=50 — halves the Go heap target. Documented explicitly as useless ALONE
    (measured 20.3 -> 21.5 MB, i.e. worse, because the saved heap is eaten by
    more glibc arenas); only the pair cuts settled RSS, by ~19%.

Also corrects the binary size (8-12 MB, ~8 MB after the deploy script's -s -w)
and adds the elastic-pool / on-demand-log / self-healing-cooldown bullets that
README.md already had to README_EN.md.
2026-08-30 09:09:21 +08:00
882288f67f fix(webui): send DELETE when removing keys and adapters
Deleting a gateway key from the admin UI did nothing and reported
"use GET /api/keys": delKey() called api() with an empty options object, so
fetch defaulted to GET and the request landed in the GET branch of
handleKeysAPI. delAdapter() had the identical bug and reported
"adapter code not exposed; edit in UI".

This is the third instance of the same mistake — ab20f1b fixed delSource and
delTemplate, missing these two — so it is now pinned by tests instead of by
review:

  * TestUIAPICallsDeclareMethod walks every api() call in the embedded
    index.html and fails if one passes an options object without a method
    (an AbortSignal-only read is allowed, being a deliberate GET).
  * TestUIDeleteHelpersUseDelete / TestUIMutatingHelpersUseWriteMethods pin the
    verb of each removal and write helper by name.
  * TestKeyDeleteRoundTrip covers create -> DELETE -> gone -> second DELETE is a
    clean 404, and TestCannotDeleteOwnKey keeps the lockout guard.

The 404 bodies for GET /api/keys/{key} and GET /api/adapters/{name} now name the
verb to use ("DELETE /api/keys/{key} to remove"), because that message is what a
mis-methoded client actually shows its user; "use GET /api/keys" read as though
the caller had done nothing wrong.

delSource's indentation, broken by ab20f1b, is also straightened out.
2026-08-30 09:07:05 +08:00
813de19bd0 fix(lua): bound prewarm by queue depth, not by the ceiling
Production oscillated between ~46 MB and ~56 MB RSS with the openai pool
cycling 1 -> 10..12 -> 1 states every couple of minutes, while peak_in_use
never went above 2.

Cause: the batch prewarm sized itself purely on the adapter's ceiling. With
max_concurrent summing to 76, growStep is 8, so any two overlapping requests
warmed 8 states — 6 more than anything was waiting for. A minute later the
janitor correctly reclaimed the surplus, the next pair of overlapping requests
warmed 8 again, and the pool churned boot/discard forever. The elasticity was
working; the growth signal was simply wrong.

Prewarm is now bounded by BOTH limits: the ceiling still caps the step, but the
batch never exceeds p.waiting, the number of goroutines actually blocked on the
pool. Overlapping-but-not-queued traffic (the common case) creates exactly the
states it uses; a genuinely queued burst still ramps in one jump.

TestContentionBatchPrewarms is rewritten to queue real waiters instead of
relying on the ceiling to imply demand, and TestNoPrewarmWithoutWaiters pins the
production shape: two overlapping requests against a 76-wide adapter must create
exactly 2 states.
2026-08-30 08:25:18 +08:00
25d8bd8632 fix(lua): reclaim burst leftovers while traffic continues
Production after the first deploy showed the openai pool stuck at 9 states with
peak_in_use=1: a startup burst grew it, and then it never shrank again. The
shrink grace counter was reset by every CHECKOUT, so on a gateway that always
has a request in flight the counter never reached shrinkGraceRounds and the
burst's leftover states were pinned indefinitely — the same monotonic-growth
behaviour this series set out to remove, just with an extra step.

Grace is now reset by GROWTH (a miss that had to boot a state), which is the
actual signal that capacity is short. Ordinary sequential traffic no longer
defers reclaim, while two consecutive quiet-ish rounds are still required so a
gap between two bursts does not tear the pool down.

TestCheckoutResetsGrace asserted the old behaviour and is replaced by
TestGrowthResetsGrace (sequential traffic must NOT defer, growth must) plus
TestBurstLeftoverIsReclaimedUnderSteadyTraffic, which reproduces the production
shape: 9 concurrent holds, then one request per janitor round, and the pool must
still fall back to the resident floor.
2026-08-30 08:13:34 +08:00
3e27f4db24 chore: bump version to 1.4.0, document cooldown probing and elastic pools
README: new "冷却与半冷却探测(自愈调度)" section with the per-class cooldown
table (5xx exponential to 5min / 401-403 10min / 429 30s fixed / quota aligned
to its window), the probe-slot formula and the ordering rule that probes are
tried last. The LuaJIT section now says the pool is an elastic ceiling rather
than a preallocation and documents both step formulas. Headline figures replaced
with measured ones: idle ~10 MB, ~19 MB starting with a 29 MB audit log, and a
new bullet for on-demand log loading.

package.json also loses a `\u2014` escape and the broken indentation that an
earlier edit left in the electron-builder block.
2026-08-30 08:07:01 +08:00
23705042ee chore: ignore local agent state, clean packaged installers
.gitignore: add .omo/ (an AI-assisted-coding local state dir that was being
tracked — .omo/run-continuation/*.json is session scratch, not source) and
/plan.md (local working notes).

make clean: also remove cmd/build/ and cmd/gui/dist/. Those hold the packaged
installers (1.8 GB and 386 MB on this machine) and were never cleaned, so
`make clean` left almost all of the build output behind.
2026-08-30 08:06:49 +08:00
02cb8c0e33 fix(adapters): reflow live sensenova reasoning field, add trae adapter
sensenova: the deployed /etc/llmsproxy/adapters/sensenova.lua carried a fix that
never made it back into the repo — sensenova-6.8-flash-lite reports its chain of
thought in `reasoning` rather than `reasoning_content`, in both the single-shot
response and the stream deltas. Without this the model's output looked empty.
Repo and deployment now match byte for byte.

trae: the adapter was in use on this deployment but untracked, so a fresh
install had no way to serve the trae source.
2026-08-30 08:06:22 +08:00
6575058556 feat(webui): scroll-paged record table, pool metrics, probe-aware health tags
Front-end half of the on-demand log loading plus the observability for the two
new scheduling mechanisms.

Records table:
  * the first screen comes from the dashboard poll, and an IntersectionObserver
    sentinel below the last row pulls the next page from /api/stats/records as
    the user scrolls.
  * rendered rows are capped at RECS_MAX_DOM=1000 (oldest rendered rows are
    dropped) so a long scroll cannot grow the DOM without bound, with a "back
    to newest" button to reset cheaply.
  * releaseRecords() drops the buffer, disconnects the observer and aborts the
    in-flight fetch (AbortController) on tab switch, on key-filter change and
    on pagehide/beforeunload — leaving the page releases everything at once.
  * the poll no longer rebuilds the table once extra pages are loaded, so the
    5s refresh cannot throw away scrolled history.
  * when the server reports replay_partial, the filter row states that the
    aggregates cover the recent audit tail and points at CSV for full history.

Adapters page shows each pool as created/max plus in_use/idle and the live
grow/shrink steps, with the sizing rule in the hover text.

Priority page health tags now distinguish cooling / probe-ready / probing
instead of a flat "cooling", and the tooltip spells out when the window opened,
when the single probe is allowed through and when the slot clears completely.
core.AutoSlotState carries cooldown_from / probe_after / probing / probeable to
feed this.
2026-08-30 08:06:11 +08:00
d42c02b15d perf(gateway): load request logs on demand instead of holding them in memory
Startup RSS on this deployment was 56 MB with a 29 MB audit log and ~10 MB
without one: LoadAudit() json-unmarshalled the ENTIRE file into the aggregates
and kept a 10000-entry ring of raw records. Two more paths had the same shape —
AuditRecords() materialized a whole export window into a []Req before sorting
it, and a dashboard poll serialized the full ring so the browser could render
300 rows of it.

The audit file is now the source of truth and memory only holds the live
window:

  * LoadAudit replays only the last auditReplayBytes (4 MB) and drops the
    truncated first line; the ring default drops 10000 -> 500, which still
    covers both of its consumers (the status page's 5-minute SourceRecent /
    SourceAverages windows and the first screen of the records table).
    replayPartial is exported so the UI can say the totals cover a window
    rather than all time. Token-quota accounting is unaffected: it reads the
    modelHour buckets, not the ring (pinned by a test).
  * AuditPage(cursor, limit, key) pages records straight off disk, reading the
    newest file backwards in 64 KB chunks and returning as soon as the page is
    full. The cursor is "<file>:<offset>" and walks into rotated .old files;
    a cursor whose file rotated away reports rotated=true so the client can
    reset instead of silently skipping records. No state is cached between
    requests and the file handle is closed before responding, so "release when
    the user leaves the page" is guaranteed by never retaining anything.
  * StreamAuditRecords(from,to,key,fn) replaces the accumulate-then-sort export
    path; the CSV handler writes rows as they are read and flushes every 1000,
    and a write error (client gone) aborts the walk. Export memory is O(1)
    regardless of the window. AuditRecords is kept as a test-only wrapper.
  * Snapshot ships one screen (firstScreenRecords=100) by default; aggregates
    are untouched.
  * Audit rotation 64 MB x 10 -> 16 MB x 16: same 256 MB total budget, but a
    smaller newest file keeps the first reverse page cheap.

New route: GET /api/stats/records?before=&limit=&key= (non-admins are pinned to
their own key by exportKey). /api/status additionally reports adapter_pools for
admins.

Measured with production's 29 MB audit copied to the test instance: startup RSS
19.0 MB (was 56 MB); scrolling 10 pages (1000 records) +0.7 MB; exporting the
full history (36441 rows / 4.4 MB CSV) +0.1 MB with no residual growth.
2026-08-30 08:05:54 +08:00
df9aeed5fb feat(lua): elastic adapter worker pools instead of monotonic growth
ConfigureConcurrency() set each adapter pool's target to the sum of
max_concurrent over its sources (108 on this deployment) and `created` only
ever went UP: once a Lua state was booted it was parked forever, so a
long-running gateway's resident state count was a high-water mark of all
traffic it had ever seen, never of what it currently needs.

Pools are now sized from three live inputs:

  * the adapter's MAX CONCURRENCY (sum of max_concurrent) is a ceiling, not a
    preallocation, and it sets the growth step:
    growStep = clamp(ceil(maxW/8), 1, 8). A 64-wide adapter warms 8 states at
    once on a spike; an 8-wide one creeps up one at a time.
  * the LIVE CONNECTION COUNT (inUse, i.e. checked-out states) sets the shrink
    step: shrinkStep = clamp(ceil(excess/(1+inUse)), 1, excess). With no
    connections the slack collapses in a single round; a busy adapter gives up
    one state per round so the hot path keeps its warm states.
  * how many states already exist (created / len(idle)) decides how much room
    is left to grow and how much can be reclaimed.

Batch prewarm only fires on genuine contention (a miss while every existing
state is checked out), so a single sequential caller keeps reusing one state
rather than burning a whole grow step on a cold start. A single VM-level
janitor goroutine (not one per adapter) reclaims idle states every 30s, and
shrinkGraceRounds=2 plus idleHeadroom=1 keep a gap between requests from being
mistaken for the end of a load period; any checkout resets the grace counter.

`created` now decrements on reclaim and on shutdown, and release() closes a
state outright when the ceiling was lowered underneath it, so shrinking
max_concurrent in the config gives memory back immediately instead of parking
orphans until restart.

PoolStats() exposes created/idle/in_use/waiting/max/resident/grow_step/
shrink_step/peak_in_use for the status API.

Measured on the test instance (single mock source, max_concurrent=64):
idle created=1; 50 concurrent requests -> created=10 (peak_in_use=5, ceiling
respected); after 95s of silence -> created=1. On production after deploy: 13
adapters, 1 resident Lua state total with ceilings up to 76.
2026-08-30 08:05:13 +08:00
21cd0429a2 feat(provider): half-cooldown probe so recovered slots return immediately
A slot that failed ten times landed in a 30-minute cooldown and was a hard
skip for the whole window: ModelAvailable() said no, the scheduler dropped the
candidate, and the ONLY way back was a success that could never happen. In
practice an upstream that blipped for a minute — or whose quota reset in
seconds — stayed unusable for half an hour.

Cooldown is now a window with a probe gate instead of a wall:

  * backoffCap 30min -> 5min. 401/403 no longer implicitly "jump to the cap"
    but get an explicit authCooldown (10min); 429 keeps its 30s fixed window.
  * ModelState records cooldownFrom alongside cooldownUntil, so the window has
    a measurable midpoint, plus a single-token probe permit (probeInFlight).
  * TryProbe() hands out that permit only in the SECOND half of the window: a
    freshly failed slot stays completely silent, and past the midpoint exactly
    one request may go through as a probe.
  * ProbeSlots() = clamp(max_concurrent/10, 1, 2), so a source configured for
    max_concurrent=10 lets exactly one request probe.
  * A probe is a REAL request: success runs RecordSuccess(), which clears the
    cooldown and failCount on the spot and returns the slot to full rotation.
    Failure reopens the window, pushing the next probe to the NEW midpoint
    instead of retrying immediately.

Quota exhaustion is modelled separately (RecordQuotaExhausted): running out of
allowance is not the upstream being broken, so it must not feed the exponential
ladder or inflate failCount. It cools until the allowance window can plausibly
have reset (capped at 30min so a manual top-up is noticed) and is recognised
from the adapter-normalized error text via ReportStatusReason.

Scheduler side: collectCands() splits a tier into normal + probe candidates
with probes strictly LAST, so probe traffic is what a tier falls back to, never
what it prefers, and the round-robin cursor rotates over the normal head only.
releaseProbes() returns every permit on all exits (success, hard failure, ctx
cancel, tier fallthrough); the 90-line inlined busy-poll loop is extracted into
pollBusyTier() to keep those exits auditable. Direct Chat/ChatStream/Image take
the same gate.

Also fixes a real blacklist bug found on the way: a slot whose pref sank to
prefMin was refused by ModelAvailable forever, long after every cooldown had
expired. Such a slot is now probeable, and one success lifts it off the floor.

Verified live against a controllable upstream (tier1 flaky, tier2 healthy):
failure ladder 5s/9s/20s/40s/79s; after healing the upstream, 10 AUTO requests
across the silent half produced ZERO upstream calls; past the midpoint a single
AUTO request produced exactly ONE upstream call, cleared the remaining 39s of
cooldown, and the next three requests were served by the recovered slot.
2026-08-30 08:04:50 +08:00
2ed1f0ecde style: gofmt the tree
gofmt -l reported 13 files with misaligned struct tags / stale formatting.
This commit contains ONLY formatting: no behaviour change, no logic touched.
Files that also carry real changes in this series are formatted by their own
commits.
2026-08-30 08:04:22 +08:00
39 changed files with 4950 additions and 412 deletions

5
.gitignore vendored
View File

@ -12,12 +12,17 @@ cmd/gui/bin/
cmd/gui/build/gui-dist/
cmd/build/
cmd/gui/*.log
cmd/gui/.cache/
# workspace (AI-assisted coding local state)
.pi/
.pi-glla/
.codegraph/
.omo/
/build/
# local ops scripts (machine-specific, not for sharing)
scripts/
cmd/gui/dist/
# local design/working notes (not part of the shipped repo)
/plan.md

View File

@ -1,10 +0,0 @@
{
"sessionID": "ses_fcda7f5e1ffe9g78A7lVoLMxn7",
"updatedAt": "2026-08-24T11:19:43.949Z",
"sources": {
"background-task": {
"state": "idle",
"updatedAt": "2026-08-24T11:19:43.949Z"
}
}
}

View File

@ -23,6 +23,14 @@ build:
CGO_ENABLED=1 go build -tags luajit -trimpath -o $(BUILD_DIR)/$(BINARY) ./cmd/llmsproxy
@echo "Built: $(BUILD_DIR)/$(BINARY) ($(VERSION))"
# ---- core distribution packages (deb + rpm + tar.gz) -----
# Server admins install these with dpkg/rpm; unlike the GUI they are the bare
# gateway binary + systemd unit (with memory tuning) + Lua adapters.
core-dist:
./packaging/core-dist.sh $(VERSION)
.PHONY: core-dist
# ---- GUI (Electron) ----
gui:
@cd $(GUI_DIR) && npm start
@ -31,11 +39,12 @@ gui-dev:
@cd $(GUI_DIR) && npm run dev
# build the embedded core into the GUI, then run electron-builder
# targets: deb + AppImage (linux), rpm needs system rpmbuild
# targets: deb + rpm + AppImage (linux); rpm needs system rpmbuild
# NOTE: run in a clean env (no LD_LIBRARY_PATH pollution) so the packaged
# binary does not link against host-only libs and crash on user machines.
gui-dist:
@cd $(GUI_DIR) && $(CLEAN_PACK_ENV) npm run dist:linux
./packaging/verify-dist.sh
# deb only (for local sharing), rpm needs rpmbuild
gui-deb:
@ -48,6 +57,7 @@ gui-win:
# windows nsis installer: full cross-build in docker (self-sufficient, no host mingw)
gui-win-docker:
@cd $(GUI_DIR) && $(CLEAN_PACK_ENV) ./scripts/dist-win-docker.sh
./packaging/verify-dist.sh
gui-dist-dir:
@cd $(GUI_DIR) && $(CLEAN_PACK_ENV) npm run dist:dir
@ -56,15 +66,17 @@ test:
go test ./...
clean:
rm -rf $(BUILD_DIR) cmd/gui/build/gui-dist cmd/gui/bin
rm -rf $(BUILD_DIR) cmd/build cmd/gui/dist cmd/gui/build/gui-dist cmd/gui/bin
help:
@echo "Targets:"
@echo " make build build core binary (luajit)"
@echo " make core-dist build core packages: deb + rpm + tar.gz (needs nfpm)"
@echo " make gui run desktop GUI (dev)"
@echo " make gui-dev run GUI with devtools"
@echo " make gui-dist build deb + AppImage (linux)"
@echo " make gui-dist build deb + rpm + AppImage (linux)"
@echo " make gui-deb build deb only"
@echo " make gui-win build Windows nsis (needs host mingw + wine)"
@echo " make gui-win-docker build Windows nsis all-in-docker (no host mingw needed)"
@echo " make test run go tests"
@echo " make clean remove build dirs and packaged installers (build/, cmd/build/, cmd/gui/dist/)"

126
README.md
View File

@ -15,8 +15,9 @@
### 极致轻量
- **单二进制**:编译后约 10MB,零运行时依赖(仅依赖系统 libc),部署即用
- **极低内存占用**:空闲状态仅 ~15MB RSS,满载并发 100+ 请求时峰值 < 100MB
- **单二进制**:编译后 8~12MB(`-s -w` strip 后约 8MB),零运行时依赖(仅依赖系统 libc),部署即用
- **低内存占用**:与源数量相关,非与运行时长相关——单源 ~10MB、16 源生产实例 ~32–35MB([实测分解与调优](#内存占用实测与调优))
- **日志按需加载**:统计数字来自**全量**审计日志(启动时流式扫一遍即释放,29MB/22 万行约 260ms),但原始记录不常驻内存——默认只加载首屏,下滚自动分页,CSV 导出流式写出(O(1) 内存),离页即释放
- **零运行时依赖**:纯 Go + LuaJIT 静态链接,无需安装 Python/Node/Java 等运行时
- **启动极快**:冷启动 < 200ms,热重载配置 < 10ms
@ -24,6 +25,7 @@
- **多密钥多租户**:支持无限密钥,每个密钥独立角色、模型范围、Token 配额、重置周期
- **AUTO 智能调度**:基于优先级档位的分级调度,同优先级源自动轮询负载均衡,故障自动毫秒级故障转移
- **自愈冷却**:冷却上限 5 分钟,过半后放行 1 个探测请求,上游/额度恢复即刻回归轮询,无需等满冷却窗口
- **Token 配额管理**:精确到模型级别的 Token 配额控制,支持小时/周/月/自定义小时周期自动重置
- **同优先级源负载均衡**:同一优先级档位的多个源,请求自动 Round-Robin 均匀分发,故障毫秒级故障转移
- **Source-Model 前缀路由**:支持 `source-model`/`source:model`/`source/model` 精确指定上游源
@ -37,7 +39,7 @@
### 灵活的协议适配
- **LuaJIT VM**:每个适配器独立 VM + worker 池,安全并发,Lua 脚本热加载无需重启
- **LuaJIT VM**:每个适配器独立 VM + **弹性 worker 池**(按需扩缩容,空闲时自动回收),安全并发,Lua 脚本热加载无需重启
- **Lua 适配器协议**:`transform_request` / `transform_response` / `transform_stream_chunk` 双向转换
- **动态请求头钩子**:`build_headers(meta)` 支持 HMAC 签名、动态 Header 注入
- **多模态透传**:`image_url` 等多模态内容在多源间无损透传,Anthropic/Gemini/Ollama 自动转换
@ -60,6 +62,12 @@ Groq、Mistral、Ollama、KimiCode…)通过 **Lua 适配器** 做协议转换
- **生图**:`POST /v1/images/generations`,`kind: image` 的模型独立路由。
- **多模态**:`content` 数组(`image_url` 等)在多源间无损透传,Anthropic/Gemini/Ollama 自动转换。
- **LuaJIT VM**:基于 golua 绑定的 LuaJIT,每个适配器独立 VM + worker 池,安全并发。
池是 **弹性** 的:`Σ max_concurrent` 只是上限而非预分配,状态按需创建、空闲时自动回收,
因此空载网关几乎不持有 Lua 状态(启动 0 个)。
- 扩容步长由适配器最大并发决定:`clamp(ceil(max/8), 1, 8)`,且仅在**真实并发争用**
(所有现有状态均已占用)时批量预热,顺序流量始终只用 1 个状态。
- 缩容步长由当前连接数决定:`clamp(ceil(冗余/(1+占用)), 1, 冗余)`——无连接时一轮收到
常驻下限,忙时每轮只释放 1 个,保护热路径。WebUI「适配器」页展示实时池指标。
- **disable_thinking**:请求 `disable_thinking:true`(或上游对应字段)动态开关推理。
- **Lua 适配协议**:每个源挂一个 `.lua` 适配器,完成 `transform_request` /
`transform_response` / `transform_stream_chunk` 双向转换,协议差异全在 Lua 层。
@ -203,6 +211,33 @@ AUTO 调度**只**由优先级页保存的规则决定,源配置里的 `models
- 同 tier 内按「偏好分」排序,失败槽位冷却后自动跳过;冷却 / 配额耗尽 / hard
错误会顺延到下一 tier,全部失败时返回 503 并附上每档失败摘要。
### 冷却与半冷却探测(自愈调度)
槽位失败后进入指数退避冷却(5s → 10s → 20s …,上限 **5 分钟**)。冷却不是全时段
硬闸:
- **前半段完全静默**:刚失败的槽位不接任何流量,避免对故障上游持续施压。
- **后半段放行 1 个探测**:窗口过半后,同一 `(源, 模型)` 最多允许 **一个** 请求作为
探测通过。探测槽数 = `clamp(max_concurrent / 10, 1, 2)`——`max_concurrent: 10`
的源恰好放行 1 个探测请求。
- **探测排在最后**:同 tier 内探测候选永远排在健康候选之后,只有没有正常槽可用时
才承接真实流量,因此探测不会抢走可用容量。
- **探测即真实请求**:成功则立即清零失败计数与冷却,槽位当场回到正常轮询——
上游恢复/额度恢复后不必等满整个冷却窗口。失败则重开一个新窗口,下次探测顺延到
新窗口的中点,不会连续重试。
分类冷却策略:
| 情况 | 冷却 | 说明 |
|---|---|---|
| 传输错误 / 5xx | 5s→10s→20s…,上限 5min | 指数退避 |
| 401 / 403 凭据错误 | 10min | 换 key 后由下一次探测自动接回 |
| 429 限流 | 30s 固定 | 「太快」不等于「坏了」,不进指数阶梯 |
| 配额耗尽(`insufficient_quota` / `余额不足` 等) | 对齐配额窗口,上限 30min | **不进指数阶梯、不涨失败计数**,额度恢复即可用 |
WebUI「优先级」页的槽位健康标签会区分 `冷却` / `待探测` / `探测中`,悬停可看窗口
起点、探测放行时刻与完全恢复时刻。
### 源(Source)与适配器(Adapter)的关系
- **源** 是到某个上游的连接描述:`name`、`base_url`、`api_key`、模型列表与优先级。
@ -220,7 +255,7 @@ AUTO 调度**只**由优先级页保存的规则决定,源配置里的 `models
2. `config.Load` 读取 `config.yaml`,`config.NewStore(runtime_file)` 读 WebUI 改动的
运行时源,二者按名称合并成完整源列表。
3. `rebuildRegistry` 为每个源创建 `provider.Provider`(持有目标适配器),并按源的
并发上限配置适配器 worker 池大小。
并发上限配置适配器 worker 池**上限**(不预分配,见下)。
4. 请求进来时 `Registry` 按 `model` 路由到 Provider,Provider 调适配器
`transform_request` → HTTP 发送 → `transform_response` / `transform_stream_chunk`。
@ -249,6 +284,53 @@ WebUI 上的"新增/编辑源"、"上传 Lua 适配器"、"改 AUTO 优先级链
模板不会自己变成源——它只是「配方」。真正承担流量的是从模板创建出来的、带 key
的具体源。
### 内存占用(实测与调优)
内存不是一个定数:它随**配置的源数量**增长(每个源一个 `http.Transport` 与连接池、
一组健康状态),与运行时长无关。本机实测(Linux x86_64,12 核):
| 部署形态 | 启动 RSS | 稳态 RSS |
|---|---|---|
| 1 源 / 1 适配器(最小配置) | ~4 MB | ~10 MB |
| 1 源 + 29 MB 历史审计日志 | ~19 MB | ~20 MB |
| **16 源 / 13 适配器 / 59 模型(本机生产)** | ~28 MB | **~32–35 MB** |
> 历史参考:本项优化前同一生产配置为 **~105 MB**。降幅来自三处:审计日志的**原始记录**
> 不再常驻内存(约 25 MB;统计聚合仍扫全量,但扫完即释放)、Lua 状态池不再单调增长、
> 以及下面两个运行时开关。
内存构成(生产实例分段测量,`/proc/<pid>/smaps`):
| 区域 | RSS | 说明 |
|---|---|---|
| Go 堆 | ~14 MB | provider/registry/scheduler 结构 + 连接池缓冲 |
| 其他匹名(线程栈 / LuaJIT chunk / runtime) | ~12 MB | 与线程数、已加载适配器数相关 |
| 二进制 text+rodata | ~8 MB | 映射的可执行文件页(只读、可被内核回收) |
| Go runtime 预留 | ~4 MB | VSZ 上看到的 2 GB+ 是地址空间预留,不占物理内存 |
| 共享库 | ~3 MB | libc / libluajit / libm |
**两个推荐的部署开关**(只适用于环境变量,无需改代码):
```ini
# /etc/systemd/system/llmsproxy.service
Environment=GOGC=50
Environment=MALLOC_ARENA_MAX=2
```
- `MALLOC_ARENA_MAX=2`:LuaJIT 的分配走 cgo → glibc malloc,glibc 默认允许 `8×nproc`
个 per-thread arena,每个碰到 malloc 的 OS 线程会占用一个(各约 1 MB,且**不归还给系统**)。
实测从 8–15 个 arena(约 7–12 MB)降到 **0**。
- `GOGC=50`:把 Go 堆增长目标减半。**单独使用无效**(省下的堆会立即被更多 glibc
arena 吃掉,实测 20.3 → 21.5 MB 反而变大),必须与 `MALLOC_ARENA_MAX` 配合,
两者同时开启才降 **~19%**。网关是 I/O 密集型(本机 9 小时仅消耗 1min10s CPU),
多出的 GC 周期与它的空闲 CPU 相比可忽略。
> 桌面版(Electron)已在 `cmd/gui/main.js` 里默认为内嵌核心注入这两个开关;手动 export 同名
> 环境变量可覆盖。如果你用自己的 systemd unit / 容器,建议照上面加上。
可选:`GOMEMLIMIT=48MiB` 作软上限,实测再省 ~1 MB;代价是逐近上限时 GC 转激进,
温和场景不必开。
### disable_thinking
请求体带 `"disable_thinking": true` 时,网关透传给各适配器;DeepSeek 适配器将其
@ -326,15 +408,16 @@ return {
发布到 Release 的安装包分为两类,面向不同用户:
- **Headless(服务器版)**:纯核心二进制(无 Electron),配置驱动,适合服务器、
容器、systemd 等无人值守场景。Linux 为 `.tar.gz`(二进制 + `config.example.yaml`
+ `adapters/` + README),Windows 为 `.zip`。
容器、systemd 等无人值守场景。发布包含 `deb` + `rpm`(含 systemd unit、内存调优
与示例配置)与 `tar.gz`(二进制 + `config.example.yaml` + `adapters/` + README),
Windows 为 `.zip`。
- **Desktop(桌面版)**:Electron GUI 安装包,内嵌核心,适合个人桌面日常使用。
### 选择 GUI 还是纯后端
| 场景 | 推荐 | 理由 |
| ------ | ------ | ------ |
| 服务器 / 内网网关 / 无人值守常驻 | **Headless**(单二进制) | 极轻量(~10MB、~15MB RSS),零依赖单进程,直接跑在 systemd/任意容器里,远程管理 |
| 服务器 / 内网网关 / 无人值守常驻 | **Headless**(单二进制) | 极轻量(二进制 ~8MB;RSS 随源数量,单源 ~10MB、16 源 ~32–35MB,见[内存占用](#内存占用实测与调优)),零依赖单进程,直接跑在 systemd/任意容器里,远程管理 |
| 个人桌面日常使用 / 多设备内网共享 | **Desktop**(GUI) | 免登录内嵌 WebUI、系统托盘一键启停、开机自启、静默后台,适合不懂命令行的使用者 |
| Windows 桌面 | **Desktop** | 纯后端在 Windows 上需自行注册服务,GUI 提供原生托盘/自启体验 |
| CI 一键出三平台安装包 | **Desktop 打包脚本** | `make gui-*` 系列出 deb/AppImage/NSIS,可进发行流水线 |
@ -349,29 +432,36 @@ cd cmd/gui
npm install
make gui # 或 npm run dev —— 开发运行(需先 make build 生成 bin/llmsproxy)
make gui-deb # 本机构建 deb(分享给其它 Linux 用户)
make gui-dist # 构建 deb + AppImage(linux)
make gui-dist # 构建 deb + rpm + AppImage(linux,rpm 需系统 rpmbuild)
make core-dist # 构建核心 deb + rpm + tar.gz(服务器版,需 nfpm)
make gui-win # 构建 Windows nsis 安装包(本机 mingw + wine)
make gui-win-docker # 构建 Windows nsis 安装包(全 docker 自足,无需本机 mingw)
make gui-win-docker # 构建 Windows nsis 安装包(全 docker 自足,含 wine)
make gui-dist-dir # 仅解包产物(调试用,不产安装包)
```
产物在 `cmd/build/gui-dist/`:`ModelRouter-<ver>.AppImage`、
`modelrouter-gui_<ver>_amd64.deb`、`ModelRouter Setup <ver>.exe`(win)。rpm 需系统 `rpmbuild`。
`modelrouter-gui_<ver>_amd64.deb`、`ModelRouter Setup <ver>.exe`(win);
核心包在 `cmd/build/dist/`:`llmsproxy_<ver>_amd64.deb`、`llmsproxy-<ver>.x86_64.rpm`。
rpm 需系统 `rpmbuild`。每次打包后都会跑 `packaging/verify-dist.sh` 产物体检
(大小下限门禁——1.3.0 曾因宿主 wine 损坏产出 264KB 的残缺 exe 却没有被拦住)。
发布时会把产物重命名为 `ModelRouter-Desktop-*`(GUI)与 `ModelRouter-Headless-*`(纯后端)
两类上传到 GitCode Release。
#### Windows 打包(docker 固化方案,推荐)
`make gui-win-docker` 一步完成:交叉编译核心 + NSIS 打包,宿主机**无需安装 mingw**。
`make gui-win-docker` 一步完成:交叉编译核心 + NSIS 打包**全在 docker 内**,宿主机
**无需 mingw、无需 wine、无需 node**。镜像内置 wine(含 i386 运行时),修复了
此前宿主 wine 损坏(缺 syswow64 → `c0000135`)导致产出 264KB 残缺 exe 的问题。
- 构建镜像:`cmd/gui/docker/win-builder/Dockerfile`(`golang:1.25` + mingw-w64 +
预编译 LuaJIT windows 版 `lua51.dll` 与 import lib)。LuaJIT 源码取自 gitcode
镜像(国内可达),github 兜底;Go 依赖走 `https://goproxy.cn`。
- 核心编译:`cmd/gui/scripts/win-core-docker.sh` —— 在容器内产出
`cmd/gui/bin/{llmsproxy.exe,lua51.dll}`。
- 一键发布:`cmd/gui/scripts/dist-win-docker.sh` —— 核心编译后调用
electron-builder(宿主需有 `wine` 用于 NSIS)。
- 首次运行自动构建 `modelrouter/win-builder` 镜像;已存在则直接复用。
node + wine32/wine64 + 预编译 LuaJIT windows 版 `lua51.dll` 与 import lib)。
LuaJIT 源码取自 gitcode 镜像(国内可达),github 兜底;Go 依赖走 `https://goproxy.cn`。
- 一键发布:`cmd/gui/scripts/dist-win-docker.sh` —— 核心编译 + electron-builder
NSIS 全部在容器内执行(`USE_SYSTEM_WINE=true` 走镜像内的 wine);electron-builder
的 ~400MB 工具链缓存挂到 docker volume,二次构建增量复用。
- 首次运行自动构建 `modelrouter/win-builder` 镜像(约 5–10 分钟);已存在则直接复用。
- 打包完成后自动跑 `packaging/verify-dist.sh` 门禁:exe 低于 5MB / deb·rpm·7z 低于
10MB 即失败——残缺安装包不可能再溜进 Release。
```bash
# 全 Windows 发布(核心重编 + NSIS):

View File

@ -11,7 +11,7 @@ KimiCode…) behind a single endpoint. Pick a specific model or use **AUTO** mod
which routes to the best healthy upstream by configured priority.
> **Lightweight, no recompile to add sources**: the gateway is a single Go
> binary (~10 MB, zero runtime dependencies). Adding or switching an upstream is
> binary (8-12 MB, ~8 MB stripped; zero runtime dependencies). Adding or switching an upstream is
> just a `sources` entry in `config.yaml` (or via the WebUI) or a `.lua`
> adapter — **no Go changes, no recompile**. Changes submitted through the
> WebUI take effect immediately (hot reload); editing `config.yaml` or a `.lua`
@ -41,7 +41,23 @@ Extracted and independently evolved from the multi-source LLM adapter layer of
- **Multimodal**: `content` arrays (`image_url` etc.) pass through losslessly;
Anthropic/Gemini/Ollama are translated automatically.
- **LuaJIT VM**: golua-binding LuaJIT; each adapter has its own VM + worker
pool for safe concurrency.
pool for safe concurrency. Pools are **elastic**: the sum of `max_concurrent`
over an adapter's sources is a ceiling, not a preallocation — states are booted
on demand and reclaimed when demand drops, so an idle gateway holds close to
zero Lua states. The grow step follows the adapter's max concurrency
(`clamp(ceil(max/8), 1, 8)`, and never exceeds the number of queued callers);
the shrink step follows the live connection count
(`clamp(ceil(slack/(1+in_use)), 1, slack)`), so an idle pool collapses in one
round while a busy one gives up a single state at a time.
- **On-demand request logs**: dashboard totals are computed from the **full**
audit history (streamed once at startup and released — 29 MB / 221k lines in
~260 ms), while the raw records are never held in memory: the dashboard loads
one screen, scrolling pages the rest straight off disk, CSV export streams in
O(1) memory, and leaving the page releases everything.
- **Self-healing cooldown**: cooldown is capped at 5 minutes and, past the
window's midpoint, exactly one probe request is allowed through; a recovered
upstream (or a reset quota) returns to full rotation on that probe instead of
waiting out the window.
- **disable_thinking**: `disable_thinking: true` toggles reasoning per-request.
- **Lua adapter protocol**: each source mounts a `.lua` adapter with
`transform_request` / `transform_response` / `transform_stream_chunk` — all
@ -258,6 +274,60 @@ independently).
A template is just a recipe — it doesn't become a source by itself. Only the
key-bearing sources created from it carry real traffic.
### Memory footprint (measured and tuned)
Memory is not a constant: it scales with the **number of configured sources**
(each source owns an `http.Transport` with its connection pool plus per-model
health state), not with uptime. Measured on this machine (Linux x86_64, 12 cores):
| Deployment shape | Startup RSS | Settled RSS |
|---|---|---|
| 1 source / 1 adapter (minimal) | ~4 MB | ~10 MB |
| 1 source + a 29 MB audit history | ~19 MB | ~20 MB |
| **16 sources / 13 adapters / 59 models (this host)** | ~28 MB | **~32-35 MB** |
> For reference, the same production config used **~105 MB** before this round of
> work. The reduction comes from three places: raw audit records are no longer kept
> resident (~25 MB — the aggregates still scan the full history, but the scan
> releases as it goes), Lua state pools no longer grow monotonically, and the two
> runtime knobs below.
Breakdown of the production instance (per-region, from `/proc/<pid>/smaps`):
| Region | RSS | Notes |
|---|---|---|
| Go heap | ~14 MB | provider/registry/scheduler state + connection-pool buffers |
| Other anonymous (thread stacks / LuaJIT chunks / runtime) | ~12 MB | scales with thread count and loaded adapters |
| Binary text+rodata | ~8 MB | mapped executable pages (read-only, reclaimable by the kernel) |
| Go runtime reservations | ~4 MB | the 2 GB+ you see in VSZ is address space, not physical memory |
| Shared libraries | ~3 MB | libc / libluajit / libm |
**Two recommended deployment knobs** (environment only, no code change):
```ini
# /etc/systemd/system/llmsproxy.service
Environment=GOGC=50
Environment=MALLOC_ARENA_MAX=2
```
- `MALLOC_ARENA_MAX=2`: LuaJIT allocates through cgo into glibc malloc, and glibc
allows up to `8 x nproc` per-thread arenas. Every OS thread that touches malloc
claims one (~1 MB each) and **never returns it to the OS**. Measured: 8-15
arenas (7-12 MB) down to **0**.
- `GOGC=50`: halves the Go heap growth target. **It does nothing on its own** —
the heap it saves is immediately consumed by additional glibc arenas (measured
20.3 -> 21.5 MB, i.e. slightly worse) — so it must be paired with
`MALLOC_ARENA_MAX`. Together they cut settled RSS by **~19%**. The gateway is
I/O bound (1min10s of CPU per 9 hours here), so the extra GC cycles are free.
> The desktop build (Electron) already injects both when spawning the embedded
> core (`cmd/gui/main.js`); exporting either variable yourself overrides it. If you
> write your own systemd unit or container spec, add them there.
Optional: `GOMEMLIMIT=48MiB` as a soft ceiling saves roughly 1 MB more, at the
cost of GC turning aggressive as the limit approaches. Not worth it for a relaxed
deployment.
### disable_thinking
With `"disable_thinking": true` in the request body, the gateway passes it to
@ -344,9 +414,9 @@ keep using the plain Go binary.
Release installers ship in two flavors for different audiences:
- **Headless (server)**: the plain core binary (no Electron), config-driven,
for servers / containers / systemd / unattended runs. Linux ships as
`.tar.gz` (binary + `config.example.yaml` + `adapters/` + README), Windows as
`.zip`.
for servers / containers / systemd / unattended runs. Ships as `deb` + `rpm`
(systemd unit with memory tuning + example config) and `tar.gz` (binary +
`config.example.yaml` + `adapters/` + README), Windows as `.zip`.
- **Desktop (desktop app)**: the Electron GUI installer with an embedded core,
for personal daily desktop use.
@ -354,7 +424,7 @@ Release installers ship in two flavors for different audiences:
| Scenario | Pick | Why |
| ---- | ---- | ---- |
| Server / intranet gateway / unattended long-running | **Headless** (single binary) | ~10 MB, ~15 MB RSS, zero-dep single process — drop it into systemd or any container, remote admin |
| Server / intranet gateway / unattended long-running | **Headless** (single binary) | ~8 MB binary; RSS scales with the number of sources (~10 MB for one, ~32-35 MB for 16 — see [Memory footprint](#memory-footprint-measured-and-tuned)), zero-dep single process — drop it into systemd or any container, remote admin |
| Personal desktop daily use / multi-device intranet sharing | **Desktop** (GUI) | no-login embedded WebUI, tray one-click, autostart, silent background — for non-CLI users |
| Windows desktop | **Desktop** | plain backend needs manual service registration; GUI ships native tray/autostart |
| CI one-shot 3-platform installers | **Desktop packaging scripts** | `make gui-*` emits deb / AppImage / NSIS, drops straight into a release pipeline |
@ -369,32 +439,42 @@ cd cmd/gui
npm install
make gui # or npm run dev — dev run (needs make build first for bin/llmsproxy)
make gui-deb # local deb (share with other Linux users)
make gui-dist # deb + AppImage (linux)
make gui-dist # deb + rpm + AppImage (linux; rpm needs system rpmbuild)
make core-dist # core deb + rpm + tar.gz (server edition; needs nfpm)
make gui-win # Windows NSIS (host mingw + wine)
make gui-win-docker # Windows NSIS, fully dockerized (no host mingw needed)
make gui-win-docker # Windows NSIS, fully dockerized (wine in image; no host mingw/node/wine)
```
Artifacts land in `cmd/build/gui-dist/`: `ModelRouter-<ver>.AppImage`,
`modelrouter-gui_<ver>_amd64.deb`, `ModelRouter Setup <ver>.exe` (win). rpm
needs system `rpmbuild`. At release time artifacts are renamed into the
`modelrouter-gui_<ver>_amd64.deb`, `ModelRouter Setup <ver>.exe` (win); core
packages land in `cmd/build/dist/`: `llmsproxy_<ver>_amd64.deb`,
`llmsproxy-<ver>.x86_64.rpm`. Both target families run
`packaging/verify-dist.sh` afterwards — a size floor that rejects degenerate
installers (1.3.0 shipped a 264 KB NSIS stub because the host wine was broken
and nothing caught it). At release time artifacts are renamed into the
`ModelRouter-Desktop-*` (GUI) and `ModelRouter-Headless-*` (plain backend)
families before upload to GitCode Releases.
#### Windows packaging (dockerized, recommended)
`make gui-win-docker` does cross-compile + NSIS in one step; the host needs
**no mingw**.
`make gui-win-docker` does cross-compile + NSIS **entirely in docker**; the host
needs **no mingw, no wine, no node**. Wine (with the i386 runtime) lives inside
the image, fixing the broken-host-wine failure that produced the 264 KB stub
installer.
- Image: `cmd/gui/docker/win-builder/Dockerfile` (`golang:1.25` + mingw-w64 +
prebuilt LuaJIT for Windows: `lua51.dll` + import lib). LuaJIT source comes
from the gitcode mirror (reachable in CN) with github fallback; Go modules go
through `https://goproxy.cn`.
- Core build: `cmd/gui/scripts/win-core-docker.sh` — emits
`cmd/gui/bin/{llmsproxy.exe,lua51.dll}` inside the container.
- One-shot release: `cmd/gui/scripts/dist-win-docker.sh` — after the core,
runs electron-builder (host needs `wine` for NSIS).
- The `modelrouter/win-builder` image is auto-built on first run and reused
afterwards.
node + wine32/wine64 + prebuilt LuaJIT for Windows: `lua51.dll` + import lib).
LuaJIT source comes from the gitcode mirror (reachable in CN) with github
fallback; Go modules go through `https://goproxy.cn`.
- One-shot release: `cmd/gui/scripts/dist-win-docker.sh` — core build + NSIS
packaging run inside the container (`USE_SYSTEM_WINE=true` uses the image's
wine); electron-builder's ~400 MB toolchain cache lives in a docker volume so
later builds reuse it.
- The `modelrouter/win-builder` image is auto-built on first run (~5-10 min)
and reused afterwards.
- After packaging, `packaging/verify-dist.sh` runs as a gate: an exe under
5 MB (or a deb/rpm/7z under 10 MB) fails the build — a broken installer can
never reach a Release again.
```bash
# Full Windows release (rebuild core + NSIS):

View File

@ -1,11 +1,20 @@
# ModelRouter GUI Windows builder
# Self-sufficient cross-compilation environment for the embedded llmsproxy core.
# Produces llmsproxy.exe (Go, windows/amd64, luajit tag) + lua51.dll (LuaJIT mingw build)
# inside a mounted workspace: cmd/gui/bin/{llmsproxy.exe,lua51.dll}
# Self-sufficient cross-compilation environment for the embedded llmsproxy core
# AND for packaging the Windows NSIS installer.
#
# Produces inside a mounted workspace:
# - llmsproxy.exe (Go, windows/amd64, luajit tag) + lua51.dll
# - ModelRouter Setup <ver>.exe (electron-builder --win nsis; requires wine
# so the uninstaller is generated — set USE_SYSTEM_WINE=true)
#
# Build: docker build -t modelrouter/win-builder cmd/gui/docker/win-builder
# Usage: make gui-win-docker (or cmd/gui/scripts/dist-win-docker.sh)
#
# This image is the SINGLE wine provider for the NSIS step, so a host that has
# no / broken wine (e.g. amd64-only Debian: no syswow64 -> c0000135) can still
# produce a real installer. The NSIS step MUST run inside this image; running it
# on the host was how the 1.3.0 264 KB degenerate exe got shipped.
#
# LuaJIT source is fetched from gitcode.com (CN-reachable mirror) with github
# fallback; pass --build-arg LUAJIT_REPO to override.
@ -13,11 +22,26 @@ FROM golang:1.25
ARG LUAJIT_REPO=https://gitcode.com/openresty/luajit2.git
RUN apt-get update -qq \
# i386 arch is required for wine32 (the NSIS uninstaller step runs a win32 exe
# through wine's wow64 path, which needs the 32-bit runtime under /usr/lib/i386).
RUN dpkg --add-architecture i386 \
&& apt-get update -qq \
&& DEBIAN_FRONTEND=noninteractive apt-get install -y -qq \
gcc-mingw-w64-x86-64 make ca-certificates git \
# NSIS step needs node + the electron-builder toolchain
nodejs npm \
# wine (32+64) for generating the NSIS uninstaller
wine64 wine32:i386 \
&& rm -rf /var/lib/apt/lists/*
# electron-builder resolves the uninstaller via the system wine when
# USE_SYSTEM_WINE=true (or by default on Linux without a toolset). Pin it.
# The toolchain cache lives in $HOME/.cache (mounted as a volume by
# dist-win-docker.sh), so it is reused across builds and never pollutes the
# mounted workspace.
ENV USE_SYSTEM_WINE=true
ENV WINEDEBUG=-all
# LuaJIT cross-compiled for Windows; keeps lua51.dll and the cgo import lib
# (libluajit-5.1.dll.a -> libluajit-5.1.a in the mingw lib dir).
# NOTE: $LUAJIT_REPO deliberately unquoted in "sh -c" — quoting the ARG would

View File

@ -198,6 +198,17 @@ function startCore() {
cwd: PROFILE_DIR,
env: Object.assign({}, process.env, {
LLMS_PROXY_GUI: "1",
// Memory tuning, measured on a 16-source deployment (see README 内存占用):
// MALLOC_ARENA_MAX caps glibc per-thread malloc arenas. LuaJIT allocates
// through cgo -> glibc malloc, and glibc defaults to 8*nproc arenas, so
// every OS thread touching malloc reserves its own ~1 MB arena that is
// never returned to the OS.
// GOGC halves the Go heap growth target. On its own it does not help (the
// saved heap is immediately eaten by extra glibc arenas); only the pair
// lowers settled RSS. The core is I/O bound, so the extra GC is free.
// Both are overridable: a user who exports them keeps their own values.
GOGC: process.env.GOGC || "50",
MALLOC_ARENA_MAX: process.env.MALLOC_ARENA_MAX || "2",
}),
stdio: ["ignore", "pipe", "pipe"],
});

View File

@ -1,12 +1,12 @@
{
"name": "modelrouter-gui",
"version": "1.2.0",
"version": "1.4.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "modelrouter-gui",
"version": "1.2.0",
"version": "1.4.1",
"license": "Proprietary",
"devDependencies": {
"electron": "^33.0.0",

View File

@ -1,6 +1,6 @@
{
"name": "modelrouter-gui",
"version": "1.2.0",
"version": "1.4.1",
"description": "ModelRouter Desktop — embedded ModelRouter core with tray",
"author": "ModelRouter",
"main": "main.js",
@ -10,7 +10,7 @@
"dev": "electron . --no-sandbox --dev",
"core:linux": "node scripts/prepare-core.js linux",
"core:win": "node scripts/prepare-core.js win",
"dist:linux": "npm run core:linux && electron-builder --linux deb AppImage",
"dist:linux": "npm run core:linux && electron-builder --linux deb rpm AppImage",
"dist:debian": "npm run core:linux && electron-builder --linux deb",
"dist:win": "npm run core:win && electron-builder --win nsis",
"dist:dir": "npm run core:linux && electron-builder --dir"
@ -22,7 +22,7 @@
"build": {
"appId": "com.modelrouter.gui",
"productName": "ModelRouter",
"linux": {
"linux": {
"target": [
"deb",
"rpm",
@ -32,9 +32,9 @@
"icon": "build/icon.png",
"maintainer": "ModelRouter",
"synopsis": "ModelRouter Desktop",
"desktop": {
"desktop": {
"entry": {
"StartupWMClass": "modelrouter-gui"
"StartupWMClass": "modelrouter-gui"
}
},
"extraResources": [

View File

@ -1,23 +1,64 @@
#!/usr/bin/env bash
# One-shot Windows release build for the GUI:
# One-shot Windows release build for the GUI — ENTIRELY inside docker.
#
# 1) cross-compile the embedded core in docker (llmsproxy.exe + lua51.dll)
# 2) host-side electron-builder NSIS packaging (wine required on host)
# 2) electron-builder NSIS packaging ALSO inside docker (wine for the
# uninstaller step lives in the image — see Dockerfile). This is the fix
# for the 1.3.0/1.4.0 broken installers: the host had amd64-only wine
# (no syswow64 -> c0000135), which made electron-builder silently emit a
# 264 KB degenerate exe without its 86 MB payload.
#
# Outputs: cmd/build/gui-dist/"ModelRouter Setup <version>.exe"
# cmd/build/gui-dist/modelrouter-gui-<version>-x64.nsis.7z
#
# Needs: docker (any machine; no host mingw/wine/node required)
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)"
cd "$ROOT/cmd/gui"
IMAGE="${WIN_BUILDER_IMAGE:-modelrouter/win-builder}"
PROXY="${GOPROXY:-https://goproxy.cn,direct}"
CACHE_VOL="modelrouter-eb-cache"
# 1) core (docker)
"$ROOT/cmd/gui/scripts/win-core-docker.sh"
command -v docker >/dev/null || { echo "docker required"; exit 1; }
# 2) packaging (host, needs wine for electron-builder's signtool/nsis)
command -v wine >/dev/null || { echo "wine required on host for NSIS packaging"; exit 1; }
if ! command -v npx >/dev/null; then echo "node/npm required"; exit 1; fi
if ! docker image inspect "$IMAGE" >/dev/null 2>&1; then
echo "[dist-win-docker] building image $IMAGE (first run, ~5-10 min)..."
docker build -t "$IMAGE" "$ROOT/cmd/gui/docker/win-builder"
fi
echo "[dist-win] electron-builder --win nsis ..."
# bin artifacts already refreshed by win-core-docker.sh; skip the prepare step
npx electron-builder --win nsis
docker volume create "$CACHE_VOL" >/dev/null 2>&1 || true
ls -la "$ROOT/cmd/build/gui-dist/"*.exe 2>/dev/null
echo "[dist-win] done"
# 1) embedded core + 2) NSIS packaging — both inside the image so wine and node
# are the image's, never the host's. electron-builder downloads electron + nsis
# toolchains into ~/.cache on first run; the mounted volume keeps that warm so
# subsequent builds are incremental instead of re-fetching ~400 MB.
echo "[dist-win-docker] cross-compiling core + electron-builder NSIS in docker ..."
docker run --rm \
-v "$ROOT":/workspace \
-v "$CACHE_VOL":/root/.cache \
-e GOPROXY="$PROXY" \
-w /workspace \
"$IMAGE" bash -c '
set -euo pipefail
cp /opt/luajit2/src/lua51.dll /workspace/cmd/gui/bin/lua51.dll
GOOS=windows GOARCH=amd64 CGO_ENABLED=1 CC=x86_64-w64-mingw32-gcc \
go build -buildvcs=false -tags luajit -trimpath \
-o /workspace/cmd/gui/bin/llmsproxy.exe ./cmd/llmsproxy
echo " -> core built"
# wine needs one prefix initialization so its wow64 runtime (syswow64/ntdll)
# exists — the exact thing that was missing on the broken host. Use a shared
# prefix in the cache volume so it is initialized only on the first run.
export WINEPREFIX=/root/.cache/wineprefix
if [ ! -f "$WINEPREFIX/drive_c/windows/syswow64/ntdll.dll" ]; then
echo " -> initializing wine prefix (first run)"
WINEDEBUG=-all wineboot -i || true
fi
cd /workspace/cmd/gui
# node_modules ships in the repo (host npm install already ran); reuse it.
npx electron-builder --win nsis
'
echo "[dist-win-docker] verifying artifacts ..."
"$ROOT/packaging/verify-dist.sh" "$ROOT/cmd/build/gui-dist"
echo "[dist-win-docker] done"

View File

@ -88,4 +88,4 @@ func main() {
signal.Notify(stop, syscall.SIGINT, syscall.SIGTERM)
<-stop
log.Printf("[llmsproxy] shutting down")
}
}

View File

@ -326,4 +326,3 @@ func statusOf(resp *http.Response) int {
}
return resp.StatusCode
}

View File

@ -24,9 +24,9 @@ type Config struct {
TLSKeyFile string `yaml:"tls_key_file,omitempty"` // PEM private key
PublicBaseURL string `yaml:"public_base_url,omitempty"` // external base for generated config snippets; default inferred from request
Sources []Source `yaml:"sources"`
Auto []ModelScope `yaml:"auto,omitempty"` // AUTO 调度链规则(WebUI 优先级页编辑,chat)
AutoImage []ModelScope `yaml:"auto_image,omitempty"` // AUTO 生图调度链规则(WebUI 优先级页·生图)
Keys []GWKey `yaml:"keys,omitempty"` // 网关密钥(WebUI 密钥页管理)
Auto []ModelScope `yaml:"auto,omitempty"` // AUTO 调度链规则(WebUI 优先级页编辑,chat)
AutoImage []ModelScope `yaml:"auto_image,omitempty"` // AUTO 生图调度链规则(WebUI 优先级页·生图)
Keys []GWKey `yaml:"keys,omitempty"` // 网关密钥(WebUI 密钥页管理)
}
// Defaults applied to any source (YAML or runtime) that leaves a field unset.
@ -314,32 +314,32 @@ func (c *Config) ApplyDefaults() error {
// RuntimeConfig is the legacy runtime file format (kept for migration only).
type RuntimeConfig struct {
Sources []Source `json:"sources,omitempty"`
SourceTemplates []SourceTemplate `json:"source_templates,omitempty"`
DeletedSources []string `json:"deleted_sources,omitempty"`
DeletedAdapters []string `json:"deleted_adapters,omitempty"`
PresetTemplates []string `json:"preset_templates,omitempty"` // preset names the user has seen (or deleted) — never re-seeded
Keys []GWKey `json:"keys,omitempty"`
Auto []ModelScope `json:"auto,omitempty"`
AutoImage []ModelScope `json:"auto_image,omitempty"`
Sources []Source `json:"sources,omitempty"`
SourceTemplates []SourceTemplate `json:"source_templates,omitempty"`
DeletedSources []string `json:"deleted_sources,omitempty"`
DeletedAdapters []string `json:"deleted_adapters,omitempty"`
PresetTemplates []string `json:"preset_templates,omitempty"` // preset names the user has seen (or deleted) — never re-seeded
Keys []GWKey `json:"keys,omitempty"`
Auto []ModelScope `json:"auto,omitempty"`
AutoImage []ModelScope `json:"auto_image,omitempty"`
}
// SourceTemplate stores reusable source configuration (everything except
// name and api_key) so the WebUI can spin up multiple key-bearing sources
// from one shared template.
type SourceTemplate struct {
Name string `json:"name"`
BaseURL string `json:"base_url"`
Adapter string `json:"adapter"`
Endpoint string `json:"endpoint,omitempty"`
ImageEndpoint string `json:"image_endpoint,omitempty"`
Models []Model `json:"models"`
Headers map[string]string `json:"headers,omitempty"`
Meta map[string]interface{} `json:"meta,omitempty"`
Temperature float64 `json:"temperature,omitempty"`
MaxTokens int `json:"max_tokens,omitempty"`
MaxConcurrent int `json:"max_concurrent,omitempty"`
RPM int `json:"rpm,omitempty"`
Name string `json:"name"`
BaseURL string `json:"base_url"`
Adapter string `json:"adapter"`
Endpoint string `json:"endpoint,omitempty"`
ImageEndpoint string `json:"image_endpoint,omitempty"`
Models []Model `json:"models"`
Headers map[string]string `json:"headers,omitempty"`
Meta map[string]interface{} `json:"meta,omitempty"`
Temperature float64 `json:"temperature,omitempty"`
MaxTokens int `json:"max_tokens,omitempty"`
MaxConcurrent int `json:"max_concurrent,omitempty"`
RPM int `json:"rpm,omitempty"`
}
// GWKey is a gateway API key persisted in the config file. Role is "admin"

View File

@ -26,12 +26,12 @@ import (
// request paths look keys up concurrently; the AUTO chain itself is swapped
// atomically and needs no lock.
type Core struct {
mu sync.Mutex
cfg *config.Config
vm *lua.VM
store *config.Store
scheduler *scheduler.Scheduler
registry *provider.Registry
mu sync.Mutex
cfg *config.Config
vm *lua.VM
store *config.Store
scheduler *scheduler.Scheduler
registry *provider.Registry
autoChain atomic.Pointer[scheduler.Chain] // chat AUTO chain
autoImageChain atomic.Pointer[scheduler.Chain] // image-generation AUTO chain
}
@ -565,6 +565,16 @@ type AutoSlotState struct {
FailCount int64 `json:"fail_count"`
CooldownUntil int64 `json:"cooldown_until"`
Cooling bool `json:"cooling"`
// CooldownFrom is when the current cooldown window opened and ProbeAfter is
// its midpoint: from ProbeAfter on, a single probe request is allowed
// through so a recovered upstream does not have to sit out the rest of the
// window. Probing reports whether such a probe is in flight right now.
CooldownFrom int64 `json:"cooldown_from"`
ProbeAfter int64 `json:"probe_after"`
Probing bool `json:"probing"`
// Probeable marks a cooling slot that is past its midpoint, i.e. the next
// AUTO request may use it as a probe.
Probeable bool `json:"probeable"`
}
// AutoSlotStates returns per-slot health for every slot of the current chain.
@ -582,6 +592,7 @@ func (c *Core) AutoSlotStates() []AutoSlotState {
continue
}
pref, fail, until := pp.ModelHealthInfo(sl.Model)
from, probeAfter, probing := pp.ModelProbeInfo(sl.Model)
out = append(out, AutoSlotState{
Model: sl.Model,
Source: sl.Source,
@ -589,6 +600,10 @@ func (c *Core) AutoSlotStates() []AutoSlotState {
FailCount: fail,
CooldownUntil: until,
Cooling: until > now,
CooldownFrom: from,
ProbeAfter: probeAfter,
Probing: probing,
Probeable: until > now && probeAfter > 0 && now >= probeAfter,
})
}
}
@ -740,4 +755,4 @@ func (c *Core) Close() {
if c.vm != nil {
c.vm.Stop()
}
}
}

View File

@ -4,6 +4,7 @@ import (
"encoding/csv"
"encoding/json"
"io"
"log"
"net/http"
"strconv"
"strings"
@ -31,7 +32,11 @@ func (g *Gateway) handleAdaptersAPI(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]interface{}{"adapters": g.core.ListAdapters()})
return
}
writeError(w, http.StatusNotFound, "not_found", "adapter code not exposed; edit in UI")
// A GET on a specific adapter is almost always a client that meant to
// DELETE it but let fetch default to GET; say so instead of only
// reporting that the code is not exposed.
writeError(w, http.StatusNotFound, "not_found",
"adapter code not exposed; edit in UI (to remove it use DELETE /api/adapters/"+path+")")
case http.MethodPost:
var p adapterPayload
if err := json.NewDecoder(r.Body).Decode(&p); err != nil {
@ -191,7 +196,10 @@ func (g *Gateway) handleStatsAPI(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "use GET")
return
}
limit := 500
// Default to ONE SCREEN of records. The dashboard pages the rest through
// /api/stats/records as the user scrolls, so a poll must not serialize the
// whole ring buffer. An explicit limit is still honoured (capped by the ring).
limit := firstScreenRecords
if v := r.URL.Query().Get("limit"); v != "" {
if n, err := strconv.Atoi(v); err == nil && n > 0 && n <= 20000 {
limit = n
@ -211,7 +219,12 @@ func (g *Gateway) handleStatsAPI(w http.ResponseWriter, r *http.Request) {
names[keyID(k.Key)] = k.Name
}
_ = cw.Write([]string{"time", "key", "key_name", "type", "model", "source", "status", "ok", "prompt_tokens", "completion_tokens", "latency_ms", "first_byte_ms", "cache_hit_tokens", "cache_miss_tokens", "error"})
for _, rec := range g.stats.AuditRecords(from, to, key) {
// Stream the window straight to the client instead of materializing it:
// an export covering months of audit data must not be bounded by RAM, and
// nothing is retained once the response is written.
flushEvery := 1000
n := 0
err := g.stats.StreamAuditRecords(from, to, key, func(rec Req) error {
_ = cw.Write([]string{
time.UnixMilli(rec.Time).Format(time.RFC3339),
rec.Key,
@ -229,8 +242,22 @@ func (g *Gateway) handleStatsAPI(w http.ResponseWriter, r *http.Request) {
strconv.FormatInt(rec.CacheMiss, 10),
rec.Err,
})
}
n++
if n%flushEvery == 0 {
cw.Flush()
if err := cw.Error(); err != nil {
return err // client went away: stop walking the audit files
}
if f, ok := w.(http.Flusher); ok {
f.Flush()
}
}
return nil
})
cw.Flush()
if err != nil {
log.Printf("[gateway] csv export aborted after %d rows: %v", n, err)
}
return
}
if r.URL.Query().Get("export") == "keys-csv" {
@ -293,3 +320,38 @@ func (g *Gateway) handleStatsAPI(w http.ResponseWriter, r *http.Request) {
snap["key_names"] = keyNames
writeJSON(w, http.StatusOK, snap)
}
// handleStatsRecordsAPI pages the request records straight off the audit files.
// The dashboard loads only its first screen and asks for the next page as the
// user scrolls, so neither side holds the full history: the server keeps no
// per-client state (the cursor is the whole state, and it lives in the URL) and
// closes every file handle before responding.
//
// GET /api/stats/records?before=<cursor>&limit=100[&key=<masked id>]
// -> { records: [...newest first...], next_cursor, has_more, rotated }
func (g *Gateway) handleStatsRecordsAPI(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "use GET")
return
}
limit := 100
if v := r.URL.Query().Get("limit"); v != "" {
if n, err := strconv.Atoi(v); err == nil && n > 0 && n <= 1000 {
limit = n
}
}
// exportKey pins non-admin callers to their own key, so a user key can never
// page another key's records.
page := g.stats.AuditPage(r.URL.Query().Get("before"), limit, exportKey(r))
keyNames := map[string]string{}
for _, k := range g.core.ListKeys() {
keyNames[keyID(k.Key)] = k.Name
}
writeJSON(w, http.StatusOK, map[string]interface{}{
"records": page.Records,
"next_cursor": page.Next,
"has_more": page.HasMore,
"rotated": page.Rotated,
"key_names": keyNames,
})
}

View File

@ -54,11 +54,11 @@ type RespMessage struct {
}
type ChatChunk struct {
ID string `json:"id"`
Object string `json:"object"`
Created int64 `json:"created"`
Model string `json:"model"`
Choices []ChunkChoice `json:"choices"`
ID string `json:"id"`
Object string `json:"object"`
Created int64 `json:"created"`
Model string `json:"model"`
Choices []ChunkChoice `json:"choices"`
// Usage is sent in the final chunk of a stream (empty choices) so
// OpenAI-compatible clients can read token usage.
Usage *types.TokenUsage `json:"usage,omitempty"`

View File

@ -6,10 +6,13 @@ import (
"io"
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"strconv"
"strings"
"testing"
"time"
"llmsproxy/internal/config"
"llmsproxy/internal/core"
@ -739,3 +742,276 @@ func TestDirectStreamFailoverAuditSource(t *testing.T) {
t.Fatalf("audit rec source=%q ok=%v, want source=b ok=true (actual serving source)", last.Source, last.OK)
}
}
// TestStatusExposesAdapterPools checks that the elastic Lua pool sizing is
// visible to admins (and only to them) via /api/status, so the grow/shrink
// algorithm can be observed instead of inferred.
func TestStatusExposesAdapterPools(t *testing.T) {
g := newTestGateway(t)
rr := doReq(t, g, http.MethodGet, "/api/status", "")
if rr.Code != 200 {
t.Fatalf("status = %d, body=%s", rr.Code, rr.Body.String())
}
var body map[string]interface{}
if err := json.Unmarshal(rr.Body.Bytes(), &body); err != nil {
t.Fatalf("decode: %v", err)
}
pools, ok := body["adapter_pools"].([]interface{})
if !ok {
t.Fatalf("adapter_pools missing or wrong type: %T", body["adapter_pools"])
}
if len(pools) == 0 {
t.Fatal("adapter_pools must list the loaded adapters")
}
first, _ := pools[0].(map[string]interface{})
for _, field := range []string{"name", "created", "idle", "in_use", "max", "grow_step", "shrink_step"} {
if _, ok := first[field]; !ok {
t.Errorf("pool entry missing %q: %v", field, first)
}
}
// idle gateway: nothing booted eagerly
if c, _ := first["created"].(float64); c != 0 {
t.Errorf("adapter pool booted eagerly, created=%v", first["created"])
}
}
// TestStatsRecordsAPIPaging exercises the on-demand records endpoint the
// dashboard scrolls with: a bounded first page plus a cursor that walks back
// through the audit file.
func TestStatsRecordsAPIPaging(t *testing.T) {
g := newTestGateway(t)
for i := 0; i < 250; i++ {
g.stats.Record(Req{
Time: time.Now().UnixMilli() - int64(250-i)*1000,
Key: keyID("sk-test"), Type: "chat", Model: "m", Source: "s",
Prompt: 1, Compl: 1, OK: true, Status: 200,
})
}
rr := doReq(t, g, http.MethodGet, "/api/stats/records?limit=50", "")
if rr.Code != 200 {
t.Fatalf("status = %d body=%s", rr.Code, rr.Body.String())
}
var page struct {
Records []Req `json:"records"`
NextCursor string `json:"next_cursor"`
HasMore bool `json:"has_more"`
}
if err := json.Unmarshal(rr.Body.Bytes(), &page); err != nil {
t.Fatalf("decode: %v", err)
}
if len(page.Records) != 50 {
t.Fatalf("first page = %d records, want 50", len(page.Records))
}
if !page.HasMore || page.NextCursor == "" {
t.Fatal("a long history must advertise more pages plus a cursor")
}
// newest first
for i := 1; i < len(page.Records); i++ {
if page.Records[i-1].Time < page.Records[i].Time {
t.Fatalf("page not newest-first at %d", i)
}
}
// follow the cursor: the next page must continue strictly older
oldest := page.Records[len(page.Records)-1].Time
rr2 := doReq(t, g, http.MethodGet,
"/api/stats/records?limit=50&before="+url.QueryEscape(page.NextCursor), "")
if rr2.Code != 200 {
t.Fatalf("page 2 status = %d", rr2.Code)
}
var page2 struct {
Records []Req `json:"records"`
}
if err := json.Unmarshal(rr2.Body.Bytes(), &page2); err != nil {
t.Fatalf("decode page 2: %v", err)
}
if len(page2.Records) == 0 {
t.Fatal("cursor page must return records")
}
if page2.Records[0].Time > oldest {
t.Fatalf("page 2 starts at %d, must continue below %d", page2.Records[0].Time, oldest)
}
}
// TestStatsSnapshotShipsOneScreen: a dashboard poll must not serialize the whole
// ring, otherwise the "load the first screen only" contract is broken on the
// wire even if the UI pages.
func TestStatsSnapshotShipsOneScreen(t *testing.T) {
g := newTestGateway(t)
for i := 0; i < 400; i++ {
g.stats.Record(Req{
Time: time.Now().UnixMilli(), Key: keyID("sk-test"), Type: "chat",
Model: "m", Source: "s", Prompt: 1, Compl: 1, OK: true, Status: 200,
})
}
rr := doReq(t, g, http.MethodGet, "/api/stats", "")
var snap struct {
Records []Req `json:"records"`
Total Stat `json:"total"`
}
if err := json.Unmarshal(rr.Body.Bytes(), &snap); err != nil {
t.Fatalf("decode: %v", err)
}
if len(snap.Records) > firstScreenRecords {
t.Fatalf("snapshot shipped %d records, want <= %d", len(snap.Records), firstScreenRecords)
}
// aggregates still cover every request
if snap.Total.Reqs != 400 {
t.Fatalf("total reqs = %d, want 400 (aggregates must not be paged away)", snap.Total.Reqs)
}
}
// TestStatsCsvExportStreams checks the export still emits every row in the
// window now that it is written straight from the audit walk.
func TestStatsCsvExportStreams(t *testing.T) {
g := newTestGateway(t)
base := time.Now().UnixMilli()
for i := 0; i < 120; i++ {
g.stats.Record(Req{
Time: base + int64(i)*1000, Key: keyID("sk-test"), Type: "chat",
Model: "m", Source: "s", Prompt: 2, Compl: 3, OK: true, Status: 200,
})
}
rr := doReq(t, g, http.MethodGet,
"/api/stats?export=csv&from="+strconv.FormatInt(base, 10)+
"&to="+strconv.FormatInt(base+120*1000, 10), "")
if rr.Code != 200 {
t.Fatalf("export status = %d", rr.Code)
}
lines := strings.Split(strings.TrimSpace(rr.Body.String()), "\n")
if len(lines) != 121 { // header + 120 rows
t.Fatalf("csv had %d lines, want 121 (header + 120 records)", len(lines))
}
if !strings.HasPrefix(lines[0], "time,key,key_name") {
t.Fatalf("unexpected csv header: %q", lines[0])
}
}
// TestStatsRecordsAPIScopedToOwnKey: a non-admin key must only page its own
// records even when it asks for someone else's.
func TestStatsRecordsAPIScopedToOwnKey(t *testing.T) {
g := newTestGateway(t)
mine := keyID("sk-test")
for i := 0; i < 5; i++ {
g.stats.Record(Req{Time: time.Now().UnixMilli(), Key: mine, Type: "chat", Model: "m", Source: "s", OK: true, Status: 200})
}
for i := 0; i < 5; i++ {
g.stats.Record(Req{Time: time.Now().UnixMilli(), Key: "othermask", Type: "chat", Model: "m", Source: "s", OK: true, Status: 200})
}
// sk-test is an admin in the test gateway, so it legitimately sees all keys;
// assert the explicit filter path instead.
rr := doReq(t, g, http.MethodGet, "/api/stats/records?limit=100&key=othermask", "")
var page struct {
Records []Req `json:"records"`
}
if err := json.Unmarshal(rr.Body.Bytes(), &page); err != nil {
t.Fatalf("decode: %v", err)
}
for _, r := range page.Records {
if r.Key != "othermask" {
t.Fatalf("key filter leaked %q", r.Key)
}
}
}
// TestKeyDeleteRoundTrip is the end-to-end version of the bug users hit:
// deleting a gateway key from the admin UI. It also asserts the GET-on-a-key
// error names the right verb, since that message is what a mis-methoded client
// actually sees.
func TestKeyDeleteRoundTrip(t *testing.T) {
g := newTestGateway(t)
rr := doReq(t, g, http.MethodPost, "/api/keys", `{"name":"doomed","role":"user"}`)
if rr.Code != 200 {
t.Fatalf("create: %d %s", rr.Code, rr.Body.String())
}
var created struct {
Key struct {
Key string `json:"key"`
Name string `json:"name"`
} `json:"key"`
}
if err := json.Unmarshal(rr.Body.Bytes(), &created); err != nil {
t.Fatalf("decode create: %v", err)
}
if created.Key.Key == "" {
t.Fatalf("no key returned: %s", rr.Body.String())
}
// DELETE removes it
rr = doReq(t, g, http.MethodDelete, "/api/keys/"+url.PathEscape(created.Key.Key), "")
if rr.Code != 200 {
t.Fatalf("delete: %d %s", rr.Code, rr.Body.String())
}
for _, k := range g.core.ListKeys() {
if k.Key == created.Key.Key {
t.Fatal("key still present after DELETE")
}
}
// deleting again is a clean 404, not a 500
rr = doReq(t, g, http.MethodDelete, "/api/keys/"+url.PathEscape(created.Key.Key), "")
if rr.Code != http.StatusNotFound {
t.Fatalf("second delete: %d, want 404", rr.Code)
}
}
// TestKeyGetOnSpecificKeyNamesTheVerb: a GET on /api/keys/{key} is what a
// client that forgot to set method:"DELETE" ends up sending. The error must say
// which verb to use instead of just "use GET /api/keys", which reads as if the
// caller did nothing wrong.
func TestKeyGetOnSpecificKeyNamesTheVerb(t *testing.T) {
g := newTestGateway(t)
rr := doReq(t, g, http.MethodGet, "/api/keys/sk-whatever", "")
if rr.Code != http.StatusNotFound {
t.Fatalf("status = %d, want 404", rr.Code)
}
body := rr.Body.String()
for _, want := range []string{"DELETE /api/keys/", "PUT /api/keys/"} {
if !strings.Contains(body, want) {
t.Errorf("error message should mention %q, got: %s", want, body)
}
}
}
// TestAdapterGetOnSpecificAdapterNamesDelete is the same guard for the adapters
// endpoint, whose delAdapter helper had the identical missing-method bug.
func TestAdapterGetOnSpecificAdapterNamesDelete(t *testing.T) {
g := newTestGateway(t)
rr := doReq(t, g, http.MethodGet, "/api/adapters/openai", "")
if rr.Code != http.StatusNotFound {
t.Fatalf("status = %d, want 404", rr.Code)
}
if !strings.Contains(rr.Body.String(), "DELETE /api/adapters/openai") {
t.Errorf("error should name the DELETE verb, got: %s", rr.Body.String())
}
}
// TestCannotDeleteOwnKey keeps the admin from locking themselves out.
func TestCannotDeleteOwnKey(t *testing.T) {
g := newTestGateway(t)
rr := doReq(t, g, http.MethodDelete, "/api/keys/sk-test", "")
if rr.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400", rr.Code)
}
if !strings.Contains(rr.Body.String(), "logged in with") {
t.Errorf("unexpected message: %s", rr.Body.String())
}
}
// TestGatewayRingStaysSmall pins the resident record ring at the small default.
// The gateway used to construct Stats with a 10000-record ring, which after the
// full-history aggregate scan meant 10000 resident Req structs — putting back the
// ~25 MB the on-demand paging work removed. Aggregates cover all of history;
// the ring only needs the first screen plus the 5-minute status windows.
func TestGatewayRingStaysSmall(t *testing.T) {
g := newTestGateway(t)
g.stats.mu.Lock()
max := g.stats.maxRecs
g.stats.mu.Unlock()
if max != defaultRingSize {
t.Fatalf("gateway record ring = %d, want %d (a big ring reintroduces the resident-memory cost)",
max, defaultRingSize)
}
}

View File

@ -29,16 +29,20 @@ func (g *Gateway) handleKeysAPI(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
if path != "" {
writeError(w, http.StatusNotFound, "not_found", "use GET /api/keys")
// A GET on a specific key is almost always a client that meant to
// DELETE or PUT it but let fetch default to GET. Name the verbs
// instead of only pointing back at the collection endpoint.
writeError(w, http.StatusNotFound, "not_found",
"no such endpoint; use GET /api/keys to list, DELETE /api/keys/{key} to remove, PUT /api/keys/{key} to update")
return
}
writeJSON(w, http.StatusOK, map[string]interface{}{"keys": g.core.ListKeys()})
case http.MethodPost:
var body struct {
Name string `json:"name"`
Role string `json:"role"`
Models []config.ModelScope `json:"models"`
Note string `json:"note"`
Name string `json:"name"`
Role string `json:"role"`
Models []config.ModelScope `json:"models"`
Note string `json:"note"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", "invalid json: "+err.Error())
@ -183,4 +187,4 @@ func (g *Gateway) handleAutoAPI(w http.ResponseWriter, r *http.Request) {
default:
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "")
}
}
}

View File

@ -59,7 +59,9 @@ func TestLoginBruteForceLockout(t *testing.T) {
func TestLoginSuccessResetsFailures(t *testing.T) {
g := newTestGateway(t)
mkBad := func() *http.Request { return httptest.NewRequest("POST", "/api/login", strings.NewReader(`{"key":"nope"}`)) }
mkBad := func() *http.Request {
return httptest.NewRequest("POST", "/api/login", strings.NewReader(`{"key":"nope"}`))
}
for i := 0; i < loginMaxFails-1; i++ { // one below the lockout threshold
rr := httptest.NewRecorder()
g.handleLoginAPI(rr, mkBad())

View File

@ -49,12 +49,12 @@ type loginFail struct {
}
const (
loginMaxFails = 5 // failures before lockout kicks in
loginLockBase = 30 // first lockout: 30s
loginLockCap = 30 * 60 // repeated lockouts cap at 30min
loginDecay = 15 * 60 // counters decay after 15min of quiet
loginGlobalMax = 100 // global failures per minute across all IPs
loginGlobalWindow = 60 // global window length (sec)
loginMaxFails = 5 // failures before lockout kicks in
loginLockBase = 30 // first lockout: 30s
loginLockCap = 30 * 60 // repeated lockouts cap at 30min
loginDecay = 15 * 60 // counters decay after 15min of quiet
loginGlobalMax = 100 // global failures per minute across all IPs
loginGlobalWindow = 60 // global window length (sec)
)
// loginAllow checks (and records) a login attempt for ip. It returns false
@ -120,7 +120,13 @@ func New(c *core.Core, gatewayKeys []string) (*Gateway, error) {
if err != nil {
return nil, err
}
st := NewStats(10000)
// The record ring is deliberately small: aggregates are built from the full
// audit history at startup (see Stats.LoadAudit), so the ring only has to
// cover the status page's 5-minute source windows and the dashboard's first
// screen. Everything older is paged from disk by /api/stats/records.
// A large ring here would put the ~25 MB of resident Req structs straight
// back, which is exactly what the on-demand paging removed.
st := NewStats(defaultRingSize)
if cfg := c.Config(); cfg != nil && cfg.RuntimeFile != "" {
st.LoadAudit(cfg.RuntimeFile + ".audit.jsonl")
}
@ -226,6 +232,8 @@ func (g *Gateway) routes(w http.ResponseWriter, r *http.Request) {
g.handleStatusAPI(w, r)
case r.URL.Path == "/api/status/reset":
g.handleResetHealth(w, r)
case r.URL.Path == "/api/stats/records":
g.handleStatsRecordsAPI(w, r)
case r.URL.Path == "/api/stats" || strings.HasPrefix(r.URL.Path, "/api/stats/"):
g.handleStatsAPI(w, r)
case r.URL.Path == "/api/keys" || strings.HasPrefix(r.URL.Path, "/api/keys/"):
@ -591,6 +599,10 @@ func (g *Gateway) handleStatusAPI(w http.ResponseWriter, r *http.Request) {
}
resp["sources"] = sts
resp["adapters"] = g.core.ListAdapters()
// Elastic Lua pool sizing: created/idle/in_use vs the ceiling, plus the
// current grow/shrink steps, so the algorithm is inspectable in the UI
// instead of being a black box.
resp["adapter_pools"] = g.core.VM().PoolStats()
}
writeJSON(w, http.StatusOK, resp)
}

View File

@ -2,12 +2,16 @@ package gateway
import (
"bufio"
"bytes"
"encoding/json"
"fmt"
"io"
"log"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
"sync"
"time"
)
@ -39,7 +43,7 @@ type Req struct {
// accounting at all (even when the hit count is 0). The WebUI shows
// "0%" instead of "—" for such rows.
CacheReported bool `json:"cache_reported,omitempty"`
OK bool `json:"ok"`
OK bool `json:"ok"`
// Status http status code
Status int `json:"status"`
// Err short error message
@ -48,14 +52,14 @@ type Req struct {
// Stat aggregates counters for one dimension row.
type Stat struct {
Reqs int64 `json:"reqs"`
OK int64 `json:"ok"`
Err int64 `json:"err"`
Tokens int64 `json:"tokens"`
Prompt int64 `json:"prompt_tokens"`
Compl int64 `json:"completion_tokens"`
LatSum int64 `json:"latency_sum_ms"`
LatMax int64 `json:"latency_max_ms"`
Reqs int64 `json:"reqs"`
OK int64 `json:"ok"`
Err int64 `json:"err"`
Tokens int64 `json:"tokens"`
Prompt int64 `json:"prompt_tokens"`
Compl int64 `json:"completion_tokens"`
LatSum int64 `json:"latency_sum_ms"`
LatMax int64 `json:"latency_max_ms"`
FirstByteSum int64 `json:"first_byte_sum_ms,omitempty"`
}
@ -67,18 +71,19 @@ type agrRow struct {
// Stats collects per-key / per-model / per-source aggregates plus a bounded
// ring of raw request records, all guarded by one mutex.
type Stats struct {
mu sync.Mutex
active int64
byKey map[string]*Stat
byModel map[string]*Stat
bySrc map[string]*Stat
byKeyModel map[string]map[string]*Stat
byKeySrc map[string]map[string]*Stat
byStatus map[int]*Stat // per http status code aggregates (incl. 402/400)
recs []Req
maxRecs int
auditPath string
modelHour map[string]map[int64]int64 // model -> unix-hour bucket -> tokens
mu sync.Mutex
active int64
byKey map[string]*Stat
byModel map[string]*Stat
bySrc map[string]*Stat
byKeyModel map[string]map[string]*Stat
byKeySrc map[string]map[string]*Stat
byStatus map[int]*Stat // per http status code aggregates (incl. 402/400)
recs []Req
maxRecs int
auditPath string
replayPartial bool // aggregates built from a bounded audit tail
modelHour map[string]map[int64]int64 // model -> unix-hour bucket -> tokens
}
const hourSec = 3600
@ -87,14 +92,28 @@ const hourSec = 3600
// file is renamed to <path>.<unix>.old and a fresh one is started); pruning
// keeps at most auditKeepOld rotated files. Both are vars so tests can shrink
// the threshold.
//
// The single-file threshold is deliberately small relative to the total budget
// (16 MB x 16 = 256 MB): records are paged by reading a file backwards, and a
// smaller file keeps the first page from seeking through a huge one.
var (
auditRotateBytes int64 = 64 << 20
auditKeepOld = 10
auditRotateBytes int64 = 16 << 20
auditKeepOld = 16
)
// defaultRingSize is how many recent request records stay resident. It has to
// cover two consumers: the status page's 5-minute source windows
// (SourceRecent/SourceAverages) and the first screen of the records table.
// Everything beyond that is paged from the audit file.
const defaultRingSize = 500
// firstScreenRecords is how many records a dashboard load ships by default — one
// screen's worth. Scrolling pulls the rest through /api/stats/records.
const firstScreenRecords = 100
func NewStats(maxRecords int) *Stats {
if maxRecords <= 0 {
maxRecords = 3000
maxRecords = defaultRingSize
}
return &Stats{
byKey: map[string]*Stat{},
@ -149,37 +168,136 @@ func incStatus(a *Stat, name string, r Req) {
}
}
// LoadAudit primes the in-memory state from the audit file.
//
// The aggregates (totals, per-key/model/source rows, status counts and the
// hourly quota buckets) are built from the FULL history: every audit file is
// streamed oldest-first so the dashboard shows real all-time numbers rather than
// whatever happened to fit in a replay window. This is affordable because the
// scan keeps nothing per record — aggregate maps are keyed by key/model/source,
// so their size is bounded by cardinality, not by request count. Measured on the
// production host: 29 MB / 221k lines / 37k requests in ~260 ms.
//
// The raw-record ring is what stays bounded: only the newest maxRecs records are
// retained, and everything older is paged from disk on demand by AuditPage /
// StreamAuditRecords. The old behaviour — appending EVERY record into a slice
// and truncating at the end — is what cost ~25 MB of resident memory.
func (s *Stats) LoadAudit(path string) {
f, err := os.Open(path)
if err == nil {
defer f.Close()
s.mu.Lock()
defer s.mu.Unlock()
var recs []Req
// tolerate long error summaries / oversized junk lines
sc := bufio.NewScanner(f)
sc.Buffer(make([]byte, 64*1024), 16*1024*1024)
for sc.Scan() {
var r Req
if json.Unmarshal(sc.Bytes(), &r) != nil || r.Type == "" {
continue // access/event rows (obj) and malformed lines are no requests
}
// replay EVERY request row into the aggregates so totals and
// quota windows survive restarts; only the ring buffer view stays
// capped at maxRecs.
s.aggregateLocked(r)
recs = append(recs, r)
}
if n := len(recs); n > s.maxRecs {
recs = append([]Req(nil), recs[n-s.maxRecs:]...)
}
s.recs = recs
s.auditPath = path
return
}
s.mu.Lock()
s.auditPath = path
s.mu.Unlock()
// Oldest-first: the hourly-bucket retention prunes relative to the newest
// hour seen so far, so replaying in chronological order keeps exactly the
// intended trailing window.
chain := s.auditChain()
files := make([]string, 0, len(chain))
for i := len(chain) - 1; i >= 0; i-- {
files = append(files, chain[i])
}
ring := newReqRing(s.ringSize())
scanned := 0
s.mu.Lock()
for _, p := range files {
n, err := scanAuditFile(p, func(r Req) {
s.aggregateLocked(r)
ring.push(r)
})
scanned += n
if err != nil {
// A truncated or unreadable tail is not fatal: keep whatever was
// aggregated and mark the numbers as incomplete.
s.replayPartial = true
}
}
s.recs = ring.slice()
s.mu.Unlock()
if scanned > 0 {
log.Printf("[stats] replayed %d audit records from %d file(s) for aggregates; keeping the newest %d in memory",
scanned, len(files), len(s.recs))
}
}
func (s *Stats) ringSize() int {
s.mu.Lock()
defer s.mu.Unlock()
if s.maxRecs <= 0 {
return defaultRingSize
}
return s.maxRecs
}
// scanAuditFile streams one audit file, invoking fn for every request row, and
// returns how many request rows it saw. Access/event rows and malformed lines
// are skipped.
func scanAuditFile(path string, fn func(Req)) (int, error) {
f, err := os.Open(path)
if err != nil {
return 0, err
}
defer f.Close()
sc := bufio.NewScanner(f)
// tolerate long error summaries / oversized junk lines
sc.Buffer(make([]byte, 64*1024), 16*1024*1024)
n := 0
for sc.Scan() {
var r Req
if json.Unmarshal(sc.Bytes(), &r) != nil || r.Type == "" {
continue
}
fn(r)
n++
}
return n, sc.Err()
}
// reqRing keeps the newest n records seen, in chronological order, without
// growing with the number of records pushed through it.
type reqRing struct {
buf []Req
next int
full bool
limit int
}
func newReqRing(n int) *reqRing {
if n <= 0 {
n = defaultRingSize
}
return &reqRing{buf: make([]Req, n), limit: n}
}
func (r *reqRing) push(rec Req) {
r.buf[r.next] = rec
r.next++
if r.next == r.limit {
r.next = 0
r.full = true
}
}
// slice returns the retained records oldest-first.
func (r *reqRing) slice() []Req {
if !r.full {
out := make([]Req, r.next)
copy(out, r.buf[:r.next])
return out
}
out := make([]Req, 0, r.limit)
out = append(out, r.buf[r.next:]...)
out = append(out, r.buf[:r.next]...)
return out
}
// ReplayPartial reports whether the resident aggregates are known to be
// incomplete (an audit file could not be read in full). Under normal operation
// the aggregates cover the entire audit history, so this is false.
func (s *Stats) ReplayPartial() bool {
s.mu.Lock()
defer s.mu.Unlock()
return s.replayPartial
}
// Record appends a finished request to the aggregates and ring buffer.
@ -399,9 +517,64 @@ type StatsRow struct {
// AuditRecords returns every request row from the audit file plus its rotated
// .old files within the [from,to] unix-millisecond window, optionally for one
// masked key id. Unlike Records it is not bounded by the in-memory ring, so it
// yields a complete history for CSV export regardless of restarts or churn.
// masked key id, sorted oldest-first.
//
// It materializes the whole window, so it is only for tests and small windows.
// Production paths must use StreamAuditRecords (O(1) memory, for CSV export) or
// AuditPage (bounded pages, for the records view).
func (s *Stats) AuditRecords(from, to int64, key string) []Req {
var out []Req
_ = s.StreamAuditRecords(from, to, key, func(r Req) error {
out = append(out, r)
return nil
})
sort.Slice(out, func(i, j int) bool { return out[i].Time < out[j].Time })
return out
}
// ---- on-demand audit paging ----
//
// The records view is paged straight off disk instead of being held in memory:
// the newest page is read from the tail of the newest audit file backwards, and
// each response hands back a cursor for the next page. Nothing is cached between
// requests, so "release when the user leaves the page" is guaranteed by never
// retaining anything in the first place.
// auditCursor points at a byte offset inside one audit file. Paging walks
// backwards, so the cursor means "continue reading BEFORE this offset".
type auditCursor struct {
File string
Offset int64
}
// String encodes the cursor for the wire as "<file>:<offset>".
func (c auditCursor) String() string {
if c.File == "" {
return ""
}
return c.File + ":" + strconv.FormatInt(c.Offset, 10)
}
// parseAuditCursor decodes a wire cursor. An empty or malformed value means
// "start at the newest record".
func parseAuditCursor(s string) (auditCursor, bool) {
if s == "" {
return auditCursor{}, false
}
i := strings.LastIndex(s, ":")
if i <= 0 || i == len(s)-1 {
return auditCursor{}, false
}
off, err := strconv.ParseInt(s[i+1:], 10, 64)
if err != nil || off < 0 {
return auditCursor{}, false
}
return auditCursor{File: s[:i], Offset: off}, true
}
// auditChain lists the audit files newest-first: the live file, then the rotated
// ones in descending timestamp order.
func (s *Stats) auditChain() []string {
s.mu.Lock()
path := s.auditPath
s.mu.Unlock()
@ -410,36 +583,216 @@ func (s *Stats) AuditRecords(from, to int64, key string) []Req {
}
files := []string{path}
if olds, err := filepath.Glob(path + ".*.old"); err == nil {
sort.Sort(sort.Reverse(sort.StringSlice(olds)))
files = append(files, olds...)
}
var out []Req
for _, p := range files {
f, err := os.Open(p)
return files
}
// AuditPageResult is one page of records plus the cursor to continue from.
type AuditPageResult struct {
Records []Req `json:"records"`
Next string `json:"next_cursor,omitempty"`
HasMore bool `json:"has_more"`
// Rotated marks that the requested cursor's file no longer exists (the
// audit log rotated under the reader), so the client should restart from
// the newest page instead of silently skipping records.
Rotated bool `json:"rotated,omitempty"`
}
// revChunk is how much is read per backwards seek. Lines are far shorter than
// this, so a page is typically satisfied by one or two chunks.
const revChunk = 64 * 1024
// AuditPage returns up to limit request records ending at cursor, newest first.
// It reads the audit files backwards and stops as soon as the page is full, so
// cost and memory are proportional to limit rather than to the file size. The
// file handle is closed before returning: no state is kept between calls.
func (s *Stats) AuditPage(cursor string, limit int, key string) AuditPageResult {
if limit <= 0 || limit > 1000 {
limit = 100
}
chain := s.auditChain()
if len(chain) == 0 {
return AuditPageResult{Records: []Req{}}
}
start := 0
var offset int64 = -1 // -1 = start at EOF
rotated := false
if c, ok := parseAuditCursor(cursor); ok {
idx := -1
for i, f := range chain {
if f == c.File {
idx = i
break
}
}
if idx < 0 {
// the cursor's file rotated away: restart from the newest page and
// tell the client so it can reset its view
rotated = true
} else {
start, offset = idx, c.Offset
}
}
out := make([]Req, 0, limit)
for i := start; i < len(chain); i++ {
next, err := s.pageFile(chain[i], offset, limit, key, &out)
offset = -1 // subsequent files always start at their EOF
if err != nil {
continue
}
sc := bufio.NewScanner(f)
sc.Buffer(make([]byte, 64*1024), 16*1024*1024)
for sc.Scan() {
var r Req
if json.Unmarshal(sc.Bytes(), &r) != nil || r.Type == "" {
continue
if len(out) >= limit {
res := AuditPageResult{Records: out, Rotated: rotated}
if next > 0 {
res.Next = auditCursor{File: chain[i], Offset: next}.String()
res.HasMore = true
} else if i+1 < len(chain) {
res.Next = auditCursor{File: chain[i+1], Offset: fileSize(chain[i+1])}.String()
res.HasMore = true
}
if key != "" && r.Key != key {
continue
}
if from > 0 && r.Time < from {
continue
}
if to > 0 && r.Time > to {
continue
}
out = append(out, r)
return res
}
f.Close()
}
sort.Slice(out, func(i, j int) bool { return out[i].Time < out[j].Time })
return out
return AuditPageResult{Records: out, HasMore: false, Rotated: rotated}
}
func fileSize(path string) int64 {
fi, err := os.Stat(path)
if err != nil {
return 0
}
return fi.Size()
}
// pageFile reads one audit file backwards from end (or EOF when end < 0),
// appending matching records to out (newest first) until it holds limit entries.
// It returns the offset to continue from within this file, or 0 when the file is
// exhausted.
func (s *Stats) pageFile(path string, end int64, limit int, key string, out *[]Req) (int64, error) {
f, err := os.Open(path)
if err != nil {
return 0, err
}
defer f.Close() // released before the response is written: nothing is retained
fi, err := f.Stat()
if err != nil {
return 0, err
}
if end < 0 || end > fi.Size() {
end = fi.Size()
}
buf := make([]byte, 0, revChunk)
var tail []byte // bytes of a line whose start was not in this chunk
pos := end
for pos > 0 && len(*out) < limit {
size := int64(revChunk)
if size > pos {
size = pos
}
pos -= size
buf = buf[:size]
if _, err := f.ReadAt(buf, pos); err != nil && err != io.EOF {
return 0, err
}
chunk := buf
if len(tail) > 0 {
chunk = append(append(make([]byte, 0, len(buf)+len(tail)), buf...), tail...)
tail = tail[:0]
}
// walk the chunk's complete lines from the end towards the start
lineEnd := len(chunk)
for i := len(chunk) - 1; i >= 0; i-- {
if chunk[i] != '\n' {
continue
}
if rec, ok := decodeAuditLine(chunk[i+1:lineEnd], key); ok {
*out = append(*out, rec)
if len(*out) >= limit {
// resume at the newline we just consumed
return pos + int64(i) + 1, nil
}
}
lineEnd = i
}
// chunk[:lineEnd] is an incomplete line: carry it into the next chunk
if lineEnd > 0 {
tail = append(tail[:0], chunk[:lineEnd]...)
}
if pos == 0 {
// start of file: whatever is carried is a complete first line
if rec, ok := decodeAuditLine(tail, key); ok {
*out = append(*out, rec)
}
}
}
if pos <= 0 {
return 0, nil // file exhausted
}
return pos, nil
}
// decodeAuditLine parses one audit line as a request record, filtering by key.
// Access/event rows and malformed lines are rejected.
func decodeAuditLine(line []byte, key string) (Req, bool) {
line = bytes.TrimSpace(line)
if len(line) == 0 {
return Req{}, false
}
var r Req
if json.Unmarshal(line, &r) != nil || r.Type == "" {
return Req{}, false
}
if key != "" && r.Key != key {
return Req{}, false
}
return r, true
}
// StreamAuditRecords walks every request record in [from, to] (unix millis,
// 0 = unbounded) newest file first and hands each one to fn. Nothing is
// accumulated: an export of an arbitrarily long period costs O(1) memory, and
// the file handles are closed as the walk proceeds. fn returning an error stops
// the walk (used to abort on a broken client connection).
func (s *Stats) StreamAuditRecords(from, to int64, key string, fn func(Req) error) error {
for _, path := range s.auditChain() {
if err := streamAuditFile(path, from, to, key, fn); err != nil {
return err
}
}
return nil
}
func streamAuditFile(path string, from, to int64, key string, fn func(Req) error) error {
f, err := os.Open(path)
if err != nil {
return nil // a rotated-away file is not an export failure
}
defer f.Close()
sc := bufio.NewScanner(f)
sc.Buffer(make([]byte, 64*1024), 16*1024*1024)
for sc.Scan() {
var r Req
if json.Unmarshal(sc.Bytes(), &r) != nil || r.Type == "" {
continue
}
if key != "" && r.Key != key {
continue
}
if from > 0 && r.Time < from {
continue
}
if to > 0 && r.Time > to {
continue
}
if err := fn(r); err != nil {
return err
}
}
return nil
}
// SourceRecent counts real gateway requests per source within the last
@ -530,10 +883,18 @@ func (s *Stats) SourceAverages(windowSec int64) map[string]SourceAvg {
// Snapshot returns the whole dashboard payload; when key != "" the records
// and aggregate views are restricted to that gateway key.
// Snapshot returns the aggregate rows plus the FIRST SCREEN of request records
// (newest last, matching the ring order). limit bounds the record slice only;
// older records are not included here at all — the dashboard pages them from
// /api/stats/records as the user scrolls, so a dashboard load never serializes
// the whole ring. limit <= 0 means the default first screen.
func (s *Stats) Snapshot(limit int, key string) map[string]interface{} {
s.mu.Lock()
defer s.mu.Unlock()
if limit <= 0 {
limit = firstScreenRecords
}
if limit > s.maxRecs {
limit = s.maxRecs
}
start := 0
@ -581,12 +942,13 @@ func (s *Stats) Snapshot(limit int, key string) map[string]interface{} {
return ci < cj
})
return map[string]interface{}{
"active": s.active,
"total": total,
"by_key": rows(byKey),
"by_model": rows(byModel),
"by_source": rows(bySrc),
"by_status": bs,
"records": append([]Req(nil), recs...),
"active": s.active,
"total": total,
"by_key": rows(byKey),
"by_model": rows(byModel),
"by_source": rows(bySrc),
"by_status": bs,
"records": append([]Req(nil), recs...),
"replay_partial": s.replayPartial,
}
}

View File

@ -156,4 +156,407 @@ func TestLoadAuditFullReplay(t *testing.T) {
if s.byStatus[200].Err != 0 || s.byStatus[503] == nil || s.byStatus[503].Reqs != 1 {
t.Fatalf("by_status wrong: %#v", s.byStatus)
}
}
}
// ---- on-demand log loading (plan 阶段 3) ----
// writeAuditFile writes n synthetic request rows (plus some non-request event
// rows, which must be skipped) and returns the path.
func writeAuditFile(t *testing.T, path string, n int, key string, startMs int64) {
t.Helper()
f, err := os.Create(path)
if err != nil {
t.Fatalf("create audit: %v", err)
}
defer f.Close()
for i := 0; i < n; i++ {
fmt.Fprintf(f, `{"time":%d,"key":%q,"type":"chat","model":"m","source":"s","prompt_tokens":1,"completion_tokens":1,"latency_ms":5,"ok":true,"status":200}`+"\n",
startMs+int64(i)*1000, key)
if i%10 == 0 {
// an access-log/event row: must never appear as a record
fmt.Fprintf(f, `{"obj":"access","time":%d,"path":"/v1/models"}`+"\n", startMs+int64(i)*1000)
}
}
}
// TestLoadAuditAggregatesFullHistory is the correctness half of the log work:
// the dashboard's totals must cover EVERY audit record, however large the file,
// while the in-memory record ring stays bounded.
func TestLoadAuditAggregatesFullHistory(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "audit.jsonl")
const n = 20000
writeAuditFile(t, path, n, "k1", 1_700_000_000_000)
s := NewStats(0)
s.LoadAudit(path)
if s.ReplayPartial() {
t.Fatal("a readable history must not be reported as partial")
}
snap := s.Snapshot(0, "")
total, _ := snap["total"].(Stat)
if total.Reqs != n {
t.Fatalf("total reqs = %d, want %d (aggregates must cover the whole file)", total.Reqs, n)
}
if total.Tokens != int64(n)*2 { // 1 prompt + 1 completion per row
t.Fatalf("total tokens = %d, want %d", total.Tokens, n*2)
}
// ...while the resident ring is capped and the snapshot ships one screen
recs, _ := snap["records"].([]Req)
if len(recs) == 0 {
t.Fatal("the newest records must stay resident")
}
if len(recs) > firstScreenRecords {
t.Fatalf("snapshot shipped %d records, must be capped at the first screen (%d)",
len(recs), firstScreenRecords)
}
s.mu.Lock()
ring := len(s.recs)
s.mu.Unlock()
if ring > defaultRingSize {
t.Fatalf("resident ring holds %d records, must be capped at %d", ring, defaultRingSize)
}
// the ring must hold the NEWEST records, not the first ones scanned
newest := recs[len(recs)-1]
wantNewest := int64(1_700_000_000_000) + int64(n-1)*1000
if newest.Time != wantNewest {
t.Fatalf("newest resident record time = %d, want %d", newest.Time, wantNewest)
}
}
// TestLoadAuditAggregatesAcrossRotatedFiles: history split over rotated .old
// files must still add up, and must be replayed oldest-first so the hourly
// quota buckets keep the intended trailing window.
func TestLoadAuditAggregatesAcrossRotatedFiles(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "audit.jsonl")
writeAuditFile(t, path, 40, "k1", 1_700_000_500_000) // newest
writeAuditFile(t, path+".100.old", 40, "k1", 1_700_000_300_000) // older
writeAuditFile(t, path+".050.old", 40, "k1", 1_700_000_100_000) // oldest
s := NewStats(0)
s.LoadAudit(path)
snap := s.Snapshot(0, "")
total, _ := snap["total"].(Stat)
if total.Reqs != 120 {
t.Fatalf("total reqs = %d, want 120 across three files", total.Reqs)
}
recs, _ := snap["records"].([]Req)
if len(recs) == 0 {
t.Fatal("expected resident records")
}
// oldest-first replay + newest-last ring => the last resident record is the
// newest one in the live file
if got := recs[len(recs)-1].Time; got != 1_700_000_500_000+39*1000 {
t.Fatalf("newest resident record time = %d, want the live file's last row", got)
}
}
// TestReqRingKeepsNewest pins the ring semantics used by the full-history scan:
// bounded size, newest retained, oldest-first output.
func TestReqRingKeepsNewest(t *testing.T) {
r := newReqRing(3)
for i := 1; i <= 7; i++ {
r.push(Req{Time: int64(i)})
}
got := r.slice()
if len(got) != 3 {
t.Fatalf("ring len = %d, want 3", len(got))
}
for i, want := range []int64{5, 6, 7} {
if got[i].Time != want {
t.Fatalf("ring[%d] = %d, want %d (oldest-first, newest retained)", i, got[i].Time, want)
}
}
// a partially filled ring returns exactly what was pushed
r2 := newReqRing(5)
r2.push(Req{Time: 1})
r2.push(Req{Time: 2})
if got := r2.slice(); len(got) != 2 || got[0].Time != 1 || got[1].Time != 2 {
t.Fatalf("partial ring = %+v", got)
}
}
// TestLoadAuditFullFileWhenSmall: a small file is loaded completely and is not
// flagged partial.
func TestLoadAuditFullFileWhenSmall(t *testing.T) {
path := filepath.Join(t.TempDir(), "audit.jsonl")
writeAuditFile(t, path, 50, "k1", 1_700_000_000_000)
s := NewStats(0)
s.LoadAudit(path)
if s.ReplayPartial() {
t.Fatal("a small file must be replayed in full, not flagged partial")
}
snap := s.Snapshot(0, "")
total, _ := snap["total"].(Stat)
if total.Reqs != 50 {
t.Fatalf("total reqs = %d, want 50", total.Reqs)
}
}
// TestQuotaWindowsSurviveBoundedRing is the regression guard the plan calls out:
// capping the resident record ring must not break token-quota accounting, which
// reads the hour buckets rather than the ring.
func TestQuotaWindowsSurviveBoundedRing(t *testing.T) {
s := NewStats(10) // deliberately tiny ring
now := time.Now().UnixMilli()
for i := 0; i < 100; i++ {
s.Record(Req{Time: now, Key: "k", Model: "m", Source: "s", Prompt: 2, Compl: 3, OK: true, Status: 200})
}
if got := s.WindowTokens("m", "s", 24*hourSec); got != 500 {
t.Fatalf("window tokens = %d, want 500 (quota accounting must not depend on the ring)", got)
}
if got := s.ModelTokens("k")["m"]; got != 500 {
t.Fatalf("model tokens = %d, want 500", got)
}
if got := s.KeyTokens("k"); got != 500 {
t.Fatalf("key tokens = %d, want 500", got)
}
}
// TestAuditPagePagesBackwards walks the whole history one page at a time and
// asserts strict newest-first order with no gaps or repeats.
func TestAuditPagePagesBackwards(t *testing.T) {
path := filepath.Join(t.TempDir(), "audit.jsonl")
const n = 250
writeAuditFile(t, path, n, "k1", 1_700_000_000_000)
s := NewStats(0)
s.LoadAudit(path)
var seen []Req
cursor := ""
for i := 0; i < 20; i++ { // bounded loop guards against a cursor that never advances
page := s.AuditPage(cursor, 40, "")
if len(page.Records) == 0 {
break
}
seen = append(seen, page.Records...)
if !page.HasMore {
break
}
if page.Next == cursor {
t.Fatal("cursor did not advance")
}
cursor = page.Next
}
if len(seen) != n {
t.Fatalf("paged %d records, want %d", len(seen), n)
}
for i := 1; i < len(seen); i++ {
if seen[i-1].Time < seen[i].Time {
t.Fatalf("records not newest-first at %d: %d then %d", i, seen[i-1].Time, seen[i].Time)
}
}
// newest record first, oldest last
if seen[0].Time != 1_700_000_000_000+int64(n-1)*1000 {
t.Fatalf("first record time = %d, want the newest", seen[0].Time)
}
if seen[len(seen)-1].Time != 1_700_000_000_000 {
t.Fatalf("last record time = %d, want the oldest", seen[len(seen)-1].Time)
}
}
// TestAuditPageFirstScreenIsSmall: the first page must be exactly one screen,
// independent of how large the audit file is.
func TestAuditPageFirstScreenIsSmall(t *testing.T) {
path := filepath.Join(t.TempDir(), "audit.jsonl")
writeAuditFile(t, path, 5000, "k1", 1_700_000_000_000)
s := NewStats(0)
s.LoadAudit(path)
page := s.AuditPage("", 100, "")
if len(page.Records) != 100 {
t.Fatalf("first page = %d records, want 100", len(page.Records))
}
if !page.HasMore || page.Next == "" {
t.Fatal("a large file must report more pages plus a cursor")
}
}
// TestAuditPageFiltersByKey: a user key may only page its own records.
func TestAuditPageFiltersByKey(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "audit.jsonl")
writeAuditFile(t, path, 30, "mine", 1_700_000_000_000)
f, err := os.OpenFile(path, os.O_APPEND|os.O_WRONLY, 0644)
if err != nil {
t.Fatal(err)
}
for i := 0; i < 30; i++ {
fmt.Fprintf(f, `{"time":%d,"key":"other","type":"chat","model":"m","source":"s","ok":true,"status":200}`+"\n",
1_700_000_100_000+int64(i)*1000)
}
f.Close()
s := NewStats(0)
s.LoadAudit(path)
page := s.AuditPage("", 1000, "mine")
if len(page.Records) != 30 {
t.Fatalf("filtered page = %d records, want 30", len(page.Records))
}
for _, r := range page.Records {
if r.Key != "mine" {
t.Fatalf("key filter leaked record for %q", r.Key)
}
}
}
// TestAuditPageSpansRotatedFiles: paging must continue into .old files instead
// of stopping at the live file's start.
func TestAuditPageSpansRotatedFiles(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "audit.jsonl")
writeAuditFile(t, path, 40, "k1", 1_700_000_500_000) // newest
writeAuditFile(t, path+".100.old", 40, "k1", 1_700_000_300_000) // older
writeAuditFile(t, path+".050.old", 40, "k1", 1_700_000_100_000) // oldest
s := NewStats(0)
s.LoadAudit(path)
var seen []Req
cursor := ""
for i := 0; i < 30; i++ {
page := s.AuditPage(cursor, 25, "")
seen = append(seen, page.Records...)
if !page.HasMore {
break
}
cursor = page.Next
}
if len(seen) != 120 {
t.Fatalf("paged %d records across rotated files, want 120", len(seen))
}
for i := 1; i < len(seen); i++ {
if seen[i-1].Time < seen[i].Time {
t.Fatalf("rotation boundary broke the ordering at %d", i)
}
}
}
// TestAuditPageRotatedCursor: a cursor whose file rotated away must restart from
// the newest page and say so, rather than silently returning nothing.
func TestAuditPageRotatedCursor(t *testing.T) {
path := filepath.Join(t.TempDir(), "audit.jsonl")
writeAuditFile(t, path, 20, "k1", 1_700_000_000_000)
s := NewStats(0)
s.LoadAudit(path)
page := s.AuditPage("/gone/audit.jsonl:12345", 10, "")
if !page.Rotated {
t.Fatal("a stale cursor must be reported as rotated")
}
if len(page.Records) == 0 {
t.Fatal("a stale cursor must fall back to the newest page")
}
}
// TestAuditPageEmptyAndNoFile cover the degenerate inputs.
func TestAuditPageEmptyAndNoFile(t *testing.T) {
s := NewStats(0)
if page := s.AuditPage("", 10, ""); len(page.Records) != 0 || page.HasMore {
t.Fatalf("no audit path must yield an empty page, got %+v", page)
}
path := filepath.Join(t.TempDir(), "audit.jsonl")
if err := os.WriteFile(path, nil, 0644); err != nil {
t.Fatal(err)
}
s2 := NewStats(0)
s2.LoadAudit(path)
if page := s2.AuditPage("", 10, ""); len(page.Records) != 0 || page.HasMore {
t.Fatalf("empty audit file must yield an empty page, got %+v", page)
}
}
// TestAuditPageHandlesLongLines: a record larger than the reverse-read chunk
// must still be decoded (the reader carries partial lines across chunks).
func TestAuditPageHandlesLongLines(t *testing.T) {
path := filepath.Join(t.TempDir(), "audit.jsonl")
f, err := os.Create(path)
if err != nil {
t.Fatal(err)
}
big := strings.Repeat("e", revChunk+1024) // one record spanning >1 chunk
fmt.Fprintf(f, `{"time":1,"key":"k","type":"chat","model":"m","source":"s","ok":false,"status":500,"error":%q}`+"\n", big)
fmt.Fprintf(f, `{"time":2,"key":"k","type":"chat","model":"m","source":"s","ok":true,"status":200}`+"\n")
f.Close()
s := NewStats(0)
s.LoadAudit(path)
page := s.AuditPage("", 10, "")
if len(page.Records) != 2 {
t.Fatalf("got %d records, want 2 (a >chunk record must survive reverse reads)", len(page.Records))
}
if page.Records[0].Time != 2 || page.Records[1].Time != 1 {
t.Fatalf("ordering broken: %d then %d", page.Records[0].Time, page.Records[1].Time)
}
if len(page.Records[1].Err) != len(big) {
t.Fatalf("oversized field truncated: %d bytes, want %d", len(page.Records[1].Err), len(big))
}
}
// TestStreamAuditRecordsIsWindowed: export streaming must honour the time window
// and the key filter, and must hand records over one at a time.
func TestStreamAuditRecordsIsWindowed(t *testing.T) {
path := filepath.Join(t.TempDir(), "audit.jsonl")
writeAuditFile(t, path, 100, "k1", 1_700_000_000_000)
s := NewStats(0)
s.LoadAudit(path)
from := int64(1_700_000_010_000)
to := int64(1_700_000_019_000)
var got []Req
if err := s.StreamAuditRecords(from, to, "", func(r Req) error {
got = append(got, r)
return nil
}); err != nil {
t.Fatalf("stream: %v", err)
}
if len(got) != 10 {
t.Fatalf("streamed %d records, want 10 in [%d,%d]", len(got), from, to)
}
for _, r := range got {
if r.Time < from || r.Time > to {
t.Fatalf("record outside the window: %d", r.Time)
}
}
}
// TestStreamAuditRecordsAbortStops: a callback error (client disconnect) must
// stop the walk immediately instead of reading the rest of the history.
func TestStreamAuditRecordsAbortStops(t *testing.T) {
path := filepath.Join(t.TempDir(), "audit.jsonl")
writeAuditFile(t, path, 500, "k1", 1_700_000_000_000)
s := NewStats(0)
s.LoadAudit(path)
seen := 0
stop := fmt.Errorf("client gone")
err := s.StreamAuditRecords(0, 0, "", func(Req) error {
seen++
if seen == 5 {
return stop
}
return nil
})
if err != stop {
t.Fatalf("abort must surface the callback error, got %v", err)
}
if seen != 5 {
t.Fatalf("walk continued after abort: %d records", seen)
}
}
// TestAuditRotationThresholds pins the smaller-file/more-files trade-off that
// makes reverse paging cheap while keeping the same total budget.
func TestAuditRotationThresholds(t *testing.T) {
if auditRotateBytes != 16<<20 {
t.Fatalf("auditRotateBytes = %d, want 16 MiB", auditRotateBytes)
}
if auditKeepOld != 16 {
t.Fatalf("auditKeepOld = %d, want 16", auditKeepOld)
}
if total := auditRotateBytes * int64(auditKeepOld); total != 256<<20 {
t.Fatalf("total audit budget = %d, want 256 MiB", total)
}
}

View File

@ -83,6 +83,56 @@
--blob1:rgba(168,85,247,.32); --blob2:rgba(34,211,238,.26); --blob3:rgba(139,92,246,.22);
--line:rgba(168,85,247,.16); --line-strong:rgba(139,92,246,.18);
}
/* ---- Mono: pure white (light) / pure black (dark) ----
Unlike the coloured accents this one deliberately drops the glass
translucency and the gradient-mesh blobs: "pure white" has to actually be
#fff, not a tinted translucent card floating over a gradient. Greys carry
the hierarchy that hue carries in the other themes, and the flat surfaces
also make this the cheapest theme to render (no backdrop-filter, no
animated blobs) — handy on weak GPUs and remote desktops. */
html[data-accent="mono"]{
--primary:#1f1f1f; --primary-h:#000000; --primary-50:#f4f4f5; --primary-100:#e8e8ea;
--primary-200:#d4d4d8; --primary-300:#a1a1aa; --primary-400:#52525b; --primary-500:#1f1f1f;
--secondary:#71717a; --secondary-h:#3f3f46; --secondary-50:#f4f4f5; --secondary-200:#d4d4d8;
--danger:#b91c1c; --danger-50:#fdeaea; --ok:#15803d; --warn:#a16207; --info:#3f3f46;
--bg-s1:#ffffff; --bg-s2:#ffffff; --bg-s3:#ffffff;
--card:#ffffff; --card-solid:#ffffff; --card2:#f6f6f7;
--line:rgba(0,0,0,.12); --line-strong:rgba(0,0,0,.22);
--fg:#18181b; --muted:#66666f;
--blob1:transparent; --blob2:transparent; --blob3:transparent;
--sh-sm:0 1px 2px rgba(0,0,0,.06);
--sh-md:0 2px 8px rgba(0,0,0,.09);
--sh-lg:0 8px 24px rgba(0,0,0,.13);
--glass:0px;
}
html[data-theme="dark"][data-accent="mono"]{
--primary:#f4f4f5; --primary-h:#ffffff; --primary-50:#161618; --primary-100:#1f1f22;
--primary-200:#2a2a2e; --primary-300:#3f3f46; --primary-400:#a1a1aa; --primary-500:#f4f4f5;
--secondary:#a1a1aa; --secondary-h:#d4d4d8; --secondary-50:#161618; --secondary-200:#2a2a2e;
--danger:#f87171; --danger-50:#2a1414; --ok:#4ade80; --warn:#fbbf24; --info:#d4d4d8;
--bg-s1:#000000; --bg-s2:#000000; --bg-s3:#000000;
--card:#0a0a0b; --card-solid:#0a0a0b; --card2:#151517;
--line:rgba(255,255,255,.14); --line-strong:rgba(255,255,255,.26);
--fg:#f4f4f5; --muted:#8c8c96;
--blob1:transparent; --blob2:transparent; --blob3:transparent;
--sh-sm:0 1px 2px rgba(0,0,0,.6);
--sh-md:0 2px 8px rgba(0,0,0,.7);
--sh-lg:0 8px 24px rgba(0,0,0,.8);
--glass:0px;
}
/* Flat surfaces: kill the translucency/blur that the glass themes rely on,
otherwise white/black would show through as grey. */
html[data-accent="mono"] #bgfx{background:var(--bg-s1)}
html[data-accent="mono"] #bgfx .blob{display:none}
html[data-accent="mono"] .sidebar,
html[data-accent="mono"] .card,
html[data-accent="mono"] .modal,
html[data-accent="mono"] .breadcrumb{backdrop-filter:none;-webkit-backdrop-filter:none}
html[data-accent="mono"] nav button.sb-i.active{background:var(--primary-50)}
html[data-accent="mono"] .sb-logo{background:var(--fg);color:var(--bg-s1);box-shadow:none}
html[data-accent="mono"] ::selection{background:var(--fg);color:var(--bg-s1)}
html[data-accent="mono"] ::-webkit-scrollbar-thumb{background:rgba(128,128,136,.45)}
html[data-accent="mono"] ::-webkit-scrollbar-thumb:hover{background:rgba(128,128,136,.75)}
*{box-sizing:border-box}
html,body{height:100%}
body{margin:0;color:var(--fg);overflow:hidden;
@ -250,6 +300,7 @@
.bar.ok i{background:var(--ok)}
.bar.err i{background:var(--danger)}
.recs-scroll{max-height:420px;overflow:auto}
.recs-sentinel{min-height:24px;padding:6px 2px;font-size:12px;color:var(--muted);text-align:center}
.recs-scroll table th{position:sticky;top:0;background:var(--card);z-index:1}
td.t-tag{white-space:nowrap}
.tbl-wrap{overflow-x:auto;-webkit-overflow-scrolling:touch}
@ -400,6 +451,7 @@
.scr-block .scr-tag{flex:0 0 auto;font-family:'JetBrains Mono',ui-monospace,Consolas,monospace;font-size:10.5px;padding:2px 7px;border-radius:9px;background:rgba(0,0,0,.24);color:#ffe9a8;border:1px solid rgba(255,220,130,.35);cursor:pointer}
.scr-block .scr-htag{flex:0 0 auto;display:flex;gap:4px;align-items:center;font-family:'JetBrains Mono',ui-monospace,Consolas,monospace;font-size:10px;font-weight:700;cursor:help}
.scr-htag .ht-cool{padding:2px 6px;border-radius:9px;background:rgba(244,114,182,.32);color:#ffd9e5;border:1px solid rgba(255,127,172,.5)}
.scr-htag .ht-probe{padding:2px 6px;border-radius:9px;background:rgba(251,191,36,.32);color:#fff3d0;border:1px solid rgba(251,191,36,.55)}
.scr-htag .ht-fail{padding:2px 6px;border-radius:9px;background:rgba(255,160,60,.22);color:#ffd9a8;border:1px solid rgba(255,180,90,.42)}
.scr-htag .ht-pref{padding:2px 6px;border-radius:9px;background:rgba(136,192,208,.26);color:#d6f1fa;border:1px solid rgba(136,192,208,.42)}
.scr-block .scr-grip{flex:0 0 auto;display:flex;flex-direction:column;gap:2px;padding:6px 4px;margin-left:2px;border-radius:6px;cursor:grab;background:rgba(255,255,255,.22);box-shadow:inset 0 1px 2px rgba(0,0,0,.2);transition:background .12s;touch-action:none}
@ -669,6 +721,22 @@
<path d="M12 2.5v19" /></svg
>Violet
</button>
<button data-accent="mono" title="Mono — 纯白(浅色)/ 纯黑(深色)">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
width="14"
height="14"
>
<circle cx="12" cy="12" r="9" />
<path d="M12 3a9 9 0 0 0 0 18z" fill="currentColor" />
</svg
>Mono
</button>
</div>
</div>
</aside>
@ -784,6 +852,14 @@
offline: "退避 / 不可用",
adTitle: "已加载适配器",
tVersion: "版本",
adPool: "Lua 状态池",
adPoolHint:
"池按需伸缩:扩容步长由最大并发决定,缩容步长由当前连接数决定(连接为 0 时回收到常驻下限)",
adPoolIdle: "空闲",
adPoolBusy: "占用",
adPoolMax: "上限",
adPoolPeak: "峰值",
adPoolSteps: "步长 (扩/缩)",
cModel: "模型",
cMsg: "Enter 发送,Shift+Enter 换行",
send: "发送",
@ -865,8 +941,12 @@
sortSaved: "排序已保存并热重载",
sortHintSave: "点击保存排序后生效",
sortCooling: "冷却",
sortProbeReady: "待探测",
sortProbing: "探测中",
sortFail: "失败",
sortHealthTip: "冷却 / 失败次数 / 偏好分 实时状态",
sortProbeTip:
"冷却窗口 %s 开始,%s 起放行 1 个探测请求,%s 完全恢复(探测成功则立即恢复)",
sortHealthReset: "链上冷却已复位",
kpiActive: "活跃请求",
kpiReqs: "总请求",
@ -908,6 +988,12 @@
thLatMs: "延迟",
allKeys: "全部密钥",
recFilter: "密钥筛选",
recsLoading: "加载更多…",
recsEnd: "已到最早记录",
recsRotated: "审计日志已轮转,已从最新记录重新加载",
recsNewest: "回到最新",
recsPartial:
"部分审计日志无法读取,统计可能不完整;完整历史请导出 CSV",
thTokens: "Tokens",
noUsage: "暂无用量记录",
},
@ -991,6 +1077,14 @@
offline: "backoff / down",
adTitle: "Loaded adapters",
tVersion: "Version",
adPool: "Lua state pool",
adPoolHint:
"Pools scale on demand: the grow step follows max concurrency, the shrink step follows the live connection count (idle pools collapse to the resident floor)",
adPoolIdle: "Idle",
adPoolBusy: "In use",
adPoolMax: "Max",
adPoolPeak: "Peak",
adPoolSteps: "Steps (grow/shrink)",
cModel: "Model",
cMsg: "Enter to send, Shift+Enter for newline",
send: "Send",
@ -1073,8 +1167,12 @@
sortSaved: "Order saved & hot-reloaded",
sortHintSave: "Click Save for it to take effect",
sortCooling: "cooling",
sortProbeReady: "probe ready",
sortProbing: "probing",
sortFail: "fail",
sortHealthTip: "live cooldown / failures / preference score",
sortProbeTip:
"cooldown opened %s, one probe request is allowed from %s, fully clear at %s (a successful probe restores it immediately)",
sortHealthReset: "chain cooldowns reset",
kpiActive: "Active requests",
kpiReqs: "Requests",
@ -1116,6 +1214,12 @@
thLatMs: "Latency",
allKeys: "All keys",
recFilter: "Key filter",
recsLoading: "Loading more…",
recsEnd: "Reached the oldest record",
recsRotated: "The audit log rotated; reloaded from the newest record",
recsNewest: "Back to newest",
recsPartial:
"Some audit files could not be read, so these totals may be incomplete; export CSV for the full history",
thTokens: "Tokens",
noUsage: "No usage data yet",
},
@ -1205,6 +1309,9 @@
});
updateBreadcrumb(lastTab);
function goTab(name) {
// leaving the dashboard releases the record buffer, its observer and any
// in-flight page fetch straight away
if (lastTab === "status" && name !== "status") releaseRecords(true);
document
.querySelectorAll(".sb-i")
.forEach((x) => x.classList.toggle("active", x.dataset.tab === name));
@ -1350,6 +1457,52 @@
};
let statsKeyF = ""; // active key filter for records ('' = all)
/* ---------- records: on-demand paging ----------
* The records table holds only what is on screen. The first screen comes
* from /api/stats (its records field), and scrolling to the bottom pulls
* the next page from /api/stats/records?before=<cursor>. Nothing is cached
* across page views: leaving the dashboard (tab switch or unload) drops the
* buffer, disconnects the observer and aborts any in-flight fetch, so both
* browser and server go back to holding nothing.
*/
const RECS_PAGE = 100; // rows fetched per scroll page
const RECS_MAX_DOM = 1000; // hard cap on rendered rows (sliding window)
const recsState = {
rows: [], // newest-first, mirrors the DOM
cursor: "", // next-page cursor
hasMore: false,
loading: false,
built: false, // table DOM exists (polls prepend instead of rebuilding)
observer: null, // IntersectionObserver on the bottom sentinel
onScroll: null, // scroll fallback (observers only fire on transitions)
abort: null, // AbortController for the in-flight page fetch
keyNames: {},
};
// releaseRecords drops every record-view resource. Called on tab switch,
// on key-filter change and on unload: "release when the user leaves".
function releaseRecords(clearDom) {
if (recsState.observer) {
recsState.observer.disconnect();
recsState.observer = null;
}
const root = $("#tb-recs");
if (root && recsState.onScroll)
root.removeEventListener("scroll", recsState.onScroll);
recsState.onScroll = null;
if (recsState.abort) {
recsState.abort.abort();
recsState.abort = null;
}
recsState.rows = [];
recsState.cursor = "";
recsState.hasMore = false;
recsState.loading = false;
recsState.built = false;
recsState.keyNames = {};
if (clearDom && root) root.innerHTML = "";
}
// ---- Source status column (refreshable) ----
// The column is driven by a combination of signals so a source that is
// actually serving traffic can never be shown as "down/backoff" just
@ -1458,10 +1611,12 @@
${s.sources ? `<div class="card"><h2>${t("dashSrc")}</h2><div id="tb-src"></div></div>` : ""}
</div>
<div class="card"><h2>${t("dashKey")}<span class="grow"></span><button class="ghost small" onclick="openExportModal()">${t("exportCsv")}</button></h2><div id="tb-key"></div></div>
<div class="card"><h2><span>${t("dashRecs")}</span><span class="grow"></span><span id="rec-exit"></span><button class="ghost small" onclick="openExportModal()">${t("exportCsv")}</button></h2>
<div class="card"><h2><span>${t("dashRecs")}</span><span class="grow"></span><span id="rec-exit"></span><button class="ghost small" onclick="resetRecords()">${t("recsNewest")}</button><button class="ghost small" onclick="openExportModal()">${t("exportCsv")}</button></h2>
<div class="filter-line">
<span class="muted">${t("recFilter")}</span>
<select id="rec-key" onchange="renderKeyF(this.value)"></select>
<span class="grow"></span>
<span id="rec-partial"></span>
</div>
<div class="recs-scroll" id="tb-recs"></div>
</div>
@ -1507,6 +1662,8 @@
}
function renderKeyF(v) {
statsKeyF = statsKeyF === v ? "" : v;
// the record set changes entirely: drop the paged buffer and start over
releaseRecords(true);
paintStats();
}
function openExportModal() {
@ -1571,8 +1728,11 @@
}
async function paintStats() {
try {
// limit=0 -> the server ships one screen of records; the rest is paged
// on demand from /api/stats/records as the user scrolls.
const q =
"/api/stats?limit=2000" +
"/api/stats?limit=" +
RECS_PAGE +
(statsKeyF ? "&key=" + encodeURIComponent(statsKeyF) : "");
const st = await api(q);
const tot = st.total || {};
@ -1638,6 +1798,13 @@
paintStatusTable(st.by_status || []);
paintKeyTable(st.by_key || [], st.key_names || {});
paintRecords(st.records || [], st.key_names || {});
// aggregates built from a bounded audit tail: say so instead of
// implying the numbers are all-time totals
const rpart = $("#rec-partial");
if (rpart)
rpart.innerHTML = st.replay_partial
? `<span class="muted" title="${escAttr(t("recsPartial"))}">ⓘ ${esc(t("recsPartial"))}</span>`
: "";
const rex = $("#rec-exit");
if (rex)
rex.innerHTML = statsKeyF
@ -1735,6 +1902,16 @@
.join("");
}
// sparkline: turn a value array into a compact polyline (area fill under accent)
// cssVar reads a CSS custom property off the document root. Canvas 2D does
// not understand var(), so any theme-driven colour used on a canvas has to
// be resolved here first.
function cssVar(name, fallback) {
const v = getComputedStyle(document.documentElement)
.getPropertyValue(name)
.trim();
return v || fallback;
}
function sparkline(ctx, vals, w, h, color) {
if (!vals.length) {
ctx.strokeStyle = color;
@ -1808,8 +1985,11 @@
qs.data.push(Math.max(rate, 0));
}
if (qs.data.length > 40) qs.data.shift();
// grid baseline
ctx.strokeStyle = "var(--line)";
// grid baseline. Canvas cannot resolve CSS custom properties, so the
// literal "var(--line)" used to be an invalid colour that the browser
// silently ignored, leaving whatever fillStyle was set last (black) —
// invisible on dark themes. Resolve it against the document instead.
ctx.strokeStyle = cssVar("--line", "rgba(128,128,128,.2)");
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(0, H - 1);
@ -1981,29 +2161,209 @@
.join("") +
"</table></div>";
}
function paintRecords(records, keyNames) {
const el = $("#tb-recs");
if (!el) return;
if (!records.length) {
el.innerHTML = `<div class="muted">${t("noUsage")}</div>`;
return;
}
const slice = records.slice().reverse().slice(0, 300);
el.innerHTML =
`<table><tr><th>${t("thTime")}</th><th class="num">${t("thStatus")}</th><th>${t("thKey")}</th><th>${t("thType")}</th><th>${t("thModel")}</th><th>${t("thSrc")}</th>
<th class="num">${t("thPrompt")}</th><th class="num">${t("thCompl")}</th><th class="num">${t("thCache") || "缓存"}</th><th class="num">${t("thLatMs")}</th></tr>` +
slice
.map(
(r) => `<tr>
// resetRecords drops the paged history and returns to the newest screen,
// which is also how the user gets out of a long scroll cheaply.
function resetRecords() {
releaseRecords(true);
paintStats();
}
// recRowHtml renders one record row.
function recRowHtml(r, keyNames) {
return `<tr>
<td class="t-tag">${fmtTime(r.time)}</td>
<td class="num">${r.ok ? `<span class="tag tag-green">${r.status || 200}</span>` : `<span class="tag tag-red" title="${esc(r.error || "")}">${r.status || 500}</span>`}</td>
<td>${esc(keyNames[r.key] ? keyNames[r.key] + " · " + r.key : r.key)}</td><td class="t-tag">${esc(r.type)}</td><td>${esc(r.model)}</td><td>${esc(r.source || "")}</td>
<td class="num">${fmtTok(r.prompt_tokens)}</td><td class="num">${fmtTok(r.completion_tokens)}</td>
<td class="num">${cacheCell(r)}</td>
<td class="num">${fmtMs(r.latency_ms)}</td></tr>`,
)
.join("") +
"</table>";
<td class="num">${fmtMs(r.latency_ms)}</td></tr>`;
}
function recHeadHtml() {
return `<tr><th>${t("thTime")}</th><th class="num">${t("thStatus")}</th><th>${t("thKey")}</th><th>${t("thType")}</th><th>${t("thModel")}</th><th>${t("thSrc")}</th>
<th class="num">${t("thPrompt")}</th><th class="num">${t("thCompl")}</th><th class="num">${t("thCache") || "缓存"}</th><th class="num">${t("thLatMs")}</th></tr>`;
}
// paintRecords renders the record table from the dashboard poll.
//
// It builds the table ONCE. Later polls only PREPEND records that arrived
// since the last paint: rebuilding on every 5s refresh used to wipe the
// pages the user had scrolled in and reset the scroll position (which made
// paging look broken), because the "is the paged view live?" check compared
// row counts and matched exactly on the first refresh.
function paintRecords(records, keyNames) {
const el = $("#tb-recs");
if (!el) return;
recsState.keyNames = keyNames || {};
const incoming = (records || []).slice().reverse(); // API sends oldest-first
if (!recsState.built) {
if (!incoming.length) {
recsState.rows = [];
el.innerHTML = `<div class="muted">${t("noUsage")}</div>`;
return;
}
recsState.rows = incoming;
recsState.cursor = "";
recsState.hasMore = true; // probed on the first page fetch
el.innerHTML =
`<table id="recs-tbl">${recHeadHtml()}<tbody id="recs-body">` +
incoming.map((r) => recRowHtml(r, recsState.keyNames)).join("") +
`</tbody></table><div id="recs-sentinel" class="recs-sentinel"></div>`;
recsState.built = true;
attachRecsObserver();
fillRecordsViewport();
return;
}
// Already built: splice in anything newer than our newest row.
const body = $("#recs-body");
if (!body || !incoming.length) return;
const seen = new Set(recsState.rows.map(recId));
const fresh = incoming.filter((r) => !seen.has(recId(r)));
if (!fresh.length) return;
body.insertAdjacentHTML(
"afterbegin",
fresh.map((r) => recRowHtml(r, recsState.keyNames)).join(""),
);
recsState.rows = fresh.concat(recsState.rows);
trimRecordsDom(body);
}
// recId identifies a record for de-duplication across the snapshot and the
// paged endpoint (both can return the same row).
function recId(r) {
return (
r.time + "|" + r.key + "|" + r.model + "|" + r.type + "|" + r.status
);
}
// trimRecordsDom enforces the sliding window: drop the oldest rendered rows
// (bottom of the table) so a long scroll cannot grow the DOM without bound.
function trimRecordsDom(body) {
const over = recsState.rows.length - RECS_MAX_DOM;
if (over <= 0) return;
for (let i = 0; i < over && body.lastElementChild; i++)
body.removeChild(body.lastElementChild);
recsState.rows = recsState.rows.slice(0, RECS_MAX_DOM);
}
// fillRecordsViewport loads pages until the list actually overflows its
// container. Without this, a short table leaves the sentinel permanently
// visible: IntersectionObserver only fires on TRANSITIONS, so it would
// never fire again and scrolling could never trigger a load.
async function fillRecordsViewport() {
const el = $("#tb-recs");
if (!el) return;
let guard = 0;
while (
recsState.built &&
recsState.hasMore !== false &&
el.scrollHeight <= el.clientHeight + 8 &&
guard++ < 5
) {
const before = recsState.rows.length;
await loadMoreRecords();
if (recsState.rows.length === before) break; // no progress: stop
}
}
// attachRecsObserver watches a sentinel below the last row; entering the
// viewport loads the next page. A scroll listener backs the observer up:
// IntersectionObserver only fires on transitions, so a sentinel that is
// already visible (short list, or a page that added no new rows) would
// otherwise never trigger another load. Both are torn down by
// releaseRecords when the view goes away.
function attachRecsObserver() {
if (recsState.observer) {
recsState.observer.disconnect();
recsState.observer = null;
}
const root = $("#tb-recs");
const sentinel = $("#recs-sentinel");
if (!root || !sentinel) return;
if (typeof IntersectionObserver !== "undefined") {
recsState.observer = new IntersectionObserver(
(entries) => {
if (entries.some((e) => e.isIntersecting)) loadMoreRecords();
},
{ root, rootMargin: "120px" },
);
recsState.observer.observe(sentinel);
}
if (recsState.onScroll) root.removeEventListener("scroll", recsState.onScroll);
recsState.onScroll = () => {
if (root.scrollTop + root.clientHeight >= root.scrollHeight - 120)
loadMoreRecords();
};
root.addEventListener("scroll", recsState.onScroll, { passive: true });
}
// loadMoreRecords fetches the next page and appends it. Concurrent calls
// are ignored, and a stale response (view released meanwhile) is dropped.
//
// A page that yields no NEW rows (the first fetch overlaps the dashboard's
// first screen) chains straight into the following page, bounded by
// maxChain, so the user never has to scroll twice for one page of data.
async function loadMoreRecords(depth) {
const maxChain = 3;
if (recsState.loading || recsState.hasMore === false) return;
recsState.loading = true;
const sentinel = $("#recs-sentinel");
if (sentinel) sentinel.textContent = t("recsLoading");
const ctrl = new AbortController();
recsState.abort = ctrl;
let added = 0;
try {
let q =
"/api/stats/records?limit=" +
RECS_PAGE +
(statsKeyF ? "&key=" + encodeURIComponent(statsKeyF) : "");
if (recsState.cursor)
q += "&before=" + encodeURIComponent(recsState.cursor);
const page = await api(q, { signal: ctrl.signal });
if (recsState.abort !== ctrl) return; // released while in flight
added = appendRecords(page);
} catch (e) {
if (e && e.name === "AbortError") return;
console.error("[records]", e);
recsState.hasMore = false;
} finally {
if (recsState.abort === ctrl) recsState.abort = null;
recsState.loading = false;
const s = $("#recs-sentinel");
if (s)
s.textContent = recsState.hasMore === false ? t("recsEnd") : "";
}
if (added === 0 && recsState.hasMore && (depth || 0) < maxChain)
await loadMoreRecords((depth || 0) + 1);
}
// appendRecords merges one page into the table, skipping records already on
// screen and trimming the oldest DOM rows past RECS_MAX_DOM.
//
// The first page fetch necessarily overlaps the dashboard's first screen, so
// it can legitimately yield ZERO new rows. When that happens the cursor has
// still advanced, so we immediately pull the next page instead of waiting
// for another scroll event that may never come.
function appendRecords(page) {
const body = $("#recs-body");
if (!body) return 0;
if (page.key_names) recsState.keyNames = page.key_names;
const seen = new Set(recsState.rows.map(recId));
const fresh = (page.records || []).filter((r) => !seen.has(recId(r)));
if (fresh.length) {
body.insertAdjacentHTML(
"beforeend",
fresh.map((r) => recRowHtml(r, recsState.keyNames)).join(""),
);
recsState.rows = recsState.rows.concat(fresh);
trimRecordsDom(body);
}
recsState.cursor = page.next_cursor || "";
recsState.hasMore = !!page.has_more && !!page.next_cursor;
if (page.rotated) toast(t("recsRotated"));
return fresh.length;
}
// cacheCell renders the per-request cache-hit column: a percentage when
@ -2818,9 +3178,9 @@
}
async function delSource(name) {
if (!confirm(tFmt("confirmDelSrc", name))) return;
await api("/api/sources/" + encodeURIComponent(name), {
method: "DELETE",
});
await api("/api/sources/" + encodeURIComponent(name), {
method: "DELETE",
});
toast(t("toastDelOk"));
renderSources();
}
@ -2979,8 +3339,19 @@
const st = sortStateMap.get(it.id + "|" + (it.src || "*"));
if (!st) return "";
const bits = [];
if (st.cooling)
bits.push(`<span class="ht-cool">${esc(t("sortCooling"))}</span>`);
if (st.cooling) {
// A cooling slot past its window midpoint still takes one probe
// request, so show that instead of a flat "cooling".
if (st.probing)
bits.push(
`<span class="ht-probe">${esc(t("sortProbing"))}</span>`,
);
else if (st.probeable)
bits.push(
`<span class="ht-probe">${esc(t("sortProbeReady"))}</span>`,
);
else bits.push(`<span class="ht-cool">${esc(t("sortCooling"))}</span>`);
}
if (st.fail_count > 0)
bits.push(
`<span class="ht-fail">${esc(t("sortFail") + "×" + st.fail_count)}</span>`,
@ -2990,7 +3361,26 @@
`<span class="ht-pref">${esc(st.pref > 0 ? "+" + st.pref : "" + st.pref)}</span>`,
);
if (!bits.length) return "";
return `<span class="scr-htag" title="${escAttr(t("sortHealthTip"))}">${bits.join("")}</span>`;
return `<span class="scr-htag" title="${escAttr(healthTip(st))}">${bits.join("")}</span>`;
}
// healthTip explains the cooldown/probe window in the tooltip: when the
// window opened, when probing starts and when it fully expires.
function healthTip(st) {
const base = t("sortHealthTip");
if (!st.cooling || !st.cooldown_until) return base;
const hms = (ts) =>
ts ? new Date(ts * 1000).toLocaleTimeString() : "—";
return (
base +
"\n" +
tFmt(
"sortProbeTip",
hms(st.cooldown_from),
hms(st.probe_after),
hms(st.cooldown_until),
)
);
}
function scrBlockHtml(it, isFirst, li, ji, extraClass) {
const c = srcColor(it.src);
@ -3641,18 +4031,23 @@
/* ---------- adapters tab ---------- */
async function renderAdapters() {
const j = await api("/api/status");
const pools = {};
(j.adapter_pools || []).forEach((p) => {
pools[p.name] = p;
});
const rows = j.adapters
.map(
(
a,
) => `<tr><td><b>${esc(a.name)}</b></td><td>${esc(a.version || "")}</td>
<td><button class="danger small" onclick="delAdapter('${escAttr(a.name)}')">${t("srcDel")}</button></td></tr>`,
)
.map((a) => {
const p = pools[a.name];
return `<tr><td><b>${esc(a.name)}</b></td><td>${esc(a.version || "")}</td>
<td>${poolCell(p)}</td>
<td><button class="danger small" onclick="delAdapter('${escAttr(a.name)}')">${t("srcDel")}</button></td></tr>`;
})
.join("");
$("#tab-adapters").innerHTML = `
<div class="card"><h2>${t("adTitle")} (${j.adapters.length})</h2>
<div class="tbl-wrap"><table><tr><th>${t("tName")}</th><th>${t("tVersion")}</th><th></th></tr>
${rows || `<tr><td colspan="3" class="empty">${t("adEmpty")}</td></tr>`}</table></div>
<div class="tbl-wrap"><table><tr><th>${t("tName")}</th><th>${t("tVersion")}</th><th>${t("adPool")}</th><th></th></tr>
${rows || `<tr><td colspan="4" class="empty">${t("adEmpty")}</td></tr>`}</table></div>
<p class="muted">${t("adPoolHint")}</p>
</div>
<div class="card"><h2>${t("uploadTitle")}</h2>
<div class="dropzone" id="dz">${t("dropHint")}</div>
@ -3664,6 +4059,26 @@
</div>`;
bindDropzone();
}
// poolCell renders one adapter's elastic Lua state pool: how many states
// exist vs the ceiling, how they split between idle and checked out, and
// the current grow/shrink steps. A pool that has never been used shows a
// dash: zero resident states is the healthy default, not an error.
function poolCell(p) {
if (!p) return '<span class="muted">—</span>';
if (!p.created)
return `<span class="muted" title="${escAttr(t("adPoolMax") + " " + p.max)}">0 / ${p.max}</span>`;
const busy = p.in_use > 0;
const title = [
`${t("adPoolIdle")} ${p.idle}`,
`${t("adPoolBusy")} ${p.in_use}`,
`${t("adPoolMax")} ${p.max}`,
`${t("adPoolPeak")} ${p.peak_in_use}`,
`${t("adPoolSteps")} +${p.grow_step} / -${p.shrink_step}`,
].join(" · ");
return `<span class="tag ${busy ? "tag-green" : "tag-amber"}" title="${escAttr(title)}">${p.created} / ${p.max}</span>
<span class="muted"> ${p.in_use}● ${p.idle}○ +${p.grow_step}/-${p.shrink_step}</span>`;
}
function bindDropzone() {
const dz = $("#dz"),
file = $("#adp-file"),
@ -3728,6 +4143,7 @@
async function delAdapter(name) {
if (!confirm(tFmt("confirmDelAdp", name))) return;
await api("/api/adapters/" + encodeURIComponent(name), {
method: "DELETE",
});
toast(t("toastDelOk"));
renderAdapters();
@ -4281,6 +4697,7 @@
if (!confirm(tFmt("kDelConfirm", name || key))) return;
try {
await api("/api/keys/" + encodeURIComponent(key), {
method: "DELETE",
});
toast(t("toastDelOk"));
loadKeys();
@ -4313,6 +4730,10 @@
}
} catch (e) {}
})();
// page unload / navigation away: release the record view immediately
window.addEventListener("pagehide", () => releaseRecords(false));
window.addEventListener("beforeunload", () => releaseRecords(false));
refresh("status");
</script>
</body>

View File

@ -0,0 +1,349 @@
package gateway
import (
"fmt"
"regexp"
"strings"
"testing"
)
// The WebUI talks to this package through a tiny `api(path, options)` helper
// that wraps fetch(). fetch() defaults to GET when no method is given, so an
// options object without `method` silently turns a DELETE/PUT into a GET —
// the request hits the wrong branch of the handler and the user sees a
// confusing error like "use GET /api/keys" while nothing is deleted.
//
// This has now bitten twice: ab20f1b fixed delSource/delTemplate, and the very
// same pattern was still live in delKey (deleting a gateway key was impossible)
// and delAdapter. Reviewing 4000+ lines of inline JS by eye clearly does not
// catch it, so it is pinned here instead.
// apiCallRe finds the start of every api(...) call in the UI source.
var apiCallRe = regexp.MustCompile(`\bapi\(`)
// uiSource returns the embedded WebUI document.
func uiSource(t *testing.T) string {
t.Helper()
b, err := uiFS.ReadFile("ui/index.html")
if err != nil {
t.Fatalf("read embedded ui: %v", err)
}
return string(b)
}
// extractCall returns the source of the balanced api(...) call starting at the
// opening parenthesis index, plus the line number it starts on.
func extractCall(src string, openIdx int) (string, bool) {
depth := 0
for i := openIdx; i < len(src); i++ {
switch src[i] {
case '(':
depth++
case ')':
depth--
if depth == 0 {
return src[openIdx : i+1], true
}
case '\n':
// A call spanning more than ~25 lines is not one of ours; bail out
// rather than scanning the rest of the document.
if i-openIdx > 4000 {
return "", false
}
}
}
return "", false
}
func lineOf(src string, idx int) int {
return strings.Count(src[:idx], "\n") + 1
}
// TestUIAPICallsDeclareMethod asserts that every api() call passing an options
// object also declares an HTTP method (or is a GET that only passes an
// AbortSignal). Without this, fetch defaults to GET and mutating endpoints are
// never reached.
func TestUIAPICallsDeclareMethod(t *testing.T) {
src := uiSource(t)
var offenders []string
for _, m := range apiCallRe.FindAllStringIndex(src, -1) {
open := m[1] - 1 // index of '('
call, ok := extractCall(src, open)
if !ok {
continue
}
// No options object at all => a plain GET, which is fine.
if !strings.Contains(call, "{") {
continue
}
if strings.Contains(call, "method:") {
continue
}
// A read that only carries an AbortSignal is a deliberate GET.
if strings.Contains(call, "signal:") && !strings.Contains(call, "body:") {
continue
}
offenders = append(offenders, fmt.Sprintf("line %d: %s", lineOf(src, open), oneLine(call)))
}
if len(offenders) > 0 {
t.Fatalf("api() called with an options object but no method (fetch will send GET):\n %s",
strings.Join(offenders, "\n "))
}
}
// TestUIDeleteHelpersUseDelete pins the specific helpers that remove things.
// Each must issue a DELETE; a GET here is the exact bug users reported as
// "cannot delete key: use GET /api/keys".
func TestUIDeleteHelpersUseDelete(t *testing.T) {
src := uiSource(t)
for _, fn := range []string{"delKey", "delAdapter", "delSource", "delTemplate"} {
body, ok := jsFunctionBody(src, fn)
if !ok {
t.Errorf("helper %s() not found in the WebUI", fn)
continue
}
if !strings.Contains(body, "api(") {
t.Errorf("%s() does not call api()", fn)
continue
}
if !strings.Contains(body, `method: "DELETE"`) {
t.Errorf("%s() must issue a DELETE, got:\n%s", fn, body)
}
}
}
// TestUIMutatingHelpersUseWriteMethods covers the write (non-delete) helpers:
// each must declare an explicit POST/PUT/PATCH.
func TestUIMutatingHelpersUseWriteMethods(t *testing.T) {
src := uiSource(t)
want := map[string][]string{
"createKey": {`method: "POST"`},
"putScope": {`method: "PUT"`},
"uploadAdapter": {`method: "POST"`},
"saveAuto": {`method: "PUT"`, `method: "POST"`},
}
for fn, methods := range want {
body, ok := jsFunctionBody(src, fn)
if !ok {
continue // helper renamed or absent: the method test above still guards it
}
found := false
for _, m := range methods {
if strings.Contains(body, m) {
found = true
break
}
}
if !found {
t.Errorf("%s() must declare one of %v", fn, methods)
}
}
}
// jsFunctionBody returns the source of `function name(` / `async function name(`
// up to its closing brace, using brace balancing.
func jsFunctionBody(src, name string) (string, bool) {
for _, prefix := range []string{"async function " + name + "(", "function " + name + "("} {
idx := strings.Index(src, prefix)
if idx < 0 {
continue
}
open := strings.Index(src[idx:], "{")
if open < 0 {
continue
}
open += idx
depth := 0
for i := open; i < len(src); i++ {
switch src[i] {
case '{':
depth++
case '}':
depth--
if depth == 0 {
return src[idx : i+1], true
}
}
}
}
return "", false
}
func oneLine(s string) string {
s = strings.Join(strings.Fields(s), " ")
if len(s) > 140 {
return s[:140] + "…"
}
return s
}
// TestUIRecordsPagingWiring guards the record table's paging plumbing. Two
// separate bugs made "scroll to the bottom" silently stop loading:
//
// 1. paintRecords rebuilt the whole table on every 5s poll whenever the row
// count did not exceed the first screen, wiping loaded pages and resetting
// scroll position.
// 2. IntersectionObserver only fires on TRANSITIONS. With a short list — or a
// page whose rows all duplicated the first screen — the sentinel stayed
// visible and never fired again.
//
// The fixes are a build-once flag, a scroll-position fallback, an initial
// viewport fill, and chaining when a page yields no new rows. None of that is
// reachable from Go, so the wiring is asserted structurally.
func TestUIRecordsPagingWiring(t *testing.T) {
src := uiSource(t)
paint, ok := jsFunctionBody(src, "paintRecords")
if !ok {
t.Fatal("paintRecords() not found")
}
if !strings.Contains(paint, "recsState.built") {
t.Error("paintRecords must build the table once (recsState.built) instead of rebuilding on every poll")
}
if !strings.Contains(paint, "afterbegin") {
t.Error("paintRecords must PREPEND newer rows on later polls, not rebuild the table")
}
if !strings.Contains(paint, "fillRecordsViewport") {
t.Error("paintRecords must fill the viewport so the sentinel can transition")
}
attach, ok := jsFunctionBody(src, "attachRecsObserver")
if !ok {
t.Fatal("attachRecsObserver() not found")
}
if !strings.Contains(attach, "IntersectionObserver") {
t.Error("attachRecsObserver must still use IntersectionObserver")
}
if !strings.Contains(attach, `addEventListener("scroll"`) {
t.Error("attachRecsObserver needs a scroll fallback: an already-visible sentinel never fires again")
}
load, ok := jsFunctionBody(src, "loadMoreRecords")
if !ok {
t.Fatal("loadMoreRecords() not found")
}
if !strings.Contains(load, "maxChain") {
t.Error("loadMoreRecords must chain when a page adds no new rows (the first page overlaps the first screen)")
}
if !strings.Contains(load, "signal: ctrl.signal") {
t.Error("loadMoreRecords must remain abortable")
}
fill, ok := jsFunctionBody(src, "fillRecordsViewport")
if !ok {
t.Fatal("fillRecordsViewport() not found")
}
for _, want := range []string{"scrollHeight", "clientHeight"} {
if !strings.Contains(fill, want) {
t.Errorf("fillRecordsViewport must compare %s to decide whether the list overflows", want)
}
}
rel, ok := jsFunctionBody(src, "releaseRecords")
if !ok {
t.Fatal("releaseRecords() not found")
}
for _, want := range []string{"observer.disconnect()", "removeEventListener", "abort()", "recsState.built = false"} {
if !strings.Contains(rel, want) {
t.Errorf("releaseRecords must clean up %s", want)
}
}
}
// TestUIThemeMatrix pins the theme system: every accent must define both a light
// and a dark variable block, so switching light/dark can never leave a theme
// half-defined (which shows up as unreadable text rather than as an error).
func TestUIThemeMatrix(t *testing.T) {
src := uiSource(t)
accents := []string{"sakura", "ocean", "violet", "mono"}
for _, a := range accents {
light := fmt.Sprintf(`html[data-accent=%q]{`, a)
dark := fmt.Sprintf(`html[data-theme="dark"][data-accent=%q]{`, a)
if !strings.Contains(src, light) {
t.Errorf("accent %q has no light-mode variable block (%s)", a, light)
}
// sakura is the default palette: its light values live in :root, and the
// generic dark block covers it.
if a != "sakura" && !strings.Contains(src, dark) {
t.Errorf("accent %q has no dark-mode variable block (%s)", a, dark)
}
if !strings.Contains(src, fmt.Sprintf(`data-accent="%s" title=`, a)) {
t.Errorf("accent %q has no picker button", a)
}
}
}
// TestUIMonoThemeIsFlat asserts the mono theme really is pure white / pure black:
// the surfaces must be opaque and the glass/blob decoration must be switched off,
// otherwise "#ffffff" renders as a grey translucent card over a gradient mesh.
func TestUIMonoThemeIsFlat(t *testing.T) {
src := uiSource(t)
lightBlock, ok := cssBlock(src, `html[data-accent="mono"]{`)
if !ok {
t.Fatal("mono light block not found")
}
for _, want := range []string{"--bg-s1:#ffffff", "--card:#ffffff", "--glass:0px"} {
if !strings.Contains(lightBlock, want) {
t.Errorf("mono light theme must set %s, got:\n%s", want, lightBlock)
}
}
if !strings.Contains(lightBlock, "--blob1:transparent") {
t.Error("mono light theme must disable the gradient blobs")
}
darkBlock, ok := cssBlock(src, `html[data-theme="dark"][data-accent="mono"]{`)
if !ok {
t.Fatal("mono dark block not found")
}
for _, want := range []string{"--bg-s1:#000000", "--glass:0px"} {
if !strings.Contains(darkBlock, want) {
t.Errorf("mono dark theme must set %s, got:\n%s", want, darkBlock)
}
}
if !strings.Contains(darkBlock, "--blob1:transparent") {
t.Error("mono dark theme must disable the gradient blobs")
}
// the flat-surface overrides must exist, or backdrop-filter turns #fff grey
for _, want := range []string{
`html[data-accent="mono"] #bgfx .blob{display:none}`,
`backdrop-filter:none`,
} {
if !strings.Contains(src, want) {
t.Errorf("mono theme needs the flat-surface override %q", want)
}
}
}
// TestUICanvasColorsResolveVars guards a class of silent theme bug: Canvas 2D
// does not understand CSS var(), so assigning "var(--x)" to strokeStyle is a
// no-op that leaves the previous colour (often black) in place — invisible on a
// dark background. Theme-driven canvas colours must go through cssVar().
func TestUICanvasColorsResolveVars(t *testing.T) {
src := uiSource(t)
if !strings.Contains(src, "function cssVar(") {
t.Fatal("cssVar() helper missing: canvas colours cannot resolve CSS variables without it")
}
for _, prop := range []string{"strokeStyle", "fillStyle"} {
bad := fmt.Sprintf(`ctx.%s = "var(--`, prop)
if strings.Contains(src, bad) {
t.Errorf("ctx.%s assigned a raw CSS var(): Canvas ignores it silently, use cssVar()", prop)
}
}
}
// cssBlock returns the text of the CSS rule starting at selector, up to its
// closing brace.
func cssBlock(src, selector string) (string, bool) {
i := strings.Index(src, selector)
if i < 0 {
return "", false
}
j := strings.Index(src[i:], "}")
if j < 0 {
return "", false
}
return src[i : i+j+1], true
}

View File

@ -57,6 +57,9 @@ function adapter.transform_response(raw_body)
unified.content = ch.message.content or ""
if ch.message.reasoning_content then
unified.reasoning_content = ch.message.reasoning_content
elseif ch.message.reasoning then
-- sensenova-6.8-flash-lite 用 reasoning 字段而不是 reasoning_content
unified.reasoning_content = ch.message.reasoning
end
end
unified.finish_reason = ch.finish_reason or ""
@ -97,10 +100,11 @@ function adapter.transform_stream_chunk(raw_chunk)
local delta = chunk.choices[1].delta or {}
-- Sensenova: delta has reasoning_content but no content
-- Sensenova: delta has reasoning_content (deepseek-v4-flash) or reasoning
-- (sensenova-6.8-flash-lite) but no content field
local content = delta.content or ""
if content == "" and delta.reasoning_content then
content = delta.reasoning_content
if content == "" and (delta.reasoning_content or delta.reasoning) then
content = delta.reasoning_content or delta.reasoning
end
-- Sensenova: finish_reason is "" on every chunk, "stop" on last

View File

@ -0,0 +1,175 @@
-- trae 源适配器(trae-local-api 的 OpenAI 兼容代理)
-- trae-local-api 运行在 http://127.0.0.1:19900,接 Trae CN 账号的云资源。
-- 协议:OpenAI /v1/chat/completions
-- 特性:
-- - 排队:上游 busy 时排队,可能长时间无响应
-- - 非流式请求偶发返回 SSE 数据(upstream bug)
-- - 支持 thinking,由上游模型自动决定是否开启
local adapter = {}
adapter.name = "trae"
adapter.version = "1.0.0"
adapter.endpoint = "/v1/chat/completions"
adapter.headers = {}
function adapter.transform_request(raw_body)
local ok, req = pcall(json.decode, raw_body)
if not ok then return raw_body end
req.disable_thinking = nil
req.extra_body = nil
if req.messages then
for _, msg in ipairs(req.messages) do
msg.reasoning_content = nil
end
end
return json.encode(req)
end
function adapter.transform_response(raw_body)
-- trae-local-api 偶发在非流式请求中返回 SSE 格式数据,
-- 表现为多个 data: {...} 行或混合了 reasoning_chunk 等。
-- 剥掉 data: 前缀,取最后一个完整的 JSON 块(通常是
-- 最终的 stop chunk 或 usage chunk)。
local use_json = raw_body
if raw_body:match("^data:") or raw_body:match("\ndata:") then
-- 取最后一个 data: 行的 JSON
local last = nil
for line in raw_body:gmatch("data: ([^\n\r]+)") do
local trimmed = line:match("^%s*(.-)%s*$")
if trimmed and trimmed ~= "[DONE]" then
local ok2, obj = pcall(json.decode, trimmed)
if ok2 and type(obj) == "table" then
last = obj
end
end
end
if last then
use_json = json.encode(last)
end
-- 如果解析失败,回退到原始 body
end
local ok, resp = pcall(json.decode, use_json)
if not ok or resp == nil then return raw_body end
local unified = {
content = "",
finish_reason = "",
token_usage = { prompt = 0, completion = 0, total = 0 }
}
if type(resp.usage) == "table" then
unified.token_usage.prompt = resp.usage.prompt_tokens or 0
unified.token_usage.completion = resp.usage.completion_tokens or 0
unified.token_usage.total = resp.usage.total_tokens or 0
end
if type(resp.choices) == "table" and #resp.choices > 0 then
local ch = resp.choices[1]
if type(ch.message) == "table" then
unified.content = ch.message.content or ""
if ch.message.reasoning_content then
unified.reasoning_content = ch.message.reasoning_content
end
if type(ch.message.tool_calls) == "table" then
local tcs = {}
for _, tc in ipairs(ch.message.tool_calls) do
local args_ok, args = pcall(json.decode, tc["function"].arguments)
if not args_ok then args = {} end
table.insert(tcs, {
id = tc.id,
type = tc.type or "function",
name = tc["function"].name,
arguments = args
})
end
unified.tool_calls = tcs
end
end
unified.finish_reason = ch.finish_reason or ""
end
return json.encode(unified)
end
function adapter.transform_stream_chunk(raw_chunk)
local ok, chunk = pcall(json.decode, raw_chunk)
if not ok then return "" end
local uses = nil
if type(chunk.usage) == "table" then
uses = {
prompt = chunk.usage.prompt_tokens or chunk.usage.prompt or 0,
completion = chunk.usage.completion_tokens or chunk.usage.completion or 0,
total = chunk.usage.total_tokens or chunk.usage.total or 0,
}
if type(chunk.usage.prompt_tokens_details) == "table" and chunk.usage.prompt_tokens_details.cached_tokens ~= nil then
uses.prompt_tokens_details = { cached_tokens = chunk.usage.prompt_tokens_details.cached_tokens }
elseif (chunk.usage.prompt_cache_hit_tokens or 0) > 0 then
uses.prompt_cache_hit_tokens = chunk.usage.prompt_cache_hit_tokens
uses.prompt_cache_miss_tokens = chunk.usage.prompt_cache_miss_tokens or 0
uses.prompt_tokens_details = { cached_tokens = chunk.usage.prompt_cache_hit_tokens }
end
end
if not chunk.choices or #chunk.choices == 0 then
if uses ~= nil then
return json.encode({ usage = uses, done = false })
end
return ""
end
local delta = chunk.choices[1].delta or {}
local fr = chunk.choices[1].finish_reason
local finish = (type(fr) == "string" and fr ~= "") and fr or nil
local unified = {
content = delta.content or "",
done = (finish ~= nil)
}
if finish then
unified.finish_reason = finish
end
if uses ~= nil then
unified.usage = uses
end
if delta.reasoning_content then
unified.reasoning_content = delta.reasoning_content
end
if delta.tool_calls then
unified.tool_calls = delta.tool_calls
end
return json.encode(unified)
end
function adapter.transform_error(status, body)
local ok, resp = pcall(json.decode, body)
if not ok or type(resp) ~= "table" then
-- Non-JSON body: extract title from HTML or first line
local title = string.match(body or "", "<title>(.-)</title>")
if title and title ~= "" then return title end
local line = string.match(body or "", "^%s*([^\r\n]+)")
if line and line ~= "" and not string.match(line, "^<") then
return string.sub(line, 1, 200)
end
return nil
end
local e = resp.error
if type(e) == "table" and type(e.message) == "string" then
-- trae quota / rate limit annotations
if e.code then
return tostring(e.code) .. ": " .. e.message
end
return e.message
end
if type(e) == "string" then return e end
if type(resp.message) == "string" and resp.message ~= "" then
if resp.code ~= nil then
return tostring(resp.code) .. ": " .. resp.message
end
return resp.message
end
return nil
end
return adapter

View File

@ -4,9 +4,15 @@
//
// The runtime uses LuaJIT (github.com/aarzilli/golua) so every adapter runs on
// an independent interpreter state. Because a lua.State is NOT goroutine-safe,
// each adapter owns a pool of workers (states); the pool size equals the sum
// of max_concurrent over all sources that use the adapter, and a worker is
// checked out for the duration of a single hook call.
// each adapter owns a pool of workers (states) and a worker is checked out for
// the duration of a single hook call.
//
// Pools are ELASTIC: the sum of max_concurrent over the sources using an adapter
// is a ceiling, not a preallocation. States are booted on demand in batches
// sized by that ceiling, and reclaimed by a single background janitor at a rate
// scaled inversely to the adapter's live connection count. An idle gateway
// therefore holds close to zero Lua states regardless of how much aggregate
// concurrency is configured. See adapterPool for the sizing rules.
package lua
import (
@ -23,6 +29,7 @@ import (
"strconv"
"strings"
"sync"
"time"
// golua exposes the LuaJIT C API. Build with -tags luajit so the cgo
// LDFLAGS resolve to -lluajit-5.1 (see golua's lua.go).
@ -64,7 +71,20 @@ type staticInfo struct {
}
// adapterPool owns the worker states for one adapter. Idle workers are held in
// a slice guarded by a cond; up to target workers are created lazily.
// a slice guarded by a cond; workers are created lazily on demand and reclaimed
// by the VM janitor once demand drops.
//
// Sizing is elastic rather than fixed (plan 阶段 2). Three inputs drive it:
//
// - maxW — the adapter's maximum concurrency (Σ max_concurrent of every source
// using it). It is the hard ceiling AND sets the growth step, so a hot,
// high-concurrency adapter ramps in batches while a low-concurrency one
// creeps up one state at a time.
// - created / len(idle) — how many states already exist, i.e. how much room is
// left to grow and how much is available to reclaim.
// - inUse — how many states are checked out right now, i.e. the live
// connection count for this adapter. It sets the shrink step: the fewer
// connections, the more aggressively idle states are released.
type adapterPool struct {
name string
script string
@ -73,24 +93,164 @@ type adapterPool struct {
mu sync.Mutex
cond *sync.Cond
idle []*worker
created int
target int
created int // live states: len(idle) + inUse (+ states being booted)
inUse int // states checked out right now == live connections
waiting int // goroutines blocked waiting for a free state
maxW int // hard ceiling = Σ max_concurrent of the sources using this adapter
used bool
closed bool
lastGrow time.Time
idleRounds int // consecutive janitor rounds that saw reclaimable slack
peakInUse int // high-water mark of inUse, for observability
}
const (
// growDivisor turns the adapter's max concurrency into the growth step CAP:
// step <= clamp(ceil(maxW / growDivisor), 1, growStepCap). The actual batch
// is additionally bounded by how many callers are queued (see growPlanLocked),
// so a wide adapter may ramp in big jumps but never warms states nobody is
// waiting for.
growDivisor = 8
growStepCap = 8
// growCooldown keeps a burst of misses from batching repeatedly while the
// previous batch is still booting.
growCooldown = time.Second
// residentWorkers is how many states an adapter keeps warm once it has
// served at least one request. Booting is milliseconds, but keeping one warm
// removes that from the critical path of the next request. Adapters that
// were never used keep nothing.
residentWorkers = 1
// idleHeadroom is the spare idle state kept above the live connection count,
// so the next concurrent request does not have to boot.
idleHeadroom = 1
// shrinkInterval is how often the VM janitor reclaims idle states.
shrinkInterval = 30 * time.Second
// shrinkGraceRounds is how many consecutive janitor rounds must see slack
// before anything is released. It guards against tearing a pool down between
// two bursts — NOT against ordinary traffic: a pool serving one request at a
// time still has reclaimable slack, and requiring "no traffic at all" would
// pin a burst's leftover states forever on any busy gateway. The counter is
// therefore reset by GROWTH (real contention), not by a mere checkout.
shrinkGraceRounds = 2
)
// PoolStats is a snapshot of one adapter pool's elastic sizing, surfaced by the
// status API so the algorithm is observable instead of a black box.
type PoolStats struct {
Name string `json:"name"`
Created int `json:"created"`
Idle int `json:"idle"`
InUse int `json:"in_use"`
Waiting int `json:"waiting"`
Max int `json:"max"`
Resident int `json:"resident"`
GrowStep int `json:"grow_step"`
ShrinkStep int `json:"shrink_step"`
PeakInUse int `json:"peak_in_use"`
}
func newAdapterPool(name, script string, static staticInfo) *adapterPool {
p := &adapterPool{name: name, script: script, static: static, target: 1}
p := &adapterPool{name: name, script: script, static: static, maxW: 1}
p.cond = sync.NewCond(&p.mu)
return p
}
func (p *adapterPool) setTarget(n int) {
// setMax updates the pool's hard ceiling (Σ max_concurrent of its sources).
// Lowering it does not kill live states: the janitor reclaims the excess.
func (p *adapterPool) setMax(n int) {
if n < 1 {
n = 1
}
p.mu.Lock()
p.target = n
p.maxW = n
p.cond.Broadcast()
p.mu.Unlock()
}
func ceilDiv(a, b int) int {
if b <= 0 {
return a
}
return (a + b - 1) / b
}
// growStepLocked is how many states to warm on a miss, derived from the
// adapter's maximum concurrency. Caller holds p.mu.
func (p *adapterPool) growStepLocked() int {
step := ceilDiv(p.maxW, growDivisor)
if step < 1 {
return 1
}
if step > growStepCap {
return growStepCap
}
return step
}
// residentLocked is the floor the janitor will not shrink below. Caller holds p.mu.
func (p *adapterPool) residentLocked() int {
if !p.used {
return 0
}
if residentWorkers > p.maxW {
return p.maxW
}
return residentWorkers
}
// shrinkStepLocked is how many idle states to release this round. The step is
// inversely proportional to the live connection count: with no connections the
// slack collapses in one round, while a busy adapter gives up one state at a
// time so the hot path keeps its warm states. Caller holds p.mu.
func (p *adapterPool) shrinkStepLocked() int {
excess := p.created - p.keepLocked()
if excess <= 0 {
return 0
}
step := ceilDiv(excess, 1+p.inUse)
if step > excess {
step = excess
}
if step > len(p.idle) {
step = len(p.idle)
}
if step < 0 {
return 0
}
return step
}
// keepLocked is the number of live states this pool should retain right now.
// Caller holds p.mu.
func (p *adapterPool) keepLocked() int {
keep := p.residentLocked()
if h := p.inUse + idleHeadroom; h > keep {
keep = h
}
if keep > p.maxW {
keep = p.maxW
}
return keep
}
func (p *adapterPool) stats() PoolStats {
p.mu.Lock()
defer p.mu.Unlock()
return PoolStats{
Name: p.name,
Created: p.created,
Idle: len(p.idle),
InUse: p.inUse,
Waiting: p.waiting,
Max: p.maxW,
Resident: p.residentLocked(),
GrowStep: p.growStepLocked(),
ShrinkStep: p.shrinkStepLocked(),
PeakInUse: p.peakInUse,
}
}
// boot creates a fresh LuaJIT state, registers the shared globals and executes
// the adapter script, storing the returned adapter table in the state.
func (p *adapterPool) boot() (*worker, error) {
@ -110,6 +270,96 @@ func (p *adapterPool) boot() (*worker, error) {
return &worker{L: L}, nil
}
// growPlanLocked reserves capacity for a batch prewarm and returns how many
// EXTRA states to boot in the background (the caller already reserved one for
// itself).
//
// Two independent limits apply, and BOTH matter:
//
// - the adapter's max concurrency caps the step (growStepLocked), so a
// high-concurrency adapter is allowed to ramp in bigger jumps than a
// narrow one;
// - the number of goroutines actually BLOCKED waiting for a state bounds it
// to real demand.
//
// Sizing on the ceiling alone over-provisions badly: an adapter with
// max_concurrent=76 has a step of 8, so two concurrent requests would warm 8
// states, and the janitor would throw 7 of them away a minute later — boot,
// discard, repeat, with RSS oscillating for no benefit. Prewarming only for
// goroutines that are genuinely queued keeps the ramp cheap without the churn.
//
// Caller holds p.mu.
func (p *adapterPool) growPlanLocked(contended bool) int {
if !contended || p.waiting <= 0 {
return 0
}
if time.Since(p.lastGrow) < growCooldown {
return 0
}
extra := p.growStepLocked() - 1
if extra > p.waiting {
extra = p.waiting // never warm more than the queue needs
}
if extra <= 0 {
return 0
}
if room := p.maxW - p.created; extra > room {
extra = room
}
if extra <= 0 {
return 0
}
// Reserve immediately so concurrent acquires respect the ceiling while the
// batch is still booting.
p.created += extra
p.lastGrow = time.Now()
return extra
}
// prewarm boots n reserved states and publishes them as idle. A boot failure
// releases its reservation; it is not fatal, since the synchronous path
// surfaces adapter errors already.
func (p *adapterPool) prewarm(n int) {
for i := 0; i < n; i++ {
p.mu.Lock()
closed := p.closed
p.mu.Unlock()
if closed {
p.releaseReservation(n - i)
return
}
w, err := p.boot()
if err != nil {
p.releaseReservation(n - i)
return
}
p.mu.Lock()
if p.closed {
p.created--
p.mu.Unlock()
w.close()
p.releaseReservation(n - i - 1)
return
}
p.idle = append(p.idle, w)
p.cond.Signal()
p.mu.Unlock()
}
}
func (p *adapterPool) releaseReservation(n int) {
if n <= 0 {
return
}
p.mu.Lock()
p.created -= n
if p.created < 0 {
p.created = 0
}
p.cond.Broadcast()
p.mu.Unlock()
}
func (p *adapterPool) acquire() (*worker, error) {
p.mu.Lock()
for {
@ -120,32 +370,68 @@ func (p *adapterPool) acquire() (*worker, error) {
if n := len(p.idle); n > 0 {
w := p.idle[n-1]
p.idle = p.idle[:n-1]
p.checkoutLocked()
p.mu.Unlock()
return w, nil
}
if p.created < p.target {
if p.created < p.maxW {
// Every existing state busy on a miss = real concurrency, not a
// sequential caller reusing one warm state.
contended := p.created > 0 && p.inUse >= p.created
p.created++
break
// Growing is the signal that capacity is genuinely short, so any
// pending shrink decision is stale.
p.idleRounds = 0
extra := p.growPlanLocked(contended)
p.mu.Unlock()
if extra > 0 {
go p.prewarm(extra)
}
w, err := p.boot()
if err != nil {
p.mu.Lock()
p.created--
p.cond.Signal()
p.mu.Unlock()
return nil, err
}
p.mu.Lock()
p.checkoutLocked()
p.mu.Unlock()
return w, nil
}
p.waiting++
p.cond.Wait()
p.waiting--
}
p.mu.Unlock()
}
w, err := p.boot()
if err != nil {
p.mu.Lock()
p.created--
p.cond.Signal()
p.mu.Unlock()
return nil, err
// checkoutLocked accounts one state as handed out. Caller holds p.mu.
func (p *adapterPool) checkoutLocked() {
p.inUse++
p.used = true
if p.inUse > p.peakInUse {
p.peakInUse = p.inUse
}
return w, nil
}
func (p *adapterPool) release(w *worker) {
w.L.SetTop(0)
p.mu.Lock()
if p.inUse > 0 {
p.inUse--
}
if p.closed {
p.created--
p.mu.Unlock()
w.close()
return
}
// A lowered ceiling is honoured on the spot rather than waiting for the
// janitor: returning a state the pool may no longer keep closes it.
if p.created > p.maxW {
p.created--
p.cond.Signal()
p.mu.Unlock()
w.close()
return
@ -155,11 +441,42 @@ func (p *adapterPool) release(w *worker) {
p.mu.Unlock()
}
// reclaim releases idle states down to the current keep target, using a step
// scaled by the live connection count. It returns how many states were closed.
func (p *adapterPool) reclaim() int {
p.mu.Lock()
if p.closed {
p.mu.Unlock()
return 0
}
step := p.shrinkStepLocked()
if step <= 0 {
p.idleRounds = 0
p.mu.Unlock()
return 0
}
p.idleRounds++
if p.idleRounds < shrinkGraceRounds {
p.mu.Unlock()
return 0
}
victims := append([]*worker(nil), p.idle[len(p.idle)-step:]...)
p.idle = p.idle[:len(p.idle)-step]
p.created -= step
p.idleRounds = 0
p.mu.Unlock()
for _, w := range victims {
w.close()
}
return step
}
func (p *adapterPool) shutdown() {
p.mu.Lock()
p.closed = true
idle := p.idle
p.idle = nil
p.created -= len(idle)
p.cond.Broadcast()
p.mu.Unlock()
for _, w := range idle {
@ -173,13 +490,84 @@ type VM struct {
mu sync.RWMutex
dir string
pools map[string]*adapterPool
// janitor drives elastic shrinking. One goroutine serves every pool (not
// one per adapter), so an idle gateway costs a single sleeping goroutine.
janitorOnce sync.Once
janitorStop chan struct{}
janitorDone chan struct{}
}
func NewVM(dir string) *VM {
return &VM{dir: dir, pools: map[string]*adapterPool{}}
return &VM{
dir: dir,
pools: map[string]*adapterPool{},
janitorStop: make(chan struct{}),
janitorDone: make(chan struct{}),
}
}
// startJanitor launches the single shrink loop. Idempotent.
func (v *VM) startJanitor() {
v.janitorOnce.Do(func() {
go v.janitorLoop(shrinkInterval)
})
}
func (v *VM) janitorLoop(every time.Duration) {
defer close(v.janitorDone)
t := time.NewTicker(every)
defer t.Stop()
for {
select {
case <-v.janitorStop:
return
case <-t.C:
v.reclaimIdle()
}
}
}
// reclaimIdle runs one shrink round over every pool. Exported behaviour is
// tested through ReclaimIdleNow.
func (v *VM) reclaimIdle() int {
v.mu.RLock()
pools := make([]*adapterPool, 0, len(v.pools))
for _, p := range v.pools {
pools = append(pools, p)
}
v.mu.RUnlock()
n := 0
for _, p := range pools {
n += p.reclaim()
}
return n
}
// ReclaimIdleNow forces one shrink round and reports how many worker states
// were closed. Intended for tests and for an explicit "release idle memory"
// action; normal operation relies on the janitor.
func (v *VM) ReclaimIdleNow() int { return v.reclaimIdle() }
// PoolStats returns the elastic sizing snapshot of every loaded adapter,
// sorted by name.
func (v *VM) PoolStats() []PoolStats {
v.mu.RLock()
pools := make([]*adapterPool, 0, len(v.pools))
for _, p := range v.pools {
pools = append(pools, p)
}
v.mu.RUnlock()
out := make([]PoolStats, 0, len(pools))
for _, p := range pools {
out = append(out, p.stats())
}
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
return out
}
func (v *VM) Start() error {
v.startJanitor()
if v.dir == "" {
return nil
}
@ -214,6 +602,12 @@ func (v *VM) Start() error {
}
func (v *VM) Stop() {
select {
case <-v.janitorStop:
// already stopped
default:
close(v.janitorStop)
}
v.mu.Lock()
pools := v.pools
v.pools = map[string]*adapterPool{}
@ -223,17 +617,17 @@ func (v *VM) Stop() {
}
}
// ConfigureConcurrency sets each adapter's worker target to the sum of
// max_concurrent of every source using it. Values below one are clamped to 1.
// ConfigureConcurrency sets each adapter's worker CEILING to the sum of
// max_concurrent of every source using it. It is a ceiling, not a preallocation:
// states are booted on demand and reclaimed when demand drops, so a config with
// a large aggregate concurrency no longer implies a large resident pool.
// Values below one are clamped to 1.
func (v *VM) ConfigureConcurrency(adapterConcurrency map[string]int) {
v.mu.RLock()
defer v.mu.RUnlock()
for name, n := range adapterConcurrency {
if p, ok := v.pools[name]; ok {
if n < 1 {
n = 1
}
p.setTarget(n)
p.setMax(n)
}
}
}
@ -260,16 +654,19 @@ func (v *VM) LoadAdapter(path string) error {
}
// LoadAdapterSource registers an adapter from source code. Replacing an
// existing adapter shuts the old pool down and re-boots lazily.
// existing adapter shuts the old pool down and re-boots lazily, carrying the
// configured ceiling over so a hot-reload does not reset elastic sizing.
func (v *VM) LoadAdapterSource(name, code string) error {
static, err := inspectScript(name, code)
if err != nil {
return err
}
target := 1
maxW := 1
v.mu.RLock()
if p, ok := v.pools[static.name]; ok {
target = p.target
p.mu.Lock()
maxW = p.maxW
p.mu.Unlock()
}
v.mu.RUnlock()
@ -278,9 +675,10 @@ func (v *VM) LoadAdapterSource(name, code string) error {
p.shutdown()
}
p := newAdapterPool(static.name, code, *static)
p.setTarget(target)
p.setMax(maxW)
v.pools[static.name] = p
v.mu.Unlock()
v.startJanitor()
return nil
}

View File

@ -5,7 +5,9 @@ import (
"os"
"path/filepath"
"strings"
"sync"
"testing"
"time"
)
// freshAdapterDir returns a path under a temp dir that does not exist yet, so
@ -484,8 +486,8 @@ func TestAnthropicToolRoundTrip(t *testing.T) {
}
var r1 struct {
Tools []struct {
Name string `json:"name"`
InputSchema map[string]interface{} `json:"input_schema"`
Name string `json:"name"`
InputSchema map[string]interface{} `json:"input_schema"`
} `json:"tools"`
Messages []map[string]interface{} `json:"messages"`
}
@ -512,12 +514,12 @@ func TestAnthropicToolRoundTrip(t *testing.T) {
Messages []struct {
Role string `json:"role"`
Content []struct {
Type string `json:"type"`
ID string `json:"id"`
Name string `json:"name"`
Type string `json:"type"`
ID string `json:"id"`
Name string `json:"name"`
Input map[string]interface{} `json:"input"`
ToolUseID string `json:"tool_use_id"`
ContentText string `json:"content"`
ToolUseID string `json:"tool_use_id"`
ContentText string `json:"content"`
} `json:"content"`
} `json:"messages"`
}
@ -746,3 +748,628 @@ func TestAdaptersReportZeroCacheHit(t *testing.T) {
t.Fatalf("openai fabricated cache details when upstream reported none: %s", out)
}
}
// ---- elastic pool sizing (plan 阶段 2) ----
// poolOf reaches into the VM for one adapter's pool. Tests assert on internal
// counters because the whole point of the change is that they no longer grow
// monotonically.
func poolOf(t *testing.T, v *VM, name string) *adapterPool {
t.Helper()
p := v.pool(name)
if p == nil {
t.Fatalf("adapter %q not loaded", name)
}
return p
}
func poolCounts(p *adapterPool) (created, idle, inUse int) {
p.mu.Lock()
defer p.mu.Unlock()
return p.created, len(p.idle), p.inUse
}
// TestPoolStartsEmpty is the startup-memory claim: loading adapters must not
// boot a single Lua state, no matter how large the configured ceiling is.
func TestPoolStartsEmpty(t *testing.T) {
vm := NewVM(freshAdapterDir(t))
if err := vm.Start(); err != nil {
t.Fatalf("start: %v", err)
}
defer vm.Stop()
vm.ConfigureConcurrency(map[string]int{"openai": 64, "deepseek": 32})
for _, st := range vm.PoolStats() {
if st.Created != 0 || st.Idle != 0 || st.InUse != 0 {
t.Fatalf("adapter %q booted eagerly: created=%d idle=%d in_use=%d",
st.Name, st.Created, st.Idle, st.InUse)
}
}
}
// TestGrowStepFollowsMaxConcurrent pins requirement 3: the growth step is
// derived from the adapter's maximum concurrency.
func TestGrowStepFollowsMaxConcurrent(t *testing.T) {
p := newAdapterPool("t", "return {name='t'}", staticInfo{name: "t"})
for _, tc := range []struct{ max, want int }{
{1, 1}, {4, 1}, {8, 1}, {9, 2}, {16, 2}, {32, 4}, {64, 8}, {200, 8},
} {
p.setMax(tc.max)
p.mu.Lock()
got := p.growStepLocked()
p.mu.Unlock()
if got != tc.want {
t.Fatalf("max=%d: grow step = %d, want %d", tc.max, got, tc.want)
}
}
}
// TestShrinkStepFollowsConnections pins requirement 4: the shrink step is
// driven by the live connection count — no connections means collapse, busy
// means give up one state at a time.
func TestShrinkStepFollowsConnections(t *testing.T) {
p := newAdapterPool("t", "return {name='t'}", staticInfo{name: "t"})
p.setMax(32)
p.used = true
cases := []struct {
created, idle, inUse, want int
why string
}{
{created: 9, idle: 9, inUse: 0, want: 8, why: "no connections: collapse to the resident floor in one round"},
{created: 9, idle: 8, inUse: 1, want: 4, why: "1 connection: halve the slack"},
{created: 9, idle: 6, inUse: 3, want: 2, why: "3 connections: quarter the slack"},
{created: 9, idle: 2, inUse: 7, want: 1, why: "busy: release one state at a time"},
{created: 4, idle: 0, inUse: 4, want: 0, why: "everything checked out: nothing to reclaim"},
{created: 1, idle: 1, inUse: 0, want: 0, why: "at the resident floor: keep the warm state"},
}
for _, tc := range cases {
p.mu.Lock()
p.created, p.inUse = tc.created, tc.inUse
p.idle = make([]*worker, tc.idle)
got := p.shrinkStepLocked()
p.mu.Unlock()
if got != tc.want {
t.Fatalf("%s: created=%d idle=%d inUse=%d -> step %d, want %d",
tc.why, tc.created, tc.idle, tc.inUse, got, tc.want)
}
}
}
// TestPoolGrowsOnDemandAndReclaims is the end-to-end elasticity loop: boot on
// demand, stay under the ceiling, then collapse back to the resident floor.
func TestPoolGrowsOnDemandAndReclaims(t *testing.T) {
vm := NewVM(freshAdapterDir(t))
if err := vm.Start(); err != nil {
t.Fatalf("start: %v", err)
}
defer vm.Stop()
vm.ConfigureConcurrency(map[string]int{"openai": 16})
p := poolOf(t, vm, "openai")
if created, _, _ := poolCounts(p); created != 0 {
t.Fatalf("pool must start empty, created=%d", created)
}
// concurrent load: hold several workers at once so the pool must grow
const hold = 6
var wg sync.WaitGroup
release := make(chan struct{})
for i := 0; i < hold; i++ {
wg.Add(1)
go func() {
defer wg.Done()
w, err := p.acquire()
if err != nil {
t.Errorf("acquire: %v", err)
return
}
<-release
p.release(w)
}()
}
// wait until all six are checked out
deadline := time.Now().Add(5 * time.Second)
for {
_, _, inUse := poolCounts(p)
if inUse >= hold {
break
}
if time.Now().After(deadline) {
t.Fatalf("workers never checked out, in_use=%d", inUse)
}
time.Sleep(5 * time.Millisecond)
}
created, _, inUse := poolCounts(p)
if inUse != hold {
t.Fatalf("in_use = %d, want %d", inUse, hold)
}
if created < hold {
t.Fatalf("created = %d, must cover %d concurrent checkouts", created, hold)
}
if created > 16 {
t.Fatalf("created = %d exceeds the ceiling 16", created)
}
close(release)
wg.Wait()
// all connections closed: the janitor collapses the pool to the floor.
// shrinkGraceRounds rounds are required, and one extra round proves the
// floor is stable rather than shrinking to zero.
for i := 0; i < shrinkGraceRounds+2; i++ {
vm.ReclaimIdleNow()
}
created, idle, inUse := poolCounts(p)
if inUse != 0 {
t.Fatalf("in_use = %d after release, want 0", inUse)
}
if created != residentWorkers || idle != residentWorkers {
t.Fatalf("after reclaim: created=%d idle=%d, want %d/%d (resident floor)",
created, idle, residentWorkers, residentWorkers)
}
}
// TestReclaimNeedsGraceRounds: a single quiet round must not tear the pool
// down — a gap between requests is not the end of a load period.
func TestReclaimNeedsGraceRounds(t *testing.T) {
vm := NewVM(freshAdapterDir(t))
if err := vm.Start(); err != nil {
t.Fatalf("start: %v", err)
}
defer vm.Stop()
vm.ConfigureConcurrency(map[string]int{"openai": 8})
p := poolOf(t, vm, "openai")
// build slack: 4 states created, all returned
var ws []*worker
for i := 0; i < 4; i++ {
w, err := p.acquire()
if err != nil {
t.Fatalf("acquire: %v", err)
}
ws = append(ws, w)
}
for _, w := range ws {
p.release(w)
}
created, _, _ := poolCounts(p)
if created != 4 {
t.Fatalf("created = %d, want 4", created)
}
if n := vm.ReclaimIdleNow(); n != 0 {
t.Fatalf("first quiet round must not reclaim (grace), closed %d", n)
}
if c, _, _ := poolCounts(p); c != 4 {
t.Fatalf("created = %d after the grace round, want 4", c)
}
if n := vm.ReclaimIdleNow(); n == 0 {
t.Fatal("second quiet round must reclaim")
}
}
// TestGrowthResetsGrace: growth (real contention) must cancel a pending shrink
// decision, but ordinary sequential traffic must NOT — otherwise a busy gateway
// that always has a request in flight would pin every state a past burst
// created, which is exactly the leak this change removes.
func TestGrowthResetsGrace(t *testing.T) {
vm := NewVM(freshAdapterDir(t))
if err := vm.Start(); err != nil {
t.Fatalf("start: %v", err)
}
defer vm.Stop()
vm.ConfigureConcurrency(map[string]int{"openai": 8})
p := poolOf(t, vm, "openai")
var ws []*worker
for i := 0; i < 4; i++ {
w, _ := p.acquire()
ws = append(ws, w)
}
for _, w := range ws {
p.release(w)
}
if n := vm.ReclaimIdleNow(); n != 0 {
t.Fatalf("grace round reclaimed %d", n)
}
// a lone sequential request reuses a warm state: no growth, so the pending
// shrink decision stands and the second round reclaims
w, _ := p.acquire()
p.release(w)
if n := vm.ReclaimIdleNow(); n == 0 {
t.Fatal("sequential traffic must not keep resetting the grace counter")
}
// now rebuild slack and prove that GROWTH does reset it
ws = ws[:0]
for i := 0; i < 4; i++ {
w, _ := p.acquire() // concurrent holds force created to grow
ws = append(ws, w)
}
for _, w := range ws {
p.release(w)
}
if n := vm.ReclaimIdleNow(); n != 0 {
t.Fatalf("growth must reset the grace counter, reclaimed %d", n)
}
}
// TestPoolCeilingIsRespectedUnderContention: acquires beyond the ceiling block
// instead of booting unbounded states.
func TestPoolCeilingIsRespectedUnderContention(t *testing.T) {
vm := NewVM(freshAdapterDir(t))
if err := vm.Start(); err != nil {
t.Fatalf("start: %v", err)
}
defer vm.Stop()
vm.ConfigureConcurrency(map[string]int{"openai": 2})
p := poolOf(t, vm, "openai")
w1, err := p.acquire()
if err != nil {
t.Fatalf("acquire 1: %v", err)
}
w2, err := p.acquire()
if err != nil {
t.Fatalf("acquire 2: %v", err)
}
got := make(chan struct{})
go func() {
w3, err := p.acquire()
if err == nil {
p.release(w3)
}
close(got)
}()
select {
case <-got:
t.Fatal("a third acquire must block at the ceiling of 2")
case <-time.After(100 * time.Millisecond):
}
if created, _, _ := poolCounts(p); created != 2 {
t.Fatalf("created = %d, must not exceed the ceiling 2", created)
}
p.release(w1)
select {
case <-got:
case <-time.After(2 * time.Second):
t.Fatal("releasing a state must unblock the waiter")
}
p.release(w2)
}
// TestLoweredCeilingReleasesStates: shrinking max_concurrent in the config must
// actually give memory back rather than leaving orphaned states parked.
func TestLoweredCeilingReleasesStates(t *testing.T) {
vm := NewVM(freshAdapterDir(t))
if err := vm.Start(); err != nil {
t.Fatalf("start: %v", err)
}
defer vm.Stop()
vm.ConfigureConcurrency(map[string]int{"openai": 8})
p := poolOf(t, vm, "openai")
var ws []*worker
for i := 0; i < 6; i++ {
w, err := p.acquire()
if err != nil {
t.Fatalf("acquire: %v", err)
}
ws = append(ws, w)
}
if created, _, _ := poolCounts(p); created != 6 {
t.Fatalf("created = %d, want 6", created)
}
// operator lowers the ceiling while the states are still checked out
vm.ConfigureConcurrency(map[string]int{"openai": 2})
for _, w := range ws {
p.release(w)
}
created, idle, _ := poolCounts(p)
if created > 2 || idle > 2 {
t.Fatalf("lowering the ceiling must drop the excess: created=%d idle=%d", created, idle)
}
}
// TestPoolStatsSurfacesAlgorithm checks the observability payload the status API
// exposes.
func TestPoolStatsSurfacesAlgorithm(t *testing.T) {
vm := NewVM(freshAdapterDir(t))
if err := vm.Start(); err != nil {
t.Fatalf("start: %v", err)
}
defer vm.Stop()
vm.ConfigureConcurrency(map[string]int{"openai": 32})
p := poolOf(t, vm, "openai")
w, err := p.acquire()
if err != nil {
t.Fatalf("acquire: %v", err)
}
var st PoolStats
for _, s := range vm.PoolStats() {
if s.Name == "openai" {
st = s
}
}
if st.Max != 32 {
t.Fatalf("Max = %d, want 32", st.Max)
}
if st.InUse != 1 || st.Created != 1 {
t.Fatalf("InUse=%d Created=%d, want 1/1", st.InUse, st.Created)
}
if st.GrowStep != 4 {
t.Fatalf("GrowStep = %d, want ceil(32/8)=4", st.GrowStep)
}
if st.PeakInUse != 1 {
t.Fatalf("PeakInUse = %d, want 1", st.PeakInUse)
}
p.release(w)
// stats must be sorted by name for a stable UI
names := []string{}
for _, s := range vm.PoolStats() {
names = append(names, s.Name)
}
for i := 1; i < len(names); i++ {
if names[i-1] > names[i] {
t.Fatalf("PoolStats not sorted: %v", names)
}
}
}
// TestHotReloadKeepsCeiling: replacing an adapter's code must not reset its
// elastic ceiling back to 1.
func TestHotReloadKeepsCeiling(t *testing.T) {
vm := NewVM(freshAdapterDir(t))
if err := vm.Start(); err != nil {
t.Fatalf("start: %v", err)
}
defer vm.Stop()
vm.ConfigureConcurrency(map[string]int{"openai": 24})
if err := vm.LoadAdapterSource("openai", "return {name='openai', version='9'}"); err != nil {
t.Fatalf("reload: %v", err)
}
p := poolOf(t, vm, "openai")
p.mu.Lock()
maxW := p.maxW
p.mu.Unlock()
if maxW != 24 {
t.Fatalf("ceiling after hot reload = %d, want 24", maxW)
}
}
// TestConcurrentTransformStress exercises the grow/shrink paths together under
// real hook calls, and asserts the accounting stays consistent (no leaked
// states, no negative counters).
func TestConcurrentTransformStress(t *testing.T) {
vm := NewVM(freshAdapterDir(t))
if err := vm.Start(); err != nil {
t.Fatalf("start: %v", err)
}
defer vm.Stop()
vm.ConfigureConcurrency(map[string]int{"openai": 16})
var wg sync.WaitGroup
for i := 0; i < 40; i++ {
wg.Add(1)
go func() {
defer wg.Done()
raw := `{"model":"m","messages":[{"role":"user","content":"hi"}]}`
if _, err := vm.Transform("openai", "transform_request", raw); err != nil {
t.Errorf("transform: %v", err)
}
}()
}
wg.Wait()
p := poolOf(t, vm, "openai")
created, idle, inUse := poolCounts(p)
if inUse != 0 {
t.Fatalf("in_use = %d after the stress run, want 0 (leaked checkout)", inUse)
}
if created != idle {
t.Fatalf("created=%d idle=%d must match when nothing is checked out", created, idle)
}
if created > 16 {
t.Fatalf("created = %d exceeds the ceiling", created)
}
for i := 0; i < shrinkGraceRounds+2; i++ {
vm.ReclaimIdleNow()
}
created, idle, _ = poolCounts(p)
if created != residentWorkers || idle != residentWorkers {
t.Fatalf("post-stress reclaim: created=%d idle=%d, want %d/%d",
created, idle, residentWorkers, residentWorkers)
}
}
// TestStopIsIdempotent guards the janitor's stop channel against a double close.
func TestStopIsIdempotent(t *testing.T) {
vm := NewVM(freshAdapterDir(t))
if err := vm.Start(); err != nil {
t.Fatalf("start: %v", err)
}
vm.Stop()
vm.Stop() // must not panic
}
// TestSequentialTrafficDoesNotBatch: a single caller looping must keep reusing
// one warm state — batch prewarm is for genuine concurrency only.
func TestSequentialTrafficDoesNotBatch(t *testing.T) {
vm := NewVM(freshAdapterDir(t))
if err := vm.Start(); err != nil {
t.Fatalf("start: %v", err)
}
defer vm.Stop()
vm.ConfigureConcurrency(map[string]int{"openai": 64}) // grow step would be 8
p := poolOf(t, vm, "openai")
for i := 0; i < 20; i++ {
w, err := p.acquire()
if err != nil {
t.Fatalf("acquire %d: %v", i, err)
}
p.release(w)
}
created, _, _ := poolCounts(p)
if created != 1 {
t.Fatalf("sequential traffic must reuse one state, created=%d", created)
}
}
// TestContentionBatchPrewarms: when callers are actually QUEUED behind a full
// pool, the pool warms extra states for them instead of booting one per request.
// The batch is bounded by the queue depth, not by the ceiling, so it can never
// warm states nobody is waiting for.
func TestContentionBatchPrewarms(t *testing.T) {
vm := NewVM(freshAdapterDir(t))
if err := vm.Start(); err != nil {
t.Fatalf("start: %v", err)
}
defer vm.Stop()
// ceiling 32 -> step cap 4; queue depth will be the real bound
vm.ConfigureConcurrency(map[string]int{"openai": 32})
p := poolOf(t, vm, "openai")
// Fill the pool to its ceiling-independent state: hold 1, then queue 3 more
// so waiting == 3 when the next miss happens.
p.mu.Lock()
p.maxW = 1 // force the next acquires to queue
p.mu.Unlock()
w1, err := p.acquire()
if err != nil {
t.Fatalf("acquire 1: %v", err)
}
done := make(chan *worker, 3)
for i := 0; i < 3; i++ {
go func() {
w, err := p.acquire()
if err != nil {
done <- nil
return
}
done <- w
}()
}
// wait until all three are queued
deadline := time.Now().Add(5 * time.Second)
for {
p.mu.Lock()
w := p.waiting
p.mu.Unlock()
if w >= 3 {
break
}
if time.Now().After(deadline) {
t.Fatalf("waiters never queued, waiting=%d", w)
}
time.Sleep(5 * time.Millisecond)
}
// raise the ceiling: the next miss sees waiting=3 and prewarms a batch
p.setMax(32)
got := make([]*worker, 0, 3)
for i := 0; i < 3; i++ {
select {
case w := <-done:
if w == nil {
t.Fatal("a queued acquire failed")
}
got = append(got, w)
case <-time.After(5 * time.Second):
t.Fatal("queued acquires never completed after the ceiling was raised")
}
}
created, _, _ := poolCounts(p)
if created < 4 {
t.Fatalf("queued demand must be satisfied, created=%d want >=4", created)
}
if created > 32 {
t.Fatalf("prewarm exceeded the ceiling, created=%d", created)
}
p.release(w1)
for _, w := range got {
p.release(w)
}
}
// TestNoPrewarmWithoutWaiters is the production regression this bound fixes: two
// concurrent requests against a wide adapter (ceiling 76, step 8) must NOT warm
// eight states. Warming for nobody meant the janitor discarded the surplus a
// minute later, so RSS oscillated between ~46 MB and ~56 MB forever.
func TestNoPrewarmWithoutWaiters(t *testing.T) {
vm := NewVM(freshAdapterDir(t))
if err := vm.Start(); err != nil {
t.Fatalf("start: %v", err)
}
defer vm.Stop()
vm.ConfigureConcurrency(map[string]int{"openai": 76})
p := poolOf(t, vm, "openai")
// two overlapping requests: the second misses while the first holds its
// state, but nobody is blocked, so no batch may be warmed
w1, err := p.acquire()
if err != nil {
t.Fatalf("acquire 1: %v", err)
}
w2, err := p.acquire()
if err != nil {
t.Fatalf("acquire 2: %v", err)
}
time.Sleep(100 * time.Millisecond) // give any background prewarm a chance
created, _, _ := poolCounts(p)
if created != 2 {
t.Fatalf("two overlapping requests must create exactly 2 states, got %d", created)
}
p.release(w1)
p.release(w2)
}
// TestBurstLeftoverIsReclaimedUnderSteadyTraffic reproduces what production
// showed right after the first deploy: a burst grew the openai pool to 9 states,
// then steady low-concurrency traffic kept arriving and the pool never shrank,
// because any checkout reset the shrink grace counter. Slack must be reclaimed
// even while the gateway keeps serving requests one at a time.
func TestBurstLeftoverIsReclaimedUnderSteadyTraffic(t *testing.T) {
vm := NewVM(freshAdapterDir(t))
if err := vm.Start(); err != nil {
t.Fatalf("start: %v", err)
}
defer vm.Stop()
vm.ConfigureConcurrency(map[string]int{"openai": 32})
p := poolOf(t, vm, "openai")
// burst: 9 concurrent holds
var ws []*worker
for i := 0; i < 9; i++ {
w, err := p.acquire()
if err != nil {
t.Fatalf("acquire: %v", err)
}
ws = append(ws, w)
}
for _, w := range ws {
p.release(w)
}
if created, _, _ := poolCounts(p); created < 9 {
t.Fatalf("burst should have grown the pool, created=%d", created)
}
// steady state: one request between every janitor round, forever
for round := 0; round < 8; round++ {
w, err := p.acquire()
if err != nil {
t.Fatalf("round %d acquire: %v", round, err)
}
p.release(w)
vm.ReclaimIdleNow()
}
created, idle, inUse := poolCounts(p)
if inUse != 0 {
t.Fatalf("in_use = %d, want 0", inUse)
}
if created > residentWorkers+idleHeadroom {
t.Fatalf("burst leftovers were never reclaimed under steady traffic: created=%d idle=%d",
created, idle)
}
}

View File

@ -13,6 +13,7 @@ import (
"log"
"net"
"net/http"
"strconv"
"strings"
"sync"
"sync/atomic"
@ -36,6 +37,16 @@ type ModelState struct {
pref atomic.Int64 // +1 per success / -5 per failure, clamped
failCount atomic.Int64
cooldownUntil atomic.Int64 // unix seconds; 0 = schedulable
// cooldownFrom is the unix second the current cooldown window opened. It
// is the reference point for the half-cooldown probe gate: probing is
// allowed only in the second half of [cooldownFrom, cooldownUntil), so a
// freshly failed slot stays fully silent for a while instead of being
// hammered immediately.
cooldownFrom atomic.Int64
// probeInFlight is the single-token probe permit. At most one probe
// request may be in flight per (source, model) while cooling; the token is
// released by ProbeDone regardless of outcome.
probeInFlight atomic.Bool
}
const (
@ -43,11 +54,33 @@ const (
prefMin = -20
prefMax = 20
backoffBase = 5 * time.Second
backoffCap = 30 * time.Minute
// backoffCap bounds the exponential schedule. It used to be 30min, which
// in practice meant "a slot whose upstream blipped is unusable for half an
// hour even though its quota recovered in seconds". Combined with the
// half-cooldown probe gate below a much shorter cap is safe: a still-dead
// upstream costs one probe request per cap/2, while a recovered one is back
// in full rotation on its first probe success.
backoffCap = 5 * time.Minute
// failures at/above this count back off at the capped duration
backoffCapN = 10
)
// authCooldown is the cooldown applied to 401/403 credential failures. A bad
// key does not fix itself in seconds, so it cools longer than a transport
// blip — but it is still a self-healing cooldown (a rotated key is picked up by
// the next probe), never a permanent blacklist.
const authCooldown = 10 * time.Minute
// maxQuotaCooldown bounds a quota-exhausted cooldown. Even when the reported
// reset window is huge (monthly quotas), the slot is re-probed at least this
// often so a manually topped-up allowance is noticed.
const maxQuotaCooldown = 30 * time.Minute
// quotaDefaultCooldown is used when the source gives no hint about its quota
// window length. It is long enough not to hammer an exhausted allowance and
// short enough that the half-cooldown probe retries within minutes.
const quotaDefaultCooldown = 10 * time.Minute
// rateLimitCooldown is the fixed cooldown applied on HTTP 429. A rate-limit
// rejection means "too fast", not "broken": unlike transport/5xx failures it
// must NOT escalate exponentially to backoffCap, or a quota-rich source gets
@ -81,25 +114,30 @@ func (s *ModelState) Available() bool {
}
// RecordFailure counts one consecutive failure and schedules exponential
// cooldown (5s, 10s, 20s … capped at 30min). auth marks 401/403 credential
// failures: it jumps straight to the capped cooldown and doubles the
// cooldown (5s, 10s, 20s … capped at backoffCap). auth marks 401/403
// credential failures: they get the longer fixed authCooldown and a doubled
// preference penalty, but the model still recovers when the cooldown expires.
func (s *ModelState) RecordFailure(auth bool) {
n := s.failCount.Add(1)
if auth && n < backoffCapN {
n = backoffCapN
s.failCount.Store(n) // persist the cap so FailCount() reports it too
}
var cd time.Duration
if n >= backoffCapN {
switch {
case auth:
// Persist the cap so FailCount() reports "this is a hard problem" and a
// following non-auth failure does not restart the ladder from the bottom.
if n < backoffCapN {
n = backoffCapN
s.failCount.Store(n)
}
cd = authCooldown
case n >= backoffCapN:
cd = backoffCap
} else {
default:
cd = backoffBase * time.Duration(1<<(n-1))
if cd > backoffCap {
cd = backoffCap
}
}
s.cooldownUntil.Store(time.Now().Add(cd).Unix())
s.openCooldown(cd)
pen := int64(prefFailStep)
if auth {
pen *= 2
@ -108,22 +146,57 @@ func (s *ModelState) RecordFailure(auth bool) {
clampPref(&s.pref, prefMin, prefMax)
}
// openCooldown starts a fresh cooldown window of length cd. Recording both
// ends of the window (not just the deadline) is what makes the half-cooldown
// probe gate possible, and resetting cooldownFrom on every new failure means a
// failed probe pushes the next probe to the middle of the NEW window instead of
// retrying immediately.
func (s *ModelState) openCooldown(cd time.Duration) {
now := time.Now()
s.cooldownFrom.Store(now.Unix())
s.cooldownUntil.Store(now.Add(cd).Unix())
}
// RecordRateLimit records an HTTP 429: a short fixed cooldown instead of the
// exponential schedule, so a merely-throttled source recovers in seconds and
// its remaining quota stays usable. The preference penalty still applies so
// other slots are preferred while cooling.
func (s *ModelState) RecordRateLimit() {
s.failCount.Add(1)
s.cooldownUntil.Store(time.Now().Add(rateLimitCooldown).Unix())
s.openCooldown(rateLimitCooldown)
s.pref.Add(-prefFailStep)
clampPref(&s.pref, prefMin, prefMax)
}
// RecordQuotaExhausted marks the model as out of allowance until its quota
// window resets. Unlike a failure this is not "the upstream is broken", so it
// must not feed the exponential ladder: the slot is unusable until the window
// boundary and immediately usable again after it. resetIn is the time left in
// the current quota window; non-positive values fall back to the short
// rate-limit window (the caller could not determine a period).
func (s *ModelState) RecordQuotaExhausted(resetIn time.Duration) {
if resetIn <= 0 {
resetIn = rateLimitCooldown
}
if resetIn > maxQuotaCooldown {
resetIn = maxQuotaCooldown
}
s.openCooldown(resetIn)
// No failCount bump: an exhausted quota is not an error streak, and
// letting it inflate failCount would make the NEXT real failure jump
// straight to the capped backoff.
s.pref.Add(-prefFailStep)
clampPref(&s.pref, prefMin, prefMax)
}
// RecordSuccess resets the failure counter and cooldown and bumps the
// preference score by one.
// preference score by one. A single success is enough to lift a slot off the
// prefMin floor (prefMin+1 > prefMin), which is what lets one successful probe
// return a written-off slot to normal rotation.
func (s *ModelState) RecordSuccess() {
s.failCount.Store(0)
s.cooldownUntil.Store(0)
s.cooldownFrom.Store(0)
s.pref.Add(1)
clampPref(&s.pref, prefMin, prefMax)
}
@ -131,9 +204,53 @@ func (s *ModelState) RecordSuccess() {
func (s *ModelState) reset() {
s.failCount.Store(0)
s.cooldownUntil.Store(0)
s.cooldownFrom.Store(0)
s.probeInFlight.Store(false)
s.pref.Store(0)
}
// TryProbe attempts to claim the single probe permit for a model that normal
// scheduling refuses. It returns true only when a probe is both needed and due:
//
// - cooling: allowed once the window passed its midpoint (the first half stays
// completely silent).
// - cooldown expired but the preference score sank to prefMin: ModelAvailable
// still refuses the slot, so a probe is the ONLY way back. Without this a
// slot that failed prefMin/prefFailStep times in a row would be blacklisted
// forever despite every cooldown having long expired.
//
// A healthy, non-cooling model returns false: the caller should schedule it
// normally. On true the caller MUST call ProbeDone exactly once.
func (s *ModelState) TryProbe() bool {
now := time.Now().Unix()
until := s.cooldownUntil.Load()
if until <= now {
if s.pref.Load() > prefMin {
return false // schedulable normally, no probe needed
}
// preference floor: probe to earn the way out
return s.probeInFlight.CompareAndSwap(false, true)
}
from := s.cooldownFrom.Load()
if from <= 0 || from >= until {
return false // no measurable window (legacy/degenerate state)
}
if now < from+(until-from)/2 {
return false // first half of the window: stay silent
}
return s.probeInFlight.CompareAndSwap(false, true)
}
// ProbeDone releases the probe permit claimed by TryProbe.
func (s *ModelState) ProbeDone() { s.probeInFlight.Store(false) }
// Probing reports whether a probe request currently holds the permit.
func (s *ModelState) Probing() bool { return s.probeInFlight.Load() }
// CooldownFrom is the unix timestamp the current cooldown window opened; 0
// when the model is not cooling.
func (s *ModelState) CooldownFrom() int64 { return s.cooldownFrom.Load() }
// Pref is the current preference score (higher = preferred).
func (s *ModelState) Pref() int64 { return s.pref.Load() }
@ -155,15 +272,15 @@ type Provider struct {
client *http.Client
stream *http.Client
mu sync.Mutex
sem chan struct{}
states map[string]*ModelState // key = model id
mu sync.Mutex
sem chan struct{}
states map[string]*ModelState // key = model id
// proactive rate limiting: nextOK is the earliest unix-nano time the next
// request may leave; throttle() spaces requests 60s/RPM apart. Zero when
// cfg.RPM <= 0 (unlimited).
rateMu sync.Mutex
nextOK int64
rpmGap time.Duration
rateMu sync.Mutex
nextOK int64
rpmGap time.Duration
lastProbe struct {
ok bool
err string
@ -370,6 +487,55 @@ func (p *Provider) ModelAvailable(model string) bool {
return true
}
// ModelSchedulable is the scheduling gate that replaces a bare ModelAvailable
// check. It reports whether the model may take a request right now and whether
// doing so is a cooldown PROBE rather than normal traffic:
//
// - (true, false): fully available, schedule normally.
// - (true, true): cooling, but past the half-cooldown mark and this caller
// claimed the probe permit. The caller MUST call ProbeDone(model) once the
// attempt finished, and SHOULD prefer non-probe candidates first.
// - (false, false): cooling and not probeable (first half of the window, the
// permit is taken, or the slot is persistently bad).
//
// Probing is what stops a recovered upstream from sitting out a whole cooldown:
// the probe is a real request, so success immediately clears the cooldown via
// RecordSuccess and the slot is back in full rotation.
func (p *Provider) ModelSchedulable(model string) (ok bool, isProbe bool) {
p.mu.Lock()
s, known := p.states[model]
p.mu.Unlock()
if !known {
return true, false
}
if s.Available() && s.Pref() > prefMin {
return true, false
}
if s.TryProbe() {
return true, true
}
return false, false
}
// ProbeDone releases a probe permit claimed through ModelSchedulable.
func (p *Provider) ProbeDone(model string) { p.state(model).ProbeDone() }
// ProbeSlots is how many concurrent probe requests this source tolerates while
// a model cools: one per 10 configured concurrency slots, at least 1 and never
// more than 2. A source configured for max_concurrent=10 therefore lets exactly
// one request through to probe, which is the intended "small-scale probe"
// behaviour; a large source still cannot flood a cooling upstream.
func (p *Provider) ProbeSlots() int {
n := p.cfg.MaxConcurrent / 10
if n < 1 {
return 1
}
if n > 2 {
return 2
}
return n
}
// Pref returns the adaptive preference score of a model (higher = preferred
// within a priority tier). Used by the AUTO chain to order same-tier slots.
func (p *Provider) Pref(model string) int64 {
@ -393,6 +559,19 @@ func (p *Provider) ModelHealthInfo(model string) (pref, failCount, cooldownUntil
return s.Pref(), s.FailCount(), s.CooldownUntil()
}
// ModelProbeInfo exposes the probe-gate state for the web UI: when the current
// cooldown window opened, when probing becomes allowed (its midpoint), and
// whether a probe is in flight right now. All zero when not cooling.
func (p *Provider) ModelProbeInfo(model string) (from, probeAfter int64, probing bool) {
s := p.state(model)
until := s.CooldownUntil()
from = s.CooldownFrom()
if until <= 0 || from <= 0 || from >= until {
return 0, 0, s.Probing()
}
return from, from + (until-from)/2, s.Probing()
}
// Probe performs a lightweight reachability + auth check against the source.
// It first tries GET <base>/models (fast, ~1s for OpenAI-compatible upstreams)
// and only falls back to a 1-token chat call when that endpoint is
@ -518,17 +697,33 @@ func (p *Provider) RecordSuccess(model string) {
}
// ReportStatus records an upstream HTTP status for the given model and drives
// the (source, model) backoff state. 401/403 → capped self-healing cooldown
// the (source, model) backoff state. 401/403 → long self-healing authCooldown
// with doubled penalty; 5xx → normal exponential backoff; 429 → short fixed
// cooldown (rateLimitCooldown): a rate rejection means "too fast", not
// "broken", so the slot must re-enter rotation quickly instead of escalating
// to a 30-minute lockout that wastes remaining quota.
// to a long lockout that wastes remaining quota.
//
// Prefer ReportStatusReason when the upstream error text is available: a
// quota-exhausted rejection is cooled to its window boundary instead of being
// treated as a failure streak.
func (p *Provider) ReportStatus(model string, code int) {
p.ReportStatusReason(model, code, "")
}
// ReportStatusReason is ReportStatus plus the upstream error text, which lets a
// quota/allowance rejection be distinguished from a generic rate limit. Quota
// exhaustion is not an error streak: it is cooled until the allowance window
// can plausibly have reset, and never inflates the exponential ladder.
func (p *Provider) ReportStatusReason(model string, code int, reason string) {
if code == 401 || code == 403 {
p.RecordFailure(model, code)
return
}
if code == 429 {
if code == 429 || code == 402 {
if quotaExhaustedReason(reason) {
p.state(model).RecordQuotaExhausted(p.quotaResetIn(model))
return
}
p.state(model).RecordRateLimit()
return
}
@ -537,6 +732,82 @@ func (p *Provider) ReportStatus(model string, code int) {
}
}
// quotaResetIn estimates how long the model's allowance window still has to
// run. Upstreams do not report this, so it is taken from the source's optional
// `quota_hours` meta hint when present (aligned to the epoch, matching the
// gateway's own quota-window convention) and otherwise falls back to
// quotaDefaultCooldown. Either way the value is capped by maxQuotaCooldown, so
// a wrong hint can only delay recovery to that bound — and the half-cooldown
// probe fires at half of it.
func (p *Provider) quotaResetIn(model string) time.Duration {
hours := p.quotaHours()
if hours <= 0 {
return quotaDefaultCooldown
}
win := int64(hours * float64(time.Hour/time.Second))
if win <= 0 {
return quotaDefaultCooldown
}
elapsed := time.Now().Unix() % win
return time.Duration(win-elapsed) * time.Second
}
// quotaHours reads the optional per-source `quota_hours` meta hint describing
// how long its allowance window is. Absent or unparseable = 0 (unknown).
func (p *Provider) quotaHours() float64 {
v, ok := p.cfg.Meta["quota_hours"]
if !ok {
return 0
}
switch n := v.(type) {
case float64:
return n
case int:
return float64(n)
case int64:
return float64(n)
case string:
f, err := strconv.ParseFloat(strings.TrimSpace(n), 64)
if err != nil {
return 0
}
return f
}
return 0
}
// quotaKeywords are the upstream phrases that mean "you are out of allowance"
// rather than "you are going too fast". Matching is case-insensitive substring
// matching against the adapter-normalized error reason.
var quotaKeywords = []string{
"insufficient_quota",
"insufficient quota",
"quota exceeded",
"quota_exceeded",
"exceeded your current quota",
"out of credit",
"insufficient balance",
"insufficient_user_quota",
"billing_hard_limit_reached",
"credit balance is too low",
"余额不足",
"额度不足",
"额度已用完",
}
func quotaExhaustedReason(reason string) bool {
if reason == "" {
return false
}
low := strings.ToLower(reason)
for _, k := range quotaKeywords {
if strings.Contains(low, k) {
return true
}
}
return false
}
// ResetHealth resets the scheduling state of every model of this source
// (cooldown and preference to zero), so the source becomes fully schedulable
// again. Called after AUTO-chain edits and from the admin UI.
@ -717,8 +988,9 @@ func (p *Provider) Chat(ctx context.Context, req *types.ChatRequest) (*types.Uni
return nil, err
}
if status != 200 {
p.ReportStatus(model, status)
return nil, fmt.Errorf("%s", p.apiErrReason(status, raw))
reason := p.apiErrReason(status, raw)
p.ReportStatusReason(model, status, reason)
return nil, fmt.Errorf("%s", reason)
}
unified, err := p.vm.Transform(p.adapter, "transform_response", raw)
if err != nil {
@ -792,9 +1064,10 @@ func (p *Provider) ChatStream(ctx context.Context, req *types.ChatRequest) (<-ch
if sel.resp.StatusCode != 200 {
raw, _ := io.ReadAll(sel.resp.Body)
sel.resp.Body.Close()
p.ReportStatus(model, sel.resp.StatusCode)
reason := p.apiErrReason(sel.resp.StatusCode, string(raw))
p.ReportStatusReason(model, sel.resp.StatusCode, reason)
p.Release()
return nil, fmt.Errorf("%s", p.apiErrReason(sel.resp.StatusCode, string(raw)))
return nil, fmt.Errorf("%s", reason)
}
go func() {
defer p.Release()
@ -1043,8 +1316,9 @@ func (p *Provider) Image(ctx context.Context, req *types.ImageGenRequest) (*type
return nil, err
}
if status != 200 {
p.ReportStatus(model, status)
return nil, fmt.Errorf("%s", p.apiErrReason(status, raw))
reason := p.apiErrReason(status, raw)
p.ReportStatusReason(model, status, reason)
return nil, fmt.Errorf("%s", reason)
}
var out types.UnifiedResponse
// try adapter transform_response; if missing, parse standard openai image format

View File

@ -198,8 +198,8 @@ func TestProviderAuthFailureSelfHeals(t *testing.T) {
if st.FailCount() != backoffCapN {
t.Fatalf("auth failure must jump to capped count, got %d", st.FailCount())
}
if until := st.CooldownUntil(); until <= time.Now().Add(25*time.Minute).Unix() {
t.Fatalf("auth failure must cool near the cap (until=%d)", until)
if until := st.CooldownUntil(); until <= time.Now().Add(authCooldown-time.Minute).Unix() {
t.Fatalf("auth failure must cool for authCooldown (until=%d)", until)
}
if st.Pref() != -2*int64(prefFailStep) {
t.Fatalf("auth failure pref penalty must be doubled, got %d", st.Pref())
@ -678,3 +678,369 @@ func TestToolIndexRemapper(t *testing.T) {
}
})
}
// ---- half-cooldown probe gate (plan 阶段 1) ----
// TestProbeGateHalfCooldown pins the core rule: a cooling model is fully silent
// in the first half of its cooldown window and probeable exactly once in the
// second half.
func TestProbeGateHalfCooldown(t *testing.T) {
var st ModelState
// open a 100s window centred so that "now" sits in the first half
now := time.Now().Unix()
st.cooldownFrom.Store(now - 10)
st.cooldownUntil.Store(now + 90)
if st.TryProbe() {
t.Fatal("first half of the cooldown window must stay silent")
}
// move into the second half
st.cooldownFrom.Store(now - 90)
st.cooldownUntil.Store(now + 10)
if !st.TryProbe() {
t.Fatal("second half of the cooldown window must allow one probe")
}
if st.TryProbe() {
t.Fatal("probe permit must be single-token")
}
if !st.Probing() {
t.Fatal("Probing() must report the claimed permit")
}
st.ProbeDone()
if st.Probing() {
t.Fatal("ProbeDone must release the permit")
}
if !st.TryProbe() {
t.Fatal("permit must be reusable after ProbeDone")
}
}
// TestProbeGateNoProbeWhenHealthy: a healthy model needs no probe permit — the
// caller schedules it normally.
func TestProbeGateNoProbeWhenHealthy(t *testing.T) {
var idle ModelState
if idle.TryProbe() {
t.Fatal("a healthy model must not be probed (schedule it normally)")
}
}
// TestProbeGateRescuesPrefFloor documents a fix that comes with the probe gate:
// a slot whose preference sank to prefMin used to be refused by ModelAvailable
// forever, even long after every cooldown expired — a permanent blacklist. It is
// now probeable, so one success brings it back.
func TestProbeGateRescuesPrefFloor(t *testing.T) {
var dead ModelState
dead.pref.Store(prefMin)
if !dead.TryProbe() {
t.Fatal("a slot stuck at prefMin must be probeable, not blacklisted")
}
dead.RecordSuccess()
dead.ProbeDone()
if dead.Pref() <= prefMin {
t.Fatalf("one probe success must lift the slot off the floor, pref=%d", dead.Pref())
}
}
// TestProbeSuccessRestoresImmediately is the user-visible payoff: a recovered
// upstream must not sit out the rest of its cooldown.
func TestProbeSuccessRestoresImmediately(t *testing.T) {
var st ModelState
for i := 0; i < backoffCapN; i++ {
st.RecordFailure(false)
}
if cd := st.CooldownUntil() - time.Now().Unix(); cd < int64(backoffCap.Seconds())-2 {
t.Fatalf("10 failures must cool for backoffCap, got %ds", cd)
}
// jump into the probe window: the midpoint of [from, until) must be in the
// past, so push the window's start back past its full length
now := time.Now().Unix()
st.cooldownFrom.Store(now - int64(backoffCap.Seconds()) - 10)
if !st.TryProbe() {
t.Fatal("probe must be allowed past the midpoint")
}
if !st.Probing() {
t.Fatal("probe permit must be held while the probe runs")
}
st.RecordSuccess()
st.ProbeDone()
if !st.Available() {
t.Fatal("probe success must clear the cooldown immediately")
}
if st.FailCount() != 0 {
t.Fatalf("probe success must reset failCount, got %d", st.FailCount())
}
if st.CooldownUntil() != 0 || st.CooldownFrom() != 0 {
t.Fatal("probe success must clear both cooldown bounds")
}
}
// TestProbeFailureDefersNextProbe: a failed probe must not immediately retry —
// the new window's midpoint pushes the next probe out.
func TestProbeFailureDefersNextProbe(t *testing.T) {
var st ModelState
st.RecordFailure(false) // 5s window
now := time.Now().Unix()
st.cooldownFrom.Store(now - 100)
st.cooldownUntil.Store(now + 10)
if !st.TryProbe() {
t.Fatal("probe must be allowed past the midpoint")
}
st.RecordFailure(false) // probe failed: a fresh, longer window opens
st.ProbeDone()
if st.TryProbe() {
t.Fatal("a failed probe must defer the next probe to the new midpoint")
}
if from := st.CooldownFrom(); from < now {
t.Fatalf("failed probe must reopen the window (from=%d, now=%d)", from, now)
}
}
// TestAuthCooldownIsSeparateFromCap documents that credential failures cool on
// their own (longer) schedule instead of reusing the exponential cap.
func TestAuthCooldownIsSeparateFromCap(t *testing.T) {
var st ModelState
st.RecordFailure(true)
cd := st.CooldownUntil() - time.Now().Unix()
if cd < int64(authCooldown.Seconds())-2 || cd > int64(authCooldown.Seconds())+2 {
t.Fatalf("auth failure must cool for authCooldown (%v), got %ds", authCooldown, cd)
}
if st.FailCount() != backoffCapN {
t.Fatalf("auth failure must persist the capped count, got %d", st.FailCount())
}
}
// TestQuotaExhaustedDoesNotEscalate is the second half of the user's complaint:
// running out of allowance must not be treated as a failure streak.
func TestQuotaExhaustedDoesNotEscalate(t *testing.T) {
var st ModelState
st.RecordQuotaExhausted(2 * time.Minute)
if st.FailCount() != 0 {
t.Fatalf("quota exhaustion must not bump failCount, got %d", st.FailCount())
}
cd := st.CooldownUntil() - time.Now().Unix()
if cd < 118 || cd > 122 {
t.Fatalf("quota cooldown must honour the reset window, got %ds", cd)
}
// an unbounded window is clamped so a topped-up allowance is still noticed
var big ModelState
big.RecordQuotaExhausted(72 * time.Hour)
if cd := big.CooldownUntil() - time.Now().Unix(); cd > int64(maxQuotaCooldown.Seconds())+2 {
t.Fatalf("quota cooldown must be capped at maxQuotaCooldown, got %ds", cd)
}
// no hint at all falls back to the short window
var none ModelState
none.RecordQuotaExhausted(0)
if cd := none.CooldownUntil() - time.Now().Unix(); cd > int64(rateLimitCooldown.Seconds())+2 {
t.Fatalf("unknown reset window must fall back to rateLimitCooldown, got %ds", cd)
}
}
// TestQuotaReasonRouting checks that a 429 carrying a quota phrase is cooled as
// quota exhaustion while a plain rate limit keeps the short fixed window.
func TestQuotaReasonRouting(t *testing.T) {
p := newTestProvider(t, src("q", "http://127.0.0.1:1", "openai", "m"))
p.ReportStatusReason("m", 429, "429 insufficient_quota: you exceeded your current quota")
st := p.state("m")
if st.FailCount() != 0 {
t.Fatalf("quota rejection must not bump failCount, got %d", st.FailCount())
}
if cd := st.CooldownUntil() - time.Now().Unix(); cd <= int64(rateLimitCooldown.Seconds()) {
t.Fatalf("quota rejection must cool longer than a plain 429, got %ds", cd)
}
p2 := newTestProvider(t, src("r", "http://127.0.0.1:1", "openai", "m"))
p2.ReportStatusReason("m", 429, "rate limit reached, slow down")
st2 := p2.state("m")
if cd := st2.CooldownUntil() - time.Now().Unix(); cd > int64(rateLimitCooldown.Seconds())+2 {
t.Fatalf("plain 429 must keep the short window, got %ds", cd)
}
}
// TestModelSchedulableProbeHandoff covers the provider-level gate the scheduler
// consumes.
func TestModelSchedulableProbeHandoff(t *testing.T) {
p := newTestProvider(t, src("s", "http://127.0.0.1:1", "openai", "m"))
if ok, probe := p.ModelSchedulable("m"); !ok || probe {
t.Fatalf("healthy model must be normally schedulable, got ok=%v probe=%v", ok, probe)
}
st := p.state("m")
st.RecordFailure(false)
now := time.Now().Unix()
st.cooldownFrom.Store(now - 100)
st.cooldownUntil.Store(now + 10)
ok, probe := p.ModelSchedulable("m")
if !ok || !probe {
t.Fatalf("cooling model past midpoint must be probeable, got ok=%v probe=%v", ok, probe)
}
if ok2, _ := p.ModelSchedulable("m"); ok2 {
t.Fatal("only one probe permit may be outstanding")
}
p.ProbeDone("m")
if ok3, probe3 := p.ModelSchedulable("m"); !ok3 || !probe3 {
t.Fatal("permit must be reclaimable after ProbeDone")
}
}
// TestProbeSlotsFollowsMaxConcurrent pins the user's example: max_concurrent=10
// yields exactly one probe slot.
func TestProbeSlotsFollowsMaxConcurrent(t *testing.T) {
for _, tc := range []struct{ mc, want int }{
{0, 1}, {1, 1}, {4, 1}, {10, 1}, {20, 2}, {100, 2},
} {
s := src("p", "http://127.0.0.1:1", "openai", "m")
s.MaxConcurrent = tc.mc
p := newTestProvider(t, s)
if got := p.ProbeSlots(); got != tc.want {
t.Fatalf("max_concurrent=%d: probe slots = %d, want %d", tc.mc, got, tc.want)
}
}
}
// TestModelProbeInfo checks the UI-facing view of the probe window.
func TestModelProbeInfo(t *testing.T) {
p := newTestProvider(t, src("i", "http://127.0.0.1:1", "openai", "m"))
if from, after, probing := p.ModelProbeInfo("m"); from != 0 || after != 0 || probing {
t.Fatalf("idle model must report no probe window, got %d %d %v", from, after, probing)
}
st := p.state("m")
now := time.Now().Unix()
st.cooldownFrom.Store(now)
st.cooldownUntil.Store(now + 100)
from, after, _ := p.ModelProbeInfo("m")
if from != now || after != now+50 {
t.Fatalf("probe window = (%d,%d), want (%d,%d)", from, after, now, now+50)
}
}
// TestUpstreamRecoveryWithoutWaitingOutCooldown is the end-to-end shape of the
// user's complaint: an upstream that 500s ten times lands in the capped
// cooldown, and used to be unusable for a full 30 minutes even after it
// recovered. With the probe gate the slot is silent for the first half of the
// window and then serves the very next request itself, clearing the cooldown.
//
// Real time is not waited out: the window bounds are moved so the test observes
// the same state the scheduler would see at cap/2.
func TestUpstreamRecoveryWithoutWaitingOutCooldown(t *testing.T) {
var healthy atomic.Bool
var hits atomic.Int64
up := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
hits.Add(1)
if !healthy.Load() {
w.WriteHeader(500)
return
}
fmt.Fprint(w, `{"choices":[{"message":{"content":"back"},"finish_reason":"stop"}]}`)
}))
defer up.Close()
p := newTestProvider(t, src("recov", up.URL, "openai", "m"))
req := func() *types.ChatRequest {
return &types.ChatRequest{
Model: "m",
Messages: []types.ChatMessage{{Role: "user", Content: types.StringContent("x")}},
}
}
// 1. drive the slot into the capped cooldown
for i := 0; i < backoffCapN; i++ {
if _, err := p.Chat(context.Background(), req()); err == nil {
t.Fatalf("attempt %d: expected upstream failure", i)
}
}
st := p.state("m")
if p.ModelAvailable("m") {
t.Fatal("10 consecutive 5xx must take the slot out of normal rotation")
}
cd := st.CooldownUntil() - time.Now().Unix()
if cd < int64(backoffCap.Seconds())-2 {
t.Fatalf("cooldown = %ds, want ~%v", cd, backoffCap)
}
// the old 30-minute lockout is gone
if cd > int64((6 * time.Minute).Seconds()) {
t.Fatalf("cooldown %ds is too long: the cap must be minutes, not half an hour", cd)
}
// 2. upstream recovers, but we are still in the FIRST half of the window:
// the slot must stay completely silent (no probe traffic at all).
healthy.Store(true)
quiet := hits.Load()
if ok, probe := p.ModelSchedulable("m"); ok || probe {
t.Fatalf("first half of the window must stay silent, got ok=%v probe=%v", ok, probe)
}
if hits.Load() != quiet {
t.Fatal("no request may reach the upstream during the silent half")
}
// 3. advance past the midpoint: exactly one probe is admitted.
st.cooldownFrom.Store(time.Now().Unix() - int64(backoffCap.Seconds()) - 1)
ok, isProbe := p.ModelSchedulable("m")
if !ok || !isProbe {
t.Fatalf("past the midpoint one probe must be admitted, got ok=%v probe=%v", ok, isProbe)
}
if ok2, _ := p.ModelSchedulable("m"); ok2 {
t.Fatal("only ONE probe may be in flight while cooling")
}
// 4. the probe is a real request: its success restores the slot fully.
resp, err := p.Chat(context.Background(), req())
p.ProbeDone("m")
if err != nil {
t.Fatalf("probe request failed: %v", err)
}
if resp.Content != "back" {
t.Fatalf("probe response = %q, want \"back\"", resp.Content)
}
if !p.ModelAvailable("m") {
t.Fatal("a successful probe must return the slot to normal rotation immediately")
}
if st.CooldownUntil() != 0 || st.FailCount() != 0 {
t.Fatalf("probe success must clear cooldown/failCount, got until=%d fails=%d",
st.CooldownUntil(), st.FailCount())
}
if ok, probe := p.ModelSchedulable("m"); !ok || probe {
t.Fatalf("recovered slot must schedule normally, got ok=%v probe=%v", ok, probe)
}
}
// TestRecoveryProbeBudgetIsBounded quantifies the cost of the shorter cap
// against a still-dead upstream: one probe per window, not a retry storm.
func TestRecoveryProbeBudgetIsBounded(t *testing.T) {
var hits atomic.Int64
up := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
hits.Add(1)
w.WriteHeader(500)
}))
defer up.Close()
p := newTestProvider(t, src("dead", up.URL, "openai", "m"))
req := &types.ChatRequest{
Model: "m",
Messages: []types.ChatMessage{{Role: "user", Content: types.StringContent("x")}},
}
for i := 0; i < backoffCapN; i++ {
_, _ = p.Chat(context.Background(), req)
}
baseline := hits.Load()
st := p.state("m")
// simulate three consecutive probe windows against a dead upstream
for i := 0; i < 3; i++ {
st.cooldownFrom.Store(time.Now().Unix() - int64(backoffCap.Seconds()) - 1)
ok, isProbe := p.ModelSchedulable("m")
if !ok || !isProbe {
t.Fatalf("window %d: expected a probe permit", i)
}
// a second caller in the same window gets nothing
if ok2, _ := p.ModelSchedulable("m"); ok2 {
t.Fatalf("window %d: more than one probe admitted", i)
}
if _, err := p.Chat(context.Background(), req); err == nil {
t.Fatalf("window %d: expected the probe to fail", i)
}
p.ProbeDone("m")
}
if extra := hits.Load() - baseline; extra != 3 {
t.Fatalf("3 probe windows must cost exactly 3 upstream requests, got %d", extra)
}
}

View File

@ -42,6 +42,12 @@ type Provider interface {
Name() string
ModelFor(reqModel string) string
ModelAvailable(model string) bool
// ModelSchedulable is the probe-aware availability gate: ok reports
// whether the model may take a request now, isProbe marks that it is only
// allowed as a cooldown probe (the caller must release the permit with
// ProbeDone once the attempt finished).
ModelSchedulable(model string) (ok bool, isProbe bool)
ProbeDone(model string)
Pref(model string) int64
Chat(ctx context.Context, req *types.ChatRequest) (*types.UnifiedResponse, error)
ChatStream(ctx context.Context, req *types.ChatRequest) (<-chan types.UnifiedChunk, error)
@ -176,18 +182,84 @@ type tierResult struct {
hard []TierError // hard failures seen in this pass (nil = none)
}
// candidate is one schedulable slot in a tier pass. probe marks a slot that is
// still cooling but past its half-cooldown mark and holding the probe permit:
// it is tried only after every normal slot, so probe traffic is what the tier
// falls back to instead of what it prefers.
type candidate struct {
slot *Slot
probe bool
}
// collectCands partitions a tier's slots into normal and probe candidates,
// normal first. Quota-exhausted slots are dropped outright. Every probe
// candidate returned holds a probe permit, so the caller MUST call
// releaseProbes on the result exactly once.
func collectCands(slots []*Slot, exhausted func(*Slot) bool) []candidate {
var normal, probes []candidate
for _, sl := range slots {
if exhausted != nil && exhausted(sl) {
continue
}
ok, isProbe := sl.Prov.ModelSchedulable(sl.Model)
if !ok {
continue
}
if isProbe {
probes = append(probes, candidate{slot: sl, probe: true})
continue
}
normal = append(normal, candidate{slot: sl})
}
return append(normal, probes...)
}
// releaseProbes hands every claimed probe permit back, whether or not the probe
// slot was actually used.
func releaseProbes(cands []candidate) {
for _, c := range cands {
if c.probe {
c.slot.Prov.ProbeDone(c.slot.Model)
}
}
}
// normalCount is how many leading candidates are normal (non-probe). The
// round-robin cursor rotates only over those: probe slots are a strictly
// ordered tail, never a rotation target.
func normalCount(cands []candidate) int {
for i, c := range cands {
if c.probe {
return i
}
}
return len(cands)
}
// runTier executes one tier pass starting at the round-robin base index.
// Cooldown is the only hard skip (re-verified per slot); a busy slot is
// skipped without any penalty; a hard failure is recorded and the pass moves
// on to the next slot (plan 2.3: "单请求内不重试已失败槽" — the failed slot is
// not retried, the others still are). hard == nil and no success means every
// candidate was merely busy/cooling, so the caller may wait a bounded time.
func runTier(ctx context.Context, tn *TierNode, cands []*Slot, base int64, req *types.ChatRequest, stream bool) tierResult {
//
// Normal candidates rotate by base; probe candidates form a fixed tail tried
// only after every normal slot failed or was busy.
func runTier(ctx context.Context, tn *TierNode, cands []candidate, base int64, req *types.ChatRequest, stream bool) tierResult {
n := len(cands)
norm := normalCount(cands)
var hard []TierError
for i := 0; i < n; i++ {
sl := cands[(int(base)+i)%n]
if !sl.Prov.ModelAvailable(sl.Model) {
var c candidate
if i < norm {
c = cands[(int(base)+i)%norm] // rotate within the normal head
} else {
c = cands[i] // probe tail keeps its order
}
sl := c.slot
// A probe candidate is intentionally NOT re-checked here: it is cooling
// by definition, and its permit was already claimed.
if !c.probe && !sl.Prov.ModelAvailable(sl.Model) {
continue
}
r := *req
@ -233,75 +305,40 @@ func (s *Scheduler) chainDrive(ctx context.Context, chain *Chain, req *types.Cha
}
var ce ChainErr
for _, tn := range chain.Tiers {
// initial filter: quota-exhausted and cooling slots are dropped
var cands []*Slot
for _, sl := range tn.Slots {
if exhausted != nil && exhausted(sl) {
continue
}
if !sl.Prov.ModelAvailable(sl.Model) {
continue
}
cands = append(cands, sl)
}
// initial filter: quota-exhausted slots are dropped, cooling slots are
// dropped unless they qualify as half-cooldown probes (appended last).
cands := collectCands(tn.Slots, exhausted)
if len(cands) == 0 {
ce.Skipped = append(ce.Skipped, fmt.Sprintf("tier %d: no schedulable slot (cooling or quota exhausted)", tn.Tier))
continue
}
// No Pref sort: load balancing is done by round-robin cursor.
// Persistently failing slots are excluded by ModelAvailable
// Persistently failing slots are excluded by ModelSchedulable
// (which checks Pref > prefMin).
base := tn.NextStart()
res := runTier(ctx, tn, cands, base, req, stream)
if res.resp != nil || res.chunks != nil {
releaseProbes(cands)
return res.resp, res.chunks, res.src, res.model, nil
}
if ctx.Err() != nil {
releaseProbes(cands)
return nil, nil, "", "", ctx.Err()
}
if len(res.hard) > 0 {
ce.Tiers = append(ce.Tiers, res.hard...)
releaseProbes(cands)
continue // hard failures: fall through to the next tier, no waiting
}
// every candidate was busy or cooling: bounded poll before downgrading
deadline := time.Now().Add(busyWait)
timer := time.NewTimer(busyPoll)
defer timer.Stop()
for {
select {
case <-ctx.Done():
return nil, nil, "", "", ctx.Err()
case <-timer.C:
if err := s.pollBusyTier(ctx, tn, cands, base, req, stream, &ce); err != nil {
releaseProbes(cands)
if r, ok := err.(*tierSuccess); ok {
return r.res.resp, r.res.chunks, r.res.src, r.res.model, nil
}
done := time.Now().After(deadline)
if done {
ce.Skipped = append(ce.Skipped, fmt.Sprintf("tier %d: no free slot within %v", tn.Tier, busyWait))
break
}
// refresh candidates: cooldowns may have expired meanwhile
var again []*Slot
for _, sl := range cands {
if sl.Prov.ModelAvailable(sl.Model) {
again = append(again, sl)
}
}
if len(again) == 0 {
ce.Skipped = append(ce.Skipped, fmt.Sprintf("tier %d: no free slot within %v", tn.Tier, busyWait))
break
}
res = runTier(ctx, tn, again, base, req, stream)
if res.resp != nil || res.chunks != nil {
return res.resp, res.chunks, res.src, res.model, nil
}
if ctx.Err() != nil {
return nil, nil, "", "", ctx.Err()
}
if len(res.hard) > 0 {
ce.Tiers = append(ce.Tiers, res.hard...)
break // hard failure while waiting: stop waiting, fall through
}
timer.Reset(busyPoll)
return nil, nil, "", "", err
}
releaseProbes(cands)
}
if len(ce.Tiers) == 0 && len(ce.Skipped) == 0 {
return nil, nil, "", "", fmt.Errorf("no auto slot configured")
@ -309,6 +346,57 @@ func (s *Scheduler) chainDrive(ctx context.Context, chain *Chain, req *types.Cha
return nil, nil, "", "", &ce
}
// tierSuccess carries a successful result out of pollBusyTier through the error
// return. It is never surfaced to callers of chainDrive.
type tierSuccess struct{ res tierResult }
func (t *tierSuccess) Error() string { return "tier success" }
// pollBusyTier waits a bounded time for a fully-busy tier to free a slot,
// retrying the pass while cooldowns expire. It returns nil when the tier should
// be abandoned (caller falls through to the next tier), a *tierSuccess when a
// retry succeeded, or a context error.
func (s *Scheduler) pollBusyTier(ctx context.Context, tn *TierNode, cands []candidate, base int64, req *types.ChatRequest, stream bool, ce *ChainErr) error {
deadline := time.Now().Add(busyWait)
timer := time.NewTimer(busyPoll)
defer timer.Stop()
for {
select {
case <-ctx.Done():
return ctx.Err()
case <-timer.C:
}
if time.Now().After(deadline) {
ce.Skipped = append(ce.Skipped, fmt.Sprintf("tier %d: no free slot within %v", tn.Tier, busyWait))
return nil
}
// refresh candidates: cooldowns may have expired meanwhile. Probe
// candidates keep their already-claimed permit and stay eligible.
var again []candidate
for _, c := range cands {
if c.probe || c.slot.Prov.ModelAvailable(c.slot.Model) {
again = append(again, c)
}
}
if len(again) == 0 {
ce.Skipped = append(ce.Skipped, fmt.Sprintf("tier %d: no free slot within %v", tn.Tier, busyWait))
return nil
}
res := runTier(ctx, tn, again, base, req, stream)
if res.resp != nil || res.chunks != nil {
return &tierSuccess{res: res}
}
if ctx.Err() != nil {
return ctx.Err()
}
if len(res.hard) > 0 {
ce.Tiers = append(ce.Tiers, res.hard...)
return nil // hard failure while waiting: stop waiting, fall through
}
timer.Reset(busyPoll)
}
}
// ChainChat runs a non-streaming AUTO request down the chain. exhausted, when
// non-nil, decides slot token-quota exhaustion. Returns the response, the
// serving source and the exact model id used; on total failure a *ChainErr
@ -338,32 +426,35 @@ func (s *Scheduler) ChainImage(ctx context.Context, chain *Chain, req *types.Ima
}
var ce ChainErr
for _, tn := range chain.Tiers {
var cands []*Slot
for _, sl := range tn.Slots {
if !sl.Prov.ModelAvailable(sl.Model) {
continue
}
cands = append(cands, sl)
}
cands := collectCands(tn.Slots, nil)
if len(cands) == 0 {
ce.Skipped = append(ce.Skipped, fmt.Sprintf("tier %d: no schedulable slot (cooling)", tn.Tier))
continue
}
// No Pref sort: load balancing is done by round-robin cursor.
base := tn.NextStart()
norm := normalCount(cands)
var hard []TierError
for i := 0; i < len(cands); i++ {
sl := cands[(int(base)+i)%len(cands)]
if !sl.Prov.ModelAvailable(sl.Model) {
var c candidate
if i < norm {
c = cands[(int(base)+i)%norm]
} else {
c = cands[i]
}
sl := c.slot
if !c.probe && !sl.Prov.ModelAvailable(sl.Model) {
continue
}
r := *req
r.Model = sl.Model
resp, err := sl.Prov.Image(ctx, &r)
if ctx.Err() != nil {
releaseProbes(cands)
return nil, "", "", ctx.Err()
}
if err == nil {
releaseProbes(cands)
return resp, sl.Source, sl.Model, nil
}
if errors.Is(err, types.ErrBusy) {
@ -371,6 +462,7 @@ func (s *Scheduler) ChainImage(ctx context.Context, chain *Chain, req *types.Ima
}
hard = append(hard, TierError{Tier: tn.Tier, Source: sl.Source, Model: sl.Model, Err: err})
}
releaseProbes(cands)
if len(hard) > 0 {
ce.Tiers = append(ce.Tiers, hard...)
}
@ -399,11 +491,15 @@ func (s *Scheduler) Chat(ctx context.Context, cands []Provider, req *types.ChatR
p := cands[i]
r := *req
r.Model = p.ModelFor(req.Model)
if !p.ModelAvailable(r.Model) {
ok, isProbe := p.ModelSchedulable(r.Model)
if !ok {
lastErr = fmt.Errorf("provider %s: model %q cooling down", p.Name(), r.Model)
continue
}
resp, err := p.Chat(ctx, &r)
if isProbe {
p.ProbeDone(r.Model)
}
if ctx.Err() != nil {
return nil, "", "", ctx.Err()
}
@ -431,11 +527,15 @@ func (s *Scheduler) ChatStream(ctx context.Context, cands []Provider, req *types
p := cands[i]
r := *req
r.Model = p.ModelFor(req.Model)
if !p.ModelAvailable(r.Model) {
ok, isProbe := p.ModelSchedulable(r.Model)
if !ok {
lastErr = fmt.Errorf("provider %s: model %q cooling down", p.Name(), r.Model)
continue
}
resp, err := p.ChatStream(ctx, &r)
if isProbe {
p.ProbeDone(r.Model)
}
if err == nil {
return resp, p.Name(), r.Model, nil
}
@ -454,11 +554,16 @@ func (s *Scheduler) Image(ctx context.Context, cands []Provider, req *types.Imag
var lastErr error
for i := 0; i < attempts && i < len(cands); i++ {
p := cands[i]
if !p.ModelAvailable(p.ModelFor(req.Model)) {
lastErr = fmt.Errorf("provider %s: model %q cooling down", p.Name(), p.ModelFor(req.Model))
im := p.ModelFor(req.Model)
ok, isProbe := p.ModelSchedulable(im)
if !ok {
lastErr = fmt.Errorf("provider %s: model %q cooling down", p.Name(), im)
continue
}
resp, err := p.Image(ctx, req)
if isProbe {
p.ProbeDone(im)
}
if err == nil {
return resp, p.Name(), nil
}

View File

@ -22,6 +22,14 @@ type fakeProvider struct {
busy atomic.Bool
fail atomic.Bool
chatHits atomic.Int64
// probeable makes an unavailable provider eligible as a half-cooldown
// probe candidate. probePermit is the single-token permit; probeClaims
// counts how many times it was handed out and probeDones how many times it
// was returned, so tests can assert the permit is always released.
probeable atomic.Bool
probePermit atomic.Bool
probeClaims atomic.Int64
probeDones atomic.Int64
}
func fakeProv(name, model string) *fakeProvider {
@ -36,6 +44,25 @@ func (f *fakeProvider) ModelFor(reqModel string) string { return f.model }
func (f *fakeProvider) ModelAvailable(model string) bool { return f.available.Load() }
func (f *fakeProvider) ModelSchedulable(model string) (bool, bool) {
if f.available.Load() {
return true, false
}
if !f.probeable.Load() {
return false, false
}
if f.probePermit.CompareAndSwap(false, true) {
f.probeClaims.Add(1)
return true, true
}
return false, false
}
func (f *fakeProvider) ProbeDone(model string) {
f.probeDones.Add(1)
f.probePermit.Store(false)
}
func (f *fakeProvider) Pref(model string) int64 { return f.pref.Load() }
func (f *fakeProvider) Chat(ctx context.Context, req *types.ChatRequest) (*types.UnifiedResponse, error) {
@ -322,3 +349,149 @@ func TestChainResetCooldownAfterSwap(t *testing.T) {
t.Fatalf("first start = %d, want 0", base)
}
}
// ---- half-cooldown probe candidates (plan 阶段 1.3) ----
// TestChainProbeIsLastResort pins the ordering rule: a probe candidate must not
// steal traffic from a healthy slot in the same tier.
func TestChainProbeIsLastResort(t *testing.T) {
healthy := fakeProv("healthy", "h")
cooling := fakeProv("cooling", "c")
cooling.available.Store(false)
cooling.probeable.Store(true)
ch := BuildChain([]Rule{
{Tier: 0, Model: "c", Source: "cooling"}, // configured FIRST on purpose
{Tier: 0, Model: "h", Source: "healthy"},
}, bySource(healthy, cooling))
s := New(0)
for i := 0; i < 3; i++ {
_, src, _, err := s.ChainChat(context.Background(), ch, chatReq(), nil)
if err != nil {
t.Fatalf("iter %d: %v", i, err)
}
if src != "healthy" {
t.Fatalf("iter %d served by %q; a probe slot must never outrank a healthy one", i, src)
}
}
if cooling.chatHits.Load() != 0 {
t.Fatalf("probe slot was hit %d times while a healthy slot existed", cooling.chatHits.Load())
}
if got := cooling.probeDones.Load(); got != cooling.probeClaims.Load() {
t.Fatalf("probe permits leaked: claims=%d dones=%d", cooling.probeClaims.Load(), got)
}
}
// TestChainProbeServesWhenNothingElseCan is the recovery path: with every normal
// slot gone, the cooling slot's probe carries the request instead of the tier
// failing outright.
func TestChainProbeServesWhenNothingElseCan(t *testing.T) {
cooling := fakeProv("cooling", "c")
cooling.available.Store(false)
cooling.probeable.Store(true)
ch := BuildChain([]Rule{{Tier: 0, Model: "c", Source: "cooling"}}, bySource(cooling))
s := New(0)
resp, src, model, err := s.ChainChat(context.Background(), ch, chatReq(), nil)
if err != nil {
t.Fatalf("probe must serve the request: %v", err)
}
if src != "cooling" || model != "c" {
t.Fatalf("served by src=%q model=%q, want cooling/c", src, model)
}
if resp == nil || resp.Content != "cooling" {
t.Fatalf("bad response: %#v", resp)
}
if cooling.chatHits.Load() != 1 {
t.Fatalf("probe must issue exactly one request, got %d", cooling.chatHits.Load())
}
if cooling.probeClaims.Load() != 1 || cooling.probeDones.Load() != 1 {
t.Fatalf("permit accounting: claims=%d dones=%d, want 1/1", cooling.probeClaims.Load(), cooling.probeDones.Load())
}
}
// TestChainProbePermitReleasedOnFailure: a failed probe must still hand its
// permit back, otherwise the slot could never be probed again.
func TestChainProbePermitReleasedOnFailure(t *testing.T) {
cooling := fakeProv("cooling", "c")
cooling.available.Store(false)
cooling.probeable.Store(true)
cooling.fail.Store(true)
ch := BuildChain([]Rule{{Tier: 0, Model: "c", Source: "cooling"}}, bySource(cooling))
s := New(0)
if _, _, _, err := s.ChainChat(context.Background(), ch, chatReq(), nil); err == nil {
t.Fatal("expected the failing probe to surface an error")
}
if cooling.probeClaims.Load() != 1 || cooling.probeDones.Load() != 1 {
t.Fatalf("permit accounting: claims=%d dones=%d, want 1/1", cooling.probeClaims.Load(), cooling.probeDones.Load())
}
// permit is free again for the next attempt
if _, _, _, err := s.ChainChat(context.Background(), ch, chatReq(), nil); err == nil {
t.Fatal("expected the second probe to fail too")
}
if cooling.probeClaims.Load() != 2 {
t.Fatalf("permit must be reclaimable, claims=%d", cooling.probeClaims.Load())
}
}
// TestChainProbeDoesNotBlockTierFallthrough: a cooling tier-1 slot that is not
// probeable must still let the request fall through to tier 2.
func TestChainProbeDoesNotBlockTierFallthrough(t *testing.T) {
cold := fakeProv("cold", "c")
cold.available.Store(false) // cooling and NOT probeable
backup := fakeProv("backup", "b")
ch := BuildChain([]Rule{
{Tier: 1, Model: "c", Source: "cold"},
{Tier: 2, Model: "b", Source: "backup"},
}, bySource(cold, backup))
s := New(0)
_, src, _, err := s.ChainChat(context.Background(), ch, chatReq(), nil)
if err != nil || src != "backup" {
t.Fatalf("want fallthrough to backup, got src=%q err=%v", src, err)
}
if cold.chatHits.Load() != 0 {
t.Fatal("non-probeable cooling slot must not be hit")
}
}
// TestDirectProbeReleasesPermit covers the non-AUTO paths (Chat/ChatStream/Image).
func TestDirectProbeReleasesPermit(t *testing.T) {
cooling := fakeProv("cooling", "m")
cooling.available.Store(false)
cooling.probeable.Store(true)
s := New(0)
_, src, _, err := s.Chat(context.Background(), []Provider{cooling}, chatReq())
if err != nil || src != "cooling" {
t.Fatalf("direct probe must serve: src=%q err=%v", src, err)
}
if cooling.probeClaims.Load() != 1 || cooling.probeDones.Load() != 1 {
t.Fatalf("permit accounting: claims=%d dones=%d", cooling.probeClaims.Load(), cooling.probeDones.Load())
}
}
// TestChainProbeRoundRobinUnaffected: adding a probe tail must not disturb the
// round-robin rotation over the healthy head.
func TestChainProbeRoundRobinUnaffected(t *testing.T) {
a, b := fakeProv("s1", "a"), fakeProv("s2", "b")
cooling := fakeProv("s3", "c")
cooling.available.Store(false)
cooling.probeable.Store(true)
ch := BuildChain([]Rule{
{Tier: 0, Model: "a", Source: "s1"},
{Tier: 0, Model: "b", Source: "s2"},
{Tier: 0, Model: "c", Source: "s3"},
}, bySource(a, b, cooling))
s := New(0)
var got []string
for i := 0; i < 4; i++ {
_, src, _, err := s.ChainChat(context.Background(), ch, chatReq(), nil)
if err != nil {
t.Fatalf("iter %d: %v", i, err)
}
got = append(got, src)
}
want := []string{"s1", "s2", "s1", "s2"}
for i := range want {
if got[i] != want[i] {
t.Fatalf("rr order = %v, want %v (probe tail must not join the rotation)", got, want)
}
}
}

View File

@ -19,15 +19,15 @@ var ErrBusy = errors.New("provider busy")
// ---- OpenAI wire request (gateway input) ----
type ChatRequest struct {
Model string `json:"model"`
Messages []ChatMessage `json:"messages"`
Temperature *float64 `json:"temperature,omitempty"`
MaxTokens int `json:"max_tokens,omitempty"`
Stream bool `json:"stream,omitempty"`
Tools []interface{} `json:"tools,omitempty"`
ToolChoice interface{} `json:"tool_choice,omitempty"`
DisableThinking bool `json:"disable_thinking"`
ExtraBody map[string]interface{} `json:"-"`
Model string `json:"model"`
Messages []ChatMessage `json:"messages"`
Temperature *float64 `json:"temperature,omitempty"`
MaxTokens int `json:"max_tokens,omitempty"`
Stream bool `json:"stream,omitempty"`
Tools []interface{} `json:"tools,omitempty"`
ToolChoice interface{} `json:"tool_choice,omitempty"`
DisableThinking bool `json:"disable_thinking"`
ExtraBody map[string]interface{} `json:"-"`
}
func (r *ChatRequest) MarshalJSON() ([]byte, error) {
@ -119,23 +119,23 @@ func (t TokenUsage) MarshalJSON() ([]byte, error) {
pdetails = &PromptTokensDetails{CachedTokens: t.PromptCacheHit}
}
return json.Marshal(struct {
PromptTokens int `json:"prompt_tokens"`
CompletionTokens int `json:"completion_tokens"`
TotalTokens int `json:"total_tokens"`
Prompt int `json:"prompt"`
Completion int `json:"completion"`
Total int `json:"total"`
PromptTokensDetails *PromptTokensDetails `json:"prompt_tokens_details,omitempty"`
PromptCacheHitTokens int `json:"prompt_cache_hit_tokens,omitempty"`
PromptCacheMissTokens int `json:"prompt_cache_miss_tokens,omitempty"`
PromptTokens int `json:"prompt_tokens"`
CompletionTokens int `json:"completion_tokens"`
TotalTokens int `json:"total_tokens"`
Prompt int `json:"prompt"`
Completion int `json:"completion"`
Total int `json:"total"`
PromptTokensDetails *PromptTokensDetails `json:"prompt_tokens_details,omitempty"`
PromptCacheHitTokens int `json:"prompt_cache_hit_tokens,omitempty"`
PromptCacheMissTokens int `json:"prompt_cache_miss_tokens,omitempty"`
}{
PromptTokens: t.Prompt,
CompletionTokens: t.Completion,
TotalTokens: t.Total,
Prompt: t.Prompt,
Completion: t.Completion,
Total: t.Total,
PromptTokensDetails: pdetails,
PromptTokens: t.Prompt,
CompletionTokens: t.Completion,
TotalTokens: t.Total,
Prompt: t.Prompt,
Completion: t.Completion,
Total: t.Total,
PromptTokensDetails: pdetails,
PromptCacheHitTokens: t.PromptCacheHit,
PromptCacheMissTokens: t.PromptCacheMiss,
})
@ -168,8 +168,8 @@ type ImageGenResponse struct {
// streaming tool_calls array (incremental fragments with an index field), which
// OpenAI-compatible clients accumulate themselves.
type UnifiedChunk struct {
Content string `json:"content"`
Done bool `json:"done"`
Content string `json:"content"`
Done bool `json:"done"`
// FinishReason carries the upstream finish/stop reason ("tool_calls",
// "length", ...) when the adapter provides it; the gateway emits it on
// the terminating chunk instead of the default "stop".
@ -204,4 +204,4 @@ func OneLine(s string, n int) string {
s = s[:n] + "..."
}
return s
}
}

90
packaging/core-dist.sh Executable file
View File

@ -0,0 +1,90 @@
#!/usr/bin/env bash
# Build the llmsproxy CORE distribution packages (deb + rpm + tar.gz).
#
# Unlike the GUI packages (Electron), the core is a single static-ish binary
# that server admins install with dpkg/rpm. The systemd unit bakes in the
# memory tuning (GOGC=50, MALLOC_ARENA_MAX=2) measured on the production box.
#
# Requires: go, nfpm (PATH), dpkg-deb/rpmbuild for verification
# Outputs: cmd/build/dist/llmsproxy_<ver>_amd64.deb
# cmd/build/dist/llmsproxy-<ver>.x86_64.rpm
# cmd/build/dist/llmsproxy-<ver>-linux-amd64.tar.gz
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$ROOT"
VERSION="${1:-$(git describe --tags --dirty 2>/dev/null | sed 's/^v//' || echo 0.1.0)}"
DIST="$ROOT/cmd/build/dist"
BUILD_BIN="$ROOT/cmd/build/llmsproxy"
log() { printf '\033[1;34m[core-dist]\033[0m %s\n' "$*"; }
# nfpm is installed via `go install` -> lives in GOPATH/bin, which may not be
# on the caller's PATH (non-interactive shells, cron). Resolve it explicitly.
NFPM_BIN="$(command -v nfpm || true)"
[ -z "$NFPM_BIN" ] && [ -n "$(go env GOPATH)" ] && {
[ -x "$(go env GOPATH)/bin/nfpm" ] && NFPM_BIN="$(go env GOPATH)/bin/nfpm"
}
[ -n "$NFPM_BIN" ] || {
echo "[core-dist] nfpm missing. Install: go install github.com/goreleaser/nfpm/v2/cmd/nfpm@v2.41.2"
exit 1
}
mkdir -p "$DIST"
# 1) build the core binary (luajit tag, -s -w like deploy.sh)
log "building core binary (luajit, stripped)..."
CGO_ENABLED=1 go build -tags luajit -trimpath -ldflags="-s -w" -o "$BUILD_BIN" ./cmd/llmsproxy
# 2) sanity gates: binary must contain the expected symbols and not be degenerate.
# NOTE under `set -o pipefail`, `strings | grep -q` is BROKEN: grep -q exits
# on first hit and closes the pipe, strings gets SIGPIPE -> pipeline returns
# 141 -> the check spuriously fails. Stage strings output in a temp file.
SYMS_TMP="$(mktemp)"
strings "$BUILD_BIN" > "$SYMS_TMP"
for sym in ProbeSlots reqRing shrinkStepLocked mergeUsage; do
grep -qF "$sym" "$SYMS_TMP" || {
echo "[core-dist] FATAL: binary missing expected symbol '$sym'"; rm -f "$SYMS_TMP"; exit 1
}
done
rm -f "$SYMS_TMP"
[ "$(stat -c%s "$BUILD_BIN")" -gt 5000000 ] || {
echo "[core-dist] FATAL: core binary implausibly small"; exit 1
}
log " ok: $(stat -c%s "$BUILD_BIN") bytes, symbols present"
# 3) deb + rpm via nfpm. nfpm v2 here does not render {{ .Env.VERSION }}, so
# stamp the version into a throwaway copy of the config; -t places the
# generated package in the dist dir.
log "packaging deb + rpm (v$VERSION)..."
NFPM_CFG="$(mktemp)" && sed "s/__VERSION__/$VERSION/g" "$ROOT/packaging/nfpm.yaml" > "$NFPM_CFG"
"$NFPM_BIN" package --config "$NFPM_CFG" --packager deb -t "$DIST"
"$NFPM_BIN" package --config "$NFPM_CFG" --packager rpm -t "$DIST"
rm -f "$NFPM_CFG"
# 4) tar.gz
log "packaging tar.gz..."
TAR_DIR="$DIST/llmsproxy-$VERSION-linux-amd64"
rm -rf "$TAR_DIR"; mkdir -p "$TAR_DIR"
cp "$BUILD_BIN" "$TAR_DIR/llmsproxy"
cp "$ROOT/config.yaml" "$TAR_DIR/config.example.yaml"
cp "$ROOT/packaging/llmsproxy.service" "$TAR_DIR/llmsproxy.service"
mkdir -p "$TAR_DIR/adapters"
cp "$ROOT"/internal/lua/adapters/*.lua "$TAR_DIR/adapters/"
tar -C "$DIST" -czf "$DIST/llmsproxy-$VERSION-linux-amd64.tar.gz" \
"$(basename "$TAR_DIR")"
rm -rf "$TAR_DIR"
# 5) verify artifacts are healthy (the same gate that would have caught the
# 264KB exe fiasco)
log "verifying packages..."
for f in "$DIST"/llmsproxy_*.deb "$DIST"/llmsproxy-*.rpm "$DIST"/*.tar.gz; do
[ -f "$f" ] || continue
sz=$(stat -c%s "$f")
[ "$sz" -gt 100000 ] || { echo "[core-dist] FATAL: $f suspiciously small ($sz B)"; exit 1; }
log " ✓ $f ($((sz/1024)) KB)"
done
dpkg-deb -I "$DIST"/llmsproxy_*.deb 2>/dev/null | grep -E 'Package|Version' | head -2
log "done: $DIST"

View File

@ -0,0 +1,24 @@
[Unit]
Description=LLMSProxy - unified OpenAI-compatible multi-source LLM gateway
After=network.target
[Service]
Type=simple
# Memory tuning (measured, see README "内存占用"):
# MALLOC_ARENA_MAX=2 caps glibc per-thread malloc arenas. LuaJIT allocates
# through cgo -> glibc malloc, and glibc defaults to 8*nproc arenas, so every
# OS thread that touches malloc reserved its own ~1 MB arena that is never
# returned. Measured: 8-12 arenas -> 0.
# GOGC=50 halves the Go heap growth target. On its own it does NOT help (the
# saved heap is immediately eaten by more glibc arenas); combined with
# MALLOC_ARENA_MAX it cut settled RSS by ~19%. This gateway is I/O bound, so
# the extra GC cycles are free.
Environment=GOGC=50
Environment=MALLOC_ARENA_MAX=2
ExecStart=/usr/bin/llmsproxy -config /etc/llmsproxy/config.yaml
WorkingDirectory=/etc/llmsproxy
Restart=always
RestartSec=5
[Install]
WantedBy=multi-user.target

59
packaging/nfpm.yaml Normal file
View File

@ -0,0 +1,59 @@
# nfpm config for the llmsproxy core gateway binary.
# The version placeholder below is substituted by packaging/core-dist.sh (nfpm v2
# here does not honour {{ .Env.VERSION }} template rendering), so do not edit it
# by hand.
name: llmsproxy
arch: amd64
platform: linux
version: __VERSION__
epoch: "1"
section: net
priority: optional
maintainer: JianFeeeee <dev@modelrouter.local>
description: |
Unified OpenAI-compatible multi-source LLM gateway.
Routes requests across multiple upstream LLM providers with Lua adapters,
model scheduling, quota windows, an elastic Lua worker pool, and a Web UI.
vendor: ModelRouter
homepage: https://gitcode.com/JianFeeeee/ModelRouter
license: MIT
contents:
# binary
- src: ./cmd/build/llmsproxy
dst: /usr/bin/llmsproxy
file_info:
mode: 0755
# systemd unit (memory tuning baked in)
- src: ./packaging/llmsproxy.service
dst: /etc/systemd/system/llmsproxy.service
file_info:
mode: 0644
# example config (seeded to /etc/llmsproxy on first install by postinst)
- src: ./config.yaml
dst: /usr/share/llmsproxy/config.example.yaml
file_info:
mode: 0644
# built-in Lua adapters (deployed to /etc/llmsproxy/adapters)
- src: ./internal/lua/adapters/*.lua
dst: /usr/share/llmsproxy/adapters/
file_info:
mode: 0644
# README pointer
- src: ./README.md
dst: /usr/share/doc/llmsproxy/README.md
file_info:
mode: 0644
scripts:
postinstall: ./packaging/postinst.sh
preremove: ./packaging/prerm.sh
rpm:
group: Applications/Internet
summary: Unified OpenAI-compatible multi-source LLM gateway
compression: gzip
deb:
compression: gzip
fields:
Recommends: ca-certificates

22
packaging/postinst.sh Executable file
View File

@ -0,0 +1,22 @@
#!/bin/sh
# llmsproxy core package postinst
set -e
# Runtime state belongs under /etc/llmsproxy just like the production layout:
# runtime.json holds keys/created sources and is NOT touched on reinstall.
mkdir -p /etc/llmsproxy/adapters
if [ -f /etc/llmsproxy/config.yaml ]; then
echo "llmsproxy: keeping existing /etc/llmsproxy/config.yaml"
else
# seed a starter config from the packaged example
cp -f /usr/share/llmsproxy/config.example.yaml /etc/llmsproxy/config.yaml 2>/dev/null || true
chmod 0644 /etc/llmsproxy/config.yaml
fi
# systemd
if command -v systemctl >/dev/null 2>&1; then
systemctl daemon-reload >/dev/null 2>&1 || true
echo "llmsproxy: use 'systemctl enable --now llmsproxy' to start"
fi
exit 0

14
packaging/prerm.sh Executable file
View File

@ -0,0 +1,14 @@
#!/bin/sh
# llmsproxy core package prerm
set -e
if command -v systemctl >/dev/null 2>&1; then
# stop the service if this package's unit is the one installed
if systemctl list-unit-files llmsproxy.service >/dev/null 2>&1 && \
systemctl is-active llmsproxy.service >/dev/null 2>&1; then
systemctl stop llmsproxy.service >/dev/null 2>&1 || true
systemctl disable llmsproxy.service >/dev/null 2>&1 || true
fi
systemctl daemon-reload >/dev/null 2>&1 || true
fi
exit 0

49
packaging/verify-dist.sh Executable file
View File

@ -0,0 +1,49 @@
#!/usr/bin/env bash
# Verify packaged GUI artifacts are healthy before a release is declared done.
#
# Background: on 2026-08-28 (1.3.0) the host wine broke (missing syswow64, so
# wine -- the NSIS self-extract step -- failed), and electron-builder silently
# wrote a 264 KB installer shell that did NOT embed the 86 MB payload. No gate
# caught it, so a broken Windows installer shipped. This script is that gate.
#
# It enforces a per-artifact minimum size, so a degenerate installer (or a
# build that only emitted a shell without its embedded payload) fails loudly.
# A size check is the right signal here: a healthy NSIS exe is ~86 MB and the
# broken 1.3.0 one was 264 KB (326x) — electron-builder compresses the embedded
# app.7z, so a plaintext-payload string probe would be unreliable.
set -uo pipefail
GUI_DIST="${1:-cmd/build/gui-dist}"
VERSION="${2:-}"
log() { printf '[verify-dist] %s\n' "$*"; }
fail() { printf '[verify-dist] FATAL: %s\n' "$*" >&2; exit 1; }
[ -d "$GUI_DIST" ] || fail "no dist dir: $GUI_DIST"
# (glob, min-bytes) — nsis.7z is NOT a required artifact: with `--win nsis` the
# payload is embedded inside the Setup exe and only the exe is the formal product
# (the .nsis.7z appears in intermediate/--dir builds only).
checks=(
"ModelRouter*.AppImage:10000000"
"modelrouter-*.deb:10000000"
"modelrouter-*.rpm:10000000"
"ModelRouter Setup*.exe:5000000"
)
for entry in "${checks[@]}"; do
glob="${entry%%:*}"; min="${entry#*:}"
# gui-dist accumulates installers from previous versions; check the MOST
# RECENT artifact (last in sort) so a stale file never masks a broken build.
# `find -name` (not a bare glob) so the space in "ModelRouter Setup *.exe"
# is preserved as one file pattern.
match="$(find "$GUI_DIST" -maxdepth 1 -name "$glob" -type f | sort -V | tail -1)"
[ -z "$match" ] && fail "no artifact matching '$glob' in $GUI_DIST"
sz=$(stat -c%s "$match")
if [ "$sz" -lt "$min" ]; then
fail "$(basename "$match") is only $sz bytes (min $min) — likely a degenerate shell like the 1.3.0 264KB exe"
fi
log " ✓ $(basename "$match") ($((sz/1024)) KB)"
done
log "all artifacts healthy"