chore: merge dev into v2

This commit is contained in:
Aiden Cline 2026-07-10 20:03:05 +00:00
commit 2799784503
82 changed files with 4519 additions and 1231 deletions

10
artifacts/glm52-rise-video/sst-env.d.ts vendored Normal file
View file

@ -0,0 +1,10 @@
/* This file is auto-generated by SST. Do not edit. */
/* tslint:disable */
/* eslint-disable */
/* deno-fmt-ignore-file */
/* biome-ignore-all lint: auto-generated */
/// <reference path="../../sst-env.d.ts" />
import "sst"
export {}

View file

@ -67,6 +67,10 @@ const athenaWorkgroup = new aws.athena.Workgroup("LakeAthenaWorkgroup", {
configuration: { configuration: {
enforceWorkgroupConfiguration: true, enforceWorkgroupConfiguration: true,
publishCloudwatchMetricsEnabled: true, publishCloudwatchMetricsEnabled: true,
// Athena bills $5/TB scanned; kill any query that would scan more than 2 TB
// so a regression cannot silently burn money. Stats sync full passes scan
// ~250 GB as of 2026-07.
bytesScannedCutoffPerQuery: 2 * 1024 ** 4,
resultConfiguration: { resultConfiguration: {
outputLocation: $interpolate`s3://${athenaResultsBucket.bucket}/`, outputLocation: $interpolate`s3://${athenaResultsBucket.bucket}/`,
}, },

View file

@ -185,7 +185,9 @@ export const statSync = new sst.aws.Service("StatsSyncService", {
cluster: lakeCluster, cluster: lakeCluster,
architecture: "arm64", architecture: "arm64",
cpu: "0.25 vCPU", cpu: "0.25 vCPU",
memory: "0.5 GB", // 0.5 GB caused an OOM crash loop: every restart immediately re-ran the 4 Athena
// stats queries (~$5/pass) every ~5 minutes instead of hourly.
memory: "2 GB",
image: { image: {
context: ".", context: ".",
dockerfile: "packages/stats/server/Dockerfile", dockerfile: "packages/stats/server/Dockerfile",

View file

@ -74,6 +74,10 @@ test("opens and searches project files inline", async ({ page }) => {
"opencode.global.dat:layout", "opencode.global.dat:layout",
JSON.stringify({ review: { diffStyle: "split", panelOpened: true } }), JSON.stringify({ review: { diffStyle: "split", panelOpened: true } }),
) )
localStorage.setItem(
"opencode.global.dat:review-panel-v2",
JSON.stringify({ sidebarOpened: false, sidebarWidth: 240, expandMode: "collapse" }),
)
localStorage.setItem( localStorage.setItem(
"opencode.window.browser.dat:tabs", "opencode.window.browser.dat:tabs",
JSON.stringify([{ type: "session", server, sessionId: sessionID }]), JSON.stringify([{ type: "session", server, sessionId: sessionID }]),
@ -86,13 +90,16 @@ test("opens and searches project files inline", async ({ page }) => {
await expectSessionTitle(page, title) await expectSessionTitle(page, title)
const panel = page.locator("#review-panel") const panel = page.locator("#review-panel")
const sidebar = panel.locator('[data-slot="session-review-v2-sidebar"]')
const contextButton = page.getByRole("button", { name: "View context usage" }) const contextButton = page.getByRole("button", { name: "View context usage" })
await contextButton.click() await contextButton.click()
await expect(panel.getByRole("tab", { name: "Context" })).toHaveAttribute("data-selected", "") await expect(panel.getByRole("tab", { name: "Context" })).toHaveAttribute("data-selected", "")
await panel.getByRole("button", { name: "Open file" }).click() await panel.getByRole("button", { name: "Open file" }).click()
await expect(panel.getByRole("tab", { name: "Open file" })).toHaveAttribute("data-selected", "") await expect(panel.getByRole("tab", { name: "Open file" })).toHaveAttribute("data-selected", "")
await expect(sidebar).toBeVisible()
await contextButton.click() await contextButton.click()
await expect(panel.getByRole("tab", { name: "Context" })).toHaveAttribute("data-selected", "") await expect(panel.getByRole("tab", { name: "Context" })).toHaveAttribute("data-selected", "")
await expect(sidebar).toHaveCount(0)
await panel.getByRole("button", { name: "Open file" }).click() await panel.getByRole("button", { name: "Open file" }).click()
const filter = panel.getByRole("combobox", { name: "Filter files" }) const filter = panel.getByRole("combobox", { name: "Filter files" })
await expect(filter).toBeFocused() await expect(filter).toBeFocused()
@ -102,9 +109,11 @@ test("opens and searches project files inline", async ({ page }) => {
await panel.getByRole("button", { name: "README.md" }).click() await panel.getByRole("button", { name: "README.md" }).click()
await expect(panel.getByRole("tab", { name: "README.md" })).toHaveAttribute("data-selected", "") await expect(panel.getByRole("tab", { name: "README.md" })).toHaveAttribute("data-selected", "")
await expect(panel.getByText("contents:README.md", { exact: true })).toBeVisible() await expect(panel.getByText("contents:README.md", { exact: true })).toBeVisible()
await expect(sidebar).toHaveCount(0)
await panel.getByRole("button", { name: "Open file" }).click() await panel.getByRole("button", { name: "Open file" }).click()
await expect(panel.getByRole("tab", { name: "README.md" })).toHaveCount(0) await expect(panel.getByRole("tab", { name: "README.md" })).toHaveCount(0)
await expect(sidebar).toBeVisible()
await filter.fill("nested") await filter.fill("nested")
const result = panel.getByRole("option", { name: /nested\.ts/ }) const result = panel.getByRole("option", { name: /nested\.ts/ })
await expect(result).toBeVisible() await expect(result).toBeVisible()

View file

@ -0,0 +1,228 @@
import { expect, test, type Locator, type Page } from "@playwright/test"
import {
assistantMessage,
setupTimeline,
shell,
textPart,
toolPart,
userMessage,
} from "../performance/timeline-stability/fixture"
for (const deviceScaleFactor of [1.25, 1.5]) {
test(`keeps the shell outline inside a fractionally short virtual row at ${deviceScaleFactor}x`, async ({ page }) => {
const shellID = "prt_shell_outline"
const timeline = await setupTimeline(page, {
messages: [userMessage(), assistantMessage([shell(shellID, "completed", "shell output")])],
settings: { newLayoutDesigns: true, shellToolPartsExpanded: true },
reducedMotion: true,
deviceScaleFactor,
})
const part = page.locator(`[data-timeline-part-id="${shellID}"]`)
const output = part.locator('[data-component="bash-output"]')
const row = page.locator("[data-timeline-key]", { has: part })
await expect(output).toBeVisible()
await timeline.settle()
const geometry = await row.evaluate((element) => {
const output = element.querySelector<HTMLElement>('[data-component="bash-output"]')
if (!output) throw new Error("Shell output is unavailable")
const rowRect = element.getBoundingClientRect()
const outputRect = output.getBoundingClientRect()
// Match a rounded-down measurement at a fractional device-pixel phase.
element.style.height = `${outputRect.bottom - rowRect.top - 0.49}px`
element.style.transform = "translateY(0.25px)"
output.style.setProperty("--v2-border-border-base", "rgb(255, 0, 255)")
output.style.setProperty("background", "rgb(0, 0, 0)", "important")
const style = getComputedStyle(output)
return {
outputWidth: outputRect.width,
outputHeight: outputRect.height,
borderColor: style.borderTopColor,
boxShadow: style.boxShadow,
clipMargin: getComputedStyle(element).overflowClipMargin,
}
})
await timeline.settle()
const clipped = await row.evaluate((element) => {
const output = element.querySelector<HTMLElement>('[data-component="bash-output"]')!
return output.getBoundingClientRect().bottom - element.getBoundingClientRect().bottom
})
expect(clipped).toBeCloseTo(0.49, 1)
expect(await page.evaluate(() => devicePixelRatio)).toBe(deviceScaleFactor)
const edges = await captureCardEdges(page, output)
expect(edges.box.width).toBeCloseTo(geometry.outputWidth, 2)
expect(edges.box.height).toBeCloseTo(geometry.outputHeight, 2)
expect(geometry.borderColor).toBe("rgb(255, 0, 255)")
expect(geometry.boxShadow).toBe("none")
expect(geometry.clipMargin).toBe("0.5px")
expect(edges.magenta.top).toBeGreaterThan(0.75)
expect(edges.magenta.bottom).toBeGreaterThan(0.75)
expect(edges.magenta.vertical).toBeGreaterThanOrEqual(2)
})
}
test("keeps the patch card inside a fractionally short virtual row", async ({ page }) => {
const patchID = "prt_patch_outline"
const file = {
filePath: "src/outline.ts",
relativePath: "src/outline.ts",
type: "update",
additions: 1,
deletions: 1,
before: "const outline = false\n",
after: "const outline = true\n",
}
const timeline = await setupTimeline(page, {
messages: [
userMessage(),
assistantMessage([
toolPart(patchID, "apply_patch", "completed", { files: [file.filePath] }, { metadata: { files: [file] } }),
]),
],
settings: { editToolPartsExpanded: true, newLayoutDesigns: true },
reducedMotion: true,
})
const part = page.locator(`[data-timeline-part-id="${patchID}"]`)
const card = part.locator('[data-component="accordion"][data-scope="apply-patch"]')
const row = page.locator("[data-timeline-key]", { has: part })
await expect(card).toBeVisible()
await timeline.settle()
const geometry = await row.evaluate((element) => {
const card = element.querySelector<HTMLElement>('[data-component="accordion"][data-scope="apply-patch"]')
if (!card) throw new Error("Patch card is unavailable")
const rowRect = element.getBoundingClientRect()
const cardRect = card.getBoundingClientRect()
element.style.height = `${cardRect.bottom - rowRect.top - 0.49}px`
const clipMargin = getComputedStyle(element).overflowClipMargin
const bottom = element.getBoundingClientRect().bottom
return {
overflow: card.getBoundingClientRect().bottom - bottom,
paintOverflow: card.getBoundingClientRect().bottom - bottom - Number.parseFloat(clipMargin),
clipMargin,
cardWidth: cardRect.width,
cardHeight: cardRect.height,
}
})
await timeline.settle()
expect(geometry.overflow).toBeCloseTo(0.49, 1)
expect(geometry.paintOverflow).toBeLessThanOrEqual(0)
const edges = await captureCardEdges(page, card)
expect(edges.box.width).toBeCloseTo(geometry.cardWidth, 2)
expect(edges.box.height).toBeCloseTo(geometry.cardHeight, 2)
expect(edges.luminance.top).toBeLessThan(245)
expect(edges.luminance.bottom).toBeLessThan(245)
expect(Math.abs(edges.luminance.bottom - edges.luminance.top)).toBeLessThan(10)
expect(geometry.clipMargin).toBe("0.5px")
})
test("allows paint rounding for every framed row but not fixed turn gaps", async ({ page }) => {
const secondUserID = "msg_outline_second_user"
await setupTimeline(page, {
messages: [
userMessage(undefined, {
summary: {
diffs: [
{
file: "src/summary.ts",
additions: 1,
deletions: 1,
patch: "@@ -1 +1 @@\n-export const value = 1\n+export const value = 2",
},
],
},
}),
assistantMessage([textPart("prt_outline_text", "Assistant text")]),
userMessage(undefined, { id: secondUserID, created: 1700000010000 }),
assistantMessage([], {
id: "msg_outline_second_assistant",
parentID: secondUserID,
created: 1700000011000,
}),
],
})
await expect(page.locator('[data-timeline-row="DiffSummary"]')).toBeVisible()
await expect(page.locator('[data-timeline-row="TurnGap"]')).toBeVisible()
const rows = await page.locator("[data-timeline-key]").evaluateAll((elements) =>
elements.map((element) => ({
tag: element.querySelector<HTMLElement>("[data-timeline-row]")?.dataset.timelineRow,
clipMargin: getComputedStyle(element).overflowClipMargin,
})),
)
expect(rows.filter((row) => row.tag !== "TurnGap").every((row) => row.clipMargin === "0.5px")).toBe(true)
expect(rows.filter((row) => row.tag === "TurnGap")).toEqual([{ tag: "TurnGap", clipMargin: "0px" }])
})
async function captureCardEdges(page: Page, card: Locator) {
const box = await card.boundingBox()
if (!box) throw new Error("Tool card bounds are unavailable")
const viewport = page.viewportSize()
if (!viewport) throw new Error("Viewport bounds are unavailable")
const screenshot = await page.screenshot()
return page.evaluate(
async ({ source, box, viewport }) => {
const image = new Image()
image.src = source
await image.decode()
const canvas = document.createElement("canvas")
canvas.width = image.naturalWidth
canvas.height = image.naturalHeight
const context = canvas.getContext("2d")
if (!context) throw new Error("2D canvas is unavailable")
context.drawImage(image, 0, 0)
const scale = {
x: image.naturalWidth / viewport.width,
y: image.naturalHeight / viewport.height,
}
const rows = (candidates: number[]) => {
const left = Math.floor((box.x + 8) * scale.x)
const width = Math.floor((box.width - 16) * scale.x)
return candidates.map((row) => {
const pixels = context.getImageData(left, row, width, 1).data
const indexes = Array.from({ length: width }, (_, index) => index * 4)
return {
luminance:
indexes
.map((index) => (pixels[index]! + pixels[index + 1]! + pixels[index + 2]!) / 3)
.reduce((sum, value) => sum + value, 0) / width,
magenta:
indexes.filter((index) => pixels[index]! > 200 && pixels[index + 1]! < 180 && pixels[index + 2]! > 200)
.length / width,
}
})
}
const pixels = context.getImageData(0, 0, image.naturalWidth, image.naturalHeight).data
const columns = new Uint32Array(image.naturalWidth)
for (let index = 0; index < pixels.length; index += 4) {
if (pixels[index]! <= 200 || pixels[index + 1]! >= 180 || pixels[index + 2]! <= 200) continue
columns[(index / 4) % image.naturalWidth] = columns[(index / 4) % image.naturalWidth]! + 1
}
const top = box.y * scale.y
const bottom = (box.y + box.height) * scale.y
const topRows = rows([Math.floor(top) - 1, Math.floor(top), Math.ceil(top)])
const bottomRows = rows([Math.floor(bottom) - 2, Math.floor(bottom) - 1, Math.ceil(bottom) - 1])
return {
box,
luminance: {
top: Math.min(...topRows.map((row) => row.luminance)),
bottom: rows([Math.ceil(bottom) - 1])[0]!.luminance,
},
magenta: {
top: Math.max(...topRows.map((row) => row.magenta)),
bottom: Math.max(...bottomRows.map((row) => row.magenta)),
vertical: Array.from(columns).filter((count) => count > box.height * scale.y * 0.75).length,
},
}
},
{
source: `data:image/png;base64,${screenshot.toString("base64")}`,
viewport,
box,
},
)
}

View file

@ -36,6 +36,8 @@ import { Icon } from "@opencode-ai/ui/icon"
import { ProviderIcon } from "@opencode-ai/ui/provider-icon" import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip" import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2" import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
import { KeybindV2 } from "@opencode-ai/ui/v2/keybind-v2" import { KeybindV2 } from "@opencode-ai/ui/v2/keybind-v2"
import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2" import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2"
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2" import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
@ -1333,6 +1335,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
onQueue: props.onQueue, onQueue: props.onQueue,
onAbort: props.onAbort, onAbort: props.onAbort,
onSubmit: props.onSubmit, onSubmit: props.onSubmit,
model: props.controls.model.selection,
}) })
const handleKeyDown = (event: KeyboardEvent) => { const handleKeyDown = (event: KeyboardEvent) => {
@ -1704,22 +1707,19 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
> >
<MenuV2 gutter={6} modal={false} placement="top-start"> <MenuV2 gutter={6} modal={false} placement="top-start">
<MenuV2.Trigger <MenuV2.Trigger
as={IconButton} as={IconButtonV2}
data-action="prompt-attach" data-action="prompt-attach"
type="button" type="button"
icon="plus" icon={<IconV2 name="plus" />}
variant="ghost" variant="ghost-muted"
class="size-7 rounded-md p-[6px] text-v2-icon-icon-muted" size="large"
style={buttons()} style={buttons()}
disabled={store.mode !== "normal"} disabled={store.mode !== "normal"}
tabIndex={store.mode === "normal" ? undefined : -1} tabIndex={store.mode === "normal" ? undefined : -1}
aria-label={language.t("prompt.menu.addImagesAndFiles")} aria-label={language.t("prompt.menu.addImagesAndFiles")}
/> />
<MenuV2.Portal> <MenuV2.Portal>
<MenuV2.Content <MenuV2.Content style={{ "min-width": "180px" }}>
class="[&_[data-slot=menu-v2-item-shortcut]]:w-5 [&_[data-slot=menu-v2-item-shortcut]]:justify-center"
style={{ "min-width": "180px" }}
>
<MenuV2.Item onSelect={pick} shortcut={command.keybind("file.attach")}> <MenuV2.Item onSelect={pick} shortcut={command.keybind("file.attach")}>
{language.t("prompt.menu.imagesAndFiles")} {language.t("prompt.menu.imagesAndFiles")}
</MenuV2.Item> </MenuV2.Item>

View file

@ -1,5 +1,6 @@
import { beforeAll, beforeEach, describe, expect, mock, test } from "bun:test" import { beforeAll, beforeEach, describe, expect, mock, test } from "bun:test"
import type { Prompt } from "@/context/prompt" import type { Prompt } from "@/context/prompt"
import type { ModelSelection } from "@/context/local"
let createPromptSubmit: typeof import("./submit").createPromptSubmit let createPromptSubmit: typeof import("./submit").createPromptSubmit
@ -33,6 +34,10 @@ const prompt = {
current: () => promptValue, current: () => promptValue,
cursor: () => 0, cursor: () => 0,
dirty: () => true, dirty: () => true,
model: {
current: () => undefined,
set: () => undefined,
},
reset: () => undefined, reset: () => undefined,
set: () => undefined, set: () => undefined,
context: { context: {
@ -378,6 +383,39 @@ describe("prompt submit worktree selection", () => {
}) })
}) })
test("uses an injected model selection", async () => {
params = { id: "session-1" }
const model = {
current: () => ({ id: "draft-model", provider: { id: "draft-provider" } }),
variant: { current: () => "draft-variant" },
} as unknown as ModelSelection
const submit = createPromptSubmit({
prompt,
info: () => ({ id: "session-1" }),
imageAttachments: () => [],
commentCount: () => 0,
autoAccept: () => false,
mode: () => "normal",
working: () => false,
editor: () => undefined,
queueScroll: () => undefined,
promptLength: (value) => value.reduce((sum, part) => sum + ("content" in part ? part.content.length : 0), 0),
addToHistory: () => undefined,
resetHistoryNavigation: () => undefined,
setMode: () => undefined,
setPopover: () => undefined,
model,
})
await submit.handleSubmit({ preventDefault: () => undefined } as unknown as Event)
expect(optimistic[0]).toMatchObject({
message: {
model: { providerID: "draft-provider", modelID: "draft-model", variant: "draft-variant" },
},
})
})
test("seeds new sessions before optimistic prompts are added", async () => { test("seeds new sessions before optimistic prompts are added", async () => {
const submit = createPromptSubmit({ const submit = createPromptSubmit({
prompt, prompt,

View file

@ -3,12 +3,12 @@ import { showToast } from "@/utils/toast"
import { base64Encode } from "@opencode-ai/core/util/encode" import { base64Encode } from "@opencode-ai/core/util/encode"
import { Binary } from "@opencode-ai/core/util/binary" import { Binary } from "@opencode-ai/core/util/binary"
import { useNavigate, useParams, useSearchParams } from "@solidjs/router" import { useNavigate, useParams, useSearchParams } from "@solidjs/router"
import { batch, type Accessor } from "solid-js" import { batch, startTransition, type Accessor } from "solid-js"
import { useTabs } from "@/context/tabs" import { useTabs } from "@/context/tabs"
import { useServerSync, type ServerSync } from "@/context/server-sync" import { useServerSync, type ServerSync } from "@/context/server-sync"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { useLayout } from "@/context/layout" import { useLayout } from "@/context/layout"
import { useLocal } from "@/context/local" import { useLocal, type ModelSelection } from "@/context/local"
import { usePermission } from "@/context/permission" import { usePermission } from "@/context/permission"
import { type ContextItem, type ImageAttachmentPart, type Prompt, type usePrompt } from "@/context/prompt" import { type ContextItem, type ImageAttachmentPart, type Prompt, type usePrompt } from "@/context/prompt"
import { useSDK, type DirectorySDK } from "@/context/sdk" import { useSDK, type DirectorySDK } from "@/context/sdk"
@ -191,6 +191,7 @@ type PromptSubmitInput = {
onQueue?: (draft: FollowupDraft) => void onQueue?: (draft: FollowupDraft) => void
onAbort?: () => void onAbort?: () => void
onSubmit?: () => void onSubmit?: () => void
model?: ModelSelection
} }
export function createPromptSubmit(input: PromptSubmitInput) { export function createPromptSubmit(input: PromptSubmitInput) {
@ -296,9 +297,10 @@ export function createPromptSubmit(input: PromptSubmitInput) {
return return
} }
const currentModel = local.model.current() const modelSelection = input.model ?? local.model
const currentModel = modelSelection.current()
const currentAgent = local.agent.current() const currentAgent = local.agent.current()
const variant = local.model.variant.current() const variant = modelSelection.variant.current()
if (!currentModel || !currentAgent) { if (!currentModel || !currentAgent) {
showToast({ showToast({
title: language.t("prompt.toast.modelAgentRequired.title"), title: language.t("prompt.toast.modelAgentRequired.title"),
@ -372,13 +374,20 @@ export function createPromptSubmit(input: PromptSubmitInput) {
if (created) { if (created) {
seed(sessionDirectory, created) seed(sessionDirectory, created)
session = created session = created
if (shouldAutoAccept) permission.enableAutoAccept(session.id, sessionDirectory) await startTransition(() => {
local.session.promote(sessionDirectory, session.id) if (!session) return
layout.handoff.setTabs(base64Encode(sessionDirectory), session.id) if (shouldAutoAccept) permission.enableAutoAccept(session.id, sessionDirectory)
const draftID = search.draftId local.session.promote(sessionDirectory, session.id, {
if (draftID) tabs.promoteDraft(draftID, { server: tabs.draft(draftID).server, sessionId: session.id }) agent: currentAgent.name,
else navigate(`/${base64Encode(sessionDirectory)}/session/${session.id}`) model: { providerID: currentModel.provider.id, modelID: currentModel.id },
submission.retarget(prompt.capture({ dir: base64Encode(sessionDirectory), id: session.id })) variant: variant ?? null,
})
layout.handoff.setTabs(base64Encode(sessionDirectory), session.id)
const draftID = search.draftId
if (draftID) tabs.promoteDraft(draftID, { server: tabs.draft(draftID).server, sessionId: session.id })
else navigate(`/${base64Encode(sessionDirectory)}/session/${session.id}`)
submission.retarget(prompt.capture({ dir: base64Encode(sessionDirectory), id: session.id }))
})
} }
} }
if (!session) { if (!session) {

View file

@ -12,7 +12,7 @@ import { useSync } from "@/context/sync"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { useProviders } from "@/hooks/use-providers" import { useProviders } from "@/hooks/use-providers"
import { useSDK } from "@/context/sdk" import { useSDK } from "@/context/sdk"
import { getSessionContext, getSessionTokenTotal } from "@/components/session/session-context-metrics" import { getSessionContext } from "@/components/session/session-context-metrics"
import { useSessionLayout } from "@/pages/session/session-layout" import { useSessionLayout } from "@/pages/session/session-layout"
import { createSessionTabs } from "@/pages/session/helpers" import { createSessionTabs } from "@/pages/session/helpers"
import { useSettings } from "@/context/settings" import { useSettings } from "@/context/settings"
@ -74,7 +74,6 @@ export function SessionContextUsage(props: SessionContextUsageProps) {
) )
const context = createMemo(() => getSessionContext(messages(), [...providers.all().values()])) const context = createMemo(() => getSessionContext(messages(), [...providers.all().values()]))
const tokens = createMemo(() => info()?.tokens)
const cost = createMemo(() => { const cost = createMemo(() => {
return usd().format(info()?.cost ?? 0) return usd().format(info()?.cost ?? 0)
}) })
@ -132,7 +131,7 @@ export function SessionContextUsage(props: SessionContextUsageProps) {
<ContextTooltipRow name={language.t("context.usage.usage")} value={`${context()?.usage ?? 0}%`} /> <ContextTooltipRow name={language.t("context.usage.usage")} value={`${context()?.usage ?? 0}%`} />
<ContextTooltipRow <ContextTooltipRow
name={language.t("context.usage.tokens")} name={language.t("context.usage.tokens")}
value={getSessionTokenTotal(tokens())?.toLocaleString(language.intl()) ?? "0"} value={context()?.total.toLocaleString(language.intl()) ?? "0"}
/> />
</div> </div>
) )

View file

@ -1,6 +1,6 @@
import { describe, expect, test } from "bun:test" import { describe, expect, test } from "bun:test"
import type { Message } from "@opencode-ai/sdk/v2/client" import type { Message } from "@opencode-ai/sdk/v2/client"
import { getSessionContext, getSessionTokenTotal } from "./session-context-metrics" import { getSessionContext } from "./session-context-metrics"
const assistant = ( const assistant = (
id: string, id: string,
@ -38,10 +38,10 @@ const user = (id: string) => {
} }
describe("getSessionContext", () => { describe("getSessionContext", () => {
test("computes usage from latest assistant with tokens", () => { test("computes token totals and usage from latest assistant with tokens", () => {
const messages = [ const messages = [
user("u1"), user("u1"),
assistant("a1", { input: 0, output: 0, reasoning: 0, read: 0, write: 0 }, 0.5), assistant("a1", { input: 600, output: 200, reasoning: 100, read: 50, write: 50 }, 0.5),
assistant("a2", { input: 300, output: 100, reasoning: 50, read: 25, write: 25 }, 1.25), assistant("a2", { input: 300, output: 100, reasoning: 50, read: 25, write: 25 }, 1.25),
] ]
const providers = [ const providers = [
@ -60,6 +60,8 @@ describe("getSessionContext", () => {
const ctx = getSessionContext(messages, providers) const ctx = getSessionContext(messages, providers)
expect(ctx?.message.id).toBe("a2") expect(ctx?.message.id).toBe("a2")
expect(ctx?.total).toBe(500)
expect(ctx?.input).toBe(300)
expect(ctx?.usage).toBe(50) expect(ctx?.usage).toBe(50)
expect(ctx?.providerLabel).toBe("OpenAI") expect(ctx?.providerLabel).toBe("OpenAI")
expect(ctx?.modelLabel).toBe("GPT-4.1") expect(ctx?.modelLabel).toBe("GPT-4.1")
@ -94,15 +96,4 @@ describe("getSessionContext", () => {
expect(ctx).toBeUndefined() expect(ctx).toBeUndefined()
}) })
test("computes stored session token totals", () => {
expect(
getSessionTokenTotal({
input: 10,
output: 20,
reasoning: 30,
cache: { read: 40, write: 50 },
}),
).toBe(150)
})
}) })

View file

@ -1,4 +1,4 @@
import type { AssistantMessage, Message, Session } from "@opencode-ai/sdk/v2/client" import type { AssistantMessage, Message } from "@opencode-ai/sdk/v2/client"
type Provider = { type Provider = {
id: string id: string
@ -21,6 +21,7 @@ type Context = {
modelLabel: string modelLabel: string
limit: number | undefined limit: number | undefined
input: number input: number
total: number
usage: number | null usage: number | null
} }
@ -54,6 +55,7 @@ const build = (messages: Message[] = [], providers: Provider[] = []): Context |
modelLabel: model?.name ?? message.modelID, modelLabel: model?.name ?? message.modelID,
limit, limit,
input: message.tokens.input, input: message.tokens.input,
total,
usage: limit ? Math.round((total / limit) * 100) : null, usage: limit ? Math.round((total / limit) * 100) : null,
} }
} }
@ -61,8 +63,3 @@ const build = (messages: Message[] = [], providers: Provider[] = []): Context |
export function getSessionContext(messages: Message[] = [], providers: Provider[] = []) { export function getSessionContext(messages: Message[] = [], providers: Provider[] = []) {
return build(messages, providers) return build(messages, providers)
} }
export function getSessionTokenTotal(tokens: Session["tokens"] | undefined) {
if (!tokens) return undefined
return tokens.input + tokens.output + tokens.reasoning + tokens.cache.read + tokens.cache.write
}

View file

@ -15,7 +15,7 @@ import { useLanguage } from "@/context/language"
import { useProviders } from "@/hooks/use-providers" import { useProviders } from "@/hooks/use-providers"
import { useSDK } from "@/context/sdk" import { useSDK } from "@/context/sdk"
import { useSessionLayout } from "@/pages/session/session-layout" import { useSessionLayout } from "@/pages/session/session-layout"
import { getSessionContext, getSessionTokenTotal } from "./session-context-metrics" import { getSessionContext } from "./session-context-metrics"
import { estimateSessionContextBreakdown, type SessionContextBreakdownKey } from "./session-context-breakdown" import { estimateSessionContextBreakdown, type SessionContextBreakdownKey } from "./session-context-breakdown"
import { createSessionContextFormatter } from "./session-context-format" import { createSessionContextFormatter } from "./session-context-format"
@ -135,7 +135,6 @@ export function SessionContextTab() {
) )
const ctx = createMemo(() => getSessionContext(messages(), [...providers.all().values()])) const ctx = createMemo(() => getSessionContext(messages(), [...providers.all().values()]))
const tokens = createMemo(() => info()?.tokens)
const formatter = createMemo(() => createSessionContextFormatter(language.intl())) const formatter = createMemo(() => createSessionContextFormatter(language.intl()))
const cost = createMemo(() => { const cost = createMemo(() => {
@ -204,14 +203,15 @@ export function SessionContextTab() {
{ label: "context.stats.provider", value: providerLabel }, { label: "context.stats.provider", value: providerLabel },
{ label: "context.stats.model", value: modelLabel }, { label: "context.stats.model", value: modelLabel },
{ label: "context.stats.limit", value: () => formatter().number(ctx()?.limit) }, { label: "context.stats.limit", value: () => formatter().number(ctx()?.limit) },
{ label: "context.stats.totalTokens", value: () => formatter().number(getSessionTokenTotal(tokens())) }, { label: "context.stats.totalTokens", value: () => formatter().number(ctx()?.total) },
{ label: "context.stats.usage", value: () => formatter().percent(ctx()?.usage) }, { label: "context.stats.usage", value: () => formatter().percent(ctx()?.usage) },
{ label: "context.stats.inputTokens", value: () => formatter().number(tokens()?.input) }, { label: "context.stats.inputTokens", value: () => formatter().number(ctx()?.input) },
{ label: "context.stats.outputTokens", value: () => formatter().number(tokens()?.output) }, { label: "context.stats.outputTokens", value: () => formatter().number(ctx()?.message.tokens.output) },
{ label: "context.stats.reasoningTokens", value: () => formatter().number(tokens()?.reasoning) }, { label: "context.stats.reasoningTokens", value: () => formatter().number(ctx()?.message.tokens.reasoning) },
{ {
label: "context.stats.cacheTokens", label: "context.stats.cacheTokens",
value: () => `${formatter().number(tokens()?.cache.read)} / ${formatter().number(tokens()?.cache.write)}`, value: () =>
`${formatter().number(ctx()?.message.tokens.cache.read)} / ${formatter().number(ctx()?.message.tokens.cache.write)}`,
}, },
{ label: "context.stats.userMessages", value: () => counts().user.toLocaleString(language.intl()) }, { label: "context.stats.userMessages", value: () => counts().user.toLocaleString(language.intl()) },
{ label: "context.stats.assistantMessages", value: () => counts().assistant.toLocaleString(language.intl()) }, { label: "context.stats.assistantMessages", value: () => counts().assistant.toLocaleString(language.intl()) },

View file

@ -211,7 +211,17 @@ export function SortableTerminalTabV2(props: {
<MenuV2.Context.Trigger class="relative" as="div"> <MenuV2.Context.Trigger class="relative" as="div">
<Tabs.Trigger <Tabs.Trigger
value={props.terminal.id} value={props.terminal.id}
onClick={focus} onMouseDown={(e) => {
// Switch on mousedown to shave the press-release delay off tab switches.
if (e.button !== 0) return
if (store.editing) return
focus()
}}
onClick={(e) => {
// Mouse navigation already happened on mousedown; detail 0 means keyboard activation.
if (e.detail > 0) return
focus()
}}
closeButton={ closeButton={
<IconButton <IconButton
icon="close-small" icon="close-small"

View file

@ -20,6 +20,11 @@
height: 100%; height: 100%;
overflow-y: auto; overflow-y: auto;
scrollbar-width: none; scrollbar-width: none;
user-select: none;
}
.settings-v2-panel :is(input, textarea, [contenteditable="true"]) {
user-select: text;
} }
.settings-v2-panel::-webkit-scrollbar { .settings-v2-panel::-webkit-scrollbar {
@ -181,6 +186,7 @@
flex-direction: column; flex-direction: column;
gap: 8px; gap: 8px;
padding: 4px 0 4px 4px; padding: 4px 0 4px 4px;
user-select: none;
} }
.settings-v2-nav-footer > span { .settings-v2-nav-footer > span {

View file

@ -1,7 +1,8 @@
import { withAlpha } from "@opencode-ai/ui/theme/color" import { withAlpha } from "@opencode-ai/ui/theme/color"
import { useTheme } from "@opencode-ai/ui/theme/context" import { useTheme } from "@opencode-ai/ui/theme/context"
import { resolveThemeVariant } from "@opencode-ai/ui/theme/resolve" import { resolveThemeVariant } from "@opencode-ai/ui/theme/resolve"
import type { HexColor } from "@opencode-ai/ui/theme/types" import { resolveThemeVariantV2 } from "@opencode-ai/ui/theme/v2/resolve"
import type { HexColor, ResolvedV2Theme } from "@opencode-ai/ui/theme/types"
import { showToast } from "@/utils/toast" import { showToast } from "@/utils/toast"
import type { FitAddon, Ghostty, Terminal as Term } from "ghostty-web" import type { FitAddon, Ghostty, Terminal as Term } from "ghostty-web"
import { type ComponentProps, createEffect, createMemo, onCleanup, onMount, splitProps } from "solid-js" import { type ComponentProps, createEffect, createMemo, onCleanup, onMount, splitProps } from "solid-js"
@ -68,6 +69,19 @@ const debugTerminal = (...values: unknown[]) => {
console.debug("[terminal]", ...values) console.debug("[terminal]", ...values)
} }
const resolveV2Token = (tokens: ResolvedV2Theme, key: string) => {
let current = tokens[key]
for (let i = 0; i < 8 && current; i++) {
const match = /^var\(--([^)]+)\)$/.exec(current.trim())
if (!match) {
const hex = current.trim()
if (/^#[0-9a-fA-F]{8}$/.test(hex)) return hex.slice(0, 7)
return hex
}
current = tokens[match[1]]
}
}
const useTerminalUiBindings = (input: { const useTerminalUiBindings = (input: {
container: HTMLDivElement container: HTMLDivElement
term: Term term: Term
@ -238,7 +252,10 @@ export const Terminal = (props: TerminalProps) => {
if (!variant?.seeds && !variant?.palette) return fallback if (!variant?.seeds && !variant?.palette) return fallback
const resolved = resolveThemeVariant(variant, mode === "dark") const resolved = resolveThemeVariant(variant, mode === "dark")
const text = resolved["text-stronger"] ?? fallback.foreground const text = resolved["text-stronger"] ?? fallback.foreground
const background = resolved["background-stronger"] ?? fallback.background const background = settings.general.newLayoutDesigns()
? (resolveV2Token(resolveThemeVariantV2(variant, mode === "dark"), "v2-background-bg-base") ??
fallback.background)
: (resolved["background-stronger"] ?? fallback.background)
const alpha = mode === "dark" ? 0.25 : 0.2 const alpha = mode === "dark" ? 0.25 : 0.2
const base = text.startsWith("#") ? (text as HexColor) : (fallback.foreground as HexColor) const base = text.startsWith("#") ? (text as HexColor) : (fallback.foreground as HexColor)
const selectionBackground = withAlpha(base, alpha) const selectionBackground = withAlpha(base, alpha)

View file

@ -25,6 +25,7 @@ import { readSessionTabsRemovedDetail, SESSION_TABS_REMOVED_EVENT } from "@/comp
import { useGlobal } from "@/context/global" import { useGlobal } from "@/context/global"
import { ServerConnection, useServer } from "@/context/server" import { ServerConnection, useServer } from "@/context/server"
import { tabKey, useTabs } from "@/context/tabs" import { tabKey, useTabs } from "@/context/tabs"
import type { PromptSession } from "@/context/prompt"
import "./titlebar.css" import "./titlebar.css"
import { newTabTooltipKeybind } from "./command-tooltip-keybind" import { newTabTooltipKeybind } from "./command-tooltip-keybind"
@ -324,13 +325,20 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
const route = layout.route() const route = layout.route()
const activeSession = session() const activeSession = session()
if (route.type === "session" && activeSession) { if (route.type === "session" && activeSession) {
tabs.newDraft({ server: route.server ?? server.key, directory: activeSession.directory }, "") const sessionTab = {
type: "session" as const,
server: route.server ?? server.key,
sessionId: activeSession.id,
}
const model = tabs.stateValue<PromptSession>(sessionTab, "prompt")?.model.current()
tabs.newDraft({ server: sessionTab.server, directory: activeSession.directory }, "", model)
return return
} }
const activeTab = currentTab() const activeTab = currentTab()
if (activeTab?.type === "draft") { if (activeTab?.type === "draft") {
tabs.newDraft({ server: activeTab.server, directory: activeTab.directory }, "") const model = tabs.stateValue<PromptSession>(activeTab, "prompt")?.model.current()
tabs.newDraft({ server: activeTab.server, directory: activeTab.directory }, "", model)
return return
} }

View file

@ -67,7 +67,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
const list = createMemo(() => sync().data.agent.filter((item) => item.mode !== "subagent" && !item.hidden)) const list = createMemo(() => sync().data.agent.filter((item) => item.mode !== "subagent" && !item.hidden))
const connected = createMemo(() => new Set(providers.connected().map((item) => item.id))) const connected = createMemo(() => new Set(providers.connected().map((item) => item.id)))
const [saved, setSaved] = persisted( const [saved, setSaved, , savedReady] = persisted(
{ {
...Persist.serverWorkspace(serverSDK().scope, sdk().directory, "model-selection", ["model-selection.v1"]), ...Persist.serverWorkspace(serverSDK().scope, sdk().directory, "model-selection", ["model-selection.v1"]),
migrate, migrate,
@ -375,11 +375,12 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
model, model,
agent, agent,
session: { session: {
ready: savedReady,
reset() { reset() {
setStore({ draft: undefined, promoting: undefined }) setStore({ draft: undefined, promoting: undefined })
}, },
promote(dir: string, session: string) { promote(dir: string, session: string, state?: State) {
const next = clone(snapshot()) const next = clone(state ?? snapshot())
if (!next) return if (!next) return
const key = handoffKey(serverSDK().scope, dir, session) const key = handoffKey(serverSDK().scope, dir, session)
handoff.set(key, next) handoff.set(key, next)
@ -409,3 +410,5 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
return result return result
}, },
}) })
export type ModelSelection = ReturnType<typeof useLocal>["model"]

View file

@ -0,0 +1,29 @@
import { describe, expect, test } from "bun:test"
import { createRoot } from "solid-js"
import { createPromptState, DEFAULT_PROMPT } from "./prompt-state"
describe("prompt state initialization", () => {
test("initializes prompt text, cursor, and model together", () => {
createRoot((dispose) => {
const model = { providerID: "anthropic", modelID: "claude", variant: "high" }
const prompt = createPromptState({ prompt: "hello", model })
expect(prompt.current()).toEqual([{ type: "text", content: "hello", start: 0, end: 5 }])
expect(prompt.cursor()).toBe(5)
expect(prompt.model.current()).toEqual(model)
expect(prompt.model.current()).not.toBe(model)
dispose()
})
})
test("uses the default prompt without initial values", () => {
createRoot((dispose) => {
const prompt = createPromptState()
expect(prompt.current()).toEqual(DEFAULT_PROMPT)
expect(prompt.cursor()).toBeUndefined()
expect(prompt.model.current()).toBeUndefined()
dispose()
})
})
})

View file

@ -0,0 +1,265 @@
import { checksum } from "@opencode-ai/core/util/encode"
import type { FilePartSource } from "@opencode-ai/sdk/v2/client"
import { batch, createMemo, type Accessor } from "solid-js"
import { createStore, type SetStoreFunction } from "solid-js/store"
import type { FileSelection } from "@/context/file"
import { Persist, persisted } from "@/utils/persist"
import type { ServerScope } from "@/utils/server-scope"
interface PartBase {
content: string
start: number
end: number
}
export interface TextPart extends PartBase {
type: "text"
}
export interface FileAttachmentPart extends PartBase {
type: "file"
path: string
selection?: FileSelection
mime?: string
filename?: string
url?: string
source?: FilePartSource
}
export interface AgentPart extends PartBase {
type: "agent"
name: string
}
export interface ImageAttachmentPart {
type: "image"
id: string
filename: string
sourcePath?: string
mime: string
dataUrl: string
}
export type ContentPart = TextPart | FileAttachmentPart | AgentPart | ImageAttachmentPart
export type Prompt = ContentPart[]
export type PromptModel = {
providerID: string
modelID: string
variant?: string | null
}
export type FileContextItem = {
type: "file"
path: string
selection?: FileSelection
comment?: string
commentID?: string
commentOrigin?: "review" | "file"
preview?: string
}
export type ContextItem = FileContextItem
export type PromptScope = { draftID: string } | { dir: string; id?: string }
export const DEFAULT_PROMPT: Prompt = [{ type: "text", content: "", start: 0, end: 0 }]
type PromptStore = {
prompt: Prompt
cursor?: number
model?: PromptModel
context: {
items: (ContextItem & { key: string })[]
}
}
type InitialPrompt = {
prompt?: string
model?: PromptModel
}
function isSelectionEqual(a?: FileSelection, b?: FileSelection) {
if (!a && !b) return true
if (!a || !b) return false
return (
a.startLine === b.startLine && a.startChar === b.startChar && a.endLine === b.endLine && a.endChar === b.endChar
)
}
function isPartEqual(partA: ContentPart, partB: ContentPart) {
switch (partA.type) {
case "text":
return partB.type === "text" && partA.content === partB.content
case "file":
return (
partB.type === "file" &&
partA.path === partB.path &&
partA.mime === partB.mime &&
partA.filename === partB.filename &&
isSelectionEqual(partA.selection, partB.selection)
)
case "agent":
return partB.type === "agent" && partA.name === partB.name
case "image":
return partB.type === "image" && partA.id === partB.id
}
}
export function isPromptEqual(promptA: Prompt, promptB: Prompt): boolean {
if (promptA.length !== promptB.length) return false
for (let i = 0; i < promptA.length; i++) {
if (!isPartEqual(promptA[i], promptB[i])) return false
}
return true
}
function cloneSelection(selection?: FileSelection) {
if (!selection) return undefined
return { ...selection }
}
function clonePart(part: ContentPart): ContentPart {
if (part.type === "text") return { ...part }
if (part.type === "image") return { ...part }
if (part.type === "agent") return { ...part }
return {
...part,
selection: cloneSelection(part.selection),
}
}
function clonePrompt(prompt: Prompt): Prompt {
return prompt.map(clonePart)
}
function contextItemKey(item: ContextItem) {
if (item.type !== "file") return item.type
const start = item.selection?.startLine
const end = item.selection?.endLine
const key = `${item.type}:${item.path}:${start}:${end}`
if (item.commentID) return `${key}:c=${item.commentID}`
const comment = item.comment?.trim()
if (!comment) return key
const digest = checksum(comment) ?? comment
return `${key}:c=${digest.slice(0, 8)}`
}
function isCommentItem(item: ContextItem | (ContextItem & { key: string })) {
return item.type === "file" && !!item.comment?.trim()
}
function createPromptActions(setStore: SetStoreFunction<PromptStore>) {
return {
set(prompt: Prompt, cursorPosition?: number) {
const next = clonePrompt(prompt)
batch(() => {
setStore("prompt", next)
if (cursorPosition !== undefined) setStore("cursor", cursorPosition)
})
},
reset() {
batch(() => {
setStore("prompt", clonePrompt(DEFAULT_PROMPT))
setStore("cursor", 0)
})
},
}
}
function promptTarget(serverScope: ServerScope, scope: PromptScope) {
if ("draftID" in scope) return Persist.draft(scope.draftID, "prompt")
const legacy = `${scope.dir}/prompt${scope.id ? "/" + scope.id : ""}.v2`
return Persist.serverScoped(serverScope, scope.dir, scope.id, "prompt", [legacy])
}
function promptStore(initial?: InitialPrompt): PromptStore {
const text = initial?.prompt
return {
prompt:
text === undefined ? clonePrompt(DEFAULT_PROMPT) : [{ type: "text", content: text, start: 0, end: text.length }],
cursor: text === undefined ? undefined : text.length,
model: initial?.model ? { ...initial.model } : undefined,
context: {
items: [],
},
}
}
function createPromptStateValue(store: PromptStore, setStore: SetStoreFunction<PromptStore>) {
const actions = createPromptActions(setStore)
const value = {
current: () => store.prompt,
cursor: createMemo(() => store.cursor),
dirty: () => !isPromptEqual(store.prompt, DEFAULT_PROMPT),
model: {
current: () => store.model,
set: (model: PromptModel | undefined) => setStore("model", model),
},
context: {
items: createMemo(() => store.context.items),
add(item: ContextItem) {
const key = contextItemKey(item)
if (store.context.items.find((x) => x.key === key)) return
setStore("context", "items", (items) => [...items, { key, ...item }])
},
remove(key: string) {
setStore("context", "items", (items) => items.filter((x) => x.key !== key))
},
removeComment(path: string, commentID: string) {
setStore("context", "items", (items) =>
items.filter((item) => !(item.type === "file" && item.path === path && item.commentID === commentID)),
)
},
updateComment(path: string, commentID: string, next: Partial<FileContextItem> & { comment?: string }) {
setStore("context", "items", (items) =>
items.map((item) => {
if (item.type !== "file" || item.path !== path || item.commentID !== commentID) return item
const value = { ...item, ...next }
return { ...value, key: contextItemKey(value) }
}),
)
},
replaceComments(items: FileContextItem[]) {
setStore("context", "items", (current) => [
...current.filter((item) => !isCommentItem(item)),
...items.map((item) => ({ ...item, key: contextItemKey(item) })),
])
},
},
set: actions.set,
reset: actions.reset,
capture: () => value,
}
return value
}
function createPersistedPrompt(target: ReturnType<typeof promptTarget>, initial?: InitialPrompt) {
const [store, setStore, _, ready] = persisted(target, createStore<PromptStore>(promptStore(initial)))
return { ready, ...createPromptStateValue(store, setStore) }
}
export function createPromptSession(serverScope: ServerScope, scope: PromptScope, initial?: InitialPrompt) {
return createPersistedPrompt(promptTarget(serverScope, scope), initial)
}
export function createDraftPromptSession(draftID: string, initial?: InitialPrompt) {
return createPersistedPrompt(Persist.draft(draftID, "prompt"), initial)
}
export type PromptSession = ReturnType<typeof createPromptSession>
export function createPromptReady(session: Accessor<PromptSession>) {
return Object.defineProperty(() => session().ready(), "promise", {
get: () => session().ready.promise,
}) as (() => boolean) & { readonly promise: Promise<unknown> | undefined }
}
export function createPromptState(initial?: InitialPrompt) {
const [store, setStore] = createStore<PromptStore>(promptStore(initial))
const ready = Object.assign(() => true, { promise: Promise.resolve(true) })
return {
ready,
...createPromptStateValue(store, setStore),
}
}

View file

@ -1,186 +1,49 @@
import { base64Encode } from "@opencode-ai/core/util/encode"
import { createSimpleContext } from "@opencode-ai/ui/context" import { createSimpleContext } from "@opencode-ai/ui/context"
import { base64Encode, checksum } from "@opencode-ai/core/util/encode"
import { useParams, useSearchParams } from "@solidjs/router" import { useParams, useSearchParams } from "@solidjs/router"
import { batch, createMemo, createRoot, getOwner, onCleanup, type Accessor } from "solid-js" import { createMemo, createRoot, getOwner, onCleanup } from "solid-js"
import { createStore, type SetStoreFunction } from "solid-js/store" import { requireServerKey } from "@/utils/session-route"
import type { FileSelection } from "@/context/file" import { ServerConnection } from "./server"
import { Persist, persisted } from "@/utils/persist"
import { useServerSDK } from "./server-sdk" import { useServerSDK } from "./server-sdk"
import type { ServerScope } from "@/utils/server-scope" import { useSettings } from "./settings"
import { useSDK } from "./sdk" import { useSDK } from "./sdk"
import { useTabs, type Tab } from "./tabs" import { useTabs, type Tab } from "./tabs"
import { ServerConnection } from "./server" import {
import { requireServerKey } from "@/utils/session-route" createPromptReady,
import { useSettings } from "./settings" createPromptSession,
import type { FilePartSource } from "@opencode-ai/sdk/v2/client" type ContextItem,
type FileContextItem,
type Prompt,
type PromptModel,
type PromptScope,
type PromptSession,
} from "./prompt-state"
interface PartBase { export {
content: string createPromptReady,
start: number createPromptSession,
end: number createPromptState,
} DEFAULT_PROMPT,
isPromptEqual,
export interface TextPart extends PartBase { } from "./prompt-state"
type: "text" export type {
} AgentPart,
ContentPart,
export interface FileAttachmentPart extends PartBase { ContextItem,
type: "file" FileAttachmentPart,
path: string FileContextItem,
selection?: FileSelection ImageAttachmentPart,
mime?: string Prompt,
filename?: string PromptModel,
url?: string PromptScope,
source?: FilePartSource PromptSession,
} TextPart,
} from "./prompt-state"
export interface AgentPart extends PartBase {
type: "agent"
name: string
}
export interface ImageAttachmentPart {
type: "image"
id: string
filename: string
sourcePath?: string
mime: string
dataUrl: string
}
export type ContentPart = TextPart | FileAttachmentPart | AgentPart | ImageAttachmentPart
export type Prompt = ContentPart[]
export type FileContextItem = {
type: "file"
path: string
selection?: FileSelection
comment?: string
commentID?: string
commentOrigin?: "review" | "file"
preview?: string
}
export type ContextItem = FileContextItem
export const DEFAULT_PROMPT: Prompt = [{ type: "text", content: "", start: 0, end: 0 }]
function isSelectionEqual(a?: FileSelection, b?: FileSelection) {
if (!a && !b) return true
if (!a || !b) return false
return (
a.startLine === b.startLine && a.startChar === b.startChar && a.endLine === b.endLine && a.endChar === b.endChar
)
}
function isPartEqual(partA: ContentPart, partB: ContentPart) {
switch (partA.type) {
case "text":
return partB.type === "text" && partA.content === partB.content
case "file":
return (
partB.type === "file" &&
partA.path === partB.path &&
partA.mime === partB.mime &&
partA.filename === partB.filename &&
isSelectionEqual(partA.selection, partB.selection)
)
case "agent":
return partB.type === "agent" && partA.name === partB.name
case "image":
return partB.type === "image" && partA.id === partB.id
}
}
export function isPromptEqual(promptA: Prompt, promptB: Prompt): boolean {
if (promptA.length !== promptB.length) return false
for (let i = 0; i < promptA.length; i++) {
if (!isPartEqual(promptA[i], promptB[i])) return false
}
return true
}
function cloneSelection(selection?: FileSelection) {
if (!selection) return undefined
return { ...selection }
}
function clonePart(part: ContentPart): ContentPart {
if (part.type === "text") return { ...part }
if (part.type === "image") return { ...part }
if (part.type === "agent") return { ...part }
return {
...part,
selection: cloneSelection(part.selection),
}
}
function clonePrompt(prompt: Prompt): Prompt {
return prompt.map(clonePart)
}
function contextItemKey(item: ContextItem) {
if (item.type !== "file") return item.type
const start = item.selection?.startLine
const end = item.selection?.endLine
const key = `${item.type}:${item.path}:${start}:${end}`
if (item.commentID) {
return `${key}:c=${item.commentID}`
}
const comment = item.comment?.trim()
if (!comment) return key
const digest = checksum(comment) ?? comment
return `${key}:c=${digest.slice(0, 8)}`
}
function isCommentItem(item: ContextItem | (ContextItem & { key: string })) {
return item.type === "file" && !!item.comment?.trim()
}
function createPromptActions(
setStore: SetStoreFunction<{
prompt: Prompt
cursor?: number
context: {
items: (ContextItem & { key: string })[]
}
}>,
) {
return {
set(prompt: Prompt, cursorPosition?: number) {
const next = clonePrompt(prompt)
batch(() => {
setStore("prompt", next)
if (cursorPosition !== undefined) setStore("cursor", cursorPosition)
})
},
reset() {
batch(() => {
setStore("prompt", clonePrompt(DEFAULT_PROMPT))
setStore("cursor", 0)
})
},
}
}
const WORKSPACE_KEY = "__workspace__" const WORKSPACE_KEY = "__workspace__"
const MAX_PROMPT_SESSIONS = 20 const MAX_PROMPT_SESSIONS = 20
type PromptSession = ReturnType<typeof createPromptSession> export function selectPromptTab(tabs: Tab[], scope: PromptScope, server: ServerConnection.Key) {
type PromptStore = {
prompt: Prompt
cursor?: number
context: {
items: (ContextItem & { key: string })[]
}
}
type Scope = { draftID: string } | { dir: string; id?: string }
export function selectPromptTab(tabs: Tab[], scope: Scope, server: ServerConnection.Key) {
if ("draftID" in scope) return tabs.find((tab) => tab.type === "draft" && tab.draftID === scope.draftID) if ("draftID" in scope) return tabs.find((tab) => tab.type === "draft" && tab.draftID === scope.draftID)
if (!scope.id) return if (!scope.id) return
return ( return (
@ -189,7 +52,7 @@ export function selectPromptTab(tabs: Tab[], scope: Scope, server: ServerConnect
) )
} }
function scopeKey(scope: Scope) { function scopeKey(scope: PromptScope) {
if ("draftID" in scope) return `draft:${scope.draftID}` if ("draftID" in scope) return `draft:${scope.draftID}`
return `${scope.dir}:${scope.id ?? WORKSPACE_KEY}` return `${scope.dir}:${scope.id ?? WORKSPACE_KEY}`
} }
@ -199,91 +62,6 @@ type PromptCacheEntry = {
dispose: VoidFunction dispose: VoidFunction
} }
function promptTarget(serverScope: ServerScope, scope: Scope) {
if ("draftID" in scope) return Persist.draft(scope.draftID, "prompt")
const legacy = `${scope.dir}/prompt${scope.id ? "/" + scope.id : ""}.v2`
return Persist.serverScoped(serverScope, scope.dir, scope.id, "prompt", [legacy])
}
export function createPromptSession(serverScope: ServerScope, scope: Scope) {
const [store, setStore, _, ready] = persisted(
promptTarget(serverScope, scope),
createStore<PromptStore>(promptStore()),
)
return { ready, ...createPromptStateValue(store, setStore) }
}
export function createPromptReady(session: Accessor<PromptSession>) {
return Object.defineProperty(() => session().ready(), "promise", {
get: () => session().ready.promise,
}) as (() => boolean) & { readonly promise: Promise<unknown> | undefined }
}
function promptStore(): PromptStore {
return {
prompt: clonePrompt(DEFAULT_PROMPT),
cursor: undefined,
context: {
items: [],
},
}
}
function createPromptStateValue(store: PromptStore, setStore: SetStoreFunction<PromptStore>) {
const actions = createPromptActions(setStore)
const value = {
current: () => store.prompt,
cursor: createMemo(() => store.cursor),
dirty: () => !isPromptEqual(store.prompt, DEFAULT_PROMPT),
context: {
items: createMemo(() => store.context.items),
add(item: ContextItem) {
const key = contextItemKey(item)
if (store.context.items.find((x) => x.key === key)) return
setStore("context", "items", (items) => [...items, { key, ...item }])
},
remove(key: string) {
setStore("context", "items", (items) => items.filter((x) => x.key !== key))
},
removeComment(path: string, commentID: string) {
setStore("context", "items", (items) =>
items.filter((item) => !(item.type === "file" && item.path === path && item.commentID === commentID)),
)
},
updateComment(path: string, commentID: string, next: Partial<FileContextItem> & { comment?: string }) {
setStore("context", "items", (items) =>
items.map((item) => {
if (item.type !== "file" || item.path !== path || item.commentID !== commentID) return item
const value = { ...item, ...next }
return { ...value, key: contextItemKey(value) }
}),
)
},
replaceComments(items: FileContextItem[]) {
setStore("context", "items", (current) => [
...current.filter((item) => !isCommentItem(item)),
...items.map((item) => ({ ...item, key: contextItemKey(item) })),
])
},
},
set: actions.set,
reset: actions.reset,
capture: () => value,
}
return value
}
export function createPromptState() {
const [store, setStore] = createStore<PromptStore>(promptStore())
const ready = Object.assign(() => true, { promise: Promise.resolve(true) })
return {
ready,
...createPromptStateValue(store, setStore),
}
}
export const createTabPromptState = ( export const createTabPromptState = (
tabs: ReturnType<typeof useTabs>, tabs: ReturnType<typeof useTabs>,
tab: Tab, tab: Tab,
@ -303,9 +81,7 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext(
const cache = new Map<string, PromptCacheEntry>() const cache = new Map<string, PromptCacheEntry>()
const disposeAll = () => { const disposeAll = () => {
for (const entry of cache.values()) { for (const entry of cache.values()) entry.dispose()
entry.dispose()
}
cache.clear() cache.clear()
} }
@ -324,13 +100,11 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext(
const owner = getOwner() const owner = getOwner()
const serverKey = () => const serverKey = () =>
params.serverKey ? requireServerKey(params.serverKey) : ServerConnection.key(serverSDK().server) params.serverKey ? requireServerKey(params.serverKey) : ServerConnection.key(serverSDK().server)
const scope = () => const scope = (): PromptScope =>
search.draftId ? { draftID: search.draftId } : { dir: base64Encode(sdk().directory), id: params.id } search.draftId ? { draftID: search.draftId } : { dir: base64Encode(sdk().directory), id: params.id }
const load = (scope: Scope) => { const load = (scope: PromptScope) => {
const current = settings.general.newLayoutDesigns() ? selectPromptTab(tabs.store, scope, serverKey()) : undefined const current = settings.general.newLayoutDesigns() ? selectPromptTab(tabs.store, scope, serverKey()) : undefined
if (current) { if (current) return createTabPromptState(tabs, current, serverSDK().scope, scope)
return createTabPromptState(tabs, current, serverSDK().scope, scope)
}
const key = scopeKey(scope) const key = scopeKey(scope)
const existing = cache.get(key) const existing = cache.get(key)
@ -354,15 +128,19 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext(
} }
const session = createMemo(() => load(scope())) const session = createMemo(() => load(scope()))
const pick = (scope?: Scope) => (scope ? load(scope) : session()) const pick = (scope?: PromptScope) => (scope ? load(scope) : session())
const ready = createPromptReady(session) const ready = createPromptReady(session)
return { return {
ready, ready,
capture: (scope?: Scope) => pick(scope).capture(), capture: (scope?: PromptScope) => pick(scope).capture(),
current: () => session().current(), current: () => session().current(),
cursor: () => session().cursor(), cursor: () => session().cursor(),
dirty: () => session().dirty(), dirty: () => session().dirty(),
model: {
current: () => session().model.current(),
set: (model: PromptModel | undefined) => session().model.set(model),
},
context: { context: {
items: () => session().context.items(), items: () => session().context.items(),
add: (item: ContextItem) => session().context.add(item), add: (item: ContextItem) => session().context.add(item),
@ -372,8 +150,8 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext(
session().context.updateComment(path, commentID, next), session().context.updateComment(path, commentID, next),
replaceComments: (items: FileContextItem[]) => session().context.replaceComments(items), replaceComments: (items: FileContextItem[]) => session().context.replaceComments(items),
}, },
set: (prompt: Prompt, cursorPosition?: number, scope?: Scope) => pick(scope).set(prompt, cursorPosition), set: (prompt: Prompt, cursorPosition?: number, scope?: PromptScope) => pick(scope).set(prompt, cursorPosition),
reset: (scope?: Scope) => pick(scope).reset(), reset: (scope?: PromptScope) => pick(scope).reset(),
} }
}, },
}) })

View file

@ -16,6 +16,9 @@ export function createTabMemory(owner: Owner | null) {
} }
return { return {
get<T>(key: string, name: string) {
return entries.get(key)?.get(name)?.value as T | undefined
},
ensure<T>(key: string, name: string, init: () => T) { ensure<T>(key: string, name: string, init: () => T) {
const state = entries.get(key) ?? new Map<string, Entry>() const state = entries.get(key) ?? new Map<string, Entry>()
if (!entries.has(key)) entries.set(key, state) if (!entries.has(key)) entries.set(key, state)

View file

@ -22,6 +22,8 @@ describe("tab memory", () => {
}) })
expect(memory.ensure("tab", "prompt", () => ({ value: "other" }))).toBe(first) expect(memory.ensure("tab", "prompt", () => ({ value: "other" }))).toBe(first)
expect(memory.get<typeof first>("tab", "prompt")).toBe(first)
expect(memory.get("missing", "prompt")).toBeUndefined()
expect(memory.ensure("other", "prompt", () => ({ value: "other" }))).not.toBe(first) expect(memory.ensure("other", "prompt", () => ({ value: "other" }))).not.toBe(first)
memory.remove("tab") memory.remove("tab")

View file

@ -11,6 +11,7 @@ import { SessionTabsRemovedDetail } from "@/components/titlebar-session-events"
import { sessionHref } from "@/utils/session-route" import { sessionHref } from "@/utils/session-route"
import { createTabMemory } from "./tab-memory" import { createTabMemory } from "./tab-memory"
import { nextTabAfterClose, pushClosedTab, removeClosedTabs, takeClosedTab, type ClosedTab } from "./closed-tabs" import { nextTabAfterClose, pushClosedTab, removeClosedTabs, takeClosedTab, type ClosedTab } from "./closed-tabs"
import { createDraftPromptSession, type PromptModel } from "./prompt-state"
export type SessionTab = { export type SessionTab = {
type: "session" type: "session"
@ -207,15 +208,17 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
if (!tab || tab.type !== "draft") throw new Error(`Draft not found: ${draftID}`) if (!tab || tab.type !== "draft") throw new Error(`Draft not found: ${draftID}`)
return tab return tab
}, },
newDraft(draft: Omit<DraftTab, "type" | "draftID">, prompt?: string) { newDraft(draft: Omit<DraftTab, "type" | "draftID">, prompt?: string, model?: PromptModel) {
const draftID = uuid() const draftID = uuid()
const tab = { type: "draft" as const, draftID, ...draft }
memory.ensure(tabKey(tab), "prompt", () => createDraftPromptSession(draftID, { prompt, model }))
void startTransition(() => { void startTransition(() => {
setStore( setStore(
produce((tabs) => { produce((tabs) => {
tabs.push({ type: "draft", draftID, ...draft }) tabs.push(tab)
}), }),
) )
navigate(prompt ? `${draftHref(draftID)}&prompt=${encodeURIComponent(prompt)}` : draftHref(draftID)) navigate(draftHref(draftID))
}) })
}, },
updateDraft(draftID: string, draft: Partial<Omit<DraftTab, "type" | "draftID">>) { updateDraft(draftID: string, draft: Partial<Omit<DraftTab, "type" | "draftID">>) {
@ -373,6 +376,9 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
state<T>(tab: Tab, name: string, init: () => T) { state<T>(tab: Tab, name: string, init: () => T) {
return memory.ensure(tabKey(tab), name, init) return memory.ensure(tabKey(tab), name, init)
}, },
stateValue<T>(tab: Tab, name: string) {
return memory.get<T>(tabKey(tab), name)
},
} }
return { ...actions, store, info, ready, recentReady } return { ...actions, store, info, ready, recentReady }

View file

@ -26,10 +26,13 @@ import { useComposerCommands } from "@/pages/session/use-composer-commands"
import { NEW_SESSION_CONTENT_WIDTH } from "@/pages/session/new-session-layout" import { NEW_SESSION_CONTENT_WIDTH } from "@/pages/session/new-session-layout"
import { PromptWorkspaceSelector } from "@/components/prompt-workspace-selector" import { PromptWorkspaceSelector } from "@/components/prompt-workspace-selector"
import { useTitlebarRightMount } from "@/components/titlebar" import { useTitlebarRightMount } from "@/components/titlebar"
import { useCommand } from "@/context/command"
import { useProviders } from "@/hooks/use-providers" import { useProviders } from "@/hooks/use-providers"
import { useSettingsDialog } from "@/components/settings-dialog" import { useSettingsDialog } from "@/components/settings-dialog"
import { Persist, persisted } from "@/utils/persist" import { Persist, persisted } from "@/utils/persist"
import createPresence from "solid-presence" import createPresence from "solid-presence"
import { useLocal } from "@/context/local"
import { createPromptModelSelection } from "@/pages/session/composer/prompt-model-selection"
const workspaceBarEnabled = import.meta.env.VITE_OPENCODE_CHANNEL !== "prod" const workspaceBarEnabled = import.meta.env.VITE_OPENCODE_CHANNEL !== "prod"
const providerTipDismissalDuration = 30 * 24 * 60 * 60 * 1000 const providerTipDismissalDuration = 30 * 24 * 60 * 60 * 1000
@ -48,12 +51,15 @@ export default function NewSessionPage() {
const comments = useComments() const comments = useComments()
const language = useLanguage() const language = useLanguage()
const settings = useSettings() const settings = useSettings()
const command = useCommand()
const providers = useProviders(() => sdk().directory) const providers = useProviders(() => sdk().directory)
const openProviderSettings = useSettingsDialog("providers") const openProviderSettings = useSettingsDialog("providers")
const route = useSessionKey() const route = useSessionKey()
const [searchParams, setSearchParams] = useSearchParams<{ draftId?: string; prompt?: string }>() const [searchParams, setSearchParams] = useSearchParams<{ draftId?: string; prompt?: string }>()
const local = useLocal()
const model = createPromptModelSelection({ agent: local.agent.current })
useComposerCommands() useComposerCommands({ model })
let inputRef: HTMLDivElement | undefined let inputRef: HTMLDivElement | undefined
@ -61,6 +67,7 @@ export default function NewSessionPage() {
sessionKey: route.sessionKey, sessionKey: route.sessionKey,
sessionID: () => route.params.id, sessionID: () => route.params.id,
queryOptions: serverSync().queryOptions, queryOptions: serverSync().queryOptions,
model,
}) })
const projectControls = createPromptProjectControls() const projectControls = createPromptProjectControls()
const projectController = createPromptProjectController({ const projectController = createPromptProjectController({
@ -68,6 +75,16 @@ export default function NewSessionPage() {
onDone: () => inputRef?.focus(), onDone: () => inputRef?.focus(),
}) })
command.register("new-session", () => [
{
id: "input.focus",
title: language.t("command.input.focus"),
category: language.t("command.category.view"),
keybind: "ctrl+l",
onSelect: () => inputRef?.focus(),
},
])
const [store, setStore] = createStore<{ worktree?: string }>({}) const [store, setStore] = createStore<{ worktree?: string }>({})
const rightMount = useTitlebarRightMount() const rightMount = useTitlebarRightMount()

View file

@ -69,7 +69,7 @@ import { MessageTimeline } from "@/pages/session/timeline/message-timeline"
import { createTimelineModel } from "@/pages/session/timeline/model" import { createTimelineModel } from "@/pages/session/timeline/model"
import { type DiffStyle, SessionReviewTab, type SessionReviewTabProps } from "@/pages/session/review-tab" import { type DiffStyle, SessionReviewTab, type SessionReviewTabProps } from "@/pages/session/review-tab"
import { useSessionLayout } from "@/pages/session/session-layout" import { useSessionLayout } from "@/pages/session/session-layout"
import { syncSessionModel } from "@/pages/session/session-model-helpers" import { restorePromptModel, syncPromptModel, syncSessionModel } from "@/pages/session/session-model-helpers"
import { import {
clampSessionPanelWidth, clampSessionPanelWidth,
SESSION_PANEL_WIDTH_MIN, SESSION_PANEL_WIDTH_MIN,
@ -483,7 +483,7 @@ export default function Page() {
if (desktopSessionResizeOpen()) return `${sessionPanelResizedWidth()}px` if (desktopSessionResizeOpen()) return `${sessionPanelResizedWidth()}px`
return `calc(100% - ${layout.fileTree.width()}px)` return `calc(100% - ${layout.fileTree.width()}px)`
}) })
const centered = createMemo(() => isDesktop() && !desktopReviewOpen()) const centered = createMemo(() => isDesktop() && (newSessionDesign() || !desktopReviewOpen()))
const desktopV2PanelLayout = createMemo(() => const desktopV2PanelLayout = createMemo(() =>
sessionPanelLayout({ sessionPanelLayout({
review: desktopV2ReviewOpen(), review: desktopV2ReviewOpen(),
@ -557,6 +557,17 @@ export default function Page() {
), ),
) )
let restoredModelSession: string | undefined
createEffect(() => {
const id = params.id
if (!id || !prompt.ready() || !local.session.ready()) return
if (restoredModelSession !== id) {
restoredModelSession = id
if (restorePromptModel(local, prompt)) return
}
syncPromptModel(local, prompt)
})
createEffect( createEffect(
on( on(
() => ({ dir: sdk().directory, id: params.id }), () => ({ dir: sdk().directory, id: params.id }),
@ -1267,7 +1278,7 @@ export default function Page() {
const reviewPanelV2Rendered = createMemo<boolean>((prev) => prev || !store.deferRender, false) const reviewPanelV2Rendered = createMemo<boolean>((prev) => prev || !store.deferRender, false)
const reviewPanelV2 = () => ( const reviewPanelV2 = () => (
<div class="flex flex-col h-full overflow-hidden bg-background-stronger contain-strict"> <div class="flex flex-col h-full overflow-hidden bg-v2-background-bg-base contain-strict">
<Show when={reviewPanelV2Rendered()}> <Show when={reviewPanelV2Rendered()}>
<ReviewPanelV2 {...reviewPanelV2Props()} /> <ReviewPanelV2 {...reviewPanelV2Props()} />
</Show> </Show>

View file

@ -0,0 +1,133 @@
import { batch, createMemo, startTransition } from "solid-js"
import { useModels } from "@/context/models"
import type { ModelKey, ModelSelection } from "@/context/local"
import { cycleModelVariant, getConfiguredAgentVariant, resolveModelVariant } from "@/context/model-variant"
import { usePrompt } from "@/context/prompt"
import { useSDK } from "@/context/sdk"
import { useSync } from "@/context/sync"
import { useProviders } from "@/hooks/use-providers"
export function createPromptModelSelection(input: { agent: () => { model?: ModelKey; variant?: string } | undefined }) {
const sdk = useSDK()
const sync = useSync()
const models = useModels()
const prompt = usePrompt()
const providers = useProviders(() => sdk().directory)
const connected = createMemo(() => new Set(providers.connected().map((item) => item.id)))
const valid = (model: ModelKey) => {
const provider = providers.all().get(model.providerID)
return !!provider?.models[model.modelID] && connected().has(model.providerID)
}
const configured = () => {
const value = sync().data.config.model
if (!value) return
const [providerID, modelID] = value.split("/")
const model = { providerID, modelID }
if (valid(model)) return model
}
const recent = () => models.recent.list().find(valid)
const fallback = () => {
const defaults = providers.default()
return providers.connected().flatMap((provider) => {
const modelID = defaults[provider.id] ?? Object.values(provider.models)[0]?.id
return modelID ? [{ providerID: provider.id, modelID }] : []
})[0]
}
const current = () => {
const key = [prompt.model.current(), input.agent()?.model, configured(), recent(), fallback()].find(
(item): item is ModelKey => !!item && valid(item),
)
if (!key) return
return models.find(key)
}
const recentModels = createMemo(() =>
models.recent
.list()
.map(models.find)
.filter((item): item is NonNullable<typeof item> => !!item),
)
const selection = {
ready: models.ready,
current,
recent: recentModels,
list: models.list,
cycle(direction: 1 | -1) {
const items = recentModels()
const item = current()
if (!item) return
const index = items.findIndex((entry) => entry.provider.id === item.provider.id && entry.id === item.id)
if (index === -1) return
const next = items[(index + direction + items.length) % items.length]
if (next) selection.set({ providerID: next.provider.id, modelID: next.id })
},
set(item: ModelKey | undefined, options?: { recent?: boolean }) {
startTransition(() =>
batch(() => {
prompt.model.set(item ? { ...item, variant: prompt.model.current()?.variant } : undefined)
if (!item) return
models.setVisibility(item, true)
if (options?.recent) models.recent.push(item)
}),
)
},
visible: models.visible,
setVisibility: models.setVisibility,
variant: {
configured() {
const item = input.agent()
const model = current()
if (!item || !model) return
return getConfiguredAgentVariant({
agent: { model: item.model, variant: item.variant },
model: { providerID: model.provider.id, modelID: model.id, variants: model.variants },
})
},
selected() {
return prompt.model.current()?.variant
},
current() {
const resolved = resolveModelVariant({
variants: this.list(),
selected: this.selected(),
configured: this.configured(),
})
if (resolved) return resolved
const model = current()
if (!model) return
const saved = models.variant.get({ providerID: model.provider.id, modelID: model.id })
if (saved && this.list().includes(saved)) return saved
},
list() {
return Object.keys(current()?.variants ?? {})
},
set(value: string | undefined) {
startTransition(() =>
batch(() => {
const model = current()
if (!model) return
prompt.model.set({ providerID: model.provider.id, modelID: model.id, variant: value ?? null })
models.variant.set({ providerID: model.provider.id, modelID: model.id }, value)
}),
)
},
cycle() {
const variants = this.list()
if (variants.length === 0) return
this.set(
cycleModelVariant({
variants,
selected: this.selected(),
configured: this.configured(),
}),
)
},
},
} satisfies ModelSelection
return selection
}

View file

@ -7,7 +7,7 @@ import type { PromptProjectControls } from "@/components/prompt-project-selector
import { useDirectoryPicker } from "@/components/directory-picker" import { useDirectoryPicker } from "@/components/directory-picker"
import { useGlobal } from "@/context/global" import { useGlobal } from "@/context/global"
import { useLayout } from "@/context/layout" import { useLayout } from "@/context/layout"
import { useLocal } from "@/context/local" import { useLocal, type ModelSelection } from "@/context/local"
import type { QueryOptionsApi } from "@/context/server-sync" import type { QueryOptionsApi } from "@/context/server-sync"
import { useServerSDK } from "@/context/server-sdk" import { useServerSDK } from "@/context/server-sdk"
import { serverName, ServerConnection, useServer } from "@/context/server" import { serverName, ServerConnection, useServer } from "@/context/server"
@ -22,6 +22,7 @@ export function createPromptInputController(input: {
sessionKey: Accessor<string> sessionKey: Accessor<string>
sessionID: Accessor<string | undefined> sessionID: Accessor<string | undefined>
queryOptions: Pick<QueryOptionsApi, "agents" | "providers"> queryOptions: Pick<QueryOptionsApi, "agents" | "providers">
model?: ModelSelection
}) { }) {
const layout = useLayout() const layout = useLayout()
const local = useLocal() const local = useLocal()
@ -44,7 +45,7 @@ export function createPromptInputController(input: {
select: local.agent.set, select: local.agent.set,
}, },
model: { model: {
selection: local.model, selection: input.model ?? local.model,
paid: providers.paid().length > 0, paid: providers.paid().length > 0,
loading: agentsQuery.isLoading || providersQuery.isLoading || globalProvidersQuery.isLoading, loading: agentsQuery.isLoading || providersQuery.isLoading || globalProvidersQuery.isLoading,
}, },

View file

@ -1,6 +1,6 @@
import { describe, expect, test } from "bun:test" import { describe, expect, test } from "bun:test"
import type { UserMessage } from "@opencode-ai/sdk/v2" import type { UserMessage } from "@opencode-ai/sdk/v2"
import { resetSessionModel, syncSessionModel } from "./session-model-helpers" import { resetSessionModel, restorePromptModel, syncPromptModel, syncSessionModel } from "./session-model-helpers"
const message = (input?: { agent?: string; model?: UserMessage["model"] }) => const message = (input?: { agent?: string; model?: UserMessage["model"] }) =>
({ ({
@ -50,3 +50,102 @@ describe("resetSessionModel", () => {
expect(calls).toEqual(["reset"]) expect(calls).toEqual(["reset"])
}) })
}) })
describe("syncPromptModel", () => {
test("stores the effective session model in prompt state", () => {
const calls: unknown[] = []
syncPromptModel(
{
model: {
current: () => ({ id: "claude-sonnet-4", provider: { id: "anthropic" } }),
set() {},
variant: { current: () => "high", set() {} },
},
},
{
model: {
current: () => undefined,
set: (model) => calls.push(model),
},
},
)
expect(calls).toEqual([{ providerID: "anthropic", modelID: "claude-sonnet-4", variant: "high" }])
})
test("does not rewrite an unchanged prompt model", () => {
const calls: unknown[] = []
const model = { providerID: "anthropic", modelID: "claude-sonnet-4", variant: "high" }
syncPromptModel(
{
model: {
current: () => ({ id: model.modelID, provider: { id: model.providerID } }),
set() {},
variant: { current: () => model.variant, set() {} },
},
},
{
model: {
current: () => model,
set: (value) => calls.push(value),
},
},
)
expect(calls).toEqual([])
})
})
describe("restorePromptModel", () => {
test("restores the persisted prompt model into session selection", () => {
const calls: unknown[] = []
const restored = restorePromptModel(
{
model: {
current: () => ({ id: "gpt", provider: { id: "openai" } }),
set: (model) => calls.push(model),
variant: {
current: () => undefined,
set: (variant) => calls.push(variant),
},
},
},
{
model: {
current: () => ({ providerID: "anthropic", modelID: "claude", variant: "high" }),
set() {},
},
},
)
expect(restored).toBe(true)
expect(calls).toEqual([{ providerID: "anthropic", modelID: "claude" }, "high"])
})
test("does nothing without a persisted prompt model", () => {
const calls: unknown[] = []
const restored = restorePromptModel(
{
model: {
current: () => ({ id: "gpt", provider: { id: "openai" } }),
set: (model) => calls.push(model),
variant: {
current: () => undefined,
set: (variant) => calls.push(variant),
},
},
},
{
model: {
current: () => undefined,
set() {},
},
},
)
expect(restored).toBe(false)
expect(calls).toEqual([])
})
})

View file

@ -7,6 +7,24 @@ type Local = {
} }
} }
type ModelSelection = {
model: {
current(): { id: string; provider: { id: string } } | undefined
set(model: { providerID: string; modelID: string }): void
variant: {
current(): string | undefined
set(variant: string | undefined): void
}
}
}
type PromptState = {
model: {
current(): { providerID: string; modelID: string; variant?: string | null } | undefined
set(model: { providerID: string; modelID: string; variant?: string | null }): void
}
}
export const resetSessionModel = (local: Local) => { export const resetSessionModel = (local: Local) => {
local.session.reset() local.session.reset()
} }
@ -14,3 +32,32 @@ export const resetSessionModel = (local: Local) => {
export const syncSessionModel = (local: Local, msg: UserMessage) => { export const syncSessionModel = (local: Local, msg: UserMessage) => {
local.session.restore(msg) local.session.restore(msg)
} }
export const syncPromptModel = (local: ModelSelection, prompt: PromptState) => {
const model = local.model.current()
if (!model) return
const next = {
providerID: model.provider.id,
modelID: model.id,
variant: local.model.variant.current(),
}
const current = prompt.model.current()
if (current?.providerID === next.providerID && current.modelID === next.modelID && current.variant === next.variant)
return
prompt.model.set(next)
}
export const restorePromptModel = (local: ModelSelection, prompt: PromptState) => {
const model = prompt.model.current()
if (!model) return false
const current = local.model.current()
if (
current?.provider.id === model.providerID &&
current.id === model.modelID &&
local.model.variant.current() === (model.variant ?? undefined)
)
return true
local.model.set({ providerID: model.providerID, modelID: model.modelID })
local.model.variant.set(model.variant ?? undefined)
return true
}

View file

@ -249,8 +249,10 @@ export function SessionSidePanel(props: {
aria-label={language.t("session.panel.reviewAndFiles")} aria-label={language.t("session.panel.reviewAndFiles")}
aria-hidden={!open()} aria-hidden={!open()}
inert={!open()} inert={!open()}
class="relative min-w-0 flex overflow-hidden bg-background-base" class="relative min-w-0 flex overflow-hidden"
classList={{ classList={{
"bg-v2-background-bg-base": settings.general.newLayoutDesigns(),
"bg-background-base": !settings.general.newLayoutDesigns(),
"h-full shrink-0": !props.stacked, "h-full shrink-0": !props.stacked,
"h-full min-h-0": props.stacked, "h-full min-h-0": props.stacked,
"pointer-events-none": !open(), "pointer-events-none": !open(),
@ -269,8 +271,20 @@ export function SessionSidePanel(props: {
}} }}
> >
<Show when={reviewOpen()}> <Show when={reviewOpen()}>
<div class="relative min-w-0 h-full flex-1 overflow-hidden bg-background-base"> <div
<div class="size-full min-w-0 h-full bg-background-base"> class="relative min-w-0 h-full flex-1 overflow-hidden"
classList={{
"bg-v2-background-bg-base": settings.general.newLayoutDesigns(),
"bg-background-base": !settings.general.newLayoutDesigns(),
}}
>
<div
class="size-full min-w-0 h-full"
classList={{
"bg-v2-background-bg-base": settings.general.newLayoutDesigns(),
"bg-background-base": !settings.general.newLayoutDesigns(),
}}
>
<DragDropProvider <DragDropProvider
onDragStart={handleDragStart} onDragStart={handleDragStart}
onDragEnd={handleDragEnd} onDragEnd={handleDragEnd}
@ -373,7 +387,13 @@ export function SessionSidePanel(props: {
)} )}
</For> </For>
</SortableProvider> </SortableProvider>
<div class="bg-background-stronger h-full shrink-0 sticky right-0 z-10 flex items-center justify-center pr-3"> <div
class="h-full shrink-0 sticky right-0 z-10 flex items-center justify-center pr-3"
classList={{
"bg-v2-background-bg-base": settings.general.newLayoutDesigns(),
"bg-background-stronger": !settings.general.newLayoutDesigns(),
}}
>
<TooltipKeybind <TooltipKeybind
title={language.t("command.file.open")} title={language.t("command.file.open")}
keybind={command.keybind("file.open")} keybind={command.keybind("file.open")}

View file

@ -195,7 +195,7 @@ export function TerminalPanelV2(props: { stacked?: boolean } = {}) {
aria-label={language.t("terminal.title")} aria-label={language.t("terminal.title")}
aria-hidden={!opened()} aria-hidden={!opened()}
inert={!opened()} inert={!opened()}
class="relative shrink-0 overflow-hidden bg-background-stronger" class="relative shrink-0 overflow-hidden bg-v2-background-bg-base"
classList={{ classList={{
"w-full": !isDesktop() || stacked(), "w-full": !isDesktop() || stacked(),
"min-w-0 h-full flex-1": isDesktop() && opened() && !stacked(), "min-w-0 h-full flex-1": isDesktop() && opened() && !stacked(),
@ -237,7 +237,7 @@ export function TerminalPanelV2(props: { stacked?: boolean } = {}) {
when={terminal.ready()} when={terminal.ready()}
fallback={ fallback={
<div class="flex flex-col h-full pointer-events-none"> <div class="flex flex-col h-full pointer-events-none">
<div class="h-10 flex items-center gap-2 px-2 border-b border-border-weaker-base bg-background-stronger overflow-hidden"> <div class="h-10 flex items-center gap-2 px-2 border-b border-border-weaker-base bg-v2-background-bg-base overflow-hidden">
<For each={handoff()}> <For each={handoff()}>
{(title) => ( {(title) => (
<div class="px-2 py-1 rounded-md bg-surface-base text-14-regular text-text-weak truncate max-w-40"> <div class="px-2 py-1 rounded-md bg-surface-base text-14-regular text-text-weak truncate max-w-40">

View file

@ -1243,12 +1243,13 @@ export function MessageTimeline(props: {
const initialRow = timelineRowByKey().get(props.rowKey)! const initialRow = timelineRowByKey().get(props.rowKey)!
const item = createMemo(() => virtualItemByKey().get(props.rowKey) ?? initialItem) const item = createMemo(() => virtualItemByKey().get(props.rowKey) ?? initialItem)
const row = createMemo(() => timelineRowByKey().get(props.rowKey) ?? initialRow) const row = createMemo(() => timelineRowByKey().get(props.rowKey) ?? initialRow)
const asyncFile = () => { const tool = () => {
const value = row() const value = row()
if (value._tag !== "AssistantPart" || value.group.type !== "part") return false if (value._tag !== "AssistantPart" || value.group.type !== "part") return
const part = getMsgPart(value.group.ref.messageID, value.group.ref.partID) const part = getMsgPart(value.group.ref.messageID, value.group.ref.partID)
return part?.type === "tool" && ["edit", "write", "patch", "apply_patch"].includes(part.tool) if (part?.type === "tool") return part
} }
const asyncFile = () => ["edit", "write", "patch", "apply_patch"].includes(tool()?.tool ?? "")
const [ready, setReady] = createSignal(initialItem.size <= timelineFallbackItemSize || !asyncFile()) const [ready, setReady] = createSignal(initialItem.size <= timelineFallbackItemSize || !asyncFile())
let contentMeasureFrame: number | undefined let contentMeasureFrame: number | undefined
@ -1278,6 +1279,8 @@ export function MessageTimeline(props: {
width: "100%", width: "100%",
height: `${item().size}px`, height: `${item().size}px`,
overflow: "clip", overflow: "clip",
// Rounded virtual measurements can otherwise clip a framed row's outer paint.
"overflow-clip-margin": row()._tag === "TurnGap" ? undefined : "0.5px",
}} }}
> >
<div <div
@ -1383,7 +1386,7 @@ export function MessageTimeline(props: {
"w-full": true, "w-full": true,
"pb-4": true, "pb-4": true,
"pr-3": true, "pr-3": true,
"pl-2": settings.general.newLayoutDesigns(), "pl-2.5": settings.general.newLayoutDesigns(),
"pl-2 md:pl-4": !settings.general.newLayoutDesigns(), "pl-2 md:pl-4": !settings.general.newLayoutDesigns(),
"md:max-w-200 md:mx-auto 2xl:max-w-[1000px]": props.centered && !settings.general.newLayoutDesigns(), "md:max-w-200 md:mx-auto 2xl:max-w-[1000px]": props.centered && !settings.general.newLayoutDesigns(),
}} }}

View file

@ -1,6 +1,6 @@
import { useCommand, type CommandOption } from "@/context/command" import { useCommand, type CommandOption } from "@/context/command"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { useLocal } from "@/context/local" import { useLocal, type ModelSelection } from "@/context/local"
import { useSettings } from "@/context/settings" import { useSettings } from "@/context/settings"
import { useDialog } from "@opencode-ai/ui/context/dialog" import { useDialog } from "@opencode-ai/ui/context/dialog"
import { getCursorPosition, setCursorPosition } from "@/components/prompt-input/editor-dom" import { getCursorPosition, setCursorPosition } from "@/components/prompt-input/editor-dom"
@ -14,7 +14,7 @@ const withCategory = (category: string) => {
}) })
} }
export const useComposerCommands = () => { export const useComposerCommands = (input: { model?: ModelSelection } = {}) => {
const command = useCommand() const command = useCommand()
const dialog = useDialog() const dialog = useDialog()
const language = useLanguage() const language = useLanguage()
@ -22,6 +22,7 @@ export const useComposerCommands = () => {
const settings = useSettings() const settings = useSettings()
const { sessionKey } = useSessionLayout() const { sessionKey } = useSessionLayout()
const sessionOwnership = createSessionOwnership(sessionKey) const sessionOwnership = createSessionOwnership(sessionKey)
const model = input.model ?? local.model
const modelCommand = withCategory(language.t("command.category.model")) const modelCommand = withCategory(language.t("command.category.model"))
const agentCommand = withCategory(language.t("command.category.agent")) const agentCommand = withCategory(language.t("command.category.agent"))
@ -43,7 +44,7 @@ export const useComposerCommands = () => {
} }
const { DialogSelectModel } = await import("@/components/dialog-select-model") const { DialogSelectModel } = await import("@/components/dialog-select-model")
owner.run(() => { owner.run(() => {
void dialog.show(() => <DialogSelectModel model={local.model} />, restoreComposer) void dialog.show(() => <DialogSelectModel model={model} />, restoreComposer)
}) })
} }
@ -61,7 +62,7 @@ export const useComposerCommands = () => {
title: language.t("command.model.variant.cycle"), title: language.t("command.model.variant.cycle"),
description: language.t("command.model.variant.cycle.description"), description: language.t("command.model.variant.cycle.description"),
keybind: "shift+mod+d", keybind: "shift+mod+d",
onSelect: () => local.model.variant.cycle(), onSelect: () => model.variant.cycle(),
}), }),
agentCommand({ agentCommand({
id: "agent.cycle", id: "agent.cycle",

View file

@ -46,6 +46,7 @@ export function SessionFileBrowserTab(props: {
const resultsID = `session-file-browser-results-${createUniqueId()}` const resultsID = `session-file-browser-results-${createUniqueId()}`
const [filter, setFilter] = createSignal("") const [filter, setFilter] = createSignal("")
const [explicitHighlight, setExplicitHighlight] = createSignal<string>() const [explicitHighlight, setExplicitHighlight] = createSignal<string>()
const sidebarOpened = () => props.placeholder || props.state.sidebarOpened()
const query = createMemo(() => filter().trim()) const query = createMemo(() => filter().trim())
const search = createQuery(() => { const search = createQuery(() => {
const value = query() const value = query()
@ -98,15 +99,15 @@ export function SessionFileBrowserTab(props: {
toolbar toolbar
toolbarStart={ toolbarStart={
<> <>
<SessionReviewV2SidebarToggle opened={props.state.sidebarOpened()} onToggle={props.state.toggleSidebar} /> <SessionReviewV2SidebarToggle opened={sidebarOpened()} onToggle={props.state.toggleSidebar} />
<Show when={!props.state.sidebarOpened()}> <Show when={!sidebarOpened()}>
<SessionFilePanelV2Title>{title()}</SessionFilePanelV2Title> <SessionFilePanelV2Title>{title()}</SessionFilePanelV2Title>
</Show> </Show>
</> </>
} }
sidebar={ sidebar={
<SessionReviewV2Sidebar <SessionReviewV2Sidebar
open={props.state.sidebarOpened()} open={sidebarOpened()}
title={<span class="truncate">{title()}</span>} title={<span class="truncate">{title()}</span>}
filter={filter()} filter={filter()}
onFilterChange={setFilter} onFilterChange={setFilter}

View file

@ -463,7 +463,7 @@ function localStorageDirect(): SyncStorage {
} }
} }
const DRAFT_PERSISTED_KEYS = ["prompt", "comments", "model-selection", "file-view", "layout"] const DRAFT_PERSISTED_KEYS = ["prompt", "comments", "file-view", "layout"]
export function draftPersistedKeys() { export function draftPersistedKeys() {
return DRAFT_PERSISTED_KEYS return DRAFT_PERSISTED_KEYS

10
packages/codemode/sst-env.d.ts vendored Normal file
View file

@ -0,0 +1,10 @@
/* This file is auto-generated by SST. Do not edit. */
/* tslint:disable */
/* eslint-disable */
/* deno-fmt-ignore-file */
/* biome-ignore-all lint: auto-generated */
/// <reference path="../../sst-env.d.ts" />
import "sst"
export {}

View file

@ -3,4 +3,7 @@ Allow: /
# Disallow shared content pages # Disallow shared content pages
Disallow: /s/ Disallow: /s/
Disallow: /share/ Disallow: /share/
Sitemap: https://opencode.ai/sitemap.xml
Sitemap: https://opencode.ai/data/sitemap.xml

View file

@ -4,6 +4,7 @@ type Usage = {
input_tokens?: number input_tokens?: number
input_tokens_details?: { input_tokens_details?: {
cached_tokens?: number cached_tokens?: number
cache_write_tokens?: number
} }
output_tokens?: number output_tokens?: number
output_tokens_details?: { output_tokens_details?: {
@ -48,12 +49,13 @@ export const openaiHelper: ProviderHelper = ({ workspaceID }) => ({
const outputTokens = usage.output_tokens ?? 0 const outputTokens = usage.output_tokens ?? 0
const reasoningTokens = usage.output_tokens_details?.reasoning_tokens ?? undefined const reasoningTokens = usage.output_tokens_details?.reasoning_tokens ?? undefined
const cacheReadTokens = usage.input_tokens_details?.cached_tokens ?? undefined const cacheReadTokens = usage.input_tokens_details?.cached_tokens ?? undefined
const cacheWriteTokens = usage.input_tokens_details?.cache_write_tokens ?? undefined
return { return {
inputTokens: inputTokens - (cacheReadTokens ?? 0), inputTokens: inputTokens - (cacheReadTokens ?? 0),
outputTokens, outputTokens,
reasoningTokens, reasoningTokens,
cacheReadTokens, cacheReadTokens,
cacheWrite5mTokens: undefined, cacheWrite5mTokens: cacheWriteTokens,
cacheWrite1hTokens: undefined, cacheWrite1hTokens: undefined,
} }
}, },

View file

@ -65,4 +65,20 @@ describe("provider usage extraction", () => {
output_tokens: 7, output_tokens: 7,
}) })
}) })
test("parses OpenAI stream cache write usage", () => {
const usageParser = providers.openai.createUsageParser()
usageParser.parse(
'event: response.completed\ndata: {"response":{"usage":{"input_tokens":10,"input_tokens_details":{"cached_tokens":4,"cache_write_tokens":3},"output_tokens":2}}}',
)
expect(providers.openai.normalizeUsage(usageParser.retrieve())).toEqual({
inputTokens: 6,
outputTokens: 2,
reasoningTokens: undefined,
cacheReadTokens: 4,
cacheWrite5mTokens: 3,
cacheWrite1hTokens: undefined,
})
})
}) })

View file

@ -112,7 +112,11 @@ export const TuiThreadCommand = cmd({
} }
const cwd = Filesystem.resolve(process.cwd()) const cwd = Filesystem.resolve(process.cwd())
const worker = new Worker(file) const worker = new Worker(file, {
env: Object.fromEntries(
Object.entries(process.env).filter((entry): entry is [string, string] => entry[1] !== undefined),
),
})
const client = Rpc.client<typeof rpc>(worker) const client = Rpc.client<typeof rpc>(worker)
const reload = () => { const reload = () => {
client.call("reload", undefined).catch(() => {}) client.call("reload", undefined).catch(() => {})

View file

@ -16,6 +16,12 @@ describe("tui thread", () => {
expect(source).not.toContain('import("./app")') expect(source).not.toContain('import("./app")')
}) })
test("forwards the CLI environment to the TUI worker", async () => {
const source = await Bun.file(new URL("../../../src/cli/cmd/tui.ts", import.meta.url)).text()
expect(source).toMatch(/new Worker\(file, \{\s*env: Object\.fromEntries\(\s*Object\.entries\(process\.env\)/)
})
async function check(project?: string) { async function check(project?: string) {
await using tmp = await tmpdir({ git: true }) await using tmp = await tmpdir({ git: true })
const link = path.join(path.dirname(tmp.path), path.basename(tmp.path) + "-link") const link = path.join(path.dirname(tmp.path), path.basename(tmp.path) + "-link")

View file

@ -4,7 +4,7 @@
height: 100%; height: 100%;
min-height: 0; min-height: 0;
overflow: hidden; overflow: hidden;
background: var(--background-stronger, var(--v2-background-bg-base)); background: var(--v2-background-bg-base);
} }
[data-component="session-review-v2"] [data-slot="session-review-v2-body"] { [data-component="session-review-v2"] [data-slot="session-review-v2-body"] {
@ -31,7 +31,7 @@
min-height: 0; min-height: 0;
overflow: hidden; overflow: hidden;
border-right: 1px solid var(--border-weaker-base, var(--v2-border-border-weak)); border-right: 1px solid var(--border-weaker-base, var(--v2-border-border-weak));
background: var(--background-stronger, var(--v2-background-bg-base)); background: var(--v2-background-bg-base);
} }
[data-component="session-review-v2-sidebar-root"] [data-slot="session-review-v2-sidebar"][aria-hidden="true"] { [data-component="session-review-v2-sidebar-root"] [data-slot="session-review-v2-sidebar"][aria-hidden="true"] {

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,286 @@
import { catalogSlug, findModelCatalogEntry, type ModelCatalog, type ModelCatalogEntry } from "../routes/model-catalog"
type ComparisonFamilyDefinition = {
slug: string
name: string
lab: string
prefixes: string[]
aliases?: string[]
preferredFamilies?: string[]
}
export type ResolvedComparisonFamily = ComparisonFamilyDefinition & {
model: ModelCatalogEntry
}
export const comparisonFamilies: ComparisonFamilyDefinition[] = [
{
slug: "gpt",
name: "GPT",
lab: "openai",
prefixes: ["gpt", "o"],
aliases: ["openai"],
preferredFamilies: ["gpt", "o"],
},
{
slug: "claude",
name: "Claude",
lab: "anthropic",
prefixes: ["claude"],
aliases: ["anthropic"],
preferredFamilies: ["claude-sonnet", "claude-opus"],
},
{
slug: "gemini",
name: "Gemini",
lab: "google",
prefixes: ["gemini"],
aliases: ["google"],
preferredFamilies: ["gemini-pro", "gemini-flash", "gemini"],
},
{
slug: "deepseek",
name: "DeepSeek",
lab: "deepseek",
prefixes: ["deepseek"],
preferredFamilies: ["deepseek-thinking", "deepseek"],
},
{
slug: "qwen",
name: "Qwen",
lab: "alibaba",
prefixes: ["qwen"],
aliases: ["alibaba"],
preferredFamilies: ["qwen"],
},
{
slug: "glm",
name: "GLM",
lab: "zhipuai",
prefixes: ["glm"],
aliases: ["zhipu", "zhipuai", "zai"],
preferredFamilies: ["glm"],
},
{
slug: "kimi",
name: "Kimi",
lab: "moonshotai",
prefixes: ["kimi"],
aliases: ["moonshot", "moonshotai"],
preferredFamilies: ["kimi-k2", "kimi-thinking"],
},
{
slug: "minimax",
name: "MiniMax",
lab: "minimax",
prefixes: ["minimax"],
},
{
slug: "grok",
name: "Grok",
lab: "xai",
prefixes: ["grok"],
aliases: ["xai"],
preferredFamilies: ["grok"],
},
{
slug: "mistral",
name: "Mistral",
lab: "mistral",
prefixes: ["mistral", "magistral", "devstral", "codestral"],
preferredFamilies: ["mistral-large", "mistral-medium", "mistral-small"],
},
{
slug: "llama",
name: "Llama",
lab: "meta",
prefixes: ["llama"],
aliases: ["meta"],
},
{
slug: "nemotron",
name: "Nemotron",
lab: "nvidia",
prefixes: ["nemotron", "llama-nemotron"],
aliases: ["nvidia"],
},
{
slug: "mimo",
name: "MiMo",
lab: "xiaomi",
prefixes: ["mimo"],
aliases: ["xiaomi"],
},
{
slug: "command",
name: "Command",
lab: "cohere",
prefixes: ["command"],
aliases: ["cohere"],
preferredFamilies: ["command-a", "command-r"],
},
{
slug: "sonar",
name: "Sonar",
lab: "perplexity",
prefixes: ["sonar"],
aliases: ["perplexity"],
preferredFamilies: ["sonar-pro", "sonar-reasoning", "sonar"],
},
{
slug: "longcat",
name: "LongCat",
lab: "meituan",
prefixes: ["longcat"],
aliases: ["meituan"],
},
{
slug: "step",
name: "Step",
lab: "stepfun",
prefixes: ["step"],
aliases: ["stepfun"],
},
{
slug: "mai",
name: "MAI",
lab: "microsoft",
prefixes: ["mai"],
aliases: ["microsoft"],
},
]
export function resolveComparisonFamily(catalog: ModelCatalog, value: string) {
const family = findComparisonFamily(value)
if (!family) return undefined
const model = comparisonFamilyCandidates(catalog, family.slug)[0]
if (!model) return undefined
return { ...family, model } satisfies ResolvedComparisonFamily
}
export function findComparisonFamily(value: string) {
const slug = catalogSlug(value)
return comparisonFamilies.find((family) => family.slug === slug || family.aliases?.includes(slug))
}
export function comparisonFamilyCandidates(catalog: ModelCatalog, value: string) {
const family = findComparisonFamily(value)
if (!family) return []
const matches = catalog.models
.filter((model) => model.lab === family.lab && isFamilyModel(model, family) && isGeneralComparisonModel(model))
.toSorted((a, b) => comparisonFamilyModelSort(a, b, family))
return matches.filter((model) => !isDuplicateAliasModel(model, matches))
}
export function comparisonSitemapModels(
catalog: ModelCatalog,
leaderboard: { model: string; provider: string }[] = [],
) {
return uniqueModels([
...comparisonFamilies.flatMap((family) => comparisonFamilyCandidates(catalog, family.slug).slice(0, 2)),
...leaderboard.flatMap((entry) => {
const model =
findModelCatalogEntry(catalog, entry.model, entry.provider) ?? findModelCatalogEntry(catalog, entry.model)
return model && isGeneralComparisonModel(model) ? [model] : []
}),
]).toSorted((a, b) => a.id.localeCompare(b.id))
}
export function canonicalModelComparisonPath(first: ModelCatalogEntry, second: ModelCatalogEntry) {
const models = [first, second].toSorted((a, b) => a.id.localeCompare(b.id))
return `/data/compare/${models[0].lab}/${models[0].slug}/${models[1].lab}/${models[1].slug}`
}
export function canonicalFamilyComparisonPath(first: ResolvedComparisonFamily, second: ResolvedComparisonFamily) {
const families = [first, second].toSorted((a, b) => a.slug.localeCompare(b.slug))
return `/data/compare/${families[0].slug}/${families[1].slug}`
}
export function latestFamilyComparisonPath(catalog: ModelCatalog, first: ModelCatalogEntry, second: ModelCatalogEntry) {
const firstFamily = comparisonFamilyForModel(catalog, first)
const secondFamily = comparisonFamilyForModel(catalog, second)
if (!firstFamily || !secondFamily || firstFamily.slug === secondFamily.slug) return undefined
if (firstFamily.model.id !== first.id || secondFamily.model.id !== second.id) return undefined
return canonicalFamilyComparisonPath(firstFamily, secondFamily)
}
export function comparisonFamilyForModel(catalog: ModelCatalog, model: ModelCatalogEntry) {
const family = comparisonFamilies.find(
(candidate) => candidate.lab === model.lab && isFamilyModel(model, candidate) && isGeneralComparisonModel(model),
)
if (!family) return undefined
const latest = comparisonFamilyCandidates(catalog, family.slug)[0]
if (!latest) return undefined
return { ...family, model: latest } satisfies ResolvedComparisonFamily
}
function isFamilyModel(model: ModelCatalogEntry, family: ComparisonFamilyDefinition) {
const values = [model.family, model.slug, model.name]
.filter((value): value is string => Boolean(value))
.map(catalogSlug)
return family.prefixes.some((prefix) => values.some((value) => value === prefix || value.startsWith(`${prefix}-`)))
}
function isGeneralComparisonModel(model: ModelCatalogEntry) {
const input = model.modalities.input.map(catalogSlug)
const output = model.modalities.output.map(catalogSlug)
if (!input.includes("text") || !output.includes("text")) return false
return !/(?:^|-)(?:audio|embedding|guard|image|moderation|omni|rerank|safety|speech|transcribe|tts|vision)(?:-|$)/.test(
model.slug,
)
}
function comparisonFamilyModelSort(
first: ModelCatalogEntry,
second: ModelCatalogEntry,
family: ComparisonFamilyDefinition,
) {
return (
displayDateTime(second.releaseDate ?? second.lastUpdated) -
displayDateTime(first.releaseDate ?? first.lastUpdated) ||
preferredFamilyIndex(first, family) - preferredFamilyIndex(second, family) ||
modelVariantPenalty(first) - modelVariantPenalty(second) ||
first.slug.length - second.slug.length ||
first.name.localeCompare(second.name)
)
}
function preferredFamilyIndex(model: ModelCatalogEntry, family: ComparisonFamilyDefinition) {
const index = family.preferredFamilies?.indexOf(catalogSlug(model.family ?? "")) ?? -1
return index === -1 ? (family.preferredFamilies?.length ?? 0) : index
}
function modelVariantPenalty(model: ModelCatalogEntry) {
return /(?:highspeed|latest|preview|turbo|ultraspeed)/.test(model.slug) ? 1 : 0
}
function isDuplicateAliasModel(model: ModelCatalogEntry, models: ModelCatalogEntry[]) {
if (!/(?:-latest|-highspeed|-ultraspeed)$/.test(model.slug)) return false
return models.some(
(candidate) =>
candidate.id !== model.id &&
candidate.releaseDate === model.releaseDate &&
candidate.family === model.family &&
!/(?:-latest|-highspeed|-ultraspeed)$/.test(candidate.slug),
)
}
function uniqueModels(models: ModelCatalogEntry[]) {
return models.reduce<{ ids: Set<string>; models: ModelCatalogEntry[] }>(
(result, model) => {
if (result.ids.has(model.id)) return result
result.ids.add(model.id)
result.models.push(model)
return result
},
{ ids: new Set(), models: [] },
).models
}
function displayDateTime(value: string | undefined) {
if (!value) return 0
const date = new Date(value)
if (!Number.isNaN(date.getTime())) return date.getTime()
const year = Number(value.match(/\d{4}/)?.[0] ?? 0)
return Number.isFinite(year) ? year : 0
}

View file

@ -1,3 +1,4 @@
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
import { For, Show } from "solid-js" import { For, Show } from "solid-js"
import { catalogSlug, formatCatalogLabName, type ModelCatalogEntry } from "./model-catalog" import { catalogSlug, formatCatalogLabName, type ModelCatalogEntry } from "./model-catalog"
@ -13,6 +14,7 @@ export type ComparisonPair = {
first: ComparisonModelRef first: ComparisonModelRef
second: ComparisonModelRef second: ComparisonModelRef
detail: string detail: string
description?: string
} }
export function modelRefFromCatalog(entry: ModelCatalogEntry): ComparisonModelRef { export function modelRefFromCatalog(entry: ModelCatalogEntry): ComparisonModelRef {
@ -30,6 +32,11 @@ export function comparisonHref(first: ComparisonModelRef, second: ComparisonMode
)}/${catalogSlug(second.slug)}` )}/${catalogSlug(second.slug)}`
} }
export function canonicalComparisonHref(first: ComparisonModelRef, second: ComparisonModelRef) {
const models = [first, second].toSorted((a, b) => modelKey(a).localeCompare(modelKey(b)))
return comparisonHref(models[0], models[1])
}
export function uniqueComparisonPairs(pairs: ComparisonPair[]) { export function uniqueComparisonPairs(pairs: ComparisonPair[]) {
return pairs.reduce<{ keys: Set<string>; pairs: ComparisonPair[] }>( return pairs.reduce<{ keys: Set<string>; pairs: ComparisonPair[] }>(
(result, pair) => { (result, pair) => {
@ -48,34 +55,25 @@ export function ComparisonCardsSection(props: {
title?: string title?: string
description?: string description?: string
compact?: boolean compact?: boolean
variant?: "panel" | "featured"
}) { }) {
const featured = () => props.variant === "featured"
const pairs = () => (featured() ? props.pairs.slice(0, 4) : props.pairs)
return ( return (
<Show when={props.pairs.length > 0}> <Show when={props.pairs.length > 0}>
<section id="model-comparison" data-section="model-panel" data-variant={props.compact ? "compact" : undefined}> <section
id="model-comparison"
data-section={featured() ? "compare-home-related" : "model-panel"}
data-variant={!featured() && props.compact ? "compact" : undefined}
>
<p data-slot="section-title"> <p data-slot="section-title">
<strong>{props.title ?? "Model Comparisons"}.</strong>{" "} <strong>{props.title ?? "Model Comparisons"}.</strong>{" "}
<span>{props.description ?? "Compare usage, cost, limits, and features."}</span> <span>{props.description ?? "Compare usage, cost, limits, and features."}</span>
</p> </p>
<div data-component="comparison-card-grid"> <div data-component={featured() ? "compare-home-card-grid" : "comparison-card-grid"}>
<For each={props.pairs}> <For each={pairs()}>
{(pair) => ( {(pair) => (featured() ? <FeaturedComparisonCard pair={pair} /> : <ComparisonPanelCard pair={pair} />)}
<a data-component="comparison-card" href={comparisonHref(pair.first, pair.second)}>
<span>{pair.detail}</span>
<strong>
{pair.first.name} <em>vs</em> {pair.second.name}
</strong>
<p>
<b>{pair.first.labName ?? formatCatalogLabName(pair.first.lab)}</b>
<i />
<b>{pair.second.labName ?? formatCatalogLabName(pair.second.lab)}</b>
</p>
<Show when={pair.first.metric || pair.second.metric}>
<small>
{pair.first.metric ?? "Listed"} / {pair.second.metric ?? "Listed"}
</small>
</Show>
</a>
)}
</For> </For>
</div> </div>
</section> </section>
@ -83,6 +81,90 @@ export function ComparisonCardsSection(props: {
) )
} }
function FeaturedComparisonCard(props: { pair: ComparisonPair }) {
return (
<a
data-component="compare-home-card"
href={canonicalComparisonHref(props.pair.first, props.pair.second)}
aria-label={`${props.pair.detail}: ${props.pair.first.name} vs ${props.pair.second.name}`}
>
<span data-slot="compare-home-card-head">
<span>
<strong>{props.pair.detail}</strong>
<em>{props.pair.description ?? `${props.pair.first.name} vs ${props.pair.second.name}`}</em>
</span>
<ComparisonCardIcon />
</span>
<span data-slot="compare-home-card-divider" aria-hidden="true" />
<span data-slot="compare-home-card-models">
<span>{props.pair.first.name}</span>
<i aria-hidden="true">·</i>
<span>{props.pair.second.name}</span>
</span>
<span data-slot="compare-home-card-avatars" aria-hidden="true">
<ComparisonLabLogo model={props.pair.first} />
<ComparisonLabLogo model={props.pair.second} />
</span>
</a>
)
}
function ComparisonCardIcon() {
return (
<b aria-hidden="true">
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M12.9509 12.9884L14.4069 14.4444M2.44431 2.44434H6.44431V6.44434H2.44431V2.44434ZM2.44431 9.55542H6.44431V13.5554H2.44431V9.55542ZM9.55539 2.44434H13.5554V6.44434H9.55539V2.44434ZM13.5554 11.5554C13.5554 12.66 12.66 13.5554 11.5554 13.5554C10.4508 13.5554 9.55539 12.66 9.55539 11.5554C9.55539 10.4509 10.4508 9.55542 11.5554 9.55542C12.66 9.55542 13.5554 10.4509 13.5554 11.5554Z"
stroke="#808080"
/>
</svg>
</b>
)
}
function ComparisonPanelCard(props: { pair: ComparisonPair }) {
return (
<a data-component="comparison-card" href={canonicalComparisonHref(props.pair.first, props.pair.second)}>
<span>{props.pair.detail}</span>
<strong>
{props.pair.first.name} <em>vs</em> {props.pair.second.name}
</strong>
<p>
<b>{props.pair.first.labName ?? formatCatalogLabName(props.pair.first.lab)}</b>
<i />
<b>{props.pair.second.labName ?? formatCatalogLabName(props.pair.second.lab)}</b>
</p>
<Show when={props.pair.first.metric || props.pair.second.metric}>
<small>
{props.pair.first.metric ?? "Listed"} / {props.pair.second.metric ?? "Listed"}
</small>
</Show>
</a>
)
}
function ComparisonLabLogo(props: { model: ComparisonModelRef }) {
const iconId = () => providerIconId(props.model.lab)
return (
<span
data-slot="compare-home-avatar"
data-lab={iconId()}
data-size="small"
aria-label={props.model.labName ?? formatCatalogLabName(props.model.lab)}
>
<ProviderIcon aria-hidden="true" id={iconId()} />
</span>
)
}
function modelKey(model: ComparisonModelRef) { function modelKey(model: ComparisonModelRef) {
return `${catalogSlug(model.lab)}/${catalogSlug(model.slug)}` return `${catalogSlug(model.lab)}/${catalogSlug(model.slug)}`
} }
function providerIconId(provider: string) {
const id = provider.toLowerCase().replace(/[^a-z0-9]+/g, "")
if (id === "moonshot") return "moonshotai"
if (id === "zhipu") return "zhipuai"
return id
}

View file

@ -0,0 +1,391 @@
import { createMemo, createSignal, For, Show, type JSX } from "solid-js"
import type { ModelCatalogBenchmark, ModelCatalogEntry } from "./model-catalog"
const radarRingCount = 5
const radarColors = ["#294bdb", "#159447", "#d24a3b", "#8a4fd2", "#b47400", "#008c95"] as const
const codingBenchmarkPattern = /(swe|aider|code|coding|nl2repo)/
const reasoningBenchmarkPattern = /(gpqa|humanity|last exam|reasoning|aime|hmmt|math|mmlu|mrcr|charxiv|cti realm)/
const toolUseBenchmarkPattern = /(terminal bench|claw eval|tau ?(?:bench|2|3))/
export type ComparisonRadarModel = {
name: string
labName: string
catalog: ModelCatalogEntry | null
}
type ComparisonRadarProps = {
models: readonly ComparisonRadarModel[]
catalogModels: readonly ModelCatalogEntry[]
}
type RadarAxis = {
label: string
description: string
score: (model: ModelCatalogEntry) => number | undefined
}
type RadarPoint = {
x: number
y: number
}
export function ComparisonRadar(props: ComparisonRadarProps) {
const [activeAxis, setActiveAxis] = createSignal<number>()
const axes = createMemo(() => buildRadarAxes(props.catalogModels))
const series = createMemo(() =>
props.models.map((model, index) => ({
name: model.name,
labName: model.labName,
color: radarColors[index % radarColors.length],
scores: axes().map((axis) => (model.catalog ? axis.score(model.catalog) : undefined)),
})),
)
const accessibleDescription = createMemo(() =>
series()
.map(
(model) =>
`${model.name}: ${axes()
.map((axis, index) => `${axis.label} ${formatRadarScore(model.scores[index])}`)
.join(", ")}`,
)
.join(". "),
)
const clearActiveAxis = (index: number) => setActiveAxis((active) => (active === index ? undefined : active))
return (
<section data-section="compare-radar" aria-label="Model capabilities">
<ol data-slot="compare-radar-legend">
<For each={series()}>
{(model) => (
<li>
<i style={{ background: model.color }} aria-hidden="true" />
<span>
<strong>{model.name}</strong>
<small>{model.labName}</small>
</span>
</li>
)}
</For>
</ol>
<div data-slot="compare-radar-chart" role="img" aria-label={accessibleDescription()}>
<div data-slot="compare-radar-plot" aria-hidden="true">
<svg viewBox="0 0 100 100" preserveAspectRatio="xMidYMid meet">
<g data-slot="compare-radar-grid">
<For each={Array.from({ length: radarRingCount })}>
{(_, index) => (
<polygon points={radarPolygonPoints(axes().length, ((index() + 1) / radarRingCount) * 100)} />
)}
</For>
<For each={axes()}>
{(_, index) => {
const point = () => radarPoint(index(), axes().length, 100)
return <line x1="50" y1="50" x2={point().x} y2={point().y} />
}}
</For>
</g>
<For each={series()}>
{(model) => (
<g data-slot="compare-radar-series" style={{ color: model.color }}>
<Show when={radarSeriesPolygon(model.scores)}>
{(points) => <polygon data-slot="compare-radar-area" points={points()} />}
</Show>
<Show when={!radarSeriesPolygon(model.scores)}>
<For each={radarSeriesConnections(model.scores)}>
{(connection) => (
<line
data-slot="compare-radar-line"
x1={connection.start.x}
y1={connection.start.y}
x2={connection.end.x}
y2={connection.end.y}
/>
)}
</For>
</Show>
<For each={model.scores}>
{(score, index) => {
if (score === undefined) return null
const point = () => radarPoint(index(), axes().length, score)
return (
<>
<circle data-slot="compare-radar-point" cx={point().x} cy={point().y} r="0.95" />
<circle
data-slot="compare-radar-point-hit"
cx={point().x}
cy={point().y}
r="3"
onMouseEnter={() => setActiveAxis(index())}
onMouseLeave={() => clearActiveAxis(index())}
/>
</>
)
}}
</For>
</g>
)}
</For>
</svg>
</div>
<For each={axes()}>
{(axis, index) => (
<span
data-slot="compare-radar-axis"
data-active={activeAxis() === index() ? "true" : undefined}
style={radarAxisStyle(index(), axes().length)}
tabIndex="0"
aria-label={`${axis.label}. ${axis.description}`}
onMouseEnter={() => setActiveAxis(index())}
onMouseLeave={() => clearActiveAxis(index())}
onFocus={() => setActiveAxis(index())}
onBlur={() => clearActiveAxis(index())}
onKeyDown={(event) => {
if (event.key === "Escape") event.currentTarget.blur()
}}
>
<span data-slot="compare-radar-axis-label">{axis.label}</span>
</span>
)}
</For>
<Show when={activeAxis() !== undefined}>
<div
data-slot="compare-radar-tooltip"
role="tooltip"
style={radarTooltipStyle(activeAxis() ?? 0, axes().length)}
>
<strong>{axes()[activeAxis() ?? 0]?.label}</strong>
<p>{axes()[activeAxis() ?? 0]?.description}</p>
</div>
</Show>
</div>
<div data-slot="compare-radar-data">
<table>
<caption>Normalized model capability scores</caption>
<thead>
<tr>
<th>Model</th>
<For each={axes()}>{(axis) => <th>{axis.label}</th>}</For>
</tr>
</thead>
<tbody>
<For each={series()}>
{(model) => (
<tr>
<th>{model.name}</th>
<For each={model.scores}>{(score) => <td>{formatRadarScore(score)}</td>}</For>
</tr>
)}
</For>
</tbody>
</table>
</div>
</section>
)
}
function buildRadarAxes(catalogModels: readonly ModelCatalogEntry[]): RadarAxis[] {
const benchmarks = benchmarkScoreGroups(catalogModels)
const toolUseBenchmarks = benchmarkScoreGroups(catalogModels, true)
const costs = catalogModels.flatMap((model) => {
const cost = modelCost(model)
return cost === undefined ? [] : [cost]
})
const contexts = catalogModels.flatMap((model) => (model.limit?.context === undefined ? [] : [model.limit.context]))
const multimodalMaximum = Math.max(...catalogModels.map(multimodalFeatureCount), 0)
// Speed and safety stay out until the catalog exposes comparable values for them.
return [
{
label: "Reasoning",
description: "Ability to solve complex, multi-step problems. Based on reasoning benchmarks when available.",
score: (model) =>
benchmarkPercentile(model, benchmarks, reasoningBenchmarkPattern) ?? (model.reasoning ? 100 : 0),
},
{
label: "Coding",
description: "Performance on software engineering and coding benchmarks.",
score: (model) => benchmarkPercentile(model, benchmarks, codingBenchmarkPattern),
},
{
label: "Cost efficiency",
description: "Relative input and output pricing. Lower-cost models score higher.",
score: (model) => {
const cost = modelCost(model)
if (cost === undefined) return
if (cost === 0) return 100
return percentileScore(cost, costs, "lower")
},
},
{
label: "Context window",
description: "How much input the model can process at once. Larger context windows score higher.",
score: (model) => {
const context = model.limit?.context
if (context === undefined) return
return percentileScore(context, contexts, "higher")
},
},
{
label: "Multimodal",
description: "Support for non-text input and output, including images, audio, and video.",
score: (model) => {
if (multimodalMaximum === 0) return
return (multimodalFeatureCount(model) / multimodalMaximum) * 100
},
},
{
label: "Tool use",
description: "Performance on agent benchmarks including Terminal-Bench, Tau3, and Claw-Eval.",
score: (model) =>
benchmarkPercentile(model, toolUseBenchmarks, toolUseBenchmarkPattern, {
aggregate: "average",
includeHarness: true,
}),
},
]
}
function benchmarkScoreGroups(catalogModels: readonly ModelCatalogEntry[], includeHarness = false) {
return catalogModels.reduce<Map<string, number[]>>((groups, model) => {
model.benchmarks
.reduce<Map<string, number>>((scores, benchmark) => {
const key = benchmarkKey(benchmark, includeHarness)
scores.set(key, Math.max(scores.get(key) ?? -Infinity, benchmark.score))
return scores
}, new Map())
.forEach((score, key) => {
groups.set(key, [...(groups.get(key) ?? []), score])
})
return groups
}, new Map())
}
function benchmarkKey(benchmark: ModelCatalogBenchmark, includeHarness: boolean) {
const name = normalizeBenchmarkName(benchmark.name)
const version = normalizeBenchmarkName(benchmark.version ?? "")
const versioned = version && !name.endsWith(version) ? `${name} ${version}` : name
if (!includeHarness) return versioned
const harness = normalizeBenchmarkName(benchmark.harness ?? benchmark.variant ?? "")
return harness ? `${versioned} | ${harness}` : versioned
}
function benchmarkPercentile(
model: ModelCatalogEntry,
benchmarks: Map<string, number[]>,
pattern: RegExp,
options?: { aggregate?: "average" | "best"; includeHarness?: boolean },
) {
const scores = Object.entries(
model.benchmarks.reduce<Record<string, number>>((result, benchmark) => {
const key = benchmarkKey(benchmark, options?.includeHarness ?? false)
if (!pattern.test(key)) return result
result[key] = Math.max(result[key] ?? -Infinity, benchmark.score)
return result
}, {}),
).flatMap(([key, score]) => {
const values = benchmarks.get(key)
const percentile = values ? percentileScore(score, values, "higher") : undefined
return percentile === undefined ? [] : [percentile]
})
if (scores.length === 0) return
if (options?.aggregate === "average") return scores.reduce((sum, score) => sum + score, 0) / scores.length
// Benchmark coverage varies by model, so additional published results should not lower a model's score.
return Math.max(...scores)
}
function normalizeBenchmarkName(value: string) {
return value
.toLowerCase()
.replace(/\u03c4/g, "tau")
.replace(/\u00b2/g, "2")
.replace(/\u00b3/g, "3")
.replace(/[^a-z0-9]+/g, " ")
.trim()
}
function modelCost(model: ModelCatalogEntry) {
if (!model.cost) return
return model.cost.input + model.cost.output
}
function multimodalFeatureCount(model: ModelCatalogEntry) {
return new Set([
...model.modalities.input.filter((modality) => modality !== "text").map((modality) => `input:${modality}`),
...model.modalities.output.filter((modality) => modality !== "text").map((modality) => `output:${modality}`),
...(model.attachment ? ["attachment"] : []),
]).size
}
function percentileScore(value: number, values: number[], direction: "higher" | "lower") {
const finite = values.filter(Number.isFinite)
if (!Number.isFinite(value) || finite.length < 2) return
const below = finite.filter((candidate) => candidate < value).length
const equal = finite.filter((candidate) => candidate === value).length
const percentile = ((below + (equal - 1) / 2) / (finite.length - 1)) * 100
return direction === "higher" ? percentile : 100 - percentile
}
function radarPoint(index: number, count: number, score: number): RadarPoint {
const angle = -Math.PI / 2 + (index * Math.PI * 2) / count
const radius = Math.max(0, Math.min(100, score)) / 2
return {
x: roundRadarCoordinate(50 + Math.cos(angle) * radius),
y: roundRadarCoordinate(50 + Math.sin(angle) * radius),
}
}
function radarPolygonPoints(count: number, score: number) {
return Array.from({ length: count })
.map((_, index) => radarPoint(index, count, score))
.map((point) => `${point.x},${point.y}`)
.join(" ")
}
function radarSeriesPolygon(scores: (number | undefined)[]) {
if (scores.some((score) => score === undefined)) return
return scores
.map((score, index) => radarPoint(index, scores.length, score ?? 0))
.map((point) => `${point.x},${point.y}`)
.join(" ")
}
function radarSeriesConnections(scores: (number | undefined)[]) {
return scores.flatMap((score, index) => {
const nextIndex = (index + 1) % scores.length
const next = scores[nextIndex]
if (score === undefined || next === undefined) return []
return [
{
start: radarPoint(index, scores.length, score),
end: radarPoint(nextIndex, scores.length, next),
},
]
})
}
function radarAxisStyle(index: number, count: number) {
const angle = -Math.PI / 2 + (index * Math.PI * 2) / count
const horizontal = Math.cos(angle)
return {
"--compare-radar-axis-x": `${roundRadarCoordinate(50 + horizontal * 42)}%`,
"--compare-radar-axis-mobile-x": `${roundRadarCoordinate(50 + horizontal * 36)}%`,
"--compare-radar-axis-y": `${roundRadarCoordinate(50 + Math.sin(angle) * 42)}%`,
"--compare-radar-axis-translate-x": horizontal > 0.25 ? "0%" : horizontal < -0.25 ? "-100%" : "-50%",
} as JSX.CSSProperties
}
function radarTooltipStyle(index: number, count: number) {
const angle = -Math.PI / 2 + (index * Math.PI * 2) / count
return {
"--compare-radar-tooltip-x": `${roundRadarCoordinate(50 + Math.cos(angle) * 42)}%`,
"--compare-radar-tooltip-y": `${roundRadarCoordinate(50 + Math.sin(angle) * 42)}%`,
"--compare-radar-tooltip-translate-y": Math.sin(angle) < -0.9 ? "20px" : "calc(-100% - 12px)",
} as JSX.CSSProperties
}
function roundRadarCoordinate(value: number) {
return Math.round(value * 1000) / 1000
}
function formatRadarScore(score: number | undefined) {
return score === undefined ? "No data" : `${Math.round(score)}/100`
}

View file

@ -0,0 +1,47 @@
import { Meta, Title } from "@solidjs/meta"
import { createAsync, useParams } from "@solidjs/router"
import { createMemo, Show } from "solid-js"
import ModelCompareDetailPage from "../../../component/model-compare-detail"
import { resolveComparisonFamily } from "../../../lib/comparison-pages"
import { getModelCatalog } from "../../model-catalog"
export default function ModelCompareFamily() {
const params = useParams()
const catalog = createAsync(() => getModelCatalog())
const comparison = createMemo(() => {
const source = catalog()
if (!source) return undefined
const first = resolveComparisonFamily(source, params.firstFamily ?? "")
const second = resolveComparisonFamily(source, params.secondFamily ?? "")
if (!first || !second || first.slug === second.slug) return null
return { first, second }
})
return (
<Show
when={comparison()}
fallback={
<Show when={comparison() === null}>
<Title>Model comparison not found</Title>
<Meta name="robots" content="noindex,follow" />
<main data-page="stats">
<div data-component="empty-state">
<strong>Comparison not found</strong>
<p>Choose two model families to compare.</p>
<a href={`${import.meta.env.BASE_URL}compare`}>Compare models</a>
</div>
</main>
</Show>
}
>
{(resolved) => (
<ModelCompareDetailPage
first={{ lab: resolved().first.model.lab, slug: resolved().first.model.slug }}
second={{ lab: resolved().second.model.lab, slug: resolved().second.model.slug }}
family={resolved()}
catalog={catalog()}
/>
)}
</Show>
)
}

View file

@ -1,611 +1 @@
import "../../../../index.css" export { default } from "../../../../../component/model-compare-detail"
import { Link, Meta, Title } from "@solidjs/meta"
import { getStatsModelComparisonData, type StatsModelComparisonEntry } from "@opencode-ai/stats-core/domain/home"
import { runtime } from "@opencode-ai/stats-core/runtime"
import { createAsync, query, useParams } from "@solidjs/router"
import { createMemo, createSignal, For, onMount, Show } from "solid-js"
import { getRequestEvent } from "solid-js/web"
import {
ComparisonCardsSection,
modelRefFromCatalog,
uniqueComparisonPairs,
type ComparisonModelRef,
type ComparisonPair,
} from "../../../../compare-cards"
import { ComparisonSelector } from "../../../../compare-selector"
import {
catalogSlug,
findModelCatalogEntry,
formatCatalogLabName,
getModelCatalog,
type ModelCatalog,
type ModelCatalogEntry,
} from "../../../../model-catalog"
import {
applyThemePreference,
Footer,
getGitHubStars,
Header,
isThemePreference,
themeStorageKey,
type HeaderLink,
type ThemePreference,
} from "../../../../stats-shell"
const compareFallbackUrl = "https://stats.opencode.ai"
const compareHeaderLinks: readonly HeaderLink[] = [
{ href: "#overview", label: "Overview" },
{ href: "#comparison", label: "Comparison" },
{ href: "#compare-tool", label: "Compare" },
{ href: "#model-comparison", label: "Related" },
]
const compareFooterLinks: readonly HeaderLink[] = [
{ href: import.meta.env.BASE_URL, label: "Data Home" },
{ href: `${import.meta.env.BASE_URL}compare`, label: "Model Compare" },
{ href: `${import.meta.env.BASE_URL}#top-models`, label: "Top Models" },
{ href: `${import.meta.env.BASE_URL}#token-cost`, label: "Token Cost" },
]
type ComparisonModel = {
name: string
lab: string
labName: string
slug: string
catalog: ModelCatalogEntry | null
stats: StatsModelComparisonEntry | null
}
type ComparisonDirection = "higher" | "lower"
type ComparisonCell = { value: string; detail?: string; score?: number }
type ComparisonRow = {
label: string
description: string
direction: ComparisonDirection
cells: [ComparisonCell, ComparisonCell]
}
const getComparisonData = query(
async (firstLab: string, firstModel: string, secondLab: string, secondModel: string) => {
"use server"
return runtime.runPromise(getStatsModelComparisonData(firstLab, firstModel, secondLab, secondModel))
},
"getStatsModelComparisonData",
)
export default function ModelComparePair() {
const event = getRequestEvent()
event?.response.headers.set("Cache-Control", "public, max-age=60, s-maxage=300, stale-while-revalidate=86400")
const params = useParams()
const firstLabParam = createMemo(() => params.firstLab ?? "")
const firstModelParam = createMemo(() => params.firstModel ?? "")
const secondLabParam = createMemo(() => params.secondLab ?? "")
const secondModelParam = createMemo(() => params.secondModel ?? "")
const catalog = createAsync(() => getModelCatalog())
const firstCatalog = createMemo(() => resolvedCatalogEntry(catalog(), firstLabParam(), firstModelParam()))
const secondCatalog = createMemo(() => resolvedCatalogEntry(catalog(), secondLabParam(), secondModelParam()))
const stats = createAsync(() => {
if (catalog() === undefined || firstCatalog() === undefined || secondCatalog() === undefined)
return Promise.resolve(undefined)
return getComparisonData(
firstCatalog()?.lab ?? firstLabParam(),
firstCatalog()?.slug ?? firstModelParam(),
secondCatalog()?.lab ?? secondLabParam(),
secondCatalog()?.slug ?? secondModelParam(),
)
})
const githubStars = createAsync(() => getGitHubStars())
const [themePreference, setThemePreference] = createSignal<ThemePreference>("system")
const models = createMemo(
() =>
[
buildComparisonModel(firstLabParam(), firstModelParam(), firstCatalog() ?? null, stats()?.models[0] ?? null),
buildComparisonModel(secondLabParam(), secondModelParam(), secondCatalog() ?? null, stats()?.models[1] ?? null),
] as const,
)
const title = createMemo(() => `${models()[0].name} vs ${models()[1].name} - Model Comparison`)
const description = createMemo(
() =>
`Compare ${models()[0].name} and ${models()[1].name} by usage, rank, context window, output limit, cache ratio, and cost across OpenCode data.`,
)
const canonicalPath = createMemo(
() =>
`${import.meta.env.BASE_URL}compare/${catalogSlug(models()[0].lab)}/${catalogSlug(models()[0].slug)}/${catalogSlug(
models()[1].lab,
)}/${catalogSlug(models()[1].slug)}`,
)
const canonicalUrl = createMemo(() =>
new URL(
canonicalPath(),
event?.request.url ?? (typeof window === "undefined" ? compareFallbackUrl : window.location.href),
).toString(),
)
const rows = createMemo(() => buildComparisonRows(models()[0], models()[1]))
const relatedPairs = createMemo(() => buildRelatedPairs(catalog(), models()[0], models()[1]))
const selectorModels = createMemo(() =>
uniqueCatalogModels([
comparisonCatalogEntry(models()[0]),
comparisonCatalogEntry(models()[1]),
...(catalog()?.models ?? []),
]),
)
const structuredData = createMemo(() =>
JSON.stringify({
"@context": "https://schema.org",
"@type": "WebPage",
name: title(),
description: description(),
url: canonicalUrl(),
about: models().map((model) => ({
"@type": "SoftwareApplication",
name: model.name,
applicationCategory: "AI model",
provider: model.labName,
})),
}),
)
const updateThemePreference = (preference: ThemePreference) => {
applyThemePreference(preference)
setThemePreference(preference)
if (typeof window === "undefined") return
window.localStorage.setItem(themeStorageKey, preference)
}
onMount(() => {
if (typeof window === "undefined") return
const preference = window.localStorage.getItem(themeStorageKey)
const nextPreference = isThemePreference(preference) ? preference : "system"
applyThemePreference(nextPreference)
setThemePreference(nextPreference)
})
return (
<main data-page="stats" data-theme={themePreference()}>
<Title>{title()}</Title>
<Meta name="description" content={description()} />
<Link rel="canonical" href={canonicalUrl()} />
<Meta property="og:type" content="website" />
<Meta property="og:site_name" content="OpenCode" />
<Meta property="og:title" content={title()} />
<Meta property="og:description" content={description()} />
<Meta property="og:url" content={canonicalUrl()} />
<Meta name="twitter:card" content="summary" />
<Meta name="twitter:title" content={title()} />
<Meta name="twitter:description" content={description()} />
<script type="application/ld+json">{structuredData()}</script>
<Header githubStars={githubStars() ?? "150K"} links={compareHeaderLinks} brandHref={import.meta.env.BASE_URL} />
<div data-component="container">
<div data-component="content">
<ComparisonHero models={models()} />
<section id="comparison" data-section="model-panel">
<p data-slot="section-title">
<strong>Comparison Table.</strong> <span>Compare usage, cost, limits, and features.</span>
</p>
<Show
when={stats() !== undefined}
fallback={
<div data-component="empty-state" data-compact="true">
<strong>Loading comparison</strong>
<p>Loading stats for both models.</p>
</div>
}
>
<ComparisonTable models={models()} rows={rows()} />
</Show>
</section>
<section id="compare-tool" data-section="model-panel" data-variant="compact">
<p data-slot="section-title">
<strong>Compare Another Pair.</strong> <span>Choose two models to compare.</span>
</p>
<Show
when={selectorModels().length > 1}
fallback={
<div data-component="empty-state" data-compact="true">
<strong>No models found</strong>
<p>The model list could not be loaded.</p>
</div>
}
>
<ComparisonSelector
models={selectorModels()}
firstId={comparisonCatalogEntry(models()[0]).id}
secondId={comparisonCatalogEntry(models()[1]).id}
/>
</Show>
</section>
<ComparisonCardsSection
pairs={relatedPairs()}
title="Related Model Comparisons"
description="Other model pairs to check."
/>
</div>
<Footer
themePreference={themePreference()}
onThemePreferenceChange={updateThemePreference}
links={compareFooterLinks}
bridge={{ href: "#comparison", label: "COMPARE TABLE" }}
/>
</div>
</main>
)
}
function ComparisonHero(props: { models: readonly [ComparisonModel, ComparisonModel] }) {
return (
<section id="overview" data-section="model-hero">
<a data-slot="model-back-link" href={`${import.meta.env.BASE_URL}compare`}>
Compare
</a>
<div data-slot="model-hero-copy">
<h1>
{props.models[0].name} vs {props.models[1].name}
</h1>
<p>Compare usage, cost, limits, and features for these two models.</p>
</div>
<div data-slot="model-hero-pattern" aria-hidden="true" />
</section>
)
}
function ComparisonTable(props: { models: readonly [ComparisonModel, ComparisonModel]; rows: ComparisonRow[] }) {
return (
<div data-component="comparison-table-wrap">
<table data-component="comparison-table">
<caption>
{props.models[0].name} compared with {props.models[1].name}
</caption>
<thead>
<tr>
<th scope="col">Metric</th>
<For each={props.models}>{(model) => <th scope="col">{model.name}</th>}</For>
</tr>
</thead>
<tbody>
<For each={props.rows}>
{(row) => {
const best = () => bestCellIndex(row)
return (
<tr>
<th scope="row">
<strong>{row.label}</strong>
<span>{row.description}</span>
</th>
<For each={row.cells}>
{(cell, index) => (
<td data-best={best() === index() ? "true" : undefined}>
<strong>{cell.value}</strong>
<Show when={cell.detail}>{(detail) => <span>{detail()}</span>}</Show>
</td>
)}
</For>
</tr>
)
}}
</For>
</tbody>
</table>
</div>
)
}
function resolvedCatalogEntry(catalog: ModelCatalog | undefined, lab: string, model: string) {
if (!catalog) return undefined
return findModelCatalogEntry(catalog, model, lab) ?? null
}
function buildComparisonModel(
labParam: string,
modelParam: string,
catalog: ModelCatalogEntry | null,
stats: StatsModelComparisonEntry | null,
): ComparisonModel {
return {
name: catalog?.name ?? stats?.model ?? formatParamName(modelParam),
lab: catalog?.lab ?? stats?.provider ?? catalogSlug(labParam),
labName: formatCatalogLabName(catalog?.lab ?? stats?.provider ?? labParam),
slug: catalog?.slug ?? stats?.slug ?? catalogSlug(modelParam),
catalog,
stats,
}
}
function comparisonCatalogEntry(model: ComparisonModel): ModelCatalogEntry {
if (model.catalog) return model.catalog
return {
id: `${catalogSlug(model.lab)}/${catalogSlug(model.slug)}`,
lab: catalogSlug(model.lab),
slug: catalogSlug(model.slug),
name: model.name,
modalities: { input: [], output: [] },
openWeights: false,
reasoning: false,
toolCall: false,
attachment: false,
temperature: false,
weights: [],
benchmarks: [],
}
}
function uniqueCatalogModels(models: ModelCatalogEntry[]) {
return Object.values(
models.reduce<Record<string, ModelCatalogEntry>>((result, model) => {
result[model.id] = result[model.id] ?? model
return result
}, {}),
)
}
function buildComparisonRows(first: ComparisonModel, second: ComparisonModel): ComparisonRow[] {
return [
comparisonRow(
"Recent Rank",
"Lower is better.",
{
value: first.stats?.rank == null ? "No usage" : `#${first.stats.rank}`,
score: first.stats?.rank ?? undefined,
},
{
value: second.stats?.rank == null ? "No usage" : `#${second.stats.rank}`,
score: second.stats?.rank ?? undefined,
},
"lower",
),
comparisonRow(
"Token Share",
"Share of recent OpenCode usage.",
{ value: first.stats ? formatPercent(first.stats.tokenShare) : "No usage", score: first.stats?.tokenShare },
{ value: second.stats ? formatPercent(second.stats.tokenShare) : "No usage", score: second.stats?.tokenShare },
"higher",
),
comparisonRow(
"Tokens",
"Recent token volume.",
{ value: first.stats ? formatTokens(first.stats.totals.tokens) : "No usage", score: first.stats?.totals.tokens },
{
value: second.stats ? formatTokens(second.stats.totals.tokens) : "No usage",
score: second.stats?.totals.tokens,
},
"higher",
),
comparisonRow(
"Sessions",
"Recent session count.",
{
value: first.stats ? formatInteger(first.stats.totals.sessions) : "No usage",
score: first.stats?.totals.sessions,
},
{
value: second.stats ? formatInteger(second.stats.totals.sessions) : "No usage",
score: second.stats?.totals.sessions,
},
"higher",
),
comparisonRow(
"Cost / 1M Tokens",
"Lower is better.",
{
value: first.stats ? formatMoney(first.stats.totals.costPerMillion) : "No usage",
score: positiveScore(first.stats?.totals.costPerMillion),
},
{
value: second.stats ? formatMoney(second.stats.totals.costPerMillion) : "No usage",
score: positiveScore(second.stats?.totals.costPerMillion),
},
"lower",
),
comparisonRow(
"Cost / Session",
"Lower is better.",
{
value: first.stats ? formatSessionCost(first.stats.totals.costPerSession) : "No usage",
score: positiveScore(first.stats?.totals.costPerSession),
},
{
value: second.stats ? formatSessionCost(second.stats.totals.costPerSession) : "No usage",
score: positiveScore(second.stats?.totals.costPerSession),
},
"lower",
),
comparisonRow(
"Cache Ratio",
"Higher is better.",
{
value: first.stats ? formatPercent(first.stats.totals.cacheRatio) : "No usage",
score: first.stats?.totals.cacheRatio,
},
{
value: second.stats ? formatPercent(second.stats.totals.cacheRatio) : "No usage",
score: second.stats?.totals.cacheRatio,
},
"higher",
),
comparisonRow(
"Context Window",
"Higher limit is better.",
{
value: formatCatalogLimit(first.catalog?.limit?.context),
score: first.catalog?.limit?.context,
},
{
value: formatCatalogLimit(second.catalog?.limit?.context),
score: second.catalog?.limit?.context,
},
"higher",
),
comparisonRow(
"Output Limit",
"Higher limit is better.",
{
value: formatCatalogLimit(first.catalog?.limit?.output),
score: first.catalog?.limit?.output,
},
{
value: formatCatalogLimit(second.catalog?.limit?.output),
score: second.catalog?.limit?.output,
},
"higher",
),
comparisonRow(
"Release Date",
"Newer release is highlighted.",
{
value: formatCatalogDate(first.catalog?.releaseDate),
score: catalogDateScore(first.catalog?.releaseDate),
},
{
value: formatCatalogDate(second.catalog?.releaseDate),
score: catalogDateScore(second.catalog?.releaseDate),
},
"higher",
),
comparisonRow(
"Reasoning",
"Supports reasoning.",
booleanCell(first.catalog?.reasoning),
booleanCell(second.catalog?.reasoning),
"higher",
),
comparisonRow(
"Tool Calling",
"Supports tool calls.",
booleanCell(first.catalog?.toolCall),
booleanCell(second.catalog?.toolCall),
"higher",
),
comparisonRow(
"Attachments",
"Supports attachments.",
booleanCell(first.catalog?.attachment),
booleanCell(second.catalog?.attachment),
"higher",
),
comparisonRow(
"Open Weights",
"Open weights available.",
booleanCell(first.catalog?.openWeights),
booleanCell(second.catalog?.openWeights),
"higher",
),
]
}
function comparisonRow(
label: string,
description: string,
first: ComparisonCell,
second: ComparisonCell,
direction: ComparisonDirection,
): ComparisonRow {
return { label, description, direction, cells: [first, second] }
}
function bestCellIndex(row: ComparisonRow) {
const [first, second] = row.cells.map((cell) => cell.score)
if (first === undefined || second === undefined || first === second) return undefined
if (row.direction === "higher") return first > second ? 0 : 1
return first < second ? 0 : 1
}
function buildRelatedPairs(
catalog: ModelCatalog | undefined,
first: ComparisonModel,
second: ComparisonModel,
): ComparisonPair[] {
const current = [comparisonRef(first), comparisonRef(second)] as const
const alternatives = (catalog?.models ?? [])
.filter((model) => model.id !== first.catalog?.id && model.id !== second.catalog?.id)
.slice(0, 4)
.map(modelRefFromCatalog)
return uniqueComparisonPairs([
...alternatives.slice(0, 3).flatMap((model, index) => [
{ first: current[0], second: model, detail: index === 0 ? "Nearby alternative" : "Related comparison" },
{ first: current[1], second: model, detail: index === 0 ? "Nearby alternative" : "Related comparison" },
]),
]).slice(0, 6)
}
function comparisonRef(model: ComparisonModel): ComparisonModelRef {
return {
name: model.name,
lab: model.lab,
slug: model.slug,
labName: model.labName,
metric: model.stats ? `#${model.stats.rank}` : "Catalog",
}
}
function positiveScore(value: number | undefined) {
return value && value > 0 ? value : undefined
}
function booleanCell(value: boolean | undefined): ComparisonCell {
if (value === undefined) return { value: "Unknown" }
return { value: value ? "Yes" : "No", score: value ? 1 : 0 }
}
function catalogDateScore(value: string | undefined) {
if (!value) return undefined
const match = /^(\d{4})(?:-(\d{2}))?(?:-(\d{2}))?$/.exec(value)
if (!match) return undefined
return Date.UTC(Number(match[1]), match[2] ? Number(match[2]) - 1 : 0, match[3] ? Number(match[3]) : 1)
}
function formatParamName(value: string) {
return value
.replace(/[-_]/g, " ")
.replace(/\b\w/g, (letter) => letter.toUpperCase())
.trim()
}
function formatCatalogLimit(value: number | undefined) {
return value === undefined ? "Unknown" : formatTokens(value)
}
function formatCatalogDate(value: string | undefined) {
if (!value) return "Unknown"
const match = /^(\d{4})(?:-(\d{2}))?(?:-(\d{2}))?$/.exec(value)
if (!match) return value
const year = Number(match[1])
const month = match[2] ? Number(match[2]) - 1 : 0
const day = match[3] ? Number(match[3]) : 1
return new Intl.DateTimeFormat("en", {
month: match[2] ? "short" : undefined,
day: match[3] ? "numeric" : undefined,
year: "numeric",
timeZone: "UTC",
}).format(new Date(Date.UTC(year, month, day)))
}
function formatTokens(value: number) {
if (value >= 1_000_000_000_000)
return `${trimNumber(value / 1_000_000_000_000, value >= 10_000_000_000_000 ? 0 : 1)}T`
if (value >= 1_000_000_000) return `${trimNumber(value / 1_000_000_000, value >= 10_000_000_000 ? 0 : 1)}B`
if (value >= 1_000_000) return `${trimNumber(value / 1_000_000, value >= 10_000_000 ? 0 : 1)}M`
if (value >= 1_000) return `${trimNumber(value / 1_000, value >= 10_000 ? 0 : 1)}K`
return String(Math.round(value))
}
function formatInteger(value: number) {
return new Intl.NumberFormat("en").format(value)
}
function formatPercent(value: number) {
return `${trimNumber(value, value >= 10 ? 1 : 2)}%`
}
function formatMoney(value: number) {
if (value >= 1) return `$${trimNumber(value, 2)}`
if (value > 0) return `$${value.toFixed(4)}`
return "$0"
}
function formatSessionCost(value: number) {
if (value >= 1) return `$${trimNumber(value, 2)}`
if (value >= 0.01) return `$${value.toFixed(2)}`
if (value > 0) return `$${value.toFixed(4)}`
return "$0"
}
function trimNumber(value: number, digits: number) {
return Number(value.toFixed(digits)).toLocaleString("en")
}

View file

@ -8,7 +8,13 @@ import { LocaleLinks } from "../../component/locale-links"
import { useI18n } from "../../context/i18n" import { useI18n } from "../../context/i18n"
import { useLanguage } from "../../context/language" import { useLanguage } from "../../context/language"
import { localizedUrl } from "../../lib/language" import { localizedUrl } from "../../lib/language"
import { comparisonHref, modelRefFromCatalog, type ComparisonModelRef } from "../compare-cards" import {
ComparisonCardsSection,
comparisonHref,
modelRefFromCatalog,
type ComparisonModelRef,
type ComparisonPair,
} from "../compare-cards"
import { formatCatalogLabName, getModelCatalog, type ModelCatalogEntry } from "../model-catalog" import { formatCatalogLabName, getModelCatalog, type ModelCatalogEntry } from "../model-catalog"
import { setStatsPageCacheHeaders } from "../stats-cache" import { setStatsPageCacheHeaders } from "../stats-cache"
import { import {
@ -56,13 +62,6 @@ const categoryTemplates = [
}, },
] as const ] as const
type CompareCategory = {
title: string
description: string
first: ComparisonModelRef
second: ComparisonModelRef
avatars: ComparisonModelRef[]
}
type CompareSlot = "first" | "second" type CompareSlot = "first" | "second"
export default function ModelCompareIndex() { export default function ModelCompareIndex() {
@ -162,16 +161,12 @@ export default function ModelCompareIndex() {
<CompareHomeSelector models={featuredModels()} /> <CompareHomeSelector models={featuredModels()} />
</Show> </Show>
</section> </section>
<Show when={categories().length > 0}> <ComparisonCardsSection
<section id="model-comparison" data-section="compare-home-related"> pairs={categories()}
<p data-slot="section-title"> title="Related comparisons"
<strong>Related comparisons.</strong> <span>Other model pairs to check.</span> description="Other model pairs to check."
</p> variant="featured"
<div data-component="compare-home-card-grid"> />
<For each={categories()}>{(category) => <CompareHomeCard category={category} />}</For>
</div>
</section>
</Show>
</div> </div>
<Footer <Footer
themePreference={themePreference()} themePreference={themePreference()}
@ -443,35 +438,6 @@ function HeroModelStack() {
) )
} }
function CompareHomeCard(props: { category: CompareCategory }) {
return (
<a
data-component="compare-home-card"
href={comparisonHref(props.category.first, props.category.second)}
aria-label={`${props.category.title}: ${props.category.first.name} vs ${props.category.second.name}`}
>
<span data-slot="compare-home-card-head">
<span>
<strong>{props.category.title}</strong>
<em>{props.category.description}</em>
</span>
<b aria-hidden="true" />
</span>
<span data-slot="compare-home-card-divider" aria-hidden="true" />
<span data-slot="compare-home-card-models">
<span>{props.category.first.name}</span>
<i aria-hidden="true">·</i>
<span>{props.category.second.name}</span>
</span>
<span data-slot="compare-home-card-avatars" aria-hidden="true">
<For each={props.category.avatars}>
{(model) => <LabLogo lab={model.lab} label={model.name} size="small" />}
</For>
</span>
</a>
)
}
function ModelAvatar(props: { model: ModelCatalogEntry; size: "large" | "small" | "tiny" }) { function ModelAvatar(props: { model: ModelCatalogEntry; size: "large" | "small" | "tiny" }) {
return <LabLogo lab={props.model.lab} label={props.model.name} size={props.size} /> return <LabLogo lab={props.model.lab} label={props.model.name} size={props.size} />
} }
@ -486,8 +452,8 @@ function LabLogo(props: { lab: string; label: string; size: "large" | "small" |
) )
} }
function buildComparisonCategories(models: ModelCatalogEntry[]): CompareCategory[] { function buildComparisonCategories(models: ModelCatalogEntry[]): ComparisonPair[] {
return categoryTemplates.reduce<{ keys: Set<string>; categories: CompareCategory[] }>( return categoryTemplates.reduce<{ keys: Set<string>; categories: ComparisonPair[] }>(
(result, template, index) => { (result, template, index) => {
const candidates = categoryCandidates(template.kind, models) const candidates = categoryCandidates(template.kind, models)
const pair = categoryPair(candidates, models, index, result.keys) const pair = categoryPair(candidates, models, index, result.keys)
@ -496,11 +462,10 @@ function buildComparisonCategories(models: ModelCatalogEntry[]): CompareCategory
const first = modelRefFromCatalog(pair.first) const first = modelRefFromCatalog(pair.first)
const second = modelRefFromCatalog(pair.second) const second = modelRefFromCatalog(pair.second)
result.categories.push({ result.categories.push({
title: template.title, detail: template.title,
description: template.description, description: template.description,
first, first,
second, second,
avatars: [first, second],
}) })
return result return result
}, },

View file

@ -87,6 +87,11 @@
display: none !important; display: none !important;
} }
[data-page="stats"][data-layout="compare-detail"] {
/* The table contains its own wide rows; keep the page itself out of the horizontal scroll chain. */
overflow-x: visible;
}
[data-page="stats"] section[id], [data-page="stats"] section[id],
[data-page="stats"] [data-component="leaderboard"][id] { [data-page="stats"] [data-component="leaderboard"][id] {
scroll-margin-top: 88px; scroll-margin-top: 88px;
@ -5852,7 +5857,6 @@
} }
[data-page="stats"] [data-slot="compare-home-card-head"] b { [data-page="stats"] [data-slot="compare-home-card-head"] b {
position: relative;
display: grid; display: grid;
place-items: center; place-items: center;
width: 32px; width: 32px;
@ -5863,32 +5867,10 @@
box-shadow: 0 1px 1.5px #0000000f; box-shadow: 0 1px 1.5px #0000000f;
} }
[data-page="stats"] [data-slot="compare-home-card-head"] b::before { [data-page="stats"] [data-slot="compare-home-card-head"] b svg {
position: absolute; display: block;
top: 8px; width: 16px;
left: 8px; height: 16px;
width: 3px;
height: 3px;
content: "";
background: currentColor;
box-shadow:
7px 0 currentColor,
0 7px currentColor;
color: var(--stats-muted);
}
[data-page="stats"] [data-slot="compare-home-card-head"] b::after {
position: absolute;
right: 5px;
bottom: 5px;
width: 12px;
height: 12px;
box-sizing: border-box;
content: "";
background:
radial-gradient(circle at 4px 4px, transparent 2.5px, var(--stats-muted) 2.75px 4px, transparent 4.25px),
linear-gradient(var(--stats-muted) 0 0) 7px 8px / 5px 1.5px no-repeat;
transform: rotate(45deg);
} }
[data-page="stats"] [data-slot="compare-home-card-divider"] { [data-page="stats"] [data-slot="compare-home-card-divider"] {
@ -5923,6 +5905,858 @@
min-width: 0; min-width: 0;
} }
[data-page="stats"] [data-section="compare-detail-hero"] {
position: relative;
display: grid;
align-content: end;
gap: 24px;
min-height: 316px;
box-sizing: border-box;
padding: 128px 40px 40px;
color: var(--stats-text);
}
[data-page="stats"] [data-slot="compare-detail-hero-grid"] {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 24px;
align-items: center;
}
[data-page="stats"] [data-section="compare-detail-hero"] h1 {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 16px;
min-width: 0;
margin: 0;
color: var(--stats-text);
font-size: 40px;
font-weight: 500;
line-height: 60px;
letter-spacing: 0;
}
[data-page="stats"] [data-slot="compare-detail-actions"] {
display: inline-flex;
align-items: center;
justify-content: flex-end;
gap: 8px;
min-width: 0;
}
[data-page="stats"] button[data-slot="compare-detail-action"],
[data-page="stats"] a[data-slot="compare-detail-action"] {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
height: 32px;
box-sizing: border-box;
padding: 0 12px 0 8px;
overflow: hidden;
border: 0;
border-radius: 0;
color: var(--stats-text);
background: var(--stats-bg);
box-shadow:
0 0 0 0.5px color-mix(in srgb, var(--stats-text) 14%, transparent),
0 1px 1.5px color-mix(in srgb, #000000 10%, transparent);
cursor: pointer;
font: inherit;
font-size: 13px;
font-weight: 500;
line-height: 1.1;
text-decoration: none;
white-space: nowrap;
}
[data-page="stats"] button[data-slot="compare-detail-action"]::before,
[data-page="stats"] a[data-slot="compare-detail-action"]::before {
position: absolute;
inset: 0 0 auto;
height: 16px;
background: linear-gradient(to bottom, rgb(255 255 255 / 7%), transparent);
content: "";
pointer-events: none;
}
[data-page="stats"] [data-slot="compare-detail-action"] > * {
position: relative;
z-index: 1;
}
[data-page="stats"] button[data-slot="compare-detail-action"][aria-pressed] {
gap: 12px;
}
[data-page="stats"] button[data-slot="compare-detail-action"]:hover,
[data-page="stats"] button[data-slot="compare-detail-action"]:focus-visible,
[data-page="stats"] a[data-slot="compare-detail-action"]:hover,
[data-page="stats"] a[data-slot="compare-detail-action"]:focus-visible {
background: var(--stats-layer);
outline: none;
text-decoration: none;
}
[data-page="stats"] button[data-slot="compare-detail-action"]:disabled {
color: var(--stats-muted);
background: var(--stats-bg);
cursor: not-allowed;
}
[data-page="stats"] button[data-slot="compare-detail-action"][data-active="true"] {
color: var(--stats-text);
background: var(--stats-bg);
}
[data-page="stats"] [data-slot="compare-detail-highlight-icon"] {
display: flex;
align-items: center;
gap: 1px;
width: 31px;
height: 16px;
box-sizing: border-box;
padding: 1px;
overflow: hidden;
background: var(--stats-line-strong);
}
[data-page="stats"] [data-slot="compare-detail-highlight-icon"] i {
flex: 0 0 14px;
width: 14px;
height: 14px;
background: transparent;
}
[data-page="stats"]
[data-slot="compare-detail-action"][aria-pressed="true"]
[data-slot="compare-detail-highlight-icon"] {
background: var(--stats-accent);
}
[data-page="stats"]
[data-slot="compare-detail-action"][aria-pressed="false"]
[data-slot="compare-detail-highlight-icon"]
i:first-child,
[data-page="stats"]
[data-slot="compare-detail-action"][aria-pressed="true"]
[data-slot="compare-detail-highlight-icon"]
i:last-child {
background: #fafafa;
box-shadow:
0 0 0 0.5px rgb(0 0 0 / 12%),
0 1px 2px -1px rgb(0 0 0 / 8%),
0 2px 4px rgb(0 0 0 / 4%);
}
[data-page="stats"] [data-slot="compare-detail-action"] [data-slot="compare-home-plus"] {
display: grid;
place-items: center;
width: 16px;
height: 16px;
font-size: 16px;
line-height: 16px;
}
[data-page="stats"] [data-section="compare-radar"] {
position: relative;
display: grid;
grid-template-columns: minmax(0, 1fr) 800px minmax(0, 1fr);
width: 100%;
height: 800px;
box-sizing: border-box;
border-right: 1px solid var(--stats-line);
border-left: 1px solid var(--stats-line);
color: var(--stats-text);
background: var(--stats-bg);
}
[data-page="stats"] [data-slot="compare-radar-legend"] {
display: flex;
flex-direction: column;
gap: 14px;
min-width: 0;
margin: 0;
padding: 40px;
list-style: none;
}
[data-page="stats"] [data-slot="compare-radar-legend"] li {
display: grid;
grid-template-columns: 6px minmax(0, 1fr);
gap: 12px;
align-items: start;
min-width: 0;
}
[data-page="stats"] [data-slot="compare-radar-legend"] li > i {
width: 6px;
height: 6px;
margin-top: 6px;
}
[data-page="stats"] [data-slot="compare-radar-legend"] li > span {
display: grid;
gap: 2px;
min-width: 0;
}
[data-page="stats"] [data-slot="compare-radar-legend"] strong,
[data-page="stats"] [data-slot="compare-radar-legend"] small {
overflow: hidden;
font-size: 13px;
line-height: 18px;
text-overflow: ellipsis;
white-space: nowrap;
}
[data-page="stats"] [data-slot="compare-radar-legend"] strong {
color: var(--stats-text);
font-weight: 500;
}
[data-page="stats"] [data-slot="compare-radar-legend"] small {
color: var(--stats-muted);
font-weight: 400;
}
[data-page="stats"] [data-slot="compare-radar-chart"] {
position: relative;
width: 800px;
height: 800px;
}
[data-page="stats"] [data-slot="compare-radar-plot"] {
position: absolute;
inset: 17.5%;
}
[data-page="stats"] [data-slot="compare-radar-plot"] svg {
display: block;
width: 100%;
height: 100%;
overflow: visible;
}
[data-page="stats"] [data-slot="compare-radar-grid"] polygon,
[data-page="stats"] [data-slot="compare-radar-grid"] line {
fill: none;
stroke: var(--stats-line);
stroke-width: 1px;
vector-effect: non-scaling-stroke;
}
[data-page="stats"] [data-slot="compare-radar-area"],
[data-page="stats"] [data-slot="compare-radar-line"] {
stroke: currentColor;
stroke-width: 1.5px;
stroke-linejoin: round;
vector-effect: non-scaling-stroke;
}
[data-page="stats"] [data-slot="compare-radar-area"] {
fill: currentColor;
fill-opacity: 0.09;
}
[data-page="stats"] [data-slot="compare-radar-line"] {
fill: none;
}
[data-page="stats"] [data-slot="compare-radar-point"] {
fill: currentColor;
stroke: currentColor;
stroke-width: 1px;
vector-effect: non-scaling-stroke;
}
[data-page="stats"] [data-slot="compare-radar-point-hit"] {
fill: transparent;
cursor: pointer;
}
[data-page="stats"] [data-slot="compare-radar-axis"] {
position: absolute;
top: var(--compare-radar-axis-y);
left: var(--compare-radar-axis-x);
max-width: 160px;
color: var(--stats-text);
font-size: 16px;
font-weight: 400;
line-height: 20px;
text-align: center;
cursor: pointer;
transform: translate(var(--compare-radar-axis-translate-x), -50%);
white-space: nowrap;
}
[data-page="stats"] [data-slot="compare-radar-axis"]:focus-visible {
outline: none;
}
[data-page="stats"] [data-slot="compare-radar-axis-label"] {
display: inline-flex;
align-items: center;
height: 24px;
box-sizing: border-box;
padding: 0 8px;
margin: 0 -8px;
}
[data-page="stats"] [data-slot="compare-radar-axis"][data-active="true"] [data-slot="compare-radar-axis-label"] {
background: var(--stats-layer-2);
}
[data-page="stats"] [data-slot="compare-radar-axis"]:focus-visible [data-slot="compare-radar-axis-label"] {
outline: 1px solid var(--stats-text);
outline-offset: 2px;
}
[data-page="stats"] [data-slot="compare-radar-tooltip"] {
position: absolute;
z-index: 5;
top: var(--compare-radar-tooltip-y);
left: clamp(104px, var(--compare-radar-tooltip-x), calc(100% - 104px));
display: flex;
flex-direction: column;
gap: 4px;
width: 192px;
box-sizing: border-box;
padding: 8px;
color: var(--stats-text);
background: var(--stats-layer);
box-shadow:
0 0 0 0.5px color-mix(in srgb, var(--stats-text) 12%, transparent),
0 4px 8px color-mix(in srgb, #000000 8%, transparent),
0 8px 16px color-mix(in srgb, #000000 4%, transparent);
pointer-events: none;
transform: translate(-50%, var(--compare-radar-tooltip-translate-y));
}
[data-page="stats"] [data-slot="compare-radar-tooltip"] strong,
[data-page="stats"] [data-slot="compare-radar-tooltip"] p {
margin: 0;
font-size: 11px;
letter-spacing: 0;
}
[data-page="stats"] [data-slot="compare-radar-tooltip"] strong {
color: var(--stats-text);
font-weight: 500;
line-height: 12px;
}
[data-page="stats"] [data-slot="compare-radar-tooltip"] p {
color: var(--stats-muted);
font-weight: 400;
line-height: 16px;
}
[data-page="stats"] [data-slot="compare-radar-data"] {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
overflow: hidden;
border: 0;
clip-path: inset(50%);
white-space: nowrap;
}
[data-page="stats"] [data-component="compare-detail-table"] {
--compare-detail-label-column: 292px;
--compare-detail-model-column-min: 360px;
position: relative;
}
[data-page="stats"] [data-component="compare-detail-heading-scroll"] {
position: sticky;
top: 72px;
z-index: 9;
overflow-x: auto;
overscroll-behavior-x: none;
background: var(--stats-bg);
scrollbar-width: none;
}
[data-page="stats"] [data-component="compare-detail-heading-scroll"]::-webkit-scrollbar {
display: none;
}
[data-page="stats"] [data-component="compare-detail-body-scroll"] {
overflow-x: auto;
overscroll-behavior-x: none;
}
[data-page="stats"] [data-section="compare-detail-selector"],
[data-page="stats"] [data-section="compare-detail-matrix"] {
min-width: calc(
var(--compare-detail-label-column) + var(--compare-detail-model-column-min) + var(--compare-detail-model-column-min)
);
}
[data-page="stats"] [data-section="compare-detail-selector"] {
position: relative;
height: 98px;
min-height: 98px;
box-sizing: border-box;
border: 0;
}
[data-page="stats"] [data-section="compare-detail-selector"]::after {
position: absolute;
z-index: 5;
inset: 0;
border-top: 1px solid var(--stats-line);
border-bottom: 1px solid var(--stats-line);
content: "";
pointer-events: none;
}
[data-page="stats"] [data-component="compare-detail-selector-grid"] {
display: grid;
grid-template-columns: var(--compare-detail-grid);
height: 100%;
}
[data-page="stats"]
[data-component="compare-detail-table"][data-model-count="3"]
[data-section="compare-detail-selector"],
[data-page="stats"]
[data-component="compare-detail-table"][data-model-count="3"]
[data-section="compare-detail-matrix"] {
min-width: calc(
var(--compare-detail-label-column) + var(--compare-detail-model-column-min) +
var(--compare-detail-model-column-min) + var(--compare-detail-model-column-min)
);
}
[data-page="stats"]
[data-component="compare-detail-table"][data-model-count="4"]
[data-section="compare-detail-selector"],
[data-page="stats"]
[data-component="compare-detail-table"][data-model-count="4"]
[data-section="compare-detail-matrix"] {
min-width: calc(
var(--compare-detail-label-column) + var(--compare-detail-model-column-min) +
var(--compare-detail-model-column-min) + var(--compare-detail-model-column-min) +
var(--compare-detail-model-column-min)
);
}
[data-page="stats"]
[data-component="compare-detail-table"][data-model-count="5"]
[data-section="compare-detail-selector"],
[data-page="stats"]
[data-component="compare-detail-table"][data-model-count="5"]
[data-section="compare-detail-matrix"] {
min-width: calc(
var(--compare-detail-label-column) + var(--compare-detail-model-column-min) +
var(--compare-detail-model-column-min) + var(--compare-detail-model-column-min) +
var(--compare-detail-model-column-min) + var(--compare-detail-model-column-min)
);
}
[data-page="stats"]
[data-component="compare-detail-table"][data-model-count="6"]
[data-section="compare-detail-selector"],
[data-page="stats"]
[data-component="compare-detail-table"][data-model-count="6"]
[data-section="compare-detail-matrix"] {
min-width: calc(
var(--compare-detail-label-column) + var(--compare-detail-model-column-min) +
var(--compare-detail-model-column-min) + var(--compare-detail-model-column-min) +
var(--compare-detail-model-column-min) + var(--compare-detail-model-column-min) +
var(--compare-detail-model-column-min)
);
}
[data-page="stats"] [data-slot="compare-detail-selector-spacer"] {
position: sticky;
left: 0;
z-index: 3;
min-width: 0;
box-sizing: border-box;
border-right: 1px solid var(--stats-line);
border-left: 1px solid var(--stats-line);
background: var(--stats-bg);
}
[data-page="stats"] button[data-slot="compare-detail-select-model"] {
position: relative;
display: flex;
align-items: center;
gap: 12px;
min-width: 0;
height: 100%;
box-sizing: border-box;
padding: 0 40px;
border: 0;
appearance: none;
border-radius: 0;
color: var(--stats-text);
background: var(--stats-bg);
cursor: pointer;
font: inherit;
text-align: left;
}
[data-page="stats"] button[data-slot="compare-detail-select-model"][data-column]:not([data-column="0"]) {
border-left: 1px solid var(--stats-line);
}
[data-page="stats"] button[data-slot="compare-detail-select-model"][data-last="true"] {
border-right: 1px solid var(--stats-line);
}
[data-page="stats"] button[data-slot="compare-detail-select-model"]:hover,
[data-page="stats"] button[data-slot="compare-detail-select-model"]:focus-visible {
background: var(--stats-layer);
outline: none;
}
[data-page="stats"] [data-slot="compare-detail-select-name"] {
min-width: 0;
overflow: hidden;
color: var(--stats-text);
font-size: 13px;
font-weight: 500;
line-height: 18px;
text-overflow: ellipsis;
white-space: nowrap;
}
[data-page="stats"] button[data-slot="compare-detail-select-model"] svg {
flex: 0 0 auto;
color: var(--stats-muted);
}
[data-page="stats"] [data-section="compare-detail-matrix"] {
position: relative;
color: var(--stats-text);
border-bottom: 1px solid var(--stats-line);
}
[data-page="stats"] [data-component="compare-detail-matrix"] {
position: relative;
}
[data-page="stats"] [data-slot="compare-detail-group"] {
display: grid;
grid-template-columns: var(--compare-detail-grid);
}
[data-page="stats"] [data-slot="compare-detail-group"] + [data-slot="compare-detail-group"] {
border-top: 1px solid var(--stats-line);
}
[data-page="stats"] [data-slot="compare-detail-label"],
[data-page="stats"] [data-slot="compare-detail-value"] {
display: flex;
align-items: center;
min-width: 0;
min-height: 56px;
box-sizing: border-box;
padding: 0 40px;
font-size: 14px;
line-height: 24px;
}
[data-page="stats"] [data-slot="compare-detail-label"] {
position: sticky;
left: 0;
z-index: 3;
border-right: 1px solid var(--stats-line);
border-left: 1px solid var(--stats-line);
color: var(--stats-muted);
background: var(--stats-bg);
font-weight: 400;
}
[data-page="stats"] [data-slot="compare-detail-value"][data-column]:not([data-column="0"]) {
border-left: 1px solid var(--stats-line);
}
[data-page="stats"] [data-slot="compare-detail-value"][data-last="true"] {
border-right: 1px solid var(--stats-line);
}
[data-page="stats"] [data-slot="compare-detail-label"][data-spacer="true"],
[data-page="stats"] [data-slot="compare-detail-value"][data-spacer="true"] {
min-height: 40px;
padding: 0;
}
[data-page="stats"] [data-slot="compare-detail-label"][data-heading="true"] {
gap: 12px;
color: var(--stats-text);
font-weight: 500;
}
[data-page="stats"] [data-slot="compare-detail-label"][data-heading="true"] strong {
font-weight: 600;
}
[data-page="stats"] [data-slot="compare-detail-label"][data-heading="true"] span {
display: inline-flex;
align-items: center;
height: 24px;
padding: 0 8px;
color: var(--stats-muted);
background: var(--stats-layer-2);
font-size: 12px;
font-weight: 500;
line-height: 1;
}
[data-page="stats"] [data-slot="compare-detail-value"] {
justify-content: flex-end;
color: var(--stats-text);
font-weight: 400;
text-align: right;
}
[data-page="stats"] [data-slot="compare-detail-value"][data-best="true"] {
background: color-mix(in srgb, #198b43 8%, transparent);
}
[data-page="stats"] [data-slot="compare-detail-value-main"],
[data-page="stats"] [data-slot="compare-detail-value-link"] {
display: inline-flex;
align-items: center;
justify-content: flex-end;
gap: 12px;
min-width: 0;
}
[data-page="stats"] [data-slot="compare-detail-value-link"] {
color: inherit;
text-decoration: underline;
text-decoration-color: color-mix(in srgb, var(--stats-text) 30%, transparent);
text-underline-offset: 2px;
}
[data-page="stats"] [data-slot="compare-detail-unit"] {
color: var(--stats-muted);
}
[data-page="stats"] [data-slot="compare-detail-trend"] {
display: inline-flex;
align-items: center;
height: 16px;
padding: 0 5px;
color: var(--stats-muted);
background: var(--stats-layer-2);
font-size: 11px;
font-weight: 600;
line-height: 1;
}
[data-page="stats"] [data-slot="compare-detail-trend"][data-trend="up"] {
color: #198b43;
background: #e2f8e9;
}
[data-page="stats"] [data-slot="compare-detail-trend"][data-trend="down"] {
color: #c93737;
background: #fae8e8;
}
[data-page="stats"] [data-slot="compare-detail-boolean"] {
display: inline-flex;
align-items: center;
height: 24px;
padding: 0 8px;
font-size: 12px;
font-weight: 700;
line-height: 1;
}
[data-page="stats"] [data-slot="compare-detail-boolean"][data-value="true"] {
color: #198b43;
background: #e2f8e9;
}
[data-page="stats"] [data-slot="compare-detail-boolean"][data-value="false"] {
color: #c93737;
background: #fae8e8;
}
[data-page="stats"] [data-slot="compare-detail-value"][data-chart="true"] {
display: grid;
align-content: center;
gap: 12px;
min-height: 102px;
}
[data-page="stats"] [data-slot="compare-detail-bars"] {
display: flex;
align-items: flex-end;
gap: 2px;
width: 100%;
height: 40px;
}
[data-page="stats"] [data-slot="compare-detail-bars"] i {
flex: 1 1 0;
min-width: 2px;
max-width: 6px;
background: color-mix(in srgb, var(--stats-text) 18%, transparent);
}
[data-page="stats"] [data-slot="compare-detail-bar-dates"] {
display: flex;
justify-content: space-between;
gap: 16px;
color: var(--stats-muted);
font-size: 11px;
font-weight: 500;
line-height: 14px;
}
[data-page="stats"] [data-slot="compare-detail-no-chart"] {
justify-self: end;
color: var(--stats-muted);
font-size: 12px;
}
@media (max-width: 80rem) {
[data-page="stats"] [data-section="compare-detail-hero"] {
min-height: 280px;
padding: 104px 32px 40px;
}
[data-page="stats"] [data-slot="compare-detail-hero-grid"] {
grid-template-columns: 1fr;
align-items: start;
}
[data-page="stats"] [data-slot="compare-detail-actions"] {
justify-content: flex-start;
}
[data-page="stats"] [data-section="compare-radar"] {
grid-template-columns: minmax(180px, 208px) minmax(0, 1fr);
}
[data-page="stats"] [data-slot="compare-radar-legend"] {
padding-right: 24px;
padding-left: 32px;
}
[data-page="stats"] [data-slot="compare-radar-chart"] {
align-self: center;
width: min(100%, 800px);
height: auto;
aspect-ratio: 1;
}
[data-page="stats"] [data-component="compare-detail-table"] {
--compare-detail-label-column: 220px;
--compare-detail-model-column-min: 320px;
}
[data-page="stats"] button[data-slot="compare-detail-select-model"],
[data-page="stats"] [data-slot="compare-detail-label"],
[data-page="stats"] [data-slot="compare-detail-value"] {
padding-right: 32px;
padding-left: 32px;
}
}
@media (max-width: 60rem) {
[data-page="stats"] [data-section="compare-detail-hero"] {
gap: 20px;
min-height: 316px;
padding: 72px 24px 40px;
}
[data-page="stats"] [data-section="compare-detail-hero"] h1 {
gap: 12px;
font-size: 32px;
line-height: 42px;
}
[data-page="stats"] [data-slot="compare-detail-actions"] {
flex-wrap: wrap;
}
[data-page="stats"] [data-section="compare-radar"] {
grid-template-columns: minmax(0, 1fr);
height: auto;
padding: 32px 24px 24px;
}
[data-page="stats"] [data-slot="compare-radar-legend"] {
flex-direction: row;
flex-wrap: wrap;
gap: 16px 32px;
padding: 0 0 16px;
}
[data-page="stats"] [data-slot="compare-radar-legend"] li {
flex: 1 1 180px;
}
[data-page="stats"] [data-slot="compare-radar-chart"] {
justify-self: center;
width: min(100%, 720px);
}
}
@media (max-width: 40rem) {
[data-page="stats"] [data-section="compare-detail-hero"] h1 {
align-items: flex-start;
flex-direction: column;
}
[data-page="stats"] [data-slot="compare-detail-action"] {
width: 100%;
}
[data-page="stats"] [data-section="compare-radar"] {
padding-right: 16px;
padding-left: 16px;
}
[data-page="stats"] [data-slot="compare-radar-legend"] {
gap: 12px;
}
[data-page="stats"] [data-slot="compare-radar-legend"] li {
flex-basis: 140px;
}
[data-page="stats"] [data-slot="compare-radar-axis"] {
left: var(--compare-radar-axis-mobile-x);
max-width: 104px;
font-size: 13px;
line-height: 16px;
white-space: normal;
}
[data-page="stats"] [data-slot="compare-radar-axis-label"] {
padding-right: 6px;
padding-left: 6px;
margin-right: -6px;
margin-left: -6px;
}
[data-page="stats"] [data-component="compare-detail-table"] {
--compare-detail-label-column: 188px;
--compare-detail-model-column-min: 246px;
}
[data-page="stats"] button[data-slot="compare-detail-select-model"],
[data-page="stats"] [data-slot="compare-detail-label"],
[data-page="stats"] [data-slot="compare-detail-value"] {
padding-right: 24px;
padding-left: 24px;
}
}
[data-page="stats"] [data-component="compare-model-modal-scrim"] { [data-page="stats"] [data-component="compare-model-modal-scrim"] {
position: fixed; position: fixed;
inset: 0; inset: 0;

View file

@ -194,6 +194,7 @@ export default function StatsHome() {
pairs={homeComparisonPairs(stats().leaderboard["All Users"]["2M"])} pairs={homeComparisonPairs(stats().leaderboard["All Users"]["2M"])}
title="Model Comparisons" title="Model Comparisons"
description="Popular model pairs from the leaderboard." description="Popular model pairs from the leaderboard."
variant="featured"
/> />
</> </>
)} )}

View file

@ -56,14 +56,18 @@ export type ModelCatalog = {
labs: ModelCatalogLab[] labs: ModelCatalogLab[]
} }
export const getModelCatalog = query(async () => { export async function loadModelCatalog() {
"use server"
const [models, pricing, labs] = await Promise.all([ const [models, pricing, labs] = await Promise.all([
fetchCatalogPayload(modelCatalogSourceUrl), fetchCatalogPayload(modelCatalogSourceUrl),
fetchCatalogPayload(modelCatalogPricingUrl), fetchCatalogPayload(modelCatalogPricingUrl),
fetchLabCatalogPayload(modelCatalogLabSourceUrl), fetchLabCatalogPayload(modelCatalogLabSourceUrl),
]) ])
return buildModelCatalog(models, pricing, labs) return buildModelCatalog(models, pricing, labs)
}
export const getModelCatalog = query(async () => {
"use server"
return loadModelCatalog()
}, "getModelCatalog") }, "getModelCatalog")
export function findModelCatalogEntry(catalog: ModelCatalog, model: string, lab?: string) { export function findModelCatalogEntry(catalog: ModelCatalog, model: string, lab?: string) {

View file

@ -0,0 +1,113 @@
import { getStatsHomeData } from "@opencode-ai/stats-core/domain/home"
import { runtime } from "@opencode-ai/stats-core/runtime"
import {
canonicalFamilyComparisonPath,
canonicalModelComparisonPath,
comparisonFamilies,
comparisonSitemapModels,
latestFamilyComparisonPath,
resolveComparisonFamily,
} from "../lib/comparison-pages"
import { baseUrl } from "../lib/language"
import { loadModelCatalog } from "./model-catalog"
type SitemapEntry = {
path: string
lastmod?: string
}
export async function GET() {
const [catalog, stats] = await Promise.all([
loadModelCatalog(),
runtime.runPromise(getStatsHomeData()).catch(() => undefined),
])
const lastmod = sitemapDate(
stats?.updatedAt,
...catalog.models.map((model) => model.lastUpdated ?? model.releaseDate),
)
const families = comparisonFamilies.flatMap((family) => {
const resolved = resolveComparisonFamily(catalog, family.slug)
return resolved ? [resolved] : []
})
const familyComparisons = families.flatMap((first, index) =>
families.slice(index + 1).map((second) => ({
path: canonicalFamilyComparisonPath(first, second),
lastmod: sitemapDate(
stats?.updatedAt,
first.model.lastUpdated ?? first.model.releaseDate,
second.model.lastUpdated ?? second.model.releaseDate,
),
})),
)
const models = comparisonSitemapModels(catalog, stats?.leaderboard["All Users"]["2M"])
const modelComparisons = models.flatMap((first, index) =>
models.slice(index + 1).flatMap((second) => {
if (latestFamilyComparisonPath(catalog, first, second)) return []
return [
{
path: canonicalModelComparisonPath(first, second),
lastmod: sitemapDate(
stats?.updatedAt,
first.lastUpdated ?? first.releaseDate,
second.lastUpdated ?? second.releaseDate,
),
},
]
}),
)
const entries = uniqueSitemapEntries([{ path: "/data/compare", lastmod }, ...familyComparisons, ...modelComparisons])
return new Response(sitemapXml(entries), {
headers: {
"Cache-Control": "public, max-age=300, s-maxage=3600, stale-while-revalidate=86400",
"Content-Type": "application/xml; charset=utf-8",
},
})
}
function uniqueSitemapEntries(entries: SitemapEntry[]) {
return Object.values(
entries.reduce<Record<string, SitemapEntry>>((result, entry) => {
result[entry.path] = entry
return result
}, {}),
).toSorted((a, b) => a.path.localeCompare(b.path))
}
function sitemapXml(entries: SitemapEntry[]) {
const urls = entries
.map(
(entry) => ` <url>
<loc>${escapeXml(new URL(entry.path, baseUrl).toString())}</loc>${
entry.lastmod
? `
<lastmod>${entry.lastmod}</lastmod>`
: ""
}
</url>`,
)
.join("\n")
return `<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
${urls}
</urlset>`
}
function sitemapDate(...values: (string | undefined | null)[]) {
const dates = values.flatMap((value) => {
if (!value) return []
const date = new Date(value)
return Number.isNaN(date.getTime()) ? [] : [date]
})
if (dates.length === 0) return undefined
return new Date(Math.min(Date.now(), Math.max(...dates.map((date) => date.getTime())))).toISOString().slice(0, 10)
}
function escapeXml(value: string) {
return value
.replaceAll("&", "&amp;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&apos;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
}

View file

@ -113,32 +113,32 @@ const poll: (
return yield* poll(client, queryExecutionId, attempt + 1) return yield* poll(client, queryExecutionId, attempt + 1)
}) })
const results: ( const results: (client: AwsAthenaClient, queryExecutionId: string) => Effect.Effect<AthenaData[], AthenaQueryError> =
client: AwsAthenaClient, Effect.fn("Athena.results")(function* (client: AwsAthenaClient, queryExecutionId: string) {
queryExecutionId: string, // Accumulate pages iteratively; recursive spreads copied every previously
nextToken?: string, // fetched row per page and blew up memory on large result sets.
) => Effect.Effect<AthenaData[], AthenaQueryError> = Effect.fn("Athena.results")(function* ( const rows: AthenaData[] = []
client: AwsAthenaClient, let nextToken: string | undefined
queryExecutionId: string, while (true) {
nextToken?: string, const result = yield* Effect.tryPromise({
) { try: () =>
const result = yield* Effect.tryPromise({ client.send(
try: () => new GetQueryResultsCommand({
client.send( QueryExecutionId: queryExecutionId,
new GetQueryResultsCommand({ NextToken: nextToken,
QueryExecutionId: queryExecutionId, MaxResults: ATHENA_PAGE_SIZE,
NextToken: nextToken, }),
MaxResults: ATHENA_PAGE_SIZE, ),
}), catch: (cause) =>
), new AthenaQueryError({ message: "Failed to read Athena stats results", queryExecutionId, cause }),
catch: (cause) => new AthenaQueryError({ message: "Failed to read Athena stats results", queryExecutionId, cause }), })
const columns = result.ResultSet?.ResultSetMetadata?.ColumnInfo?.map((item) => item.Name ?? "") ?? []
// The first page starts with the header row.
for (const row of (result.ResultSet?.Rows ?? []).slice(nextToken ? 0 : 1)) rows.push(rowData(columns, row))
if (!result.NextToken) return rows
nextToken = result.NextToken
}
}) })
const columns = result.ResultSet?.ResultSetMetadata?.ColumnInfo?.map((item) => item.Name ?? "") ?? []
const rows = (result.ResultSet?.Rows ?? []).slice(nextToken ? 0 : 1).map((row) => rowData(columns, row))
if (!result.NextToken) return rows
return [...rows, ...(yield* results(client, queryExecutionId, result.NextToken))]
})
function rowData(columns: string[], row: Row): AthenaData { function rowData(columns: string[], row: Row): AthenaData {
return Object.fromEntries( return Object.fromEntries(

View file

@ -94,10 +94,15 @@ export type StatsModelComparisonEntry = {
tokenShare: number tokenShare: number
tokenChange: number tokenChange: number
totals: StatsModelData["totals"] totals: StatsModelData["totals"]
usage: ModelUsagePoint[]
}
export type StatsModelComparisonInput = {
provider: string
model: string
} }
export type StatsModelComparisonData = { export type StatsModelComparisonData = {
updatedAt: string | null updatedAt: string | null
models: [StatsModelComparisonEntry | null, StatsModelComparisonEntry | null] models: (StatsModelComparisonEntry | null)[]
} }
export type StatsHomeData = { export type StatsHomeData = {
updatedAt: string | null updatedAt: string | null
@ -289,27 +294,35 @@ function dateValue(value: unknown) {
return value instanceof Date ? value : new Date(stringValue(value)) return value instanceof Date ? value : new Date(stringValue(value))
} }
export const getStatsModelComparisonData: ( export const getStatsModelsComparisonData: (
firstProvider: string, models: readonly StatsModelComparisonInput[],
firstModel: string, ) => Effect.Effect<StatsModelComparisonData, DatabaseError, ModelStatRepo> = Effect.fn("StatsModelsComparison.getData")(
secondProvider: string, function* (models) {
secondModel: string,
) => Effect.Effect<StatsModelComparisonData, DatabaseError, ModelStatRepo> = Effect.fn("StatsModelComparison.getData")(
function* (firstProvider, firstModel, secondProvider, secondModel) {
const modelStats = yield* ModelStatRepo const modelStats = yield* ModelStatRepo
const rows = yield* modelStats.listDaily() const rows = yield* modelStats.listDaily()
const first = toComparisonEntry(buildStatsModelData(firstModel, rows, [], firstProvider)) const entries = models.map((model) => toComparisonEntry(buildStatsModelData(model.model, rows, [], model.provider)))
const second = toComparisonEntry(buildStatsModelData(secondModel, rows, [], secondProvider)) const latest = entries
const latest = [first?.updatedAt, second?.updatedAt] .map((model) => model?.updatedAt)
.flatMap((value) => (value ? [dateTime(value)] : [])) .flatMap((value) => (value ? [dateTime(value)] : []))
.toSorted((a, b) => b - a)[0] .toSorted((a, b) => b - a)[0]
return { return {
updatedAt: latest === undefined ? null : new Date(latest).toISOString(), updatedAt: latest === undefined ? null : new Date(latest).toISOString(),
models: [first, second], models: entries,
} }
}, },
) )
export const getStatsModelComparisonData = (
firstProvider: string,
firstModel: string,
secondProvider: string,
secondModel: string,
) =>
getStatsModelsComparisonData([
{ provider: firstProvider, model: firstModel },
{ provider: secondProvider, model: secondModel },
])
function buildStatsHomeData( function buildStatsHomeData(
modelRows: ModelStatMetric[], modelRows: ModelStatMetric[],
providerRows: ProviderStatMetric[], providerRows: ProviderStatMetric[],
@ -501,6 +514,7 @@ function toComparisonEntry(data: StatsModelData | null): StatsModelComparisonEnt
tokenShare: data.tokenShare, tokenShare: data.tokenShare,
tokenChange: data.tokenChange, tokenChange: data.tokenChange,
totals: data.totals, totals: data.totals,
usage: data.usage,
} }
} }

View file

@ -14,7 +14,10 @@ import { normalizeCountry, normalizeTier, type StatBaseAggregate } from "./stat"
export type StatDimension = "model" | "provider" | "geo" | "geo_model" export type StatDimension = "model" | "provider" | "geo" | "geo_model"
export function buildStatsQuery(periodStart: Date, periodEnd: Date, dimension: StatDimension) { // All stat dimensions and both grains are computed in one query via GROUPING SETS so
// the source table is scanned once per sync pass; separate queries per dimension (and
// the previous weekly/daily UNION ALL) each re-scanned the same events.
export function buildStatsQuery(periodStart: Date, periodEnd: Date) {
const periodStartValue = sqlString(periodStart.toISOString()) const periodStartValue = sqlString(periodStart.toISOString())
const periodEndValue = sqlString(periodEnd.toISOString()) const periodEndValue = sqlString(periodEnd.toISOString())
const periodStartDateValue = sqlString(periodStart.toISOString().slice(0, 10)) const periodStartDateValue = sqlString(periodStart.toISOString().slice(0, 10))
@ -22,23 +25,6 @@ export function buildStatsQuery(periodStart: Date, periodEnd: Date, dimension: S
const sourceTable = [Resource.InferenceEvent.catalog, Resource.InferenceEvent.database, Resource.InferenceEvent.table] const sourceTable = [Resource.InferenceEvent.catalog, Resource.InferenceEvent.database, Resource.InferenceEvent.table]
.map(sqlIdentifier) .map(sqlIdentifier)
.join(".") .join(".")
const dimensionSql = (() => {
if (dimension === "model")
return {
select: "provider, model, COALESCE(MAX(NULLIF(provider_model, '')), '') AS provider_model",
groupBy: "provider, model",
}
if (dimension === "provider") return { select: "provider", groupBy: "provider" }
if (dimension === "geo_model")
return {
select: "provider, model, country, COALESCE(MAX(NULLIF(continent, '')), '') AS continent",
groupBy: "provider, model, country",
}
return {
select: "'all' AS provider, 'all' AS model, country, COALESCE(MAX(NULLIF(continent, '')), '') AS continent",
groupBy: "country",
}
})()
const aggregateColumns = ` const aggregateColumns = `
COUNT(DISTINCT session) AS sessions, COUNT(DISTINCT session) AS sessions,
COUNT(*) AS requests, COUNT(*) AS requests,
@ -135,34 +121,41 @@ WITH normalized AS (
COALESCE(cost_total_microcents, cost_total * 1000000) AS cost_total_microcents COALESCE(cost_total_microcents, cost_total * 1000000) AS cost_total_microcents
FROM normalized FROM normalized
WHERE lower(model) NOT IN (${[...EXCLUDED_MODELS].map(sqlString).join(", ")}) WHERE lower(model) NOT IN (${[...EXCLUDED_MODELS].map(sqlString).join(", ")})
), weekly AS ( ), periods AS (
SELECT SELECT
concat(CAST(year_of_week(event_time) AS varchar), '-W', lpad(CAST(week(event_time) AS varchar), 2, '0')) AS week_key, concat(CAST(year_of_week(event_time) AS varchar), '-W', lpad(CAST(week(event_time) AS varchar), 2, '0')) AS week_key,
substr(to_iso8601(date_trunc('day', event_time)), 1, 10) AS day_key,
* *
FROM filtered FROM filtered
), daily AS (
SELECT substr(to_iso8601(date_trunc('day', event_time)), 1, 10) AS day_key, *
FROM filtered
) )
SELECT SELECT
'week' AS grain, CASE WHEN grouping(week_key) = 0 THEN 'week' ELSE 'day' END AS grain,
week_key AS period_key, COALESCE(week_key, day_key) AS period_key,
${sqlString(Resource.StatsSyncConfig.dataset)} AS dataset, ${sqlString(Resource.StatsSyncConfig.dataset)} AS dataset,
CASE
WHEN grouping(country) = 0 AND grouping(model) = 0 THEN 'geo_model'
WHEN grouping(country) = 0 THEN 'geo'
WHEN grouping(model) = 0 THEN 'model'
ELSE 'provider'
END AS dimension,
tier, tier,
${dimensionSql.select}, CASE WHEN grouping(provider) = 0 THEN provider ELSE 'all' END AS provider,
CASE WHEN grouping(model) = 0 THEN model WHEN grouping(country) = 0 THEN 'all' END AS model,
CASE WHEN grouping(model) = 0 AND grouping(country) = 1 THEN COALESCE(MAX(NULLIF(provider_model, '')), '') END AS provider_model,
CASE WHEN grouping(country) = 0 THEN country END AS country,
CASE WHEN grouping(country) = 0 THEN COALESCE(MAX(NULLIF(continent, '')), '') END AS continent,
${aggregateColumns} ${aggregateColumns}
FROM weekly FROM periods
GROUP BY week_key, tier, ${dimensionSql.groupBy} GROUP BY GROUPING SETS (
UNION ALL (week_key, tier, provider, model),
SELECT (week_key, tier, provider),
'day' AS grain, (week_key, tier, country),
day_key AS period_key, (week_key, tier, provider, model, country),
${sqlString(Resource.StatsSyncConfig.dataset)} AS dataset, (day_key, tier, provider, model),
tier, (day_key, tier, provider),
${dimensionSql.select}, (day_key, tier, country),
${aggregateColumns} (day_key, tier, provider, model, country)
FROM daily )
GROUP BY day_key, tier, ${dimensionSql.groupBy}
ORDER BY grain, period_key, total_tokens DESC ORDER BY grain, period_key, total_tokens DESC
` `
} }

View file

@ -1,4 +1,4 @@
import { and, asc, eq, inArray, or } from "drizzle-orm" import { and, asc, eq, inArray, max, or } from "drizzle-orm"
import { Effect, Layer } from "effect" import { Effect, Layer } from "effect"
import * as Context from "effect/Context" import * as Context from "effect/Context"
import { DatabaseError, DrizzleClient } from "../database" import { DatabaseError, DrizzleClient } from "../database"
@ -43,6 +43,7 @@ export type ModelStatMetric = {
export declare namespace ModelStatRepo { export declare namespace ModelStatRepo {
export interface Service { export interface Service {
readonly listDaily: () => Effect.Effect<ModelStatMetric[], DatabaseError> readonly listDaily: () => Effect.Effect<ModelStatMetric[], DatabaseError>
readonly lastSyncedAt: () => Effect.Effect<Date | null, DatabaseError>
readonly upsert: (rows: ModelStatRow[]) => Effect.Effect<void, DatabaseError> readonly upsert: (rows: ModelStatRow[]) => Effect.Effect<void, DatabaseError>
readonly deleteRetiredDimensions: (rows: ModelStatRow[]) => Effect.Effect<void, DatabaseError> readonly deleteRetiredDimensions: (rows: ModelStatRow[]) => Effect.Effect<void, DatabaseError>
} }
@ -111,6 +112,14 @@ export class ModelStatRepo extends Context.Service<ModelStatRepo, ModelStatRepo.
}) })
}) })
const lastSyncedAt = Effect.fn("ModelStatRepo.lastSyncedAt")(function* () {
const result = yield* Effect.tryPromise({
try: () => db.select({ value: max(modelStat.updated_at) }).from(modelStat),
catch: (cause) => DatabaseError.make({ cause }),
})
return result[0]?.value ?? null
})
const upsert = Effect.fn("ModelStatRepo.upsert")(function* (rows: ModelStatRow[]) { const upsert = Effect.fn("ModelStatRepo.upsert")(function* (rows: ModelStatRow[]) {
yield* Effect.forEach( yield* Effect.forEach(
chunks(rows, UPSERT_CHUNK_SIZE), chunks(rows, UPSERT_CHUNK_SIZE),
@ -192,7 +201,7 @@ export class ModelStatRepo extends Context.Service<ModelStatRepo, ModelStatRepo.
}) })
}) })
return ModelStatRepo.of({ listDaily, upsert, deleteRetiredDimensions }) return ModelStatRepo.of({ listDaily, lastSyncedAt, upsert, deleteRetiredDimensions })
}), }),
) )
} }

View file

@ -12,85 +12,90 @@ const DATALAKE_INGESTION_LAG_MS = 5 * 60_000
const STATS_DATA_START_MS = new Date("2026-05-28T00:00:00.000Z").getTime() const STATS_DATA_START_MS = new Date("2026-05-28T00:00:00.000Z").getTime()
const WEEK_MS = 7 * 86_400_000 const WEEK_MS = 7 * 86_400_000
const DISPLAY_WINDOW_MS = 56 * 86_400_000 const DISPLAY_WINDOW_MS = 56 * 86_400_000
// Anchor incremental passes to the ISO week containing this lookback, so the pass
// after a week boundary still recomputes the previous week's final aggregates even
// if the boundary pass itself failed.
const INCREMENTAL_LOOKBACK_MS = 2 * 3_600_000
export type SyncStatsResult = { ok: true; rows: number; startedAt: string; periodStart: string; periodEnd: string } export type SyncStatsResult = { ok: true; rows: number; startedAt: string; periodStart: string; periodEnd: string }
export type SyncStatsError = AthenaQueryError | AthenaQueryTimeoutError | DatabaseError export type SyncStatsError = AthenaQueryError | AthenaQueryTimeoutError | DatabaseError
export const syncStats: () => Effect.Effect< export const syncStats: (options?: {
SyncStatsResult, full?: boolean
SyncStatsError, }) => Effect.Effect<SyncStatsResult, SyncStatsError, Athena | ModelStatRepo | ProviderStatRepo | GeoStatRepo> =
Athena | ModelStatRepo | ProviderStatRepo | GeoStatRepo Effect.fn("StatSync.sync")(function* (options?: { full?: boolean }) {
> = Effect.fn("StatSync.sync")(function* () { const startedAt = yield* DateTime.nowAsDate
const startedAt = yield* DateTime.nowAsDate const periodEnd = new Date(Math.floor((startedAt.getTime() - DATALAKE_INGESTION_LAG_MS) / 60_000) * 60_000)
const periodEnd = new Date(Math.floor((startedAt.getTime() - DATALAKE_INGESTION_LAG_MS) / 60_000) * 60_000) const periodStart = options?.full ? fullPeriodStart(periodEnd) : incrementalPeriodStart(periodEnd)
// May 27 was partial, so keep Athena stats anchored at the first complete day. const athena = yield* Athena
const periodStart = new Date( const modelStats = yield* ModelStatRepo
const providerStats = yield* ProviderStatRepo
const geoStats = yield* GeoStatRepo
yield* logRuntimeCheck()
const rows = yield* athena.query(buildStatsQuery(periodStart, periodEnd))
const modelRows = modelRowsFromAggregates(rows.filter((row) => row.dimension === "model").flatMap(toModelAggregate))
const providerRows = providerRowsFromAggregates(
rows.filter((row) => row.dimension === "provider").flatMap(toProviderAggregate),
)
const geoRows = geoRowsFromAggregates(
rows.filter((row) => row.dimension === "geo" || row.dimension === "geo_model").flatMap(toGeoAggregate),
)
yield* Effect.all([modelStats.upsert(modelRows), providerStats.upsert(providerRows), geoStats.upsert(geoRows)], {
concurrency: "unbounded",
discard: true,
})
yield* Effect.all(
[
modelStats.deleteRetiredDimensions(modelRows),
providerStats.deleteRetiredDimensions(providerRows),
geoStats.deleteRetiredDimensions(geoRows),
],
{ concurrency: "unbounded", discard: true },
)
yield* Effect.logInfo(
`stats sync complete ${JSON.stringify({
startedAt: startedAt.toISOString(),
periodStart: periodStart.toISOString(),
periodEnd: periodEnd.toISOString(),
rows: modelRows.length,
providerRows: providerRows.length,
geoRows: geoRows.length,
stage: Resource.App.stage,
})}`,
)
return {
ok: true,
rows: modelRows.length,
startedAt: startedAt.toISOString(),
periodStart: periodStart.toISOString(),
periodEnd: periodEnd.toISOString(),
}
})
// May 27 was partial, so keep Athena stats anchored at the first complete day.
function fullPeriodStart(periodEnd: Date) {
return new Date(
Math.max( Math.max(
Math.min(startOfIsoWeek(periodEnd).getTime() - WEEK_MS, periodEnd.getTime() - DISPLAY_WINDOW_MS), Math.min(startOfIsoWeek(periodEnd).getTime() - WEEK_MS, periodEnd.getTime() - DISPLAY_WINDOW_MS),
STATS_DATA_START_MS, STATS_DATA_START_MS,
), ),
) )
const athena = yield* Athena }
const modelStats = yield* ModelStatRepo
const providerStats = yield* ProviderStatRepo
const geoStats = yield* GeoStatRepo
yield* logRuntimeCheck() // Events are append-only, so completed periods never change once synced; hourly
// passes only recompute the periods the current ISO week can still touch. The daily
const [modelAggregates, providerAggregates, geoAggregates, geoModelAggregates] = yield* Effect.all( // full pass refreshes the whole display window (normalization changes, retired
[ // dimension cleanup).
athena function incrementalPeriodStart(periodEnd: Date) {
.query(buildStatsQuery(periodStart, periodEnd, "model")) return new Date(
.pipe(Effect.map((rows) => rows.flatMap(toModelAggregate))), Math.max(startOfIsoWeek(new Date(periodEnd.getTime() - INCREMENTAL_LOOKBACK_MS)).getTime(), STATS_DATA_START_MS),
athena
.query(buildStatsQuery(periodStart, periodEnd, "provider"))
.pipe(Effect.map((rows) => rows.flatMap(toProviderAggregate))),
athena
.query(buildStatsQuery(periodStart, periodEnd, "geo"))
.pipe(Effect.map((rows) => rows.flatMap(toGeoAggregate))),
athena
.query(buildStatsQuery(periodStart, periodEnd, "geo_model"))
.pipe(Effect.map((rows) => rows.flatMap(toGeoAggregate))),
],
{ concurrency: "unbounded" },
) )
const modelRows = modelRowsFromAggregates(modelAggregates) }
const providerRows = providerRowsFromAggregates(providerAggregates)
const geoRows = geoRowsFromAggregates([...geoAggregates, ...geoModelAggregates])
yield* Effect.all([modelStats.upsert(modelRows), providerStats.upsert(providerRows), geoStats.upsert(geoRows)], {
concurrency: "unbounded",
discard: true,
})
yield* Effect.all(
[
modelStats.deleteRetiredDimensions(modelRows),
providerStats.deleteRetiredDimensions(providerRows),
geoStats.deleteRetiredDimensions(geoRows),
],
{ concurrency: "unbounded", discard: true },
)
yield* Effect.logInfo(
`stats sync complete ${JSON.stringify({
startedAt: startedAt.toISOString(),
periodStart: periodStart.toISOString(),
periodEnd: periodEnd.toISOString(),
rows: modelRows.length,
providerRows: providerRows.length,
geoRows: geoRows.length,
stage: Resource.App.stage,
})}`,
)
return {
ok: true,
rows: modelRows.length,
startedAt: startedAt.toISOString(),
periodStart: periodStart.toISOString(),
periodEnd: periodEnd.toISOString(),
}
})
function logRuntimeCheck() { function logRuntimeCheck() {
return Effect.logInfo( return Effect.logInfo(

View file

@ -1,21 +1,49 @@
import * as NodeRuntime from "@effect/platform-node/NodeRuntime" import * as NodeRuntime from "@effect/platform-node/NodeRuntime"
import { Athena } from "@opencode-ai/stats-core/athena" import { Athena } from "@opencode-ai/stats-core/athena"
import { ModelStatRepo } from "@opencode-ai/stats-core/domain/model"
import { layer as statsLayer } from "@opencode-ai/stats-core/runtime" import { layer as statsLayer } from "@opencode-ai/stats-core/runtime"
import { syncStats } from "@opencode-ai/stats-core/stat-sync" import { syncStats } from "@opencode-ai/stats-core/stat-sync"
import { Cause, Effect, Layer, Schedule } from "effect" import { Cause, Duration, Effect, Layer, Schedule } from "effect"
const SYNC_INTERVAL = "1 hour" const SYNC_INTERVAL = "1 hour"
const SYNC_INTERVAL_MS = 3_600_000
const runtimeLayer = Layer.mergeAll(statsLayer, Athena.layer) const runtimeLayer = Layer.mergeAll(statsLayer, Athena.layer)
const syncPass = syncStats().pipe(
Effect.catchCause((cause) => const daemon = Effect.gen(function* () {
Effect.logWarning(`stats sync failed ${JSON.stringify({ cause: Cause.pretty(cause) })}`), yield* Effect.logInfo("stats sync daemon started")
), yield* initialDelay()
)
const daemon = Effect.logInfo("stats sync daemon started").pipe( // One full pass per UTC day (including the first pass after boot) refreshes the
Effect.andThen(syncPass.pipe(Effect.repeat(Schedule.fixed(SYNC_INTERVAL)))), // whole display window; every other pass only recomputes the current ISO week.
Effect.forkScoped, let lastFullDay = ""
) const pass = Effect.gen(function* () {
const today = new Date().toISOString().slice(0, 10)
const full = lastFullDay !== today
yield* syncStats({ full })
if (full) lastFullDay = today
}).pipe(
Effect.catchCause((cause) =>
Effect.logWarning(`stats sync failed ${JSON.stringify({ cause: Cause.pretty(cause) })}`),
),
)
yield* pass.pipe(Effect.repeat(Schedule.fixed(SYNC_INTERVAL)))
}).pipe(Effect.forkScoped)
// A restarted daemon must not immediately re-run the expensive Athena pass; resume
// the hourly cadence from the last completed sync instead. This caps the Athena
// spend of a crash loop at one pass per interval.
const initialDelay = Effect.fnUntraced(function* () {
const modelStats = yield* ModelStatRepo
const lastSynced = yield* modelStats.lastSyncedAt().pipe(Effect.catchCause(() => Effect.succeed(null)))
if (!lastSynced) return
const delayMs = Math.min(SYNC_INTERVAL_MS - (Date.now() - lastSynced.getTime()), SYNC_INTERVAL_MS)
if (delayMs <= 0) return
yield* Effect.logInfo(
`stats sync delaying first pass ${JSON.stringify({ lastSyncedAt: lastSynced.toISOString(), delayMs })}`,
)
yield* Effect.sleep(Duration.millis(delayMs))
})
NodeRuntime.runMain(Layer.launch(Layer.effectDiscard(daemon).pipe(Layer.provide(runtimeLayer))), { NodeRuntime.runMain(Layer.launch(Layer.effectDiscard(daemon).pipe(Layer.provide(runtimeLayer))), {
disableErrorReporting: true, disableErrorReporting: true,

View file

@ -679,3 +679,22 @@
[data-component="tabs-drag-preview"] > * { [data-component="tabs-drag-preview"] > * {
position: relative; position: relative;
} }
body[data-new-layout] #review-panel [data-component="tabs"],
body[data-new-layout] #terminal-panel [data-component="tabs"],
body[data-new-layout] #review-panel [data-component="tabs"][data-variant="normal"][data-orientation="horizontal"],
body[data-new-layout] #terminal-panel [data-component="tabs"][data-variant="normal"][data-orientation="horizontal"] {
background-color: var(--v2-background-bg-base);
[data-slot="tabs-list"] {
background-color: var(--v2-background-bg-base);
> .sticky {
background-color: var(--v2-background-bg-base);
&::before {
background: linear-gradient(90deg, transparent, var(--v2-background-bg-base));
}
}
}
}

View file

@ -112,8 +112,8 @@
color: var(--v2-text-text-base); color: var(--v2-text-text-base);
} }
[data-component="icon-button-v2"][data-variant="ghost"]:is(:hover, [data-state="hover"], [data-expanded]):not( [data-component="icon-button-v2"][data-variant="ghost"]:is(:hover, [data-state="hover"]):not(:disabled):not(
:disabled [data-expanded]
) { ) {
background-color: var(--v2-overlay-simple-overlay-hover); background-color: var(--v2-overlay-simple-overlay-hover);
} }
@ -122,6 +122,10 @@
background-color: var(--v2-overlay-simple-overlay-pressed); background-color: var(--v2-overlay-simple-overlay-pressed);
} }
[data-component="icon-button-v2"][data-variant="ghost"]:where([data-expanded]):not(:disabled) {
background-color: var(--v2-overlay-simple-overlay-pressed);
}
[data-component="icon-button-v2"][data-variant="ghost"]:is(:disabled, [data-state="disabled"]) { [data-component="icon-button-v2"][data-variant="ghost"]:is(:disabled, [data-state="disabled"]) {
opacity: 0.5; opacity: 0.5;
cursor: not-allowed; cursor: not-allowed;
@ -133,8 +137,8 @@
color: var(--v2-icon-icon-muted); color: var(--v2-icon-icon-muted);
} }
[data-component="icon-button-v2"][data-variant="ghost-muted"]:is(:hover, [data-state="hover"], [data-expanded]):not( [data-component="icon-button-v2"][data-variant="ghost-muted"]:is(:hover, [data-state="hover"]):not(:disabled):not(
:disabled [data-expanded]
) { ) {
background-color: var(--v2-overlay-simple-overlay-hover); background-color: var(--v2-overlay-simple-overlay-hover);
} }

View file

@ -6,6 +6,7 @@
[data-component="line-comment-v2"] { [data-component="line-comment-v2"] {
box-sizing: border-box; box-sizing: border-box;
font-family: var(--v2-font-family-sans);
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
min-width: 0; min-width: 0;
width: 100%; width: 100%;

View file

@ -65,6 +65,7 @@
box-shadow: var(--v2-elevation-button-neutral); box-shadow: var(--v2-elevation-button-neutral);
flex: none; flex: none;
align-self: stretch; align-self: stretch;
user-select: none;
transition: transition:
background 85ms ease-out, background 85ms ease-out,
outline-color 85ms ease-out, outline-color 85ms ease-out,

View file

@ -192,6 +192,7 @@
color: var(--v2-text-text-muted); color: var(--v2-text-text-muted);
font-size: 12px; font-size: 12px;
font-weight: 500; font-weight: 500;
user-select: none;
} }
[data-component="tabs-v2"][data-variant="settings"][data-orientation="vertical"] [data-slot="tabs-v2-trigger-wrapper"] { [data-component="tabs-v2"][data-variant="settings"][data-orientation="vertical"] [data-slot="tabs-v2-trigger-wrapper"] {

View file

@ -55,6 +55,9 @@ OpenCode Zen هي بوابة AI تتيح لك الوصول إلى هذه الن
| النموذج | معرّف النموذج | نقطة النهاية | حزمة AI SDK | | النموذج | معرّف النموذج | نقطة النهاية | حزمة AI SDK |
| ---------------------- | ---------------------- | ---------------------------------------------------- | --------------------------- | | ---------------------- | ---------------------- | ---------------------------------------------------- | --------------------------- |
| GPT 5.6 Sol | gpt-5.6-sol | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| GPT 5.6 Terra | gpt-5.6-terra | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| GPT 5.6 Luna | gpt-5.6-luna | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| GPT 5.5 | gpt-5.5 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | | GPT 5.5 | gpt-5.5 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| GPT 5.5 Pro | gpt-5.5-pro | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | | GPT 5.5 Pro | gpt-5.5-pro | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| GPT 5.4 | gpt-5.4 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | | GPT 5.4 | gpt-5.4 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
@ -164,6 +167,12 @@ https://opencode.ai/zen/v1/models
| Gemini 3.1 Pro (≤ 200K tokens) | $2.00 | $12.00 | $0.20 | - | | Gemini 3.1 Pro (≤ 200K tokens) | $2.00 | $12.00 | $0.20 | - |
| Gemini 3.1 Pro (> 200K tokens) | $4.00 | $18.00 | $0.40 | - | | Gemini 3.1 Pro (> 200K tokens) | $4.00 | $18.00 | $0.40 | - |
| Gemini 3 Flash | $0.50 | $3.00 | $0.05 | - | | Gemini 3 Flash | $0.50 | $3.00 | $0.05 | - |
| GPT 5.6 Sol (≤ 272K tokens) | $5.00 | $30.00 | $0.50 | $6.25 |
| GPT 5.6 Sol (> 272K tokens) | $10.00 | $45.00 | $1.00 | $12.50 |
| GPT 5.6 Terra (≤ 272K tokens) | $2.50 | $15.00 | $0.25 | $3.125 |
| GPT 5.6 Terra (> 272K tokens) | $5.00 | $22.50 | $0.50 | $6.25 |
| GPT 5.6 Luna (≤ 272K tokens) | $1.00 | $6.00 | $0.10 | $1.25 |
| GPT 5.6 Luna (> 272K tokens) | $2.00 | $9.00 | $0.20 | $2.50 |
| GPT 5.5 (≤ 272K tokens) | $5.00 | $30.00 | $0.50 | - | | GPT 5.5 (≤ 272K tokens) | $5.00 | $30.00 | $0.50 | - |
| GPT 5.5 (> 272K tokens) | $10.00 | $45.00 | $1.00 | - | | GPT 5.5 (> 272K tokens) | $10.00 | $45.00 | $1.00 | - |
| GPT 5.5 Pro | $30.00 | $180.00 | $30.00 | - | | GPT 5.5 Pro | $30.00 | $180.00 | $30.00 | - |

View file

@ -60,6 +60,9 @@ Našim modelima možete pristupiti i preko sljedećih API endpointa.
| Model | Model ID | Endpoint | AI SDK Package | | Model | Model ID | Endpoint | AI SDK Package |
| ---------------------- | ---------------------- | ---------------------------------------------------- | --------------------------- | | ---------------------- | ---------------------- | ---------------------------------------------------- | --------------------------- |
| GPT 5.6 Sol | gpt-5.6-sol | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| GPT 5.6 Terra | gpt-5.6-terra | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| GPT 5.6 Luna | gpt-5.6-luna | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| GPT 5.5 | gpt-5.5 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | | GPT 5.5 | gpt-5.5 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| GPT 5.5 Pro | gpt-5.5-pro | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | | GPT 5.5 Pro | gpt-5.5-pro | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| GPT 5.4 | gpt-5.4 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | | GPT 5.4 | gpt-5.4 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
@ -171,6 +174,12 @@ Podržavamo pay-as-you-go model. Ispod su cijene **po 1M tokena**.
| Gemini 3.1 Pro (≤ 200K tokens) | $2.00 | $12.00 | $0.20 | - | | Gemini 3.1 Pro (≤ 200K tokens) | $2.00 | $12.00 | $0.20 | - |
| Gemini 3.1 Pro (> 200K tokens) | $4.00 | $18.00 | $0.40 | - | | Gemini 3.1 Pro (> 200K tokens) | $4.00 | $18.00 | $0.40 | - |
| Gemini 3 Flash | $0.50 | $3.00 | $0.05 | - | | Gemini 3 Flash | $0.50 | $3.00 | $0.05 | - |
| GPT 5.6 Sol (≤ 272K tokens) | $5.00 | $30.00 | $0.50 | $6.25 |
| GPT 5.6 Sol (> 272K tokens) | $10.00 | $45.00 | $1.00 | $12.50 |
| GPT 5.6 Terra (≤ 272K tokens) | $2.50 | $15.00 | $0.25 | $3.125 |
| GPT 5.6 Terra (> 272K tokens) | $5.00 | $22.50 | $0.50 | $6.25 |
| GPT 5.6 Luna (≤ 272K tokens) | $1.00 | $6.00 | $0.10 | $1.25 |
| GPT 5.6 Luna (> 272K tokens) | $2.00 | $9.00 | $0.20 | $2.50 |
| GPT 5.5 (≤ 272K tokens) | $5.00 | $30.00 | $0.50 | - | | GPT 5.5 (≤ 272K tokens) | $5.00 | $30.00 | $0.50 | - |
| GPT 5.5 (> 272K tokens) | $10.00 | $45.00 | $1.00 | - | | GPT 5.5 (> 272K tokens) | $10.00 | $45.00 | $1.00 | - |
| GPT 5.5 Pro | $30.00 | $180.00 | $30.00 | - | | GPT 5.5 Pro | $30.00 | $180.00 | $30.00 | - |

View file

@ -60,6 +60,9 @@ Du kan også få adgang til vores modeller gennem følgende API-endpoints.
| Model | Model ID | Endpoint | AI SDK-pakke | | Model | Model ID | Endpoint | AI SDK-pakke |
| ---------------------- | ---------------------- | ---------------------------------------------------- | --------------------------- | | ---------------------- | ---------------------- | ---------------------------------------------------- | --------------------------- |
| GPT 5.6 Sol | gpt-5.6-sol | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| GPT 5.6 Terra | gpt-5.6-terra | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| GPT 5.6 Luna | gpt-5.6-luna | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| GPT 5.5 | gpt-5.5 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | | GPT 5.5 | gpt-5.5 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| GPT 5.5 Pro | gpt-5.5-pro | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | | GPT 5.5 Pro | gpt-5.5-pro | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| GPT 5.4 | gpt-5.4 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | | GPT 5.4 | gpt-5.4 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
@ -171,6 +174,12 @@ Vi understøtter en pay-as-you-go-model. Nedenfor er priserne **pr. 1M tokens**.
| Gemini 3.1 Pro (≤ 200K tokens) | $2.00 | $12.00 | $0.20 | - | | Gemini 3.1 Pro (≤ 200K tokens) | $2.00 | $12.00 | $0.20 | - |
| Gemini 3.1 Pro (> 200K tokens) | $4.00 | $18.00 | $0.40 | - | | Gemini 3.1 Pro (> 200K tokens) | $4.00 | $18.00 | $0.40 | - |
| Gemini 3 Flash | $0.50 | $3.00 | $0.05 | - | | Gemini 3 Flash | $0.50 | $3.00 | $0.05 | - |
| GPT 5.6 Sol (≤ 272K tokens) | $5.00 | $30.00 | $0.50 | $6.25 |
| GPT 5.6 Sol (> 272K tokens) | $10.00 | $45.00 | $1.00 | $12.50 |
| GPT 5.6 Terra (≤ 272K tokens) | $2.50 | $15.00 | $0.25 | $3.125 |
| GPT 5.6 Terra (> 272K tokens) | $5.00 | $22.50 | $0.50 | $6.25 |
| GPT 5.6 Luna (≤ 272K tokens) | $1.00 | $6.00 | $0.10 | $1.25 |
| GPT 5.6 Luna (> 272K tokens) | $2.00 | $9.00 | $0.20 | $2.50 |
| GPT 5.5 (≤ 272K tokens) | $5.00 | $30.00 | $0.50 | - | | GPT 5.5 (≤ 272K tokens) | $5.00 | $30.00 | $0.50 | - |
| GPT 5.5 (> 272K tokens) | $10.00 | $45.00 | $1.00 | - | | GPT 5.5 (> 272K tokens) | $10.00 | $45.00 | $1.00 | - |
| GPT 5.5 Pro | $30.00 | $180.00 | $30.00 | - | | GPT 5.5 Pro | $30.00 | $180.00 | $30.00 | - |

View file

@ -51,6 +51,9 @@ Du kannst auch über die folgenden API-Endpunkte auf unsere Modelle zugreifen.
| Model | Model ID | Endpoint | AI SDK Package | | Model | Model ID | Endpoint | AI SDK Package |
| ---------------------- | ---------------------- | ---------------------------------------------------- | --------------------------- | | ---------------------- | ---------------------- | ---------------------------------------------------- | --------------------------- |
| GPT 5.6 Sol | gpt-5.6-sol | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| GPT 5.6 Terra | gpt-5.6-terra | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| GPT 5.6 Luna | gpt-5.6-luna | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| GPT 5.5 | gpt-5.5 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | | GPT 5.5 | gpt-5.5 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| GPT 5.5 Pro | gpt-5.5-pro | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | | GPT 5.5 Pro | gpt-5.5-pro | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| GPT 5.4 | gpt-5.4 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | | GPT 5.4 | gpt-5.4 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
@ -160,6 +163,12 @@ Wir unterstützen ein Pay-as-you-go-Modell. Unten findest du die Preise **pro 1M
| Gemini 3.1 Pro (≤ 200K tokens) | $2.00 | $12.00 | $0.20 | - | | Gemini 3.1 Pro (≤ 200K tokens) | $2.00 | $12.00 | $0.20 | - |
| Gemini 3.1 Pro (> 200K tokens) | $4.00 | $18.00 | $0.40 | - | | Gemini 3.1 Pro (> 200K tokens) | $4.00 | $18.00 | $0.40 | - |
| Gemini 3 Flash | $0.50 | $3.00 | $0.05 | - | | Gemini 3 Flash | $0.50 | $3.00 | $0.05 | - |
| GPT 5.6 Sol (≤ 272K tokens) | $5.00 | $30.00 | $0.50 | $6.25 |
| GPT 5.6 Sol (> 272K tokens) | $10.00 | $45.00 | $1.00 | $12.50 |
| GPT 5.6 Terra (≤ 272K tokens) | $2.50 | $15.00 | $0.25 | $3.125 |
| GPT 5.6 Terra (> 272K tokens) | $5.00 | $22.50 | $0.50 | $6.25 |
| GPT 5.6 Luna (≤ 272K tokens) | $1.00 | $6.00 | $0.10 | $1.25 |
| GPT 5.6 Luna (> 272K tokens) | $2.00 | $9.00 | $0.20 | $2.50 |
| GPT 5.5 (≤ 272K tokens) | $5.00 | $30.00 | $0.50 | - | | GPT 5.5 (≤ 272K tokens) | $5.00 | $30.00 | $0.50 | - |
| GPT 5.5 (> 272K tokens) | $10.00 | $45.00 | $1.00 | - | | GPT 5.5 (> 272K tokens) | $10.00 | $45.00 | $1.00 | - |
| GPT 5.5 Pro | $30.00 | $180.00 | $30.00 | - | | GPT 5.5 Pro | $30.00 | $180.00 | $30.00 | - |

View file

@ -60,6 +60,9 @@ También puedes acceder a nuestros modelos a través de los siguientes endpoints
| Modelo | Model ID | Endpoint | AI SDK Package | | Modelo | Model ID | Endpoint | AI SDK Package |
| ---------------------- | ---------------------- | ---------------------------------------------------- | --------------------------- | | ---------------------- | ---------------------- | ---------------------------------------------------- | --------------------------- |
| GPT 5.6 Sol | gpt-5.6-sol | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| GPT 5.6 Terra | gpt-5.6-terra | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| GPT 5.6 Luna | gpt-5.6-luna | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| GPT 5.5 | gpt-5.5 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | | GPT 5.5 | gpt-5.5 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| GPT 5.5 Pro | gpt-5.5-pro | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | | GPT 5.5 Pro | gpt-5.5-pro | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| GPT 5.4 | gpt-5.4 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | | GPT 5.4 | gpt-5.4 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
@ -171,6 +174,12 @@ Admitimos un modelo de pago por uso. A continuación se muestran los precios **p
| Gemini 3.1 Pro (≤ 200K tokens) | $2.00 | $12.00 | $0.20 | - | | Gemini 3.1 Pro (≤ 200K tokens) | $2.00 | $12.00 | $0.20 | - |
| Gemini 3.1 Pro (> 200K tokens) | $4.00 | $18.00 | $0.40 | - | | Gemini 3.1 Pro (> 200K tokens) | $4.00 | $18.00 | $0.40 | - |
| Gemini 3 Flash | $0.50 | $3.00 | $0.05 | - | | Gemini 3 Flash | $0.50 | $3.00 | $0.05 | - |
| GPT 5.6 Sol (≤ 272K tokens) | $5.00 | $30.00 | $0.50 | $6.25 |
| GPT 5.6 Sol (> 272K tokens) | $10.00 | $45.00 | $1.00 | $12.50 |
| GPT 5.6 Terra (≤ 272K tokens) | $2.50 | $15.00 | $0.25 | $3.125 |
| GPT 5.6 Terra (> 272K tokens) | $5.00 | $22.50 | $0.50 | $6.25 |
| GPT 5.6 Luna (≤ 272K tokens) | $1.00 | $6.00 | $0.10 | $1.25 |
| GPT 5.6 Luna (> 272K tokens) | $2.00 | $9.00 | $0.20 | $2.50 |
| GPT 5.5 (≤ 272K tokens) | $5.00 | $30.00 | $0.50 | - | | GPT 5.5 (≤ 272K tokens) | $5.00 | $30.00 | $0.50 | - |
| GPT 5.5 (> 272K tokens) | $10.00 | $45.00 | $1.00 | - | | GPT 5.5 (> 272K tokens) | $10.00 | $45.00 | $1.00 | - |
| GPT 5.5 Pro | $30.00 | $180.00 | $30.00 | - | | GPT 5.5 Pro | $30.00 | $180.00 | $30.00 | - |

View file

@ -51,6 +51,9 @@ Vous pouvez également accéder à nos modèles via les points de terminaison AP
| Modèle | ID du modèle | Point de terminaison | Package AI SDK | | Modèle | ID du modèle | Point de terminaison | Package AI SDK |
| ---------------------- | ---------------------- | ---------------------------------------------------- | --------------------------- | | ---------------------- | ---------------------- | ---------------------------------------------------- | --------------------------- |
| GPT 5.6 Sol | gpt-5.6-sol | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| GPT 5.6 Terra | gpt-5.6-terra | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| GPT 5.6 Luna | gpt-5.6-luna | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| GPT 5.5 | gpt-5.5 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | | GPT 5.5 | gpt-5.5 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| GPT 5.5 Pro | gpt-5.5-pro | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | | GPT 5.5 Pro | gpt-5.5-pro | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| GPT 5.4 | gpt-5.4 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | | GPT 5.4 | gpt-5.4 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
@ -160,6 +163,12 @@ Nous prenons en charge un modèle de paiement à l'utilisation. Vous trouverez c
| Gemini 3.1 Pro (≤ 200K tokens) | $2.00 | $12.00 | $0.20 | - | | Gemini 3.1 Pro (≤ 200K tokens) | $2.00 | $12.00 | $0.20 | - |
| Gemini 3.1 Pro (> 200K tokens) | $4.00 | $18.00 | $0.40 | - | | Gemini 3.1 Pro (> 200K tokens) | $4.00 | $18.00 | $0.40 | - |
| Gemini 3 Flash | $0.50 | $3.00 | $0.05 | - | | Gemini 3 Flash | $0.50 | $3.00 | $0.05 | - |
| GPT 5.6 Sol (≤ 272K tokens) | $5.00 | $30.00 | $0.50 | $6.25 |
| GPT 5.6 Sol (> 272K tokens) | $10.00 | $45.00 | $1.00 | $12.50 |
| GPT 5.6 Terra (≤ 272K tokens) | $2.50 | $15.00 | $0.25 | $3.125 |
| GPT 5.6 Terra (> 272K tokens) | $5.00 | $22.50 | $0.50 | $6.25 |
| GPT 5.6 Luna (≤ 272K tokens) | $1.00 | $6.00 | $0.10 | $1.25 |
| GPT 5.6 Luna (> 272K tokens) | $2.00 | $9.00 | $0.20 | $2.50 |
| GPT 5.5 (≤ 272K tokens) | $5.00 | $30.00 | $0.50 | - | | GPT 5.5 (≤ 272K tokens) | $5.00 | $30.00 | $0.50 | - |
| GPT 5.5 (> 272K tokens) | $10.00 | $45.00 | $1.00 | - | | GPT 5.5 (> 272K tokens) | $10.00 | $45.00 | $1.00 | - |
| GPT 5.5 Pro | $30.00 | $180.00 | $30.00 | - | | GPT 5.5 Pro | $30.00 | $180.00 | $30.00 | - |

View file

@ -60,6 +60,9 @@ Puoi anche accedere ai nostri modelli tramite i seguenti endpoint API.
| Modello | Model ID | Endpoint | Pacchetto AI SDK | | Modello | Model ID | Endpoint | Pacchetto AI SDK |
| ---------------------- | ---------------------- | ---------------------------------------------------- | --------------------------- | | ---------------------- | ---------------------- | ---------------------------------------------------- | --------------------------- |
| GPT 5.6 Sol | gpt-5.6-sol | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| GPT 5.6 Terra | gpt-5.6-terra | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| GPT 5.6 Luna | gpt-5.6-luna | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| GPT 5.5 | gpt-5.5 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | | GPT 5.5 | gpt-5.5 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| GPT 5.5 Pro | gpt-5.5-pro | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | | GPT 5.5 Pro | gpt-5.5-pro | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| GPT 5.4 | gpt-5.4 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | | GPT 5.4 | gpt-5.4 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
@ -171,6 +174,12 @@ Supportiamo un modello pay-as-you-go. Qui sotto trovi i prezzi **per 1M token**.
| Gemini 3.1 Pro (≤ 200K tokens) | $2.00 | $12.00 | $0.20 | - | | Gemini 3.1 Pro (≤ 200K tokens) | $2.00 | $12.00 | $0.20 | - |
| Gemini 3.1 Pro (> 200K tokens) | $4.00 | $18.00 | $0.40 | - | | Gemini 3.1 Pro (> 200K tokens) | $4.00 | $18.00 | $0.40 | - |
| Gemini 3 Flash | $0.50 | $3.00 | $0.05 | - | | Gemini 3 Flash | $0.50 | $3.00 | $0.05 | - |
| GPT 5.6 Sol (≤ 272K tokens) | $5.00 | $30.00 | $0.50 | $6.25 |
| GPT 5.6 Sol (> 272K tokens) | $10.00 | $45.00 | $1.00 | $12.50 |
| GPT 5.6 Terra (≤ 272K tokens) | $2.50 | $15.00 | $0.25 | $3.125 |
| GPT 5.6 Terra (> 272K tokens) | $5.00 | $22.50 | $0.50 | $6.25 |
| GPT 5.6 Luna (≤ 272K tokens) | $1.00 | $6.00 | $0.10 | $1.25 |
| GPT 5.6 Luna (> 272K tokens) | $2.00 | $9.00 | $0.20 | $2.50 |
| GPT 5.5 (≤ 272K tokens) | $5.00 | $30.00 | $0.50 | - | | GPT 5.5 (≤ 272K tokens) | $5.00 | $30.00 | $0.50 | - |
| GPT 5.5 (> 272K tokens) | $10.00 | $45.00 | $1.00 | - | | GPT 5.5 (> 272K tokens) | $10.00 | $45.00 | $1.00 | - |
| GPT 5.5 Pro | $30.00 | $180.00 | $30.00 | - | | GPT 5.5 Pro | $30.00 | $180.00 | $30.00 | - |

View file

@ -51,6 +51,9 @@ OpenCode Zen は、OpenCode のほかのプロバイダーと同じように動
| Model | Model ID | Endpoint | AI SDK Package | | Model | Model ID | Endpoint | AI SDK Package |
| ---------------------- | ---------------------- | ---------------------------------------------------- | --------------------------- | | ---------------------- | ---------------------- | ---------------------------------------------------- | --------------------------- |
| GPT 5.6 Sol | gpt-5.6-sol | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| GPT 5.6 Terra | gpt-5.6-terra | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| GPT 5.6 Luna | gpt-5.6-luna | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| GPT 5.5 | gpt-5.5 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | | GPT 5.5 | gpt-5.5 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| GPT 5.5 Pro | gpt-5.5-pro | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | | GPT 5.5 Pro | gpt-5.5-pro | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| GPT 5.4 | gpt-5.4 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | | GPT 5.4 | gpt-5.4 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
@ -160,6 +163,12 @@ https://opencode.ai/zen/v1/models
| Gemini 3.1 Pro (≤ 200K tokens) | $2.00 | $12.00 | $0.20 | - | | Gemini 3.1 Pro (≤ 200K tokens) | $2.00 | $12.00 | $0.20 | - |
| Gemini 3.1 Pro (> 200K tokens) | $4.00 | $18.00 | $0.40 | - | | Gemini 3.1 Pro (> 200K tokens) | $4.00 | $18.00 | $0.40 | - |
| Gemini 3 Flash | $0.50 | $3.00 | $0.05 | - | | Gemini 3 Flash | $0.50 | $3.00 | $0.05 | - |
| GPT 5.6 Sol (≤ 272K tokens) | $5.00 | $30.00 | $0.50 | $6.25 |
| GPT 5.6 Sol (> 272K tokens) | $10.00 | $45.00 | $1.00 | $12.50 |
| GPT 5.6 Terra (≤ 272K tokens) | $2.50 | $15.00 | $0.25 | $3.125 |
| GPT 5.6 Terra (> 272K tokens) | $5.00 | $22.50 | $0.50 | $6.25 |
| GPT 5.6 Luna (≤ 272K tokens) | $1.00 | $6.00 | $0.10 | $1.25 |
| GPT 5.6 Luna (> 272K tokens) | $2.00 | $9.00 | $0.20 | $2.50 |
| GPT 5.5 (≤ 272K tokens) | $5.00 | $30.00 | $0.50 | - | | GPT 5.5 (≤ 272K tokens) | $5.00 | $30.00 | $0.50 | - |
| GPT 5.5 (> 272K tokens) | $10.00 | $45.00 | $1.00 | - | | GPT 5.5 (> 272K tokens) | $10.00 | $45.00 | $1.00 | - |
| GPT 5.5 Pro | $30.00 | $180.00 | $30.00 | - | | GPT 5.5 Pro | $30.00 | $180.00 | $30.00 | - |

View file

@ -51,6 +51,9 @@ OpenCode Zen은 OpenCode의 다른 provider와 똑같이 작동합니다.
| 모델 | 모델 ID | 엔드포인트 | AI SDK 패키지 | | 모델 | 모델 ID | 엔드포인트 | AI SDK 패키지 |
| ---------------------- | ---------------------- | ---------------------------------------------------- | --------------------------- | | ---------------------- | ---------------------- | ---------------------------------------------------- | --------------------------- |
| GPT 5.6 Sol | gpt-5.6-sol | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| GPT 5.6 Terra | gpt-5.6-terra | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| GPT 5.6 Luna | gpt-5.6-luna | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| GPT 5.5 | gpt-5.5 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | | GPT 5.5 | gpt-5.5 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| GPT 5.5 Pro | gpt-5.5-pro | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | | GPT 5.5 Pro | gpt-5.5-pro | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| GPT 5.4 | gpt-5.4 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | | GPT 5.4 | gpt-5.4 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
@ -160,6 +163,12 @@ https://opencode.ai/zen/v1/models
| Gemini 3.1 Pro (≤ 200K tokens) | $2.00 | $12.00 | $0.20 | - | | Gemini 3.1 Pro (≤ 200K tokens) | $2.00 | $12.00 | $0.20 | - |
| Gemini 3.1 Pro (> 200K tokens) | $4.00 | $18.00 | $0.40 | - | | Gemini 3.1 Pro (> 200K tokens) | $4.00 | $18.00 | $0.40 | - |
| Gemini 3 Flash | $0.50 | $3.00 | $0.05 | - | | Gemini 3 Flash | $0.50 | $3.00 | $0.05 | - |
| GPT 5.6 Sol (≤ 272K tokens) | $5.00 | $30.00 | $0.50 | $6.25 |
| GPT 5.6 Sol (> 272K tokens) | $10.00 | $45.00 | $1.00 | $12.50 |
| GPT 5.6 Terra (≤ 272K tokens) | $2.50 | $15.00 | $0.25 | $3.125 |
| GPT 5.6 Terra (> 272K tokens) | $5.00 | $22.50 | $0.50 | $6.25 |
| GPT 5.6 Luna (≤ 272K tokens) | $1.00 | $6.00 | $0.10 | $1.25 |
| GPT 5.6 Luna (> 272K tokens) | $2.00 | $9.00 | $0.20 | $2.50 |
| GPT 5.5 (≤ 272K tokens) | $5.00 | $30.00 | $0.50 | - | | GPT 5.5 (≤ 272K tokens) | $5.00 | $30.00 | $0.50 | - |
| GPT 5.5 (> 272K tokens) | $10.00 | $45.00 | $1.00 | - | | GPT 5.5 (> 272K tokens) | $10.00 | $45.00 | $1.00 | - |
| GPT 5.5 Pro | $30.00 | $180.00 | $30.00 | - | | GPT 5.5 Pro | $30.00 | $180.00 | $30.00 | - |

View file

@ -60,6 +60,9 @@ Du kan også få tilgang til modellene våre gjennom følgende API-endepunkter.
| Modell | Modell-ID | Endepunkt | AI SDK-pakke | | Modell | Modell-ID | Endepunkt | AI SDK-pakke |
| ---------------------- | ---------------------- | ---------------------------------------------------- | --------------------------- | | ---------------------- | ---------------------- | ---------------------------------------------------- | --------------------------- |
| GPT 5.6 Sol | gpt-5.6-sol | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| GPT 5.6 Terra | gpt-5.6-terra | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| GPT 5.6 Luna | gpt-5.6-luna | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| GPT 5.5 | gpt-5.5 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | | GPT 5.5 | gpt-5.5 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| GPT 5.5 Pro | gpt-5.5-pro | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | | GPT 5.5 Pro | gpt-5.5-pro | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| GPT 5.4 | gpt-5.4 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | | GPT 5.4 | gpt-5.4 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
@ -171,6 +174,12 @@ Vi støtter en pay-as-you-go-modell. Nedenfor er prisene **per 1M tokens**.
| Gemini 3.1 Pro (≤ 200K tokens) | $2.00 | $12.00 | $0.20 | - | | Gemini 3.1 Pro (≤ 200K tokens) | $2.00 | $12.00 | $0.20 | - |
| Gemini 3.1 Pro (> 200K tokens) | $4.00 | $18.00 | $0.40 | - | | Gemini 3.1 Pro (> 200K tokens) | $4.00 | $18.00 | $0.40 | - |
| Gemini 3 Flash | $0.50 | $3.00 | $0.05 | - | | Gemini 3 Flash | $0.50 | $3.00 | $0.05 | - |
| GPT 5.6 Sol (≤ 272K tokens) | $5.00 | $30.00 | $0.50 | $6.25 |
| GPT 5.6 Sol (> 272K tokens) | $10.00 | $45.00 | $1.00 | $12.50 |
| GPT 5.6 Terra (≤ 272K tokens) | $2.50 | $15.00 | $0.25 | $3.125 |
| GPT 5.6 Terra (> 272K tokens) | $5.00 | $22.50 | $0.50 | $6.25 |
| GPT 5.6 Luna (≤ 272K tokens) | $1.00 | $6.00 | $0.10 | $1.25 |
| GPT 5.6 Luna (> 272K tokens) | $2.00 | $9.00 | $0.20 | $2.50 |
| GPT 5.5 (≤ 272K tokens) | $5.00 | $30.00 | $0.50 | - | | GPT 5.5 (≤ 272K tokens) | $5.00 | $30.00 | $0.50 | - |
| GPT 5.5 (> 272K tokens) | $10.00 | $45.00 | $1.00 | - | | GPT 5.5 (> 272K tokens) | $10.00 | $45.00 | $1.00 | - |
| GPT 5.5 Pro | $30.00 | $180.00 | $30.00 | - | | GPT 5.5 Pro | $30.00 | $180.00 | $30.00 | - |

View file

@ -60,6 +60,9 @@ Możesz też uzyskać dostęp do naszych modeli przez poniższe endpointy API.
| Model | ID modelu | Endpoint | Pakiet AI SDK | | Model | ID modelu | Endpoint | Pakiet AI SDK |
| ---------------------- | ---------------------- | ---------------------------------------------------- | --------------------------- | | ---------------------- | ---------------------- | ---------------------------------------------------- | --------------------------- |
| GPT 5.6 Sol | gpt-5.6-sol | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| GPT 5.6 Terra | gpt-5.6-terra | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| GPT 5.6 Luna | gpt-5.6-luna | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| GPT 5.5 | gpt-5.5 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | | GPT 5.5 | gpt-5.5 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| GPT 5.5 Pro | gpt-5.5-pro | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | | GPT 5.5 Pro | gpt-5.5-pro | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| GPT 5.4 | gpt-5.4 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | | GPT 5.4 | gpt-5.4 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
@ -171,6 +174,12 @@ Obsługujemy model pay-as-you-go. Poniżej znajdują się ceny **za 1M tokenów*
| Gemini 3.1 Pro (≤ 200K tokens) | $2.00 | $12.00 | $0.20 | - | | Gemini 3.1 Pro (≤ 200K tokens) | $2.00 | $12.00 | $0.20 | - |
| Gemini 3.1 Pro (> 200K tokens) | $4.00 | $18.00 | $0.40 | - | | Gemini 3.1 Pro (> 200K tokens) | $4.00 | $18.00 | $0.40 | - |
| Gemini 3 Flash | $0.50 | $3.00 | $0.05 | - | | Gemini 3 Flash | $0.50 | $3.00 | $0.05 | - |
| GPT 5.6 Sol (≤ 272K tokens) | $5.00 | $30.00 | $0.50 | $6.25 |
| GPT 5.6 Sol (> 272K tokens) | $10.00 | $45.00 | $1.00 | $12.50 |
| GPT 5.6 Terra (≤ 272K tokens) | $2.50 | $15.00 | $0.25 | $3.125 |
| GPT 5.6 Terra (> 272K tokens) | $5.00 | $22.50 | $0.50 | $6.25 |
| GPT 5.6 Luna (≤ 272K tokens) | $1.00 | $6.00 | $0.10 | $1.25 |
| GPT 5.6 Luna (> 272K tokens) | $2.00 | $9.00 | $0.20 | $2.50 |
| GPT 5.5 (≤ 272K tokens) | $5.00 | $30.00 | $0.50 | - | | GPT 5.5 (≤ 272K tokens) | $5.00 | $30.00 | $0.50 | - |
| GPT 5.5 (> 272K tokens) | $10.00 | $45.00 | $1.00 | - | | GPT 5.5 (> 272K tokens) | $10.00 | $45.00 | $1.00 | - |
| GPT 5.5 Pro | $30.00 | $180.00 | $30.00 | - | | GPT 5.5 Pro | $30.00 | $180.00 | $30.00 | - |

View file

@ -51,6 +51,9 @@ Você também pode acessar nossos modelos pelos seguintes endpoints de API.
| Modelo | ID do modelo | Endpoint | Pacote AI SDK | | Modelo | ID do modelo | Endpoint | Pacote AI SDK |
| ---------------------- | ---------------------- | ---------------------------------------------------- | --------------------------- | | ---------------------- | ---------------------- | ---------------------------------------------------- | --------------------------- |
| GPT 5.6 Sol | gpt-5.6-sol | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| GPT 5.6 Terra | gpt-5.6-terra | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| GPT 5.6 Luna | gpt-5.6-luna | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| GPT 5.5 | gpt-5.5 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | | GPT 5.5 | gpt-5.5 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| GPT 5.5 Pro | gpt-5.5-pro | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | | GPT 5.5 Pro | gpt-5.5-pro | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| GPT 5.4 | gpt-5.4 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | | GPT 5.4 | gpt-5.4 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
@ -160,6 +163,12 @@ Oferecemos um modelo pay-as-you-go. Abaixo estão os preços **por 1M tokens**.
| Gemini 3.1 Pro (≤ 200K tokens) | $2.00 | $12.00 | $0.20 | - | | Gemini 3.1 Pro (≤ 200K tokens) | $2.00 | $12.00 | $0.20 | - |
| Gemini 3.1 Pro (> 200K tokens) | $4.00 | $18.00 | $0.40 | - | | Gemini 3.1 Pro (> 200K tokens) | $4.00 | $18.00 | $0.40 | - |
| Gemini 3 Flash | $0.50 | $3.00 | $0.05 | - | | Gemini 3 Flash | $0.50 | $3.00 | $0.05 | - |
| GPT 5.6 Sol (≤ 272K tokens) | $5.00 | $30.00 | $0.50 | $6.25 |
| GPT 5.6 Sol (> 272K tokens) | $10.00 | $45.00 | $1.00 | $12.50 |
| GPT 5.6 Terra (≤ 272K tokens) | $2.50 | $15.00 | $0.25 | $3.125 |
| GPT 5.6 Terra (> 272K tokens) | $5.00 | $22.50 | $0.50 | $6.25 |
| GPT 5.6 Luna (≤ 272K tokens) | $1.00 | $6.00 | $0.10 | $1.25 |
| GPT 5.6 Luna (> 272K tokens) | $2.00 | $9.00 | $0.20 | $2.50 |
| GPT 5.5 (≤ 272K tokens) | $5.00 | $30.00 | $0.50 | - | | GPT 5.5 (≤ 272K tokens) | $5.00 | $30.00 | $0.50 | - |
| GPT 5.5 (> 272K tokens) | $10.00 | $45.00 | $1.00 | - | | GPT 5.5 (> 272K tokens) | $10.00 | $45.00 | $1.00 | - |
| GPT 5.5 Pro | $30.00 | $180.00 | $30.00 | - | | GPT 5.5 Pro | $30.00 | $180.00 | $30.00 | - |

View file

@ -60,6 +60,9 @@ OpenCode Zen работает как любой другой провайдер
| Модель | Идентификатор модели | Конечная точка | Пакет AI SDK | | Модель | Идентификатор модели | Конечная точка | Пакет AI SDK |
| ---------------------- | ---------------------- | ---------------------------------------------------- | --------------------------- | | ---------------------- | ---------------------- | ---------------------------------------------------- | --------------------------- |
| GPT 5.6 Sol | gpt-5.6-sol | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| GPT 5.6 Terra | gpt-5.6-terra | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| GPT 5.6 Luna | gpt-5.6-luna | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| GPT 5.5 | gpt-5.5 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | | GPT 5.5 | gpt-5.5 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| GPT 5.5 Pro | gpt-5.5-pro | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | | GPT 5.5 Pro | gpt-5.5-pro | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| GPT 5.4 | gpt-5.4 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | | GPT 5.4 | gpt-5.4 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
@ -171,6 +174,12 @@ https://opencode.ai/zen/v1/models
| Gemini 3.1 Pro (≤ 200K tokens) | $2.00 | $12.00 | $0.20 | - | | Gemini 3.1 Pro (≤ 200K tokens) | $2.00 | $12.00 | $0.20 | - |
| Gemini 3.1 Pro (> 200K tokens) | $4.00 | $18.00 | $0.40 | - | | Gemini 3.1 Pro (> 200K tokens) | $4.00 | $18.00 | $0.40 | - |
| Gemini 3 Flash | $0.50 | $3.00 | $0.05 | - | | Gemini 3 Flash | $0.50 | $3.00 | $0.05 | - |
| GPT 5.6 Sol (≤ 272K tokens) | $5.00 | $30.00 | $0.50 | $6.25 |
| GPT 5.6 Sol (> 272K tokens) | $10.00 | $45.00 | $1.00 | $12.50 |
| GPT 5.6 Terra (≤ 272K tokens) | $2.50 | $15.00 | $0.25 | $3.125 |
| GPT 5.6 Terra (> 272K tokens) | $5.00 | $22.50 | $0.50 | $6.25 |
| GPT 5.6 Luna (≤ 272K tokens) | $1.00 | $6.00 | $0.10 | $1.25 |
| GPT 5.6 Luna (> 272K tokens) | $2.00 | $9.00 | $0.20 | $2.50 |
| GPT 5.5 (≤ 272K tokens) | $5.00 | $30.00 | $0.50 | - | | GPT 5.5 (≤ 272K tokens) | $5.00 | $30.00 | $0.50 | - |
| GPT 5.5 (> 272K tokens) | $10.00 | $45.00 | $1.00 | - | | GPT 5.5 (> 272K tokens) | $10.00 | $45.00 | $1.00 | - |
| GPT 5.5 Pro | $30.00 | $180.00 | $30.00 | - | | GPT 5.5 Pro | $30.00 | $180.00 | $30.00 | - |

View file

@ -53,6 +53,9 @@ OpenCode Zen ทำงานเหมือน provider อื่น ๆ ใน
| Model | Model ID | Endpoint | AI SDK Package | | Model | Model ID | Endpoint | AI SDK Package |
| ---------------------- | ---------------------- | ---------------------------------------------------- | --------------------------- | | ---------------------- | ---------------------- | ---------------------------------------------------- | --------------------------- |
| GPT 5.6 Sol | gpt-5.6-sol | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| GPT 5.6 Terra | gpt-5.6-terra | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| GPT 5.6 Luna | gpt-5.6-luna | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| GPT 5.5 | gpt-5.5 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | | GPT 5.5 | gpt-5.5 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| GPT 5.5 Pro | gpt-5.5-pro | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | | GPT 5.5 Pro | gpt-5.5-pro | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| GPT 5.4 | gpt-5.4 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | | GPT 5.4 | gpt-5.4 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
@ -162,6 +165,12 @@ https://opencode.ai/zen/v1/models
| Gemini 3.1 Pro (≤ 200K tokens) | $2.00 | $12.00 | $0.20 | - | | Gemini 3.1 Pro (≤ 200K tokens) | $2.00 | $12.00 | $0.20 | - |
| Gemini 3.1 Pro (> 200K tokens) | $4.00 | $18.00 | $0.40 | - | | Gemini 3.1 Pro (> 200K tokens) | $4.00 | $18.00 | $0.40 | - |
| Gemini 3 Flash | $0.50 | $3.00 | $0.05 | - | | Gemini 3 Flash | $0.50 | $3.00 | $0.05 | - |
| GPT 5.6 Sol (≤ 272K tokens) | $5.00 | $30.00 | $0.50 | $6.25 |
| GPT 5.6 Sol (> 272K tokens) | $10.00 | $45.00 | $1.00 | $12.50 |
| GPT 5.6 Terra (≤ 272K tokens) | $2.50 | $15.00 | $0.25 | $3.125 |
| GPT 5.6 Terra (> 272K tokens) | $5.00 | $22.50 | $0.50 | $6.25 |
| GPT 5.6 Luna (≤ 272K tokens) | $1.00 | $6.00 | $0.10 | $1.25 |
| GPT 5.6 Luna (> 272K tokens) | $2.00 | $9.00 | $0.20 | $2.50 |
| GPT 5.5 (≤ 272K tokens) | $5.00 | $30.00 | $0.50 | - | | GPT 5.5 (≤ 272K tokens) | $5.00 | $30.00 | $0.50 | - |
| GPT 5.5 (> 272K tokens) | $10.00 | $45.00 | $1.00 | - | | GPT 5.5 (> 272K tokens) | $10.00 | $45.00 | $1.00 | - |
| GPT 5.5 Pro | $30.00 | $180.00 | $30.00 | - | | GPT 5.5 Pro | $30.00 | $180.00 | $30.00 | - |

View file

@ -51,6 +51,9 @@ Modellerimize aşağıdaki API uç noktaları aracılığıyla da erişebilirsin
| Model | Model ID | Endpoint | AI SDK Package | | Model | Model ID | Endpoint | AI SDK Package |
| ---------------------- | ---------------------- | ---------------------------------------------------- | --------------------------- | | ---------------------- | ---------------------- | ---------------------------------------------------- | --------------------------- |
| GPT 5.6 Sol | gpt-5.6-sol | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| GPT 5.6 Terra | gpt-5.6-terra | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| GPT 5.6 Luna | gpt-5.6-luna | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| GPT 5.5 | gpt-5.5 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | | GPT 5.5 | gpt-5.5 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| GPT 5.5 Pro | gpt-5.5-pro | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | | GPT 5.5 Pro | gpt-5.5-pro | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| GPT 5.4 | gpt-5.4 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | | GPT 5.4 | gpt-5.4 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
@ -160,6 +163,12 @@ Kullandıkça öde modelini destekliyoruz. Aşağıda **1M token başına** fiya
| Gemini 3.1 Pro (≤ 200K tokens) | $2.00 | $12.00 | $0.20 | - | | Gemini 3.1 Pro (≤ 200K tokens) | $2.00 | $12.00 | $0.20 | - |
| Gemini 3.1 Pro (> 200K tokens) | $4.00 | $18.00 | $0.40 | - | | Gemini 3.1 Pro (> 200K tokens) | $4.00 | $18.00 | $0.40 | - |
| Gemini 3 Flash | $0.50 | $3.00 | $0.05 | - | | Gemini 3 Flash | $0.50 | $3.00 | $0.05 | - |
| GPT 5.6 Sol (≤ 272K tokens) | $5.00 | $30.00 | $0.50 | $6.25 |
| GPT 5.6 Sol (> 272K tokens) | $10.00 | $45.00 | $1.00 | $12.50 |
| GPT 5.6 Terra (≤ 272K tokens) | $2.50 | $15.00 | $0.25 | $3.125 |
| GPT 5.6 Terra (> 272K tokens) | $5.00 | $22.50 | $0.50 | $6.25 |
| GPT 5.6 Luna (≤ 272K tokens) | $1.00 | $6.00 | $0.10 | $1.25 |
| GPT 5.6 Luna (> 272K tokens) | $2.00 | $9.00 | $0.20 | $2.50 |
| GPT 5.5 (≤ 272K tokens) | $5.00 | $30.00 | $0.50 | - | | GPT 5.5 (≤ 272K tokens) | $5.00 | $30.00 | $0.50 | - |
| GPT 5.5 (> 272K tokens) | $10.00 | $45.00 | $1.00 | - | | GPT 5.5 (> 272K tokens) | $10.00 | $45.00 | $1.00 | - |
| GPT 5.5 Pro | $30.00 | $180.00 | $30.00 | - | | GPT 5.5 Pro | $30.00 | $180.00 | $30.00 | - |

View file

@ -60,6 +60,9 @@ You can also access our models through the following API endpoints.
| Model | Model ID | Endpoint | AI SDK Package | | Model | Model ID | Endpoint | AI SDK Package |
| ---------------------- | ---------------------- | ---------------------------------------------------- | --------------------------- | | ---------------------- | ---------------------- | ---------------------------------------------------- | --------------------------- |
| GPT 5.6 Sol | gpt-5.6-sol | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| GPT 5.6 Terra | gpt-5.6-terra | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| GPT 5.6 Luna | gpt-5.6-luna | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| GPT 5.5 | gpt-5.5 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | | GPT 5.5 | gpt-5.5 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| GPT 5.5 Pro | gpt-5.5-pro | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | | GPT 5.5 Pro | gpt-5.5-pro | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| GPT 5.4 | gpt-5.4 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | | GPT 5.4 | gpt-5.4 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
@ -171,6 +174,12 @@ We support a pay-as-you-go model. Below are the prices **per 1M tokens**.
| Gemini 3.1 Pro (≤ 200K tokens) | $2.00 | $12.00 | $0.20 | - | | Gemini 3.1 Pro (≤ 200K tokens) | $2.00 | $12.00 | $0.20 | - |
| Gemini 3.1 Pro (> 200K tokens) | $4.00 | $18.00 | $0.40 | - | | Gemini 3.1 Pro (> 200K tokens) | $4.00 | $18.00 | $0.40 | - |
| Gemini 3 Flash | $0.50 | $3.00 | $0.05 | - | | Gemini 3 Flash | $0.50 | $3.00 | $0.05 | - |
| GPT 5.6 Sol (≤ 272K tokens) | $5.00 | $30.00 | $0.50 | $6.25 |
| GPT 5.6 Sol (> 272K tokens) | $10.00 | $45.00 | $1.00 | $12.50 |
| GPT 5.6 Terra (≤ 272K tokens) | $2.50 | $15.00 | $0.25 | $3.125 |
| GPT 5.6 Terra (> 272K tokens) | $5.00 | $22.50 | $0.50 | $6.25 |
| GPT 5.6 Luna (≤ 272K tokens) | $1.00 | $6.00 | $0.10 | $1.25 |
| GPT 5.6 Luna (> 272K tokens) | $2.00 | $9.00 | $0.20 | $2.50 |
| GPT 5.5 (≤ 272K tokens) | $5.00 | $30.00 | $0.50 | - | | GPT 5.5 (≤ 272K tokens) | $5.00 | $30.00 | $0.50 | - |
| GPT 5.5 (> 272K tokens) | $10.00 | $45.00 | $1.00 | - | | GPT 5.5 (> 272K tokens) | $10.00 | $45.00 | $1.00 | - |
| GPT 5.5 Pro | $30.00 | $180.00 | $30.00 | - | | GPT 5.5 Pro | $30.00 | $180.00 | $30.00 | - |

View file

@ -51,6 +51,9 @@ OpenCode Zen 的工作方式与 OpenCode 中的任何其他提供商相同。
| 模型 | 模型 ID | 端点 | AI SDK 包 | | 模型 | 模型 ID | 端点 | AI SDK 包 |
| ---------------------- | ---------------------- | ---------------------------------------------------- | --------------------------- | | ---------------------- | ---------------------- | ---------------------------------------------------- | --------------------------- |
| GPT 5.6 Sol | gpt-5.6-sol | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| GPT 5.6 Terra | gpt-5.6-terra | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| GPT 5.6 Luna | gpt-5.6-luna | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| GPT 5.5 | gpt-5.5 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | | GPT 5.5 | gpt-5.5 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| GPT 5.5 Pro | gpt-5.5-pro | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | | GPT 5.5 Pro | gpt-5.5-pro | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| GPT 5.4 | gpt-5.4 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | | GPT 5.4 | gpt-5.4 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
@ -160,6 +163,12 @@ https://opencode.ai/zen/v1/models
| Gemini 3.1 Pro (≤ 200K tokens) | $2.00 | $12.00 | $0.20 | - | | Gemini 3.1 Pro (≤ 200K tokens) | $2.00 | $12.00 | $0.20 | - |
| Gemini 3.1 Pro (> 200K tokens) | $4.00 | $18.00 | $0.40 | - | | Gemini 3.1 Pro (> 200K tokens) | $4.00 | $18.00 | $0.40 | - |
| Gemini 3 Flash | $0.50 | $3.00 | $0.05 | - | | Gemini 3 Flash | $0.50 | $3.00 | $0.05 | - |
| GPT 5.6 Sol (≤ 272K tokens) | $5.00 | $30.00 | $0.50 | $6.25 |
| GPT 5.6 Sol (> 272K tokens) | $10.00 | $45.00 | $1.00 | $12.50 |
| GPT 5.6 Terra (≤ 272K tokens) | $2.50 | $15.00 | $0.25 | $3.125 |
| GPT 5.6 Terra (> 272K tokens) | $5.00 | $22.50 | $0.50 | $6.25 |
| GPT 5.6 Luna (≤ 272K tokens) | $1.00 | $6.00 | $0.10 | $1.25 |
| GPT 5.6 Luna (> 272K tokens) | $2.00 | $9.00 | $0.20 | $2.50 |
| GPT 5.5 (≤ 272K tokens) | $5.00 | $30.00 | $0.50 | - | | GPT 5.5 (≤ 272K tokens) | $5.00 | $30.00 | $0.50 | - |
| GPT 5.5 (> 272K tokens) | $10.00 | $45.00 | $1.00 | - | | GPT 5.5 (> 272K tokens) | $10.00 | $45.00 | $1.00 | - |
| GPT 5.5 Pro | $30.00 | $180.00 | $30.00 | - | | GPT 5.5 Pro | $30.00 | $180.00 | $30.00 | - |

View file

@ -55,6 +55,9 @@ OpenCode Zen 的運作方式和 OpenCode 中的其他供應商一樣。
| 模型 | Model ID | 端點 | AI SDK Package | | 模型 | Model ID | 端點 | AI SDK Package |
| ---------------------- | ---------------------- | ---------------------------------------------------- | --------------------------- | | ---------------------- | ---------------------- | ---------------------------------------------------- | --------------------------- |
| GPT 5.6 Sol | gpt-5.6-sol | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| GPT 5.6 Terra | gpt-5.6-terra | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| GPT 5.6 Luna | gpt-5.6-luna | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| GPT 5.5 | gpt-5.5 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | | GPT 5.5 | gpt-5.5 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| GPT 5.5 Pro | gpt-5.5-pro | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | | GPT 5.5 Pro | gpt-5.5-pro | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| GPT 5.4 | gpt-5.4 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | | GPT 5.4 | gpt-5.4 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
@ -165,6 +168,12 @@ https://opencode.ai/zen/v1/models
| Gemini 3.1 Pro (≤ 200K tokens) | $2.00 | $12.00 | $0.20 | - | | Gemini 3.1 Pro (≤ 200K tokens) | $2.00 | $12.00 | $0.20 | - |
| Gemini 3.1 Pro (> 200K tokens) | $4.00 | $18.00 | $0.40 | - | | Gemini 3.1 Pro (> 200K tokens) | $4.00 | $18.00 | $0.40 | - |
| Gemini 3 Flash | $0.50 | $3.00 | $0.05 | - | | Gemini 3 Flash | $0.50 | $3.00 | $0.05 | - |
| GPT 5.6 Sol (≤ 272K tokens) | $5.00 | $30.00 | $0.50 | $6.25 |
| GPT 5.6 Sol (> 272K tokens) | $10.00 | $45.00 | $1.00 | $12.50 |
| GPT 5.6 Terra (≤ 272K tokens) | $2.50 | $15.00 | $0.25 | $3.125 |
| GPT 5.6 Terra (> 272K tokens) | $5.00 | $22.50 | $0.50 | $6.25 |
| GPT 5.6 Luna (≤ 272K tokens) | $1.00 | $6.00 | $0.10 | $1.25 |
| GPT 5.6 Luna (> 272K tokens) | $2.00 | $9.00 | $0.20 | $2.50 |
| GPT 5.5 (≤ 272K tokens) | $5.00 | $30.00 | $0.50 | - | | GPT 5.5 (≤ 272K tokens) | $5.00 | $30.00 | $0.50 | - |
| GPT 5.5 (> 272K tokens) | $10.00 | $45.00 | $1.00 | - | | GPT 5.5 (> 272K tokens) | $10.00 | $45.00 | $1.00 | - |
| GPT 5.5 Pro | $30.00 | $180.00 | $30.00 | - | | GPT 5.5 Pro | $30.00 | $180.00 | $30.00 | - |