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..a23b88a68fa 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -1289,7 +1289,10 @@ 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, 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 new file mode 100644 index 00000000000..a80805f0268 --- /dev/null +++ b/docs/usage-statistics.md @@ -0,0 +1,115 @@ +# 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, 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 + +The proxy never sees a repository name. Coding agents state their working +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 | +| --- | --- | +| `~/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 for the same client key, 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..85c4da23ee2 --- /dev/null +++ b/internal/usagestats/prices.go @@ -0,0 +1,227 @@ +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 { + seen := make(map[string]string, len(s.ModelPrices)) + for model, price := range s.ModelPrices { + 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) + } + } + } + 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..fa278593faf --- /dev/null +++ b/internal/usagestats/project.go @@ -0,0 +1,207 @@ +package usagestats + +import ( + "strings" + + "github.com/MoeForge/moego/ojson" +) + +const ( + // maxWorkingDirLength bounds the stored working directory. + maxWorkingDirLength = 512 + // 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 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 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 "" + } + // 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 + } + if dir := claudeSystemMessageWorkingDir(ojson.GetBytesNoCopy(payload, "messages")); dir != "" { + return dir + } + return codexWorkingDir(ojson.GetBytesNoCopy(payload, "input")) +} + +// claudeWorkingDir reads the working directory from Anthropic prompt content, +// which is either a string or an array of text blocks. +func claudeWorkingDir(content ojson.Result) string { + if !content.Exists() { + return "" + } + if !content.IsArray() { + return workingDirFromLine(content.String()) + } + found := "" + content.ForEach(func(_, block ojson.Result) bool { + found = workingDirFromLine(block.Get("text").String()) + return found == "" + }) + return found +} + +// 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 + } + if message.Get("role").String() == "system" { + found = claudeWorkingDir(message.Get("content")) + } + 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 "" +} + +// 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 > contextScanItems { + 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 +} + +// 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.HasPrefix(strings.TrimSpace(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..fed86b54757 --- /dev/null +++ b/internal/usagestats/project_test.go @@ -0,0 +1,199 @@ +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) + } + } +} + +// 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{ + "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) + } + + // 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) { + 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..ac166b3f2ca --- /dev/null +++ b/internal/usagestats/query.go @@ -0,0 +1,375 @@ +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) + } + 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 +} + +// 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..4c868d8d8ca --- /dev/null +++ b/internal/usagestats/recorder.go @@ -0,0 +1,238 @@ +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 + // recordMu is held for reading while a record is written, so a store is + // never closed underneath an insert. + recordMu sync.RWMutex +) + +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 + } + 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 { + // 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 replacement != nil { + 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) { + recordMu.RLock() + defer recordMu.RUnlock() + 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, row.ClientKey) + 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 status := internallogging.GetResponseStatus(ctx); !failed && status >= http.StatusOK { + statusCode = status + } + 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) + clientKey := strings.TrimSpace(record.APIKey) + account := strings.TrimSpace(record.Source) + // 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) + } + + 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(clientKey), + 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..91691c99932 --- /dev/null +++ b/internal/usagestats/store.go @@ -0,0 +1,289 @@ +// 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) + } + 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. + 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 +} + +// 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 + 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. +// 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 client_key = ? AND working_dir != '' ORDER BY id DESC LIMIT 1", + sessionID, clientKey, + ).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..a5d4a223a30 --- /dev/null +++ b/internal/usagestats/usagestats_test.go @@ -0,0 +1,561 @@ +package usagestats + +import ( + "context" + "errors" + "math" + "os" + "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"}) +} + +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/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/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) 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..82792aef5c4 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,16 @@ 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) + } }) return shutdownErr } 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 new file mode 100644 index 00000000000..630b1880c48 --- /dev/null +++ b/sdk/cliproxy/usage_stats.go @@ -0,0 +1,36 @@ +package cliproxy + +import ( + "path/filepath" + "time" + + "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" +) + +// 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 +// 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) + } +}