From 817b9ae4936a6a17f967f1613031d69362039224 Mon Sep 17 00:00:00 2001 From: Tom Beckenham <34339192+tombeckenham@users.noreply.github.com> Date: Tue, 10 Nov 2026 03:28:09 +1100 Subject: [PATCH] meta: read token prices when Contributor is listed first (#607) Meta's pricing page now puts the Contributor column before Standard. The parser required the old header order and failed the models poll. Columns are matched by name, and an unread Usage table names its section. --- src/server/providers/adapters/meta.test.ts | 64 +++++++++++++++++++++- src/server/providers/meta-docs.ts | 62 +++++++++++++++------ 2 files changed, 107 insertions(+), 19 deletions(-) diff --git a/src/server/providers/adapters/meta.test.ts b/src/server/providers/adapters/meta.test.ts index 5839a437..154930c7 100644 --- a/src/server/providers/adapters/meta.test.ts +++ b/src/server/providers/adapters/meta.test.ts @@ -426,7 +426,69 @@ describe('Meta native docs', () => { models, { url: META_PRICING, hash: 'fixture', extractedAt: '2026-10-12' }, ), - ).toThrow('unknown pricing table') + ).toThrow('unknown pricing table in Muse Wonder') + }) + it('reads token prices when Contributor is the first column', () => { + // Trimmed from https://dev.meta.ai/docs/pricing-rate-limits on 2026-11-10. + // Decorative attributes removed; cell text is the live table. + const html = `

Pricing

+

Muse Wonder

+

Model: muse-wonder-1.0.

+ + + + +
UsageContributor mode (per 1M tokens)Standard mode (per 1M tokens)
Cached inputNot available$0.20
InputNot available$4.00
OutputNot available$20.00
+

Muse Spark

+

Models: muse-spark-1.3, muse-spark-1.2, and muse-spark-1.1 in Standard mode; muse-spark-1.3-contributor and muse-spark-1.2-contributor in Contributor mode. Muse Spark 1.1 is Standard mode only.

+ + + + +
UsageContributor mode (per 1M tokens)Standard mode (per 1M tokens)
Cached input$0.002$0.15
Input$0.10$1.25
Output$0.20$4.25
` + const models = parseMetaModels(docs[META_MODELS] ?? '', pages) + const cards = parseMetaPricing(html, models, { + url: META_PRICING, + hash: 'live-2026-11-10', + extractedAt: '2026-11-10', + }) + expect(cards.get('muse-wonder-1.0')).toMatchObject({ + tables: { + rate: { + base: { + input_tokens: 4 / 1e6, + output_tokens: 20 / 1e6, + cache_read_tokens: 0.2 / 1e6, + }, + }, + }, + }) + expect(cards.get('muse-spark-1.3')).toMatchObject({ + tables: { + rate: { + base: { + input_tokens: 1.25 / 1e6, + output_tokens: 4.25 / 1e6, + cache_read_tokens: 0.15 / 1e6, + }, + }, + }, + }) + expect(cards.get('muse-spark-1.3-contributor')).toMatchObject({ + tables: { + rate: { + base: { + input_tokens: 0.1 / 1e6, + output_tokens: 0.2 / 1e6, + cache_read_tokens: 0.002 / 1e6, + }, + }, + }, + }) + expect(cards.get('muse-spark-1.1')).toEqual(cards.get('muse-spark-1.3')) + expect(cards.get('muse-spark-1.2-contributor')).toEqual( + cards.get('muse-spark-1.3-contributor'), + ) }) it('fails when a pay-as-you-go chat ID gets no token price', () => { const models = parseMetaModels(docs[META_MODELS] ?? '', pages) diff --git a/src/server/providers/meta-docs.ts b/src/server/providers/meta-docs.ts index d496e8f4..28b765f2 100644 --- a/src/server/providers/meta-docs.ts +++ b/src/server/providers/meta-docs.ts @@ -232,6 +232,29 @@ export function parseMetaModels( fail('model page listed no chat models') return out } +type TokenMode = 'Standard' | 'Contributor' +/** Mode columns are named. Meta publishes them in either order. */ +function tokenModeColumns( + head: Array, +): Record | undefined { + if (head[0] !== 'Usage' || head.length !== 3) return + const found = new Map() + for (let index = 1; index < head.length; index++) { + const cell = head[index] + const mode: TokenMode | undefined = + cell === 'Standard mode (per 1M tokens)' + ? 'Standard' + : cell === 'Contributor mode (per 1M tokens)' + ? 'Contributor' + : undefined + if (!mode || found.has(mode)) return + found.set(mode, index) + } + const standard = found.get('Standard') + const contributor = found.get('Contributor') + if (standard === undefined || contributor === undefined) return + return { Standard: standard, Contributor: contributor } +} export function parseMetaPricing( html: string, models: Array, @@ -246,13 +269,11 @@ export function parseMetaPricing( ) for (const section of sections(html, 3)) { const rows = tables(section.body)[0] - if ( - rows?.[0]?.join('|') === - 'Usage|Standard mode (per 1M tokens)|Contributor mode (per 1M tokens)' - ) { + const tokenCols = tokenModeColumns(rows?.[0] ?? []) + if (rows && tokenCols) { // "Models: a, b in Standard mode; c in Contributor mode." A section // that names no mode must have only one priced column. - const columns = new Map() + const columns = new Map() for (const clause of plain(section.body.split(/> = { 1: {}, 2: {} } + const rates: Record> = { + Standard: {}, + Contributor: {}, + } for (const row of rows.slice(1)) { const key = meters[row[0] ?? ''] if (!key || row.length !== 3) fail('unreadable token price') - for (const col of [1, 2]) { - const cell = row[col] ?? '' + for (const mode of ['Standard', 'Contributor'] as const) { + const cell = row[tokenCols[mode]] ?? '' if (cell === 'Not available') continue const price = /^\$(\d+(?:\.\d+)?)$/.exec(cell) if (!price) fail('unreadable token price') - const target = rates[col] - if (target) target[key] = Number(price[1]) / 1e6 + rates[mode][key] = Number(price[1]) / 1e6 } } - const priced = [1, 2].filter( - (col) => Object.keys(rates[col] ?? {}).length, + const priced = (['Standard', 'Contributor'] as const).filter( + (mode) => Object.keys(rates[mode]).length, ) for (const [id, stated] of columns) { - const col = stated || (priced.length === 1 ? priced[0] : undefined) - if (!col) fail('token price mode unstated') + const mode = stated ?? (priced.length === 1 ? priced[0] : undefined) + if (!mode) fail('token price mode unstated') const tier = tiers.get(id) - if (tier && col !== (tier === 'Contributor' ? 2 : 1)) + if (tier && mode !== tier) fail('token price mode disagrees with model tier') - const colRates = rates[col] ?? {} + const colRates = rates[mode] if (!('input_tokens' in colRates) || !('output_tokens' in colRates)) fail('incomplete token pricing') if (cards.has(id)) fail('duplicate model price') @@ -344,7 +369,8 @@ export function parseMetaPricing( const p = /flat \$(\d+(?:\.\d+)?) per generated image/.exec(text) if (!p) fail('unreadable image price') rates.generated_images = Number(p[1]) - } else if (rows?.[0]?.[0] === 'Usage') fail('unknown pricing table') + } else if (rows?.[0]?.[0] === 'Usage') + fail(`unknown pricing table in ${section.name}`) else continue const card = compileTokenCard(rates, [], source) if (!card) fail('uncompilable unit pricing')