diff --git a/CLAUDE.md b/CLAUDE.md index 8ad6c6ad..b3f44c1a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -156,7 +156,9 @@ ones). (`recordCompleteness`, one `cache_meta` row); requests only read it. BytePlus chat, Seedance, and Seedream cards come from its public ModelArk pricing doc (`byteplus-pricing.ts`; the console - pricing view and `arkcli pricing` are login-gated). FAL cards come from `extract-fal-pricing.ts`: + pricing view and `arkcli pricing` are login-gated). An id that page + names nowhere is `cleared` (the "Retiring models" tables left it on + 2026-11-12, #646); one it names but no table prices keeps its card. FAL cards come from `extract-fal-pricing.ts`: `fal-unit-rate.ts` compiles the common `$X per ` sections with no model call, leftovers go to one extract model per distinct Pricing-section hash and are copied onto every row sharing it. A priced leftover must diff --git a/src/server/providers/byteplus-pricing.ts b/src/server/providers/byteplus-pricing.ts index cc110b55..60bf862c 100644 --- a/src/server/providers/byteplus-pricing.ts +++ b/src/server/providers/byteplus-pricing.ts @@ -3,9 +3,15 @@ * is a Lark document: `window._ROUTER_DATA` embeds `curDoc.Content`, and * each table is an `aceTable` of a row zone plus a column zone. Cell text * lives in a zone id `x{rowId}x{colId}`. Since 2026-10-23 (#462) most - * tables sit in Standard/Flex/Batch or "Input without/with video" tabs and - * in a folded "Retiring models" section, and merged cells are listed by - * `mergeCellID` on their rows and columns. + * tables sit in Standard/Flex/Batch or "Input without/with video" tabs, and + * merged cells are listed by `mergeCellID` on their rows and columns. The + * folded "Retiring models" section was removed on 2026-11-12 (#646); a + * folded section is still read if one returns. + * + * A catalog id the page names nowhere is `cleared`: BytePlus publishes no + * price for it, so a stored card is nulled rather than served as current. + * An id the page names but no table prices (a row this parser cannot read) + * keeps its stored card and logs `pricing_lost`. * * Only Standard-tab tables are read: the chat tables (the ones that quote * cache storage per hour) and the Audio input tables, whose audio rates @@ -888,6 +894,20 @@ export function parseByteplusImages(doc: ByteplusDoc): Map { return out } +/** + * Every model-id-shaped word in the page text: hyphenated lowercase with a + * digit. `byteplusRatesFor` over this set says whether the page still names + * a catalog id, whatever table, note, or unreadable row it sits in. + */ +export function byteplusNamedIds(doc: ByteplusDoc): Set { + const text = Object.values(doc.data).map(zoneText).join('\n') + return new Set( + (text.match(/[a-z0-9]+(?:-[a-z0-9]+)+/g) ?? []).filter((id) => + /\d/.test(id), + ), + ) +} + /** A priced row this parser could not read: logged so drift is visible. */ function warnUnread(table: string, id: string, row: Array): void { console.warn( @@ -1443,6 +1463,8 @@ interface CachedPricing { // Absent on entries cached before #102. pixelTiers?: Record serverTools?: Record> + /** Ids the page text names (`byteplusNamedIds`). */ + named: Array hash: string extractedAt: string } @@ -1511,7 +1533,11 @@ async function modelListContent(): Promise { } } -/** Card lookup by catalog id. Ids no table prices get nothing. */ +/** + * Card lookup by catalog id. An id the page does not name is `cleared`; an + * id it names but no table prices gets nothing (stored card kept, + * `pricing_lost`). The page failing to load or parse throws before either. + */ export async function byteplusModelPricing( run: DocsRun, kv?: KVNamespace, @@ -1545,6 +1571,7 @@ export async function byteplusModelPricing( images: Object.fromEntries(images), pixelTiers: Object.fromEntries(pixelTiers), serverTools: Object.fromEntries(parseByteplusServerTools(html)), + named: [...byteplusNamedIds(page)], hash: await sha256Text(listContent ? content + listContent : content), extractedAt: new Date().toISOString(), } @@ -1564,6 +1591,7 @@ export async function byteplusModelPricing( const video = new Map(Object.entries(doc.video)) const images = new Map(Object.entries(doc.images)) const pixelTiers = new Map(Object.entries(doc.pixelTiers ?? {})) + const named = new Map(doc.named.map((id) => [id, true])) const source = { url: BYTEPLUS_PRICING_URL, hash: doc.hash, @@ -1617,8 +1645,6 @@ export async function byteplusModelPricing( const tools = doc.serverTools?.[rawId] const own = byteplusRatesFor(rawId, pages) const page = own?.page ?? null - // Page failed this poll: keep the stored card (`tryDocs` counted it). - const lost = own && !page ? unavailable('pricing') : {} // A model with its own page is priced from it alone, never the pricing page. const pricing = !own ? card(rawId) @@ -1639,6 +1665,14 @@ export async function byteplusModelPricing( ? tagDocsFacts({ serverTools: tools }, BYTEPLUS_PRICING_URL, doc.hash) : {} const factSources = { ...pricingFacts, ...toolFacts } + const lost: PricedFacts = own + ? // Page failed this poll: keep the stored card (`tryDocs` counted it). + page + ? {} + : unavailable('pricing') + : pricing || byteplusRatesFor(rawId, named) + ? {} + : { absent: { pricing: 'cleared' } } if (!pricing && !tools) return lost return { ...lost, diff --git a/src/server/providers/byteplus.test.ts b/src/server/providers/byteplus.test.ts index 9568dc59..b3b74a47 100644 --- a/src/server/providers/byteplus.test.ts +++ b/src/server/providers/byteplus.test.ts @@ -469,6 +469,44 @@ describe('byteplus pricing pages failing', () => { }) }) +describe('byteplus ids the pricing page no longer names (#646)', () => { + // 2026-11-12: the page dropped its "Retiring models" tables. Eight listed + // ids (seed-1-8-251228, glm-4-7-251222, …) are named nowhere on it. + it('clears the price of an id the page does not name', async () => { + const { models } = await listWith({}) + const row = (rawId: string) => models.find((m) => m.rawId === rawId) + expect(row('seed-1-8-251228')?.pricing).toBeFalsy() + expect(row('seed-1-8-251228')?.absent?.pricing).toBe('cleared') + expect(row('glm-4-7-251222')?.absent?.pricing).toBe('cleared') + // A priced row carries no reason. + expect(row('seed-2-0-pro-260328')?.absent?.pricing).toBeUndefined() + }) + + it('keeps the stored card of an id the page names but no table prices', async () => { + const unread = () => + routerHtml( + pricingDoc([ + ...PRICING_TABLES, + { + tab: 'Standard', + rows: [ + PRICING_TABLES[0]?.rows[0] ?? [], + // A bracket this parser refuses, and an undated id. + ['', 'seed-1-8-251228', 'Peak', '$1', '$1', '$1', '$1'], + ['', 'glm-4-7', 'About 128', '$1', '$1', '$1', '$1'], + ], + }, + ]), + ) + const { models } = await listWith({ [BYTEPLUS_PRICING_URL]: unread }) + for (const rawId of ['seed-1-8-251228', 'glm-4-7-251222']) { + const row = models.find((m) => m.rawId === rawId) + expect(row?.pricing).toBeFalsy() + expect(row?.absent?.pricing).toBeUndefined() + } + }) +}) + /** The doc center's client-render shell on the first fetch, then `page`. */ function shellThen(page: () => string): () => string { const shell = readFileSync( diff --git a/src/server/providers/model-facts.ts b/src/server/providers/model-facts.ts index 7afbcf39..dbed0cda 100644 --- a/src/server/providers/model-facts.ts +++ b/src/server/providers/model-facts.ts @@ -281,7 +281,8 @@ export const DOCS_TTL_SECONDS = 6 * 60 * 60 // v41: A Mistral model the pricing table dropped keeps the price its // markdown page states (#543). A v40 page entry has no markdown price // and would log pricing_lost until the TTL. -const DOCS_CACHE_VERSION = 'v41' +// v42: the BytePlus pricing entry gained `named` (#646); a v41 entry has none. +const DOCS_CACHE_VERSION = 'v42' /** Fresh-window key for one parsed docs page. Shared with the stale copy. */ export function docsCacheKey(url: string): string {