mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-06 09:10:47 -04:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ca357ee2a0 |
@@ -29,7 +29,7 @@
|
||||
},
|
||||
"packages/app": {
|
||||
"name": "@opencode-ai/app",
|
||||
"version": "1.18.14",
|
||||
"version": "1.18.13",
|
||||
"dependencies": {
|
||||
"@corvu/drawer": "catalog:",
|
||||
"@dnd-kit/abstract": "0.5.0",
|
||||
@@ -96,7 +96,7 @@
|
||||
},
|
||||
"packages/cli": {
|
||||
"name": "@opencode-ai/cli",
|
||||
"version": "1.18.14",
|
||||
"version": "1.18.13",
|
||||
"bin": {
|
||||
"lildax": "./bin/lildax.cjs",
|
||||
},
|
||||
@@ -144,7 +144,7 @@
|
||||
},
|
||||
"packages/codemode": {
|
||||
"name": "@opencode-ai/codemode",
|
||||
"version": "1.18.14",
|
||||
"version": "1.18.13",
|
||||
"dependencies": {
|
||||
"acorn": "8.15.0",
|
||||
"effect": "catalog:",
|
||||
@@ -158,7 +158,7 @@
|
||||
},
|
||||
"packages/console/app": {
|
||||
"name": "@opencode-ai/console-app",
|
||||
"version": "1.18.14",
|
||||
"version": "1.18.13",
|
||||
"dependencies": {
|
||||
"@cloudflare/vite-plugin": "1.15.2",
|
||||
"@ibm/plex": "6.4.1",
|
||||
@@ -194,7 +194,7 @@
|
||||
},
|
||||
"packages/console/core": {
|
||||
"name": "@opencode-ai/console-core",
|
||||
"version": "1.18.14",
|
||||
"version": "1.18.13",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-sts": "3.782.0",
|
||||
"@jsx-email/render": "1.1.1",
|
||||
@@ -221,7 +221,7 @@
|
||||
},
|
||||
"packages/console/function": {
|
||||
"name": "@opencode-ai/console-function",
|
||||
"version": "1.18.14",
|
||||
"version": "1.18.13",
|
||||
"dependencies": {
|
||||
"@ai-sdk/anthropic": "3.0.82",
|
||||
"@ai-sdk/openai": "3.0.48",
|
||||
@@ -243,7 +243,7 @@
|
||||
},
|
||||
"packages/console/mail": {
|
||||
"name": "@opencode-ai/console-mail",
|
||||
"version": "1.18.14",
|
||||
"version": "1.18.13",
|
||||
"dependencies": {
|
||||
"@jsx-email/all": "2.2.3",
|
||||
"@jsx-email/cli": "1.4.3",
|
||||
@@ -267,7 +267,7 @@
|
||||
},
|
||||
"packages/console/support": {
|
||||
"name": "@opencode-ai/console-support",
|
||||
"version": "1.18.14",
|
||||
"version": "1.18.13",
|
||||
"dependencies": {
|
||||
"@cloudflare/vite-plugin": "1.15.2",
|
||||
"@opencode-ai/console-core": "workspace:*",
|
||||
@@ -287,7 +287,7 @@
|
||||
},
|
||||
"packages/core": {
|
||||
"name": "@opencode-ai/core",
|
||||
"version": "1.18.14",
|
||||
"version": "1.18.13",
|
||||
"bin": {
|
||||
"opencode": "./bin/opencode",
|
||||
},
|
||||
@@ -381,7 +381,7 @@
|
||||
},
|
||||
"packages/desktop": {
|
||||
"name": "@opencode-ai/desktop",
|
||||
"version": "1.18.14",
|
||||
"version": "1.18.13",
|
||||
"dependencies": {
|
||||
"@zip.js/zip.js": "2.7.62",
|
||||
"drizzle-orm": "catalog:",
|
||||
@@ -435,7 +435,7 @@
|
||||
},
|
||||
"packages/effect-drizzle-sqlite": {
|
||||
"name": "@opencode-ai/effect-drizzle-sqlite",
|
||||
"version": "1.18.14",
|
||||
"version": "1.18.13",
|
||||
"dependencies": {
|
||||
"drizzle-orm": "catalog:",
|
||||
"effect": "catalog:",
|
||||
@@ -449,7 +449,7 @@
|
||||
},
|
||||
"packages/effect-sqlite-node": {
|
||||
"name": "@opencode-ai/effect-sqlite-node",
|
||||
"version": "1.18.14",
|
||||
"version": "1.18.13",
|
||||
"dependencies": {
|
||||
"effect": "catalog:",
|
||||
},
|
||||
@@ -461,7 +461,7 @@
|
||||
},
|
||||
"packages/enterprise": {
|
||||
"name": "@opencode-ai/enterprise",
|
||||
"version": "1.18.14",
|
||||
"version": "1.18.13",
|
||||
"dependencies": {
|
||||
"@hono/standard-validator": "catalog:",
|
||||
"@opencode-ai/core": "workspace:*",
|
||||
@@ -493,7 +493,7 @@
|
||||
},
|
||||
"packages/function": {
|
||||
"name": "@opencode-ai/function",
|
||||
"version": "1.18.14",
|
||||
"version": "1.18.13",
|
||||
"dependencies": {
|
||||
"@octokit/auth-app": "8.0.1",
|
||||
"@octokit/rest": "catalog:",
|
||||
@@ -509,7 +509,7 @@
|
||||
},
|
||||
"packages/http-recorder": {
|
||||
"name": "@opencode-ai/http-recorder",
|
||||
"version": "1.18.14",
|
||||
"version": "1.18.13",
|
||||
"dependencies": {
|
||||
"@effect/platform-node": "4.0.0-beta.83",
|
||||
"@effect/platform-node-shared": "4.0.0-beta.83",
|
||||
@@ -540,7 +540,7 @@
|
||||
},
|
||||
"packages/llm": {
|
||||
"name": "@opencode-ai/llm",
|
||||
"version": "1.18.14",
|
||||
"version": "1.18.13",
|
||||
"dependencies": {
|
||||
"@opencode-ai/schema": "workspace:*",
|
||||
"@smithy/eventstream-codec": "4.2.14",
|
||||
@@ -559,7 +559,7 @@
|
||||
},
|
||||
"packages/opencode": {
|
||||
"name": "opencode",
|
||||
"version": "1.18.14",
|
||||
"version": "1.18.13",
|
||||
"bin": {
|
||||
"opencode": "./bin/opencode",
|
||||
},
|
||||
@@ -690,7 +690,7 @@
|
||||
},
|
||||
"packages/plugin": {
|
||||
"name": "@opencode-ai/plugin",
|
||||
"version": "1.18.14",
|
||||
"version": "1.18.13",
|
||||
"dependencies": {
|
||||
"@ai-sdk/provider": "3.0.8",
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
@@ -766,7 +766,7 @@
|
||||
},
|
||||
"packages/sdk/js": {
|
||||
"name": "@opencode-ai/sdk",
|
||||
"version": "1.18.14",
|
||||
"version": "1.18.13",
|
||||
"dependencies": {
|
||||
"cross-spawn": "catalog:",
|
||||
},
|
||||
@@ -781,7 +781,7 @@
|
||||
},
|
||||
"packages/server": {
|
||||
"name": "@opencode-ai/server",
|
||||
"version": "1.18.14",
|
||||
"version": "1.18.13",
|
||||
"dependencies": {
|
||||
"@opencode-ai/core": "workspace:*",
|
||||
"@opencode-ai/protocol": "workspace:*",
|
||||
@@ -796,7 +796,7 @@
|
||||
},
|
||||
"packages/session-ui": {
|
||||
"name": "@opencode-ai/session-ui",
|
||||
"version": "1.18.14",
|
||||
"version": "1.18.13",
|
||||
"dependencies": {
|
||||
"@kobalte/core": "catalog:",
|
||||
"@opencode-ai/client": "file:../app/vendor/opencode-ai-client-1.17.13-v2.tgz",
|
||||
@@ -836,7 +836,7 @@
|
||||
},
|
||||
"packages/slack": {
|
||||
"name": "@opencode-ai/slack",
|
||||
"version": "1.18.14",
|
||||
"version": "1.18.13",
|
||||
"dependencies": {
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"@slack/bolt": "^3.17.1",
|
||||
@@ -849,7 +849,7 @@
|
||||
},
|
||||
"packages/stats/app": {
|
||||
"name": "@opencode-ai/stats-app",
|
||||
"version": "1.18.14",
|
||||
"version": "1.18.13",
|
||||
"dependencies": {
|
||||
"@ibm/plex": "6.4.1",
|
||||
"@kobalte/core": "catalog:",
|
||||
@@ -883,7 +883,7 @@
|
||||
},
|
||||
"packages/stats/core": {
|
||||
"name": "@opencode-ai/stats-core",
|
||||
"version": "1.18.14",
|
||||
"version": "1.18.13",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-athena": "3.933.0",
|
||||
"@planetscale/database": "1.19.0",
|
||||
@@ -902,7 +902,7 @@
|
||||
},
|
||||
"packages/stats/server": {
|
||||
"name": "@opencode-ai/stats-server",
|
||||
"version": "1.18.14",
|
||||
"version": "1.18.13",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-firehose": "3.933.0",
|
||||
"@effect/platform-node": "catalog:",
|
||||
@@ -944,7 +944,7 @@
|
||||
},
|
||||
"packages/tui": {
|
||||
"name": "@opencode-ai/tui",
|
||||
"version": "1.18.14",
|
||||
"version": "1.18.13",
|
||||
"dependencies": {
|
||||
"@opencode-ai/core": "workspace:*",
|
||||
"@opencode-ai/plugin": "workspace:*",
|
||||
@@ -971,7 +971,7 @@
|
||||
},
|
||||
"packages/ui": {
|
||||
"name": "@opencode-ai/ui",
|
||||
"version": "1.18.14",
|
||||
"version": "1.18.13",
|
||||
"dependencies": {
|
||||
"@kobalte/core": "catalog:",
|
||||
"@pierre/diffs": "catalog:",
|
||||
@@ -1022,7 +1022,7 @@
|
||||
},
|
||||
"packages/web": {
|
||||
"name": "@opencode-ai/web",
|
||||
"version": "1.18.14",
|
||||
"version": "1.18.13",
|
||||
"dependencies": {
|
||||
"@astrojs/cloudflare": "12.6.3",
|
||||
"@astrojs/markdown-remark": "6.3.1",
|
||||
@@ -1078,7 +1078,6 @@
|
||||
"@ai-sdk/google@3.0.73": "patches/@ai-sdk%2Fgoogle@3.0.73.patch",
|
||||
"pacote@21.5.0": "patches/pacote@21.5.0.patch",
|
||||
"@dnd-kit/dom@0.5.0": "patches/@dnd-kit%2Fdom@0.5.0.patch",
|
||||
"@ai-sdk/openai-compatible@2.0.41": "patches/@ai-sdk%2Fopenai-compatible@2.0.41.patch",
|
||||
},
|
||||
"overrides": {
|
||||
"@opentui/core": "catalog:",
|
||||
|
||||
+1
-2
@@ -158,7 +158,6 @@
|
||||
"@pierre/trees@1.0.0-beta.4": "patches/@pierre%2Ftrees@1.0.0-beta.4.patch",
|
||||
"@modelcontextprotocol/sdk@1.29.0": "patches/@modelcontextprotocol%2Fsdk@1.29.0.patch",
|
||||
"effect@4.0.0-beta.83": "patches/effect@4.0.0-beta.83.patch",
|
||||
"@tanstack/virtual-core@3.17.3": "patches/@tanstack%2Fvirtual-core@3.17.3.patch",
|
||||
"@ai-sdk/openai-compatible@2.0.41": "patches/@ai-sdk%2Fopenai-compatible@2.0.41.patch"
|
||||
"@tanstack/virtual-core@3.17.3": "patches/@tanstack%2Fvirtual-core@3.17.3.patch"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/app",
|
||||
"version": "1.18.14",
|
||||
"version": "1.18.13",
|
||||
"description": "",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode-ai/cli",
|
||||
"version": "1.18.14",
|
||||
"version": "1.18.13",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode-ai/codemode",
|
||||
"version": "1.18.14",
|
||||
"version": "1.18.13",
|
||||
"description": "Effect-native confined code execution over schema-described tools",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/console-app",
|
||||
"version": "1.18.14",
|
||||
"version": "1.18.13",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode-ai/console-core",
|
||||
"version": "1.18.14",
|
||||
"version": "1.18.13",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/console-function",
|
||||
"version": "1.18.14",
|
||||
"version": "1.18.13",
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/console-mail",
|
||||
"version": "1.18.14",
|
||||
"version": "1.18.13",
|
||||
"dependencies": {
|
||||
"@jsx-email/all": "2.2.3",
|
||||
"@jsx-email/cli": "1.4.3",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/console-support",
|
||||
"version": "1.18.14",
|
||||
"version": "1.18.13",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"version": "1.18.14",
|
||||
"version": "1.18.13",
|
||||
"name": "@opencode-ai/core",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@opencode-ai/desktop",
|
||||
"private": true,
|
||||
"version": "1.18.14",
|
||||
"version": "1.18.13",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"homepage": "https://opencode.ai",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"version": "1.18.14",
|
||||
"version": "1.18.13",
|
||||
"name": "@opencode-ai/effect-drizzle-sqlite",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"version": "1.18.14",
|
||||
"version": "1.18.13",
|
||||
"name": "@opencode-ai/effect-sqlite-node",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/enterprise",
|
||||
"version": "1.18.14",
|
||||
"version": "1.18.13",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/function",
|
||||
"version": "1.18.14",
|
||||
"version": "1.18.13",
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"version": "1.18.14",
|
||||
"version": "1.18.13",
|
||||
"name": "@opencode-ai/http-recorder",
|
||||
"description": "Record and replay Effect HTTP client traffic with deterministic cassettes",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"version": "1.18.14",
|
||||
"version": "1.18.13",
|
||||
"name": "@opencode-ai/llm",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"version": "1.18.14",
|
||||
"version": "1.18.13",
|
||||
"name": "opencode",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { AgentSideConnection } from "@agentclientprotocol/sdk"
|
||||
import type { AgentSideConnection, SessionUpdate } from "@agentclientprotocol/sdk"
|
||||
import type {
|
||||
Event,
|
||||
EventMessagePartDelta,
|
||||
@@ -29,6 +29,13 @@ type GlobalEventEnvelope = {
|
||||
type GlobalEventStream = {
|
||||
stream: AsyncIterable<GlobalEventEnvelope>
|
||||
}
|
||||
type ChildSession = {
|
||||
id: string
|
||||
parentID: string
|
||||
rootID: string
|
||||
depth: number
|
||||
title?: string
|
||||
}
|
||||
|
||||
export function start(input: { sdk: OpencodeClient; connection: Connection; session: ACPSession.Interface }) {
|
||||
const subscription = new Subscription(input)
|
||||
@@ -42,6 +49,7 @@ export class Subscription {
|
||||
private readonly toolStarts = new Set<string>()
|
||||
private readonly connectionWaiters = new Set<() => void>()
|
||||
private readonly idleWaiters = new Map<string, Set<ReturnType<typeof signal>>>()
|
||||
private readonly children = new Map<string, ChildSession>()
|
||||
private readonly permission: ACPPermission.Handler
|
||||
private connected = false
|
||||
private started = false
|
||||
@@ -92,12 +100,30 @@ export class Subscription {
|
||||
|
||||
async handle(event: Event) {
|
||||
switch (event.type) {
|
||||
case "session.created":
|
||||
await this.registerChild(event.properties.info)
|
||||
return
|
||||
case "session.deleted":
|
||||
this.children.delete(event.properties.sessionID)
|
||||
return
|
||||
case "session.status":
|
||||
if (event.properties.status.type === "idle") this.idle(event.properties.sessionID)
|
||||
return
|
||||
case "permission.asked":
|
||||
this.permission.handle(event)
|
||||
case "permission.asked": {
|
||||
const target = await this.resolveSession(event.properties.sessionID)
|
||||
if (!target) return
|
||||
this.permission.handle(event, {
|
||||
sessionId: target.session.id,
|
||||
cwd: target.session.cwd,
|
||||
...(target.child
|
||||
? {
|
||||
toolCallPrefix: target.child.id,
|
||||
titlePrefix: target.child.title,
|
||||
}
|
||||
: {}),
|
||||
})
|
||||
return
|
||||
}
|
||||
case "message.part.updated":
|
||||
return this.handlePartUpdated(event)
|
||||
case "message.part.delta":
|
||||
@@ -112,7 +138,7 @@ export class Subscription {
|
||||
for (const part of message.parts) {
|
||||
await this.recordFetchedPart(message.info.sessionID, message, part)
|
||||
if (part.type === "tool") {
|
||||
await this.handleToolPart(message.info.sessionID, part, cwd ?? process.cwd())
|
||||
await this.handleToolPart(message.info.sessionID, message.info.sessionID, part, cwd ?? process.cwd())
|
||||
continue
|
||||
}
|
||||
await this.replayContentPart(message, part)
|
||||
@@ -190,13 +216,13 @@ export class Subscription {
|
||||
|
||||
private async handlePartUpdated(event: EventMessagePartUpdated) {
|
||||
const part = event.properties.part
|
||||
const sessionId = part.sessionID || event.properties.sessionID
|
||||
const session = await Effect.runPromise(this.input.session.tryGet(sessionId))
|
||||
if (!session) return
|
||||
const sourceSessionId = part.sessionID || event.properties.sessionID
|
||||
const target = await this.resolveSession(sourceSessionId)
|
||||
if (!target) return
|
||||
|
||||
await Effect.runPromise(
|
||||
this.input.session.recordPartMetadata({
|
||||
sessionId: session.id,
|
||||
sessionId: target.session.id,
|
||||
messageId: part.messageID,
|
||||
partId: part.id,
|
||||
partType: part.type,
|
||||
@@ -207,18 +233,18 @@ export class Subscription {
|
||||
}),
|
||||
)
|
||||
if (part.type === "tool") {
|
||||
await this.handleToolPart(session.id, part, session.cwd)
|
||||
await this.handleToolPart(target.session.id, sourceSessionId, part, target.session.cwd, target.child)
|
||||
}
|
||||
}
|
||||
|
||||
private async handlePartDelta(event: EventMessagePartDelta) {
|
||||
const props = event.properties
|
||||
const session = await Effect.runPromise(this.input.session.tryGet(props.sessionID))
|
||||
if (!session) return
|
||||
const target = await this.resolveSession(props.sessionID)
|
||||
if (!target) return
|
||||
|
||||
const known = await Effect.runPromise(
|
||||
this.input.session.tryGetPartMetadata({
|
||||
sessionId: session.id,
|
||||
sessionId: target.session.id,
|
||||
messageId: props.messageID,
|
||||
partId: props.partID,
|
||||
}),
|
||||
@@ -226,12 +252,18 @@ export class Subscription {
|
||||
const metadata =
|
||||
known?.role && known.partType
|
||||
? known
|
||||
: await this.fetchPartMetadata(session.id, session.cwd, props.messageID, props.partID)
|
||||
: await this.fetchPartMetadata(
|
||||
props.sessionID,
|
||||
target.session.id,
|
||||
target.session.cwd,
|
||||
props.messageID,
|
||||
props.partID,
|
||||
)
|
||||
if (metadata?.role !== "assistant") return
|
||||
if (metadata.partType === "text" && props.field === "text" && metadata.ignored !== true) {
|
||||
await this.input.connection.sessionUpdate({
|
||||
sessionId: session.id,
|
||||
update: {
|
||||
await this.update(
|
||||
target.session.id,
|
||||
{
|
||||
sessionUpdate: "agent_message_chunk",
|
||||
messageId: props.messageID,
|
||||
content: {
|
||||
@@ -239,14 +271,15 @@ export class Subscription {
|
||||
text: props.delta,
|
||||
},
|
||||
},
|
||||
})
|
||||
target.child,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
if (metadata.partType === "reasoning" && props.field === "text") {
|
||||
await this.input.connection.sessionUpdate({
|
||||
sessionId: session.id,
|
||||
update: {
|
||||
await this.update(
|
||||
target.session.id,
|
||||
{
|
||||
sessionUpdate: "agent_thought_chunk",
|
||||
messageId: props.messageID,
|
||||
content: {
|
||||
@@ -254,15 +287,22 @@ export class Subscription {
|
||||
text: props.delta,
|
||||
},
|
||||
},
|
||||
})
|
||||
target.child,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private async fetchPartMetadata(sessionId: string, cwd: string, messageId: string, partId: string) {
|
||||
private async fetchPartMetadata(
|
||||
sourceSessionId: string,
|
||||
targetSessionId: string,
|
||||
cwd: string,
|
||||
messageId: string,
|
||||
partId: string,
|
||||
) {
|
||||
const message = await this.input.sdk.session
|
||||
.message(
|
||||
{
|
||||
sessionID: sessionId,
|
||||
sessionID: sourceSessionId,
|
||||
messageID: messageId,
|
||||
directory: cwd,
|
||||
},
|
||||
@@ -274,7 +314,7 @@ export class Subscription {
|
||||
|
||||
const part = message.parts.find((item) => item.id === partId)
|
||||
if (!part) return
|
||||
return await this.recordFetchedPart(sessionId, message, part)
|
||||
return await this.recordFetchedPart(targetSessionId, message, part)
|
||||
}
|
||||
|
||||
private async recordFetchedPart(sessionId: string, message: SessionMessageResponse, part: Part) {
|
||||
@@ -292,23 +332,30 @@ export class Subscription {
|
||||
)
|
||||
}
|
||||
|
||||
private async handleToolPart(sessionId: string, part: ToolPart, cwd: string) {
|
||||
await this.toolStart(sessionId, part, cwd)
|
||||
private async handleToolPart(
|
||||
sessionId: string,
|
||||
sourceSessionId: string,
|
||||
part: ToolPart,
|
||||
cwd: string,
|
||||
child?: ChildSession,
|
||||
) {
|
||||
const key = toolKey(sourceSessionId, part.callID)
|
||||
await this.toolStart(sessionId, key, part, cwd, child)
|
||||
|
||||
switch (part.state.status) {
|
||||
case "pending":
|
||||
this.shellSnapshots.delete(part.callID)
|
||||
this.shellSnapshots.delete(key)
|
||||
return
|
||||
|
||||
case "running":
|
||||
await this.runningTool(sessionId, part, cwd)
|
||||
await this.runningTool(sessionId, key, part, cwd, child)
|
||||
return
|
||||
|
||||
case "completed":
|
||||
this.clearTool(part.callID)
|
||||
await this.input.connection.sessionUpdate({
|
||||
this.clearTool(key)
|
||||
await this.update(
|
||||
sessionId,
|
||||
update: {
|
||||
{
|
||||
sessionUpdate: "tool_call_update",
|
||||
...completedToolUpdate({
|
||||
toolCallId: part.callID,
|
||||
@@ -317,14 +364,15 @@ export class Subscription {
|
||||
cwd,
|
||||
}),
|
||||
},
|
||||
})
|
||||
child,
|
||||
)
|
||||
return
|
||||
|
||||
case "error":
|
||||
this.clearTool(part.callID)
|
||||
await this.input.connection.sessionUpdate({
|
||||
this.clearTool(key)
|
||||
await this.update(
|
||||
sessionId,
|
||||
update: {
|
||||
{
|
||||
sessionUpdate: "tool_call_update",
|
||||
...errorToolUpdate({
|
||||
toolCallId: part.callID,
|
||||
@@ -333,20 +381,21 @@ export class Subscription {
|
||||
cwd,
|
||||
}),
|
||||
},
|
||||
})
|
||||
child,
|
||||
)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
private async runningTool(sessionId: string, part: ToolPart, cwd: string) {
|
||||
private async runningTool(sessionId: string, key: string, part: ToolPart, cwd: string, child?: ChildSession) {
|
||||
if (part.state.status !== "running") return
|
||||
|
||||
const output = part.tool === "bash" ? shellOutputSnapshot(part.state) : undefined
|
||||
if (output !== undefined) {
|
||||
if (this.shellSnapshots.get(part.callID) === output) {
|
||||
await this.input.connection.sessionUpdate({
|
||||
if (this.shellSnapshots.get(key) === output) {
|
||||
await this.update(
|
||||
sessionId,
|
||||
update: {
|
||||
{
|
||||
sessionUpdate: "tool_call_update",
|
||||
...duplicateRunningToolUpdate({
|
||||
toolCallId: part.callID,
|
||||
@@ -355,15 +404,16 @@ export class Subscription {
|
||||
cwd,
|
||||
}),
|
||||
},
|
||||
})
|
||||
child,
|
||||
)
|
||||
return
|
||||
}
|
||||
this.shellSnapshots.set(part.callID, output)
|
||||
this.shellSnapshots.set(key, output)
|
||||
}
|
||||
|
||||
await this.input.connection.sessionUpdate({
|
||||
await this.update(
|
||||
sessionId,
|
||||
update: {
|
||||
{
|
||||
sessionUpdate: "tool_call_update",
|
||||
...runningToolUpdate({
|
||||
toolCallId: part.callID,
|
||||
@@ -373,15 +423,16 @@ export class Subscription {
|
||||
cwd,
|
||||
}),
|
||||
},
|
||||
})
|
||||
child,
|
||||
)
|
||||
}
|
||||
|
||||
private async toolStart(sessionId: string, part: ToolPart, cwd: string) {
|
||||
if (this.toolStarts.has(part.callID)) return
|
||||
this.toolStarts.add(part.callID)
|
||||
await this.input.connection.sessionUpdate({
|
||||
private async toolStart(sessionId: string, key: string, part: ToolPart, cwd: string, child?: ChildSession) {
|
||||
if (this.toolStarts.has(key)) return
|
||||
this.toolStarts.add(key)
|
||||
await this.update(
|
||||
sessionId,
|
||||
update: {
|
||||
{
|
||||
sessionUpdate: "tool_call",
|
||||
...pendingToolCall({
|
||||
toolCallId: part.callID,
|
||||
@@ -390,13 +441,67 @@ export class Subscription {
|
||||
cwd,
|
||||
}),
|
||||
},
|
||||
child,
|
||||
)
|
||||
}
|
||||
|
||||
private clearTool(key: string) {
|
||||
this.toolStarts.delete(key)
|
||||
this.shellSnapshots.delete(key)
|
||||
}
|
||||
|
||||
private async registerChild(info: Extract<Event, { type: "session.created" }>["properties"]["info"]) {
|
||||
if (!info.parentID) return
|
||||
const parent = this.children.get(info.parentID)
|
||||
const rootID = parent?.rootID ?? (await Effect.runPromise(this.input.session.tryGet(info.parentID)))?.id
|
||||
if (!rootID) return
|
||||
this.children.set(info.id, {
|
||||
id: info.id,
|
||||
parentID: info.parentID,
|
||||
rootID,
|
||||
depth: (parent?.depth ?? 0) + 1,
|
||||
title: info.title,
|
||||
})
|
||||
}
|
||||
|
||||
private clearTool(toolCallId: string) {
|
||||
this.toolStarts.delete(toolCallId)
|
||||
this.shellSnapshots.delete(toolCallId)
|
||||
private async resolveSession(sessionId: string) {
|
||||
const session = await Effect.runPromise(this.input.session.tryGet(sessionId))
|
||||
if (session) return { session, child: undefined }
|
||||
const child = this.children.get(sessionId)
|
||||
if (!child) return
|
||||
const root = await Effect.runPromise(this.input.session.tryGet(child.rootID))
|
||||
if (!root) return
|
||||
return { session: root, child }
|
||||
}
|
||||
|
||||
private update(sessionId: string, update: SessionUpdate, child?: ChildSession) {
|
||||
return this.input.connection.sessionUpdate({
|
||||
sessionId,
|
||||
update: child ? projectChildUpdate(update, child) : update,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function toolKey(sessionId: string, toolCallId: string) {
|
||||
return `${sessionId}:${toolCallId}`
|
||||
}
|
||||
|
||||
function projectChildUpdate(update: SessionUpdate, child: ChildSession) {
|
||||
const projected = { ...update }
|
||||
projected._meta = {
|
||||
...projected._meta,
|
||||
"opencode/child-session": {
|
||||
id: child.id,
|
||||
parentID: child.parentID,
|
||||
depth: child.depth,
|
||||
...(child.title ? { title: child.title } : {}),
|
||||
},
|
||||
}
|
||||
if (projected.sessionUpdate === "tool_call" || projected.sessionUpdate === "tool_call_update") {
|
||||
projected.toolCallId = `${child.id}:${projected.toolCallId}`
|
||||
if (projected.title && child.title) projected.title = `${child.title}: ${projected.title}`
|
||||
}
|
||||
return projected
|
||||
}
|
||||
|
||||
function signal() {
|
||||
|
||||
@@ -16,6 +16,12 @@ import { Effect } from "effect"
|
||||
type PermissionEvent = Extract<Event, { type: "permission.asked" }>
|
||||
type Reply = "once" | "always" | "reject"
|
||||
type Connection = Partial<Pick<AgentSideConnection, "requestPermission" | "writeTextFile">>
|
||||
export type Context = {
|
||||
sessionId: string
|
||||
cwd: string
|
||||
toolCallPrefix?: string
|
||||
titlePrefix?: string
|
||||
}
|
||||
|
||||
const permissionOptions: PermissionOption[] = [
|
||||
{ optionId: "once", kind: "allow_once", name: "Allow once" },
|
||||
@@ -34,11 +40,11 @@ export class Handler {
|
||||
},
|
||||
) {}
|
||||
|
||||
handle(event: PermissionEvent) {
|
||||
handle(event: PermissionEvent, context?: Context) {
|
||||
const permission = event.properties
|
||||
const previous = this.queues.get(permission.sessionID) ?? Promise.resolve()
|
||||
const next = previous
|
||||
.then(() => this.process(event))
|
||||
.then(() => this.process(event, context))
|
||||
.catch(() => {})
|
||||
.finally(() => {
|
||||
if (this.queues.get(permission.sessionID) === next) {
|
||||
@@ -48,28 +54,31 @@ export class Handler {
|
||||
this.queues.set(permission.sessionID, next)
|
||||
}
|
||||
|
||||
private async process(event: PermissionEvent) {
|
||||
private async process(event: PermissionEvent, context?: Context) {
|
||||
const permission = event.properties
|
||||
const session = await Effect.runPromise(this.input.session.tryGet(permission.sessionID))
|
||||
if (!session) return
|
||||
const registered = context ? undefined : await Effect.runPromise(this.input.session.tryGet(permission.sessionID))
|
||||
const target = context ?? (registered ? { sessionId: registered.id, cwd: registered.cwd } : undefined)
|
||||
if (!target) return
|
||||
|
||||
if (!this.input.connection.requestPermission) {
|
||||
await this.reply(permission.id, "reject", session.cwd)
|
||||
await this.reply(permission.id, "reject", target.cwd)
|
||||
return
|
||||
}
|
||||
|
||||
const toolCallId = permission.tool?.callID ?? permission.id
|
||||
const result = await this.input.connection
|
||||
.requestPermission({
|
||||
sessionId: permission.sessionID,
|
||||
sessionId: target.sessionId,
|
||||
toolCall: await permissionToolCall({
|
||||
toolCallId: permission.tool?.callID ?? permission.id,
|
||||
toolCallId: target.toolCallPrefix ? `${target.toolCallPrefix}:${toolCallId}` : toolCallId,
|
||||
toolName: permission.permission,
|
||||
input: permission.metadata,
|
||||
titlePrefix: target.titlePrefix,
|
||||
}),
|
||||
options: permissionOptions,
|
||||
})
|
||||
.catch(async () => {
|
||||
await this.reply(permission.id, "reject", session.cwd)
|
||||
await this.reply(permission.id, "reject", target.cwd)
|
||||
return undefined
|
||||
})
|
||||
|
||||
@@ -77,15 +86,15 @@ export class Handler {
|
||||
|
||||
const reply = selectedReply(result)
|
||||
if (reply !== "once" && reply !== "always") {
|
||||
await this.reply(permission.id, "reject", session.cwd)
|
||||
await this.reply(permission.id, "reject", target.cwd)
|
||||
return
|
||||
}
|
||||
|
||||
if (permission.permission === "edit") {
|
||||
await this.writeProposedEdit(session.id, permission.metadata).catch(() => {})
|
||||
await this.writeProposedEdit(target.sessionId, permission.metadata).catch(() => {})
|
||||
}
|
||||
|
||||
await this.reply(permission.id, reply, session.cwd)
|
||||
await this.reply(permission.id, reply, target.cwd)
|
||||
}
|
||||
|
||||
private async reply(requestID: string, reply: Reply, directory: string) {
|
||||
@@ -119,6 +128,7 @@ async function permissionToolCall(input: {
|
||||
readonly toolCallId: string
|
||||
readonly toolName: string
|
||||
readonly input: ToolInput
|
||||
readonly titlePrefix?: string
|
||||
}): Promise<ToolCallUpdate> {
|
||||
const toolCall = pendingToolCall({
|
||||
toolCallId: input.toolCallId,
|
||||
@@ -131,11 +141,18 @@ async function permissionToolCall(input: {
|
||||
const content = await permissionContent(input.toolName, input.input)
|
||||
return {
|
||||
...toolCall,
|
||||
title: prefixedTitle(input.titlePrefix, toolCall.title),
|
||||
locations: permissionLocations(input.toolName, input.input),
|
||||
...(content.length ? { content } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
function prefixedTitle(prefix: string | undefined, title: string | undefined) {
|
||||
if (!prefix) return title
|
||||
if (!title) return prefix
|
||||
return `${prefix}: ${title}`
|
||||
}
|
||||
|
||||
function permissionTitle(toolName: string, input: ToolInput) {
|
||||
const tool = toolName.toLocaleLowerCase()
|
||||
switch (tool) {
|
||||
|
||||
@@ -28,15 +28,6 @@ export const RETRY_BACKOFF_FACTOR = 2
|
||||
export const RETRY_MAX_DELAY_NO_HEADERS = 30_000 // 30 seconds
|
||||
export const RETRY_MAX_DELAY = 2_147_483_647 // max 32-bit signed integer for setTimeout
|
||||
|
||||
const RETRYABLE_MESSAGE_PATTERNS = [
|
||||
/429|500|502|503|504|524/i,
|
||||
/rate increased too quickly|rate limit|rate-limit|rate_limit|too many requests/i,
|
||||
/overloaded|service unavailable|service_unavailable|service-unavailable|internal error|internal_error|internal server error|server error|server_error|server-error|provider returned error|provider_returned_error|provider-returned-error/i,
|
||||
/terminated|fetch failed|failed to fetch|network error|upstream connect|connection error|connection refused|connection lost|socket connection was closed|socket hang up|reset before headers|getaddrinfo|enotfound|eai_again|econnrefused|econnreset|etimedout/i,
|
||||
/^timeout$|\b(?:request|response|connection|network|stream|read) (?:timeout|timed out|time out)\b/i,
|
||||
/try your request again|retry your request|resource exhausted|resource_exhausted/i,
|
||||
]
|
||||
|
||||
function cap(ms: number) {
|
||||
return Math.min(ms, RETRY_MAX_DELAY)
|
||||
}
|
||||
@@ -81,13 +72,7 @@ export function retryable(error: Err, provider: string) {
|
||||
const status = error.data.statusCode
|
||||
// 5xx errors are transient server failures and should always be retried,
|
||||
// even when the provider SDK doesn't explicitly mark them as retryable.
|
||||
if (
|
||||
!error.data.isRetryable &&
|
||||
!(status !== undefined && status >= 500) &&
|
||||
!matchesRetryableMessage(error.data.message) &&
|
||||
!matchesRetryableMessage(error.data.responseBody)
|
||||
)
|
||||
return undefined
|
||||
if (!error.data.isRetryable && !(status !== undefined && status >= 500)) return undefined
|
||||
if (error.data.responseBody?.includes("FreeUsageLimitError")) {
|
||||
return {
|
||||
message: GO_UPSELL_MESSAGE,
|
||||
@@ -137,17 +122,33 @@ export function retryable(error: Err, provider: string) {
|
||||
return { message: error.data.message.includes("Overloaded") ? "Provider is overloaded" : error.data.message }
|
||||
}
|
||||
|
||||
const message = isRecord(error.data) ? error.data.message : undefined
|
||||
if (typeof message !== "string") return undefined
|
||||
const lower = message.toLowerCase()
|
||||
if (lower.includes("too_many_requests")) return { message: "Too Many Requests" }
|
||||
if (lower.includes("exhausted") || lower.includes("unavailable")) return { message: "Provider is overloaded" }
|
||||
if (matchesRetryableMessage(message)) return { message }
|
||||
return undefined
|
||||
}
|
||||
// Check for rate limit patterns in plain text error messages
|
||||
const msg = isRecord(error.data) ? error.data.message : undefined
|
||||
if (typeof msg === "string") {
|
||||
const lower = msg.toLowerCase()
|
||||
if (
|
||||
lower.includes("rate increased too quickly") ||
|
||||
lower.includes("rate limit") ||
|
||||
lower.includes("too many requests")
|
||||
) {
|
||||
return { message: msg }
|
||||
}
|
||||
}
|
||||
|
||||
function matchesRetryableMessage(value: unknown) {
|
||||
return typeof value === "string" && RETRYABLE_MESSAGE_PATTERNS.some((pattern) => pattern.test(value))
|
||||
const json = parseJSON(msg)
|
||||
if (!json || typeof json !== "object") return undefined
|
||||
const code = typeof json.code === "string" ? json.code : ""
|
||||
|
||||
if (json.type === "error" && json.error?.type === "too_many_requests") {
|
||||
return { message: "Too Many Requests" }
|
||||
}
|
||||
if (code.includes("exhausted") || code.includes("unavailable")) {
|
||||
return { message: "Provider is overloaded" }
|
||||
}
|
||||
if (json.type === "error" && typeof json.error?.code === "string" && json.error.code.includes("rate_limit")) {
|
||||
return { message: "Rate Limited" }
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
function str(value: unknown) {
|
||||
|
||||
@@ -167,6 +167,21 @@ function toolUpdated(part: ToolPart): Event {
|
||||
}
|
||||
}
|
||||
|
||||
function sessionCreated(sessionID: string, parentID: string, title: string): Event {
|
||||
return {
|
||||
id: `evt_${sessionID}_created`,
|
||||
type: "session.created",
|
||||
properties: {
|
||||
sessionID,
|
||||
info: {
|
||||
id: sessionID,
|
||||
parentID,
|
||||
title,
|
||||
},
|
||||
},
|
||||
} as Event
|
||||
}
|
||||
|
||||
function assistantMessage(sessionID: string, messageID: string, partID: string, type: DeltaPartType) {
|
||||
return {
|
||||
info: {
|
||||
@@ -319,6 +334,63 @@ async function createKnownSession(
|
||||
}
|
||||
|
||||
describe("acp event routing", () => {
|
||||
it("projects nested subagent messages onto the root session", async () => {
|
||||
const harness = createHarness({
|
||||
msg_grandchild: assistantMessage("ses_grandchild", "msg_grandchild", "part_grandchild", "text"),
|
||||
})
|
||||
await Effect.runPromise(harness.session.create({ id: "ses_root", cwd: "/workspace" }))
|
||||
await harness.subscription.handle(sessionCreated("ses_child", "ses_root", "Explore"))
|
||||
await harness.subscription.handle(sessionCreated("ses_grandchild", "ses_child", "Research"))
|
||||
|
||||
await harness.subscription.handle(partUpdated("ses_grandchild", "msg_grandchild", "part_grandchild", "text"))
|
||||
await harness.subscription.handle(textDelta("ses_grandchild", "msg_grandchild", "part_grandchild", "nested result"))
|
||||
|
||||
expect(harness.updates).toEqual([
|
||||
{
|
||||
sessionId: "ses_root",
|
||||
update: {
|
||||
sessionUpdate: "agent_message_chunk",
|
||||
messageId: "msg_grandchild",
|
||||
content: { type: "text", text: "nested result" },
|
||||
_meta: {
|
||||
"opencode/child-session": {
|
||||
id: "ses_grandchild",
|
||||
parentID: "ses_child",
|
||||
depth: 2,
|
||||
title: "Research",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it("namespaces subagent tool calls without colliding with root tools", async () => {
|
||||
const harness = createHarness()
|
||||
await Effect.runPromise(harness.session.create({ id: "ses_root", cwd: "/workspace" }))
|
||||
await harness.subscription.handle(sessionCreated("ses_child", "ses_root", "Explore"))
|
||||
|
||||
await harness.subscription.handle(toolUpdated(runningTool("ses_root", "call_shared", "root")))
|
||||
await harness.subscription.handle(toolUpdated(runningTool("ses_child", "call_shared", "child")))
|
||||
|
||||
const starts = toolUpdates(harness.updates).filter((item) => item.update.sessionUpdate === "tool_call")
|
||||
expect(starts.map((item) => item.update.toolCallId)).toEqual(["call_shared", "ses_child:call_shared"])
|
||||
expect(starts[1]).toMatchObject({
|
||||
sessionId: "ses_root",
|
||||
update: {
|
||||
title: "Explore: printf hello",
|
||||
_meta: {
|
||||
"opencode/child-session": {
|
||||
id: "ses_child",
|
||||
parentID: "ses_root",
|
||||
depth: 1,
|
||||
title: "Explore",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it("routes message.part.delta by sessionID without cross-session pollution", async () => {
|
||||
const harness = createHarness()
|
||||
await createKnownSession(harness.session, "ses_a", { messageId: "msg_a", partId: "part_a", partType: "text" })
|
||||
|
||||
@@ -122,6 +122,21 @@ function permissionAsked(
|
||||
} as PermissionEvent
|
||||
}
|
||||
|
||||
function sessionCreated(sessionID: string, parentID: string, title: string): Event {
|
||||
return {
|
||||
id: `evt_${sessionID}_created`,
|
||||
type: "session.created",
|
||||
properties: {
|
||||
sessionID,
|
||||
info: {
|
||||
id: sessionID,
|
||||
parentID,
|
||||
title,
|
||||
},
|
||||
},
|
||||
} as Event
|
||||
}
|
||||
|
||||
function textDelta(sessionID: string, messageID: string, partID: string, delta: string) {
|
||||
return {
|
||||
id: `evt_${sessionID}_${messageID}_${partID}`,
|
||||
@@ -156,6 +171,29 @@ async function tempFile(name: string, content: string) {
|
||||
}
|
||||
|
||||
describe("acp permissions", () => {
|
||||
it("routes subagent permissions through the root session", async () => {
|
||||
const harness = createHarness()
|
||||
await createSession(harness.session, "ses_root")
|
||||
await harness.subscription.handle(sessionCreated("ses_child", "ses_root", "Explore"))
|
||||
|
||||
harness.subscription.handle(
|
||||
permissionAsked("ses_child", "perm_child", {
|
||||
tool: { messageID: "msg_child", callID: "call_child" },
|
||||
}),
|
||||
)
|
||||
|
||||
await pollUntil(() => harness.replies.length === 1, "subagent permission was never replied")
|
||||
|
||||
expect(harness.requests[0]).toMatchObject({
|
||||
sessionId: "ses_root",
|
||||
toolCall: {
|
||||
toolCallId: "ses_child:call_child",
|
||||
title: "Explore: printf hello",
|
||||
},
|
||||
})
|
||||
expect(harness.replies).toEqual([{ requestID: "perm_child", reply: "once", directory: "/workspace" }])
|
||||
})
|
||||
|
||||
it("sends requestPermission and replies with the selected outcome", async () => {
|
||||
const harness = createHarness()
|
||||
await createSession(harness.session, "ses_a")
|
||||
|
||||
@@ -604,55 +604,6 @@ it.live("session.processor effect tests retry recognized structured json errors"
|
||||
),
|
||||
)
|
||||
|
||||
it.live("session.processor effect tests retry OpenAI-compatible midstream server errors", () =>
|
||||
provideTmpdirServer(
|
||||
({ dir, llm }) =>
|
||||
Effect.gen(function* () {
|
||||
const { processors, session, provider } = yield* boot()
|
||||
|
||||
yield* llm.push(
|
||||
raw({ chunks: [{ error: { type: "server_error", code: "server_error", message: "xxx" } }] }),
|
||||
)
|
||||
yield* llm.text("after")
|
||||
|
||||
const chat = yield* session.create({})
|
||||
const parent = yield* user(chat.id, "retry midstream server error")
|
||||
const msg = yield* assistant(chat.id, parent.id, path.resolve(dir))
|
||||
const mdl = yield* provider.getModel(ref.providerID, ref.modelID)
|
||||
const handle = yield* processors.create({
|
||||
assistantMessage: msg,
|
||||
sessionID: chat.id,
|
||||
model: mdl,
|
||||
})
|
||||
|
||||
const value = yield* handle.process({
|
||||
user: {
|
||||
id: parent.id,
|
||||
sessionID: chat.id,
|
||||
role: "user",
|
||||
time: parent.time,
|
||||
agent: parent.agent,
|
||||
model: { providerID: ref.providerID, modelID: ref.modelID },
|
||||
} satisfies SessionV1.User,
|
||||
sessionID: chat.id,
|
||||
model: mdl,
|
||||
agent: agent(),
|
||||
system: [],
|
||||
messages: [{ role: "user", content: "retry midstream server error" }],
|
||||
tools: {},
|
||||
})
|
||||
|
||||
const parts = yield* MessageV2.parts(msg.id)
|
||||
|
||||
expect(value).toBe("continue")
|
||||
expect(yield* llm.calls).toBe(2)
|
||||
expect(parts.some((part) => part.type === "text" && part.text === "after")).toBe(true)
|
||||
expect(handle.message.error).toBeUndefined()
|
||||
}),
|
||||
{ config: (url) => providerCfg(url) },
|
||||
),
|
||||
)
|
||||
|
||||
it.live("session.processor effect tests publish retry status updates", () =>
|
||||
provideTmpdirServer(
|
||||
({ dir, llm }) =>
|
||||
|
||||
@@ -118,21 +118,16 @@ describe("session.retry.delay", () => {
|
||||
})
|
||||
|
||||
describe("session.retry.retryable", () => {
|
||||
test("retries serialized too_many_requests messages", () => {
|
||||
test("maps too_many_requests json messages", () => {
|
||||
const error = wrap(JSON.stringify({ type: "error", error: { type: "too_many_requests" } }))
|
||||
expect(SessionRetry.retryable(error, retryProvider)).toEqual({ message: "Too Many Requests" })
|
||||
})
|
||||
|
||||
test("retries serialized overloaded provider codes", () => {
|
||||
test("maps overloaded provider codes", () => {
|
||||
const error = wrap(JSON.stringify({ code: "resource_exhausted" }))
|
||||
expect(SessionRetry.retryable(error, retryProvider)).toEqual({ message: "Provider is overloaded" })
|
||||
})
|
||||
|
||||
test("retries serialized rate_limit messages", () => {
|
||||
const message = JSON.stringify({ type: "error", error: { code: "rate_limit_exceeded" } })
|
||||
expect(SessionRetry.retryable(wrap(message), retryProvider)).toEqual({ message })
|
||||
})
|
||||
|
||||
test("does not retry unknown json messages", () => {
|
||||
const error = wrap(JSON.stringify({ error: { message: "no_kv_space" } }))
|
||||
expect(SessionRetry.retryable(error, retryProvider)).toBeUndefined()
|
||||
@@ -168,45 +163,6 @@ describe("session.retry.retryable", () => {
|
||||
expect(SessionRetry.retryable(error, retryProvider)).toEqual({ message: msg })
|
||||
})
|
||||
|
||||
test.each([
|
||||
"Internal server error",
|
||||
"internal error",
|
||||
"server-error",
|
||||
"Provider returned error",
|
||||
"provider-returned-error",
|
||||
"terminated",
|
||||
"fetch failed",
|
||||
"connection refused",
|
||||
"connect ECONNREFUSED",
|
||||
"request ETIMEDOUT",
|
||||
"failed to fetch",
|
||||
"EAI_AGAIN",
|
||||
"response timed out",
|
||||
"Please retry your request",
|
||||
"try your request again",
|
||||
"upstream returned status 524",
|
||||
])("retries matching API error text: %s", (message) => {
|
||||
expect(SessionRetry.retryable(wrap(message), retryProvider)).toEqual({ message })
|
||||
})
|
||||
|
||||
test("retries hyphenated service-unavailable errors", () => {
|
||||
expect(SessionRetry.retryable(wrap("service-unavailable"), retryProvider)).toEqual({
|
||||
message: "Provider is overloaded",
|
||||
})
|
||||
})
|
||||
|
||||
test("matches retryable API response bodies", () => {
|
||||
const error = Schema.decodeUnknownSync(SessionV1.APIError.Schema)(
|
||||
new SessionV1.APIError({
|
||||
message: "Request failed",
|
||||
isRetryable: false,
|
||||
statusCode: 400,
|
||||
responseBody: JSON.stringify({ error: { message: "upstream connection refused" } }),
|
||||
}).toObject(),
|
||||
)
|
||||
expect(SessionRetry.retryable(error, retryProvider)).toEqual({ message: "Request failed" })
|
||||
})
|
||||
|
||||
test("retries transport timeout errors", () => {
|
||||
const request = MessageV2.fromError(new ProviderError.HeaderTimeoutError(10000), { providerID })
|
||||
expect(SessionV1.APIError.isInstance(request)).toBe(true)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode-ai/plugin",
|
||||
"version": "1.18.14",
|
||||
"version": "1.18.13",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode-ai/sdk",
|
||||
"version": "1.18.14",
|
||||
"version": "1.18.13",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode-ai/server",
|
||||
"version": "1.18.14",
|
||||
"version": "1.18.13",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/session-ui",
|
||||
"version": "1.18.14",
|
||||
"version": "1.18.13",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/slack",
|
||||
"version": "1.18.14",
|
||||
"version": "1.18.13",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode-ai/stats-app",
|
||||
"version": "1.18.14",
|
||||
"version": "1.18.13",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,23 +1,34 @@
|
||||
import { Meta, Title } from "@solidjs/meta"
|
||||
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
|
||||
import { geoEquirectangular, geoPath } from "d3-geo"
|
||||
import { scaleSqrt } from "d3-scale"
|
||||
import countryCodesSource from "i18n-iso-countries/codes.json?raw"
|
||||
import { feature, mesh } from "topojson-client"
|
||||
import countriesTopologySource from "world-atlas/countries-50m.json?raw"
|
||||
import {
|
||||
getStatsModelData,
|
||||
type CountryEntry,
|
||||
type ModelPeerEntry,
|
||||
type ModelUsagePoint,
|
||||
type StatsModelData,
|
||||
type UsageRange,
|
||||
} from "@opencode-ai/stats-core/domain/home"
|
||||
import { createAsync, query, useParams } from "@solidjs/router"
|
||||
import { createMemo, createSignal, createUniqueId, For, onMount, Show, type JSX } from "solid-js"
|
||||
import { getRequestEvent } from "solid-js/web"
|
||||
import type { FeatureCollection, GeometryObject, GeoJsonProperties } from "geojson"
|
||||
import type { GeometryCollection, Topology } from "topojson-specification"
|
||||
import { LocaleLinks } from "../../component/locale-links"
|
||||
import { useI18n } from "../../context/i18n"
|
||||
import { useLanguage } from "../../context/language"
|
||||
import { localizedUrl } from "../../lib/language"
|
||||
import { findModelCatalogEntry, formatCatalogLabName, loadModelCatalog, type ModelCatalogEntry } from "../model-catalog"
|
||||
import { geoMapHeight, geoMapWidth, worldBorderPath, worldCountryMarkers, worldCountryPaths } from "../geo-map"
|
||||
import {
|
||||
findModelCatalogEntry,
|
||||
formatCatalogLabName,
|
||||
getModelCatalog,
|
||||
type ModelCatalog,
|
||||
type ModelCatalogEntry,
|
||||
} from "../model-catalog"
|
||||
import { SectionHeading } from "../section-heading"
|
||||
import { runStatsEffect } from "../../stats-runtime"
|
||||
import { setStatsPageCacheHeaders } from "../stats-cache"
|
||||
@@ -40,41 +51,45 @@ import {
|
||||
} from "../stats-shell"
|
||||
|
||||
const statsUnfurlPath = "banner.png"
|
||||
const geoMapWidth = 960
|
||||
const geoMapHeight = 430
|
||||
const shortMonths = ["JAN", "FEB", "MAR", "APR", "MAY", "JUN", "JUL", "AUG", "SEP", "OCT", "NOV", "DEC"] as const
|
||||
|
||||
type IsoCountryCode = readonly [string, string, string]
|
||||
type ModelCatalogOption = Pick<ModelCatalogEntry, "id" | "lab" | "slug" | "name">
|
||||
type ModelPageCatalog = {
|
||||
entry: ModelCatalogEntry | null
|
||||
labs: { id: string; name: string }[]
|
||||
labModels: ModelCatalogOption[]
|
||||
}
|
||||
type StatsModelPageData = Omit<StatsModelData, "country"> & { country: CountryEntry[] }
|
||||
type ModelPageData = { catalog: ModelPageCatalog; stats: StatsModelPageData | null }
|
||||
type WorldCountryProperties = GeoJsonProperties & { name?: string }
|
||||
type WorldTopology = Topology<{ countries: GeometryCollection<WorldCountryProperties> }>
|
||||
|
||||
const countryNumericIds = new Map(
|
||||
(JSON.parse(countryCodesSource) as IsoCountryCode[]).map((country) => [country[0], country[2]] as const),
|
||||
)
|
||||
const worldTopology = JSON.parse(countriesTopologySource) as WorldTopology
|
||||
const worldCountryGeometries: GeometryCollection<WorldCountryProperties> = {
|
||||
...worldTopology.objects.countries,
|
||||
geometries: worldTopology.objects.countries.geometries.filter((country) => String(country.id ?? "") !== "010"),
|
||||
}
|
||||
const worldCountries = feature<WorldCountryProperties>(worldTopology, worldCountryGeometries) as FeatureCollection<
|
||||
GeometryObject,
|
||||
WorldCountryProperties
|
||||
>
|
||||
const worldProjection = geoEquirectangular().fitExtent(
|
||||
[
|
||||
[10, 12],
|
||||
[geoMapWidth - 10, geoMapHeight - 12],
|
||||
],
|
||||
worldCountries,
|
||||
)
|
||||
const worldPath = geoPath(worldProjection)
|
||||
const worldCountryPaths = worldCountries.features.map((country) => ({
|
||||
id: String(country.id ?? "").padStart(3, "0"),
|
||||
path: worldPath(country) ?? "",
|
||||
marker: geoCountryMarker(country),
|
||||
}))
|
||||
const worldBorderPath = worldPath(mesh(worldTopology, worldCountryGeometries, (a, b) => a !== b)) ?? ""
|
||||
|
||||
const getModelPageData = query(async (labParam: string, modelParam: string) => {
|
||||
const getModelData = query(async (lab: string, model: string) => {
|
||||
"use server"
|
||||
const catalog = await loadModelCatalog()
|
||||
const entry = findModelCatalogEntry(catalog, modelParam, labParam) ?? null
|
||||
const lab = entry?.lab ?? labParam
|
||||
const model = entry?.slug ?? modelParam
|
||||
const stats = lab && model ? await runStatsEffect(getStatsModelData(model, lab)) : null
|
||||
return {
|
||||
catalog: {
|
||||
entry,
|
||||
labs: catalog.labs.map((item) => ({ id: item.id, name: item.name })),
|
||||
labModels:
|
||||
catalog.labs
|
||||
.find((item) => item.id === (entry?.lab ?? providerSlug(labParam)))
|
||||
?.models.map((item) => ({ id: item.id, lab: item.lab, slug: item.slug, name: item.name })) ?? [],
|
||||
},
|
||||
stats: stats ? { ...stats, country: stats.country["2M"] } : null,
|
||||
} satisfies ModelPageData
|
||||
}, "getStatsModelPageData")
|
||||
return runStatsEffect(getStatsModelData(model, lab))
|
||||
}, "getStatsModelData")
|
||||
|
||||
export default function StatsModel() {
|
||||
const i18n = useI18n()
|
||||
@@ -84,9 +99,18 @@ export default function StatsModel() {
|
||||
const params = useParams()
|
||||
const labParam = createMemo(() => params.lab ?? "")
|
||||
const modelParam = createMemo(() => params.model ?? "")
|
||||
const page = createAsync(() => getModelPageData(labParam(), modelParam()))
|
||||
const catalogEntry = createMemo(() => page()?.catalog.entry)
|
||||
const stats = createMemo(() => page()?.stats)
|
||||
const catalog = createAsync(() => getModelCatalog())
|
||||
const catalogEntry = createMemo(() => {
|
||||
const data = catalog()
|
||||
if (!data) return undefined
|
||||
return findModelCatalogEntry(data, modelParam(), labParam()) ?? null
|
||||
})
|
||||
const stats = createAsync(() => {
|
||||
const entry = catalogEntry()
|
||||
if (catalog() === undefined || entry === undefined) return Promise.resolve(undefined)
|
||||
if (!entry && (!labParam() || !modelParam())) return Promise.resolve(null)
|
||||
return getModelData(labParam(), entry?.slug ?? modelParam())
|
||||
})
|
||||
const githubStars = createAsync(() => getGitHubStars())
|
||||
const [themePreference, setThemePreference] = createSignal<ThemePreference>("system")
|
||||
const modelName = createMemo(() => catalogEntry()?.name ?? stats()?.model ?? modelParam() ?? i18n.t("model.fallback"))
|
||||
@@ -155,13 +179,13 @@ export default function StatsModel() {
|
||||
<Header githubStars={githubStars() ?? "150K"} links={modelHeaderLinks()} brandHref={import.meta.env.BASE_URL} />
|
||||
<div data-component="container">
|
||||
<div data-component="content">
|
||||
<Show when={page() !== undefined} fallback={<ModelLoading />}>
|
||||
<Show when={catalogEntry() || stats() !== undefined} fallback={<ModelLoading />}>
|
||||
<Show when={catalogEntry() || stats()} fallback={<ModelNotFound lab={labParam()} model={modelParam()} />}>
|
||||
<>
|
||||
<ModelHero
|
||||
data={stats() ?? null}
|
||||
catalog={catalogEntry() ?? null}
|
||||
catalogData={page()?.catalog ?? null}
|
||||
catalogData={catalog() ?? null}
|
||||
labName={labName()}
|
||||
/>
|
||||
<ModelOverview catalog={catalogEntry() ?? null} />
|
||||
@@ -169,10 +193,10 @@ export default function StatsModel() {
|
||||
<ModelUsageSection data={stats() ?? null} />
|
||||
<ModelUniqueUsersSection data={stats() ?? null} />
|
||||
<ModelEfficiencySection data={stats() ?? null} catalog={catalogEntry() ?? null} />
|
||||
<ModelGeoBreakdownSection data={stats()?.country ?? []} />
|
||||
<ModelGeoBreakdownSection data={stats()?.country ?? emptyCountryRecord()} />
|
||||
<ModelPeersSection data={stats() ?? null} />
|
||||
<ComparisonCardsSection
|
||||
pairs={modelComparisonPairs(page()?.catalog.labModels, catalogEntry() ?? null, stats() ?? null)}
|
||||
pairs={modelComparisonPairs(catalog(), catalogEntry() ?? null, stats() ?? null)}
|
||||
title="Compare This Model"
|
||||
description="Other models to compare with this one."
|
||||
variant="featured"
|
||||
@@ -247,9 +271,9 @@ function ModelNotFound(props: { lab: string; model: string }) {
|
||||
}
|
||||
|
||||
function ModelHero(props: {
|
||||
data: StatsModelPageData | null
|
||||
data: StatsModelData | null
|
||||
catalog: ModelCatalogEntry | null
|
||||
catalogData: ModelPageCatalog | null
|
||||
catalogData: ModelCatalog | null
|
||||
labName: string
|
||||
}) {
|
||||
const i18n = useI18n()
|
||||
@@ -258,7 +282,9 @@ function ModelHero(props: {
|
||||
const modelName = () => props.catalog?.name ?? props.data?.model ?? i18n.t("model.fallback")
|
||||
const weights = () => props.catalog?.weights[0]
|
||||
const labs = () => props.catalogData?.labs ?? []
|
||||
const labModels = () => props.catalogData?.labModels ?? (props.catalog ? [props.catalog] : [])
|
||||
const labModels = () =>
|
||||
props.catalogData?.labs.find((lab) => lab.id === providerSlug(labId()))?.models ??
|
||||
(props.catalog ? [props.catalog] : [])
|
||||
return (
|
||||
<section id="overview" data-section="model-hero">
|
||||
<nav data-component="model-hero-breadcrumb" aria-label="Data breadcrumb">
|
||||
@@ -377,7 +403,7 @@ function ModelHeroActionIcon(props: { kind: "weights" | "compare" }) {
|
||||
)
|
||||
}
|
||||
|
||||
function ModelHeroSparkline(props: { data: StatsModelPageData }) {
|
||||
function ModelHeroSparkline(props: { data: StatsModelData }) {
|
||||
const values = () => props.data.usage.slice(-14).map((point) => point.tokens)
|
||||
return (
|
||||
<span data-slot="model-hero-sparkline" aria-hidden="true">
|
||||
@@ -440,7 +466,7 @@ function ModelOverview(props: { catalog: ModelCatalogEntry | null }) {
|
||||
)
|
||||
}
|
||||
|
||||
function ModelMomentumSection(props: { data: StatsModelPageData | null }) {
|
||||
function ModelMomentumSection(props: { data: StatsModelData | null }) {
|
||||
const i18n = useI18n()
|
||||
const language = useLanguage()
|
||||
return (
|
||||
@@ -479,7 +505,7 @@ function ModelMomentumSection(props: { data: StatsModelPageData | null }) {
|
||||
)
|
||||
}
|
||||
|
||||
function MomentumChart(props: { data: StatsModelPageData; locale: string }) {
|
||||
function MomentumChart(props: { data: StatsModelData; locale: string }) {
|
||||
const chart = createMemo(() => momentumChart(props.data.usage, props.data.updatedAt))
|
||||
const changeState = createMemo(() => (props.data.tokenChange < 0 ? "negative" : "positive"))
|
||||
return (
|
||||
@@ -536,7 +562,7 @@ function MomentumMetric(props: { label: string; value: string; watermark?: strin
|
||||
)
|
||||
}
|
||||
|
||||
function ModelUsageSection(props: { data: StatsModelPageData | null }) {
|
||||
function ModelUsageSection(props: { data: StatsModelData | null }) {
|
||||
const i18n = useI18n()
|
||||
return (
|
||||
<ModelTrendSection
|
||||
@@ -556,7 +582,7 @@ function ModelUsageSection(props: { data: StatsModelPageData | null }) {
|
||||
)
|
||||
}
|
||||
|
||||
function ModelUniqueUsersSection(props: { data: StatsModelPageData | null }) {
|
||||
function ModelUniqueUsersSection(props: { data: StatsModelData | null }) {
|
||||
const i18n = useI18n()
|
||||
return (
|
||||
<ModelTrendSection
|
||||
@@ -580,7 +606,7 @@ function ModelUniqueUsersSection(props: { data: StatsModelPageData | null }) {
|
||||
}
|
||||
|
||||
function ModelTrendSection(props: {
|
||||
data: StatsModelPageData | null
|
||||
data: StatsModelData | null
|
||||
id: string
|
||||
title: string
|
||||
description: string
|
||||
@@ -833,7 +859,7 @@ function ModelTrendSection(props: {
|
||||
)
|
||||
}
|
||||
|
||||
function ModelEfficiencySection(props: { data: StatsModelPageData | null; catalog: ModelCatalogEntry | null }) {
|
||||
function ModelEfficiencySection(props: { data: StatsModelData | null; catalog: ModelCatalogEntry | null }) {
|
||||
const i18n = useI18n()
|
||||
return (
|
||||
<section id="efficiency" data-section="model-panel">
|
||||
@@ -884,11 +910,11 @@ function ModelEfficiencySection(props: { data: StatsModelPageData | null; catalo
|
||||
)
|
||||
}
|
||||
|
||||
function ModelGeoBreakdownSection(props: { data: CountryEntry[] }) {
|
||||
function ModelGeoBreakdownSection(props: { data: Record<UsageRange, CountryEntry[]> }) {
|
||||
const i18n = useI18n()
|
||||
const language = useLanguage()
|
||||
const [activeCountry, setActiveCountry] = createSignal<string>()
|
||||
const data = createMemo(() => props.data)
|
||||
const data = createMemo(() => props.data["2M"])
|
||||
const countryById = createMemo(
|
||||
() =>
|
||||
new Map(
|
||||
@@ -1005,30 +1031,31 @@ function GeoWorldMap(props: {
|
||||
</For>
|
||||
</g>
|
||||
<g data-slot="geo-country-markers">
|
||||
<For each={worldCountryMarkers}>
|
||||
<For each={worldCountryPaths}>
|
||||
{(country) => {
|
||||
const entry = () => props.countryById.get(country.id)
|
||||
return (
|
||||
<Show when={entry()}>
|
||||
<circle
|
||||
cx={country.marker.x}
|
||||
cy={country.marker.y}
|
||||
data-country-id={country.id}
|
||||
r={entry()?.country === props.activeCountry ? 3.4 : 2.4}
|
||||
data-active={entry()?.country === props.activeCountry ? "true" : undefined}
|
||||
style={{ "--geo-country-opacity": String(countryOpacity(entry())) } as JSX.CSSProperties}
|
||||
aria-hidden="true"
|
||||
onPointerEnter={() => {
|
||||
const item = entry()
|
||||
if (!item) return
|
||||
props.onActiveCountryChange(item.country)
|
||||
}}
|
||||
onClick={() => {
|
||||
const item = entry()
|
||||
if (!item) return
|
||||
props.onActiveCountryChange(item.country)
|
||||
}}
|
||||
/>
|
||||
<Show when={country.marker && entry() ? country.marker : undefined}>
|
||||
{(marker) => (
|
||||
<circle
|
||||
cx={marker().x}
|
||||
cy={marker().y}
|
||||
r={entry()?.country === props.activeCountry ? 3.4 : 2.4}
|
||||
data-active={entry()?.country === props.activeCountry ? "true" : undefined}
|
||||
style={{ "--geo-country-opacity": String(countryOpacity(entry())) } as JSX.CSSProperties}
|
||||
aria-hidden="true"
|
||||
onPointerEnter={() => {
|
||||
const item = entry()
|
||||
if (!item) return
|
||||
props.onActiveCountryChange(item.country)
|
||||
}}
|
||||
onClick={() => {
|
||||
const item = entry()
|
||||
if (!item) return
|
||||
props.onActiveCountryChange(item.country)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Show>
|
||||
)
|
||||
}}
|
||||
@@ -1076,7 +1103,7 @@ function GeoCountryList(props: {
|
||||
)
|
||||
}
|
||||
|
||||
function ModelPeersSection(props: { data: StatsModelPageData | null }) {
|
||||
function ModelPeersSection(props: { data: StatsModelData | null }) {
|
||||
const i18n = useI18n()
|
||||
return (
|
||||
<section id="peers" data-section="model-panel">
|
||||
@@ -1145,9 +1172,9 @@ function ModelEmptyState(props: { title: string; description: string; compact?:
|
||||
}
|
||||
|
||||
function modelComparisonPairs(
|
||||
catalogModels: ModelCatalogOption[] | undefined,
|
||||
catalog: ModelCatalog | undefined,
|
||||
catalogEntry: ModelCatalogEntry | null,
|
||||
data: StatsModelPageData | null,
|
||||
data: StatsModelData | null,
|
||||
) {
|
||||
const current = modelComparisonRef(catalogEntry, data)
|
||||
if (!current) return []
|
||||
@@ -1165,7 +1192,9 @@ function modelComparisonPairs(
|
||||
},
|
||||
detail: "Usage peer",
|
||||
}))
|
||||
const catalogPairs = (catalogEntry ? (catalogModels ?? []) : [])
|
||||
const catalogPairs = (
|
||||
catalogEntry && catalog ? (catalog.labs.find((lab) => lab.id === catalogEntry.lab)?.models ?? []) : []
|
||||
)
|
||||
.filter((model) => model.id !== catalogEntry?.id)
|
||||
.slice(0, 3)
|
||||
.map((model) => ({
|
||||
@@ -1178,7 +1207,7 @@ function modelComparisonPairs(
|
||||
|
||||
function modelComparisonRef(
|
||||
catalogEntry: ModelCatalogEntry | null,
|
||||
data: StatsModelPageData | null,
|
||||
data: StatsModelData | null,
|
||||
): ComparisonModelRef | undefined {
|
||||
if (catalogEntry) return modelRefFromCatalog(catalogEntry)
|
||||
if (!data) return undefined
|
||||
@@ -1198,10 +1227,31 @@ function getProviderIconId(author: string) {
|
||||
return author.toLowerCase().replace(/[^a-z0-9]+/g, "")
|
||||
}
|
||||
|
||||
function emptyCountryRecord(): Record<UsageRange, CountryEntry[]> {
|
||||
return {
|
||||
"1D": [],
|
||||
"1W": [],
|
||||
"2W": [],
|
||||
"1M": [],
|
||||
"2M": [],
|
||||
"3M": [],
|
||||
YTD: [],
|
||||
ALL: [],
|
||||
}
|
||||
}
|
||||
|
||||
function countryNumericId(country: string) {
|
||||
return countryNumericIds.get(country.toUpperCase())?.padStart(3, "0")
|
||||
}
|
||||
|
||||
function geoCountryMarker(country: (typeof worldCountries.features)[number]) {
|
||||
const bounds = worldPath.bounds(country)
|
||||
const [x, y] = worldPath.centroid(country)
|
||||
if (!Number.isFinite(x) || !Number.isFinite(y)) return undefined
|
||||
if (bounds[1][0] - bounds[0][0] >= 3 && bounds[1][1] - bounds[0][1] >= 3) return undefined
|
||||
return { x, y }
|
||||
}
|
||||
|
||||
function formatCountryName(country: string, locale: string, i18n: ReturnType<typeof useI18n>) {
|
||||
const code = country.toUpperCase()
|
||||
if (code === "ZZ") return i18n.t("home.unknown")
|
||||
@@ -1458,7 +1508,7 @@ function formatSparklinePoint(value: number) {
|
||||
return Number(value.toFixed(2)).toString()
|
||||
}
|
||||
|
||||
function formatModelRankMoveLabel(data: StatsModelPageData, i18n: ReturnType<typeof useI18n>) {
|
||||
function formatModelRankMoveLabel(data: StatsModelData, i18n: ReturnType<typeof useI18n>) {
|
||||
if (data.rank === null) return i18n.t("model.noUsageLastWeek")
|
||||
if (data.previousRank === null) return i18n.t("model.newThisWeek")
|
||||
const change = data.previousRank - data.rank
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
type LabUsageModelEntry,
|
||||
type MarketDay,
|
||||
type ModelUsagePoint,
|
||||
type StatsHomeData,
|
||||
type StatsLabData,
|
||||
} from "@opencode-ai/stats-core/domain/home"
|
||||
import { createAsync, query, useParams } from "@solidjs/router"
|
||||
@@ -19,7 +20,7 @@ import {
|
||||
catalogSlug,
|
||||
findModelCatalogLab,
|
||||
formatCatalogLabName,
|
||||
loadModelCatalog,
|
||||
getModelCatalog,
|
||||
type ModelCatalogEntry,
|
||||
type ModelCatalogLab,
|
||||
} from "../model-catalog"
|
||||
@@ -41,33 +42,15 @@ import {
|
||||
|
||||
const statsUnfurlPath = "banner.png"
|
||||
|
||||
type RelatedCatalogLab = Pick<ModelCatalogLab, "id" | "name" | "description"> & {
|
||||
models: Pick<ModelCatalogEntry, "name">[]
|
||||
}
|
||||
|
||||
type LabPageData = {
|
||||
lab: ModelCatalogLab | null
|
||||
labs: RelatedCatalogLab[]
|
||||
market: MarketDay[]
|
||||
stats: StatsLabData | null
|
||||
}
|
||||
|
||||
const getLabPageData = query(async (labParam: string) => {
|
||||
const getLabData = query(async (lab: string) => {
|
||||
"use server"
|
||||
const [catalog, home] = await Promise.all([loadModelCatalog(), runStatsEffect(getStatsHomeData())])
|
||||
const lab = findModelCatalogLab(catalog, labParam) ?? null
|
||||
return {
|
||||
lab,
|
||||
labs: catalog.labs.map((entry) => ({
|
||||
id: entry.id,
|
||||
name: entry.name,
|
||||
description: entry.description,
|
||||
models: entry.models.map((model) => ({ name: model.name })),
|
||||
})),
|
||||
market: home.market["2M"],
|
||||
stats: lab ? await runStatsEffect(getStatsLabData(lab.id)) : null,
|
||||
} satisfies LabPageData
|
||||
}, "getStatsLabPageData")
|
||||
return runStatsEffect(getStatsLabData(lab))
|
||||
}, "getStatsLabData")
|
||||
|
||||
const getHomeData = query(async () => {
|
||||
"use server"
|
||||
return runStatsEffect(getStatsHomeData())
|
||||
}, "getStatsHomeData")
|
||||
|
||||
type LabModelTooltipState = {
|
||||
model: ModelCatalogEntry
|
||||
@@ -84,9 +67,19 @@ export default function StatsLab() {
|
||||
setStatsPageCacheHeaders(event?.response.headers)
|
||||
const params = useParams()
|
||||
const labParam = createMemo(() => params.lab ?? "")
|
||||
const page = createAsync(() => getLabPageData(labParam()))
|
||||
const lab = createMemo(() => page()?.lab)
|
||||
const stats = createMemo(() => page()?.stats)
|
||||
const catalog = createAsync(() => getModelCatalog())
|
||||
const lab = createMemo(() => {
|
||||
const data = catalog()
|
||||
if (!data) return undefined
|
||||
return findModelCatalogLab(data, labParam()) ?? null
|
||||
})
|
||||
const stats = createAsync(() => {
|
||||
const entry = lab()
|
||||
if (catalog() === undefined || entry === undefined) return Promise.resolve(undefined)
|
||||
if (!entry) return Promise.resolve(null)
|
||||
return getLabData(entry.id)
|
||||
})
|
||||
const homeStats = createAsync((): Promise<StatsHomeData | undefined> => getHomeData())
|
||||
const githubStars = createAsync(() => getGitHubStars())
|
||||
const [themePreference, setThemePreference] = createSignal<ThemePreference>("system")
|
||||
const labName = createMemo(() => lab()?.name ?? formatCatalogLabName(labParam()))
|
||||
@@ -144,15 +137,19 @@ export default function StatsLab() {
|
||||
<Header githubStars={githubStars() ?? "150K"} links={labHeaderLinks()} brandHref={import.meta.env.BASE_URL} />
|
||||
<div data-component="container">
|
||||
<div data-component="content">
|
||||
<Show when={page() !== undefined} fallback={<LabLoading />}>
|
||||
<Show when={lab()} fallback={<LabNotFound lab={labParam()} labs={page()?.labs ?? []} />}>
|
||||
<Show when={catalog() !== undefined} fallback={<LabLoading />}>
|
||||
<Show when={lab()} fallback={<LabNotFound lab={labParam()} labs={catalog()?.labs ?? []} />}>
|
||||
{(data) => (
|
||||
<>
|
||||
<LabHero lab={data()} labs={page()?.labs ?? []} />
|
||||
<LabHero lab={data()} labs={catalog()?.labs ?? []} />
|
||||
<LabOverview lab={data()} data={stats() ?? null} />
|
||||
<LabUsageSection lab={data()} data={stats() ?? null} />
|
||||
<LabModelsSection lab={data()} usage={stats()?.models ?? []} />
|
||||
<LabRelatedSection lab={data()} labs={page()?.labs ?? []} market={page()?.market ?? []} />
|
||||
<LabRelatedSection
|
||||
lab={data()}
|
||||
labs={catalog()?.labs ?? []}
|
||||
market={homeStats()?.market["2M"] ?? []}
|
||||
/>
|
||||
<ComparisonCardsSection
|
||||
pairs={labComparisonPairs(data(), stats()?.models ?? [])}
|
||||
title={`${data().name} Model Comparisons`}
|
||||
@@ -185,7 +182,7 @@ function LabLoading() {
|
||||
)
|
||||
}
|
||||
|
||||
function LabNotFound(props: { lab: string; labs: RelatedCatalogLab[] }) {
|
||||
function LabNotFound(props: { lab: string; labs: ModelCatalogLab[] }) {
|
||||
const i18n = useI18n()
|
||||
const labName = () => formatCatalogLabName(props.lab)
|
||||
return (
|
||||
@@ -197,7 +194,7 @@ function LabNotFound(props: { lab: string; labs: RelatedCatalogLab[] }) {
|
||||
)
|
||||
}
|
||||
|
||||
function LabHero(props: { lab: ModelCatalogLab; labs: RelatedCatalogLab[] }) {
|
||||
function LabHero(props: { lab: ModelCatalogLab; labs: ModelCatalogLab[] }) {
|
||||
return (
|
||||
<section id="overview" data-section="lab-hero">
|
||||
<LabHeroBreadcrumb label={props.lab.name} labs={props.labs} />
|
||||
@@ -206,7 +203,7 @@ function LabHero(props: { lab: ModelCatalogLab; labs: RelatedCatalogLab[] }) {
|
||||
)
|
||||
}
|
||||
|
||||
function LabHeroBreadcrumb(props: { label: string; labs?: RelatedCatalogLab[] }) {
|
||||
function LabHeroBreadcrumb(props: { label: string; labs?: ModelCatalogLab[] }) {
|
||||
const language = useLanguage()
|
||||
const labs = () => props.labs ?? []
|
||||
const current = () => labs().find((lab) => lab.name === props.label)
|
||||
@@ -671,7 +668,7 @@ function LabModelTooltip(props: { state: LabModelTooltipState }) {
|
||||
)
|
||||
}
|
||||
|
||||
function LabRelatedSection(props: { lab: ModelCatalogLab; labs: RelatedCatalogLab[]; market: MarketDay[] }) {
|
||||
function LabRelatedSection(props: { lab: ModelCatalogLab; labs: ModelCatalogLab[]; market: MarketDay[] }) {
|
||||
const related = createMemo(() => relatedLabs(props.lab, props.labs, props.market))
|
||||
return (
|
||||
<section id="related-labs" data-section="model-panel" data-variant="lab-related">
|
||||
@@ -760,9 +757,9 @@ function labComparisonPairs(lab: ModelCatalogLab, usage: LabUsageModelEntry[]) {
|
||||
)
|
||||
}
|
||||
|
||||
type RelatedLabEntry = { lab: RelatedCatalogLab; share: number; tokens: number }
|
||||
type RelatedLabEntry = { lab: ModelCatalogLab; share: number; tokens: number }
|
||||
|
||||
function relatedLabs(current: ModelCatalogLab, labs: RelatedCatalogLab[], market: MarketDay[]): RelatedLabEntry[] {
|
||||
function relatedLabs(current: ModelCatalogLab, labs: ModelCatalogLab[], market: MarketDay[]): RelatedLabEntry[] {
|
||||
const stats = relatedLabStats(labs, market)
|
||||
return labs
|
||||
.filter((lab) => lab.id !== current.id)
|
||||
@@ -771,8 +768,8 @@ function relatedLabs(current: ModelCatalogLab, labs: RelatedCatalogLab[], market
|
||||
.slice(0, 3)
|
||||
}
|
||||
|
||||
function relatedLabStats(labs: RelatedCatalogLab[], market: MarketDay[]) {
|
||||
const labByKey = new Map<string, RelatedCatalogLab>()
|
||||
function relatedLabStats(labs: ModelCatalogLab[], market: MarketDay[]) {
|
||||
const labByKey = new Map<string, ModelCatalogLab>()
|
||||
labs.forEach((lab) => {
|
||||
labByKey.set(lab.id, lab)
|
||||
labByKey.set(catalogSlug(lab.name), lab)
|
||||
@@ -797,7 +794,7 @@ function relatedLabStats(labs: RelatedCatalogLab[], market: MarketDay[]) {
|
||||
)
|
||||
}
|
||||
|
||||
function labRelatedDescription(lab: RelatedCatalogLab) {
|
||||
function labRelatedDescription(lab: ModelCatalogLab) {
|
||||
return lab.description ?? ""
|
||||
}
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ export type ComparisonPair = {
|
||||
description?: string
|
||||
}
|
||||
|
||||
export function modelRefFromCatalog(entry: Pick<ModelCatalogEntry, "name" | "lab" | "slug">): ComparisonModelRef {
|
||||
export function modelRefFromCatalog(entry: ModelCatalogEntry): ComparisonModelRef {
|
||||
return {
|
||||
name: entry.name,
|
||||
lab: entry.lab,
|
||||
|
||||
@@ -1,120 +0,0 @@
|
||||
import { geoEquirectangular, geoPath } from "d3-geo"
|
||||
import { feature, mesh } from "topojson-client"
|
||||
import countriesTopologySource from "world-atlas/countries-110m.json?raw"
|
||||
import type { FeatureCollection, GeometryObject, GeoJsonProperties } from "geojson"
|
||||
import type { GeometryCollection, Topology } from "topojson-specification"
|
||||
|
||||
export const geoMapWidth = 960
|
||||
export const geoMapHeight = 430
|
||||
|
||||
type WorldCountryProperties = GeoJsonProperties & { name?: string }
|
||||
type WorldTopology = Topology<{ countries: GeometryCollection<WorldCountryProperties> }>
|
||||
|
||||
const worldTopology = JSON.parse(countriesTopologySource) as WorldTopology
|
||||
const worldCountryGeometries: GeometryCollection<WorldCountryProperties> = {
|
||||
...worldTopology.objects.countries,
|
||||
geometries: worldTopology.objects.countries.geometries.filter((country) => String(country.id ?? "") !== "010"),
|
||||
}
|
||||
const worldCountries = feature<WorldCountryProperties>(worldTopology, worldCountryGeometries) as FeatureCollection<
|
||||
GeometryObject,
|
||||
WorldCountryProperties
|
||||
>
|
||||
const worldProjection = geoEquirectangular().fitExtent(
|
||||
[
|
||||
[10, 12],
|
||||
[geoMapWidth - 10, geoMapHeight - 12],
|
||||
],
|
||||
worldCountries,
|
||||
)
|
||||
const worldPath = geoPath(worldProjection)
|
||||
|
||||
export const worldCountryPaths = worldCountries.features.map((country) => ({
|
||||
id: String(country.id ?? "").padStart(3, "0"),
|
||||
path: worldPath(country) ?? "",
|
||||
}))
|
||||
|
||||
export const worldBorderPath = worldPath(mesh(worldTopology, worldCountryGeometries, (a, b) => a !== b)) ?? ""
|
||||
|
||||
function geoCountryMarker(country: (typeof worldCountries.features)[number]) {
|
||||
const bounds = worldPath.bounds(country)
|
||||
const [x, y] = worldPath.centroid(country)
|
||||
if (!Number.isFinite(x) || !Number.isFinite(y)) return undefined
|
||||
if (bounds[1][0] - bounds[0][0] >= 3 && bounds[1][1] - bounds[0][1] >= 3) return undefined
|
||||
return { x, y }
|
||||
}
|
||||
|
||||
// The 110m topology omits small regions. Geographic centroids keep those countries interactive without shipping 50m paths.
|
||||
const fallbackCountryMarkerCoordinates = [
|
||||
["016", -170.7179, -14.3046],
|
||||
["020", 1.5606, 42.542],
|
||||
["028", -61.7945, 17.2762],
|
||||
["048", 50.5425, 26.0417],
|
||||
["052", -59.5602, 13.1811],
|
||||
["060", -64.7558, 32.3131],
|
||||
["086", 72.4453, -7.3312],
|
||||
["092", -64.4704, 18.5276],
|
||||
["132", -23.9576, 15.9551],
|
||||
["136", -80.9129, 19.43],
|
||||
["174", 43.6844, -11.879],
|
||||
["184", -159.7871, -21.2195],
|
||||
["212", -61.3576, 15.4394],
|
||||
["234", -6.8808, 62.0527],
|
||||
["239", -36.4863, -54.4641],
|
||||
["248", 19.9528, 60.2153],
|
||||
["258", -144.8045, -14.7283],
|
||||
["296", -167.9217, 0.893],
|
||||
["308", -61.6818, 12.1174],
|
||||
["316", 144.767, 13.4406],
|
||||
["334", 73.52, -53.0872],
|
||||
["336", 12.4343, 41.9021],
|
||||
["344", 114.1143, 22.3983],
|
||||
["438", 9.5357, 47.1367],
|
||||
["446", 113.509, 22.2231],
|
||||
["462", 73.4573, 3.7316],
|
||||
["470", 14.405, 35.9215],
|
||||
["480", 57.5714, -20.2779],
|
||||
["492", 7.4073, 43.7526],
|
||||
["500", -62.1856, 16.7404],
|
||||
["520", 166.9326, -0.5189],
|
||||
["531", -68.9721, 12.1957],
|
||||
["533", -69.9827, 12.521],
|
||||
["534", -63.0572, 18.0509],
|
||||
["570", -169.8704, -19.0489],
|
||||
["574", 167.9497, -29.0516],
|
||||
["580", 145.6193, 15.8288],
|
||||
["583", 153.2966, 7.5361],
|
||||
["584", 170.3313, 7.015],
|
||||
["585", 134.4056, 7.286],
|
||||
["612", -128.3167, -24.3649],
|
||||
["652", -62.841, 17.8988],
|
||||
["654", -9.7009, -12.3548],
|
||||
["659", -62.6873, 17.2647],
|
||||
["660", -63.066, 18.2243],
|
||||
["662", -60.9696, 13.8946],
|
||||
["663", -63.0599, 18.0888],
|
||||
["666", -56.3037, 46.9187],
|
||||
["670", -61.2008, 13.2251],
|
||||
["674", 12.4594, 43.9415],
|
||||
["678", 6.7235, 0.4434],
|
||||
["690", 55.476, -4.6601],
|
||||
["702", 103.817, 1.359],
|
||||
["776", -174.7998, -20.4161],
|
||||
["796", -71.9734, 21.8312],
|
||||
["831", -2.5726, 49.4678],
|
||||
["832", -2.1272, 49.2181],
|
||||
["833", -4.5388, 54.224],
|
||||
["850", -64.8028, 17.9555],
|
||||
["876", -177.3469, -13.8898],
|
||||
["882", -172.1649, -13.7536],
|
||||
] as const
|
||||
|
||||
export const worldCountryMarkers = [
|
||||
...worldCountries.features.flatMap((country) => {
|
||||
const marker = geoCountryMarker(country)
|
||||
return marker ? [{ id: String(country.id ?? "").padStart(3, "0"), marker }] : []
|
||||
}),
|
||||
...fallbackCountryMarkerCoordinates.flatMap(([id, longitude, latitude]) => {
|
||||
const marker = worldProjection([longitude, latitude])
|
||||
return marker ? [{ id, marker: { x: marker[0], y: marker[1] } }] : []
|
||||
}),
|
||||
]
|
||||
@@ -1,7 +1,10 @@
|
||||
import { Link, Meta, Title } from "@solidjs/meta"
|
||||
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
|
||||
import { geoEquirectangular, geoPath } from "d3-geo"
|
||||
import { scaleSqrt } from "d3-scale"
|
||||
import countryCodesSource from "i18n-iso-countries/codes.json?raw"
|
||||
import { feature, mesh } from "topojson-client"
|
||||
import countriesTopologySource from "world-atlas/countries-50m.json?raw"
|
||||
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"
|
||||
@@ -12,6 +15,7 @@ import {
|
||||
type CountryEntry,
|
||||
type LeaderboardEntry,
|
||||
type MarketDay,
|
||||
type StatsHomeData,
|
||||
type SessionCostEntry,
|
||||
type TokenCostEntry,
|
||||
type UsagePoint,
|
||||
@@ -19,13 +23,14 @@ import {
|
||||
import { createAsync, query } from "@solidjs/router"
|
||||
import { createEffect, createMemo, createSignal, For, onCleanup, onMount, Show, type JSX } from "solid-js"
|
||||
import { getRequestEvent } from "solid-js/web"
|
||||
import type { FeatureCollection, GeometryObject, GeoJsonProperties } from "geojson"
|
||||
import type { GeometryCollection, Topology } from "topojson-specification"
|
||||
import { runStatsEffect } from "../stats-runtime"
|
||||
import { LocaleLinks } from "../component/locale-links"
|
||||
import { useI18n } from "../context/i18n"
|
||||
import { useLanguage } from "../context/language"
|
||||
import { localizedUrl } from "../lib/language"
|
||||
import { findModelCatalogEntry, loadModelCatalog, type ModelCatalog } from "./model-catalog"
|
||||
import { geoMapHeight, geoMapWidth, worldBorderPath, worldCountryMarkers, worldCountryPaths } from "./geo-map"
|
||||
import { findModelCatalogEntry, getModelCatalog, type ModelCatalog } from "./model-catalog"
|
||||
import { SectionHeading } from "./section-heading"
|
||||
import { setStatsPageCacheHeaders } from "./stats-cache"
|
||||
import { ComparisonCardsSection, uniqueComparisonPairs, type ComparisonModelRef } from "./compare-cards"
|
||||
@@ -40,6 +45,9 @@ import {
|
||||
type ThemePreference,
|
||||
} from "./stats-shell"
|
||||
|
||||
const products = ["All Users", "Zen", "Go"] as const
|
||||
const tokenProducts = ["Zen", "Go"] as const
|
||||
const ranges = ["1D", "1W", "2W", "1M", "2M"] as const
|
||||
const comparisonPairIndexes = [
|
||||
[0, 1, "Top two by recent usage"],
|
||||
[0, 2, "Leader vs challenger"],
|
||||
@@ -61,40 +69,60 @@ const usageColors = [
|
||||
"#ff6467",
|
||||
]
|
||||
const marketColors = ["#ed6aff", "#a684ff", "#7c86ff", "#51a2ff", "#00d3f2", "#00d5be", "#00bc7d", "#9ae600", "#ffb900"]
|
||||
const geoMapWidth = 960
|
||||
const geoMapHeight = 430
|
||||
|
||||
type UsageRange = "1D" | "1W" | "2W" | "1M" | "2M"
|
||||
type UsageProduct = (typeof products)[number]
|
||||
type TokenProduct = (typeof tokenProducts)[number]
|
||||
type UsageRange = (typeof ranges)[number]
|
||||
type IsoCountryCode = readonly [string, string, string]
|
||||
type WorldCountryProperties = GeoJsonProperties & { name?: string }
|
||||
type WorldTopology = Topology<{ countries: GeometryCollection<WorldCountryProperties> }>
|
||||
|
||||
type StatsHomePageData = {
|
||||
updatedAt: string | null
|
||||
usage: UsagePoint[]
|
||||
users: UsagePoint[]
|
||||
leaderboard: LeaderboardEntry[]
|
||||
market: MarketDay[]
|
||||
tokenCost: TokenCostEntry[]
|
||||
cacheRatio: CacheRatioEntry[]
|
||||
sessionCost: SessionCostEntry[]
|
||||
country: CountryEntry[]
|
||||
function productLabel(product: UsageProduct | TokenProduct, i18n: ReturnType<typeof useI18n>) {
|
||||
if (product === "All Users") return i18n.t("product.allUsers")
|
||||
if (product === "Zen") return i18n.t("product.zen")
|
||||
return i18n.t("product.go")
|
||||
}
|
||||
|
||||
function rangeLabel(range: UsageRange, i18n: ReturnType<typeof useI18n>) {
|
||||
if (range === "1D") return i18n.t("range.1D")
|
||||
if (range === "1W") return i18n.t("range.1W")
|
||||
if (range === "2W") return i18n.t("range.2W")
|
||||
if (range === "1M") return i18n.t("range.1M")
|
||||
return i18n.t("range.2M")
|
||||
}
|
||||
|
||||
const countryNumericIds = new Map(
|
||||
(JSON.parse(countryCodesSource) as IsoCountryCode[]).map((country) => [country[0], country[2]] as const),
|
||||
)
|
||||
const worldTopology = JSON.parse(countriesTopologySource) as WorldTopology
|
||||
const worldCountryGeometries: GeometryCollection<WorldCountryProperties> = {
|
||||
...worldTopology.objects.countries,
|
||||
geometries: worldTopology.objects.countries.geometries.filter((country) => String(country.id ?? "") !== "010"),
|
||||
}
|
||||
const worldCountries = feature<WorldCountryProperties>(worldTopology, worldCountryGeometries) as FeatureCollection<
|
||||
GeometryObject,
|
||||
WorldCountryProperties
|
||||
>
|
||||
const worldProjection = geoEquirectangular().fitExtent(
|
||||
[
|
||||
[10, 12],
|
||||
[geoMapWidth - 10, geoMapHeight - 12],
|
||||
],
|
||||
worldCountries,
|
||||
)
|
||||
const worldPath = geoPath(worldProjection)
|
||||
const worldCountryPaths = worldCountries.features.map((country) => ({
|
||||
id: String(country.id ?? "").padStart(3, "0"),
|
||||
path: worldPath(country) ?? "",
|
||||
marker: geoCountryMarker(country),
|
||||
}))
|
||||
const worldBorderPath = worldPath(mesh(worldTopology, worldCountryGeometries, (a, b) => a !== b)) ?? ""
|
||||
|
||||
const getData = query(async () => {
|
||||
"use server"
|
||||
const [stats, catalog] = await Promise.all([runStatsEffect(getStatsHomeData()), loadModelCatalog()])
|
||||
return {
|
||||
updatedAt: stats.updatedAt,
|
||||
usage: stats.usage.Go["2M"],
|
||||
users: stats.users.Go["2M"],
|
||||
leaderboard: stats.leaderboard.Go["2M"],
|
||||
market: stats.market["2M"],
|
||||
tokenCost: priceTokenCostFromCatalog(stats.tokenCost.Go, catalog),
|
||||
cacheRatio: stats.cacheRatio.Go,
|
||||
sessionCost: stats.sessionCost.Go,
|
||||
country: stats.country["2M"],
|
||||
} satisfies StatsHomePageData
|
||||
return runStatsEffect(getStatsHomeData())
|
||||
}, "getStatsHomeData")
|
||||
|
||||
export default function StatsHome() {
|
||||
@@ -105,6 +133,7 @@ export default function StatsHome() {
|
||||
const statsHomeUrl = localizedUrl(language.locale(), "/data/")
|
||||
const statsUnfurlUrl = new URL(statsUnfurlPath, localizedUrl("en", "/data/")).toString()
|
||||
const data = createAsync(() => getData())
|
||||
const catalog = createAsync(() => getModelCatalog())
|
||||
const githubStars = createAsync(() => getGitHubStars())
|
||||
const [themePreference, setThemePreference] = createSignal<ThemePreference>("system")
|
||||
const updateThemePreference = (preference: ThemePreference) => {
|
||||
@@ -156,12 +185,12 @@ export default function StatsHome() {
|
||||
<TopModelsSection data={stats().usage} leaderboard={stats().leaderboard} />
|
||||
<UniqueUsersSection data={stats().users} />
|
||||
<SessionCostSection data={stats().sessionCost} />
|
||||
<TokenCostSection data={stats().tokenCost} />
|
||||
<TokenCostSection data={stats().tokenCost} catalog={catalog() ?? null} />
|
||||
<CacheRatioSection data={stats().cacheRatio} />
|
||||
<MarketShareSection data={stats().market} />
|
||||
<GeoBreakdownSection data={stats().country} />
|
||||
<ComparisonCardsSection
|
||||
pairs={homeComparisonPairs(stats().leaderboard)}
|
||||
pairs={homeComparisonPairs(stats().leaderboard["All Users"]["2M"])}
|
||||
title="Model Comparisons"
|
||||
description="Popular model pairs from the leaderboard."
|
||||
variant="featured"
|
||||
@@ -372,9 +401,32 @@ function formatUpdatedAtLabel(value: { date: string; time: string }) {
|
||||
return `${value.date}, ${value.time}`
|
||||
}
|
||||
|
||||
function TopModelsSection(props: { data: UsagePoint[]; leaderboard: LeaderboardEntry[] }) {
|
||||
function TopModelsSection(props: { data: StatsHomeData["usage"]; leaderboard: StatsHomeData["leaderboard"] }) {
|
||||
const i18n = useI18n()
|
||||
const [product, setProduct] = createSignal<UsageProduct>("Go")
|
||||
const [range, setRange] = createSignal<UsageRange>("2M")
|
||||
const [sheet, setSheet] = createSignal<"product" | "range">()
|
||||
const [activeModel, setActiveModel] = createSignal<string>()
|
||||
const data = createMemo(() => props.data[product()][range()])
|
||||
const leaderboard = createMemo(() => props.leaderboard[product()][range()])
|
||||
|
||||
createEffect(() => {
|
||||
if (!sheet()) return
|
||||
if (typeof document === "undefined") return
|
||||
const htmlOverflow = document.documentElement.style.overflow
|
||||
const bodyOverflow = document.body.style.overflow
|
||||
document.documentElement.style.overflow = "hidden"
|
||||
document.body.style.overflow = "hidden"
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") setSheet(undefined)
|
||||
}
|
||||
document.addEventListener("keydown", onKeyDown)
|
||||
onCleanup(() => {
|
||||
document.documentElement.style.overflow = htmlOverflow
|
||||
document.body.style.overflow = bodyOverflow
|
||||
document.removeEventListener("keydown", onKeyDown)
|
||||
})
|
||||
})
|
||||
|
||||
return (
|
||||
<section id="top-models" data-section="top-models">
|
||||
@@ -386,23 +438,201 @@ function TopModelsSection(props: { data: UsagePoint[]; leaderboard: LeaderboardE
|
||||
description={i18n.t("home.topModelsDescription")}
|
||||
/>
|
||||
<Show
|
||||
when={props.data.some((item) => usageTotal(item) > 0)}
|
||||
when={data().some((item) => usageTotal(item) > 0)}
|
||||
fallback={<EmptyState title={i18n.t("home.noUsageTitle")} description={i18n.t("home.noUsageDescription")} />}
|
||||
>
|
||||
<TopModelsChart data={props.data} range="2M" activeModel={activeModel()} onActiveModelChange={setActiveModel} />
|
||||
<TopModelsChart
|
||||
data={data()}
|
||||
range={range()}
|
||||
activeModel={activeModel()}
|
||||
onActiveModelChange={setActiveModel}
|
||||
/>
|
||||
</Show>
|
||||
<Show
|
||||
when={props.leaderboard.length > 0}
|
||||
when={leaderboard().length > 0}
|
||||
fallback={
|
||||
<EmptyState title={i18n.t("home.noLeaderboardTitle")} description={i18n.t("home.noLeaderboardDescription")} />
|
||||
}
|
||||
>
|
||||
<Leaderboard data={props.leaderboard} activeModel={activeModel()} onActiveModelChange={setActiveModel} />
|
||||
<Leaderboard data={leaderboard()} activeModel={activeModel()} onActiveModelChange={setActiveModel} />
|
||||
</Show>
|
||||
<div data-slot="chart-footer" hidden>
|
||||
<StatsFilters product={product()} range={range()} onProductSelect={setProduct} onRangeSelect={setRange} />
|
||||
<div data-slot="top-models-mobile-controls">
|
||||
<MobileFilterButton
|
||||
label={i18n.t("home.productFilter")}
|
||||
value={productLabel(product(), i18n)}
|
||||
expanded={sheet() === "product"}
|
||||
onClick={() => setSheet(sheet() === "product" ? undefined : "product")}
|
||||
/>
|
||||
<MobileFilterButton
|
||||
label={i18n.t("home.dateRange")}
|
||||
value={rangeLabel(range(), i18n)}
|
||||
expanded={sheet() === "range"}
|
||||
onClick={() => setSheet(sheet() === "range" ? undefined : "range")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Show when={sheet()}>
|
||||
{(kind) => (
|
||||
<MobileFilterSheet
|
||||
kind={kind()}
|
||||
product={product()}
|
||||
range={range()}
|
||||
onProductSelect={(value) => {
|
||||
setProduct(value)
|
||||
setSheet(undefined)
|
||||
}}
|
||||
onRangeSelect={(value) => {
|
||||
setRange(value)
|
||||
setSheet(undefined)
|
||||
}}
|
||||
onClose={() => setSheet(undefined)}
|
||||
/>
|
||||
)}
|
||||
</Show>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function MobileFilterButton(props: { label: string; value: string; expanded: boolean; onClick: () => void }) {
|
||||
return (
|
||||
<button
|
||||
data-slot="mobile-filter-button"
|
||||
type="button"
|
||||
aria-label={props.label}
|
||||
aria-expanded={props.expanded ? "true" : "false"}
|
||||
onClick={props.onClick}
|
||||
>
|
||||
<span>{props.value}</span>
|
||||
<ChevronDown />
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function MobileFilterSheet(props: {
|
||||
kind: "product" | "range"
|
||||
product: UsageProduct
|
||||
range: UsageRange
|
||||
onProductSelect: (product: UsageProduct) => void
|
||||
onRangeSelect: (range: UsageRange) => void
|
||||
onClose: () => void
|
||||
}) {
|
||||
const i18n = useI18n()
|
||||
return (
|
||||
<div data-component="mobile-filter-sheet" role="presentation" onClick={props.onClose}>
|
||||
<div
|
||||
data-slot="filter-sheet-panel"
|
||||
role="radiogroup"
|
||||
aria-label={props.kind === "product" ? i18n.t("home.productFilter") : i18n.t("home.dateRange")}
|
||||
>
|
||||
<Show
|
||||
when={props.kind === "product"}
|
||||
fallback={
|
||||
<For each={ranges}>
|
||||
{(item) => (
|
||||
<button
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={props.range === item}
|
||||
data-active={props.range === item ? "true" : undefined}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
props.onRangeSelect(item)
|
||||
}}
|
||||
>
|
||||
{rangeLabel(item, i18n)}
|
||||
</button>
|
||||
)}
|
||||
</For>
|
||||
}
|
||||
>
|
||||
<For each={products}>
|
||||
{(item) => (
|
||||
<button
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={props.product === item}
|
||||
data-active={props.product === item ? "true" : undefined}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
props.onProductSelect(item)
|
||||
}}
|
||||
>
|
||||
{productLabel(item, i18n)}
|
||||
</button>
|
||||
)}
|
||||
</For>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ChevronDown() {
|
||||
return (
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" aria-hidden="true" fill="none">
|
||||
<path d="M5 7L8 10L11 7" stroke="currentColor" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function StatsFilters(props: {
|
||||
product: UsageProduct
|
||||
range: UsageRange
|
||||
onProductSelect: (product: UsageProduct) => void
|
||||
onRangeSelect: (range: UsageRange) => void
|
||||
}) {
|
||||
const i18n = useI18n()
|
||||
return (
|
||||
<>
|
||||
<FilterPills
|
||||
items={products}
|
||||
selected={props.product}
|
||||
label={i18n.t("home.productFilter")}
|
||||
variant="product"
|
||||
formatLabel={(item) => productLabel(item, i18n)}
|
||||
onSelect={props.onProductSelect}
|
||||
/>
|
||||
<FilterPills
|
||||
items={ranges}
|
||||
selected={props.range}
|
||||
label={i18n.t("home.dateRange")}
|
||||
variant="range"
|
||||
formatLabel={(item) => rangeLabel(item, i18n)}
|
||||
onSelect={props.onRangeSelect}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function FilterPills<T extends string>(props: {
|
||||
items: readonly T[]
|
||||
selected: T
|
||||
label: string
|
||||
variant: "product" | "range"
|
||||
formatLabel?: (item: T) => string
|
||||
onSelect: (item: T) => void
|
||||
}) {
|
||||
return (
|
||||
<div data-component="usage-filter" data-variant={props.variant} role="radiogroup" aria-label={props.label}>
|
||||
<For each={props.items}>
|
||||
{(item) => (
|
||||
<button
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={props.selected === item}
|
||||
data-active={props.selected === item ? "true" : undefined}
|
||||
onClick={() => props.onSelect(item)}
|
||||
>
|
||||
{props.formatLabel ? props.formatLabel(item) : item}
|
||||
</button>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function TopModelsChart(props: {
|
||||
data: UsagePoint[]
|
||||
range: UsageRange
|
||||
@@ -593,9 +823,10 @@ function TopModelsChart(props: {
|
||||
)
|
||||
}
|
||||
|
||||
function UniqueUsersSection(props: { data: UsagePoint[] }) {
|
||||
function UniqueUsersSection(props: { data: StatsHomeData["users"] }) {
|
||||
const i18n = useI18n()
|
||||
const [activeModel, setActiveModel] = createSignal<string>()
|
||||
const data = createMemo(() => props.data.Go["2M"])
|
||||
|
||||
return (
|
||||
<section id="unique-users" data-section="unique-users">
|
||||
@@ -606,13 +837,13 @@ function UniqueUsersSection(props: { data: UsagePoint[] }) {
|
||||
description={i18n.t("home.uniqueUsersDescription")}
|
||||
/>
|
||||
<Show
|
||||
when={props.data.some((item) => usageTotal(item) > 0)}
|
||||
when={data().some((item) => usageTotal(item) > 0)}
|
||||
fallback={
|
||||
<EmptyState title={i18n.t("home.noUserDataTitle")} description={i18n.t("home.noUserDataDescription")} />
|
||||
}
|
||||
>
|
||||
<TopModelsChart
|
||||
data={props.data}
|
||||
data={data()}
|
||||
range="2M"
|
||||
metric="users"
|
||||
ariaLabel={i18n.t("home.uniqueUsersChart")}
|
||||
@@ -853,14 +1084,16 @@ function formatChange(value: number | null, i18n: ReturnType<typeof useI18n>) {
|
||||
return `${value}%`
|
||||
}
|
||||
|
||||
function MarketShareSection(props: { data: MarketDay[] }) {
|
||||
function MarketShareSection(props: { data: StatsHomeData["market"] }) {
|
||||
const i18n = useI18n()
|
||||
const [range, setRange] = createSignal<UsageRange>("2M")
|
||||
const [activeIndex, setActiveIndex] = createSignal(2)
|
||||
const [activeAuthor, setActiveAuthor] = createSignal<string>()
|
||||
const [inspecting, setInspecting] = createSignal(false)
|
||||
const authorOrder = createMemo(() => getMarketAuthorOrder(props.data))
|
||||
const selectedIndex = createMemo(() => Math.min(activeIndex(), Math.max(props.data.length - 1, 0)))
|
||||
const activeDay = createMemo(() => props.data[selectedIndex()])
|
||||
const data = createMemo(() => props.data[range()])
|
||||
const authorOrder = createMemo(() => getMarketAuthorOrder(data()))
|
||||
const selectedIndex = createMemo(() => Math.min(activeIndex(), Math.max(data().length - 1, 0)))
|
||||
const activeDay = createMemo(() => data()[selectedIndex()])
|
||||
|
||||
return (
|
||||
<section
|
||||
@@ -885,8 +1118,8 @@ function MarketShareSection(props: { data: MarketDay[] }) {
|
||||
{(day) => (
|
||||
<>
|
||||
<MarketShare
|
||||
data={props.data}
|
||||
range="2M"
|
||||
data={data()}
|
||||
range={range()}
|
||||
authorOrder={authorOrder()}
|
||||
activeIndex={selectedIndex()}
|
||||
activeAuthor={activeAuthor()}
|
||||
@@ -918,9 +1151,22 @@ function MarketShareSection(props: { data: MarketDay[] }) {
|
||||
<strong>
|
||||
{inspecting()
|
||||
? formatMarketDate(activeDay(), i18n.t("home.noData"))
|
||||
: formatMarketRange(props.data, i18n.t("home.noData"))}
|
||||
: formatMarketRange(data(), i18n.t("home.noData"))}
|
||||
</strong>
|
||||
</p>
|
||||
<div hidden>
|
||||
<FilterPills
|
||||
items={ranges}
|
||||
selected={range()}
|
||||
label={i18n.t("home.dateRange")}
|
||||
variant="range"
|
||||
onSelect={(item) => {
|
||||
setRange(item)
|
||||
setActiveAuthor(undefined)
|
||||
setInspecting(false)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
@@ -1087,22 +1333,23 @@ function MarketShareList(props: {
|
||||
)
|
||||
}
|
||||
|
||||
function GeoBreakdownSection(props: { data: CountryEntry[] }) {
|
||||
function GeoBreakdownSection(props: { data: StatsHomeData["country"] }) {
|
||||
const i18n = useI18n()
|
||||
const language = useLanguage()
|
||||
const [activeCountry, setActiveCountry] = createSignal<string>()
|
||||
const data = createMemo(() => props.data["2M"])
|
||||
const countryById = createMemo(
|
||||
() =>
|
||||
new Map(
|
||||
props.data.flatMap((country) => {
|
||||
data().flatMap((country) => {
|
||||
const id = countryNumericId(country.country)
|
||||
return id ? [[id, country] as const] : []
|
||||
}),
|
||||
),
|
||||
)
|
||||
const maxTokens = createMemo(() => Math.max(0, ...props.data.map((country) => country.tokens)) || 1)
|
||||
const topCountries = createMemo(() => props.data.slice(0, 15))
|
||||
const active = createMemo(() => props.data.find((country) => country.country === activeCountry()) ?? props.data[0])
|
||||
const maxTokens = createMemo(() => Math.max(0, ...data().map((country) => country.tokens)) || 1)
|
||||
const topCountries = createMemo(() => data().slice(0, 15))
|
||||
const active = createMemo(() => data().find((country) => country.country === activeCountry()) ?? data()[0])
|
||||
|
||||
return (
|
||||
<section
|
||||
@@ -1116,7 +1363,7 @@ function GeoBreakdownSection(props: { data: CountryEntry[] }) {
|
||||
<SectionBridge label={i18n.t("nav.marketShare").toUpperCase()} href="#market-share" />
|
||||
<SectionTitle id="geo-breakdown" title={i18n.t("home.geoTitle")} description={i18n.t("home.geoDescription")} />
|
||||
<Show
|
||||
when={props.data.length > 0}
|
||||
when={data().length > 0}
|
||||
fallback={<EmptyState title={i18n.t("home.noGeoTitle")} description={i18n.t("home.noGeoDescription")} />}
|
||||
>
|
||||
<div data-component="geo-breakdown">
|
||||
@@ -1206,30 +1453,31 @@ function GeoWorldMap(props: {
|
||||
</For>
|
||||
</g>
|
||||
<g data-slot="geo-country-markers">
|
||||
<For each={worldCountryMarkers}>
|
||||
<For each={worldCountryPaths}>
|
||||
{(country) => {
|
||||
const entry = () => props.countryById.get(country.id)
|
||||
return (
|
||||
<Show when={entry()}>
|
||||
<circle
|
||||
cx={country.marker.x}
|
||||
cy={country.marker.y}
|
||||
data-country-id={country.id}
|
||||
r={entry()?.country === props.activeCountry ? 3.4 : 2.4}
|
||||
data-active={entry()?.country === props.activeCountry ? "true" : undefined}
|
||||
style={{ "--geo-country-opacity": String(countryOpacity(entry())) } as JSX.CSSProperties}
|
||||
aria-hidden="true"
|
||||
onPointerEnter={() => {
|
||||
const item = entry()
|
||||
if (!item) return
|
||||
props.onActiveCountryChange(item.country)
|
||||
}}
|
||||
onClick={() => {
|
||||
const item = entry()
|
||||
if (!item) return
|
||||
props.onActiveCountryChange(item.country)
|
||||
}}
|
||||
/>
|
||||
<Show when={country.marker && entry() ? country.marker : undefined}>
|
||||
{(marker) => (
|
||||
<circle
|
||||
cx={marker().x}
|
||||
cy={marker().y}
|
||||
r={entry()?.country === props.activeCountry ? 3.4 : 2.4}
|
||||
data-active={entry()?.country === props.activeCountry ? "true" : undefined}
|
||||
style={{ "--geo-country-opacity": String(countryOpacity(entry())) } as JSX.CSSProperties}
|
||||
aria-hidden="true"
|
||||
onPointerEnter={() => {
|
||||
const item = entry()
|
||||
if (!item) return
|
||||
props.onActiveCountryChange(item.country)
|
||||
}}
|
||||
onClick={() => {
|
||||
const item = entry()
|
||||
if (!item) return
|
||||
props.onActiveCountryChange(item.country)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Show>
|
||||
)
|
||||
}}
|
||||
@@ -1283,6 +1531,14 @@ function countryNumericId(country: string) {
|
||||
return countryNumericIds.get(country.toUpperCase())?.padStart(3, "0")
|
||||
}
|
||||
|
||||
function geoCountryMarker(country: (typeof worldCountries.features)[number]) {
|
||||
const bounds = worldPath.bounds(country)
|
||||
const [x, y] = worldPath.centroid(country)
|
||||
if (!Number.isFinite(x) || !Number.isFinite(y)) return undefined
|
||||
if (bounds[1][0] - bounds[0][0] >= 3 && bounds[1][1] - bounds[0][1] >= 3) return undefined
|
||||
return { x, y }
|
||||
}
|
||||
|
||||
function formatCountryName(country: string, locale: string, unknown: string) {
|
||||
const code = country.toUpperCase()
|
||||
if (code === "ZZ") return unknown
|
||||
@@ -1380,10 +1636,12 @@ function marketDateParts(label: string) {
|
||||
return { start: start ?? label, end: end ?? start ?? label }
|
||||
}
|
||||
|
||||
function TokenCostSection(props: { data: TokenCostEntry[] }) {
|
||||
function TokenCostSection(props: { data: StatsHomeData["tokenCost"]; catalog: ModelCatalog | null }) {
|
||||
const i18n = useI18n()
|
||||
const [product, setProduct] = createSignal<TokenProduct>("Go")
|
||||
const [activeIndex, setActiveIndex] = createSignal(2)
|
||||
const visible = createMemo(() => props.data.slice(0, 13))
|
||||
const data = createMemo(() => priceTokenCostFromCatalog(props.data[product()], props.catalog))
|
||||
const visible = createMemo(() => data().slice(0, 13))
|
||||
const selectedIndex = createMemo(() => Math.min(activeIndex(), Math.max(visible().length - 1, 0)))
|
||||
|
||||
return (
|
||||
@@ -1402,6 +1660,17 @@ function TokenCostSection(props: { data: TokenCostEntry[] }) {
|
||||
>
|
||||
<TokenCostChart data={visible()} activeIndex={selectedIndex()} onActiveIndexChange={setActiveIndex} />
|
||||
</Show>
|
||||
<div data-slot="token-footer" hidden>
|
||||
<FilterPills
|
||||
items={tokenProducts}
|
||||
selected={product()}
|
||||
label={i18n.t("home.productFilter")}
|
||||
variant="product"
|
||||
formatLabel={(item) => productLabel(item, i18n)}
|
||||
onSelect={setProduct}
|
||||
/>
|
||||
<LiveIndicator />
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -1454,10 +1723,12 @@ function TokenCostChart(props: {
|
||||
)
|
||||
}
|
||||
|
||||
function CacheRatioSection(props: { data: CacheRatioEntry[] }) {
|
||||
function CacheRatioSection(props: { data: StatsHomeData["cacheRatio"] }) {
|
||||
const i18n = useI18n()
|
||||
const [product, setProduct] = createSignal<TokenProduct>("Go")
|
||||
const [activeIndex, setActiveIndex] = createSignal(2)
|
||||
const visible = createMemo(() => props.data.slice(0, 16))
|
||||
const data = createMemo(() => props.data[product()])
|
||||
const visible = createMemo(() => data().slice(0, 16))
|
||||
const selectedIndex = createMemo(() => Math.min(activeIndex(), Math.max(visible().length - 1, 0)))
|
||||
|
||||
return (
|
||||
@@ -1474,6 +1745,17 @@ function CacheRatioSection(props: { data: CacheRatioEntry[] }) {
|
||||
>
|
||||
<CacheRatioChart data={visible()} activeIndex={selectedIndex()} onActiveIndexChange={setActiveIndex} />
|
||||
</Show>
|
||||
<div data-slot="token-footer" hidden>
|
||||
<FilterPills
|
||||
items={tokenProducts}
|
||||
selected={product()}
|
||||
label={i18n.t("home.productFilter")}
|
||||
variant="product"
|
||||
formatLabel={(item) => productLabel(item, i18n)}
|
||||
onSelect={setProduct}
|
||||
/>
|
||||
<LiveIndicator />
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -1571,10 +1853,12 @@ function MetricBar(props: { value: number; max: number; active: boolean }) {
|
||||
)
|
||||
}
|
||||
|
||||
function SessionCostSection(props: { data: SessionCostEntry[] }) {
|
||||
function SessionCostSection(props: { data: StatsHomeData["sessionCost"] }) {
|
||||
const i18n = useI18n()
|
||||
const [product, setProduct] = createSignal<TokenProduct>("Go")
|
||||
const [activeIndex, setActiveIndex] = createSignal(2)
|
||||
const visible = createMemo(() => props.data.slice(0, 16))
|
||||
const data = createMemo(() => props.data[product()])
|
||||
const visible = createMemo(() => data().slice(0, 16))
|
||||
const selectedIndex = createMemo(() => Math.min(activeIndex(), Math.max(visible().length - 1, 0)))
|
||||
|
||||
return (
|
||||
@@ -1593,6 +1877,17 @@ function SessionCostSection(props: { data: SessionCostEntry[] }) {
|
||||
>
|
||||
<SessionCostChart data={visible()} activeIndex={selectedIndex()} onActiveIndexChange={setActiveIndex} />
|
||||
</Show>
|
||||
<div data-slot="token-footer" hidden>
|
||||
<FilterPills
|
||||
items={tokenProducts}
|
||||
selected={product()}
|
||||
label={i18n.t("home.productFilter")}
|
||||
variant="product"
|
||||
formatLabel={(item) => productLabel(item, i18n)}
|
||||
onSelect={setProduct}
|
||||
/>
|
||||
<LiveIndicator />
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -1654,6 +1949,11 @@ function SessionCostChart(props: {
|
||||
)
|
||||
}
|
||||
|
||||
function LiveIndicator() {
|
||||
const i18n = useI18n()
|
||||
return <span data-component="live-filter">{i18n.t("chart.live")}</span>
|
||||
}
|
||||
|
||||
function formatTokenCount(value: number) {
|
||||
if (value >= 1_000_000) return `${Number((value / 1_000_000).toFixed(1))}M`
|
||||
return `${Math.round(value / 1_000)}K`
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode-ai/stats-core",
|
||||
"version": "1.18.14",
|
||||
"version": "1.18.13",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode-ai/stats-server",
|
||||
"version": "1.18.14",
|
||||
"version": "1.18.13",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode-ai/tui",
|
||||
"version": "1.18.14",
|
||||
"version": "1.18.13",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/ui",
|
||||
"version": "1.18.14",
|
||||
"version": "1.18.13",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"name": "@opencode-ai/web",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"version": "1.18.14",
|
||||
"version": "1.18.13",
|
||||
"scripts": {
|
||||
"dev": "astro dev",
|
||||
"dev:remote": "VITE_API_URL=https://api.opencode.ai astro dev",
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
diff --git a/dist/index.js b/dist/index.js
|
||||
index dca128d3a790378c51a24a16d92585178343b278..da75f9d64acd2b607abd15079ce2d03b7a8d675a 100644
|
||||
--- a/dist/index.js
|
||||
+++ b/dist/index.js
|
||||
@@ -696,7 +696,7 @@ var OpenAICompatibleChatLanguageModel = class {
|
||||
finishReason = { unified: "error", raw: void 0 };
|
||||
controller.enqueue({
|
||||
type: "error",
|
||||
- error: chunk.value.error.message
|
||||
+ error: chunk.value.error
|
||||
});
|
||||
return;
|
||||
}
|
||||
diff --git a/dist/index.mjs b/dist/index.mjs
|
||||
index 3b1e1b6bdec5032e3b4fa5ffbcc8cdf3dfe1cc40..eaffc446f80552ea0b26573b89daa4dfc7776e6e 100644
|
||||
--- a/dist/index.mjs
|
||||
+++ b/dist/index.mjs
|
||||
@@ -683,7 +683,7 @@ var OpenAICompatibleChatLanguageModel = class {
|
||||
finishReason = { unified: "error", raw: void 0 };
|
||||
controller.enqueue({
|
||||
type: "error",
|
||||
- error: chunk.value.error.message
|
||||
+ error: chunk.value.error
|
||||
});
|
||||
return;
|
||||
}
|
||||
diff --git a/src/chat/openai-compatible-chat-language-model.ts b/src/chat/openai-compatible-chat-language-model.ts
|
||||
index 8c622db23c2d9a7373701f5a1b0c2ba109e24602..643c3db68a6043e097edc1122e0eb53fd13495c5 100644
|
||||
--- a/src/chat/openai-compatible-chat-language-model.ts
|
||||
+++ b/src/chat/openai-compatible-chat-language-model.ts
|
||||
@@ -442,7 +442,7 @@ export class OpenAICompatibleChatLanguageModel implements LanguageModelV3 {
|
||||
finishReason = { unified: 'error', raw: undefined };
|
||||
controller.enqueue({
|
||||
type: 'error',
|
||||
- error: chunk.value.error.message,
|
||||
+ error: chunk.value.error,
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
"name": "opencode",
|
||||
"displayName": "opencode",
|
||||
"description": "opencode for VS Code",
|
||||
"version": "1.18.14",
|
||||
"version": "1.18.13",
|
||||
"publisher": "sst-dev",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
||||
Reference in New Issue
Block a user