# LLM Pricing Change Feed (`saiviki/llm-pricing-feed`) Actor

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.

- **URL**: https://apify.com/saiviki/llm-pricing-feed.md
- **Developed by:** [Sairam S](https://apify.com/saiviki) (community)
- **Stats:** 2 total users, 1 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

from $10.00 / 1,000 source checkeds

This Actor is paid per event. You are not charged for the Apify platform usage, but only a fixed price for specific events.

Learn more: https://docs.apify.com/actors/running/actors-in-store.md#pay-per-event

## What's an Apify Actor?

Actors are web data automations that power AI and operations. They run on the Apify platform to scrape websites, process data, connect APIs, and automate workflows.
In Batch mode, an Actor accepts a well-defined JSON input, performs an action which can take anything from a few seconds to a few hours,
and optionally produces a well-defined JSON output, datasets with results, or files in key-value store.
In Standby mode, an Actor provides a web server which can be used as a website, API, or an MCP server.
Actors are written with capital "A".

## How to integrate an Actor?

If asked about integration, you help developers integrate Actors into their projects.
You adapt to their stack and deliver integrations that are safe, well-documented, and production-ready.
The best way to integrate Actors is as follows.

- **AI agents and MCP clients** — the [Apify MCP server](https://docs.apify.com/integrations/mcp.md) at `https://mcp.apify.com` (remote, streamable HTTP, OAuth on first use).
- **Agentic workflows and local Actor development** — [Agent Skills](https://apify.com/.well-known/agent-skills/index.json) with the [Apify CLI](https://docs.apify.com/cli/docs.md): `npm install -g apify-cli`, then `apify login`.
- **JavaScript/TypeScript projects** — the official [JS/TS client](https://docs.apify.com/api/client/js/docs.md): `npm install apify-client`.
- **Python projects** — the official [Python client](https://docs.apify.com/api/client/python/docs.md): `pip install apify-client`.
- **Any other language** — the [REST API](https://docs.apify.com/api/v2.md).

For usage examples, see the [API](#api) section below.

For more details, see Apify documentation as [Markdown index](https://docs.apify.com/llms.txt) and [Markdown full-text](https://docs.apify.com/llms-full.txt).

# README

## LLM Pricing Change Feed

An [Apify](https://apify.com) 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

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

```json
{
  "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):

```json
{
  "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:

```json
{ "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:

```json
{ "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:

```json
{ "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:

| 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

```bash
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/`](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](https://ntfy.sh) 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
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.

# Actor input Schema

## `sources` (type: `array`):

Pages to watch. Each item: { "id": "anthropic", "url": "https://...", "strategy": "html" | "json" | "browser", "minFacts": 20 (optional, overrides the global minimum for this source) }. "browser" renders the page in headless Chromium before extraction (needed for client-rendered pages; costs more compute per run, and such runs log "browser: launched"). Leave empty to use the built-in list of 11 provider pricing pages.

## `fullSnapshot` (type: `boolean`):

If true, also push every currently observed pricing fact as a 'fact' record (one row per fact per source). If false (default), push only change records, plus one 'baseline' record per source on its first observation.

## `resetBaseline` (type: `boolean`):

Ignore the stored snapshot and treat this run as the first observation for every source. Use after you change the source list or when you want a fresh baseline.

## `minFacts` (type: `integer`):

A source that yields fewer pricing facts than this is reported as an error (extraction collapsed) instead of a diff, and its baseline is left untouched.

## `shrinkTolerance` (type: `integer`):

Percent of a source's previous fact count that may disappear in one run before the run is flagged as 'suspicious\_shrink' (likely markup change) instead of being trusted as a diff. 50 = flag if more than half the facts vanished.

## `timeoutSecs` (type: `integer`):

HTTP timeout per source fetch.

## Actor input object example

```json
{
  "sources": [],
  "fullSnapshot": false,
  "resetBaseline": false,
  "minFacts": 5,
  "shrinkTolerance": 50,
  "timeoutSecs": 30
}
```

# Actor output Schema

## `records` (type: `string`):

One JSON object per record in the default dataset. `type` is change, baseline, error, run\_summary or fact. Change records carry source, url, key, old, new, observedAt.

## `changes` (type: `string`):

The product: only records with type=change (added / removed / changed pricing facts).

# API

You can run this Actor programmatically using our API. Below are code examples in JavaScript, Python, and CLI, as well as the OpenAPI specification and MCP server setup.

## JavaScript example

```javascript
import { ApifyClient } from 'apify-client';

// Initialize the ApifyClient with your Apify API token
// Replace the '<YOUR_API_TOKEN>' with your token
const client = new ApifyClient({
    token: '<YOUR_API_TOKEN>',
});

// Prepare Actor input
const input = {
    "sources": []
};

// Run the Actor and wait for it to finish
const run = await client.actor("saiviki/llm-pricing-feed").call(input);

// Fetch and print Actor results from the run's dataset (if any)
console.log('Results from dataset');
console.log(`💾 Check your data here: https://console.apify.com/storage/datasets/${run.defaultDatasetId}`);
const { items } = await client.dataset(run.defaultDatasetId).listItems();
items.forEach((item) => {
    console.dir(item);
});

// 📚 Want to learn more 📖? Go to → https://docs.apify.com/api/client/js/docs

```

## Python example

```python
from apify_client import ApifyClient

# Initialize the ApifyClient with your Apify API token
# Replace '<YOUR_API_TOKEN>' with your token.
client = ApifyClient("<YOUR_API_TOKEN>")

# Prepare the Actor input
run_input = { "sources": [] }

# Run the Actor and wait for it to finish
run = client.actor("saiviki/llm-pricing-feed").call(run_input=run_input)

# Fetch and print Actor results from the run's dataset (if there are any)
print(f"💾 Check your data here: https://console.apify.com/storage/datasets/{run.default_dataset_id}")
for item in client.dataset(run.default_dataset_id).iterate_items():
    print(item)

# 📚 Want to learn more 📖? Go to → https://docs.apify.com/api/client/python/docs/quick-start

```

## CLI example

```bash
echo '{
  "sources": []
}' |
apify call saiviki/llm-pricing-feed --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,saiviki/llm-pricing-feed"
        }
    }
}
```

The hosted server signs you in with OAuth on first connect, so no API token belongs in this config. Clients without OAuth support can send an `Authorization: Bearer <APIFY_API_TOKEN>` header instead, using a token from API & Integrations in Apify Console (https://console.apify.com/settings/integrations).

## OpenAPI specification

Download the OpenAPI definition: https://api.apify.com/v2/actors/vh4jx4blbrDtn4Znq/builds/POG1eDiZuLvA8LOxS/openapi.json
