The inference index
The inference index is the price of intelligence, written on-chain. Once an hour a sampler reads OpenRouter's public list of model prices, works out what the top frontier models and the top open-weight models charge per million tokens, and submits the answer to the InferenceIndex contract. The contract keeps a week of those hourly samples and publishes their median, which is where INT, OPEN and AGENT get their prices. Nobody edits it: the program the sampler runs and the lists of models it looks at both live on-chain, so any sample it submits can be recomputed and checked against them.
The four numbers
Every sample is four values, each in USD × 1e18 per one million tokens:
| Metric | Index | Meaning |
|---|---|---|
inFrontier |
0 | median input (prompt) price across the frontier class |
outFrontier |
1 | median output (completion) price across the frontier class |
inOpen |
2 | median input price across the open-weight class |
outOpen |
3 | median output price across the open-weight class |
The three coin prices are derived from them at publish time:
| Coin | Derivation | In the contract |
|---|---|---|
INT |
outFrontier |
median(1) |
OPEN |
1000 × outOpen |
median(3) × 1000 |
AGENT |
1.6 × inFrontier + 0.4 × outFrontier |
(median(0) × 16 + median(1) × 4) / 10 |
How a sample is made
The contract stores the exact program in its config, along with two arguments: the frontier model list and the open-weight model list, both as comma-separated OpenRouter model ids. The source is contracts/functions/inference-index.js and it is short enough to read in full. Publishing it is the point — you can fetch it from the contract, run it yourself, and compare the answer to what was submitted.
The sampler runs it hourly. The script fetches https://openrouter.ai/api/v1/models, keeps only the models named in the two lists, reads each one's listed prompt and completion price, and takes the equal-weighted median per class and per side. It converts dollars per token to dollars per million tokens in 1e18 fixed point and returns the four values ABI-encoded as a uint256[4].
Two guards keep every stored sample a real reading. The script requires a well-formed model list from OpenRouter, and it requires at least five models in a class to carry a positive listed price. When either is not met it submits nothing that hour and waits for the next one. A gap in the window closes itself as samples resume; a guessed number would live in the median for a week.
Storing and publishing
submitSample(uint256[4]) does one thing: it stores the sample. Only the sampler address may call it, and not more than once every 55 minutes. If any of the four values is zero or above MAX_SAMPLE (1e24, which is $1,000,000 per million tokens) the whole call reverts BadSample, so a bad reading cannot half-write the ring — and the interval clock does not move either, so a corrected sample can follow immediately. Otherwise it writes the four values into a 168-slot ring, one slot per metric per hour, and emits SampleStored.
publish() is a separate call anyone can make once a new sample has been stored since the last publish, and once at least 24 samples are in the window — a day of hourly readings — so the very first price the index ever publishes is already a day's median, not a single reading. It sorts each metric's stored samples, takes the median, derives the three coin prices from the table above, and pushes them to the PriceFeed under keccak256("INT"), keccak256("OPEN") and keccak256("AGENT"). The InferenceIndex is the only address the feed accepts pushes from, and it can only push what the median says. publish reverts with NotEnoughSamples during that first day and with NothingToPublish when there is nothing new.
Published class prices carry a six-hour freshness window on the feed. Everything downstream acts on the published price while it is inside that window; outside it, launches, repricing and redemption wait for the next publish rather than acting on an older number. publish is permissionless, so anyone can bring the window current, and the moment one lands they all resume. Everything else keeps working throughout.
What a single sample cannot do
The published price is the median of up to 168 hourly samples, so one wrong sample does not move it at all. If a provider mislists a price for an hour, or the sampler submits one strange reading, that sample sits at the edge of the sorted window and the median stays where it was.
This is also what bounds the sampler itself. It can add one number an hour and nothing else — it cannot publish, cannot touch the feed and cannot touch funds. With a full window, 83 consecutive submissions leave the published price unmoved, and the 84th is the first that shifts it at all, and only halfway. Moving it onto a chosen number takes the whole window: over six days of hourly samples, every one of them on-chain and checkable against the published source before the price has moved a wei.
The same property sets the speed of the index. When the real price of inference steps to a new level, the median only follows once more than half the window has been sampled at the new level. That takes 84 hours, or three and a half days. Until then the published price moves gradually from the old level toward the new one as the window turns. A price cut announced today reaches INT in full on the fourth day.
Before the ring is full the median is taken over however many samples exist, so the index is coarser in its first week than after it.
The model lists
A class is a list of OpenRouter model ids. The rule at launch: the ten highest-scoring models on the Artificial Analysis Intelligence Index that have a listed output price on OpenRouter form the frontier class, and the ten highest-scoring open-weight models form the open-weight class. The lists are stored in the contract's config as frontierIds and openIds, readable by anyone, and the script only prices models that are on them.
Changing a list or the source is a two-step owner action. proposeConfig(newConfig) records the proposal and its hash and emits ConfigProposed with the time it can apply. applyConfig() succeeds only after CONFIG_DELAY, which is 24 hours, and only for the owner. In between, pendingConfig() returns the whole proposed config, so anyone can read what is about to change and act on it before it does. A proposal can be replaced by a new one, which restarts the delay. A config with an empty source or an empty list is rejected.
A list change does not rewrite history: the ring keeps its stored samples, and the median blends the old class into the new one over the same three and a half days.
Who calls it, and who pays
submitSample is the one call in the protocol with a named caller: only the sampler address the contract holds may make it, and not more than once every 55 minutes. The sampler pays its own gas, which on Base is cents a day. The address is published in the deployments file as indexSampler and shown on the units page, so you can watch what it does; the owner can rotate it with setSampler.
publish() is permissionless and costs its caller a sort of up to 168 values per metric plus the push. Nobody in particular has to be the one who calls it: the units page shows a Publish button whenever lastSampleAt is later than lastPublishedAt, and any cron or keeper service can be pointed at it. Automation covers the rest of the calls that work this way.
The other price sources
The PriceFeed holds three kinds of asset. Only the three class prices are pushed. Everything else is read from the chain at the moment a contract asks.
| Asset | Source | How it is read | Stale when |
|---|---|---|---|
ETH |
Chainlink ETH/USD | latestRoundData() at call time, scaled from 8 to 18 decimals |
the round is older than 2 hours, or the answer is not positive |
VVV |
the Aerodrome VVV/WETH pool | quote(VVV, 1e18, 12), a time-weighted average over the last 12 half-hour observations, about six hours, multiplied by the Chainlink ETH/USD price |
the pool returns zero, or ETH/USD is stale |
DIEM |
the Aerodrome VVV/DIEM pool | quote(DIEM, 1e18, 12), the same six-hour average in VVV, multiplied by the VVV price above |
the pool returns zero, or anything under it is stale |
The Aerodrome quote is a cumulative-price average maintained by the pool itself, so the DIEM price a contract sees is not a spot price anyone can move inside one block; moving it by 1% for six hours costs real money on a real pool. DIEM therefore reads as DIEM → VVV → WETH → USD, three on-chain steps, with the deepest VVV venue (against WETH, about $14M of liquidity) doing the heavy lifting. On 2026-09-16 that chain read about 79 VVV per DIEM and about $22 per VVV, so around $1,700 per DIEM.
An answer outside its window reads Stale() through getPrice, so no contract acts on it. The vault's coverageBps and every needs* view read prices without reverting, which is what keeps the interface readable at all times: a source outside its window reads as a zero component or a false flag, and the page renders. The addresses of every source are in SPEC.md §1 and in Contracts.
Reading it
| Read | What it tells you |
|---|---|
median(metric) |
the current median for metric 0–3 over the stored window |
latestSample() |
the last sample stored, all four values |
count, head |
how many samples are in the ring (up to 168) and the next slot to be written |
lastRequestAt, lastSampleAt, lastPublishedAt |
timestamps of the last request, the last stored sample and the last publish |
config(), pendingConfig(), pendingApplyAfter |
the live config, the proposed one, and when it may apply |
PriceFeed.getPrice(keccak256("INT")) |
the published price and when it was pushed |
Events: Requested, SampleStored, RequestFailed, Published, ConfigProposed, ConfigApplied. The units page draws the 168-sample window, the median, the last sample and the two model lists from these reads.