LLM Pricing Change Feed avatar

LLM Pricing Change Feed

Pricing

from $10.00 / 1,000 source checkeds

Go to Apify Store
LLM Pricing Change Feed

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

Sairam S

Maintained by Community

Actor 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)

idpagestrategy
anthropichttps://platform.claude.com/docs/en/about-claude/pricinghtml
openaihttps://developers.openai.com/api/docs/pricinghtml
google-vertexhttps://cloud.google.com/vertex-ai/generative-ai/pricinghtml
mistralhttps://mistral.ai/pricing/api/browser
xaihttps://docs.x.ai/developers/pricingbrowser
groqhttps://console.groq.com/docs/modelshtml
togetherhttps://www.together.ai/pricinghtml
deepseekhttps://api-docs.deepseek.com/quick_start/pricinghtml
coherehttps://cohere.com/pricinghtml
fireworkshttps://fireworks.ai/pricinghtml
openrouterhttps://openrouter.ai/api/v1/modelsjson

Public pages only. No login walls, no personal data. You can replace the list entirely through the sources input.

How it works

  1. Fetch each source: plain HTTP with browser-like headers for html/json strategies (cheap and fast), or a headless Chromium (Playwright) for the browser strategy, which renders the page before extraction. Chromium is launched lazily, only when at least one source uses browser (a run without browser sources logs browser: not needed and never starts it), and is closed at the end of the run.
  2. 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 pricing object becomes model:<id> → its pricing fields, sorted.
  3. 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 → error record with reason http_<status>; the other sources keep running.
  • Extraction yields fewer than minFacts facts (page moved, markup changed, content went client-side) → error with reason extraction_collapsed; the stored baseline is not overwritten.
  • Fact count fell by more than shrinkTolerance percent since the last run → error with reason suspicious_shrink; baseline kept.
  • Every run ends with a run_summary record, 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
}
fieldtypedefaultmeaning
sourcesarray of { id, url, strategy, minFacts? }[] = built-in listpages 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)
fullSnapshotbooleanfalsealso emit one fact record per observed fact (full current state)
resetBaselinebooleanfalseignore the stored snapshot; every source is re-baselined
minFactsinteger5fewer facts than this = extraction collapsed
shrinkToleranceinteger (%)50max share of facts that may vanish in one run before it is flagged
timeoutSecsinteger30per-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 baseline records (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 a run_summary with baselines: 11, changes: 0.
  • Day 2, nothing moved: exactly one record, the run_summary with changes: 0, sourcesOk: 11.
  • Day 3, Anthropic cuts Opus prices and OpenRouter lists a new model: one change record with changeType: "changed" for the Opus row (old and new cell values), one change with changeType: "added" for model:<new id>, and the run_summary with changes: 2.
  • Day 4, Groq moves its models page: one error record (http_404) for groq, the other ten sources diff normally, the summary lists groq under failedSources. 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:

eventwhenprice
run-startonce per run$0.05
source-checkedper source that succeeded (failed sources are free)$0.01
change-recordper 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-feed
npm install
npm test # unit tests: differ + extractor on saved fixtures
npx apify-cli run --purge # first run: baselines into ./storage
npx apify-cli run --no-purge # second run: diff against the stored snapshot
cat 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 healthy
bash scripts/canary.sh --input-file path/to/input.json # custom input
cat 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 are text: keys per price class (input-/m-tokens, output-/m-tokens, …) instead of model-keyed table: rows; a pure price move shows as changed on 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/models page lists featured models only; the default xai source therefore watches docs.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_collapsed guard 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 global minFacts; that is what the per-source minFacts override is for (see test/fixtures/input-failure-demo.json).
  • Google: ai.google.dev sends 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 + added pairs; 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.