diff --git a/packages/stats/app/src/routes/[lab]/[model].tsx b/packages/stats/app/src/routes/[lab]/[model].tsx index d2fbfdf7c5e..498b7f7016d 100644 --- a/packages/stats/app/src/routes/[lab]/[model].tsx +++ b/packages/stats/app/src/routes/[lab]/[model].tsx @@ -9,6 +9,7 @@ import { type ModelUsagePoint, type StatsModelData, } from "@opencode-ai/stats-core/domain/home" +import { statModel } from "@opencode-ai/stats-core/domain/model-normalization" import { createAsync, query, useParams } from "@solidjs/router" import { createMemo, createSignal, createUniqueId, For, onMount, Show, type JSX } from "solid-js" import { getRequestEvent } from "solid-js/web" @@ -40,6 +41,8 @@ import { } from "../stats-shell" const statsUnfurlPath = "banner.png" +const glmFlashCatalogId = "zhipuai/glm-5.3-flash" +const glmFlashModel = "glm-5.3-flash" const shortMonths = ["JAN", "FEB", "MAR", "APR", "MAY", "JUN", "JUL", "AUG", "SEP", "OCT", "NOV", "DEC"] as const type IsoCountryCode = readonly [string, string, string] @@ -89,14 +92,23 @@ export default function StatsModel() { const stats = createMemo(() => page()?.stats) const githubStars = createAsync(() => getGitHubStars()) const [themePreference, setThemePreference] = createSignal("system") - const modelName = createMemo(() => catalogEntry()?.name ?? stats()?.model ?? modelParam() ?? i18n.t("model.fallback")) - const labName = createMemo(() => formatCatalogLabName(catalogEntry()?.lab ?? stats()?.provider ?? labParam())) - const modelTitle = createMemo(() => i18n.t("model.title", { model: modelName() })) - const modelDescription = createMemo(() => i18n.t("model.description", { model: modelName() })) - const modelPath = createMemo( - () => - `/data/${catalogEntry()?.id ?? [labParam(), stats()?.slug ?? modelParam()].filter((part) => part.length > 0).join("/")}`, + const canonicalModel = createMemo(() => statModel(stats()?.model ?? modelParam(), undefined)) + const modelName = createMemo( + () => catalogEntry()?.name ?? publicModelName(canonicalModel()) ?? i18n.t("model.fallback"), ) + const labName = createMemo(() => formatCatalogLabName(catalogEntry()?.lab ?? stats()?.provider ?? labParam())) + const formerName = createMemo(() => formerModelName(canonicalModel())) + const searchModelName = createMemo(() => + formerName() ? `${modelName()} (formerly ${formerName()})` : modelName(), + ) + const modelTitle = createMemo(() => i18n.t("model.title", { model: searchModelName() })) + const modelDescription = createMemo(() => i18n.t("model.description", { model: searchModelName() })) + const modelPath = createMemo(() => { + const fallback = formerName() + ? glmFlashCatalogId + : [labParam(), stats()?.slug ?? canonicalModel()].filter((part) => part.length > 0).join("/") + return `/data/${catalogEntry()?.id ?? fallback}` + }) const modelUrl = createMemo(() => localizedUrl(language.locale(), modelPath())) const statsUnfurlUrl = new URL(statsUnfurlPath, localizedUrl("en", "/data/")).toString() const modelHeaderLinks = createMemo(() => [ @@ -167,6 +179,7 @@ export default function StatsModel() { catalog={catalogEntry() ?? null} catalogData={page()?.catalog ?? null} labName={labName()} + formerName={formerName()} /> @@ -255,6 +268,7 @@ function ModelHero(props: { catalog: ModelCatalogEntry | null catalogData: ModelPageCatalog | null labName: string + formerName?: string }) { const i18n = useI18n() const language = useLanguage() @@ -336,6 +350,9 @@ function ModelHero(props: { when={props.data} fallback={

+ + {(name) => {`Formerly ${name()}.`}} + Listed across the shared model catalog.

@@ -343,6 +360,9 @@ function ModelHero(props: { > {(data) => (

+ + {(name) => {`Formerly ${name()}.`}} + Ranked {formatHeroRank(data().rank)} @@ -1438,3 +1458,13 @@ function providerSlug(provider: string) { .replace(/^-+|-+$/g, "") .replace(/-{2,}/g, "-") } + +function formerModelName(model: string) { + return statModel(model, undefined) === glmFlashModel ? "ox-alpha" : undefined +} + +function publicModelName(model: string) { + if (model === "unknown") return undefined + if (model === glmFlashModel) return "GLM-5.3-Flash" + return model +} diff --git a/packages/stats/app/src/routes/model-catalog.ts b/packages/stats/app/src/routes/model-catalog.ts index 47fa1cf3474..87ae460ae1b 100644 --- a/packages/stats/app/src/routes/model-catalog.ts +++ b/packages/stats/app/src/routes/model-catalog.ts @@ -1,3 +1,4 @@ +import { statModel } from "@opencode-ai/stats-core/domain/model-normalization" import { query } from "@solidjs/router" export const modelCatalogSourceUrl = "https://models.opencode.ai/catalog.json" @@ -71,8 +72,9 @@ export const getModelCatalog = query(async () => { }, "getModelCatalog") export function findModelCatalogEntry(catalog: ModelCatalog, model: string, lab?: string) { - const normalizedId = lab ? `${catalogLabSlug(lab)}/${catalogSlug(model)}` : model.trim().toLowerCase() - const leaf = catalogSlug(model) + const canonicalModel = statModel(model, undefined) + const normalizedId = lab ? `${catalogLabSlug(lab)}/${catalogSlug(canonicalModel)}` : canonicalModel.trim().toLowerCase() + const leaf = catalogSlug(canonicalModel) return ( catalog.models.find((entry) => entry.id.toLowerCase() === normalizedId) ?? catalog.models.find((entry) => (lab ? entry.lab === catalogLabSlug(lab) : true) && entry.slug === leaf) ?? diff --git a/packages/stats/core/src/domain/inference.test.ts b/packages/stats/core/src/domain/inference.test.ts index c2889ff4c66..5f7e0266bf6 100644 --- a/packages/stats/core/src/domain/inference.test.ts +++ b/packages/stats/core/src/domain/inference.test.ts @@ -50,14 +50,18 @@ describe("inference stat normalization", () => { }) test("merges renamed models under their current name", () => { - expect(statModel("x-preview-f", "")).toBe("ox-alpha") + expect(statModel("x-preview-f", "")).toBe("glm-5.3-flash") + expect(statModel("ox-alpha", "")).toBe("glm-5.3-flash") + expect(statModel("ox-alpha-free", "")).toBe("glm-5.3-flash") + expect(statModel("big-pickle", "zhipuai/ox-alpha-free")).toBe("glm-5.3-flash") expect(statModel("xiaomi/mimo-v2.5", "")).toBe("mimo-v2.5") - expect(toModelAggregate(aggregate("x-preview-f", "openai"))).toMatchObject([ + expect(toModelAggregate(aggregate("x-preview-f", "unknown"))).toMatchObject([ { - provider: "openai", - model: "ox-alpha", + provider: "zhipu", + model: "glm-5.3-flash", }, ]) + expect(toProviderAggregate(aggregate("ox-alpha", "unknown"))).toMatchObject([{ provider: "zhipu" }]) }) test("model aggregates prefer provider.model and use normalized model", () => { @@ -126,6 +130,8 @@ describe("inference stat normalization", () => { expect(queries[0]).toContain("COALESCE(NULLIF(lower(model_tier), ''), '') AS raw_tier") expect(queries[0]).toContain("WHEN lower(COALESCE(raw_tier, '')) = 'free'") expect(queries[0]).toContain("regexp_replace(NULLIF(route_model, ''), '^.*/', '')") + expect(queries[0]).toContain("= 'ox-alpha' THEN 'glm-5.3-flash'") + expect(queries[0]).toContain("= 'x-preview-f' THEN 'glm-5.3-flash'") expect(queries[0]).toContain("OR lower(raw_model) IN ('gpt-5-nano', 'grok-code', 'big-pickle')") expect(queries[0]).toContain("OR lower(raw_model) LIKE '%-free'") expect(queries[0]).toContain("THEN 'Free'") diff --git a/packages/stats/core/src/domain/inference.ts b/packages/stats/core/src/domain/inference.ts index a1d1a01625f..3767b7ba4d0 100644 --- a/packages/stats/core/src/domain/inference.ts +++ b/packages/stats/core/src/domain/inference.ts @@ -462,13 +462,16 @@ function retentionPeriods(periodStart: Date, periodEnd: Date) { } function statModelSql(model: string, providerModel: string) { - return `COALESCE(NULLIF(regexp_replace(CASE + const normalized = `regexp_replace(CASE WHEN lower(${model}) = 'big-pickle' THEN regexp_replace(NULLIF(${providerModel}, ''), '^.*/', '') -${Object.entries(MODEL_NAME_ALIASES) - .map(([from, to]) => ` WHEN lower(${model}) = ${sqlString(from)} THEN ${sqlString(to)}`) - .join("\n")} ELSE ${model} - END, '(-free|:free|:global)+$', ''), ''), 'unknown')` + END, '(-free|:free|:global)+$', '')` + return `COALESCE(NULLIF(CASE +${Object.entries(MODEL_NAME_ALIASES) + .map(([from, to]) => ` WHEN lower(${normalized}) = ${sqlString(from)} THEN ${sqlString(to)}`) + .join("\n")} + ELSE ${normalized} + END, ''), 'unknown')` } function freeTierSql(tier: string, model: string) { diff --git a/packages/stats/core/src/domain/model-normalization.ts b/packages/stats/core/src/domain/model-normalization.ts index c950fda937a..52c7c189015 100644 --- a/packages/stats/core/src/domain/model-normalization.ts +++ b/packages/stats/core/src/domain/model-normalization.ts @@ -16,7 +16,8 @@ export const MODEL_AUTHOR_RULES = [ export const EXCLUDED_MODELS = new Set(["alpha-gpt-next"]) export const FREE_MODELS = new Set(["gpt-5-nano", "grok-code", "big-pickle"]) export const MODEL_NAME_ALIASES: Record = { - "x-preview-f": "ox-alpha", + "ox-alpha": "glm-5.3-flash", + "x-preview-f": "glm-5.3-flash", "xiaomi/mimo-v2.5": "mimo-v2.5", } export const RETIRED_STAT_MODELS = ["big-pickle", ...Object.keys(MODEL_NAME_ALIASES)] @@ -35,11 +36,9 @@ export function modelAuthor(value: string | undefined) { export function statModel(model: string | undefined, providerModel: string | undefined) { const normalized = normalizeInferenceModel(model) - const alias = MODEL_NAME_ALIASES[normalized.toLowerCase()] - if (alias) return alias - if (RETIRED_STAT_MODELS.includes(normalized.toLowerCase())) - return normalizeInferenceModel(providerModel?.split("/").at(-1)) - return normalized + const resolved = + normalized === "big-pickle" ? normalizeInferenceModel(providerModel?.split("/").at(-1)) : normalized + return MODEL_NAME_ALIASES[resolved.toLowerCase()] ?? resolved } export function statProvider(