From bd17b7fdccb61b42855a7dea62f911c2ba08091b Mon Sep 17 00:00:00 2001 From: Jiro Amato Date: Thu, 12 Nov 2026 01:58:33 -0700 Subject: [PATCH 1/5] test: unregister the shared registry client in the websocket home test The test registered a codex client with gpt-5.4 in the process-global model registry and never removed it. Tests that enumerate every registered model then saw an unexpected entry, so TestCodexClientModelsApplyPatchRouting failed whenever it ran after this one (8 of 12 runs with -count=12). --- sdk/api/handlers/openai/openai_responses_websocket_test.go | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/sdk/api/handlers/openai/openai_responses_websocket_test.go b/sdk/api/handlers/openai/openai_responses_websocket_test.go index 4a3b8de3fed..84af73fdc2d 100644 --- a/sdk/api/handlers/openai/openai_responses_websocket_test.go +++ b/sdk/api/handlers/openai/openai_responses_websocket_test.go @@ -120,6 +120,11 @@ func TestResponsesWebsocketHomeSelectedAuthCallbackPinsAndReusesFirstSelection(t manager.PublishHomeDispatch(dispatcher, executionregistry.New(), 1) manager.RegisterExecutor(executor) registry.GetGlobalRegistry().RegisterClient("home-responses-websocket-auth", "codex", []*registry.ModelInfo{{ID: "gpt-5.4"}}) + // The registry is process-global: without this, gpt-5.4 leaks into later + // tests that enumerate every registered model. + t.Cleanup(func() { + registry.GetGlobalRegistry().UnregisterClient("home-responses-websocket-auth") + }) base := handlers.NewBaseAPIHandlers(&sdkconfig.SDKConfig{}, manager) h := NewOpenAIResponsesAPIHandler(base) From 2d48db31860bf802ad14e7e03515c7e81428d7d0 Mon Sep 17 00:00:00 2001 From: Jiro Amato Date: Thu, 12 Nov 2026 01:58:50 -0700 Subject: [PATCH 2/5] feat(usage): record usage in a local store and serve it to the panel Adds a persistent usage statistics store and the management endpoints the panel's Usage page reads. - internal/usagestats records one row per upstream request in SQLite at /usage.db when usage-statistics-enabled is true: time, model, account, token buckets, latency, status, session and project. No prompt or response content is stored, and API keys are masked. - Cost is computed at query time from a built-in list-price table plus operator overrides, so a price change re-prices history. - The project comes from the working directory a coding agent declares in its request (Claude Code system prompt, Codex environment context), with agent worktrees folded into their repository. - New endpoints under /v8/management/observability/usage: stats, speed, settings. Adds modernc.org/sqlite, a pure-Go driver, so builds stay cgo-free. --- .gitignore | 1 + README.md | 4 +- config.example.yaml | 4 +- docs/usage-statistics.md | 112 ++ go.mod | 16 +- go.sum | 57 +- .../api/handlers/management/usage_stats.go | 201 +++ .../handlers/management/usage_stats_test.go | 146 +++ internal/api/server_management_v8.go | 4 + internal/config/config.go | 3 +- internal/logging/requestmeta.go | 3 + internal/usagestats/default_prices.json | 1084 +++++++++++++++++ internal/usagestats/prices.go | 219 ++++ internal/usagestats/project.go | 172 +++ internal/usagestats/project_test.go | 134 ++ internal/usagestats/query.go | 361 ++++++ internal/usagestats/recorder.go | 219 ++++ internal/usagestats/store.go | 267 ++++ internal/usagestats/usagestats_test.go | 467 +++++++ sdk/api/handlers/handlers.go | 7 + sdk/api/handlers/handlers_working_dir_test.go | 39 + sdk/cliproxy/service_config.go | 1 + sdk/cliproxy/service_lifecycle.go | 5 + sdk/cliproxy/usage_stats.go | 31 + 24 files changed, 3540 insertions(+), 17 deletions(-) create mode 100644 docs/usage-statistics.md create mode 100644 internal/api/handlers/management/usage_stats.go create mode 100644 internal/api/handlers/management/usage_stats_test.go create mode 100644 internal/usagestats/default_prices.json create mode 100644 internal/usagestats/prices.go create mode 100644 internal/usagestats/project.go create mode 100644 internal/usagestats/project_test.go create mode 100644 internal/usagestats/query.go create mode 100644 internal/usagestats/recorder.go create mode 100644 internal/usagestats/store.go create mode 100644 internal/usagestats/usagestats_test.go create mode 100644 sdk/api/handlers/handlers_working_dir_test.go create mode 100644 sdk/cliproxy/usage_stats.go diff --git a/.gitignore b/.gitignore index a83af7cfe0d..3fbb538e9cb 100644 --- a/.gitignore +++ b/.gitignore @@ -32,6 +32,7 @@ auths/* docs/* !docs/management-api-v8.md !docs/audio-transcription.md +!docs/usage-statistics.md AGENTS.md CLAUDE.md GEMINI.md diff --git a/README.md b/README.md index eba2e893ddf..084a2792830 100644 --- a/README.md +++ b/README.md @@ -247,7 +247,9 @@ see [MANAGEMENT_API.md](https://help.router-for.me/management/api) ## Usage Statistics -Since v6.10.0, CLIProxyAPI and [CPAMC](https://github.com/router-for-me/Cli-Proxy-API-Management-Center) no longer ship built-in usage statistics. If you need usage statistics, use: +This fork records usage in a local database and shows it on the management panel's Usage page: tokens and API-equivalent cost per day, value per subscription, and breakdowns by project and model. See [docs/usage-statistics.md](docs/usage-statistics.md). + +Upstream CLIProxyAPI and [CPAMC](https://github.com/router-for-me/Cli-Proxy-API-Management-Center) have not shipped built-in usage statistics since v6.10.0. These external tools remain an alternative: ### [CPA Usage Keeper](https://github.com/Willxup/cpa-usage-keeper) diff --git a/config.example.yaml b/config.example.yaml index baaafcaca17..6f75c2b7bcd 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -1289,7 +1289,9 @@ observability: request-log: false usage: - # When false, disable in-memory usage statistics aggregation + # When true, record one row per upstream request (tokens, model, account, latency, project) + # in "/usage.db" for the management usage dashboard, and feed the in-memory usage + # queue. No prompt or response content is stored. When false, usage data is discarded. usage-statistics-enabled: false # How long (in seconds) usage queue items are retained in memory for the Management API. diff --git a/docs/usage-statistics.md b/docs/usage-statistics.md new file mode 100644 index 00000000000..60e23395691 --- /dev/null +++ b/docs/usage-statistics.md @@ -0,0 +1,112 @@ +# Usage statistics + +The proxy can record one row per upstream request and serve aggregates for the +management panel's **Usage** page: tokens and API-equivalent cost over time, +what each subscription delivered against its price, and breakdowns by project, +model, client key, and more. + +## Turning it on + +Set the flag in `config.yaml`, or use the "Turn on recording" button on the +Usage page: + +```yaml +observability: + usage: + usage-statistics-enabled: true +``` + +The change applies on config reload, with no restart. Recording is off in Home +(cluster) mode, where usage is reported to Home instead. + +## What is stored + +Rows go to a SQLite database at `/usage.db`. Each row holds: + +- Time, provider, model, endpoint, status code, latency, and time to first token. +- Token counts in mutually exclusive buckets: uncached input, cache read, cache + write (with the 5 minute and 1 hour split), output, and reasoning. +- The upstream account. OAuth accounts are stored by their email or project; + API-key credentials are stored masked (`sk-a...0001`). +- The client API key, masked. +- Client IP, user agent, and session id. +- The working directory the coding agent declared, and the project derived + from it. + +No prompt or response content is stored, and no full API key. + +Rows are kept until you delete them. To start over, stop the server and delete +`usage.db` together with its `-wal` and `-shm` files. + +## Projects + +The proxy never sees a repository name. Coding agents state their working +directory in each request (Claude Code in its system prompt, Codex in its +environment context), and the project is the last directory of that path. +Agent worktrees are folded into their repository: + +| Working directory | Project | +| --- | --- | +| `~/repos/app` | `app` | +| `~/.t3/worktrees/app/t3-1234` | `app` | +| `~/repos/app/.claude/worktrees/feature` | `app` | +| `~/repos/app/.worktrees/feature` | `app` | +| `~/.codex/worktrees/1a2b/app` | `app` | + +A request that declares no working directory inherits the one last seen in its +session, and is otherwise listed as unknown. + +## Cost + +Cost is computed at query time from the stored token counts and a price table, +so it is an estimate of what the same usage would cost at API list prices. It is +not a bill. Because nothing is priced at write time, changing a price re-prices +all history. + +- Built-in prices live in `internal/usagestats/default_prices.json`, which + records its source and retrieval date. Refresh that file when providers + change their prices. +- A model name matches its exact price first, then the longest priced name it + extends at a separator, so `claude-opus-4-5-20251101` uses the + `claude-opus-4-5` price. +- Models without a price are listed on the Usage page, and their tokens are + left out of the cost. Add or override prices with the page's **Prices** button. +- Service tiers, batch discounts, and long-context surcharges are not modelled. + +Subscription value compares that cost with the monthly plan price you enter for +each account, prorated to the days recorded in the selected range. Each account +also lists the models it served, with every model's API-equivalent cost and its +share of the account's total. + +## Management API + +All endpoints are under `/v8/management/observability/usage` and need the +management key. They answer `409` with `usage_statistics_disabled` while +recording is off. + +| Endpoint | Purpose | +| --- | --- | +| `GET /stats` | Token, request, and cost totals grouped by `group_by` | +| `GET /speed` | Latency and first-token percentiles and output tokens per second | +| `GET /settings` | Price overrides and plan prices, plus the built-in price table | +| `PUT /settings` | Replace price overrides and plan prices | + +Query parameters for `/stats` and `/speed`: + +- `from`, `to`: Unix milliseconds. Default is the last 30 days. +- `utc_offset_minutes`: minutes east of UTC, used to bucket days and hours in + the viewer's calendar. +- `group_by`: comma-separated dimensions. `/stats` accepts `day`, `hour`, + `model`, `alias`, `provider`, `account`, `auth_index`, `auth_type`, + `client_key`, `client_ip`, `user_agent`, `endpoint`, `project`, `session_id`, + `reasoning_effort`, `service_tier`, `status_code`, `stream`, and `failed`. + `/speed` accepts one of those, except `day` and `hour`. +- `filter.=`: keep only rows with that exact value. +- `limit`: maximum number of groups (default 2000). The largest groups are kept. + +Example: + +```bash +curl -H "Authorization: Bearer $MANAGEMENT_KEY" \ + "http://127.0.0.1:8317/v8/management/observability/usage/stats?group_by=day,project" +``` diff --git a/go.mod b/go.mod index 3b4b50d7ed6..6b4fbab7ffb 100644 --- a/go.mod +++ b/go.mod @@ -31,14 +31,16 @@ require ( github.com/sirupsen/logrus v1.9.3 github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966 github.com/tiktoken-go/tokenizer v0.8.1 + go.etcd.io/bbolt v1.5.0 golang.org/x/crypto v0.57.0 golang.org/x/net v0.60.0 golang.org/x/oauth2 v0.30.0 - golang.org/x/sync v0.23.0 - golang.org/x/sys v0.48.0 + golang.org/x/sync v0.24.0 + golang.org/x/sys v0.49.0 golang.org/x/text v0.42.0 gopkg.in/natefinch/lumberjack.v2 v2.2.1 gopkg.in/yaml.v3 v3.0.1 + modernc.org/sqlite v1.62.0 ) replace github.com/MoeForge/moego v0.1.0-alpha.1 => github.com/router-for-me/moego v0.1.2 @@ -46,6 +48,7 @@ replace github.com/MoeForge/moego v0.1.0-alpha.1 => github.com/router-for-me/moe require ( github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/dlclark/regexp2/v2 v2.5.1 // indirect + github.com/ncruces/go-strftime v1.1.0 // indirect github.com/pion/datachannel v1.6.2 // indirect github.com/pion/dtls/v3 v3.1.5 // indirect github.com/pion/logging v0.2.4 // indirect @@ -56,11 +59,14 @@ require ( github.com/pion/srtp/v3 v3.0.12 // indirect github.com/pion/transport/v4 v4.0.2 // indirect github.com/pion/turn/v5 v5.0.12 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/rogpeppe/go-internal v1.15.0 // indirect github.com/wlynxg/anet v0.0.5 // indirect - go.etcd.io/bbolt v1.5.0 // indirect go.uber.org/atomic v1.11.0 // indirect golang.org/x/time v0.14.0 // indirect + modernc.org/libc v1.80.1 // indirect + modernc.org/mathutil v1.7.1 // indirect + modernc.org/memory v1.12.1 // indirect ) require ( @@ -80,7 +86,7 @@ require ( github.com/cloudflare/circl v1.6.3 // indirect github.com/cloudwego/base64x v0.1.4 // indirect github.com/cloudwego/iasm v0.2.0 // indirect - github.com/dustin/go-humanize v1.0.1 // indirect + github.com/dustin/go-humanize v1.1.0 // indirect github.com/emirpasic/gods v1.18.1 // indirect github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect github.com/gabriel-vasile/mimetype v1.4.3 // indirect @@ -99,7 +105,7 @@ require ( github.com/klauspost/cpuid/v2 v2.3.0 // indirect github.com/leodido/go-urn v1.4.0 // indirect github.com/lucasb-eyer/go-colorful v1.3.0 // indirect - github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-isatty v0.0.24 // indirect github.com/mattn/go-localereader v0.0.1 // indirect github.com/mattn/go-runewidth v0.0.19 // indirect github.com/minio/md5-simd v1.1.2 // indirect diff --git a/go.sum b/go.sum index 8873b5cdf17..8b91343c340 100644 --- a/go.sum +++ b/go.sum @@ -55,8 +55,8 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dlclark/regexp2/v2 v2.5.1 h1:E5Ug7Dh264W1ymdySmiHNcDG7fmsR307APCE5R07a20= github.com/dlclark/regexp2/v2 v2.5.1/go.mod h1:avUrQvPaLz2DrFNHJF0taWAFFX2C1GMSSoeiqFjcBmU= -github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= -github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/dustin/go-humanize v1.1.0 h1:dbKTrvD0klcbBV/h4AWJdMuZogJACoMlvWIWZ5b2xWg= +github.com/dustin/go-humanize v1.1.0/go.mod h1:hc1CvRkJMsgxqjmjMQF3QNRAZBwY8AXBAzKYoSX9sFI= github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc= github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= @@ -92,10 +92,14 @@ github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MG github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU= github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/pprof v0.0.0-20261016150608-1facf8f91b50 h1:PlNWJRxu5APSV0pWqfIsZ0qt9OOGHSWfZQ4zvx+WgWs= +github.com/google/pprof v0.0.0-20261016150608-1facf8f91b50/go.mod h1:yUo1AMMpsPIDFGuwDK8nLcf8+vvAd5l2O6JDZ6hpwrM= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= +github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= @@ -130,8 +134,8 @@ github.com/libp2p/zeroconf/v2 v2.2.0 h1:Cup06Jv6u81HLhIj1KasuNM/RHHrJ8T7wOTS4+Tv github.com/libp2p/zeroconf/v2 v2.2.0/go.mod h1:fuJqLnUwZTshS3U/bMRJ3+ow/v9oid1n0DmyYyNO1Xs= github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= -github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= -github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI= +github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A= github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw= @@ -155,6 +159,8 @@ github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELU github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= +github.com/ncruces/go-strftime v1.1.0 h1:vtPmPWMkb539RGV9WpWT9EwJGvrOCKvMNI4obWL47Hk= +github.com/ncruces/go-strftime v1.1.0/go.mod h1:yVaq7iwCN09vHRrKBrrflXT0M9tTMG2KmZ9Fj7xeG/g= github.com/pelletier/go-toml/v2 v2.4.3-0.20260624044523-35f78d5908f1 h1:gIZQQ2+wbsdQGWLPDSdJFptR25tBCjSC0KbSIQZ1rgE= github.com/pelletier/go-toml/v2 v2.4.3-0.20260624044523-35f78d5908f1/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= github.com/pierrec/xxHash v0.1.5 h1:n/jBpwTHiER4xYvK3/CdPVnLDPchj8eTJFFLUb4QHBo= @@ -201,6 +207,8 @@ github.com/redis/go-redis/v9 v9.19.0 h1:XPVaaPSnG6RhYf7p+rmSa9zZfeVAnWsH5h3lxthO github.com/redis/go-redis/v9 v9.19.0/go.mod h1:v/M13XI1PVCDcm01VtPFOADfZtHf8YW3baQf57KlIkA= github.com/refraction-networking/utls v1.8.2 h1:j4Q1gJj0xngdeH+Ox/qND11aEfhpgoEvV+S9iJ2IdQo= github.com/refraction-networking/utls v1.8.2/go.mod h1:jkSOEkLqn+S/jtpEHPOsVv/4V4EVnelwbMQl4vCWXAM= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/rogpeppe/go-internal v1.15.0 h1:D0RCU5rMAp+SpgkiNdrjfJ+LX4J1M32V2NeCY7EJ6hc= @@ -249,6 +257,8 @@ golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M= golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA= golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= +golang.org/x/mod v0.42.0 h1:ICeL3t2L30ljSPHHxDnR4fqVcBt/zR2zJAb9juavmQg= +golang.org/x/mod v0.42.0/go.mod h1:u5fzIrRInkL8MaO1cbRkUHT64v303YIT5KEqpNr7jRs= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20210423184538-5f58ad60dda6/go.mod h1:OJAsFXCWl8Ukc7SiCT/9KSuxbyM7479/AVlXFRxuMCk= golang.org/x/net v0.60.0 h1:79p50tfZlm0J9YfoDsSi639qSXNGVwEzOPLCxM2FsYU= @@ -256,17 +266,16 @@ golang.org/x/net v0.60.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg= golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI= golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU= golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk= -golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0= +golang.org/x/sync v0.24.0 h1:iqiDrGoDgyuvAKrhLQthUEzU/R378N78gXbBjxed7oA= +golang.org/x/sync v0.24.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210303074136-134d130e1a04/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210426080607-c94f62235c83/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo= -golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= +golang.org/x/sys v0.49.0 h1:XbzkgYJHdqh/8m2Uu0W/dQv8nktxx4BFHp1M0gROTXA= +golang.org/x/sys v0.49.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.46.0 h1:3+OXuTbaKDgwk8jTi3aSLHRlmWqHEUDUtxnbFigO4YE= golang.org/x/term v0.46.0/go.mod h1:+K02xbkittuwc0Am4abfA3Fc+XRGXkvBXNO88NCXPoc= @@ -277,6 +286,8 @@ golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E= golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.52.0 h1:ryxg4U63oaUYk7uOLTF37n43f4qgtGvqV4Ioit1BaEs= +golang.org/x/tools v0.52.0/go.mod h1:ItcP5UNaVGG+Yc4eIYmaFLNJ4soEopkx5Y2d4lcEurs= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= google.golang.org/protobuf v1.34.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFWg= @@ -294,5 +305,33 @@ gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +modernc.org/cc/v4 v4.30.6-0.20261101114202-1b9d1a076f6b h1:WE5SLOURcNd8s6uPR/n4WV4weQxxYKi5OpEZkfNVvdE= +modernc.org/cc/v4 v4.30.6-0.20261101114202-1b9d1a076f6b/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI= +modernc.org/ccgo/v4 v4.38.1 h1:wdxJVBuFT8JufZnnQyS6Bbz3NOWhViD0xJOGR96Bpd4= +modernc.org/ccgo/v4 v4.38.1/go.mod h1:kOpEkJRbFwBvjUWwn4j1j9B7iFHbl1+orhgTMCDkDBk= +modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM= +modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU= +modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= +modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= +modernc.org/gc/v3 v3.2.0 h1:/Y3LGDMQaoYf55PebP1qsZ5FDGgAZyG1RYKEHeQzh1o= +modernc.org/gc/v3 v3.2.0/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY= +modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= +modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= +modernc.org/libc v1.80.1 h1:ejn7HtTV3GknNKbklqWQ1nvUrfX8mTxs1D+xp/JQwy4= +modernc.org/libc v1.80.1/go.mod h1:pvo25Rs3OJEbDqhUV9jdEGevxeLJrJeDmNid/AcQV1g= +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= +modernc.org/memory v1.12.1 h1:nFMiWrpStgZczNl6XI9GnIk/rWhYIyHGUaR04pGbp9g= +modernc.org/memory v1.12.1/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg= +modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= +modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= +modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= +modernc.org/sqlite v1.62.0 h1:gMaCjSM1qpl+iOjiWhR1LYP3zqrYDI+5GYLGt6veP2g= +modernc.org/sqlite v1.62.0/go.mod h1:Jzjh1mt4s0HuQ0KvtB1LIuHiGRmU2D5pgj8dUhW7/SE= +modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= +modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50= rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4= diff --git a/internal/api/handlers/management/usage_stats.go b/internal/api/handlers/management/usage_stats.go new file mode 100644 index 00000000000..ec9edb7af46 --- /dev/null +++ b/internal/api/handlers/management/usage_stats.go @@ -0,0 +1,201 @@ +package management + +import ( + "encoding/json" + "errors" + "fmt" + "net/http" + "strconv" + "strings" + "time" + + "github.com/gin-gonic/gin" + "github.com/router-for-me/CLIProxyAPI/v8/internal/usagestats" +) + +const ( + usageStatsDefaultRange = 30 * 24 * time.Hour + usageStatsMaxOffset = 14 * time.Hour + usageStatsFilterPrefix = "filter." + usageStatsSettingsMaxSize = 1 << 20 +) + +// usageStatsStore returns the open usage store, or writes a 409 and returns nil +// when usage statistics are not being recorded. +func usageStatsStore(c *gin.Context) *usagestats.Store { + store := usagestats.Current() + if store == nil { + c.JSON(http.StatusConflict, gin.H{ + "error": "usage_statistics_disabled", + "message": "usage statistics are not being recorded; set usage-statistics-enabled to true", + }) + } + return store +} + +// parseUsageStatsQuery reads the time range, UTC offset, filters, and limit +// shared by the usage statistics endpoints. Times are Unix milliseconds. +func parseUsageStatsQuery(c *gin.Context, now time.Time) (usagestats.Query, error) { + query := usagestats.Query{To: now, Filters: map[string]string{}} + if raw := strings.TrimSpace(c.Query("to")); raw != "" { + ms, errParse := strconv.ParseInt(raw, 10, 64) + if errParse != nil { + return query, errors.New("to must be a Unix timestamp in milliseconds") + } + query.To = time.UnixMilli(ms) + } + query.From = query.To.Add(-usageStatsDefaultRange) + if raw := strings.TrimSpace(c.Query("from")); raw != "" { + ms, errParse := strconv.ParseInt(raw, 10, 64) + if errParse != nil { + return query, errors.New("from must be a Unix timestamp in milliseconds") + } + query.From = time.UnixMilli(ms) + } + if raw := strings.TrimSpace(c.Query("utc_offset_minutes")); raw != "" { + minutes, errParse := strconv.Atoi(raw) + offset := time.Duration(minutes) * time.Minute + if errParse != nil || offset < -usageStatsMaxOffset || offset > usageStatsMaxOffset { + return query, errors.New("utc_offset_minutes must be an integer between -840 and 840") + } + query.UTCOffset = offset + } + if raw := strings.TrimSpace(c.Query("limit")); raw != "" { + limit, errParse := strconv.Atoi(raw) + if errParse != nil || limit <= 0 { + return query, errors.New("limit must be a positive integer") + } + query.Limit = limit + } + for key, values := range c.Request.URL.Query() { + if name, ok := strings.CutPrefix(key, usageStatsFilterPrefix); ok && len(values) > 0 { + query.Filters[name] = values[0] + } + } + return query, nil +} + +func splitUsageStatsList(raw string) []string { + var out []string + for part := range strings.SplitSeq(raw, ",") { + if trimmed := strings.TrimSpace(part); trimmed != "" { + out = append(out, trimmed) + } + } + return out +} + +// GetUsageStats returns token, request, and API-equivalent cost totals grouped +// by the requested dimensions (group_by=day,model). +func (h *Handler) GetUsageStats(c *gin.Context) { + store := usageStatsStore(c) + if store == nil { + return + } + query, errQuery := parseUsageStatsQuery(c, time.Now()) + if errQuery != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": errQuery.Error()}) + return + } + query.GroupBy = splitUsageStatsList(c.Query("group_by")) + + aggregate, errAggregate := store.Aggregate(c.Request.Context(), query) + if errAggregate != nil { + writeUsageStatsError(c, errAggregate) + return + } + c.JSON(http.StatusOK, gin.H{ + "from": query.From.UnixMilli(), + "to": query.To.UnixMilli(), + "groups": aggregate.Groups, + "truncated": aggregate.Truncated, + "unpriced_models": aggregate.UnpricedModels, + }) +} + +// GetUsageSpeed returns latency percentiles and output throughput grouped by +// one dimension (group_by=model by default). +func (h *Handler) GetUsageSpeed(c *gin.Context) { + store := usageStatsStore(c) + if store == nil { + return + } + query, errQuery := parseUsageStatsQuery(c, time.Now()) + if errQuery != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": errQuery.Error()}) + return + } + dimension := strings.TrimSpace(c.Query("group_by")) + if dimension == "" { + dimension = "model" + } + groups, errSpeed := store.Speed(c.Request.Context(), query, dimension) + if errSpeed != nil { + writeUsageStatsError(c, errSpeed) + return + } + c.JSON(http.StatusOK, gin.H{ + "from": query.From.UnixMilli(), + "to": query.To.UnixMilli(), + "groups": groups, + }) +} + +// GetUsageSettings returns the operator settings together with the built-in +// price table they override. +func (h *Handler) GetUsageSettings(c *gin.Context) { + store := usageStatsStore(c) + if store == nil { + return + } + settings, errSettings := store.Settings(c.Request.Context()) + if errSettings != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": errSettings.Error()}) + return + } + defaults, errDefaults := usagestats.DefaultPrices() + if errDefaults != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": errDefaults.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"settings": settings, "default_prices": defaults}) +} + +// PutUsageSettings replaces the operator settings. +func (h *Handler) PutUsageSettings(c *gin.Context) { + store := usageStatsStore(c) + if store == nil { + return + } + var settings usagestats.Settings + decoder := json.NewDecoder(http.MaxBytesReader(c.Writer, c.Request.Body, usageStatsSettingsMaxSize)) + decoder.DisallowUnknownFields() + if errDecode := decoder.Decode(&settings); errDecode != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("invalid settings: %v", errDecode)}) + return + } + if errValidate := settings.Validate(); errValidate != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": errValidate.Error()}) + return + } + if errSave := store.SaveSettings(c.Request.Context(), settings); errSave != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": errSave.Error()}) + return + } + saved, errSettings := store.Settings(c.Request.Context()) + if errSettings != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": errSettings.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"settings": saved}) +} + +// writeUsageStatsError maps a query error to a status code: malformed queries +// are the caller's fault, anything else is a storage failure. +func writeUsageStatsError(c *gin.Context, err error) { + status := http.StatusInternalServerError + if errors.Is(err, usagestats.ErrInvalidQuery) { + status = http.StatusBadRequest + } + c.JSON(status, gin.H{"error": err.Error()}) +} diff --git a/internal/api/handlers/management/usage_stats_test.go b/internal/api/handlers/management/usage_stats_test.go new file mode 100644 index 00000000000..f9494d9f690 --- /dev/null +++ b/internal/api/handlers/management/usage_stats_test.go @@ -0,0 +1,146 @@ +package management + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/gin-gonic/gin" + "github.com/router-for-me/CLIProxyAPI/v8/internal/usagestats" +) + +func withUsageStatsStore(t *testing.T) *usagestats.Store { + t.Helper() + if errConfigure := usagestats.Configure(true, filepath.Join(t.TempDir(), usagestats.DefaultFileName)); errConfigure != nil { + t.Fatalf("configure usage statistics: %v", errConfigure) + } + t.Cleanup(func() { + if errShutdown := usagestats.Shutdown(); errShutdown != nil { + t.Errorf("shutdown usage statistics: %v", errShutdown) + } + }) + return usagestats.Current() +} + +func serveUsageStats(t *testing.T, handler gin.HandlerFunc, method, target, body string) *httptest.ResponseRecorder { + t.Helper() + rec := httptest.NewRecorder() + ginCtx, _ := gin.CreateTestContext(rec) + ginCtx.Request = httptest.NewRequest(method, target, strings.NewReader(body)) + handler(ginCtx) + return rec +} + +func TestUsageStatsEndpointsReportDisabled(t *testing.T) { + h := &Handler{} + for name, handler := range map[string]gin.HandlerFunc{ + "stats": h.GetUsageStats, "speed": h.GetUsageSpeed, "settings": h.GetUsageSettings, "put settings": h.PutUsageSettings, + } { + rec := serveUsageStats(t, handler, http.MethodGet, "/v8/management/observability/usage/stats", "{}") + if rec.Code != http.StatusConflict || !strings.Contains(rec.Body.String(), "usage_statistics_disabled") { + t.Errorf("%s: status = %d body = %s, want 409 usage_statistics_disabled", name, rec.Code, rec.Body.String()) + } + } +} + +func TestGetUsageStatsGroupsFiltersAndPrices(t *testing.T) { + store := withUsageStatsStore(t) + at := time.Date(2026, 11, 12, 6, 0, 0, 0, time.UTC) + for _, row := range []usagestats.Row{ + {Timestamp: at, Model: "claude-opus-5-5", Project: "alpha", InputTokens: 1_000_000, TotalTokens: 1_000_000}, + {Timestamp: at, Model: "claude-opus-5-5", Project: "beta", InputTokens: 2_000_000, TotalTokens: 2_000_000}, + } { + if errInsert := store.Insert(context.Background(), row); errInsert != nil { + t.Fatalf("insert row: %v", errInsert) + } + } + + target := "/v8/management/observability/usage/stats?group_by=day,project&filter.project=beta&utc_offset_minutes=-480" + + "&from=" + msString(at.Add(-time.Hour)) + "&to=" + msString(at.Add(time.Hour)) + rec := serveUsageStats(t, (&Handler{}).GetUsageStats, http.MethodGet, target, "") + if rec.Code != http.StatusOK { + t.Fatalf("status = %d body = %s", rec.Code, rec.Body.String()) + } + var payload struct { + Groups []struct { + Dimensions map[string]string `json:"dimensions"` + TotalTokens int64 `json:"total_tokens"` + CostUSD float64 `json:"cost_usd"` + } `json:"groups"` + } + if errDecode := json.Unmarshal(rec.Body.Bytes(), &payload); errDecode != nil { + t.Fatalf("decode response: %v", errDecode) + } + if len(payload.Groups) != 1 { + t.Fatalf("groups = %+v, want only the filtered project", payload.Groups) + } + group := payload.Groups[0] + // 06:00 UTC is still the previous day at UTC-8. + if group.Dimensions["day"] != "2026-11-11" || group.Dimensions["project"] != "beta" || group.TotalTokens != 2_000_000 || group.CostUSD != 8 { + t.Fatalf("group = %+v", group) + } +} + +func TestGetUsageStatsRejectsBadQueries(t *testing.T) { + withUsageStatsStore(t) + for _, query := range []string{ + "group_by=nonsense", "filter.nonsense=x", "from=abc", "to=abc", "utc_offset_minutes=9000", "limit=0", "from=20&to=10", + } { + rec := serveUsageStats(t, (&Handler{}).GetUsageStats, http.MethodGet, "/v8/management/observability/usage/stats?"+query, "") + if rec.Code != http.StatusBadRequest { + t.Errorf("%s: status = %d body = %s, want 400", query, rec.Code, rec.Body.String()) + } + } + rec := serveUsageStats(t, (&Handler{}).GetUsageSpeed, http.MethodGet, "/v8/management/observability/usage/speed?group_by=day", "") + if rec.Code != http.StatusBadRequest { + t.Errorf("speed by day: status = %d, want 400", rec.Code) + } +} + +func TestUsageSettingsRoundTripAndValidation(t *testing.T) { + withUsageStatsStore(t) + h := &Handler{} + + body := `{"model_prices":{"my-model":{"input":1.5,"output":6}},"account_plans":[{"provider":"claude","account":"me@example.com","monthly_usd":200}]}` + if rec := serveUsageStats(t, h.PutUsageSettings, http.MethodPut, "/v8/management/observability/usage/settings", body); rec.Code != http.StatusOK { + t.Fatalf("put status = %d body = %s", rec.Code, rec.Body.String()) + } + + rec := serveUsageStats(t, h.GetUsageSettings, http.MethodGet, "/v8/management/observability/usage/settings", "") + if rec.Code != http.StatusOK { + t.Fatalf("get status = %d body = %s", rec.Code, rec.Body.String()) + } + var payload struct { + Settings usagestats.Settings `json:"settings"` + DefaultPrices map[string]usagestats.Price `json:"default_prices"` + } + if errDecode := json.Unmarshal(rec.Body.Bytes(), &payload); errDecode != nil { + t.Fatalf("decode response: %v", errDecode) + } + if payload.Settings.ModelPrices["my-model"].Output != 6 || len(payload.Settings.AccountPlans) != 1 { + t.Fatalf("settings = %+v", payload.Settings) + } + if len(payload.DefaultPrices) == 0 { + t.Fatal("default prices missing from response") + } + + for name, invalid := range map[string]string{ + "negative price": `{"model_prices":{"m":{"input":-1,"output":1}}}`, + "unknown field": `{"model_prices":{},"surprise":true}`, + "malformed": `{`, + } { + if rec := serveUsageStats(t, h.PutUsageSettings, http.MethodPut, "/v8/management/observability/usage/settings", invalid); rec.Code != http.StatusBadRequest { + t.Errorf("%s: status = %d body = %s, want 400", name, rec.Code, rec.Body.String()) + } + } +} + +func msString(t time.Time) string { + raw, _ := json.Marshal(t.UnixMilli()) + return string(raw) +} diff --git a/internal/api/server_management_v8.go b/internal/api/server_management_v8.go index e73ad893132..abab810981f 100644 --- a/internal/api/server_management_v8.go +++ b/internal/api/server_management_v8.go @@ -41,6 +41,10 @@ func (s *Server) registerManagementV8Routes() { v8.GET("/observability/logs/requests/:id", s.mgmt.GetRequestLogByID) v8.GET("/observability/usage/api-keys", s.mgmt.GetAPIKeyUsage) v8.GET("/observability/usage/queue", s.mgmt.GetUsageQueue) + v8.GET("/observability/usage/stats", s.mgmt.GetUsageStats) + v8.GET("/observability/usage/speed", s.mgmt.GetUsageSpeed) + v8.GET("/observability/usage/settings", s.mgmt.GetUsageSettings) + v8.PUT("/observability/usage/settings", s.mgmt.PutUsageSettings) v8.GET("/credentials", s.mgmt.ListAuthFiles) v8.POST("/credentials", s.mgmt.UploadAuthFileV8) diff --git a/internal/config/config.go b/internal/config/config.go index dad8d4354d5..2d0f7d3ed4f 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -83,7 +83,8 @@ type Config struct { // When exceeded, the oldest error log files are deleted. Default is 10. Set to 0 to disable cleanup. ErrorLogsMaxFiles int `yaml:"error-logs-max-files" json:"error-logs-max-files"` - // UsageStatisticsEnabled toggles in-memory usage aggregation; when false, usage data is discarded. + // UsageStatisticsEnabled toggles usage recording: the persistent usage statistics store + // ("/usage.db") and the in-memory usage queue. When false, usage data is discarded. UsageStatisticsEnabled bool `yaml:"usage-statistics-enabled" json:"usage-statistics-enabled"` // RedisUsageQueueRetentionSeconds controls how long usage queue items are retained diff --git a/internal/logging/requestmeta.go b/internal/logging/requestmeta.go index be2051122d5..4a6e304d0e0 100644 --- a/internal/logging/requestmeta.go +++ b/internal/logging/requestmeta.go @@ -23,6 +23,9 @@ type ClientRequestMetadata struct { NodeKind string IsFork bool IsCompaction bool + // WorkingDir is the working directory the calling agent declared in its + // request, when usage statistics are recording. Empty when unknown. + WorkingDir string } type responseStatusHolder struct { diff --git a/internal/usagestats/default_prices.json b/internal/usagestats/default_prices.json new file mode 100644 index 00000000000..ca70c99927d --- /dev/null +++ b/internal/usagestats/default_prices.json @@ -0,0 +1,1084 @@ +{ + "source": "https://github.com/BerriAI/litellm/blob/main/model_prices_and_context_window.json", + "retrieved": "2026-11-12", + "unit": "USD per 1M tokens", + "models": { + "claude-fable-5": { + "input": 10.0, + "output": 50.0, + "cache_read": 1.0, + "cache_write_5m": 12.5, + "cache_write_1h": 20.0 + }, + "claude-fable-5-1": { + "input": 10.0, + "output": 50.0, + "cache_read": 0.25, + "cache_write_5m": 12.5, + "cache_write_1h": 20.0 + }, + "claude-fable-5-2": { + "input": 8.0, + "output": 40.0, + "cache_read": 0.2, + "cache_write_5m": 10.0, + "cache_write_1h": 16.0 + }, + "claude-haiku-4-5": { + "input": 1.0, + "output": 5.0, + "cache_read": 0.1, + "cache_write_5m": 1.25, + "cache_write_1h": 2.0 + }, + "claude-haiku-4-5-20251001": { + "input": 1.0, + "output": 5.0, + "cache_read": 0.1, + "cache_write_5m": 1.25, + "cache_write_1h": 2.0 + }, + "claude-haiku-5-5": { + "input": 0.1, + "output": 0.5, + "cache_read": 0.01, + "cache_write_5m": 0.125, + "cache_write_1h": 0.2 + }, + "claude-mythos-5": { + "input": 10.0, + "output": 50.0, + "cache_read": 1.0, + "cache_write_5m": 12.5, + "cache_write_1h": 20.0 + }, + "claude-mythos-5-1": { + "input": 10.0, + "output": 50.0, + "cache_read": 0.25, + "cache_write_5m": 12.5, + "cache_write_1h": 20.0 + }, + "claude-mythos-5-2": { + "input": 8.0, + "output": 40.0, + "cache_read": 0.2, + "cache_write_5m": 10.0, + "cache_write_1h": 16.0 + }, + "claude-mythos-preview": { + "input": 10.0, + "output": 50.0, + "cache_read": 1.0, + "cache_write_5m": 12.5, + "cache_write_1h": 20.0 + }, + "claude-opus-4-5": { + "input": 5.0, + "output": 25.0, + "cache_read": 0.5, + "cache_write_5m": 6.25, + "cache_write_1h": 10.0 + }, + "claude-opus-4-5-20251101": { + "input": 5.0, + "output": 25.0, + "cache_read": 0.5, + "cache_write_5m": 6.25, + "cache_write_1h": 10.0 + }, + "claude-opus-4-6": { + "input": 5.0, + "output": 25.0, + "cache_read": 0.5, + "cache_write_5m": 6.25, + "cache_write_1h": 10.0 + }, + "claude-opus-4-6-20260205": { + "input": 5.0, + "output": 25.0, + "cache_read": 0.5, + "cache_write_5m": 6.25, + "cache_write_1h": 10.0 + }, + "claude-opus-4-7": { + "input": 5.0, + "output": 25.0, + "cache_read": 0.5, + "cache_write_5m": 6.25, + "cache_write_1h": 10.0 + }, + "claude-opus-4-7-20260416": { + "input": 5.0, + "output": 25.0, + "cache_read": 0.5, + "cache_write_5m": 6.25, + "cache_write_1h": 10.0 + }, + "claude-opus-4-8": { + "input": 5.0, + "output": 25.0, + "cache_read": 0.5, + "cache_write_5m": 6.25, + "cache_write_1h": 10.0 + }, + "claude-opus-5": { + "input": 5.0, + "output": 25.0, + "cache_read": 0.5, + "cache_write_5m": 6.25, + "cache_write_1h": 10.0 + }, + "claude-opus-5-5": { + "input": 4.0, + "output": 20.0, + "cache_read": 0.2, + "cache_write_5m": 5.0, + "cache_write_1h": 8.0 + }, + "claude-sonnet-4-5": { + "input": 3.0, + "output": 15.0, + "cache_read": 0.3, + "cache_write_5m": 3.75, + "cache_write_1h": 6.0 + }, + "claude-sonnet-4-5-20250929": { + "input": 3.0, + "output": 15.0, + "cache_read": 0.3, + "cache_write_5m": 3.75, + "cache_write_1h": 6.0 + }, + "claude-sonnet-4-6": { + "input": 3.0, + "output": 15.0, + "cache_read": 0.3, + "cache_write_5m": 3.75, + "cache_write_1h": 6.0 + }, + "claude-sonnet-5": { + "input": 2.0, + "output": 10.0, + "cache_read": 0.2, + "cache_write_5m": 2.5, + "cache_write_1h": 4.0 + }, + "claude-sonnet-5-5": { + "input": 2.0, + "output": 10.0, + "cache_read": 0.1, + "cache_write_5m": 2.5, + "cache_write_1h": 4.0 + }, + "deepseek-chat": { + "input": 0.28, + "output": 0.42, + "cache_read": 0.028 + }, + "deepseek-coder": { + "input": 0.14, + "output": 0.28, + "cache_read": 0.014 + }, + "deepseek-flash": { + "input": 0.3, + "output": 1.2, + "cache_read": 0.006, + "cache_write_5m": 0.0 + }, + "deepseek-r1": { + "input": 0.55, + "output": 2.19, + "cache_read": 0.14 + }, + "deepseek-reasoner": { + "input": 0.28, + "output": 0.42, + "cache_read": 0.028 + }, + "deepseek-v3": { + "input": 0.27, + "output": 1.1, + "cache_read": 0.07, + "cache_write_5m": 0.0 + }, + "deepseek-v3.2": { + "input": 0.28, + "output": 0.4, + "cache_read": 0.028 + }, + "deepseek-v4-flash": { + "input": 0.3, + "output": 1.2, + "cache_read": 0.006, + "cache_write_5m": 0.0 + }, + "deepseek-v4-pro": { + "input": 1.32, + "output": 3.96, + "cache_read": 0.044, + "cache_write_5m": 0.0 + }, + "gemini-2.0-flash": { + "input": 0.15, + "output": 0.6 + }, + "gemini-2.0-flash-lite": { + "input": 0.075, + "output": 0.3 + }, + "gemini-2.5-flash": { + "input": 0.3, + "output": 2.5, + "cache_read": 0.03 + }, + "gemini-2.5-flash-lite": { + "input": 0.1, + "output": 0.4, + "cache_read": 0.01 + }, + "gemini-2.5-flash-lite-preview-09-2025": { + "input": 0.1, + "output": 0.4, + "cache_read": 0.01 + }, + "gemini-2.5-flash-preview-09-2025": { + "input": 0.3, + "output": 2.5, + "cache_read": 0.03 + }, + "gemini-2.5-pro": { + "input": 1.25, + "output": 10.0, + "cache_read": 0.125 + }, + "gemini-3-flash-preview": { + "input": 0.5, + "output": 3.0, + "cache_read": 0.05 + }, + "gemini-3-pro-preview": { + "input": 2.0, + "output": 12.0, + "cache_read": 0.2 + }, + "gemini-3.1-flash-lite": { + "input": 0.25, + "output": 1.5, + "cache_read": 0.025 + }, + "gemini-3.1-flash-lite-preview": { + "input": 0.25, + "output": 1.5, + "cache_read": 0.025 + }, + "gemini-3.1-pro-preview": { + "input": 2.0, + "output": 12.0, + "cache_read": 0.2 + }, + "gemini-3.1-pro-preview-customtools": { + "input": 2.0, + "output": 12.0, + "cache_read": 0.2 + }, + "gemini-3.5-flash": { + "input": 1.5, + "output": 9.0, + "cache_read": 0.15 + }, + "gemini-3.5-flash-lite": { + "input": 0.3, + "output": 2.5, + "cache_read": 0.03 + }, + "gemini-3.6-flash": { + "input": 0.75, + "output": 3.75, + "cache_read": 0.075 + }, + "gemini-3.7-flash": { + "input": 0.75, + "output": 3.75, + "cache_read": 0.075 + }, + "gemini-3.8-flash": { + "input": 0.75, + "output": 3.75, + "cache_read": 0.075 + }, + "gemini-3.8-flash-cyber": { + "input": 1.5, + "output": 7.5, + "cache_read": 0.15 + }, + "gemini-4-argon": { + "input": 2.0, + "output": 10.0, + "cache_read": 0.1 + }, + "gemini-4-argon-cyber-permissive": { + "input": 4.0, + "output": 20.0, + "cache_read": 0.2 + }, + "gemini-4-carbon": { + "input": 2.0, + "output": 10.0, + "cache_read": 0.1 + }, + "gemini-4-carbon-cyber-permissive": { + "input": 4.0, + "output": 20.0, + "cache_read": 0.2 + }, + "gemini-flash-latest": { + "input": 0.75, + "output": 3.75, + "cache_read": 0.075 + }, + "gemini-flash-lite-latest": { + "input": 0.3, + "output": 2.5, + "cache_read": 0.03 + }, + "gemini-omni-1.1-flash": { + "input": 1.5, + "output": 9.0 + }, + "gemini-omni-1.1-flash-preview": { + "input": 1.5, + "output": 9.0 + }, + "gemini-omni-1.2-flash": { + "input": 1.5, + "output": 9.0 + }, + "gemini-omni-flash-preview": { + "input": 1.5, + "output": 9.0 + }, + "gemini-pro-latest": { + "input": 2.0, + "output": 12.0, + "cache_read": 0.2 + }, + "gpt-3.5-turbo": { + "input": 0.5, + "output": 1.5 + }, + "gpt-3.5-turbo-0125": { + "input": 0.5, + "output": 1.5 + }, + "gpt-3.5-turbo-1106": { + "input": 1.0, + "output": 2.0 + }, + "gpt-3.5-turbo-16k": { + "input": 3.0, + "output": 4.0 + }, + "gpt-4": { + "input": 30.0, + "output": 60.0 + }, + "gpt-4-0613": { + "input": 30.0, + "output": 60.0 + }, + "gpt-4-1106-preview": { + "input": 10.0, + "output": 30.0 + }, + "gpt-4-turbo": { + "input": 10.0, + "output": 30.0 + }, + "gpt-4-turbo-2024-04-09": { + "input": 10.0, + "output": 30.0 + }, + "gpt-4.1": { + "input": 2.0, + "output": 8.0, + "cache_read": 0.5 + }, + "gpt-4.1-2025-04-14": { + "input": 2.0, + "output": 8.0, + "cache_read": 0.5 + }, + "gpt-4.1-mini": { + "input": 0.4, + "output": 1.6, + "cache_read": 0.1 + }, + "gpt-4.1-mini-2025-04-14": { + "input": 0.4, + "output": 1.6, + "cache_read": 0.1 + }, + "gpt-4.1-nano": { + "input": 0.1, + "output": 0.4, + "cache_read": 0.025 + }, + "gpt-4.1-nano-2025-04-14": { + "input": 0.1, + "output": 0.4, + "cache_read": 0.025 + }, + "gpt-4o": { + "input": 2.5, + "output": 10.0, + "cache_read": 1.25 + }, + "gpt-4o-2024-05-13": { + "input": 5.0, + "output": 15.0 + }, + "gpt-4o-2024-08-06": { + "input": 2.5, + "output": 10.0, + "cache_read": 1.25 + }, + "gpt-4o-2024-11-20": { + "input": 2.5, + "output": 10.0, + "cache_read": 1.25 + }, + "gpt-4o-mini": { + "input": 0.15, + "output": 0.6, + "cache_read": 0.075 + }, + "gpt-4o-mini-2024-07-18": { + "input": 0.15, + "output": 0.6, + "cache_read": 0.075 + }, + "gpt-5": { + "input": 1.25, + "output": 10.0, + "cache_read": 0.125 + }, + "gpt-5-2025-08-07": { + "input": 1.25, + "output": 10.0, + "cache_read": 0.125 + }, + "gpt-5-chat": { + "input": 1.25, + "output": 10.0, + "cache_read": 0.125 + }, + "gpt-5-chat-latest": { + "input": 1.25, + "output": 10.0, + "cache_read": 0.125 + }, + "gpt-5-codex": { + "input": 1.25, + "output": 10.0, + "cache_read": 0.125 + }, + "gpt-5-mini": { + "input": 0.25, + "output": 2.0, + "cache_read": 0.025 + }, + "gpt-5-mini-2025-08-07": { + "input": 0.25, + "output": 2.0, + "cache_read": 0.025 + }, + "gpt-5-nano": { + "input": 0.05, + "output": 0.4, + "cache_read": 0.005 + }, + "gpt-5-nano-2025-08-07": { + "input": 0.05, + "output": 0.4, + "cache_read": 0.005 + }, + "gpt-5-pro": { + "input": 15.0, + "output": 120.0 + }, + "gpt-5-pro-2025-10-06": { + "input": 15.0, + "output": 120.0 + }, + "gpt-5-search-api": { + "input": 1.25, + "output": 10.0, + "cache_read": 0.125 + }, + "gpt-5-search-api-2025-10-14": { + "input": 1.25, + "output": 10.0, + "cache_read": 0.125 + }, + "gpt-5.1": { + "input": 1.25, + "output": 10.0, + "cache_read": 0.125 + }, + "gpt-5.1-2025-11-13": { + "input": 1.25, + "output": 10.0, + "cache_read": 0.125 + }, + "gpt-5.1-chat-latest": { + "input": 1.25, + "output": 10.0, + "cache_read": 0.125 + }, + "gpt-5.1-codex": { + "input": 1.25, + "output": 10.0, + "cache_read": 0.125 + }, + "gpt-5.1-codex-max": { + "input": 1.25, + "output": 10.0, + "cache_read": 0.125 + }, + "gpt-5.1-codex-mini": { + "input": 0.25, + "output": 2.0, + "cache_read": 0.025 + }, + "gpt-5.2": { + "input": 1.75, + "output": 14.0, + "cache_read": 0.175 + }, + "gpt-5.2-2025-12-11": { + "input": 1.75, + "output": 14.0, + "cache_read": 0.175 + }, + "gpt-5.2-chat-latest": { + "input": 1.75, + "output": 14.0, + "cache_read": 0.175 + }, + "gpt-5.2-codex": { + "input": 1.75, + "output": 14.0, + "cache_read": 0.175 + }, + "gpt-5.2-pro": { + "input": 21.0, + "output": 168.0 + }, + "gpt-5.2-pro-2025-12-11": { + "input": 21.0, + "output": 168.0 + }, + "gpt-5.3-chat-latest": { + "input": 1.75, + "output": 14.0, + "cache_read": 0.175 + }, + "gpt-5.3-codex": { + "input": 1.75, + "output": 14.0, + "cache_read": 0.175 + }, + "gpt-5.4": { + "input": 2.5, + "output": 15.0, + "cache_read": 0.25 + }, + "gpt-5.4-2026-03-05": { + "input": 2.5, + "output": 15.0, + "cache_read": 0.25 + }, + "gpt-5.4-mini": { + "input": 0.75, + "output": 4.5, + "cache_read": 0.075 + }, + "gpt-5.4-mini-2026-03-17": { + "input": 0.75, + "output": 4.5, + "cache_read": 0.075 + }, + "gpt-5.4-nano": { + "input": 0.2, + "output": 1.25, + "cache_read": 0.02 + }, + "gpt-5.4-nano-2026-03-17": { + "input": 0.2, + "output": 1.25, + "cache_read": 0.02 + }, + "gpt-5.4-pro": { + "input": 30.0, + "output": 180.0 + }, + "gpt-5.4-pro-2026-03-05": { + "input": 30.0, + "output": 180.0 + }, + "gpt-5.5": { + "input": 5.0, + "output": 30.0, + "cache_read": 0.5 + }, + "gpt-5.5-2026-04-23": { + "input": 5.0, + "output": 30.0, + "cache_read": 0.5 + }, + "gpt-5.5-cyber": { + "input": 12.5, + "output": 75.0, + "cache_read": 1.25 + }, + "gpt-5.5-cyber-preview": { + "input": 12.5, + "output": 75.0, + "cache_read": 1.25 + }, + "gpt-5.5-pro": { + "input": 30.0, + "output": 180.0 + }, + "gpt-5.5-pro-2026-04-23": { + "input": 30.0, + "output": 180.0 + }, + "gpt-5.6": { + "input": 4.0, + "output": 20.0, + "cache_read": 0.4, + "cache_write_5m": 5.0 + }, + "gpt-5.6-cyber": { + "input": 12.5, + "output": 75.0, + "cache_read": 1.25, + "cache_write_5m": 15.625 + }, + "gpt-5.6-luna": { + "input": 0.2, + "output": 1.2, + "cache_read": 0.02, + "cache_write_5m": 0.25 + }, + "gpt-5.6-sol": { + "input": 4.0, + "output": 20.0, + "cache_read": 0.4, + "cache_write_5m": 5.0 + }, + "gpt-5.6-terra": { + "input": 2.0, + "output": 12.0, + "cache_read": 0.2, + "cache_write_5m": 2.5 + }, + "gpt-6-astra": { + "input": 10.0, + "output": 50.0, + "cache_read": 1.0, + "cache_write_5m": 12.5 + }, + "gpt-6-astra-law": { + "input": 10.0, + "output": 50.0, + "cache_read": 1.0 + }, + "gpt-6-cyber": { + "input": 20.0, + "output": 100.0, + "cache_read": 2.0, + "cache_write_5m": 25.0 + }, + "gpt-6-luna": { + "input": 0.1, + "output": 0.5, + "cache_read": 0.01, + "cache_write_5m": 0.125 + }, + "gpt-6-rosalind": { + "input": 15.0, + "output": 75.0, + "cache_read": 1.5, + "cache_write_5m": 18.75 + }, + "gpt-6-sol": { + "input": 2.0, + "output": 10.0, + "cache_read": 0.2, + "cache_write_5m": 2.5 + }, + "gpt-6.1-sol": { + "input": 2.0, + "output": 10.0, + "cache_read": 0.1, + "cache_write_5m": 2.5 + }, + "gpt-6.1-terra": { + "input": 1.0, + "output": 5.0, + "cache_read": 0.05, + "cache_write_5m": 1.25 + }, + "gpt-daybreak-blue-latest": { + "input": 4.0, + "output": 20.0, + "cache_read": 0.4, + "cache_write_5m": 5.0 + }, + "gpt-daybreak-red-latest": { + "input": 12.5, + "output": 75.0, + "cache_read": 1.25, + "cache_write_5m": 15.625 + }, + "gpt-rosalind-discovery": { + "input": 5.0, + "output": 25.0, + "cache_read": 0.5 + }, + "gpt-rosalind-research": { + "input": 5.0, + "output": 25.0, + "cache_read": 0.5 + }, + "grok-4.1-fast-non-reasoning": { + "input": 0.2, + "output": 0.5, + "cache_read": 0.05 + }, + "grok-4.1-fast-reasoning": { + "input": 0.2, + "output": 0.5, + "cache_read": 0.05 + }, + "grok-4.20": { + "input": 1.25, + "output": 2.5, + "cache_read": 0.2 + }, + "grok-4.20-0309": { + "input": 1.25, + "output": 2.5, + "cache_read": 0.2 + }, + "grok-4.20-0309-non-reasoning": { + "input": 1.25, + "output": 2.5, + "cache_read": 0.2 + }, + "grok-4.20-0309-reasoning": { + "input": 1.25, + "output": 2.5, + "cache_read": 0.2 + }, + "grok-4.20-beta": { + "input": 1.25, + "output": 2.5, + "cache_read": 0.2 + }, + "grok-4.20-beta-0309": { + "input": 1.25, + "output": 2.5, + "cache_read": 0.2 + }, + "grok-4.20-beta-0309-non-reasoning": { + "input": 1.25, + "output": 2.5, + "cache_read": 0.2 + }, + "grok-4.20-beta-0309-reasoning": { + "input": 1.25, + "output": 2.5, + "cache_read": 0.2 + }, + "grok-4.20-beta-latest": { + "input": 1.25, + "output": 2.5, + "cache_read": 0.2 + }, + "grok-4.20-beta-latest-non-reasoning": { + "input": 1.25, + "output": 2.5, + "cache_read": 0.2 + }, + "grok-4.20-beta-latest-reasoning": { + "input": 1.25, + "output": 2.5, + "cache_read": 0.2 + }, + "grok-4.20-beta-non-reasoning": { + "input": 1.25, + "output": 2.5, + "cache_read": 0.2 + }, + "grok-4.20-beta-reasoning": { + "input": 1.25, + "output": 2.5, + "cache_read": 0.2 + }, + "grok-4.20-multi-agent": { + "input": 1.25, + "output": 2.5, + "cache_read": 0.2 + }, + "grok-4.20-multi-agent-0309": { + "input": 1.25, + "output": 2.5, + "cache_read": 0.2 + }, + "grok-4.20-multi-agent-beta-0309": { + "input": 1.25, + "output": 2.5, + "cache_read": 0.2 + }, + "grok-4.20-multi-agent-beta-latest": { + "input": 1.25, + "output": 2.5, + "cache_read": 0.2 + }, + "grok-4.20-multi-agent-latest": { + "input": 1.25, + "output": 2.5, + "cache_read": 0.2 + }, + "grok-4.20-non-reasoning": { + "input": 1.25, + "output": 2.5, + "cache_read": 0.2 + }, + "grok-4.20-non-reasoning-gv2": { + "input": 1.25, + "output": 2.5, + "cache_read": 0.2 + }, + "grok-4.20-non-reasoning-latest": { + "input": 1.25, + "output": 2.5, + "cache_read": 0.2 + }, + "grok-4.20-reasoning": { + "input": 1.25, + "output": 2.5, + "cache_read": 0.2 + }, + "grok-4.20-reasoning-gv2": { + "input": 1.25, + "output": 2.5, + "cache_read": 0.2 + }, + "grok-4.20-reasoning-latest": { + "input": 1.25, + "output": 2.5, + "cache_read": 0.2 + }, + "grok-4.3": { + "input": 1.25, + "output": 2.5, + "cache_read": 0.2 + }, + "grok-4.3-latest": { + "input": 1.25, + "output": 2.5, + "cache_read": 0.2 + }, + "grok-4.5": { + "input": 2.0, + "output": 6.0, + "cache_read": 0.3 + }, + "grok-4.5-latest": { + "input": 2.0, + "output": 6.0, + "cache_read": 0.3 + }, + "grok-4.6": { + "input": 2.0, + "output": 6.0, + "cache_read": 0.5 + }, + "grok-4.7": { + "input": 2.0, + "output": 6.0, + "cache_read": 0.5 + }, + "grok-build-0.1": { + "input": 1.0, + "output": 2.0, + "cache_read": 0.2 + }, + "grok-build-latest": { + "input": 2.0, + "output": 6.0, + "cache_read": 0.3 + }, + "grok-code-fast": { + "input": 1.0, + "output": 2.0, + "cache_read": 0.2 + }, + "grok-code-fast-1": { + "input": 1.0, + "output": 2.0, + "cache_read": 0.2 + }, + "grok-code-fast-1-0825": { + "input": 1.0, + "output": 2.0, + "cache_read": 0.2 + }, + "kimi-k2.5": { + "input": 0.6, + "output": 3.0, + "cache_read": 0.1 + }, + "kimi-k2.6": { + "input": 0.95, + "output": 4.0, + "cache_read": 0.16 + }, + "kimi-k2.7-code": { + "input": 0.95, + "output": 4.0, + "cache_read": 0.19 + }, + "kimi-k3": { + "input": 3.0, + "output": 15.0, + "cache_read": 0.3, + "cache_write_5m": 3.0, + "cache_write_1h": 6.0 + }, + "kimi-k3.1-pro": { + "input": 2.0, + "output": 10.0, + "cache_read": 0.2, + "cache_write_5m": 2.0, + "cache_write_1h": 4.0 + }, + "kimi-k3.1-pro-highspeed": { + "input": 4.0, + "output": 20.0, + "cache_read": 0.2, + "cache_write_5m": 4.0, + "cache_write_1h": 8.0 + }, + "moonshot-v1-128k": { + "input": 2.0, + "output": 5.0 + }, + "moonshot-v1-128k-vision-preview": { + "input": 2.0, + "output": 5.0 + }, + "moonshot-v1-32k": { + "input": 1.0, + "output": 3.0 + }, + "moonshot-v1-32k-vision-preview": { + "input": 1.0, + "output": 3.0 + }, + "moonshot-v1-8k": { + "input": 0.2, + "output": 2.0 + }, + "moonshot-v1-8k-vision-preview": { + "input": 0.2, + "output": 2.0 + }, + "moonshot-v1-auto": { + "input": 2.0, + "output": 5.0 + }, + "o1": { + "input": 15.0, + "output": 60.0, + "cache_read": 7.5 + }, + "o1-2024-12-17": { + "input": 15.0, + "output": 60.0, + "cache_read": 7.5 + }, + "o1-pro": { + "input": 150.0, + "output": 600.0 + }, + "o1-pro-2025-03-19": { + "input": 150.0, + "output": 600.0 + }, + "o3": { + "input": 2.0, + "output": 8.0, + "cache_read": 0.5 + }, + "o3-2025-04-16": { + "input": 2.0, + "output": 8.0, + "cache_read": 0.5 + }, + "o3-deep-research": { + "input": 10.0, + "output": 40.0, + "cache_read": 2.5 + }, + "o3-deep-research-2025-06-26": { + "input": 10.0, + "output": 40.0, + "cache_read": 2.5 + }, + "o3-mini": { + "input": 1.1, + "output": 4.4, + "cache_read": 0.55 + }, + "o3-mini-2025-01-31": { + "input": 1.1, + "output": 4.4, + "cache_read": 0.55 + }, + "o3-pro": { + "input": 20.0, + "output": 80.0 + }, + "o3-pro-2025-06-10": { + "input": 20.0, + "output": 80.0 + }, + "o4-mini": { + "input": 1.1, + "output": 4.4, + "cache_read": 0.275 + }, + "o4-mini-2025-04-16": { + "input": 1.1, + "output": 4.4, + "cache_read": 0.275 + }, + "o4-mini-deep-research": { + "input": 2.0, + "output": 8.0, + "cache_read": 0.5 + }, + "o4-mini-deep-research-2025-06-26": { + "input": 2.0, + "output": 8.0, + "cache_read": 0.5 + } + } +} diff --git a/internal/usagestats/prices.go b/internal/usagestats/prices.go new file mode 100644 index 00000000000..8918236b03b --- /dev/null +++ b/internal/usagestats/prices.go @@ -0,0 +1,219 @@ +package usagestats + +import ( + "context" + "database/sql" + _ "embed" + "encoding/json" + "errors" + "fmt" + "math" + "strings" +) + +// defaultPricesJSON holds public list prices, in USD per one million tokens. +// The file records its source and retrieval date. +// +//go:embed default_prices.json +var defaultPricesJSON []byte + +const settingsKey = "settings" + +// Price is a model's list price in USD per one million tokens. Optional +// buckets fall back to the nearest broader price when the model has no +// dedicated rate for them. +type Price struct { + Input float64 `json:"input"` + Output float64 `json:"output"` + CacheRead *float64 `json:"cache_read,omitempty"` + CacheWrite5m *float64 `json:"cache_write_5m,omitempty"` + CacheWrite1h *float64 `json:"cache_write_1h,omitempty"` +} + +// AccountPlan is what an upstream subscription costs per month, used to +// compare the API-equivalent value an account delivered against its price. +type AccountPlan struct { + Provider string `json:"provider"` + Account string `json:"account"` + MonthlyUSD float64 `json:"monthly_usd"` +} + +// Settings are the operator-editable dashboard settings stored with the data. +type Settings struct { + // ModelPrices override or extend the built-in price table, keyed by model name. + ModelPrices map[string]Price `json:"model_prices"` + AccountPlans []AccountPlan `json:"account_plans"` +} + +// PriceTable resolves a model name to its price. +type PriceTable struct { + prices map[string]Price +} + +// DefaultPrices returns the built-in price table. +func DefaultPrices() (map[string]Price, error) { + var file struct { + Models map[string]Price `json:"models"` + } + if errDecode := json.Unmarshal(defaultPricesJSON, &file); errDecode != nil { + return nil, fmt.Errorf("usagestats: decode built-in prices: %w", errDecode) + } + return file.Models, nil +} + +// NewPriceTable builds a price table from the built-in prices overlaid with overrides. +func NewPriceTable(overrides map[string]Price) (*PriceTable, error) { + defaults, errDefaults := DefaultPrices() + if errDefaults != nil { + return nil, errDefaults + } + prices := make(map[string]Price, len(defaults)+len(overrides)) + for model, price := range defaults { + prices[normalizeModel(model)] = price + } + for model, price := range overrides { + prices[normalizeModel(model)] = price + } + return &PriceTable{prices: prices}, nil +} + +// Lookup returns the price for model. It matches the exact name first, then +// the longest priced name that model extends at a separator, so dated or +// suffixed variants ("claude-opus-4-5-20251101", "gemini-4-argon-high") +// resolve to their base model while "gpt-5.4" never matches "gpt-5". +func (t *PriceTable) Lookup(model string) (Price, bool) { + if t == nil { + return Price{}, false + } + model = normalizeModel(model) + if price, ok := t.prices[model]; ok { + return price, true + } + for end := len(model) - 1; end > 0; end-- { + if isModelNameChar(model[end]) { + continue + } + if price, ok := t.prices[model[:end]]; ok { + return price, true + } + } + return Price{}, false +} + +func normalizeModel(model string) string { + return strings.ToLower(strings.TrimSpace(model)) +} + +// isModelNameChar reports whether c continues a model name component. Dots +// count, so version numbers are never split ("5.4" is one component). +func isModelNameChar(c byte) bool { + return c == '.' || (c >= '0' && c <= '9') || (c >= 'a' && c <= 'z') +} + +// cost returns the USD cost of the given token buckets at this price. +func (p Price) cost(input, cacheRead, cacheWrite, cacheWrite1h, output int64) float64 { + cacheReadPrice := p.Input + if p.CacheRead != nil { + cacheReadPrice = *p.CacheRead + } + write5mPrice := p.Input + if p.CacheWrite5m != nil { + write5mPrice = *p.CacheWrite5m + } + write1hPrice := write5mPrice + if p.CacheWrite1h != nil { + write1hPrice = *p.CacheWrite1h + } + // Cache writes without a reported TTL are billed at the default (5m) rate. + otherWrites := max(cacheWrite-cacheWrite1h, 0) + total := float64(input)*p.Input + + float64(cacheRead)*cacheReadPrice + + float64(otherWrites)*write5mPrice + + float64(cacheWrite1h)*write1hPrice + + float64(output)*p.Output + return total / 1e6 +} + +// Validate reports whether the settings are well formed. +func (s Settings) Validate() error { + for model, price := range s.ModelPrices { + if normalizeModel(model) == "" { + return errors.New("model price has an empty model name") + } + for _, value := range []*float64{&price.Input, &price.Output, price.CacheRead, price.CacheWrite5m, price.CacheWrite1h} { + if value != nil && !validAmount(*value) { + return fmt.Errorf("model %q has an invalid price", model) + } + } + } + for _, plan := range s.AccountPlans { + if strings.TrimSpace(plan.Account) == "" { + return errors.New("account plan has an empty account") + } + if !validAmount(plan.MonthlyUSD) { + return fmt.Errorf("account %q has an invalid monthly price", plan.Account) + } + } + return nil +} + +func validAmount(v float64) bool { + return v >= 0 && !math.IsNaN(v) && !math.IsInf(v, 0) +} + +// Settings returns the stored settings, or empty settings when none are stored. +func (s *Store) Settings(ctx context.Context) (Settings, error) { + settings := Settings{ModelPrices: map[string]Price{}, AccountPlans: []AccountPlan{}} + if s == nil { + return settings, errors.New("usagestats: store is closed") + } + var raw string + errScan := s.db.QueryRowContext(ctx, "SELECT value FROM settings WHERE key = ?", settingsKey).Scan(&raw) + if errors.Is(errScan, sql.ErrNoRows) { + return settings, nil + } + if errScan != nil { + return settings, fmt.Errorf("usagestats: read settings: %w", errScan) + } + if errDecode := json.Unmarshal([]byte(raw), &settings); errDecode != nil { + return settings, fmt.Errorf("usagestats: decode settings: %w", errDecode) + } + if settings.ModelPrices == nil { + settings.ModelPrices = map[string]Price{} + } + if settings.AccountPlans == nil { + settings.AccountPlans = []AccountPlan{} + } + return settings, nil +} + +// SaveSettings validates and stores the settings. +func (s *Store) SaveSettings(ctx context.Context, settings Settings) error { + if s == nil { + return errors.New("usagestats: store is closed") + } + if errValidate := settings.Validate(); errValidate != nil { + return errValidate + } + raw, errEncode := json.Marshal(settings) + if errEncode != nil { + return fmt.Errorf("usagestats: encode settings: %w", errEncode) + } + _, errExec := s.db.ExecContext(ctx, + "INSERT INTO settings (key, value) VALUES (?, ?) ON CONFLICT (key) DO UPDATE SET value = excluded.value", + settingsKey, string(raw), + ) + if errExec != nil { + return fmt.Errorf("usagestats: save settings: %w", errExec) + } + return nil +} + +// priceTable builds the effective price table from the stored settings. +func (s *Store) priceTable(ctx context.Context) (*PriceTable, error) { + settings, errSettings := s.Settings(ctx) + if errSettings != nil { + return nil, errSettings + } + return NewPriceTable(settings.ModelPrices) +} diff --git a/internal/usagestats/project.go b/internal/usagestats/project.go new file mode 100644 index 00000000000..b088afd62d2 --- /dev/null +++ b/internal/usagestats/project.go @@ -0,0 +1,172 @@ +package usagestats + +import ( + "strings" + + "github.com/MoeForge/moego/ojson" +) + +const ( + // maxWorkingDirLength bounds the stored working directory. + maxWorkingDirLength = 512 + // codexContextScanItems bounds how many leading input items are inspected. + // Codex emits its environment context at the start of the conversation, and + // scanning a long history on every request would be wasted work. + codexContextScanItems = 8 +) + +// claudeWorkingDirMarkers are the system prompt line prefixes Claude Code uses +// to announce its working directory, newest first. +var claudeWorkingDirMarkers = []string{"Primary working directory: ", "Working directory: "} + +// ExtractWorkingDir returns the working directory a coding agent declared in +// its request payload, or "" when the payload declares none. +// +// It only reads the fields where agents state their environment (the Anthropic +// "system" prompt and the Responses "input" messages), so a path that merely +// appears in conversation content is never mistaken for the working directory. +func ExtractWorkingDir(payload []byte) string { + if len(payload) == 0 { + return "" + } + // NoCopy avoids duplicating multi-megabyte payloads; cleanWorkingDir clones + // the result so it never pins the payload buffer. + if dir := claudeWorkingDir(ojson.GetBytesNoCopy(payload, "system")); dir != "" { + return dir + } + return codexWorkingDir(ojson.GetBytesNoCopy(payload, "input")) +} + +// claudeWorkingDir reads the working directory from an Anthropic system prompt, +// which is either a string or an array of text blocks. +func claudeWorkingDir(system ojson.Result) string { + if !system.Exists() { + return "" + } + if !system.IsArray() { + return workingDirFromLine(system.String()) + } + found := "" + system.ForEach(func(_, block ojson.Result) bool { + found = workingDirFromLine(block.Get("text").String()) + return found == "" + }) + return found +} + +func workingDirFromLine(text string) string { + for _, marker := range claudeWorkingDirMarkers { + start := strings.Index(text, marker) + if start < 0 { + continue + } + // Require the marker to start a line (optionally as a "- " list item) so + // "Primary working directory: " is not also matched as "Working directory: ". + prefix := strings.TrimRight(text[:start], " \t-") + if prefix != "" && !strings.HasSuffix(prefix, "\n") { + continue + } + rest := text[start+len(marker):] + if end := strings.IndexAny(rest, "\r\n"); end >= 0 { + rest = rest[:end] + } + if dir := cleanWorkingDir(rest); dir != "" { + return dir + } + } + return "" +} + +// codexWorkingDir reads the tag Codex places in its environment context +// message at the start of the Responses input list. +func codexWorkingDir(input ojson.Result) string { + if !input.IsArray() { + return "" + } + found := "" + scanned := 0 + input.ForEach(func(_, item ojson.Result) bool { + if scanned++; scanned > codexContextScanItems { + return false + } + content := item.Get("content") + if content.IsArray() { + content.ForEach(func(_, part ojson.Result) bool { + found = workingDirFromCwdTag(part.Get("text").String()) + return found == "" + }) + } else { + found = workingDirFromCwdTag(content.String()) + } + return found == "" + }) + return found +} + +func workingDirFromCwdTag(text string) string { + if !strings.Contains(text, "") { + return "" + } + start := strings.Index(text, "") + if start < 0 { + return "" + } + rest := text[start+len(""):] + end := strings.Index(rest, "") + if end < 0 { + return "" + } + return cleanWorkingDir(rest[:end]) +} + +func cleanWorkingDir(dir string) string { + dir = strings.TrimSpace(dir) + if len(dir) > maxWorkingDirLength { + return "" + } + return strings.Clone(dir) +} + +// ProjectFromWorkingDir derives a project name from a working directory: the +// repository directory name, with agent worktree checkouts folded back into +// the repository they belong to. +func ProjectFromWorkingDir(dir string) string { + dir = strings.TrimRight(strings.ReplaceAll(strings.TrimSpace(dir), "\\", "/"), "/") + if dir == "" { + return "" + } + segments := strings.Split(dir, "/") + for i, segment := range segments { + if i == 0 { + continue + } + if segment == ".worktrees" { + // /.worktrees/ + if segments[i-1] != "" { + return segments[i-1] + } + continue + } + if segment != "worktrees" { + continue + } + switch segments[i-1] { + case ".claude": + // /.claude/worktrees/ + if i >= 2 && segments[i-2] != "" { + return segments[i-2] + } + case ".t3": + // ~/.t3/worktrees// + if i+1 < len(segments) { + return segments[i+1] + } + case ".codex": + // ~/.codex/worktrees// + if i+2 < len(segments) { + return segments[i+2] + } + } + } + return segments[len(segments)-1] +} diff --git a/internal/usagestats/project_test.go b/internal/usagestats/project_test.go new file mode 100644 index 00000000000..8be56dae3e2 --- /dev/null +++ b/internal/usagestats/project_test.go @@ -0,0 +1,134 @@ +package usagestats + +import ( + "encoding/json" + "strings" + "testing" +) + +func mustJSON(t *testing.T, value any) []byte { + t.Helper() + raw, errMarshal := json.Marshal(value) + if errMarshal != nil { + t.Fatalf("marshal payload: %v", errMarshal) + } + return raw +} + +func TestExtractWorkingDirFromClaudeSystemPrompt(t *testing.T) { + const windowsDir = `C:\Users\me\repos\new-app` + cases := map[string]struct { + system any + want string + }{ + "text blocks, current format": { + system: []map[string]string{ + {"type": "text", "text": "You are Claude Code."}, + {"type": "text", "text": "# Environment\n - Primary working directory: " + windowsDir + "\n - Is a git repository: true"}, + }, + want: windowsDir, + }, + "string prompt, legacy format": { + system: "\nWorking directory: /home/me/repos/app\nIs directory a git repo: Yes\n", + want: "/home/me/repos/app", + }, + "primary wins over additional directories": { + system: "Primary working directory: /a/primary\nAdditional working directories:\n - /b/other", + want: "/a/primary", + }, + "marker inside a sentence is ignored": { + system: "Never change the Working directory: it is fixed.", + want: "", + }, + "no marker": { + system: "You are a helpful assistant.", + want: "", + }, + } + for name, tc := range cases { + payload := mustJSON(t, map[string]any{ + "model": "claude-opus-5-5", + "system": tc.system, + "messages": []map[string]string{{"role": "user", "content": "hi"}}, + }) + if got := ExtractWorkingDir(payload); got != tc.want { + t.Errorf("%s: ExtractWorkingDir = %q, want %q", name, got, tc.want) + } + } +} + +func TestExtractWorkingDirIgnoresConversationContent(t *testing.T) { + // A path quoted in the conversation is not the agent's environment. + payload := mustJSON(t, map[string]any{ + "system": "You are a helpful assistant.", + "messages": []map[string]string{ + {"role": "user", "content": "Primary working directory: /not/the/real/one"}, + }, + }) + if got := ExtractWorkingDir(payload); got != "" { + t.Fatalf("ExtractWorkingDir = %q, want empty", got) + } +} + +func TestExtractWorkingDirFromCodexEnvironmentContext(t *testing.T) { + environment := "\n /home/me/repos/service\n zsh\n" + structured := mustJSON(t, map[string]any{ + "instructions": "You are Codex.", + "input": []map[string]any{ + {"type": "message", "role": "user", "content": []map[string]string{{"type": "input_text", "text": environment}}}, + {"type": "message", "role": "user", "content": []map[string]string{{"type": "input_text", "text": "fix the bug"}}}, + }, + }) + if got := ExtractWorkingDir(structured); got != "/home/me/repos/service" { + t.Errorf("structured content: ExtractWorkingDir = %q", got) + } + + plain := mustJSON(t, map[string]any{ + "input": []map[string]any{{"role": "user", "content": environment}}, + }) + if got := ExtractWorkingDir(plain); got != "/home/me/repos/service" { + t.Errorf("string content: ExtractWorkingDir = %q", got) + } + + // A tag outside an environment context is conversation content. + quoted := mustJSON(t, map[string]any{ + "input": []map[string]any{{"role": "user", "content": "what does /x mean?"}}, + }) + if got := ExtractWorkingDir(quoted); got != "" { + t.Errorf("quoted tag: ExtractWorkingDir = %q, want empty", got) + } +} + +func TestExtractWorkingDirHandlesUnusablePayloads(t *testing.T) { + oversized := mustJSON(t, map[string]any{"system": "Working directory: /" + strings.Repeat("x", maxWorkingDirLength)}) + for name, payload := range map[string][]byte{ + "empty": nil, + "not json": []byte("not json at all"), + "string input": []byte(`{"input":"hello"}`), + "oversized dir": oversized, + } { + if got := ExtractWorkingDir(payload); got != "" { + t.Errorf("%s: ExtractWorkingDir = %q, want empty", name, got) + } + } +} + +func TestProjectFromWorkingDir(t *testing.T) { + cases := map[string]string{ + "": "", + "/home/me/repos/app": "app", + "/home/me/repos/app/": "app", + `C:\Users\me\repos\CLIProxyAPI`: "CLIProxyAPI", + `C:\Users\me\.t3\worktrees\CLIProxyAPI\t3-bacb3dc8-adcd500e58eb3fff`: "CLIProxyAPI", + "/home/me/repos/app/.claude/worktrees/feature-x": "app", + "/home/me/.codex/worktrees/1a2b/service": "service", + `C:\Users\me\repos\app\.worktrees\feat-password`: "app", + "/home/me/repos/app/.worktrees/fix/nested-branch": "app", + "/srv/worktrees/thing": "thing", + } + for dir, want := range cases { + if got := ProjectFromWorkingDir(dir); got != want { + t.Errorf("ProjectFromWorkingDir(%q) = %q, want %q", dir, got, want) + } + } +} diff --git a/internal/usagestats/query.go b/internal/usagestats/query.go new file mode 100644 index 00000000000..a1e13b91d8d --- /dev/null +++ b/internal/usagestats/query.go @@ -0,0 +1,361 @@ +package usagestats + +import ( + "context" + "database/sql" + "errors" + "fmt" + "slices" + "sort" + "strings" + "time" + + log "github.com/sirupsen/logrus" +) + +// DefaultGroupLimit caps how many groups an aggregate query returns. +const DefaultGroupLimit = 2000 + +// ErrInvalidQuery marks errors caused by a malformed query rather than storage. +var ErrInvalidQuery = errors.New("invalid usage query") + +// dimensionColumns maps the dimensions a query may group or filter by to +// their column. The time buckets "day" and "hour" are handled separately +// because they depend on the caller's UTC offset. +var dimensionColumns = map[string]string{ + "model": "model", + "alias": "alias", + "provider": "provider", + "account": "account", + "auth_index": "auth_index", + "auth_type": "auth_type", + "client_key": "client_key", + "client_ip": "client_ip", + "user_agent": "user_agent", + "endpoint": "endpoint", + "project": "project", + "session_id": "session_id", + "reasoning_effort": "reasoning_effort", + "service_tier": "service_tier", + "status_code": "status_code", + "stream": "stream", + "failed": "failed", +} + +// Query selects and groups usage rows. +type Query struct { + // From and To bound the request time as [From, To). + From time.Time + To time.Time + // UTCOffset shifts timestamps before bucketing into days or hours, so + // buckets follow the viewer's calendar rather than UTC. + UTCOffset time.Duration + // GroupBy lists dimensions; "day" and "hour" are also accepted. + GroupBy []string + // Filters restrict rows to exact dimension values. + Filters map[string]string + // Limit caps the number of groups; zero means DefaultGroupLimit. + Limit int +} + +// Metrics are the summed measures of a group of rows. Token buckets are +// mutually exclusive, so TotalTokens is their sum. +type Metrics struct { + Requests int64 `json:"requests"` + Failed int64 `json:"failed"` + InputTokens int64 `json:"input_tokens"` + CacheReadTokens int64 `json:"cache_read_tokens"` + CacheWriteTokens int64 `json:"cache_write_tokens"` + OutputTokens int64 `json:"output_tokens"` + ReasoningTokens int64 `json:"reasoning_tokens"` + UnclassifiedTokens int64 `json:"unclassified_tokens"` + TotalTokens int64 `json:"total_tokens"` + WebSearchRequests int64 `json:"web_search_requests"` + // CostUSD is the API-equivalent cost at list prices. + CostUSD float64 `json:"cost_usd"` + // UnpricedTokens counts tokens that CostUSD does not cover, either because + // the model has no known price or because the tokens were unclassified. + UnpricedTokens int64 `json:"unpriced_tokens"` +} + +// Group is one aggregate row: its dimension values and summed metrics. +type Group struct { + Dimensions map[string]string `json:"dimensions"` + Metrics +} + +// Aggregate is the result of an aggregate query. +type Aggregate struct { + Groups []Group `json:"groups"` + // Truncated reports that groups beyond the limit were dropped, smallest first. + Truncated bool `json:"truncated"` + // UnpricedModels lists models seen in the range that have no known price. + UnpricedModels []string `json:"unpriced_models"` +} + +// dimensionExpr returns the SQL expression for a dimension. +func dimensionExpr(name string, offset time.Duration) (string, error) { + // The offset is an integer computed from a duration, so it is safe to inline. + // Bound parameters cannot be reused across SELECT and GROUP BY positions. + shifted := fmt.Sprintf("(ts_ms + %d) / 1000", offset.Milliseconds()) + switch name { + case "day": + return "strftime('%Y-%m-%d', " + shifted + ", 'unixepoch')", nil + case "hour": + return "strftime('%Y-%m-%dT%H:00', " + shifted + ", 'unixepoch')", nil + } + if column, ok := dimensionColumns[name]; ok { + return column, nil + } + return "", fmt.Errorf("%w: unknown dimension %q", ErrInvalidQuery, name) +} + +// whereClause builds the row filter shared by every query. +func (q Query) whereClause() (string, []any, error) { + if q.From.IsZero() || q.To.IsZero() || !q.To.After(q.From) { + return "", nil, fmt.Errorf("%w: from must be before to", ErrInvalidQuery) + } + clauses := []string{"ts_ms >= ?", "ts_ms < ?"} + args := []any{q.From.UnixMilli(), q.To.UnixMilli()} + // Sorted for a stable statement text. + names := make([]string, 0, len(q.Filters)) + for name := range q.Filters { + names = append(names, name) + } + sort.Strings(names) + for _, name := range names { + column, ok := dimensionColumns[name] + if !ok { + return "", nil, fmt.Errorf("%w: unknown filter %q", ErrInvalidQuery, name) + } + clauses = append(clauses, column+" = ?") + args = append(args, q.Filters[name]) + } + return strings.Join(clauses, " AND "), args, nil +} + +// Aggregate groups the selected rows by the query's dimensions and prices them. +func (s *Store) Aggregate(ctx context.Context, q Query) (Aggregate, error) { + result := Aggregate{Groups: []Group{}, UnpricedModels: []string{}} + if s == nil { + return result, errors.New("usagestats: store is closed") + } + where, args, errWhere := q.whereClause() + if errWhere != nil { + return result, errWhere + } + exprs := make([]string, 0, len(q.GroupBy)+1) + for _, name := range q.GroupBy { + expr, errExpr := dimensionExpr(name, q.UTCOffset) + if errExpr != nil { + return result, errExpr + } + exprs = append(exprs, expr) + } + // Rows are always grouped by model as well, because cost depends on the + // model. Groups are then folded back to the requested dimensions. + exprs = append(exprs, "model") + positions := make([]string, len(exprs)) + for i := range exprs { + positions[i] = fmt.Sprint(i + 1) + } + statement := "SELECT " + strings.Join(exprs, ", ") + `, + COUNT(*), SUM(failed), SUM(input_tokens), SUM(cache_read_tokens), SUM(cache_write_tokens), + SUM(cache_write_1h_tokens), SUM(output_tokens), SUM(reasoning_tokens), SUM(unclassified_tokens), + SUM(total_tokens), SUM(web_search_requests) + FROM requests WHERE ` + where + " GROUP BY " + strings.Join(positions, ", ") + + prices, errPrices := s.priceTable(ctx) + if errPrices != nil { + return result, errPrices + } + rows, errQuery := s.db.QueryContext(ctx, statement, args...) + if errQuery != nil { + return result, fmt.Errorf("usagestats: aggregate: %w", errQuery) + } + defer closeRows(rows) + + groups := make(map[string]*Group) + unpriced := make(map[string]struct{}) + values := make([]string, len(exprs)) + for rows.Next() { + var m Metrics + var cacheWrite1h int64 + dest := make([]any, 0, len(exprs)+11) + for i := range values { + dest = append(dest, &values[i]) + } + dest = append(dest, &m.Requests, &m.Failed, &m.InputTokens, &m.CacheReadTokens, &m.CacheWriteTokens, + &cacheWrite1h, &m.OutputTokens, &m.ReasoningTokens, &m.UnclassifiedTokens, &m.TotalTokens, &m.WebSearchRequests) + if errScan := rows.Scan(dest...); errScan != nil { + return result, fmt.Errorf("usagestats: scan aggregate row: %w", errScan) + } + model := values[len(values)-1] + if price, ok := prices.Lookup(model); ok { + m.CostUSD = price.cost(m.InputTokens, m.CacheReadTokens, m.CacheWriteTokens, cacheWrite1h, m.OutputTokens+m.ReasoningTokens) + m.UnpricedTokens = m.UnclassifiedTokens + } else { + m.UnpricedTokens = m.TotalTokens + if m.TotalTokens > 0 { + unpriced[model] = struct{}{} + } + } + + dims := values[:len(q.GroupBy)] + key := strings.Join(dims, "\x00") + group, ok := groups[key] + if !ok { + group = &Group{Dimensions: make(map[string]string, len(dims))} + for i, name := range q.GroupBy { + group.Dimensions[name] = dims[i] + } + groups[key] = group + } + group.add(m) + } + if errRows := rows.Err(); errRows != nil { + return result, fmt.Errorf("usagestats: read aggregate rows: %w", errRows) + } + + keys := make([]string, 0, len(groups)) + for key := range groups { + keys = append(keys, key) + } + limit := q.Limit + if limit <= 0 { + limit = DefaultGroupLimit + } + if len(keys) > limit { + // Keep the largest groups, which are the ones a dashboard shows. + sort.Slice(keys, func(i, j int) bool { return groups[keys[i]].TotalTokens > groups[keys[j]].TotalTokens }) + keys = keys[:limit] + result.Truncated = true + } + sort.Strings(keys) + for _, key := range keys { + result.Groups = append(result.Groups, *groups[key]) + } + for model := range unpriced { + result.UnpricedModels = append(result.UnpricedModels, model) + } + sort.Strings(result.UnpricedModels) + return result, nil +} + +func (m *Metrics) add(other Metrics) { + m.Requests += other.Requests + m.Failed += other.Failed + m.InputTokens += other.InputTokens + m.CacheReadTokens += other.CacheReadTokens + m.CacheWriteTokens += other.CacheWriteTokens + m.OutputTokens += other.OutputTokens + m.ReasoningTokens += other.ReasoningTokens + m.UnclassifiedTokens += other.UnclassifiedTokens + m.TotalTokens += other.TotalTokens + m.WebSearchRequests += other.WebSearchRequests + m.CostUSD += other.CostUSD + m.UnpricedTokens += other.UnpricedTokens +} + +// Speed summarizes how fast the successful requests of a group completed. +type Speed struct { + Value string `json:"value"` + Requests int64 `json:"requests"` + LatencyP50Ms int64 `json:"latency_p50_ms"` + LatencyP95Ms int64 `json:"latency_p95_ms"` + TTFTP50Ms int64 `json:"ttft_p50_ms"` + TTFTP95Ms int64 `json:"ttft_p95_ms"` + // OutputTokensPerSecond is output throughput after the first token, over + // streamed requests, where generation time can be told apart from waiting. + OutputTokensPerSecond float64 `json:"output_tokens_per_second"` +} + +// Speed returns latency percentiles and output throughput of successful +// requests, grouped by one dimension. +func (s *Store) Speed(ctx context.Context, q Query, dimension string) ([]Speed, error) { + if s == nil { + return nil, errors.New("usagestats: store is closed") + } + column, ok := dimensionColumns[dimension] + if !ok { + return nil, fmt.Errorf("%w: unknown dimension %q", ErrInvalidQuery, dimension) + } + where, args, errWhere := q.whereClause() + if errWhere != nil { + return nil, errWhere + } + rows, errQuery := s.db.QueryContext(ctx, + "SELECT "+column+", latency_ms, ttft_ms, output_tokens + reasoning_tokens, stream FROM requests WHERE "+where+" AND failed = 0", + args..., + ) + if errQuery != nil { + return nil, fmt.Errorf("usagestats: speed: %w", errQuery) + } + defer closeRows(rows) + + type samples struct { + latency, ttft []int64 + outputTokens, generateMs int64 + } + byValue := make(map[string]*samples) + for rows.Next() { + var value string + var latency, ttft, output int64 + var stream bool + if errScan := rows.Scan(&value, &latency, &ttft, &output, &stream); errScan != nil { + return nil, fmt.Errorf("usagestats: scan speed row: %w", errScan) + } + group, okGroup := byValue[value] + if !okGroup { + group = &samples{} + byValue[value] = group + } + group.latency = append(group.latency, latency) + if ttft > 0 { + group.ttft = append(group.ttft, ttft) + } + if stream && ttft > 0 && latency > ttft && output > 0 { + group.outputTokens += output + group.generateMs += latency - ttft + } + } + if errRows := rows.Err(); errRows != nil { + return nil, fmt.Errorf("usagestats: read speed rows: %w", errRows) + } + + result := make([]Speed, 0, len(byValue)) + for value, group := range byValue { + slices.Sort(group.latency) + slices.Sort(group.ttft) + speed := Speed{ + Value: value, + Requests: int64(len(group.latency)), + LatencyP50Ms: percentile(group.latency, 50), + LatencyP95Ms: percentile(group.latency, 95), + TTFTP50Ms: percentile(group.ttft, 50), + TTFTP95Ms: percentile(group.ttft, 95), + } + if group.generateMs > 0 { + speed.OutputTokensPerSecond = float64(group.outputTokens) / (float64(group.generateMs) / 1000) + } + result = append(result, speed) + } + sort.Slice(result, func(i, j int) bool { return result[i].Value < result[j].Value }) + return result, nil +} + +// closeRows releases a result set, logging rather than dropping a close error. +func closeRows(rows *sql.Rows) { + if errClose := rows.Close(); errClose != nil { + log.Warnf("usage statistics: close rows: %v", errClose) + } +} + +// percentile returns the nearest-rank percentile of sorted values, or 0 when empty. +func percentile(sorted []int64, p int) int64 { + if len(sorted) == 0 { + return 0 + } + rank := (len(sorted)*p + 99) / 100 + return sorted[max(rank, 1)-1] +} diff --git a/internal/usagestats/recorder.go b/internal/usagestats/recorder.go new file mode 100644 index 00000000000..3ce34c0d1b0 --- /dev/null +++ b/internal/usagestats/recorder.go @@ -0,0 +1,219 @@ +package usagestats + +import ( + "context" + "net/http" + "strings" + "sync" + "sync/atomic" + "time" + + internallogging "github.com/router-for-me/CLIProxyAPI/v8/internal/logging" + "github.com/router-for-me/CLIProxyAPI/v8/internal/util" + coresession "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/session" + coreusage "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/usage" + log "github.com/sirupsen/logrus" +) + +// authTypeOAuth is the auth kind whose usage source is an account identity +// (an email or project) rather than an upstream API key. +const authTypeOAuth = "oauth" + +var ( + current atomic.Pointer[Store] + configureMu sync.Mutex +) + +func init() { + coreusage.RegisterPlugin(recorder{}) +} + +// Configure opens the usage store at path when enabled, and closes it otherwise. +// It is safe to call repeatedly, for example on every config reload. +func Configure(enabled bool, path string) error { + configureMu.Lock() + defer configureMu.Unlock() + + existing := current.Load() + if enabled && existing != nil && existing.Path() == path { + return nil + } + if existing != nil { + current.Store(nil) + if errClose := existing.Close(); errClose != nil { + log.Warnf("usage statistics: close store: %v", errClose) + } + } + if !enabled { + return nil + } + store, errOpen := Open(path) + if errOpen != nil { + return errOpen + } + current.Store(store) + log.Infof("usage statistics: recording to %s", path) + return nil +} + +// Shutdown closes the usage store. +func Shutdown() error { + return Configure(false, "") +} + +// Current returns the open usage store, or nil when recording is disabled. +func Current() *Store { + return current.Load() +} + +// Enabled reports whether usage records are being persisted. +func Enabled() bool { + return current.Load() != nil +} + +// recorder persists every usage record published by the proxy runtime. +type recorder struct{} + +func (recorder) HandleUsage(ctx context.Context, record coreusage.Record) { + store := current.Load() + if store == nil { + return + } + row := rowFromRecord(ctx, record) + // The request context is usually already cancelled by the time usage is + // published, so storage runs on its own context. + storeCtx := context.Background() + if row.WorkingDir == "" && row.SessionID != "" { + dir, errLookup := store.WorkingDirForSession(storeCtx, row.SessionID) + if errLookup != nil { + log.Warnf("usage statistics: %v", errLookup) + } + row.WorkingDir = dir + } + row.Project = ProjectFromWorkingDir(row.WorkingDir) + if errInsert := store.Insert(storeCtx, row); errInsert != nil { + log.Warnf("usage statistics: %v", errInsert) + } +} + +// rowFromRecord flattens a usage record and its request context into a row. +func rowFromRecord(ctx context.Context, record coreusage.Record) Row { + meta := internallogging.GetClientRequestMetadata(ctx) + + timestamp := record.RequestedAt + if timestamp.IsZero() { + timestamp = time.Now() + } + + failed := record.Failed + if !failed { + failed = internallogging.GetResponseStatus(ctx) >= http.StatusBadRequest + } + statusCode := http.StatusOK + if failed { + statusCode = record.Fail.StatusCode + if statusCode <= 0 { + statusCode = internallogging.GetResponseStatus(ctx) + } + if statusCode <= 0 { + statusCode = http.StatusInternalServerError + } + } + + sessionID := strings.TrimSpace(record.SessionID) + if sessionID == "" { + sessionID = strings.TrimSpace(meta.SessionID) + } + + clientIP := strings.TrimSpace(meta.ResolvedClientIP) + if clientIP == "" { + clientIP = strings.TrimSpace(meta.ClientIP) + } + + authType := strings.TrimSpace(record.AuthType) + account := strings.TrimSpace(record.Source) + if !strings.EqualFold(authType, authTypeOAuth) { + // For key-based credentials the source is the upstream API key itself. + account = util.HideAPIKey(account) + } + + detail := coreusage.EnsureTokenBreakdownForProvider(record.Detail, record.Provider, record.ExecutorType) + tokens := detail.TokenBreakdown + + row := Row{ + Timestamp: timestamp, + RequestID: strings.TrimSpace(record.RequestID), + TraceID: firstNonEmpty(record.TraceID, internallogging.GetRequestID(ctx)), + SessionID: coresession.NormalizeToCanonicalUUID(sessionID), + Provider: strings.TrimSpace(record.Provider), + ExecutorType: strings.TrimSpace(record.ExecutorType), + Model: strings.TrimSpace(record.Model), + Alias: firstNonEmpty(record.Alias, record.Model), + ResponseModel: strings.TrimSpace(record.ResponseModel), + Endpoint: strings.TrimSpace(internallogging.GetEndpoint(ctx)), + AuthIndex: strings.TrimSpace(record.AuthIndex), + AuthType: authType, + Account: account, + ClientKey: util.HideAPIKey(strings.TrimSpace(record.APIKey)), + ClientIP: clientIP, + UserAgent: strings.TrimSpace(meta.UserAgent), + WorkingDir: strings.TrimSpace(meta.WorkingDir), + ReasoningEffort: firstNonEmpty(record.ReasoningEffort, coreusage.ReasoningEffortFromContext(ctx)), + ServiceTier: serviceTierOf(ctx, record), + Stream: record.Stream || coreusage.StreamFromContext(ctx), + Generate: coreusage.GenerateEnabled(record.Generate), + Failed: failed, + StatusCode: statusCode, + LatencyMs: record.Latency.Milliseconds(), + TTFTMs: record.TTFT.Milliseconds(), + + InputTokens: tokens.Input.UncachedTokens, + CacheReadTokens: tokens.Input.CacheReadTokens, + CacheWriteTokens: tokens.Input.CacheWriteTokens, + CacheWrite5mTokens: int64Value(detail.CacheCreation5mTokens), + CacheWrite1hTokens: int64Value(detail.CacheCreation1hTokens), + OutputTokens: tokens.Output.NonReasoningTokens, + ReasoningTokens: tokens.Output.ReasoningTokens, + UnclassifiedTokens: tokens.UnclassifiedTokens, + TotalTokens: tokens.TotalTokens, + WebSearchRequests: int64Value(detail.WebSearchRequests), + } + if failed { + row.FailCode = strings.TrimSpace(record.Fail.Code) + } + if detail.CostDetails != nil && detail.CostDetails.Cost != nil { + cost := *detail.CostDetails.Cost + row.UpstreamCostUSD = &cost + } + return row +} + +// serviceTierOf returns the tier the request was actually served at: the one +// the upstream reported, else the one sent upstream, else the one the client +// asked for. A client that asks for "auto" is usually served "standard". +func serviceTierOf(ctx context.Context, record coreusage.Record) string { + return firstNonEmpty( + record.ResponseServiceTier, + record.Detail.ResponseServiceTier, + record.EffectiveServiceTier, + record.ServiceTier, + record.RequestServiceTier, + coreusage.ServiceTierFromContext(ctx), + ) +} + +func firstNonEmpty(values ...string) string { + for _, value := range values { + if trimmed := strings.TrimSpace(value); trimmed != "" { + return trimmed + } + } + return "" +} + +func int64Value(v *int64) int64 { + if v == nil { + return 0 + } + return *v +} diff --git a/internal/usagestats/store.go b/internal/usagestats/store.go new file mode 100644 index 00000000000..de60502c0bb --- /dev/null +++ b/internal/usagestats/store.go @@ -0,0 +1,267 @@ +// Package usagestats persists one row per upstream model execution in a local +// SQLite database and answers aggregate queries over those rows. +// +// Rows are deliberately raw: every statistic (tokens per day, cost, cache hit +// ratio, latency) is derived at query time, so new statistics never require a +// schema change or a backfill. +package usagestats + +import ( + "context" + "database/sql" + "errors" + "fmt" + "net/url" + "os" + "path/filepath" + "strings" + "time" + + // Pure-Go SQLite driver, registered as "sqlite". + _ "modernc.org/sqlite" +) + +// DefaultFileName is the database file name placed under the auth directory. +const DefaultFileName = "usage.db" + +// Row is one persisted model execution. Token buckets are mutually exclusive, +// mirroring the canonical usage.TokenBreakdown contract, so they can be priced +// independently and summed without double counting. +type Row struct { + Timestamp time.Time + RequestID string + TraceID string + SessionID string + Provider string + ExecutorType string + Model string + Alias string + ResponseModel string + Endpoint string + AuthIndex string + AuthType string + Account string + ClientKey string + ClientIP string + UserAgent string + // WorkingDir is the directory the calling agent declared; Project is derived from it. + WorkingDir string + Project string + ReasoningEffort string + ServiceTier string + Stream bool + Generate bool + Failed bool + StatusCode int + FailCode string + LatencyMs int64 + TTFTMs int64 + + InputTokens int64 // uncached input + CacheReadTokens int64 + CacheWriteTokens int64 // all cache writes, including the TTL buckets below + CacheWrite5mTokens int64 + CacheWrite1hTokens int64 + OutputTokens int64 // non-reasoning output + ReasoningTokens int64 + UnclassifiedTokens int64 + TotalTokens int64 + + WebSearchRequests int64 + // UpstreamCostUSD is the cost reported by the upstream itself, when it reports one. + UpstreamCostUSD *float64 +} + +// migrations are applied in order; the database's user_version records how +// many have run. Append new statements, never edit an applied one. +var migrations = []string{ + `CREATE TABLE requests ( + id INTEGER PRIMARY KEY, + ts_ms INTEGER NOT NULL, + request_id TEXT NOT NULL, + trace_id TEXT NOT NULL, + session_id TEXT NOT NULL, + provider TEXT NOT NULL, + executor_type TEXT NOT NULL, + model TEXT NOT NULL, + alias TEXT NOT NULL, + response_model TEXT NOT NULL, + endpoint TEXT NOT NULL, + auth_index TEXT NOT NULL, + auth_type TEXT NOT NULL, + account TEXT NOT NULL, + client_key TEXT NOT NULL, + client_ip TEXT NOT NULL, + user_agent TEXT NOT NULL, + working_dir TEXT NOT NULL, + project TEXT NOT NULL, + reasoning_effort TEXT NOT NULL, + service_tier TEXT NOT NULL, + stream INTEGER NOT NULL, + generate INTEGER NOT NULL, + failed INTEGER NOT NULL, + status_code INTEGER NOT NULL, + fail_code TEXT NOT NULL, + latency_ms INTEGER NOT NULL, + ttft_ms INTEGER NOT NULL, + input_tokens INTEGER NOT NULL, + cache_read_tokens INTEGER NOT NULL, + cache_write_tokens INTEGER NOT NULL, + cache_write_5m_tokens INTEGER NOT NULL, + cache_write_1h_tokens INTEGER NOT NULL, + output_tokens INTEGER NOT NULL, + reasoning_tokens INTEGER NOT NULL, + unclassified_tokens INTEGER NOT NULL, + total_tokens INTEGER NOT NULL, + web_search_requests INTEGER NOT NULL, + upstream_cost_usd REAL +); +CREATE INDEX idx_requests_ts ON requests (ts_ms); +CREATE INDEX idx_requests_session ON requests (session_id) WHERE session_id != ''; +CREATE TABLE settings ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL +);`, +} + +const insertSQL = ` +INSERT INTO requests ( + ts_ms, request_id, trace_id, session_id, provider, executor_type, model, alias, response_model, + endpoint, auth_index, auth_type, account, client_key, client_ip, user_agent, working_dir, project, + reasoning_effort, service_tier, stream, generate, failed, status_code, fail_code, latency_ms, ttft_ms, + input_tokens, cache_read_tokens, cache_write_tokens, cache_write_5m_tokens, cache_write_1h_tokens, + output_tokens, reasoning_tokens, unclassified_tokens, total_tokens, web_search_requests, upstream_cost_usd +) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)` + +// Store is a SQLite-backed usage store. It is safe for concurrent use. +type Store struct { + db *sql.DB + insert *sql.Stmt + path string +} + +// Open opens (creating when needed) the usage database at path. +func Open(path string) (*Store, error) { + path = strings.TrimSpace(path) + if path == "" { + return nil, errors.New("usagestats: empty database path") + } + if errMkdir := os.MkdirAll(filepath.Dir(path), 0o700); errMkdir != nil { + return nil, fmt.Errorf("usagestats: create database directory: %w", errMkdir) + } + + // WAL keeps dashboard reads from blocking the writer; NORMAL is durable + // across process crashes, which is the failure mode that matters here. + pragmas := url.Values{} + pragmas.Add("_pragma", "journal_mode(WAL)") + pragmas.Add("_pragma", "synchronous(NORMAL)") + pragmas.Add("_pragma", "busy_timeout(5000)") + db, errOpen := sql.Open("sqlite", path+"?"+pragmas.Encode()) + if errOpen != nil { + return nil, fmt.Errorf("usagestats: open database: %w", errOpen) + } + if errMigrate := migrate(db); errMigrate != nil { + _ = db.Close() + return nil, errMigrate + } + insert, errPrepare := db.Prepare(insertSQL) + if errPrepare != nil { + _ = db.Close() + return nil, fmt.Errorf("usagestats: prepare insert: %w", errPrepare) + } + return &Store{db: db, insert: insert, path: path}, nil +} + +// migrate brings the schema up to date, one transaction per migration. +func migrate(db *sql.DB) error { + var version int + if errVersion := db.QueryRow("PRAGMA user_version").Scan(&version); errVersion != nil { + return fmt.Errorf("usagestats: read schema version: %w", errVersion) + } + if version > len(migrations) { + return fmt.Errorf("usagestats: database schema version %d is newer than this build supports (%d)", version, len(migrations)) + } + for i := version; i < len(migrations); i++ { + tx, errBegin := db.Begin() + if errBegin != nil { + return fmt.Errorf("usagestats: begin migration %d: %w", i+1, errBegin) + } + // PRAGMA does not accept bound parameters; the value is a loop index. + _, errApply := tx.Exec(fmt.Sprintf("%s\nPRAGMA user_version = %d;", migrations[i], i+1)) + if errApply != nil { + _ = tx.Rollback() + return fmt.Errorf("usagestats: apply migration %d: %w", i+1, errApply) + } + if errCommit := tx.Commit(); errCommit != nil { + return fmt.Errorf("usagestats: commit migration %d: %w", i+1, errCommit) + } + } + return nil +} + +// WorkingDirForSession returns the most recent working directory recorded for +// a session, or "" when none is known. Agents that only declare their working +// directory on the first request of a session rely on this for later requests. +func (s *Store) WorkingDirForSession(ctx context.Context, sessionID string) (string, error) { + if s == nil || sessionID == "" { + return "", nil + } + var dir string + errScan := s.db.QueryRowContext(ctx, + "SELECT working_dir FROM requests WHERE session_id = ? AND working_dir != '' ORDER BY id DESC LIMIT 1", + sessionID, + ).Scan(&dir) + if errors.Is(errScan, sql.ErrNoRows) { + return "", nil + } + if errScan != nil { + return "", fmt.Errorf("usagestats: look up session working directory: %w", errScan) + } + return dir, nil +} + +// Path returns the database file path. +func (s *Store) Path() string { + if s == nil { + return "" + } + return s.path +} + +// Insert persists one row. +func (s *Store) Insert(ctx context.Context, row Row) error { + if s == nil { + return errors.New("usagestats: store is closed") + } + var cost any + if row.UpstreamCostUSD != nil { + cost = *row.UpstreamCostUSD + } + _, errExec := s.insert.ExecContext(ctx, + row.Timestamp.UnixMilli(), row.RequestID, row.TraceID, row.SessionID, row.Provider, row.ExecutorType, + row.Model, row.Alias, row.ResponseModel, row.Endpoint, row.AuthIndex, row.AuthType, row.Account, + row.ClientKey, row.ClientIP, row.UserAgent, row.WorkingDir, row.Project, row.ReasoningEffort, row.ServiceTier, + row.Stream, row.Generate, row.Failed, row.StatusCode, row.FailCode, row.LatencyMs, row.TTFTMs, + row.InputTokens, row.CacheReadTokens, row.CacheWriteTokens, row.CacheWrite5mTokens, row.CacheWrite1hTokens, + row.OutputTokens, row.ReasoningTokens, row.UnclassifiedTokens, row.TotalTokens, row.WebSearchRequests, cost, + ) + if errExec != nil { + return fmt.Errorf("usagestats: insert row: %w", errExec) + } + return nil +} + +// Close releases the database. +func (s *Store) Close() error { + if s == nil { + return nil + } + errStmt := s.insert.Close() + if errDB := s.db.Close(); errDB != nil { + return fmt.Errorf("usagestats: close database: %w", errDB) + } + if errStmt != nil { + return fmt.Errorf("usagestats: close insert statement: %w", errStmt) + } + return nil +} diff --git a/internal/usagestats/usagestats_test.go b/internal/usagestats/usagestats_test.go new file mode 100644 index 00000000000..c3020b2ea18 --- /dev/null +++ b/internal/usagestats/usagestats_test.go @@ -0,0 +1,467 @@ +package usagestats + +import ( + "context" + "errors" + "math" + "path/filepath" + "testing" + "time" + + internallogging "github.com/router-for-me/CLIProxyAPI/v8/internal/logging" + coreusage "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/usage" +) + +func openTestStore(t *testing.T) *Store { + t.Helper() + store, errOpen := Open(filepath.Join(t.TempDir(), DefaultFileName)) + if errOpen != nil { + t.Fatalf("open store: %v", errOpen) + } + t.Cleanup(func() { + if errClose := store.Close(); errClose != nil { + t.Errorf("close store: %v", errClose) + } + }) + return store +} + +func mustInsert(t *testing.T, store *Store, rows ...Row) { + t.Helper() + for _, row := range rows { + if errInsert := store.Insert(context.Background(), row); errInsert != nil { + t.Fatalf("insert row: %v", errInsert) + } + } +} + +func groupByKey(t *testing.T, aggregate Aggregate, dimension string) map[string]Group { + t.Helper() + out := make(map[string]Group, len(aggregate.Groups)) + for _, group := range aggregate.Groups { + out[group.Dimensions[dimension]] = group + } + return out +} + +func approxEqual(a, b float64) bool { + return math.Abs(a-b) < 1e-9 +} + +var testDay = time.Date(2026, 11, 12, 0, 0, 0, 0, time.UTC) + +func TestAggregatePricesTokenBucketsIndependently(t *testing.T) { + store := openTestStore(t) + mustInsert(t, store, Row{ + Timestamp: testDay.Add(time.Hour), + Model: "claude-opus-5-5", + InputTokens: 1_000_000, + CacheReadTokens: 2_000_000, + CacheWriteTokens: 3_000_000, + CacheWrite5mTokens: 1_000_000, + CacheWrite1hTokens: 2_000_000, + OutputTokens: 400_000, + ReasoningTokens: 100_000, + TotalTokens: 6_500_000, + }) + + aggregate, errAggregate := store.Aggregate(context.Background(), Query{ + From: testDay, To: testDay.Add(24 * time.Hour), GroupBy: []string{"model"}, + }) + if errAggregate != nil { + t.Fatalf("aggregate: %v", errAggregate) + } + group := groupByKey(t, aggregate, "model")["claude-opus-5-5"] + + // Built-in prices per 1M: input 4, cache read 0.2, 5m write 5, 1h write 8, output 20. + want := 1*4.0 + 2*0.2 + 1*5.0 + 2*8.0 + 0.5*20.0 + if !approxEqual(group.CostUSD, want) { + t.Fatalf("cost = %v, want %v", group.CostUSD, want) + } + if group.UnpricedTokens != 0 || len(aggregate.UnpricedModels) != 0 { + t.Fatalf("unpriced tokens = %d, models = %v, want none", group.UnpricedTokens, aggregate.UnpricedModels) + } +} + +func TestAggregateFoldsModelsIntoRequestedGroups(t *testing.T) { + store := openTestStore(t) + mustInsert(t, store, + Row{Timestamp: testDay, Model: "claude-opus-5-5", Project: "alpha", InputTokens: 1_000_000, TotalTokens: 1_000_000}, + Row{Timestamp: testDay, Model: "claude-haiku-5-5", Project: "alpha", InputTokens: 1_000_000, TotalTokens: 1_000_000, Failed: true}, + Row{Timestamp: testDay, Model: "claude-opus-5-5", Project: "beta", InputTokens: 2_000_000, TotalTokens: 2_000_000}, + ) + + aggregate, errAggregate := store.Aggregate(context.Background(), Query{ + From: testDay, To: testDay.Add(time.Hour), GroupBy: []string{"project"}, + }) + if errAggregate != nil { + t.Fatalf("aggregate: %v", errAggregate) + } + groups := groupByKey(t, aggregate, "project") + if len(groups) != 2 { + t.Fatalf("groups = %d, want 2", len(groups)) + } + alpha := groups["alpha"] + if alpha.Requests != 2 || alpha.Failed != 1 || alpha.TotalTokens != 2_000_000 { + t.Fatalf("alpha = %+v", alpha.Metrics) + } + // Opus input 4 + Haiku input 0.1 per 1M: cost must be summed per model, not averaged. + if !approxEqual(alpha.CostUSD, 4.1) { + t.Fatalf("alpha cost = %v, want 4.1", alpha.CostUSD) + } + if !approxEqual(groups["beta"].CostUSD, 8) { + t.Fatalf("beta cost = %v, want 8", groups["beta"].CostUSD) + } +} + +func TestAggregateBucketsDaysInTheViewersOffset(t *testing.T) { + store := openTestStore(t) + // 02:00 UTC on the 12th is still the 11th in UTC-8. + mustInsert(t, store, Row{Timestamp: testDay.Add(2 * time.Hour), Model: "m", TotalTokens: 10}) + + query := Query{From: testDay.Add(-24 * time.Hour), To: testDay.Add(24 * time.Hour), GroupBy: []string{"day"}} + utc, errUTC := store.Aggregate(context.Background(), query) + if errUTC != nil { + t.Fatalf("aggregate utc: %v", errUTC) + } + if _, ok := groupByKey(t, utc, "day")["2026-11-12"]; !ok { + t.Fatalf("utc groups = %+v, want 2026-11-12", utc.Groups) + } + + query.UTCOffset = -8 * time.Hour + pacific, errPacific := store.Aggregate(context.Background(), query) + if errPacific != nil { + t.Fatalf("aggregate pacific: %v", errPacific) + } + if _, ok := groupByKey(t, pacific, "day")["2026-11-11"]; !ok { + t.Fatalf("pacific groups = %+v, want 2026-11-11", pacific.Groups) + } +} + +func TestAggregateReportsUnpricedModelsAndRespectsRangeAndFilters(t *testing.T) { + store := openTestStore(t) + mustInsert(t, store, + Row{Timestamp: testDay, Model: "mystery-model", Provider: "p1", TotalTokens: 500, InputTokens: 500}, + Row{Timestamp: testDay, Model: "claude-opus-5-5", Provider: "p2", TotalTokens: 100, InputTokens: 90, UnclassifiedTokens: 10}, + Row{Timestamp: testDay.Add(48 * time.Hour), Model: "claude-opus-5-5", Provider: "p2", TotalTokens: 999}, + ) + query := Query{From: testDay, To: testDay.Add(time.Hour)} + + all, errAll := store.Aggregate(context.Background(), query) + if errAll != nil { + t.Fatalf("aggregate: %v", errAll) + } + if len(all.Groups) != 1 || all.Groups[0].TotalTokens != 600 { + t.Fatalf("groups = %+v, want one group of 600 tokens", all.Groups) + } + if all.Groups[0].UnpricedTokens != 510 { + t.Fatalf("unpriced tokens = %d, want 510", all.Groups[0].UnpricedTokens) + } + if len(all.UnpricedModels) != 1 || all.UnpricedModels[0] != "mystery-model" { + t.Fatalf("unpriced models = %v", all.UnpricedModels) + } + + query.Filters = map[string]string{"provider": "p2"} + filtered, errFiltered := store.Aggregate(context.Background(), query) + if errFiltered != nil { + t.Fatalf("aggregate filtered: %v", errFiltered) + } + if len(filtered.Groups) != 1 || filtered.Groups[0].TotalTokens != 100 { + t.Fatalf("filtered groups = %+v, want one group of 100 tokens", filtered.Groups) + } +} + +func TestAggregateTruncatesToLargestGroups(t *testing.T) { + store := openTestStore(t) + mustInsert(t, store, + Row{Timestamp: testDay, Model: "m", Project: "small", TotalTokens: 1}, + Row{Timestamp: testDay, Model: "m", Project: "large", TotalTokens: 100}, + ) + aggregate, errAggregate := store.Aggregate(context.Background(), Query{ + From: testDay, To: testDay.Add(time.Hour), GroupBy: []string{"project"}, Limit: 1, + }) + if errAggregate != nil { + t.Fatalf("aggregate: %v", errAggregate) + } + if !aggregate.Truncated || len(aggregate.Groups) != 1 || aggregate.Groups[0].Dimensions["project"] != "large" { + t.Fatalf("aggregate = %+v, want only the large group and truncated", aggregate) + } +} + +func TestQueryRejectsMalformedInput(t *testing.T) { + store := openTestStore(t) + valid := Query{From: testDay, To: testDay.Add(time.Hour)} + cases := map[string]Query{ + "unknown dimension": {From: valid.From, To: valid.To, GroupBy: []string{"model; DROP TABLE requests"}}, + "unknown filter": {From: valid.From, To: valid.To, Filters: map[string]string{"nope": "x"}}, + "empty range": {From: valid.To, To: valid.From}, + } + for name, query := range cases { + if _, errAggregate := store.Aggregate(context.Background(), query); !errors.Is(errAggregate, ErrInvalidQuery) { + t.Errorf("%s: error = %v, want ErrInvalidQuery", name, errAggregate) + } + } + if _, errSpeed := store.Speed(context.Background(), valid, "day"); !errors.Is(errSpeed, ErrInvalidQuery) { + t.Errorf("speed by day: error = %v, want ErrInvalidQuery", errSpeed) + } +} + +func TestSpeedReportsPercentilesAndThroughputOfSuccessfulRequests(t *testing.T) { + store := openTestStore(t) + for i := int64(1); i <= 100; i++ { + mustInsert(t, store, Row{ + Timestamp: testDay, Model: "m", Stream: true, + LatencyMs: i * 100, TTFTMs: i * 10, OutputTokens: 90, ReasoningTokens: 10, + }) + } + // Failed requests must not skew latency. + mustInsert(t, store, Row{Timestamp: testDay, Model: "m", Failed: true, LatencyMs: 9_999_999}) + + speeds, errSpeed := store.Speed(context.Background(), Query{From: testDay, To: testDay.Add(time.Hour)}, "model") + if errSpeed != nil { + t.Fatalf("speed: %v", errSpeed) + } + if len(speeds) != 1 { + t.Fatalf("speeds = %+v, want one group", speeds) + } + got := speeds[0] + if got.Requests != 100 || got.LatencyP50Ms != 5000 || got.LatencyP95Ms != 9500 || got.TTFTP50Ms != 500 || got.TTFTP95Ms != 950 { + t.Fatalf("speed = %+v", got) + } + // 100 requests x 100 tokens over sum(i*90ms) = 454.5s of generation. + if want := 10000 / 454.5; math.Abs(got.OutputTokensPerSecond-want) > 1e-6 { + t.Fatalf("output tokens per second = %v, want %v", got.OutputTokensPerSecond, want) + } +} + +func TestSettingsOverridePricesAndPersistAcrossReopen(t *testing.T) { + path := filepath.Join(t.TempDir(), DefaultFileName) + store, errOpen := Open(path) + if errOpen != nil { + t.Fatalf("open store: %v", errOpen) + } + mustInsert(t, store, Row{Timestamp: testDay, Model: "mystery-model", InputTokens: 1_000_000, TotalTokens: 1_000_000}) + + settings := Settings{ + ModelPrices: map[string]Price{"Mystery-Model": {Input: 7, Output: 9}}, + AccountPlans: []AccountPlan{{Provider: "claude", Account: "me@example.com", MonthlyUSD: 200}}, + } + if errSave := store.SaveSettings(context.Background(), settings); errSave != nil { + t.Fatalf("save settings: %v", errSave) + } + if errClose := store.Close(); errClose != nil { + t.Fatalf("close store: %v", errClose) + } + + // Reopening must keep both rows and settings, and must not rerun migrations. + reopened, errReopen := Open(path) + if errReopen != nil { + t.Fatalf("reopen store: %v", errReopen) + } + defer func() { + if errClose := reopened.Close(); errClose != nil { + t.Errorf("close reopened store: %v", errClose) + } + }() + loaded, errLoad := reopened.Settings(context.Background()) + if errLoad != nil { + t.Fatalf("load settings: %v", errLoad) + } + if len(loaded.AccountPlans) != 1 || loaded.AccountPlans[0].MonthlyUSD != 200 { + t.Fatalf("account plans = %+v", loaded.AccountPlans) + } + aggregate, errAggregate := reopened.Aggregate(context.Background(), Query{From: testDay, To: testDay.Add(time.Hour)}) + if errAggregate != nil { + t.Fatalf("aggregate: %v", errAggregate) + } + if len(aggregate.Groups) != 1 || !approxEqual(aggregate.Groups[0].CostUSD, 7) || len(aggregate.UnpricedModels) != 0 { + t.Fatalf("aggregate = %+v, want cost 7 from the override", aggregate) + } +} + +func TestSaveSettingsRejectsInvalidValues(t *testing.T) { + store := openTestStore(t) + invalid := []Settings{ + {ModelPrices: map[string]Price{"m": {Input: -1}}}, + {ModelPrices: map[string]Price{" ": {Input: 1}}}, + {AccountPlans: []AccountPlan{{Account: "a", MonthlyUSD: math.NaN()}}}, + {AccountPlans: []AccountPlan{{Account: "", MonthlyUSD: 1}}}, + } + for i, settings := range invalid { + if errSave := store.SaveSettings(context.Background(), settings); errSave == nil { + t.Errorf("case %d: save succeeded, want validation error", i) + } + } +} + +func TestPriceLookupMatchesBaseModelAtSeparators(t *testing.T) { + table, errTable := NewPriceTable(map[string]Price{"gpt-5": {Input: 1}, "gpt-5.4": {Input: 2}}) + if errTable != nil { + t.Fatalf("new price table: %v", errTable) + } + cases := map[string]float64{ + "gpt-5": 1, + "GPT-5.4": 2, + "gpt-5.4-2026-01-01": 2, + "gpt-5-high": 1, + "gpt-5[1m]": 1, + } + for model, want := range cases { + price, ok := table.Lookup(model) + if !ok || price.Input != want { + t.Errorf("Lookup(%q) = %v, %v; want input %v", model, price.Input, ok, want) + } + } + // A longer version number is a different model, not a variant. + if price, ok := table.Lookup("gpt-5.45"); ok { + t.Errorf("Lookup(gpt-5.45) = %+v, want no match", price) + } + if _, ok := table.Lookup("unknown-model"); ok { + t.Error("Lookup(unknown-model) matched, want no match") + } +} + +func TestBuiltInPricesCoverCurrentModels(t *testing.T) { + table, errTable := NewPriceTable(nil) + if errTable != nil { + t.Fatalf("new price table: %v", errTable) + } + for _, model := range []string{"claude-fable-5-2", "claude-opus-4-5-20251101", "gpt-6-sol", "gemini-4-argon-high"} { + if price, ok := table.Lookup(model); !ok || price.Input <= 0 || price.Output <= 0 { + t.Errorf("Lookup(%q) = %+v, %v; want a positive price", model, price, ok) + } + } +} + +func TestRecorderPersistsUsageRecordWithoutSecrets(t *testing.T) { + path := filepath.Join(t.TempDir(), DefaultFileName) + if errConfigure := Configure(true, path); errConfigure != nil { + t.Fatalf("configure: %v", errConfigure) + } + t.Cleanup(func() { + if errShutdown := Shutdown(); errShutdown != nil { + t.Errorf("shutdown: %v", errShutdown) + } + }) + + ctx := internallogging.WithClientRequestMetadata(context.Background(), internallogging.ClientRequestMetadata{ + ResolvedClientIP: "100.64.0.1", + UserAgent: "claude-cli/9.9.9", + WorkingDir: `C:\Users\me\.t3\worktrees\CLIProxyAPI\t3-abc`, + }) + ctx = internallogging.WithEndpoint(ctx, "POST /v1/messages") + const sessionID = "0196f4a2-7c1e-7c3a-9d5e-3f2a1b4c5d6e" + record := coreusage.Record{ + RequestID: "exec-1", + Provider: "claude", + Model: "claude-opus-5-5", + AuthType: "apikey", + Source: "sk-ant-upstream-secret-key", + APIKey: "sk-client-secret-key", + SessionID: sessionID, + RequestedAt: testDay, + Latency: 1500 * time.Millisecond, + TTFT: 300 * time.Millisecond, + Detail: coreusage.Detail{ + TokenBreakdown: coreusage.NewIndependentTokenBreakdown(100, 200, 300, 40, 10, 650), + CacheCreation1hTokens: coreusage.Int64Ptr(300), + }, + } + recorder{}.HandleUsage(ctx, record) + + // A later request of the same session that declares no working directory + // inherits the session's project. + record.RequestID = "exec-2" + recorder{}.HandleUsage(context.Background(), record) + + store := Current() + aggregate, errAggregate := store.Aggregate(context.Background(), Query{ + From: testDay, To: testDay.Add(time.Hour), + GroupBy: []string{"project", "account", "client_key", "session_id"}, + }) + if errAggregate != nil { + t.Fatalf("aggregate: %v", errAggregate) + } + if len(aggregate.Groups) != 1 { + t.Fatalf("groups = %+v, want both requests in one group", aggregate.Groups) + } + group := aggregate.Groups[0] + want := map[string]string{ + "project": "CLIProxyAPI", + "account": "sk-a...-key", + "client_key": "sk-c...-key", + "session_id": sessionID, + } + for name, value := range want { + if group.Dimensions[name] != value { + t.Errorf("%s = %q, want %q", name, group.Dimensions[name], value) + } + } + if group.Requests != 2 || group.InputTokens != 200 || group.CacheReadTokens != 400 || + group.CacheWriteTokens != 600 || group.OutputTokens != 80 || group.ReasoningTokens != 20 || group.TotalTokens != 1300 { + t.Fatalf("metrics = %+v", group.Metrics) + } + + byEndpoint, errEndpoint := store.Aggregate(context.Background(), Query{ + From: testDay, To: testDay.Add(time.Hour), GroupBy: []string{"endpoint", "client_ip", "user_agent", "status_code"}, + Filters: map[string]string{"endpoint": "POST /v1/messages"}, + }) + if errEndpoint != nil { + t.Fatalf("aggregate by endpoint: %v", errEndpoint) + } + if len(byEndpoint.Groups) != 1 { + t.Fatalf("endpoint groups = %+v, want one", byEndpoint.Groups) + } + dims := byEndpoint.Groups[0].Dimensions + if dims["client_ip"] != "100.64.0.1" || dims["user_agent"] != "claude-cli/9.9.9" || dims["status_code"] != "200" { + t.Fatalf("dimensions = %+v", dims) + } +} + +func TestRecorderPrefersTheServiceTierTheUpstreamReported(t *testing.T) { + requested := coreusage.Record{ServiceTier: "auto"} + if got := rowFromRecord(context.Background(), requested).ServiceTier; got != "auto" { + t.Errorf("service tier = %q, want the requested tier when nothing else is known", got) + } + served := coreusage.Record{ServiceTier: "auto", EffectiveServiceTier: "priority", ResponseServiceTier: "standard"} + if got := rowFromRecord(context.Background(), served).ServiceTier; got != "standard" { + t.Errorf("service tier = %q, want the tier the upstream reported", got) + } +} + +func TestRecorderKeepsOAuthAccountIdentityAndFailureStatus(t *testing.T) { + row := rowFromRecord(context.Background(), coreusage.Record{ + AuthType: "oauth", + Source: "me@example.com", + Failed: true, + Fail: coreusage.Failure{StatusCode: 429, Code: "rate_limited"}, + }) + if row.Account != "me@example.com" { + t.Errorf("account = %q, want the email kept", row.Account) + } + if !row.Failed || row.StatusCode != 429 || row.FailCode != "rate_limited" { + t.Errorf("failure = %v/%d/%q", row.Failed, row.StatusCode, row.FailCode) + } +} + +func TestConfigureIsIdempotentAndDisables(t *testing.T) { + path := filepath.Join(t.TempDir(), DefaultFileName) + if errConfigure := Configure(true, path); errConfigure != nil { + t.Fatalf("configure: %v", errConfigure) + } + first := Current() + if errConfigure := Configure(true, path); errConfigure != nil { + t.Fatalf("reconfigure: %v", errConfigure) + } + if Current() != first { + t.Fatal("reconfiguring with the same path reopened the store") + } + if errShutdown := Shutdown(); errShutdown != nil { + t.Fatalf("shutdown: %v", errShutdown) + } + if Enabled() { + t.Fatal("store still enabled after shutdown") + } + // Recording while disabled must be a no-op, not a panic. + recorder{}.HandleUsage(context.Background(), coreusage.Record{Model: "m"}) +} diff --git a/sdk/api/handlers/handlers.go b/sdk/api/handlers/handlers.go index 47c9f9969f7..6db5cf0e6da 100644 --- a/sdk/api/handlers/handlers.go +++ b/sdk/api/handlers/handlers.go @@ -20,6 +20,7 @@ import ( "github.com/router-for-me/CLIProxyAPI/v8/internal/interfaces" "github.com/router-for-me/CLIProxyAPI/v8/internal/logging" "github.com/router-for-me/CLIProxyAPI/v8/internal/thinking" + "github.com/router-for-me/CLIProxyAPI/v8/internal/usagestats" "github.com/router-for-me/CLIProxyAPI/v8/internal/util" coreauth "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/auth" coreexecutor "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/executor" @@ -360,6 +361,12 @@ func extractSessionIDsFromRequest(request *http.Request) (string, string) { // from headers, payload, and metadata and records them in ClientRequestMetadata. func EnrichContextWithSessionHierarchy(ctx context.Context, headers http.Header, payload []byte, metadata map[string]any) context.Context { meta := logging.GetClientRequestMetadata(ctx) + if meta.WorkingDir == "" && usagestats.Enabled() { + if dir := usagestats.ExtractWorkingDir(payload); dir != "" { + meta.WorkingDir = dir + ctx = logging.WithClientRequestMetadata(ctx, meta) + } + } if info, ok := coresession.ExtractSessionInfo(headers, payload, metadata); ok { meta.SessionID = info.SessionID meta.ParentSessionID = info.ParentSessionID diff --git a/sdk/api/handlers/handlers_working_dir_test.go b/sdk/api/handlers/handlers_working_dir_test.go new file mode 100644 index 00000000000..f22045af0f0 --- /dev/null +++ b/sdk/api/handlers/handlers_working_dir_test.go @@ -0,0 +1,39 @@ +package handlers + +import ( + "context" + "path/filepath" + "testing" + + "github.com/router-for-me/CLIProxyAPI/v8/internal/logging" + "github.com/router-for-me/CLIProxyAPI/v8/internal/usagestats" +) + +func TestEnrichContextCapturesWorkingDirOnlyWhileRecordingUsage(t *testing.T) { + payload := []byte(`{"system":"Primary working directory: /home/me/repos/app\n","messages":[]}`) + + ctx := EnrichContextWithSessionHierarchy(context.Background(), nil, payload, nil) + if got := logging.GetClientRequestMetadata(ctx).WorkingDir; got != "" { + t.Fatalf("working dir = %q while usage statistics are disabled, want empty", got) + } + + if errConfigure := usagestats.Configure(true, filepath.Join(t.TempDir(), usagestats.DefaultFileName)); errConfigure != nil { + t.Fatalf("configure usage statistics: %v", errConfigure) + } + t.Cleanup(func() { + if errShutdown := usagestats.Shutdown(); errShutdown != nil { + t.Errorf("shutdown usage statistics: %v", errShutdown) + } + }) + + ctx = EnrichContextWithSessionHierarchy(context.Background(), nil, payload, nil) + if got := logging.GetClientRequestMetadata(ctx).WorkingDir; got != "/home/me/repos/app" { + t.Fatalf("working dir = %q, want /home/me/repos/app", got) + } + + // A later enrichment with a payload that declares nothing keeps the captured value. + ctx = EnrichContextWithSessionHierarchy(ctx, nil, []byte(`{"messages":[]}`), nil) + if got := logging.GetClientRequestMetadata(ctx).WorkingDir; got != "/home/me/repos/app" { + t.Fatalf("working dir after re-enrichment = %q, want it preserved", got) + } +} diff --git a/sdk/cliproxy/service_config.go b/sdk/cliproxy/service_config.go index 672548dbfe4..fa817d2bd3f 100644 --- a/sdk/cliproxy/service_config.go +++ b/sdk/cliproxy/service_config.go @@ -221,6 +221,7 @@ func (s *Service) applyConfigRuntime(ctx context.Context, commit configCommit, s if s.kvStoreManaged.Load() { s.applyKVStoreConfig(cfg) } + s.applyUsageStatsConfig(cfg) if errContext := ctx.Err(); errContext != nil { return false } diff --git a/sdk/cliproxy/service_lifecycle.go b/sdk/cliproxy/service_lifecycle.go index 3da93345a47..73e87e2625f 100644 --- a/sdk/cliproxy/service_lifecycle.go +++ b/sdk/cliproxy/service_lifecycle.go @@ -13,6 +13,7 @@ import ( "github.com/router-for-me/CLIProxyAPI/v8/internal/registry" "github.com/router-for-me/CLIProxyAPI/v8/internal/runtime/executor" "github.com/router-for-me/CLIProxyAPI/v8/internal/runtime/executor/helps" + "github.com/router-for-me/CLIProxyAPI/v8/internal/usagestats" sdkaccess "github.com/router-for-me/CLIProxyAPI/v8/sdk/access" sdkAuth "github.com/router-for-me/CLIProxyAPI/v8/sdk/auth" coreauth "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/auth" @@ -82,6 +83,7 @@ func (s *Service) Run(ctx context.Context) error { } s.applyKVStoreConfig(cfg) } + s.applyUsageStatsConfig(cfg) s.applyRetryConfig(cfg) s.configureCooldownStateStore(cfg) @@ -401,6 +403,9 @@ func (s *Service) Shutdown(ctx context.Context) error { helps.RetireProxyPoolPublisher(s.proxyPoolPublisher) usage.StopDefault() + if errShutdownUsage := usagestats.Shutdown(); errShutdownUsage != nil { + log.Errorf("failed to stop usage statistics store: %v", errShutdownUsage) + } }) return shutdownErr } diff --git a/sdk/cliproxy/usage_stats.go b/sdk/cliproxy/usage_stats.go new file mode 100644 index 00000000000..507a08caa3d --- /dev/null +++ b/sdk/cliproxy/usage_stats.go @@ -0,0 +1,31 @@ +package cliproxy + +import ( + "path/filepath" + + "github.com/router-for-me/CLIProxyAPI/v8/internal/config" + "github.com/router-for-me/CLIProxyAPI/v8/internal/usagestats" + "github.com/router-for-me/CLIProxyAPI/v8/internal/util" + log "github.com/sirupsen/logrus" +) + +// applyUsageStatsConfig opens or closes the persistent usage statistics store +// to match usage-statistics-enabled. The database lives next to the +// credentials, at "/usage.db". Home mode reports usage to Home +// instead, so nothing is stored locally there. +func (s *Service) applyUsageStatsConfig(cfg *config.Config) { + enabled := cfg != nil && cfg.UsageStatisticsEnabled && !cfg.Home.Enabled + path := "" + if enabled { + authDir, errResolve := util.ResolveAuthDir(cfg.AuthDir) + if errResolve != nil { + log.Errorf("usage statistics disabled: %v", errResolve) + enabled = false + } else { + path = filepath.Join(authDir, usagestats.DefaultFileName) + } + } + if errConfigure := usagestats.Configure(enabled, path); errConfigure != nil { + log.Errorf("usage statistics disabled: %v", errConfigure) + } +} From cf76f29e2885c424a7fcc8f38b56102c8356efb0 Mon Sep 17 00:00:00 2001 From: Jiro Amato Date: Thu, 12 Nov 2026 02:08:17 -0700 Subject: [PATCH 3/5] fix(usage): read the working directory from system-role messages Claude Code 2.1 sends its environment block as a system-role message after the first user message rather than in the top-level system prompt, so every real request was recorded without a project. Read system-role messages near the start of the conversation as well, and scan every occurrence of the marker instead of only the first. Found by capturing a real Claude Code request; the earlier end-to-end run used a hand-written payload with the block in the system prompt. --- docs/usage-statistics.md | 6 +- internal/usagestats/project.go | 97 +++++++++++++++++++---------- internal/usagestats/project_test.go | 57 +++++++++++++++++ 3 files changed, 126 insertions(+), 34 deletions(-) diff --git a/docs/usage-statistics.md b/docs/usage-statistics.md index 60e23395691..52d3ef8bae1 100644 --- a/docs/usage-statistics.md +++ b/docs/usage-statistics.md @@ -41,8 +41,10 @@ Rows are kept until you delete them. To start over, stop the server and delete ## Projects The proxy never sees a repository name. Coding agents state their working -directory in each request (Claude Code in its system prompt, Codex in its -environment context), and the project is the last directory of that path. +directory in each request (Claude Code in a system message or its system +prompt, Codex in its environment context), and the project is the last +directory of that path. Only those places are read, never user or assistant +content, and only near the start of the conversation. Agent worktrees are folded into their repository: | Working directory | Project | diff --git a/internal/usagestats/project.go b/internal/usagestats/project.go index b088afd62d2..7e4a1fc65d8 100644 --- a/internal/usagestats/project.go +++ b/internal/usagestats/project.go @@ -9,22 +9,25 @@ import ( const ( // maxWorkingDirLength bounds the stored working directory. maxWorkingDirLength = 512 - // codexContextScanItems bounds how many leading input items are inspected. - // Codex emits its environment context at the start of the conversation, and - // scanning a long history on every request would be wasted work. - codexContextScanItems = 8 + // contextScanItems bounds how many leading conversation items are inspected. + // Agents state their environment at the start of the conversation, and + // scanning a long history on every request would be wasted work. A later + // request that misses it inherits the working directory of its session. + contextScanItems = 8 ) -// claudeWorkingDirMarkers are the system prompt line prefixes Claude Code uses -// to announce its working directory, newest first. +// claudeWorkingDirMarkers are the line prefixes Claude Code uses to announce +// its working directory, newest first. var claudeWorkingDirMarkers = []string{"Primary working directory: ", "Working directory: "} // ExtractWorkingDir returns the working directory a coding agent declared in // its request payload, or "" when the payload declares none. // -// It only reads the fields where agents state their environment (the Anthropic -// "system" prompt and the Responses "input" messages), so a path that merely -// appears in conversation content is never mistaken for the working directory. +// It only reads the places where agents state their environment: the +// Anthropic "system" prompt, system-role messages (where current Claude Code +// puts its environment block), and the Codex environment context in the +// Responses "input". A path that merely appears in user or assistant content is +// never mistaken for the working directory. func ExtractWorkingDir(payload []byte) string { if len(payload) == 0 { return "" @@ -34,44 +37,74 @@ func ExtractWorkingDir(payload []byte) string { if dir := claudeWorkingDir(ojson.GetBytesNoCopy(payload, "system")); dir != "" { return dir } + if dir := claudeSystemMessageWorkingDir(ojson.GetBytesNoCopy(payload, "messages")); dir != "" { + return dir + } return codexWorkingDir(ojson.GetBytesNoCopy(payload, "input")) } -// claudeWorkingDir reads the working directory from an Anthropic system prompt, +// claudeWorkingDir reads the working directory from Anthropic prompt content, // which is either a string or an array of text blocks. -func claudeWorkingDir(system ojson.Result) string { - if !system.Exists() { +func claudeWorkingDir(content ojson.Result) string { + if !content.Exists() { return "" } - if !system.IsArray() { - return workingDirFromLine(system.String()) + if !content.IsArray() { + return workingDirFromLine(content.String()) } found := "" - system.ForEach(func(_, block ojson.Result) bool { + content.ForEach(func(_, block ojson.Result) bool { found = workingDirFromLine(block.Get("text").String()) return found == "" }) return found } -func workingDirFromLine(text string) string { - for _, marker := range claudeWorkingDirMarkers { - start := strings.Index(text, marker) - if start < 0 { - continue - } - // Require the marker to start a line (optionally as a "- " list item) so - // "Primary working directory: " is not also matched as "Working directory: ". - prefix := strings.TrimRight(text[:start], " \t-") - if prefix != "" && !strings.HasSuffix(prefix, "\n") { - continue +// claudeSystemMessageWorkingDir reads the working directory from system-role +// messages near the start of an Anthropic conversation. +func claudeSystemMessageWorkingDir(messages ojson.Result) string { + if !messages.IsArray() { + return "" + } + found := "" + scanned := 0 + messages.ForEach(func(_, message ojson.Result) bool { + if scanned++; scanned > contextScanItems { + return false } - rest := text[start+len(marker):] - if end := strings.IndexAny(rest, "\r\n"); end >= 0 { - rest = rest[:end] + if message.Get("role").String() == "system" { + found = claudeWorkingDir(message.Get("content")) } - if dir := cleanWorkingDir(rest); dir != "" { - return dir + return found == "" + }) + return found +} + +// workingDirFromLine returns the directory from the first line of text that +// starts with a working directory marker. +func workingDirFromLine(text string) string { + for _, marker := range claudeWorkingDirMarkers { + for offset := 0; offset < len(text); { + index := strings.Index(text[offset:], marker) + if index < 0 { + break + } + start := offset + index + offset = start + len(marker) + // The marker must start a line (optionally as a "- " list item), so a + // mention inside a sentence is skipped, and "Primary working + // directory: " is not also matched as "Working directory: ". + prefix := strings.TrimRight(text[:start], " \t-") + if prefix != "" && !strings.HasSuffix(prefix, "\n") { + continue + } + rest := text[offset:] + if end := strings.IndexAny(rest, "\r\n"); end >= 0 { + rest = rest[:end] + } + if dir := cleanWorkingDir(rest); dir != "" { + return dir + } } } return "" @@ -86,7 +119,7 @@ func codexWorkingDir(input ojson.Result) string { found := "" scanned := 0 input.ForEach(func(_, item ojson.Result) bool { - if scanned++; scanned > codexContextScanItems { + if scanned++; scanned > contextScanItems { return false } content := item.Get("content") diff --git a/internal/usagestats/project_test.go b/internal/usagestats/project_test.go index 8be56dae3e2..dbe5b496a49 100644 --- a/internal/usagestats/project_test.go +++ b/internal/usagestats/project_test.go @@ -57,6 +57,63 @@ func TestExtractWorkingDirFromClaudeSystemPrompt(t *testing.T) { } } +// Claude Code 2.1 sends its environment block as a system-role message after +// the first user message, not in the top-level system prompt. +func TestExtractWorkingDirFromClaudeSystemRoleMessage(t *testing.T) { + const dir = `C:\Users\me\.t3\worktrees\app\t3-1234` + environment := "# Environment\nYou have been invoked in the following environment: \n - Primary working directory: " + + dir + "\n - Is a git repository: true\n - Additional working directories:\n - C:\\Users\\me\\other\n" + payload := mustJSON(t, map[string]any{ + "model": "claude-opus-5-5", + "system": []map[string]string{ + {"type": "text", "text": "x-anthropic-billing-header: cc_version=2.1.322;"}, + {"type": "text", "text": "You are a Claude agent."}, + }, + "messages": []map[string]any{ + {"role": "user", "content": []map[string]string{ + {"type": "text", "text": "Primary working directory: /decoy/in/user/content"}, + {"type": "text", "text": "say ok"}, + }}, + {"role": "system", "content": []map[string]any{ + {"type": "text", "text": environment, "cache_control": map[string]string{"type": "ephemeral"}}, + }}, + }, + }) + if got := ExtractWorkingDir(payload); got != dir { + t.Fatalf("ExtractWorkingDir = %q, want %q", got, dir) + } + + // A system-role message with string content works too. + plain := mustJSON(t, map[string]any{ + "messages": []map[string]any{ + {"role": "user", "content": "hi"}, + {"role": "system", "content": "Primary working directory: /home/me/repos/app\n"}, + }, + }) + if got := ExtractWorkingDir(plain); got != "/home/me/repos/app" { + t.Fatalf("string content: ExtractWorkingDir = %q", got) + } +} + +func TestExtractWorkingDirOnlyScansTheStartOfTheConversation(t *testing.T) { + messages := make([]map[string]any, 0, contextScanItems+1) + for range contextScanItems { + messages = append(messages, map[string]any{"role": "user", "content": "filler"}) + } + messages = append(messages, map[string]any{"role": "system", "content": "Primary working directory: /too/late\n"}) + if got := ExtractWorkingDir(mustJSON(t, map[string]any{"messages": messages})); got != "" { + t.Fatalf("ExtractWorkingDir = %q, want empty beyond the scan window", got) + } +} + +func TestExtractWorkingDirSkipsMentionsBeforeTheRealLine(t *testing.T) { + system := "Paths are relative to the Primary working directory: keep that in mind.\n" + + " - Primary working directory: /home/me/repos/app\n" + if got := ExtractWorkingDir(mustJSON(t, map[string]any{"system": system})); got != "/home/me/repos/app" { + t.Fatalf("ExtractWorkingDir = %q, want the line-start occurrence", got) + } +} + func TestExtractWorkingDirIgnoresConversationContent(t *testing.T) { // A path quoted in the conversation is not the agent's environment. payload := mustJSON(t, map[string]any{ From ee1bd67dd57dd12c98ab1b637f8e5048de28b53c Mon Sep 17 00:00:00 2001 From: Jiro Amato Date: Thu, 12 Nov 2026 02:37:10 -0700 Subject: [PATCH 4/5] fix(usage): mask fallback client keys and drain records on shutdown Review fixes: - An OAuth credential without an account identity reports the client key as its source; that key is now masked like any other. - Shutdown waits for queued usage records to reach the store before closing it (usage.Manager.Wait), bounded by a timeout. - A store is never closed underneath an insert, and a replacement store is opened before the working one is retired. - The session working-directory fallback is scoped to the client key. - usage.db is created readable by its owner only. - /speed honours limit; settings reject two spellings of one model; a quoted Codex environment context is not read as the working directory; successful non-200 statuses are kept. --- .../console-2026-11-12T09-33-09-769Z.log | 1 + .../page-2026-11-12T09-33-10-122Z.yml | 9 ++ config.example.yaml | 3 +- docs/usage-statistics.md | 7 +- internal/usagestats/prices.go | 10 +- internal/usagestats/project.go | 4 +- internal/usagestats/project_test.go | 8 ++ internal/usagestats/query.go | 14 +++ internal/usagestats/recorder.go | 47 +++++++--- internal/usagestats/store.go | 28 +++++- internal/usagestats/usagestats_test.go | 94 +++++++++++++++++++ sdk/cliproxy/service_lifecycle.go | 7 ++ sdk/cliproxy/usage/manager.go | 42 ++++++++- sdk/cliproxy/usage/manager_wait_test.go | 56 +++++++++++ sdk/cliproxy/usage_stats.go | 5 + 15 files changed, 307 insertions(+), 28 deletions(-) create mode 100644 .playwright-mcp/console-2026-11-12T09-33-09-769Z.log create mode 100644 .playwright-mcp/page-2026-11-12T09-33-10-122Z.yml create mode 100644 sdk/cliproxy/usage/manager_wait_test.go diff --git a/.playwright-mcp/console-2026-11-12T09-33-09-769Z.log b/.playwright-mcp/console-2026-11-12T09-33-09-769Z.log new file mode 100644 index 00000000000..519890ba8c6 --- /dev/null +++ b/.playwright-mcp/console-2026-11-12T09-33-09-769Z.log @@ -0,0 +1 @@ +[ 381ms] [VERBOSE] [DOM] Password field is not contained in a form: (More info: https://goo.gl/9p2vKq) %o @ http://127.0.0.1:18317/management.html#/login:1 diff --git a/.playwright-mcp/page-2026-11-12T09-33-10-122Z.yml b/.playwright-mcp/page-2026-11-12T09-33-10-122Z.yml new file mode 100644 index 00000000000..ae0a1e7c996 --- /dev/null +++ b/.playwright-mcp/page-2026-11-12T09-33-10-122Z.yml @@ -0,0 +1,9 @@ +- generic [ref=e3]: + - generic [ref=e5]: + - generic [ref=e6]: CLI + - generic [ref=e7]: PROXY + - generic [ref=e8]: API + - generic [ref=e10]: + - img "CPAMC" [ref=e11] + - heading "CLI Proxy API" [level=1] [ref=e12] + - paragraph [ref=e13]: Management Center \ No newline at end of file diff --git a/config.example.yaml b/config.example.yaml index 6f75c2b7bcd..a23b88a68fa 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -1291,7 +1291,8 @@ observability: usage: # When true, record one row per upstream request (tokens, model, account, latency, project) # in "/usage.db" for the management usage dashboard, and feed the in-memory usage - # queue. No prompt or response content is stored. When false, usage data is discarded. + # queue. No prompt or response content is stored. When false, new usage is discarded; rows + # already recorded stay in usage.db until the file is deleted. usage-statistics-enabled: false # How long (in seconds) usage queue items are retained in memory for the Management API. diff --git a/docs/usage-statistics.md b/docs/usage-statistics.md index 52d3ef8bae1..a80805f0268 100644 --- a/docs/usage-statistics.md +++ b/docs/usage-statistics.md @@ -35,8 +35,9 @@ Rows go to a SQLite database at `/usage.db`. Each row holds: No prompt or response content is stored, and no full API key. -Rows are kept until you delete them. To start over, stop the server and delete -`usage.db` together with its `-wal` and `-shm` files. +Rows are kept until you delete them, also after recording is turned off. To +start over, stop the server and delete `usage.db` together with its `-wal` and +`-shm` files. The database file is readable by its owner only. ## Projects @@ -56,7 +57,7 @@ Agent worktrees are folded into their repository: | `~/.codex/worktrees/1a2b/app` | `app` | A request that declares no working directory inherits the one last seen in its -session, and is otherwise listed as unknown. +session for the same client key, and is otherwise listed as unknown. ## Cost diff --git a/internal/usagestats/prices.go b/internal/usagestats/prices.go index 8918236b03b..85c4da23ee2 100644 --- a/internal/usagestats/prices.go +++ b/internal/usagestats/prices.go @@ -136,10 +136,18 @@ func (p Price) cost(input, cacheRead, cacheWrite, cacheWrite1h, output int64) fl // Validate reports whether the settings are well formed. func (s Settings) Validate() error { + seen := make(map[string]string, len(s.ModelPrices)) for model, price := range s.ModelPrices { - if normalizeModel(model) == "" { + normalized := normalizeModel(model) + if normalized == "" { return errors.New("model price has an empty model name") } + // Names match case-insensitively, so two spellings of one model would + // make its price depend on map order. + if other, duplicate := seen[normalized]; duplicate { + return fmt.Errorf("models %q and %q are the same model", min(model, other), max(model, other)) + } + seen[normalized] = model for _, value := range []*float64{&price.Input, &price.Output, price.CacheRead, price.CacheWrite5m, price.CacheWrite1h} { if value != nil && !validAmount(*value) { return fmt.Errorf("model %q has an invalid price", model) diff --git a/internal/usagestats/project.go b/internal/usagestats/project.go index 7e4a1fc65d8..fa278593faf 100644 --- a/internal/usagestats/project.go +++ b/internal/usagestats/project.go @@ -136,8 +136,10 @@ func codexWorkingDir(input ojson.Result) string { return found } +// workingDirFromCwdTag reads from text that is an environment context +// block. Text that only quotes such a block is conversation content. func workingDirFromCwdTag(text string) string { - if !strings.Contains(text, "") { + if !strings.HasPrefix(strings.TrimSpace(text), "") { return "" } start := strings.Index(text, "") diff --git a/internal/usagestats/project_test.go b/internal/usagestats/project_test.go index dbe5b496a49..fed86b54757 100644 --- a/internal/usagestats/project_test.go +++ b/internal/usagestats/project_test.go @@ -154,6 +154,14 @@ func TestExtractWorkingDirFromCodexEnvironmentContext(t *testing.T) { if got := ExtractWorkingDir(quoted); got != "" { t.Errorf("quoted tag: ExtractWorkingDir = %q, want empty", got) } + + // So is a whole environment context quoted inside a question. + asked := mustJSON(t, map[string]any{ + "input": []map[string]any{{"role": "user", "content": "explain this: " + environment}}, + }) + if got := ExtractWorkingDir(asked); got != "" { + t.Errorf("quoted block: ExtractWorkingDir = %q, want empty", got) + } } func TestExtractWorkingDirHandlesUnusablePayloads(t *testing.T) { diff --git a/internal/usagestats/query.go b/internal/usagestats/query.go index a1e13b91d8d..ac166b3f2ca 100644 --- a/internal/usagestats/query.go +++ b/internal/usagestats/query.go @@ -340,6 +340,20 @@ func (s *Store) Speed(ctx context.Context, q Query, dimension string) ([]Speed, } result = append(result, speed) } + limit := q.Limit + if limit <= 0 { + limit = DefaultGroupLimit + } + if len(result) > limit { + // Keep the groups with the most requests, as Aggregate keeps the largest. + sort.Slice(result, func(i, j int) bool { + if result[i].Requests != result[j].Requests { + return result[i].Requests > result[j].Requests + } + return result[i].Value < result[j].Value + }) + result = result[:limit] + } sort.Slice(result, func(i, j int) bool { return result[i].Value < result[j].Value }) return result, nil } diff --git a/internal/usagestats/recorder.go b/internal/usagestats/recorder.go index 3ce34c0d1b0..4c868d8d8ca 100644 --- a/internal/usagestats/recorder.go +++ b/internal/usagestats/recorder.go @@ -22,6 +22,9 @@ const authTypeOAuth = "oauth" var ( current atomic.Pointer[Store] configureMu sync.Mutex + // recordMu is held for reading while a record is written, so a store is + // never closed underneath an insert. + recordMu sync.RWMutex ) func init() { @@ -38,21 +41,29 @@ func Configure(enabled bool, path string) error { if enabled && existing != nil && existing.Path() == path { return nil } + var replacement *Store + if enabled { + // Open the replacement first: when it cannot be opened, recording + // carries on in the store that already works. + store, errOpen := Open(path) + if errOpen != nil { + return errOpen + } + replacement = store + } + current.Store(replacement) if existing != nil { - current.Store(nil) - if errClose := existing.Close(); errClose != nil { + // Wait for writes that already hold the retired store. + recordMu.Lock() + errClose := existing.Close() + recordMu.Unlock() + if errClose != nil { log.Warnf("usage statistics: close store: %v", errClose) } } - if !enabled { - return nil + if replacement != nil { + log.Infof("usage statistics: recording to %s", path) } - store, errOpen := Open(path) - if errOpen != nil { - return errOpen - } - current.Store(store) - log.Infof("usage statistics: recording to %s", path) return nil } @@ -75,6 +86,8 @@ func Enabled() bool { type recorder struct{} func (recorder) HandleUsage(ctx context.Context, record coreusage.Record) { + recordMu.RLock() + defer recordMu.RUnlock() store := current.Load() if store == nil { return @@ -84,7 +97,7 @@ func (recorder) HandleUsage(ctx context.Context, record coreusage.Record) { // published, so storage runs on its own context. storeCtx := context.Background() if row.WorkingDir == "" && row.SessionID != "" { - dir, errLookup := store.WorkingDirForSession(storeCtx, row.SessionID) + dir, errLookup := store.WorkingDirForSession(storeCtx, row.SessionID, row.ClientKey) if errLookup != nil { log.Warnf("usage statistics: %v", errLookup) } @@ -110,6 +123,9 @@ func rowFromRecord(ctx context.Context, record coreusage.Record) Row { failed = internallogging.GetResponseStatus(ctx) >= http.StatusBadRequest } statusCode := http.StatusOK + if status := internallogging.GetResponseStatus(ctx); !failed && status >= http.StatusOK { + statusCode = status + } if failed { statusCode = record.Fail.StatusCode if statusCode <= 0 { @@ -131,9 +147,12 @@ func rowFromRecord(ctx context.Context, record coreusage.Record) Row { } authType := strings.TrimSpace(record.AuthType) + clientKey := strings.TrimSpace(record.APIKey) account := strings.TrimSpace(record.Source) - if !strings.EqualFold(authType, authTypeOAuth) { - // For key-based credentials the source is the upstream API key itself. + // For key-based credentials the source is the upstream API key itself, and + // an OAuth credential without an account identity falls back to the client + // key. Only a real account identity is stored unmasked. + if !strings.EqualFold(authType, authTypeOAuth) || account == clientKey { account = util.HideAPIKey(account) } @@ -154,7 +173,7 @@ func rowFromRecord(ctx context.Context, record coreusage.Record) Row { AuthIndex: strings.TrimSpace(record.AuthIndex), AuthType: authType, Account: account, - ClientKey: util.HideAPIKey(strings.TrimSpace(record.APIKey)), + ClientKey: util.HideAPIKey(clientKey), ClientIP: clientIP, UserAgent: strings.TrimSpace(meta.UserAgent), WorkingDir: strings.TrimSpace(meta.WorkingDir), diff --git a/internal/usagestats/store.go b/internal/usagestats/store.go index de60502c0bb..91691c99932 100644 --- a/internal/usagestats/store.go +++ b/internal/usagestats/store.go @@ -149,6 +149,9 @@ func Open(path string) (*Store, error) { if errMkdir := os.MkdirAll(filepath.Dir(path), 0o700); errMkdir != nil { return nil, fmt.Errorf("usagestats: create database directory: %w", errMkdir) } + if errRestrict := restrictToOwner(path); errRestrict != nil { + return nil, errRestrict + } // WAL keeps dashboard reads from blocking the writer; NORMAL is durable // across process crashes, which is the failure mode that matters here. @@ -172,6 +175,23 @@ func Open(path string) (*Store, error) { return &Store{db: db, insert: insert, path: path}, nil } +// restrictToOwner makes the database file readable by its owner only, creating +// it when needed. SQLite gives its WAL and shared-memory files the same mode. +// The auth directory may predate this store and be readable by other users. +func restrictToOwner(path string) error { + file, errOpen := os.OpenFile(path, os.O_RDWR|os.O_CREATE, 0o600) + if errOpen != nil { + return fmt.Errorf("usagestats: create database file: %w", errOpen) + } + if errClose := file.Close(); errClose != nil { + return fmt.Errorf("usagestats: create database file: %w", errClose) + } + if errChmod := os.Chmod(path, 0o600); errChmod != nil { + return fmt.Errorf("usagestats: restrict database file: %w", errChmod) + } + return nil +} + // migrate brings the schema up to date, one transaction per migration. func migrate(db *sql.DB) error { var version int @@ -202,14 +222,16 @@ func migrate(db *sql.DB) error { // WorkingDirForSession returns the most recent working directory recorded for // a session, or "" when none is known. Agents that only declare their working // directory on the first request of a session rely on this for later requests. -func (s *Store) WorkingDirForSession(ctx context.Context, sessionID string) (string, error) { +// Session ids can be chosen by clients, so the lookup is scoped to the stored +// (masked) client key: one client never inherits another's directory. +func (s *Store) WorkingDirForSession(ctx context.Context, sessionID, clientKey string) (string, error) { if s == nil || sessionID == "" { return "", nil } var dir string errScan := s.db.QueryRowContext(ctx, - "SELECT working_dir FROM requests WHERE session_id = ? AND working_dir != '' ORDER BY id DESC LIMIT 1", - sessionID, + "SELECT working_dir FROM requests WHERE session_id = ? AND client_key = ? AND working_dir != '' ORDER BY id DESC LIMIT 1", + sessionID, clientKey, ).Scan(&dir) if errors.Is(errScan, sql.ErrNoRows) { return "", nil diff --git a/internal/usagestats/usagestats_test.go b/internal/usagestats/usagestats_test.go index c3020b2ea18..a5d4a223a30 100644 --- a/internal/usagestats/usagestats_test.go +++ b/internal/usagestats/usagestats_test.go @@ -4,6 +4,7 @@ import ( "context" "errors" "math" + "os" "path/filepath" "testing" "time" @@ -465,3 +466,96 @@ func TestConfigureIsIdempotentAndDisables(t *testing.T) { // Recording while disabled must be a no-op, not a panic. recorder{}.HandleUsage(context.Background(), coreusage.Record{Model: "m"}) } + +func TestConfigureKeepsTheWorkingStoreWhenTheReplacementCannotOpen(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, DefaultFileName) + if errConfigure := Configure(true, path); errConfigure != nil { + t.Fatalf("configure: %v", errConfigure) + } + t.Cleanup(func() { + if errShutdown := Shutdown(); errShutdown != nil { + t.Errorf("shutdown: %v", errShutdown) + } + }) + working := Current() + + // A regular file where the database directory should be cannot be opened. + blocker := filepath.Join(dir, "blocker") + if errWrite := os.WriteFile(blocker, nil, 0o600); errWrite != nil { + t.Fatalf("write blocker: %v", errWrite) + } + if errConfigure := Configure(true, filepath.Join(blocker, DefaultFileName)); errConfigure == nil { + t.Fatal("configure with an unusable path succeeded") + } + if Current() != working { + t.Fatal("the working store was retired although its replacement failed to open") + } + recorder{}.HandleUsage(context.Background(), coreusage.Record{Model: "m", RequestedAt: testDay}) + aggregate, errAggregate := working.Aggregate(context.Background(), Query{From: testDay, To: testDay.Add(time.Hour)}) + if errAggregate != nil || len(aggregate.Groups) != 1 { + t.Fatalf("aggregate = %+v, %v; want the record in the working store", aggregate, errAggregate) + } +} + +// An OAuth credential without an account identity reports the client key as +// its source, which must not be stored in full. +func TestRecorderMasksAClientKeyReportedAsTheOAuthSource(t *testing.T) { + row := rowFromRecord(context.Background(), coreusage.Record{ + AuthType: "oauth", + Source: "sk-client-secret-key", + APIKey: "sk-client-secret-key", + }) + if row.Account != "sk-c...-key" || row.ClientKey != "sk-c...-key" { + t.Fatalf("account = %q, client key = %q; want both masked", row.Account, row.ClientKey) + } +} + +func TestRecorderKeepsTheSuccessfulResponseStatus(t *testing.T) { + ctx := internallogging.WithResponseStatusHolder(context.Background()) + internallogging.SetResponseStatus(ctx, 201) + if got := rowFromRecord(ctx, coreusage.Record{}).StatusCode; got != 201 { + t.Errorf("status code = %d, want 201", got) + } + if got := rowFromRecord(context.Background(), coreusage.Record{}).StatusCode; got != 200 { + t.Errorf("status code = %d, want 200 when no status was captured", got) + } +} + +func TestWorkingDirForSessionIsScopedToTheClientKey(t *testing.T) { + store := openTestStore(t) + mustInsert(t, store, Row{Timestamp: testDay, SessionID: "shared", ClientKey: "sk-a...aaaa", WorkingDir: "/home/a/repo"}) + + own, errOwn := store.WorkingDirForSession(context.Background(), "shared", "sk-a...aaaa") + if errOwn != nil || own != "/home/a/repo" { + t.Fatalf("own lookup = %q, %v", own, errOwn) + } + other, errOther := store.WorkingDirForSession(context.Background(), "shared", "sk-b...bbbb") + if errOther != nil || other != "" { + t.Fatalf("another client's lookup = %q, %v; want nothing", other, errOther) + } +} + +func TestSpeedKeepsTheBusiestGroupsWithinTheLimit(t *testing.T) { + store := openTestStore(t) + for model, requests := range map[string]int{"busy": 3, "busier": 5, "quiet": 1} { + for range requests { + mustInsert(t, store, Row{Timestamp: testDay, Model: model, LatencyMs: 100}) + } + } + speeds, errSpeed := store.Speed(context.Background(), Query{From: testDay, To: testDay.Add(time.Hour), Limit: 2}, "model") + if errSpeed != nil { + t.Fatalf("speed: %v", errSpeed) + } + if len(speeds) != 2 || speeds[0].Value != "busier" || speeds[1].Value != "busy" { + t.Fatalf("speeds = %+v, want busier and busy", speeds) + } +} + +func TestSaveSettingsRejectsTwoSpellingsOfOneModel(t *testing.T) { + store := openTestStore(t) + settings := Settings{ModelPrices: map[string]Price{"GPT-5": {Input: 1}, "gpt-5": {Input: 2}}} + if errSave := store.SaveSettings(context.Background(), settings); errSave == nil { + t.Fatal("save succeeded, want a duplicate model error") + } +} diff --git a/sdk/cliproxy/service_lifecycle.go b/sdk/cliproxy/service_lifecycle.go index 73e87e2625f..82792aef5c4 100644 --- a/sdk/cliproxy/service_lifecycle.go +++ b/sdk/cliproxy/service_lifecycle.go @@ -403,6 +403,13 @@ func (s *Service) Shutdown(ctx context.Context) error { helps.RetireProxyPoolPublisher(s.proxyPoolPublisher) usage.StopDefault() + // Requests that just finished are still queued; let them reach the + // store before it closes, without letting a stuck plugin hold up exit. + drainCtx, cancelDrain := context.WithTimeout(ctx, usageDrainTimeout) + if !usage.WaitDefault(drainCtx) { + log.Warn("usage dispatcher did not drain before shutdown; recent usage records may be lost") + } + cancelDrain() if errShutdownUsage := usagestats.Shutdown(); errShutdownUsage != nil { log.Errorf("failed to stop usage statistics store: %v", errShutdownUsage) } diff --git a/sdk/cliproxy/usage/manager.go b/sdk/cliproxy/usage/manager.go index dd1f84ec455..58160a9f3a4 100644 --- a/sdk/cliproxy/usage/manager.go +++ b/sdk/cliproxy/usage/manager.go @@ -641,10 +641,14 @@ type Manager struct { stopOnce sync.Once cancel context.CancelFunc - mu sync.Mutex - cond *sync.Cond - queue []queueItem - closed bool + mu sync.Mutex + cond *sync.Cond + queue []queueItem + closed bool + started bool + // done is closed once the dispatcher has delivered every queued record + // and exited. + done chan struct{} pluginsMu sync.RWMutex plugins []Plugin @@ -653,7 +657,7 @@ type Manager struct { // NewManager constructs a manager with a buffered queue. func NewManager(buffer int) *Manager { - m := &Manager{} + m := &Manager{done: make(chan struct{})} m.cond = sync.NewCond(&m.mu) return m } @@ -669,6 +673,9 @@ func (m *Manager) Start(ctx context.Context) { } var workerCtx context.Context workerCtx, m.cancel = context.WithCancel(ctx) + m.mu.Lock() + m.started = true + m.mu.Unlock() go m.run(workerCtx) }) } @@ -689,6 +696,27 @@ func (m *Manager) Stop() { }) } +// Wait blocks until the dispatcher has delivered every record queued before +// Stop, and reports whether it finished before ctx ended. Stop only closes the +// queue; a caller that tears down what plugins write to must wait first. +func (m *Manager) Wait(ctx context.Context) bool { + if m == nil { + return true + } + m.mu.Lock() + started := m.started + m.mu.Unlock() + if !started { + return true + } + select { + case <-m.done: + return true + case <-ctx.Done(): + return false + } +} + // Register appends a plugin to the delivery list. func (m *Manager) Register(plugin Plugin) { if m == nil || plugin == nil { @@ -782,6 +810,7 @@ func (m *Manager) Publish(ctx context.Context, record Record) { } func (m *Manager) run(ctx context.Context) { + defer close(m.done) for { m.mu.Lock() for !m.closed && len(m.queue) == 0 { @@ -843,3 +872,6 @@ func StartDefault(ctx context.Context) { DefaultManager().Start(ctx) } // StopDefault stops the default manager's dispatcher. func StopDefault() { DefaultManager().Stop() } + +// WaitDefault waits for the stopped default dispatcher to drain its queue. +func WaitDefault(ctx context.Context) bool { return DefaultManager().Wait(ctx) } diff --git a/sdk/cliproxy/usage/manager_wait_test.go b/sdk/cliproxy/usage/manager_wait_test.go new file mode 100644 index 00000000000..c555c939e7c --- /dev/null +++ b/sdk/cliproxy/usage/manager_wait_test.go @@ -0,0 +1,56 @@ +package usage + +import ( + "context" + "sync/atomic" + "testing" +) + +// gatedPlugin blocks its first delivery until released, so records pile up +// in the queue behind it. +type gatedPlugin struct { + gate chan struct{} + entered chan struct{} + delivered atomic.Int64 +} + +func (p *gatedPlugin) HandleUsage(context.Context, Record) { + if p.delivered.Add(1) == 1 { + close(p.entered) + <-p.gate + } +} + +func TestManagerWaitDeliversEverythingQueuedBeforeStop(t *testing.T) { + const records = 20 + mgr := NewManager(0) + plugin := &gatedPlugin{gate: make(chan struct{}), entered: make(chan struct{})} + mgr.Register(plugin) + for range records { + mgr.Publish(context.Background(), Record{Model: "m"}) + } + <-plugin.entered + mgr.Stop() + + // The dispatcher is still inside the first delivery, so a wait that has + // already run out of time reports the queue as not drained. + expired, cancel := context.WithCancel(context.Background()) + cancel() + if mgr.Wait(expired) { + t.Fatal("Wait reported a drained queue while a delivery was blocked") + } + + close(plugin.gate) + if !mgr.Wait(context.Background()) { + t.Fatal("Wait did not report the queue as drained") + } + if got := plugin.delivered.Load(); got != records { + t.Fatalf("delivered = %d, want all %d records queued before Stop", got, records) + } +} + +func TestManagerWaitReturnsAtOnceWhenNeverStarted(t *testing.T) { + if !NewManager(0).Wait(context.Background()) { + t.Fatal("Wait blocked on a dispatcher that never started") + } +} diff --git a/sdk/cliproxy/usage_stats.go b/sdk/cliproxy/usage_stats.go index 507a08caa3d..630b1880c48 100644 --- a/sdk/cliproxy/usage_stats.go +++ b/sdk/cliproxy/usage_stats.go @@ -2,6 +2,7 @@ package cliproxy import ( "path/filepath" + "time" "github.com/router-for-me/CLIProxyAPI/v8/internal/config" "github.com/router-for-me/CLIProxyAPI/v8/internal/usagestats" @@ -9,6 +10,10 @@ import ( log "github.com/sirupsen/logrus" ) +// usageDrainTimeout bounds how long shutdown waits for queued usage records to +// reach their plugins. Draining normally takes milliseconds. +const usageDrainTimeout = 5 * time.Second + // applyUsageStatsConfig opens or closes the persistent usage statistics store // to match usage-statistics-enabled. The database lives next to the // credentials, at "/usage.db". Home mode reports usage to Home From 58c8cca8e371971af5d55efc8e8b88c6aa060010 Mon Sep 17 00:00:00 2001 From: Jiro Amato Date: Thu, 12 Nov 2026 02:37:19 -0700 Subject: [PATCH 5/5] chore: remove stray browser test logs --- .playwright-mcp/console-2026-11-12T09-33-09-769Z.log | 1 - .playwright-mcp/page-2026-11-12T09-33-10-122Z.yml | 9 --------- 2 files changed, 10 deletions(-) delete mode 100644 .playwright-mcp/console-2026-11-12T09-33-09-769Z.log delete mode 100644 .playwright-mcp/page-2026-11-12T09-33-10-122Z.yml diff --git a/.playwright-mcp/console-2026-11-12T09-33-09-769Z.log b/.playwright-mcp/console-2026-11-12T09-33-09-769Z.log deleted file mode 100644 index 519890ba8c6..00000000000 --- a/.playwright-mcp/console-2026-11-12T09-33-09-769Z.log +++ /dev/null @@ -1 +0,0 @@ -[ 381ms] [VERBOSE] [DOM] Password field is not contained in a form: (More info: https://goo.gl/9p2vKq) %o @ http://127.0.0.1:18317/management.html#/login:1 diff --git a/.playwright-mcp/page-2026-11-12T09-33-10-122Z.yml b/.playwright-mcp/page-2026-11-12T09-33-10-122Z.yml deleted file mode 100644 index ae0a1e7c996..00000000000 --- a/.playwright-mcp/page-2026-11-12T09-33-10-122Z.yml +++ /dev/null @@ -1,9 +0,0 @@ -- generic [ref=e3]: - - generic [ref=e5]: - - generic [ref=e6]: CLI - - generic [ref=e7]: PROXY - - generic [ref=e8]: API - - generic [ref=e10]: - - img "CPAMC" [ref=e11] - - heading "CLI Proxy API" [level=1] [ref=e12] - - paragraph [ref=e13]: Management Center \ No newline at end of file