Skip to content

Commit a79ecfe

Browse files
authored
feat(stats): add canonical redirects and agent-readable data formats
1 parent cff9078 commit a79ecfe

45 files changed

Lines changed: 2707 additions & 664 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
// Shared by the console proxy and the stats app so both agree on which requests get Markdown.
2+
export function prefersMarkdown(accept: string | null | undefined) {
3+
if (!accept) return false
4+
const weights = new Map(
5+
accept.split(",").map((part) => {
6+
const fields = part.split(";").map((value) => value.trim().toLowerCase())
7+
const quality = fields.slice(1).find((field) => field.startsWith("q="))
8+
return [fields[0] ?? "", quality ? Number(quality.slice(2)) : 1] as const
9+
}),
10+
)
11+
const markdown = Math.max(weights.get("text/markdown") ?? 0, weights.get("text/x-markdown") ?? 0)
12+
return markdown > 0 && markdown >= (weights.get("text/html") ?? 0)
13+
}

‎packages/console/app/src/lib/stats-proxy.ts‎

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import type { APIEvent } from "@solidjs/start/server"
22
import { Resource, waitUntil } from "@opencode-ai/console-resource"
3+
import { prefersMarkdown } from "~/lib/content-negotiation"
34
import { LOCALE_HEADER, cookie, localeFromRequest, route, tag } from "~/lib/language"
45

56
const dataPath = "/data"
@@ -40,16 +41,20 @@ export async function statsProxy(evt: APIEvent) {
4041
method: req.method,
4142
headers: requestHeaders,
4243
body: req.body,
44+
// Pass canonical redirects from the stats app to the client instead of following them here.
45+
redirect: "manual",
4346
})
4447

48+
const location = response.headers.get("location")
49+
if (location) return withPublicLocation(response, new URL(location, targetUrl), targetUrl)
4550
if (!response.headers.get("content-type")?.includes("text/html")) return response
4651

4752
const headers = new Headers(response.headers)
4853
headers.delete("content-encoding")
4954
headers.delete("content-length")
5055
headers.delete("etag")
5156
headers.delete("set-cookie")
52-
appendVary(headers, "Accept-Language", "Cookie", LOCALE_HEADER)
57+
appendVary(headers, "Accept", "Accept-Language", "Cookie", LOCALE_HEADER)
5358

5459
const result = new Response(rewriteStatsHtml(await response.text()), {
5560
status: response.status,
@@ -73,6 +78,14 @@ export function statsRedirect(evt: APIEvent) {
7378
})
7479
}
7580

81+
// A redirect to the stats origin itself would send clients past the proxy, so keep only the path.
82+
function withPublicLocation(response: Response, location: URL, upstream: URL) {
83+
if (location.origin !== upstream.origin) return response
84+
const headers = new Headers(response.headers)
85+
headers.set("location", `${location.pathname}${location.search}${location.hash}`)
86+
return new Response(response.body, { status: response.status, statusText: response.statusText, headers })
87+
}
88+
7689
function rewriteStatsHtml(html: string) {
7790
return html.replaceAll('"/_build/', `"${dataPath}/_build/`).replaceAll("'/_build/", `'${dataPath}/_build/`)
7891
}
@@ -120,7 +133,7 @@ function redirectToLocalizedData(request: Request, url: URL, locale: ReturnType<
120133
Location: next.toString(),
121134
})
122135
headers.append("set-cookie", cookie(locale))
123-
appendVary(headers, "Accept-Language", "Cookie", LOCALE_HEADER)
136+
appendVary(headers, "Accept", "Accept-Language", "Cookie", LOCALE_HEADER)
124137

125138
return new Response(null, {
126139
status: 308,
@@ -130,6 +143,8 @@ function redirectToLocalizedData(request: Request, url: URL, locale: ReturnType<
130143

131144
function acceptsHtml(request: Request) {
132145
const accept = request.headers.get("accept")
146+
// The stats app answers Markdown requests itself, so keep them out of the HTML cache and locale redirect.
147+
if (prefersMarkdown(accept)) return false
133148
return !accept || accept.includes("text/html") || accept.includes("*/*")
134149
}
135150

@@ -140,7 +155,10 @@ function isDataBypassPath(pathname: string) {
140155
pathname.startsWith(`${dataPath}/_server`) ||
141156
pathname === `${dataPath}/banner.jpg` ||
142157
pathname === `${dataPath}/banner.png` ||
143-
pathname === `${dataPath}/sitemap.xml`
158+
pathname === `${dataPath}/sitemap.xml` ||
159+
pathname.endsWith("/llms.txt") ||
160+
pathname.endsWith(".md") ||
161+
pathname.endsWith(".json")
144162
)
145163
}
146164

‎packages/opencode/test/cli/run/run-process.test.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,8 @@ describe("opencode run (non-interactive subprocess)", () => {
6767
// makes the SDK call surface an error promptly so the process exits nonzero.
6868
// We assert nonzero exit AND wall-clock under the harness timeout — a hang
6969
// would expire the timeout and produce a different (signal-killed) failure.
70-
cliIt.concurrent(
70+
// Keep competing CLI startups out of this wall-clock assertion on busy CI runners.
71+
cliIt.live(
7172
"exits nonzero promptly when the model is unknown (regression for #27371)",
7273
({ opencode }) =>
7374
Effect.gen(function* () {
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import { Link } from "@solidjs/meta"
2+
import { Show } from "solid-js"
3+
import { getRequestEvent } from "solid-js/web"
4+
import { jsonUrl, markdownUrl } from "../lib/language"
5+
6+
export function JsonLd(props: { data: object }) {
7+
// Escape "<" so values from the catalog can never close the script element.
8+
return <script type="application/ld+json" innerHTML={JSON.stringify(props.data).replaceAll("<", "\\u003c")} />
9+
}
10+
11+
export function FormatLinks(props: { path: string; json?: boolean }) {
12+
return (
13+
<>
14+
<Link rel="alternate" type="text/markdown" href={markdownUrl(props.path)} />
15+
<Show when={props.json !== false}>
16+
<Link rel="alternate" type="application/json" href={jsonUrl(props.path)} />
17+
</Show>
18+
</>
19+
)
20+
}
21+
22+
export function LastModified(props: { value: string | null | undefined }) {
23+
const event = getRequestEvent()
24+
if (event && props.value) event.response.headers.set("Last-Modified", new Date(props.value).toUTCString())
25+
return null
26+
}
27+
28+
export function breadcrumbList(items: { name: string; url: string }[]) {
29+
return {
30+
"@type": "BreadcrumbList",
31+
itemListElement: items.map((item, index) => ({
32+
"@type": "ListItem",
33+
position: index + 1,
34+
name: item.name,
35+
item: item.url,
36+
})),
37+
}
38+
}
39+
40+
export const openCodeOrganization = { "@type": "Organization", name: "OpenCode", url: "https://opencode.ai" }
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import { For } from "solid-js"
2+
3+
// Text equivalent of a chart for screen readers and text-only clients. The wrapper hides it because tables
4+
// treat height as a minimum and would grow to fit their rows.
5+
export function ChartDataTable(props: { caption: string; headers: string[]; rows: (string | number)[][] }) {
6+
return (
7+
<div data-slot="visually-hidden">
8+
<table>
9+
<caption>{props.caption}</caption>
10+
<thead>
11+
<tr>
12+
<For each={props.headers}>{(header) => <th scope="col">{header}</th>}</For>
13+
</tr>
14+
</thead>
15+
<tbody>
16+
<For each={props.rows}>
17+
{(row) => (
18+
<tr>
19+
<th scope="row">{row[0]}</th>
20+
<For each={row.slice(1)}>{(cell) => <td>{cell}</td>}</For>
21+
</tr>
22+
)}
23+
</For>
24+
</tbody>
25+
</table>
26+
</div>
27+
)
28+
}

‎packages/stats/app/src/component/model-compare-detail.tsx‎

Lines changed: 26 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import {
2020
} from "../routes/compare-cards"
2121
import { ComparisonRadar } from "../routes/compare-radar"
2222
import {
23+
catalogLabPath,
2324
catalogSlug,
2425
findModelCatalogEntry,
2526
formatCatalogLabName,
@@ -48,6 +49,7 @@ import {
4849
} from "../lib/comparison-pages"
4950
import { baseUrl } from "../lib/language"
5051
import { runStatsEffect } from "../stats-runtime"
52+
import { breadcrumbList, FormatLinks, JsonLd } from "./agent-meta"
5153
import { NotFoundMeta } from "./not-found-meta"
5254

5355
const compareHeaderLinks: readonly HeaderLink[] = [
@@ -62,6 +64,7 @@ const compareFooterLinks: readonly HeaderLink[] = [
6264
{ href: `${import.meta.env.BASE_URL}compare`, label: "Model Compare" },
6365
{ href: `${import.meta.env.BASE_URL}#top-models`, label: "Top Models" },
6466
{ href: `${import.meta.env.BASE_URL}#token-cost`, label: "Token Cost" },
67+
{ href: `${import.meta.env.BASE_URL}#methodology`, label: "Methodology" },
6568
]
6669
const heroLabs = [
6770
{ lab: "deepseek", label: "DeepSeek" },
@@ -204,21 +207,24 @@ export default function ModelCompareDetailPage(props: ModelCompareDetailPageProp
204207
const syncComparisonScroll = (source: HTMLDivElement, target: HTMLDivElement | undefined) => {
205208
if (target && target.scrollLeft !== source.scrollLeft) target.scrollLeft = source.scrollLeft
206209
}
207-
const structuredData = createMemo(() =>
208-
JSON.stringify({
209-
"@context": "https://schema.org",
210-
"@type": "WebPage",
211-
name: title(),
212-
description: description(),
213-
url: canonicalUrl(),
214-
about: models().map((model) => ({
215-
"@type": "SoftwareApplication",
216-
name: model.name,
217-
applicationCategory: "AI model",
218-
...(model.labName ? { provider: model.labName } : {}),
219-
})),
220-
}),
221-
)
210+
const structuredData = createMemo(() => ({
211+
"@context": "https://schema.org",
212+
"@type": "WebPage",
213+
name: title(),
214+
description: description(),
215+
url: canonicalUrl(),
216+
about: models().map((model) => ({
217+
"@type": "SoftwareApplication",
218+
name: model.name,
219+
applicationCategory: "AI model",
220+
...(model.labName ? { provider: model.labName } : {}),
221+
})),
222+
breadcrumb: breadcrumbList([
223+
{ name: "Data", url: new URL("/data/", baseUrl).toString() },
224+
{ name: "Compare", url: new URL("/data/compare", baseUrl).toString() },
225+
{ name: `${models()[0].name} vs ${models()[1].name}`, url: canonicalUrl() },
226+
]),
227+
}))
222228
const updateThemePreference = (preference: ThemePreference) => {
223229
applyThemePreference(preference)
224230
setThemePreference(preference)
@@ -256,7 +262,8 @@ export default function ModelCompareDetailPage(props: ModelCompareDetailPageProp
256262
<Meta name="twitter:card" content="summary" />
257263
<Meta name="twitter:title" content={title()} />
258264
<Meta name="twitter:description" content={description()} />
259-
<script type="application/ld+json">{structuredData()}</script>
265+
<FormatLinks path={canonicalPath()} json={false} />
266+
<JsonLd data={structuredData()} />
260267
</Show>
261268
</Show>
262269
<Header
@@ -348,9 +355,8 @@ function ComparisonHero(props: {
348355
</nav>
349356
<div data-slot="compare-detail-hero-grid">
350357
<h1 aria-label={`Compare ${props.models.map((model) => model.name).join(", ")}`}>
351-
<span>Compare</span>
352-
<HeroModelStack />
353-
<span>AI models</span>
358+
<span>Compare</span> <HeroModelStack /> <span>AI models</span>
359+
<span data-slot="visually-hidden">: {props.models.map((model) => model.name).join(" vs ")}</span>
354360
</h1>
355361
<div data-slot="compare-detail-actions">
356362
<button
@@ -1033,7 +1039,7 @@ function comparisonModelLabel(model: ComparisonModel) {
10331039

10341040
function providerDetailCell(model: ComparisonModel): ComparisonDetailCell {
10351041
if (!model.labName) return textCell("")
1036-
return linkedTextCell(model.stats?.author ?? model.labName ?? "", labHref(model.lab))
1042+
return linkedTextCell(model.stats?.author ?? model.labName ?? "", catalogLabPath(model.lab))
10371043
}
10381044

10391045
function textCell(value: string): ComparisonDetailCell {
@@ -1080,10 +1086,6 @@ function tokenCell(value: number | undefined, trend: number | undefined): Compar
10801086
return { value: formatTokens(value), score: value, trend }
10811087
}
10821088

1083-
function labHref(lab: string) {
1084-
return `${import.meta.env.BASE_URL}${catalogSlug(lab)}`
1085-
}
1086-
10871089
function modelSearchText(model: ModelCatalogEntry) {
10881090
return `${model.name} ${formatCatalogLabName(model.lab)} ${model.id}`.toLowerCase()
10891091
}

‎packages/stats/app/src/context/i18n.tsx‎

Lines changed: 2 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,15 @@
1-
import { createMemo } from "solid-js"
21
import { createSimpleContext } from "@opencode-ai/ui/context"
3-
import { dict, type Key } from "../i18n"
2+
import { translate, type Key } from "../i18n"
43
import { useLanguage } from "./language"
54

6-
function resolve(text: string, params?: Record<string, string | number>) {
7-
if (!params) return text
8-
return text.replace(/\{\{(\w+)\}\}/g, (raw, key) => {
9-
const value = params[key]
10-
if (value === undefined || value === null) return raw
11-
return String(value)
12-
})
13-
}
14-
155
export const { use: useI18n, provider: I18nProvider } = createSimpleContext({
166
name: "StatsI18n",
177
init: () => {
188
const language = useLanguage()
19-
const dictionary = createMemo(() => dict(language.locale()))
209

2110
return {
2211
t(key: Key, params?: Record<string, string | number>) {
23-
return resolve(dictionary()[key], params)
12+
return translate(language.locale(), key, params)
2413
},
2514
}
2615
},

‎packages/stats/app/src/i18n.ts‎

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,10 +98,14 @@ const en = {
9898
"chart.leaderboardAria": "Model token leaderboard",
9999
"chart.scrollableLeaderboardAria": "Scrollable model token leaderboard",
100100
"chart.byAuthor": "by {{author}}",
101+
"chart.vsPreviousWeek": "vs previous week",
102+
"chart.date": "Date",
101103
"home.updated": "Updated",
102104
"home.noRows": "No rows yet",
103105
"home.justNow": "just now",
104106
"home.heroCopy": "See which models are winning real usage, how the mix is shifting, and what that means for cost.",
107+
"home.summary":
108+
"As of {{date}}, {{first}} led OpenCode usage over the past 7 days with {{firstTokens}} tokens, followed by {{second}} ({{secondTokens}}) and {{third}} ({{thirdTokens}}).",
105109
"home.loadingTitle": "Loading data",
106110
"home.loadingDescription": "Reading model aggregates.",
107111
"home.usageTitle": "Usage",
@@ -147,6 +151,8 @@ const en = {
147151
"lab.title": "{{lab}} AI Model Usage & Rankings | OpenCode Data",
148152
"lab.description":
149153
"Compare {{lab}} models used in OpenCode, including token usage, model rankings, context windows, release dates, costs, and model-specific data.",
154+
"lab.summary":
155+
"{{lab}} models processed {{tokens}} tokens across OpenCode over the past two months, {{share}} of all usage. {{model}} was the most-used {{lab}} model.",
150156
"lab.loadingTitle": "Model Lab",
151157
"lab.loadingDescription": "Reading model availability and recent OpenCode usage.",
152158
"lab.notFound": "No models matched this lab.",
@@ -173,6 +179,10 @@ const en = {
173179
"model.title": "{{model}} Usage, Cost & Rank | OpenCode Data",
174180
"model.description":
175181
"View {{model}} OpenCode usage data, including token volume, weekly rank, token mix, costs, cache ratio, sessions, geo breakdowns, and peer models.",
182+
"model.summary":
183+
"{{model}} ranked #{{rank}} by tokens across OpenCode last week, with {{share}} of tokens over the past two months.",
184+
"model.summaryUnranked": "{{model}} had {{share}} of tokens across OpenCode over the past two months.",
185+
"model.summaryPrice": "{{model}} costs {{input}} per 1M input tokens and {{output}} per 1M output tokens.",
176186
"model.loadingTitle": "Model Data",
177187
"model.loadingDescription": "Reading model aggregates.",
178188
"model.loadingProfile": "Reading the model profile.",
@@ -246,6 +256,21 @@ const en = {
246256
"model.pdf": "PDF",
247257
"format.users": "users",
248258
"format.tokens": "tokens",
259+
"methodology.title": "Methodology",
260+
"methodology.description": "How this data is collected.",
261+
"methodology.updatesLabel": "Updates",
262+
"methodology.updates": "Aggregated every hour. Days and weeks use UTC.",
263+
"methodology.tokensLabel": "Tokens",
264+
"methodology.tokens": "Input, output, reasoning, and cached tokens for each request.",
265+
"methodology.usersLabel": "Users and sessions",
266+
"methodology.users": "Approximate counts of distinct users and OpenCode sessions.",
267+
"methodology.costLabel": "Cost",
268+
"methodology.cost":
269+
"Session cost is the average cost per OpenCode session. Token prices are list prices from the OpenCode model catalog.",
270+
"methodology.retentionLabel": "Retention",
271+
"methodology.retention": "The share of a model's users in one week who use it again the next week.",
272+
"methodology.citeLabel": "Citation",
273+
"methodology.cite": "Cite OpenCode Data (opencode.ai/data) with the update time shown at the top of the page.",
249274
} as const
250275

251276
export type Key = keyof typeof en
@@ -275,3 +300,13 @@ const dictionaries = {
275300
export function dict(locale: Locale) {
276301
return dictionaries[locale]
277302
}
303+
304+
export function translate(locale: Locale, key: Key, params?: Record<string, string | number>) {
305+
const text = dictionaries[locale][key]
306+
if (!params) return text
307+
return text.replace(/\{\{(\w+)\}\}/g, (raw, key) => {
308+
const value = params[key]
309+
if (value === undefined || value === null) return raw
310+
return String(value)
311+
})
312+
}

0 commit comments

Comments
 (0)