openvibers/openvibe.report README.md
README.md
| 1 | # OpenVibe.Report |
| 2 | |
| 3 | > Every number on OpenVibe, honest. |
| 4 | |
| 5 | **Live site:** https://openvibe.report · **API docs:** https://openvibe.services · **For AI:** https://openvibe.report/llms.txt |
| 6 | **Run it:** `npm ci && npm run dev` (Node 22 or newer) · **Tests:** `npm test` |
| 7 | |
| 8 | **Status:** v0.1.0 service, now with its own analytics database and a shadow of Network's numbers (REP-2, plan |
| 9 | 2026-10-28 §4.8 and §5.4). The pages still read where they read today; `REPORT_SOURCE=report` switches the numbers |
| 10 | when the cutover is approved. |
| 11 | **Domain:** `openvibe.report` · **Port:** 5080 · **Service id:** `report` · **Env prefix:** `OV_` / `REPORT_` |
| 12 | **License:** AGPL-3.0 (same as every OpenVibe service). |
| 13 | |
| 14 | The Report room of the OpenVibe network: the open numbers every site publishes, The Rundown of what streamers talked |
| 15 | about, and each stream's after-show report. |
| 16 | |
| 17 | ## Purpose |
| 18 | |
| 19 | OpenVibe.Report is the analytics authority in shadow: |
| 20 | |
| 21 | - It **ingests** the analytics OpenVibe.Network records — `live.stream.ended` for creator analytics and the shared |
| 22 | navbar's anonymous frame beacon — and Network's open-stats answer (`GET /api/v1/stats?days=7|30|90`) into its own |
| 23 | PostgreSQL tables (migrations/). |
| 24 | - It **computes** the same numbers Network answers, site-wide, per site and per channel, by UTC day. |
| 25 | - It **quotes money from OpenVibe.Billing** through Billing's read API; it never derives a price, fee or amount. |
| 26 | - It **shadows the AI Exchange** behind `REPORT_AI_EXCHANGE` (off): Billing's `billing.credit.settled@1` |
| 27 | settlements are summed by UTC day, resolved exact model id and provider access. The raw settlement is not stored, |
| 28 | and Report never sets a rate, a price or a balance. |
| 29 | - It **shadows**: a job compares the same window from Network and from Report's tables and records every difference |
| 30 | in `report_shadow_diffs`. |
| 31 | |
| 32 | The pages and `GET /api/v1/stats` answer from `REPORT_SOURCE`, which defaults to `network`: today's behaviour. Setting |
| 33 | `REPORT_SOURCE=report` makes them answer from Report's tables. The Rundown and after-show reports still come from |
| 34 | OpenVibe.Live in both modes. |
| 35 | |
| 36 | | Source | Read | Used for | |
| 37 | |---|---|---| |
| 38 | | **OpenVibe.Network** | `GET /api/v1/stats?days=7\|30\|90` | `/`, `/stats`, `GET /api/v1/stats` while `REPORT_SOURCE=network`; also the ingest source | |
| 39 | | **Report's tables** | `report_metric_days`, `report_metric_windows`, `report_streams` | the same routes while `REPORT_SOURCE=report` | |
| 40 | | **OpenVibe.Live** — The Rundown | `GET /api/rundown/`, `/api/rundown/topics`, `/api/rundown/t/:slug`, `/api/rundown/e/:streamId`, `/api/rundown/s/:username` | `/rundown`, `/rundown/t/:slug`, `/rundown/e/:streamId`, `/rundown/s/:username` | |
| 41 | | **OpenVibe.Live** — after-show reports | `GET /api/recap/:streamId`, `GET /api/recap/channel/:username` | `/after-show/:streamId`, `/c/:username` | |
| 42 | | **OpenVibe.Bench** | `GET /api/v1/kinds`, `/api/v1/boards/:kind?type=elo\|pareto\|cost`, `/api/v1/benchmarks`, `/api/v1/rankings`, `/api/v1/runs`, `/api/v1/overview`, `/api/v1/models/:model`, `/api/v1/models/:model/summary` | `/ai`, `/ai/:kind`, `/ai/<benchmark>`, `/ai/models/:model`, `/ai/compare`, `/ai/method`, `/ai/feed.xml`, `GET /api/v1/ai/results` | |
| 43 | | **OpenVibe.AI** | `GET /api/v1/exchange/catalog` | `GET /api/v1/ai/leaderboard`, `/api/v1/ai/models/:id`, `/api/v1/ai/providers`, `/api/v1/ai/compare?models=`, `/api/v1/ai/picks` | |
| 44 | | **OpenVibe.Billing** | `GET /api/v1/stats`, `GET /api/v1/transactions?subject=…` | the revenue read for a channel; network money counts | |
| 45 | | **OpenVibe.Billing** — settlements, through Events | `billing.credit.settled@1` on `POST /internal/events` (only with `REPORT_AI_EXCHANGE=1`) | the AI Exchange aggregates and the staff page/API | |
| 46 | |
| 47 | A source that does not answer is null, after one short log line; the page (or API route) becomes a 404. The service |
| 48 | never invents a zero and never throws a source error at the reader. |
| 49 | |
| 50 | ## What Report owns now |
| 51 | |
| 52 | - **The analytics tables** in `migrations/0001_initial.sql`: `report_inbox` (event ids already handled), |
| 53 | `report_events`, `report_streams`, `report_frame_days`, `report_metric_days`, `report_metric_windows`, |
| 54 | `report_metric_json`, `report_dimensions`, `report_shadow_runs` and `report_shadow_diffs`. |
| 55 | - **The AI meter** in `migrations/0002_ai_usage.sql` and `0003_ai_usage_surface.sql`: `report_ai_usage` (raw calls, |
| 56 | kept 90 days) and `report_ai_usage_days`; the consumer is `server/analytics/ai-usage.js`. |
| 57 | - **The AI Exchange aggregates** in `migrations/0005_ai_exchange.sql`: `report_ai_exchange_days` (day, model, |
| 58 | provider access, requests, sandbox requests, the four token counts, exact nano-USD retail/shortfall/supply/margin), |
| 59 | `report_ai_exchange_seen` (settlement ids already counted) and `report_ai_exchange_rejected` (invalid payloads). |
| 60 | The consumer is `server/analytics/ai-exchange.js`. |
| 61 | - **The computation** in `server/analytics/`: `ingest.js` (idempotent writes), `query.js` (Network's stats shape and |
| 62 | a channel's creator analytics), `shadow.js` (the comparison), `ai-exchange.js` (the Exchange aggregates). |
| 63 | - **The jobs** in `server/jobs/analytics.js`: ingest every five minutes (7, 30 and 90-day windows) and shadow every |
| 64 | fifteen minutes (7 days by default). |
| 65 | |
| 66 | ## Does not own |
| 67 | |
| 68 | - **Money** — OpenVibe.Billing. Report reads it with `billing.stats.read` and `billing.balance.read`; it holds no |
| 69 | ledger and computes no price. |
| 70 | - **The Rundown and the after-show reports** — OpenVibe.Live. Report quotes them, including their counts and text. |
| 71 | - **The benchmark results** — OpenVibe.Bench. Report renders its rows and never derives a score, cost or time; Bench stayed the authority when its public pages moved here (2026-11-08) and its old HTML pages answer 301 to `/ai` counterparts. |
| 72 | - **Identity, sessions and writes** — Report has no sign-in, no cookies and no public write route. `POST |
| 73 | /internal/events` is loopback-only and HMAC-verified. |
| 74 | - **The Shared Frame** — `openvibe-shared` (navbar, footer, theme-loader, static files), served from this site's own |
| 75 | pinned copy at `/shared/*`. |
| 76 | |
| 77 | ## Pages |
| 78 | |
| 79 | All server-rendered in `server/views/index.js`; every dynamic value goes through `esc()`. |
| 80 | |
| 81 | | Page | What it shows | |
| 82 | |---|---| |
| 83 | | `/` | the 7-day open numbers, the Rundown's counts and lead, and the trending topics | |
| 84 | | `/stats?days=7\|30\|90` | the network totals and a first look at every site (anything else is 30) | |
| 85 | | `/me?days=7\|30\|90` | **signed in only, private, noindex**: your sites and activity (Network's history), what you made, your AI and Vibes, with a switch per section to publish it (the owner, 2026-11-09) | |
| 86 | | `/u/:username` | a person's published stats: only the sections they turned on, with Person JSON-LD; noindex and no numbers until they publish | |
| 87 | | `/rundown` | on air, the lead, the latest streams and every topic; canonical is OpenVibe.Live's `/rundown` | |
| 88 | | `/rundown/t/:slug` | one topic, its takes and related topics; canonical is Live's `/rundown/t/:slug` | |
| 89 | | `/rundown/e/:streamId` | one stream as an episode, with its segments; canonical is Live's `/rundown/e/:streamId` | |
| 90 | | `/rundown/s/:username` | one streamer's episodes and topics; canonical is Live's `/rundown/:username` | |
| 91 | | `/after-show/:streamId` | one after-show report | |
| 92 | | `/c/:username` | one channel's latest after-show reports | |
| 93 | | `/ai` | the AI overview: every published benchmark with its leader, runs and last run; every task kind with its leader, quality, cost and speed; a link to the method and compare pages | |
| 94 | | `/ai/:kind` | one task kind: Elo rank, quality against cost, cost per solved, the verified runs on its benchmarks, and the latest runs | |
| 95 | | `/ai/<benchmark>` | one public Bench benchmark: verified rankings with score, cost, speed and last run, a score-against-cost SVG, and the latest runs | |
| 96 | | `/ai/models/:model` | a model's runs over time, its best and worst tasks, cost, per-benchmark record and recent runs | |
| 97 | | `/ai/compare` | two or three models on the same benchmarks, side by side | |
| 98 | | `/ai/method` | how a score is made, how runs are judged, verified vs submitted, and what the counts mean | |
| 99 | | `/ai/feed.xml` | new public runs as RSS; the same rows are `GET /api/v1/ai/results` | |
| 100 | | `/ai/exchange` | **staff only**, with `REPORT_AI_EXCHANGE=1`: Billing's settlements by day, model and access, a daily chart and the exact amounts. Needs a signed-in person whose staff role holds `staff.site.view` | |
| 101 | |
| 102 | A bad path parameter (slug, stream id, username) or a source that answered nothing renders `views.notFound()` with |
| 103 | status 404. Pages are `Cache-Control: public, max-age=120`. |
| 104 | AI pages use a 60-second Bench read cache and serve its last good copy with the read time during an outage. Their |
| 105 | HTML is `no-store` so the outage notice stays current. |
| 106 | |
| 107 | ## API |
| 108 | |
| 109 | All under `/api/v1`, CORS `*`, `Cache-Control: public, max-age=120` (the popularity pair: 300), 120 requests a minute per address: |
| 110 | |
| 111 | ``` |
| 112 | GET /api/v1/stats?days=7|30|90 REPORT_SOURCE=network|report |
| 113 | GET /api/v1/popular?limit=5 the front door's ranked sites and tools (public, aggregate) |
| 114 | GET /api/v1/popular/co-usage opt-in site pairs for "Try next", small groups omitted |
| 115 | GET /api/v1/rundown |
| 116 | GET /api/v1/rundown/topics |
| 117 | GET /api/v1/rundown/t/:slug |
| 118 | GET /api/v1/after-show/:streamId |
| 119 | GET /api/v1/c/:username |
| 120 | GET /api/v1/ai/results the new AI runs, newest first |
| 121 | GET /api/v1/me/stats?days=7|30|90 the signed-in person's own numbers (private, no-store) |
| 122 | PUT /api/v1/me/stats/published the five per-section switches (same-origin, JSON) |
| 123 | GET /api/v1/u/:name/stats only the sections that person published (404 when none) |
| 124 | ``` |
| 125 | |
| 126 | A bad parameter or a missing source answer is a 404 `application/problem+json` body built with |
| 127 | `openvibe-contracts` `http.sendProblem`. The API is read-only except for the person's own publish switches; other |
| 128 | methods get 405. `GET /api/v1/me/stats` and `PUT /api/v1/me/stats/published` answer the `report.me-stats@1` document |
| 129 | (the switch request is `report.me-stats-publish@1`); the public route copies only the published sections from the |
| 130 | person's last snapshot, so a section a source did not answer for is absent, never a zero. |
| 131 | |
| 132 | `/api/v1/popular` ranks Report's own daily and window numbers: opens today (UTC, still counting) and over seven days, |
| 133 | the seven before that for growth, and the distinct signed-in people Network's cross-site history counts. A tool is a |
| 134 | destination beside a site. The answer is public and aggregate only, held five minutes. `/api/v1/popular/co-usage` |
| 135 | reads only people who switched their `sites` section public (`report_me_stats.published`); it returns pair counts, never |
| 136 | a person, username or subject, and a pair below three people is left out. |
| 137 | |
| 138 | The AI Exchange JSON read is the staff exception (with `REPORT_AI_EXCHANGE=1`): a Network service token with the |
| 139 | same `report.ai_usage.read` capability the AI meter uses, never cached, no person or project in the answer. The |
| 140 | HTML page is gated by the signed-in person's staff role (`staff.site.view`) instead, like the other staff pages. |
| 141 | |
| 142 | ``` |
| 143 | GET /api/v1/ai/exchange?from=YYYY-MM-DD&to=YYYY-MM-DD the Exchange aggregates, staff only |
| 144 | ``` |
| 145 | |
| 146 | The AI performance rows (`report.ai-performance@1`, plan L40/L41) answer the speed, reliability, tokens and money of |
| 147 | the calls Report meters. Model, provider and operation are public and show only a group with at least 20 calls; system, |
| 148 | task, place and harness need a signed-in person with `staff.site.view` or a service token with |
| 149 | `report.ai_usage.read`, and are never cached. The percentiles come from log-scale histograms and are the upper bound |
| 150 | of the bucket a call lands in; a task's cost and time are the sums of the calls that share its `task_id`. |
| 151 | |
| 152 | ``` |
| 153 | GET /api/v1/ai/performance?group=model|provider|operation&days=7|30|90 public, 20-call floor |
| 154 | GET /api/v1/ai/performance?group=system|task|place|harness&days=7|30|90 staff, filters allowed |
| 155 | ``` |
| 156 | |
| 157 | ### Internal (loopback only) |
| 158 | |
| 159 | ``` |
| 160 | POST /internal/events OpenVibe.Events delivery (v2 HMAC, REPORT_EVENTS_SECRET) |
| 161 | GET /internal/shadow?days=7&limit=5 the latest shadow runs and their differences |
| 162 | POST /internal/shadow?days=7 run a comparison now |
| 163 | GET /internal/metrics?scope=&id=&days= the flat report.metric@1 rows |
| 164 | GET /internal/channels/:channel/analytics a channel's creator analytics plus Billing revenue |
| 165 | ``` |
| 166 | |
| 167 | nginx refuses `/internal/` from outside; the app also checks the socket is loopback. The flat rows are proposed as |
| 168 | `report.metric@1` in [docs/report.metric.v1.json](docs/report.metric.v1.json) for Contracts to own. |
| 169 | |
| 170 | ## Ingest and idempotency |
| 171 | |
| 172 | - `live.stream.ended` → one `report_streams` row (same fields as Network's `creator_streams`) and the channel/day |
| 173 | counts. A redelivered event id is a no-op; a corrected copy with a new event id replaces the stream and does not |
| 174 | count it twice. |
| 175 | - `network.frame.hit` / `network.frame.day` → `report_frame_days` (day, host, hits). |
| 176 | - `GET /api/v1/stats` → `report_metric_days`, `report_metric_windows`, `report_metric_json` and site dimensions, |
| 177 | keyed by the answer's content hash. Polling the same numbers again is a no-op. |
| 178 | - Every delivery is claimed in `report_inbox` (openvibe-sdk/events `createPgInbox`) in the same transaction as its |
| 179 | writes. |
| 180 | - `billing.credit.settled` (with `REPORT_AI_EXCHANGE=1`) → `report_ai_exchange_days`, keyed by day, model and |
| 181 | provider access, and `report_ai_exchange_seen` by settlement id. The payload is validated against |
| 182 | `billing.credit.settled@1`; an invalid one is counted in `report_ai_exchange_rejected` and never aggregated. |
| 183 | Replaying the same settlement under a new event id counts once. A sandbox settlement only increments |
| 184 | `sandbox_requests`. The raw settlement is never stored. With the flag off, the delivery is ignored before anything |
| 185 | is written. |
| 186 | - Billing's other money still comes from Billing's read API, never from an event. |
| 187 | |
| 188 | ## Configuration |
| 189 | |
| 190 | Every value comes from the environment (production: `/etc/openvibe/report.env`). See [.env.example](.env.example). |
| 191 | |
| 192 | | Name | Default | Meaning | |
| 193 | |---|---|---| |
| 194 | | `NODE_ENV` | `development` | production switches the base URL and is required for a real deploy | |
| 195 | | `PORT` | `5080` | the loopback port nginx proxies to | |
| 196 | | `HOST` | `127.0.0.1` | listen address | |
| 197 | | `BASE_URL` | `https://openvibe.report` in production, otherwise `http://localhost:5080` | canonical links, sitemap and llms.txt | |
| 198 | | `TRUST_PROXY` | `2` | Express trust proxy (the app sets no cookies, but the per-address limit uses the client address) | |
| 199 | | `DATABASE_URL` | unset in development | Report's PostgreSQL through PgBouncer; without it development uses PGlite | |
| 200 | | `DATABASE_DIRECT_URL` | unset | the owner connection migrations run on | |
| 201 | | `REPORT_PGLITE_DIR` | `./data/pglite` | the embedded development database | |
| 202 | | `REPORT_SOURCE` | `network` | `network` keeps today's behaviour; `report` answers from Report's tables | |
| 203 | | `OV_NETWORK_INTERNAL_URL` | `http://127.0.0.1:4000` | Network's loopback origin (its manifest's `internalOrigin`) | |
| 204 | | `OV_NETWORK_URL` | `https://openvibe.network` | the OAuth token endpoint's origin | |
| 205 | | `OV_LIVE_INTERNAL_URL` | `http://127.0.0.1:3000` | Live's loopback origin | |
| 206 | | `OV_BENCH_INTERNAL_URL` | `https://bench.openvibe.services` | Bench's public read origin, optionally an internal origin | |
| 207 | | `OV_AI_INTERNAL_URL` | `https://api.openvibe.exchange` | AI's public Exchange catalogue and measured provider offers (`/api/v1/exchange/catalog`; the ai.openvibe.services vhost does not pass it) | |
| 208 | | `OV_BILLING_INTERNAL_URL` | `http://127.0.0.1:4600` | Billing's loopback origin | |
| 209 | | `OV_OAUTH_CLIENT_ID` / `OV_OAUTH_CLIENT_SECRET` | `report` / unset | Report's client-credentials token for `openvibe.billing` | |
| 210 | | `REPORT_BILLING_TOKEN` | unset | an explicit Billing bearer token for an operator run | |
| 211 | | `REPORT_EVENTS_SECRET` | unset | the HMAC secret for `POST /internal/events` (32+ chars, comma list for rotation) | |
| 212 | | `REPORT_AI_EXCHANGE` | `0` | `1` consumes Billing's `billing.credit.settled` events into the AI Exchange aggregates and opens `/ai/exchange` and `GET /api/v1/ai/exchange`; off consumes nothing | |
| 213 | | `REPORT_JOBS` | `1` | `0` turns the ingest and shadow jobs off | |
| 214 | | `REPORT_JOBS_ON_BOOT` | `1` | run both jobs once shortly after boot | |
| 215 | | `REPORT_INGEST_INTERVAL_MS` | `300000` | how often the ingest job runs | |
| 216 | | `REPORT_SHADOW_INTERVAL_MS` | `900000` | how often the shadow job runs | |
| 217 | | `REPORT_SHADOW_WINDOW_DAYS` | `7` | the shadow window (7, 30 or 90) | |
| 218 | | `REPORT_API_RATE_LIMIT_PER_MIN` | `120` | per-address API requests a minute | |
| 219 | | `REPORT_READY_TIMEOUT_MS` | `2500` | how long a readiness probe may take | |
| 220 | | `RELEASE_COMMIT` | unset | `/release.json`'s release; otherwise read from git in the checkout | |
| 221 | |
| 222 | ## Run and test |
| 223 | |
| 224 | ```sh |
| 225 | npm install |
| 226 | npm start # http://127.0.0.1:5080; development uses the embedded PGlite database |
| 227 | npm test # every test/*.test.js, in its own process |
| 228 | ``` |
| 229 | |
| 230 | Most tests stub both sources and pass `store: null`, so they need no database. The analytics tests use |
| 231 | `test/helpers/db.js` (`createTestDb` from openvibe-sdk/testing) with a migrated PGlite, or the PostgreSQL containers |
| 232 | with `REPORT_TEST_STORE=pg`. They cover ingest idempotency, every metric against a fixture's expected numbers, the |
| 233 | shadow job's difference and clean run, `GET /internal/shadow`, the source flag, the Billing revenue read with a stub, |
| 234 | the AI Exchange aggregation/validation/replay/BigInt/sandbox/staff-gate/flag-off cases, and the existing |
| 235 | pages/API/health/crawl behaviour. |
| 236 | |
| 237 | `node --check` is run over `server/`, `test/` and `public/` by CI (`.github/workflows/ci.yml`, the shared |
| 238 | OpenVibe.Shared test workflow). |
| 239 | |
| 240 | ## Cutover |
| 241 | |
| 242 | 1. Run the ingest and shadow jobs long enough to cover a full 90-day window, and check |
| 243 | `GET /internal/shadow` has zero differences (or only explained ones). |
| 244 | 2. Create Report's first-party grants on Network: `billing.stats.read` and `billing.balance.read` for the `report` |
| 245 | service (and the Network read grants when Report starts pulling creator analytics directly). |
| 246 | 3. Set `REPORT_SOURCE=report` in `/etc/openvibe/report.env` and restart; keep the Network ingest running. |
| 247 | 4. Leave Network writing during the dual-write window. Live's `ANALYTICS_SOURCE` can then move to Report; Network's |
| 248 | `/stats` becomes a 301 to `openvibe.report/stats`. |
| 249 | 5. After the N-1 window, a Contracts migration removes Network's analytics tables. Rollback before that is |
| 250 | `REPORT_SOURCE=network` and a restart. |
| 251 | |
| 252 | ## Deploy |
| 253 | |
| 254 | The reference files are in `deploy/`: |
| 255 | |
| 256 | - `deploy/systemd/openvibe-report.service` — `User=ubuntu`, `WorkingDirectory=/opt/openvibe.report`, |
| 257 | `EnvironmentFile=/etc/openvibe/report.env`, `PORT=5080`, `SIGTERM` with a 10 s drain. |
| 258 | - `deploy/nginx/openvibe.report.conf` — HTTP → HTTPS, `www` → apex 301, Let's Encrypt certificates under |
| 259 | `/etc/letsencrypt/live/openvibe.report/`, proxy to `127.0.0.1:5080`, the same security headers as Help's vhost, |
| 260 | `/api/` GET-only with a coarse rate limit, and `/internal/` refused from outside. |
| 261 | |
| 262 | `/api/ready` reports `network` and `live` as optional checks: one or both down is `status: "degraded"` with HTTP 200. |
| 263 | When `DATABASE_URL` is configured, the database check is included and is required only after `REPORT_SOURCE=report`. |
| 264 | |
| 265 | ## Security |
| 266 | |
| 267 | Every source text is escaped before it reaches HTML. Path parameters are checked against fixed patterns before any |
| 268 | request. Report's own sign-in serves `/you` and the staff page, which reads the signed-in person's staff role. Its |
| 269 | database has public channel facts, the AI meter and aggregate AI Exchange numbers; a raw settlement (a person, a |
| 270 | project, money) is never stored. `/internal/` is loopback-only, and `POST /internal/events` verifies the Events v2 |
| 271 | HMAC. See [SECURITY.md](SECURITY.md). |
| 272 | |
| 273 | <!-- versions:start --> |
| 274 | - openvibe-contracts: v0.421.0 |
| 275 | - openvibe-sdk: v0.161.0 |
| 276 | - openvibe-shared: v4.241.0 |
| 277 | - openvibe-publishing: v1.7.0 |
| 278 | <!-- versions:end --> |
| 279 | |
| 280 | ### AI market data and provenance |
| 281 | |
| 282 | The AI pages and `GET /api/v1/ai/leaderboard` and `/api/v1/ai/providers` read |
| 283 | `https://api.openvibe.exchange/api/v1/exchange/market` alongside the routed catalog. |
| 284 | Report follows every market cursor and caches the complete result for 120 seconds. |
| 285 | An upstream failure retains the last complete market snapshot with `stale: true`; |
| 286 | without a snapshot, Report falls back to the routed catalog. |
| 287 | |
| 288 | The market is public (`exchange.market@1`, cached 60 seconds). It accepts `limit` |
| 289 | (1–500, default 100), opaque `cursor`, `maker`, `open_weights=true|false`, and |
| 290 | `kind=chat|embedding|stt|image`. Responses contain `offers`, `total_models`, |
| 291 | `total_offers`, `next_cursor`, `generated_at`, and the models.dev metadata snapshot's |
| 292 | `source`, `checked_at`, and `stale` state. Each offer identifies `model`, `provider`, |
| 293 | `provider_model`, `routed`, `provider_access`, and the preset's `uncensored` flag. |
| 294 | Prices are USD per 1M tokens (`in`, `out`, `cached`), with `price_source` pointing |
| 295 | to that provider's own page and `price_checked_at`. Unknown prices remain null. |
| 296 | A latest observed price can differ from the price currently approved for routing. |
| 297 | |
| 298 | Model fields include maker, context, max output, open weights, licence, release |
| 299 | date, knowledge cutoff and modalities. `field_sources` names the provider page or |
| 300 | `https://models.dev/api.json` for each known field. Metadata refreshes daily on |
| 301 | read; a failed refresh preserves the previous copy and marks it stale. |
| 302 | |
| 303 | Comparison rows include every market model, rated rows first, then unrated rows. |
| 304 | Their first provider offer has the lowest known own-page output price, breaking |
| 305 | ties by input price; input/output/cached prices always come from that same offer. |
| 306 | Provider measurements join only routed offers. Other offers display “Not routed |
| 307 | here”; missing measurements on routed offers display “Not measured yet”. |
| 308 |