mirror of
https://github.com/anomalyco/opencode.git
synced 2026-07-21 10:16:03 +00:00
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:
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
+10
@@ -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 {}
|
||||
@@ -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
@@ -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,
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
Vendored
+10
@@ -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 {}
|
||||
@@ -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,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,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"
|
||||
|
||||
@@ -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
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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("&", "&")
|
||||
.replaceAll('"', """)
|
||||
.replaceAll("'", "'")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
`
|
||||
}
|
||||
|
||||
@@ -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 })
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"] {
|
||||
|
||||
@@ -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 | - |
|
||||
|
||||
@@ -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 | - |
|
||||
|
||||
@@ -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 | - |
|
||||
|
||||
@@ -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 | - |
|
||||
|
||||
@@ -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 | - |
|
||||
|
||||
@@ -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 | - |
|
||||
|
||||
@@ -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 | - |
|
||||
|
||||
@@ -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 | - |
|
||||
|
||||
@@ -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 | - |
|
||||
|
||||
@@ -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 | - |
|
||||
|
||||
@@ -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 | - |
|
||||
|
||||
@@ -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 | - |
|
||||
|
||||
@@ -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 | - |
|
||||
|
||||
@@ -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 | - |
|
||||
|
||||
@@ -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 | - |
|
||||
|
||||
Reference in New Issue
Block a user