# Model changes

kenari publishes every change to the model list and its prices: a new model, a model that was removed, a price that moved, a discount that started or ended, a price change on a known date, a model that is going to retire, a model joining or leaving a subscription plan, and a model moved to a newer vendor revision under the same id. You can read these changes on a web page, in a Telegram channel, or from a public endpoint that a script or an agent can poll. All three show the same changes.

## What counts as a change

| `kind` | What happened |
| --- | --- |
| `added` | A new model is in the list. |
| `removed` | A model left the list. |
| `price_changed` | The price of a model changed, or it became free or stopped being free. |
| `discount_started` | The market price of a model is now above the kenari price on the input or output side, where it was not before. |
| `discount_ended` | The market price is no longer above the kenari price on a side. |
| `price_scheduled` | A price change was scheduled, with the date it applies. |
| `sunset_scheduled` | A model was scheduled to retire, with the date. |
| `plans_changed` | The subscription plans that include a model changed: it joined a plan, left one, or both. |
| `updated` | The model moved to a new revision from its vendor, such as a new checkpoint, with the same model id and price. |

The market price is the reference price that `GET /api/public/pricing` shows next to the kenari price. See [Models and pricing](/en/docs/models#public-price-list).

Other changes, such as a model becoming subscriber-only, are not published. A plan whose price or name changed without a model joining or leaving it is not a `plans_changed` entry.

## When a change appears

A change is published only after kenari is sure it is real. The list is checked every 5 minutes.

| `kind` | Appears |
| --- | --- |
| `price_changed`, `discount_started`, `discount_ended`, `plans_changed`, `updated` | After the change has held for about 10 minutes. |
| `added` | After the model has been listed for about an hour. |
| `removed` | After the model has been gone for about an hour. |
| `price_scheduled`, `sunset_scheduled` | At once, because the date is the news. |

A change that reverts quickly is never published. A price that is typed wrong and fixed within 10 minutes does not appear, and a model that disappears for a few minutes and comes back is not reported as removed. If a price goes from A to B to C within 10 minutes, the change is published as A to C.

The hour before a new model appears counts from when it was first listed. If its price or plans are edited during that hour, the hour does not start again, and the `added` entry shows the price and plans the model has when it is published.

A model that was already announced with `sunset_scheduled` still gets a `removed` entry in the feed when it leaves. The Telegram channel does not post it a second time.

## Where to read it

- The page [kenari.id/models/changes](https://kenari.id/models/changes) lists the changes. You do not need to sign in.
- The Telegram channel [t.me/kenari_updates](https://t.me/kenari_updates) gets the same changes. Posts are in English, with at most one post per check. Each post groups the changes by kind, with prices in Rupiah per 1M tokens and dates in WIB. A price change shows the percent it moved, a discount line shows the market price it is compared with, a retiring or removed model names its successor when it has one, and a new model names the plans that include it. A post that gets too long is split into more than one message.
- `GET /v1/models/changes` returns the changes as JSON.

## The endpoint

`GET /v1/models/changes`

The endpoint is public. It needs no API key, and browsers can call it from any origin (CORS is open). The response can be cached for 30 seconds, so asking more often than that returns the same answer.

### Request

| Parameter | Type | Required | Description |
| --- | --- | --- | --- |
| `since` | integer | No | Unix time in seconds. Return only changes that were confirmed at or after this moment. Defaults to 0, which means no lower bound. |
| `limit` | integer | No | How many changes to return, from 1 to 200. Defaults to 50. A value outside the range is moved to the nearest end of it. |

### Response

The response is a list: `{"object": "list", "channel": ..., "data": [...]}`.

| Field | Type | Description |
| --- | --- | --- |
| `object` | string | Always `list`. |
| `channel` | string or null | The public Telegram handle that gets the same changes, such as `@kenari_updates`. `null` when there is none. |
| `data` | array | The changes, newest first by `confirmed_at`. Empty when there is nothing at or after `since`. |

Each item in `data` has these fields:

| Field | Type | Description |
| --- | --- | --- |
| `model` | string | The model id, as in `GET /v1/models`. |
| `kind` | string | One of the kinds in [What counts as a change](#what-counts-as-a-change). |
| `old` | object, integer or null | The state before the change. The shape depends on `kind`. See [Old and new by kind](#old-and-new-by-kind). |
| `new` | object, integer or null | The state after the change, in the same shape as `old`. |
| `effective_at` | integer or null | Unix seconds when a scheduled change takes effect. Set for `price_scheduled` and `sunset_scheduled`, `null` for the other kinds. |
| `detected_at` | integer | Unix seconds when kenari first saw the change. |
| `confirmed_at` | integer | Unix seconds when the change entered this list. For scheduled changes it equals `detected_at`. About 10 minutes later for price changes, discounts and plan changes, and about an hour after the model was first listed or gone for `added` and `removed`. |
| `successor` | string or null | The model named as this model's successor, for `sunset_scheduled` and `removed`. `null` otherwise. Naming a successor does not by itself mean requests are forwarded to it. |

```json
{
  "object": "list",
  "channel": "@kenari_updates",
  "data": [
    {
      "model": "step-3-7-flash",
      "kind": "price_changed",
      "old": { "free": false, "price_in": 1000000000, "price_out": 4000000000, "image": null },
      "new": { "free": false, "price_in": 1200000000, "price_out": 4000000000, "image": null },
      "effective_at": null,
      "detected_at": 1791529200,
      "confirmed_at": 1791532800,
      "successor": null
    }
  ]
}
```

### Old and new by kind

Prices are whole numbers in micro-Rupiah, where Rp 1 is 1,000,000 micro-Rupiah. A token price is per 1M tokens, so `1200000000` means Rp 1,200 per 1M tokens. See [How billing works](/en/docs/billing).

**Price object.** Used by `added`, `removed`, `price_changed` and `price_scheduled`. It has these four fields:

| Field | Description |
| --- | --- |
| `free` | `true` when the model is free. |
| `price_in` | Input price per 1M tokens. |
| `price_out` | Output price per 1M tokens. |
| `image` | Price per image, for models that are priced per image. |

A price field is `null` when the model has no price of that kind, for example `image` on a chat model.

On `added`, the price object also has a `plans` field when one or more subscription plans include the new model: the plan names, cheapest plan first. It is left out when no plan includes the model.

On `added`, a model priced in tokens also has `price_cached` (the Cache read price per 1M tokens) and `price_cache_write` (the Cache write price per 1M tokens) when it has that price set. Each is left out when it is not set, and on older records.

On `price_changed`, the price object also has `price_cached` (the Cache read price per 1M tokens) and `price_cache_write` (the Cache write price per 1M tokens) when that price moved, on both `old` and `new`. Each is left out when that price did not move, and on older records.

A model with [long-context prices](/en/docs/billing#long-context-prices) on either side changes two things on `price_changed`. Each cache price is present on a side whenever the model has it set there, moved or not, and left out when it is not set. An unset cache price is billed at the Input price. Both `old` and `new` also have `price_tiers`, the long-context prices, lowest threshold first, with `[]` on a side that has none. On `added`, `new` has `price_tiers` when the new model has long-context prices. Each entry has these fields:

| Field | Description |
| --- | --- |
| `above_input_tokens` | The threshold. The entry applies when a request's input tokens, cache included, are above it. |
| `input_micro_idr_per_1m` | Input price per 1M tokens. |
| `output_micro_idr_per_1m` | Output price per 1M tokens. |
| `cached_input_micro_idr_per_1m` | Cache read price per 1M tokens. `0` means Cache read is billed at this entry's Input price. |
| `cache_write_micro_idr_per_1m` | Cache write price per 1M tokens. `0` means Cache write is billed at this entry's Input price. |

Older records of such a model have `price_tiers` only when the long-context prices changed, and a cache price only when it moved.

On `added`, the price object also has a `sunset_at` field when the model was listed with a retirement time already set. It holds the Unix time in seconds when kenari stops serving the model. When a replacement model is also set, there is a `successor` field too, with that model's public id. `successor` only appears together with `sunset_at`. Requests are forwarded to the successor after `sunset_at` when forwarding is on. Both fields are left out when no retirement time is set.

**Discount object.** Used by `discount_started` and `discount_ended`. It has an `in` key for the input side, an `out` key for the output side, or both. Only the sides that started or ended a discount are present. Each side is `{"price": ..., "market": ...}`:

| Field | Description |
| --- | --- |
| `price` | The kenari price per 1M tokens on that side. |
| `market` | The market price per 1M tokens on that side, or `null` when there is none. |

**Plans object.** Used by `plans_changed`. It has one field:

| Field | Description |
| --- | --- |
| `plans` | The names of the subscription plans that include the model, cheapest plan first. An empty list when no plan includes it. |

To see what changed, compare the two lists: a name only in `new` is a plan the model joined, and a name only in `old` is a plan it left.

**Revision object.** Used by `updated`:

| Field | Description |
| --- | --- |
| `revision` | The vendor's revision name, for example `1017`. On `old` it can be `null` when the model had no revision before. |
| `note` | Only on `new`: a one-line note about the revision, shaped `{"id": ..., "en": ...}` with each language a string or `null`, or `null` when there is no note. |

A revision that is cleared is not published. The current revision is also on every `GET /api/public/pricing` item as `revision`, `revision_at` and `revision_note`.

**Shape per kind:**

| `kind` | `old` | `new` | `effective_at` |
| --- | --- | --- | --- |
| `added` | `null` | Price object. | `null` |
| `removed` | Price object from before the model left. | `null` | `null` |
| `price_changed` | Price object before. | Price object after. | `null` |
| `discount_started` | Discount object before. | Discount object after, with `market` above `price`. | `null` |
| `discount_ended` | Discount object before, with `market` above `price`. | Discount object after. | `null` |
| `price_scheduled` | Price object with today's prices. | Price object with the prices that apply from `effective_at`. | Unix seconds when the new prices apply. |
| `sunset_scheduled` | The previous retirement time in unix seconds, or `null` when none was set. | The retirement time in unix seconds. | The same retirement time. |
| `plans_changed` | Plans object before. | Plans object after. | `null` |
| `updated` | Revision object before. | Revision object after, with `note`. | `null` |

## Examples

### curl

The first call returns the newest changes. Later calls pass `since` to get only what is new.

```bash
curl "https://kenari.id/v1/models/changes?limit=20"

curl "https://kenari.id/v1/models/changes?since=1791532800"
```

### JavaScript

This loop polls every 10 minutes. It keeps the newest `confirmed_at` it has seen and passes it as `since` on the next call.

```js
let since = 0;
const seen = new Set();

async function poll() {
  const response = await fetch(`https://kenari.id/v1/models/changes?since=${since}&limit=200`);
  if (!response.ok) return;
  const { data } = await response.json();

  // Newest first, so walk it backwards to handle the oldest change first.
  for (const change of [...data].reverse()) {
    const key = `${change.confirmed_at}:${change.model}:${change.kind}`;
    if (seen.has(key)) continue;
    seen.add(key);
    console.log(change.kind, change.model, change.old, "->", change.new);
  }
  if (data.length > 0) since = data[0].confirmed_at;
}

await poll();
setInterval(poll, 10 * 60 * 1000);
```

`since` includes its own second, so the newest change comes back once more on the next call. The `seen` set drops it.

## Why `since` uses confirmation time

`since` and the order of the list use `confirmed_at`, the moment a change enters the list, and not `detected_at`. A change is first seen, then confirmed 10 minutes or an hour later. If `since` used the time it was first seen, a change seen just before your last poll and confirmed just after it would look older than your `since`, and you would never get it. A change gets its `confirmed_at` at the moment it enters the list, so it is never older than a poll that did not have it yet. A poller that passes the newest `confirmed_at` it has seen as `since` therefore does not miss a change.

`detected_at` stays on each item, for when the change first happened.

If more than `limit` changes are confirmed between two polls, only the newest `limit` come back. Use `limit=200` and poll at least once an hour.

## Errors

A `since` or `limit` that is not a whole number gets a 400 error. The other errors are in [Errors](/en/docs/errors).

## See also

- [Models and pricing](/en/docs/models)
- [Free models](/en/docs/free-models)
- [Notifications](/en/docs/notifications)
- [How billing works](/en/docs/billing)
