chore: merge dev into v2 (#36312)

Co-authored-by: opencode-agent[bot] <219766164+opencode-agent[bot]@users.noreply.github.com>
Co-authored-by: LukeParkerDev <10430890+Hona@users.noreply.github.com>
Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
Co-authored-by: Brendan Allan <14191578+Brendonovich@users.noreply.github.com>
Co-authored-by: Aarav Sareen <96787824+arvsrn@users.noreply.github.com>
Co-authored-by: Julian Coy <julian@ex-machina.co>
Co-authored-by: Brendan Allan <git@brendonovich.dev>
Co-authored-by: usrnk1 <7547651+usrnk1@users.noreply.github.com>
Co-authored-by: opencode <opencode@sst.dev>
Co-authored-by: Vladimir Glafirov <vglafirov@gitlab.com>
Co-authored-by: Adam <2363879+adamdotdevin@users.noreply.github.com>
Co-authored-by: Frank <frank@anoma.ly>
Co-authored-by: Jay <53023+jayair@users.noreply.github.com>
Co-authored-by: Dustin Deus <deusdustin@gmail.com>
Co-authored-by: Kit Langton <kit.langton@gmail.com>
Co-authored-by: James Long <longster@gmail.com>
Co-authored-by: Simon Klee <hello@simonklee.dk>
Co-authored-by: Jay <air@live.ca>
Co-authored-by: Jack <jack@anoma.ly>
Co-authored-by: David Hill <1879069+iamdavidhill@users.noreply.github.com>
Co-authored-by: Aiden Cline <aidenpcline@gmail.com>
Co-authored-by: James Long <jlongster@users.noreply.github.com>
Co-authored-by: 冯基魁 <56265583+fengjikui@users.noreply.github.com>
Co-authored-by: Aiden Cline <rekram1-node@users.noreply.github.com>
Co-authored-by: Victor Navarro <vn4varro@gmail.com>
This commit is contained in:
opencode-agent[bot]
2026-07-10 15:11:29 -05:00
committed by GitHub
co-authored by opencode-agent[bot] <219766164+opencode-agent[bot]@users.noreply.github.com> LukeParkerDev opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com> Aiden Cline Brendan Allan Aarav Sareen Julian Coy Brendan Allan usrnk1 opencode Vladimir Glafirov Adam Frank Jay Dustin Deus Kit Langton James Long Simon Klee Jay Jack David Hill Aiden Cline James Long 冯基魁 Aiden Cline Victor Navarro
parent 9028c2d8f8
commit 43ecf3ff1b
82 changed files with 4520 additions and 1232 deletions
+10
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 {}
+4
View File
@@ -67,6 +67,10 @@ const athenaWorkgroup = new aws.athena.Workgroup("LakeAthenaWorkgroup", {
configuration: {
enforceWorkgroupConfiguration: 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: {
outputLocation: $interpolate`s3://${athenaResultsBucket.bucket}/`,
},
+3 -1
View File
@@ -185,7 +185,9 @@ export const statSync = new sst.aws.Service("StatsSyncService", {
cluster: lakeCluster,
architecture: "arm64",
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: {
context: ".",
dockerfile: "packages/stats/server/Dockerfile",
@@ -74,6 +74,10 @@ test("opens and searches project files inline", async ({ page }) => {
"opencode.global.dat:layout",
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(
"opencode.window.browser.dat:tabs",
JSON.stringify([{ type: "session", server, sessionId: sessionID }]),
@@ -86,13 +90,16 @@ test("opens and searches project files inline", async ({ page }) => {
await expectSessionTitle(page, title)
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" })
await contextButton.click()
await expect(panel.getByRole("tab", { name: "Context" })).toHaveAttribute("data-selected", "")
await panel.getByRole("button", { name: "Open file" }).click()
await expect(panel.getByRole("tab", { name: "Open file" })).toHaveAttribute("data-selected", "")
await expect(sidebar).toBeVisible()
await contextButton.click()
await expect(panel.getByRole("tab", { name: "Context" })).toHaveAttribute("data-selected", "")
await expect(sidebar).toHaveCount(0)
await panel.getByRole("button", { name: "Open file" }).click()
const filter = panel.getByRole("combobox", { name: "Filter files" })
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 expect(panel.getByRole("tab", { name: "README.md" })).toHaveAttribute("data-selected", "")
await expect(panel.getByText("contents:README.md", { exact: true })).toBeVisible()
await expect(sidebar).toHaveCount(0)
await panel.getByRole("button", { name: "Open file" }).click()
await expect(panel.getByRole("tab", { name: "README.md" })).toHaveCount(0)
await expect(sidebar).toBeVisible()
await filter.fill("nested")
const result = panel.getByRole("option", { name: /nested\.ts/ })
await expect(result).toBeVisible()
@@ -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,
},
)
}
+8 -8
View File
@@ -36,6 +36,8 @@ import { Icon } from "@opencode-ai/ui/icon"
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
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 { MenuV2 } from "@opencode-ai/ui/v2/menu-v2"
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
@@ -1333,6 +1335,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
onQueue: props.onQueue,
onAbort: props.onAbort,
onSubmit: props.onSubmit,
model: props.controls.model.selection,
})
const handleKeyDown = (event: KeyboardEvent) => {
@@ -1704,22 +1707,19 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
>
<MenuV2 gutter={6} modal={false} placement="top-start">
<MenuV2.Trigger
as={IconButton}
as={IconButtonV2}
data-action="prompt-attach"
type="button"
icon="plus"
variant="ghost"
class="size-7 rounded-md p-[6px] text-v2-icon-icon-muted"
icon={<IconV2 name="plus" />}
variant="ghost-muted"
size="large"
style={buttons()}
disabled={store.mode !== "normal"}
tabIndex={store.mode === "normal" ? undefined : -1}
aria-label={language.t("prompt.menu.addImagesAndFiles")}
/>
<MenuV2.Portal>
<MenuV2.Content
class="[&_[data-slot=menu-v2-item-shortcut]]:w-5 [&_[data-slot=menu-v2-item-shortcut]]:justify-center"
style={{ "min-width": "180px" }}
>
<MenuV2.Content style={{ "min-width": "180px" }}>
<MenuV2.Item onSelect={pick} shortcut={command.keybind("file.attach")}>
{language.t("prompt.menu.imagesAndFiles")}
</MenuV2.Item>
@@ -1,5 +1,6 @@
import { beforeAll, beforeEach, describe, expect, mock, test } from "bun:test"
import type { Prompt } from "@/context/prompt"
import type { ModelSelection } from "@/context/local"
let createPromptSubmit: typeof import("./submit").createPromptSubmit
@@ -33,6 +34,10 @@ const prompt = {
current: () => promptValue,
cursor: () => 0,
dirty: () => true,
model: {
current: () => undefined,
set: () => undefined,
},
reset: () => undefined,
set: () => undefined,
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 () => {
const submit = createPromptSubmit({
prompt,
@@ -3,12 +3,12 @@ import { showToast } from "@/utils/toast"
import { base64Encode } from "@opencode-ai/core/util/encode"
import { Binary } from "@opencode-ai/core/util/binary"
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 { useServerSync, type ServerSync } from "@/context/server-sync"
import { useLanguage } from "@/context/language"
import { useLayout } from "@/context/layout"
import { useLocal } from "@/context/local"
import { useLocal, type ModelSelection } from "@/context/local"
import { usePermission } from "@/context/permission"
import { type ContextItem, type ImageAttachmentPart, type Prompt, type usePrompt } from "@/context/prompt"
import { useSDK, type DirectorySDK } from "@/context/sdk"
@@ -191,6 +191,7 @@ type PromptSubmitInput = {
onQueue?: (draft: FollowupDraft) => void
onAbort?: () => void
onSubmit?: () => void
model?: ModelSelection
}
export function createPromptSubmit(input: PromptSubmitInput) {
@@ -296,9 +297,10 @@ export function createPromptSubmit(input: PromptSubmitInput) {
return
}
const currentModel = local.model.current()
const modelSelection = input.model ?? local.model
const currentModel = modelSelection.current()
const currentAgent = local.agent.current()
const variant = local.model.variant.current()
const variant = modelSelection.variant.current()
if (!currentModel || !currentAgent) {
showToast({
title: language.t("prompt.toast.modelAgentRequired.title"),
@@ -372,13 +374,20 @@ export function createPromptSubmit(input: PromptSubmitInput) {
if (created) {
seed(sessionDirectory, created)
session = created
if (shouldAutoAccept) permission.enableAutoAccept(session.id, sessionDirectory)
local.session.promote(sessionDirectory, session.id)
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 }))
await startTransition(() => {
if (!session) return
if (shouldAutoAccept) permission.enableAutoAccept(session.id, sessionDirectory)
local.session.promote(sessionDirectory, session.id, {
agent: currentAgent.name,
model: { providerID: currentModel.provider.id, modelID: currentModel.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) {
@@ -12,7 +12,7 @@ import { useSync } from "@/context/sync"
import { useLanguage } from "@/context/language"
import { useProviders } from "@/hooks/use-providers"
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 { createSessionTabs } from "@/pages/session/helpers"
import { useSettings } from "@/context/settings"
@@ -74,7 +74,6 @@ export function SessionContextUsage(props: SessionContextUsageProps) {
)
const context = createMemo(() => getSessionContext(messages(), [...providers.all().values()]))
const tokens = createMemo(() => info()?.tokens)
const cost = createMemo(() => {
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.tokens")}
value={getSessionTokenTotal(tokens())?.toLocaleString(language.intl()) ?? "0"}
value={context()?.total.toLocaleString(language.intl()) ?? "0"}
/>
</div>
)
@@ -1,6 +1,6 @@
import { describe, expect, test } from "bun:test"
import type { Message } from "@opencode-ai/sdk/v2/client"
import { getSessionContext, getSessionTokenTotal } from "./session-context-metrics"
import { getSessionContext } from "./session-context-metrics"
const assistant = (
id: string,
@@ -38,10 +38,10 @@ const user = (id: string) => {
}
describe("getSessionContext", () => {
test("computes usage from latest assistant with tokens", () => {
test("computes token totals and usage from latest assistant with tokens", () => {
const messages = [
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),
]
const providers = [
@@ -60,6 +60,8 @@ describe("getSessionContext", () => {
const ctx = getSessionContext(messages, providers)
expect(ctx?.message.id).toBe("a2")
expect(ctx?.total).toBe(500)
expect(ctx?.input).toBe(300)
expect(ctx?.usage).toBe(50)
expect(ctx?.providerLabel).toBe("OpenAI")
expect(ctx?.modelLabel).toBe("GPT-4.1")
@@ -94,15 +96,4 @@ describe("getSessionContext", () => {
expect(ctx).toBeUndefined()
})
test("computes stored session token totals", () => {
expect(
getSessionTokenTotal({
input: 10,
output: 20,
reasoning: 30,
cache: { read: 40, write: 50 },
}),
).toBe(150)
})
})
@@ -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 = {
id: string
@@ -21,6 +21,7 @@ type Context = {
modelLabel: string
limit: number | undefined
input: number
total: number
usage: number | null
}
@@ -54,6 +55,7 @@ const build = (messages: Message[] = [], providers: Provider[] = []): Context |
modelLabel: model?.name ?? message.modelID,
limit,
input: message.tokens.input,
total,
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[] = []) {
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
}
@@ -15,7 +15,7 @@ import { useLanguage } from "@/context/language"
import { useProviders } from "@/hooks/use-providers"
import { useSDK } from "@/context/sdk"
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 { createSessionContextFormatter } from "./session-context-format"
@@ -135,7 +135,6 @@ export function SessionContextTab() {
)
const ctx = createMemo(() => getSessionContext(messages(), [...providers.all().values()]))
const tokens = createMemo(() => info()?.tokens)
const formatter = createMemo(() => createSessionContextFormatter(language.intl()))
const cost = createMemo(() => {
@@ -204,14 +203,15 @@ export function SessionContextTab() {
{ label: "context.stats.provider", value: providerLabel },
{ label: "context.stats.model", value: modelLabel },
{ 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.inputTokens", value: () => formatter().number(tokens()?.input) },
{ label: "context.stats.outputTokens", value: () => formatter().number(tokens()?.output) },
{ label: "context.stats.reasoningTokens", value: () => formatter().number(tokens()?.reasoning) },
{ label: "context.stats.inputTokens", value: () => formatter().number(ctx()?.input) },
{ label: "context.stats.outputTokens", value: () => formatter().number(ctx()?.message.tokens.output) },
{ label: "context.stats.reasoningTokens", value: () => formatter().number(ctx()?.message.tokens.reasoning) },
{
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.assistantMessages", value: () => counts().assistant.toLocaleString(language.intl()) },
@@ -211,7 +211,17 @@ export function SortableTerminalTabV2(props: {
<MenuV2.Context.Trigger class="relative" as="div">
<Tabs.Trigger
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={
<IconButton
icon="close-small"
@@ -20,6 +20,11 @@
height: 100%;
overflow-y: auto;
scrollbar-width: none;
user-select: none;
}
.settings-v2-panel :is(input, textarea, [contenteditable="true"]) {
user-select: text;
}
.settings-v2-panel::-webkit-scrollbar {
@@ -181,6 +186,7 @@
flex-direction: column;
gap: 8px;
padding: 4px 0 4px 4px;
user-select: none;
}
.settings-v2-nav-footer > span {
+19 -2
View File
@@ -1,7 +1,8 @@
import { withAlpha } from "@opencode-ai/ui/theme/color"
import { useTheme } from "@opencode-ai/ui/theme/context"
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 type { FitAddon, Ghostty, Terminal as Term } from "ghostty-web"
import { type ComponentProps, createEffect, createMemo, onCleanup, onMount, splitProps } from "solid-js"
@@ -68,6 +69,19 @@ const debugTerminal = (...values: unknown[]) => {
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: {
container: HTMLDivElement
term: Term
@@ -238,7 +252,10 @@ export const Terminal = (props: TerminalProps) => {
if (!variant?.seeds && !variant?.palette) return fallback
const resolved = resolveThemeVariant(variant, mode === "dark")
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 base = text.startsWith("#") ? (text as HexColor) : (fallback.foreground as HexColor)
const selectionBackground = withAlpha(base, alpha)
+10 -2
View File
@@ -25,6 +25,7 @@ import { readSessionTabsRemovedDetail, SESSION_TABS_REMOVED_EVENT } from "@/comp
import { useGlobal } from "@/context/global"
import { ServerConnection, useServer } from "@/context/server"
import { tabKey, useTabs } from "@/context/tabs"
import type { PromptSession } from "@/context/prompt"
import "./titlebar.css"
import { newTabTooltipKeybind } from "./command-tooltip-keybind"
@@ -324,13 +325,20 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
const route = layout.route()
const activeSession = session()
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
}
const activeTab = currentTab()
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
}
+6 -3
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 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"]),
migrate,
@@ -375,11 +375,12 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
model,
agent,
session: {
ready: savedReady,
reset() {
setStore({ draft: undefined, promoting: undefined })
},
promote(dir: string, session: string) {
const next = clone(snapshot())
promote(dir: string, session: string, state?: State) {
const next = clone(state ?? snapshot())
if (!next) return
const key = handoffKey(serverSDK().scope, dir, session)
handoff.set(key, next)
@@ -409,3 +410,5 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
return result
},
})
export type ModelSelection = ReturnType<typeof useLocal>["model"]
@@ -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()
})
})
})
+265
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),
}
}
+49 -271
View File
@@ -1,186 +1,49 @@
import { base64Encode } from "@opencode-ai/core/util/encode"
import { createSimpleContext } from "@opencode-ai/ui/context"
import { base64Encode, checksum } from "@opencode-ai/core/util/encode"
import { useParams, useSearchParams } from "@solidjs/router"
import { batch, createMemo, createRoot, getOwner, onCleanup, 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 { createMemo, createRoot, getOwner, onCleanup } from "solid-js"
import { requireServerKey } from "@/utils/session-route"
import { ServerConnection } from "./server"
import { useServerSDK } from "./server-sdk"
import type { ServerScope } from "@/utils/server-scope"
import { useSettings } from "./settings"
import { useSDK } from "./sdk"
import { useTabs, type Tab } from "./tabs"
import { ServerConnection } from "./server"
import { requireServerKey } from "@/utils/session-route"
import { useSettings } from "./settings"
import type { FilePartSource } from "@opencode-ai/sdk/v2/client"
import {
createPromptReady,
createPromptSession,
type ContextItem,
type FileContextItem,
type Prompt,
type PromptModel,
type PromptScope,
type PromptSession,
} from "./prompt-state"
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 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)
})
},
}
}
export {
createPromptReady,
createPromptSession,
createPromptState,
DEFAULT_PROMPT,
isPromptEqual,
} from "./prompt-state"
export type {
AgentPart,
ContentPart,
ContextItem,
FileAttachmentPart,
FileContextItem,
ImageAttachmentPart,
Prompt,
PromptModel,
PromptScope,
PromptSession,
TextPart,
} from "./prompt-state"
const WORKSPACE_KEY = "__workspace__"
const MAX_PROMPT_SESSIONS = 20
type PromptSession = ReturnType<typeof createPromptSession>
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) {
export function selectPromptTab(tabs: Tab[], scope: PromptScope, server: ServerConnection.Key) {
if ("draftID" in scope) return tabs.find((tab) => tab.type === "draft" && tab.draftID === scope.draftID)
if (!scope.id) 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}`
return `${scope.dir}:${scope.id ?? WORKSPACE_KEY}`
}
@@ -199,91 +62,6 @@ type PromptCacheEntry = {
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 = (
tabs: ReturnType<typeof useTabs>,
tab: Tab,
@@ -303,9 +81,7 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext(
const cache = new Map<string, PromptCacheEntry>()
const disposeAll = () => {
for (const entry of cache.values()) {
entry.dispose()
}
for (const entry of cache.values()) entry.dispose()
cache.clear()
}
@@ -324,13 +100,11 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext(
const owner = getOwner()
const serverKey = () =>
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 }
const load = (scope: Scope) => {
const load = (scope: PromptScope) => {
const current = settings.general.newLayoutDesigns() ? selectPromptTab(tabs.store, scope, serverKey()) : undefined
if (current) {
return createTabPromptState(tabs, current, serverSDK().scope, scope)
}
if (current) return createTabPromptState(tabs, current, serverSDK().scope, scope)
const key = scopeKey(scope)
const existing = cache.get(key)
@@ -354,15 +128,19 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext(
}
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)
return {
ready,
capture: (scope?: Scope) => pick(scope).capture(),
capture: (scope?: PromptScope) => pick(scope).capture(),
current: () => session().current(),
cursor: () => session().cursor(),
dirty: () => session().dirty(),
model: {
current: () => session().model.current(),
set: (model: PromptModel | undefined) => session().model.set(model),
},
context: {
items: () => session().context.items(),
add: (item: ContextItem) => session().context.add(item),
@@ -372,8 +150,8 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext(
session().context.updateComment(path, commentID, next),
replaceComments: (items: FileContextItem[]) => session().context.replaceComments(items),
},
set: (prompt: Prompt, cursorPosition?: number, scope?: Scope) => pick(scope).set(prompt, cursorPosition),
reset: (scope?: Scope) => pick(scope).reset(),
set: (prompt: Prompt, cursorPosition?: number, scope?: PromptScope) => pick(scope).set(prompt, cursorPosition),
reset: (scope?: PromptScope) => pick(scope).reset(),
}
},
})
+3
View File
@@ -16,6 +16,9 @@ export function createTabMemory(owner: Owner | null) {
}
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) {
const state = entries.get(key) ?? new Map<string, Entry>()
if (!entries.has(key)) entries.set(key, state)
+2
View File
@@ -22,6 +22,8 @@ describe("tab memory", () => {
})
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)
memory.remove("tab")
+9 -3
View File
@@ -11,6 +11,7 @@ import { SessionTabsRemovedDetail } from "@/components/titlebar-session-events"
import { sessionHref } from "@/utils/session-route"
import { createTabMemory } from "./tab-memory"
import { nextTabAfterClose, pushClosedTab, removeClosedTabs, takeClosedTab, type ClosedTab } from "./closed-tabs"
import { createDraftPromptSession, type PromptModel } from "./prompt-state"
export type SessionTab = {
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}`)
return tab
},
newDraft(draft: Omit<DraftTab, "type" | "draftID">, prompt?: string) {
newDraft(draft: Omit<DraftTab, "type" | "draftID">, prompt?: string, model?: PromptModel) {
const draftID = uuid()
const tab = { type: "draft" as const, draftID, ...draft }
memory.ensure(tabKey(tab), "prompt", () => createDraftPromptSession(draftID, { prompt, model }))
void startTransition(() => {
setStore(
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">>) {
@@ -373,6 +376,9 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
state<T>(tab: Tab, name: string, init: () => T) {
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 }
+18 -1
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 { PromptWorkspaceSelector } from "@/components/prompt-workspace-selector"
import { useTitlebarRightMount } from "@/components/titlebar"
import { useCommand } from "@/context/command"
import { useProviders } from "@/hooks/use-providers"
import { useSettingsDialog } from "@/components/settings-dialog"
import { Persist, persisted } from "@/utils/persist"
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 providerTipDismissalDuration = 30 * 24 * 60 * 60 * 1000
@@ -48,12 +51,15 @@ export default function NewSessionPage() {
const comments = useComments()
const language = useLanguage()
const settings = useSettings()
const command = useCommand()
const providers = useProviders(() => sdk().directory)
const openProviderSettings = useSettingsDialog("providers")
const route = useSessionKey()
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
@@ -61,6 +67,7 @@ export default function NewSessionPage() {
sessionKey: route.sessionKey,
sessionID: () => route.params.id,
queryOptions: serverSync().queryOptions,
model,
})
const projectControls = createPromptProjectControls()
const projectController = createPromptProjectController({
@@ -68,6 +75,16 @@ export default function NewSessionPage() {
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 rightMount = useTitlebarRightMount()
+14 -3
View File
@@ -69,7 +69,7 @@ import { MessageTimeline } from "@/pages/session/timeline/message-timeline"
import { createTimelineModel } from "@/pages/session/timeline/model"
import { type DiffStyle, SessionReviewTab, type SessionReviewTabProps } from "@/pages/session/review-tab"
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 {
clampSessionPanelWidth,
SESSION_PANEL_WIDTH_MIN,
@@ -483,7 +483,7 @@ export default function Page() {
if (desktopSessionResizeOpen()) return `${sessionPanelResizedWidth()}px`
return `calc(100% - ${layout.fileTree.width()}px)`
})
const centered = createMemo(() => isDesktop() && !desktopReviewOpen())
const centered = createMemo(() => isDesktop() && (newSessionDesign() || !desktopReviewOpen()))
const desktopV2PanelLayout = createMemo(() =>
sessionPanelLayout({
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(
on(
() => ({ dir: sdk().directory, id: params.id }),
@@ -1267,7 +1278,7 @@ export default function Page() {
const reviewPanelV2Rendered = createMemo<boolean>((prev) => prev || !store.deferRender, false)
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()}>
<ReviewPanelV2 {...reviewPanelV2Props()} />
</Show>
@@ -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
}
@@ -7,7 +7,7 @@ import type { PromptProjectControls } from "@/components/prompt-project-selector
import { useDirectoryPicker } from "@/components/directory-picker"
import { useGlobal } from "@/context/global"
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 { useServerSDK } from "@/context/server-sdk"
import { serverName, ServerConnection, useServer } from "@/context/server"
@@ -22,6 +22,7 @@ export function createPromptInputController(input: {
sessionKey: Accessor<string>
sessionID: Accessor<string | undefined>
queryOptions: Pick<QueryOptionsApi, "agents" | "providers">
model?: ModelSelection
}) {
const layout = useLayout()
const local = useLocal()
@@ -44,7 +45,7 @@ export function createPromptInputController(input: {
select: local.agent.set,
},
model: {
selection: local.model,
selection: input.model ?? local.model,
paid: providers.paid().length > 0,
loading: agentsQuery.isLoading || providersQuery.isLoading || globalProvidersQuery.isLoading,
},
@@ -1,6 +1,6 @@
import { describe, expect, test } from "bun:test"
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"] }) =>
({
@@ -50,3 +50,102 @@ describe("resetSessionModel", () => {
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([])
})
})
@@ -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) => {
local.session.reset()
}
@@ -14,3 +32,32 @@ export const resetSessionModel = (local: Local) => {
export const syncSessionModel = (local: Local, msg: UserMessage) => {
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
}
@@ -249,8 +249,10 @@ export function SessionSidePanel(props: {
aria-label={language.t("session.panel.reviewAndFiles")}
aria-hidden={!open()}
inert={!open()}
class="relative min-w-0 flex overflow-hidden bg-background-base"
class="relative min-w-0 flex overflow-hidden"
classList={{
"bg-v2-background-bg-base": settings.general.newLayoutDesigns(),
"bg-background-base": !settings.general.newLayoutDesigns(),
"h-full shrink-0": !props.stacked,
"h-full min-h-0": props.stacked,
"pointer-events-none": !open(),
@@ -269,8 +271,20 @@ export function SessionSidePanel(props: {
}}
>
<Show when={reviewOpen()}>
<div class="relative min-w-0 h-full flex-1 overflow-hidden bg-background-base">
<div class="size-full min-w-0 h-full bg-background-base">
<div
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
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
@@ -373,7 +387,13 @@ export function SessionSidePanel(props: {
)}
</For>
</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
title={language.t("command.file.open")}
keybind={command.keybind("file.open")}
@@ -195,7 +195,7 @@ export function TerminalPanelV2(props: { stacked?: boolean } = {}) {
aria-label={language.t("terminal.title")}
aria-hidden={!opened()}
inert={!opened()}
class="relative shrink-0 overflow-hidden bg-background-stronger"
class="relative shrink-0 overflow-hidden bg-v2-background-bg-base"
classList={{
"w-full": !isDesktop() || stacked(),
"min-w-0 h-full flex-1": isDesktop() && opened() && !stacked(),
@@ -237,7 +237,7 @@ export function TerminalPanelV2(props: { stacked?: boolean } = {}) {
when={terminal.ready()}
fallback={
<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()}>
{(title) => (
<div class="px-2 py-1 rounded-md bg-surface-base text-14-regular text-text-weak truncate max-w-40">
@@ -1243,12 +1243,13 @@ export function MessageTimeline(props: {
const initialRow = timelineRowByKey().get(props.rowKey)!
const item = createMemo(() => virtualItemByKey().get(props.rowKey) ?? initialItem)
const row = createMemo(() => timelineRowByKey().get(props.rowKey) ?? initialRow)
const asyncFile = () => {
const tool = () => {
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)
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())
let contentMeasureFrame: number | undefined
@@ -1278,6 +1279,8 @@ export function MessageTimeline(props: {
width: "100%",
height: `${item().size}px`,
overflow: "clip",
// Rounded virtual measurements can otherwise clip a framed row's outer paint.
"overflow-clip-margin": row()._tag === "TurnGap" ? undefined : "0.5px",
}}
>
<div
@@ -1383,7 +1386,7 @@ export function MessageTimeline(props: {
"w-full": true,
"pb-4": true,
"pr-3": true,
"pl-2": settings.general.newLayoutDesigns(),
"pl-2.5": 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(),
}}
@@ -1,6 +1,6 @@
import { useCommand, type CommandOption } from "@/context/command"
import { useLanguage } from "@/context/language"
import { useLocal } from "@/context/local"
import { useLocal, type ModelSelection } from "@/context/local"
import { useSettings } from "@/context/settings"
import { useDialog } from "@opencode-ai/ui/context/dialog"
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 dialog = useDialog()
const language = useLanguage()
@@ -22,6 +22,7 @@ export const useComposerCommands = () => {
const settings = useSettings()
const { sessionKey } = useSessionLayout()
const sessionOwnership = createSessionOwnership(sessionKey)
const model = input.model ?? local.model
const modelCommand = withCategory(language.t("command.category.model"))
const agentCommand = withCategory(language.t("command.category.agent"))
@@ -43,7 +44,7 @@ export const useComposerCommands = () => {
}
const { DialogSelectModel } = await import("@/components/dialog-select-model")
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"),
description: language.t("command.model.variant.cycle.description"),
keybind: "shift+mod+d",
onSelect: () => local.model.variant.cycle(),
onSelect: () => model.variant.cycle(),
}),
agentCommand({
id: "agent.cycle",
@@ -46,6 +46,7 @@ export function SessionFileBrowserTab(props: {
const resultsID = `session-file-browser-results-${createUniqueId()}`
const [filter, setFilter] = createSignal("")
const [explicitHighlight, setExplicitHighlight] = createSignal<string>()
const sidebarOpened = () => props.placeholder || props.state.sidebarOpened()
const query = createMemo(() => filter().trim())
const search = createQuery(() => {
const value = query()
@@ -98,15 +99,15 @@ export function SessionFileBrowserTab(props: {
toolbar
toolbarStart={
<>
<SessionReviewV2SidebarToggle opened={props.state.sidebarOpened()} onToggle={props.state.toggleSidebar} />
<Show when={!props.state.sidebarOpened()}>
<SessionReviewV2SidebarToggle opened={sidebarOpened()} onToggle={props.state.toggleSidebar} />
<Show when={!sidebarOpened()}>
<SessionFilePanelV2Title>{title()}</SessionFilePanelV2Title>
</Show>
</>
}
sidebar={
<SessionReviewV2Sidebar
open={props.state.sidebarOpened()}
open={sidebarOpened()}
title={<span class="truncate">{title()}</span>}
filter={filter()}
onFilterChange={setFilter}
+1 -1
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() {
return DRAFT_PERSISTED_KEYS
+10
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 {}
+4 -1
View File
@@ -3,4 +3,7 @@ Allow: /
# Disallow shared content pages
Disallow: /s/
Disallow: /share/
Disallow: /share/
Sitemap: https://opencode.ai/sitemap.xml
Sitemap: https://opencode.ai/data/sitemap.xml
@@ -4,6 +4,7 @@ type Usage = {
input_tokens?: number
input_tokens_details?: {
cached_tokens?: number
cache_write_tokens?: number
}
output_tokens?: number
output_tokens_details?: {
@@ -48,12 +49,13 @@ export const openaiHelper: ProviderHelper = ({ workspaceID }) => ({
const outputTokens = usage.output_tokens ?? 0
const reasoningTokens = usage.output_tokens_details?.reasoning_tokens ?? undefined
const cacheReadTokens = usage.input_tokens_details?.cached_tokens ?? undefined
const cacheWriteTokens = usage.input_tokens_details?.cache_write_tokens ?? undefined
return {
inputTokens: inputTokens - (cacheReadTokens ?? 0),
outputTokens,
reasoningTokens,
cacheReadTokens,
cacheWrite5mTokens: undefined,
cacheWrite5mTokens: cacheWriteTokens,
cacheWrite1hTokens: undefined,
}
},
@@ -65,4 +65,20 @@ describe("provider usage extraction", () => {
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,
})
})
})
+5 -1
View File
@@ -112,7 +112,11 @@ export const TuiThreadCommand = cmd({
}
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 reload = () => {
client.call("reload", undefined).catch(() => {})
@@ -16,6 +16,12 @@ describe("tui thread", () => {
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) {
await using tmp = await tmpdir({ git: true })
const link = path.join(path.dirname(tmp.path), path.basename(tmp.path) + "-link")
@@ -4,7 +4,7 @@
height: 100%;
min-height: 0;
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"] {
@@ -31,7 +31,7 @@
min-height: 0;
overflow: hidden;
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"] {
File diff suppressed because it is too large Load Diff
@@ -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
}
+103 -21
View File
@@ -1,3 +1,4 @@
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
import { For, Show } from "solid-js"
import { catalogSlug, formatCatalogLabName, type ModelCatalogEntry } from "./model-catalog"
@@ -13,6 +14,7 @@ export type ComparisonPair = {
first: ComparisonModelRef
second: ComparisonModelRef
detail: string
description?: string
}
export function modelRefFromCatalog(entry: ModelCatalogEntry): ComparisonModelRef {
@@ -30,6 +32,11 @@ export function comparisonHref(first: ComparisonModelRef, second: ComparisonMode
)}/${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[]) {
return pairs.reduce<{ keys: Set<string>; pairs: ComparisonPair[] }>(
(result, pair) => {
@@ -48,34 +55,25 @@ export function ComparisonCardsSection(props: {
title?: string
description?: string
compact?: boolean
variant?: "panel" | "featured"
}) {
const featured = () => props.variant === "featured"
const pairs = () => (featured() ? props.pairs.slice(0, 4) : props.pairs)
return (
<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">
<strong>{props.title ?? "Model Comparisons"}.</strong>{" "}
<span>{props.description ?? "Compare usage, cost, limits, and features."}</span>
</p>
<div data-component="comparison-card-grid">
<For each={props.pairs}>
{(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>
)}
<div data-component={featured() ? "compare-home-card-grid" : "comparison-card-grid"}>
<For each={pairs()}>
{(pair) => (featured() ? <FeaturedComparisonCard pair={pair} /> : <ComparisonPanelCard pair={pair} />)}
</For>
</div>
</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) {
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
}
@@ -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`
}
@@ -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>
)
}
@@ -1,611 +1 @@
import "../../../../index.css"
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")
}
export { default } from "../../../../../component/model-compare-detail"
+16 -51
View File
@@ -8,7 +8,13 @@ import { LocaleLinks } from "../../component/locale-links"
import { useI18n } from "../../context/i18n"
import { useLanguage } from "../../context/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 { setStatsPageCacheHeaders } from "../stats-cache"
import {
@@ -56,13 +62,6 @@ const categoryTemplates = [
},
] as const
type CompareCategory = {
title: string
description: string
first: ComparisonModelRef
second: ComparisonModelRef
avatars: ComparisonModelRef[]
}
type CompareSlot = "first" | "second"
export default function ModelCompareIndex() {
@@ -162,16 +161,12 @@ export default function ModelCompareIndex() {
<CompareHomeSelector models={featuredModels()} />
</Show>
</section>
<Show when={categories().length > 0}>
<section id="model-comparison" data-section="compare-home-related">
<p data-slot="section-title">
<strong>Related comparisons.</strong> <span>Other model pairs to check.</span>
</p>
<div data-component="compare-home-card-grid">
<For each={categories()}>{(category) => <CompareHomeCard category={category} />}</For>
</div>
</section>
</Show>
<ComparisonCardsSection
pairs={categories()}
title="Related comparisons"
description="Other model pairs to check."
variant="featured"
/>
</div>
<Footer
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" }) {
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[] {
return categoryTemplates.reduce<{ keys: Set<string>; categories: CompareCategory[] }>(
function buildComparisonCategories(models: ModelCatalogEntry[]): ComparisonPair[] {
return categoryTemplates.reduce<{ keys: Set<string>; categories: ComparisonPair[] }>(
(result, template, index) => {
const candidates = categoryCandidates(template.kind, models)
const pair = categoryPair(candidates, models, index, result.keys)
@@ -496,11 +462,10 @@ function buildComparisonCategories(models: ModelCatalogEntry[]): CompareCategory
const first = modelRefFromCatalog(pair.first)
const second = modelRefFromCatalog(pair.second)
result.categories.push({
title: template.title,
detail: template.title,
description: template.description,
first,
second,
avatars: [first, second],
})
return result
},
+861 -27
View File
@@ -87,6 +87,11 @@
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"] [data-component="leaderboard"][id] {
scroll-margin-top: 88px;
@@ -5852,7 +5857,6 @@
}
[data-page="stats"] [data-slot="compare-home-card-head"] b {
position: relative;
display: grid;
place-items: center;
width: 32px;
@@ -5863,32 +5867,10 @@
box-shadow: 0 1px 1.5px #0000000f;
}
[data-page="stats"] [data-slot="compare-home-card-head"] b::before {
position: absolute;
top: 8px;
left: 8px;
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-head"] b svg {
display: block;
width: 16px;
height: 16px;
}
[data-page="stats"] [data-slot="compare-home-card-divider"] {
@@ -5923,6 +5905,858 @@
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"] {
position: fixed;
inset: 0;
+1
View File
@@ -194,6 +194,7 @@ export default function StatsHome() {
pairs={homeComparisonPairs(stats().leaderboard["All Users"]["2M"])}
title="Model Comparisons"
description="Popular model pairs from the leaderboard."
variant="featured"
/>
</>
)}
@@ -56,14 +56,18 @@ export type ModelCatalog = {
labs: ModelCatalogLab[]
}
export const getModelCatalog = query(async () => {
"use server"
export async function loadModelCatalog() {
const [models, pricing, labs] = await Promise.all([
fetchCatalogPayload(modelCatalogSourceUrl),
fetchCatalogPayload(modelCatalogPricingUrl),
fetchLabCatalogPayload(modelCatalogLabSourceUrl),
])
return buildModelCatalog(models, pricing, labs)
}
export const getModelCatalog = query(async () => {
"use server"
return loadModelCatalog()
}, "getModelCatalog")
export function findModelCatalogEntry(catalog: ModelCatalog, model: string, lab?: string) {
@@ -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;")
}
+25 -25
View File
@@ -113,32 +113,32 @@ const poll: (
return yield* poll(client, queryExecutionId, attempt + 1)
})
const results: (
client: AwsAthenaClient,
queryExecutionId: string,
nextToken?: string,
) => Effect.Effect<AthenaData[], AthenaQueryError> = Effect.fn("Athena.results")(function* (
client: AwsAthenaClient,
queryExecutionId: string,
nextToken?: string,
) {
const result = yield* Effect.tryPromise({
try: () =>
client.send(
new GetQueryResultsCommand({
QueryExecutionId: queryExecutionId,
NextToken: nextToken,
MaxResults: ATHENA_PAGE_SIZE,
}),
),
catch: (cause) => new AthenaQueryError({ message: "Failed to read Athena stats results", queryExecutionId, cause }),
const results: (client: AwsAthenaClient, queryExecutionId: string) => Effect.Effect<AthenaData[], AthenaQueryError> =
Effect.fn("Athena.results")(function* (client: AwsAthenaClient, queryExecutionId: string) {
// Accumulate pages iteratively; recursive spreads copied every previously
// fetched row per page and blew up memory on large result sets.
const rows: AthenaData[] = []
let nextToken: string | undefined
while (true) {
const result = yield* Effect.tryPromise({
try: () =>
client.send(
new GetQueryResultsCommand({
QueryExecutionId: queryExecutionId,
NextToken: nextToken,
MaxResults: ATHENA_PAGE_SIZE,
}),
),
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 {
return Object.fromEntries(
+26 -12
View File
@@ -94,10 +94,15 @@ export type StatsModelComparisonEntry = {
tokenShare: number
tokenChange: number
totals: StatsModelData["totals"]
usage: ModelUsagePoint[]
}
export type StatsModelComparisonInput = {
provider: string
model: string
}
export type StatsModelComparisonData = {
updatedAt: string | null
models: [StatsModelComparisonEntry | null, StatsModelComparisonEntry | null]
models: (StatsModelComparisonEntry | null)[]
}
export type StatsHomeData = {
updatedAt: string | null
@@ -289,27 +294,35 @@ function dateValue(value: unknown) {
return value instanceof Date ? value : new Date(stringValue(value))
}
export const getStatsModelComparisonData: (
firstProvider: string,
firstModel: string,
secondProvider: string,
secondModel: string,
) => Effect.Effect<StatsModelComparisonData, DatabaseError, ModelStatRepo> = Effect.fn("StatsModelComparison.getData")(
function* (firstProvider, firstModel, secondProvider, secondModel) {
export const getStatsModelsComparisonData: (
models: readonly StatsModelComparisonInput[],
) => Effect.Effect<StatsModelComparisonData, DatabaseError, ModelStatRepo> = Effect.fn("StatsModelsComparison.getData")(
function* (models) {
const modelStats = yield* ModelStatRepo
const rows = yield* modelStats.listDaily()
const first = toComparisonEntry(buildStatsModelData(firstModel, rows, [], firstProvider))
const second = toComparisonEntry(buildStatsModelData(secondModel, rows, [], secondProvider))
const latest = [first?.updatedAt, second?.updatedAt]
const entries = models.map((model) => toComparisonEntry(buildStatsModelData(model.model, rows, [], model.provider)))
const latest = entries
.map((model) => model?.updatedAt)
.flatMap((value) => (value ? [dateTime(value)] : []))
.toSorted((a, b) => b - a)[0]
return {
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(
modelRows: ModelStatMetric[],
providerRows: ProviderStatMetric[],
@@ -501,6 +514,7 @@ function toComparisonEntry(data: StatsModelData | null): StatsModelComparisonEnt
tokenShare: data.tokenShare,
tokenChange: data.tokenChange,
totals: data.totals,
usage: data.usage,
}
}
+30 -37
View File
@@ -14,7 +14,10 @@ import { normalizeCountry, normalizeTier, type StatBaseAggregate } from "./stat"
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 periodEndValue = sqlString(periodEnd.toISOString())
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]
.map(sqlIdentifier)
.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 = `
COUNT(DISTINCT session) AS sessions,
COUNT(*) AS requests,
@@ -135,34 +121,41 @@ WITH normalized AS (
COALESCE(cost_total_microcents, cost_total * 1000000) AS cost_total_microcents
FROM normalized
WHERE lower(model) NOT IN (${[...EXCLUDED_MODELS].map(sqlString).join(", ")})
), weekly AS (
), periods AS (
SELECT
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
), daily AS (
SELECT substr(to_iso8601(date_trunc('day', event_time)), 1, 10) AS day_key, *
FROM filtered
)
SELECT
'week' AS grain,
week_key AS period_key,
CASE WHEN grouping(week_key) = 0 THEN 'week' ELSE 'day' END AS grain,
COALESCE(week_key, day_key) AS period_key,
${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,
${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}
FROM weekly
GROUP BY week_key, tier, ${dimensionSql.groupBy}
UNION ALL
SELECT
'day' AS grain,
day_key AS period_key,
${sqlString(Resource.StatsSyncConfig.dataset)} AS dataset,
tier,
${dimensionSql.select},
${aggregateColumns}
FROM daily
GROUP BY day_key, tier, ${dimensionSql.groupBy}
FROM periods
GROUP BY GROUPING SETS (
(week_key, tier, provider, model),
(week_key, tier, provider),
(week_key, tier, country),
(week_key, tier, provider, model, country),
(day_key, tier, provider, model),
(day_key, tier, provider),
(day_key, tier, country),
(day_key, tier, provider, model, country)
)
ORDER BY grain, period_key, total_tokens DESC
`
}
+11 -2
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 * as Context from "effect/Context"
import { DatabaseError, DrizzleClient } from "../database"
@@ -43,6 +43,7 @@ export type ModelStatMetric = {
export declare namespace ModelStatRepo {
export interface Service {
readonly listDaily: () => Effect.Effect<ModelStatMetric[], DatabaseError>
readonly lastSyncedAt: () => Effect.Effect<Date | null, DatabaseError>
readonly upsert: (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[]) {
yield* Effect.forEach(
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 })
}),
)
}
+73 -68
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 WEEK_MS = 7 * 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 SyncStatsError = AthenaQueryError | AthenaQueryTimeoutError | DatabaseError
export const syncStats: () => Effect.Effect<
SyncStatsResult,
SyncStatsError,
Athena | ModelStatRepo | ProviderStatRepo | GeoStatRepo
> = Effect.fn("StatSync.sync")(function* () {
const startedAt = yield* DateTime.nowAsDate
const periodEnd = new Date(Math.floor((startedAt.getTime() - DATALAKE_INGESTION_LAG_MS) / 60_000) * 60_000)
// May 27 was partial, so keep Athena stats anchored at the first complete day.
const periodStart = new Date(
export const syncStats: (options?: {
full?: boolean
}) => Effect.Effect<SyncStatsResult, SyncStatsError, Athena | ModelStatRepo | ProviderStatRepo | GeoStatRepo> =
Effect.fn("StatSync.sync")(function* (options?: { full?: boolean }) {
const startedAt = yield* DateTime.nowAsDate
const periodEnd = new Date(Math.floor((startedAt.getTime() - DATALAKE_INGESTION_LAG_MS) / 60_000) * 60_000)
const periodStart = options?.full ? fullPeriodStart(periodEnd) : incrementalPeriodStart(periodEnd)
const athena = yield* Athena
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.min(startOfIsoWeek(periodEnd).getTime() - WEEK_MS, periodEnd.getTime() - DISPLAY_WINDOW_MS),
STATS_DATA_START_MS,
),
)
const athena = yield* Athena
const modelStats = yield* ModelStatRepo
const providerStats = yield* ProviderStatRepo
const geoStats = yield* GeoStatRepo
}
yield* logRuntimeCheck()
const [modelAggregates, providerAggregates, geoAggregates, geoModelAggregates] = yield* Effect.all(
[
athena
.query(buildStatsQuery(periodStart, periodEnd, "model"))
.pipe(Effect.map((rows) => rows.flatMap(toModelAggregate))),
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" },
// 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
// full pass refreshes the whole display window (normalization changes, retired
// dimension cleanup).
function incrementalPeriodStart(periodEnd: Date) {
return new Date(
Math.max(startOfIsoWeek(new Date(periodEnd.getTime() - INCREMENTAL_LOOKBACK_MS)).getTime(), STATS_DATA_START_MS),
)
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() {
return Effect.logInfo(
+38 -10
View File
@@ -1,21 +1,49 @@
import * as NodeRuntime from "@effect/platform-node/NodeRuntime"
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 { 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_MS = 3_600_000
const runtimeLayer = Layer.mergeAll(statsLayer, Athena.layer)
const syncPass = syncStats().pipe(
Effect.catchCause((cause) =>
Effect.logWarning(`stats sync failed ${JSON.stringify({ cause: Cause.pretty(cause) })}`),
),
)
const daemon = Effect.logInfo("stats sync daemon started").pipe(
Effect.andThen(syncPass.pipe(Effect.repeat(Schedule.fixed(SYNC_INTERVAL)))),
Effect.forkScoped,
)
const daemon = Effect.gen(function* () {
yield* Effect.logInfo("stats sync daemon started")
yield* initialDelay()
// One full pass per UTC day (including the first pass after boot) refreshes the
// whole display window; every other pass only recomputes the current ISO week.
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))), {
disableErrorReporting: true,
+19
View File
@@ -679,3 +679,22 @@
[data-component="tabs-drag-preview"] > * {
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));
}
}
}
}
@@ -112,8 +112,8 @@
color: var(--v2-text-text-base);
}
[data-component="icon-button-v2"][data-variant="ghost"]:is(:hover, [data-state="hover"], [data-expanded]):not(
:disabled
[data-component="icon-button-v2"][data-variant="ghost"]:is(:hover, [data-state="hover"]):not(:disabled):not(
[data-expanded]
) {
background-color: var(--v2-overlay-simple-overlay-hover);
}
@@ -122,6 +122,10 @@
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"]) {
opacity: 0.5;
cursor: not-allowed;
@@ -133,8 +137,8 @@
color: var(--v2-icon-icon-muted);
}
[data-component="icon-button-v2"][data-variant="ghost-muted"]:is(:hover, [data-state="hover"], [data-expanded]):not(
:disabled
[data-component="icon-button-v2"][data-variant="ghost-muted"]:is(:hover, [data-state="hover"]):not(:disabled):not(
[data-expanded]
) {
background-color: var(--v2-overlay-simple-overlay-hover);
}
@@ -6,6 +6,7 @@
[data-component="line-comment-v2"] {
box-sizing: border-box;
font-family: var(--v2-font-family-sans);
font-variant-numeric: tabular-nums;
min-width: 0;
width: 100%;
@@ -65,6 +65,7 @@
box-shadow: var(--v2-elevation-button-neutral);
flex: none;
align-self: stretch;
user-select: none;
transition:
background 85ms ease-out,
outline-color 85ms ease-out,
@@ -192,6 +192,7 @@
color: var(--v2-text-text-muted);
font-size: 12px;
font-weight: 500;
user-select: none;
}
[data-component="tabs-v2"][data-variant="settings"][data-orientation="vertical"] [data-slot="tabs-v2-trigger-wrapper"] {
+9
View File
@@ -55,6 +55,9 @@ OpenCode Zen هي بوابة AI تتيح لك الوصول إلى هذه الن
| النموذج | معرّف النموذج | نقطة النهاية | حزمة 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 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` |
@@ -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) | $4.00 | $18.00 | $0.40 | - |
| 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) | $10.00 | $45.00 | $1.00 | - |
| GPT 5.5 Pro | $30.00 | $180.00 | $30.00 | - |
+9
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 |
| ---------------------- | ---------------------- | ---------------------------------------------------- | --------------------------- |
| 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 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` |
@@ -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) | $4.00 | $18.00 | $0.40 | - |
| 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) | $10.00 | $45.00 | $1.00 | - |
| GPT 5.5 Pro | $30.00 | $180.00 | $30.00 | - |
+9
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 |
| ---------------------- | ---------------------- | ---------------------------------------------------- | --------------------------- |
| 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 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` |
@@ -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) | $4.00 | $18.00 | $0.40 | - |
| 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) | $10.00 | $45.00 | $1.00 | - |
| GPT 5.5 Pro | $30.00 | $180.00 | $30.00 | - |
+9
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 |
| ---------------------- | ---------------------- | ---------------------------------------------------- | --------------------------- |
| 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 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` |
@@ -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) | $4.00 | $18.00 | $0.40 | - |
| 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) | $10.00 | $45.00 | $1.00 | - |
| GPT 5.5 Pro | $30.00 | $180.00 | $30.00 | - |
+9
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 |
| ---------------------- | ---------------------- | ---------------------------------------------------- | --------------------------- |
| 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 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` |
@@ -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) | $4.00 | $18.00 | $0.40 | - |
| 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) | $10.00 | $45.00 | $1.00 | - |
| GPT 5.5 Pro | $30.00 | $180.00 | $30.00 | - |
+9
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 |
| ---------------------- | ---------------------- | ---------------------------------------------------- | --------------------------- |
| 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 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` |
@@ -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) | $4.00 | $18.00 | $0.40 | - |
| 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) | $10.00 | $45.00 | $1.00 | - |
| GPT 5.5 Pro | $30.00 | $180.00 | $30.00 | - |
+9
View File
@@ -60,6 +60,9 @@ Puoi anche accedere ai nostri modelli tramite i seguenti endpoint API.
| 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 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` |
@@ -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) | $4.00 | $18.00 | $0.40 | - |
| 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) | $10.00 | $45.00 | $1.00 | - |
| GPT 5.5 Pro | $30.00 | $180.00 | $30.00 | - |
+9
View File
@@ -51,6 +51,9 @@ OpenCode Zen は、OpenCode のほかのプロバイダーと同じように動
| 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 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` |
@@ -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) | $4.00 | $18.00 | $0.40 | - |
| 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) | $10.00 | $45.00 | $1.00 | - |
| GPT 5.5 Pro | $30.00 | $180.00 | $30.00 | - |
+9
View File
@@ -51,6 +51,9 @@ OpenCode Zen은 OpenCode의 다른 provider와 똑같이 작동합니다.
| 모델 | 모델 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 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` |
@@ -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) | $4.00 | $18.00 | $0.40 | - |
| 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) | $10.00 | $45.00 | $1.00 | - |
| GPT 5.5 Pro | $30.00 | $180.00 | $30.00 | - |
+9
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 |
| ---------------------- | ---------------------- | ---------------------------------------------------- | --------------------------- |
| 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 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` |
@@ -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) | $4.00 | $18.00 | $0.40 | - |
| 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) | $10.00 | $45.00 | $1.00 | - |
| GPT 5.5 Pro | $30.00 | $180.00 | $30.00 | - |
+9
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 |
| ---------------------- | ---------------------- | ---------------------------------------------------- | --------------------------- |
| 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 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` |
@@ -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) | $4.00 | $18.00 | $0.40 | - |
| 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) | $10.00 | $45.00 | $1.00 | - |
| GPT 5.5 Pro | $30.00 | $180.00 | $30.00 | - |
@@ -51,6 +51,9 @@ Você também pode acessar nossos modelos pelos seguintes endpoints de API.
| 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 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` |
@@ -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) | $4.00 | $18.00 | $0.40 | - |
| 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) | $10.00 | $45.00 | $1.00 | - |
| GPT 5.5 Pro | $30.00 | $180.00 | $30.00 | - |
+9
View File
@@ -60,6 +60,9 @@ OpenCode Zen работает как любой другой провайдер
| Модель | Идентификатор модели | Конечная точка | Пакет 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 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` |
@@ -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) | $4.00 | $18.00 | $0.40 | - |
| 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) | $10.00 | $45.00 | $1.00 | - |
| GPT 5.5 Pro | $30.00 | $180.00 | $30.00 | - |
+9
View File
@@ -53,6 +53,9 @@ OpenCode Zen ทำงานเหมือน provider อื่น ๆ ใน
| 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 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` |
@@ -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) | $4.00 | $18.00 | $0.40 | - |
| 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) | $10.00 | $45.00 | $1.00 | - |
| GPT 5.5 Pro | $30.00 | $180.00 | $30.00 | - |
+9
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 |
| ---------------------- | ---------------------- | ---------------------------------------------------- | --------------------------- |
| 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 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` |
@@ -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) | $4.00 | $18.00 | $0.40 | - |
| 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) | $10.00 | $45.00 | $1.00 | - |
| GPT 5.5 Pro | $30.00 | $180.00 | $30.00 | - |
+9
View File
@@ -60,6 +60,9 @@ You can also access our models through the following API endpoints.
| 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 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` |
@@ -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) | $4.00 | $18.00 | $0.40 | - |
| 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) | $10.00 | $45.00 | $1.00 | - |
| GPT 5.5 Pro | $30.00 | $180.00 | $30.00 | - |
@@ -51,6 +51,9 @@ OpenCode Zen 的工作方式与 OpenCode 中的任何其他提供商相同。
| 模型 | 模型 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 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` |
@@ -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) | $4.00 | $18.00 | $0.40 | - |
| 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) | $10.00 | $45.00 | $1.00 | - |
| GPT 5.5 Pro | $30.00 | $180.00 | $30.00 | - |
@@ -55,6 +55,9 @@ OpenCode Zen 的運作方式和 OpenCode 中的其他供應商一樣。
| 模型 | 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 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` |
@@ -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) | $4.00 | $18.00 | $0.40 | - |
| 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) | $10.00 | $45.00 | $1.00 | - |
| GPT 5.5 Pro | $30.00 | $180.00 | $30.00 | - |