From 28a06e52fcfaea87a749e4e4c9a74d90b3195fb0 Mon Sep 17 00:00:00 2001 From: James Long Date: Wed, 27 May 2026 14:09:40 -0400 Subject: [PATCH 001/113] feat(tui): add workspace management dialog (#29612) --- packages/opencode/src/cli/cmd/tui/app.tsx | 12 ++ .../tui/component/dialog-workspace-list.tsx | 112 ++++++++++++++++++ .../src/cli/cmd/tui/ui/dialog-select.tsx | 52 +++++--- packages/opencode/src/worktree/index.ts | 2 +- .../opencode/test/project/worktree.test.ts | 16 +++ 5 files changed, 174 insertions(+), 20 deletions(-) create mode 100644 packages/opencode/src/cli/cmd/tui/component/dialog-workspace-list.tsx diff --git a/packages/opencode/src/cli/cmd/tui/app.tsx b/packages/opencode/src/cli/cmd/tui/app.tsx index 39182dac0e..68803d0d11 100644 --- a/packages/opencode/src/cli/cmd/tui/app.tsx +++ b/packages/opencode/src/cli/cmd/tui/app.tsx @@ -41,6 +41,7 @@ import { DialogThemeList } from "@tui/component/dialog-theme-list" import { DialogHelp } from "./ui/dialog-help" import { DialogAgent } from "@tui/component/dialog-agent" import { DialogSessionList } from "@tui/component/dialog-session-list" +import { DialogWorkspaceList } from "@tui/component/dialog-workspace-list" import { DialogConsoleOrg } from "@tui/component/dialog-console-org" import { ThemeProvider, useTheme } from "@tui/context/theme" import { Home } from "@tui/routes/home" @@ -114,6 +115,7 @@ const appBindingCommands = [ "theme.mode.lock", "help.show", "docs.open", + "workspace.list", "app.debug", "app.console", "app.heap_snapshot", @@ -606,6 +608,16 @@ function App(props: { onSnapshot?: () => Promise }) { dialog.clear() }, }, + { + name: "workspace.list", + title: "Manage workspaces", + category: "Workspace", + hidden: !Flag.OPENCODE_EXPERIMENTAL_WORKSPACES, + slashName: "workspaces", + run: () => { + dialog.replace(() => ) + }, + }, ...Array.from({ length: 9 }, (_, i) => ({ name: `session.quick_switch.${i + 1}`, title: `Switch to session in quick slot ${i + 1}`, diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-workspace-list.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-workspace-list.tsx new file mode 100644 index 0000000000..0790d64796 --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-workspace-list.tsx @@ -0,0 +1,112 @@ +import type { Workspace } from "@opencode-ai/sdk/v2" +import { useDialog } from "@tui/ui/dialog" +import { DialogSelect, type DialogSelectOption } from "@tui/ui/dialog-select" +import { useProject } from "@tui/context/project" +import { useRoute } from "@tui/context/route" +import { useSync } from "@tui/context/sync" +import { useTheme } from "@tui/context/theme" +import { createMemo, createSignal, onMount } from "solid-js" +import { createStore } from "solid-js/store" +import { errorMessage } from "@/util/error" +import { useSDK } from "../context/sdk" +import { useToast } from "../ui/toast" + +type WorkspaceOption = { workspace: Workspace } + +export function DialogWorkspaceList() { + const dialog = useDialog() + const route = useRoute() + const sync = useSync() + const sdk = useSDK() + const toast = useToast() + const project = useProject() + const { theme } = useTheme() + const [deleting, setDeleting] = createSignal() + const [removing, setRemoving] = createSignal() + const [expanded, setExpanded] = createStore>({}) + + const current = createMemo(() => { + if (route.data.type === "session") return sync.session.get(route.data.sessionID)?.workspaceID + return project.workspace.current() + }) + + const options = createMemo[]>(() => + project.workspace + .list() + .toSorted((a, b) => a.name.localeCompare(b.name)) + .map((workspace) => { + const status = project.workspace.status(workspace.id) + return { + title: + removing() === workspace.id + ? "Deleting..." + : deleting() === workspace.id + ? `Delete ${workspace.name}? Press delete again` + : workspace.name, + value: { workspace }, + footer: workspace.type, + details: expanded[workspace.id] && workspace.directory ? [workspace.directory] : undefined, + gutter: () => , + } + }), + ) + + function showDetails(workspace: Workspace) { + setExpanded(workspace.id, (open) => !open) + } + + async function remove(workspace: Workspace) { + if (removing()) return + if (deleting() !== workspace.id) { + setDeleting(workspace.id) + return + } + + setDeleting(undefined) + setRemoving(workspace.id) + const result = await sdk.client.experimental.workspace.remove({ id: workspace.id }).catch((err) => ({ + error: err, + })) + if (result?.error) { + setRemoving(undefined) + toast.show({ + variant: "error", + title: "Failed to delete workspace", + message: errorMessage(result.error), + }) + return + } + + if (current() === workspace.id) { + project.workspace.set(undefined) + route.navigate({ type: "home" }) + } + await project.workspace.sync() + await sync.bootstrap({ fatal: false }).catch(() => undefined) + setRemoving(undefined) + } + + onMount(() => { + dialog.setSize("large") + void sdk.client.experimental.workspace.syncList().catch(() => undefined) + void project.workspace.sync() + }) + + return ( + { + setDeleting(undefined) + }} + onSelect={(option) => showDetails(option.value.workspace)} + actions={[ + { + command: "session.delete", + title: "delete", + onTrigger: (option) => void remove(option.value.workspace), + }, + ]} + /> + ) +} diff --git a/packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx b/packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx index 700735d38c..a9e9730955 100644 --- a/packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx +++ b/packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx @@ -51,6 +51,7 @@ export interface DialogSelectOption { title: string value: T description?: string + details?: string[] footer?: JSX.Element | string category?: string categoryView?: JSX.Element @@ -167,7 +168,7 @@ export function DialogSelect(props: DialogSelectProps) { if (!category) return acc return acc + (i > 0 ? 2 : 1) }, 0) - return flat().length + headers + return flat().reduce((acc, option) => acc + 1 + (option.details?.length ?? 0), headers) }) const dimensions = useTerminalDimensions() @@ -426,7 +427,7 @@ export function DialogSelect(props: DialogSelectProps) { return ( { setStore("input", "mouse") @@ -446,24 +447,37 @@ export function DialogSelect(props: DialogSelectProps) { if (index === -1) return moveTo(index) }} - backgroundColor={active() ? (option.bg ?? theme.primary) : RGBA.fromInts(0, 0, 0, 0)} - paddingLeft={current() || option.gutter ? 1 : 3} - paddingRight={3} - gap={1} > - - - {option.margin} - - - ) }} diff --git a/packages/opencode/src/worktree/index.ts b/packages/opencode/src/worktree/index.ts index a1d4f89c2a..d9743e563b 100644 --- a/packages/opencode/src/worktree/index.ts +++ b/packages/opencode/src/worktree/index.ts @@ -351,7 +351,7 @@ export const layer: Layer.Layer< return yield* new ListFailedError({ message: result.stderr || result.text || "Failed to read git worktrees" }) } - const primary = yield* canonical(ctx.worktree) + const primary = yield* canonical(ctx.project.worktree) const primaryName = pathSvc.basename(primary).toLowerCase() return yield* Effect.forEach(parseWorktreeList(result.text), (entry) => Effect.gen(function* () { diff --git a/packages/opencode/test/project/worktree.test.ts b/packages/opencode/test/project/worktree.test.ts index 688b818bee..fedf98371e 100644 --- a/packages/opencode/test/project/worktree.test.ts +++ b/packages/opencode/test/project/worktree.test.ts @@ -214,6 +214,22 @@ describe("Worktree", () => { { git: true }, ) + it.instance( + "lists the active linked worktree but not the project checkout", + () => + withCreatedWorktree(undefined, ({ info }) => + Effect.gen(function* () { + const test = yield* TestInstance + const svc = yield* Worktree.Service + const list = yield* svc.list().pipe(provideInstance(info.directory)) + + expect(list.map((item) => item.name)).toContain(info.name) + expect(list.map((item) => item.name)).not.toContain(path.basename(test.directory).toLowerCase()) + }), + ), + { git: true }, + ) + it.instance( "create with custom name", () => From 62da1e76826276b493ce7f8a9581d482cd7c16ee Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Wed, 27 May 2026 15:02:37 -0500 Subject: [PATCH 002/113] feat(openai): add responses websocket transport (#29477) --- bun.lock | 10 +- packages/opencode/package.json | 2 + packages/opencode/src/effect/runtime-flags.ts | 1 + packages/opencode/src/plugin/index.ts | 37 +- packages/opencode/src/plugin/openai/README.md | 31 + .../opencode/src/plugin/{ => openai}/codex.ts | 61 +- .../opencode/src/plugin/openai/ws-pool.ts | 247 +++++++ packages/opencode/src/plugin/openai/ws.ts | 315 +++++++++ .../test/effect/runtime-flags.test.ts | 11 + packages/opencode/test/plugin/codex.test.ts | 20 +- .../test/plugin/openai-rollout.test.ts | 17 + .../opencode/test/plugin/openai-ws.test.ts | 619 ++++++++++++++++++ 12 files changed, 1326 insertions(+), 45 deletions(-) create mode 100644 packages/opencode/src/plugin/openai/README.md rename packages/opencode/src/plugin/{ => openai}/codex.ts (91%) create mode 100644 packages/opencode/src/plugin/openai/ws-pool.ts create mode 100644 packages/opencode/src/plugin/openai/ws.ts create mode 100644 packages/opencode/test/plugin/openai-rollout.test.ts create mode 100644 packages/opencode/test/plugin/openai-ws.test.ts diff --git a/bun.lock b/bun.lock index a5764fc15f..aee242b042 100644 --- a/bun.lock +++ b/bun.lock @@ -478,6 +478,7 @@ "@solid-primitives/event-bus": "1.1.2", "@solid-primitives/scheduled": "1.5.2", "@standard-schema/spec": "1.0.0", + "@types/ws": "8.18.1", "@zip.js/zip.js": "2.7.62", "ai": "catalog:", "ai-gateway-provider": "3.1.2", @@ -519,6 +520,7 @@ "vscode-jsonrpc": "8.2.1", "web-tree-sitter": "0.25.10", "which": "6.0.1", + "ws": "8.21.0", "xdg-basedir": "5.1.0", "yargs": "18.0.0", "zod": "catalog:", @@ -5138,7 +5140,7 @@ "write-file-atomic": ["write-file-atomic@7.0.1", "", { "dependencies": { "signal-exit": "^4.0.1" } }, "sha512-OTIk8iR8/aCRWBqvxrzxR0hgxWpnYBblY1S5hDWBQfk/VFmJwzmJgQFN3WsoUKHISv2eAwe+PpbUzyL1CKTLXg=="], - "ws": ["ws@8.18.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw=="], + "ws": ["ws@8.21.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g=="], "wsl-utils": ["wsl-utils@0.3.1", "", { "dependencies": { "is-wsl": "^3.1.0", "powershell-utils": "^0.1.0" } }, "sha512-g/eziiSUNBSsdDJtCLB8bdYEUMj4jR7AGeUo96p/3dTafgjHhpF4RiCFPiRILwjQoDXx5MqkBr4fwWtR3Ky4Wg=="], @@ -5496,6 +5498,8 @@ "@cloudflare/kv-asset-handler/mime": ["mime@3.0.0", "", { "bin": { "mime": "cli.js" } }, "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A=="], + "@cloudflare/vite-plugin/ws": ["ws@8.18.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw=="], + "@cspotcode/source-map-support/@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.9", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.0.3", "@jridgewell/sourcemap-codec": "^1.4.10" } }, "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ=="], "@develar/schema-utils/ajv": ["ajv@6.14.0", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw=="], @@ -5958,6 +5962,8 @@ "miniflare/undici": ["undici@7.14.0", "", {}, "sha512-Vqs8HTzjpQXZeXdpsfChQTlafcMQaaIwnGwLam1wudSSjlJeQ3bw1j+TLPePgrCnCpUXx7Ba5Pdpf5OBih62NQ=="], + "miniflare/ws": ["ws@8.18.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw=="], + "miniflare/zod": ["zod@3.22.3", "", {}, "sha512-EjIevzuJRiRPbVH4mGc8nApb/lVLKVpmUhAaR5R5doKGfAnGJ6Gr3CViAVjP+4FWSxCsybeWQdcgCtbX+7oZug=="], "minipass-flush/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="], @@ -6074,6 +6080,8 @@ "storybook/open": ["open@10.2.0", "", { "dependencies": { "default-browser": "^5.2.1", "define-lazy-prop": "^3.0.0", "is-inside-container": "^1.0.0", "wsl-utils": "^0.1.0" } }, "sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA=="], + "storybook/ws": ["ws@8.18.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw=="], + "storybook-solidjs-vite/vite-plugin-solid": ["vite-plugin-solid@2.11.12", "", { "dependencies": { "@babel/core": "^7.23.3", "@types/babel__core": "^7.20.4", "babel-preset-solid": "^1.8.4", "merge-anything": "^5.1.7", "solid-refresh": "^0.6.3", "vitefu": "^1.0.4" }, "peerDependencies": { "@testing-library/jest-dom": "^5.16.6 || ^5.17.0 || ^6.*", "solid-js": "^1.7.2", "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["@testing-library/jest-dom"] }, "sha512-FgjPcx2OwX9h6f28jli7A4bG7PP3te8uyakE5iqsmpq3Jqi1TWLgSroC9N6cMfGRU2zXsl4Q6ISvTr2VL0QHpA=="], "string-width-cjs/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], diff --git a/packages/opencode/package.json b/packages/opencode/package.json index 991a6c4571..4dcc3da664 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -122,6 +122,7 @@ "@solid-primitives/event-bus": "1.1.2", "@solid-primitives/scheduled": "1.5.2", "@standard-schema/spec": "1.0.0", + "@types/ws": "8.18.1", "@zip.js/zip.js": "2.7.62", "ai": "catalog:", "ai-gateway-provider": "3.1.2", @@ -163,6 +164,7 @@ "vscode-jsonrpc": "8.2.1", "web-tree-sitter": "0.25.10", "which": "6.0.1", + "ws": "8.21.0", "xdg-basedir": "5.1.0", "yargs": "18.0.0", "zod": "catalog:" diff --git a/packages/opencode/src/effect/runtime-flags.ts b/packages/opencode/src/effect/runtime-flags.ts index b12a5d5707..e0360ea722 100644 --- a/packages/opencode/src/effect/runtime-flags.ts +++ b/packages/opencode/src/effect/runtime-flags.ts @@ -54,6 +54,7 @@ export class Service extends ConfigService.Service()("@opencode/Runtime outputTokenMax: positiveInteger("OPENCODE_EXPERIMENTAL_OUTPUT_TOKEN_MAX"), bashDefaultTimeoutMs: positiveInteger("OPENCODE_EXPERIMENTAL_BASH_DEFAULT_TIMEOUT_MS"), experimentalNativeLlm: bool("OPENCODE_EXPERIMENTAL_NATIVE_LLM"), + experimentalWebSockets: bool("OPENCODE_EXPERIMENTAL_WEBSOCKETS"), client: Config.string("OPENCODE_CLIENT").pipe(Config.withDefault("cli")), }) {} diff --git a/packages/opencode/src/plugin/index.ts b/packages/opencode/src/plugin/index.ts index 793a072b29..717dff8db2 100644 --- a/packages/opencode/src/plugin/index.ts +++ b/packages/opencode/src/plugin/index.ts @@ -10,7 +10,7 @@ import { Bus } from "../bus" import * as Log from "@opencode-ai/core/util/log" import { createOpencodeClient } from "@opencode-ai/sdk" import { ServerAuth } from "@/server/auth" -import { CodexAuthPlugin } from "./codex" +import { CodexAuthPlugin } from "./openai/codex" import { Session } from "@/session/session" import { NamedError } from "@opencode-ai/core/util/error" import { CopilotAuthPlugin } from "./github-copilot/copilot" @@ -29,6 +29,7 @@ import { parsePluginSpecifier, readPluginId, readV1Plugin, resolvePluginId } fro import { registerAdapter } from "@/control-plane/adapters" import type { WorkspaceAdapter } from "@/control-plane/types" import { RuntimeFlags } from "@/effect/runtime-flags" +import { InstallationChannel } from "@opencode-ai/core/installation/version" const log = Log.create({ service: "plugin" }) @@ -57,18 +58,28 @@ export interface Interface { export class Service extends Context.Service()("@opencode/Plugin") {} +export function experimentalWebSocketsEnabled(input: { enabled: boolean; channel?: string }) { + return input.enabled || ["local", "dev", "beta"].includes(input.channel ?? InstallationChannel) +} + // Built-in plugins that are directly imported (not installed from npm) -const INTERNAL_PLUGINS: PluginInstance[] = [ - CodexAuthPlugin, - CopilotAuthPlugin, - GitlabAuthPlugin, - PoeAuthPlugin, - CloudflareWorkersAuthPlugin, - CloudflareAIGatewayAuthPlugin, - AzureAuthPlugin, - DigitalOceanAuthPlugin, - XaiAuthPlugin, -] +function internalPlugins(flags: RuntimeFlags.Info): PluginInstance[] { + return [ + // Temporary rollout: pre-release builds use WebSockets by default; releases require explicit opt-in. + (input) => + CodexAuthPlugin(input, { + experimentalWebSockets: experimentalWebSocketsEnabled({ enabled: flags.experimentalWebSockets }), + }), + CopilotAuthPlugin, + GitlabAuthPlugin, + PoeAuthPlugin, + CloudflareWorkersAuthPlugin, + CloudflareAIGatewayAuthPlugin, + AzureAuthPlugin, + DigitalOceanAuthPlugin, + XaiAuthPlugin, + ] +} function isServerPlugin(value: unknown): value is PluginInstance { return typeof value === "function" @@ -151,7 +162,7 @@ export const layer = Layer.effect( $: typeof Bun === "undefined" ? undefined : Bun.$, } - for (const plugin of flags.disableDefaultPlugins ? [] : INTERNAL_PLUGINS) { + for (const plugin of flags.disableDefaultPlugins ? [] : internalPlugins(flags)) { log.info("loading internal plugin", { name: plugin.name }) const init = yield* Effect.tryPromise({ try: () => plugin(input), diff --git a/packages/opencode/src/plugin/openai/README.md b/packages/opencode/src/plugin/openai/README.md new file mode 100644 index 0000000000..c359f8c60c --- /dev/null +++ b/packages/opencode/src/plugin/openai/README.md @@ -0,0 +1,31 @@ +# OpenAI Responses WebSocket + +Enabled by default on `local`, `dev`, and `beta`. On `latest` and `prod`, set `OPENCODE_EXPERIMENTAL_WEBSOCKETS=true`. + +## Flow + +1. A streamed `POST /responses` request arrives. +2. If it has no `session-id` or `x-session-affinity` header, use HTTP. +3. Title requests use HTTP. +4. If that session's socket is busy or already in fallback mode, use HTTP. +5. Otherwise, reuse its open socket or open a new one. +6. Send `response.create` and return WebSocket events as SSE. + +## Lifetime + +- Connect timeout: 15 seconds. +- Idle timeout: 5 minutes. +- After a completed response, keep the socket for reuse. +- Reuse a socket for up to 55 minutes, then replace it on the next request. + +## Retries + +- If WebSocket setup fails or it fails before its first event, replay over HTTP and keep that session on HTTP until idle-pruned. +- If the server returns `websocket_connection_limit_reached` before output, reconnect up to 5 times, then follow the same HTTP fallback. +- If a WebSocket fails after its first event, fail the stream. Do not replay partial output. +- Abort or cancel closes the socket. + +## Next Steps + +- `previous_response_id` continuation. +- Optional second WebSocket for concurrent requests in one session. Currently these use HTTP. diff --git a/packages/opencode/src/plugin/codex.ts b/packages/opencode/src/plugin/openai/codex.ts similarity index 91% rename from packages/opencode/src/plugin/codex.ts rename to packages/opencode/src/plugin/openai/codex.ts index bbd65e0f00..b6edf173c8 100644 --- a/packages/opencode/src/plugin/codex.ts +++ b/packages/opencode/src/plugin/openai/codex.ts @@ -1,10 +1,11 @@ import type { Hooks, PluginInput } from "@opencode-ai/plugin" import * as Log from "@opencode-ai/core/util/log" import { InstallationVersion } from "@opencode-ai/core/installation/version" -import { OAUTH_DUMMY_KEY } from "../auth" +import { OAUTH_DUMMY_KEY } from "../../auth" import os from "os" import { setTimeout as sleep } from "node:timers/promises" import { createServer } from "http" +import { OpenAIWebSocketPool } from "./ws-pool" const log = Log.create({ service: "plugin.codex" }) @@ -28,20 +29,12 @@ interface PkceCodes { } async function generatePKCE(): Promise { - const verifier = generateRandomString(43) - const encoder = new TextEncoder() - const data = encoder.encode(verifier) - const hash = await crypto.subtle.digest("SHA-256", data) - const challenge = base64UrlEncode(hash) - return { verifier, challenge } -} - -function generateRandomString(length: number): string { const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~" - const bytes = crypto.getRandomValues(new Uint8Array(length)) - return Array.from(bytes) + const verifier = Array.from(crypto.getRandomValues(new Uint8Array(43))) .map((b) => chars[b % chars.length]) .join("") + const challenge = base64UrlEncode(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier))) + return { verifier, challenge } } function base64UrlEncode(buffer: ArrayBuffer): string { @@ -50,10 +43,6 @@ function base64UrlEncode(buffer: ArrayBuffer): string { return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "") } -function generateState(): string { - return base64UrlEncode(crypto.getRandomValues(new Uint8Array(32)).buffer) -} - export interface IdTokenClaims { chatgpt_account_id?: string organizations?: Array<{ id: string }> @@ -120,6 +109,7 @@ interface TokenResponse { interface CodexAuthPluginOptions { issuer?: string codexApiEndpoint?: string + experimentalWebSockets?: boolean } async function exchangeCodeForTokens(code: string, redirectUri: string, pkce: PkceCodes): Promise { @@ -371,8 +361,14 @@ function waitForOAuthCallback(pkce: PkceCodes, state: string): Promise { const issuer = options.issuer ?? ISSUER const codexApiEndpoint = options.codexApiEndpoint ?? CODEX_API_ENDPOINT + let websocketFetchInstalled = false + const websocketFetches: Array> = [] return { + async dispose() { + for (const websocketFetch of websocketFetches) websocketFetch.close() + websocketFetches.length = 0 + }, provider: { id: "openai", async models(provider, ctx) { @@ -410,7 +406,14 @@ export async function CodexAuthPlugin(input: PluginInput, options: CodexAuthPlug provider: "openai", async loader(getAuth) { const auth = await getAuth() - if (auth.type !== "oauth") return {} + const websocketFetch = options.experimentalWebSockets + ? OpenAIWebSocketPool.createWebSocketFetch({ httpFetch: fetch }) + : undefined + if (websocketFetch) { + websocketFetches.push(websocketFetch) + websocketFetchInstalled = true + } + if (auth.type !== "oauth") return websocketFetch ? { fetch: websocketFetch } : {} let refreshPromise: | Promise<{ @@ -422,7 +425,6 @@ export async function CodexAuthPlugin(input: PluginInput, options: CodexAuthPlug return { apiKey: OAUTH_DUMMY_KEY, async fetch(requestInput: RequestInfo | URL, init?: RequestInit) { - // Remove dummy API key authorization header if (init?.headers) { if (init.headers instanceof Headers) { init.headers.delete("authorization") @@ -436,12 +438,11 @@ export async function CodexAuthPlugin(input: PluginInput, options: CodexAuthPlug } const currentAuth = await getAuth() - if (currentAuth.type !== "oauth") return fetch(requestInput, init) + if (currentAuth.type !== "oauth") + return websocketFetch ? websocketFetch(requestInput, init) : fetch(requestInput, init) - // Cast to include accountId field const authWithAccount = currentAuth as typeof currentAuth & { accountId?: string } - // Check if token needs refresh if (!currentAuth.access || currentAuth.expires < Date.now()) { if (!refreshPromise) { log.info("refreshing codex access token") @@ -473,7 +474,6 @@ export async function CodexAuthPlugin(input: PluginInput, options: CodexAuthPlug authWithAccount.accountId = refreshed.accountId } - // Build headers const headers = new Headers() if (init?.headers) { if (init.headers instanceof Headers) { @@ -488,16 +488,11 @@ export async function CodexAuthPlugin(input: PluginInput, options: CodexAuthPlug } } } - - // Set authorization header with access token headers.set("authorization", `Bearer ${currentAuth.access}`) - - // Set ChatGPT-Account-Id header for organization subscriptions if (authWithAccount.accountId) { headers.set("ChatGPT-Account-Id", authWithAccount.accountId) } - // Rewrite URL to Codex endpoint const parsed = requestInput instanceof URL ? requestInput @@ -507,10 +502,12 @@ export async function CodexAuthPlugin(input: PluginInput, options: CodexAuthPlug ? new URL(codexApiEndpoint) : parsed - return fetch(url, { + const requestInit = { ...init, headers, - }) + } + if (websocketFetch && parsed.pathname.includes("/v1/responses")) return websocketFetch(url, requestInit) + return fetch(url, OpenAIWebSocketPool.withoutInternalHeaders(requestInit)) }, } }, @@ -521,7 +518,7 @@ export async function CodexAuthPlugin(input: PluginInput, options: CodexAuthPlug authorize: async () => { const { redirectUri } = await startOAuthServer() const pkce = await generatePKCE() - const state = generateState() + const state = base64UrlEncode(crypto.getRandomValues(new Uint8Array(32)).buffer) const authUrl = buildAuthorizeUrl(redirectUri, pkce, state) const callbackPromise = waitForOAuthCallback(pkce, state) @@ -639,6 +636,10 @@ export async function CodexAuthPlugin(input: PluginInput, options: CodexAuthPlug output.headers.originator = "opencode" output.headers["User-Agent"] = `opencode/${InstallationVersion} (${os.platform()} ${os.release()}; ${os.arch()})` output.headers["session-id"] = input.sessionID + // Temporary fetch-layer hack: title generation currently shares the conversation + // session ID, so the OpenAI plugin marks it for HTTP fallback until transport + // context can be passed directly instead of smuggled through headers. + if (websocketFetchInstalled && input.agent === "title") output.headers[OpenAIWebSocketPool.TITLE_HEADER] = "true" }, "chat.params": async (input, output) => { if (input.model.providerID !== "openai") return diff --git a/packages/opencode/src/plugin/openai/ws-pool.ts b/packages/opencode/src/plugin/openai/ws-pool.ts new file mode 100644 index 0000000000..ae18eb5407 --- /dev/null +++ b/packages/opencode/src/plugin/openai/ws-pool.ts @@ -0,0 +1,247 @@ +import WebSocket from "ws" +import * as Log from "@opencode-ai/core/util/log" +import { isRecord } from "@/util/record" +import { OpenAIWebSocket } from "./ws" + +export const TITLE_HEADER = "x-opencode-title" + +const log = Log.create({ service: "plugin.openai.ws" }) + +export interface CreateWebSocketFetchOptions { + httpFetch?: typeof globalThis.fetch + url?: string + connectTimeout?: number + idleTimeout?: number + maxConnectionAge?: number + connectionLimitRetries?: number +} + +interface PoolEntry { + socket?: WebSocket + connectedAt?: number + lastUsedAt: number + busy: boolean + fallback: boolean +} + +const DEFAULT_CONNECT_TIMEOUT = 15_000 +const DEFAULT_IDLE_TIMEOUT = 5 * 60 * 1000 +const DEFAULT_MAX_CONNECTION_AGE = 55 * 60 * 1000 +const CONNECTION_LIMIT_REACHED_CODE = "websocket_connection_limit_reached" + +export function createWebSocketFetch(options?: CreateWebSocketFetchOptions) { + const httpFetch = options?.httpFetch ?? globalThis.fetch + const pool = new Map() + const connectTimeout = options?.connectTimeout ?? DEFAULT_CONNECT_TIMEOUT + const idleTimeout = options?.idleTimeout ?? DEFAULT_IDLE_TIMEOUT + const maxConnectionAge = options?.maxConnectionAge ?? DEFAULT_MAX_CONNECTION_AGE + const connectionLimitRetries = options?.connectionLimitRetries ?? 5 + const pruneTimer = setInterval(() => prune(), Math.min(idleTimeout, 60_000)) + if (typeof pruneTimer === "object" && "unref" in pruneTimer && typeof pruneTimer.unref === "function") { + pruneTimer.unref() + } + + async function websocketFetch(input: RequestInfo | URL, init?: RequestInit): Promise { + const url = input instanceof URL ? input.toString() : typeof input === "string" ? input : input.url + const internalHeaders = OpenAIWebSocket.normalizeHeaders(init?.headers) + const httpInit = withoutInternalHeaders(init) + + if (init?.method !== "POST" || !new URL(url).pathname.endsWith("/responses")) { + return httpFetch(input, httpInit) + } + + const body = (() => { + try { + if (typeof init?.body !== "string") return undefined + const parsed = JSON.parse(init.body) + return typeof parsed === "object" && parsed !== null ? parsed : undefined + } catch { + return undefined + } + })() + if (!body?.stream) return httpFetch(input, httpInit) + if (internalHeaders[TITLE_HEADER] === "true") { + log.debug("http fallback", { reason: "title" }) + return httpFetch(input, httpInit) + } + + const sessionID = internalHeaders["x-session-affinity"] ?? internalHeaders["session-id"] + if (!sessionID) { + log.debug("http fallback", { reason: "missing_session" }) + return httpFetch(input, httpInit) + } + const key = `${sessionID}:conversation` + + const entry = pool.get(key) ?? { lastUsedAt: Date.now(), busy: false, fallback: false } + pool.set(key, entry) + + if (entry.fallback) { + log.debug("http fallback", { key, reason: "fallback_active" }) + return httpFetch(input, httpInit) + } + if (entry.busy) { + log.debug("http fallback", { key, reason: "busy" }) + return httpFetch(input, httpInit) + } + + entry.busy = true + entry.lastUsedAt = Date.now() + try { + let connectionLimitAttempts = 0 + entry.socket = await socket( + entry, + options?.url ?? url, + OpenAIWebSocket.normalizeHeaders(httpInit?.headers), + connectTimeout, + maxConnectionAge, + init?.signal, + ) + let resolveFirstEvent: (started: boolean) => void = () => {} + let rejectFirstEvent: (error: Error) => void = () => {} + const firstEvent = new Promise((resolve, reject) => { + resolveFirstEvent = resolve + rejectFirstEvent = reject + }) + const response = OpenAIWebSocket.streamResponsesWebSocket({ + socket: entry.socket, + body, + idleTimeout, + signal: init?.signal ?? undefined, + onFirstEvent: () => resolveFirstEvent(true), + onTerminal: (event) => { + entry.busy = false + entry.lastUsedAt = Date.now() + if (event.type !== "response.completed" && event.type !== "response.done") { + log.warn("websocket terminal failure", { key, type: event.type }) + invalidate(entry) + } + }, + onConnectionInvalid: (error) => { + log.warn("websocket invalidated", { key, error: error instanceof Error ? error.message : String(error) }) + entry.busy = false + entry.fallback = true + invalidate(entry) + resolveFirstEvent(false) + }, + onAbort: (error) => { + log.debug("websocket aborted", { key }) + entry.busy = false + entry.lastUsedAt = Date.now() + invalidate(entry) + rejectFirstEvent(error) + }, + onRetryableTerminal: async (event) => { + const error = connectionLimitError(event) + if (!error) return undefined + if (connectionLimitAttempts >= connectionLimitRetries) throw error + + connectionLimitAttempts++ + log.warn("websocket connection limit reached", { key, attempt: connectionLimitAttempts }) + invalidate(entry) + entry.socket = await socket( + entry, + options?.url ?? url, + OpenAIWebSocket.normalizeHeaders(httpInit?.headers), + connectTimeout, + maxConnectionAge, + init?.signal, + ) + entry.lastUsedAt = Date.now() + return entry.socket + }, + }) + if (await firstEvent) return response + log.debug("http fallback", { key, reason: "websocket_failed_before_first_event" }) + return httpFetch(input, httpInit) + } catch (error) { + entry.busy = false + entry.lastUsedAt = Date.now() + if (OpenAIWebSocket.isAbortError(error)) { + invalidate(entry) + throw error + } + + entry.fallback = true + log.warn("websocket setup failed", { key, error: error instanceof Error ? error.message : String(error), fallback: "http" }) + invalidate(entry) + return httpFetch(input, httpInit) + } + } + + function prune() { + const now = Date.now() + for (const [key, entry] of pool) { + if (entry.busy) continue + if (now - entry.lastUsedAt < idleTimeout) continue + log.debug("websocket idle prune", { key }) + invalidate(entry) + pool.delete(key) + } + } + + function close() { + log.debug("websocket pool close", { count: pool.size }) + clearInterval(pruneTimer) + for (const entry of pool.values()) invalidate(entry) + pool.clear() + } + + return Object.assign(websocketFetch, { close }) +} + +function connectionLimitError(event: Record) { + if (event.type !== "error" || !isRecord(event.error) || event.error.code !== CONNECTION_LIMIT_REACHED_CODE) return + return new Error(typeof event.error.message === "string" ? event.error.message : CONNECTION_LIMIT_REACHED_CODE) +} + +async function socket( + entry: PoolEntry, + url: string, + headers: Record, + connectTimeout: number, + maxConnectionAge: number, + signal?: AbortSignal | null, +) { + if (entry.socket?.readyState === WebSocket.OPEN && entry.connectedAt && Date.now() - entry.connectedAt < maxConnectionAge) { + return entry.socket + } + + invalidate(entry) + const next = await OpenAIWebSocket.connectResponsesWebSocket({ + url: OpenAIWebSocket.toWebSocketUrl(url), + headers, + timeout: connectTimeout, + signal: signal ?? undefined, + }) + entry.connectedAt = Date.now() + return next +} + +function invalidate(entry: PoolEntry) { + if (entry.socket) { + entry.socket.on("error", () => {}) + entry.socket.terminate() + entry.socket = undefined + } + entry.connectedAt = undefined +} + +export function withoutInternalHeaders(init: T | undefined): T | undefined { + if (!init?.headers) return init + if (init.headers instanceof Headers) { + const headers = new Headers(init.headers) + headers.delete(TITLE_HEADER) + return { ...init, headers } + } + + if (Array.isArray(init.headers)) { + return { ...init, headers: init.headers.filter((item) => item[0].toLowerCase() !== TITLE_HEADER) } + } + + return { + ...init, + headers: Object.fromEntries(Object.entries(init.headers).filter(([key]) => key.toLowerCase() !== TITLE_HEADER)), + } +} + +export * as OpenAIWebSocketPool from "./ws-pool" diff --git a/packages/opencode/src/plugin/openai/ws.ts b/packages/opencode/src/plugin/openai/ws.ts new file mode 100644 index 0000000000..9d434d89f4 --- /dev/null +++ b/packages/opencode/src/plugin/openai/ws.ts @@ -0,0 +1,315 @@ +// Low-level OpenAI Responses WebSocket protocol helpers. Session pooling, +// fallback, and continuation state intentionally live above this file. + +import WebSocket from "ws" + +export const PROTOCOL_HEADER = "responses_websockets=2026-02-06" + +export interface ConnectResponsesWebSocketOptions { + url: string + headers: Record + timeout?: number + signal?: AbortSignal +} + +export interface StreamResponsesWebSocketOptions { + socket: WebSocket + body: Record + idleTimeout?: number + signal?: AbortSignal + onFirstEvent?: () => void + onComplete?: (event: Record) => void + onTerminal?: (event: Record) => void + onRetryableTerminal?: (event: Record) => Promise + onConnectionInvalid?: (error: Error) => void + onAbort?: (error: Error) => void +} + +export function toWebSocketUrl(url: string) { + return url.replace(/^http/, "ws") +} + +export function normalizeHeaders(headers: HeadersInit | undefined): Record { + const result: Record = {} + if (!headers) return result + + if (headers instanceof Headers) { + headers.forEach((value, key) => { + result[key.toLowerCase()] = value + }) + return result + } + + if (Array.isArray(headers)) { + for (const [key, value] of headers) { + result[key.toLowerCase()] = value + } + return result + } + + for (const [key, value] of Object.entries(headers)) { + if (value != null) result[key.toLowerCase()] = value + } + return result +} + +export function isAbortError(error: unknown): error is DOMException { + return error instanceof DOMException && error.name === "AbortError" +} + +export function connectResponsesWebSocket(options: ConnectResponsesWebSocketOptions) { + return new Promise((resolve, reject) => { + if (options.signal?.aborted) { + reject(abortError(options.signal)) + return + } + + const headers: Record = { + ...options.headers, + "openai-beta": options.headers["openai-beta"] ?? PROTOCOL_HEADER, + } + delete headers["content-length"] + + const socket = new WebSocket(options.url, { headers }) + const timeout = options.timeout + ? setTimeout(() => { + cleanup() + socket.on("error", () => {}) + socket.terminate() + reject(new Error("WebSocket connect timed out")) + }, options.timeout) + : undefined + + function cleanup() { + if (timeout) clearTimeout(timeout) + socket.off("open", onOpen) + socket.off("error", onError) + socket.off("close", onClose) + options.signal?.removeEventListener("abort", onAbort) + } + + function onOpen() { + cleanup() + resolve(socket) + } + + function onError(error: Error) { + socket.on("error", () => {}) + cleanup() + reject(error) + } + + function onClose(code: number, reason: Buffer) { + cleanup() + reject(closeError("WebSocket closed before open", code, reason)) + } + + function onAbort() { + cleanup() + socket.on("error", () => {}) + socket.terminate() + reject(abortError(options.signal)) + } + + socket.once("open", onOpen) + socket.once("error", onError) + socket.once("close", onClose) + options.signal?.addEventListener("abort", onAbort, { once: true }) + }) +} + +export function streamResponsesWebSocket(options: StreamResponsesWebSocketOptions) { + const encoder = new TextEncoder() + + let socket = options.socket + let controller: ReadableStreamDefaultController | undefined + let cleanupSocket = () => {} + let completed = false + let emitted = false + let idleTimer: ReturnType | undefined + + function cleanup() { + if (idleTimer) clearTimeout(idleTimer) + cleanupSocket() + options.signal?.removeEventListener("abort", onAbort) + } + + function terminateSocket(target = socket) { + target.on("error", () => {}) + target.terminate() + } + + function closeCompleted() { + cleanup() + controller?.enqueue(encoder.encode("data: [DONE]\n\n")) + controller?.close() + } + + function invalidate(error: Error) { + if (completed) return + completed = true + cleanup() + options.onConnectionInvalid?.(error) + controller?.error(error) + } + + function resetIdleTimeout(message: string) { + if (completed) return + if (!options.idleTimeout) return + if (idleTimer) clearTimeout(idleTimer) + idleTimer = setTimeout(() => invalidate(new Error(message)), options.idleTimeout) + if (typeof idleTimer === "object" && "unref" in idleTimer && typeof idleTimer.unref === "function") { + idleTimer.unref() + } + } + + async function onMessage(data: WebSocket.RawData, isBinary: boolean) { + if (completed) return + if (isBinary) { + invalidate(new Error("Unexpected binary WebSocket frame")) + return + } + + const text = data.toString() + const event = (() => { + try { + const parsed = JSON.parse(text) + return typeof parsed === "object" && parsed !== null ? parsed : undefined + } catch { + return undefined + } + })() + + if (event?.type === "error" && !emitted && options.onRetryableTerminal) { + cleanupSocket() + if (idleTimer) clearTimeout(idleTimer) + idleTimer = undefined + try { + const next = await options.onRetryableTerminal(event) + if (completed) { + if (next) terminateSocket(next) + return + } + if (next) { + attach(next) + return + } + } catch (error) { + invalidate(error instanceof Error ? error : new Error(String(error))) + return + } + } + + if (!emitted) options.onFirstEvent?.() + controller?.enqueue(encoder.encode(`${text.split(/\r?\n/).map((line) => `data: ${line}`).join("\n")}\n\n`)) + emitted = true + resetIdleTimeout("idle timeout waiting for websocket") + + if (!event) return + + if (event.type === "response.completed" || event.type === "response.done") { + completed = true + options.onComplete?.(event) + options.onTerminal?.(event) + closeCompleted() + return + } + + if (event.type === "response.failed" || event.type === "response.incomplete" || event.type === "error") { + completed = true + options.onTerminal?.(event) + closeCompleted() + } + } + + function onError(error: Error) { + invalidate(error) + } + + function onClose(code: number, reason: Buffer) { + if (completed) return + invalidate(closeError("WebSocket closed before response.completed", code, reason)) + } + + function onAbort() { + const error = abortError(options.signal) + if (completed) return + completed = true + cleanup() + terminateSocket() + options.onAbort?.(error) + controller?.error(error) + } + + function onCancel(reason: unknown) { + if (completed) return + completed = true + cleanup() + terminateSocket() + options.onAbort?.(cancelError(reason)) + } + + function attach(next: WebSocket) { + cleanupSocket() + socket = next + socket.on("message", onMessage) + socket.once("error", onError) + socket.once("close", onClose) + cleanupSocket = () => { + socket.off("message", onMessage) + socket.off("error", onError) + socket.off("close", onClose) + } + const { stream: _stream, background: _background, ...payload } = options.body + resetIdleTimeout("idle timeout sending websocket request") + socket.send(JSON.stringify({ type: "response.create", ...payload }), (error) => { + if (completed) return + resetIdleTimeout("idle timeout waiting for websocket") + if (error) invalidate(error) + }) + } + + return new Response( + new ReadableStream({ + start(next) { + controller = next + options.signal?.addEventListener("abort", onAbort, { once: true }) + + if (options.signal?.aborted) { + onAbort() + return + } + + attach(socket) + }, + cancel(reason) { + onCancel(reason) + }, + }), + { + status: 200, + headers: { "content-type": "text/event-stream" }, + }, + ) +} + +function cancelError(reason: unknown) { + if (isAbortError(reason)) return reason + if (reason instanceof Error) return reason + return new DOMException(typeof reason === "string" ? reason : "Aborted", "AbortError") +} + +function abortError(signal: AbortSignal | undefined) { + const reason = signal?.reason + if (isAbortError(reason)) return reason + return new DOMException(reason instanceof Error ? reason.message : "Aborted", "AbortError") +} + +function closeError(message: string, code: number, reason: Buffer) { + const details = [`code ${code}`] + if (code === 1009) details.push("message too big") + if (reason.length > 0) details.push(reason.toString()) + return new Error(`${message} (${details.join(": ")})`) +} + +export * as OpenAIWebSocket from "./ws" diff --git a/packages/opencode/test/effect/runtime-flags.test.ts b/packages/opencode/test/effect/runtime-flags.test.ts index b044d07f23..36579f48ea 100644 --- a/packages/opencode/test/effect/runtime-flags.test.ts +++ b/packages/opencode/test/effect/runtime-flags.test.ts @@ -63,6 +63,7 @@ describe("RuntimeFlags", () => { expect(flags.experimentalWorkspaces).toBe(true) expect(flags.experimentalIconDiscovery).toBe(true) expect(flags.experimentalNativeLlm).toBe(false) + expect(flags.experimentalWebSockets).toBe(false) expect(flags.client).toBe("desktop") }), ) @@ -91,6 +92,16 @@ describe("RuntimeFlags", () => { }), ) + it.effect("enables WebSockets via dedicated flag only", () => + Effect.gen(function* () { + const explicit = yield* readFlags.pipe(Effect.provide(fromConfig({ OPENCODE_EXPERIMENTAL_WEBSOCKETS: "true" }))) + const umbrella = yield* readFlags.pipe(Effect.provide(fromConfig({ OPENCODE_EXPERIMENTAL: "true" }))) + + expect(explicit.experimentalWebSockets).toBe(true) + expect(umbrella.experimentalWebSockets).toBe(false) + }), + ) + it.effect("layer accepts partial test overrides and fills defaults from Config definitions", () => Effect.gen(function* () { const flags = yield* readFlags.pipe( diff --git a/packages/opencode/test/plugin/codex.test.ts b/packages/opencode/test/plugin/codex.test.ts index 271bcde99b..a375fe4ee1 100644 --- a/packages/opencode/test/plugin/codex.test.ts +++ b/packages/opencode/test/plugin/codex.test.ts @@ -5,7 +5,7 @@ import { extractAccountIdFromClaims, extractAccountId, type IdTokenClaims, -} from "../../src/plugin/codex" +} from "../../src/plugin/openai/codex" function createTestJwt(payload: object): string { const header = Buffer.from(JSON.stringify({ alg: "none" })).toString("base64url") @@ -122,6 +122,24 @@ describe("plugin.codex", () => { }) }) + test("installs websocket transport only when experimental websockets are enabled", async () => { + const disabled = await CodexAuthPlugin({} as never) + const enabled = await CodexAuthPlugin({} as never, { experimentalWebSockets: true }) + + const disabledOptions = await disabled.auth!.loader!( + async () => ({ type: "api", key: "sk-test" }) as never, + {} as never, + ) + const enabledOptions = await enabled.auth!.loader!( + async () => ({ type: "api", key: "sk-test" }) as never, + {} as never, + ) + + expect(disabledOptions.fetch).toBeUndefined() + expect(enabledOptions.fetch).toBeFunction() + await enabled.dispose?.() + }) + test("deduplicates concurrent Codex token refreshes", async () => { let auth = { type: "oauth" as const, diff --git a/packages/opencode/test/plugin/openai-rollout.test.ts b/packages/opencode/test/plugin/openai-rollout.test.ts new file mode 100644 index 0000000000..1278e1cfbb --- /dev/null +++ b/packages/opencode/test/plugin/openai-rollout.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, test } from "bun:test" +import { experimentalWebSocketsEnabled } from "../../src/plugin" + +describe("plugin.openai.websocket rollout", () => { + test("enables websockets by default only on pre-release channels", () => { + expect(experimentalWebSocketsEnabled({ enabled: false, channel: "local" })).toBe(true) + expect(experimentalWebSocketsEnabled({ enabled: false, channel: "dev" })).toBe(true) + expect(experimentalWebSocketsEnabled({ enabled: false, channel: "beta" })).toBe(true) + expect(experimentalWebSocketsEnabled({ enabled: false, channel: "latest" })).toBe(false) + expect(experimentalWebSocketsEnabled({ enabled: false, channel: "prod" })).toBe(false) + }) + + test("allows releases to opt in through the experimental flag", () => { + expect(experimentalWebSocketsEnabled({ enabled: true, channel: "latest" })).toBe(true) + expect(experimentalWebSocketsEnabled({ enabled: true, channel: "prod" })).toBe(true) + }) +}) diff --git a/packages/opencode/test/plugin/openai-ws.test.ts b/packages/opencode/test/plugin/openai-ws.test.ts new file mode 100644 index 0000000000..65eaca655d --- /dev/null +++ b/packages/opencode/test/plugin/openai-ws.test.ts @@ -0,0 +1,619 @@ +import { describe, expect, test } from "bun:test" +import { EventEmitter } from "node:events" +import type { IncomingMessage } from "node:http" +import net, { type AddressInfo, type Socket } from "node:net" +import WebSocket, { WebSocketServer } from "ws" +import { OpenAIWebSocket } from "../../src/plugin/openai/ws" +import { OpenAIWebSocketPool, TITLE_HEADER } from "../../src/plugin/openai/ws-pool" + +describe("plugin.openai.ws", () => { + test("derives websocket URLs and sends auth plus protocol headers", async () => { + let headers: IncomingMessage["headers"] | undefined + await using server = await createWebSocketServer((_socket, request) => { + headers = request.headers + }) + + const socket = await OpenAIWebSocket.connectResponsesWebSocket({ + url: server.wsUrl, + headers: { authorization: "Bearer test", "content-length": "123" }, + }) + + expect(OpenAIWebSocket.toWebSocketUrl("http://example.com/v1/responses")).toBe("ws://example.com/v1/responses") + expect(OpenAIWebSocket.toWebSocketUrl("https://example.com/v1/responses")).toBe("wss://example.com/v1/responses") + expect(headers?.authorization).toBe("Bearer test") + expect(headers?.["openai-beta"]).toBe(OpenAIWebSocket.PROTOCOL_HEADER) + expect(headers?.["content-length"]).toBeUndefined() + socket.terminate() + }) + + test("enforces websocket connect timeout", async () => { + await using server = await createHangingTcpServer() + + await expect( + OpenAIWebSocket.connectResponsesWebSocket({ + url: server.wsUrl, + headers: {}, + timeout: 20, + }), + ).rejects.toThrow("WebSocket connect timed out") + }) + + test("enforces websocket send idle timeout", async () => { + const socket = new (class extends EventEmitter { + send(_data: string, _callback: (error?: Error) => void) {} + })() as unknown as WebSocket + const invalid: string[] = [] + const response = OpenAIWebSocket.streamResponsesWebSocket({ + socket, + body: { stream: true, input: "hi" }, + idleTimeout: 20, + onConnectionInvalid: (error) => invalid.push(error.message), + }) + + await expect(response.text()).rejects.toThrow("idle timeout sending websocket request") + expect(invalid).toEqual(["idle timeout sending websocket request"]) + }) + + test("streams websocket events as SSE and handles response.done", async () => { + let requestBody: unknown + await using server = await createWebSocketServer((socket) => { + socket.once("message", (data) => { + requestBody = JSON.parse(data.toString()) + socket.send(JSON.stringify({ type: "response.output_text.delta", delta: "hello" })) + socket.send(JSON.stringify({ type: "response.done", response: { id: "resp_123" } })) + socket.close(1000, "done") + }) + }) + + const socket = await OpenAIWebSocket.connectResponsesWebSocket({ + url: server.wsUrl, + headers: { authorization: "Bearer test", "content-length": "123" }, + }) + const completed: Record[] = [] + const response = OpenAIWebSocket.streamResponsesWebSocket({ + socket, + body: { stream: true, background: true, input: "hi" }, + onComplete: (event) => completed.push(event), + }) + + expect(await response.text()).toBe( + 'data: {"type":"response.output_text.delta","delta":"hello"}\n\ndata: {"type":"response.done","response":{"id":"resp_123"}}\n\ndata: [DONE]\n\n', + ) + expect(requestBody).toEqual({ type: "response.create", input: "hi" }) + expect(completed).toHaveLength(1) + expect(completed[0]?.type).toBe("response.done") + }) + + test("errors the SSE stream when the server closes before a terminal event", async () => { + const invalid: string[] = [] + await using server = await createWebSocketServer((socket) => { + socket.once("message", () => { + socket.close(1009, "payload too large") + }) + }) + + const socket = await OpenAIWebSocket.connectResponsesWebSocket({ url: server.wsUrl, headers: {} }) + const response = OpenAIWebSocket.streamResponsesWebSocket({ + socket, + body: { stream: true, input: "hi" }, + onConnectionInvalid: (error) => invalid.push(error.message), + }) + + await expect(response.text()).rejects.toThrow( + "WebSocket closed before response.completed (code 1009: message too big: payload too large)", + ) + expect(invalid).toEqual([ + "WebSocket closed before response.completed (code 1009: message too big: payload too large)", + ]) + }) + + test("rejects unexpected binary websocket frames", async () => { + const invalid: string[] = [] + await using server = await createWebSocketServer((socket) => { + socket.once("message", () => { + socket.send(Buffer.from("not json text")) + }) + }) + + const socket = await OpenAIWebSocket.connectResponsesWebSocket({ url: server.wsUrl, headers: {} }) + const response = OpenAIWebSocket.streamResponsesWebSocket({ + socket, + body: { stream: true, input: "hi" }, + onConnectionInvalid: (error) => invalid.push(error.message), + }) + + await expect(response.text()).rejects.toThrow("Unexpected binary WebSocket frame") + expect(invalid).toEqual(["Unexpected binary WebSocket frame"]) + }) +}) + +describe("plugin.openai.ws-pool", () => { + test("reuses one healthy websocket for sequential requests", async () => { + let connections = 0 + let messages = 0 + await using server = await createWebSocketServer((socket) => { + connections += 1 + socket.on("message", () => { + messages += 1 + socket.send(JSON.stringify({ type: "response.completed", response: { id: `resp_${messages}` } })) + }) + }) + const fetch = OpenAIWebSocketPool.createWebSocketFetch({ + url: server.url, + httpFetch: mockFetch(async () => new Response("http")), + }) + + const first = await fetch("https://api.openai.com/v1/responses", streamRequest()) + expect(await first.text()).toContain("data: [DONE]") + + const second = await fetch("https://api.openai.com/v1/responses", streamRequest()) + expect(await second.text()).toContain("data: [DONE]") + expect(connections).toBe(1) + expect(messages).toBe(2) + fetch.close() + }) + + test("rotates a socket that exceeds max connection age", async () => { + let connections = 0 + await using server = await createWebSocketServer((socket) => { + connections += 1 + socket.on("message", () => { + socket.send(JSON.stringify({ type: "response.completed", response: { id: `resp_${connections}` } })) + }) + }) + const fetch = OpenAIWebSocketPool.createWebSocketFetch({ + url: server.url, + httpFetch: mockFetch(async () => new Response("http")), + maxConnectionAge: 0, + }) + + const first = await fetch("https://api.openai.com/v1/responses", streamRequest()) + expect(await first.text()).toContain("data: [DONE]") + + const second = await fetch("https://api.openai.com/v1/responses", streamRequest()) + expect(await second.text()).toContain("data: [DONE]") + expect(connections).toBe(2) + fetch.close() + }) + + test("falls back to HTTP when websocket setup fails and keeps the fallback sticky", async () => { + const attempts: string[] = [] + await using server = await createRejectingWebSocketServer(() => attempts.push("websocket")) + const httpRequests: Headers[] = [] + const fetch = OpenAIWebSocketPool.createWebSocketFetch({ + url: server.url, + httpFetch: mockFetch(async (_input, init) => { + httpRequests.push(new Headers(init?.headers)) + return new Response("http") + }), + connectTimeout: 100, + }) + + const first = await fetch("https://api.openai.com/v1/responses", streamRequest({ [TITLE_HEADER]: "false" })) + const second = await fetch("https://api.openai.com/v1/responses", streamRequest({ [TITLE_HEADER]: "false" })) + + expect(await first.text()).toBe("http") + expect(await second.text()).toBe("http") + expect(attempts).toEqual(["websocket"]) + expect(httpRequests).toHaveLength(2) + expect(httpRequests[0]?.get(TITLE_HEADER)).toBeNull() + expect(httpRequests[1]?.get(TITLE_HEADER)).toBeNull() + fetch.close() + }) + + test("invalidates but does not reuse a socket after terminal failure frames", async () => { + let connections = 0 + await using server = await createWebSocketServer((socket) => { + connections += 1 + socket.once("message", () => { + socket.send(JSON.stringify({ type: connections === 1 ? "response.failed" : "response.completed" })) + }) + }) + const httpRequests: Headers[] = [] + const fetch = OpenAIWebSocketPool.createWebSocketFetch({ + url: server.url, + httpFetch: mockFetch(async (_input, init) => { + httpRequests.push(new Headers(init?.headers)) + return new Response("http") + }), + }) + + const first = await fetch("https://api.openai.com/v1/responses", streamRequest()) + expect(await first.text()).toContain('data: {"type":"response.failed"}') + + const second = await fetch("https://api.openai.com/v1/responses", streamRequest()) + expect(await second.text()).toContain('data: {"type":"response.completed"}') + expect(connections).toBe(2) + expect(httpRequests).toHaveLength(0) + fetch.close() + }) + + test("reconnects and replays after websocket connection limit errors", async () => { + let connections = 0 + let messages = 0 + await using server = await createWebSocketServer((socket) => { + connections += 1 + socket.once("message", () => { + messages += 1 + if (connections === 1) { + socket.send( + JSON.stringify({ + type: "error", + status: 400, + error: { + type: "invalid_request_error", + code: "websocket_connection_limit_reached", + message: "Responses websocket connection limit reached", + }, + }), + ) + return + } + socket.send(JSON.stringify({ type: "response.completed", response: { id: "resp_retry" } })) + }) + }) + const httpRequests: Headers[] = [] + const fetch = OpenAIWebSocketPool.createWebSocketFetch({ + url: server.url, + httpFetch: mockFetch(async (_input, init) => { + httpRequests.push(new Headers(init?.headers)) + return new Response("http") + }), + }) + + const response = await fetch("https://api.openai.com/v1/responses", streamRequest()) + const text = await response.text() + + expect(text).not.toContain("websocket_connection_limit_reached") + expect(text).toContain('data: {"type":"response.completed","response":{"id":"resp_retry"}}') + expect(text).toContain("data: [DONE]") + expect(connections).toBe(2) + expect(messages).toBe(2) + expect(httpRequests).toHaveLength(0) + fetch.close() + }) + + test("falls back to HTTP after websocket connection limit retries are exhausted", async () => { + let connections = 0 + await using server = await createWebSocketServer((socket) => { + connections += 1 + socket.once("message", () => { + socket.send( + JSON.stringify({ + type: "error", + status: 400, + error: { + type: "invalid_request_error", + code: "websocket_connection_limit_reached", + message: "Responses websocket connection limit reached", + }, + }), + ) + }) + }) + let httpRequests = 0 + const fetch = OpenAIWebSocketPool.createWebSocketFetch({ + url: server.url, + connectionLimitRetries: 2, + httpFetch: mockFetch(async () => { + httpRequests += 1 + return new Response("http") + }), + }) + + const first = await fetch("https://api.openai.com/v1/responses", streamRequest()) + const second = await fetch("https://api.openai.com/v1/responses", streamRequest()) + + expect(await first.text()).toBe("http") + expect(await second.text()).toBe("http") + expect(connections).toBe(3) + expect(httpRequests).toBe(2) + fetch.close() + }) + + test("replays over HTTP when websocket idles before its first event", async () => { + let connections = 0 + await using server = await createWebSocketServer((socket) => { + connections += 1 + socket.once("message", () => {}) + }) + const httpRequests: Headers[] = [] + const fetch = OpenAIWebSocketPool.createWebSocketFetch({ + url: server.url, + idleTimeout: 20, + httpFetch: mockFetch(async (_input, init) => { + httpRequests.push(new Headers(init?.headers)) + return new Response("http") + }), + }) + + const first = await fetch("https://api.openai.com/v1/responses", streamRequest()) + expect(await first.text()).toBe("http") + const second = await fetch("https://api.openai.com/v1/responses", streamRequest()) + + expect(await second.text()).toBe("http") + expect(connections).toBe(1) + expect(httpRequests).toHaveLength(2) + fetch.close() + }) + + test("does not replay over HTTP after a websocket event was emitted", async () => { + await using server = await createWebSocketServer((socket) => { + socket.once("message", () => { + socket.send(JSON.stringify({ type: "response.output_text.delta", delta: "started" })) + }) + }) + const httpRequests: Headers[] = [] + const fetch = OpenAIWebSocketPool.createWebSocketFetch({ + url: server.url, + idleTimeout: 20, + httpFetch: mockFetch(async (_input, init) => { + httpRequests.push(new Headers(init?.headers)) + return new Response("http") + }), + }) + + const first = await fetch("https://api.openai.com/v1/responses", streamRequest()) + await expect(first.text()).rejects.toThrow("idle timeout waiting for websocket") + const second = await fetch("https://api.openai.com/v1/responses", streamRequest()) + + expect(await second.text()).toBe("http") + expect(httpRequests).toHaveLength(1) + fetch.close() + }) + + test("falls back to HTTP for missing session and title requests", async () => { + const httpRequests: Headers[] = [] + const fetch = OpenAIWebSocketPool.createWebSocketFetch({ + httpFetch: mockFetch(async (_input, init) => { + httpRequests.push(new Headers(init?.headers)) + return new Response("http") + }), + }) + + const missingSession = await fetch("https://api.openai.com/v1/responses", { + method: "POST", + headers: { [TITLE_HEADER]: "false" }, + body: JSON.stringify({ stream: true }), + }) + const title = await fetch("https://api.openai.com/v1/responses", streamRequest({ [TITLE_HEADER]: "true" })) + + expect(await missingSession.text()).toBe("http") + expect(await title.text()).toBe("http") + expect(httpRequests).toHaveLength(2) + expect(httpRequests[0]?.get(TITLE_HEADER)).toBeNull() + expect(httpRequests[1]?.get(TITLE_HEADER)).toBeNull() + fetch.close() + }) + + test("falls back to HTTP while a websocket lane is busy", async () => { + let connections = 0 + await using server = await createWebSocketServer((socket) => { + connections += 1 + socket.once("message", () => { + socket.send(JSON.stringify({ type: "response.output_text.delta", delta: "started" })) + }) + }) + const abort = new AbortController() + const httpRequests: Headers[] = [] + const fetch = OpenAIWebSocketPool.createWebSocketFetch({ + url: server.url, + httpFetch: mockFetch(async (_input, init) => { + httpRequests.push(new Headers(init?.headers)) + return new Response("http") + }), + }) + + const first = await fetch("https://api.openai.com/v1/responses", streamRequest({}, abort.signal)) + const firstText = first.text() + await waitFor(() => connections === 1, "websocket did not connect") + const second = await fetch("https://api.openai.com/v1/responses", streamRequest()) + + expect(await second.text()).toBe("http") + expect(httpRequests).toHaveLength(1) + expect(connections).toBe(1) + abort.abort(new Error("stop")) + await expect(firstText).rejects.toThrow("stop") + fetch.close() + }) + + test("reserves a websocket lane while its socket is connecting", async () => { + await using server = await createHangingTcpServer() + let httpRequests = 0 + const fetch = OpenAIWebSocketPool.createWebSocketFetch({ + url: server.url, + connectTimeout: 20, + httpFetch: mockFetch(async () => { + httpRequests += 1 + return new Response("http") + }), + }) + + const first = fetch("https://api.openai.com/v1/responses", streamRequest()) + await waitFor(() => server.connections() === 1, "first websocket did not begin connecting") + const second = fetch("https://api.openai.com/v1/responses", streamRequest()) + + expect(await (await second).text()).toBe("http") + expect(await (await first).text()).toBe("http") + expect(server.connections()).toBe(1) + expect(httpRequests).toBe(2) + fetch.close() + }) + + test("replays over HTTP after an unexpected close before the first event", async () => { + let connections = 0 + await using server = await createWebSocketServer((socket) => { + connections += 1 + socket.once("message", () => { + socket.close(1001, "server shutdown") + }) + }) + const httpRequests: Headers[] = [] + const fetch = OpenAIWebSocketPool.createWebSocketFetch({ + url: server.url, + httpFetch: mockFetch(async (_input, init) => { + httpRequests.push(new Headers(init?.headers)) + return new Response("http") + }), + }) + + const first = await fetch("https://api.openai.com/v1/responses", streamRequest()) + expect(await first.text()).toBe("http") + const second = await fetch("https://api.openai.com/v1/responses", streamRequest()) + + expect(await second.text()).toBe("http") + expect(connections).toBe(1) + expect(httpRequests).toHaveLength(2) + fetch.close() + }) + + test("does not keep HTTP fallback active after aborting a websocket response", async () => { + let connections = 0 + await using server = await createWebSocketServer((socket) => { + connections += 1 + socket.once("message", () => { + if (connections === 1) { + socket.send(JSON.stringify({ type: "response.output_text.delta", delta: "started" })) + return + } + socket.send(JSON.stringify({ type: "response.completed", response: { id: "resp_456" } })) + }) + }) + const httpRequests: Headers[] = [] + const abort = new AbortController() + const fetch = OpenAIWebSocketPool.createWebSocketFetch({ + url: server.url, + httpFetch: mockFetch(async (_input, init) => { + httpRequests.push(new Headers(init?.headers)) + return new Response("http") + }), + }) + + const first = await fetch("https://api.openai.com/v1/responses", streamRequest({}, abort.signal)) + const firstText = first.text() + await waitFor(() => connections === 1, "first websocket did not connect") + abort.abort(new Error("stop")) + await expect(firstText).rejects.toThrow("stop") + + const second = await fetch("https://api.openai.com/v1/responses", streamRequest()) + + expect(await second.text()).toContain("data: [DONE]") + expect(connections).toBe(2) + expect(httpRequests).toHaveLength(0) + fetch.close() + }) + + test("releases the websocket lane when the response body is cancelled", async () => { + let connections = 0 + await using server = await createWebSocketServer((socket) => { + connections += 1 + socket.once("message", () => { + if (connections === 1) { + socket.send(JSON.stringify({ type: "response.output_text.delta", delta: "started" })) + return + } + socket.send(JSON.stringify({ type: "response.completed", response: { id: "resp_after_cancel" } })) + }) + }) + const httpRequests: Headers[] = [] + const fetch = OpenAIWebSocketPool.createWebSocketFetch({ + url: server.url, + httpFetch: mockFetch(async (_input, init) => { + httpRequests.push(new Headers(init?.headers)) + return new Response("http") + }), + }) + + const first = await fetch("https://api.openai.com/v1/responses", streamRequest()) + await waitFor(() => connections === 1, "first websocket did not connect") + await first.body!.cancel("stop") + + const second = await fetch("https://api.openai.com/v1/responses", streamRequest()) + + expect(await second.text()).toContain("data: [DONE]") + expect(connections).toBe(2) + expect(httpRequests).toHaveLength(0) + fetch.close() + }) +}) + +function streamRequest(headers?: Record, signal?: AbortSignal): RequestInit { + return { + method: "POST", + headers: { + "session-id": "session-1", + authorization: "Bearer test", + ...headers, + }, + body: JSON.stringify({ stream: true, input: "hi" }), + signal, + } +} + +function mockFetch( + fn: (input: Parameters[0], init: Parameters[1]) => ReturnType, +): typeof globalThis.fetch { + return Object.assign(fn, { preconnect: globalThis.fetch.preconnect }) +} + +async function createWebSocketServer(onConnection: (socket: WebSocket, request: IncomingMessage) => void) { + const server = new WebSocketServer({ host: "127.0.0.1", port: 0 }) + server.on("connection", onConnection) + await new Promise((resolve) => server.once("listening", resolve)) + return websocketServerHandle(server) +} + +async function createHangingTcpServer() { + const sockets = new Set() + let connections = 0 + const server = net.createServer((socket) => { + connections += 1 + sockets.add(socket) + socket.on("close", () => sockets.delete(socket)) + }) + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)) + const address = server.address() as AddressInfo + return { + url: `http://127.0.0.1:${address.port}/v1/responses`, + wsUrl: `ws://127.0.0.1:${address.port}/v1/responses`, + connections: () => connections, + async [Symbol.asyncDispose]() { + for (const socket of sockets) socket.destroy() + server.close() + }, + } +} + +async function createRejectingWebSocketServer(onAttempt: () => void) { + const server = new WebSocketServer({ + host: "127.0.0.1", + port: 0, + verifyClient(_info, callback) { + onAttempt() + callback(false, 401, "denied") + }, + }) + await new Promise((resolve) => server.once("listening", resolve)) + return websocketServerHandle(server) +} + +function websocketServerHandle(server: WebSocketServer) { + const address = server.address() as AddressInfo + const url = `http://127.0.0.1:${address.port}/v1/responses` + return { + url, + wsUrl: url.replace(/^http/, "ws"), + async [Symbol.asyncDispose]() { + for (const socket of server.clients) socket.terminate() + server.close() + }, + } +} + +async function waitFor(predicate: () => boolean, message: string) { + const started = Date.now() + while (!predicate()) { + if (Date.now() - started > 1_000) throw new Error(message) + await new Promise((resolve) => setTimeout(resolve, 1)) + } +} From 2f1547ebafe5852027949ace288752aad937e25d Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Wed, 27 May 2026 20:05:11 +0000 Subject: [PATCH 003/113] chore: generate --- packages/opencode/src/plugin/openai/ws-pool.ts | 12 ++++++++++-- packages/opencode/src/plugin/openai/ws.ts | 9 ++++++++- packages/opencode/test/plugin/openai-ws.test.ts | 5 ++++- 3 files changed, 22 insertions(+), 4 deletions(-) diff --git a/packages/opencode/src/plugin/openai/ws-pool.ts b/packages/opencode/src/plugin/openai/ws-pool.ts index ae18eb5407..0af4c57c91 100644 --- a/packages/opencode/src/plugin/openai/ws-pool.ts +++ b/packages/opencode/src/plugin/openai/ws-pool.ts @@ -162,7 +162,11 @@ export function createWebSocketFetch(options?: CreateWebSocketFetchOptions) { } entry.fallback = true - log.warn("websocket setup failed", { key, error: error instanceof Error ? error.message : String(error), fallback: "http" }) + log.warn("websocket setup failed", { + key, + error: error instanceof Error ? error.message : String(error), + fallback: "http", + }) invalidate(entry) return httpFetch(input, httpInit) } @@ -202,7 +206,11 @@ async function socket( maxConnectionAge: number, signal?: AbortSignal | null, ) { - if (entry.socket?.readyState === WebSocket.OPEN && entry.connectedAt && Date.now() - entry.connectedAt < maxConnectionAge) { + if ( + entry.socket?.readyState === WebSocket.OPEN && + entry.connectedAt && + Date.now() - entry.connectedAt < maxConnectionAge + ) { return entry.socket } diff --git a/packages/opencode/src/plugin/openai/ws.ts b/packages/opencode/src/plugin/openai/ws.ts index 9d434d89f4..7176ab2104 100644 --- a/packages/opencode/src/plugin/openai/ws.ts +++ b/packages/opencode/src/plugin/openai/ws.ts @@ -201,7 +201,14 @@ export function streamResponsesWebSocket(options: StreamResponsesWebSocketOption } if (!emitted) options.onFirstEvent?.() - controller?.enqueue(encoder.encode(`${text.split(/\r?\n/).map((line) => `data: ${line}`).join("\n")}\n\n`)) + controller?.enqueue( + encoder.encode( + `${text + .split(/\r?\n/) + .map((line) => `data: ${line}`) + .join("\n")}\n\n`, + ), + ) emitted = true resetIdleTimeout("idle timeout waiting for websocket") diff --git a/packages/opencode/test/plugin/openai-ws.test.ts b/packages/opencode/test/plugin/openai-ws.test.ts index 65eaca655d..1fe26cfcb4 100644 --- a/packages/opencode/test/plugin/openai-ws.test.ts +++ b/packages/opencode/test/plugin/openai-ws.test.ts @@ -551,7 +551,10 @@ function streamRequest(headers?: Record, signal?: AbortSignal): } function mockFetch( - fn: (input: Parameters[0], init: Parameters[1]) => ReturnType, + fn: ( + input: Parameters[0], + init: Parameters[1], + ) => ReturnType, ): typeof globalThis.fetch { return Object.assign(fn, { preconnect: globalThis.fetch.preconnect }) } From a1a401dc9b69370ef0b508befe259077394839b6 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Wed, 27 May 2026 20:22:29 +0000 Subject: [PATCH 004/113] chore: update nix node_modules hashes --- nix/hashes.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/nix/hashes.json b/nix/hashes.json index 163c77be6a..95408b6b27 100644 --- a/nix/hashes.json +++ b/nix/hashes.json @@ -1,8 +1,8 @@ { "nodeModules": { - "x86_64-linux": "sha256-FT8N4SBP7OmVu73OwNyPJvBoxFd2+IXzNnFubB8y6J0=", - "aarch64-linux": "sha256-rGst01voY1/qr/fIMEgfL2zep/+W2RhjJh5pKs+/P3s=", - "aarch64-darwin": "sha256-+oTdR3CuR88Ra3QQsDn7ixNwJf0YjOxySTSiRsPPUOg=", - "x86_64-darwin": "sha256-3ZkB83KWtp6N7u9xhzmCouv27UuisXYvr2SBap4k++E=" + "x86_64-linux": "sha256-wZJ7bkQWYprzEEXnekLTMGoOeKiFAkfOUAqQSDlPKUY=", + "aarch64-linux": "sha256-Y01rLf2LwZoX53MjcnlUsjJ2v8BAB//WCrYPOQDD08g=", + "aarch64-darwin": "sha256-lbqBTxA2JdZc/Yt9gOh2lQAS4XX8+sEhaPG4nOIlVT0=", + "x86_64-darwin": "sha256-PMcLaqYtYS5apt9Q24Gk3eip9hcaCwhlWBEepCRfMSE=" } } From 60b9ba4db73794c5d1f65b13d239b7ef79af4af4 Mon Sep 17 00:00:00 2001 From: Sebastian Date: Thu, 28 May 2026 00:01:32 +0200 Subject: [PATCH 005/113] upgrade opentui to 0.2.16 (#29463) --- bun.lock | 30 +++++++++++++++--------------- package.json | 6 +++--- packages/plugin/package.json | 6 +++--- 3 files changed, 21 insertions(+), 21 deletions(-) diff --git a/bun.lock b/bun.lock index aee242b042..ec1240b64e 100644 --- a/bun.lock +++ b/bun.lock @@ -577,9 +577,9 @@ "typescript": "catalog:", }, "peerDependencies": { - "@opentui/core": ">=0.2.15", - "@opentui/keymap": ">=0.2.15", - "@opentui/solid": ">=0.2.15", + "@opentui/core": ">=0.2.16", + "@opentui/keymap": ">=0.2.16", + "@opentui/solid": ">=0.2.16", }, "optionalPeers": [ "@opentui/core", @@ -826,9 +826,9 @@ "@npmcli/arborist": "9.4.0", "@octokit/rest": "22.0.0", "@openauthjs/openauth": "0.0.0-20250322224806", - "@opentui/core": "0.2.15", - "@opentui/keymap": "0.2.15", - "@opentui/solid": "0.2.15", + "@opentui/core": "0.2.16", + "@opentui/keymap": "0.2.16", + "@opentui/solid": "0.2.16", "@pierre/diffs": "1.1.0-beta.18", "@playwright/test": "1.59.1", "@sentry/solid": "10.36.0", @@ -1718,23 +1718,23 @@ "@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.40.0", "", {}, "sha512-cifvXDhcqMwwTlTK04GBNeIe7yyo28Mfby85QXFe1Yk8nmi36Ab/5UQwptOx84SsoGNRg+EVSjwzfSZMy6pmlw=="], - "@opentui/core": ["@opentui/core@0.2.15", "", { "dependencies": { "bun-ffi-structs": "0.2.2", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.2.15", "@opentui/core-darwin-x64": "0.2.15", "@opentui/core-linux-arm64": "0.2.15", "@opentui/core-linux-x64": "0.2.15", "@opentui/core-win32-arm64": "0.2.15", "@opentui/core-win32-x64": "0.2.15" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-YGHttdZWScMcSvtYgZkLR6VhUO1OoUiQzwYjZgIusf5eCkPLD8PapH+PTMVqAiX16CHO6JxfMlkHv5qDiHAccQ=="], + "@opentui/core": ["@opentui/core@0.2.16", "", { "dependencies": { "bun-ffi-structs": "0.2.2", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.2.16", "@opentui/core-darwin-x64": "0.2.16", "@opentui/core-linux-arm64": "0.2.16", "@opentui/core-linux-x64": "0.2.16", "@opentui/core-win32-arm64": "0.2.16", "@opentui/core-win32-x64": "0.2.16" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-4vWN15Zc3nsXJlOiHhhpqkBXD+wrNFKxCPtiTiillZYDRre+XsZogVTOOGUDwaBIC23OSxq7imezLmmtShVBEA=="], - "@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.2.15", "", { "os": "darwin", "cpu": "arm64" }, "sha512-s25f9GmZd6wxNM5ExRmwwnLT+NLCKxnTWuO9aObOlqsXfLMGHQZrb6YwgAn/PSTua98KmH7GJCVWdPgZ/P+0RQ=="], + "@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.2.16", "", { "os": "darwin", "cpu": "arm64" }, "sha512-aFb2Yp+oqDu3h6VCWi7xpQ9yjpKSQcROzGGfHgqC6Nd3U+uiLfPJBkmiI87iK0opCggCFj5TkKI004050DmGjg=="], - "@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.2.15", "", { "os": "darwin", "cpu": "x64" }, "sha512-GyaipN+nOcEr8rcTO2mqKTGmOBk0C300I69fLtubD3BadHcMI1DVNlQrcf/J1mkQEuMYbmBTi/1hT1ybWGr2Mw=="], + "@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.2.16", "", { "os": "darwin", "cpu": "x64" }, "sha512-KimiHE0j7EsTB5P8doW0lr1eH5iZKLPKWQO+tmy1VcdYr/TzqhdHSvGuJXrZvfTFi9/rV57Eq0d7964Ri9O0vQ=="], - "@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.2.15", "", { "os": "linux", "cpu": "arm64" }, "sha512-h+uyufselGT4afKMP8Lg4yUl5Kp+DJBlhu3XpWXhphE5Pnq5+f0uGBr4P+34CNcWxMsDnvagSQLFRCS4rGrOWA=="], + "@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.2.16", "", { "os": "linux", "cpu": "arm64" }, "sha512-4fCwRCfTtUgS/5QcSEkSuBjgQymSOUWXgrXG2ycrf3Swi0QhKDA/pVjwLrUJ6eF+/8mQyQSEV72T8MxMO3M2qg=="], - "@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.2.15", "", { "os": "linux", "cpu": "x64" }, "sha512-jx+NImPq4wSp3Apfe7tlixiEJNnRyECTRJRWhGF6ZJz4PwFfgK2UHZKYR0DZHbV8nYawoDNQPJDXEWcoZShnMg=="], + "@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.2.16", "", { "os": "linux", "cpu": "x64" }, "sha512-KgQBGjiucw4e7gM+R8qOzHWBFhjCY1IfCrGjW3Wzxv2hKUlL+mPhelaeJwnEqtNxMUdVTYjlwlu3IHxslXMJWQ=="], - "@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.2.15", "", { "os": "win32", "cpu": "arm64" }, "sha512-2SQQLvf3sgmToxrNika9AdcccKrjPJEn5jW6sSv0oEixNBzUzW41vSZZG4LM/V3lL8eg0LoYDnRZeKLB4gwSqQ=="], + "@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.2.16", "", { "os": "win32", "cpu": "arm64" }, "sha512-C6WqEI3VkXatXraMgSFXZjEXq0pzURGjRpFAJZYmuVDmpqE57o7E80Np2UkdZ6m5kpJDt4mRyu3krc/P825iNQ=="], - "@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.2.15", "", { "os": "win32", "cpu": "x64" }, "sha512-SVMVgnC7LVEm+yVZKdmmhRBj/xAT94PanT+UCcHxaCWK+OLmv/AX+ohHq2m0odup6iXcEqj+7mAltO9fgJLFIg=="], + "@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.2.16", "", { "os": "win32", "cpu": "x64" }, "sha512-kCX3CMTns6DMCFDNTDV4sjmBKyA/iEvzaVhl/jYi4JRIVT2zcy1lo+lhXT5mPgYHmJZu8Uye6j3Zi3c7Z2Me5A=="], - "@opentui/keymap": ["@opentui/keymap@0.2.15", "", { "dependencies": { "@opentui/core": "0.2.15" }, "peerDependencies": { "@opentui/react": "0.2.15", "@opentui/solid": "0.2.15", "react": ">=19.2.0", "solid-js": "1.9.12" }, "optionalPeers": ["@opentui/react", "@opentui/solid", "react", "solid-js"] }, "sha512-wqQp6y7P2jZZJiOMwupxjGryuSWCs+njjglwW/xny9J17gomBmUvTIcIIWNG0Jv+EGO9ScBzCScGlwBHFhHyYw=="], + "@opentui/keymap": ["@opentui/keymap@0.2.16", "", { "dependencies": { "@opentui/core": "0.2.16" }, "peerDependencies": { "@opentui/react": "0.2.16", "@opentui/solid": "0.2.16", "react": ">=19.2.0", "solid-js": "1.9.12" }, "optionalPeers": ["@opentui/react", "@opentui/solid", "react", "solid-js"] }, "sha512-YBLQfNLbU2kx49bjEY9rrFoNlvIoi5qNJfRcOt6frvnR3C6MLl0/8hZY+vMQ2PEQWeEiNejFnl1lQw+z4Nk2FQ=="], - "@opentui/solid": ["@opentui/solid@0.2.15", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.2.15", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.12", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.12" } }, "sha512-CViepAjsCWXwrLndMt+qlLo7cooVX7DXwSJHNizw7mfrRJtOPzSYJZCIk1vF4IJTWffCHygoYMe3uSeKvzAcbw=="], + "@opentui/solid": ["@opentui/solid@0.2.16", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.2.16", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.12", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.12" } }, "sha512-2Q+v1PPpXXr+sALi9Aj6I5Jvo7xDfbmstYjRLL7lW3Hghh9i7ONQKpt/gyDDRbhSsYrhxKYTNenF9OxgoXkTHg=="], "@oslojs/asn1": ["@oslojs/asn1@1.0.0", "", { "dependencies": { "@oslojs/binary": "1.0.0" } }, "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA=="], diff --git a/package.json b/package.json index 86db5cd125..5fecf69bab 100644 --- a/package.json +++ b/package.json @@ -38,9 +38,9 @@ "@types/cross-spawn": "6.0.6", "@octokit/rest": "22.0.0", "@hono/zod-validator": "0.4.2", - "@opentui/core": "0.2.15", - "@opentui/keymap": "0.2.15", - "@opentui/solid": "0.2.15", + "@opentui/core": "0.2.16", + "@opentui/keymap": "0.2.16", + "@opentui/solid": "0.2.16", "ulid": "3.0.1", "@kobalte/core": "0.13.11", "@types/luxon": "3.7.1", diff --git a/packages/plugin/package.json b/packages/plugin/package.json index d002e0fcd9..1bfea92721 100644 --- a/packages/plugin/package.json +++ b/packages/plugin/package.json @@ -22,9 +22,9 @@ "zod": "catalog:" }, "peerDependencies": { - "@opentui/core": ">=0.2.15", - "@opentui/keymap": ">=0.2.15", - "@opentui/solid": ">=0.2.15" + "@opentui/core": ">=0.2.16", + "@opentui/keymap": ">=0.2.16", + "@opentui/solid": ">=0.2.16" }, "peerDependenciesMeta": { "@opentui/core": { From 49e8d324ddaa7a31ed21af2c1108c50505afce29 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Wed, 27 May 2026 22:16:44 +0000 Subject: [PATCH 006/113] chore: update nix node_modules hashes --- nix/hashes.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/nix/hashes.json b/nix/hashes.json index 95408b6b27..10d2815500 100644 --- a/nix/hashes.json +++ b/nix/hashes.json @@ -1,8 +1,8 @@ { "nodeModules": { - "x86_64-linux": "sha256-wZJ7bkQWYprzEEXnekLTMGoOeKiFAkfOUAqQSDlPKUY=", - "aarch64-linux": "sha256-Y01rLf2LwZoX53MjcnlUsjJ2v8BAB//WCrYPOQDD08g=", - "aarch64-darwin": "sha256-lbqBTxA2JdZc/Yt9gOh2lQAS4XX8+sEhaPG4nOIlVT0=", - "x86_64-darwin": "sha256-PMcLaqYtYS5apt9Q24Gk3eip9hcaCwhlWBEepCRfMSE=" + "x86_64-linux": "sha256-KFVkxs0jtN1EXvwuxFMOlWg6BDr8L6K/wJZ/N73p8cI=", + "aarch64-linux": "sha256-7OLglRBBt5prwb9e0I0iMTXi3MrDfDI+8xcDrxtq/JI=", + "aarch64-darwin": "sha256-Q7iE6xJyomDnvqUgsHh+FlCnJFI6vK0zJSfI9BBYitQ=", + "x86_64-darwin": "sha256-9lVsq2Bw74Vgphg6GSo/HxCtDXUyabxcS/vE1Um/Hfw=" } } From ec26d7845005d5db3166b9d181f802b04e99d864 Mon Sep 17 00:00:00 2001 From: DS <78942835+Tarquinen@users.noreply.github.com> Date: Wed, 27 May 2026 19:46:30 -0400 Subject: [PATCH 007/113] fix(openai): support websocket custom base URLs (#29636) --- packages/opencode/src/plugin/openai/codex.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/opencode/src/plugin/openai/codex.ts b/packages/opencode/src/plugin/openai/codex.ts index b6edf173c8..7ed48d0879 100644 --- a/packages/opencode/src/plugin/openai/codex.ts +++ b/packages/opencode/src/plugin/openai/codex.ts @@ -506,7 +506,7 @@ export async function CodexAuthPlugin(input: PluginInput, options: CodexAuthPlug ...init, headers, } - if (websocketFetch && parsed.pathname.includes("/v1/responses")) return websocketFetch(url, requestInit) + if (websocketFetch && parsed.pathname.endsWith("/responses")) return websocketFetch(url, requestInit) return fetch(url, OpenAIWebSocketPool.withoutInternalHeaders(requestInit)) }, } From 7bafbb309a7ca28c82f3dcdf7b06b0beb8fd6b64 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Wed, 27 May 2026 21:54:36 -0400 Subject: [PATCH 008/113] fix(opencode): forward remote workspace request bodies (#29458) --- .../instance/httpapi/middleware/proxy.ts | 10 ++-------- .../server/httpapi-workspace-routing.test.ts | 18 +++++++++++++++--- .../test/server/httpapi-workspace.test.ts | 10 ++++++++++ .../test/server/workspace-proxy.test.ts | 16 ++++++++++++++++ 4 files changed, 43 insertions(+), 11 deletions(-) diff --git a/packages/opencode/src/server/routes/instance/httpapi/middleware/proxy.ts b/packages/opencode/src/server/routes/instance/httpapi/middleware/proxy.ts index 230f5b105b..e5362f8cbe 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/middleware/proxy.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/middleware/proxy.ts @@ -4,17 +4,11 @@ import { HttpBody, HttpClient, HttpClientRequest, HttpServerRequest, HttpServerR import * as Socket from "effect/unstable/socket/Socket" import { WebSocketTracker } from "../websocket-tracker" -function webSource(request: HttpServerRequest.HttpServerRequest): Request | undefined { - return request.source instanceof Request ? request.source : undefined -} - function requestBody(request: HttpServerRequest.HttpServerRequest) { if (request.method === "GET" || request.method === "HEAD") return HttpBody.empty + if (request.source instanceof Request && request.source.body === null) return HttpBody.empty const len = request.headers["content-length"] - return HttpBody.raw(webSource(request)?.body ?? null, { - contentType: request.headers["content-type"], - contentLength: len ? Number(len) : undefined, - }) + return HttpBody.stream(request.stream, request.headers["content-type"], len ? Number(len) : undefined) } export function websocket( diff --git a/packages/opencode/test/server/httpapi-workspace-routing.test.ts b/packages/opencode/test/server/httpapi-workspace-routing.test.ts index bc7074e23c..50a400674a 100644 --- a/packages/opencode/test/server/httpapi-workspace-routing.test.ts +++ b/packages/opencode/test/server/httpapi-workspace-routing.test.ts @@ -1,6 +1,6 @@ import { NodeHttpServer, NodeServices } from "@effect/platform-node" import { describe, expect } from "bun:test" -import { Context, Effect, Layer, Queue, Ref, Schema } from "effect" +import { Context, Effect, Layer, Queue, Ref, Schema, Stream } from "effect" import { FetchHttpClient, HttpClient, @@ -64,6 +64,7 @@ type ProxiedRequest = { url: string method: string headers: Record + body: string } type TestHandler = ( @@ -181,7 +182,12 @@ const startRemoteWorkspaceHttpServer = ( // everything else is the request being proxied by the middleware. const sync = syncResponse(request) if (sync) return yield* sync - return yield* handler({ url: request.url, method: request.method, headers: request.headers }) + return yield* handler({ + url: request.url, + method: request.method, + headers: request.headers, + body: yield* request.text, + }) }), ) @@ -285,13 +291,18 @@ describe("HttpApi workspace routing middleware", () => { // should make the middleware call HttpApiProxy.http instead. yield* serveProbe + const body = '{"title":"Remote workspace request"}' const response = yield* HttpClientRequest.patch(`/probe?workspace=${workspace.id}&keep=yes`).pipe( HttpClientRequest.setHeaders({ - "content-type": "application/json", "x-opencode-directory": "/secret/path", "x-opencode-workspace": "internal", }), + HttpClientRequest.bodyStream( + Stream.make(new TextEncoder().encode('{"title":"Remote '), new TextEncoder().encode('workspace request"}')), + { contentType: "application/json" }, + ), HttpClient.execute, + Effect.timeout("2 seconds"), ) expect(response.status).toBe(201) @@ -304,6 +315,7 @@ describe("HttpApi workspace routing middleware", () => { expect(forwardedURL?.searchParams.get("keep")).toBe("yes") expect(forwardedURL?.searchParams.get("workspace")).toBeNull() expect(forwarded?.method).toBe("PATCH") + expect(forwarded?.body).toBe(body) expect(forwarded?.headers["content-type"]).toBe("application/json") expect(forwarded?.headers["x-target-auth"]).toBe("secret") expect(forwarded?.headers["x-opencode-directory"]).toBeUndefined() diff --git a/packages/opencode/test/server/httpapi-workspace.test.ts b/packages/opencode/test/server/httpapi-workspace.test.ts index e1c6e5701d..35d6cb313e 100644 --- a/packages/opencode/test/server/httpapi-workspace.test.ts +++ b/packages/opencode/test/server/httpapi-workspace.test.ts @@ -479,6 +479,16 @@ describe("workspace HttpApi", () => { method: "POST", }), ]) + + const aborted = yield* request(`http://localhost/session/${session.id}/abort`, dir, { method: "POST" }) + expect(aborted.status).toBe(200) + expect(proxied.filter((item) => new URL(item.url).pathname === `/base/session/${session.id}/abort`)).toEqual([ + expect.objectContaining({ + url: `http://127.0.0.1:${remote.port}/base/session/${session.id}/abort`, + method: "POST", + body: "", + }), + ]) } finally { void remote.stop(true) yield* request(WorkspacePaths.remove.replace(":id", workspace.id), dir, { method: "DELETE" }) diff --git a/packages/opencode/test/server/workspace-proxy.test.ts b/packages/opencode/test/server/workspace-proxy.test.ts index 732f2560a2..869570f62d 100644 --- a/packages/opencode/test/server/workspace-proxy.test.ts +++ b/packages/opencode/test/server/workspace-proxy.test.ts @@ -112,6 +112,22 @@ describe("HttpApi workspace proxy", () => { }), ) + it.live("proxies bodyless Web mutation requests as an empty body", () => + Effect.gen(function* () { + const url = yield* listenServer( + Effect.fnUntraced(function* (req: HttpServerRequest.HttpServerRequest) { + return yield* HttpServerResponse.json({ method: req.method, body: yield* req.text }) + }), + ) + const request = HttpServerRequest.fromWeb(new Request("http://localhost/session/abc/abort", { method: "POST" })) + const httpClient = yield* HttpClient.HttpClient + const response = yield* HttpApiProxy.http(httpClient, `${url}/session/abc/abort`, undefined, request) + + expect(response.status).toBe(200) + expect(yield* HttpServerResponse.toClientResponse(response).json).toEqual({ method: "POST", body: "" }) + }), + ) + it.live("strips opencode-internal headers and merges extra headers", () => Effect.gen(function* () { let forwarded: Record = {} From 69910f361ce4e5d1650c79d733d33e6d8f3a7b74 Mon Sep 17 00:00:00 2001 From: James Long Date: Wed, 27 May 2026 22:58:33 -0400 Subject: [PATCH 009/113] fix(server): use persisted session directory for existing-session routes (#29640) --- .../httpapi/middleware/workspace-routing.ts | 13 +++--- .../test/server/httpapi-session.test.ts | 42 ++++++++++++++++++- 2 files changed, 49 insertions(+), 6 deletions(-) diff --git a/packages/opencode/src/server/routes/instance/httpapi/middleware/workspace-routing.ts b/packages/opencode/src/server/routes/instance/httpapi/middleware/workspace-routing.ts index 68ba95a7c4..66fe1e12e1 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/middleware/workspace-routing.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/middleware/workspace-routing.ts @@ -159,14 +159,14 @@ function planWorkspaceRequest( function planRequest( request: HttpServerRequest.HttpServerRequest, - sessionWorkspaceID?: WorkspaceID, + session?: Session.Info, ): Effect.Effect { return Effect.gen(function* () { const url = requestURL(request) const envWorkspaceID = configuredWorkspaceID() const workspaceID = url.pathname.startsWith("/api/") - ? selectedV2WorkspaceID(url, sessionWorkspaceID) - : selectedWorkspaceID(url, sessionWorkspaceID) + ? selectedV2WorkspaceID(url, session?.workspaceID) + : selectedWorkspaceID(url, session?.workspaceID) if (workspaceID === InvalidWorkspaceID) return RequestPlan.InvalidWorkspace() const workspace = yield* resolveWorkspace(workspaceID, envWorkspaceID) @@ -178,7 +178,10 @@ function planRequest( return yield* planWorkspaceRequest(request, url, workspace) } - return RequestPlan.Local({ directory: defaultDirectory(request, url), workspaceID: envWorkspaceID ?? workspaceID }) + return RequestPlan.Local({ + directory: session?.directory || defaultDirectory(request, url), + workspaceID: envWorkspaceID ?? workspaceID, + }) }) } @@ -226,7 +229,7 @@ function routeHttpApiWorkspace( Effect.catchDefect(() => Effect.succeed(undefined)), ) : undefined - const plan = yield* planRequest(request, session?.workspaceID) + const plan = yield* planRequest(request, session) return yield* routeWorkspace(client, effect, plan) }) } diff --git a/packages/opencode/test/server/httpapi-session.test.ts b/packages/opencode/test/server/httpapi-session.test.ts index 5c8f3fb24e..eb740125af 100644 --- a/packages/opencode/test/server/httpapi-session.test.ts +++ b/packages/opencode/test/server/httpapi-session.test.ts @@ -2,6 +2,7 @@ import { afterEach, describe, expect } from "bun:test" import { mkdir } from "node:fs/promises" import path from "node:path" import { Cause, Effect, Exit, Layer } from "effect" +import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { Flag } from "@opencode-ai/core/flag/flag" import { registerAdapter } from "../../src/control-plane/adapters" import type { WorkspaceAdapter } from "../../src/control-plane/types" @@ -27,7 +28,9 @@ import * as DateTime from "effect/DateTime" import * as Log from "@opencode-ai/core/util/log" import { eq } from "drizzle-orm" import { resetDatabase } from "../fixture/db" -import { disposeAllInstances, TestInstance } from "../fixture/fixture" +import { disposeAllInstances, provideInstanceEffect, TestInstance, tmpdirScoped } from "../fixture/fixture" +import { TestLLMServer } from "../lib/llm-server" +import { testProviderConfig } from "../lib/test-provider" import { testEffect } from "../lib/effect" void Log.init({ print: false }) @@ -368,6 +371,43 @@ describe("session HttpApi", () => { { git: true, config: { formatter: false, lsp: false } }, ) + it.live("uses the persisted session directory for prompt requests", () => + Effect.gen(function* () { + const llm = yield* TestLLMServer + yield* llm.text("ok", { usage: { input: 1, output: 1 } }) + + const config = testProviderConfig(llm.url) + const sessionDirectory = yield* tmpdirScoped({ git: true, config }) + const requestDirectory = yield* tmpdirScoped({ git: true, config }) + const session = yield* createSession({ title: "directory regression" }).pipe(provideInstanceEffect(sessionDirectory)) + + const response = yield* request( + `${pathFor(SessionPaths.prompt, { sessionID: session.id })}?directory=${encodeURIComponent(requestDirectory)}`, + { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + agent: "build", + model: { providerID: "test", modelID: "test-model" }, + parts: [{ type: "text", text: "which directory?" }], + }), + }, + ) + + expect(response.status).toBe(200) + yield* responseJson(response) + + const messages = yield* Session.use + .messages({ sessionID: session.id }) + .pipe(provideInstanceEffect(sessionDirectory), Effect.orDie) + const assistant = messages.find((message) => message.info.role === "assistant") + expect(assistant?.info.role === "assistant" ? assistant.info.path : undefined).toEqual({ + cwd: sessionDirectory, + root: sessionDirectory, + }) + }).pipe(Effect.provide(TestLLMServer.layer), Effect.provide(CrossSpawnSpawner.defaultLayer)), + ) + it.instance( "returns v2 public request errors for cursor and workspace query failures", () => From 0c9cad86ec2bbbbd106f7595cc0d285f491e7937 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Thu, 28 May 2026 02:59:53 +0000 Subject: [PATCH 010/113] chore: generate --- packages/opencode/test/server/httpapi-session.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/opencode/test/server/httpapi-session.test.ts b/packages/opencode/test/server/httpapi-session.test.ts index eb740125af..db06a7a77c 100644 --- a/packages/opencode/test/server/httpapi-session.test.ts +++ b/packages/opencode/test/server/httpapi-session.test.ts @@ -379,7 +379,9 @@ describe("session HttpApi", () => { const config = testProviderConfig(llm.url) const sessionDirectory = yield* tmpdirScoped({ git: true, config }) const requestDirectory = yield* tmpdirScoped({ git: true, config }) - const session = yield* createSession({ title: "directory regression" }).pipe(provideInstanceEffect(sessionDirectory)) + const session = yield* createSession({ title: "directory regression" }).pipe( + provideInstanceEffect(sessionDirectory), + ) const response = yield* request( `${pathFor(SessionPaths.prompt, { sessionID: session.id })}?directory=${encodeURIComponent(requestDirectory)}`, From aa553dea94979a0fb46ff862cbba025e3b41d8ad Mon Sep 17 00:00:00 2001 From: Shoubhit Dash Date: Thu, 28 May 2026 09:11:49 +0530 Subject: [PATCH 011/113] fix(acp): handle acp-next permission events (#29656) --- packages/opencode/src/acp-next/event.ts | 12 +- packages/opencode/src/acp-next/permission.ts | 149 +++++++++++ packages/opencode/src/acp-next/service.ts | 3 +- packages/opencode/src/acp-next/tool.ts | 2 +- .../opencode/test/acp-next/permission.test.ts | 232 ++++++++++++++++++ 5 files changed, 394 insertions(+), 4 deletions(-) create mode 100644 packages/opencode/src/acp-next/permission.ts create mode 100644 packages/opencode/test/acp-next/permission.test.ts diff --git a/packages/opencode/src/acp-next/event.ts b/packages/opencode/src/acp-next/event.ts index 25aa5c08ef..9df7d8142d 100644 --- a/packages/opencode/src/acp-next/event.ts +++ b/packages/opencode/src/acp-next/event.ts @@ -11,6 +11,7 @@ import type { } from "@opencode-ai/sdk/v2" import { Effect } from "effect" import { ACPNextSession } from "./session" +import { ACPNextPermission } from "./permission" import { duplicateRunningToolUpdate, errorToolUpdate, @@ -22,7 +23,8 @@ import { const log = Log.create({ service: "acp-next-event" }) -type Connection = Pick +type Connection = Pick & + Partial> type GlobalEventEnvelope = { payload?: Event } @@ -40,6 +42,7 @@ export class Subscription { private readonly abort = new AbortController() private readonly shellSnapshots = new Map() private readonly toolStarts = new Set() + private readonly permission: ACPNextPermission.Handler private started = false constructor( @@ -48,7 +51,9 @@ export class Subscription { connection: Connection session: ACPNextSession.Interface }, - ) {} + ) { + this.permission = new ACPNextPermission.Handler(input) + } start() { if (this.started) return @@ -65,6 +70,9 @@ export class Subscription { async handle(event: Event) { switch (event.type) { + case "permission.asked": + this.permission.handle(event) + return case "message.part.updated": return this.handlePartUpdated(event) case "message.part.delta": diff --git a/packages/opencode/src/acp-next/permission.ts b/packages/opencode/src/acp-next/permission.ts new file mode 100644 index 0000000000..747c1d8633 --- /dev/null +++ b/packages/opencode/src/acp-next/permission.ts @@ -0,0 +1,149 @@ +import type { + AgentSideConnection, + PermissionOption, + RequestPermissionResponse, +} from "@agentclientprotocol/sdk" +import * as Log from "@opencode-ai/core/util/log" +import type { Event, OpencodeClient } from "@opencode-ai/sdk/v2" +import { applyPatch } from "diff" +import { exists, readText } from "@/util/filesystem" +import type { ACPNextSession } from "./session" +import { toLocations, toToolKind, type ToolInput } from "./tool" +import { Effect } from "effect" + +const log = Log.create({ service: "acp-next-permission" }) + +type PermissionEvent = Extract +type Reply = "once" | "always" | "reject" +type Connection = Partial> + +const permissionOptions: PermissionOption[] = [ + { optionId: "once", kind: "allow_once", name: "Allow once" }, + { optionId: "always", kind: "allow_always", name: "Always allow" }, + { optionId: "reject", kind: "reject_once", name: "Reject" }, +] + +export class Handler { + private readonly queues = new Map>() + + constructor( + private readonly input: { + sdk: OpencodeClient + connection: Connection + session: ACPNextSession.Interface + }, + ) {} + + handle(event: PermissionEvent) { + const permission = event.properties + const previous = this.queues.get(permission.sessionID) ?? Promise.resolve() + const next = previous + .then(() => this.process(event)) + .catch((error: unknown) => { + log.error("failed to handle permission", { error, permissionID: permission.id }) + }) + .finally(() => { + if (this.queues.get(permission.sessionID) === next) { + this.queues.delete(permission.sessionID) + } + }) + this.queues.set(permission.sessionID, next) + } + + private async process(event: PermissionEvent) { + const permission = event.properties + const session = await Effect.runPromise(this.input.session.tryGet(permission.sessionID)) + if (!session) return + + if (!this.input.connection.requestPermission) { + log.error("ACP connection cannot request permission", { + permissionID: permission.id, + sessionID: permission.sessionID, + }) + await this.reply(permission.id, "reject", session.cwd) + return + } + + const result = await this.input.connection + .requestPermission({ + sessionId: permission.sessionID, + toolCall: { + toolCallId: permission.tool?.callID ?? permission.id, + status: "pending", + title: permission.permission, + rawInput: permission.metadata, + kind: toToolKind(permission.permission), + locations: toLocations(permission.permission, permission.metadata), + }, + options: permissionOptions, + }) + .catch(async (error: unknown) => { + log.error("failed to request permission from ACP", { + error, + permissionID: permission.id, + sessionID: permission.sessionID, + }) + await this.reply(permission.id, "reject", session.cwd) + return undefined + }) + + if (!result) return + + const reply = selectedReply(result) + if (reply !== "once" && reply !== "always") { + await this.reply(permission.id, "reject", session.cwd) + return + } + + if (permission.permission === "edit") { + await this.writeProposedEdit(session.id, permission.metadata).catch((error: unknown) => { + log.error("failed to write proposed edit through ACP", { + error, + permissionID: permission.id, + sessionID: permission.sessionID, + }) + }) + } + + await this.reply(permission.id, reply, session.cwd) + } + + private async reply(requestID: string, reply: Reply, directory: string) { + await this.input.sdk.permission.reply({ + requestID, + reply, + directory, + }) + } + + private async writeProposedEdit(sessionId: string, metadata: ToolInput) { + const filepath = stringValue(metadata.filepath) + const diff = stringValue(metadata.diff) + if (!filepath || !diff || !this.input.connection.writeTextFile) return + + const content = (await exists(filepath)) ? await readText(filepath) : "" + const next = applyPatch(content, diff) + if (next === false) { + log.error("Failed to apply unified diff (context mismatch)") + return + } + + void this.input.connection.writeTextFile({ + sessionId, + path: filepath, + content: next, + }) + } +} + +function selectedReply(result: RequestPermissionResponse): Reply { + if (result.outcome.outcome !== "selected") return "reject" + if (result.outcome.optionId === "once" || result.outcome.optionId === "always") return result.outcome.optionId + return "reject" +} + +function stringValue(value: unknown) { + return typeof value === "string" ? value : undefined +} + +export * as ACPNextPermission from "./permission" diff --git a/packages/opencode/src/acp-next/service.ts b/packages/opencode/src/acp-next/service.ts index be368da623..76adf85e6e 100644 --- a/packages/opencode/src/acp-next/service.ts +++ b/packages/opencode/src/acp-next/service.ts @@ -69,7 +69,8 @@ export class Service extends Context.Service()("@opencode/AC export function make(input: { sdk: OpencodeClient - connection?: Pick + connection?: Pick & + Partial> directory?: Directory.Interface session?: ACPNextSession.Interface eventSubscription?: (subscription: ACPNextEvent.Subscription) => void diff --git a/packages/opencode/src/acp-next/tool.ts b/packages/opencode/src/acp-next/tool.ts index 288d24e31e..08cf7ff845 100644 --- a/packages/opencode/src/acp-next/tool.ts +++ b/packages/opencode/src/acp-next/tool.ts @@ -74,7 +74,7 @@ export function toLocations(toolName: string, input: ToolInput): ToolCallLocatio case "read": case "edit": case "write": - return locationFrom(input.filePath) + return locationFrom(input.filePath ?? input.filepath) case "grep": case "glob": diff --git a/packages/opencode/test/acp-next/permission.test.ts b/packages/opencode/test/acp-next/permission.test.ts new file mode 100644 index 0000000000..5f7e439b9f --- /dev/null +++ b/packages/opencode/test/acp-next/permission.test.ts @@ -0,0 +1,232 @@ +import { describe, expect, it } from "bun:test" +import type { + AgentSideConnection, + RequestPermissionRequest, + RequestPermissionResponse, + SessionUpdate, +} from "@agentclientprotocol/sdk" +import type { Event, OpencodeClient } from "@opencode-ai/sdk/v2" +import { Effect, ManagedRuntime } from "effect" +import { ACPNextEvent } from "@/acp-next/event" +import { ACPNextSession } from "@/acp-next/session" + +type PermissionEvent = Extract +type PermissionReplyParams = Parameters[0] +type SessionUpdateParams = Parameters[0] + +const pollUntil = async ( + check: () => boolean | Promise, + message: string, + opts?: { timeoutMs?: number; intervalMs?: number }, +) => { + const started = Date.now() + while (true) { + if (await check()) return + if (Date.now() - started > (opts?.timeoutMs ?? 2000)) throw new Error(message) + await new Promise((resolve) => setTimeout(resolve, opts?.intervalMs ?? 5)) + } +} + +function makeSessionService() { + return ManagedRuntime.make(ACPNextSession.defaultLayer).runSync( + ACPNextSession.Service.use((service) => Effect.succeed(service)), + ) +} + +function createHarness( + requestPermission: (params: RequestPermissionRequest) => Promise = () => + Promise.resolve({ outcome: { outcome: "selected", optionId: "once" } }), +) { + const replies: PermissionReplyParams[] = [] + const requests: RequestPermissionRequest[] = [] + const updates: SessionUpdateParams[] = [] + const session = makeSessionService() + const sdk = { + permission: { + reply: (params: PermissionReplyParams) => { + replies.push(params) + return Promise.resolve({ data: true }) + }, + }, + session: { + message: () => Promise.resolve({ data: undefined }), + }, + } as unknown as OpencodeClient + const connection = { + requestPermission: (params: RequestPermissionRequest) => { + requests.push(params) + return requestPermission(params) + }, + sessionUpdate: (params: SessionUpdateParams) => { + updates.push(params) + return Promise.resolve() + }, + } satisfies Pick + const subscription = new ACPNextEvent.Subscription({ sdk, connection, session }) + + return { connection, replies, requests, sdk, session, subscription, updates } +} + +async function createSession(session: ACPNextSession.Interface, sessionId: string, cwd = "/workspace") { + await Effect.runPromise(session.create({ id: sessionId, cwd })) +} + +async function createKnownTextPart(session: ACPNextSession.Interface, sessionId: string, messageId: string, partId: string) { + await Effect.runPromise( + session.recordPartMetadata({ + sessionId, + messageId, + partId, + partType: "text", + role: "assistant", + }), + ) +} + +function permissionAsked( + sessionID: string, + id: string, + input: { + permission?: string + metadata?: Record + tool?: { messageID: string; callID: string } + } = {}, +) { + return { + id: `evt_${id}`, + type: "permission.asked", + properties: { + id, + sessionID, + permission: input.permission ?? "bash", + patterns: ["*"], + metadata: input.metadata ?? { command: "printf hello" }, + always: [], + ...(input.tool ? { tool: input.tool } : {}), + }, + } as PermissionEvent +} + +function textDelta(sessionID: string, messageID: string, partID: string, delta: string) { + return { + id: `evt_${sessionID}_${messageID}_${partID}`, + type: "message.part.delta", + properties: { + sessionID, + messageID, + partID, + field: "text", + delta, + }, + } as Event +} + +function textFromUpdates(updates: SessionUpdateParams[], sessionId: string) { + return updates + .filter((item) => item.sessionId === sessionId) + .map((item) => item.update) + .filter((update): update is Extract => { + return update.sessionUpdate === "agent_message_chunk" + }) + .map((update) => (update.content.type === "text" ? update.content.text : "")) + .join("") +} + +describe("acp-next permissions", () => { + it("sends requestPermission and replies with the selected outcome", async () => { + const harness = createHarness() + await createSession(harness.session, "ses_a") + + harness.subscription.handle(permissionAsked("ses_a", "perm_1", { tool: { messageID: "msg_1", callID: "call_1" } })) + + await pollUntil(() => harness.replies.length === 1, "permission was never replied") + + expect(harness.requests[0]).toMatchObject({ + sessionId: "ses_a", + toolCall: { + toolCallId: "call_1", + status: "pending", + title: "bash", + rawInput: { command: "printf hello" }, + kind: "execute", + locations: [], + }, + options: [ + { optionId: "once", kind: "allow_once", name: "Allow once" }, + { optionId: "always", kind: "allow_always", name: "Always allow" }, + { optionId: "reject", kind: "reject_once", name: "Reject" }, + ], + }) + expect(harness.replies).toEqual([{ requestID: "perm_1", reply: "once", directory: "/workspace" }]) + }) + + it("rejects non-selected outcomes", async () => { + const harness = createHarness(() => Promise.resolve({ outcome: { outcome: "cancelled" } })) + await createSession(harness.session, "ses_a") + + harness.subscription.handle(permissionAsked("ses_a", "perm_cancelled")) + + await pollUntil(() => harness.replies.length === 1, "cancelled permission was never replied") + + expect(harness.replies[0]).toMatchObject({ requestID: "perm_cancelled", reply: "reject" }) + }) + + it("rejects when requestPermission fails", async () => { + const harness = createHarness(() => Promise.reject(new Error("client permission UI failed"))) + await createSession(harness.session, "ses_a") + + harness.subscription.handle(permissionAsked("ses_a", "perm_failed")) + + await pollUntil(() => harness.replies.length === 1, "failed permission was never rejected") + + expect(harness.replies[0]).toMatchObject({ requestID: "perm_failed", reply: "reject" }) + }) + + it("does not let a blocked session A permission block session B message updates", async () => { + let releasePermission: (() => void) | undefined + const blocked = new Promise((resolve) => { + releasePermission = () => resolve({ outcome: { outcome: "selected", optionId: "once" } }) + }) + const harness = createHarness(() => blocked) + await createSession(harness.session, "ses_a") + await createSession(harness.session, "ses_b") + await createKnownTextPart(harness.session, "ses_b", "msg_b", "part_b") + + harness.subscription.handle(permissionAsked("ses_a", "perm_blocked")) + await pollUntil(() => harness.requests.length === 1, "blocked permission was never requested") + + await harness.subscription.handle(textDelta("ses_b", "msg_b", "part_b", "session_b_message")) + + expect(textFromUpdates(harness.updates, "ses_b")).toBe("session_b_message") + expect(harness.replies).toHaveLength(0) + + releasePermission?.() + await pollUntil(() => harness.replies.length === 1, "blocked permission was never replied after release") + }) + + it("serializes permission requests per session", async () => { + let releaseFirst: (() => void) | undefined + const first = new Promise((resolve) => { + releaseFirst = () => resolve({ outcome: { outcome: "selected", optionId: "once" } }) + }) + const harness = createHarness(() => + harness.requests.length === 1 ? first : Promise.resolve({ outcome: { outcome: "selected", optionId: "always" } }), + ) + await createSession(harness.session, "ses_a") + + harness.subscription.handle(permissionAsked("ses_a", "perm_1")) + harness.subscription.handle(permissionAsked("ses_a", "perm_2")) + + await pollUntil(() => harness.requests.length === 1, "first permission was never requested") + expect(harness.requests.map((request) => request.toolCall.toolCallId)).toEqual(["perm_1"]) + + releaseFirst?.() + await pollUntil(() => harness.requests.length === 2, "second permission was not requested after first resolved") + await pollUntil(() => harness.replies.length === 2, "serialized permissions were not both replied") + + expect(harness.replies.map((reply) => [reply.requestID, reply.reply])).toEqual([ + ["perm_1", "once"], + ["perm_2", "always"], + ]) + }) +}) From 82da3320ba3aeb2b79f6a8785c397c768a6b4efc Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Thu, 28 May 2026 03:43:10 +0000 Subject: [PATCH 012/113] chore: generate --- packages/opencode/src/acp-next/permission.ts | 6 +----- packages/opencode/test/acp-next/permission.test.ts | 7 ++++++- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/packages/opencode/src/acp-next/permission.ts b/packages/opencode/src/acp-next/permission.ts index 747c1d8633..73ebb38b1f 100644 --- a/packages/opencode/src/acp-next/permission.ts +++ b/packages/opencode/src/acp-next/permission.ts @@ -1,8 +1,4 @@ -import type { - AgentSideConnection, - PermissionOption, - RequestPermissionResponse, -} from "@agentclientprotocol/sdk" +import type { AgentSideConnection, PermissionOption, RequestPermissionResponse } from "@agentclientprotocol/sdk" import * as Log from "@opencode-ai/core/util/log" import type { Event, OpencodeClient } from "@opencode-ai/sdk/v2" import { applyPatch } from "diff" diff --git a/packages/opencode/test/acp-next/permission.test.ts b/packages/opencode/test/acp-next/permission.test.ts index 5f7e439b9f..ec9712d72d 100644 --- a/packages/opencode/test/acp-next/permission.test.ts +++ b/packages/opencode/test/acp-next/permission.test.ts @@ -71,7 +71,12 @@ async function createSession(session: ACPNextSession.Interface, sessionId: strin await Effect.runPromise(session.create({ id: sessionId, cwd })) } -async function createKnownTextPart(session: ACPNextSession.Interface, sessionId: string, messageId: string, partId: string) { +async function createKnownTextPart( + session: ACPNextSession.Interface, + sessionId: string, + messageId: string, + partId: string, +) { await Effect.runPromise( session.recordPartMetadata({ sessionId, From e5cf3ad3c2c775ba86deb9cef2d6dddf74ad8752 Mon Sep 17 00:00:00 2001 From: Frank Date: Thu, 28 May 2026 00:50:15 -0400 Subject: [PATCH 013/113] zen: fix db issue --- .../app/src/routes/workspace/[id]/keys/key-section.tsx | 5 ----- packages/console/app/src/routes/zen/util/handler.ts | 5 ----- 2 files changed, 10 deletions(-) diff --git a/packages/console/app/src/routes/workspace/[id]/keys/key-section.tsx b/packages/console/app/src/routes/workspace/[id]/keys/key-section.tsx index cb273a422e..09c67f230b 100644 --- a/packages/console/app/src/routes/workspace/[id]/keys/key-section.tsx +++ b/packages/console/app/src/routes/workspace/[id]/keys/key-section.tsx @@ -4,7 +4,6 @@ import { IconCopy, IconCheck } from "~/component/icon" import { Key } from "@opencode-ai/console-core/key.js" import { withActor } from "~/context/auth.withActor" import { createStore } from "solid-js/store" -import { formatDateUTC, formatDateForTable } from "../../common" import styles from "./key-section.module.css" import { Actor } from "@opencode-ai/console-core/actor.js" import { useI18n } from "~/context/i18n" @@ -124,7 +123,6 @@ export function KeySection() { {i18n.t("workspace.keys.table.name")} {i18n.t("workspace.keys.table.key")} {i18n.t("workspace.keys.table.createdBy")} - {i18n.t("workspace.keys.table.lastUsed")} @@ -156,9 +154,6 @@ export function KeySection() { {key.email} - - {key.timeUsed ? formatDateForTable(key.timeUsed) : "-"} -
diff --git a/packages/console/app/src/routes/zen/util/handler.ts b/packages/console/app/src/routes/zen/util/handler.ts index dac287551f..6ddc967097 100644 --- a/packages/console/app/src/routes/zen/util/handler.ts +++ b/packages/console/app/src/routes/zen/util/handler.ts @@ -506,7 +506,6 @@ export async function handler( if (retry.retryCount !== MAX_FAILOVER_RETRIES) { let topPriority = Infinity const providers = allProviders - .filter((provider) => !provider.disabled) .filter((provider) => provider.weight !== 0) .filter((provider) => !retry.excludeProviders.includes(provider.id)) .filter((provider) => { @@ -990,10 +989,6 @@ export async function handler( return undefined })(), }), - db - .update(KeyTable) - .set({ timeUsed: sql`now()` }) - .where(and(eq(KeyTable.workspaceID, authInfo.workspaceID), eq(KeyTable.id, authInfo.apiKeyId))), ...(() => { if (billingSource === "subscription") { const plan = authInfo.billing.subscription!.plan From 16234a0baf5a48c9c15321a60850a79172e997cb Mon Sep 17 00:00:00 2001 From: Shoubhit Dash Date: Thu, 28 May 2026 10:43:59 +0530 Subject: [PATCH 014/113] feat(acp): implement acp-next prompt (#29664) --- packages/opencode/src/acp-next/service.ts | 209 ++++++++++++- .../test/acp-next/service-session.test.ts | 274 +++++++++++++++++- .../cli/acp-next/acp-next-process.test.ts | 15 +- 3 files changed, 489 insertions(+), 9 deletions(-) diff --git a/packages/opencode/src/acp-next/service.ts b/packages/opencode/src/acp-next/service.ts index 76adf85e6e..2373df3ac5 100644 --- a/packages/opencode/src/acp-next/service.ts +++ b/packages/opencode/src/acp-next/service.ts @@ -35,9 +35,11 @@ import type { Message, OpencodeClient, SessionMessageResponse } from "@opencode- import { Context, Effect, Layer, ManagedRuntime } from "effect" import * as ACPNextError from "./error" import { buildConfigOptions, parseModelSelection } from "./config-option" +import { promptContentToParts } from "./content" import { Directory } from "./directory" import { ACPNextEvent } from "./event" import { ACPNextSession } from "./session" +import { UsageService } from "./usage" import { ModelID, ProviderID } from "@/provider/schema" import { Provider } from "@/provider/provider" import type { Command } from "@/command" @@ -46,6 +48,8 @@ export const AuthMethodID = "opencode-login" const log = Log.create({ service: "acp-next-service" }) export type Error = ACPNextError.Error +type ServiceConnection = Pick & + Partial> export type Interface = { readonly initialize: (input: InitializeRequest) => Effect.Effect @@ -69,10 +73,10 @@ export class Service extends Context.Service()("@opencode/AC export function make(input: { sdk: OpencodeClient - connection?: Pick & - Partial> + connection?: ServiceConnection directory?: Directory.Interface session?: ACPNextSession.Interface + usage?: UsageService.Interface eventSubscription?: (subscription: ACPNextEvent.Subscription) => void }): Interface { const session = input.session ?? makeSessionService() @@ -444,8 +448,81 @@ export function make(input: { setSessionConfigOption, setSessionMode, setSessionModel, - prompt: Effect.fn("ACPNext.prompt")(function* (_input: PromptRequest) { - return yield* new ACPNextError.UnsupportedOperationError({ method: "session/prompt" }) + prompt: Effect.fn("ACPNext.prompt")(function* (params: PromptRequest) { + const current = yield* session.get(params.sessionId) + const snapshot = yield* directorySnapshot(current.cwd) + const selected = current.model ?? selectDefaultModel(snapshot) + if (!current.model) { + yield* session.setModel(params.sessionId, selected) + } + const variant = current.variant ?? selectVariant(snapshot, selected) + const modeId = current.modeId ?? (snapshot.availableModes.length > 0 ? snapshot.defaultModeID : undefined) + const parts = promptContentToParts(params.prompt) + const command = detectSlashCommand(parts) + + if (!command) { + const response = yield* request( + () => + input.sdk.session.prompt( + { + sessionID: current.id, + model: { + providerID: selected.providerID, + modelID: selected.modelID, + }, + ...(variant ? { variant } : {}), + parts, + ...(modeId ? { agent: modeId } : {}), + directory: current.cwd, + }, + { throwOnError: true }, + ), + "session", + ) + yield* sendUsageUpdate(input.usage, input.sdk, input.connection, current.id, current.cwd) + return promptResponse(response.info, params.messageId) + } + + const known = snapshot.availableCommands.find((item) => item.name === command.name) + if (known) { + const response = yield* request( + () => + input.sdk.session.command( + { + sessionID: current.id, + command: known.name, + arguments: command.args, + model: `${selected.providerID}/${selected.modelID}`, + ...(variant ? { variant } : {}), + ...(modeId ? { agent: modeId } : {}), + directory: current.cwd, + }, + { throwOnError: true }, + ), + "session", + ) + yield* sendUsageUpdate(input.usage, input.sdk, input.connection, current.id, current.cwd) + return promptResponse(response.info, params.messageId) + } + + if (command.name === "compact") { + yield* request( + () => + input.sdk.session.summarize( + { + sessionID: current.id, + directory: current.cwd, + providerID: selected.providerID, + modelID: selected.modelID, + }, + { throwOnError: true }, + ), + "session", + ) + } + + yield* sendUsageUpdate(input.usage, input.sdk, input.connection, current.id, current.cwd) + return promptResponse(undefined, params.messageId) }), cancel: Effect.fn("ACPNext.cancel")(function* (_input: CancelNotification) { return yield* new ACPNextError.UnsupportedOperationError({ method: "session/cancel" }) @@ -474,6 +551,91 @@ function makeDirectoryService(sdk: OpencodeClient) { ).runSync(Directory.Service.use((service) => Effect.succeed(service))) } +function makeUsageService(sdk: OpencodeClient) { + const limits = new Map>() + const contextLimit: UsageService.Interface["contextLimit"] = Effect.fn("ACPNext.promptUsage.contextLimit")(function* ( + params, + ) { + const key = `${params.directory}\u0000${params.providerID}\u0000${params.modelID}` + const current = limits.get(key) + if (current) return yield* Effect.promise(() => current) + + const next = sdk.config + .providers({ directory: params.directory }, { throwOnError: true }) + .then((response) => { + const providers = Object.fromEntries( + (response.data?.providers ?? []).map((provider) => [provider.id, provider]), + ) as Record + return UsageService.findContextLimit(providers, params.providerID, params.modelID) + }) + .catch((error: unknown) => { + log.error("failed to get providers for usage context limit", { error }) + return undefined + }) + limits.set(key, next) + return yield* Effect.promise(() => next) + }) + + const sendUpdate: UsageService.Interface["sendUpdate"] = Effect.fn("ACPNext.promptUsage.sendUpdate")(function* ( + params, + ) { + const messages = yield* request( + () => + sdk.session.messages( + { + sessionID: params.sessionID, + directory: params.directory, + }, + { throwOnError: true }, + ), + "session", + ).pipe( + Effect.map((messages) => messages as readonly UsageService.SessionMessage[]), + Effect.catch((error) => + Effect.sync(() => { + log.error("failed to fetch messages for usage update", { error }) + return undefined + }), + ), + ) + if (!messages) return + + const message = UsageService.latestAssistantMessage(messages) + if (!message?.providerID || !message.modelID) return + + const size = yield* contextLimit({ + directory: params.directory, + providerID: ProviderID.make(message.providerID), + modelID: ModelID.make(message.modelID), + }) + if (!size) return + + yield* Effect.promise(() => + params.connection + .sessionUpdate({ + sessionId: params.sessionID, + update: { + sessionUpdate: "usage_update", + used: message.tokens.input + message.tokens.cache.read, + size, + cost: { amount: UsageService.totalSessionCost(messages), currency: "USD" }, + }, + }) + .catch((error) => { + log.error("failed to send usage update", { error }) + }), + ) + }) + + return UsageService.Service.of({ + buildUsage: UsageService.buildUsage, + latestAssistantMessage: UsageService.latestAssistantMessage, + totalSessionCost: UsageService.totalSessionCost, + contextLimit, + sendUpdate, + }) +} + function replayMessages(subscription: ACPNextEvent.Subscription | undefined, messages: SessionMessageResponse[]) { if (!subscription) return Effect.void return Effect.promise(async () => { @@ -506,6 +668,8 @@ type MessageInfo = { readonly agent?: Message["agent"] } +type AssistantInfo = UsageService.AssistantTokenCost | undefined + function request(fn: () => Promise>, service?: string) { return Effect.tryPromise({ try: async () => { @@ -620,6 +784,43 @@ function selectDefaultModel(snapshot: Directory.Snapshot) { return { providerID: "unknown" as ProviderID, modelID: "unknown" as ModelID } } +function detectSlashCommand(parts: ReturnType) { + const text = parts + .filter((part): part is Extract<(typeof parts)[number], { type: "text" }> => part.type === "text") + .map((part) => part.text) + .join("") + .trim() + if (!text.startsWith("/")) return + + const [name, ...rest] = text.slice(1).split(/\s+/) + if (!name) return + return { name, args: rest.join(" ").trim() } +} + +function promptResponse(info: AssistantInfo, messageId: string | null | undefined): PromptResponse { + return { + stopReason: "end_turn", + ...(info ? { usage: UsageService.buildUsage(info) } : {}), + ...(messageId ? { userMessageId: messageId } : {}), + _meta: {}, + } +} + +function sendUsageUpdate( + usage: UsageService.Interface | undefined, + sdk: OpencodeClient, + connection: ServiceConnection | undefined, + sessionID: string, + directory: string, +) { + if (!connection) return Effect.void + return (usage ?? makeUsageService(sdk)).sendUpdate({ + connection, + sessionID, + directory, + }) +} + function selectVariant(snapshot: Directory.Snapshot, model: Directory.DefaultModel) { const variants = Directory.variants(snapshot, model) if (!variants) return diff --git a/packages/opencode/test/acp-next/service-session.test.ts b/packages/opencode/test/acp-next/service-session.test.ts index 96d4066f7e..4c0563b63e 100644 --- a/packages/opencode/test/acp-next/service-session.test.ts +++ b/packages/opencode/test/acp-next/service-session.test.ts @@ -4,6 +4,7 @@ import type { ForkSessionResponse, LoadSessionResponse, NewSessionResponse, + SessionNotification, ResumeSessionResponse, SessionConfigOption, SessionConfigSelectOption, @@ -14,6 +15,7 @@ import { Effect, ManagedRuntime } from "effect" import * as ACPNextService from "@/acp-next/service" import * as ACPNextError from "@/acp-next/error" import { ACPNextSession } from "@/acp-next/session" +import { UsageService } from "@/acp-next/usage" import { ModelID, ProviderID } from "@/provider/schema" import type { Provider } from "@/provider/provider" @@ -141,10 +143,14 @@ const provider: Provider.Info = { describe("ACP next service sessions", () => { const makeService = (messages: readonly { info: unknown; parts: readonly unknown[] }[] = []) => { - const updates: unknown[] = [] + const updates: SessionNotification[] = [] const mcpAdds: string[] = [] const aborts: string[] = [] const forks: string[] = [] + const prompts: unknown[] = [] + const commands: unknown[] = [] + const summarizes: unknown[] = [] + const usageUpdates: string[] = [] const sessions = Array.from({ length: 102 }, (_, index) => ({ id: `ses_${index + 1}`, directory: index % 2 === 0 ? "/workspace" : "/other", @@ -184,6 +190,36 @@ describe("ACP next service sessions", () => { data: input.directory ? sessions.filter((session) => session.directory === input.directory) : sessions, }), messages: () => Promise.resolve({ data: messages }), + prompt: (input: unknown) => { + prompts.push(input) + return Promise.resolve({ + data: { + info: assistantInfo({ + input: 100, + output: 40, + reasoning: 7, + cache: { read: 11, write: 13 }, + }), + }, + }) + }, + command: (input: unknown) => { + commands.push(input) + return Promise.resolve({ + data: { + info: assistantInfo({ + input: 3, + output: 4, + reasoning: 0, + cache: { read: 0, write: 0 }, + }), + }, + }) + }, + summarize: (input: unknown) => { + summarizes.push(input) + return Promise.resolve({ data: true }) + }, abort: (input: { sessionID: string }) => { aborts.push(input.sessionID) return Promise.resolve({ data: true }) @@ -201,13 +237,33 @@ describe("ACP next service sessions", () => { }, } as unknown as OpencodeClient const connection = { - sessionUpdate: (update: unknown) => { + sessionUpdate: (update: SessionNotification) => { updates.push(update) return Promise.resolve() }, } as Pick + const usage = UsageService.Service.of({ + buildUsage: UsageService.buildUsage, + latestAssistantMessage: UsageService.latestAssistantMessage, + totalSessionCost: UsageService.totalSessionCost, + contextLimit: () => Effect.succeed(128000), + sendUpdate: (input) => + Effect.sync(() => { + usageUpdates.push(input.sessionID) + }), + }) - return { service: ACPNextService.make({ sdk, connection }), updates, mcpAdds, aborts, forks } + return { + service: ACPNextService.make({ sdk, connection, usage }), + updates, + mcpAdds, + aborts, + forks, + prompts, + commands, + summarizes, + usageUpdates, + } } it("creates a backed session with config options and command update", async () => { @@ -657,8 +713,220 @@ describe("ACP next service sessions", () => { expect(providersCalls).toBe(1) expect(commandCalls).toBe(1) }) + + it("normal text prompt sends model variant mode and converted parts", async () => { + const { service, prompts, usageUpdates } = makeService() + const session = await Effect.runPromise(service.newSession({ cwd: "/workspace", mcpServers: [] })) + await Effect.runPromise( + service.setSessionConfigOption({ + sessionId: session.sessionId, + configId: "effort", + value: "high", + }), + ) + await Effect.runPromise( + service.setSessionConfigOption({ + sessionId: session.sessionId, + configId: "mode", + value: "plan", + }), + ) + + const result = await Effect.runPromise( + service.prompt({ + sessionId: session.sessionId, + messageId: "00000000-0000-4000-8000-000000000001", + prompt: [{ type: "text", text: "hello" }], + }), + ) + + expect(prompts).toEqual([ + { + sessionID: session.sessionId, + model: { providerID, modelID }, + variant: "high", + parts: [{ type: "text", text: "hello" }], + agent: "plan", + directory: "/workspace", + }, + ]) + expect(result).toEqual({ + stopReason: "end_turn", + usage: { + inputTokens: 100, + outputTokens: 40, + thoughtTokens: 7, + cachedReadTokens: 11, + cachedWriteTokens: 13, + totalTokens: 171, + }, + userMessageId: "00000000-0000-4000-8000-000000000001", + _meta: {}, + }) + expect(usageUpdates).toEqual([session.sessionId]) + }) + + it("prompt maps assistant and user audience annotations", async () => { + const { service, prompts } = makeService() + const session = await Effect.runPromise(service.newSession({ cwd: "/workspace", mcpServers: [] })) + + await Effect.runPromise( + service.prompt({ + sessionId: session.sessionId, + prompt: [ + { type: "text", text: "assistant context", annotations: { audience: ["assistant"] } }, + { type: "text", text: "user context", annotations: { audience: ["user"] } }, + ], + }), + ) + + expect(prompts).toContainEqual({ + sessionID: session.sessionId, + model: { providerID, modelID }, + variant: "default", + parts: [ + { type: "text", text: "assistant context", synthetic: true }, + { type: "text", text: "user context", ignored: true }, + ], + agent: "build", + directory: "/workspace", + }) + }) + + it("prompt sends image and resource parts", async () => { + const { service, prompts } = makeService() + const session = await Effect.runPromise(service.newSession({ cwd: "/workspace", mcpServers: [] })) + + await Effect.runPromise( + service.prompt({ + sessionId: session.sessionId, + prompt: [ + { type: "image", data: "AAAA", mimeType: "image/png", uri: "file:///tmp/screenshot.png" }, + { + type: "resource", + resource: { + uri: "file:///tmp/report.pdf", + mimeType: "application/pdf", + blob: "JVBERg==", + }, + }, + ], + }), + ) + + expect((prompts[0] as { parts?: unknown }).parts).toEqual([ + { + type: "file", + url: "data:image/png;base64,AAAA", + filename: "screenshot.png", + mime: "image/png", + }, + { + type: "file", + url: "data:application/pdf;base64,JVBERg==", + filename: "report.pdf", + mime: "application/pdf", + }, + ]) + }) + + it("slash command prompt calls session command", async () => { + const { service, prompts, commands } = makeService() + const session = await Effect.runPromise(service.newSession({ cwd: "/workspace", mcpServers: [] })) + + const result = await Effect.runPromise( + service.prompt({ sessionId: session.sessionId, prompt: [{ type: "text", text: "/init now" }] }), + ) + + expect(prompts).toEqual([]) + expect(commands).toEqual([ + { + sessionID: session.sessionId, + command: "init", + arguments: "now", + model: "test/test-model", + variant: "default", + agent: "build", + directory: "/workspace", + }, + ]) + expect(result.usage).toEqual({ inputTokens: 3, outputTokens: 4, totalTokens: 7 }) + }) + + it("compact slash command calls summarize path", async () => { + const { service, prompts, commands, summarizes } = makeService() + const session = await Effect.runPromise(service.newSession({ cwd: "/workspace", mcpServers: [] })) + + await Effect.runPromise( + service.prompt({ sessionId: session.sessionId, prompt: [{ type: "text", text: "/compact" }] }), + ) + + expect(prompts).toEqual([]) + expect(commands).toEqual([]) + expect(summarizes).toEqual([ + { + sessionID: session.sessionId, + directory: "/workspace", + providerID, + modelID, + }, + ]) + }) + + it("maps prompt auth failures to auth-required request errors", async () => { + const { service } = makeService() + const session = await Effect.runPromise(service.newSession({ cwd: "/workspace", mcpServers: [] })) + const failing = ACPNextService.make({ + sdk: { + config: { + providers: () => Promise.resolve({ data: { providers: [provider], default: { test: modelID } } }), + get: () => Promise.resolve({ data: {} }), + }, + app: { + agents: () => Promise.resolve({ data: [{ name: "build", mode: "primary", permission: [], options: {} }] }), + skills: () => Promise.resolve({ data: [] }), + }, + command: { + list: () => Promise.resolve({ data: [] }), + }, + session: { + create: () => Promise.resolve({ data: { id: session.sessionId } }), + list: () => Promise.resolve({ data: [] }), + prompt: () => Promise.reject({ name: "ProviderAuthError", data: { providerID: "test" } }), + }, + mcp: { + add: () => Promise.resolve({ data: {} }), + }, + } as unknown as OpencodeClient, + usage: UsageService.Service.of({ + buildUsage: UsageService.buildUsage, + latestAssistantMessage: UsageService.latestAssistantMessage, + totalSessionCost: UsageService.totalSessionCost, + contextLimit: () => Effect.succeed(128000), + sendUpdate: () => Effect.void, + }), + }) + await Effect.runPromise(failing.newSession({ cwd: "/workspace", mcpServers: [] })) + const error = await Effect.runPromise( + failing + .prompt({ sessionId: session.sessionId, prompt: [{ type: "text", text: "hello" }] }) + .pipe(Effect.mapError(ACPNextError.toRequestError), Effect.flip), + ) + + expect(error.code).toBe(-32000) + }) }) +function assistantInfo(tokens: UsageService.AssistantTokenCost["tokens"]): UsageService.AssistantMessage { + return { + role: "assistant", + providerID: "test", + modelID: "test-model", + cost: 0, + tokens, + } +} + function categories(result: NewSessionResponse | LoadSessionResponse) { return result.configOptions?.map((option) => option.category) ?? [] } diff --git a/packages/opencode/test/cli/acp-next/acp-next-process.test.ts b/packages/opencode/test/cli/acp-next/acp-next-process.test.ts index 95b905543b..5c01929d95 100644 --- a/packages/opencode/test/cli/acp-next/acp-next-process.test.ts +++ b/packages/opencode/test/cli/acp-next/acp-next-process.test.ts @@ -5,6 +5,7 @@ import type { InitializeResponse, LoadSessionResponse, NewSessionResponse, + PromptResponse, ResumeSessionResponse, SessionNotification, SetSessionConfigOptionResponse, @@ -95,11 +96,21 @@ describe("opencode acp-next (subprocess)", () => { ) expect(selectConfigOption(loaded.configOptions, "model")?.category).toBe("model") - const prompt = yield* acp.request("session/prompt", { + yield* llm.text("hello from acp-next", { usage: { input: 11, output: 7 } }) + const prompted = expectOk( + yield* acp.request("session/prompt", { + sessionId: session.sessionId, + prompt: [{ type: "text", text: "hello" }], + }), + ) + expect(prompted.stopReason).toBe("end_turn") + expect(prompted.usage?.totalTokens).toBeGreaterThan(0) + + const missing = yield* acp.request("session/prompt", { sessionId: "ses_missing", prompt: [{ type: "text", text: "hello" }], }) - expect(errorCode(prompt.error)).toBe(-32601) + expect(errorCode(missing.error)).toBe(-32602) }), 60_000, ) From e5524f5bf9110adc6bddd7b9e5e99a40669ce9ae Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Thu, 28 May 2026 05:15:21 +0000 Subject: [PATCH 015/113] chore: generate --- packages/opencode/src/acp-next/service.ts | 142 +++++++++++----------- 1 file changed, 71 insertions(+), 71 deletions(-) diff --git a/packages/opencode/src/acp-next/service.ts b/packages/opencode/src/acp-next/service.ts index 2373df3ac5..04cf71fff8 100644 --- a/packages/opencode/src/acp-next/service.ts +++ b/packages/opencode/src/acp-next/service.ts @@ -553,79 +553,79 @@ function makeDirectoryService(sdk: OpencodeClient) { function makeUsageService(sdk: OpencodeClient) { const limits = new Map>() - const contextLimit: UsageService.Interface["contextLimit"] = Effect.fn("ACPNext.promptUsage.contextLimit")(function* ( - params, - ) { - const key = `${params.directory}\u0000${params.providerID}\u0000${params.modelID}` - const current = limits.get(key) - if (current) return yield* Effect.promise(() => current) + const contextLimit: UsageService.Interface["contextLimit"] = Effect.fn("ACPNext.promptUsage.contextLimit")( + function* (params) { + const key = `${params.directory}\u0000${params.providerID}\u0000${params.modelID}` + const current = limits.get(key) + if (current) return yield* Effect.promise(() => current) - const next = sdk.config - .providers({ directory: params.directory }, { throwOnError: true }) - .then((response) => { - const providers = Object.fromEntries( - (response.data?.providers ?? []).map((provider) => [provider.id, provider]), - ) as Record - return UsageService.findContextLimit(providers, params.providerID, params.modelID) - }) - .catch((error: unknown) => { - log.error("failed to get providers for usage context limit", { error }) - return undefined - }) - limits.set(key, next) - return yield* Effect.promise(() => next) - }) - - const sendUpdate: UsageService.Interface["sendUpdate"] = Effect.fn("ACPNext.promptUsage.sendUpdate")(function* ( - params, - ) { - const messages = yield* request( - () => - sdk.session.messages( - { - sessionID: params.sessionID, - directory: params.directory, - }, - { throwOnError: true }, - ), - "session", - ).pipe( - Effect.map((messages) => messages as readonly UsageService.SessionMessage[]), - Effect.catch((error) => - Effect.sync(() => { - log.error("failed to fetch messages for usage update", { error }) - return undefined - }), - ), - ) - if (!messages) return - - const message = UsageService.latestAssistantMessage(messages) - if (!message?.providerID || !message.modelID) return - - const size = yield* contextLimit({ - directory: params.directory, - providerID: ProviderID.make(message.providerID), - modelID: ModelID.make(message.modelID), - }) - if (!size) return - - yield* Effect.promise(() => - params.connection - .sessionUpdate({ - sessionId: params.sessionID, - update: { - sessionUpdate: "usage_update", - used: message.tokens.input + message.tokens.cache.read, - size, - cost: { amount: UsageService.totalSessionCost(messages), currency: "USD" }, - }, + const next = sdk.config + .providers({ directory: params.directory }, { throwOnError: true }) + .then((response) => { + const providers = Object.fromEntries( + (response.data?.providers ?? []).map((provider) => [provider.id, provider]), + ) as Record + return UsageService.findContextLimit(providers, params.providerID, params.modelID) }) - .catch((error) => { - log.error("failed to send usage update", { error }) - }), - ) - }) + .catch((error: unknown) => { + log.error("failed to get providers for usage context limit", { error }) + return undefined + }) + limits.set(key, next) + return yield* Effect.promise(() => next) + }, + ) + + const sendUpdate: UsageService.Interface["sendUpdate"] = Effect.fn("ACPNext.promptUsage.sendUpdate")( + function* (params) { + const messages = yield* request( + () => + sdk.session.messages( + { + sessionID: params.sessionID, + directory: params.directory, + }, + { throwOnError: true }, + ), + "session", + ).pipe( + Effect.map((messages) => messages as readonly UsageService.SessionMessage[]), + Effect.catch((error) => + Effect.sync(() => { + log.error("failed to fetch messages for usage update", { error }) + return undefined + }), + ), + ) + if (!messages) return + + const message = UsageService.latestAssistantMessage(messages) + if (!message?.providerID || !message.modelID) return + + const size = yield* contextLimit({ + directory: params.directory, + providerID: ProviderID.make(message.providerID), + modelID: ModelID.make(message.modelID), + }) + if (!size) return + + yield* Effect.promise(() => + params.connection + .sessionUpdate({ + sessionId: params.sessionID, + update: { + sessionUpdate: "usage_update", + used: message.tokens.input + message.tokens.cache.read, + size, + cost: { amount: UsageService.totalSessionCost(messages), currency: "USD" }, + }, + }) + .catch((error) => { + log.error("failed to send usage update", { error }) + }), + ) + }, + ) return UsageService.Service.of({ buildUsage: UsageService.buildUsage, From 14e0b9b17f886c9157c92e1b98caca5a40d21797 Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Thu, 28 May 2026 01:16:18 -0500 Subject: [PATCH 016/113] fix(openai): retry websocket stream failures (#29673) --- packages/opencode/src/plugin/openai/README.md | 6 +- .../opencode/src/plugin/openai/ws-pool.ts | 67 ++-- packages/opencode/src/plugin/openai/ws.ts | 29 +- packages/opencode/src/provider/error.ts | 8 + packages/opencode/src/session/message-v2.ts | 11 + .../opencode/test/plugin/openai-ws.test.ts | 359 +++++++++++------- packages/opencode/test/session/retry.test.ts | 11 + 7 files changed, 305 insertions(+), 186 deletions(-) diff --git a/packages/opencode/src/plugin/openai/README.md b/packages/opencode/src/plugin/openai/README.md index c359f8c60c..2286b3509d 100644 --- a/packages/opencode/src/plugin/openai/README.md +++ b/packages/opencode/src/plugin/openai/README.md @@ -20,9 +20,9 @@ Enabled by default on `local`, `dev`, and `beta`. On `latest` and `prod`, set `O ## Retries -- If WebSocket setup fails or it fails before its first event, replay over HTTP and keep that session on HTTP until idle-pruned. -- If the server returns `websocket_connection_limit_reached` before output, reconnect up to 5 times, then follow the same HTTP fallback. -- If a WebSocket fails after its first event, fail the stream. Do not replay partial output. +- Retry WebSocket stream/setup failures up to 5 times, then use HTTP for that session until the pool entry is idle-pruned. +- `websocket_connection_limit_reached` consumes the same retry budget and HTTP fallback. +- If a WebSocket fails after its first event, fail it as retryable rather than replaying partial output in transport. - Abort or cancel closes the socket. ## Next Steps diff --git a/packages/opencode/src/plugin/openai/ws-pool.ts b/packages/opencode/src/plugin/openai/ws-pool.ts index 0af4c57c91..3316570976 100644 --- a/packages/opencode/src/plugin/openai/ws-pool.ts +++ b/packages/opencode/src/plugin/openai/ws-pool.ts @@ -1,5 +1,6 @@ import WebSocket from "ws" import * as Log from "@opencode-ai/core/util/log" +import { ProviderError } from "@/provider/error" import { isRecord } from "@/util/record" import { OpenAIWebSocket } from "./ws" @@ -13,7 +14,7 @@ export interface CreateWebSocketFetchOptions { connectTimeout?: number idleTimeout?: number maxConnectionAge?: number - connectionLimitRetries?: number + streamRetries?: number } interface PoolEntry { @@ -22,6 +23,7 @@ interface PoolEntry { lastUsedAt: number busy: boolean fallback: boolean + streamFailures: number } const DEFAULT_CONNECT_TIMEOUT = 15_000 @@ -35,7 +37,7 @@ export function createWebSocketFetch(options?: CreateWebSocketFetchOptions) { const connectTimeout = options?.connectTimeout ?? DEFAULT_CONNECT_TIMEOUT const idleTimeout = options?.idleTimeout ?? DEFAULT_IDLE_TIMEOUT const maxConnectionAge = options?.maxConnectionAge ?? DEFAULT_MAX_CONNECTION_AGE - const connectionLimitRetries = options?.connectionLimitRetries ?? 5 + const streamRetries = options?.streamRetries ?? 5 const pruneTimer = setInterval(() => prune(), Math.min(idleTimeout, 60_000)) if (typeof pruneTimer === "object" && "unref" in pruneTimer && typeof pruneTimer.unref === "function") { pruneTimer.unref() @@ -72,7 +74,7 @@ export function createWebSocketFetch(options?: CreateWebSocketFetchOptions) { } const key = `${sessionID}:conversation` - const entry = pool.get(key) ?? { lastUsedAt: Date.now(), busy: false, fallback: false } + const entry = pool.get(key) ?? { lastUsedAt: Date.now(), busy: false, fallback: false, streamFailures: 0 } pool.set(key, entry) if (entry.fallback) { @@ -87,7 +89,6 @@ export function createWebSocketFetch(options?: CreateWebSocketFetchOptions) { entry.busy = true entry.lastUsedAt = Date.now() try { - let connectionLimitAttempts = 0 entry.socket = await socket( entry, options?.url ?? url, @@ -111,15 +112,16 @@ export function createWebSocketFetch(options?: CreateWebSocketFetchOptions) { onTerminal: (event) => { entry.busy = false entry.lastUsedAt = Date.now() + entry.streamFailures = 0 if (event.type !== "response.completed" && event.type !== "response.done") { log.warn("websocket terminal failure", { key, type: event.type }) invalidate(entry) } }, onConnectionInvalid: (error) => { - log.warn("websocket invalidated", { key, error: error instanceof Error ? error.message : String(error) }) + log.warn("websocket invalidated", { key, error: error.message }) entry.busy = false - entry.fallback = true + if (!entry.fallback) recordStreamFailure(entry) invalidate(entry) resolveFirstEvent(false) }, @@ -127,51 +129,52 @@ export function createWebSocketFetch(options?: CreateWebSocketFetchOptions) { log.debug("websocket aborted", { key }) entry.busy = false entry.lastUsedAt = Date.now() + entry.streamFailures = 0 invalidate(entry) rejectFirstEvent(error) }, onRetryableTerminal: async (event) => { const error = connectionLimitError(event) if (!error) return undefined - if (connectionLimitAttempts >= connectionLimitRetries) throw error - - connectionLimitAttempts++ - log.warn("websocket connection limit reached", { key, attempt: connectionLimitAttempts }) - invalidate(entry) - entry.socket = await socket( - entry, - options?.url ?? url, - OpenAIWebSocket.normalizeHeaders(httpInit?.headers), - connectTimeout, - maxConnectionAge, - init?.signal, - ) - entry.lastUsedAt = Date.now() - return entry.socket + log.warn("websocket connection limit reached", { key }) + throw error }, }) if (await firstEvent) return response - log.debug("http fallback", { key, reason: "websocket_failed_before_first_event" }) + if (!entry.fallback) return response + log.debug("http fallback", { key, reason: "websocket_retries_exhausted" }) return httpFetch(input, httpInit) } catch (error) { entry.busy = false entry.lastUsedAt = Date.now() if (OpenAIWebSocket.isAbortError(error)) { + entry.streamFailures = 0 invalidate(entry) throw error } - entry.fallback = true + recordStreamFailure(entry) log.warn("websocket setup failed", { key, error: error instanceof Error ? error.message : String(error), - fallback: "http", + fallback: entry.fallback ? "http" : undefined, }) invalidate(entry) - return httpFetch(input, httpInit) + if (entry.fallback) return httpFetch(input, httpInit) + return failedResponse( + new ProviderError.ResponseStreamError(error instanceof Error ? error.message : String(error), { + cause: error, + }), + ) } } + function recordStreamFailure(entry: PoolEntry) { + entry.streamFailures++ + // Codex counts retries after the initial failed WebSocket attempt. + if (entry.streamFailures > streamRetries) entry.fallback = true + } + function prune() { const now = Date.now() for (const [key, entry] of pool) { @@ -198,6 +201,20 @@ function connectionLimitError(event: Record) { return new Error(typeof event.error.message === "string" ? event.error.message : CONNECTION_LIMIT_REACHED_CODE) } +function failedResponse(error: ProviderError.ResponseStreamError) { + return new Response( + new ReadableStream({ + start(controller) { + controller.error(error) + }, + }), + { + status: 200, + headers: { "content-type": "text/event-stream" }, + }, + ) +} + async function socket( entry: PoolEntry, url: string, diff --git a/packages/opencode/src/plugin/openai/ws.ts b/packages/opencode/src/plugin/openai/ws.ts index 7176ab2104..cad6769609 100644 --- a/packages/opencode/src/plugin/openai/ws.ts +++ b/packages/opencode/src/plugin/openai/ws.ts @@ -2,6 +2,7 @@ // fallback, and continuation state intentionally live above this file. import WebSocket from "ws" +import { ProviderError } from "@/provider/error" export const PROTOCOL_HEADER = "responses_websockets=2026-02-06" @@ -21,7 +22,7 @@ export interface StreamResponsesWebSocketOptions { onComplete?: (event: Record) => void onTerminal?: (event: Record) => void onRetryableTerminal?: (event: Record) => Promise - onConnectionInvalid?: (error: Error) => void + onConnectionInvalid?: (error: ProviderError.ResponseStreamError) => void onAbort?: (error: Error) => void } @@ -101,7 +102,7 @@ export function connectResponsesWebSocket(options: ConnectResponsesWebSocketOpti function onClose(code: number, reason: Buffer) { cleanup() - reject(closeError("WebSocket closed before open", code, reason)) + reject(new Error(closeMessage("WebSocket closed before open", code, reason))) } function onAbort() { @@ -145,7 +146,7 @@ export function streamResponsesWebSocket(options: StreamResponsesWebSocketOption controller?.close() } - function invalidate(error: Error) { + function invalidate(error: ProviderError.ResponseStreamError) { if (completed) return completed = true cleanup() @@ -157,7 +158,7 @@ export function streamResponsesWebSocket(options: StreamResponsesWebSocketOption if (completed) return if (!options.idleTimeout) return if (idleTimer) clearTimeout(idleTimer) - idleTimer = setTimeout(() => invalidate(new Error(message)), options.idleTimeout) + idleTimer = setTimeout(() => invalidate(new ProviderError.ResponseStreamError(message)), options.idleTimeout) if (typeof idleTimer === "object" && "unref" in idleTimer && typeof idleTimer.unref === "function") { idleTimer.unref() } @@ -166,7 +167,7 @@ export function streamResponsesWebSocket(options: StreamResponsesWebSocketOption async function onMessage(data: WebSocket.RawData, isBinary: boolean) { if (completed) return if (isBinary) { - invalidate(new Error("Unexpected binary WebSocket frame")) + invalidate(new ProviderError.ResponseStreamError("Unexpected binary WebSocket frame")) return } @@ -195,7 +196,11 @@ export function streamResponsesWebSocket(options: StreamResponsesWebSocketOption return } } catch (error) { - invalidate(error instanceof Error ? error : new Error(String(error))) + invalidate( + new ProviderError.ResponseStreamError(error instanceof Error ? error.message : String(error), { + cause: error, + }), + ) return } } @@ -230,12 +235,14 @@ export function streamResponsesWebSocket(options: StreamResponsesWebSocketOption } function onError(error: Error) { - invalidate(error) + invalidate(new ProviderError.ResponseStreamError(error.message, { cause: error })) } function onClose(code: number, reason: Buffer) { if (completed) return - invalidate(closeError("WebSocket closed before response.completed", code, reason)) + invalidate( + new ProviderError.ResponseStreamError(closeMessage("WebSocket closed before response.completed", code, reason)), + ) } function onAbort() { @@ -272,7 +279,7 @@ export function streamResponsesWebSocket(options: StreamResponsesWebSocketOption socket.send(JSON.stringify({ type: "response.create", ...payload }), (error) => { if (completed) return resetIdleTimeout("idle timeout waiting for websocket") - if (error) invalidate(error) + if (error) invalidate(new ProviderError.ResponseStreamError(error.message, { cause: error })) }) } @@ -312,11 +319,11 @@ function abortError(signal: AbortSignal | undefined) { return new DOMException(reason instanceof Error ? reason.message : "Aborted", "AbortError") } -function closeError(message: string, code: number, reason: Buffer) { +function closeMessage(message: string, code: number, reason: Buffer) { const details = [`code ${code}`] if (code === 1009) details.push("message too big") if (reason.length > 0) details.push(reason.toString()) - return new Error(`${message} (${details.join(": ")})`) + return `${message} (${details.join(": ")})` } export * as OpenAIWebSocket from "./ws" diff --git a/packages/opencode/src/provider/error.ts b/packages/opencode/src/provider/error.ts index 4233839171..06b388b48d 100644 --- a/packages/opencode/src/provider/error.ts +++ b/packages/opencode/src/provider/error.ts @@ -11,6 +11,14 @@ export class HeaderTimeoutError extends Error { } } +export class ResponseStreamError extends Error { + public override readonly name = "ProviderResponseStreamError" + + constructor(message: string, options?: ErrorOptions) { + super(message, options) + } +} + // Adapted from overflow detection patterns in: // https://github.com/badlogic/pi-mono/blob/main/packages/ai/src/utils/overflow.ts const OVERFLOW_PATTERNS = [ diff --git a/packages/opencode/src/session/message-v2.ts b/packages/opencode/src/session/message-v2.ts index 29cc83a1eb..68157f5f4d 100644 --- a/packages/opencode/src/session/message-v2.ts +++ b/packages/opencode/src/session/message-v2.ts @@ -1155,6 +1155,17 @@ export function fromError( }, { cause: e }, ).toObject() + case e instanceof ProviderError.ResponseStreamError: + return new APIError( + { + message: e.message, + isRetryable: true, + metadata: { + code: e.name, + }, + }, + { cause: e }, + ).toObject() case APICallError.isInstance(e): const parsed = ProviderError.parseAPICallError({ providerID: ctx.providerID, diff --git a/packages/opencode/test/plugin/openai-ws.test.ts b/packages/opencode/test/plugin/openai-ws.test.ts index 1fe26cfcb4..d27774037d 100644 --- a/packages/opencode/test/plugin/openai-ws.test.ts +++ b/packages/opencode/test/plugin/openai-ws.test.ts @@ -1,8 +1,9 @@ import { describe, expect, test } from "bun:test" import { EventEmitter } from "node:events" -import type { IncomingMessage } from "node:http" +import { createServer, type IncomingMessage, type Server as HttpServer } from "node:http" import net, { type AddressInfo, type Socket } from "node:net" import WebSocket, { WebSocketServer } from "ws" +import { ProviderError } from "../../src/provider/error" import { OpenAIWebSocket } from "../../src/plugin/openai/ws" import { OpenAIWebSocketPool, TITLE_HEADER } from "../../src/plugin/openai/ws-pool" @@ -85,7 +86,7 @@ describe("plugin.openai.ws", () => { }) test("errors the SSE stream when the server closes before a terminal event", async () => { - const invalid: string[] = [] + const invalid: Error[] = [] await using server = await createWebSocketServer((socket) => { socket.once("message", () => { socket.close(1009, "payload too large") @@ -96,13 +97,14 @@ describe("plugin.openai.ws", () => { const response = OpenAIWebSocket.streamResponsesWebSocket({ socket, body: { stream: true, input: "hi" }, - onConnectionInvalid: (error) => invalid.push(error.message), + onConnectionInvalid: (error) => invalid.push(error), }) await expect(response.text()).rejects.toThrow( "WebSocket closed before response.completed (code 1009: message too big: payload too large)", ) - expect(invalid).toEqual([ + expect(invalid[0]).toBeInstanceOf(ProviderError.ResponseStreamError) + expect(invalid.map((error) => error.message)).toEqual([ "WebSocket closed before response.completed (code 1009: message too big: payload too large)", ]) }) @@ -140,13 +142,12 @@ describe("plugin.openai.ws-pool", () => { }) const fetch = OpenAIWebSocketPool.createWebSocketFetch({ url: server.url, - httpFetch: mockFetch(async () => new Response("http")), }) - const first = await fetch("https://api.openai.com/v1/responses", streamRequest()) + const first = await fetch(server.url, streamRequest()) expect(await first.text()).toContain("data: [DONE]") - const second = await fetch("https://api.openai.com/v1/responses", streamRequest()) + const second = await fetch(server.url, streamRequest()) expect(await second.text()).toContain("data: [DONE]") expect(connections).toBe(1) expect(messages).toBe(2) @@ -163,41 +164,59 @@ describe("plugin.openai.ws-pool", () => { }) const fetch = OpenAIWebSocketPool.createWebSocketFetch({ url: server.url, - httpFetch: mockFetch(async () => new Response("http")), maxConnectionAge: 0, }) - const first = await fetch("https://api.openai.com/v1/responses", streamRequest()) + const first = await fetch(server.url, streamRequest()) expect(await first.text()).toContain("data: [DONE]") - const second = await fetch("https://api.openai.com/v1/responses", streamRequest()) + const second = await fetch(server.url, streamRequest()) expect(await second.text()).toContain("data: [DONE]") expect(connections).toBe(2) fetch.close() }) - test("falls back to HTTP when websocket setup fails and keeps the fallback sticky", async () => { + test("falls back to HTTP after websocket setup retries are exhausted", async () => { const attempts: string[] = [] await using server = await createRejectingWebSocketServer(() => attempts.push("websocket")) - const httpRequests: Headers[] = [] const fetch = OpenAIWebSocketPool.createWebSocketFetch({ url: server.url, - httpFetch: mockFetch(async (_input, init) => { - httpRequests.push(new Headers(init?.headers)) - return new Response("http") - }), connectTimeout: 100, + streamRetries: 1, }) - const first = await fetch("https://api.openai.com/v1/responses", streamRequest({ [TITLE_HEADER]: "false" })) - const second = await fetch("https://api.openai.com/v1/responses", streamRequest({ [TITLE_HEADER]: "false" })) + const first = await fetch(server.url, streamRequest({ [TITLE_HEADER]: "false" })) + await expect(first.text()).rejects.toBeInstanceOf(ProviderError.ResponseStreamError) + const second = await fetch(server.url, streamRequest({ [TITLE_HEADER]: "false" })) + const third = await fetch(server.url, streamRequest({ [TITLE_HEADER]: "false" })) - expect(await first.text()).toBe("http") expect(await second.text()).toBe("http") - expect(attempts).toEqual(["websocket"]) - expect(httpRequests).toHaveLength(2) - expect(httpRequests[0]?.get(TITLE_HEADER)).toBeNull() - expect(httpRequests[1]?.get(TITLE_HEADER)).toBeNull() + expect(await third.text()).toBe("http") + expect(attempts).toEqual(["websocket", "websocket"]) + expect(server.httpRequests).toHaveLength(2) + expect(server.httpRequests[0]?.headers[TITLE_HEADER]).toBeUndefined() + expect(server.httpRequests[1]?.headers[TITLE_HEADER]).toBeUndefined() + fetch.close() + }) + + test("prunes HTTP fallback after its idle timeout", async () => { + let websocketAttempts = 0 + await using server = await createRejectingWebSocketServer(() => websocketAttempts++) + const fetch = OpenAIWebSocketPool.createWebSocketFetch({ + url: server.url, + connectTimeout: 100, + idleTimeout: 20, + streamRetries: 0, + }) + + const first = await fetch(server.url, streamRequest()) + expect(await first.text()).toBe("http") + await new Promise((resolve) => setTimeout(resolve, 50)) + const second = await fetch(server.url, streamRequest()) + + expect(await second.text()).toBe("http") + expect(websocketAttempts).toBe(2) + expect(server.httpRequests).toHaveLength(2) fetch.close() }) @@ -209,26 +228,21 @@ describe("plugin.openai.ws-pool", () => { socket.send(JSON.stringify({ type: connections === 1 ? "response.failed" : "response.completed" })) }) }) - const httpRequests: Headers[] = [] const fetch = OpenAIWebSocketPool.createWebSocketFetch({ url: server.url, - httpFetch: mockFetch(async (_input, init) => { - httpRequests.push(new Headers(init?.headers)) - return new Response("http") - }), }) - const first = await fetch("https://api.openai.com/v1/responses", streamRequest()) + const first = await fetch(server.url, streamRequest()) expect(await first.text()).toContain('data: {"type":"response.failed"}') - const second = await fetch("https://api.openai.com/v1/responses", streamRequest()) + const second = await fetch(server.url, streamRequest()) expect(await second.text()).toContain('data: {"type":"response.completed"}') expect(connections).toBe(2) - expect(httpRequests).toHaveLength(0) + expect(server.httpRequests).toHaveLength(0) fetch.close() }) - test("reconnects and replays after websocket connection limit errors", async () => { + test("retries websocket connection limit errors on the next stream attempt", async () => { let connections = 0 let messages = 0 await using server = await createWebSocketServer((socket) => { @@ -252,24 +266,21 @@ describe("plugin.openai.ws-pool", () => { socket.send(JSON.stringify({ type: "response.completed", response: { id: "resp_retry" } })) }) }) - const httpRequests: Headers[] = [] const fetch = OpenAIWebSocketPool.createWebSocketFetch({ url: server.url, - httpFetch: mockFetch(async (_input, init) => { - httpRequests.push(new Headers(init?.headers)) - return new Response("http") - }), }) - const response = await fetch("https://api.openai.com/v1/responses", streamRequest()) - const text = await response.text() + const first = await fetch(server.url, streamRequest()) + await expect(first.text()).rejects.toThrow("Responses websocket connection limit reached") + const second = await fetch(server.url, streamRequest()) + const text = await second.text() expect(text).not.toContain("websocket_connection_limit_reached") expect(text).toContain('data: {"type":"response.completed","response":{"id":"resp_retry"}}') expect(text).toContain("data: [DONE]") expect(connections).toBe(2) expect(messages).toBe(2) - expect(httpRequests).toHaveLength(0) + expect(server.httpRequests).toHaveLength(0) fetch.close() }) @@ -291,98 +302,159 @@ describe("plugin.openai.ws-pool", () => { ) }) }) - let httpRequests = 0 const fetch = OpenAIWebSocketPool.createWebSocketFetch({ url: server.url, - connectionLimitRetries: 2, - httpFetch: mockFetch(async () => { - httpRequests += 1 - return new Response("http") - }), + streamRetries: 2, }) - const first = await fetch("https://api.openai.com/v1/responses", streamRequest()) - const second = await fetch("https://api.openai.com/v1/responses", streamRequest()) + const first = await fetch(server.url, streamRequest()) + await expect(first.text()).rejects.toThrow("Responses websocket connection limit reached") + const second = await fetch(server.url, streamRequest()) + await expect(second.text()).rejects.toThrow("Responses websocket connection limit reached") + const third = await fetch(server.url, streamRequest()) + const fourth = await fetch(server.url, streamRequest()) - expect(await first.text()).toBe("http") - expect(await second.text()).toBe("http") + expect(await third.text()).toBe("http") + expect(await fourth.text()).toBe("http") expect(connections).toBe(3) - expect(httpRequests).toBe(2) + expect(server.httpRequests).toHaveLength(2) fetch.close() }) - test("replays over HTTP when websocket idles before its first event", async () => { + test("shares the websocket retry budget across stream and connection limit failures", async () => { + let connections = 0 + await using server = await createWebSocketServer((socket) => { + connections += 1 + socket.once("message", () => { + if (connections === 1) { + socket.send(JSON.stringify({ type: "response.output_text.delta", delta: "started" })) + socket.terminate() + return + } + socket.send( + JSON.stringify({ + type: "error", + error: { + code: "websocket_connection_limit_reached", + message: "Responses websocket connection limit reached", + }, + }), + ) + }) + }) + const fetch = OpenAIWebSocketPool.createWebSocketFetch({ + url: server.url, + streamRetries: 1, + }) + + const first = await fetch(server.url, streamRequest()) + await expect(first.text()).rejects.toThrow("WebSocket closed before response.completed") + const second = await fetch(server.url, streamRequest()) + + expect(await second.text()).toBe("http") + expect(connections).toBe(2) + expect(server.httpRequests).toHaveLength(1) + fetch.close() + }) + + test("retries websocket idle failures before first event then falls back to HTTP", async () => { let connections = 0 await using server = await createWebSocketServer((socket) => { connections += 1 socket.once("message", () => {}) }) - const httpRequests: Headers[] = [] const fetch = OpenAIWebSocketPool.createWebSocketFetch({ url: server.url, idleTimeout: 20, - httpFetch: mockFetch(async (_input, init) => { - httpRequests.push(new Headers(init?.headers)) - return new Response("http") - }), + streamRetries: 1, }) - const first = await fetch("https://api.openai.com/v1/responses", streamRequest()) - expect(await first.text()).toBe("http") - const second = await fetch("https://api.openai.com/v1/responses", streamRequest()) + const first = await fetch(server.url, streamRequest()) + await expect(first.text()).rejects.toThrow("idle timeout waiting for websocket") + const second = await fetch(server.url, streamRequest()) + const third = await fetch(server.url, streamRequest()) expect(await second.text()).toBe("http") - expect(connections).toBe(1) - expect(httpRequests).toHaveLength(2) + expect(await third.text()).toBe("http") + expect(connections).toBe(2) + expect(server.httpRequests).toHaveLength(2) fetch.close() }) - test("does not replay over HTTP after a websocket event was emitted", async () => { + test("retries failed websocket streams before using HTTP fallback", async () => { await using server = await createWebSocketServer((socket) => { socket.once("message", () => { socket.send(JSON.stringify({ type: "response.output_text.delta", delta: "started" })) }) }) - const httpRequests: Headers[] = [] const fetch = OpenAIWebSocketPool.createWebSocketFetch({ url: server.url, idleTimeout: 20, - httpFetch: mockFetch(async (_input, init) => { - httpRequests.push(new Headers(init?.headers)) - return new Response("http") - }), + streamRetries: 1, }) - const first = await fetch("https://api.openai.com/v1/responses", streamRequest()) + const first = await fetch(server.url, streamRequest()) await expect(first.text()).rejects.toThrow("idle timeout waiting for websocket") - const second = await fetch("https://api.openai.com/v1/responses", streamRequest()) + const second = await fetch(server.url, streamRequest()) + await expect(second.text()).rejects.toThrow("idle timeout waiting for websocket") + const third = await fetch(server.url, streamRequest()) - expect(await second.text()).toBe("http") - expect(httpRequests).toHaveLength(1) + expect(await third.text()).toBe("http") + expect(server.httpRequests).toHaveLength(1) + fetch.close() + }) + + test("resets websocket stream failures after a completed response", async () => { + let connections = 0 + let requests = 0 + await using server = await createWebSocketServer((socket) => { + connections += 1 + socket.on("message", () => { + requests += 1 + if (requests === 1 || requests === 3) { + socket.send(JSON.stringify({ type: "response.output_text.delta", delta: "started" })) + socket.terminate() + return + } + socket.send(JSON.stringify({ type: "response.completed", response: { id: `resp_${requests}` } })) + }) + }) + const fetch = OpenAIWebSocketPool.createWebSocketFetch({ + url: server.url, + streamRetries: 1, + }) + + const first = await fetch(server.url, streamRequest()) + await expect(first.text()).rejects.toThrow("WebSocket closed before response.completed") + const second = await fetch(server.url, streamRequest()) + expect(await second.text()).toContain("data: [DONE]") + const third = await fetch(server.url, streamRequest()) + await expect(third.text()).rejects.toThrow("WebSocket closed before response.completed") + const fourth = await fetch(server.url, streamRequest()) + + expect(await fourth.text()).toContain("data: [DONE]") + expect(connections).toBe(3) + expect(requests).toBe(4) + expect(server.httpRequests).toHaveLength(0) fetch.close() }) test("falls back to HTTP for missing session and title requests", async () => { - const httpRequests: Headers[] = [] - const fetch = OpenAIWebSocketPool.createWebSocketFetch({ - httpFetch: mockFetch(async (_input, init) => { - httpRequests.push(new Headers(init?.headers)) - return new Response("http") - }), - }) + await using server = await createWebSocketServer(() => {}) + const fetch = OpenAIWebSocketPool.createWebSocketFetch() - const missingSession = await fetch("https://api.openai.com/v1/responses", { + const missingSession = await fetch(server.url, { method: "POST", headers: { [TITLE_HEADER]: "false" }, body: JSON.stringify({ stream: true }), }) - const title = await fetch("https://api.openai.com/v1/responses", streamRequest({ [TITLE_HEADER]: "true" })) + const title = await fetch(server.url, streamRequest({ [TITLE_HEADER]: "true" })) expect(await missingSession.text()).toBe("http") expect(await title.text()).toBe("http") - expect(httpRequests).toHaveLength(2) - expect(httpRequests[0]?.get(TITLE_HEADER)).toBeNull() - expect(httpRequests[1]?.get(TITLE_HEADER)).toBeNull() + expect(server.httpRequests).toHaveLength(2) + expect(server.httpRequests[0]?.headers[TITLE_HEADER]).toBeUndefined() + expect(server.httpRequests[1]?.headers[TITLE_HEADER]).toBeUndefined() fetch.close() }) @@ -395,22 +467,17 @@ describe("plugin.openai.ws-pool", () => { }) }) const abort = new AbortController() - const httpRequests: Headers[] = [] const fetch = OpenAIWebSocketPool.createWebSocketFetch({ url: server.url, - httpFetch: mockFetch(async (_input, init) => { - httpRequests.push(new Headers(init?.headers)) - return new Response("http") - }), }) - const first = await fetch("https://api.openai.com/v1/responses", streamRequest({}, abort.signal)) + const first = await fetch(server.url, streamRequest({}, abort.signal)) const firstText = first.text() await waitFor(() => connections === 1, "websocket did not connect") - const second = await fetch("https://api.openai.com/v1/responses", streamRequest()) + const second = await fetch(server.url, streamRequest()) expect(await second.text()).toBe("http") - expect(httpRequests).toHaveLength(1) + expect(server.httpRequests).toHaveLength(1) expect(connections).toBe(1) abort.abort(new Error("stop")) await expect(firstText).rejects.toThrow("stop") @@ -419,28 +486,25 @@ describe("plugin.openai.ws-pool", () => { test("reserves a websocket lane while its socket is connecting", async () => { await using server = await createHangingTcpServer() - let httpRequests = 0 + await using fallback = await createHttpServer() const fetch = OpenAIWebSocketPool.createWebSocketFetch({ url: server.url, connectTimeout: 20, - httpFetch: mockFetch(async () => { - httpRequests += 1 - return new Response("http") - }), + streamRetries: 0, }) - const first = fetch("https://api.openai.com/v1/responses", streamRequest()) + const first = fetch(fallback.url, streamRequest()) await waitFor(() => server.connections() === 1, "first websocket did not begin connecting") - const second = fetch("https://api.openai.com/v1/responses", streamRequest()) + const second = fetch(fallback.url, streamRequest()) expect(await (await second).text()).toBe("http") expect(await (await first).text()).toBe("http") expect(server.connections()).toBe(1) - expect(httpRequests).toBe(2) + expect(fallback.httpRequests).toHaveLength(2) fetch.close() }) - test("replays over HTTP after an unexpected close before the first event", async () => { + test("retries unexpected closes before first event then falls back to HTTP", async () => { let connections = 0 await using server = await createWebSocketServer((socket) => { connections += 1 @@ -448,22 +512,20 @@ describe("plugin.openai.ws-pool", () => { socket.close(1001, "server shutdown") }) }) - const httpRequests: Headers[] = [] const fetch = OpenAIWebSocketPool.createWebSocketFetch({ url: server.url, - httpFetch: mockFetch(async (_input, init) => { - httpRequests.push(new Headers(init?.headers)) - return new Response("http") - }), + streamRetries: 1, }) - const first = await fetch("https://api.openai.com/v1/responses", streamRequest()) - expect(await first.text()).toBe("http") - const second = await fetch("https://api.openai.com/v1/responses", streamRequest()) + const first = await fetch(server.url, streamRequest()) + await expect(first.text()).rejects.toThrow("WebSocket closed before response.completed") + const second = await fetch(server.url, streamRequest()) + const third = await fetch(server.url, streamRequest()) expect(await second.text()).toBe("http") - expect(connections).toBe(1) - expect(httpRequests).toHaveLength(2) + expect(await third.text()).toBe("http") + expect(connections).toBe(2) + expect(server.httpRequests).toHaveLength(2) fetch.close() }) @@ -479,27 +541,22 @@ describe("plugin.openai.ws-pool", () => { socket.send(JSON.stringify({ type: "response.completed", response: { id: "resp_456" } })) }) }) - const httpRequests: Headers[] = [] const abort = new AbortController() const fetch = OpenAIWebSocketPool.createWebSocketFetch({ url: server.url, - httpFetch: mockFetch(async (_input, init) => { - httpRequests.push(new Headers(init?.headers)) - return new Response("http") - }), }) - const first = await fetch("https://api.openai.com/v1/responses", streamRequest({}, abort.signal)) + const first = await fetch(server.url, streamRequest({}, abort.signal)) const firstText = first.text() await waitFor(() => connections === 1, "first websocket did not connect") abort.abort(new Error("stop")) await expect(firstText).rejects.toThrow("stop") - const second = await fetch("https://api.openai.com/v1/responses", streamRequest()) + const second = await fetch(server.url, streamRequest()) expect(await second.text()).toContain("data: [DONE]") expect(connections).toBe(2) - expect(httpRequests).toHaveLength(0) + expect(server.httpRequests).toHaveLength(0) fetch.close() }) @@ -515,24 +572,19 @@ describe("plugin.openai.ws-pool", () => { socket.send(JSON.stringify({ type: "response.completed", response: { id: "resp_after_cancel" } })) }) }) - const httpRequests: Headers[] = [] const fetch = OpenAIWebSocketPool.createWebSocketFetch({ url: server.url, - httpFetch: mockFetch(async (_input, init) => { - httpRequests.push(new Headers(init?.headers)) - return new Response("http") - }), }) - const first = await fetch("https://api.openai.com/v1/responses", streamRequest()) + const first = await fetch(server.url, streamRequest()) await waitFor(() => connections === 1, "first websocket did not connect") await first.body!.cancel("stop") - const second = await fetch("https://api.openai.com/v1/responses", streamRequest()) + const second = await fetch(server.url, streamRequest()) expect(await second.text()).toContain("data: [DONE]") expect(connections).toBe(2) - expect(httpRequests).toHaveLength(0) + expect(server.httpRequests).toHaveLength(0) fetch.close() }) }) @@ -550,20 +602,11 @@ function streamRequest(headers?: Record, signal?: AbortSignal): } } -function mockFetch( - fn: ( - input: Parameters[0], - init: Parameters[1], - ) => ReturnType, -): typeof globalThis.fetch { - return Object.assign(fn, { preconnect: globalThis.fetch.preconnect }) -} - async function createWebSocketServer(onConnection: (socket: WebSocket, request: IncomingMessage) => void) { - const server = new WebSocketServer({ host: "127.0.0.1", port: 0 }) + const http = await createHttpServer() + const server = new WebSocketServer({ server: http.server }) server.on("connection", onConnection) - await new Promise((resolve) => server.once("listening", resolve)) - return websocketServerHandle(server) + return websocketServerHandle(server, http) } async function createHangingTcpServer() { @@ -588,31 +631,53 @@ async function createHangingTcpServer() { } async function createRejectingWebSocketServer(onAttempt: () => void) { + const http = await createHttpServer() const server = new WebSocketServer({ - host: "127.0.0.1", - port: 0, + server: http.server, verifyClient(_info, callback) { onAttempt() callback(false, 401, "denied") }, }) - await new Promise((resolve) => server.once("listening", resolve)) - return websocketServerHandle(server) + return websocketServerHandle(server, http) } -function websocketServerHandle(server: WebSocketServer) { +async function createHttpServer() { + const httpRequests: IncomingMessage[] = [] + const server = createServer((request, response) => { + httpRequests.push(request) + response.writeHead(200, { "content-type": "text/plain" }) + response.end("http") + }) + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)) const address = server.address() as AddressInfo - const url = `http://127.0.0.1:${address.port}/v1/responses` return { - url, - wsUrl: url.replace(/^http/, "ws"), + server, + httpRequests, + url: `http://127.0.0.1:${address.port}/v1/responses`, + async [Symbol.asyncDispose]() { + await closeHttpServer(server) + }, + } +} + +function websocketServerHandle(server: WebSocketServer, http: Awaited>) { + return { + url: http.url, + wsUrl: http.url.replace(/^http/, "ws"), + httpRequests: http.httpRequests, async [Symbol.asyncDispose]() { for (const socket of server.clients) socket.terminate() server.close() + http.server.close() }, } } +function closeHttpServer(server: HttpServer) { + return new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve()))) +} + async function waitFor(predicate: () => boolean, message: string) { const started = Date.now() while (!predicate()) { diff --git a/packages/opencode/test/session/retry.test.ts b/packages/opencode/test/session/retry.test.ts index 26c55bde71..e1cbec0364 100644 --- a/packages/opencode/test/session/retry.test.ts +++ b/packages/opencode/test/session/retry.test.ts @@ -172,6 +172,17 @@ describe("session.retry.retryable", () => { }) }) + test("retries websocket stream transport errors", () => { + const request = MessageV2.fromError( + new ProviderError.ResponseStreamError("WebSocket closed before response.completed (code 1006: Connection ended)"), + { providerID }, + ) + expect(MessageV2.APIError.isInstance(request)).toBe(true) + expect(SessionRetry.retryable(request, retryProvider)).toEqual({ + message: "WebSocket closed before response.completed (code 1006: Connection ended)", + }) + }) + test("does not retry context overflow errors", () => { const error = new MessageV2.ContextOverflowError({ message: "Input exceeds context window of this model", From 913659890d1842c4a8f9550fada1c17efe0115d8 Mon Sep 17 00:00:00 2001 From: Luke Parker <10430890+Hona@users.noreply.github.com> Date: Thu, 28 May 2026 18:28:01 +1000 Subject: [PATCH 017/113] fix(openai): keep websocket response timeouts active (#29699) --- packages/opencode/src/plugin/openai/ws.ts | 3 -- .../opencode/test/plugin/openai-ws.test.ts | 45 ++++++++++++------- 2 files changed, 29 insertions(+), 19 deletions(-) diff --git a/packages/opencode/src/plugin/openai/ws.ts b/packages/opencode/src/plugin/openai/ws.ts index cad6769609..0f60dc211e 100644 --- a/packages/opencode/src/plugin/openai/ws.ts +++ b/packages/opencode/src/plugin/openai/ws.ts @@ -159,9 +159,6 @@ export function streamResponsesWebSocket(options: StreamResponsesWebSocketOption if (!options.idleTimeout) return if (idleTimer) clearTimeout(idleTimer) idleTimer = setTimeout(() => invalidate(new ProviderError.ResponseStreamError(message)), options.idleTimeout) - if (typeof idleTimer === "object" && "unref" in idleTimer && typeof idleTimer.unref === "function") { - idleTimer.unref() - } } async function onMessage(data: WebSocket.RawData, isBinary: boolean) { diff --git a/packages/opencode/test/plugin/openai-ws.test.ts b/packages/opencode/test/plugin/openai-ws.test.ts index d27774037d..93d6f8681d 100644 --- a/packages/opencode/test/plugin/openai-ws.test.ts +++ b/packages/opencode/test/plugin/openai-ws.test.ts @@ -51,7 +51,7 @@ describe("plugin.openai.ws", () => { onConnectionInvalid: (error) => invalid.push(error.message), }) - await expect(response.text()).rejects.toThrow("idle timeout sending websocket request") + expect((await readTextError(response.text())).message).toContain("idle timeout sending websocket request") expect(invalid).toEqual(["idle timeout sending websocket request"]) }) @@ -100,7 +100,7 @@ describe("plugin.openai.ws", () => { onConnectionInvalid: (error) => invalid.push(error), }) - await expect(response.text()).rejects.toThrow( + expect((await readTextError(response.text())).message).toContain( "WebSocket closed before response.completed (code 1009: message too big: payload too large)", ) expect(invalid[0]).toBeInstanceOf(ProviderError.ResponseStreamError) @@ -124,7 +124,7 @@ describe("plugin.openai.ws", () => { onConnectionInvalid: (error) => invalid.push(error.message), }) - await expect(response.text()).rejects.toThrow("Unexpected binary WebSocket frame") + expect((await readTextError(response.text())).message).toContain("Unexpected binary WebSocket frame") expect(invalid).toEqual(["Unexpected binary WebSocket frame"]) }) }) @@ -186,7 +186,7 @@ describe("plugin.openai.ws-pool", () => { }) const first = await fetch(server.url, streamRequest({ [TITLE_HEADER]: "false" })) - await expect(first.text()).rejects.toBeInstanceOf(ProviderError.ResponseStreamError) + expect(await readTextError(first.text())).toBeInstanceOf(ProviderError.ResponseStreamError) const second = await fetch(server.url, streamRequest({ [TITLE_HEADER]: "false" })) const third = await fetch(server.url, streamRequest({ [TITLE_HEADER]: "false" })) @@ -271,7 +271,7 @@ describe("plugin.openai.ws-pool", () => { }) const first = await fetch(server.url, streamRequest()) - await expect(first.text()).rejects.toThrow("Responses websocket connection limit reached") + expect((await readTextError(first.text())).message).toContain("Responses websocket connection limit reached") const second = await fetch(server.url, streamRequest()) const text = await second.text() @@ -308,9 +308,9 @@ describe("plugin.openai.ws-pool", () => { }) const first = await fetch(server.url, streamRequest()) - await expect(first.text()).rejects.toThrow("Responses websocket connection limit reached") + expect((await readTextError(first.text())).message).toContain("Responses websocket connection limit reached") const second = await fetch(server.url, streamRequest()) - await expect(second.text()).rejects.toThrow("Responses websocket connection limit reached") + expect((await readTextError(second.text())).message).toContain("Responses websocket connection limit reached") const third = await fetch(server.url, streamRequest()) const fourth = await fetch(server.url, streamRequest()) @@ -348,7 +348,7 @@ describe("plugin.openai.ws-pool", () => { }) const first = await fetch(server.url, streamRequest()) - await expect(first.text()).rejects.toThrow("WebSocket closed before response.completed") + expect((await readTextError(first.text())).message).toContain("WebSocket closed before response.completed") const second = await fetch(server.url, streamRequest()) expect(await second.text()).toBe("http") @@ -370,7 +370,7 @@ describe("plugin.openai.ws-pool", () => { }) const first = await fetch(server.url, streamRequest()) - await expect(first.text()).rejects.toThrow("idle timeout waiting for websocket") + expect((await readTextError(first.text())).message).toContain("idle timeout waiting for websocket") const second = await fetch(server.url, streamRequest()) const third = await fetch(server.url, streamRequest()) @@ -394,9 +394,9 @@ describe("plugin.openai.ws-pool", () => { }) const first = await fetch(server.url, streamRequest()) - await expect(first.text()).rejects.toThrow("idle timeout waiting for websocket") + expect((await readTextError(first.text())).message).toContain("idle timeout waiting for websocket") const second = await fetch(server.url, streamRequest()) - await expect(second.text()).rejects.toThrow("idle timeout waiting for websocket") + expect((await readTextError(second.text())).message).toContain("idle timeout waiting for websocket") const third = await fetch(server.url, streamRequest()) expect(await third.text()).toBe("http") @@ -425,11 +425,11 @@ describe("plugin.openai.ws-pool", () => { }) const first = await fetch(server.url, streamRequest()) - await expect(first.text()).rejects.toThrow("WebSocket closed before response.completed") + expect((await readTextError(first.text())).message).toContain("WebSocket closed before response.completed") const second = await fetch(server.url, streamRequest()) expect(await second.text()).toContain("data: [DONE]") const third = await fetch(server.url, streamRequest()) - await expect(third.text()).rejects.toThrow("WebSocket closed before response.completed") + expect((await readTextError(third.text())).message).toContain("WebSocket closed before response.completed") const fourth = await fetch(server.url, streamRequest()) expect(await fourth.text()).toContain("data: [DONE]") @@ -480,7 +480,7 @@ describe("plugin.openai.ws-pool", () => { expect(server.httpRequests).toHaveLength(1) expect(connections).toBe(1) abort.abort(new Error("stop")) - await expect(firstText).rejects.toThrow("stop") + expect((await readTextError(firstText)).message).toContain("stop") fetch.close() }) @@ -518,7 +518,7 @@ describe("plugin.openai.ws-pool", () => { }) const first = await fetch(server.url, streamRequest()) - await expect(first.text()).rejects.toThrow("WebSocket closed before response.completed") + expect((await readTextError(first.text())).message).toContain("WebSocket closed before response.completed") const second = await fetch(server.url, streamRequest()) const third = await fetch(server.url, streamRequest()) @@ -550,7 +550,7 @@ describe("plugin.openai.ws-pool", () => { const firstText = first.text() await waitFor(() => connections === 1, "first websocket did not connect") abort.abort(new Error("stop")) - await expect(firstText).rejects.toThrow("stop") + expect((await readTextError(firstText)).message).toContain("stop") const second = await fetch(server.url, streamRequest()) @@ -602,6 +602,19 @@ function streamRequest(headers?: Record, signal?: AbortSignal): } } +async function readTextError(promise: Promise) { + // Bun 1.3.14 hangs on expect(response.text()).rejects for streams errored from ws callbacks. + return promise.then( + () => { + throw new Error("Expected response text to reject") + }, + (error) => { + expect(error).toBeInstanceOf(Error) + return error as Error + }, + ) +} + async function createWebSocketServer(onConnection: (socket: WebSocket, request: IncomingMessage) => void) { const http = await createHttpServer() const server = new WebSocketServer({ server: http.server }) From 56f3b9044d21bb935ff0f35134e606e25040af3d Mon Sep 17 00:00:00 2001 From: Shoubhit Dash Date: Thu, 28 May 2026 14:16:49 +0530 Subject: [PATCH 018/113] test(acp-next): add local verifier subprocess tests (#29700) --- .../cli/acp-next/acp-next-process.test.ts | 293 ------------------ .../test/cli/acp-next/config-options.test.ts | 103 ++++++ .../opencode/test/cli/acp-next/helpers.ts | 111 +++++++ .../test/cli/acp-next/initialize-auth.test.ts | 61 ++++ .../test/cli/acp-next/lifecycle.test.ts | 96 ++++++ .../opencode/test/cli/acp-next/skills.test.ts | 38 +++ .../cli/acp-next/timing-diagnostics.test.ts | 157 ++++++++++ 7 files changed, 566 insertions(+), 293 deletions(-) delete mode 100644 packages/opencode/test/cli/acp-next/acp-next-process.test.ts create mode 100644 packages/opencode/test/cli/acp-next/config-options.test.ts create mode 100644 packages/opencode/test/cli/acp-next/helpers.ts create mode 100644 packages/opencode/test/cli/acp-next/initialize-auth.test.ts create mode 100644 packages/opencode/test/cli/acp-next/lifecycle.test.ts create mode 100644 packages/opencode/test/cli/acp-next/skills.test.ts create mode 100644 packages/opencode/test/cli/acp-next/timing-diagnostics.test.ts diff --git a/packages/opencode/test/cli/acp-next/acp-next-process.test.ts b/packages/opencode/test/cli/acp-next/acp-next-process.test.ts deleted file mode 100644 index 5c01929d95..0000000000 --- a/packages/opencode/test/cli/acp-next/acp-next-process.test.ts +++ /dev/null @@ -1,293 +0,0 @@ -import { describe, expect } from "bun:test" -import type { - AuthenticateResponse, - CloseSessionResponse, - InitializeResponse, - LoadSessionResponse, - NewSessionResponse, - PromptResponse, - ResumeSessionResponse, - SessionNotification, - SetSessionConfigOptionResponse, -} from "@agentclientprotocol/sdk" -import { Effect } from "effect" -import { cliIt } from "../../lib/cli-process" -import { testProviderConfig } from "../../lib/test-provider" -import { createAcpClient, expectOk, firstAlternateValue, selectConfigOption } from "../acp/acp-test-client" - -describe("opencode acp-next (subprocess)", () => { - cliIt.live( - "responds to initialize behind OPENCODE_ACP_NEXT", - ({ opencode }) => - Effect.gen(function* () { - const acp = createAcpClient(yield* opencode.acp({ env: { OPENCODE_ACP_NEXT: "1" } })) - const initialized = expectOk( - yield* acp.request("initialize", { - protocolVersion: 1, - clientCapabilities: { _meta: { "terminal-auth": true } }, - }), - ) - - expect(initialized.protocolVersion).toBe(1) - expect(initialized.agentCapabilities?.promptCapabilities?.embeddedContext).toBe(true) - expect(initialized.agentCapabilities?.promptCapabilities?.image).toBe(true) - expect(initialized.agentCapabilities?.mcpCapabilities?.http).toBe(true) - expect(initialized.agentCapabilities?.mcpCapabilities?.sse).toBe(true) - expect(initialized.agentCapabilities?.loadSession).toBe(true) - expect(initialized.agentCapabilities?.sessionCapabilities?.close).toEqual({}) - expect(initialized.agentCapabilities?.sessionCapabilities?.fork).toEqual({}) - expect(initialized.agentCapabilities?.sessionCapabilities?.list).toEqual({}) - expect(initialized.agentCapabilities?.sessionCapabilities?.resume).toEqual({}) - expect(initialized.agentInfo?.name).toBe("OpenCode") - expect(initialized.authMethods?.[0]?.id).toBe("opencode-login") - expect(initialized.authMethods?.[0]?._meta?.["terminal-auth"]).toBeDefined() - }), - 60_000, - ) - - cliIt.live( - "authenticate succeeds for the advertised auth method and rejects unknown methods safely", - ({ opencode }) => - Effect.gen(function* () { - const acp = createAcpClient(yield* opencode.acp({ env: { OPENCODE_ACP_NEXT: "1" } })) - const initialized = expectOk(yield* acp.request("initialize", { protocolVersion: 1 })) - const methodId = initialized.authMethods?.[0]?.id - expect(methodId).toBe("opencode-login") - - expectOk(yield* acp.request("authenticate", { methodId })) - - const rejected = yield* acp.request("authenticate", { methodId: "missing-auth-method" }) - expect(errorCode(rejected.error)).toBe(-32602) - }), - 60_000, - ) - - cliIt.live( - "creates and loads sessions behind OPENCODE_ACP_NEXT", - ({ home, llm, opencode }) => - Effect.gen(function* () { - const acp = createAcpClient( - yield* opencode.acp({ - env: { - OPENCODE_ACP_NEXT: "1", - OPENCODE_CONFIG_CONTENT: JSON.stringify(testProviderConfig(llm.url)), - }, - }), - ) - yield* acp.request("initialize", { protocolVersion: 1 }) - - const session = expectOk(yield* acp.request("session/new", { cwd: home, mcpServers: [] })) - expect(typeof session.sessionId).toBe("string") - expect(selectConfigOption(session.configOptions, "model")?.category).toBe("model") - - const update = yield* acp.waitForNotification( - "session/update", - (params) => - params.sessionId === session.sessionId && params.update.sessionUpdate === "available_commands_update", - ) - expect(update.params?.sessionId).toBe(session.sessionId) - - const loaded = expectOk( - yield* acp.request("session/load", { - cwd: home, - sessionId: session.sessionId, - mcpServers: [], - }), - ) - expect(selectConfigOption(loaded.configOptions, "model")?.category).toBe("model") - - yield* llm.text("hello from acp-next", { usage: { input: 11, output: 7 } }) - const prompted = expectOk( - yield* acp.request("session/prompt", { - sessionId: session.sessionId, - prompt: [{ type: "text", text: "hello" }], - }), - ) - expect(prompted.stopReason).toBe("end_turn") - expect(prompted.usage?.totalTokens).toBeGreaterThan(0) - - const missing = yield* acp.request("session/prompt", { - sessionId: "ses_missing", - prompt: [{ type: "text", text: "hello" }], - }) - expect(errorCode(missing.error)).toBe(-32602) - }), - 60_000, - ) - - cliIt.live( - "switches model through config options behind OPENCODE_ACP_NEXT", - ({ home, llm, opencode }) => - Effect.gen(function* () { - const acp = createAcpClient( - yield* opencode.acp({ - env: { - OPENCODE_ACP_NEXT: "1", - OPENCODE_CONFIG_CONTENT: JSON.stringify(verifierConfig(llm.url)), - }, - }), - ) - yield* acp.request("initialize", { protocolVersion: 1 }) - const session = expectOk(yield* acp.request("session/new", { cwd: home, mcpServers: [] })) - - const updated = expectOk( - yield* acp.request("session/set_config_option", { - sessionId: session.sessionId, - configId: "model", - value: "test/second-model", - }), - ) - - expect(selectConfigOption(updated.configOptions, "model")?.currentValue).toBe("test/second-model") - }), - 60_000, - ) - - cliIt.live( - "switches effort through config options behind OPENCODE_ACP_NEXT", - ({ home, llm, opencode }) => - Effect.gen(function* () { - const acp = createAcpClient( - yield* opencode.acp({ - env: { - OPENCODE_ACP_NEXT: "1", - OPENCODE_CONFIG_CONTENT: JSON.stringify(verifierConfig(llm.url)), - }, - }), - ) - yield* acp.request("initialize", { protocolVersion: 1 }) - const session = expectOk(yield* acp.request("session/new", { cwd: home, mcpServers: [] })) - const effort = selectConfigOption(session.configOptions, "effort") - expect(effort?.category).toBe("thought_level") - const nextEffort = effort ? firstAlternateValue(effort) : undefined - expect(nextEffort).toBe("high") - - const updated = expectOk( - yield* acp.request("session/set_config_option", { - sessionId: session.sessionId, - configId: "effort", - value: nextEffort, - }), - ) - - expect(selectConfigOption(updated.configOptions, "effort")?.currentValue).toBe(nextEffort) - }), - 60_000, - ) - - cliIt.live( - "advertises and supports close behind OPENCODE_ACP_NEXT", - ({ home, llm, opencode }) => - Effect.gen(function* () { - const acp = createAcpClient( - yield* opencode.acp({ - env: { - OPENCODE_ACP_NEXT: "1", - OPENCODE_CONFIG_CONTENT: JSON.stringify(verifierConfig(llm.url)), - }, - }), - ) - const initialized = expectOk(yield* acp.request("initialize", { protocolVersion: 1 })) - expect(initialized.agentCapabilities?.sessionCapabilities?.close).toEqual({}) - const session = expectOk(yield* acp.request("session/new", { cwd: home, mcpServers: [] })) - - expectOk(yield* acp.request("session/close", { sessionId: session.sessionId })) - }), - 60_000, - ) - - cliIt.live( - "advertises and supports resume behind OPENCODE_ACP_NEXT", - ({ home, llm, opencode }) => - Effect.gen(function* () { - const acp = createAcpClient( - yield* opencode.acp({ - env: { - OPENCODE_ACP_NEXT: "1", - OPENCODE_CONFIG_CONTENT: JSON.stringify(verifierConfig(llm.url)), - }, - }), - ) - const initialized = expectOk(yield* acp.request("initialize", { protocolVersion: 1 })) - expect(initialized.agentCapabilities?.sessionCapabilities?.resume).toEqual({}) - const session = expectOk(yield* acp.request("session/new", { cwd: home, mcpServers: [] })) - const resumed = expectOk( - yield* acp.request("session/resume", { - cwd: home, - sessionId: session.sessionId, - mcpServers: [], - }), - ) - - expect(selectConfigOption(resumed.configOptions, "model")?.category).toBe("model") - }), - 60_000, - ) - - cliIt.live( - "exits cleanly when flagged stdin is closed", - ({ opencode }) => - Effect.gen(function* () { - const exitedPromise = yield* Effect.scoped( - Effect.gen(function* () { - const acp = yield* opencode.acp({ env: { OPENCODE_ACP_NEXT: "1" } }) - return acp.exited - }), - ) - - const code = yield* Effect.promise(() => exitedPromise) - expect(typeof code === "number" || code === null).toBe(true) - }), - 60_000, - ) - - cliIt.live( - "default unflagged path still uses production ACP", - ({ opencode }) => - Effect.gen(function* () { - const acp = createAcpClient(yield* opencode.acp()) - const initialized = expectOk(yield* acp.request("initialize", { protocolVersion: 1 })) - - expect(initialized.agentCapabilities?.sessionCapabilities?.close).toEqual({}) - expect(initialized.agentCapabilities?.sessionCapabilities?.resume).toEqual({}) - }), - 60_000, - ) -}) - -function errorCode(error: unknown) { - if (!error || typeof error !== "object") return undefined - if (!("code" in error)) return undefined - return typeof error.code === "number" ? error.code : undefined -} - -function verifierConfig(llmUrl: string) { - const config = testProviderConfig(llmUrl) - return { - ...config, - model: "test/test-model", - provider: { - test: { - ...config.provider.test, - models: { - "test-model": { - ...config.provider.test.models["test-model"], - variants: { - low: {}, - high: {}, - }, - }, - "second-model": { - ...config.provider.test.models["test-model"], - id: "second-model", - name: "Second Test Model", - variants: { - medium: {}, - max: {}, - }, - }, - }, - }, - }, - } -} diff --git a/packages/opencode/test/cli/acp-next/config-options.test.ts b/packages/opencode/test/cli/acp-next/config-options.test.ts new file mode 100644 index 0000000000..1fb69d5ef8 --- /dev/null +++ b/packages/opencode/test/cli/acp-next/config-options.test.ts @@ -0,0 +1,103 @@ +import { describe, expect } from "bun:test" +import type { SetSessionConfigOptionResponse } from "@agentclientprotocol/sdk" +import { Effect } from "effect" +import { cliIt } from "../../lib/cli-process" +import { expectOk, flattenSelectOptions, selectConfigOption } from "../acp/acp-test-client" +import { + createAcpNextClient, + expectAlternateValue, + expectSelectOption, + initialize, + newSession, + verifierConfig, +} from "./helpers" + +describe("opencode acp-next config option subprocess", () => { + cliIt.live( + 'model option is listed with category "model"', + ({ home, llm, opencode }) => + Effect.gen(function* () { + const acp = yield* createAcpNextClient( + { opencode }, + { OPENCODE_CONFIG_CONTENT: JSON.stringify(verifierConfig(llm.url)) }, + ) + yield* initialize(acp) + const model = expectSelectOption((yield* newSession(acp, home)).configOptions, "model") + + expect(model.category).toBe("model") + expect(model.currentValue).toBe("test/test-model") + expect(flattenSelectOptions(model).length).toBeGreaterThanOrEqual(2) + }), + 60_000, + ) + + cliIt.live( + "model switch updates currentValue", + ({ home, llm, opencode }) => + Effect.gen(function* () { + const acp = yield* createAcpNextClient( + { opencode }, + { OPENCODE_CONFIG_CONTENT: JSON.stringify(verifierConfig(llm.url)) }, + ) + yield* initialize(acp) + const session = yield* newSession(acp, home) + const model = expectSelectOption(session.configOptions, "model") + const nextModel = flattenSelectOptions(model).find((option) => option.value === "test/second-model")?.value + expect(nextModel).toBe("test/second-model") + + const updated = expectOk( + yield* acp.request("session/set_config_option", { + sessionId: session.sessionId, + configId: "model", + value: nextModel, + }), + ) + + expect(selectConfigOption(updated.configOptions, "model")?.currentValue).toBe(nextModel) + }), + 60_000, + ) + + cliIt.live( + 'effort option is listed with category "thought_level" when selected model supports variants', + ({ home, llm, opencode }) => + Effect.gen(function* () { + const acp = yield* createAcpNextClient( + { opencode }, + { OPENCODE_CONFIG_CONTENT: JSON.stringify(verifierConfig(llm.url)) }, + ) + yield* initialize(acp) + const effort = expectSelectOption((yield* newSession(acp, home)).configOptions, "effort") + + expect(effort.category).toBe("thought_level") + expect(effort.currentValue).toBe("low") + expect(flattenSelectOptions(effort).map((option) => option.value)).toEqual(["low", "high"]) + }), + 60_000, + ) + + cliIt.live( + "effort switch updates currentValue", + ({ home, llm, opencode }) => + Effect.gen(function* () { + const acp = yield* createAcpNextClient( + { opencode }, + { OPENCODE_CONFIG_CONTENT: JSON.stringify(verifierConfig(llm.url)) }, + ) + yield* initialize(acp) + const session = yield* newSession(acp, home) + const nextEffort = expectAlternateValue(expectSelectOption(session.configOptions, "effort")) + + const updated = expectOk( + yield* acp.request("session/set_config_option", { + sessionId: session.sessionId, + configId: "effort", + value: nextEffort, + }), + ) + + expect(selectConfigOption(updated.configOptions, "effort")?.currentValue).toBe(nextEffort) + }), + 60_000, + ) +}) diff --git a/packages/opencode/test/cli/acp-next/helpers.ts b/packages/opencode/test/cli/acp-next/helpers.ts new file mode 100644 index 0000000000..4bd1bb9e97 --- /dev/null +++ b/packages/opencode/test/cli/acp-next/helpers.ts @@ -0,0 +1,111 @@ +import { expect } from "bun:test" +import type { InitializeResponse, NewSessionResponse, SessionConfigOption } from "@agentclientprotocol/sdk" +import { Effect } from "effect" +import type { CliFixture } from "../../lib/cli-process" +import { testProviderConfig } from "../../lib/test-provider" +import { + createAcpClient, + expectOk, + flattenSelectOptions, + selectConfigOption, + type AcpClient, +} from "../acp/acp-test-client" + +export const diagnosticFirstSessionThresholdMs = 15_000 +export const diagnosticFastPathThresholdMs = 15_000 + +// TODO: tighten to the public verifier target of 500ms once acp-next startup is optimized. +export const finalFirstSessionThresholdMs = 500 +// TODO: tighten warm session/config/skill fast paths to the public verifier target of 100ms. +export const finalFastPathThresholdMs = 100 + +export function createAcpNextClient(input: Pick, env?: Record) { + return Effect.gen(function* () { + return createAcpClient( + yield* input.opencode.acp({ + env: { + OPENCODE_ACP_NEXT: "1", + ...env, + }, + }), + ) + }) +} + +export function initialize(acp: AcpClient) { + return Effect.gen(function* () { + return expectOk( + yield* acp.request("initialize", { + protocolVersion: 1, + clientCapabilities: { _meta: { "terminal-auth": true } }, + clientInfo: { name: "opencode-local-acp-next", version: "0.1.0" }, + }), + ) + }) +} + +export function newSession(acp: AcpClient, cwd: string) { + return Effect.gen(function* () { + return expectOk(yield* acp.request("session/new", { cwd, mcpServers: [] })) + }) +} + +export function verifierConfig(llmUrl: string, skills?: string) { + const config = testProviderConfig(llmUrl) + return { + ...config, + model: "test/test-model", + ...(skills ? { skills: { paths: [skills] } } : {}), + provider: { + test: { + ...config.provider.test, + models: { + "test-model": { + ...config.provider.test.models["test-model"], + variants: { + low: {}, + high: {}, + }, + }, + "second-model": { + ...config.provider.test.models["test-model"], + id: "second-model", + name: "Second Test Model", + variants: { + medium: {}, + max: {}, + }, + }, + }, + }, + }, + } +} + +export function expectErrorCode(error: unknown, code: number) { + if (!error || typeof error !== "object" || !("code" in error)) { + expect(error).toEqual({ code }) + return + } + expect(error.code).toBe(code) +} + +export function expectSelectOption(options: SessionConfigOption[] | null | undefined, id: string) { + const option = selectConfigOption(options, id) + expect(option).toBeDefined() + return option! +} + +export function expectAlternateValue(option: ReturnType) { + const value = flattenSelectOptions(option).find((item) => item.value !== option.currentValue)?.value + expect(value).toBeDefined() + return value! +} + +export const verifierSkill = `--- +name: verifier-skill +description: Verifier compatibility skill. +--- + +# Verifier Skill +` diff --git a/packages/opencode/test/cli/acp-next/initialize-auth.test.ts b/packages/opencode/test/cli/acp-next/initialize-auth.test.ts new file mode 100644 index 0000000000..72b1187656 --- /dev/null +++ b/packages/opencode/test/cli/acp-next/initialize-auth.test.ts @@ -0,0 +1,61 @@ +import { describe, expect } from "bun:test" +import type { AuthenticateResponse, InitializeResponse } from "@agentclientprotocol/sdk" +import { Effect } from "effect" +import { cliIt } from "../../lib/cli-process" +import { createAcpNextClient, expectErrorCode, initialize } from "./helpers" + +describe("opencode acp-next initialize/auth subprocess", () => { + cliIt.live( + "initialize responds with capabilities", + ({ opencode }) => + Effect.gen(function* () { + const initialized = yield* initialize(yield* createAcpNextClient({ opencode })) + + expect(initialized.protocolVersion).toBe(1) + expect(initialized.agentCapabilities?.promptCapabilities?.embeddedContext).toBe(true) + expect(initialized.agentCapabilities?.promptCapabilities?.image).toBe(true) + expect(initialized.agentCapabilities?.mcpCapabilities?.http).toBe(true) + expect(initialized.agentCapabilities?.mcpCapabilities?.sse).toBe(true) + expect(initialized.agentCapabilities?.loadSession).toBe(true) + expect(initialized.agentCapabilities?.sessionCapabilities?.close).toEqual({}) + expect(initialized.agentCapabilities?.sessionCapabilities?.fork).toEqual({}) + expect(initialized.agentCapabilities?.sessionCapabilities?.list).toEqual({}) + expect(initialized.agentCapabilities?.sessionCapabilities?.resume).toEqual({}) + expect(initialized.agentInfo?.name).toBe("OpenCode") + }), + 60_000, + ) + + cliIt.live( + "auth negotiation is explicit and safe", + ({ opencode }) => + Effect.gen(function* () { + const acp = yield* createAcpNextClient({ opencode }) + const initialized = yield* initialize(acp) + + expect(initialized.authMethods?.[0]?.id).toBe("opencode-login") + expect(initialized.authMethods?.[0]?._meta?.["terminal-auth"]).toBeDefined() + expect(yield* acp.request("authenticate", { methodId: "opencode-login" })).toMatchObject( + { result: {} }, + ) + + const rejected = yield* acp.request("authenticate", { methodId: "missing-auth-method" }) + expectErrorCode(rejected.error, -32602) + expect(JSON.stringify(rejected.error)).not.toContain(process.env.OPENCODE_AUTH_CONTENT ?? "not-present") + }), + 60_000, + ) + + cliIt.live( + "initialize without terminal-auth metadata keeps auth command implicit", + ({ opencode }) => + Effect.gen(function* () { + const acp = yield* createAcpNextClient({ opencode }) + const initialized = yield* acp.request("initialize", { protocolVersion: 1 }) + + expect(initialized.result?.authMethods?.[0]?.id).toBe("opencode-login") + expect(initialized.result?.authMethods?.[0]?._meta?.["terminal-auth"]).toBeUndefined() + }), + 60_000, + ) +}) diff --git a/packages/opencode/test/cli/acp-next/lifecycle.test.ts b/packages/opencode/test/cli/acp-next/lifecycle.test.ts new file mode 100644 index 0000000000..8e9793921f --- /dev/null +++ b/packages/opencode/test/cli/acp-next/lifecycle.test.ts @@ -0,0 +1,96 @@ +import { describe, expect } from "bun:test" +import type { CloseSessionResponse, LoadSessionResponse, ResumeSessionResponse } from "@agentclientprotocol/sdk" +import { Duration, Effect } from "effect" +import { cliIt } from "../../lib/cli-process" +import { expectOk, selectConfigOption } from "../acp/acp-test-client" +import { createAcpNextClient, initialize, newSession, verifierConfig } from "./helpers" + +describe("opencode acp-next lifecycle subprocess", () => { + cliIt.live( + "stdin EOF exits cleanly", + ({ opencode }) => + Effect.gen(function* () { + const acp = yield* opencode.acp({ env: { OPENCODE_ACP_NEXT: "1" } }) + acp.close() + + const code = yield* Effect.promise(() => acp.exited).pipe(Effect.timeout(Duration.seconds(5))) + expect(code).toBe(0) + }), + 60_000, + ) + + cliIt.live( + "close capability and close request", + ({ home, llm, opencode }) => + Effect.gen(function* () { + const acp = yield* createAcpNextClient( + { opencode }, + { OPENCODE_CONFIG_CONTENT: JSON.stringify(verifierConfig(llm.url)) }, + ) + const initialized = yield* initialize(acp) + expect(initialized.agentCapabilities?.sessionCapabilities?.close).toEqual({}) + + const session = yield* newSession(acp, home) + expectOk(yield* acp.request("session/close", { sessionId: session.sessionId })) + }), + 60_000, + ) + + cliIt.live( + "loadSession capability and load request return session config options", + ({ home, llm, opencode }) => + Effect.gen(function* () { + const acp = yield* createAcpNextClient( + { opencode }, + { OPENCODE_CONFIG_CONTENT: JSON.stringify(verifierConfig(llm.url)) }, + ) + const initialized = yield* initialize(acp) + expect(initialized.agentCapabilities?.loadSession).toBe(true) + const session = yield* newSession(acp, home) + const loaded = expectOk( + yield* acp.request("session/load", { + cwd: home, + sessionId: session.sessionId, + mcpServers: [], + }), + ) + + expect(selectConfigOption(loaded.configOptions, "model")?.category).toBe("model") + }), + 60_000, + ) + + cliIt.live( + "resume capability advertisement", + ({ opencode }) => + Effect.gen(function* () { + const initialized = yield* initialize(yield* createAcpNextClient({ opencode })) + + expect(initialized.agentCapabilities?.sessionCapabilities?.resume).toEqual({}) + }), + 60_000, + ) + + cliIt.live( + "resume request returns session config options", + ({ home, llm, opencode }) => + Effect.gen(function* () { + const acp = yield* createAcpNextClient( + { opencode }, + { OPENCODE_CONFIG_CONTENT: JSON.stringify(verifierConfig(llm.url)) }, + ) + yield* initialize(acp) + const session = yield* newSession(acp, home) + const resumed = expectOk( + yield* acp.request("session/resume", { + cwd: home, + sessionId: session.sessionId, + mcpServers: [], + }), + ) + + expect(selectConfigOption(resumed.configOptions, "model")?.category).toBe("model") + }), + 60_000, + ) +}) diff --git a/packages/opencode/test/cli/acp-next/skills.test.ts b/packages/opencode/test/cli/acp-next/skills.test.ts new file mode 100644 index 0000000000..0485fa56d4 --- /dev/null +++ b/packages/opencode/test/cli/acp-next/skills.test.ts @@ -0,0 +1,38 @@ +import { describe, expect } from "bun:test" +import type { SessionNotification } from "@agentclientprotocol/sdk" +import { Effect } from "effect" +import { mkdir } from "node:fs/promises" +import path from "node:path" +import { cliIt } from "../../lib/cli-process" +import { createAcpNextClient, initialize, newSession, verifierConfig, verifierSkill } from "./helpers" + +describe("opencode acp-next skills subprocess", () => { + cliIt.live( + "skill slash command appears through available_commands_update", + ({ home, llm, opencode }) => + Effect.gen(function* () { + const skills = path.join(home, "skills") + yield* Effect.promise(() => mkdir(path.join(skills, "verifier-skill"), { recursive: true })) + yield* Effect.promise(() => Bun.write(path.join(skills, "verifier-skill", "SKILL.md"), verifierSkill)) + const acp = yield* createAcpNextClient( + { opencode }, + { OPENCODE_CONFIG_CONTENT: JSON.stringify(verifierConfig(llm.url, skills)) }, + ) + yield* initialize(acp) + const session = yield* newSession(acp, home) + + const update = yield* acp.waitForNotification( + "session/update", + (params) => + params.sessionId === session.sessionId && + params.update.sessionUpdate === "available_commands_update" && + params.update.availableCommands.some( + (command) => command.name === "verifier-skill" && command.description.length > 0, + ), + ) + + expect(update.params?.sessionId).toBe(session.sessionId) + }), + 60_000, + ) +}) diff --git a/packages/opencode/test/cli/acp-next/timing-diagnostics.test.ts b/packages/opencode/test/cli/acp-next/timing-diagnostics.test.ts new file mode 100644 index 0000000000..c70a82d6b5 --- /dev/null +++ b/packages/opencode/test/cli/acp-next/timing-diagnostics.test.ts @@ -0,0 +1,157 @@ +import { describe, expect } from "bun:test" +import type { SessionNotification, SetSessionConfigOptionResponse } from "@agentclientprotocol/sdk" +import { Effect } from "effect" +import { mkdir } from "node:fs/promises" +import path from "node:path" +import { cliIt } from "../../lib/cli-process" +import { expectOk, flattenSelectOptions } from "../acp/acp-test-client" +import { + createAcpNextClient, + diagnosticFastPathThresholdMs, + diagnosticFirstSessionThresholdMs, + expectAlternateValue, + expectSelectOption, + finalFastPathThresholdMs, + finalFirstSessionThresholdMs, + initialize, + newSession, + verifierConfig, + verifierSkill, +} from "./helpers" + +describe("opencode acp-next verifier timing diagnostics", () => { + cliIt.live( + "first session timing diagnostic stays below generous threshold", + ({ home, llm, opencode }) => + Effect.gen(function* () { + const acp = yield* createAcpNextClient( + { opencode }, + { OPENCODE_CONFIG_CONTENT: JSON.stringify(verifierConfig(llm.url)) }, + ) + const started = performance.now() + yield* initialize(acp) + const session = yield* newSession(acp, home) + const durationMs = Math.round(performance.now() - started) + + expect(session.sessionId).toBeTruthy() + // TODO: replace this diagnostic assertion with finalFirstSessionThresholdMs. + expect(durationMs).toBeLessThan(diagnosticFirstSessionThresholdMs) + expect(finalFirstSessionThresholdMs).toBe(500) + }), + 60_000, + ) + + cliIt.live( + "warm new session timing diagnostic stays below generous threshold", + ({ home, llm, opencode }) => + Effect.gen(function* () { + const acp = yield* createAcpNextClient( + { opencode }, + { OPENCODE_CONFIG_CONTENT: JSON.stringify(verifierConfig(llm.url)) }, + ) + yield* initialize(acp) + yield* newSession(acp, home) + + const started = performance.now() + const session = yield* newSession(acp, home) + const durationMs = Math.round(performance.now() - started) + + expect(session.sessionId).toBeTruthy() + // TODO: replace this diagnostic assertion with finalFastPathThresholdMs. + expect(durationMs).toBeLessThan(diagnosticFastPathThresholdMs) + expect(finalFastPathThresholdMs).toBe(100) + }), + 60_000, + ) + + cliIt.live( + "model switch timing diagnostic updates currentValue below generous threshold", + ({ home, llm, opencode }) => + Effect.gen(function* () { + const acp = yield* createAcpNextClient( + { opencode }, + { OPENCODE_CONFIG_CONTENT: JSON.stringify(verifierConfig(llm.url)) }, + ) + yield* initialize(acp) + const session = yield* newSession(acp, home) + const model = expectSelectOption(session.configOptions, "model") + const nextModel = flattenSelectOptions(model).find((option) => option.value === "test/second-model")?.value + if (!nextModel) throw new Error("expected second test model") + + const started = performance.now() + const updated = expectOk( + yield* acp.request("session/set_config_option", { + sessionId: session.sessionId, + configId: "model", + value: nextModel, + }), + ) + const durationMs = Math.round(performance.now() - started) + + expect(expectSelectOption(updated.configOptions, "model").currentValue).toBe(nextModel) + // TODO: replace this diagnostic assertion with finalFastPathThresholdMs. + expect(durationMs).toBeLessThan(diagnosticFastPathThresholdMs) + }), + 60_000, + ) + + cliIt.live( + "effort switch timing diagnostic updates currentValue below generous threshold", + ({ home, llm, opencode }) => + Effect.gen(function* () { + const acp = yield* createAcpNextClient( + { opencode }, + { OPENCODE_CONFIG_CONTENT: JSON.stringify(verifierConfig(llm.url)) }, + ) + yield* initialize(acp) + const session = yield* newSession(acp, home) + const nextEffort = expectAlternateValue(expectSelectOption(session.configOptions, "effort")) + + const started = performance.now() + const updated = expectOk( + yield* acp.request("session/set_config_option", { + sessionId: session.sessionId, + configId: "effort", + value: nextEffort, + }), + ) + const durationMs = Math.round(performance.now() - started) + + expect(expectSelectOption(updated.configOptions, "effort").currentValue).toBe(nextEffort) + // TODO: replace this diagnostic assertion with finalFastPathThresholdMs. + expect(durationMs).toBeLessThan(diagnosticFastPathThresholdMs) + }), + 60_000, + ) + + cliIt.live( + "warm skill command timing diagnostic stays below generous threshold", + ({ home, llm, opencode }) => + Effect.gen(function* () { + const skills = path.join(home, "skills") + yield* Effect.promise(() => mkdir(path.join(skills, "verifier-skill"), { recursive: true })) + yield* Effect.promise(() => Bun.write(path.join(skills, "verifier-skill", "SKILL.md"), verifierSkill)) + const acp = yield* createAcpNextClient( + { opencode }, + { OPENCODE_CONFIG_CONTENT: JSON.stringify(verifierConfig(llm.url, skills)) }, + ) + yield* initialize(acp) + yield* newSession(acp, home) + const secondSession = yield* newSession(acp, home) + + const started = performance.now() + yield* acp.waitForNotification( + "session/update", + (params) => + params.sessionId === secondSession.sessionId && + params.update.sessionUpdate === "available_commands_update" && + params.update.availableCommands.some((command) => command.name === "verifier-skill"), + ) + const durationMs = Math.round(performance.now() - started) + + // TODO: replace this diagnostic assertion with finalFastPathThresholdMs. + expect(durationMs).toBeLessThan(diagnosticFastPathThresholdMs) + }), + 60_000, + ) +}) From 66e6662440703239d48f964ae75eb4aa6c1c6fd3 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Thu, 28 May 2026 08:48:09 +0000 Subject: [PATCH 019/113] chore: generate --- packages/opencode/test/cli/acp-next/initialize-auth.test.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/opencode/test/cli/acp-next/initialize-auth.test.ts b/packages/opencode/test/cli/acp-next/initialize-auth.test.ts index 72b1187656..9db7c75c8a 100644 --- a/packages/opencode/test/cli/acp-next/initialize-auth.test.ts +++ b/packages/opencode/test/cli/acp-next/initialize-auth.test.ts @@ -35,9 +35,9 @@ describe("opencode acp-next initialize/auth subprocess", () => { expect(initialized.authMethods?.[0]?.id).toBe("opencode-login") expect(initialized.authMethods?.[0]?._meta?.["terminal-auth"]).toBeDefined() - expect(yield* acp.request("authenticate", { methodId: "opencode-login" })).toMatchObject( - { result: {} }, - ) + expect(yield* acp.request("authenticate", { methodId: "opencode-login" })).toMatchObject({ + result: {}, + }) const rejected = yield* acp.request("authenticate", { methodId: "missing-auth-method" }) expectErrorCode(rejected.error, -32602) From 2449b505856d9af090564419106ed0e3dc76fde5 Mon Sep 17 00:00:00 2001 From: Shoubhit Dash Date: Thu, 28 May 2026 14:53:47 +0530 Subject: [PATCH 020/113] fix(acp): improve acp-next first-session startup (#29709) --- packages/opencode/src/acp-next/profile.ts | 42 +++++ packages/opencode/src/acp-next/service.ts | 174 +++++++++--------- packages/opencode/src/cli/cmd/acp.ts | 7 +- .../test/acp-next/service-session.test.ts | 40 ++++ .../opencode/test/cli/acp-next/helpers.ts | 2 +- 5 files changed, 181 insertions(+), 84 deletions(-) create mode 100644 packages/opencode/src/acp-next/profile.ts diff --git a/packages/opencode/src/acp-next/profile.ts b/packages/opencode/src/acp-next/profile.ts new file mode 100644 index 0000000000..4b79d6e903 --- /dev/null +++ b/packages/opencode/src/acp-next/profile.ts @@ -0,0 +1,42 @@ +const enabled = process.env.OPENCODE_ACP_PROFILE === "1" +const started = performance.now() + +export function mark(name: string, fields?: Record) { + if (!enabled) return + write(`${name}.mark`, performance.now() - started, fields) +} + +export function duration( + name: string, + startedAt: number, + fields?: Record, +) { + if (!enabled) return + write(name, performance.now() - startedAt, fields) +} + +export async function measure( + name: string, + fn: () => Promise, + fields?: Record, +) { + if (!enabled) return fn() + const start = performance.now() + try { + return await fn() + } finally { + write(name, performance.now() - start, fields) + } +} + +function write(name: string, durationMs: number, fields?: Record) { + const extra = fields + ? Object.entries(fields) + .filter((entry): entry is [string, string | number | boolean] => entry[1] !== undefined) + .map(([key, value]) => `${key}=${value}`) + .join(" ") + : "" + console.error(`[acp-profile] ${name} ${Math.round(durationMs)}ms${extra ? ` ${extra}` : ""}`) +} + +export * as ACPNextProfile from "./profile" diff --git a/packages/opencode/src/acp-next/service.ts b/packages/opencode/src/acp-next/service.ts index 04cf71fff8..0e0d0f8352 100644 --- a/packages/opencode/src/acp-next/service.ts +++ b/packages/opencode/src/acp-next/service.ts @@ -40,6 +40,7 @@ import { Directory } from "./directory" import { ACPNextEvent } from "./event" import { ACPNextSession } from "./session" import { UsageService } from "./usage" +import { ACPNextProfile } from "./profile" import { ModelID, ProviderID } from "@/provider/schema" import { Provider } from "@/provider/provider" import type { Command } from "@/command" @@ -88,6 +89,7 @@ export function make(input: { if (events) input.eventSubscription?.(events) const initialize = Effect.fn("ACPNext.initialize")(function* (params: InitializeRequest) { + const started = performance.now() const authMethod: AuthMethod = { description: "Run `opencode auth login` in the terminal", name: "Login with opencode", @@ -104,7 +106,7 @@ export function make(input: { } } - return { + const response = { protocolVersion: 1, agentCapabilities: { loadSession: true, @@ -129,6 +131,8 @@ export function make(input: { version: InstallationVersion, }, } + ACPNextProfile.duration("acp.initialize", started) + return response }) const authenticate = Effect.fn("ACPNext.authenticate")(function* (params: AuthenticateRequest) { @@ -139,15 +143,20 @@ export function make(input: { }) const directorySnapshot = Effect.fn("ACPNext.directorySnapshot")(function* (cwd: string) { - return yield* directoryService.get(cwd) + const started = performance.now() + const snapshot = yield* directoryService.get(cwd) + ACPNextProfile.duration("acp.directory.snapshot", started) + return snapshot }) const newSession = Effect.fn("ACPNext.newSession")(function* (params: NewSessionRequest) { + const started = performance.now() const snapshot = yield* directorySnapshot(params.cwd) const selected = selectDefaultModel(snapshot) const variant = selectVariant(snapshot, selected) const modeId = snapshot.availableModes.length > 0 ? snapshot.defaultModeID : undefined - const created = yield* request( + const created = yield* profiledRequest( + "acp.newSession.session.create", () => input.sdk.session.create( { @@ -175,7 +184,7 @@ export function make(input: { yield* registerMcpServers(input.sdk, registeredMcp, params.cwd, state.id, params.mcpServers) yield* sendAvailableCommands(input.connection, state.id, snapshot) - return { + const response = { sessionId: state.id, configOptions: configOptions(snapshot, { model: state.model ?? selected, @@ -183,6 +192,8 @@ export function make(input: { modeId: state.modeId, }), } + ACPNextProfile.duration("acp.newSession", started) + return response }) const loadSession = Effect.fn("ACPNext.loadSession")(function* (params: LoadSessionRequest) { @@ -684,66 +695,79 @@ function request(fn: () => Promise>, service?: string) { }) } -async function loadDirectorySnapshot(sdk: OpencodeClient, directory: string) { - const [providersResponse, agentsResponse, commandsResponse, skillsResponse] = await Promise.all([ - sdk.config.providers({ directory }, { throwOnError: true }), - sdk.app.agents({ directory }, { throwOnError: true }), - sdk.command.list({ directory }, { throwOnError: true }), - sdk.app.skills({ directory }, { throwOnError: true }), - ]) - const providersData = providersResponse.data! - const agents = agentsResponse.data! - const commandsData = commandsResponse.data! - const skills = skillsResponse.data! - const providers = Object.fromEntries(providersData.providers.map((provider) => [provider.id, provider])) as Record< - ProviderID, - Provider.Info - > - const defaultModel = await defaultModelFromSdk(sdk, directory, providers) - const modes = agents - .filter((agent) => agent.mode !== "subagent" && agent.hidden !== true) - .map((agent) => ({ - id: agent.name, - name: agent.name, - ...(agent.description ? { description: agent.description } : {}), - })) - const commands = [ - ...commandsData, - ...skills - .filter((skill) => !commandsData.some((command) => command.name === skill.name)) - .map((skill) => ({ - name: skill.name, - description: skill.description, - source: "skill" as const, - template: skill.content, - hints: [], - })), - ] as Command.Info[] +function profiledRequest(name: string, fn: () => Promise>, service?: string) { + return request(() => ACPNextProfile.measure(name, fn), service) +} - return Directory.build({ - directory, - providers, - modes, - defaultModeID: agents.find((agent) => agent.mode === "primary" && agent.hidden !== true)?.name ?? "build", - commands: commands.toSorted((a, b) => a.name.localeCompare(b.name)), - ...(defaultModel ? { defaultModel } : {}), +async function loadDirectorySnapshot(sdk: OpencodeClient, directory: string) { + return ACPNextProfile.measure("acp.directory.load", async () => { + const [providersResponse, agentsResponse, commandsResponse, skillsResponse, configResponse] = await Promise.all([ + ACPNextProfile.measure("acp.directory.provider.list", () => + sdk.config.providers({ directory }, { throwOnError: true }), + ), + ACPNextProfile.measure("acp.directory.mode.defaultAgent.load", () => + sdk.app.agents({ directory }, { throwOnError: true }), + ), + ACPNextProfile.measure("acp.directory.command.list", () => + sdk.command.list({ directory }, { throwOnError: true }), + ), + ACPNextProfile.measure("acp.directory.skill.list", () => sdk.app.skills({ directory }, { throwOnError: true })), + ACPNextProfile.measure("acp.directory.defaultModel.config", () => + sdk.config.get({ directory }, { throwOnError: true }).catch(() => undefined), + ), + ]) + const providersData = providersResponse.data! + const agents = agentsResponse.data! + const commandsData = commandsResponse.data! + const skills = skillsResponse.data! + const providers = Object.fromEntries(providersData.providers.map((provider) => [provider.id, provider])) as Record< + ProviderID, + Provider.Info + > + const defaultModelStarted = performance.now() + const defaultModel = defaultModelFromConfig(configResponse?.data?.model, providers) + ACPNextProfile.duration("acp.directory.defaultModel.resolve", defaultModelStarted, { configured: !!defaultModel }) + const modes = agents + .filter((agent) => agent.mode !== "subagent" && agent.hidden !== true) + .map((agent) => ({ + id: agent.name, + name: agent.name, + ...(agent.description ? { description: agent.description } : {}), + })) + const commands = [ + ...commandsData, + ...skills + .filter((skill) => !commandsData.some((command) => command.name === skill.name)) + .map((skill) => ({ + name: skill.name, + description: skill.description, + source: "skill" as const, + template: skill.content, + hints: [], + })), + ] as Command.Info[] + + return Directory.build({ + directory, + providers, + modes, + defaultModeID: agents.find((agent) => agent.mode === "primary" && agent.hidden !== true)?.name ?? "build", + commands: commands.toSorted((a, b) => a.name.localeCompare(b.name)), + ...(defaultModel ? { defaultModel } : {}), + }) }) } -async function defaultModelFromSdk( - sdk: OpencodeClient, - directory: string, +function defaultModelFromConfig( + configuredModel: string | undefined, providers: Record, -): Promise { - const configured = await sdk.config - .get({ directory }, { throwOnError: true }) - .then((response) => (response.data?.model ? Provider.parseModel(response.data.model) : undefined)) - .catch(() => undefined) +): Directory.DefaultModel | undefined { + const configured = configuredModel ? Provider.parseModel(configuredModel) : undefined if (configured && providers[configured.providerID]?.models[configured.modelID]) return configured - const lastUsed = await lastUsedModel(sdk, directory, providers) - if (lastUsed) return lastUsed - + // First-session ACP startup must not scan historical sessions just to infer + // a default. Configured model, opencode provider, then sorted best model keep + // the protocol response deterministic without extra session/message reads. const opencodeProvider = providers[ProviderID.make("opencode")] const opencodeModel = opencodeProvider ? Provider.sort(Object.values(opencodeProvider.models))[0] : undefined if (opencodeProvider && opencodeModel) return { providerID: opencodeProvider.id, modelID: opencodeModel.id } @@ -753,30 +777,6 @@ async function defaultModelFromSdk( if (configured) return configured } -async function lastUsedModel( - sdk: OpencodeClient, - directory: string, - providers: Record, -): Promise { - const session = await sdk.session - .list({ directory, roots: true, limit: 1 }, { throwOnError: true }) - .then((response) => response.data?.[0]) - .catch(() => undefined) - if (!session) return - - const lastUser = await sdk.session - .messages({ directory, sessionID: session.id, limit: 20 }, { throwOnError: true }) - .then((response) => response.data?.findLast((message) => message.info.role === "user")?.info) - .catch(() => undefined) - if (lastUser?.role !== "user") return - if (!providers[ProviderID.make(lastUser.model.providerID)]?.models[ModelID.make(lastUser.model.modelID)]) return - - return { - providerID: ProviderID.make(lastUser.model.providerID), - modelID: ModelID.make(lastUser.model.modelID), - } -} - function selectDefaultModel(snapshot: Directory.Snapshot) { if (snapshot.defaultModel) return snapshot.defaultModel const model = snapshot.modelOptions[0] @@ -891,6 +891,7 @@ function registerMcpServers( sessionId: string, servers: readonly McpServer[], ) { + const started = performance.now() const current = registered.get(sessionId) ?? new Set() registered.set(sessionId, current) const pending = new Set() @@ -922,7 +923,16 @@ function registerMcpServers( ), ), { concurrency: "unbounded" }, - ).pipe(Effect.asVoid) + ).pipe( + Effect.tap(() => + Effect.sync(() => + ACPNextProfile.duration("acp.mcp.register", started, { + count: pending.size, + }), + ), + ), + Effect.asVoid, + ) } function mcpRegistrationKey(name: string, config: ReturnType) { diff --git a/packages/opencode/src/cli/cmd/acp.ts b/packages/opencode/src/cli/cmd/acp.ts index b113a278f9..35abfec7e5 100644 --- a/packages/opencode/src/cli/cmd/acp.ts +++ b/packages/opencode/src/cli/cmd/acp.ts @@ -9,6 +9,7 @@ import { ServerAuth } from "@/server/auth" import { createOpencodeClient } from "@opencode-ai/sdk/v2" import { withNetworkOptions, resolveNetworkOptions } from "../network" import { RuntimeFlags } from "@/effect/runtime-flags" +import { ACPNextProfile } from "@/acp-next/profile" const log = Log.create({ service: "acp-command" }) @@ -23,10 +24,13 @@ export const AcpCommand = effectCmd({ }) }, handler: Effect.fn("Cli.acp")(function* (args) { + ACPNextProfile.mark("cli.acp.handler") process.env.OPENCODE_CLIENT = "acp" const flags = yield* RuntimeFlags.Service const opts = yield* resolveNetworkOptions(args) - const server = yield* Effect.promise(() => Server.listen(opts)) + const server = yield* Effect.promise(() => + ACPNextProfile.measure("cli.acp.server.listen", () => Server.listen(opts)), + ) const sdk = createOpencodeClient({ baseUrl: `http://${server.hostname}:${server.port}`, @@ -60,6 +64,7 @@ export const AcpCommand = effectCmd({ const agent = flags.acpNext ? ACPNext.init({ sdk }) : ACP.init({ sdk }) new AgentSideConnection((conn) => { + ACPNextProfile.mark("cli.acp.connection.create", { acpNext: flags.acpNext }) return agent.create(conn, { sdk }) }, stream) diff --git a/packages/opencode/test/acp-next/service-session.test.ts b/packages/opencode/test/acp-next/service-session.test.ts index 4c0563b63e..2ea31a96c8 100644 --- a/packages/opencode/test/acp-next/service-session.test.ts +++ b/packages/opencode/test/acp-next/service-session.test.ts @@ -602,6 +602,46 @@ describe("ACP next service sessions", () => { expect(result.configOptions?.find((option) => option.id === "model")?.currentValue).toBe("test/configured-model") }) + it("does not scan last-used sessions when resolving the new session default", async () => { + const historyCalls: string[] = [] + const sdk = { + config: { + providers: () => Promise.resolve({ data: { providers: [provider], default: { test: modelID } } }), + get: () => Promise.resolve({ data: {} }), + }, + app: { + agents: () => Promise.resolve({ data: [{ name: "build", mode: "primary", permission: [], options: {} }] }), + skills: () => Promise.resolve({ data: [] }), + }, + command: { + list: () => Promise.resolve({ data: [] }), + }, + session: { + create: (input: { model?: { id?: string } }) => Promise.resolve({ data: { id: input.model?.id } }), + list: () => { + historyCalls.push("list") + return Promise.resolve({ data: [{ id: "ses_recent" }] }) + }, + messages: () => { + historyCalls.push("messages") + return Promise.resolve({ + data: [{ info: { role: "user", model: { providerID: "test", modelID: "second-model" } } }], + }) + }, + }, + mcp: { + add: () => Promise.resolve({ data: {} }), + }, + } as unknown as OpencodeClient + const service = ACPNextService.make({ sdk }) + + const result = await Effect.runPromise(service.newSession({ cwd: "/workspace", mcpServers: [] })) + + expect(result.sessionId).toBe("test-model") + expect(result.configOptions?.find((option) => option.id === "model")?.currentValue).toBe("test/test-model") + expect(historyCalls).toEqual([]) + }) + it("switches model and returns updated model and effort options", async () => { const { service } = makeService() const session = await Effect.runPromise(service.newSession({ cwd: "/workspace", mcpServers: [] })) diff --git a/packages/opencode/test/cli/acp-next/helpers.ts b/packages/opencode/test/cli/acp-next/helpers.ts index 4bd1bb9e97..9de5f37129 100644 --- a/packages/opencode/test/cli/acp-next/helpers.ts +++ b/packages/opencode/test/cli/acp-next/helpers.ts @@ -11,7 +11,7 @@ import { type AcpClient, } from "../acp/acp-test-client" -export const diagnosticFirstSessionThresholdMs = 15_000 +export const diagnosticFirstSessionThresholdMs = 5_000 export const diagnosticFastPathThresholdMs = 15_000 // TODO: tighten to the public verifier target of 500ms once acp-next startup is optimized. From 9031ce7b51323f201ac3454aad95dda88e8f047e Mon Sep 17 00:00:00 2001 From: Shoubhit Dash Date: Thu, 28 May 2026 15:10:17 +0530 Subject: [PATCH 021/113] fix(acp): speed up acp-next warm switches (#29713) --- packages/opencode/src/acp-next/service.ts | 20 ++- .../test/acp-next/service-session.test.ts | 164 ++++++++++++++++-- .../cli/acp-next/timing-diagnostics.test.ts | 16 +- 3 files changed, 175 insertions(+), 25 deletions(-) diff --git a/packages/opencode/src/acp-next/service.ts b/packages/opencode/src/acp-next/service.ts index 0e0d0f8352..f4614a5c8d 100644 --- a/packages/opencode/src/acp-next/service.ts +++ b/packages/opencode/src/acp-next/service.ts @@ -83,6 +83,7 @@ export function make(input: { const session = input.session ?? makeSessionService() const directoryService = input.directory ?? makeDirectoryService(input.sdk) const registeredMcp = new Map>() + const sessionSnapshots = new Map() const events = input.connection ? ACPNextEvent.start({ sdk: input.sdk, connection: input.connection, session }) : undefined @@ -149,6 +150,14 @@ export function make(input: { return snapshot }) + const configSnapshot = Effect.fn("ACPNext.configSnapshot")(function* (state: ACPNextSession.Info) { + const snapshot = sessionSnapshots.get(state.id) + if (snapshot) return snapshot + const loaded = yield* directorySnapshot(state.cwd) + sessionSnapshots.set(state.id, loaded) + return loaded + }) + const newSession = Effect.fn("ACPNext.newSession")(function* (params: NewSessionRequest) { const started = performance.now() const snapshot = yield* directorySnapshot(params.cwd) @@ -180,6 +189,7 @@ export function make(input: { variant, modeId, }) + sessionSnapshots.set(state.id, snapshot) yield* registerMcpServers(input.sdk, registeredMcp, params.cwd, state.id, params.mcpServers) yield* sendAvailableCommands(input.connection, state.id, snapshot) @@ -220,6 +230,7 @@ export function make(input: { variant: restored.variant ?? selectVariant(snapshot, model), modeId: restored.modeId ?? (snapshot.availableModes.length > 0 ? snapshot.defaultModeID : undefined), }) + sessionSnapshots.set(state.id, snapshot) yield* registerMcpServers(input.sdk, registeredMcp, params.cwd, state.id, params.mcpServers) yield* sendAvailableCommands(input.connection, state.id, snapshot) @@ -290,6 +301,7 @@ export function make(input: { variant: restored.variant ?? selectVariant(snapshot, model), modeId: restored.modeId ?? (snapshot.availableModes.length > 0 ? snapshot.defaultModeID : undefined), }) + sessionSnapshots.set(state.id, snapshot) yield* registerMcpServers(input.sdk, registeredMcp, params.cwd, state.id, params.mcpServers ?? []) yield* sendAvailableCommands(input.connection, state.id, snapshot) @@ -307,6 +319,7 @@ export function make(input: { const closeSession = Effect.fn("ACPNext.closeSession")(function* (params: CloseSessionRequest) { const removed = yield* session.remove(params.sessionId) registeredMcp.delete(params.sessionId) + sessionSnapshots.delete(params.sessionId) if (!removed) return {} yield* request( @@ -350,6 +363,7 @@ export function make(input: { variant: restored.variant ?? selectVariant(snapshot, model), modeId: restored.modeId ?? (snapshot.availableModes.length > 0 ? snapshot.defaultModeID : undefined), }) + sessionSnapshots.set(state.id, snapshot) yield* registerMcpServers(input.sdk, registeredMcp, params.cwd, state.id, params.mcpServers ?? []) yield* sendAvailableCommands(input.connection, state.id, snapshot) @@ -369,7 +383,7 @@ export function make(input: { params: SetSessionConfigOptionRequest, ) { const current = yield* session.get(params.sessionId) - const snapshot = yield* directorySnapshot(current.cwd) + const snapshot = yield* configSnapshot(current) if (typeof params.value !== "string") { return yield* new ACPNextError.InvalidConfigOptionError({ configId: params.configId }) } @@ -424,7 +438,7 @@ export function make(input: { const setSessionMode = Effect.fn("ACPNext.setSessionMode")(function* (params: SetSessionModeRequest) { const current = yield* session.get(params.sessionId) - const snapshot = yield* directorySnapshot(current.cwd) + const snapshot = yield* configSnapshot(current) if (!snapshot.availableModes.some((mode) => mode.id === params.modeId)) { return yield* new ACPNextError.InvalidModeError({ mode: params.modeId }) } @@ -434,7 +448,7 @@ export function make(input: { const setSessionModel = Effect.fn("ACPNext.setSessionModel")(function* (params: SetSessionModelRequest) { const current = yield* session.get(params.sessionId) - const snapshot = yield* directorySnapshot(current.cwd) + const snapshot = yield* configSnapshot(current) const selected = yield* parseSelectedModel(snapshot, params.modelId) yield* session .setVariant( diff --git a/packages/opencode/test/acp-next/service-session.test.ts b/packages/opencode/test/acp-next/service-session.test.ts index 2ea31a96c8..8ad243b047 100644 --- a/packages/opencode/test/acp-next/service-session.test.ts +++ b/packages/opencode/test/acp-next/service-session.test.ts @@ -707,24 +707,35 @@ describe("ACP next service sessions", () => { expect(results.map((error) => error.code)).toEqual([-32602, -32602, -32602, -32602]) }) - it("does not reload providers or commands when switching effort from a warm snapshot", async () => { - let providersCalls = 0 - let commandCalls = 0 + it("does not refetch providers modes or commands when switching effort from session snapshot", async () => { + const calls = { + providers: 0, + agents: 0, + commands: 0, + skills: 0, + mcpAdds: 0, + } const sdk = { config: { providers: () => { - providersCalls++ + calls.providers++ return Promise.resolve({ data: { providers: [provider], default: { test: modelID } } }) }, get: () => Promise.resolve({ data: {} }), }, app: { - agents: () => Promise.resolve({ data: [{ name: "build", mode: "primary", permission: [], options: {} }] }), - skills: () => Promise.resolve({ data: [] }), + agents: () => { + calls.agents++ + return Promise.resolve({ data: [{ name: "build", mode: "primary", permission: [], options: {} }] }) + }, + skills: () => { + calls.skills++ + return Promise.resolve({ data: [] }) + }, }, command: { list: () => { - commandCalls++ + calls.commands++ return Promise.resolve({ data: [] }) }, }, @@ -733,14 +744,16 @@ describe("ACP next service sessions", () => { list: () => Promise.resolve({ data: [] }), }, mcp: { - add: () => Promise.resolve({ data: {} }), + add: () => { + calls.mcpAdds++ + return Promise.resolve({ data: {} }) + }, }, } as unknown as OpencodeClient const service = ACPNextService.make({ sdk }) const session = await Effect.runPromise(service.newSession({ cwd: "/workspace", mcpServers: [] })) - expect(providersCalls).toBe(1) - expect(commandCalls).toBe(1) + expect(calls).toEqual({ providers: 1, agents: 1, commands: 1, skills: 1, mcpAdds: 0 }) await Effect.runPromise( service.setSessionConfigOption({ @@ -750,8 +763,135 @@ describe("ACP next service sessions", () => { }), ) - expect(providersCalls).toBe(1) - expect(commandCalls).toBe(1) + expect(calls).toEqual({ providers: 1, agents: 1, commands: 1, skills: 1, mcpAdds: 0 }) + }) + + it("switches model against the warm provider snapshot without refetching", async () => { + const calls = { + providers: 0, + agents: 0, + commands: 0, + skills: 0, + } + const sdk = { + config: { + providers: () => { + calls.providers++ + return Promise.resolve({ data: { providers: [provider], default: { test: modelID } } }) + }, + get: () => Promise.resolve({ data: {} }), + }, + app: { + agents: () => { + calls.agents++ + return Promise.resolve({ data: [{ name: "build", mode: "primary", permission: [], options: {} }] }) + }, + skills: () => { + calls.skills++ + return Promise.resolve({ data: [] }) + }, + }, + command: { + list: () => { + calls.commands++ + return Promise.resolve({ data: [] }) + }, + }, + session: { + create: () => Promise.resolve({ data: { id: "ses_model_fast" } }), + list: () => Promise.resolve({ data: [] }), + }, + mcp: { + add: () => Promise.resolve({ data: {} }), + }, + } as unknown as OpencodeClient + const service = ACPNextService.make({ sdk }) + const session = await Effect.runPromise(service.newSession({ cwd: "/workspace", mcpServers: [] })) + const updated = await Effect.runPromise( + service.setSessionConfigOption({ + sessionId: session.sessionId, + configId: "model", + value: "test/second-model", + }), + ) + + expect(select(updated, "model")?.currentValue).toBe("test/second-model") + expect(calls).toEqual({ providers: 1, agents: 1, commands: 1, skills: 1 }) + }) + + it("reuses the warm directory snapshot for a second new session in the same cwd", async () => { + const calls = { + providers: 0, + config: 0, + agents: 0, + commands: 0, + skills: 0, + sessionList: 0, + messages: 0, + creates: 0, + } + const sdk = { + config: { + providers: () => { + calls.providers++ + return Promise.resolve({ data: { providers: [provider], default: { test: modelID } } }) + }, + get: () => { + calls.config++ + return Promise.resolve({ data: {} }) + }, + }, + app: { + agents: () => { + calls.agents++ + return Promise.resolve({ data: [{ name: "build", mode: "primary", permission: [], options: {} }] }) + }, + skills: () => { + calls.skills++ + return Promise.resolve({ data: [] }) + }, + }, + command: { + list: () => { + calls.commands++ + return Promise.resolve({ data: [] }) + }, + }, + session: { + create: () => { + calls.creates++ + return Promise.resolve({ data: { id: `ses_warm_${calls.creates}` } }) + }, + list: () => { + calls.sessionList++ + return Promise.resolve({ data: [] }) + }, + messages: () => { + calls.messages++ + return Promise.resolve({ data: [] }) + }, + }, + mcp: { + add: () => Promise.resolve({ data: {} }), + }, + } as unknown as OpencodeClient + const service = ACPNextService.make({ sdk }) + + const first = await Effect.runPromise(service.newSession({ cwd: "/workspace", mcpServers: [] })) + const second = await Effect.runPromise(service.newSession({ cwd: "/workspace", mcpServers: [] })) + + expect(first.sessionId).toBe("ses_warm_1") + expect(second.sessionId).toBe("ses_warm_2") + expect(calls).toEqual({ + providers: 1, + config: 1, + agents: 1, + commands: 1, + skills: 1, + sessionList: 0, + messages: 0, + creates: 2, + }) }) it("normal text prompt sends model variant mode and converted parts", async () => { diff --git a/packages/opencode/test/cli/acp-next/timing-diagnostics.test.ts b/packages/opencode/test/cli/acp-next/timing-diagnostics.test.ts index c70a82d6b5..610c286ac0 100644 --- a/packages/opencode/test/cli/acp-next/timing-diagnostics.test.ts +++ b/packages/opencode/test/cli/acp-next/timing-diagnostics.test.ts @@ -42,7 +42,7 @@ describe("opencode acp-next verifier timing diagnostics", () => { ) cliIt.live( - "warm new session timing diagnostic stays below generous threshold", + "warm new session stays below verifier threshold", ({ home, llm, opencode }) => Effect.gen(function* () { const acp = yield* createAcpNextClient( @@ -57,15 +57,13 @@ describe("opencode acp-next verifier timing diagnostics", () => { const durationMs = Math.round(performance.now() - started) expect(session.sessionId).toBeTruthy() - // TODO: replace this diagnostic assertion with finalFastPathThresholdMs. - expect(durationMs).toBeLessThan(diagnosticFastPathThresholdMs) - expect(finalFastPathThresholdMs).toBe(100) + expect(durationMs).toBeLessThan(finalFastPathThresholdMs) }), 60_000, ) cliIt.live( - "model switch timing diagnostic updates currentValue below generous threshold", + "model switch updates currentValue below verifier threshold", ({ home, llm, opencode }) => Effect.gen(function* () { const acp = yield* createAcpNextClient( @@ -89,14 +87,13 @@ describe("opencode acp-next verifier timing diagnostics", () => { const durationMs = Math.round(performance.now() - started) expect(expectSelectOption(updated.configOptions, "model").currentValue).toBe(nextModel) - // TODO: replace this diagnostic assertion with finalFastPathThresholdMs. - expect(durationMs).toBeLessThan(diagnosticFastPathThresholdMs) + expect(durationMs).toBeLessThan(finalFastPathThresholdMs) }), 60_000, ) cliIt.live( - "effort switch timing diagnostic updates currentValue below generous threshold", + "effort switch updates currentValue below verifier threshold", ({ home, llm, opencode }) => Effect.gen(function* () { const acp = yield* createAcpNextClient( @@ -118,8 +115,7 @@ describe("opencode acp-next verifier timing diagnostics", () => { const durationMs = Math.round(performance.now() - started) expect(expectSelectOption(updated.configOptions, "effort").currentValue).toBe(nextEffort) - // TODO: replace this diagnostic assertion with finalFastPathThresholdMs. - expect(durationMs).toBeLessThan(diagnosticFastPathThresholdMs) + expect(durationMs).toBeLessThan(finalFastPathThresholdMs) }), 60_000, ) From 3109060216e940dbfd6afaca6024d60b4d6bcdd8 Mon Sep 17 00:00:00 2001 From: Shoubhit Dash Date: Thu, 28 May 2026 16:25:00 +0530 Subject: [PATCH 022/113] fix(acp): cover smoke parity gaps (#29719) --- packages/opencode/src/acp-next/service.ts | 36 ++++--- packages/opencode/src/acp-next/session.ts | 7 ++ .../test/acp-next/service-session.test.ts | 9 ++ .../test/cli/acp-next/lifecycle.test.ts | 24 ++++- .../test/cli/acp-next/prompt-content.test.ts | 98 +++++++++++++++++++ 5 files changed, 162 insertions(+), 12 deletions(-) create mode 100644 packages/opencode/test/cli/acp-next/prompt-content.test.ts diff --git a/packages/opencode/src/acp-next/service.ts b/packages/opencode/src/acp-next/service.ts index f4614a5c8d..2f8147ef67 100644 --- a/packages/opencode/src/acp-next/service.ts +++ b/packages/opencode/src/acp-next/service.ts @@ -259,21 +259,35 @@ export function make(input: { ), "session", ) - const sorted = sessions.toSorted((a, b) => b.time.updated - a.time.updated) + const serverEntries = sessions.map( + (item): SessionInfo => ({ + sessionId: item.id, + cwd: item.directory, + title: item.title, + updatedAt: new Date(item.time.updated).toISOString(), + }), + ) + const liveEntries = (yield* session.list(params.cwd ?? undefined)) + .filter((item) => !serverEntries.some((entry) => entry.sessionId === item.id)) + .map( + (item): SessionInfo => ({ + sessionId: item.id, + cwd: item.cwd, + updatedAt: item.createdAt.toISOString(), + }), + ) + const sorted = [...liveEntries, ...serverEntries].toSorted( + (a, b) => new Date(b.updatedAt ?? 0).getTime() - new Date(a.updatedAt ?? 0).getTime(), + ) const filtered = - cursor === undefined || !Number.isFinite(cursor) ? sorted : sorted.filter((item) => item.time.updated < cursor) + cursor === undefined || !Number.isFinite(cursor) + ? sorted + : sorted.filter((item) => new Date(item.updatedAt ?? 0).getTime() < cursor) const page = filtered.slice(0, limit) const last = page.at(-1) return { - sessions: page.map( - (item): SessionInfo => ({ - sessionId: item.id, - cwd: item.directory, - title: item.title, - updatedAt: new Date(item.time.updated).toISOString(), - }), - ), - ...(filtered.length > limit && last ? { nextCursor: String(last.time.updated) } : {}), + sessions: page, + ...(filtered.length > limit && last ? { nextCursor: String(new Date(last.updatedAt ?? 0).getTime()) } : {}), } }) diff --git a/packages/opencode/src/acp-next/session.ts b/packages/opencode/src/acp-next/session.ts index 6ab61f5f0a..fa803c9781 100644 --- a/packages/opencode/src/acp-next/session.ts +++ b/packages/opencode/src/acp-next/session.ts @@ -60,6 +60,7 @@ export type PartMetadataLookupInput = { export type Interface = { readonly create: (input: StoreInput) => Effect.Effect readonly load: (input: StoreInput) => Effect.Effect + readonly list: (cwd?: string) => Effect.Effect readonly get: (sessionId: string) => Effect.Effect readonly tryGet: (sessionId: string) => Effect.Effect readonly remove: (sessionId: string) => Effect.Effect @@ -168,6 +169,12 @@ export const layer = Layer.effect( return Service.of({ create: store, load: store, + list: Effect.fn("ACPNext.Session.list")(function* (cwd?: string) { + return [...(yield* Ref.get(sessions)).values()] + .filter((session) => !cwd || session.cwd === cwd) + .map(snapshot) + .toSorted((a, b) => b.createdAt.getTime() - a.createdAt.getTime()) + }), get, tryGet, remove, diff --git a/packages/opencode/test/acp-next/service-session.test.ts b/packages/opencode/test/acp-next/service-session.test.ts index 8ad243b047..a35e6aa0ae 100644 --- a/packages/opencode/test/acp-next/service-session.test.ts +++ b/packages/opencode/test/acp-next/service-session.test.ts @@ -323,6 +323,15 @@ describe("ACP next service sessions", () => { expect(second.sessions).toEqual(first.sessions) }) + it("includes live ACP sessions before they appear in server-backed session list", async () => { + const { service } = makeService() + const created = await Effect.runPromise(service.newSession({ cwd: "/workspace", mcpServers: [] })) + const listed = await Effect.runPromise(service.listSessions({ cwd: "/workspace" })) + + expect(listed.sessions[0]?.sessionId).toBe(created.sessionId) + expect(listed.sessions[0]?.cwd).toBe("/workspace") + }) + it("lists all sessions with next cursor when the first page is full", async () => { const { service } = makeService() const first = await Effect.runPromise(service.listSessions({})) diff --git a/packages/opencode/test/cli/acp-next/lifecycle.test.ts b/packages/opencode/test/cli/acp-next/lifecycle.test.ts index 8e9793921f..a1b54037af 100644 --- a/packages/opencode/test/cli/acp-next/lifecycle.test.ts +++ b/packages/opencode/test/cli/acp-next/lifecycle.test.ts @@ -1,5 +1,10 @@ import { describe, expect } from "bun:test" -import type { CloseSessionResponse, LoadSessionResponse, ResumeSessionResponse } from "@agentclientprotocol/sdk" +import type { + CloseSessionResponse, + ListSessionsResponse, + LoadSessionResponse, + ResumeSessionResponse, +} from "@agentclientprotocol/sdk" import { Duration, Effect } from "effect" import { cliIt } from "../../lib/cli-process" import { expectOk, selectConfigOption } from "../acp/acp-test-client" @@ -60,6 +65,23 @@ describe("opencode acp-next lifecycle subprocess", () => { 60_000, ) + cliIt.live( + "list request includes a live ACP-created session", + ({ home, llm, opencode }) => + Effect.gen(function* () { + const acp = yield* createAcpNextClient( + { opencode }, + { OPENCODE_CONFIG_CONTENT: JSON.stringify(verifierConfig(llm.url)) }, + ) + yield* initialize(acp) + const session = yield* newSession(acp, home) + const listed = expectOk(yield* acp.request("session/list", { cwd: home })) + + expect(listed.sessions.some((item) => item.sessionId === session.sessionId)).toBe(true) + }), + 60_000, + ) + cliIt.live( "resume capability advertisement", ({ opencode }) => diff --git a/packages/opencode/test/cli/acp-next/prompt-content.test.ts b/packages/opencode/test/cli/acp-next/prompt-content.test.ts new file mode 100644 index 0000000000..3cf70a7af6 --- /dev/null +++ b/packages/opencode/test/cli/acp-next/prompt-content.test.ts @@ -0,0 +1,98 @@ +import { describe, expect } from "bun:test" +import type { PromptResponse } from "@agentclientprotocol/sdk" +import { Effect } from "effect" +import { writeFile } from "node:fs/promises" +import path from "node:path" +import { pathToFileURL } from "node:url" +import { cliIt } from "../../lib/cli-process" +import { expectOk } from "../acp/acp-test-client" +import { createAcpNextClient, initialize, newSession, verifierConfig } from "./helpers" + +const tinyPng = + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+ip1sAAAAASUVORK5CYII=" + +describe("opencode acp-next prompt content subprocess", () => { + cliIt.live( + "accepts embedded text resource image and file resource link prompt content", + ({ home, llm, opencode }) => + Effect.gen(function* () { + yield* Effect.promise(() => writeFile(path.join(home, "README.md"), "# ACP content smoke\n")) + const acp = yield* createAcpNextClient( + { opencode }, + { OPENCODE_CONFIG_CONTENT: JSON.stringify(promptContentConfig(llm.url)) }, + ) + yield* initialize(acp) + const session = yield* newSession(acp, home) + + yield* llm.text("embedded resource accepted") + expectOk( + yield* acp.request("session/prompt", { + sessionId: session.sessionId, + prompt: [ + { type: "text", text: "Use this embedded resource." }, + { + type: "resource", + resource: { uri: "file:///context.txt", mimeType: "text/plain", text: "embedded context" }, + }, + ], + }), + ) + + yield* llm.text("image accepted") + expectOk( + yield* acp.request("session/prompt", { + sessionId: session.sessionId, + prompt: [ + { type: "text", text: "Use this image." }, + { + type: "image", + mimeType: "image/png", + data: tinyPng, + }, + ], + }), + ) + + yield* llm.text("file link accepted") + const linked = expectOk( + yield* acp.request("session/prompt", { + sessionId: session.sessionId, + prompt: [ + { type: "text", text: "Use this linked file." }, + { + type: "resource_link", + uri: pathToFileURL(path.join(home, "README.md")).href, + name: "README.md", + mimeType: "text/markdown", + }, + ], + }), + ) + + expect(linked.stopReason).toBe("end_turn") + }), + 60_000, + ) +}) + +function promptContentConfig(llmUrl: string) { + const config = verifierConfig(llmUrl) + return { + ...config, + provider: { + test: { + ...config.provider.test, + models: Object.fromEntries( + Object.entries(config.provider.test.models).map(([id, model]) => [ + id, + { + ...model, + attachment: true, + reasoning: true, + }, + ]), + ), + }, + }, + } +} From 1e5ddbd81259e3cf4493d5e1bc9fea181a8b1811 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Thu, 28 May 2026 10:56:37 +0000 Subject: [PATCH 023/113] chore: generate --- packages/opencode/test/cli/acp-next/prompt-content.test.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/opencode/test/cli/acp-next/prompt-content.test.ts b/packages/opencode/test/cli/acp-next/prompt-content.test.ts index 3cf70a7af6..2e658f70ee 100644 --- a/packages/opencode/test/cli/acp-next/prompt-content.test.ts +++ b/packages/opencode/test/cli/acp-next/prompt-content.test.ts @@ -8,8 +8,7 @@ import { cliIt } from "../../lib/cli-process" import { expectOk } from "../acp/acp-test-client" import { createAcpNextClient, initialize, newSession, verifierConfig } from "./helpers" -const tinyPng = - "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+ip1sAAAAASUVORK5CYII=" +const tinyPng = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+ip1sAAAAASUVORK5CYII=" describe("opencode acp-next prompt content subprocess", () => { cliIt.live( From fdc574ff81593b290e5dc14a4f4b478df0c991ab Mon Sep 17 00:00:00 2001 From: Victor Navarro Date: Thu, 28 May 2026 13:50:14 +0200 Subject: [PATCH 024/113] feat: improve referral system (#29720) --- .../console/app/src/component/go-referral.tsx | 19 ++++++- .../app/src/routes/auth/[...callback].ts | 12 +--- packages/console/app/vite.config.ts | 1 + packages/console/core/src/referral.ts | 28 ++++++++-- packages/enterprise/vite.config.ts | 1 + packages/stats/app/tsconfig.json | 3 +- sst-env.d.ts | 56 +++---------------- 7 files changed, 55 insertions(+), 65 deletions(-) diff --git a/packages/console/app/src/component/go-referral.tsx b/packages/console/app/src/component/go-referral.tsx index fd4d5c8b1a..14ea8194d1 100644 --- a/packages/console/app/src/component/go-referral.tsx +++ b/packages/console/app/src/component/go-referral.tsx @@ -2,6 +2,7 @@ import { action, json, query, useAction, useSubmission } from "@solidjs/router" import { createEffect, createMemo, createSignal, For, onCleanup, Show } from "solid-js" import { getRequestEvent } from "solid-js/web" import { Referral } from "@opencode-ai/console-core/referral.js" +import { Actor } from "@opencode-ai/console-core/actor.js" import { withActor } from "~/context/auth.withActor" import { Modal } from "~/component/modal" import { IconCheck, IconCopy } from "~/component/icon" @@ -9,6 +10,7 @@ import { useI18n } from "~/context/i18n" import { useLanguage } from "~/context/language" import { formatResetTime, liteResetTimeKeys } from "~/lib/format-reset-time" import { queryLiteSubscription } from "~/routes/workspace/[id]/go/lite-section" +import { clearReferralCookie, referralCodeFromCookieHeader } from "~/lib/referral-invite" import "./go-referral.css" type GoReferralSummary = Awaited> @@ -25,7 +27,21 @@ const emptyUsagePreview = { export const queryGoReferral = query(async (workspaceID: string) => { "use server" - return withActor(() => Referral.summary(), workspaceID) + return withActor(async () => { + const event = getRequestEvent() + const referralCode = referralCodeFromCookieHeader(event?.request.headers.get("cookie") ?? null) + if (referralCode) { + await Referral.createFromAccount({ + accountID: Actor.account(), + referralCode, + }).catch((error) => { + console.error("Referral create failed", error) + }) + event?.response.headers.append("set-cookie", clearReferralCookie()) + } + + return Referral.summary() + }, workspaceID) }, "go.referral.get") export const queryGoReferralUsagePreview = query(async (workspaceID: string, referralID?: string) => { @@ -65,6 +81,7 @@ function rewardDescriptionKey(source: GoReferralReward["source"]) { function rewardActionKey(reward: GoReferralReward, hasActiveGo: boolean) { if (reward.status === "applied") return "workspace.referral.reward.action.applied" as const + if (reward.status === "pending" && reward.source === "inviter") return "workspace.referral.reward.source.pendingInviter" as const if (reward.status === "pending" || !hasActiveGo) return "workspace.referral.reward.action.subscribeUnlock" as const return "workspace.referral.reward.action.view" as const } diff --git a/packages/console/app/src/routes/auth/[...callback].ts b/packages/console/app/src/routes/auth/[...callback].ts index b1b942ee72..00bb89406f 100644 --- a/packages/console/app/src/routes/auth/[...callback].ts +++ b/packages/console/app/src/routes/auth/[...callback].ts @@ -1,11 +1,9 @@ import { redirect } from "@solidjs/router" import type { APIEvent } from "@solidjs/start/server" -import { Referral } from "@opencode-ai/console-core/referral.js" import { AuthClient } from "~/context/auth" import { useAuthSession } from "~/context/auth" import { i18n } from "~/i18n" import { localeFromRequest, route } from "~/lib/language" -import { clearReferralCookie, referralCodeFromCookieHeader } from "~/lib/referral-invite" export async function GET(input: APIEvent) { const url = new URL(input.request.url) @@ -19,7 +17,6 @@ export async function GET(input: APIEvent) { if (result.err) throw new Error(result.err.message) const decoded = AuthClient.decode(result.tokens.access, {} as any) if (decoded.err) throw new Error(decoded.err.message) - const referralCode = referralCodeFromCookieHeader(input.request.headers.get("cookie")) const session = await useAuthSession() const id = decoded.subject.properties.accountID await session.update((value) => { @@ -35,15 +32,8 @@ export async function GET(input: APIEvent) { current: id, } }) - if (decoded.subject.properties.newAccount && referralCode) { - await Referral.createFromAccount({ accountID: id, referralCode }).catch((error) => { - console.error("Referral create failed", error) - }) - } const next = url.pathname === "/auth/callback" ? "/auth" : url.pathname.replace("/auth/callback", "") - const response = redirect(route(locale, next)) - if (referralCode) response.headers.append("set-cookie", clearReferralCookie()) - return response + return redirect(route(locale, next)) } catch (e: any) { return new Response( JSON.stringify({ diff --git a/packages/console/app/vite.config.ts b/packages/console/app/vite.config.ts index fb753b6be7..33455acc5e 100644 --- a/packages/console/app/vite.config.ts +++ b/packages/console/app/vite.config.ts @@ -17,6 +17,7 @@ export default defineConfig({ ], server: { allowedHosts: true, + port: 3001, }, build: { rollupOptions: { diff --git a/packages/console/core/src/referral.ts b/packages/console/core/src/referral.ts index 66b14dbe39..5686dd67ee 100644 --- a/packages/console/core/src/referral.ts +++ b/packages/console/core/src/referral.ts @@ -1,8 +1,8 @@ import { z } from "zod" -import { and, asc, eq, isNull, sql, Database } from "./drizzle" +import { and, asc, eq, inArray, isNull, sql, Database } from "./drizzle" import { Actor } from "./actor" import { Identifier } from "./identifier" -import { LiteTable } from "./schema/billing.sql" +import { LiteTable, PaymentTable } from "./schema/billing.sql" import { ReferralCodeTable, ReferralRewardTable, ReferralTable } from "./schema/referral.sql" import { AuthTable } from "./schema/auth.sql" import { UserTable } from "./schema/user.sql" @@ -318,6 +318,26 @@ export namespace Referral { .then((rows) => rows[0]) if (selfReferral) throw new Error("Self-referral is not allowed") + const workspaceIDs = await tx + .select({ workspaceID: UserTable.workspaceID }) + .from(UserTable) + .where(and(eq(UserTable.accountID, input.accountID), isNull(UserTable.timeDeleted))) + .then((rows) => rows.map((row) => row.workspaceID)) + if (workspaceIDs.length === 0) return + + const litePayment = await tx + .select({ id: PaymentTable.id }) + .from(PaymentTable) + .where( + and( + inArray(PaymentTable.workspaceID, workspaceIDs), + isNull(PaymentTable.timeDeleted), + sql`JSON_UNQUOTE(JSON_EXTRACT(${PaymentTable.enrichment}, '$.type')) = 'lite'`, + ), + ) + .then((rows) => rows[0]) + if (litePayment) return + const referralID = Identifier.create("referral") await tx.insert(ReferralTable).ignore().values({ workspaceID: code.workspaceID, @@ -355,7 +375,7 @@ export namespace Referral { .from(ReferralTable) .where(and(eq(ReferralTable.inviteeAccountID, invitee.accountID), isNull(ReferralTable.timeDeleted))) .then((rows) => rows[0]) - if (!referral) throw new Error("Referral not found") + if (!referral) return const result = await tx .insert(ReferralRewardTable) @@ -373,7 +393,7 @@ export namespace Referral { }, ]) - if (result.rowsAffected === 0) throw new Error("Referral already completed") + if (result.rowsAffected === 0) return }) } diff --git a/packages/enterprise/vite.config.ts b/packages/enterprise/vite.config.ts index 531732c2be..90f4665c59 100644 --- a/packages/enterprise/vite.config.ts +++ b/packages/enterprise/vite.config.ts @@ -29,6 +29,7 @@ export default defineConfig({ server: { host: "0.0.0.0", allowedHosts: true, + port: 3002, }, worker: { format: "es", diff --git a/packages/stats/app/tsconfig.json b/packages/stats/app/tsconfig.json index 0f96f182ce..e5d8ff3f6c 100644 --- a/packages/stats/app/tsconfig.json +++ b/packages/stats/app/tsconfig.json @@ -17,5 +17,6 @@ "paths": { "~/*": ["./src/*"] } - } + }, + "include": ["*.ts", "src", "../core/src/resource.d.ts"] } diff --git a/sst-env.d.ts b/sst-env.d.ts index 9f6a5db313..aa79ec87d3 100644 --- a/sst-env.d.ts +++ b/sst-env.d.ts @@ -26,6 +26,14 @@ declare module "sst" { "AuthApi": import("@cloudflare/workers-types").Service "AuthStorage": import("@cloudflare/workers-types").KVNamespace "Bucket": import("@cloudflare/workers-types").R2Bucket + "CLOUDFLARE_API_TOKEN": { + "type": "sst.sst.Secret" + "value": string + } + "CLOUDFLARE_DEFAULT_ACCOUNT_ID": { + "type": "sst.sst.Secret" + "value": string + } "Console": { "type": "sst.cloudflare.SolidStart" "url": string @@ -91,37 +99,6 @@ declare module "sst" { "type": "random.index/randomPassword.RandomPassword" "value": string } - "InferenceEvent": { - "catalog": string - "database": string - "region": string - "table": string - "tableBucket": string - "type": "sst.sst.Linkable" - "workgroup": string - } - "LakeIngest": { - "secret": string - "type": "sst.sst.Linkable" - "url": string - } - "LakeIngestConfig": { - "secret": string - "streamName": string - "type": "sst.sst.Linkable" - } - "LakeIngestSecret": { - "type": "random.index/randomPassword.RandomPassword" - "value": string - } - "LakeIngestService": { - "service": string - "type": "sst.aws.Service" - "url": string - } - "LakeVpc": { - "type": "sst.aws.Vpc" - } "LogProcessor": import("@cloudflare/workers-types").Service "R2AccessKey": { "type": "sst.sst.Secret" @@ -156,23 +133,6 @@ declare module "sst" { "value": string } "Stat": import("@cloudflare/workers-types").Service - "StatsDatabase": { - "database": string - "host": string - "password": string - "port": number - "type": "sst.sst.Linkable" - "url": string - "username": string - } - "StatsSyncConfig": { - "dataset": string - "type": "sst.sst.Linkable" - } - "StatsSyncService": { - "service": string - "type": "sst.aws.Service" - } "Teams": { "type": "sst.cloudflare.SolidStart" "url": string From 30a66dfc314479c7092de0eb396db967c0f68b6a Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Thu, 28 May 2026 11:51:48 +0000 Subject: [PATCH 025/113] chore: generate --- packages/console/app/src/component/go-referral.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/console/app/src/component/go-referral.tsx b/packages/console/app/src/component/go-referral.tsx index 14ea8194d1..d7ba1d7d51 100644 --- a/packages/console/app/src/component/go-referral.tsx +++ b/packages/console/app/src/component/go-referral.tsx @@ -81,7 +81,8 @@ function rewardDescriptionKey(source: GoReferralReward["source"]) { function rewardActionKey(reward: GoReferralReward, hasActiveGo: boolean) { if (reward.status === "applied") return "workspace.referral.reward.action.applied" as const - if (reward.status === "pending" && reward.source === "inviter") return "workspace.referral.reward.source.pendingInviter" as const + if (reward.status === "pending" && reward.source === "inviter") + return "workspace.referral.reward.source.pendingInviter" as const if (reward.status === "pending" || !hasActiveGo) return "workspace.referral.reward.action.subscribeUnlock" as const return "workspace.referral.reward.action.view" as const } From c3562a4a51694513f841eaaa243e8ab29b898f43 Mon Sep 17 00:00:00 2001 From: Shoubhit Dash Date: Thu, 28 May 2026 18:52:37 +0530 Subject: [PATCH 026/113] test(acp): remove timing diagnostics (#29741) --- .../opencode/test/cli/acp-next/helpers.ts | 8 - .../cli/acp-next/timing-diagnostics.test.ts | 153 ------------------ .../acp/acp-compatibility-baseline.test.ts | 31 +--- 3 files changed, 4 insertions(+), 188 deletions(-) delete mode 100644 packages/opencode/test/cli/acp-next/timing-diagnostics.test.ts diff --git a/packages/opencode/test/cli/acp-next/helpers.ts b/packages/opencode/test/cli/acp-next/helpers.ts index 9de5f37129..6e92a73ea3 100644 --- a/packages/opencode/test/cli/acp-next/helpers.ts +++ b/packages/opencode/test/cli/acp-next/helpers.ts @@ -11,14 +11,6 @@ import { type AcpClient, } from "../acp/acp-test-client" -export const diagnosticFirstSessionThresholdMs = 5_000 -export const diagnosticFastPathThresholdMs = 15_000 - -// TODO: tighten to the public verifier target of 500ms once acp-next startup is optimized. -export const finalFirstSessionThresholdMs = 500 -// TODO: tighten warm session/config/skill fast paths to the public verifier target of 100ms. -export const finalFastPathThresholdMs = 100 - export function createAcpNextClient(input: Pick, env?: Record) { return Effect.gen(function* () { return createAcpClient( diff --git a/packages/opencode/test/cli/acp-next/timing-diagnostics.test.ts b/packages/opencode/test/cli/acp-next/timing-diagnostics.test.ts deleted file mode 100644 index 610c286ac0..0000000000 --- a/packages/opencode/test/cli/acp-next/timing-diagnostics.test.ts +++ /dev/null @@ -1,153 +0,0 @@ -import { describe, expect } from "bun:test" -import type { SessionNotification, SetSessionConfigOptionResponse } from "@agentclientprotocol/sdk" -import { Effect } from "effect" -import { mkdir } from "node:fs/promises" -import path from "node:path" -import { cliIt } from "../../lib/cli-process" -import { expectOk, flattenSelectOptions } from "../acp/acp-test-client" -import { - createAcpNextClient, - diagnosticFastPathThresholdMs, - diagnosticFirstSessionThresholdMs, - expectAlternateValue, - expectSelectOption, - finalFastPathThresholdMs, - finalFirstSessionThresholdMs, - initialize, - newSession, - verifierConfig, - verifierSkill, -} from "./helpers" - -describe("opencode acp-next verifier timing diagnostics", () => { - cliIt.live( - "first session timing diagnostic stays below generous threshold", - ({ home, llm, opencode }) => - Effect.gen(function* () { - const acp = yield* createAcpNextClient( - { opencode }, - { OPENCODE_CONFIG_CONTENT: JSON.stringify(verifierConfig(llm.url)) }, - ) - const started = performance.now() - yield* initialize(acp) - const session = yield* newSession(acp, home) - const durationMs = Math.round(performance.now() - started) - - expect(session.sessionId).toBeTruthy() - // TODO: replace this diagnostic assertion with finalFirstSessionThresholdMs. - expect(durationMs).toBeLessThan(diagnosticFirstSessionThresholdMs) - expect(finalFirstSessionThresholdMs).toBe(500) - }), - 60_000, - ) - - cliIt.live( - "warm new session stays below verifier threshold", - ({ home, llm, opencode }) => - Effect.gen(function* () { - const acp = yield* createAcpNextClient( - { opencode }, - { OPENCODE_CONFIG_CONTENT: JSON.stringify(verifierConfig(llm.url)) }, - ) - yield* initialize(acp) - yield* newSession(acp, home) - - const started = performance.now() - const session = yield* newSession(acp, home) - const durationMs = Math.round(performance.now() - started) - - expect(session.sessionId).toBeTruthy() - expect(durationMs).toBeLessThan(finalFastPathThresholdMs) - }), - 60_000, - ) - - cliIt.live( - "model switch updates currentValue below verifier threshold", - ({ home, llm, opencode }) => - Effect.gen(function* () { - const acp = yield* createAcpNextClient( - { opencode }, - { OPENCODE_CONFIG_CONTENT: JSON.stringify(verifierConfig(llm.url)) }, - ) - yield* initialize(acp) - const session = yield* newSession(acp, home) - const model = expectSelectOption(session.configOptions, "model") - const nextModel = flattenSelectOptions(model).find((option) => option.value === "test/second-model")?.value - if (!nextModel) throw new Error("expected second test model") - - const started = performance.now() - const updated = expectOk( - yield* acp.request("session/set_config_option", { - sessionId: session.sessionId, - configId: "model", - value: nextModel, - }), - ) - const durationMs = Math.round(performance.now() - started) - - expect(expectSelectOption(updated.configOptions, "model").currentValue).toBe(nextModel) - expect(durationMs).toBeLessThan(finalFastPathThresholdMs) - }), - 60_000, - ) - - cliIt.live( - "effort switch updates currentValue below verifier threshold", - ({ home, llm, opencode }) => - Effect.gen(function* () { - const acp = yield* createAcpNextClient( - { opencode }, - { OPENCODE_CONFIG_CONTENT: JSON.stringify(verifierConfig(llm.url)) }, - ) - yield* initialize(acp) - const session = yield* newSession(acp, home) - const nextEffort = expectAlternateValue(expectSelectOption(session.configOptions, "effort")) - - const started = performance.now() - const updated = expectOk( - yield* acp.request("session/set_config_option", { - sessionId: session.sessionId, - configId: "effort", - value: nextEffort, - }), - ) - const durationMs = Math.round(performance.now() - started) - - expect(expectSelectOption(updated.configOptions, "effort").currentValue).toBe(nextEffort) - expect(durationMs).toBeLessThan(finalFastPathThresholdMs) - }), - 60_000, - ) - - cliIt.live( - "warm skill command timing diagnostic stays below generous threshold", - ({ home, llm, opencode }) => - Effect.gen(function* () { - const skills = path.join(home, "skills") - yield* Effect.promise(() => mkdir(path.join(skills, "verifier-skill"), { recursive: true })) - yield* Effect.promise(() => Bun.write(path.join(skills, "verifier-skill", "SKILL.md"), verifierSkill)) - const acp = yield* createAcpNextClient( - { opencode }, - { OPENCODE_CONFIG_CONTENT: JSON.stringify(verifierConfig(llm.url, skills)) }, - ) - yield* initialize(acp) - yield* newSession(acp, home) - const secondSession = yield* newSession(acp, home) - - const started = performance.now() - yield* acp.waitForNotification( - "session/update", - (params) => - params.sessionId === secondSession.sessionId && - params.update.sessionUpdate === "available_commands_update" && - params.update.availableCommands.some((command) => command.name === "verifier-skill"), - ) - const durationMs = Math.round(performance.now() - started) - - // TODO: replace this diagnostic assertion with finalFastPathThresholdMs. - expect(durationMs).toBeLessThan(diagnosticFastPathThresholdMs) - }), - 60_000, - ) -}) diff --git a/packages/opencode/test/cli/acp/acp-compatibility-baseline.test.ts b/packages/opencode/test/cli/acp/acp-compatibility-baseline.test.ts index 5352039383..cec6334333 100644 --- a/packages/opencode/test/cli/acp/acp-compatibility-baseline.test.ts +++ b/packages/opencode/test/cli/acp/acp-compatibility-baseline.test.ts @@ -40,7 +40,7 @@ describe("opencode acp verifier compatibility baseline", () => { ) cliIt.live( - "first session timing diagnostic stays bounded and returns model options", + "first session returns model options", ({ home, llm, opencode }) => Effect.gen(function* () { const acp = createAcpClient( @@ -50,7 +50,6 @@ describe("opencode acp verifier compatibility baseline", () => { }, }), ) - const started = Date.now() yield* acp.request("initialize", { protocolVersion: 1, clientCapabilities: {}, @@ -62,9 +61,6 @@ describe("opencode acp verifier compatibility baseline", () => { mcpServers: [], }), ) - const durationMs = Date.now() - started - expect(durationMs).toBeLessThan(15_000) - const model = selectConfigOption(session.configOptions, "model") expect(model?.category).toBe("model") expect(model?.currentValue).toBe("test/test-model") @@ -74,7 +70,7 @@ describe("opencode acp verifier compatibility baseline", () => { ) cliIt.live( - "warm newSession timing diagnostic stays bounded", + "newSession can be called repeatedly", ({ home, llm, opencode }) => Effect.gen(function* () { const acp = createAcpClient( @@ -87,22 +83,19 @@ describe("opencode acp verifier compatibility baseline", () => { yield* acp.request("initialize", { protocolVersion: 1 }) yield* acp.request("session/new", { cwd: home, mcpServers: [] }) - const started = Date.now() const session = expectOk( yield* acp.request("session/new", { cwd: home, mcpServers: [], }), ) - const durationMs = Date.now() - started - expect(durationMs).toBeLessThan(15_000) expect(session.sessionId).toBeTruthy() }), 60_000, ) cliIt.live( - "model switch timing diagnostic updates currentValue", + "model switch updates currentValue", ({ home, llm, opencode }) => Effect.gen(function* () { const acp = createAcpClient( @@ -121,7 +114,6 @@ describe("opencode acp verifier compatibility baseline", () => { : undefined expect(nextModel).toBe("test/second-model") - const started = Date.now() const updated = expectOk( yield* acp.request("session/set_config_option", { sessionId: session.sessionId, @@ -129,9 +121,7 @@ describe("opencode acp verifier compatibility baseline", () => { value: nextModel, }), ) - const durationMs = Date.now() - started - expect(durationMs).toBeLessThan(15_000) expect(selectConfigOption(updated.configOptions, "model")?.currentValue).toBe(nextModel) }), 60_000, @@ -189,7 +179,7 @@ describe("opencode acp verifier compatibility baseline", () => { ) cliIt.live( - "skill slash command timing diagnostic appears through available_commands_update", + "skill slash command appears through available_commands_update", ({ home, llm, opencode }) => Effect.gen(function* () { const skills = path.join(home, "skills") @@ -215,19 +205,6 @@ describe("opencode acp verifier compatibility baseline", () => { expect(update.params?.sessionId).toBe(session.sessionId) - const secondSession = expectOk( - yield* acp.request("session/new", { cwd: home, mcpServers: [] }), - ) - const started = Date.now() - yield* acp.waitForNotification( - "session/update", - (params) => - params.sessionId === secondSession.sessionId && - params.update.sessionUpdate === "available_commands_update" && - params.update.availableCommands.some((command) => command.name === "verifier-skill"), - ) - const durationMs = Date.now() - started - expect(durationMs).toBeLessThan(15_000) }), 60_000, ) From 6883309997702cedc63ae71cff9e8437cc143a74 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Thu, 28 May 2026 13:24:10 +0000 Subject: [PATCH 027/113] chore: generate --- .../opencode/test/cli/acp/acp-compatibility-baseline.test.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/opencode/test/cli/acp/acp-compatibility-baseline.test.ts b/packages/opencode/test/cli/acp/acp-compatibility-baseline.test.ts index cec6334333..a805bfef29 100644 --- a/packages/opencode/test/cli/acp/acp-compatibility-baseline.test.ts +++ b/packages/opencode/test/cli/acp/acp-compatibility-baseline.test.ts @@ -204,7 +204,6 @@ describe("opencode acp verifier compatibility baseline", () => { ) expect(update.params?.sessionId).toBe(session.sessionId) - }), 60_000, ) From cf2cd13fb8313e741ad6d85b37d860280775a009 Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Wed, 27 May 2026 14:02:53 -0500 Subject: [PATCH 028/113] chore: updated backfill --- packages/stats/core/src/honeycomb-backfill.ts | 354 +++++++++++------- 1 file changed, 209 insertions(+), 145 deletions(-) diff --git a/packages/stats/core/src/honeycomb-backfill.ts b/packages/stats/core/src/honeycomb-backfill.ts index dd193c8b9a..507b1dca0b 100644 --- a/packages/stats/core/src/honeycomb-backfill.ts +++ b/packages/stats/core/src/honeycomb-backfill.ts @@ -1,4 +1,6 @@ import { Client } from "@planetscale/database" +import { readdir } from "node:fs/promises" +import path from "node:path" import { drizzle } from "drizzle-orm/planetscale-serverless" import { geoStat, modelStat, providerStat } from "./database/schema" import { @@ -17,23 +19,30 @@ import { } from "./domain/stat" const DAY_MS = 86_400_000 -const DEFAULT_DAYS = 60 +const DEFAULT_TIERS = ["Go", "Free", "Paid"] const FREE_MODELS = new Set(["gpt-5-nano", "grok-code", "big-pickle"]) type Grain = "day" | "week" type MetricDimension = "model" | "provider" | "geo" type LookupDimension = "model-provider-model" | "geo-continent" type ImportKey = `${MetricDimension | LookupDimension}-${Grain}` +type GeneratedImportKey = `${MetricDimension}-${Grain}` +type QuerySpec = { + name: string + importKey: ImportKey + importFlag: `--${ImportKey}` + query: ReturnType +} type RawRow = Record type Period = { start: Date; end: Date } -type Timing = { start_time: number; end_time: number; granularity?: number } type ImportOptions = { dataset: string databaseUrl: string | undefined + directories: string[] dryRun: boolean periodEnd: Date | undefined periodStart: Date | undefined - files: Partial> + files: Partial> } type ModelAggregate = StatBaseAggregate & { provider: string; model: string; provider_model: string } type ProviderAggregate = StatBaseAggregate & { provider: string } @@ -66,34 +75,24 @@ async function main() { function printQueries(args: string[]) { const flags = parseFlags(args) - const periodEnd = parseDateFlag(flags, "period-end") ?? defaultPeriodEnd() - const days = parseIntegerFlag(flags, "days") ?? DEFAULT_DAYS const limit = parseIntegerFlag(flags, "limit") ?? 1000 - const dailyStart = new Date( - Date.UTC(periodEnd.getUTCFullYear(), periodEnd.getUTCMonth(), periodEnd.getUTCDate() - days + 1), - ) - const weekStart = syncWeekStart(periodEnd) + const tiers = parseListFlag(flags, "tiers") ?? DEFAULT_TIERS + const queries = buildQueries(limit, tiers, flags.has("include-weekly")) + const only = flags.get("only")?.[0] + + if (only) { + const item = queries.find((query) => query.name === only) + if (!item) fail(`Unknown --only ${only}. Expected one of: ${queries.map((query) => query.name).join(", ")}`) + console.log(JSON.stringify(item.query, null, 2)) + return + } console.log( JSON.stringify( { - period_end: periodEnd.toISOString(), - import_hint: `bun src/honeycomb-backfill.ts import --period-end ${periodEnd.toISOString()} ...`, - daily: buildQuerySet( - { - start_time: Math.floor(dailyStart.getTime() / 1000), - end_time: Math.floor(periodEnd.getTime() / 1000), - granularity: DAY_MS / 1000, - }, - limit, - ), - week: buildQuerySet( - { - start_time: Math.floor(weekStart.getTime() / 1000), - end_time: Math.floor(periodEnd.getTime() / 1000), - }, - limit, - ), + tiers, + import_hint: "bun src/honeycomb-backfill.ts import --dir downloads", + queries, }, null, 2, @@ -102,7 +101,9 @@ function printQueries(args: string[]) { } async function importFiles(args: string[]) { - const opts = parseImportOptions(args) + const parsed = parseImportOptions(args) + const opts = { ...parsed, files: mergeFiles(parsed.files, await discoverFiles(parsed.directories)) } + if (!inputKeys.some((key) => opts.files[key]?.length)) fail("No CSV or JSON import files were provided or discovered") const providerModelLookup = new Map([ ...(await lookupRows(opts.files["model-provider-model-day"], "day", opts, modelProviderModelLookup)), ...(await lookupRows(opts.files["model-provider-model-week"], "week", opts, modelProviderModelLookup)), @@ -151,6 +152,9 @@ async function importFiles(args: string[]) { console.log( JSON.stringify( { + inputs: Object.fromEntries( + inputKeys.flatMap((key) => (opts.files[key]?.length ? [[key, opts.files[key].length]] : [])), + ), modelRows: modelRows.length, providerRows: providerRows.length, geoRows: geoRows.length, @@ -170,109 +174,94 @@ async function importFiles(args: string[]) { await upsertGeoRows(db, geoRows) } -function buildQuerySet(timing: Timing, limit: number) { +function buildQueries(limit: number, tiers: string[], includeWeekly: boolean): QuerySpec[] { + const daily = tiers.flatMap((tier) => [ + querySpec( + "model-day", + tier, + metricQuery(["date", "tier", "provider.normalized", "model", "provider.model"], limit, tierFilters(tier)), + ), + querySpec( + "provider-day", + tier, + metricQuery(["date", "tier", "provider.normalized"], limit, tierFilters(tier)), + ), + querySpec("geo-day", tier, metricQuery(["date", "tier", "country", "continent"], limit, tierFilters(tier))), + ]) + const weekly = includeWeekly + ? tiers.flatMap((tier) => [ + querySpec( + "model-week", + tier, + metricQuery(["tier", "provider.normalized", "model", "provider.model"], limit, tierFilters(tier)), + ), + querySpec("provider-week", tier, metricQuery(["tier", "provider.normalized"], limit, tierFilters(tier))), + querySpec("geo-week", tier, metricQuery(["tier", "country", "continent"], limit, tierFilters(tier))), + ]) + : [] + + return [...daily, ...weekly] +} + +function querySpec(importKey: GeneratedImportKey, tier: string, query: ReturnType) { return { - model: metricQuery(["stat_tier", "stat_provider", "model"], timing, limit), - model_provider_model: lookupQuery(["stat_tier", "stat_provider", "model", "provider.model"], timing, limit), - provider: metricQuery(["stat_tier", "stat_provider"], timing, limit), - geo: metricQuery(["stat_tier", "stat_country"], timing, limit), - geo_continent: lookupQuery(["stat_tier", "stat_country", "cf.continent"], timing, limit), + name: `${importKey}-${queryNameSegment(tier)}`, + importKey, + importFlag: `--${importKey}` as const, + query, } } -function metricQuery(breakdowns: string[], timing: Timing, limit: number) { +function metricQuery( + breakdowns: string[], + limit: number, + filters: ReturnType = [], +) { return { - ...timing, + granularity: 0, breakdowns, - calculated_fields: [...commonCalculatedFields(), ...metricCalculatedFields()], calculations: [ - { op: "COUNT_DISTINCT", column: "session", name: "sessions" }, - { op: "COUNT", name: "requests" }, - { op: "SUM", column: "tokens.input", name: "input_tokens" }, - { op: "SUM", column: "tokens.output", name: "output_tokens" }, - { op: "SUM", column: "tokens.reasoning", name: "reasoning_tokens" }, - { op: "SUM", column: "tokens.cache_read", name: "cache_read_tokens" }, - { op: "SUM", column: "stat_tokens_total", name: "total_tokens" }, - { op: "SUM", column: "stat_cost_input_microcents", name: "input_cost_microcents" }, - { op: "SUM", column: "stat_cost_output_microcents", name: "output_cost_microcents" }, - { op: "SUM", column: "stat_cost_total_microcents", name: "total_cost_microcents" }, - { op: "AVG", column: "duration", name: "avg_duration_ms" }, - { op: "P50", column: "duration", name: "p50_duration_ms" }, - { op: "P95", column: "duration", name: "p95_duration_ms" }, - { op: "AVG", column: "time_to_first_byte", name: "avg_ttfb_ms" }, - { op: "P50", column: "time_to_first_byte", name: "p50_ttfb_ms" }, - { op: "P95", column: "time_to_first_byte", name: "p95_ttfb_ms" }, - { op: "AVG", column: "stat_output_tps", name: "avg_output_tps" }, - { op: "SUM", column: "stat_success", name: "success_count" }, - { op: "SUM", column: "stat_error", name: "error_count" }, - { op: "COUNT", name: "sample_count" }, + { op: "COUNT_DISTINCT", column: "session" }, + { op: "COUNT" }, + { op: "SUM", column: "tokens.input" }, + { op: "SUM", column: "tokens.output" }, + { op: "SUM", column: "tokens.reasoning" }, + { op: "SUM", column: "tokens.cache_read" }, + { op: "SUM", column: "tokens" }, + { op: "SUM", column: "cost.input.microcents" }, + { op: "SUM", column: "cost.output.microcents" }, + { op: "SUM", column: "cost.total.microcents" }, + { op: "AVG", column: "duration" }, + { op: "P50", column: "duration" }, + { op: "P95", column: "duration" }, + { op: "AVG", column: "time_to_first_byte" }, + { op: "P50", column: "time_to_first_byte" }, + { op: "P95", column: "time_to_first_byte" }, + { op: "AVG", column: "tps.output" }, + { op: "SUM", column: "success" }, + { op: "SUM", column: "error" }, ], - filters: commonFilters(), + filters: [...commonFilters(), ...filters], filter_combination: "AND", - orders: [{ column: "stat_tokens_total", op: "SUM", order: "descending" }], + orders: [{ column: "tokens", op: "SUM", order: "descending" }], + havings: [], limit, + formulas: [], } } -function lookupQuery(breakdowns: string[], timing: Timing, limit: number) { - return { - ...timing, - breakdowns, - calculated_fields: commonCalculatedFields(), - calculations: [{ op: "COUNT", name: "requests" }], - filters: commonFilters(), - filter_combination: "AND", - orders: [{ op: "COUNT", order: "descending" }], - limit, - } +function tierFilters(tier: string) { + if (tier === "all") return [] + return [{ column: "tier", op: "=", value: tier }] } -function commonCalculatedFields() { - return [ - { - name: "stat_included_client", - expression: `IF(OR(CONTAINS(COALESCE($user_agent, ""), "ai-sdk"), CONTAINS(COALESCE($user_agent, ""), "opencode")), 1, 0)`, - }, - { - name: "stat_tier", - expression: `IF(EQUALS(COALESCE($source, ""), "lite"), "Go", OR(EQUALS(COALESCE($model, ""), "gpt-5-nano"), EQUALS(COALESCE($model, ""), "grok-code"), EQUALS(COALESCE($model, ""), "big-pickle"), ENDS_WITH(COALESCE($model, ""), "-free")), "Free", "Zen")`, - }, - { - name: "stat_provider", - expression: - `IF(STARTS_WITH(COALESCE($provider, ""), "minimax-plan"), "minimax-plan", STARTS_WITH(COALESCE($provider, ""), "zai-plan"), "zai-plan", STARTS_WITH(COALESCE($provider, ""), "azure-databricks"), "azure-databricks", REG_MATCH(COALESCE($provider, ""), ` + - "`^azure[0-9]+`" + - `), "azure-openai", COALESCE($provider, "unknown"))`, - }, - { name: "stat_country", expression: `COALESCE($cf.country, "ZZ")` }, - ] -} - -function metricCalculatedFields() { - return [ - { - name: "stat_tokens_total", - expression: `SUM(COALESCE($tokens.cache_read, 0), COALESCE($tokens.cache_write_5m, 0), COALESCE($tokens.input, 0), COALESCE($tokens.output, 0))`, - }, - { - name: "stat_cost_input_microcents", - expression: `COALESCE($cost.input.microcents, MUL($cost.input, 1000000), 0)`, - }, - { - name: "stat_cost_output_microcents", - expression: `COALESCE($cost.output.microcents, MUL($cost.output, 1000000), 0)`, - }, - { - name: "stat_cost_total_microcents", - expression: `COALESCE($cost.total.microcents, MUL($cost.total, 1000000), 0)`, - }, - { - name: "stat_output_tps", - expression: `IF(LT(SUB($timestamp.last_byte, $timestamp.first_byte), 100), null, DIV(MUL($tokens.output, 1000), SUB($timestamp.last_byte, $timestamp.first_byte)))`, - }, - { name: "stat_success", expression: `IF(AND(GTE($status, 200), LT($status, 400)), 1, 0)` }, - { name: "stat_error", expression: `IF(GTE($status, 400), 1, 0)` }, - ] +function queryNameSegment(value: string) { + return ( + value + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-|-$/g, "") || "all" + ) } function commonFilters() { @@ -280,28 +269,27 @@ function commonFilters() { { column: "event_type", op: "=", value: "completions" }, { column: "model", op: "exists" }, { column: "model", op: "!=", value: "" }, - { column: "stat_included_client", op: "=", value: 1 }, ] } function metricRows( - file: string | undefined, + files: string[] | undefined, grain: Grain, opts: ImportOptions, map: (row: RawRow, base: StatBaseAggregate) => T, ) { - if (!file) return Promise.resolve([]) - return readRows(file).then((rows) => rows.map((row) => map(row, baseAggregate(row, grain, opts)))) + if (!files) return Promise.resolve([]) + return readFiles(files).then((rows) => rows.map((row) => map(row, baseAggregate(row, grain, opts)))) } function lookupRows( - file: string | undefined, + files: string[] | undefined, grain: Grain, opts: ImportOptions, map: (row: RawRow, grain: Grain, opts: ImportOptions) => readonly (readonly [string, string])[], ) { - if (!file) return Promise.resolve([]) - return readRows(file).then((rows) => + if (!files) return Promise.resolve([]) + return readFiles(files).then((rows) => Array.from( rows .flatMap((row) => map(row, grain, opts)) @@ -313,6 +301,64 @@ function lookupRows( ) } +async function readFiles(files: string[]) { + return (await Promise.all(files.map(readRows))).flat() +} + +async function discoverFiles(directories: string[]) { + const classified = await Promise.all( + (await Promise.all(directories.map(filesInDirectory))).flat().map(async (file) => ({ + file, + key: classifyRows(file, await readRows(file)), + })), + ) + return classified.reduce>>((result, item) => { + return { ...result, [item.key]: [...(result[item.key] ?? []), item.file] } + }, {}) +} + +async function filesInDirectory(directory: string): Promise { + return ( + await Promise.all( + (await readdir(directory, { withFileTypes: true })).map((entry) => { + const file = path.join(directory, entry.name) + if (entry.isDirectory()) return filesInDirectory(file) + if (entry.isFile() && /\.(csv|json)$/i.test(entry.name)) return Promise.resolve([file]) + return Promise.resolve([]) + }), + ) + ).flat() +} + +function classifyRows(file: string, rows: RawRow[]): ImportKey { + if (rows.length === 0) fail(`Cannot classify empty export: ${file}`) + const headers = new Set(rows.flatMap((row) => Object.keys(row).map(normalizeHeader))) + const grain: Grain = headers.has("date") ? "day" : "week" + if (headers.has("model")) return hasMetricHeaders(headers) ? `model-${grain}` : `model-provider-model-${grain}` + if (hasHeader(headers, ["country", "cf.country"])) + return hasMetricHeaders(headers) ? `geo-${grain}` : `geo-continent-${grain}` + if (hasHeader(headers, ["provider", "provider.normalized"])) return `provider-${grain}` + fail(`Cannot classify export from columns in ${file}`) +} + +function hasMetricHeaders(headers: Set) { + return ["sumtokens", "sumtokensinput", "inputtokens", "totaltokens", "avgduration", "countdistinctsession"].some( + (header) => headers.has(header), + ) +} + +function hasHeader(headers: Set, names: string[]) { + return names.some((name) => headers.has(normalizeHeader(name))) +} + +function mergeFiles(left: Partial>, right: Partial>) { + return inputKeys.reduce>>((result, key) => { + const files = [...(left[key] ?? []), ...(right[key] ?? [])] + if (files.length === 0) return result + return { ...result, [key]: files } + }, {}) +} + function modelProviderModelLookup(row: RawRow, grain: Grain, opts: ImportOptions): [string, string][] { const base = basePeriod(row, grain, opts) const value = providerModel(row) @@ -340,18 +386,27 @@ function baseAggregate(row: RawRow, grain: Grain, opts: ImportOptions): StatBase reasoning_tokens: integer(row, "reasoning_tokens", ["SUM(tokens.reasoning)", "SUM(tokens_reasoning)"]), cache_read_tokens: integer(row, "cache_read_tokens", ["SUM(tokens.cache_read)", "SUM(tokens_cache_read)"]), total_tokens: integer(row, "total_tokens", ["SUM(stat_tokens_total)", "SUM(tokens)", "SUM(tokens_total)"]), - input_cost_microcents: integer(row, "input_cost_microcents", ["SUM(stat_cost_input_microcents)"]), - output_cost_microcents: integer(row, "output_cost_microcents", ["SUM(stat_cost_output_microcents)"]), - total_cost_microcents: integer(row, "total_cost_microcents", ["SUM(stat_cost_total_microcents)"]), + input_cost_microcents: integer(row, "input_cost_microcents", [ + "SUM(cost.input.microcents)", + "SUM(stat_cost_input_microcents)", + ]), + output_cost_microcents: integer(row, "output_cost_microcents", [ + "SUM(cost.output.microcents)", + "SUM(stat_cost_output_microcents)", + ]), + total_cost_microcents: integer(row, "total_cost_microcents", [ + "SUM(cost.total.microcents)", + "SUM(stat_cost_total_microcents)", + ]), avg_duration_ms: nullableNumber(row, "avg_duration_ms", ["AVG(duration)", "AVG(duration_ms)"]), p50_duration_ms: nullableInteger(row, "p50_duration_ms", ["P50(duration)", "P50(duration_ms)"]), p95_duration_ms: nullableInteger(row, "p95_duration_ms", ["P95(duration)", "P95(duration_ms)"]), avg_ttfb_ms: nullableNumber(row, "avg_ttfb_ms", ["AVG(time_to_first_byte)", "AVG(ttfb_ms)"]), p50_ttfb_ms: nullableInteger(row, "p50_ttfb_ms", ["P50(time_to_first_byte)", "P50(ttfb_ms)"]), p95_ttfb_ms: nullableInteger(row, "p95_ttfb_ms", ["P95(time_to_first_byte)", "P95(ttfb_ms)"]), - avg_output_tps: nullableNumber(row, "avg_output_tps", ["AVG(stat_output_tps)", "AVG(tps.output)"]), - success_count: integer(row, "success_count", ["SUM(stat_success)"]), - error_count: integer(row, "error_count", ["SUM(stat_error)"]), + avg_output_tps: nullableNumber(row, "avg_output_tps", ["AVG(tps.output)", "AVG(stat_output_tps)"]), + success_count: integer(row, "success_count", ["SUM(success)", "SUM(is_success)", "SUM(stat_success)"]), + error_count: integer(row, "error_count", ["SUM(error)", "SUM(is_error)", "SUM(stat_error)"]), sample_count: integer(row, "sample_count", ["COUNT", "COUNT()"]), } } @@ -477,7 +532,7 @@ function deriveTier(row: RawRow) { } function provider(row: RawRow) { - return normalizeProvider(cell(row, ["stat_provider", "provider"]) || "unknown") + return normalizeProvider(cell(row, ["provider.normalized", "stat_provider", "provider"]) || "unknown") } function normalizeProvider(value: string) { @@ -544,7 +599,7 @@ function normalizeHeader(value: string) { } function parseTime(row: RawRow) { - const value = cell(row, ["time", "timestamp", "date", "datetime", "bucket"]) + const value = cell(row, ["date", "time", "timestamp", "datetime", "bucket"]) if (!value) return undefined const numeric = Number(value) const date = Number.isFinite(numeric) @@ -562,10 +617,6 @@ function syncWeekStart(periodEnd: Date) { return new Date(Date.UTC(periodEnd.getUTCFullYear(), periodEnd.getUTCMonth(), periodEnd.getUTCDate() - 6)) } -function defaultPeriodEnd() { - return new Date(Math.floor((Date.now() - 5 * 60_000) / 60_000) * 60_000) -} - function sameUtcDay(left: Date, right: Date) { return ( left.getUTCFullYear() === right.getUTCFullYear() && @@ -799,14 +850,15 @@ async function upsertGeoRows(db: ReturnType, rows: GeoStatRow[]) function parseImportOptions(args: string[]): ImportOptions { const flags = parseFlags(args) - const files = inputKeys.reduce>>((result, key) => { - const value = flags.get(key)?.[0] - if (!value) return result - return { ...result, [key]: value } + const files = inputKeys.reduce>>((result, key) => { + const values = flags.get(key) + if (!values) return result + return { ...result, [key]: values } }, {}) return { dataset: flags.get("dataset")?.[0] ?? "zen", databaseUrl: flags.get("database-url")?.[0] ?? process.env.DATABASE_URL, + directories: flags.get("dir") ?? flags.get("directory") ?? [], dryRun: flags.has("dry-run"), periodEnd: parseDateFlag(flags, "period-end"), periodStart: parseDateFlag(flags, "period-start"), @@ -820,14 +872,15 @@ function parseFlags(args: string[]) { const arg = args[index] if (!arg.startsWith("--")) fail(`Unexpected argument: ${arg}`) const name = arg.slice(2) - if (name === "dry-run") { + if (name === "dry-run" || name === "include-weekly") { result.set(name, ["true"]) continue } - const value = args[index + 1] - if (!value || value.startsWith("--")) fail(`Missing value for --${name}`) - result.set(name, [...(result.get(name) ?? []), value]) - index++ + const nextFlag = args.findIndex((value, valueIndex) => valueIndex > index && value.startsWith("--")) + const values = args.slice(index + 1, nextFlag === -1 ? args.length : nextFlag) + if (values.length === 0) fail(`Missing value for --${name}`) + result.set(name, [...(result.get(name) ?? []), ...values]) + index += values.length } return result } @@ -848,10 +901,21 @@ function parseIntegerFlag(flags: Map, name: string) { return parsed } +function parseListFlag(flags: Map, name: string) { + const value = flags.get(name)?.[0] + if (!value) return undefined + if (value === "all") return ["all"] + return value + .split(",") + .map((item) => item.trim()) + .filter(Boolean) +} + function usage(): never { fail(`Usage: - bun src/honeycomb-backfill.ts queries [--period-end ISO] [--days 60] [--limit 1000] - bun src/honeycomb-backfill.ts import --period-end ISO [--dry-run] [--database-url URL] --model-day file.csv ...`) + bun src/honeycomb-backfill.ts queries [--tiers Go,Free,Paid] [--limit 1000] [--include-weekly] + bun src/honeycomb-backfill.ts import [--period-end ISO for weekly files] [--dry-run] [--database-url URL] --dir downloads + bun src/honeycomb-backfill.ts import [--period-end ISO for weekly files] [--dry-run] [--database-url URL] --model-day file.csv [--model-day more.csv] ...`) } function fail(message: string): never { From 9ac0f3e9ac7da2abbd218a632312a793139970b4 Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Thu, 28 May 2026 07:12:47 -0500 Subject: [PATCH 029/113] fix(stats): better stat table periods, provider/model norm --- .../migration.sql | 5 + .../snapshot.json | 2105 ++++++++++++++++ .../migration.sql | 3 + .../snapshot.json | 2147 +++++++++++++++++ .../migration.sql | 6 + .../snapshot.json | 2063 ++++++++++++++++ packages/stats/core/src/database/schema.ts | 34 +- packages/stats/core/src/domain/geo.ts | 71 +- packages/stats/core/src/domain/home.ts | 58 +- .../stats/core/src/domain/inference.test.ts | 66 + packages/stats/core/src/domain/inference.ts | 132 +- .../core/src/domain/model-normalization.ts | 30 + packages/stats/core/src/domain/model.ts | 11 +- packages/stats/core/src/domain/provider.ts | 17 +- packages/stats/core/src/domain/stat.ts | 45 +- packages/stats/core/src/honeycomb-backfill.ts | 253 +- packages/stats/core/src/stat-sync.ts | 13 +- 17 files changed, 6824 insertions(+), 235 deletions(-) create mode 100644 packages/stats/core/migrations/20260528012726_worthless_ultimo/migration.sql create mode 100644 packages/stats/core/migrations/20260528012726_worthless_ultimo/snapshot.json create mode 100644 packages/stats/core/migrations/20260528114447_misty_black_knight/migration.sql create mode 100644 packages/stats/core/migrations/20260528114447_misty_black_knight/snapshot.json create mode 100644 packages/stats/core/migrations/20260528121120_easy_multiple_man/migration.sql create mode 100644 packages/stats/core/migrations/20260528121120_easy_multiple_man/snapshot.json create mode 100644 packages/stats/core/src/domain/inference.test.ts create mode 100644 packages/stats/core/src/domain/model-normalization.ts diff --git a/packages/stats/core/migrations/20260528012726_worthless_ultimo/migration.sql b/packages/stats/core/migrations/20260528012726_worthless_ultimo/migration.sql new file mode 100644 index 0000000000..250c8117e4 --- /dev/null +++ b/packages/stats/core/migrations/20260528012726_worthless_ultimo/migration.sql @@ -0,0 +1,5 @@ +ALTER TABLE `geo_stat` ADD `provider` varchar(128) DEFAULT 'all' NOT NULL;--> statement-breakpoint +ALTER TABLE `geo_stat` ADD `model` varchar(256) DEFAULT 'all' NOT NULL;--> statement-breakpoint +ALTER TABLE `geo_stat` DROP INDEX `uniq_country_period`;--> statement-breakpoint +CREATE UNIQUE INDEX `uniq_country_period` ON `geo_stat` (`grain`,`period_start`,`dataset`,`tier`,`client`,`source`,`provider`,`model`,`country`);--> statement-breakpoint +CREATE INDEX `idx_country_model` ON `geo_stat` (`model`,`country`,`grain`,`period_start`); diff --git a/packages/stats/core/migrations/20260528012726_worthless_ultimo/snapshot.json b/packages/stats/core/migrations/20260528012726_worthless_ultimo/snapshot.json new file mode 100644 index 0000000000..4a6d03ce81 --- /dev/null +++ b/packages/stats/core/migrations/20260528012726_worthless_ultimo/snapshot.json @@ -0,0 +1,2105 @@ +{ + "version": "6", + "dialect": "mysql", + "id": "c7296da3-649d-44f8-a6d5-7cbd755b2c67", + "prevIds": [ + "e246639a-0da0-4fbd-b7bb-f1781d407780" + ], + "ddl": [ + { + "name": "geo_stat", + "entityType": "tables" + }, + { + "name": "model_stat", + "entityType": "tables" + }, + { + "name": "provider_stat", + "entityType": "tables" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": true, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "geo_stat" + }, + { + "type": "varchar(16)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "grain", + "entityType": "columns", + "table": "geo_stat" + }, + { + "type": "datetime", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "period_start", + "entityType": "columns", + "table": "geo_stat" + }, + { + "type": "datetime", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "period_end", + "entityType": "columns", + "table": "geo_stat" + }, + { + "type": "varchar(64)", + "notNull": true, + "autoIncrement": false, + "default": "'all'", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "dataset", + "entityType": "columns", + "table": "geo_stat" + }, + { + "type": "varchar(64)", + "notNull": true, + "autoIncrement": false, + "default": "'all'", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "tier", + "entityType": "columns", + "table": "geo_stat" + }, + { + "type": "varchar(64)", + "notNull": true, + "autoIncrement": false, + "default": "'all'", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "client", + "entityType": "columns", + "table": "geo_stat" + }, + { + "type": "varchar(64)", + "notNull": true, + "autoIncrement": false, + "default": "'all'", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "source", + "entityType": "columns", + "table": "geo_stat" + }, + { + "type": "varchar(128)", + "notNull": true, + "autoIncrement": false, + "default": "'all'", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "provider", + "entityType": "columns", + "table": "geo_stat" + }, + { + "type": "varchar(256)", + "notNull": true, + "autoIncrement": false, + "default": "'all'", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "model", + "entityType": "columns", + "table": "geo_stat" + }, + { + "type": "char(2)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "country", + "entityType": "columns", + "table": "geo_stat" + }, + { + "type": "varchar(8)", + "notNull": true, + "autoIncrement": false, + "default": "''", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "continent", + "entityType": "columns", + "table": "geo_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "sessions", + "entityType": "columns", + "table": "geo_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "requests", + "entityType": "columns", + "table": "geo_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "input_tokens", + "entityType": "columns", + "table": "geo_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "output_tokens", + "entityType": "columns", + "table": "geo_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "reasoning_tokens", + "entityType": "columns", + "table": "geo_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "cache_read_tokens", + "entityType": "columns", + "table": "geo_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "total_tokens", + "entityType": "columns", + "table": "geo_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "input_cost_microcents", + "entityType": "columns", + "table": "geo_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "output_cost_microcents", + "entityType": "columns", + "table": "geo_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "total_cost_microcents", + "entityType": "columns", + "table": "geo_stat" + }, + { + "type": "decimal(12,2)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "avg_duration_ms", + "entityType": "columns", + "table": "geo_stat" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "p50_duration_ms", + "entityType": "columns", + "table": "geo_stat" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "p95_duration_ms", + "entityType": "columns", + "table": "geo_stat" + }, + { + "type": "decimal(12,2)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "avg_ttfb_ms", + "entityType": "columns", + "table": "geo_stat" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "p50_ttfb_ms", + "entityType": "columns", + "table": "geo_stat" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "p95_ttfb_ms", + "entityType": "columns", + "table": "geo_stat" + }, + { + "type": "decimal(12,4)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "avg_output_tps", + "entityType": "columns", + "table": "geo_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "success_count", + "entityType": "columns", + "table": "geo_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "error_count", + "entityType": "columns", + "table": "geo_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "sample_count", + "entityType": "columns", + "table": "geo_stat" + }, + { + "type": "decimal(10,6)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "market_share_tokens", + "entityType": "columns", + "table": "geo_stat" + }, + { + "type": "decimal(10,6)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "market_share_requests", + "entityType": "columns", + "table": "geo_stat" + }, + { + "type": "decimal(10,6)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "market_share_sessions", + "entityType": "columns", + "table": "geo_stat" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "rank_by_tokens", + "entityType": "columns", + "table": "geo_stat" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "rank_by_requests", + "entityType": "columns", + "table": "geo_stat" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "rank_by_sessions", + "entityType": "columns", + "table": "geo_stat" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "rank_by_cost", + "entityType": "columns", + "table": "geo_stat" + }, + { + "type": "datetime", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "created_at", + "entityType": "columns", + "table": "geo_stat" + }, + { + "type": "datetime", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": true, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "updated_at", + "entityType": "columns", + "table": "geo_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": true, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "model_stat" + }, + { + "type": "varchar(16)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "grain", + "entityType": "columns", + "table": "model_stat" + }, + { + "type": "datetime", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "period_start", + "entityType": "columns", + "table": "model_stat" + }, + { + "type": "datetime", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "period_end", + "entityType": "columns", + "table": "model_stat" + }, + { + "type": "varchar(64)", + "notNull": true, + "autoIncrement": false, + "default": "'all'", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "dataset", + "entityType": "columns", + "table": "model_stat" + }, + { + "type": "varchar(64)", + "notNull": true, + "autoIncrement": false, + "default": "'all'", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "tier", + "entityType": "columns", + "table": "model_stat" + }, + { + "type": "varchar(64)", + "notNull": true, + "autoIncrement": false, + "default": "'all'", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "client", + "entityType": "columns", + "table": "model_stat" + }, + { + "type": "varchar(64)", + "notNull": true, + "autoIncrement": false, + "default": "'all'", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "source", + "entityType": "columns", + "table": "model_stat" + }, + { + "type": "varchar(128)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "provider", + "entityType": "columns", + "table": "model_stat" + }, + { + "type": "varchar(256)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "model", + "entityType": "columns", + "table": "model_stat" + }, + { + "type": "varchar(256)", + "notNull": true, + "autoIncrement": false, + "default": "''", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "provider_model", + "entityType": "columns", + "table": "model_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "sessions", + "entityType": "columns", + "table": "model_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "requests", + "entityType": "columns", + "table": "model_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "input_tokens", + "entityType": "columns", + "table": "model_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "output_tokens", + "entityType": "columns", + "table": "model_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "reasoning_tokens", + "entityType": "columns", + "table": "model_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "cache_read_tokens", + "entityType": "columns", + "table": "model_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "total_tokens", + "entityType": "columns", + "table": "model_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "input_cost_microcents", + "entityType": "columns", + "table": "model_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "output_cost_microcents", + "entityType": "columns", + "table": "model_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "total_cost_microcents", + "entityType": "columns", + "table": "model_stat" + }, + { + "type": "decimal(12,2)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "avg_duration_ms", + "entityType": "columns", + "table": "model_stat" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "p50_duration_ms", + "entityType": "columns", + "table": "model_stat" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "p95_duration_ms", + "entityType": "columns", + "table": "model_stat" + }, + { + "type": "decimal(12,2)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "avg_ttfb_ms", + "entityType": "columns", + "table": "model_stat" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "p50_ttfb_ms", + "entityType": "columns", + "table": "model_stat" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "p95_ttfb_ms", + "entityType": "columns", + "table": "model_stat" + }, + { + "type": "decimal(12,4)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "avg_output_tps", + "entityType": "columns", + "table": "model_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "success_count", + "entityType": "columns", + "table": "model_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "error_count", + "entityType": "columns", + "table": "model_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "sample_count", + "entityType": "columns", + "table": "model_stat" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "rank_by_tokens", + "entityType": "columns", + "table": "model_stat" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "rank_by_requests", + "entityType": "columns", + "table": "model_stat" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "rank_by_cost", + "entityType": "columns", + "table": "model_stat" + }, + { + "type": "datetime", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "created_at", + "entityType": "columns", + "table": "model_stat" + }, + { + "type": "datetime", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": true, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "updated_at", + "entityType": "columns", + "table": "model_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": true, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "provider_stat" + }, + { + "type": "varchar(16)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "grain", + "entityType": "columns", + "table": "provider_stat" + }, + { + "type": "datetime", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "period_start", + "entityType": "columns", + "table": "provider_stat" + }, + { + "type": "datetime", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "period_end", + "entityType": "columns", + "table": "provider_stat" + }, + { + "type": "varchar(64)", + "notNull": true, + "autoIncrement": false, + "default": "'all'", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "dataset", + "entityType": "columns", + "table": "provider_stat" + }, + { + "type": "varchar(64)", + "notNull": true, + "autoIncrement": false, + "default": "'all'", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "tier", + "entityType": "columns", + "table": "provider_stat" + }, + { + "type": "varchar(64)", + "notNull": true, + "autoIncrement": false, + "default": "'all'", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "client", + "entityType": "columns", + "table": "provider_stat" + }, + { + "type": "varchar(64)", + "notNull": true, + "autoIncrement": false, + "default": "'all'", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "source", + "entityType": "columns", + "table": "provider_stat" + }, + { + "type": "varchar(128)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "provider", + "entityType": "columns", + "table": "provider_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "sessions", + "entityType": "columns", + "table": "provider_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "requests", + "entityType": "columns", + "table": "provider_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "input_tokens", + "entityType": "columns", + "table": "provider_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "output_tokens", + "entityType": "columns", + "table": "provider_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "reasoning_tokens", + "entityType": "columns", + "table": "provider_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "cache_read_tokens", + "entityType": "columns", + "table": "provider_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "total_tokens", + "entityType": "columns", + "table": "provider_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "input_cost_microcents", + "entityType": "columns", + "table": "provider_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "output_cost_microcents", + "entityType": "columns", + "table": "provider_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "total_cost_microcents", + "entityType": "columns", + "table": "provider_stat" + }, + { + "type": "decimal(12,2)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "avg_duration_ms", + "entityType": "columns", + "table": "provider_stat" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "p50_duration_ms", + "entityType": "columns", + "table": "provider_stat" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "p95_duration_ms", + "entityType": "columns", + "table": "provider_stat" + }, + { + "type": "decimal(12,2)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "avg_ttfb_ms", + "entityType": "columns", + "table": "provider_stat" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "p50_ttfb_ms", + "entityType": "columns", + "table": "provider_stat" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "p95_ttfb_ms", + "entityType": "columns", + "table": "provider_stat" + }, + { + "type": "decimal(12,4)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "avg_output_tps", + "entityType": "columns", + "table": "provider_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "success_count", + "entityType": "columns", + "table": "provider_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "error_count", + "entityType": "columns", + "table": "provider_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "sample_count", + "entityType": "columns", + "table": "provider_stat" + }, + { + "type": "decimal(10,6)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "market_share_tokens", + "entityType": "columns", + "table": "provider_stat" + }, + { + "type": "decimal(10,6)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "market_share_requests", + "entityType": "columns", + "table": "provider_stat" + }, + { + "type": "decimal(10,6)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "market_share_sessions", + "entityType": "columns", + "table": "provider_stat" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "rank_by_tokens", + "entityType": "columns", + "table": "provider_stat" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "rank_by_requests", + "entityType": "columns", + "table": "provider_stat" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "rank_by_sessions", + "entityType": "columns", + "table": "provider_stat" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "rank_by_cost", + "entityType": "columns", + "table": "provider_stat" + }, + { + "type": "datetime", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "created_at", + "entityType": "columns", + "table": "provider_stat" + }, + { + "type": "datetime", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": true, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "updated_at", + "entityType": "columns", + "table": "provider_stat" + }, + { + "columns": [ + "id" + ], + "name": "PRIMARY", + "table": "geo_stat", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "name": "PRIMARY", + "table": "model_stat", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "name": "PRIMARY", + "table": "provider_stat", + "entityType": "pks" + }, + { + "columns": [ + { + "value": "grain", + "isExpression": false + }, + { + "value": "period_start", + "isExpression": false + }, + { + "value": "dataset", + "isExpression": false + }, + { + "value": "tier", + "isExpression": false + }, + { + "value": "client", + "isExpression": false + }, + { + "value": "source", + "isExpression": false + }, + { + "value": "provider", + "isExpression": false + }, + { + "value": "model", + "isExpression": false + }, + { + "value": "country", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "uniq_country_period", + "entityType": "indexes", + "table": "geo_stat" + }, + { + "columns": [ + { + "value": "grain", + "isExpression": false + }, + { + "value": "period_start", + "isExpression": false + }, + { + "value": "dataset", + "isExpression": false + }, + { + "value": "tier", + "isExpression": false + }, + { + "value": "total_tokens", + "isExpression": false + } + ], + "isUnique": false, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "idx_country_map_tokens", + "entityType": "indexes", + "table": "geo_stat" + }, + { + "columns": [ + { + "value": "grain", + "isExpression": false + }, + { + "value": "period_start", + "isExpression": false + }, + { + "value": "dataset", + "isExpression": false + }, + { + "value": "tier", + "isExpression": false + }, + { + "value": "rank_by_tokens", + "isExpression": false + } + ], + "isUnique": false, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "idx_country_rank", + "entityType": "indexes", + "table": "geo_stat" + }, + { + "columns": [ + { + "value": "country", + "isExpression": false + }, + { + "value": "grain", + "isExpression": false + }, + { + "value": "period_start", + "isExpression": false + } + ], + "isUnique": false, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "idx_country", + "entityType": "indexes", + "table": "geo_stat" + }, + { + "columns": [ + { + "value": "continent", + "isExpression": false + }, + { + "value": "grain", + "isExpression": false + }, + { + "value": "period_start", + "isExpression": false + } + ], + "isUnique": false, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "idx_continent", + "entityType": "indexes", + "table": "geo_stat" + }, + { + "columns": [ + { + "value": "model", + "isExpression": false + }, + { + "value": "country", + "isExpression": false + }, + { + "value": "grain", + "isExpression": false + }, + { + "value": "period_start", + "isExpression": false + } + ], + "isUnique": false, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "idx_country_model", + "entityType": "indexes", + "table": "geo_stat" + }, + { + "columns": [ + { + "value": "grain", + "isExpression": false + }, + { + "value": "period_start", + "isExpression": false + }, + { + "value": "dataset", + "isExpression": false + }, + { + "value": "tier", + "isExpression": false + }, + { + "value": "client", + "isExpression": false + }, + { + "value": "source", + "isExpression": false + }, + { + "value": "provider", + "isExpression": false + }, + { + "value": "model", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "uniq_model_period", + "entityType": "indexes", + "table": "model_stat" + }, + { + "columns": [ + { + "value": "grain", + "isExpression": false + }, + { + "value": "period_start", + "isExpression": false + }, + { + "value": "dataset", + "isExpression": false + }, + { + "value": "tier", + "isExpression": false + }, + { + "value": "total_tokens", + "isExpression": false + } + ], + "isUnique": false, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "idx_leaderboard_tokens", + "entityType": "indexes", + "table": "model_stat" + }, + { + "columns": [ + { + "value": "model", + "isExpression": false + }, + { + "value": "grain", + "isExpression": false + }, + { + "value": "period_start", + "isExpression": false + } + ], + "isUnique": false, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "idx_model", + "entityType": "indexes", + "table": "model_stat" + }, + { + "columns": [ + { + "value": "grain", + "isExpression": false + }, + { + "value": "period_start", + "isExpression": false + }, + { + "value": "dataset", + "isExpression": false + }, + { + "value": "tier", + "isExpression": false + }, + { + "value": "client", + "isExpression": false + }, + { + "value": "source", + "isExpression": false + }, + { + "value": "provider", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "uniq_provider_period", + "entityType": "indexes", + "table": "provider_stat" + }, + { + "columns": [ + { + "value": "grain", + "isExpression": false + }, + { + "value": "period_start", + "isExpression": false + }, + { + "value": "dataset", + "isExpression": false + }, + { + "value": "tier", + "isExpression": false + }, + { + "value": "total_tokens", + "isExpression": false + } + ], + "isUnique": false, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "idx_provider_leaderboard_tokens", + "entityType": "indexes", + "table": "provider_stat" + }, + { + "columns": [ + { + "value": "grain", + "isExpression": false + }, + { + "value": "period_start", + "isExpression": false + }, + { + "value": "dataset", + "isExpression": false + }, + { + "value": "tier", + "isExpression": false + }, + { + "value": "market_share_tokens", + "isExpression": false + } + ], + "isUnique": false, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "idx_provider_market_share", + "entityType": "indexes", + "table": "provider_stat" + }, + { + "columns": [ + { + "value": "grain", + "isExpression": false + }, + { + "value": "period_start", + "isExpression": false + }, + { + "value": "dataset", + "isExpression": false + }, + { + "value": "tier", + "isExpression": false + }, + { + "value": "rank_by_tokens", + "isExpression": false + } + ], + "isUnique": false, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "idx_provider_rank", + "entityType": "indexes", + "table": "provider_stat" + }, + { + "columns": [ + { + "value": "provider", + "isExpression": false + }, + { + "value": "grain", + "isExpression": false + }, + { + "value": "period_start", + "isExpression": false + } + ], + "isUnique": false, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "idx_provider", + "entityType": "indexes", + "table": "provider_stat" + } + ], + "renames": [] +} \ No newline at end of file diff --git a/packages/stats/core/migrations/20260528114447_misty_black_knight/migration.sql b/packages/stats/core/migrations/20260528114447_misty_black_knight/migration.sql new file mode 100644 index 0000000000..4beb87b63b --- /dev/null +++ b/packages/stats/core/migrations/20260528114447_misty_black_knight/migration.sql @@ -0,0 +1,3 @@ +ALTER TABLE `geo_stat` ADD `period_key` varchar(32) NOT NULL;--> statement-breakpoint +ALTER TABLE `model_stat` ADD `period_key` varchar(32) NOT NULL;--> statement-breakpoint +ALTER TABLE `provider_stat` ADD `period_key` varchar(32) NOT NULL; \ No newline at end of file diff --git a/packages/stats/core/migrations/20260528114447_misty_black_knight/snapshot.json b/packages/stats/core/migrations/20260528114447_misty_black_knight/snapshot.json new file mode 100644 index 0000000000..ba17411cde --- /dev/null +++ b/packages/stats/core/migrations/20260528114447_misty_black_knight/snapshot.json @@ -0,0 +1,2147 @@ +{ + "version": "6", + "dialect": "mysql", + "id": "cefe97ad-34ad-42f3-9343-47b1b7f478fa", + "prevIds": [ + "c7296da3-649d-44f8-a6d5-7cbd755b2c67" + ], + "ddl": [ + { + "name": "geo_stat", + "entityType": "tables" + }, + { + "name": "model_stat", + "entityType": "tables" + }, + { + "name": "provider_stat", + "entityType": "tables" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": true, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "geo_stat" + }, + { + "type": "varchar(16)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "grain", + "entityType": "columns", + "table": "geo_stat" + }, + { + "type": "varchar(32)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "period_key", + "entityType": "columns", + "table": "geo_stat" + }, + { + "type": "datetime", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "period_start", + "entityType": "columns", + "table": "geo_stat" + }, + { + "type": "datetime", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "period_end", + "entityType": "columns", + "table": "geo_stat" + }, + { + "type": "varchar(64)", + "notNull": true, + "autoIncrement": false, + "default": "'all'", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "dataset", + "entityType": "columns", + "table": "geo_stat" + }, + { + "type": "varchar(64)", + "notNull": true, + "autoIncrement": false, + "default": "'all'", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "tier", + "entityType": "columns", + "table": "geo_stat" + }, + { + "type": "varchar(64)", + "notNull": true, + "autoIncrement": false, + "default": "'all'", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "client", + "entityType": "columns", + "table": "geo_stat" + }, + { + "type": "varchar(64)", + "notNull": true, + "autoIncrement": false, + "default": "'all'", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "source", + "entityType": "columns", + "table": "geo_stat" + }, + { + "type": "varchar(128)", + "notNull": true, + "autoIncrement": false, + "default": "'all'", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "provider", + "entityType": "columns", + "table": "geo_stat" + }, + { + "type": "varchar(256)", + "notNull": true, + "autoIncrement": false, + "default": "'all'", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "model", + "entityType": "columns", + "table": "geo_stat" + }, + { + "type": "char(2)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "country", + "entityType": "columns", + "table": "geo_stat" + }, + { + "type": "varchar(8)", + "notNull": true, + "autoIncrement": false, + "default": "''", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "continent", + "entityType": "columns", + "table": "geo_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "sessions", + "entityType": "columns", + "table": "geo_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "requests", + "entityType": "columns", + "table": "geo_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "input_tokens", + "entityType": "columns", + "table": "geo_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "output_tokens", + "entityType": "columns", + "table": "geo_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "reasoning_tokens", + "entityType": "columns", + "table": "geo_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "cache_read_tokens", + "entityType": "columns", + "table": "geo_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "total_tokens", + "entityType": "columns", + "table": "geo_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "input_cost_microcents", + "entityType": "columns", + "table": "geo_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "output_cost_microcents", + "entityType": "columns", + "table": "geo_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "total_cost_microcents", + "entityType": "columns", + "table": "geo_stat" + }, + { + "type": "decimal(12,2)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "avg_duration_ms", + "entityType": "columns", + "table": "geo_stat" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "p50_duration_ms", + "entityType": "columns", + "table": "geo_stat" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "p95_duration_ms", + "entityType": "columns", + "table": "geo_stat" + }, + { + "type": "decimal(12,2)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "avg_ttfb_ms", + "entityType": "columns", + "table": "geo_stat" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "p50_ttfb_ms", + "entityType": "columns", + "table": "geo_stat" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "p95_ttfb_ms", + "entityType": "columns", + "table": "geo_stat" + }, + { + "type": "decimal(12,4)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "avg_output_tps", + "entityType": "columns", + "table": "geo_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "success_count", + "entityType": "columns", + "table": "geo_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "error_count", + "entityType": "columns", + "table": "geo_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "sample_count", + "entityType": "columns", + "table": "geo_stat" + }, + { + "type": "decimal(10,6)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "market_share_tokens", + "entityType": "columns", + "table": "geo_stat" + }, + { + "type": "decimal(10,6)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "market_share_requests", + "entityType": "columns", + "table": "geo_stat" + }, + { + "type": "decimal(10,6)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "market_share_sessions", + "entityType": "columns", + "table": "geo_stat" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "rank_by_tokens", + "entityType": "columns", + "table": "geo_stat" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "rank_by_requests", + "entityType": "columns", + "table": "geo_stat" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "rank_by_sessions", + "entityType": "columns", + "table": "geo_stat" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "rank_by_cost", + "entityType": "columns", + "table": "geo_stat" + }, + { + "type": "datetime", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "created_at", + "entityType": "columns", + "table": "geo_stat" + }, + { + "type": "datetime", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": true, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "updated_at", + "entityType": "columns", + "table": "geo_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": true, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "model_stat" + }, + { + "type": "varchar(16)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "grain", + "entityType": "columns", + "table": "model_stat" + }, + { + "type": "varchar(32)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "period_key", + "entityType": "columns", + "table": "model_stat" + }, + { + "type": "datetime", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "period_start", + "entityType": "columns", + "table": "model_stat" + }, + { + "type": "datetime", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "period_end", + "entityType": "columns", + "table": "model_stat" + }, + { + "type": "varchar(64)", + "notNull": true, + "autoIncrement": false, + "default": "'all'", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "dataset", + "entityType": "columns", + "table": "model_stat" + }, + { + "type": "varchar(64)", + "notNull": true, + "autoIncrement": false, + "default": "'all'", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "tier", + "entityType": "columns", + "table": "model_stat" + }, + { + "type": "varchar(64)", + "notNull": true, + "autoIncrement": false, + "default": "'all'", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "client", + "entityType": "columns", + "table": "model_stat" + }, + { + "type": "varchar(64)", + "notNull": true, + "autoIncrement": false, + "default": "'all'", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "source", + "entityType": "columns", + "table": "model_stat" + }, + { + "type": "varchar(128)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "provider", + "entityType": "columns", + "table": "model_stat" + }, + { + "type": "varchar(256)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "model", + "entityType": "columns", + "table": "model_stat" + }, + { + "type": "varchar(256)", + "notNull": true, + "autoIncrement": false, + "default": "''", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "provider_model", + "entityType": "columns", + "table": "model_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "sessions", + "entityType": "columns", + "table": "model_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "requests", + "entityType": "columns", + "table": "model_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "input_tokens", + "entityType": "columns", + "table": "model_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "output_tokens", + "entityType": "columns", + "table": "model_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "reasoning_tokens", + "entityType": "columns", + "table": "model_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "cache_read_tokens", + "entityType": "columns", + "table": "model_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "total_tokens", + "entityType": "columns", + "table": "model_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "input_cost_microcents", + "entityType": "columns", + "table": "model_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "output_cost_microcents", + "entityType": "columns", + "table": "model_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "total_cost_microcents", + "entityType": "columns", + "table": "model_stat" + }, + { + "type": "decimal(12,2)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "avg_duration_ms", + "entityType": "columns", + "table": "model_stat" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "p50_duration_ms", + "entityType": "columns", + "table": "model_stat" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "p95_duration_ms", + "entityType": "columns", + "table": "model_stat" + }, + { + "type": "decimal(12,2)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "avg_ttfb_ms", + "entityType": "columns", + "table": "model_stat" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "p50_ttfb_ms", + "entityType": "columns", + "table": "model_stat" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "p95_ttfb_ms", + "entityType": "columns", + "table": "model_stat" + }, + { + "type": "decimal(12,4)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "avg_output_tps", + "entityType": "columns", + "table": "model_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "success_count", + "entityType": "columns", + "table": "model_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "error_count", + "entityType": "columns", + "table": "model_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "sample_count", + "entityType": "columns", + "table": "model_stat" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "rank_by_tokens", + "entityType": "columns", + "table": "model_stat" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "rank_by_requests", + "entityType": "columns", + "table": "model_stat" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "rank_by_cost", + "entityType": "columns", + "table": "model_stat" + }, + { + "type": "datetime", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "created_at", + "entityType": "columns", + "table": "model_stat" + }, + { + "type": "datetime", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": true, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "updated_at", + "entityType": "columns", + "table": "model_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": true, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "provider_stat" + }, + { + "type": "varchar(16)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "grain", + "entityType": "columns", + "table": "provider_stat" + }, + { + "type": "varchar(32)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "period_key", + "entityType": "columns", + "table": "provider_stat" + }, + { + "type": "datetime", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "period_start", + "entityType": "columns", + "table": "provider_stat" + }, + { + "type": "datetime", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "period_end", + "entityType": "columns", + "table": "provider_stat" + }, + { + "type": "varchar(64)", + "notNull": true, + "autoIncrement": false, + "default": "'all'", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "dataset", + "entityType": "columns", + "table": "provider_stat" + }, + { + "type": "varchar(64)", + "notNull": true, + "autoIncrement": false, + "default": "'all'", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "tier", + "entityType": "columns", + "table": "provider_stat" + }, + { + "type": "varchar(64)", + "notNull": true, + "autoIncrement": false, + "default": "'all'", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "client", + "entityType": "columns", + "table": "provider_stat" + }, + { + "type": "varchar(64)", + "notNull": true, + "autoIncrement": false, + "default": "'all'", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "source", + "entityType": "columns", + "table": "provider_stat" + }, + { + "type": "varchar(128)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "provider", + "entityType": "columns", + "table": "provider_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "sessions", + "entityType": "columns", + "table": "provider_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "requests", + "entityType": "columns", + "table": "provider_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "input_tokens", + "entityType": "columns", + "table": "provider_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "output_tokens", + "entityType": "columns", + "table": "provider_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "reasoning_tokens", + "entityType": "columns", + "table": "provider_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "cache_read_tokens", + "entityType": "columns", + "table": "provider_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "total_tokens", + "entityType": "columns", + "table": "provider_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "input_cost_microcents", + "entityType": "columns", + "table": "provider_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "output_cost_microcents", + "entityType": "columns", + "table": "provider_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "total_cost_microcents", + "entityType": "columns", + "table": "provider_stat" + }, + { + "type": "decimal(12,2)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "avg_duration_ms", + "entityType": "columns", + "table": "provider_stat" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "p50_duration_ms", + "entityType": "columns", + "table": "provider_stat" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "p95_duration_ms", + "entityType": "columns", + "table": "provider_stat" + }, + { + "type": "decimal(12,2)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "avg_ttfb_ms", + "entityType": "columns", + "table": "provider_stat" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "p50_ttfb_ms", + "entityType": "columns", + "table": "provider_stat" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "p95_ttfb_ms", + "entityType": "columns", + "table": "provider_stat" + }, + { + "type": "decimal(12,4)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "avg_output_tps", + "entityType": "columns", + "table": "provider_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "success_count", + "entityType": "columns", + "table": "provider_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "error_count", + "entityType": "columns", + "table": "provider_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "sample_count", + "entityType": "columns", + "table": "provider_stat" + }, + { + "type": "decimal(10,6)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "market_share_tokens", + "entityType": "columns", + "table": "provider_stat" + }, + { + "type": "decimal(10,6)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "market_share_requests", + "entityType": "columns", + "table": "provider_stat" + }, + { + "type": "decimal(10,6)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "market_share_sessions", + "entityType": "columns", + "table": "provider_stat" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "rank_by_tokens", + "entityType": "columns", + "table": "provider_stat" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "rank_by_requests", + "entityType": "columns", + "table": "provider_stat" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "rank_by_sessions", + "entityType": "columns", + "table": "provider_stat" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "rank_by_cost", + "entityType": "columns", + "table": "provider_stat" + }, + { + "type": "datetime", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "created_at", + "entityType": "columns", + "table": "provider_stat" + }, + { + "type": "datetime", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": true, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "updated_at", + "entityType": "columns", + "table": "provider_stat" + }, + { + "columns": [ + "id" + ], + "name": "PRIMARY", + "table": "geo_stat", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "name": "PRIMARY", + "table": "model_stat", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "name": "PRIMARY", + "table": "provider_stat", + "entityType": "pks" + }, + { + "columns": [ + { + "value": "grain", + "isExpression": false + }, + { + "value": "period_key", + "isExpression": false + }, + { + "value": "dataset", + "isExpression": false + }, + { + "value": "tier", + "isExpression": false + }, + { + "value": "client", + "isExpression": false + }, + { + "value": "source", + "isExpression": false + }, + { + "value": "provider", + "isExpression": false + }, + { + "value": "model", + "isExpression": false + }, + { + "value": "country", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "uniq_country_period", + "entityType": "indexes", + "table": "geo_stat" + }, + { + "columns": [ + { + "value": "grain", + "isExpression": false + }, + { + "value": "period_start", + "isExpression": false + }, + { + "value": "dataset", + "isExpression": false + }, + { + "value": "tier", + "isExpression": false + }, + { + "value": "total_tokens", + "isExpression": false + } + ], + "isUnique": false, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "idx_country_map_tokens", + "entityType": "indexes", + "table": "geo_stat" + }, + { + "columns": [ + { + "value": "grain", + "isExpression": false + }, + { + "value": "period_start", + "isExpression": false + }, + { + "value": "dataset", + "isExpression": false + }, + { + "value": "tier", + "isExpression": false + }, + { + "value": "rank_by_tokens", + "isExpression": false + } + ], + "isUnique": false, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "idx_country_rank", + "entityType": "indexes", + "table": "geo_stat" + }, + { + "columns": [ + { + "value": "country", + "isExpression": false + }, + { + "value": "grain", + "isExpression": false + }, + { + "value": "period_start", + "isExpression": false + } + ], + "isUnique": false, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "idx_country", + "entityType": "indexes", + "table": "geo_stat" + }, + { + "columns": [ + { + "value": "continent", + "isExpression": false + }, + { + "value": "grain", + "isExpression": false + }, + { + "value": "period_start", + "isExpression": false + } + ], + "isUnique": false, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "idx_continent", + "entityType": "indexes", + "table": "geo_stat" + }, + { + "columns": [ + { + "value": "model", + "isExpression": false + }, + { + "value": "country", + "isExpression": false + }, + { + "value": "grain", + "isExpression": false + }, + { + "value": "period_start", + "isExpression": false + } + ], + "isUnique": false, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "idx_country_model", + "entityType": "indexes", + "table": "geo_stat" + }, + { + "columns": [ + { + "value": "grain", + "isExpression": false + }, + { + "value": "period_key", + "isExpression": false + }, + { + "value": "dataset", + "isExpression": false + }, + { + "value": "tier", + "isExpression": false + }, + { + "value": "client", + "isExpression": false + }, + { + "value": "source", + "isExpression": false + }, + { + "value": "provider", + "isExpression": false + }, + { + "value": "model", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "uniq_model_period", + "entityType": "indexes", + "table": "model_stat" + }, + { + "columns": [ + { + "value": "grain", + "isExpression": false + }, + { + "value": "period_start", + "isExpression": false + }, + { + "value": "dataset", + "isExpression": false + }, + { + "value": "tier", + "isExpression": false + }, + { + "value": "total_tokens", + "isExpression": false + } + ], + "isUnique": false, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "idx_leaderboard_tokens", + "entityType": "indexes", + "table": "model_stat" + }, + { + "columns": [ + { + "value": "model", + "isExpression": false + }, + { + "value": "grain", + "isExpression": false + }, + { + "value": "period_start", + "isExpression": false + } + ], + "isUnique": false, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "idx_model", + "entityType": "indexes", + "table": "model_stat" + }, + { + "columns": [ + { + "value": "grain", + "isExpression": false + }, + { + "value": "period_key", + "isExpression": false + }, + { + "value": "dataset", + "isExpression": false + }, + { + "value": "tier", + "isExpression": false + }, + { + "value": "client", + "isExpression": false + }, + { + "value": "source", + "isExpression": false + }, + { + "value": "provider", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "uniq_provider_period", + "entityType": "indexes", + "table": "provider_stat" + }, + { + "columns": [ + { + "value": "grain", + "isExpression": false + }, + { + "value": "period_start", + "isExpression": false + }, + { + "value": "dataset", + "isExpression": false + }, + { + "value": "tier", + "isExpression": false + }, + { + "value": "total_tokens", + "isExpression": false + } + ], + "isUnique": false, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "idx_provider_leaderboard_tokens", + "entityType": "indexes", + "table": "provider_stat" + }, + { + "columns": [ + { + "value": "grain", + "isExpression": false + }, + { + "value": "period_start", + "isExpression": false + }, + { + "value": "dataset", + "isExpression": false + }, + { + "value": "tier", + "isExpression": false + }, + { + "value": "market_share_tokens", + "isExpression": false + } + ], + "isUnique": false, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "idx_provider_market_share", + "entityType": "indexes", + "table": "provider_stat" + }, + { + "columns": [ + { + "value": "grain", + "isExpression": false + }, + { + "value": "period_start", + "isExpression": false + }, + { + "value": "dataset", + "isExpression": false + }, + { + "value": "tier", + "isExpression": false + }, + { + "value": "rank_by_tokens", + "isExpression": false + } + ], + "isUnique": false, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "idx_provider_rank", + "entityType": "indexes", + "table": "provider_stat" + }, + { + "columns": [ + { + "value": "provider", + "isExpression": false + }, + { + "value": "grain", + "isExpression": false + }, + { + "value": "period_start", + "isExpression": false + } + ], + "isUnique": false, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "idx_provider", + "entityType": "indexes", + "table": "provider_stat" + } + ], + "renames": [] +} \ No newline at end of file diff --git a/packages/stats/core/migrations/20260528121120_easy_multiple_man/migration.sql b/packages/stats/core/migrations/20260528121120_easy_multiple_man/migration.sql new file mode 100644 index 0000000000..9eb6544869 --- /dev/null +++ b/packages/stats/core/migrations/20260528121120_easy_multiple_man/migration.sql @@ -0,0 +1,6 @@ +ALTER TABLE `geo_stat` DROP COLUMN `period_start`;--> statement-breakpoint +ALTER TABLE `geo_stat` DROP COLUMN `period_end`;--> statement-breakpoint +ALTER TABLE `model_stat` DROP COLUMN `period_start`;--> statement-breakpoint +ALTER TABLE `model_stat` DROP COLUMN `period_end`;--> statement-breakpoint +ALTER TABLE `provider_stat` DROP COLUMN `period_start`;--> statement-breakpoint +ALTER TABLE `provider_stat` DROP COLUMN `period_end`; \ No newline at end of file diff --git a/packages/stats/core/migrations/20260528121120_easy_multiple_man/snapshot.json b/packages/stats/core/migrations/20260528121120_easy_multiple_man/snapshot.json new file mode 100644 index 0000000000..d5a76a75ea --- /dev/null +++ b/packages/stats/core/migrations/20260528121120_easy_multiple_man/snapshot.json @@ -0,0 +1,2063 @@ +{ + "version": "6", + "dialect": "mysql", + "id": "9d4d1a06-7d28-4cb4-b0cd-3dfb7b481b8b", + "prevIds": [ + "cefe97ad-34ad-42f3-9343-47b1b7f478fa" + ], + "ddl": [ + { + "name": "geo_stat", + "entityType": "tables" + }, + { + "name": "model_stat", + "entityType": "tables" + }, + { + "name": "provider_stat", + "entityType": "tables" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": true, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "geo_stat" + }, + { + "type": "varchar(16)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "grain", + "entityType": "columns", + "table": "geo_stat" + }, + { + "type": "varchar(32)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "period_key", + "entityType": "columns", + "table": "geo_stat" + }, + { + "type": "varchar(64)", + "notNull": true, + "autoIncrement": false, + "default": "'all'", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "dataset", + "entityType": "columns", + "table": "geo_stat" + }, + { + "type": "varchar(64)", + "notNull": true, + "autoIncrement": false, + "default": "'all'", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "tier", + "entityType": "columns", + "table": "geo_stat" + }, + { + "type": "varchar(64)", + "notNull": true, + "autoIncrement": false, + "default": "'all'", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "client", + "entityType": "columns", + "table": "geo_stat" + }, + { + "type": "varchar(64)", + "notNull": true, + "autoIncrement": false, + "default": "'all'", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "source", + "entityType": "columns", + "table": "geo_stat" + }, + { + "type": "varchar(128)", + "notNull": true, + "autoIncrement": false, + "default": "'all'", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "provider", + "entityType": "columns", + "table": "geo_stat" + }, + { + "type": "varchar(256)", + "notNull": true, + "autoIncrement": false, + "default": "'all'", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "model", + "entityType": "columns", + "table": "geo_stat" + }, + { + "type": "char(2)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "country", + "entityType": "columns", + "table": "geo_stat" + }, + { + "type": "varchar(8)", + "notNull": true, + "autoIncrement": false, + "default": "''", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "continent", + "entityType": "columns", + "table": "geo_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "sessions", + "entityType": "columns", + "table": "geo_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "requests", + "entityType": "columns", + "table": "geo_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "input_tokens", + "entityType": "columns", + "table": "geo_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "output_tokens", + "entityType": "columns", + "table": "geo_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "reasoning_tokens", + "entityType": "columns", + "table": "geo_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "cache_read_tokens", + "entityType": "columns", + "table": "geo_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "total_tokens", + "entityType": "columns", + "table": "geo_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "input_cost_microcents", + "entityType": "columns", + "table": "geo_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "output_cost_microcents", + "entityType": "columns", + "table": "geo_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "total_cost_microcents", + "entityType": "columns", + "table": "geo_stat" + }, + { + "type": "decimal(12,2)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "avg_duration_ms", + "entityType": "columns", + "table": "geo_stat" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "p50_duration_ms", + "entityType": "columns", + "table": "geo_stat" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "p95_duration_ms", + "entityType": "columns", + "table": "geo_stat" + }, + { + "type": "decimal(12,2)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "avg_ttfb_ms", + "entityType": "columns", + "table": "geo_stat" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "p50_ttfb_ms", + "entityType": "columns", + "table": "geo_stat" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "p95_ttfb_ms", + "entityType": "columns", + "table": "geo_stat" + }, + { + "type": "decimal(12,4)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "avg_output_tps", + "entityType": "columns", + "table": "geo_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "success_count", + "entityType": "columns", + "table": "geo_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "error_count", + "entityType": "columns", + "table": "geo_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "sample_count", + "entityType": "columns", + "table": "geo_stat" + }, + { + "type": "decimal(10,6)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "market_share_tokens", + "entityType": "columns", + "table": "geo_stat" + }, + { + "type": "decimal(10,6)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "market_share_requests", + "entityType": "columns", + "table": "geo_stat" + }, + { + "type": "decimal(10,6)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "market_share_sessions", + "entityType": "columns", + "table": "geo_stat" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "rank_by_tokens", + "entityType": "columns", + "table": "geo_stat" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "rank_by_requests", + "entityType": "columns", + "table": "geo_stat" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "rank_by_sessions", + "entityType": "columns", + "table": "geo_stat" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "rank_by_cost", + "entityType": "columns", + "table": "geo_stat" + }, + { + "type": "datetime", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "created_at", + "entityType": "columns", + "table": "geo_stat" + }, + { + "type": "datetime", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": true, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "updated_at", + "entityType": "columns", + "table": "geo_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": true, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "model_stat" + }, + { + "type": "varchar(16)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "grain", + "entityType": "columns", + "table": "model_stat" + }, + { + "type": "varchar(32)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "period_key", + "entityType": "columns", + "table": "model_stat" + }, + { + "type": "varchar(64)", + "notNull": true, + "autoIncrement": false, + "default": "'all'", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "dataset", + "entityType": "columns", + "table": "model_stat" + }, + { + "type": "varchar(64)", + "notNull": true, + "autoIncrement": false, + "default": "'all'", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "tier", + "entityType": "columns", + "table": "model_stat" + }, + { + "type": "varchar(64)", + "notNull": true, + "autoIncrement": false, + "default": "'all'", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "client", + "entityType": "columns", + "table": "model_stat" + }, + { + "type": "varchar(64)", + "notNull": true, + "autoIncrement": false, + "default": "'all'", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "source", + "entityType": "columns", + "table": "model_stat" + }, + { + "type": "varchar(128)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "provider", + "entityType": "columns", + "table": "model_stat" + }, + { + "type": "varchar(256)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "model", + "entityType": "columns", + "table": "model_stat" + }, + { + "type": "varchar(256)", + "notNull": true, + "autoIncrement": false, + "default": "''", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "provider_model", + "entityType": "columns", + "table": "model_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "sessions", + "entityType": "columns", + "table": "model_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "requests", + "entityType": "columns", + "table": "model_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "input_tokens", + "entityType": "columns", + "table": "model_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "output_tokens", + "entityType": "columns", + "table": "model_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "reasoning_tokens", + "entityType": "columns", + "table": "model_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "cache_read_tokens", + "entityType": "columns", + "table": "model_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "total_tokens", + "entityType": "columns", + "table": "model_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "input_cost_microcents", + "entityType": "columns", + "table": "model_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "output_cost_microcents", + "entityType": "columns", + "table": "model_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "total_cost_microcents", + "entityType": "columns", + "table": "model_stat" + }, + { + "type": "decimal(12,2)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "avg_duration_ms", + "entityType": "columns", + "table": "model_stat" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "p50_duration_ms", + "entityType": "columns", + "table": "model_stat" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "p95_duration_ms", + "entityType": "columns", + "table": "model_stat" + }, + { + "type": "decimal(12,2)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "avg_ttfb_ms", + "entityType": "columns", + "table": "model_stat" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "p50_ttfb_ms", + "entityType": "columns", + "table": "model_stat" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "p95_ttfb_ms", + "entityType": "columns", + "table": "model_stat" + }, + { + "type": "decimal(12,4)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "avg_output_tps", + "entityType": "columns", + "table": "model_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "success_count", + "entityType": "columns", + "table": "model_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "error_count", + "entityType": "columns", + "table": "model_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "sample_count", + "entityType": "columns", + "table": "model_stat" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "rank_by_tokens", + "entityType": "columns", + "table": "model_stat" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "rank_by_requests", + "entityType": "columns", + "table": "model_stat" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "rank_by_cost", + "entityType": "columns", + "table": "model_stat" + }, + { + "type": "datetime", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "created_at", + "entityType": "columns", + "table": "model_stat" + }, + { + "type": "datetime", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": true, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "updated_at", + "entityType": "columns", + "table": "model_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": true, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "provider_stat" + }, + { + "type": "varchar(16)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "grain", + "entityType": "columns", + "table": "provider_stat" + }, + { + "type": "varchar(32)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "period_key", + "entityType": "columns", + "table": "provider_stat" + }, + { + "type": "varchar(64)", + "notNull": true, + "autoIncrement": false, + "default": "'all'", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "dataset", + "entityType": "columns", + "table": "provider_stat" + }, + { + "type": "varchar(64)", + "notNull": true, + "autoIncrement": false, + "default": "'all'", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "tier", + "entityType": "columns", + "table": "provider_stat" + }, + { + "type": "varchar(64)", + "notNull": true, + "autoIncrement": false, + "default": "'all'", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "client", + "entityType": "columns", + "table": "provider_stat" + }, + { + "type": "varchar(64)", + "notNull": true, + "autoIncrement": false, + "default": "'all'", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "source", + "entityType": "columns", + "table": "provider_stat" + }, + { + "type": "varchar(128)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "provider", + "entityType": "columns", + "table": "provider_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "sessions", + "entityType": "columns", + "table": "provider_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "requests", + "entityType": "columns", + "table": "provider_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "input_tokens", + "entityType": "columns", + "table": "provider_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "output_tokens", + "entityType": "columns", + "table": "provider_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "reasoning_tokens", + "entityType": "columns", + "table": "provider_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "cache_read_tokens", + "entityType": "columns", + "table": "provider_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "total_tokens", + "entityType": "columns", + "table": "provider_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "input_cost_microcents", + "entityType": "columns", + "table": "provider_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "output_cost_microcents", + "entityType": "columns", + "table": "provider_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "total_cost_microcents", + "entityType": "columns", + "table": "provider_stat" + }, + { + "type": "decimal(12,2)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "avg_duration_ms", + "entityType": "columns", + "table": "provider_stat" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "p50_duration_ms", + "entityType": "columns", + "table": "provider_stat" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "p95_duration_ms", + "entityType": "columns", + "table": "provider_stat" + }, + { + "type": "decimal(12,2)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "avg_ttfb_ms", + "entityType": "columns", + "table": "provider_stat" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "p50_ttfb_ms", + "entityType": "columns", + "table": "provider_stat" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "p95_ttfb_ms", + "entityType": "columns", + "table": "provider_stat" + }, + { + "type": "decimal(12,4)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "avg_output_tps", + "entityType": "columns", + "table": "provider_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "success_count", + "entityType": "columns", + "table": "provider_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "error_count", + "entityType": "columns", + "table": "provider_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "sample_count", + "entityType": "columns", + "table": "provider_stat" + }, + { + "type": "decimal(10,6)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "market_share_tokens", + "entityType": "columns", + "table": "provider_stat" + }, + { + "type": "decimal(10,6)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "market_share_requests", + "entityType": "columns", + "table": "provider_stat" + }, + { + "type": "decimal(10,6)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "market_share_sessions", + "entityType": "columns", + "table": "provider_stat" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "rank_by_tokens", + "entityType": "columns", + "table": "provider_stat" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "rank_by_requests", + "entityType": "columns", + "table": "provider_stat" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "rank_by_sessions", + "entityType": "columns", + "table": "provider_stat" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "rank_by_cost", + "entityType": "columns", + "table": "provider_stat" + }, + { + "type": "datetime", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "created_at", + "entityType": "columns", + "table": "provider_stat" + }, + { + "type": "datetime", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": true, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "updated_at", + "entityType": "columns", + "table": "provider_stat" + }, + { + "columns": [ + "id" + ], + "name": "PRIMARY", + "table": "geo_stat", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "name": "PRIMARY", + "table": "model_stat", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "name": "PRIMARY", + "table": "provider_stat", + "entityType": "pks" + }, + { + "columns": [ + { + "value": "grain", + "isExpression": false + }, + { + "value": "period_key", + "isExpression": false + }, + { + "value": "dataset", + "isExpression": false + }, + { + "value": "tier", + "isExpression": false + }, + { + "value": "client", + "isExpression": false + }, + { + "value": "source", + "isExpression": false + }, + { + "value": "provider", + "isExpression": false + }, + { + "value": "model", + "isExpression": false + }, + { + "value": "country", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "uniq_country_period", + "entityType": "indexes", + "table": "geo_stat" + }, + { + "columns": [ + { + "value": "grain", + "isExpression": false + }, + { + "value": "period_key", + "isExpression": false + }, + { + "value": "dataset", + "isExpression": false + }, + { + "value": "tier", + "isExpression": false + }, + { + "value": "total_tokens", + "isExpression": false + } + ], + "isUnique": false, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "idx_country_map_tokens", + "entityType": "indexes", + "table": "geo_stat" + }, + { + "columns": [ + { + "value": "grain", + "isExpression": false + }, + { + "value": "period_key", + "isExpression": false + }, + { + "value": "dataset", + "isExpression": false + }, + { + "value": "tier", + "isExpression": false + }, + { + "value": "rank_by_tokens", + "isExpression": false + } + ], + "isUnique": false, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "idx_country_rank", + "entityType": "indexes", + "table": "geo_stat" + }, + { + "columns": [ + { + "value": "country", + "isExpression": false + }, + { + "value": "grain", + "isExpression": false + }, + { + "value": "period_key", + "isExpression": false + } + ], + "isUnique": false, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "idx_country", + "entityType": "indexes", + "table": "geo_stat" + }, + { + "columns": [ + { + "value": "continent", + "isExpression": false + }, + { + "value": "grain", + "isExpression": false + }, + { + "value": "period_key", + "isExpression": false + } + ], + "isUnique": false, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "idx_continent", + "entityType": "indexes", + "table": "geo_stat" + }, + { + "columns": [ + { + "value": "model", + "isExpression": false + }, + { + "value": "country", + "isExpression": false + }, + { + "value": "grain", + "isExpression": false + }, + { + "value": "period_key", + "isExpression": false + } + ], + "isUnique": false, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "idx_country_model", + "entityType": "indexes", + "table": "geo_stat" + }, + { + "columns": [ + { + "value": "grain", + "isExpression": false + }, + { + "value": "period_key", + "isExpression": false + }, + { + "value": "dataset", + "isExpression": false + }, + { + "value": "tier", + "isExpression": false + }, + { + "value": "client", + "isExpression": false + }, + { + "value": "source", + "isExpression": false + }, + { + "value": "provider", + "isExpression": false + }, + { + "value": "model", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "uniq_model_period", + "entityType": "indexes", + "table": "model_stat" + }, + { + "columns": [ + { + "value": "grain", + "isExpression": false + }, + { + "value": "period_key", + "isExpression": false + }, + { + "value": "dataset", + "isExpression": false + }, + { + "value": "tier", + "isExpression": false + }, + { + "value": "total_tokens", + "isExpression": false + } + ], + "isUnique": false, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "idx_leaderboard_tokens", + "entityType": "indexes", + "table": "model_stat" + }, + { + "columns": [ + { + "value": "model", + "isExpression": false + }, + { + "value": "grain", + "isExpression": false + }, + { + "value": "period_key", + "isExpression": false + } + ], + "isUnique": false, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "idx_model", + "entityType": "indexes", + "table": "model_stat" + }, + { + "columns": [ + { + "value": "grain", + "isExpression": false + }, + { + "value": "period_key", + "isExpression": false + }, + { + "value": "dataset", + "isExpression": false + }, + { + "value": "tier", + "isExpression": false + }, + { + "value": "client", + "isExpression": false + }, + { + "value": "source", + "isExpression": false + }, + { + "value": "provider", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "uniq_provider_period", + "entityType": "indexes", + "table": "provider_stat" + }, + { + "columns": [ + { + "value": "grain", + "isExpression": false + }, + { + "value": "period_key", + "isExpression": false + }, + { + "value": "dataset", + "isExpression": false + }, + { + "value": "tier", + "isExpression": false + }, + { + "value": "total_tokens", + "isExpression": false + } + ], + "isUnique": false, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "idx_provider_leaderboard_tokens", + "entityType": "indexes", + "table": "provider_stat" + }, + { + "columns": [ + { + "value": "grain", + "isExpression": false + }, + { + "value": "period_key", + "isExpression": false + }, + { + "value": "dataset", + "isExpression": false + }, + { + "value": "tier", + "isExpression": false + }, + { + "value": "market_share_tokens", + "isExpression": false + } + ], + "isUnique": false, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "idx_provider_market_share", + "entityType": "indexes", + "table": "provider_stat" + }, + { + "columns": [ + { + "value": "grain", + "isExpression": false + }, + { + "value": "period_key", + "isExpression": false + }, + { + "value": "dataset", + "isExpression": false + }, + { + "value": "tier", + "isExpression": false + }, + { + "value": "rank_by_tokens", + "isExpression": false + } + ], + "isUnique": false, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "idx_provider_rank", + "entityType": "indexes", + "table": "provider_stat" + }, + { + "columns": [ + { + "value": "provider", + "isExpression": false + }, + { + "value": "grain", + "isExpression": false + }, + { + "value": "period_key", + "isExpression": false + } + ], + "isUnique": false, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "idx_provider", + "entityType": "indexes", + "table": "provider_stat" + } + ], + "renames": [] +} \ No newline at end of file diff --git a/packages/stats/core/src/database/schema.ts b/packages/stats/core/src/database/schema.ts index 7fbfb632d7..a658a4d511 100644 --- a/packages/stats/core/src/database/schema.ts +++ b/packages/stats/core/src/database/schema.ts @@ -16,7 +16,7 @@ export const modelStat = mysqlTable( (table) => [ uniqueIndex("uniq_model_period").on( table.grain, - table.period_start, + table.period_key, table.dataset, table.tier, table.client, @@ -24,8 +24,8 @@ export const modelStat = mysqlTable( table.provider, table.model, ), - index("idx_leaderboard_tokens").on(table.grain, table.period_start, table.dataset, table.tier, table.total_tokens), - index("idx_model").on(table.model, table.grain, table.period_start), + index("idx_leaderboard_tokens").on(table.grain, table.period_key, table.dataset, table.tier, table.total_tokens), + index("idx_model").on(table.model, table.grain, table.period_key), ], ) @@ -45,7 +45,7 @@ export const providerStat = mysqlTable( (table) => [ uniqueIndex("uniq_provider_period").on( table.grain, - table.period_start, + table.period_key, table.dataset, table.tier, table.client, @@ -54,20 +54,20 @@ export const providerStat = mysqlTable( ), index("idx_provider_leaderboard_tokens").on( table.grain, - table.period_start, + table.period_key, table.dataset, table.tier, table.total_tokens, ), index("idx_provider_market_share").on( table.grain, - table.period_start, + table.period_key, table.dataset, table.tier, table.market_share_tokens, ), - index("idx_provider_rank").on(table.grain, table.period_start, table.dataset, table.tier, table.rank_by_tokens), - index("idx_provider").on(table.provider, table.grain, table.period_start), + index("idx_provider_rank").on(table.grain, table.period_key, table.dataset, table.tier, table.rank_by_tokens), + index("idx_provider").on(table.provider, table.grain, table.period_key), ], ) @@ -75,6 +75,8 @@ export const geoStat = mysqlTable( "geo_stat", { ...periodColumns(), + provider: varchar({ length: 128 }).notNull().default("all"), + model: varchar({ length: 256 }).notNull().default("all"), country: char({ length: 2 }).notNull(), continent: varchar({ length: 8 }).notNull().default(""), ...metricColumns(), @@ -88,17 +90,20 @@ export const geoStat = mysqlTable( (table) => [ uniqueIndex("uniq_country_period").on( table.grain, - table.period_start, + table.period_key, table.dataset, table.tier, table.client, table.source, + table.provider, + table.model, table.country, ), - index("idx_country_map_tokens").on(table.grain, table.period_start, table.dataset, table.tier, table.total_tokens), - index("idx_country_rank").on(table.grain, table.period_start, table.dataset, table.tier, table.rank_by_tokens), - index("idx_country").on(table.country, table.grain, table.period_start), - index("idx_continent").on(table.continent, table.grain, table.period_start), + index("idx_country_map_tokens").on(table.grain, table.period_key, table.dataset, table.tier, table.total_tokens), + index("idx_country_rank").on(table.grain, table.period_key, table.dataset, table.tier, table.rank_by_tokens), + index("idx_country").on(table.country, table.grain, table.period_key), + index("idx_continent").on(table.continent, table.grain, table.period_key), + index("idx_country_model").on(table.model, table.country, table.grain, table.period_key), ], ) @@ -106,8 +111,7 @@ function periodColumns() { return { id: bigint({ mode: "number" }).autoincrement().primaryKey(), grain: varchar({ length: 16 }).notNull(), - period_start: datetime({ mode: "date" }).notNull(), - period_end: datetime({ mode: "date" }).notNull(), + period_key: varchar({ length: 32 }).notNull(), dataset: varchar({ length: 64 }).notNull().default("all"), tier: varchar({ length: 64 }).notNull().default("all"), client: varchar({ length: 64 }).notNull().default("all"), diff --git a/packages/stats/core/src/domain/geo.ts b/packages/stats/core/src/domain/geo.ts index 624f9d7c5a..fb3b83ec5b 100644 --- a/packages/stats/core/src/domain/geo.ts +++ b/packages/stats/core/src/domain/geo.ts @@ -8,6 +8,7 @@ import { collapseRows, inserted, rankRowsWithMarketShare, + statPeriodKey, synthesizeAllTierRows, toStatBaseRow, UPSERT_CHUNK_SIZE, @@ -15,11 +16,13 @@ import { } from "./stat" export type GeoStatRow = typeof geoStat.$inferInsert -export type GeoStatAggregate = StatBaseAggregate & { country: string; continent: string } +export type GeoStatAggregate = StatBaseAggregate & { provider: string; model: string; country: string; continent: string } export type GeoStatMetric = { - periodStart: Date - periodEnd: Date + periodKey: string + updatedAt: Date tier: string + provider: string + model: string country: string continent: string totalTokens: number @@ -30,11 +33,13 @@ export declare namespace GeoStatRepo { readonly listDaily: () => Effect.Effect readonly listByPeriod: (opts: { readonly grain: string - readonly periodStart: Date + readonly periodKey: string readonly dataset?: string readonly tier?: string readonly client?: string readonly source?: string + readonly provider?: string + readonly model?: string }) => Effect.Effect readonly upsert: (rows: GeoStatRow[]) => Effect.Effect } @@ -51,27 +56,39 @@ export class GeoStatRepo extends Context.Service db .select({ - periodStart: geoStat.period_start, - periodEnd: geoStat.period_end, + periodKey: geoStat.period_key, + updatedAt: geoStat.updated_at, tier: geoStat.tier, + provider: geoStat.provider, + model: geoStat.model, country: geoStat.country, continent: geoStat.continent, totalTokens: geoStat.total_tokens, }) .from(geoStat) - .where(and(eq(geoStat.grain, "day"), eq(geoStat.client, "all"), eq(geoStat.source, "all"))) - .orderBy(asc(geoStat.period_start)), + .where( + and( + eq(geoStat.grain, "day"), + eq(geoStat.client, "all"), + eq(geoStat.source, "all"), + eq(geoStat.provider, "all"), + eq(geoStat.model, "all"), + ), + ) + .orderBy(asc(geoStat.period_key)), catch: (cause) => DatabaseError.make({ cause }), }) }) const listByPeriod = Effect.fn("GeoStatRepo.listByPeriod")(function* (opts: { readonly grain: string - readonly periodStart: Date + readonly periodKey: string readonly dataset?: string readonly tier?: string readonly client?: string readonly source?: string + readonly provider?: string + readonly model?: string }) { return yield* Effect.tryPromise({ try: () => @@ -81,11 +98,13 @@ export class GeoStatRepo extends Context.Service DatabaseError.make({ cause }), @@ -103,7 +122,6 @@ export class GeoStatRepo extends Context.Service item.grain === "week").map(toRow), dimensionKey), - dimensionKey, - ), - ...synthesizeAllTierRows( - collapseRows(aggregates.filter((item) => item.grain === "day").map(toRow), dimensionKey), - dimensionKey, - ), - ]) + return rankRowsWithMarketShare( + [ + ...synthesizeAllTierRows( + collapseRows(aggregates.filter((item) => item.grain === "week").map(toRow), dimensionKey), + dimensionKey, + ), + ...synthesizeAllTierRows( + collapseRows(aggregates.filter((item) => item.grain === "day").map(toRow), dimensionKey), + dimensionKey, + ), + ], + marketShareKey, + ) } function toRow(data: GeoStatAggregate): GeoStatRow { return { ...toStatBaseRow(data), + provider: data.provider, + model: data.model, country: data.country, continent: data.continent, } } function dimensionKey(row: GeoStatRow) { - return row.country + return [row.provider, row.model, row.country].join("\u0000") +} + +function marketShareKey(row: GeoStatRow) { + return [statPeriodKey(row), row.provider, row.model].join("\u0000") } diff --git a/packages/stats/core/src/domain/home.ts b/packages/stats/core/src/domain/home.ts index 3403a86a2f..20d9550abd 100644 --- a/packages/stats/core/src/domain/home.ts +++ b/packages/stats/core/src/domain/home.ts @@ -28,17 +28,17 @@ const TOKEN_SCALE = 1_000_000 const DOLLARS_PER_MICROCENT = 1 / 100_000_000 const months = ["JAN", "FEB", "MAR", "APR", "MAY", "JUN", "JUL", "AUG", "SEP", "OCT", "NOV", "DEC"] as const -type StatMetricRow = Omit & { +type StatMetricRow = Omit & { periodStart: number - periodEnd: number + updatedAt: number } -type ProviderMetricRow = Omit & { +type ProviderMetricRow = Omit & { periodStart: number - periodEnd: number + updatedAt: number } -type GeoMetricRow = Omit & { +type GeoMetricRow = Omit & { periodStart: number - periodEnd: number + updatedAt: number } type DateWindow = { start: number; end: number; previousStart: number; previousEnd: number } @@ -85,10 +85,10 @@ function buildStatsHomeData( const earliest = Math.min(...periods.map((row) => row.periodStart)) const latest = Math.max(...periods.map((row) => row.periodStart)) - const latestEnd = Math.max(...periods.map((row) => row.periodEnd)) + const latestUpdate = Math.max(...periods.map((row) => row.updatedAt)) return { - updatedAt: new Date(latestEnd).toISOString(), + updatedAt: new Date(latestUpdate).toISOString(), usage: createUsageProductRecord((product) => createRangeRecord((range) => buildUsagePoints(normalized, product, range, getWindow(range, earliest, latest))), ), @@ -361,14 +361,14 @@ function createRangeRecord(value: (range: UsageRange) => T): Record = { anthropic: "Anthropic", + deepseek: "DeepSeek", google: "Google", minimax: "MiniMax", + moonshot: "Moonshot", moonshotai: "Moonshot", - nvidia: "Nvidia", + nvidia: "NVIDIA", + opencode: "opencode", openai: "OpenAI", + qwen: "Qwen", + tencent: "Tencent", + xai: "xAI", + xiaomi: "Xiaomi", + zhipu: "Zhipu", zhipuai: "Zhipu", } const normalized = provider.toLowerCase().replace(/[^a-z0-9]/g, "") diff --git a/packages/stats/core/src/domain/inference.test.ts b/packages/stats/core/src/domain/inference.test.ts new file mode 100644 index 0000000000..081613bc08 --- /dev/null +++ b/packages/stats/core/src/domain/inference.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, test } from "bun:test" +import { toModelAggregate } from "./inference" +import { modelAuthor, normalizeInferenceModel } from "./model-normalization" + +describe("inference stat normalization", () => { + test("normalizes model suffixes used by router/provider variants", () => { + expect(normalizeInferenceModel("deepseek-v4-flash-free")).toBe("deepseek-v4-flash") + expect(normalizeInferenceModel("deepseek-v4-flash:global")).toBe("deepseek-v4-flash") + expect(normalizeInferenceModel("mimo-v2.5-free")).toBe("mimo-v2.5") + expect(normalizeInferenceModel("nemotron-3-super-free")).toBe("nemotron-3-super") + expect(normalizeInferenceModel("mimo-v2.5-free:global")).toBe("mimo-v2.5") + }) + + test("maps normalized model ids to public authors", () => { + expect(modelAuthor("big-pickle")).toBe("opencode") + expect(modelAuthor("claude-sonnet-4-5")).toBe("anthropic") + expect(modelAuthor("deepseek-v4-pro")).toBe("deepseek") + expect(modelAuthor("gemini-3.5-flash")).toBe("google") + expect(modelAuthor("glm-5.1")).toBe("zhipu") + expect(modelAuthor("gpt-5.5-pro")).toBe("openai") + expect(modelAuthor("grok-build-0.1")).toBe("xai") + expect(modelAuthor("hy3-preview")).toBe("tencent") + expect(modelAuthor("kimi-k2.6")).toBe("moonshot") + expect(modelAuthor("mimo-v2-omni")).toBe("xiaomi") + expect(modelAuthor("minimax-m2.7")).toBe("minimax") + expect(modelAuthor("nemotron-3-super-free")).toBe("nvidia") + expect(modelAuthor("qwen3.7-max")).toBe("qwen") + expect(modelAuthor("alpha-gpt-next")).toBeUndefined() + }) + + test("model aggregates ignore datalake provider and use normalized author/model", () => { + expect(toModelAggregate(aggregate("alpha-gpt-next", "openai"))).toEqual([]) + + expect(toModelAggregate(aggregate("deepseek-v4-flash-free", "not-public-provider"))).toMatchObject([ + { + period_key: "2026-05-20", + provider: "deepseek", + model: "deepseek-v4-flash", + }, + ]) + }) + + test("model aggregates use ISO week period keys", () => { + expect( + toModelAggregate({ + ...aggregate("gpt-5.5-pro", "openai"), + grain: "week", + period_key: "2026-W20", + }), + ).toMatchObject([{ period_key: "2026-W20" }]) + }) +}) + +function aggregate(model: string, provider: string) { + return { + grain: "day", + period_key: "2026-05-20", + dataset: "zen", + tier: "Paid", + provider, + model, + sessions: "1", + requests: "1", + sample_count: "1", + } +} diff --git a/packages/stats/core/src/domain/inference.ts b/packages/stats/core/src/domain/inference.ts index 1a89d00336..83a614f351 100644 --- a/packages/stats/core/src/domain/inference.ts +++ b/packages/stats/core/src/domain/inference.ts @@ -2,10 +2,17 @@ import { Resource } from "sst/resource" import type { AthenaData } from "../athena" import type { GeoStatAggregate } from "./geo" import type { ModelStatAggregate } from "./model" +import { + EXCLUDED_MODELS, + MODEL_AUTHOR_OVERRIDES, + MODEL_AUTHOR_RULES, + modelAuthor, + normalizeInferenceModel, +} from "./model-normalization" import type { ProviderStatAggregate } from "./provider" import { normalizeCountry, normalizeTier, type StatBaseAggregate } from "./stat" -export type StatDimension = "model" | "provider" | "geo" +export type StatDimension = "model" | "provider" | "geo" | "geo_model" export function buildStatsQuery(periodStart: Date, periodEnd: Date, dimension: StatDimension) { const periodStartValue = sqlString(periodStart.toISOString()) @@ -20,8 +27,13 @@ export function buildStatsQuery(periodStart: Date, periodEnd: Date, dimension: S 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: "country, COALESCE(MAX(NULLIF(continent, '')), '') AS continent", + select: "'all' AS provider, 'all' AS model, country, COALESCE(MAX(NULLIF(continent, '')), '') AS continent", groupBy: "country", } })() @@ -48,32 +60,56 @@ export function buildStatsQuery(periodStart: Date, periodEnd: Date, dimension: S COUNT(*) AS sample_count` return ` -WITH filtered AS ( +WITH normalized AS ( SELECT from_iso8601_timestamp(event_timestamp) AS event_time, - CASE - WHEN source = 'lite' THEN 'Go' - WHEN model IN ('gpt-5-nano', 'grok-code', 'big-pickle') OR model LIKE '%-free' THEN 'Free' - ELSE 'Paid' - END AS tier, - COALESCE(NULLIF( - CASE - WHEN starts_with(provider, 'minimax-plan') THEN 'minimax-plan' - WHEN starts_with(provider, 'zai-plan') THEN 'zai-plan' - WHEN starts_with(provider, 'azure-databricks') THEN 'azure-databricks' - WHEN regexp_like(provider, '^azure[0-9]+') THEN 'azure-openai' - ELSE provider - END, - '' - ), 'unknown') AS provider, + model AS raw_model, + COALESCE(NULLIF(regexp_replace(model, '(-free|:global)+$', ''), ''), 'unknown') AS model, COALESCE(NULLIF(provider_model, ''), '') AS provider_model, - COALESCE(NULLIF(model, ''), 'unknown') AS model, UPPER(COALESCE(NULLIF(cf_country, ''), 'ZZ')) AS country, COALESCE(NULLIF(cf_continent, ''), '') AS continent, session, status, duration AS duration_ms, time_to_first_byte AS ttfb_ms, + timestamp_first_byte, + timestamp_last_byte, + tokens_input, + tokens_output, + tokens_reasoning, + tokens_cache_read, + tokens_cache_write_5m, + cost_input_microcents, + cost_output_microcents, + cost_total_microcents, + cost_input, + cost_output, + cost_total, + source + FROM ${sourceTable} + WHERE event_type = 'completions' + AND model IS NOT NULL + AND model <> '' + AND (strpos(COALESCE(user_agent, ''), 'ai-sdk') > 0 OR strpos(COALESCE(user_agent, ''), 'opencode') > 0) + AND event_timestamp >= ${periodStartValue} + AND event_timestamp < ${periodEndValue} +), filtered AS ( + SELECT + event_time, + CASE + WHEN source = 'lite' THEN 'Go' + WHEN model IN ('gpt-5-nano', 'grok-code', 'big-pickle') OR regexp_like(raw_model, '-free(:global)?$') THEN 'Free' + ELSE 'Paid' + END AS tier, + ${modelAuthorSql("model")} AS provider, + provider_model, + model, + country, + continent, + session, + status, + duration_ms, + ttfb_ms, CASE WHEN timestamp_last_byte - timestamp_first_byte < 100 THEN null ELSE CAST(tokens_output AS double) / (timestamp_last_byte - timestamp_first_byte) * 1000 @@ -86,50 +122,47 @@ WITH filtered AS ( COALESCE(cost_input_microcents, cost_input * 1000000) AS cost_input_microcents, COALESCE(cost_output_microcents, cost_output * 1000000) AS cost_output_microcents, COALESCE(cost_total_microcents, cost_total * 1000000) AS cost_total_microcents - FROM ${sourceTable} - WHERE event_type = 'completions' - AND model IS NOT NULL - AND model <> '' - AND (strpos(COALESCE(user_agent, ''), 'ai-sdk') > 0 OR strpos(COALESCE(user_agent, ''), 'opencode') > 0) - AND event_timestamp >= ${periodStartValue} - AND event_timestamp < ${periodEndValue} + FROM normalized + WHERE lower(model) NOT IN (${[...EXCLUDED_MODELS].map(sqlString).join(", ")}) +), weekly AS ( + SELECT + concat(CAST(year_of_week(event_time) AS varchar), '-W', lpad(CAST(week(event_time) AS varchar), 2, '0')) AS week_key, + * + FROM filtered ), daily AS ( - SELECT date_trunc('day', event_time) AS day, * + SELECT substr(to_iso8601(date_trunc('day', event_time)), 1, 10) AS day_key, * FROM filtered ) SELECT 'week' AS grain, - ${periodStartValue} AS period_start, - ${periodEndValue} AS period_end, + week_key AS period_key, ${sqlString(Resource.StatsSyncConfig.dataset)} AS dataset, tier, ${dimensionSql.select}, ${aggregateColumns} -FROM filtered -GROUP BY tier, ${dimensionSql.groupBy} +FROM weekly +GROUP BY week_key, tier, ${dimensionSql.groupBy} UNION ALL SELECT 'day' AS grain, - to_iso8601(day) AS period_start, - to_iso8601(least(day + INTERVAL '1' DAY, from_iso8601_timestamp(${periodEndValue}))) AS period_end, + day_key AS period_key, ${sqlString(Resource.StatsSyncConfig.dataset)} AS dataset, tier, ${dimensionSql.select}, ${aggregateColumns} FROM daily -GROUP BY day, tier, ${dimensionSql.groupBy} -ORDER BY grain, period_start, total_tokens DESC +GROUP BY day_key, tier, ${dimensionSql.groupBy} +ORDER BY grain, period_key, total_tokens DESC ` } export function toModelAggregate(data: AthenaData): ModelStatAggregate[] { + const model = normalizeInferenceModel(data.model) + const author = modelAuthor(model) + if (!author) return [] + return toStatBaseAggregate(data).flatMap((base) => [ - { - ...base, - provider: data.provider || "unknown", - model: data.model || "unknown", - provider_model: data.provider_model || "", - }, + { ...base, provider: author, model, provider_model: data.provider_model || "" }, ]) } @@ -141,6 +174,8 @@ export function toGeoAggregate(data: AthenaData): GeoStatAggregate[] { return toStatBaseAggregate(data).flatMap((base) => [ { ...base, + provider: data.provider || "all", + model: normalizeInferenceModel(data.model || "all"), country: normalizeCountry(data.country), continent: data.continent || "", }, @@ -149,15 +184,12 @@ export function toGeoAggregate(data: AthenaData): GeoStatAggregate[] { function toStatBaseAggregate(data: AthenaData): StatBaseAggregate[] { const grain = data.grain === "day" || data.grain === "week" ? data.grain : undefined - const periodStart = new Date(data.period_start ?? "") - const periodEnd = new Date(data.period_end ?? "") - if (!grain || Number.isNaN(periodStart.getTime()) || Number.isNaN(periodEnd.getTime())) return [] + if (!grain || !data.period_key) return [] return [ { grain, - period_start: periodStart, - period_end: periodEnd, + period_key: data.period_key, dataset: data.dataset || Resource.StatsSyncConfig.dataset, tier: normalizeTier(data.tier || "unknown"), sessions: integer(data, "sessions"), @@ -210,3 +242,11 @@ function sqlIdentifier(value: string) { function sqlString(value: string) { return `'${value.replace(/'/g, "''")}'` } + +function modelAuthorSql(model: string) { + return `CASE +${MODEL_AUTHOR_OVERRIDES.map((item) => ` WHEN lower(${model}) = ${sqlString(item.model)} THEN ${sqlString(item.author)}`).join("\n")} +${MODEL_AUTHOR_RULES.map((item) => ` WHEN strpos(lower(${model}), ${sqlString(item.match)}) > 0 THEN ${sqlString(item.author)}`).join("\n")} + ELSE 'unknown' + END` +} diff --git a/packages/stats/core/src/domain/model-normalization.ts b/packages/stats/core/src/domain/model-normalization.ts new file mode 100644 index 0000000000..29cb585e33 --- /dev/null +++ b/packages/stats/core/src/domain/model-normalization.ts @@ -0,0 +1,30 @@ +export const MODEL_AUTHOR_OVERRIDES = [{ model: "big-pickle", author: "opencode" }] as const +export const MODEL_AUTHOR_RULES = [ + { match: "claude", author: "anthropic" }, + { match: "gemini", author: "google" }, + { match: "deepseek", author: "deepseek" }, + { match: "glm", author: "zhipu" }, + { match: "gpt", author: "openai" }, + { match: "grok", author: "xai" }, + { match: "hy3", author: "tencent" }, + { match: "kimi", author: "moonshot" }, + { match: "mimo", author: "xiaomi" }, + { match: "minimax", author: "minimax" }, + { match: "nemotron", author: "nvidia" }, + { match: "qwen", author: "qwen" }, +] as const +export const EXCLUDED_MODELS = new Set(["alpha-gpt-next"]) + +export function normalizeInferenceModel(value: string | undefined) { + return (value || "unknown").replace(/(-free|:global)+$/, "") || "unknown" +} + +export function modelAuthor(value: string | undefined) { + const model = normalizeInferenceModel(value).toLowerCase() + if (EXCLUDED_MODELS.has(model)) return undefined + + const override = MODEL_AUTHOR_OVERRIDES.find((item) => item.model === model) + if (override) return override.author + + return MODEL_AUTHOR_RULES.find((item) => model.includes(item.match))?.author ?? "unknown" +} diff --git a/packages/stats/core/src/domain/model.ts b/packages/stats/core/src/domain/model.ts index 45cd2472ca..92aeb6eb28 100644 --- a/packages/stats/core/src/domain/model.ts +++ b/packages/stats/core/src/domain/model.ts @@ -19,8 +19,8 @@ export type ModelStatRow = typeof modelStat.$inferInsert export type ModelStatAggregate = StatBaseAggregate & { provider: string; model: string; provider_model: string } export type ModelStatMetric = { - periodStart: Date - periodEnd: Date + periodKey: string + updatedAt: Date tier: string provider: string model: string @@ -55,8 +55,8 @@ export class ModelStatRepo extends Context.Service db .select({ - periodStart: modelStat.period_start, - periodEnd: modelStat.period_end, + periodKey: modelStat.period_key, + updatedAt: modelStat.updated_at, tier: modelStat.tier, provider: modelStat.provider, model: modelStat.model, @@ -72,7 +72,7 @@ export class ModelStatRepo extends Context.Service DatabaseError.make({ cause }), }) }) @@ -88,7 +88,6 @@ export class ModelStatRepo extends Context.Service Effect.Effect readonly listByPeriod: (opts: { readonly grain: string - readonly periodStart: Date + readonly periodKey: string readonly dataset?: string readonly tier?: string readonly client?: string @@ -52,22 +52,22 @@ export class ProviderStatRepo extends Context.Service db .select({ - periodStart: providerStat.period_start, - periodEnd: providerStat.period_end, + periodKey: providerStat.period_key, + updatedAt: providerStat.updated_at, tier: providerStat.tier, provider: providerStat.provider, totalTokens: providerStat.total_tokens, }) .from(providerStat) .where(and(eq(providerStat.grain, "day"), eq(providerStat.client, "all"), eq(providerStat.source, "all"))) - .orderBy(asc(providerStat.period_start)), + .orderBy(asc(providerStat.period_key)), catch: (cause) => DatabaseError.make({ cause }), }) }) const listByPeriod = Effect.fn("ProviderStatRepo.listByPeriod")(function* (opts: { readonly grain: string - readonly periodStart: Date + readonly periodKey: string readonly dataset?: string readonly tier?: string readonly client?: string @@ -81,7 +81,7 @@ export class ProviderStatRepo extends Context.Service(rows: T[], dimensio rows.reduce>((result, row) => { const key = [ row.grain, - row.period_start.toISOString(), + row.period_key, row.dataset, row.client, row.source, @@ -119,7 +117,7 @@ export function collapseRows(rows: T[], dimensionKey: (ro rows.reduce>((result, row) => { const key = [ row.grain, - row.period_start.toISOString(), + row.period_key, row.dataset, row.tier, row.client, @@ -135,7 +133,6 @@ export function collapseRows(rows: T[], dimensionKey: (ro export function combineRows(left: T, right: T): T { return { ...left, - period_end: right.period_end > left.period_end ? right.period_end : left.period_end, sessions: (left.sessions ?? 0) + (right.sessions ?? 0), requests: (left.requests ?? 0) + (right.requests ?? 0), input_tokens: (left.input_tokens ?? 0) + (right.input_tokens ?? 0), @@ -160,17 +157,41 @@ export function combineRows(left: T, right: T): T { } export function statPeriodKey(row: StatBaseRow) { - return [row.grain, row.period_start.toISOString(), row.dataset, row.tier, row.client, row.source].join("\u0000") + return [row.grain, row.period_key, row.dataset, row.tier, row.client, row.source].join("\u0000") +} + +export function periodKeyFor(grain: StatGrain, periodStart: Date) { + if (grain === "week") return isoWeekId(periodStart) + return utcDateId(periodStart) +} + +export function startOfUtcDay(value: Date) { + return new Date(Date.UTC(value.getUTCFullYear(), value.getUTCMonth(), value.getUTCDate())) +} + +export function startOfIsoWeek(value: Date) { + return new Date(Date.UTC(value.getUTCFullYear(), value.getUTCMonth(), value.getUTCDate() - (value.getUTCDay() || 7) + 1)) +} + +export function isoWeekId(value: Date) { + const thursday = new Date( + Date.UTC(value.getUTCFullYear(), value.getUTCMonth(), value.getUTCDate() + 4 - (value.getUTCDay() || 7)), + ) + return `${thursday.getUTCFullYear()}-W${String(Math.ceil(((thursday.getTime() - Date.UTC(thursday.getUTCFullYear(), 0, 1)) / DAY_MS + 1) / 7)).padStart(2, "0")}` +} + +function utcDateId(value: Date) { + return `${value.getUTCFullYear()}-${String(value.getUTCMonth() + 1).padStart(2, "0")}-${String(value.getUTCDate()).padStart(2, "0")}` } export function rankBy(rows: T[], value: (row: T) => number) { return new Map(rows.toSorted((a, b) => value(b) - value(a)).map((row, index) => [row, index + 1])) } -export function rankRowsWithMarketShare(rows: T[]) { +export function rankRowsWithMarketShare(rows: T[], groupKey: (row: T) => string = statPeriodKey) { return Object.values( rows.reduce>((result, row) => { - const key = statPeriodKey(row) + const key = groupKey(row) result[key] = [...(result[key] ?? []), row] return result }, {}), diff --git a/packages/stats/core/src/honeycomb-backfill.ts b/packages/stats/core/src/honeycomb-backfill.ts index 507b1dca0b..2b497bdf9e 100644 --- a/packages/stats/core/src/honeycomb-backfill.ts +++ b/packages/stats/core/src/honeycomb-backfill.ts @@ -3,14 +3,19 @@ import { readdir } from "node:fs/promises" import path from "node:path" import { drizzle } from "drizzle-orm/planetscale-serverless" import { geoStat, modelStat, providerStat } from "./database/schema" +import { modelAuthor, normalizeInferenceModel } from "./domain/model-normalization" import { chunks, collapseRows, inserted, + isoWeekId, normalizeCountry, normalizeTier, + periodKeyFor, rankBy, rankRowsWithMarketShare, + startOfIsoWeek, + startOfUtcDay, statPeriodKey, synthesizeAllTierRows, toStatBaseRow, @@ -23,10 +28,9 @@ const DEFAULT_TIERS = ["Go", "Free", "Paid"] const FREE_MODELS = new Set(["gpt-5-nano", "grok-code", "big-pickle"]) type Grain = "day" | "week" -type MetricDimension = "model" | "provider" | "geo" +type MetricDimension = "model" | "provider" | "geo" | "geo-model" type LookupDimension = "model-provider-model" | "geo-continent" type ImportKey = `${MetricDimension | LookupDimension}-${Grain}` -type GeneratedImportKey = `${MetricDimension}-${Grain}` type QuerySpec = { name: string importKey: ImportKey @@ -34,19 +38,17 @@ type QuerySpec = { query: ReturnType } type RawRow = Record -type Period = { start: Date; end: Date } type ImportOptions = { dataset: string databaseUrl: string | undefined directories: string[] dryRun: boolean - periodEnd: Date | undefined periodStart: Date | undefined files: Partial> } type ModelAggregate = StatBaseAggregate & { provider: string; model: string; provider_model: string } type ProviderAggregate = StatBaseAggregate & { provider: string } -type GeoAggregate = StatBaseAggregate & { country: string; continent: string } +type GeoAggregate = StatBaseAggregate & { provider: string; model: string; country: string; continent: string } type ModelStatRow = typeof modelStat.$inferInsert type ProviderStatRow = typeof providerStat.$inferInsert type GeoStatRow = typeof geoStat.$inferInsert @@ -60,6 +62,8 @@ const inputKeys = [ "provider-week", "geo-day", "geo-week", + "geo-model-day", + "geo-model-week", "geo-continent-day", "geo-continent-week", ] as const satisfies ImportKey[] @@ -77,7 +81,7 @@ function printQueries(args: string[]) { const flags = parseFlags(args) const limit = parseIntegerFlag(flags, "limit") ?? 1000 const tiers = parseListFlag(flags, "tiers") ?? DEFAULT_TIERS - const queries = buildQueries(limit, tiers, flags.has("include-weekly")) + const queries = buildQueries(limit, tiers) const only = flags.get("only")?.[0] if (only) { @@ -112,41 +116,46 @@ async function importFiles(args: string[]) { ...(await lookupRows(opts.files["geo-continent-day"], "day", opts, geoContinentLookup)), ...(await lookupRows(opts.files["geo-continent-week"], "week", opts, geoContinentLookup)), ]) - const modelRows = modelRowsFromAggregates([ - ...(await metricRows(opts.files["model-day"], "day", opts, (row, base) => ({ - ...base, - provider: provider(row), - model: model(row), - provider_model: providerModelLookup.get(lookupKey(base, provider(row), model(row))) ?? providerModel(row), - }))), - ...(await metricRows(opts.files["model-week"], "week", opts, (row, base) => ({ - ...base, - provider: provider(row), - model: model(row), - provider_model: providerModelLookup.get(lookupKey(base, provider(row), model(row))) ?? providerModel(row), - }))), - ]) + const modelAggregates = [ + ...(await metricRows(opts.files["model-day"], "day", opts, (row, base) => + modelAggregate(row, base, providerModelLookup), + )), + ...(await metricRows(opts.files["model-week"], "week", opts, (row, base) => + modelAggregate(row, base, providerModelLookup), + )), + ] + const modelRows = modelRowsFromAggregates(modelAggregates) const providerRows = providerRowsFromAggregates([ ...(await metricRows(opts.files["provider-day"], "day", opts, (row, base) => ({ ...base, - provider: provider(row), + provider: provider(row) ?? "unknown", }))), ...(await metricRows(opts.files["provider-week"], "week", opts, (row, base) => ({ ...base, - provider: provider(row), + provider: provider(row) ?? "unknown", }))), ]) const geoRows = geoRowsFromAggregates([ ...(await metricRows(opts.files["geo-day"], "day", opts, (row, base) => ({ ...base, + provider: "all", + model: "all", country: country(row), continent: continentLookup.get(lookupKey(base, country(row))) ?? continent(row), }))), ...(await metricRows(opts.files["geo-week"], "week", opts, (row, base) => ({ ...base, + provider: "all", + model: "all", country: country(row), continent: continentLookup.get(lookupKey(base, country(row))) ?? continent(row), }))), + ...(await metricRows(opts.files["geo-model-day"], "day", opts, (row, base) => + geoModelAggregate(row, base, continentLookup), + )), + ...(await metricRows(opts.files["geo-model-week"], "week", opts, (row, base) => + geoModelAggregate(row, base, continentLookup), + )), ]) console.log( @@ -174,36 +183,40 @@ async function importFiles(args: string[]) { await upsertGeoRows(db, geoRows) } -function buildQueries(limit: number, tiers: string[], includeWeekly: boolean): QuerySpec[] { +function buildQueries(limit: number, tiers: string[]): QuerySpec[] { const daily = tiers.flatMap((tier) => [ querySpec( "model-day", tier, - metricQuery(["date", "tier", "provider.normalized", "model", "provider.model"], limit, tierFilters(tier)), + metricQuery(["date", "tier", "stat_provider", "stat_model"], limit, tierFilters(tier)), ), querySpec( "provider-day", tier, - metricQuery(["date", "tier", "provider.normalized"], limit, tierFilters(tier)), + metricQuery(["date", "tier", "stat_provider"], limit, tierFilters(tier)), ), querySpec("geo-day", tier, metricQuery(["date", "tier", "country", "continent"], limit, tierFilters(tier))), + querySpec( + "geo-model-day", + tier, + metricQuery(["date", "tier", "stat_provider", "stat_model", "country", "continent"], limit, tierFilters(tier)), + ), + ]) + const weekly = tiers.flatMap((tier) => [ + querySpec("model-week", tier, metricQuery(["week", "tier", "stat_provider", "stat_model"], limit, tierFilters(tier))), + querySpec("provider-week", tier, metricQuery(["week", "tier", "stat_provider"], limit, tierFilters(tier))), + querySpec("geo-week", tier, metricQuery(["week", "tier", "country", "continent"], limit, tierFilters(tier))), + querySpec( + "geo-model-week", + tier, + metricQuery(["week", "tier", "stat_provider", "stat_model", "country", "continent"], limit, tierFilters(tier)), + ), ]) - const weekly = includeWeekly - ? tiers.flatMap((tier) => [ - querySpec( - "model-week", - tier, - metricQuery(["tier", "provider.normalized", "model", "provider.model"], limit, tierFilters(tier)), - ), - querySpec("provider-week", tier, metricQuery(["tier", "provider.normalized"], limit, tierFilters(tier))), - querySpec("geo-week", tier, metricQuery(["tier", "country", "continent"], limit, tierFilters(tier))), - ]) - : [] return [...daily, ...weekly] } -function querySpec(importKey: GeneratedImportKey, tier: string, query: ReturnType) { +function querySpec(importKey: ImportKey, tier: string, query: ReturnType) { return { name: `${importKey}-${queryNameSegment(tier)}`, importKey, @@ -238,8 +251,6 @@ function metricQuery( { op: "P50", column: "time_to_first_byte" }, { op: "P95", column: "time_to_first_byte" }, { op: "AVG", column: "tps.output" }, - { op: "SUM", column: "success" }, - { op: "SUM", column: "error" }, ], filters: [...commonFilters(), ...filters], filter_combination: "AND", @@ -269,6 +280,7 @@ function commonFilters() { { column: "event_type", op: "=", value: "completions" }, { column: "model", op: "exists" }, { column: "model", op: "!=", value: "" }, + { column: "model", op: "!=", value: "alpha-gpt-next" }, ] } @@ -276,10 +288,10 @@ function metricRows( files: string[] | undefined, grain: Grain, opts: ImportOptions, - map: (row: RawRow, base: StatBaseAggregate) => T, + map: (row: RawRow, base: StatBaseAggregate) => T | T[], ) { if (!files) return Promise.resolve([]) - return readFiles(files).then((rows) => rows.map((row) => map(row, baseAggregate(row, grain, opts)))) + return readFiles(files).then((rows) => rows.flatMap((row) => map(row, baseAggregate(row, grain, opts)))) } function lookupRows( @@ -334,10 +346,13 @@ function classifyRows(file: string, rows: RawRow[]): ImportKey { if (rows.length === 0) fail(`Cannot classify empty export: ${file}`) const headers = new Set(rows.flatMap((row) => Object.keys(row).map(normalizeHeader))) const grain: Grain = headers.has("date") ? "day" : "week" - if (headers.has("model")) return hasMetricHeaders(headers) ? `model-${grain}` : `model-provider-model-${grain}` - if (hasHeader(headers, ["country", "cf.country"])) + if (hasHeader(headers, ["country", "cf.country"])) { + if (hasHeader(headers, ["model", "stat_model"]) && hasMetricHeaders(headers)) return `geo-model-${grain}` return hasMetricHeaders(headers) ? `geo-${grain}` : `geo-continent-${grain}` - if (hasHeader(headers, ["provider", "provider.normalized"])) return `provider-${grain}` + } + if (hasHeader(headers, ["model", "stat_model"])) + return hasMetricHeaders(headers) ? `model-${grain}` : `model-provider-model-${grain}` + if (hasHeader(headers, ["provider", "provider.normalized", "stat_provider"])) return `provider-${grain}` fail(`Cannot classify export from columns in ${file}`) } @@ -362,8 +377,27 @@ function mergeFiles(left: Partial>, right: Partial, +): ModelAggregate[] { + const author = provider(row) + if (!author) return [] + + return [ + { + ...base, + provider: author, + model: model(row), + provider_model: providerModelLookup.get(lookupKey(base, author, model(row))) ?? providerModel(row), + }, + ] } function geoContinentLookup(row: RawRow, grain: Grain, opts: ImportOptions): [string, string][] { @@ -373,6 +407,25 @@ function geoContinentLookup(row: RawRow, grain: Grain, opts: ImportOptions): [st return [[lookupKey({ ...base, dataset: opts.dataset, tier: tier(row), grain }, country(row)), value]] } +function geoModelAggregate( + row: RawRow, + base: StatBaseAggregate, + continentLookup: Map, +): GeoAggregate[] { + const author = provider(row) + if (!author) return [] + + return [ + { + ...base, + provider: author, + model: model(row), + country: country(row), + continent: continentLookup.get(lookupKey(base, country(row))) ?? continent(row), + }, + ] +} + function baseAggregate(row: RawRow, grain: Grain, opts: ImportOptions): StatBaseAggregate { return { ...basePeriod(row, grain, opts), @@ -412,24 +465,20 @@ function baseAggregate(row: RawRow, grain: Grain, opts: ImportOptions): StatBase } function basePeriod(row: RawRow, grain: Grain, opts: ImportOptions) { - const period = periodFor(row, grain, opts) - return { period_start: period.start, period_end: period.end } + return { period_key: periodKey(row, grain, opts) } } -function periodFor(row: RawRow, grain: Grain, opts: ImportOptions): Period { +function periodKey(row: RawRow, grain: Grain, opts: ImportOptions) { if (grain === "week") { - const end = opts.periodEnd ?? parseTime(row) - if (!end) fail("--period-end is required for week imports") - return { start: opts.periodStart ?? syncWeekStart(end), end } + const week = parseWeek(row) + if (week) return week + fail("weekly imports require a week or period_key column") } const time = parseTime(row) const start = time ? startOfUtcDay(time) : opts.periodStart if (!start) fail("daily imports require a time column or --period-start") - return { - start, - end: opts.periodEnd && sameUtcDay(start, opts.periodEnd) ? opts.periodEnd : new Date(start.getTime() + DAY_MS), - } + return periodKeyFor("day", start) } function modelRowsFromAggregates(aggregates: ModelAggregate[]) { @@ -459,16 +508,19 @@ function providerRowsFromAggregates(aggregates: ProviderAggregate[]) { } function geoRowsFromAggregates(aggregates: GeoAggregate[]) { - return rankRowsWithMarketShare([ - ...synthesizeAllTierRows( - collapseRows(aggregates.filter((item) => item.grain === "week").map(toGeoRow), geoDimensionKey), - geoDimensionKey, - ), - ...synthesizeAllTierRows( - collapseRows(aggregates.filter((item) => item.grain === "day").map(toGeoRow), geoDimensionKey), - geoDimensionKey, - ), - ]) + return rankRowsWithMarketShare( + [ + ...synthesizeAllTierRows( + collapseRows(aggregates.filter((item) => item.grain === "week").map(toGeoRow), geoDimensionKey), + geoDimensionKey, + ), + ...synthesizeAllTierRows( + collapseRows(aggregates.filter((item) => item.grain === "day").map(toGeoRow), geoDimensionKey), + geoDimensionKey, + ), + ], + geoMarketShareKey, + ) } function toModelRow(data: ModelAggregate): ModelStatRow { @@ -480,7 +532,13 @@ function toProviderRow(data: ProviderAggregate): ProviderStatRow { } function toGeoRow(data: GeoAggregate): GeoStatRow { - return { ...toStatBaseRow(data), country: data.country, continent: data.continent } + return { + ...toStatBaseRow(data), + provider: data.provider, + model: data.model, + country: data.country, + continent: data.continent, + } } function rankModelRows(rows: ModelStatRow[]) { @@ -512,11 +570,15 @@ function providerDimensionKey(row: ProviderStatRow) { } function geoDimensionKey(row: GeoStatRow) { - return row.country + return [row.provider, row.model, row.country].join("\u0000") } -function lookupKey(base: { grain: string; period_start: Date; dataset: string; tier: string }, ...dimension: string[]) { - return [base.grain, base.period_start.toISOString(), base.dataset, base.tier, ...dimension].join("\u0000") +function geoMarketShareKey(row: GeoStatRow) { + return [statPeriodKey(row), row.provider, row.model].join("\u0000") +} + +function lookupKey(base: { grain: string; period_key: string; dataset: string; tier: string }, ...dimension: string[]) { + return [base.grain, base.period_key, base.dataset, base.tier, ...dimension].join("\u0000") } function tier(row: RawRow) { @@ -527,23 +589,19 @@ function deriveTier(row: RawRow) { const source = cell(row, ["source"]) const value = model(row) if (source === "lite") return "Go" - if (FREE_MODELS.has(value) || value.endsWith("-free")) return "Free" + if (FREE_MODELS.has(value) || /-free(:global)?$/.test(rawModel(row))) return "Free" return "Zen" } function provider(row: RawRow) { - return normalizeProvider(cell(row, ["provider.normalized", "stat_provider", "provider"]) || "unknown") -} - -function normalizeProvider(value: string) { - if (value.startsWith("minimax-plan")) return "minimax-plan" - if (value.startsWith("zai-plan")) return "zai-plan" - if (value.startsWith("azure-databricks")) return "azure-databricks" - if (/^azure[0-9]+/.test(value)) return "azure-openai" - return value || "unknown" + return cell(row, ["stat_provider"]) || modelAuthor(model(row)) } function model(row: RawRow) { + return normalizeInferenceModel(cell(row, ["stat_model"]) || rawModel(row)) +} + +function rawModel(row: RawRow) { return cell(row, ["model"]) || "unknown" } @@ -609,20 +667,21 @@ function parseTime(row: RawRow) { return date } -function startOfUtcDay(value: Date) { - return new Date(Date.UTC(value.getUTCFullYear(), value.getUTCMonth(), value.getUTCDate())) -} +function parseWeek(row: RawRow) { + const value = cell(row, ["period_key", "week", "stat_week"]) + if (!value) return undefined -function syncWeekStart(periodEnd: Date) { - return new Date(Date.UTC(periodEnd.getUTCFullYear(), periodEnd.getUTCMonth(), periodEnd.getUTCDate() - 6)) -} + const match = /^(\d{4})-W(\d{1,2})$/.exec(value) + if (!match) fail(`Invalid week value: ${value}`) -function sameUtcDay(left: Date, right: Date) { - return ( - left.getUTCFullYear() === right.getUTCFullYear() && - left.getUTCMonth() === right.getUTCMonth() && - left.getUTCDate() === right.getUTCDate() - ) + const year = Number(match[1]) + const week = Number(match[2]) + if (week < 1 || week > 53) fail(`Invalid week value: ${value}`) + + const start = new Date(startOfIsoWeek(new Date(Date.UTC(year, 0, 4))).getTime() + (week - 1) * 7 * DAY_MS) + const id = `${year}-W${String(week).padStart(2, "0")}` + if (isoWeekId(start) !== id) fail(`Invalid week value: ${value}`) + return id } async function readRows(file: string) { @@ -732,7 +791,6 @@ async function upsertModelRows(db: ReturnType, rows: ModelStatRo .values(chunk) .onDuplicateKeyUpdate({ set: { - period_end: inserted("period_end"), provider_model: inserted("provider_model"), sessions: inserted("sessions"), requests: inserted("requests"), @@ -771,7 +829,6 @@ async function upsertProviderRows(db: ReturnType, rows: Provider .values(chunk) .onDuplicateKeyUpdate({ set: { - period_end: inserted("period_end"), sessions: inserted("sessions"), requests: inserted("requests"), input_tokens: inserted("input_tokens"), @@ -813,7 +870,6 @@ async function upsertGeoRows(db: ReturnType, rows: GeoStatRow[]) .values(chunk) .onDuplicateKeyUpdate({ set: { - period_end: inserted("period_end"), continent: inserted("continent"), sessions: inserted("sessions"), requests: inserted("requests"), @@ -860,7 +916,6 @@ function parseImportOptions(args: string[]): ImportOptions { databaseUrl: flags.get("database-url")?.[0] ?? process.env.DATABASE_URL, directories: flags.get("dir") ?? flags.get("directory") ?? [], dryRun: flags.has("dry-run"), - periodEnd: parseDateFlag(flags, "period-end"), periodStart: parseDateFlag(flags, "period-start"), files, } @@ -913,9 +968,9 @@ function parseListFlag(flags: Map, name: string) { function usage(): never { fail(`Usage: - bun src/honeycomb-backfill.ts queries [--tiers Go,Free,Paid] [--limit 1000] [--include-weekly] - bun src/honeycomb-backfill.ts import [--period-end ISO for weekly files] [--dry-run] [--database-url URL] --dir downloads - bun src/honeycomb-backfill.ts import [--period-end ISO for weekly files] [--dry-run] [--database-url URL] --model-day file.csv [--model-day more.csv] ...`) + bun src/honeycomb-backfill.ts queries [--tiers Go,Free,Paid] [--limit 1000] + bun src/honeycomb-backfill.ts import [--dry-run] [--database-url URL] --dir downloads + bun src/honeycomb-backfill.ts import [--dry-run] [--database-url URL] --model-day file.csv [--model-day more.csv] ...`) } function fail(message: string): never { diff --git a/packages/stats/core/src/stat-sync.ts b/packages/stats/core/src/stat-sync.ts index 7f72cd2ca6..b3aba13613 100644 --- a/packages/stats/core/src/stat-sync.ts +++ b/packages/stats/core/src/stat-sync.ts @@ -6,8 +6,10 @@ import { GeoStatRepo, rowsFromAggregates as geoRowsFromAggregates } from "./doma import { buildStatsQuery, toGeoAggregate, toModelAggregate, toProviderAggregate } from "./domain/inference" import { ModelStatRepo, rowsFromAggregates as modelRowsFromAggregates } from "./domain/model" import { ProviderStatRepo, rowsFromAggregates as providerRowsFromAggregates } from "./domain/provider" +import { startOfIsoWeek } from "./domain/stat" const DATALAKE_INGESTION_LAG_MS = 5 * 60_000 +const WEEK_MS = 7 * 86_400_000 export type SyncStatsResult = { ok: true; rows: number; startedAt: string; periodStart: string; periodEnd: string } export type SyncStatsError = AthenaQueryError | AthenaQueryTimeoutError | DatabaseError @@ -19,9 +21,7 @@ export const syncStats: () => Effect.Effect< > = 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) - const periodStart = new Date( - Date.UTC(periodEnd.getUTCFullYear(), periodEnd.getUTCMonth(), periodEnd.getUTCDate() - 6), - ) + const periodStart = new Date(startOfIsoWeek(periodEnd).getTime() - WEEK_MS) const athena = yield* Athena const modelStats = yield* ModelStatRepo const providerStats = yield* ProviderStatRepo @@ -29,7 +29,7 @@ export const syncStats: () => Effect.Effect< yield* logRuntimeCheck() - const [modelAggregates, providerAggregates, geoAggregates] = yield* Effect.all( + const [modelAggregates, providerAggregates, geoAggregates, geoModelAggregates] = yield* Effect.all( [ athena .query(buildStatsQuery(periodStart, periodEnd, "model")) @@ -40,12 +40,15 @@ export const syncStats: () => Effect.Effect< athena .query(buildStatsQuery(periodStart, periodEnd, "geo")) .pipe(Effect.map((rows) => rows.flatMap(toGeoAggregate))), + athena + .query(buildStatsQuery(periodStart, periodEnd, "geo_model")) + .pipe(Effect.map((rows) => rows.flatMap(toGeoAggregate))), ], { concurrency: "unbounded" }, ) const modelRows = modelRowsFromAggregates(modelAggregates) const providerRows = providerRowsFromAggregates(providerAggregates) - const geoRows = geoRowsFromAggregates(geoAggregates) + const geoRows = geoRowsFromAggregates([...geoAggregates, ...geoModelAggregates]) yield* Effect.all([modelStats.upsert(modelRows), providerStats.upsert(providerRows), geoStats.upsert(geoRows)], { concurrency: "unbounded", From 7504daa6028d99c609e6c52ad16aa7f277b2bddc Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Thu, 28 May 2026 08:37:43 -0500 Subject: [PATCH 030/113] fix(stats): batch honeycomb backfill --- package.json | 2 +- packages/stats/core/src/honeycomb-backfill.ts | 245 +++++++++--------- 2 files changed, 125 insertions(+), 122 deletions(-) diff --git a/package.json b/package.json index 5fecf69bab..f2e79709dc 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,7 @@ "dev:desktop": "bun --cwd packages/desktop dev", "dev:web": "bun --cwd packages/app dev", "dev:console": "ulimit -n 10240 2>/dev/null; bun run --cwd packages/console/app dev", - "dev:stats": "bun sst shell --stage=production -- bun run --cwd packages/stats/app dev", + "dev:stats": "bun sst shell --stage=dev -- bun run --cwd packages/stats/app dev", "dev:storybook": "bun --cwd packages/storybook storybook", "lint": "oxlint", "typecheck": "bun turbo typecheck", diff --git a/packages/stats/core/src/honeycomb-backfill.ts b/packages/stats/core/src/honeycomb-backfill.ts index 2b497bdf9e..93fc237e97 100644 --- a/packages/stats/core/src/honeycomb-backfill.ts +++ b/packages/stats/core/src/honeycomb-backfill.ts @@ -19,11 +19,11 @@ import { statPeriodKey, synthesizeAllTierRows, toStatBaseRow, - UPSERT_CHUNK_SIZE, type StatBaseAggregate, } from "./domain/stat" const DAY_MS = 86_400_000 +const DEFAULT_UPSERT_CHUNK_SIZE = 100 const DEFAULT_TIERS = ["Go", "Free", "Paid"] const FREE_MODELS = new Set(["gpt-5-nano", "grok-code", "big-pickle"]) @@ -44,6 +44,7 @@ type ImportOptions = { directories: string[] dryRun: boolean periodStart: Date | undefined + upsertChunkSize: number files: Partial> } type ModelAggregate = StatBaseAggregate & { provider: string; model: string; provider_model: string } @@ -168,6 +169,7 @@ async function importFiles(args: string[]) { providerRows: providerRows.length, geoRows: geoRows.length, dryRun: opts.dryRun, + upsertChunkSize: opts.upsertChunkSize, }, null, 2, @@ -178,9 +180,9 @@ async function importFiles(args: string[]) { if (!opts.databaseUrl) fail("DATABASE_URL is required unless --dry-run is set") const db = drizzle({ client: new Client({ url: opts.databaseUrl }) }) - await upsertModelRows(db, modelRows) - await upsertProviderRows(db, providerRows) - await upsertGeoRows(db, geoRows) + await upsertModelRows(db, modelRows, opts.upsertChunkSize) + await upsertProviderRows(db, providerRows, opts.upsertChunkSize) + await upsertGeoRows(db, geoRows, opts.upsertChunkSize) } function buildQueries(limit: number, tiers: string[]): QuerySpec[] { @@ -783,125 +785,125 @@ function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value) } -async function upsertModelRows(db: ReturnType, rows: ModelStatRow[]) { - await Promise.all( - chunks(rows, UPSERT_CHUNK_SIZE).map((chunk) => - db - .insert(modelStat) - .values(chunk) - .onDuplicateKeyUpdate({ - set: { - provider_model: inserted("provider_model"), - sessions: inserted("sessions"), - requests: inserted("requests"), - input_tokens: inserted("input_tokens"), - output_tokens: inserted("output_tokens"), - reasoning_tokens: inserted("reasoning_tokens"), - cache_read_tokens: inserted("cache_read_tokens"), - total_tokens: inserted("total_tokens"), - input_cost_microcents: inserted("input_cost_microcents"), - output_cost_microcents: inserted("output_cost_microcents"), - total_cost_microcents: inserted("total_cost_microcents"), - avg_duration_ms: inserted("avg_duration_ms"), - p50_duration_ms: inserted("p50_duration_ms"), - p95_duration_ms: inserted("p95_duration_ms"), - avg_ttfb_ms: inserted("avg_ttfb_ms"), - p50_ttfb_ms: inserted("p50_ttfb_ms"), - p95_ttfb_ms: inserted("p95_ttfb_ms"), - avg_output_tps: inserted("avg_output_tps"), - success_count: inserted("success_count"), - error_count: inserted("error_count"), - sample_count: inserted("sample_count"), - rank_by_tokens: inserted("rank_by_tokens"), - rank_by_requests: inserted("rank_by_requests"), - rank_by_cost: inserted("rank_by_cost"), - }, - }), - ), - ) +async function upsertModelRows(db: ReturnType, rows: ModelStatRow[], chunkSize: number) { + const batches = chunks(rows, chunkSize) + console.log(JSON.stringify({ table: "model_stat", batches: batches.length, chunkSize })) + for (const chunk of batches) { + await db + .insert(modelStat) + .values(chunk) + .onDuplicateKeyUpdate({ + set: { + provider_model: inserted("provider_model"), + sessions: inserted("sessions"), + requests: inserted("requests"), + input_tokens: inserted("input_tokens"), + output_tokens: inserted("output_tokens"), + reasoning_tokens: inserted("reasoning_tokens"), + cache_read_tokens: inserted("cache_read_tokens"), + total_tokens: inserted("total_tokens"), + input_cost_microcents: inserted("input_cost_microcents"), + output_cost_microcents: inserted("output_cost_microcents"), + total_cost_microcents: inserted("total_cost_microcents"), + avg_duration_ms: inserted("avg_duration_ms"), + p50_duration_ms: inserted("p50_duration_ms"), + p95_duration_ms: inserted("p95_duration_ms"), + avg_ttfb_ms: inserted("avg_ttfb_ms"), + p50_ttfb_ms: inserted("p50_ttfb_ms"), + p95_ttfb_ms: inserted("p95_ttfb_ms"), + avg_output_tps: inserted("avg_output_tps"), + success_count: inserted("success_count"), + error_count: inserted("error_count"), + sample_count: inserted("sample_count"), + rank_by_tokens: inserted("rank_by_tokens"), + rank_by_requests: inserted("rank_by_requests"), + rank_by_cost: inserted("rank_by_cost"), + }, + }) + } } -async function upsertProviderRows(db: ReturnType, rows: ProviderStatRow[]) { - await Promise.all( - chunks(rows, UPSERT_CHUNK_SIZE).map((chunk) => - db - .insert(providerStat) - .values(chunk) - .onDuplicateKeyUpdate({ - set: { - sessions: inserted("sessions"), - requests: inserted("requests"), - input_tokens: inserted("input_tokens"), - output_tokens: inserted("output_tokens"), - reasoning_tokens: inserted("reasoning_tokens"), - cache_read_tokens: inserted("cache_read_tokens"), - total_tokens: inserted("total_tokens"), - input_cost_microcents: inserted("input_cost_microcents"), - output_cost_microcents: inserted("output_cost_microcents"), - total_cost_microcents: inserted("total_cost_microcents"), - avg_duration_ms: inserted("avg_duration_ms"), - p50_duration_ms: inserted("p50_duration_ms"), - p95_duration_ms: inserted("p95_duration_ms"), - avg_ttfb_ms: inserted("avg_ttfb_ms"), - p50_ttfb_ms: inserted("p50_ttfb_ms"), - p95_ttfb_ms: inserted("p95_ttfb_ms"), - avg_output_tps: inserted("avg_output_tps"), - success_count: inserted("success_count"), - error_count: inserted("error_count"), - sample_count: inserted("sample_count"), - market_share_tokens: inserted("market_share_tokens"), - market_share_requests: inserted("market_share_requests"), - market_share_sessions: inserted("market_share_sessions"), - rank_by_tokens: inserted("rank_by_tokens"), - rank_by_requests: inserted("rank_by_requests"), - rank_by_sessions: inserted("rank_by_sessions"), - rank_by_cost: inserted("rank_by_cost"), - }, - }), - ), - ) +async function upsertProviderRows(db: ReturnType, rows: ProviderStatRow[], chunkSize: number) { + const batches = chunks(rows, chunkSize) + console.log(JSON.stringify({ table: "provider_stat", batches: batches.length, chunkSize })) + for (const chunk of batches) { + await db + .insert(providerStat) + .values(chunk) + .onDuplicateKeyUpdate({ + set: { + sessions: inserted("sessions"), + requests: inserted("requests"), + input_tokens: inserted("input_tokens"), + output_tokens: inserted("output_tokens"), + reasoning_tokens: inserted("reasoning_tokens"), + cache_read_tokens: inserted("cache_read_tokens"), + total_tokens: inserted("total_tokens"), + input_cost_microcents: inserted("input_cost_microcents"), + output_cost_microcents: inserted("output_cost_microcents"), + total_cost_microcents: inserted("total_cost_microcents"), + avg_duration_ms: inserted("avg_duration_ms"), + p50_duration_ms: inserted("p50_duration_ms"), + p95_duration_ms: inserted("p95_duration_ms"), + avg_ttfb_ms: inserted("avg_ttfb_ms"), + p50_ttfb_ms: inserted("p50_ttfb_ms"), + p95_ttfb_ms: inserted("p95_ttfb_ms"), + avg_output_tps: inserted("avg_output_tps"), + success_count: inserted("success_count"), + error_count: inserted("error_count"), + sample_count: inserted("sample_count"), + market_share_tokens: inserted("market_share_tokens"), + market_share_requests: inserted("market_share_requests"), + market_share_sessions: inserted("market_share_sessions"), + rank_by_tokens: inserted("rank_by_tokens"), + rank_by_requests: inserted("rank_by_requests"), + rank_by_sessions: inserted("rank_by_sessions"), + rank_by_cost: inserted("rank_by_cost"), + }, + }) + } } -async function upsertGeoRows(db: ReturnType, rows: GeoStatRow[]) { - await Promise.all( - chunks(rows, UPSERT_CHUNK_SIZE).map((chunk) => - db - .insert(geoStat) - .values(chunk) - .onDuplicateKeyUpdate({ - set: { - continent: inserted("continent"), - sessions: inserted("sessions"), - requests: inserted("requests"), - input_tokens: inserted("input_tokens"), - output_tokens: inserted("output_tokens"), - reasoning_tokens: inserted("reasoning_tokens"), - cache_read_tokens: inserted("cache_read_tokens"), - total_tokens: inserted("total_tokens"), - input_cost_microcents: inserted("input_cost_microcents"), - output_cost_microcents: inserted("output_cost_microcents"), - total_cost_microcents: inserted("total_cost_microcents"), - avg_duration_ms: inserted("avg_duration_ms"), - p50_duration_ms: inserted("p50_duration_ms"), - p95_duration_ms: inserted("p95_duration_ms"), - avg_ttfb_ms: inserted("avg_ttfb_ms"), - p50_ttfb_ms: inserted("p50_ttfb_ms"), - p95_ttfb_ms: inserted("p95_ttfb_ms"), - avg_output_tps: inserted("avg_output_tps"), - success_count: inserted("success_count"), - error_count: inserted("error_count"), - sample_count: inserted("sample_count"), - market_share_tokens: inserted("market_share_tokens"), - market_share_requests: inserted("market_share_requests"), - market_share_sessions: inserted("market_share_sessions"), - rank_by_tokens: inserted("rank_by_tokens"), - rank_by_requests: inserted("rank_by_requests"), - rank_by_sessions: inserted("rank_by_sessions"), - rank_by_cost: inserted("rank_by_cost"), - }, - }), - ), - ) +async function upsertGeoRows(db: ReturnType, rows: GeoStatRow[], chunkSize: number) { + const batches = chunks(rows, chunkSize) + console.log(JSON.stringify({ table: "geo_stat", batches: batches.length, chunkSize })) + for (const chunk of batches) { + await db + .insert(geoStat) + .values(chunk) + .onDuplicateKeyUpdate({ + set: { + continent: inserted("continent"), + sessions: inserted("sessions"), + requests: inserted("requests"), + input_tokens: inserted("input_tokens"), + output_tokens: inserted("output_tokens"), + reasoning_tokens: inserted("reasoning_tokens"), + cache_read_tokens: inserted("cache_read_tokens"), + total_tokens: inserted("total_tokens"), + input_cost_microcents: inserted("input_cost_microcents"), + output_cost_microcents: inserted("output_cost_microcents"), + total_cost_microcents: inserted("total_cost_microcents"), + avg_duration_ms: inserted("avg_duration_ms"), + p50_duration_ms: inserted("p50_duration_ms"), + p95_duration_ms: inserted("p95_duration_ms"), + avg_ttfb_ms: inserted("avg_ttfb_ms"), + p50_ttfb_ms: inserted("p50_ttfb_ms"), + p95_ttfb_ms: inserted("p95_ttfb_ms"), + avg_output_tps: inserted("avg_output_tps"), + success_count: inserted("success_count"), + error_count: inserted("error_count"), + sample_count: inserted("sample_count"), + market_share_tokens: inserted("market_share_tokens"), + market_share_requests: inserted("market_share_requests"), + market_share_sessions: inserted("market_share_sessions"), + rank_by_tokens: inserted("rank_by_tokens"), + rank_by_requests: inserted("rank_by_requests"), + rank_by_sessions: inserted("rank_by_sessions"), + rank_by_cost: inserted("rank_by_cost"), + }, + }) + } } function parseImportOptions(args: string[]): ImportOptions { @@ -917,6 +919,7 @@ function parseImportOptions(args: string[]): ImportOptions { directories: flags.get("dir") ?? flags.get("directory") ?? [], dryRun: flags.has("dry-run"), periodStart: parseDateFlag(flags, "period-start"), + upsertChunkSize: parseIntegerFlag(flags, "upsert-chunk-size") ?? DEFAULT_UPSERT_CHUNK_SIZE, files, } } @@ -969,8 +972,8 @@ function parseListFlag(flags: Map, name: string) { function usage(): never { fail(`Usage: bun src/honeycomb-backfill.ts queries [--tiers Go,Free,Paid] [--limit 1000] - bun src/honeycomb-backfill.ts import [--dry-run] [--database-url URL] --dir downloads - bun src/honeycomb-backfill.ts import [--dry-run] [--database-url URL] --model-day file.csv [--model-day more.csv] ...`) + bun src/honeycomb-backfill.ts import [--dry-run] [--upsert-chunk-size 100] [--database-url URL] --dir downloads + bun src/honeycomb-backfill.ts import [--dry-run] [--upsert-chunk-size 100] [--database-url URL] --model-day file.csv [--model-day more.csv] ...`) } function fail(message: string): never { From 425a4f584fb6938bcb6867ad19648004450895de Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Thu, 28 May 2026 13:39:37 +0000 Subject: [PATCH 031/113] chore: generate --- .../snapshot.json | 18 ++++------- .../snapshot.json | 18 ++++------- .../snapshot.json | 18 ++++------- packages/stats/core/src/domain/geo.ts | 7 ++++- packages/stats/core/src/domain/stat.ts | 30 +++++++------------ packages/stats/core/src/honeycomb-backfill.ts | 24 +++++---------- 6 files changed, 40 insertions(+), 75 deletions(-) diff --git a/packages/stats/core/migrations/20260528012726_worthless_ultimo/snapshot.json b/packages/stats/core/migrations/20260528012726_worthless_ultimo/snapshot.json index 4a6d03ce81..d156d9ab7b 100644 --- a/packages/stats/core/migrations/20260528012726_worthless_ultimo/snapshot.json +++ b/packages/stats/core/migrations/20260528012726_worthless_ultimo/snapshot.json @@ -2,9 +2,7 @@ "version": "6", "dialect": "mysql", "id": "c7296da3-649d-44f8-a6d5-7cbd755b2c67", - "prevIds": [ - "e246639a-0da0-4fbd-b7bb-f1781d407780" - ], + "prevIds": ["e246639a-0da0-4fbd-b7bb-f1781d407780"], "ddl": [ { "name": "geo_stat", @@ -1629,25 +1627,19 @@ "table": "provider_stat" }, { - "columns": [ - "id" - ], + "columns": ["id"], "name": "PRIMARY", "table": "geo_stat", "entityType": "pks" }, { - "columns": [ - "id" - ], + "columns": ["id"], "name": "PRIMARY", "table": "model_stat", "entityType": "pks" }, { - "columns": [ - "id" - ], + "columns": ["id"], "name": "PRIMARY", "table": "provider_stat", "entityType": "pks" @@ -2102,4 +2094,4 @@ } ], "renames": [] -} \ No newline at end of file +} diff --git a/packages/stats/core/migrations/20260528114447_misty_black_knight/snapshot.json b/packages/stats/core/migrations/20260528114447_misty_black_knight/snapshot.json index ba17411cde..d8508c18bd 100644 --- a/packages/stats/core/migrations/20260528114447_misty_black_knight/snapshot.json +++ b/packages/stats/core/migrations/20260528114447_misty_black_knight/snapshot.json @@ -2,9 +2,7 @@ "version": "6", "dialect": "mysql", "id": "cefe97ad-34ad-42f3-9343-47b1b7f478fa", - "prevIds": [ - "c7296da3-649d-44f8-a6d5-7cbd755b2c67" - ], + "prevIds": ["c7296da3-649d-44f8-a6d5-7cbd755b2c67"], "ddl": [ { "name": "geo_stat", @@ -1671,25 +1669,19 @@ "table": "provider_stat" }, { - "columns": [ - "id" - ], + "columns": ["id"], "name": "PRIMARY", "table": "geo_stat", "entityType": "pks" }, { - "columns": [ - "id" - ], + "columns": ["id"], "name": "PRIMARY", "table": "model_stat", "entityType": "pks" }, { - "columns": [ - "id" - ], + "columns": ["id"], "name": "PRIMARY", "table": "provider_stat", "entityType": "pks" @@ -2144,4 +2136,4 @@ } ], "renames": [] -} \ No newline at end of file +} diff --git a/packages/stats/core/migrations/20260528121120_easy_multiple_man/snapshot.json b/packages/stats/core/migrations/20260528121120_easy_multiple_man/snapshot.json index d5a76a75ea..193134b0af 100644 --- a/packages/stats/core/migrations/20260528121120_easy_multiple_man/snapshot.json +++ b/packages/stats/core/migrations/20260528121120_easy_multiple_man/snapshot.json @@ -2,9 +2,7 @@ "version": "6", "dialect": "mysql", "id": "9d4d1a06-7d28-4cb4-b0cd-3dfb7b481b8b", - "prevIds": [ - "cefe97ad-34ad-42f3-9343-47b1b7f478fa" - ], + "prevIds": ["cefe97ad-34ad-42f3-9343-47b1b7f478fa"], "ddl": [ { "name": "geo_stat", @@ -1587,25 +1585,19 @@ "table": "provider_stat" }, { - "columns": [ - "id" - ], + "columns": ["id"], "name": "PRIMARY", "table": "geo_stat", "entityType": "pks" }, { - "columns": [ - "id" - ], + "columns": ["id"], "name": "PRIMARY", "table": "model_stat", "entityType": "pks" }, { - "columns": [ - "id" - ], + "columns": ["id"], "name": "PRIMARY", "table": "provider_stat", "entityType": "pks" @@ -2060,4 +2052,4 @@ } ], "renames": [] -} \ No newline at end of file +} diff --git a/packages/stats/core/src/domain/geo.ts b/packages/stats/core/src/domain/geo.ts index fb3b83ec5b..9f80f35074 100644 --- a/packages/stats/core/src/domain/geo.ts +++ b/packages/stats/core/src/domain/geo.ts @@ -16,7 +16,12 @@ import { } from "./stat" export type GeoStatRow = typeof geoStat.$inferInsert -export type GeoStatAggregate = StatBaseAggregate & { provider: string; model: string; country: string; continent: string } +export type GeoStatAggregate = StatBaseAggregate & { + provider: string + model: string + country: string + continent: string +} export type GeoStatMetric = { periodKey: string updatedAt: Date diff --git a/packages/stats/core/src/domain/stat.ts b/packages/stats/core/src/domain/stat.ts index e82a299aa4..2e10f88f1c 100644 --- a/packages/stats/core/src/domain/stat.ts +++ b/packages/stats/core/src/domain/stat.ts @@ -97,14 +97,7 @@ export function synthesizeAllTierRows(rows: T[], dimensio ...rows, ...Object.values( rows.reduce>((result, row) => { - const key = [ - row.grain, - row.period_key, - row.dataset, - row.client, - row.source, - dimensionKey(row), - ].join("\u0000") + const key = [row.grain, row.period_key, row.dataset, row.client, row.source, dimensionKey(row)].join("\u0000") result[key] = result[key] ? combineRows(result[key], row) : { ...row, tier: "all" } return result }, {}), @@ -115,15 +108,9 @@ export function synthesizeAllTierRows(rows: T[], dimensio export function collapseRows(rows: T[], dimensionKey: (row: T) => string) { return Object.values( rows.reduce>((result, row) => { - const key = [ - row.grain, - row.period_key, - row.dataset, - row.tier, - row.client, - row.source, - dimensionKey(row), - ].join("\u0000") + const key = [row.grain, row.period_key, row.dataset, row.tier, row.client, row.source, dimensionKey(row)].join( + "\u0000", + ) result[key] = result[key] ? combineRows(result[key], row) : row return result }, {}), @@ -170,7 +157,9 @@ export function startOfUtcDay(value: Date) { } export function startOfIsoWeek(value: Date) { - return new Date(Date.UTC(value.getUTCFullYear(), value.getUTCMonth(), value.getUTCDate() - (value.getUTCDay() || 7) + 1)) + return new Date( + Date.UTC(value.getUTCFullYear(), value.getUTCMonth(), value.getUTCDate() - (value.getUTCDay() || 7) + 1), + ) } export function isoWeekId(value: Date) { @@ -188,7 +177,10 @@ export function rankBy(rows: T[], value: (row: T) => numb return new Map(rows.toSorted((a, b) => value(b) - value(a)).map((row, index) => [row, index + 1])) } -export function rankRowsWithMarketShare(rows: T[], groupKey: (row: T) => string = statPeriodKey) { +export function rankRowsWithMarketShare( + rows: T[], + groupKey: (row: T) => string = statPeriodKey, +) { return Object.values( rows.reduce>((result, row) => { const key = groupKey(row) diff --git a/packages/stats/core/src/honeycomb-backfill.ts b/packages/stats/core/src/honeycomb-backfill.ts index 93fc237e97..ff40a9f386 100644 --- a/packages/stats/core/src/honeycomb-backfill.ts +++ b/packages/stats/core/src/honeycomb-backfill.ts @@ -192,11 +192,7 @@ function buildQueries(limit: number, tiers: string[]): QuerySpec[] { tier, metricQuery(["date", "tier", "stat_provider", "stat_model"], limit, tierFilters(tier)), ), - querySpec( - "provider-day", - tier, - metricQuery(["date", "tier", "stat_provider"], limit, tierFilters(tier)), - ), + querySpec("provider-day", tier, metricQuery(["date", "tier", "stat_provider"], limit, tierFilters(tier))), querySpec("geo-day", tier, metricQuery(["date", "tier", "country", "continent"], limit, tierFilters(tier))), querySpec( "geo-model-day", @@ -205,7 +201,11 @@ function buildQueries(limit: number, tiers: string[]): QuerySpec[] { ), ]) const weekly = tiers.flatMap((tier) => [ - querySpec("model-week", tier, metricQuery(["week", "tier", "stat_provider", "stat_model"], limit, tierFilters(tier))), + querySpec( + "model-week", + tier, + metricQuery(["week", "tier", "stat_provider", "stat_model"], limit, tierFilters(tier)), + ), querySpec("provider-week", tier, metricQuery(["week", "tier", "stat_provider"], limit, tierFilters(tier))), querySpec("geo-week", tier, metricQuery(["week", "tier", "country", "continent"], limit, tierFilters(tier))), querySpec( @@ -227,11 +227,7 @@ function querySpec(importKey: ImportKey, tier: string, query: ReturnType = [], -) { +function metricQuery(breakdowns: string[], limit: number, filters: ReturnType = []) { return { granularity: 0, breakdowns, @@ -409,11 +405,7 @@ function geoContinentLookup(row: RawRow, grain: Grain, opts: ImportOptions): [st return [[lookupKey({ ...base, dataset: opts.dataset, tier: tier(row), grain }, country(row)), value]] } -function geoModelAggregate( - row: RawRow, - base: StatBaseAggregate, - continentLookup: Map, -): GeoAggregate[] { +function geoModelAggregate(row: RawRow, base: StatBaseAggregate, continentLookup: Map): GeoAggregate[] { const author = provider(row) if (!author) return [] From af30b315b2d047b5500d5c8f50ebe1e6ec9b4cec Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Thu, 28 May 2026 11:24:45 -0500 Subject: [PATCH 032/113] fix(tui): restore thinking spinner (#29765) --- .../tui/feature-plugins/system/session-v2.tsx | 29 +++++++----- .../src/cli/cmd/tui/routes/session/index.tsx | 45 +++++++++++-------- 2 files changed, 46 insertions(+), 28 deletions(-) diff --git a/packages/opencode/src/cli/cmd/tui/feature-plugins/system/session-v2.tsx b/packages/opencode/src/cli/cmd/tui/feature-plugins/system/session-v2.tsx index 8b9b088805..8b2b2ed37b 100644 --- a/packages/opencode/src/cli/cmd/tui/feature-plugins/system/session-v2.tsx +++ b/packages/opencode/src/cli/cmd/tui/feature-plugins/system/session-v2.tsx @@ -442,16 +442,25 @@ function ReasoningHeader(props: { toggleable: boolean; open: boolean; done: bool : theme.warning return ( - - - {props.open ? "- " : "+ "} - - {props.done ? "Thought" : "Thinking"} - - : - {props.title} - - + + + + {props.title ? "Thinking: " + props.title : "Thinking"} + + + + + + {props.open ? "- " : "+ "} + + Thought + + : + {props.title} + + + + ) } diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx index 6d9fe3688d..b3dc2a4a7d 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -1595,24 +1595,33 @@ function ReasoningHeader(props: { : theme.warning return ( - - - {props.open ? "- " : "+ "} - - {props.done ? "Thought" : "Thinking"} - - : - - - {props.title} - - - - {props.title ? " · " : ""} - {props.duration} - - - + + + + {props.title ? "Thinking: " + props.title : "Thinking"} + + + + + + {props.open ? "- " : "+ "} + + Thought + + : + + + {props.title} + + + + {props.title ? " · " : ""} + {props.duration} + + + + + ) } From 202ec120565f46712a815cf6479d07c5dcf862f9 Mon Sep 17 00:00:00 2001 From: Avnish Uba Date: Thu, 28 May 2026 21:55:34 +0530 Subject: [PATCH 033/113] docs: fix grammar in config.mdx (#29683) Co-authored-by: dnsnav Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- packages/web/src/content/docs/config.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/web/src/content/docs/config.mdx b/packages/web/src/content/docs/config.mdx index 3cb9d93748..b5b492f76d 100644 --- a/packages/web/src/content/docs/config.mdx +++ b/packages/web/src/content/docs/config.mdx @@ -116,7 +116,7 @@ For project-specific TUI settings, add `tui.json` alongside it. Place project specific config in the root of your project. ::: -When OpenCode starts up, it looks for a config file in the current directory or traverse up to the nearest Git directory. +When OpenCode starts up, it first looks for a config file in the current directory, then traverses up to the nearest Git directory. This is also safe to be checked into Git and uses the same schema as the global one. From 72d008bd5c698d68ddd5ddc805f9f159344fdaf0 Mon Sep 17 00:00:00 2001 From: Filip <34747899+neriousy@users.noreply.github.com> Date: Thu, 28 May 2026 19:17:35 +0200 Subject: [PATCH 034/113] fix(core): allow colons inside passwords (#29576) Co-authored-by: Kit Langton --- .../instance/httpapi/middleware/authorization.ts | 8 ++++---- packages/opencode/test/server/httpapi-ui.test.ts | 14 ++++++++++++++ 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/packages/opencode/src/server/routes/instance/httpapi/middleware/authorization.ts b/packages/opencode/src/server/routes/instance/httpapi/middleware/authorization.ts index c21de79a24..db6554590f 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/middleware/authorization.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/middleware/authorization.ts @@ -63,11 +63,11 @@ function decodeCredential(input: string) { Effect.match({ onFailure: emptyCredential, onSuccess: (header) => { - const parts = header.split(":") - if (parts.length !== 2) return emptyCredential() + const separator = header.indexOf(":") + if (separator === -1) return emptyCredential() return { - username: parts[0], - password: Redacted.make(parts[1]), + username: header.slice(0, separator), + password: Redacted.make(header.slice(separator + 1)), } }, }), diff --git a/packages/opencode/test/server/httpapi-ui.test.ts b/packages/opencode/test/server/httpapi-ui.test.ts index 1ffa0d2005..72e227d115 100644 --- a/packages/opencode/test/server/httpapi-ui.test.ts +++ b/packages/opencode/test/server/httpapi-ui.test.ts @@ -406,6 +406,20 @@ describe("HttpApi UI fallback", () => { }), ) + it.live("accepts basic auth passwords containing colons for the web UI", () => + Effect.gen(function* () { + const response = yield* uiApp({ + password: "sec:ret", + username: "opencode", + disableEmbeddedWebUi: true, + }).request("/", { + headers: { authorization: `Basic ${btoa("opencode:sec:ret")}` }, + }) + + expect(response.status).toBe(200) + }), + ) + // Regression for #25698 (Ope): the browser fetches the PWA manifest and // its icons via flows that don't carry app-managed credentials (the // `` request is not under page-auth control), so the From 05e3c4ecee098acf119c370eb1131c5824be8980 Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Thu, 28 May 2026 12:19:34 -0500 Subject: [PATCH 035/113] fix(opencode): enable adaptive reasoning for opus 4.7+ (#29769) --- packages/opencode/src/provider/transform.ts | 19 ++++++++----- .../opencode/test/provider/transform.test.ts | 28 +++++++++++++++++++ 2 files changed, 40 insertions(+), 7 deletions(-) diff --git a/packages/opencode/src/provider/transform.ts b/packages/opencode/src/provider/transform.ts index 08b2f49221..c67c0bca65 100644 --- a/packages/opencode/src/provider/transform.ts +++ b/packages/opencode/src/provider/transform.ts @@ -596,8 +596,16 @@ function openaiCompatibleReasoningEfforts(id: string) { return gpt5CodexReasoningEfforts(apiId) ?? versionedGpt5ReasoningEfforts(apiId) ?? OPENAI_EFFORTS } +function anthropicOpus47OrLater(apiId: string) { + const version = /opus-(\d+)[.-](\d+)(?:[.-]|$)/i.exec(apiId) + if (!version) return false + const major = Number(version[1]) + const minor = Number(version[2]) + return major > 4 || (major === 4 && minor >= 7) +} + function anthropicAdaptiveEfforts(apiId: string): string[] | null { - if (["opus-4-7", "opus-4.7"].some((v) => apiId.includes(v))) { + if (anthropicOpus47OrLater(apiId)) { return ["low", "medium", "high", "xhigh", "max"] } if (["opus-4-6", "opus-4.6", "sonnet-4-6", "sonnet-4.6"].some((v) => apiId.includes(v))) { @@ -625,6 +633,7 @@ export function variants(model: Provider.Model): Record { efforts: ["low", "medium", "high", "xhigh", "max"], expectedHigh: { thinking: { type: "adaptive", display: "summarized" }, effort: "high" }, }, + { + name: "opus 4.8", + apiIds: ["claude-opus-4-8", "claude-opus-4.8"], + efforts: ["low", "medium", "high", "xhigh", "max"], + expectedHigh: { thinking: { type: "adaptive", display: "summarized" }, effort: "high" }, + }, ]) { for (const apiId of testCase.apiIds) { test(`${testCase.name} ${apiId} returns supported reasoning efforts`, () => { @@ -3341,6 +3347,28 @@ describe("ProviderTransform.variants", () => { }) }) + test("anthropic opus 4.8 returns adaptive reasoning options with xhigh", () => { + const result = ProviderTransform.variants( + createMockModel({ + id: "bedrock/anthropic-claude-opus-4.8", + providerID: "bedrock", + api: { + id: "anthropic.claude-opus-4.8", + url: "https://bedrock.amazonaws.com", + npm: "@ai-sdk/amazon-bedrock", + }, + }), + ) + expect(Object.keys(result)).toEqual(["low", "medium", "high", "xhigh", "max"]) + expect(result.high).toEqual({ + reasoningConfig: { + type: "adaptive", + maxReasoningEffort: "high", + display: "summarized", + }, + }) + }) + test("returns WIDELY_SUPPORTED_EFFORTS with reasoningConfig", () => { const model = createMockModel({ id: "bedrock/llama-4", From 7c320fd4633c5e2870a1a70a05f99672c493f768 Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Thu, 28 May 2026 11:15:56 -0500 Subject: [PATCH 036/113] feat(stats): better header --- package.json | 2 +- packages/stats/app/src/routes/index.css | 306 ++++++++++++++++++++---- packages/stats/app/src/routes/index.tsx | 175 ++++++++++---- 3 files changed, 387 insertions(+), 96 deletions(-) diff --git a/package.json b/package.json index f2e79709dc..5fecf69bab 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,7 @@ "dev:desktop": "bun --cwd packages/desktop dev", "dev:web": "bun --cwd packages/app dev", "dev:console": "ulimit -n 10240 2>/dev/null; bun run --cwd packages/console/app dev", - "dev:stats": "bun sst shell --stage=dev -- bun run --cwd packages/stats/app dev", + "dev:stats": "bun sst shell --stage=production -- bun run --cwd packages/stats/app dev", "dev:storybook": "bun --cwd packages/storybook storybook", "lint": "oxlint", "typecheck": "bun turbo typecheck", diff --git a/packages/stats/app/src/routes/index.css b/packages/stats/app/src/routes/index.css index 2e292346d8..36179ebf2b 100644 --- a/packages/stats/app/src/routes/index.css +++ b/packages/stats/app/src/routes/index.css @@ -27,6 +27,7 @@ display: flex; flex-direction: column; gap: 4rem; + overflow-x: clip; padding-bottom: 5rem; background: var(--stats-bg); } @@ -39,6 +40,8 @@ } [data-page="stats"] [data-component="container"] { + box-sizing: border-box; + width: 100%; max-width: 67.5rem; margin: 0 auto; border-left: 1px solid var(--stats-line); @@ -49,74 +52,245 @@ position: sticky; top: 0; z-index: 10; - display: flex; - align-items: center; - justify-content: space-between; - height: 80px; - min-height: 80px; - padding: 24px var(--stats-page-padding); - color: var(--stats-muted); + box-sizing: border-box; + width: 100%; + color: var(--stats-text); background: var(--stats-bg); - border-bottom: 1px solid var(--stats-line); font-family: "IBM Plex Mono", var(--font-mono, ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace); } +[data-page="stats"] [data-component="top"] * { + box-sizing: border-box; +} + +[data-page="stats"] [data-slot="header-bar"] { + display: flex; + align-items: center; + gap: 16px; + min-height: 72px; + padding: 20px 20px 20px 24px; + overflow: hidden; +} + [data-page="stats"] [data-component="top"] a { - color: var(--stats-text); - font-size: 14px; - line-height: 18px; text-decoration: none; } [data-page="stats"] [data-component="top"] a:hover { - text-decoration: underline; - text-underline-offset: 4px; + text-decoration: none; } [data-page="stats"] [data-slot="brand"] { + flex: 0 0 auto; + min-width: 0; display: flex; align-items: center; + margin-right: auto; + color: var(--stats-text); } -[data-page="stats"] [data-slot="brand"] img { - width: auto; - height: 34px; +[data-page="stats"] [data-slot="stats-wordmark"] { + display: flex; + flex-shrink: 0; + align-items: center; + gap: 12px; } -[data-page="stats"] [data-slot="logo dark"] { +[data-page="stats"] [data-slot="brand-mark"] { + display: block; + width: 19px; + height: 24px; +} + +[data-page="stats"] [data-slot="brand-label"] { + display: block; + width: 50.851px; + height: 14px; +} + +[data-page="stats"] [data-component="section-nav"] { display: none; + align-items: center; + justify-content: center; + min-width: 0; } -[data-page="stats"] [data-component="nav-desktop"] ul { +[data-page="stats"] [data-component="section-nav"] ul { display: flex; align-items: center; - gap: 32px; margin: 0; padding: 0; list-style: none; } -[data-page="stats"] [data-component="nav-desktop"] a span { - color: var(--stats-faint); -} - -[data-page="stats"] [data-component="nav-desktop"] [data-slot="cta-button"] { +[data-page="stats"] [data-component="section-nav"] a { display: flex; align-items: center; + justify-content: center; + height: 32px; + padding: 0 15px; + color: var(--stats-muted); + font-size: 13px; + line-height: 1; + white-space: nowrap; +} + +[data-page="stats"] [data-component="section-nav"] a:hover { + color: var(--stats-text); +} + +[data-page="stats"] [data-slot="header-actions"] { + flex: 0 0 auto; + display: flex; + align-items: center; + justify-content: flex-end; gap: 8px; - padding: 8px 16px 8px 10px; - border-radius: 4px; - background: var(--color-background-strong); - color: var(--color-text-inverted); +} + +[data-page="stats"] [data-slot="header-button"], +[data-page="stats"] [data-slot="menu-button"] { + position: relative; + flex-shrink: 0; + display: inline-flex; + align-items: center; + justify-content: center; + height: 32px; + overflow: hidden; + margin: 0; + border: 0; + border-radius: 0; + appearance: none; + font: inherit; + font-size: 13px; + line-height: 1.1; + cursor: pointer; +} + +[data-page="stats"] [data-slot="header-button"]::before, +[data-page="stats"] [data-slot="menu-button"]::before { + position: absolute; + top: 0; + right: 0; + left: 0; + height: 16px; + pointer-events: none; + content: ""; + background: linear-gradient(180deg, #ffffff00 0%, #ffffff12 100%); +} + +[data-page="stats"] [data-slot="header-button"] { + padding: 0 12px; +} + +[data-page="stats"] [data-slot="header-button"] strong, +[data-page="stats"] [data-slot="header-button"] span, +[data-page="stats"] [data-slot="menu-button"] svg { + position: relative; + z-index: 1; +} + +[data-page="stats"] [data-slot="header-button"] strong { font-weight: 500; white-space: nowrap; } -[data-page="stats"] [data-component="nav-desktop"] [data-slot="cta-button"]:hover { - background: var(--color-background-strong-hover); - text-decoration: none; +[data-page="stats"] [data-slot="header-button"][data-variant="neutral"] { + display: none; + gap: 6px; + color: #161616; + background: #ffffff; + box-shadow: + 0 0 0 0 #00000024, + 0 0 0 0.5px #00000024, + 0 1px 1.5px 0 #0000001a; +} + +[data-page="stats"] [data-slot="header-button"][data-variant="neutral"] span { + color: #5c5c5c; + font-weight: 400; + font-variant-numeric: tabular-nums; +} + +[data-page="stats"] [data-slot="header-button"][data-variant="contrast"] { + gap: 8px; + color: #ffffff; + background: #242424; + box-shadow: + 0 0 0 0 #00000000, + 0 0 0 0.5px #3a3a3a, + 0 1px 1.5px 0 #00000033, + inset 0 -1px 2px 0 #0000000f, + inset 0 1px 2px 0 #ffffff24; +} + +[data-page="stats"] [data-slot="menu-button"] { + display: inline-flex; + width: 32px; + padding: 0; + color: #3a3a3a; + background: #ffffff; + box-shadow: + 0 0 0 0 #00000024, + 0 0 0 0.5px #00000024, + 0 1px 1.5px 0 #0000001a; +} + +[data-page="stats"] [data-slot="header-button"]:focus-visible, +[data-page="stats"] [data-slot="menu-button"]:focus-visible, +[data-page="stats"] [data-component="section-nav"] a:focus-visible, +[data-page="stats"] [data-slot="mobile-menu-item"]:focus-visible, +[data-page="stats"] [data-slot="brand"]:focus-visible { + outline: 2px solid var(--stats-accent); + outline-offset: 2px; +} + +[data-page="stats"] [data-slot="mobile-menu"] { + position: fixed; + top: 72px; + right: 0; + bottom: 0; + left: 0; + z-index: 9; + display: none; + overflow: auto; + background: var(--stats-bg); + border-top: 1px solid var(--stats-line); +} + +[data-page="stats"] [data-menu-open="true"] [data-slot="mobile-menu"]:not([hidden]) { + display: block; +} + +[data-page="stats"] [data-slot="mobile-menu-item"] { + display: flex; + align-items: center; + gap: 12px; + min-height: 65px; + padding: 24px; + color: var(--stats-text); + border-bottom: 1px solid var(--stats-line); + font-size: 13px; + line-height: 16px; + white-space: nowrap; +} + +[data-page="stats"] [data-slot="mobile-menu-item"]:hover { + color: var(--stats-text); + background: var(--stats-layer); +} + +[data-page="stats"] [data-slot="mobile-menu-item"] strong { + font-weight: 400; +} + +[data-page="stats"] [data-slot="mobile-menu-item"] span { + min-width: 0; + overflow: hidden; + color: var(--stats-muted); + font-variant-numeric: tabular-nums; + text-overflow: ellipsis; } [data-page="stats"] [data-component="footer"] { @@ -1168,12 +1342,27 @@ 0 2px 4px #0000003d; } - [data-page="stats"] [data-slot="logo light"] { - display: none; + [data-page="stats"] [data-slot="header-button"][data-variant="neutral"], + [data-page="stats"] [data-slot="menu-button"] { + color: #fafafa; + background: #ffffff0f; + box-shadow: + 0 -0.5px 0 0 #ffffff33, + 0 0 0 0.5px #ffffff33, + 0 1px 2px 0 #00000066; } - [data-page="stats"] [data-slot="logo dark"] { - display: block; + [data-page="stats"] [data-slot="header-button"][data-variant="neutral"] span { + color: #aeaeae; + } + + [data-page="stats"] [data-slot="header-button"][data-variant="contrast"] { + color: #ffffff; + background: #5c5c5c; + box-shadow: + 0 -0.5px 0 0 #ffffff4d, + 0 0 0 0.5px #ffffff66, + 0 1px 2px 0 #00000066; } } @@ -1183,21 +1372,46 @@ } } +@media (min-width: 48rem) { + [data-page="stats"] [data-slot="header-button"][data-variant="neutral"] { + display: inline-flex; + } +} + +@media (min-width: 75rem) { + [data-page="stats"] [data-slot="header-bar"] { + gap: 32px; + } + + [data-page="stats"] [data-slot="brand"], + [data-page="stats"] [data-component="section-nav"], + [data-page="stats"] [data-slot="header-actions"] { + flex: 1 1 0; + } + + [data-page="stats"] [data-slot="brand"] { + margin-right: 0; + } + + [data-page="stats"] [data-component="section-nav"] { + display: flex; + } + + [data-page="stats"] [data-slot="menu-button"] { + display: none; + } + + [data-page="stats"] [data-slot="mobile-menu"] { + display: none !important; + } +} + @media (max-width: 58rem) { [data-page="stats"] { --stats-page-padding: 24px; --stats-section-padding: 4rem; } - [data-page="stats"] [data-component="top"] { - padding-left: 24px; - padding-right: 24px; - } - - [data-page="stats"] [data-component="nav-desktop"] ul { - gap: 18px; - } - [data-page="stats"] [data-section="hero"], [data-page="stats"] [data-section="chart"], [data-page="stats"] [data-section="newsletter"] { @@ -1301,10 +1515,6 @@ } @media (max-width: 40rem) { - [data-page="stats"] [data-component="nav-desktop"] li:not(:last-child) { - display: none; - } - [data-page="stats"] [data-component="footer"], [data-page="stats"] [data-component="legal"] { flex-wrap: wrap; diff --git a/packages/stats/app/src/routes/index.tsx b/packages/stats/app/src/routes/index.tsx index 01c02d4717..0492a23b6a 100644 --- a/packages/stats/app/src/routes/index.tsx +++ b/packages/stats/app/src/routes/index.tsx @@ -14,14 +14,19 @@ import { import { runtime } from "@opencode-ai/stats-core/runtime" import { createAsync, query } from "@solidjs/router" import { scaleBand, scaleLinear } from "d3-scale" -import { createMemo, createSignal, For, Show, type JSX } from "solid-js" +import { createEffect, createMemo, createSignal, For, onCleanup, Show, type JSX } from "solid-js" import { getRequestEvent } from "solid-js/web" -import logoDark from "../asset/logo-ornate-dark.svg" -import logoLight from "../asset/logo-ornate-light.svg" const products = ["All Users", "Zen", "Go", "Enterprise"] as const const tokenProducts = ["Zen", "Go", "Enterprise"] as const const ranges = ["1D", "1W", "1M", "3M", "YTD", "ALL"] as const +const headerLinks = [ + { href: "#top-models", label: "Top Models" }, + { href: "#leaderboard", label: "Leaderboard" }, + { href: "#market-share", label: "Market Share" }, + { href: "#token-cost", label: "Token Cost" }, + { href: "#session-cost", label: "Session Cost" }, +] as const const usageColors = ["#ff5d64", "#ff8a00", "#8bef00", "#12c8b3", "#18c7dc", "#6c7dff", "#9d73f7"] const marketColors = ["#ed6aff", "#a684ff", "#7c86ff", "#51a2ff", "#00d3f2", "#00d5be", "#00bc7d", "#9ae600", "#ffb900"] const countryPositions = [ @@ -115,9 +120,9 @@ function StatsLoading() { ) } -function ChartSection(props: { title: string; description?: string; controls?: JSX.Element; children: JSX.Element }) { +function ChartSection(props: { id?: string; title: string; description?: string; controls?: JSX.Element; children: JSX.Element }) { return ( -
+

{props.title}

@@ -158,7 +163,7 @@ function UsageSection(props: { data: StatsHomeData["usage"] }) { const data = createMemo(() => props.data[product()][range()]) return ( - + usageTotal(item) > 0)} fallback={} @@ -394,6 +399,7 @@ function LeaderboardSection(props: { data: StatsHomeData["leaderboard"] }) { return ( @@ -476,7 +482,7 @@ function MarketShareSection(props: { data: StatsHomeData["market"] }) { const activeDay = createMemo(() => data()[selectedIndex()]) return ( - + } @@ -572,7 +578,7 @@ function TokenCostSection(props: { data: StatsHomeData["tokenCost"] }) { const selectedIndex = createMemo(() => Math.min(activeIndex(), Math.max(data().length - 1, 0))) return ( - + 0} fallback={ @@ -661,7 +667,7 @@ function SessionCostSection(props: { data: StatsHomeData["sessionCost"] }) { const selectedIndex = createMemo(() => Math.min(activeIndex(), Math.max(data().length - 1, 0))) return ( - + 0} fallback={ @@ -886,47 +892,122 @@ function Newsletter() { } function Header() { + const [menuOpen, setMenuOpen] = createSignal(false) + const [menuViewport, setMenuViewport] = createSignal(false) + + createEffect(() => { + if (typeof window === "undefined") return + const media = window.matchMedia("(max-width: 74.999rem)") + const update = () => setMenuViewport(media.matches) + update() + media.addEventListener("change", update) + onCleanup(() => media.removeEventListener("change", update)) + }) + + createEffect(() => { + if (!menuOpen()) return + if (!menuViewport()) return + if (typeof document === "undefined") return + const page = document.querySelector('[data-page="stats"]') + const scrollbarWidth = window.innerWidth - document.documentElement.clientWidth + const htmlOverflow = document.documentElement.style.overflow + const pagePaddingRight = page?.style.paddingRight + const bodyOverflow = document.body.style.overflow + document.documentElement.style.overflow = "hidden" + if (scrollbarWidth > 0 && page) page.style.paddingRight = `${scrollbarWidth}px` + document.body.style.overflow = "hidden" + onCleanup(() => { + document.documentElement.style.overflow = htmlOverflow + if (page && pagePaddingRight !== undefined) page.style.paddingRight = pagePaddingRight + document.body.style.overflow = bodyOverflow + }) + }) + return ( -
- - OpenCode - OpenCode - - -
+ + ) +} + +function StatsWordmark() { + return ( + ) } From 0a72298062c43e3baa4085faa6f77626688ad23c Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Thu, 28 May 2026 12:10:27 -0500 Subject: [PATCH 037/113] feat(stats): better hero --- bun.lock | 1 + packages/stats/app/package.json | 1 + packages/stats/app/src/routes/index.css | 168 +++++++++++++++++++++--- packages/stats/app/src/routes/index.tsx | 30 +++-- 4 files changed, 168 insertions(+), 32 deletions(-) diff --git a/bun.lock b/bun.lock index ec1240b64e..f3e6f36c09 100644 --- a/bun.lock +++ b/bun.lock @@ -629,6 +629,7 @@ "name": "@opencode-ai/stats-app", "version": "1.15.11", "dependencies": { + "@ibm/plex": "6.4.1", "@opencode-ai/stats-core": "workspace:*", "@opencode-ai/ui": "workspace:*", "@solidjs/meta": "catalog:", diff --git a/packages/stats/app/package.json b/packages/stats/app/package.json index 4e0ee95692..a0cd862ba5 100644 --- a/packages/stats/app/package.json +++ b/packages/stats/app/package.json @@ -12,6 +12,7 @@ "start": "vite start" }, "dependencies": { + "@ibm/plex": "6.4.1", "@opencode-ai/stats-core": "workspace:*", "@opencode-ai/ui": "workspace:*", "@solidjs/meta": "catalog:", diff --git a/packages/stats/app/src/routes/index.css b/packages/stats/app/src/routes/index.css index 36179ebf2b..db6439a304 100644 --- a/packages/stats/app/src/routes/index.css +++ b/packages/stats/app/src/routes/index.css @@ -1,3 +1,47 @@ +@font-face { + font-family: "IBM Plex Mono"; + font-style: normal; + font-weight: 400; + font-display: swap; + src: url("@ibm/plex/IBM-Plex-Mono/fonts/split/woff2/IBMPlexMono-Regular-Latin1.woff2") format("woff2"); + unicode-range: U+0020-007E, U+00A0-00FF, U+0131, U+0152-0153, U+02C6, U+02DA, U+02DC, U+2013-2014, + U+2018-201A, U+201C-201E, U+2020-2022, U+2026, U+2030, U+2039-203A, U+2044, U+20AC, U+2122, + U+2212, U+FB01-FB02; +} + +@font-face { + font-family: "IBM Plex Mono"; + font-style: normal; + font-weight: 500; + font-display: swap; + src: url("@ibm/plex/IBM-Plex-Mono/fonts/split/woff2/IBMPlexMono-Medium-Latin1.woff2") format("woff2"); + unicode-range: U+0020-007E, U+00A0-00FF, U+0131, U+0152-0153, U+02C6, U+02DA, U+02DC, U+2013-2014, + U+2018-201A, U+201C-201E, U+2020-2022, U+2026, U+2030, U+2039-203A, U+2044, U+20AC, U+2122, + U+2212, U+FB01-FB02; +} + +@font-face { + font-family: "IBM Plex Mono"; + font-style: normal; + font-weight: 600; + font-display: swap; + src: url("@ibm/plex/IBM-Plex-Mono/fonts/split/woff2/IBMPlexMono-SemiBold-Latin1.woff2") format("woff2"); + unicode-range: U+0020-007E, U+00A0-00FF, U+0131, U+0152-0153, U+02C6, U+02DA, U+02DC, U+2013-2014, + U+2018-201A, U+201C-201E, U+2020-2022, U+2026, U+2030, U+2039-203A, U+2044, U+20AC, U+2122, + U+2212, U+FB01-FB02; +} + +@font-face { + font-family: "IBM Plex Mono"; + font-style: normal; + font-weight: 700; + font-display: swap; + src: url("@ibm/plex/IBM-Plex-Mono/fonts/split/woff2/IBMPlexMono-Bold-Latin1.woff2") format("woff2"); + unicode-range: U+0020-007E, U+00A0-00FF, U+0131, U+0152-0153, U+02C6, U+02DA, U+02DC, U+2013-2014, + U+2018-201A, U+201C-201E, U+2020-2022, U+2026, U+2030, U+2039-203A, U+2044, U+20AC, U+2122, + U+2212, U+FB01-FB02; +} + [data-page="stats"] { --color-background: #ffffff; --color-background-weak: #fafafa; @@ -21,12 +65,15 @@ --stats-accent-text: #6c7dff; --stats-bar-idle: #d4d4d4; --stats-dot: #d4d4d4; + --stats-hero-muted: #5c5c5c; + --stats-hero-pattern: #eeeeee; --stats-page-padding: 5rem; --stats-section-padding: 6rem; min-height: 100vh; display: flex; flex-direction: column; gap: 4rem; + font-synthesis: none; overflow-x: clip; padding-bottom: 5rem; background: var(--stats-bg); @@ -42,7 +89,7 @@ [data-page="stats"] [data-component="container"] { box-sizing: border-box; width: 100%; - max-width: 67.5rem; + max-width: 80rem; margin: 0 auto; border-left: 1px solid var(--stats-line); border-right: 1px solid var(--stats-line); @@ -366,11 +413,16 @@ } [data-page="stats"] [data-section="hero"] { - min-height: 270px; - display: grid; - grid-template-columns: 1fr 1fr; - gap: 64px; - align-items: start; + box-sizing: border-box; + display: flex; + flex-direction: column; + align-items: flex-start; + justify-content: flex-end; + gap: 24px; + min-height: 0; + overflow: hidden; + padding: 128px 24px 48px; + border-bottom: 0; } [data-page="stats"] h1, @@ -384,6 +436,7 @@ line-height: 1; letter-spacing: normal; font-weight: 600; + max-width: none; } [data-page="stats"] h2 { @@ -393,29 +446,55 @@ font-weight: 500; } -[data-page="stats"] [data-section="hero"] > p, [data-page="stats"] [data-slot="section-header"] p { color: var(--stats-muted); font-size: 16px; line-height: 1.5; } -[data-page="stats"] [data-section="hero"] > div { +[data-page="stats"] [data-slot="hero-canvas"] { display: flex; flex-direction: column; - gap: 16px; + gap: 24px; + width: 100%; min-width: 0; } -[data-page="stats"] [data-slot="meta"] { +[data-page="stats"] [data-section="hero"] h1 { + color: var(--stats-text); + font-size: 64px; + font-weight: 500; + line-height: 1; + letter-spacing: 0; +} + +[data-page="stats"] [data-slot="hero-copy"] { + color: var(--stats-hero-muted); + font-size: 16px; + font-weight: 400; + line-height: 1.5; +} + +[data-page="stats"] [data-slot="hero-pattern"] { + flex: 0 0 auto; + width: 100%; + height: 16px; + overflow: hidden; + background-image: radial-gradient(circle, var(--stats-hero-pattern) 1px, transparent 1.1px); + background-position: 0 0; + background-size: 6px 6px; +} + +[data-page="stats"] [data-slot="hero-meta"] { display: flex; align-items: center; gap: 4px; width: fit-content; + max-width: 100%; height: 24px; padding: 0 8px 0 4px; background: var(--stats-layer-2); - color: var(--stats-faint); + color: var(--stats-hero-muted); font-size: 13px; font-weight: 600; line-height: 1.1; @@ -423,22 +502,69 @@ white-space: nowrap; } -[data-page="stats"] [data-slot="meta"] svg { +[data-page="stats"] [data-slot="hero-meta"] svg { width: 16px; height: 16px; - color: var(--stats-faint); flex: 0 0 auto; } -[data-page="stats"] [data-slot="meta"] span { - color: var(--stats-muted); +[data-page="stats"] [data-slot="hero-meta"] span { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; } -[data-page="stats"] [data-slot="meta"] b, -[data-page="stats"] [data-slot="meta"] em { - color: var(--stats-faint); - font-style: normal; - font-weight: 600; +@media (min-width: 58rem) { + [data-page="stats"] [data-section="hero"] { + gap: 16px; + padding: 128px 40px 24px; + } + + [data-page="stats"] [data-slot="hero-canvas"] { + position: relative; + display: block; + height: 232px; + overflow: hidden; + } + + [data-page="stats"] [data-slot="hero-pattern"] { + position: absolute; + top: 50%; + left: 50%; + width: 1280px; + height: 351px; + transform: translate(-50%, -50%); + } + + [data-page="stats"] [data-section="hero"] h1 { + position: absolute; + top: 0; + left: 0; + z-index: 1; + width: max-content; + max-width: 100%; + padding: 0 12px 12px 0; + background: var(--stats-bg); + white-space: nowrap; + } + + [data-page="stats"] [data-slot="hero-copy"] { + position: absolute; + right: 0; + bottom: 0; + z-index: 1; + width: min(36rem, 100%); + padding: 12px 0 0 16px; + background: var(--stats-bg); + text-align: right; + } +} + +@media (min-width: 90rem) { + [data-page="stats"] [data-section="hero"] { + padding-right: 0; + padding-left: 0; + } } [data-page="stats"] [data-section="chart"] { @@ -1328,6 +1454,8 @@ --stats-faint: #808080; --stats-bar-idle: #303030; --stats-dot: #303030; + --stats-hero-muted: #808080; + --stats-hero-pattern: #303030; } [data-page="stats"] [data-component="chart-tooltip"], diff --git a/packages/stats/app/src/routes/index.tsx b/packages/stats/app/src/routes/index.tsx index 0492a23b6a..b15910e6d4 100644 --- a/packages/stats/app/src/routes/index.tsx +++ b/packages/stats/app/src/routes/index.tsx @@ -1,6 +1,8 @@ import "./index.css" -import { Meta, Title } from "@solidjs/meta" +import { Link, Meta, Title } from "@solidjs/meta" import { ProviderIcon } from "@opencode-ai/ui/provider-icon" +import ibmPlexMonoMediumLatin1 from "@ibm/plex/IBM-Plex-Mono/fonts/split/woff2/IBMPlexMono-Medium-Latin1.woff2?url" +import ibmPlexMonoRegularLatin1 from "@ibm/plex/IBM-Plex-Mono/fonts/split/woff2/IBMPlexMono-Regular-Latin1.woff2?url" import { type CountryEntry, getStatsHomeData, @@ -64,6 +66,8 @@ export default function StatsHome() {
OpenCode Stats + +
@@ -92,19 +96,21 @@ export default function StatsHome() { function Hero(props: { updatedAt: string | null }) { return (
-
-

OpenCode Stats

-

- - OpenCode data ·{" "} - {props.updatedAt ? `Updated ${formatUpdatedAt(props.updatedAt)}` : "No rows yet"} +

+ + {props.updatedAt ? `Updated ${formatUpdatedAt(props.updatedAt)}` : "No rows yet"} +

+
+ -

See how model usage, provider share, cost, and geography move across OpenCode traffic.

) } From fff7781fa825c056c5cb2734aa15f5ce0420aa4f Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Thu, 28 May 2026 12:26:33 -0500 Subject: [PATCH 038/113] fix(stats): preload IBM Plex Mono weights --- packages/stats/app/src/routes/index.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/stats/app/src/routes/index.tsx b/packages/stats/app/src/routes/index.tsx index b15910e6d4..b5953c9ebd 100644 --- a/packages/stats/app/src/routes/index.tsx +++ b/packages/stats/app/src/routes/index.tsx @@ -1,8 +1,10 @@ import "./index.css" import { Link, Meta, Title } from "@solidjs/meta" import { ProviderIcon } from "@opencode-ai/ui/provider-icon" -import ibmPlexMonoMediumLatin1 from "@ibm/plex/IBM-Plex-Mono/fonts/split/woff2/IBMPlexMono-Medium-Latin1.woff2?url" import ibmPlexMonoRegularLatin1 from "@ibm/plex/IBM-Plex-Mono/fonts/split/woff2/IBMPlexMono-Regular-Latin1.woff2?url" +import ibmPlexMonoMediumLatin1 from "@ibm/plex/IBM-Plex-Mono/fonts/split/woff2/IBMPlexMono-Medium-Latin1.woff2?url" +import ibmPlexMonoSemiBoldLatin1 from "@ibm/plex/IBM-Plex-Mono/fonts/split/woff2/IBMPlexMono-SemiBold-Latin1.woff2?url" +import ibmPlexMonoBoldLatin1 from "@ibm/plex/IBM-Plex-Mono/fonts/split/woff2/IBMPlexMono-Bold-Latin1.woff2?url" import { type CountryEntry, getStatsHomeData, @@ -68,6 +70,8 @@ export default function StatsHome() { + +
From 3ce9b4be0d357fae262bae2889384a8e7fe04341 Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Thu, 28 May 2026 12:43:04 -0500 Subject: [PATCH 039/113] fix(stats): refine hero layout for responsive Figma match --- packages/stats/app/src/routes/index.css | 41 ++++++++++++++++++------- packages/stats/app/src/routes/index.tsx | 24 ++++++++++----- 2 files changed, 46 insertions(+), 19 deletions(-) diff --git a/packages/stats/app/src/routes/index.css b/packages/stats/app/src/routes/index.css index db6439a304..9ae5c5d160 100644 --- a/packages/stats/app/src/routes/index.css +++ b/packages/stats/app/src/routes/index.css @@ -89,7 +89,7 @@ [data-page="stats"] [data-component="container"] { box-sizing: border-box; width: 100%; - max-width: 80rem; + max-width: calc(80rem + 2px); margin: 0 auto; border-left: 1px solid var(--stats-line); border-right: 1px solid var(--stats-line); @@ -461,6 +461,7 @@ } [data-page="stats"] [data-section="hero"] h1 { + order: 1; color: var(--stats-text); font-size: 64px; font-weight: 500; @@ -469,20 +470,30 @@ } [data-page="stats"] [data-slot="hero-copy"] { + order: 3; color: var(--stats-hero-muted); font-size: 16px; font-weight: 400; line-height: 1.5; } +[data-page="stats"] [data-slot="hero-copy-break"] { + display: none; +} + [data-page="stats"] [data-slot="hero-pattern"] { + order: 2; flex: 0 0 auto; width: 100%; height: 16px; overflow: hidden; - background-image: radial-gradient(circle, var(--stats-hero-pattern) 1px, transparent 1.1px); - background-position: 0 0; - background-size: 6px 6px; + background: var(--stats-hero-pattern); + mask-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 6 6' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M0 0H2V2H0V0Z' fill='black'/%3E%3C/svg%3E"); + mask-repeat: repeat; + mask-size: 6px 6px; + -webkit-mask-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 6 6' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M0 0H2V2H0V0Z' fill='black'/%3E%3C/svg%3E"); + -webkit-mask-repeat: repeat; + -webkit-mask-size: 6px 6px; } [data-page="stats"] [data-slot="hero-meta"] { @@ -496,7 +507,7 @@ background: var(--stats-layer-2); color: var(--stats-hero-muted); font-size: 13px; - font-weight: 600; + font-weight: 500; line-height: 1.1; overflow: hidden; white-space: nowrap; @@ -514,10 +525,10 @@ text-overflow: ellipsis; } -@media (min-width: 58rem) { +@media (min-width: 48rem) { [data-page="stats"] [data-section="hero"] { gap: 16px; - padding: 128px 40px 24px; + padding: 128px 32px 32px; } [data-page="stats"] [data-slot="hero-canvas"] { @@ -553,11 +564,21 @@ right: 0; bottom: 0; z-index: 1; - width: min(36rem, 100%); + width: min(563px, 100%); padding: 12px 0 0 16px; background: var(--stats-bg); text-align: right; } + + [data-page="stats"] [data-slot="hero-copy-break"] { + display: block; + } +} + +@media (min-width: 75rem) { + [data-page="stats"] [data-section="hero"] { + padding: 128px 40px 24px; + } } @media (min-width: 90rem) { @@ -1494,7 +1515,7 @@ } } -@media (max-width: 74rem) { +@media (max-width: 80rem) { [data-page="stats"] [data-component="container"] { border: 0; } @@ -1540,14 +1561,12 @@ --stats-section-padding: 4rem; } - [data-page="stats"] [data-section="hero"], [data-page="stats"] [data-section="chart"], [data-page="stats"] [data-section="newsletter"] { padding-left: 24px; padding-right: 24px; } - [data-page="stats"] [data-section="hero"], [data-page="stats"] [data-section="newsletter"] { grid-template-columns: 1fr; } diff --git a/packages/stats/app/src/routes/index.tsx b/packages/stats/app/src/routes/index.tsx index b5953c9ebd..cc005d44d0 100644 --- a/packages/stats/app/src/routes/index.tsx +++ b/packages/stats/app/src/routes/index.tsx @@ -18,7 +18,7 @@ import { import { runtime } from "@opencode-ai/stats-core/runtime" import { createAsync, query } from "@solidjs/router" import { scaleBand, scaleLinear } from "d3-scale" -import { createEffect, createMemo, createSignal, For, onCleanup, Show, type JSX } from "solid-js" +import { createEffect, createMemo, createSignal, For, onCleanup, onMount, Show, type JSX } from "solid-js" import { getRequestEvent } from "solid-js/web" const products = ["All Users", "Zen", "Go", "Enterprise"] as const @@ -98,21 +98,29 @@ export default function StatsHome() { } function Hero(props: { updatedAt: string | null }) { + const [timeZone, setTimeZone] = createSignal("UTC") + onMount(() => setTimeZone(Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC")) + return (

- {props.updatedAt ? `Updated ${formatUpdatedAt(props.updatedAt)}` : "No rows yet"} + {props.updatedAt ? `Updated ${formatUpdatedAt(props.updatedAt, timeZone())}` : "No rows yet"}

@@ -154,7 +162,7 @@ function EmptyState(props: { title: string; description: string }) { ) } -function formatUpdatedAt(value: string) { +function formatUpdatedAt(value: string, timeZone: string) { const date = new Date(value) if (Number.isNaN(date.getTime())) return "just now" return new Intl.DateTimeFormat("en", { @@ -162,7 +170,7 @@ function formatUpdatedAt(value: string) { day: "numeric", hour: "numeric", minute: "2-digit", - timeZone: "UTC", + timeZone, timeZoneName: "short", }).format(date) } From cc230feca1c96db87d100d5ee09022dab18d00f6 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Thu, 28 May 2026 17:45:17 +0000 Subject: [PATCH 040/113] chore: generate --- packages/stats/app/src/routes/index.css | 24 ++++++------ packages/stats/app/src/routes/index.tsx | 52 +++++++++++++++++-------- 2 files changed, 48 insertions(+), 28 deletions(-) diff --git a/packages/stats/app/src/routes/index.css b/packages/stats/app/src/routes/index.css index 9ae5c5d160..9bf62f6e99 100644 --- a/packages/stats/app/src/routes/index.css +++ b/packages/stats/app/src/routes/index.css @@ -4,9 +4,9 @@ font-weight: 400; font-display: swap; src: url("@ibm/plex/IBM-Plex-Mono/fonts/split/woff2/IBMPlexMono-Regular-Latin1.woff2") format("woff2"); - unicode-range: U+0020-007E, U+00A0-00FF, U+0131, U+0152-0153, U+02C6, U+02DA, U+02DC, U+2013-2014, - U+2018-201A, U+201C-201E, U+2020-2022, U+2026, U+2030, U+2039-203A, U+2044, U+20AC, U+2122, - U+2212, U+FB01-FB02; + unicode-range: + U+0020-007E, U+00A0-00FF, U+0131, U+0152-0153, U+02C6, U+02DA, U+02DC, U+2013-2014, U+2018-201A, U+201C-201E, + U+2020-2022, U+2026, U+2030, U+2039-203A, U+2044, U+20AC, U+2122, U+2212, U+FB01-FB02; } @font-face { @@ -15,9 +15,9 @@ font-weight: 500; font-display: swap; src: url("@ibm/plex/IBM-Plex-Mono/fonts/split/woff2/IBMPlexMono-Medium-Latin1.woff2") format("woff2"); - unicode-range: U+0020-007E, U+00A0-00FF, U+0131, U+0152-0153, U+02C6, U+02DA, U+02DC, U+2013-2014, - U+2018-201A, U+201C-201E, U+2020-2022, U+2026, U+2030, U+2039-203A, U+2044, U+20AC, U+2122, - U+2212, U+FB01-FB02; + unicode-range: + U+0020-007E, U+00A0-00FF, U+0131, U+0152-0153, U+02C6, U+02DA, U+02DC, U+2013-2014, U+2018-201A, U+201C-201E, + U+2020-2022, U+2026, U+2030, U+2039-203A, U+2044, U+20AC, U+2122, U+2212, U+FB01-FB02; } @font-face { @@ -26,9 +26,9 @@ font-weight: 600; font-display: swap; src: url("@ibm/plex/IBM-Plex-Mono/fonts/split/woff2/IBMPlexMono-SemiBold-Latin1.woff2") format("woff2"); - unicode-range: U+0020-007E, U+00A0-00FF, U+0131, U+0152-0153, U+02C6, U+02DA, U+02DC, U+2013-2014, - U+2018-201A, U+201C-201E, U+2020-2022, U+2026, U+2030, U+2039-203A, U+2044, U+20AC, U+2122, - U+2212, U+FB01-FB02; + unicode-range: + U+0020-007E, U+00A0-00FF, U+0131, U+0152-0153, U+02C6, U+02DA, U+02DC, U+2013-2014, U+2018-201A, U+201C-201E, + U+2020-2022, U+2026, U+2030, U+2039-203A, U+2044, U+20AC, U+2122, U+2212, U+FB01-FB02; } @font-face { @@ -37,9 +37,9 @@ font-weight: 700; font-display: swap; src: url("@ibm/plex/IBM-Plex-Mono/fonts/split/woff2/IBMPlexMono-Bold-Latin1.woff2") format("woff2"); - unicode-range: U+0020-007E, U+00A0-00FF, U+0131, U+0152-0153, U+02C6, U+02DA, U+02DC, U+2013-2014, - U+2018-201A, U+201C-201E, U+2020-2022, U+2026, U+2030, U+2039-203A, U+2044, U+20AC, U+2122, - U+2212, U+FB01-FB02; + unicode-range: + U+0020-007E, U+00A0-00FF, U+0131, U+0152-0153, U+02C6, U+02DA, U+02DC, U+2013-2014, U+2018-201A, U+201C-201E, + U+2020-2022, U+2026, U+2030, U+2039-203A, U+2044, U+20AC, U+2122, U+2212, U+FB01-FB02; } [data-page="stats"] { diff --git a/packages/stats/app/src/routes/index.tsx b/packages/stats/app/src/routes/index.tsx index cc005d44d0..a3c5a75883 100644 --- a/packages/stats/app/src/routes/index.tsx +++ b/packages/stats/app/src/routes/index.tsx @@ -118,8 +118,7 @@ function Hero(props: { updatedAt: string | null }) { @@ -138,7 +137,13 @@ function StatsLoading() { ) } -function ChartSection(props: { id?: string; title: string; description?: string; controls?: JSX.Element; children: JSX.Element }) { +function ChartSection(props: { + id?: string + title: string + description?: string + controls?: JSX.Element + children: JSX.Element +}) { return (
@@ -978,14 +983,14 @@ function Header() { aria-controls="stats-mobile-nav" aria-expanded={menuOpen() ? "true" : "false"} aria-label={menuOpen() ? "Close navigation" : "Open navigation"} - onClick={() => setMenuOpen((value) => !value)} - > - - + onClick={() => setMenuOpen((value) => !value)} + > + +
) } @@ -244,6 +226,25 @@ function ChartSection(props: { ) } +function SectionTitle(props: { title: string; description: string }) { + return ( +

+ {props.title}. {props.description} +

+ ) +} + +function SectionBridge(props: { label: string; href: string }) { + return ( + + LEAN MORE + + {props.label} + + + ) +} + function EmptyState(props: { title: string; description: string }) { return (
@@ -678,10 +679,10 @@ function LeaderboardSection(props: { data: StatsHomeData["leaderboard"] }) { return (
-

- Leaderboard.{" "} - Shown are the sum of prompt and completion tokens per model, including reasoning tokens. -

+ 0} fallback={ @@ -771,44 +772,99 @@ function formatChange(value: number) { function MarketShareSection(props: { data: StatsHomeData["market"] }) { const [range, setRange] = createSignal("1W") const [activeIndex, setActiveIndex] = createSignal(2) + const [activeAuthor, setActiveAuthor] = createSignal() + const [inspecting, setInspecting] = createSignal(false) const data = createMemo(() => props.data[range()]) const selectedIndex = createMemo(() => Math.min(activeIndex(), Math.max(data().length - 1, 0))) const activeDay = createMemo(() => data()[selectedIndex()]) return ( - +
{ + if (event.pointerType === "touch") return + setActiveAuthor(undefined) + setInspecting(false) + }} + > + + } > {(day) => ( <> - - + { + setActiveIndex(index) + setInspecting(true) + }} + onActiveAuthorChange={(author) => { + setActiveAuthor(author) + setInspecting(true) + }} + /> + { + setActiveAuthor(author) + setInspecting(true) + }} + /> )}

[*] - {activeDay()?.date ?? "No data"} + {inspecting() ? formatMarketDate(activeDay()) : formatMarketRange(data())}

- + { + setRange(item) + setActiveAuthor(undefined) + setInspecting(false) + }} + />
- +
) } -function MarketShare(props: { data: MarketDay[]; activeIndex: number; onActiveIndexChange: (index: number) => void }) { +function MarketShare(props: { + data: MarketDay[] + activeIndex: number + activeAuthor: string | undefined + inspecting: boolean + onActiveIndexChange: (index: number) => void + onActiveAuthorChange: (author: string) => void +}) { return ( -
+
{(day, index) => ( - - )} - - - ) -} - -function formatCountry(country: string) { - const known: Record = { - AU: "Australia", - BR: "Brazil", - CA: "Canada", - CN: "China", - DE: "Germany", - FR: "France", - GB: "United Kingdom", - IN: "India", - JP: "Japan", - KR: "South Korea", - NL: "Netherlands", - SG: "Singapore", - US: "United States", - ZZ: "Unknown", - } - return known[country] ?? country -} - -function Newsletter() { - return ( -
-
-

Be the first to know when we release new products

-

Join the waitlist for early access.

-
- - - - -
- ) -} - function Header() { const [menuOpen, setMenuOpen] = createSignal(false) const [menuViewport, setMenuViewport] = createSignal(false) @@ -1290,10 +1282,7 @@ function Header() { function StatsWordmark() { return (