LLM Pricing Change Feed
Pricing
from $10.00 / 1,000 source checkeds
LLM Pricing Change Feed
Watches LLM provider pricing pages (Anthropic, OpenAI, Google Vertex, Mistral, Groq, Together, DeepSeek, Cohere, Fireworks, OpenRouter) and emits a structured JSON diff per run: what changed, old value, new value, timestamp, source URL. Fails loudly per source; never emits a silent empty result.
Pricing
from $10.00 / 1,000 source checkeds
Rating
0.0
(0)
Developer
Sairam S
Maintained by CommunityActor stats
0
Bookmarked
2
Total users
1
Monthly active users
2 days ago
Last modified
Categories
Share
An Apify Actor that watches LLM provider pricing pages and emits a structured JSON diff per run: what changed, old value, new value, timestamp, source URL.
It is a feed, not a snapshot scraper. The previous observation is kept in the Actor's key-value store, so every run answers one question: what is different about LLM pricing since last time? Consumers are developers and agents, so every record is a flat JSON object with stable keys.
Sources (default list)
| id | page | strategy |
|---|---|---|
anthropic | https://platform.claude.com/docs/en/about-claude/pricing | html |
openai | https://developers.openai.com/api/docs/pricing | html |
google-vertex | https://cloud.google.com/vertex-ai/generative-ai/pricing | html |
mistral | https://mistral.ai/pricing/api/ | browser |
xai | https://docs.x.ai/developers/pricing | browser |
groq | https://console.groq.com/docs/models | html |
together | https://www.together.ai/pricing | html |
deepseek | https://api-docs.deepseek.com/quick_start/pricing | html |
cohere | https://cohere.com/pricing | html |
fireworks | https://fireworks.ai/pricing | html |
openrouter | https://openrouter.ai/api/v1/models | json |
Public pages only. No login walls, no personal data. You can replace the list entirely through the sources input.
How it works
- Fetch each source: plain HTTP with browser-like headers for
html/jsonstrategies (cheap and fast), or a headless Chromium (Playwright) for thebrowserstrategy, which renders the page before extraction. Chromium is launched lazily, only when at least one source usesbrowser(a run without browser sources logsbrowser: not neededand never starts it), and is closed at the end of the run. - Extract pricing facts, a flat
{ key: value }map:- every table row that carries a currency amount, or that sits in a table whose header names a price column, becomes
table:<header signature>:<row label>→ the remaining cells; - every remaining text line with a currency amount becomes
text:<line shape>(numbers masked) → the line, so a pure number change is reported as changed with old/new, and a new plan or model as added; - for JSON sources, each model with a
pricingobject becomesmodel:<id>→ its pricing fields, sorted.
- every table row that carries a currency amount, or that sits in a table whose header names a price column, becomes
- Diff against the stored snapshot, push change records, store the new snapshot.
Fail loudly, never silently
A quiet zero-diff is indistinguishable from a broken scraper. So per source:
- HTTP status other than 200 →
errorrecord with reasonhttp_<status>; the other sources keep running. - Extraction yields fewer than
minFactsfacts (page moved, markup changed, content went client-side) →errorwith reasonextraction_collapsed; the stored baseline is not overwritten. - Fact count fell by more than
shrinkTolerancepercent since the last run →errorwith reasonsuspicious_shrink; baseline kept. - Every run ends with a
run_summaryrecord, even when nothing changed, so an empty dataset can never be mistaken for "no changes". - If every source fails, the run itself fails (non-zero exit) so schedules and alerts see it.
Input
{"sources": [],"fullSnapshot": false,"resetBaseline": false,"minFacts": 5,"shrinkTolerance": 50,"timeoutSecs": 30}
| field | type | default | meaning |
|---|---|---|---|
sources | array of { id, url, strategy, minFacts? } | [] = built-in list | pages to watch; strategy is html, json or browser (renders the page in headless Chromium first); optional per-source minFacts overrides the global one (set it to what a healthy fetch of that page yields) |
fullSnapshot | boolean | false | also emit one fact record per observed fact (full current state) |
resetBaseline | boolean | false | ignore the stored snapshot; every source is re-baselined |
minFacts | integer | 5 | fewer facts than this = extraction collapsed |
shrinkTolerance | integer (%) | 50 | max share of facts that may vanish in one run before it is flagged |
timeoutSecs | integer | 30 | per-request HTTP timeout |
Output
The default dataset holds one JSON object per record. type tells you which kind:
change (the product):
{"type": "change","changeType": "changed","source": "anthropic","url": "https://platform.claude.com/docs/en/about-claude/pricing","key": "table:model-base-input-tokens-5m-cache-writes-1h-cache-writes-cache-hits-and-refreshes:claude-opus-5","old": "$5 / MTok | $6.25 / MTok | $10 / MTok | $0.50 / MTok | $25 / MTok","new": "$4 / MTok | $5 / MTok | $8 / MTok | $0.40 / MTok | $20 / MTok","observedAt": "2026-09-08T06:00:03.101Z","previousObservedAt": "2026-09-07T06:00:01.884Z"}
changeType is changed (old and new), added (old is null) or removed (new is null).
baseline — emitted once per source on its first observation, carrying the full fact map so you can seed your own store:
{ "type": "baseline", "source": "deepseek", "url": "…", "observedAt": "…", "factCount": 6, "contentHash": "sha256…", "facts": { "table:table-1:peak": "$0.014 | $0.044 | $0.014", "…": "…" } }
error — one per failed source:
{ "type": "error", "source": "moved-page", "url": "…", "observedAt": "…", "reason": "http_404", "detail": "expected 200, got 404 (final URL …)", "previousObservedAt": null, "previousFactCount": null }
run_summary — always the last record:
{ "type": "run_summary", "observedAt": "…", "mode": "diff-only", "sourcesChecked": 11, "sourcesOk": 11, "sourcesFailed": 0, "failedSources": [], "baselines": 0, "changes": 3, "perSource": [ { "source": "anthropic", "ok": true, "factCount": 58, "changes": 1, "baseline": false, "reason": null }, "…" ] }
fact — only with fullSnapshot: true: { "type": "fact", "source", "url", "key", "value", "observedAt" }.
Worked example
Schedule the Actor daily with the default input.
- Day 1 (first run): 11
baselinerecords (one per source, 58 facts for Anthropic, 57 for OpenAI, 898 facts for Google Vertex, 430 models for OpenRouter, 50 for Mistral, 41 for xAI, …) and arun_summarywithbaselines: 11, changes: 0. - Day 2, nothing moved: exactly one record, the
run_summarywithchanges: 0,sourcesOk: 11. - Day 3, Anthropic cuts Opus prices and OpenRouter lists a new model: one
changerecord withchangeType: "changed"for the Opus row (old and new cell values), onechangewithchangeType: "added"formodel:<new id>, and therun_summarywithchanges: 2. - Day 4, Groq moves its models page: one
errorrecord (http_404) forgroq, the other ten sources diff normally, the summary listsgroqunderfailedSources. Groq's baseline is untouched, so when the URL is fixed the next diff is against the last good observation.
To consume: read the dataset, keep records where type == "change", route by source. Or run with fullSnapshot: true once to pull the whole current price table.
Cost per run
Usage-billed (pay per event), no rental. Events and suggested prices:
| event | when | price |
|---|---|---|
run-start | once per run | $0.05 |
source-checked | per source that succeeded (failed sources are free) | $0.01 |
change-record | per change record pushed | $0.005 |
A quiet daily run on the default 11 sources costs $0.16 (0.05 + 11 × 0.01). A busy day with 20 changes costs $0.26. Daily monitoring is about $5 to $7 per month.
Browser sources cost more
A browser source launches headless Chromium and renders the full page, roughly 10x the compute of a plain HTTP fetch. Chromium is shared by all browser sources in a run and never starts when no source needs it. Measured 2026-09-10 on a MacBook (local apify run): the default 11-source run with the two browser sources (mistral, xai) took about 11 seconds wall-clock, versus about 7 seconds for the same list with the browser sources removed. On the Apify platform this also means the Actor must build on the Playwright Chrome image (apify/actor-node-playwright-chrome).
Run locally
git clone https://github.com/saiviki/llm-pricing-feed && cd llm-pricing-feednpm installnpm test # unit tests: differ + extractor on saved fixturesnpx apify-cli run --purge # first run: baselines into ./storagenpx apify-cli run --no-purge # second run: diff against the stored snapshotcat storage/datasets/default/*.json
Test evidence (real captured output, hashed) is regenerated with npm run evidence and committed under evidence/: unit test output, a baseline run, a no-change run, a run against a mutated baseline showing changed / added / removed records, a failure-path run (404 page, client-rendered page, non-model JSON), and SHA256SUMS.
Monitoring (nightly canary)
.github/workflows/canary.yml runs the actor for real at 02:17 UTC every night (GitHub Actions, no Apify platform cost): npm test, then scripts/canary.sh with the default input in the runner. The canary parses the run's run_summary:
- All sources ok → exit 0, job passes, no notification (silence means healthy).
- Any source failed → exit 1, job fails, and the failure body (failed source ids + reasons) is POSTed to a self-hosted ntfy server with a Bearer token, so a message lands on the phone. The raw dataset is uploaded as a workflow artifact.
To receive notifications, set the repository secrets NTFY_URL (e.g. https://ntfy.example.com), NTFY_TOPIC and NTFY_TOKEN. Run it manually from the Actions tab (Run workflow); on green runs nothing is sent, so the failure path is best exercised via workflow_dispatch with the input file test/fixtures/input-failure-demo.json — it fails four sources on purpose and must produce a phone message.
Run the canary locally the same way the workflow does:
bash scripts/canary.sh # default input, exit 0 when healthybash scripts/canary.sh --input-file path/to/input.json # custom inputcat canary/last-summary.json # parsed run_summary (gitignored)
canary.sh accepts CANARY_DATASET_DIR to parse an existing dataset directory instead of running the actor — that is how test/canary.test.js checks both verdicts without touching the network. scripts/notify.sh sends the notification and is exercised against test/ntfy-stub.py in test/notify.test.js.
Known limitations (v0.1)
- Mistral renders its API prices as cards rather than a
<table>, so its facts aretext:keys per price class (input-/m-tokens,output-/m-tokens, …) instead of model-keyedtable:rows; a pure price move shows aschangedon that key. Mistral's page is also geo/personalization-sensitive (USD/EUR toggle), so a currency switch shows up as a large diff. - xAI's
/developers/modelspage lists featured models only; the defaultxaisource therefore watchesdocs.x.ai/developers/pricing, where the per-model price tables live. - Some documentation sites answer missing pages with HTTP 200 (soft 404). Those are caught by the
extraction_collapsedguard rather than the HTTP status check; the evidence run shows both cases. A client-rendered page can still leak a handful of orphan price strings into the server HTML and pass a low globalminFacts; that is what the per-sourceminFactsoverride is for (seetest/fixtures/input-failure-demo.json). - Google:
ai.google.devsends non-browser clients into a sign-in probe, so the Vertex AI pricing page is used. It is large (about 880 facts) and rowspan-heavy; continuation rows are keyed by parent row plus first non-empty cell. - Table keys are derived from header text and the first cell. If a provider renames a column, the affected rows show up once as
removed+addedpairs; that is the intended signal (the page structure changed), not a bug. - Values are the raw cell text, not normalized to USD per million tokens. Normalization is deliberately out of scope for the feed; it belongs in the consumer.
License
MIT.