Skip to content

Commit d211f8c

Browse files
author
guanchen.lin
committed
fix: surface new models and provider links
1 parent 84365d0 commit d211f8c

28 files changed

Lines changed: 459 additions & 47 deletions

messages/en.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -193,6 +193,7 @@
193193
"nameAZ": "A to Z",
194194
"nameZA": "Z to A",
195195
"performanceHighToLow": "⭐ Performance (High to Low)",
196+
"latestFirst": "Latest added",
196197
"chinaAccessOnly": "China Access Only",
197198
"results": "{count} results",
198199
"modelChannelPrices": "Model Channel Prices",

messages/zh.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -193,6 +193,7 @@
193193
"nameAZ": "A 到 Z",
194194
"nameZA": "Z 到 A",
195195
"performanceHighToLow": "⭐ 性能(高到低)",
196+
"latestFirst": "最新收录",
196197
"chinaAccessOnly": "仅显示国内可用",
197198
"results": "{count} 个结果",
198199
"modelChannelPrices": "模型渠道价格",
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import assert from 'node:assert/strict';
2+
import test from 'node:test';
3+
import { resolveSelector, type SelectableModel } from '../../src/lib/plan-selector';
4+
import { CLASSIFICATIONS } from './plan-classifications';
5+
6+
const catalog: SelectableModel[] = [
7+
{ id: 1, slug: 'claude-fable-5', providerSlugs: ['anthropic'], elo: null },
8+
{ id: 2, slug: 'claude-fable-5.1', providerSlugs: ['anthropic'], elo: null },
9+
{ id: 3, slug: 'claude-fable-5-batch', providerSlugs: ['anthropic'], elo: null },
10+
];
11+
12+
function selectedSlugs(planSlug: string): string[] {
13+
const classification = CLASSIFICATIONS.find(
14+
(entry) => entry.providerSlug === 'anthropic' && entry.planSlug === planSlug,
15+
);
16+
assert.ok(classification, `missing Anthropic classification for ${planSlug}`);
17+
18+
return resolveSelector(classification.selector, catalog, ['anthropic'])
19+
.models.map((model) => model.slug);
20+
}
21+
22+
test('Claude Max plans include Fable releases but not API batch variants', () => {
23+
for (const planSlug of ['claude-max', 'claude-max-5x', 'claude-max-20x']) {
24+
assert.deepEqual(selectedSlugs(planSlug), ['claude-fable-5', 'claude-fable-5.1']);
25+
}
26+
});
27+
28+
test('Claude Pro does not claim the Max-only Fable family', () => {
29+
assert.deepEqual(selectedSlugs('claude-pro'), []);
30+
});

scripts/config/plan-classifications.ts

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ const MOONSHOT_NOISE = ['kimi', '*-us'];
4949
const MINIMAX_LEGACY = ['minimax-01', 'minimax-m1'];
5050

5151
const CLAUDE_FAMILIES = ['claude-opus', 'claude-sonnet', 'claude-haiku'];
52+
const CLAUDE_MAX_FAMILIES = [...CLAUDE_FAMILIES, 'claude-fable'];
5253
const GPT_CHAT_FAMILIES = ['gpt-4o', 'gpt-4.1', 'gpt-5'];
5354

5455
// ────────────────────────────────────────────────────────────────────────────
@@ -96,16 +97,16 @@ export const CLASSIFICATIONS: Classification[] = [
9697
reason: 'Pro unlocks Opus and bundles Claude Code' },
9798
{ providerSlug: 'anthropic', planSlug: 'claude-max', kind: 'chat', line: 'claude', rank: 2,
9899
secondaryKinds: ['coding'],
99-
selector: { families: CLAUDE_FAMILIES, exclude: ANTHROPIC_NOISE },
100-
reason: 'Legacy generic Max row; same catalog as Pro, larger quota' },
100+
selector: { families: CLAUDE_MAX_FAMILIES, exclude: ANTHROPIC_NOISE },
101+
reason: 'Legacy generic Max row; includes the Max-only Fable family in addition to the Pro catalog' },
101102
{ providerSlug: 'anthropic', planSlug: 'claude-max-5x', kind: 'chat', line: 'claude', rank: 3,
102103
secondaryKinds: ['coding'],
103-
selector: { families: CLAUDE_FAMILIES, exclude: ANTHROPIC_NOISE },
104-
reason: 'Max 5x differs from Pro only in quota, so tier_rank not tier carries the difference' },
104+
selector: { families: CLAUDE_MAX_FAMILIES, exclude: ANTHROPIC_NOISE },
105+
reason: 'Max 5x includes Fable at a limited share of weekly usage; tier_rank carries its quota position' },
105106
{ providerSlug: 'anthropic', planSlug: 'claude-max-20x', kind: 'chat', line: 'claude', rank: 4,
106107
secondaryKinds: ['coding'],
107-
selector: { families: CLAUDE_FAMILIES, exclude: ANTHROPIC_NOISE },
108-
reason: 'Max 20x differs from Max 5x only in quota — both were tier=pro, which is why tier_rank exists' },
108+
selector: { families: CLAUDE_MAX_FAMILIES, exclude: ANTHROPIC_NOISE },
109+
reason: 'Max 20x includes Fable at a limited share of weekly usage; tier_rank distinguishes it from Max 5x' },
109110
{ providerSlug: 'anthropic', planSlug: 'claude-team', kind: 'chat', line: 'claude', rank: 5,
110111
selector: { families: CLAUDE_FAMILIES, exclude: ANTHROPIC_NOISE },
111112
reason: 'Team Standard: full chat catalog but no Claude Code, so no coding secondary kind' },

scripts/db/queries.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,7 +157,22 @@ export async function getOrCreateProduct(data: {
157157
provider_ids?: number[]; // Preferred: array of provider IDs
158158
type: string;
159159
context_window?: number;
160+
released_at?: string;
160161
}) {
162+
const backfillReleaseDate = async <T extends { id: number; released_at?: string | null }>(product: T): Promise<T> => {
163+
if (product.released_at || !data.released_at) return product;
164+
165+
const releasedAt = new Date(data.released_at).toISOString();
166+
const { error } = await supabaseAdmin
167+
.from('models')
168+
.update({ released_at: releasedAt, updated_at: new Date().toISOString() })
169+
.eq('id', product.id);
170+
if (error) throw error;
171+
172+
product.released_at = releasedAt;
173+
return product;
174+
};
175+
161176
// First: try to find by exact slug match
162177
const { data: existingBySlug } = await supabaseAdmin
163178
.from('models')
@@ -166,6 +181,7 @@ export async function getOrCreateProduct(data: {
166181
.single();
167182

168183
if (existingBySlug) {
184+
await backfillReleaseDate(existingBySlug);
169185
// Update slug if different
170186
if (existingBySlug.slug !== data.slug) {
171187
await supabaseAdmin
@@ -187,6 +203,7 @@ export async function getOrCreateProduct(data: {
187203
.limit(1);
188204

189205
if (existingByName && existingByName.length > 0) {
206+
await backfillReleaseDate(existingByName[0]);
190207
// Update the slug to match the normalized version
191208
if (existingByName[0].slug !== data.slug) {
192209
await supabaseAdmin
@@ -215,6 +232,7 @@ export async function getOrCreateProduct(data: {
215232
provider_ids: providerIdsArray,
216233
type: data.type,
217234
context_window: data.context_window,
235+
released_at: data.released_at,
218236
})
219237
.select()
220238
.single();

scripts/index-dynamic.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -271,7 +271,14 @@ async function processAPIScraper(
271271
.single();
272272

273273
if (existingProduct) {
274-
product = existingProduct;
274+
product = await getOrCreateProduct({
275+
name: existingProduct.name,
276+
slug: existingProduct.slug,
277+
provider_ids: existingProduct.provider_ids,
278+
type: existingProduct.type || 'llm',
279+
context_window: price.contextWindow,
280+
released_at: price.releasedAt,
281+
});
275282
} else {
276283
console.log(`⚠️ Skipping ${normalizedName} - unknown provider`);
277284
continue;
@@ -284,6 +291,7 @@ async function processAPIScraper(
284291
provider_id: providerId,
285292
type: 'llm',
286293
context_window: price.contextWindow,
294+
released_at: price.releasedAt,
287295
});
288296
}
289297

scripts/migrate-data-accuracy.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -481,6 +481,35 @@ const MIGRATIONS: Migration[] = [
481481
AND type <> 'aggregator';
482482
`,
483483
},
484+
{
485+
name: '017_backfill_openrouter_links',
486+
sql: `
487+
-- OpenRouter was created before provider metadata became part of the
488+
-- scraper contract, leaving its website blank while every other active
489+
-- provider has a usable destination. Fill only missing values so an
490+
-- operator-supplied URL always wins.
491+
UPDATE providers
492+
SET website = CASE
493+
WHEN website IS NULL OR btrim(website) = '' THEN 'https://openrouter.ai'
494+
ELSE website
495+
END,
496+
pricing_url = CASE
497+
WHEN pricing_url IS NULL OR btrim(pricing_url) = '' THEN 'https://openrouter.ai/models'
498+
ELSE pricing_url
499+
END,
500+
api_docs_url = CASE
501+
WHEN api_docs_url IS NULL OR btrim(api_docs_url) = '' THEN 'https://openrouter.ai/docs'
502+
ELSE api_docs_url
503+
END,
504+
updated_at = now()
505+
WHERE slug = 'openrouter'
506+
AND (
507+
website IS NULL OR btrim(website) = '' OR
508+
pricing_url IS NULL OR btrim(pricing_url) = '' OR
509+
api_docs_url IS NULL OR btrim(api_docs_url) = ''
510+
);
511+
`,
512+
},
484513
];
485514

486515
async function main() {

scripts/scrapers/lib/playwright-scraper.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ export interface PriceData {
2222
rateLimit?: number | null;
2323
isAvailable?: boolean;
2424
currency?: string;
25+
releasedAt?: string;
2526
}
2627

2728
export interface ScraperResult {

scripts/scrapers/openrouter.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ interface OpenRouterModel {
1717
input_cache_read?: string; // Cache price per token ($/token) - CACHE
1818
};
1919
context_length: number;
20+
created?: number;
2021
architecture?: {
2122
modality?: string;
2223
output_modalities?: string[];
@@ -172,6 +173,9 @@ export async function scrapeOpenRouter(): Promise<ScraperResult> {
172173
contextWindow: model.context_length,
173174
isAvailable: true,
174175
currency: 'USD',
176+
releasedAt: model.created && Number.isFinite(model.created)
177+
? new Date(model.created * 1000).toISOString()
178+
: undefined,
175179
});
176180
} catch (error) {
177181
errors.push(`Error processing ${model.id}: ${error}`);

scripts/utils/validator.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ export interface ScrapedPrice {
1010
rateLimit?: string | number;
1111
isAvailable: boolean;
1212
currency?: CurrencyCode;
13+
/** Source-provided release/listing timestamp, when available. */
14+
releasedAt?: string;
1315
}
1416

1517
export interface ScraperResult {

0 commit comments

Comments
 (0)