mirror of
https://github.com/anomalyco/opencode.git
synced 2026-07-26 13:33:34 +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:
parent
9028c2d8f8
commit
43ecf3ff1b
82 changed files with 4520 additions and 1232 deletions
10
artifacts/glm52-rise-video/sst-env.d.ts
vendored
Normal file
10
artifacts/glm52-rise-video/sst-env.d.ts
vendored
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
/* This file is auto-generated by SST. Do not edit. */
|
||||
/* tslint:disable */
|
||||
/* eslint-disable */
|
||||
/* deno-fmt-ignore-file */
|
||||
/* biome-ignore-all lint: auto-generated */
|
||||
|
||||
/// <reference path="../../sst-env.d.ts" />
|
||||
|
||||
import "sst"
|
||||
export {}
|
||||
|
|
@ -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}/`,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
29
packages/app/src/context/prompt-state.test.ts
Normal file
29
packages/app/src/context/prompt-state.test.ts
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
import { describe, expect, test } from "bun:test"
|
||||
import { createRoot } from "solid-js"
|
||||
import { createPromptState, DEFAULT_PROMPT } from "./prompt-state"
|
||||
|
||||
describe("prompt state initialization", () => {
|
||||
test("initializes prompt text, cursor, and model together", () => {
|
||||
createRoot((dispose) => {
|
||||
const model = { providerID: "anthropic", modelID: "claude", variant: "high" }
|
||||
const prompt = createPromptState({ prompt: "hello", model })
|
||||
|
||||
expect(prompt.current()).toEqual([{ type: "text", content: "hello", start: 0, end: 5 }])
|
||||
expect(prompt.cursor()).toBe(5)
|
||||
expect(prompt.model.current()).toEqual(model)
|
||||
expect(prompt.model.current()).not.toBe(model)
|
||||
dispose()
|
||||
})
|
||||
})
|
||||
|
||||
test("uses the default prompt without initial values", () => {
|
||||
createRoot((dispose) => {
|
||||
const prompt = createPromptState()
|
||||
|
||||
expect(prompt.current()).toEqual(DEFAULT_PROMPT)
|
||||
expect(prompt.cursor()).toBeUndefined()
|
||||
expect(prompt.model.current()).toBeUndefined()
|
||||
dispose()
|
||||
})
|
||||
})
|
||||
})
|
||||
265
packages/app/src/context/prompt-state.ts
Normal file
265
packages/app/src/context/prompt-state.ts
Normal file
|
|
@ -0,0 +1,265 @@
|
|||
import { checksum } from "@opencode-ai/core/util/encode"
|
||||
import type { FilePartSource } from "@opencode-ai/sdk/v2/client"
|
||||
import { batch, createMemo, type Accessor } from "solid-js"
|
||||
import { createStore, type SetStoreFunction } from "solid-js/store"
|
||||
import type { FileSelection } from "@/context/file"
|
||||
import { Persist, persisted } from "@/utils/persist"
|
||||
import type { ServerScope } from "@/utils/server-scope"
|
||||
|
||||
interface PartBase {
|
||||
content: string
|
||||
start: number
|
||||
end: number
|
||||
}
|
||||
|
||||
export interface TextPart extends PartBase {
|
||||
type: "text"
|
||||
}
|
||||
|
||||
export interface FileAttachmentPart extends PartBase {
|
||||
type: "file"
|
||||
path: string
|
||||
selection?: FileSelection
|
||||
mime?: string
|
||||
filename?: string
|
||||
url?: string
|
||||
source?: FilePartSource
|
||||
}
|
||||
|
||||
export interface AgentPart extends PartBase {
|
||||
type: "agent"
|
||||
name: string
|
||||
}
|
||||
|
||||
export interface ImageAttachmentPart {
|
||||
type: "image"
|
||||
id: string
|
||||
filename: string
|
||||
sourcePath?: string
|
||||
mime: string
|
||||
dataUrl: string
|
||||
}
|
||||
|
||||
export type ContentPart = TextPart | FileAttachmentPart | AgentPart | ImageAttachmentPart
|
||||
export type Prompt = ContentPart[]
|
||||
|
||||
export type PromptModel = {
|
||||
providerID: string
|
||||
modelID: string
|
||||
variant?: string | null
|
||||
}
|
||||
|
||||
export type FileContextItem = {
|
||||
type: "file"
|
||||
path: string
|
||||
selection?: FileSelection
|
||||
comment?: string
|
||||
commentID?: string
|
||||
commentOrigin?: "review" | "file"
|
||||
preview?: string
|
||||
}
|
||||
|
||||
export type ContextItem = FileContextItem
|
||||
export type PromptScope = { draftID: string } | { dir: string; id?: string }
|
||||
|
||||
export const DEFAULT_PROMPT: Prompt = [{ type: "text", content: "", start: 0, end: 0 }]
|
||||
|
||||
type PromptStore = {
|
||||
prompt: Prompt
|
||||
cursor?: number
|
||||
model?: PromptModel
|
||||
context: {
|
||||
items: (ContextItem & { key: string })[]
|
||||
}
|
||||
}
|
||||
|
||||
type InitialPrompt = {
|
||||
prompt?: string
|
||||
model?: PromptModel
|
||||
}
|
||||
|
||||
function isSelectionEqual(a?: FileSelection, b?: FileSelection) {
|
||||
if (!a && !b) return true
|
||||
if (!a || !b) return false
|
||||
return (
|
||||
a.startLine === b.startLine && a.startChar === b.startChar && a.endLine === b.endLine && a.endChar === b.endChar
|
||||
)
|
||||
}
|
||||
|
||||
function isPartEqual(partA: ContentPart, partB: ContentPart) {
|
||||
switch (partA.type) {
|
||||
case "text":
|
||||
return partB.type === "text" && partA.content === partB.content
|
||||
case "file":
|
||||
return (
|
||||
partB.type === "file" &&
|
||||
partA.path === partB.path &&
|
||||
partA.mime === partB.mime &&
|
||||
partA.filename === partB.filename &&
|
||||
isSelectionEqual(partA.selection, partB.selection)
|
||||
)
|
||||
case "agent":
|
||||
return partB.type === "agent" && partA.name === partB.name
|
||||
case "image":
|
||||
return partB.type === "image" && partA.id === partB.id
|
||||
}
|
||||
}
|
||||
|
||||
export function isPromptEqual(promptA: Prompt, promptB: Prompt): boolean {
|
||||
if (promptA.length !== promptB.length) return false
|
||||
for (let i = 0; i < promptA.length; i++) {
|
||||
if (!isPartEqual(promptA[i], promptB[i])) return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
function cloneSelection(selection?: FileSelection) {
|
||||
if (!selection) return undefined
|
||||
return { ...selection }
|
||||
}
|
||||
|
||||
function clonePart(part: ContentPart): ContentPart {
|
||||
if (part.type === "text") return { ...part }
|
||||
if (part.type === "image") return { ...part }
|
||||
if (part.type === "agent") return { ...part }
|
||||
return {
|
||||
...part,
|
||||
selection: cloneSelection(part.selection),
|
||||
}
|
||||
}
|
||||
|
||||
function clonePrompt(prompt: Prompt): Prompt {
|
||||
return prompt.map(clonePart)
|
||||
}
|
||||
|
||||
function contextItemKey(item: ContextItem) {
|
||||
if (item.type !== "file") return item.type
|
||||
const start = item.selection?.startLine
|
||||
const end = item.selection?.endLine
|
||||
const key = `${item.type}:${item.path}:${start}:${end}`
|
||||
|
||||
if (item.commentID) return `${key}:c=${item.commentID}`
|
||||
const comment = item.comment?.trim()
|
||||
if (!comment) return key
|
||||
const digest = checksum(comment) ?? comment
|
||||
return `${key}:c=${digest.slice(0, 8)}`
|
||||
}
|
||||
|
||||
function isCommentItem(item: ContextItem | (ContextItem & { key: string })) {
|
||||
return item.type === "file" && !!item.comment?.trim()
|
||||
}
|
||||
|
||||
function createPromptActions(setStore: SetStoreFunction<PromptStore>) {
|
||||
return {
|
||||
set(prompt: Prompt, cursorPosition?: number) {
|
||||
const next = clonePrompt(prompt)
|
||||
batch(() => {
|
||||
setStore("prompt", next)
|
||||
if (cursorPosition !== undefined) setStore("cursor", cursorPosition)
|
||||
})
|
||||
},
|
||||
reset() {
|
||||
batch(() => {
|
||||
setStore("prompt", clonePrompt(DEFAULT_PROMPT))
|
||||
setStore("cursor", 0)
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function promptTarget(serverScope: ServerScope, scope: PromptScope) {
|
||||
if ("draftID" in scope) return Persist.draft(scope.draftID, "prompt")
|
||||
const legacy = `${scope.dir}/prompt${scope.id ? "/" + scope.id : ""}.v2`
|
||||
return Persist.serverScoped(serverScope, scope.dir, scope.id, "prompt", [legacy])
|
||||
}
|
||||
|
||||
function promptStore(initial?: InitialPrompt): PromptStore {
|
||||
const text = initial?.prompt
|
||||
return {
|
||||
prompt:
|
||||
text === undefined ? clonePrompt(DEFAULT_PROMPT) : [{ type: "text", content: text, start: 0, end: text.length }],
|
||||
cursor: text === undefined ? undefined : text.length,
|
||||
model: initial?.model ? { ...initial.model } : undefined,
|
||||
context: {
|
||||
items: [],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function createPromptStateValue(store: PromptStore, setStore: SetStoreFunction<PromptStore>) {
|
||||
const actions = createPromptActions(setStore)
|
||||
const value = {
|
||||
current: () => store.prompt,
|
||||
cursor: createMemo(() => store.cursor),
|
||||
dirty: () => !isPromptEqual(store.prompt, DEFAULT_PROMPT),
|
||||
model: {
|
||||
current: () => store.model,
|
||||
set: (model: PromptModel | undefined) => setStore("model", model),
|
||||
},
|
||||
context: {
|
||||
items: createMemo(() => store.context.items),
|
||||
add(item: ContextItem) {
|
||||
const key = contextItemKey(item)
|
||||
if (store.context.items.find((x) => x.key === key)) return
|
||||
setStore("context", "items", (items) => [...items, { key, ...item }])
|
||||
},
|
||||
remove(key: string) {
|
||||
setStore("context", "items", (items) => items.filter((x) => x.key !== key))
|
||||
},
|
||||
removeComment(path: string, commentID: string) {
|
||||
setStore("context", "items", (items) =>
|
||||
items.filter((item) => !(item.type === "file" && item.path === path && item.commentID === commentID)),
|
||||
)
|
||||
},
|
||||
updateComment(path: string, commentID: string, next: Partial<FileContextItem> & { comment?: string }) {
|
||||
setStore("context", "items", (items) =>
|
||||
items.map((item) => {
|
||||
if (item.type !== "file" || item.path !== path || item.commentID !== commentID) return item
|
||||
const value = { ...item, ...next }
|
||||
return { ...value, key: contextItemKey(value) }
|
||||
}),
|
||||
)
|
||||
},
|
||||
replaceComments(items: FileContextItem[]) {
|
||||
setStore("context", "items", (current) => [
|
||||
...current.filter((item) => !isCommentItem(item)),
|
||||
...items.map((item) => ({ ...item, key: contextItemKey(item) })),
|
||||
])
|
||||
},
|
||||
},
|
||||
set: actions.set,
|
||||
reset: actions.reset,
|
||||
capture: () => value,
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
function createPersistedPrompt(target: ReturnType<typeof promptTarget>, initial?: InitialPrompt) {
|
||||
const [store, setStore, _, ready] = persisted(target, createStore<PromptStore>(promptStore(initial)))
|
||||
return { ready, ...createPromptStateValue(store, setStore) }
|
||||
}
|
||||
|
||||
export function createPromptSession(serverScope: ServerScope, scope: PromptScope, initial?: InitialPrompt) {
|
||||
return createPersistedPrompt(promptTarget(serverScope, scope), initial)
|
||||
}
|
||||
|
||||
export function createDraftPromptSession(draftID: string, initial?: InitialPrompt) {
|
||||
return createPersistedPrompt(Persist.draft(draftID, "prompt"), initial)
|
||||
}
|
||||
|
||||
export type PromptSession = ReturnType<typeof createPromptSession>
|
||||
|
||||
export function createPromptReady(session: Accessor<PromptSession>) {
|
||||
return Object.defineProperty(() => session().ready(), "promise", {
|
||||
get: () => session().ready.promise,
|
||||
}) as (() => boolean) & { readonly promise: Promise<unknown> | undefined }
|
||||
}
|
||||
|
||||
export function createPromptState(initial?: InitialPrompt) {
|
||||
const [store, setStore] = createStore<PromptStore>(promptStore(initial))
|
||||
const ready = Object.assign(() => true, { promise: Promise.resolve(true) })
|
||||
return {
|
||||
ready,
|
||||
...createPromptStateValue(store, setStore),
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
10
packages/codemode/sst-env.d.ts
vendored
Normal file
10
packages/codemode/sst-env.d.ts
vendored
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
/* This file is auto-generated by SST. Do not edit. */
|
||||
/* tslint:disable */
|
||||
/* eslint-disable */
|
||||
/* deno-fmt-ignore-file */
|
||||
/* biome-ignore-all lint: auto-generated */
|
||||
|
||||
/// <reference path="../../sst-env.d.ts" />
|
||||
|
||||
import "sst"
|
||||
export {}
|
||||
|
|
@ -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"] {
|
||||
|
|
|
|||
1150
packages/stats/app/src/component/model-compare-detail.tsx
Normal file
1150
packages/stats/app/src/component/model-compare-detail.tsx
Normal file
File diff suppressed because it is too large
Load diff
286
packages/stats/app/src/lib/comparison-pages.ts
Normal file
286
packages/stats/app/src/lib/comparison-pages.ts
Normal file
|
|
@ -0,0 +1,286 @@
|
|||
import { catalogSlug, findModelCatalogEntry, type ModelCatalog, type ModelCatalogEntry } from "../routes/model-catalog"
|
||||
|
||||
type ComparisonFamilyDefinition = {
|
||||
slug: string
|
||||
name: string
|
||||
lab: string
|
||||
prefixes: string[]
|
||||
aliases?: string[]
|
||||
preferredFamilies?: string[]
|
||||
}
|
||||
|
||||
export type ResolvedComparisonFamily = ComparisonFamilyDefinition & {
|
||||
model: ModelCatalogEntry
|
||||
}
|
||||
|
||||
export const comparisonFamilies: ComparisonFamilyDefinition[] = [
|
||||
{
|
||||
slug: "gpt",
|
||||
name: "GPT",
|
||||
lab: "openai",
|
||||
prefixes: ["gpt", "o"],
|
||||
aliases: ["openai"],
|
||||
preferredFamilies: ["gpt", "o"],
|
||||
},
|
||||
{
|
||||
slug: "claude",
|
||||
name: "Claude",
|
||||
lab: "anthropic",
|
||||
prefixes: ["claude"],
|
||||
aliases: ["anthropic"],
|
||||
preferredFamilies: ["claude-sonnet", "claude-opus"],
|
||||
},
|
||||
{
|
||||
slug: "gemini",
|
||||
name: "Gemini",
|
||||
lab: "google",
|
||||
prefixes: ["gemini"],
|
||||
aliases: ["google"],
|
||||
preferredFamilies: ["gemini-pro", "gemini-flash", "gemini"],
|
||||
},
|
||||
{
|
||||
slug: "deepseek",
|
||||
name: "DeepSeek",
|
||||
lab: "deepseek",
|
||||
prefixes: ["deepseek"],
|
||||
preferredFamilies: ["deepseek-thinking", "deepseek"],
|
||||
},
|
||||
{
|
||||
slug: "qwen",
|
||||
name: "Qwen",
|
||||
lab: "alibaba",
|
||||
prefixes: ["qwen"],
|
||||
aliases: ["alibaba"],
|
||||
preferredFamilies: ["qwen"],
|
||||
},
|
||||
{
|
||||
slug: "glm",
|
||||
name: "GLM",
|
||||
lab: "zhipuai",
|
||||
prefixes: ["glm"],
|
||||
aliases: ["zhipu", "zhipuai", "zai"],
|
||||
preferredFamilies: ["glm"],
|
||||
},
|
||||
{
|
||||
slug: "kimi",
|
||||
name: "Kimi",
|
||||
lab: "moonshotai",
|
||||
prefixes: ["kimi"],
|
||||
aliases: ["moonshot", "moonshotai"],
|
||||
preferredFamilies: ["kimi-k2", "kimi-thinking"],
|
||||
},
|
||||
{
|
||||
slug: "minimax",
|
||||
name: "MiniMax",
|
||||
lab: "minimax",
|
||||
prefixes: ["minimax"],
|
||||
},
|
||||
{
|
||||
slug: "grok",
|
||||
name: "Grok",
|
||||
lab: "xai",
|
||||
prefixes: ["grok"],
|
||||
aliases: ["xai"],
|
||||
preferredFamilies: ["grok"],
|
||||
},
|
||||
{
|
||||
slug: "mistral",
|
||||
name: "Mistral",
|
||||
lab: "mistral",
|
||||
prefixes: ["mistral", "magistral", "devstral", "codestral"],
|
||||
preferredFamilies: ["mistral-large", "mistral-medium", "mistral-small"],
|
||||
},
|
||||
{
|
||||
slug: "llama",
|
||||
name: "Llama",
|
||||
lab: "meta",
|
||||
prefixes: ["llama"],
|
||||
aliases: ["meta"],
|
||||
},
|
||||
{
|
||||
slug: "nemotron",
|
||||
name: "Nemotron",
|
||||
lab: "nvidia",
|
||||
prefixes: ["nemotron", "llama-nemotron"],
|
||||
aliases: ["nvidia"],
|
||||
},
|
||||
{
|
||||
slug: "mimo",
|
||||
name: "MiMo",
|
||||
lab: "xiaomi",
|
||||
prefixes: ["mimo"],
|
||||
aliases: ["xiaomi"],
|
||||
},
|
||||
{
|
||||
slug: "command",
|
||||
name: "Command",
|
||||
lab: "cohere",
|
||||
prefixes: ["command"],
|
||||
aliases: ["cohere"],
|
||||
preferredFamilies: ["command-a", "command-r"],
|
||||
},
|
||||
{
|
||||
slug: "sonar",
|
||||
name: "Sonar",
|
||||
lab: "perplexity",
|
||||
prefixes: ["sonar"],
|
||||
aliases: ["perplexity"],
|
||||
preferredFamilies: ["sonar-pro", "sonar-reasoning", "sonar"],
|
||||
},
|
||||
{
|
||||
slug: "longcat",
|
||||
name: "LongCat",
|
||||
lab: "meituan",
|
||||
prefixes: ["longcat"],
|
||||
aliases: ["meituan"],
|
||||
},
|
||||
{
|
||||
slug: "step",
|
||||
name: "Step",
|
||||
lab: "stepfun",
|
||||
prefixes: ["step"],
|
||||
aliases: ["stepfun"],
|
||||
},
|
||||
{
|
||||
slug: "mai",
|
||||
name: "MAI",
|
||||
lab: "microsoft",
|
||||
prefixes: ["mai"],
|
||||
aliases: ["microsoft"],
|
||||
},
|
||||
]
|
||||
|
||||
export function resolveComparisonFamily(catalog: ModelCatalog, value: string) {
|
||||
const family = findComparisonFamily(value)
|
||||
if (!family) return undefined
|
||||
const model = comparisonFamilyCandidates(catalog, family.slug)[0]
|
||||
if (!model) return undefined
|
||||
return { ...family, model } satisfies ResolvedComparisonFamily
|
||||
}
|
||||
|
||||
export function findComparisonFamily(value: string) {
|
||||
const slug = catalogSlug(value)
|
||||
return comparisonFamilies.find((family) => family.slug === slug || family.aliases?.includes(slug))
|
||||
}
|
||||
|
||||
export function comparisonFamilyCandidates(catalog: ModelCatalog, value: string) {
|
||||
const family = findComparisonFamily(value)
|
||||
if (!family) return []
|
||||
const matches = catalog.models
|
||||
.filter((model) => model.lab === family.lab && isFamilyModel(model, family) && isGeneralComparisonModel(model))
|
||||
.toSorted((a, b) => comparisonFamilyModelSort(a, b, family))
|
||||
return matches.filter((model) => !isDuplicateAliasModel(model, matches))
|
||||
}
|
||||
|
||||
export function comparisonSitemapModels(
|
||||
catalog: ModelCatalog,
|
||||
leaderboard: { model: string; provider: string }[] = [],
|
||||
) {
|
||||
return uniqueModels([
|
||||
...comparisonFamilies.flatMap((family) => comparisonFamilyCandidates(catalog, family.slug).slice(0, 2)),
|
||||
...leaderboard.flatMap((entry) => {
|
||||
const model =
|
||||
findModelCatalogEntry(catalog, entry.model, entry.provider) ?? findModelCatalogEntry(catalog, entry.model)
|
||||
return model && isGeneralComparisonModel(model) ? [model] : []
|
||||
}),
|
||||
]).toSorted((a, b) => a.id.localeCompare(b.id))
|
||||
}
|
||||
|
||||
export function canonicalModelComparisonPath(first: ModelCatalogEntry, second: ModelCatalogEntry) {
|
||||
const models = [first, second].toSorted((a, b) => a.id.localeCompare(b.id))
|
||||
return `/data/compare/${models[0].lab}/${models[0].slug}/${models[1].lab}/${models[1].slug}`
|
||||
}
|
||||
|
||||
export function canonicalFamilyComparisonPath(first: ResolvedComparisonFamily, second: ResolvedComparisonFamily) {
|
||||
const families = [first, second].toSorted((a, b) => a.slug.localeCompare(b.slug))
|
||||
return `/data/compare/${families[0].slug}/${families[1].slug}`
|
||||
}
|
||||
|
||||
export function latestFamilyComparisonPath(catalog: ModelCatalog, first: ModelCatalogEntry, second: ModelCatalogEntry) {
|
||||
const firstFamily = comparisonFamilyForModel(catalog, first)
|
||||
const secondFamily = comparisonFamilyForModel(catalog, second)
|
||||
if (!firstFamily || !secondFamily || firstFamily.slug === secondFamily.slug) return undefined
|
||||
if (firstFamily.model.id !== first.id || secondFamily.model.id !== second.id) return undefined
|
||||
return canonicalFamilyComparisonPath(firstFamily, secondFamily)
|
||||
}
|
||||
|
||||
export function comparisonFamilyForModel(catalog: ModelCatalog, model: ModelCatalogEntry) {
|
||||
const family = comparisonFamilies.find(
|
||||
(candidate) => candidate.lab === model.lab && isFamilyModel(model, candidate) && isGeneralComparisonModel(model),
|
||||
)
|
||||
if (!family) return undefined
|
||||
const latest = comparisonFamilyCandidates(catalog, family.slug)[0]
|
||||
if (!latest) return undefined
|
||||
return { ...family, model: latest } satisfies ResolvedComparisonFamily
|
||||
}
|
||||
|
||||
function isFamilyModel(model: ModelCatalogEntry, family: ComparisonFamilyDefinition) {
|
||||
const values = [model.family, model.slug, model.name]
|
||||
.filter((value): value is string => Boolean(value))
|
||||
.map(catalogSlug)
|
||||
return family.prefixes.some((prefix) => values.some((value) => value === prefix || value.startsWith(`${prefix}-`)))
|
||||
}
|
||||
|
||||
function isGeneralComparisonModel(model: ModelCatalogEntry) {
|
||||
const input = model.modalities.input.map(catalogSlug)
|
||||
const output = model.modalities.output.map(catalogSlug)
|
||||
if (!input.includes("text") || !output.includes("text")) return false
|
||||
return !/(?:^|-)(?:audio|embedding|guard|image|moderation|omni|rerank|safety|speech|transcribe|tts|vision)(?:-|$)/.test(
|
||||
model.slug,
|
||||
)
|
||||
}
|
||||
|
||||
function comparisonFamilyModelSort(
|
||||
first: ModelCatalogEntry,
|
||||
second: ModelCatalogEntry,
|
||||
family: ComparisonFamilyDefinition,
|
||||
) {
|
||||
return (
|
||||
displayDateTime(second.releaseDate ?? second.lastUpdated) -
|
||||
displayDateTime(first.releaseDate ?? first.lastUpdated) ||
|
||||
preferredFamilyIndex(first, family) - preferredFamilyIndex(second, family) ||
|
||||
modelVariantPenalty(first) - modelVariantPenalty(second) ||
|
||||
first.slug.length - second.slug.length ||
|
||||
first.name.localeCompare(second.name)
|
||||
)
|
||||
}
|
||||
|
||||
function preferredFamilyIndex(model: ModelCatalogEntry, family: ComparisonFamilyDefinition) {
|
||||
const index = family.preferredFamilies?.indexOf(catalogSlug(model.family ?? "")) ?? -1
|
||||
return index === -1 ? (family.preferredFamilies?.length ?? 0) : index
|
||||
}
|
||||
|
||||
function modelVariantPenalty(model: ModelCatalogEntry) {
|
||||
return /(?:highspeed|latest|preview|turbo|ultraspeed)/.test(model.slug) ? 1 : 0
|
||||
}
|
||||
|
||||
function isDuplicateAliasModel(model: ModelCatalogEntry, models: ModelCatalogEntry[]) {
|
||||
if (!/(?:-latest|-highspeed|-ultraspeed)$/.test(model.slug)) return false
|
||||
return models.some(
|
||||
(candidate) =>
|
||||
candidate.id !== model.id &&
|
||||
candidate.releaseDate === model.releaseDate &&
|
||||
candidate.family === model.family &&
|
||||
!/(?:-latest|-highspeed|-ultraspeed)$/.test(candidate.slug),
|
||||
)
|
||||
}
|
||||
|
||||
function uniqueModels(models: ModelCatalogEntry[]) {
|
||||
return models.reduce<{ ids: Set<string>; models: ModelCatalogEntry[] }>(
|
||||
(result, model) => {
|
||||
if (result.ids.has(model.id)) return result
|
||||
result.ids.add(model.id)
|
||||
result.models.push(model)
|
||||
return result
|
||||
},
|
||||
{ ids: new Set(), models: [] },
|
||||
).models
|
||||
}
|
||||
|
||||
function displayDateTime(value: string | undefined) {
|
||||
if (!value) return 0
|
||||
const date = new Date(value)
|
||||
if (!Number.isNaN(date.getTime())) return date.getTime()
|
||||
const year = Number(value.match(/\d{4}/)?.[0] ?? 0)
|
||||
return Number.isFinite(year) ? year : 0
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
391
packages/stats/app/src/routes/compare-radar.tsx
Normal file
391
packages/stats/app/src/routes/compare-radar.tsx
Normal file
|
|
@ -0,0 +1,391 @@
|
|||
import { createMemo, createSignal, For, Show, type JSX } from "solid-js"
|
||||
import type { ModelCatalogBenchmark, ModelCatalogEntry } from "./model-catalog"
|
||||
|
||||
const radarRingCount = 5
|
||||
const radarColors = ["#294bdb", "#159447", "#d24a3b", "#8a4fd2", "#b47400", "#008c95"] as const
|
||||
const codingBenchmarkPattern = /(swe|aider|code|coding|nl2repo)/
|
||||
const reasoningBenchmarkPattern = /(gpqa|humanity|last exam|reasoning|aime|hmmt|math|mmlu|mrcr|charxiv|cti realm)/
|
||||
const toolUseBenchmarkPattern = /(terminal bench|claw eval|tau ?(?:bench|2|3))/
|
||||
|
||||
export type ComparisonRadarModel = {
|
||||
name: string
|
||||
labName: string
|
||||
catalog: ModelCatalogEntry | null
|
||||
}
|
||||
|
||||
type ComparisonRadarProps = {
|
||||
models: readonly ComparisonRadarModel[]
|
||||
catalogModels: readonly ModelCatalogEntry[]
|
||||
}
|
||||
|
||||
type RadarAxis = {
|
||||
label: string
|
||||
description: string
|
||||
score: (model: ModelCatalogEntry) => number | undefined
|
||||
}
|
||||
|
||||
type RadarPoint = {
|
||||
x: number
|
||||
y: number
|
||||
}
|
||||
|
||||
export function ComparisonRadar(props: ComparisonRadarProps) {
|
||||
const [activeAxis, setActiveAxis] = createSignal<number>()
|
||||
const axes = createMemo(() => buildRadarAxes(props.catalogModels))
|
||||
const series = createMemo(() =>
|
||||
props.models.map((model, index) => ({
|
||||
name: model.name,
|
||||
labName: model.labName,
|
||||
color: radarColors[index % radarColors.length],
|
||||
scores: axes().map((axis) => (model.catalog ? axis.score(model.catalog) : undefined)),
|
||||
})),
|
||||
)
|
||||
const accessibleDescription = createMemo(() =>
|
||||
series()
|
||||
.map(
|
||||
(model) =>
|
||||
`${model.name}: ${axes()
|
||||
.map((axis, index) => `${axis.label} ${formatRadarScore(model.scores[index])}`)
|
||||
.join(", ")}`,
|
||||
)
|
||||
.join(". "),
|
||||
)
|
||||
const clearActiveAxis = (index: number) => setActiveAxis((active) => (active === index ? undefined : active))
|
||||
|
||||
return (
|
||||
<section data-section="compare-radar" aria-label="Model capabilities">
|
||||
<ol data-slot="compare-radar-legend">
|
||||
<For each={series()}>
|
||||
{(model) => (
|
||||
<li>
|
||||
<i style={{ background: model.color }} aria-hidden="true" />
|
||||
<span>
|
||||
<strong>{model.name}</strong>
|
||||
<small>{model.labName}</small>
|
||||
</span>
|
||||
</li>
|
||||
)}
|
||||
</For>
|
||||
</ol>
|
||||
<div data-slot="compare-radar-chart" role="img" aria-label={accessibleDescription()}>
|
||||
<div data-slot="compare-radar-plot" aria-hidden="true">
|
||||
<svg viewBox="0 0 100 100" preserveAspectRatio="xMidYMid meet">
|
||||
<g data-slot="compare-radar-grid">
|
||||
<For each={Array.from({ length: radarRingCount })}>
|
||||
{(_, index) => (
|
||||
<polygon points={radarPolygonPoints(axes().length, ((index() + 1) / radarRingCount) * 100)} />
|
||||
)}
|
||||
</For>
|
||||
<For each={axes()}>
|
||||
{(_, index) => {
|
||||
const point = () => radarPoint(index(), axes().length, 100)
|
||||
return <line x1="50" y1="50" x2={point().x} y2={point().y} />
|
||||
}}
|
||||
</For>
|
||||
</g>
|
||||
<For each={series()}>
|
||||
{(model) => (
|
||||
<g data-slot="compare-radar-series" style={{ color: model.color }}>
|
||||
<Show when={radarSeriesPolygon(model.scores)}>
|
||||
{(points) => <polygon data-slot="compare-radar-area" points={points()} />}
|
||||
</Show>
|
||||
<Show when={!radarSeriesPolygon(model.scores)}>
|
||||
<For each={radarSeriesConnections(model.scores)}>
|
||||
{(connection) => (
|
||||
<line
|
||||
data-slot="compare-radar-line"
|
||||
x1={connection.start.x}
|
||||
y1={connection.start.y}
|
||||
x2={connection.end.x}
|
||||
y2={connection.end.y}
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
</Show>
|
||||
<For each={model.scores}>
|
||||
{(score, index) => {
|
||||
if (score === undefined) return null
|
||||
const point = () => radarPoint(index(), axes().length, score)
|
||||
return (
|
||||
<>
|
||||
<circle data-slot="compare-radar-point" cx={point().x} cy={point().y} r="0.95" />
|
||||
<circle
|
||||
data-slot="compare-radar-point-hit"
|
||||
cx={point().x}
|
||||
cy={point().y}
|
||||
r="3"
|
||||
onMouseEnter={() => setActiveAxis(index())}
|
||||
onMouseLeave={() => clearActiveAxis(index())}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</g>
|
||||
)}
|
||||
</For>
|
||||
</svg>
|
||||
</div>
|
||||
<For each={axes()}>
|
||||
{(axis, index) => (
|
||||
<span
|
||||
data-slot="compare-radar-axis"
|
||||
data-active={activeAxis() === index() ? "true" : undefined}
|
||||
style={radarAxisStyle(index(), axes().length)}
|
||||
tabIndex="0"
|
||||
aria-label={`${axis.label}. ${axis.description}`}
|
||||
onMouseEnter={() => setActiveAxis(index())}
|
||||
onMouseLeave={() => clearActiveAxis(index())}
|
||||
onFocus={() => setActiveAxis(index())}
|
||||
onBlur={() => clearActiveAxis(index())}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Escape") event.currentTarget.blur()
|
||||
}}
|
||||
>
|
||||
<span data-slot="compare-radar-axis-label">{axis.label}</span>
|
||||
</span>
|
||||
)}
|
||||
</For>
|
||||
<Show when={activeAxis() !== undefined}>
|
||||
<div
|
||||
data-slot="compare-radar-tooltip"
|
||||
role="tooltip"
|
||||
style={radarTooltipStyle(activeAxis() ?? 0, axes().length)}
|
||||
>
|
||||
<strong>{axes()[activeAxis() ?? 0]?.label}</strong>
|
||||
<p>{axes()[activeAxis() ?? 0]?.description}</p>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
<div data-slot="compare-radar-data">
|
||||
<table>
|
||||
<caption>Normalized model capability scores</caption>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Model</th>
|
||||
<For each={axes()}>{(axis) => <th>{axis.label}</th>}</For>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<For each={series()}>
|
||||
{(model) => (
|
||||
<tr>
|
||||
<th>{model.name}</th>
|
||||
<For each={model.scores}>{(score) => <td>{formatRadarScore(score)}</td>}</For>
|
||||
</tr>
|
||||
)}
|
||||
</For>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function buildRadarAxes(catalogModels: readonly ModelCatalogEntry[]): RadarAxis[] {
|
||||
const benchmarks = benchmarkScoreGroups(catalogModels)
|
||||
const toolUseBenchmarks = benchmarkScoreGroups(catalogModels, true)
|
||||
const costs = catalogModels.flatMap((model) => {
|
||||
const cost = modelCost(model)
|
||||
return cost === undefined ? [] : [cost]
|
||||
})
|
||||
const contexts = catalogModels.flatMap((model) => (model.limit?.context === undefined ? [] : [model.limit.context]))
|
||||
const multimodalMaximum = Math.max(...catalogModels.map(multimodalFeatureCount), 0)
|
||||
|
||||
// Speed and safety stay out until the catalog exposes comparable values for them.
|
||||
return [
|
||||
{
|
||||
label: "Reasoning",
|
||||
description: "Ability to solve complex, multi-step problems. Based on reasoning benchmarks when available.",
|
||||
score: (model) =>
|
||||
benchmarkPercentile(model, benchmarks, reasoningBenchmarkPattern) ?? (model.reasoning ? 100 : 0),
|
||||
},
|
||||
{
|
||||
label: "Coding",
|
||||
description: "Performance on software engineering and coding benchmarks.",
|
||||
score: (model) => benchmarkPercentile(model, benchmarks, codingBenchmarkPattern),
|
||||
},
|
||||
{
|
||||
label: "Cost efficiency",
|
||||
description: "Relative input and output pricing. Lower-cost models score higher.",
|
||||
score: (model) => {
|
||||
const cost = modelCost(model)
|
||||
if (cost === undefined) return
|
||||
if (cost === 0) return 100
|
||||
return percentileScore(cost, costs, "lower")
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "Context window",
|
||||
description: "How much input the model can process at once. Larger context windows score higher.",
|
||||
score: (model) => {
|
||||
const context = model.limit?.context
|
||||
if (context === undefined) return
|
||||
return percentileScore(context, contexts, "higher")
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "Multimodal",
|
||||
description: "Support for non-text input and output, including images, audio, and video.",
|
||||
score: (model) => {
|
||||
if (multimodalMaximum === 0) return
|
||||
return (multimodalFeatureCount(model) / multimodalMaximum) * 100
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "Tool use",
|
||||
description: "Performance on agent benchmarks including Terminal-Bench, Tau3, and Claw-Eval.",
|
||||
score: (model) =>
|
||||
benchmarkPercentile(model, toolUseBenchmarks, toolUseBenchmarkPattern, {
|
||||
aggregate: "average",
|
||||
includeHarness: true,
|
||||
}),
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
function benchmarkScoreGroups(catalogModels: readonly ModelCatalogEntry[], includeHarness = false) {
|
||||
return catalogModels.reduce<Map<string, number[]>>((groups, model) => {
|
||||
model.benchmarks
|
||||
.reduce<Map<string, number>>((scores, benchmark) => {
|
||||
const key = benchmarkKey(benchmark, includeHarness)
|
||||
scores.set(key, Math.max(scores.get(key) ?? -Infinity, benchmark.score))
|
||||
return scores
|
||||
}, new Map())
|
||||
.forEach((score, key) => {
|
||||
groups.set(key, [...(groups.get(key) ?? []), score])
|
||||
})
|
||||
return groups
|
||||
}, new Map())
|
||||
}
|
||||
|
||||
function benchmarkKey(benchmark: ModelCatalogBenchmark, includeHarness: boolean) {
|
||||
const name = normalizeBenchmarkName(benchmark.name)
|
||||
const version = normalizeBenchmarkName(benchmark.version ?? "")
|
||||
const versioned = version && !name.endsWith(version) ? `${name} ${version}` : name
|
||||
if (!includeHarness) return versioned
|
||||
const harness = normalizeBenchmarkName(benchmark.harness ?? benchmark.variant ?? "")
|
||||
return harness ? `${versioned} | ${harness}` : versioned
|
||||
}
|
||||
|
||||
function benchmarkPercentile(
|
||||
model: ModelCatalogEntry,
|
||||
benchmarks: Map<string, number[]>,
|
||||
pattern: RegExp,
|
||||
options?: { aggregate?: "average" | "best"; includeHarness?: boolean },
|
||||
) {
|
||||
const scores = Object.entries(
|
||||
model.benchmarks.reduce<Record<string, number>>((result, benchmark) => {
|
||||
const key = benchmarkKey(benchmark, options?.includeHarness ?? false)
|
||||
if (!pattern.test(key)) return result
|
||||
result[key] = Math.max(result[key] ?? -Infinity, benchmark.score)
|
||||
return result
|
||||
}, {}),
|
||||
).flatMap(([key, score]) => {
|
||||
const values = benchmarks.get(key)
|
||||
const percentile = values ? percentileScore(score, values, "higher") : undefined
|
||||
return percentile === undefined ? [] : [percentile]
|
||||
})
|
||||
if (scores.length === 0) return
|
||||
if (options?.aggregate === "average") return scores.reduce((sum, score) => sum + score, 0) / scores.length
|
||||
// Benchmark coverage varies by model, so additional published results should not lower a model's score.
|
||||
return Math.max(...scores)
|
||||
}
|
||||
|
||||
function normalizeBenchmarkName(value: string) {
|
||||
return value
|
||||
.toLowerCase()
|
||||
.replace(/\u03c4/g, "tau")
|
||||
.replace(/\u00b2/g, "2")
|
||||
.replace(/\u00b3/g, "3")
|
||||
.replace(/[^a-z0-9]+/g, " ")
|
||||
.trim()
|
||||
}
|
||||
|
||||
function modelCost(model: ModelCatalogEntry) {
|
||||
if (!model.cost) return
|
||||
return model.cost.input + model.cost.output
|
||||
}
|
||||
|
||||
function multimodalFeatureCount(model: ModelCatalogEntry) {
|
||||
return new Set([
|
||||
...model.modalities.input.filter((modality) => modality !== "text").map((modality) => `input:${modality}`),
|
||||
...model.modalities.output.filter((modality) => modality !== "text").map((modality) => `output:${modality}`),
|
||||
...(model.attachment ? ["attachment"] : []),
|
||||
]).size
|
||||
}
|
||||
|
||||
function percentileScore(value: number, values: number[], direction: "higher" | "lower") {
|
||||
const finite = values.filter(Number.isFinite)
|
||||
if (!Number.isFinite(value) || finite.length < 2) return
|
||||
const below = finite.filter((candidate) => candidate < value).length
|
||||
const equal = finite.filter((candidate) => candidate === value).length
|
||||
const percentile = ((below + (equal - 1) / 2) / (finite.length - 1)) * 100
|
||||
return direction === "higher" ? percentile : 100 - percentile
|
||||
}
|
||||
|
||||
function radarPoint(index: number, count: number, score: number): RadarPoint {
|
||||
const angle = -Math.PI / 2 + (index * Math.PI * 2) / count
|
||||
const radius = Math.max(0, Math.min(100, score)) / 2
|
||||
return {
|
||||
x: roundRadarCoordinate(50 + Math.cos(angle) * radius),
|
||||
y: roundRadarCoordinate(50 + Math.sin(angle) * radius),
|
||||
}
|
||||
}
|
||||
|
||||
function radarPolygonPoints(count: number, score: number) {
|
||||
return Array.from({ length: count })
|
||||
.map((_, index) => radarPoint(index, count, score))
|
||||
.map((point) => `${point.x},${point.y}`)
|
||||
.join(" ")
|
||||
}
|
||||
|
||||
function radarSeriesPolygon(scores: (number | undefined)[]) {
|
||||
if (scores.some((score) => score === undefined)) return
|
||||
return scores
|
||||
.map((score, index) => radarPoint(index, scores.length, score ?? 0))
|
||||
.map((point) => `${point.x},${point.y}`)
|
||||
.join(" ")
|
||||
}
|
||||
|
||||
function radarSeriesConnections(scores: (number | undefined)[]) {
|
||||
return scores.flatMap((score, index) => {
|
||||
const nextIndex = (index + 1) % scores.length
|
||||
const next = scores[nextIndex]
|
||||
if (score === undefined || next === undefined) return []
|
||||
return [
|
||||
{
|
||||
start: radarPoint(index, scores.length, score),
|
||||
end: radarPoint(nextIndex, scores.length, next),
|
||||
},
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
function radarAxisStyle(index: number, count: number) {
|
||||
const angle = -Math.PI / 2 + (index * Math.PI * 2) / count
|
||||
const horizontal = Math.cos(angle)
|
||||
return {
|
||||
"--compare-radar-axis-x": `${roundRadarCoordinate(50 + horizontal * 42)}%`,
|
||||
"--compare-radar-axis-mobile-x": `${roundRadarCoordinate(50 + horizontal * 36)}%`,
|
||||
"--compare-radar-axis-y": `${roundRadarCoordinate(50 + Math.sin(angle) * 42)}%`,
|
||||
"--compare-radar-axis-translate-x": horizontal > 0.25 ? "0%" : horizontal < -0.25 ? "-100%" : "-50%",
|
||||
} as JSX.CSSProperties
|
||||
}
|
||||
|
||||
function radarTooltipStyle(index: number, count: number) {
|
||||
const angle = -Math.PI / 2 + (index * Math.PI * 2) / count
|
||||
return {
|
||||
"--compare-radar-tooltip-x": `${roundRadarCoordinate(50 + Math.cos(angle) * 42)}%`,
|
||||
"--compare-radar-tooltip-y": `${roundRadarCoordinate(50 + Math.sin(angle) * 42)}%`,
|
||||
"--compare-radar-tooltip-translate-y": Math.sin(angle) < -0.9 ? "20px" : "calc(-100% - 12px)",
|
||||
} as JSX.CSSProperties
|
||||
}
|
||||
|
||||
function roundRadarCoordinate(value: number) {
|
||||
return Math.round(value * 1000) / 1000
|
||||
}
|
||||
|
||||
function formatRadarScore(score: number | undefined) {
|
||||
return score === undefined ? "No data" : `${Math.round(score)}/100`
|
||||
}
|
||||
|
|
@ -0,0 +1,47 @@
|
|||
import { Meta, Title } from "@solidjs/meta"
|
||||
import { createAsync, useParams } from "@solidjs/router"
|
||||
import { createMemo, Show } from "solid-js"
|
||||
import ModelCompareDetailPage from "../../../component/model-compare-detail"
|
||||
import { resolveComparisonFamily } from "../../../lib/comparison-pages"
|
||||
import { getModelCatalog } from "../../model-catalog"
|
||||
|
||||
export default function ModelCompareFamily() {
|
||||
const params = useParams()
|
||||
const catalog = createAsync(() => getModelCatalog())
|
||||
const comparison = createMemo(() => {
|
||||
const source = catalog()
|
||||
if (!source) return undefined
|
||||
const first = resolveComparisonFamily(source, params.firstFamily ?? "")
|
||||
const second = resolveComparisonFamily(source, params.secondFamily ?? "")
|
||||
if (!first || !second || first.slug === second.slug) return null
|
||||
return { first, second }
|
||||
})
|
||||
|
||||
return (
|
||||
<Show
|
||||
when={comparison()}
|
||||
fallback={
|
||||
<Show when={comparison() === null}>
|
||||
<Title>Model comparison not found</Title>
|
||||
<Meta name="robots" content="noindex,follow" />
|
||||
<main data-page="stats">
|
||||
<div data-component="empty-state">
|
||||
<strong>Comparison not found</strong>
|
||||
<p>Choose two model families to compare.</p>
|
||||
<a href={`${import.meta.env.BASE_URL}compare`}>Compare models</a>
|
||||
</div>
|
||||
</main>
|
||||
</Show>
|
||||
}
|
||||
>
|
||||
{(resolved) => (
|
||||
<ModelCompareDetailPage
|
||||
first={{ lab: resolved().first.model.lab, slug: resolved().first.model.slug }}
|
||||
second={{ lab: resolved().second.model.lab, slug: resolved().second.model.slug }}
|
||||
family={resolved()}
|
||||
catalog={catalog()}
|
||||
/>
|
||||
)}
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,611 +1 @@
|
|||
import "../../../../index.css"
|
||||
import { Link, Meta, Title } from "@solidjs/meta"
|
||||
import { getStatsModelComparisonData, type StatsModelComparisonEntry } from "@opencode-ai/stats-core/domain/home"
|
||||
import { runtime } from "@opencode-ai/stats-core/runtime"
|
||||
import { createAsync, query, useParams } from "@solidjs/router"
|
||||
import { createMemo, createSignal, For, onMount, Show } from "solid-js"
|
||||
import { getRequestEvent } from "solid-js/web"
|
||||
import {
|
||||
ComparisonCardsSection,
|
||||
modelRefFromCatalog,
|
||||
uniqueComparisonPairs,
|
||||
type ComparisonModelRef,
|
||||
type ComparisonPair,
|
||||
} from "../../../../compare-cards"
|
||||
import { ComparisonSelector } from "../../../../compare-selector"
|
||||
import {
|
||||
catalogSlug,
|
||||
findModelCatalogEntry,
|
||||
formatCatalogLabName,
|
||||
getModelCatalog,
|
||||
type ModelCatalog,
|
||||
type ModelCatalogEntry,
|
||||
} from "../../../../model-catalog"
|
||||
import {
|
||||
applyThemePreference,
|
||||
Footer,
|
||||
getGitHubStars,
|
||||
Header,
|
||||
isThemePreference,
|
||||
themeStorageKey,
|
||||
type HeaderLink,
|
||||
type ThemePreference,
|
||||
} from "../../../../stats-shell"
|
||||
|
||||
const compareFallbackUrl = "https://stats.opencode.ai"
|
||||
const compareHeaderLinks: readonly HeaderLink[] = [
|
||||
{ href: "#overview", label: "Overview" },
|
||||
{ href: "#comparison", label: "Comparison" },
|
||||
{ href: "#compare-tool", label: "Compare" },
|
||||
{ href: "#model-comparison", label: "Related" },
|
||||
]
|
||||
const compareFooterLinks: readonly HeaderLink[] = [
|
||||
{ href: import.meta.env.BASE_URL, label: "Data Home" },
|
||||
{ href: `${import.meta.env.BASE_URL}compare`, label: "Model Compare" },
|
||||
{ href: `${import.meta.env.BASE_URL}#top-models`, label: "Top Models" },
|
||||
{ href: `${import.meta.env.BASE_URL}#token-cost`, label: "Token Cost" },
|
||||
]
|
||||
|
||||
type ComparisonModel = {
|
||||
name: string
|
||||
lab: string
|
||||
labName: string
|
||||
slug: string
|
||||
catalog: ModelCatalogEntry | null
|
||||
stats: StatsModelComparisonEntry | null
|
||||
}
|
||||
type ComparisonDirection = "higher" | "lower"
|
||||
type ComparisonCell = { value: string; detail?: string; score?: number }
|
||||
type ComparisonRow = {
|
||||
label: string
|
||||
description: string
|
||||
direction: ComparisonDirection
|
||||
cells: [ComparisonCell, ComparisonCell]
|
||||
}
|
||||
|
||||
const getComparisonData = query(
|
||||
async (firstLab: string, firstModel: string, secondLab: string, secondModel: string) => {
|
||||
"use server"
|
||||
return runtime.runPromise(getStatsModelComparisonData(firstLab, firstModel, secondLab, secondModel))
|
||||
},
|
||||
"getStatsModelComparisonData",
|
||||
)
|
||||
|
||||
export default function ModelComparePair() {
|
||||
const event = getRequestEvent()
|
||||
event?.response.headers.set("Cache-Control", "public, max-age=60, s-maxage=300, stale-while-revalidate=86400")
|
||||
const params = useParams()
|
||||
const firstLabParam = createMemo(() => params.firstLab ?? "")
|
||||
const firstModelParam = createMemo(() => params.firstModel ?? "")
|
||||
const secondLabParam = createMemo(() => params.secondLab ?? "")
|
||||
const secondModelParam = createMemo(() => params.secondModel ?? "")
|
||||
const catalog = createAsync(() => getModelCatalog())
|
||||
const firstCatalog = createMemo(() => resolvedCatalogEntry(catalog(), firstLabParam(), firstModelParam()))
|
||||
const secondCatalog = createMemo(() => resolvedCatalogEntry(catalog(), secondLabParam(), secondModelParam()))
|
||||
const stats = createAsync(() => {
|
||||
if (catalog() === undefined || firstCatalog() === undefined || secondCatalog() === undefined)
|
||||
return Promise.resolve(undefined)
|
||||
return getComparisonData(
|
||||
firstCatalog()?.lab ?? firstLabParam(),
|
||||
firstCatalog()?.slug ?? firstModelParam(),
|
||||
secondCatalog()?.lab ?? secondLabParam(),
|
||||
secondCatalog()?.slug ?? secondModelParam(),
|
||||
)
|
||||
})
|
||||
const githubStars = createAsync(() => getGitHubStars())
|
||||
const [themePreference, setThemePreference] = createSignal<ThemePreference>("system")
|
||||
const models = createMemo(
|
||||
() =>
|
||||
[
|
||||
buildComparisonModel(firstLabParam(), firstModelParam(), firstCatalog() ?? null, stats()?.models[0] ?? null),
|
||||
buildComparisonModel(secondLabParam(), secondModelParam(), secondCatalog() ?? null, stats()?.models[1] ?? null),
|
||||
] as const,
|
||||
)
|
||||
const title = createMemo(() => `${models()[0].name} vs ${models()[1].name} - Model Comparison`)
|
||||
const description = createMemo(
|
||||
() =>
|
||||
`Compare ${models()[0].name} and ${models()[1].name} by usage, rank, context window, output limit, cache ratio, and cost across OpenCode data.`,
|
||||
)
|
||||
const canonicalPath = createMemo(
|
||||
() =>
|
||||
`${import.meta.env.BASE_URL}compare/${catalogSlug(models()[0].lab)}/${catalogSlug(models()[0].slug)}/${catalogSlug(
|
||||
models()[1].lab,
|
||||
)}/${catalogSlug(models()[1].slug)}`,
|
||||
)
|
||||
const canonicalUrl = createMemo(() =>
|
||||
new URL(
|
||||
canonicalPath(),
|
||||
event?.request.url ?? (typeof window === "undefined" ? compareFallbackUrl : window.location.href),
|
||||
).toString(),
|
||||
)
|
||||
const rows = createMemo(() => buildComparisonRows(models()[0], models()[1]))
|
||||
const relatedPairs = createMemo(() => buildRelatedPairs(catalog(), models()[0], models()[1]))
|
||||
const selectorModels = createMemo(() =>
|
||||
uniqueCatalogModels([
|
||||
comparisonCatalogEntry(models()[0]),
|
||||
comparisonCatalogEntry(models()[1]),
|
||||
...(catalog()?.models ?? []),
|
||||
]),
|
||||
)
|
||||
const structuredData = createMemo(() =>
|
||||
JSON.stringify({
|
||||
"@context": "https://schema.org",
|
||||
"@type": "WebPage",
|
||||
name: title(),
|
||||
description: description(),
|
||||
url: canonicalUrl(),
|
||||
about: models().map((model) => ({
|
||||
"@type": "SoftwareApplication",
|
||||
name: model.name,
|
||||
applicationCategory: "AI model",
|
||||
provider: model.labName,
|
||||
})),
|
||||
}),
|
||||
)
|
||||
const updateThemePreference = (preference: ThemePreference) => {
|
||||
applyThemePreference(preference)
|
||||
setThemePreference(preference)
|
||||
if (typeof window === "undefined") return
|
||||
window.localStorage.setItem(themeStorageKey, preference)
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
if (typeof window === "undefined") return
|
||||
const preference = window.localStorage.getItem(themeStorageKey)
|
||||
const nextPreference = isThemePreference(preference) ? preference : "system"
|
||||
applyThemePreference(nextPreference)
|
||||
setThemePreference(nextPreference)
|
||||
})
|
||||
|
||||
return (
|
||||
<main data-page="stats" data-theme={themePreference()}>
|
||||
<Title>{title()}</Title>
|
||||
<Meta name="description" content={description()} />
|
||||
<Link rel="canonical" href={canonicalUrl()} />
|
||||
<Meta property="og:type" content="website" />
|
||||
<Meta property="og:site_name" content="OpenCode" />
|
||||
<Meta property="og:title" content={title()} />
|
||||
<Meta property="og:description" content={description()} />
|
||||
<Meta property="og:url" content={canonicalUrl()} />
|
||||
<Meta name="twitter:card" content="summary" />
|
||||
<Meta name="twitter:title" content={title()} />
|
||||
<Meta name="twitter:description" content={description()} />
|
||||
<script type="application/ld+json">{structuredData()}</script>
|
||||
<Header githubStars={githubStars() ?? "150K"} links={compareHeaderLinks} brandHref={import.meta.env.BASE_URL} />
|
||||
<div data-component="container">
|
||||
<div data-component="content">
|
||||
<ComparisonHero models={models()} />
|
||||
<section id="comparison" data-section="model-panel">
|
||||
<p data-slot="section-title">
|
||||
<strong>Comparison Table.</strong> <span>Compare usage, cost, limits, and features.</span>
|
||||
</p>
|
||||
<Show
|
||||
when={stats() !== undefined}
|
||||
fallback={
|
||||
<div data-component="empty-state" data-compact="true">
|
||||
<strong>Loading comparison</strong>
|
||||
<p>Loading stats for both models.</p>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<ComparisonTable models={models()} rows={rows()} />
|
||||
</Show>
|
||||
</section>
|
||||
<section id="compare-tool" data-section="model-panel" data-variant="compact">
|
||||
<p data-slot="section-title">
|
||||
<strong>Compare Another Pair.</strong> <span>Choose two models to compare.</span>
|
||||
</p>
|
||||
<Show
|
||||
when={selectorModels().length > 1}
|
||||
fallback={
|
||||
<div data-component="empty-state" data-compact="true">
|
||||
<strong>No models found</strong>
|
||||
<p>The model list could not be loaded.</p>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<ComparisonSelector
|
||||
models={selectorModels()}
|
||||
firstId={comparisonCatalogEntry(models()[0]).id}
|
||||
secondId={comparisonCatalogEntry(models()[1]).id}
|
||||
/>
|
||||
</Show>
|
||||
</section>
|
||||
<ComparisonCardsSection
|
||||
pairs={relatedPairs()}
|
||||
title="Related Model Comparisons"
|
||||
description="Other model pairs to check."
|
||||
/>
|
||||
</div>
|
||||
<Footer
|
||||
themePreference={themePreference()}
|
||||
onThemePreferenceChange={updateThemePreference}
|
||||
links={compareFooterLinks}
|
||||
bridge={{ href: "#comparison", label: "COMPARE TABLE" }}
|
||||
/>
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
function ComparisonHero(props: { models: readonly [ComparisonModel, ComparisonModel] }) {
|
||||
return (
|
||||
<section id="overview" data-section="model-hero">
|
||||
<a data-slot="model-back-link" href={`${import.meta.env.BASE_URL}compare`}>
|
||||
Compare
|
||||
</a>
|
||||
<div data-slot="model-hero-copy">
|
||||
<h1>
|
||||
{props.models[0].name} vs {props.models[1].name}
|
||||
</h1>
|
||||
<p>Compare usage, cost, limits, and features for these two models.</p>
|
||||
</div>
|
||||
<div data-slot="model-hero-pattern" aria-hidden="true" />
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function ComparisonTable(props: { models: readonly [ComparisonModel, ComparisonModel]; rows: ComparisonRow[] }) {
|
||||
return (
|
||||
<div data-component="comparison-table-wrap">
|
||||
<table data-component="comparison-table">
|
||||
<caption>
|
||||
{props.models[0].name} compared with {props.models[1].name}
|
||||
</caption>
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Metric</th>
|
||||
<For each={props.models}>{(model) => <th scope="col">{model.name}</th>}</For>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<For each={props.rows}>
|
||||
{(row) => {
|
||||
const best = () => bestCellIndex(row)
|
||||
return (
|
||||
<tr>
|
||||
<th scope="row">
|
||||
<strong>{row.label}</strong>
|
||||
<span>{row.description}</span>
|
||||
</th>
|
||||
<For each={row.cells}>
|
||||
{(cell, index) => (
|
||||
<td data-best={best() === index() ? "true" : undefined}>
|
||||
<strong>{cell.value}</strong>
|
||||
<Show when={cell.detail}>{(detail) => <span>{detail()}</span>}</Show>
|
||||
</td>
|
||||
)}
|
||||
</For>
|
||||
</tr>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function resolvedCatalogEntry(catalog: ModelCatalog | undefined, lab: string, model: string) {
|
||||
if (!catalog) return undefined
|
||||
return findModelCatalogEntry(catalog, model, lab) ?? null
|
||||
}
|
||||
|
||||
function buildComparisonModel(
|
||||
labParam: string,
|
||||
modelParam: string,
|
||||
catalog: ModelCatalogEntry | null,
|
||||
stats: StatsModelComparisonEntry | null,
|
||||
): ComparisonModel {
|
||||
return {
|
||||
name: catalog?.name ?? stats?.model ?? formatParamName(modelParam),
|
||||
lab: catalog?.lab ?? stats?.provider ?? catalogSlug(labParam),
|
||||
labName: formatCatalogLabName(catalog?.lab ?? stats?.provider ?? labParam),
|
||||
slug: catalog?.slug ?? stats?.slug ?? catalogSlug(modelParam),
|
||||
catalog,
|
||||
stats,
|
||||
}
|
||||
}
|
||||
|
||||
function comparisonCatalogEntry(model: ComparisonModel): ModelCatalogEntry {
|
||||
if (model.catalog) return model.catalog
|
||||
return {
|
||||
id: `${catalogSlug(model.lab)}/${catalogSlug(model.slug)}`,
|
||||
lab: catalogSlug(model.lab),
|
||||
slug: catalogSlug(model.slug),
|
||||
name: model.name,
|
||||
modalities: { input: [], output: [] },
|
||||
openWeights: false,
|
||||
reasoning: false,
|
||||
toolCall: false,
|
||||
attachment: false,
|
||||
temperature: false,
|
||||
weights: [],
|
||||
benchmarks: [],
|
||||
}
|
||||
}
|
||||
|
||||
function uniqueCatalogModels(models: ModelCatalogEntry[]) {
|
||||
return Object.values(
|
||||
models.reduce<Record<string, ModelCatalogEntry>>((result, model) => {
|
||||
result[model.id] = result[model.id] ?? model
|
||||
return result
|
||||
}, {}),
|
||||
)
|
||||
}
|
||||
|
||||
function buildComparisonRows(first: ComparisonModel, second: ComparisonModel): ComparisonRow[] {
|
||||
return [
|
||||
comparisonRow(
|
||||
"Recent Rank",
|
||||
"Lower is better.",
|
||||
{
|
||||
value: first.stats?.rank == null ? "No usage" : `#${first.stats.rank}`,
|
||||
score: first.stats?.rank ?? undefined,
|
||||
},
|
||||
{
|
||||
value: second.stats?.rank == null ? "No usage" : `#${second.stats.rank}`,
|
||||
score: second.stats?.rank ?? undefined,
|
||||
},
|
||||
"lower",
|
||||
),
|
||||
comparisonRow(
|
||||
"Token Share",
|
||||
"Share of recent OpenCode usage.",
|
||||
{ value: first.stats ? formatPercent(first.stats.tokenShare) : "No usage", score: first.stats?.tokenShare },
|
||||
{ value: second.stats ? formatPercent(second.stats.tokenShare) : "No usage", score: second.stats?.tokenShare },
|
||||
"higher",
|
||||
),
|
||||
comparisonRow(
|
||||
"Tokens",
|
||||
"Recent token volume.",
|
||||
{ value: first.stats ? formatTokens(first.stats.totals.tokens) : "No usage", score: first.stats?.totals.tokens },
|
||||
{
|
||||
value: second.stats ? formatTokens(second.stats.totals.tokens) : "No usage",
|
||||
score: second.stats?.totals.tokens,
|
||||
},
|
||||
"higher",
|
||||
),
|
||||
comparisonRow(
|
||||
"Sessions",
|
||||
"Recent session count.",
|
||||
{
|
||||
value: first.stats ? formatInteger(first.stats.totals.sessions) : "No usage",
|
||||
score: first.stats?.totals.sessions,
|
||||
},
|
||||
{
|
||||
value: second.stats ? formatInteger(second.stats.totals.sessions) : "No usage",
|
||||
score: second.stats?.totals.sessions,
|
||||
},
|
||||
"higher",
|
||||
),
|
||||
comparisonRow(
|
||||
"Cost / 1M Tokens",
|
||||
"Lower is better.",
|
||||
{
|
||||
value: first.stats ? formatMoney(first.stats.totals.costPerMillion) : "No usage",
|
||||
score: positiveScore(first.stats?.totals.costPerMillion),
|
||||
},
|
||||
{
|
||||
value: second.stats ? formatMoney(second.stats.totals.costPerMillion) : "No usage",
|
||||
score: positiveScore(second.stats?.totals.costPerMillion),
|
||||
},
|
||||
"lower",
|
||||
),
|
||||
comparisonRow(
|
||||
"Cost / Session",
|
||||
"Lower is better.",
|
||||
{
|
||||
value: first.stats ? formatSessionCost(first.stats.totals.costPerSession) : "No usage",
|
||||
score: positiveScore(first.stats?.totals.costPerSession),
|
||||
},
|
||||
{
|
||||
value: second.stats ? formatSessionCost(second.stats.totals.costPerSession) : "No usage",
|
||||
score: positiveScore(second.stats?.totals.costPerSession),
|
||||
},
|
||||
"lower",
|
||||
),
|
||||
comparisonRow(
|
||||
"Cache Ratio",
|
||||
"Higher is better.",
|
||||
{
|
||||
value: first.stats ? formatPercent(first.stats.totals.cacheRatio) : "No usage",
|
||||
score: first.stats?.totals.cacheRatio,
|
||||
},
|
||||
{
|
||||
value: second.stats ? formatPercent(second.stats.totals.cacheRatio) : "No usage",
|
||||
score: second.stats?.totals.cacheRatio,
|
||||
},
|
||||
"higher",
|
||||
),
|
||||
comparisonRow(
|
||||
"Context Window",
|
||||
"Higher limit is better.",
|
||||
{
|
||||
value: formatCatalogLimit(first.catalog?.limit?.context),
|
||||
score: first.catalog?.limit?.context,
|
||||
},
|
||||
{
|
||||
value: formatCatalogLimit(second.catalog?.limit?.context),
|
||||
score: second.catalog?.limit?.context,
|
||||
},
|
||||
"higher",
|
||||
),
|
||||
comparisonRow(
|
||||
"Output Limit",
|
||||
"Higher limit is better.",
|
||||
{
|
||||
value: formatCatalogLimit(first.catalog?.limit?.output),
|
||||
score: first.catalog?.limit?.output,
|
||||
},
|
||||
{
|
||||
value: formatCatalogLimit(second.catalog?.limit?.output),
|
||||
score: second.catalog?.limit?.output,
|
||||
},
|
||||
"higher",
|
||||
),
|
||||
comparisonRow(
|
||||
"Release Date",
|
||||
"Newer release is highlighted.",
|
||||
{
|
||||
value: formatCatalogDate(first.catalog?.releaseDate),
|
||||
score: catalogDateScore(first.catalog?.releaseDate),
|
||||
},
|
||||
{
|
||||
value: formatCatalogDate(second.catalog?.releaseDate),
|
||||
score: catalogDateScore(second.catalog?.releaseDate),
|
||||
},
|
||||
"higher",
|
||||
),
|
||||
comparisonRow(
|
||||
"Reasoning",
|
||||
"Supports reasoning.",
|
||||
booleanCell(first.catalog?.reasoning),
|
||||
booleanCell(second.catalog?.reasoning),
|
||||
"higher",
|
||||
),
|
||||
comparisonRow(
|
||||
"Tool Calling",
|
||||
"Supports tool calls.",
|
||||
booleanCell(first.catalog?.toolCall),
|
||||
booleanCell(second.catalog?.toolCall),
|
||||
"higher",
|
||||
),
|
||||
comparisonRow(
|
||||
"Attachments",
|
||||
"Supports attachments.",
|
||||
booleanCell(first.catalog?.attachment),
|
||||
booleanCell(second.catalog?.attachment),
|
||||
"higher",
|
||||
),
|
||||
comparisonRow(
|
||||
"Open Weights",
|
||||
"Open weights available.",
|
||||
booleanCell(first.catalog?.openWeights),
|
||||
booleanCell(second.catalog?.openWeights),
|
||||
"higher",
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
function comparisonRow(
|
||||
label: string,
|
||||
description: string,
|
||||
first: ComparisonCell,
|
||||
second: ComparisonCell,
|
||||
direction: ComparisonDirection,
|
||||
): ComparisonRow {
|
||||
return { label, description, direction, cells: [first, second] }
|
||||
}
|
||||
|
||||
function bestCellIndex(row: ComparisonRow) {
|
||||
const [first, second] = row.cells.map((cell) => cell.score)
|
||||
if (first === undefined || second === undefined || first === second) return undefined
|
||||
if (row.direction === "higher") return first > second ? 0 : 1
|
||||
return first < second ? 0 : 1
|
||||
}
|
||||
|
||||
function buildRelatedPairs(
|
||||
catalog: ModelCatalog | undefined,
|
||||
first: ComparisonModel,
|
||||
second: ComparisonModel,
|
||||
): ComparisonPair[] {
|
||||
const current = [comparisonRef(first), comparisonRef(second)] as const
|
||||
const alternatives = (catalog?.models ?? [])
|
||||
.filter((model) => model.id !== first.catalog?.id && model.id !== second.catalog?.id)
|
||||
.slice(0, 4)
|
||||
.map(modelRefFromCatalog)
|
||||
|
||||
return uniqueComparisonPairs([
|
||||
...alternatives.slice(0, 3).flatMap((model, index) => [
|
||||
{ first: current[0], second: model, detail: index === 0 ? "Nearby alternative" : "Related comparison" },
|
||||
{ first: current[1], second: model, detail: index === 0 ? "Nearby alternative" : "Related comparison" },
|
||||
]),
|
||||
]).slice(0, 6)
|
||||
}
|
||||
|
||||
function comparisonRef(model: ComparisonModel): ComparisonModelRef {
|
||||
return {
|
||||
name: model.name,
|
||||
lab: model.lab,
|
||||
slug: model.slug,
|
||||
labName: model.labName,
|
||||
metric: model.stats ? `#${model.stats.rank}` : "Catalog",
|
||||
}
|
||||
}
|
||||
|
||||
function positiveScore(value: number | undefined) {
|
||||
return value && value > 0 ? value : undefined
|
||||
}
|
||||
|
||||
function booleanCell(value: boolean | undefined): ComparisonCell {
|
||||
if (value === undefined) return { value: "Unknown" }
|
||||
return { value: value ? "Yes" : "No", score: value ? 1 : 0 }
|
||||
}
|
||||
|
||||
function catalogDateScore(value: string | undefined) {
|
||||
if (!value) return undefined
|
||||
const match = /^(\d{4})(?:-(\d{2}))?(?:-(\d{2}))?$/.exec(value)
|
||||
if (!match) return undefined
|
||||
return Date.UTC(Number(match[1]), match[2] ? Number(match[2]) - 1 : 0, match[3] ? Number(match[3]) : 1)
|
||||
}
|
||||
|
||||
function formatParamName(value: string) {
|
||||
return value
|
||||
.replace(/[-_]/g, " ")
|
||||
.replace(/\b\w/g, (letter) => letter.toUpperCase())
|
||||
.trim()
|
||||
}
|
||||
|
||||
function formatCatalogLimit(value: number | undefined) {
|
||||
return value === undefined ? "Unknown" : formatTokens(value)
|
||||
}
|
||||
|
||||
function formatCatalogDate(value: string | undefined) {
|
||||
if (!value) return "Unknown"
|
||||
const match = /^(\d{4})(?:-(\d{2}))?(?:-(\d{2}))?$/.exec(value)
|
||||
if (!match) return value
|
||||
const year = Number(match[1])
|
||||
const month = match[2] ? Number(match[2]) - 1 : 0
|
||||
const day = match[3] ? Number(match[3]) : 1
|
||||
return new Intl.DateTimeFormat("en", {
|
||||
month: match[2] ? "short" : undefined,
|
||||
day: match[3] ? "numeric" : undefined,
|
||||
year: "numeric",
|
||||
timeZone: "UTC",
|
||||
}).format(new Date(Date.UTC(year, month, day)))
|
||||
}
|
||||
|
||||
function formatTokens(value: number) {
|
||||
if (value >= 1_000_000_000_000)
|
||||
return `${trimNumber(value / 1_000_000_000_000, value >= 10_000_000_000_000 ? 0 : 1)}T`
|
||||
if (value >= 1_000_000_000) return `${trimNumber(value / 1_000_000_000, value >= 10_000_000_000 ? 0 : 1)}B`
|
||||
if (value >= 1_000_000) return `${trimNumber(value / 1_000_000, value >= 10_000_000 ? 0 : 1)}M`
|
||||
if (value >= 1_000) return `${trimNumber(value / 1_000, value >= 10_000 ? 0 : 1)}K`
|
||||
return String(Math.round(value))
|
||||
}
|
||||
|
||||
function formatInteger(value: number) {
|
||||
return new Intl.NumberFormat("en").format(value)
|
||||
}
|
||||
|
||||
function formatPercent(value: number) {
|
||||
return `${trimNumber(value, value >= 10 ? 1 : 2)}%`
|
||||
}
|
||||
|
||||
function formatMoney(value: number) {
|
||||
if (value >= 1) return `$${trimNumber(value, 2)}`
|
||||
if (value > 0) return `$${value.toFixed(4)}`
|
||||
return "$0"
|
||||
}
|
||||
|
||||
function formatSessionCost(value: number) {
|
||||
if (value >= 1) return `$${trimNumber(value, 2)}`
|
||||
if (value >= 0.01) return `$${value.toFixed(2)}`
|
||||
if (value > 0) return `$${value.toFixed(4)}`
|
||||
return "$0"
|
||||
}
|
||||
|
||||
function trimNumber(value: number, digits: number) {
|
||||
return Number(value.toFixed(digits)).toLocaleString("en")
|
||||
}
|
||||
export { default } from "../../../../../component/model-compare-detail"
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
113
packages/stats/app/src/routes/sitemap.xml.ts
Normal file
113
packages/stats/app/src/routes/sitemap.xml.ts
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
import { getStatsHomeData } from "@opencode-ai/stats-core/domain/home"
|
||||
import { runtime } from "@opencode-ai/stats-core/runtime"
|
||||
import {
|
||||
canonicalFamilyComparisonPath,
|
||||
canonicalModelComparisonPath,
|
||||
comparisonFamilies,
|
||||
comparisonSitemapModels,
|
||||
latestFamilyComparisonPath,
|
||||
resolveComparisonFamily,
|
||||
} from "../lib/comparison-pages"
|
||||
import { baseUrl } from "../lib/language"
|
||||
import { loadModelCatalog } from "./model-catalog"
|
||||
|
||||
type SitemapEntry = {
|
||||
path: string
|
||||
lastmod?: string
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
const [catalog, stats] = await Promise.all([
|
||||
loadModelCatalog(),
|
||||
runtime.runPromise(getStatsHomeData()).catch(() => undefined),
|
||||
])
|
||||
const lastmod = sitemapDate(
|
||||
stats?.updatedAt,
|
||||
...catalog.models.map((model) => model.lastUpdated ?? model.releaseDate),
|
||||
)
|
||||
const families = comparisonFamilies.flatMap((family) => {
|
||||
const resolved = resolveComparisonFamily(catalog, family.slug)
|
||||
return resolved ? [resolved] : []
|
||||
})
|
||||
const familyComparisons = families.flatMap((first, index) =>
|
||||
families.slice(index + 1).map((second) => ({
|
||||
path: canonicalFamilyComparisonPath(first, second),
|
||||
lastmod: sitemapDate(
|
||||
stats?.updatedAt,
|
||||
first.model.lastUpdated ?? first.model.releaseDate,
|
||||
second.model.lastUpdated ?? second.model.releaseDate,
|
||||
),
|
||||
})),
|
||||
)
|
||||
const models = comparisonSitemapModels(catalog, stats?.leaderboard["All Users"]["2M"])
|
||||
const modelComparisons = models.flatMap((first, index) =>
|
||||
models.slice(index + 1).flatMap((second) => {
|
||||
if (latestFamilyComparisonPath(catalog, first, second)) return []
|
||||
return [
|
||||
{
|
||||
path: canonicalModelComparisonPath(first, second),
|
||||
lastmod: sitemapDate(
|
||||
stats?.updatedAt,
|
||||
first.lastUpdated ?? first.releaseDate,
|
||||
second.lastUpdated ?? second.releaseDate,
|
||||
),
|
||||
},
|
||||
]
|
||||
}),
|
||||
)
|
||||
const entries = uniqueSitemapEntries([{ path: "/data/compare", lastmod }, ...familyComparisons, ...modelComparisons])
|
||||
|
||||
return new Response(sitemapXml(entries), {
|
||||
headers: {
|
||||
"Cache-Control": "public, max-age=300, s-maxage=3600, stale-while-revalidate=86400",
|
||||
"Content-Type": "application/xml; charset=utf-8",
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function uniqueSitemapEntries(entries: SitemapEntry[]) {
|
||||
return Object.values(
|
||||
entries.reduce<Record<string, SitemapEntry>>((result, entry) => {
|
||||
result[entry.path] = entry
|
||||
return result
|
||||
}, {}),
|
||||
).toSorted((a, b) => a.path.localeCompare(b.path))
|
||||
}
|
||||
|
||||
function sitemapXml(entries: SitemapEntry[]) {
|
||||
const urls = entries
|
||||
.map(
|
||||
(entry) => ` <url>
|
||||
<loc>${escapeXml(new URL(entry.path, baseUrl).toString())}</loc>${
|
||||
entry.lastmod
|
||||
? `
|
||||
<lastmod>${entry.lastmod}</lastmod>`
|
||||
: ""
|
||||
}
|
||||
</url>`,
|
||||
)
|
||||
.join("\n")
|
||||
return `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
||||
${urls}
|
||||
</urlset>`
|
||||
}
|
||||
|
||||
function sitemapDate(...values: (string | undefined | null)[]) {
|
||||
const dates = values.flatMap((value) => {
|
||||
if (!value) return []
|
||||
const date = new Date(value)
|
||||
return Number.isNaN(date.getTime()) ? [] : [date]
|
||||
})
|
||||
if (dates.length === 0) return undefined
|
||||
return new Date(Math.min(Date.now(), Math.max(...dates.map((date) => date.getTime())))).toISOString().slice(0, 10)
|
||||
}
|
||||
|
||||
function escapeXml(value: string) {
|
||||
return value
|
||||
.replaceAll("&", "&")
|
||||
.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 | - |
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue