Compare commits

..

34 Commits

Author SHA1 Message Date
opencode 58a27b95c1 release: v1.15.12 2026-05-28 19:34:35 +00:00
Frank 2eb0606998 zen: add opus 4.8 2026-05-28 13:48:05 -04:00
opencode-agent[bot] cc230feca1 chore: generate 2026-05-28 17:45:17 +00:00
Adam 3ce9b4be0d fix(stats): refine hero layout for responsive Figma match 2026-05-28 12:43:16 -05:00
Adam fff7781fa8 fix(stats): preload IBM Plex Mono weights 2026-05-28 12:43:15 -05:00
Adam 0a72298062 feat(stats): better hero 2026-05-28 12:43:15 -05:00
Adam 7c320fd463 feat(stats): better header 2026-05-28 12:43:15 -05:00
Aiden Cline 05e3c4ecee fix(opencode): enable adaptive reasoning for opus 4.7+ (#29769) 2026-05-28 12:19:34 -05:00
Filip 72d008bd5c fix(core): allow colons inside passwords (#29576)
Co-authored-by: Kit Langton <kit.langton@gmail.com>
2026-05-28 13:17:35 -04:00
Avnish Uba 202ec12056 docs: fix grammar in config.mdx (#29683)
Co-authored-by: dnsnav <dnsnav@gmail.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-05-28 11:25:34 -05:00
Aiden Cline af30b315b2 fix(tui): restore thinking spinner (#29765) 2026-05-28 11:24:45 -05:00
opencode-agent[bot] 425a4f584f chore: generate 2026-05-28 13:39:37 +00:00
Adam 7504daa602 fix(stats): batch honeycomb backfill 2026-05-28 08:37:49 -05:00
Adam 9ac0f3e9ac fix(stats): better stat table periods, provider/model norm 2026-05-28 08:37:48 -05:00
Adam cf2cd13fb8 chore: updated backfill 2026-05-28 08:37:48 -05:00
opencode-agent[bot] 6883309997 chore: generate 2026-05-28 13:24:10 +00:00
Shoubhit Dash c3562a4a51 test(acp): remove timing diagnostics (#29741) 2026-05-28 18:52:37 +05:30
opencode-agent[bot] 30a66dfc31 chore: generate 2026-05-28 11:51:48 +00:00
Victor Navarro fdc574ff81 feat: improve referral system (#29720) 2026-05-28 13:50:14 +02:00
opencode-agent[bot] 1e5ddbd812 chore: generate 2026-05-28 10:56:37 +00:00
Shoubhit Dash 3109060216 fix(acp): cover smoke parity gaps (#29719) 2026-05-28 16:25:00 +05:30
Shoubhit Dash 9031ce7b51 fix(acp): speed up acp-next warm switches (#29713) 2026-05-28 15:10:17 +05:30
Shoubhit Dash 2449b50585 fix(acp): improve acp-next first-session startup (#29709) 2026-05-28 14:53:47 +05:30
opencode-agent[bot] 66e6662440 chore: generate 2026-05-28 08:48:09 +00:00
Shoubhit Dash 56f3b9044d test(acp-next): add local verifier subprocess tests (#29700) 2026-05-28 14:16:49 +05:30
Luke Parker 913659890d fix(openai): keep websocket response timeouts active (#29699) 2026-05-28 18:28:01 +10:00
Aiden Cline 14e0b9b17f fix(openai): retry websocket stream failures (#29673) 2026-05-28 01:16:18 -05:00
opencode-agent[bot] e5524f5bf9 chore: generate 2026-05-28 05:15:21 +00:00
Shoubhit Dash 16234a0baf feat(acp): implement acp-next prompt (#29664) 2026-05-28 10:43:59 +05:30
Frank e5cf3ad3c2 zen: fix db issue 2026-05-28 00:50:15 -04:00
opencode-agent[bot] 82da3320ba chore: generate 2026-05-28 03:43:10 +00:00
Shoubhit Dash aa553dea94 fix(acp): handle acp-next permission events (#29656) 2026-05-28 09:11:49 +05:30
opencode-agent[bot] 0c9cad86ec chore: generate 2026-05-28 02:59:53 +00:00
James Long 69910f361c fix(server): use persisted session directory for existing-session routes (#29640) 2026-05-27 22:58:33 -04:00
114 changed files with 10130 additions and 3563 deletions
+23 -22
View File
@@ -29,7 +29,7 @@
},
"packages/app": {
"name": "@opencode-ai/app",
"version": "1.15.11",
"version": "1.15.12",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/core": "workspace:*",
@@ -85,7 +85,7 @@
},
"packages/console/app": {
"name": "@opencode-ai/console-app",
"version": "1.15.11",
"version": "1.15.12",
"dependencies": {
"@cloudflare/vite-plugin": "1.15.2",
"@ibm/plex": "6.4.1",
@@ -121,7 +121,7 @@
},
"packages/console/core": {
"name": "@opencode-ai/console-core",
"version": "1.15.11",
"version": "1.15.12",
"dependencies": {
"@aws-sdk/client-sts": "3.782.0",
"@jsx-email/render": "1.1.1",
@@ -148,7 +148,7 @@
},
"packages/console/function": {
"name": "@opencode-ai/console-function",
"version": "1.15.11",
"version": "1.15.12",
"dependencies": {
"@ai-sdk/anthropic": "3.0.64",
"@ai-sdk/openai": "3.0.48",
@@ -170,7 +170,7 @@
},
"packages/console/mail": {
"name": "@opencode-ai/console-mail",
"version": "1.15.11",
"version": "1.15.12",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
@@ -194,7 +194,7 @@
},
"packages/console/support": {
"name": "@opencode-ai/console-support",
"version": "1.15.11",
"version": "1.15.12",
"dependencies": {
"@cloudflare/vite-plugin": "1.15.2",
"@opencode-ai/console-core": "workspace:*",
@@ -214,7 +214,7 @@
},
"packages/core": {
"name": "@opencode-ai/core",
"version": "1.15.11",
"version": "1.15.12",
"bin": {
"opencode": "./bin/opencode",
},
@@ -275,7 +275,7 @@
},
"packages/desktop": {
"name": "@opencode-ai/desktop",
"version": "1.15.11",
"version": "1.15.12",
"dependencies": {
"@zip.js/zip.js": "2.7.62",
"drizzle-orm": "catalog:",
@@ -330,7 +330,7 @@
},
"packages/effect-drizzle-sqlite": {
"name": "@opencode-ai/effect-drizzle-sqlite",
"version": "1.15.11",
"version": "1.15.12",
"dependencies": {
"drizzle-orm": "catalog:",
"effect": "catalog:",
@@ -344,7 +344,7 @@
},
"packages/enterprise": {
"name": "@opencode-ai/enterprise",
"version": "1.15.11",
"version": "1.15.12",
"dependencies": {
"@opencode-ai/core": "workspace:*",
"@opencode-ai/ui": "workspace:*",
@@ -374,7 +374,7 @@
},
"packages/function": {
"name": "@opencode-ai/function",
"version": "1.15.11",
"version": "1.15.12",
"dependencies": {
"@octokit/auth-app": "8.0.1",
"@octokit/rest": "catalog:",
@@ -390,7 +390,7 @@
},
"packages/http-recorder": {
"name": "@opencode-ai/http-recorder",
"version": "1.15.11",
"version": "1.15.12",
"dependencies": {
"@effect/platform-node": "catalog:",
"effect": "catalog:",
@@ -403,7 +403,7 @@
},
"packages/llm": {
"name": "@opencode-ai/llm",
"version": "1.15.11",
"version": "1.15.12",
"dependencies": {
"@smithy/eventstream-codec": "4.2.14",
"@smithy/util-utf8": "4.2.2",
@@ -421,7 +421,7 @@
},
"packages/opencode": {
"name": "opencode",
"version": "1.15.11",
"version": "1.15.12",
"bin": {
"opencode": "./bin/opencode",
},
@@ -561,7 +561,7 @@
},
"packages/plugin": {
"name": "@opencode-ai/plugin",
"version": "1.15.11",
"version": "1.15.12",
"dependencies": {
"@opencode-ai/sdk": "workspace:*",
"effect": "catalog:",
@@ -599,7 +599,7 @@
},
"packages/sdk/js": {
"name": "@opencode-ai/sdk",
"version": "1.15.11",
"version": "1.15.12",
"dependencies": {
"cross-spawn": "catalog:",
},
@@ -614,7 +614,7 @@
},
"packages/slack": {
"name": "@opencode-ai/slack",
"version": "1.15.11",
"version": "1.15.12",
"dependencies": {
"@opencode-ai/sdk": "workspace:*",
"@slack/bolt": "^3.17.1",
@@ -627,8 +627,9 @@
},
"packages/stats/app": {
"name": "@opencode-ai/stats-app",
"version": "1.15.11",
"version": "1.15.12",
"dependencies": {
"@ibm/plex": "6.4.1",
"@opencode-ai/stats-core": "workspace:*",
"@opencode-ai/ui": "workspace:*",
"@solidjs/meta": "catalog:",
@@ -650,7 +651,7 @@
},
"packages/stats/core": {
"name": "@opencode-ai/stats-core",
"version": "1.15.11",
"version": "1.15.12",
"dependencies": {
"@aws-sdk/client-athena": "3.933.0",
"@planetscale/database": "1.19.0",
@@ -669,7 +670,7 @@
},
"packages/stats/server": {
"name": "@opencode-ai/stats-server",
"version": "1.15.11",
"version": "1.15.12",
"dependencies": {
"@aws-sdk/client-firehose": "3.933.0",
"@effect/platform-node": "catalog:",
@@ -709,7 +710,7 @@
},
"packages/ui": {
"name": "@opencode-ai/ui",
"version": "1.15.11",
"version": "1.15.12",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/core": "workspace:*",
@@ -758,7 +759,7 @@
},
"packages/web": {
"name": "@opencode-ai/web",
"version": "1.15.11",
"version": "1.15.12",
"dependencies": {
"@astrojs/cloudflare": "12.6.3",
"@astrojs/markdown-remark": "6.3.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/app",
"version": "1.15.11",
"version": "1.15.12",
"description": "",
"type": "module",
"exports": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-app",
"version": "1.15.11",
"version": "1.15.12",
"type": "module",
"license": "MIT",
"scripts": {
@@ -2,6 +2,7 @@ import { action, json, query, useAction, useSubmission } from "@solidjs/router"
import { createEffect, createMemo, createSignal, For, onCleanup, Show } from "solid-js"
import { getRequestEvent } from "solid-js/web"
import { Referral } from "@opencode-ai/console-core/referral.js"
import { Actor } from "@opencode-ai/console-core/actor.js"
import { withActor } from "~/context/auth.withActor"
import { Modal } from "~/component/modal"
import { IconCheck, IconCopy } from "~/component/icon"
@@ -9,6 +10,7 @@ import { useI18n } from "~/context/i18n"
import { useLanguage } from "~/context/language"
import { formatResetTime, liteResetTimeKeys } from "~/lib/format-reset-time"
import { queryLiteSubscription } from "~/routes/workspace/[id]/go/lite-section"
import { clearReferralCookie, referralCodeFromCookieHeader } from "~/lib/referral-invite"
import "./go-referral.css"
type GoReferralSummary = Awaited<ReturnType<typeof Referral.summary>>
@@ -25,7 +27,21 @@ const emptyUsagePreview = {
export const queryGoReferral = query(async (workspaceID: string) => {
"use server"
return withActor(() => Referral.summary(), workspaceID)
return withActor(async () => {
const event = getRequestEvent()
const referralCode = referralCodeFromCookieHeader(event?.request.headers.get("cookie") ?? null)
if (referralCode) {
await Referral.createFromAccount({
accountID: Actor.account(),
referralCode,
}).catch((error) => {
console.error("Referral create failed", error)
})
event?.response.headers.append("set-cookie", clearReferralCookie())
}
return Referral.summary()
}, workspaceID)
}, "go.referral.get")
export const queryGoReferralUsagePreview = query(async (workspaceID: string, referralID?: string) => {
@@ -65,6 +81,8 @@ function rewardDescriptionKey(source: GoReferralReward["source"]) {
function rewardActionKey(reward: GoReferralReward, hasActiveGo: boolean) {
if (reward.status === "applied") return "workspace.referral.reward.action.applied" as const
if (reward.status === "pending" && reward.source === "inviter")
return "workspace.referral.reward.source.pendingInviter" as const
if (reward.status === "pending" || !hasActiveGo) return "workspace.referral.reward.action.subscribeUnlock" as const
return "workspace.referral.reward.action.view" as const
}
@@ -1,11 +1,9 @@
import { redirect } from "@solidjs/router"
import type { APIEvent } from "@solidjs/start/server"
import { Referral } from "@opencode-ai/console-core/referral.js"
import { AuthClient } from "~/context/auth"
import { useAuthSession } from "~/context/auth"
import { i18n } from "~/i18n"
import { localeFromRequest, route } from "~/lib/language"
import { clearReferralCookie, referralCodeFromCookieHeader } from "~/lib/referral-invite"
export async function GET(input: APIEvent) {
const url = new URL(input.request.url)
@@ -19,7 +17,6 @@ export async function GET(input: APIEvent) {
if (result.err) throw new Error(result.err.message)
const decoded = AuthClient.decode(result.tokens.access, {} as any)
if (decoded.err) throw new Error(decoded.err.message)
const referralCode = referralCodeFromCookieHeader(input.request.headers.get("cookie"))
const session = await useAuthSession()
const id = decoded.subject.properties.accountID
await session.update((value) => {
@@ -35,15 +32,8 @@ export async function GET(input: APIEvent) {
current: id,
}
})
if (decoded.subject.properties.newAccount && referralCode) {
await Referral.createFromAccount({ accountID: id, referralCode }).catch((error) => {
console.error("Referral create failed", error)
})
}
const next = url.pathname === "/auth/callback" ? "/auth" : url.pathname.replace("/auth/callback", "")
const response = redirect(route(locale, next))
if (referralCode) response.headers.append("set-cookie", clearReferralCookie())
return response
return redirect(route(locale, next))
} catch (e: any) {
return new Response(
JSON.stringify({
@@ -4,7 +4,6 @@ import { IconCopy, IconCheck } from "~/component/icon"
import { Key } from "@opencode-ai/console-core/key.js"
import { withActor } from "~/context/auth.withActor"
import { createStore } from "solid-js/store"
import { formatDateUTC, formatDateForTable } from "../../common"
import styles from "./key-section.module.css"
import { Actor } from "@opencode-ai/console-core/actor.js"
import { useI18n } from "~/context/i18n"
@@ -124,7 +123,6 @@ export function KeySection() {
<th>{i18n.t("workspace.keys.table.name")}</th>
<th>{i18n.t("workspace.keys.table.key")}</th>
<th>{i18n.t("workspace.keys.table.createdBy")}</th>
<th>{i18n.t("workspace.keys.table.lastUsed")}</th>
<th></th>
</tr>
</thead>
@@ -156,9 +154,6 @@ export function KeySection() {
</Show>
</td>
<td data-slot="key-user-email">{key.email}</td>
<td data-slot="key-last-used" title={key.timeUsed ? formatDateUTC(key.timeUsed) : undefined}>
{key.timeUsed ? formatDateForTable(key.timeUsed) : "-"}
</td>
<td data-slot="key-actions">
<form action={removeKey} method="post">
<input type="hidden" name="id" value={key.id} />
@@ -506,7 +506,6 @@ export async function handler(
if (retry.retryCount !== MAX_FAILOVER_RETRIES) {
let topPriority = Infinity
const providers = allProviders
.filter((provider) => !provider.disabled)
.filter((provider) => provider.weight !== 0)
.filter((provider) => !retry.excludeProviders.includes(provider.id))
.filter((provider) => {
@@ -990,10 +989,6 @@ export async function handler(
return undefined
})(),
}),
db
.update(KeyTable)
.set({ timeUsed: sql`now()` })
.where(and(eq(KeyTable.workspaceID, authInfo.workspaceID), eq(KeyTable.id, authInfo.apiKeyId))),
...(() => {
if (billingSource === "subscription") {
const plan = authInfo.billing.subscription!.plan
+1
View File
@@ -17,6 +17,7 @@ export default defineConfig({
],
server: {
allowedHosts: true,
port: 3001,
},
build: {
rollupOptions: {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/console-core",
"version": "1.15.11",
"version": "1.15.12",
"private": true,
"type": "module",
"license": "MIT",
+24 -4
View File
@@ -1,8 +1,8 @@
import { z } from "zod"
import { and, asc, eq, isNull, sql, Database } from "./drizzle"
import { and, asc, eq, inArray, isNull, sql, Database } from "./drizzle"
import { Actor } from "./actor"
import { Identifier } from "./identifier"
import { LiteTable } from "./schema/billing.sql"
import { LiteTable, PaymentTable } from "./schema/billing.sql"
import { ReferralCodeTable, ReferralRewardTable, ReferralTable } from "./schema/referral.sql"
import { AuthTable } from "./schema/auth.sql"
import { UserTable } from "./schema/user.sql"
@@ -318,6 +318,26 @@ export namespace Referral {
.then((rows) => rows[0])
if (selfReferral) throw new Error("Self-referral is not allowed")
const workspaceIDs = await tx
.select({ workspaceID: UserTable.workspaceID })
.from(UserTable)
.where(and(eq(UserTable.accountID, input.accountID), isNull(UserTable.timeDeleted)))
.then((rows) => rows.map((row) => row.workspaceID))
if (workspaceIDs.length === 0) return
const litePayment = await tx
.select({ id: PaymentTable.id })
.from(PaymentTable)
.where(
and(
inArray(PaymentTable.workspaceID, workspaceIDs),
isNull(PaymentTable.timeDeleted),
sql`JSON_UNQUOTE(JSON_EXTRACT(${PaymentTable.enrichment}, '$.type')) = 'lite'`,
),
)
.then((rows) => rows[0])
if (litePayment) return
const referralID = Identifier.create("referral")
await tx.insert(ReferralTable).ignore().values({
workspaceID: code.workspaceID,
@@ -355,7 +375,7 @@ export namespace Referral {
.from(ReferralTable)
.where(and(eq(ReferralTable.inviteeAccountID, invitee.accountID), isNull(ReferralTable.timeDeleted)))
.then((rows) => rows[0])
if (!referral) throw new Error("Referral not found")
if (!referral) return
const result = await tx
.insert(ReferralRewardTable)
@@ -373,7 +393,7 @@ export namespace Referral {
},
])
if (result.rowsAffected === 0) throw new Error("Referral already completed")
if (result.rowsAffected === 0) return
})
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-function",
"version": "1.15.11",
"version": "1.15.12",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-mail",
"version": "1.15.11",
"version": "1.15.12",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-support",
"version": "1.15.11",
"version": "1.15.12",
"type": "module",
"license": "MIT",
"scripts": {
-181
View File
@@ -1,181 +0,0 @@
# Local Workspace V2 Plan
## Goal
A **workspace V2** is a local checkout directory for a project. A project may have one primary workspace V2 and any number of additional local checkouts, whether they were created by `git worktree`, `git clone`, or another mechanism. Remote execution and synchronization are not part of this concept.
`Project.resolve` already provides the key prerequisite: separate clones and linked worktrees with the same normalized Git remote resolve to the same project ID.
## Current Boundaries
- `packages/core/src/project.ts` resolves project identity, but intentionally does not persist a project or its opened directory.
- `packages/opencode/src/project/project.ts` currently persists projects and is the current durable boundary after core resolves an opened directory.
- `packages/opencode/src/control-plane/workspace.ts` and its `workspace` table describe the existing remote/control-plane feature. They should not become the storage or API for local workspace V2.
- Sessions already retain both the checkout directory and a relative `path`, which can supply known subpaths during migration.
- Existing file search uses ripgrep to enumerate paths and fuzzy ranking in the file service; the new API should reuse that capability rather than create a second search system.
## Domain Shape
Add a new `WorkspaceV2` namespace in `packages/core` for local workspace concepts only. Until the old workspace feature is deprecated, all newly introduced implementation names should retain the `V2` suffix so the two domains cannot be confused.
Suggested public value:
```ts
WorkspaceV2.Info {
projectID: Project.ID
path: AbsolutePath
type: "directory" | "worktree"
primary: boolean
}
```
Use the canonical checkout root as `path`, not the subdirectory from which opencode was launched. `type: "directory"` represents a regular local directory or normal clone; `type: "worktree"` represents a linked Git worktree. Add further local types only when a checkout mechanism requires different behavior.
Reuse the existing `workspace` table as the shared persistence model. It already has `type`, `directory`, and `project_id`; add a `primary` boolean field and enforce at most one primary row per project. `WorkspaceV2` is the new local-only service/API interpretation of rows whose `type` is a local type; legacy control-plane code can continue using the same table during transition.
Do not add discovery provenance. Local workspace identity is the canonical `directory` within a project, while `type` describes its checkout mechanism. Pruning can validate that non-primary local paths still exist rather than retaining source ownership metadata.
## Primary Location And Reconciliation
The `WorkspaceV2` service should persist checkouts as they become known and reconcile additional checkouts from enumerators in the background. Each enumerator represents one mechanism by which workspace paths can be found and owns cleanup of paths found only through that mechanism.
- Keep `Project.resolve` focused on mapping an opened directory to project identity.
- In the existing persisted project-open flow, upsert the resolved checkout root as a local `workspace` row. This is how separately cloned directories become known after being opened.
- The first stored checkout for a project becomes `primary: true` transactionally; subsequent opened or discovered paths do not change it.
- Add an initial Git worktree reconciler that runs `git worktree list --porcelain` from a stored Git workspace, upserts its current linked worktree paths as `type: "worktree"`, and prunes unavailable non-primary local rows safely.
- Normalize, deduplicate, and validate all stored or discovered paths; a discovered path equal to the primary path remains a single primary workspace.
- Additional mechanisms, such as scanning configured clone roots, can be added without changing the public workspace API.
An unavailable primary raises a policy decision: reconciliation cannot select a replacement without a user-facing rule. Recommended first behavior is to retain the primary row until a replacement operation exists, while pruning unavailable non-primary rows that are owned by an enumerator or fail validation when accessed.
## API Shape
The new methods should use object inputs and core `Schema` values, matching the existing Effect service pattern.
```ts
workspaceV2.list({ projectID })
// Effect<WorkspaceV2.Info[]>
workspaceV2.subpaths({ projectID, path })
// Effect<Array<{ path: string }>>
workspaceV2.fuzzy({ projectID, path, query, limit? })
// Effect<Array<{ path: string }>>
```
Validate that `path` exists in the stored workspace set for `projectID` and still refers to an available directory before returning subpaths or searching it. All returned subpaths and fuzzy results should use slash-separated paths relative to the workspace root.
## Listing And Discovery
`workspaceV2.list` should read stored workspace V2 rows and return immediately. It should not wait for Git commands or filesystem scanning.
Recommended synchronization model:
- Store the current opened checkout synchronously as part of the project-open persistence flow, so the active workspace is immediately visible.
- Run reconciliation once when an instance/project is bootstrapped. The current `InstanceBootstrap`/scoped background-work pattern is a suitable lifecycle hook while workspaces are associated with open instances.
- When a workspace list is requested, trigger the same deduplicated reconciliation in the background and return existing rows immediately.
- Emit a local workspace-updated event only when reconciliation changes stored rows, allowing an open picker to reload without blocking its initial response.
- Keep failures non-destructive: log an enumerator failure and retain prior stored rows rather than hiding workspaces on a transient Git failure.
For the first version, synchronization should be event-triggered rather than a perpetual timer: project open/bootstrap and list access are enough to keep active projects fresh without introducing polling lifetime and battery-use concerns. If global project browsing later needs fresh state without opening a project, add a scoped global scheduler then.
Independent `git clone` directories cannot be found from Git worktree metadata alone. They become stored workspaces when opened and resolved; discovering unopened clones later would require another enumerator with a defined source of candidate paths.
## Subpaths
`workspaceV2.subpaths` returns distinct prior session roots within a local workspace V2:
- Query sessions for the project and workspace checkout directory.
- Use the existing relative session `path` value, normalizing the workspace root as `""` or `"."` consistently in the public contract.
- Exclude paths that escape the workspace root or are no longer directories.
- Sort results deterministically and deduplicate them.
This requires resolving the existing session persistence boundary: session rows currently live in `packages/opencode`, not core. During migration the core workspace contract will need a session-subpath storage adapter, or the endpoint composition must remain in opencode until session persistence is moved.
## Fuzzy Paths
`workspaceV2.fuzzy` supports selecting a subpath by searching file and directory names beneath one stored, available workspace V2. It should accept a query and limit so the API does not transmit an unbounded repository listing.
- Reuse ripgrep-backed file enumeration and current fuzzy ranking behavior from the existing file feature.
- Include directory results, because the user is selecting a session root; directories may be derived from matched file paths as the existing file cache already does.
- Scope scanning to the requested workspace root and respect ignored/generated paths in the same way as existing file search.
- Treat indexing/caching as a later performance refinement; the first implementation can follow existing instance-level search behavior.
Because ripgrep and fuzzy file search currently live in `packages/opencode`, moving this method directly into core either requires moving that reusable capability first or introducing a narrow search dependency supplied by opencode.
## Migration Notes
- Persist the opened checkout root during the existing project-open flow, where opencode already performs project upserts after core identity resolution.
- Treat existing `Project.Info.worktree` as the primary location only where compatibility is needed while consumers move to the new local-workspace records.
- Do not migrate or consult `project.sandboxes` for local workspace enumeration.
- Do not migrate or rename remote/control-plane workspaces into workspace V2 records; they represent a different domain and may need a separate future name.
- Do not use existing session `workspace_id` for local checkout identity while it still denotes remote/control-plane workspace routing; use explicitly V2-named storage or references if a persistent association is introduced.
## Coexistence With Legacy Workspace
Both implementations can run at the same time because they own different responsibilities:
| Area | Existing `Workspace` / worktree behavior | `WorkspaceV2` during migration |
| --- | --- | --- |
| Purpose | Remote/control-plane targets, workspace routing, sync, and existing local worktree adapter | Local checkout inventory and local subpath selection |
| Persistence | `workspace` table and `session.workspace_id` | Local-typed rows in the same `workspace` table, with new `primary` field |
| API | `/experimental/workspace` and `/experimental/worktree` | New V2-only endpoints when exposed |
| Routing | `?workspace=`, `WorkspaceRef`, proxy/fence/sync behavior | No routing participation initially |
| Worktree creation/removal | Existing APIs remain authoritative | Reconciliation observes resulting directories |
Compatibility rules:
- Do not change legacy route semantics, sync behavior, feature flag behavior, or existing SDK contracts as part of initial WorkspaceV2 work.
- Reuse local legacy `type: "worktree"` rows when they already describe local checkouts. Do not interpret remote adapter types as local WorkspaceV2 entries.
- A shared local worktree row may be used by both implementations during migration. WorkspaceV2 reads only its local checkout fields; legacy behavior continues to own routing and sync behavior associated with old APIs.
- Existing worktree create/remove calls continue through the current `Worktree` and legacy adapter implementation. WorkspaceV2 discovers their paths asynchronously and must not become a prerequisite for those operations.
- Existing sessions keep their current `workspace_id` semantics. Sessions created during the first V2 phases continue to store directory/path as today; do not add a WorkspaceV2 session foreign key until a concrete session-query requirement demands it.
### Rollout Phases
1. **Shared-table extension**: add `workspace.primary`, write the actively opened local checkout after successful project resolution, and reconcile Git worktrees in the background. No existing UI, routing, or session behavior changes yet.
2. **V2 read API**: expose separately named WorkspaceV2 list/subpath/fuzzy endpoints and SDK methods. Consumers opt in; existing workspace and worktree APIs remain unchanged.
3. **UI adoption for local selection**: move only local checkout/subpath selection UI to WorkspaceV2. Keep remote selection, session warping, proxying, and sync on legacy Workspace.
4. **Legacy split/deprecation**: once no consumer treats the legacy API as the local checkout picker, rename or deprecate the old remote/control-plane workspace surface independently. Remove V2 naming only in this deliberate cutover.
### Failure And Rollback Rules
- V2 write or reconciliation failure must not prevent opening a project or using legacy workspace/worktree functionality during initial rollout.
- Reconciliation must not delete remote/control-plane `workspace` rows or alter `session.workspace_id` values; it may reconcile rows representing local checkout types.
- Disabling V2 consumers should be sufficient rollback: legacy APIs and data remain authoritative until an explicit later cutover.
## Transitional Naming
Use the V2 suffix for every newly introduced local-workspace implementation artifact while both workspace domains coexist:
- Core namespace and service: `WorkspaceV2`, `@opencode/WorkspaceV2`.
- Source module: `packages/core/src/workspace-v2.ts` unless the final module convention establishes another V2-explicit location.
- New service/API names use V2; the reused persistence table remains `workspace`, with the added `primary` column, because shared storage is intentional.
- Methods and endpoints: `workspaceV2.list`, `workspaceV2.subpaths`, and `workspaceV2.fuzzy`.
- Events, errors, runtime adapters, and reconciler names: prefix or namespace them with `WorkspaceV2`.
The suffix is transitional. Remove or rename it only as part of the deliberate removal/deprecation of the old remote workspace API, not incrementally during implementation.
## Open Decisions
- What user-facing operation changes the primary workspace when it is deleted or no longer available?
- What enumerator, if any, should discover unopened separately cloned repositories, since Git only enumerates linked worktrees from a checkout?
- Should core define the workspace/store contracts while opencode supplies its database-backed and instance-bootstrap integration during migration, or should moving persistence into core be part of this work?
- How should non-Git project directories obtain stable project identity if multiple copies should ever be grouped together?
- Should initial V2 writes be permanently best-effort during coexistence, or become required only after the UI/API cutover is complete?
## Implementation Checklist
- [x] Use `WorkspaceV2` naming for new service/API code while intentionally retaining the shared `workspace` persistence table.
- [x] Define core `WorkspaceV2.Info`, object-shaped method inputs, typed errors, and service interface.
- [x] Extend shared `workspace` persistence with a `primary` field and one-primary-per-project constraint, using `type` for local checkout kinds.
- [x] Upsert the opened checkout root during the existing project-open/upsert flow and make first-path primary selection transactional.
- [ ] Define the reconciliation interface and implement the first Git worktree reconciler.
- [ ] Trigger deduplicated background reconciliation from project bootstrap and workspace-list access, emitting an update event only when rows change.
- [ ] Implement `workspaceV2.list({ projectID })` as a fast stored read with canonicalization and safe pruning behavior.
- [ ] Implement `workspaceV2.subpaths({ projectID, path })` from distinct valid session-relative paths.
- [ ] Implement `workspaceV2.fuzzy({ projectID, path, query, limit? })` by reusing the existing file enumeration/fuzzy search capability.
- [ ] Add focused tests for clone-open persistence, primary selection, Git worktree reconciliation, unavailable-path pruning, update emission, session subpaths, and fuzzy result scoping.
- [ ] Expose the agreed methods through server/SDK surfaces only after the core contract and migration behavior are approved.
- [ ] Keep legacy `/experimental/workspace`, `/experimental/worktree`, and `session.workspace_id` behavior unchanged throughout shared-table and V2 read-API rollout.
- [ ] Add rollout tests proving V2 failures do not break legacy local worktree and remote workspace flows.
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "1.15.11",
"version": "1.15.12",
"name": "@opencode-ai/core",
"type": "module",
"license": "MIT",
-51
View File
@@ -1,51 +0,0 @@
export * as WorkspaceV2 from "./workspace-v2"
import { Context, Effect, Schema } from "effect"
import { Project } from "./project"
import { AbsolutePath, RelativePath } from "./schema"
export const Type = Schema.Literals(["directory", "worktree"])
export type Type = typeof Type.Type
export class Info extends Schema.Class<Info>("WorkspaceV2.Info")({
projectID: Project.ID,
path: AbsolutePath,
type: Type,
primary: Schema.Boolean,
}) {}
export const ListInput = Schema.Struct({
projectID: Project.ID,
}).annotate({ identifier: "WorkspaceV2.ListInput" })
export type ListInput = typeof ListInput.Type
export const SubpathsInput = Schema.Struct({
projectID: Project.ID,
path: AbsolutePath,
}).annotate({ identifier: "WorkspaceV2.SubpathsInput" })
export type SubpathsInput = typeof SubpathsInput.Type
export const FuzzyInput = Schema.Struct({
projectID: Project.ID,
path: AbsolutePath,
query: Schema.String,
limit: Schema.optional(Schema.Int.check(Schema.isGreaterThan(0))),
}).annotate({ identifier: "WorkspaceV2.FuzzyInput" })
export type FuzzyInput = typeof FuzzyInput.Type
export class Subpath extends Schema.Class<Subpath>("WorkspaceV2.Subpath")({
path: RelativePath,
}) {}
export class NotFoundError extends Schema.TaggedErrorClass<NotFoundError>()("WorkspaceV2.NotFoundError", {
projectID: Project.ID,
path: AbsolutePath,
}) {}
export interface Interface {
readonly list: (input: ListInput) => Effect.Effect<Info[]>
readonly subpaths: (input: SubpathsInput) => Effect.Effect<Subpath[], NotFoundError>
readonly fuzzy: (input: FuzzyInput) => Effect.Effect<Subpath[], NotFoundError>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/WorkspaceV2") {}
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@opencode-ai/desktop",
"private": true,
"version": "1.15.11",
"version": "1.15.12",
"type": "module",
"license": "MIT",
"homepage": "https://opencode.ai",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "1.15.11",
"version": "1.15.12",
"name": "@opencode-ai/effect-drizzle-sqlite",
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/enterprise",
"version": "1.15.11",
"version": "1.15.12",
"private": true,
"type": "module",
"license": "MIT",
+1
View File
@@ -29,6 +29,7 @@ export default defineConfig({
server: {
host: "0.0.0.0",
allowedHosts: true,
port: 3002,
},
worker: {
format: "es",
+6 -6
View File
@@ -1,7 +1,7 @@
id = "opencode"
name = "OpenCode"
description = "The open source coding agent."
version = "1.15.11"
version = "1.15.12"
schema_version = 1
authors = ["Anomaly"]
repository = "https://github.com/anomalyco/opencode"
@@ -11,26 +11,26 @@ name = "OpenCode"
icon = "./icons/opencode.svg"
[agent_servers.opencode.targets.darwin-aarch64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.15.11/opencode-darwin-arm64.zip"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.15.12/opencode-darwin-arm64.zip"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.darwin-x86_64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.15.11/opencode-darwin-x64.zip"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.15.12/opencode-darwin-x64.zip"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.linux-aarch64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.15.11/opencode-linux-arm64.tar.gz"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.15.12/opencode-linux-arm64.tar.gz"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.linux-x86_64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.15.11/opencode-linux-x64.tar.gz"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.15.12/opencode-linux-x64.tar.gz"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.windows-x86_64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.15.11/opencode-windows-x64.zip"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.15.12/opencode-windows-x64.zip"
cmd = "./opencode.exe"
args = ["acp"]
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/function",
"version": "1.15.11",
"version": "1.15.12",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "1.15.11",
"version": "1.15.12",
"name": "@opencode-ai/http-recorder",
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "1.15.11",
"version": "1.15.12",
"name": "@opencode-ai/llm",
"type": "module",
"license": "MIT",
@@ -1,2 +0,0 @@
ALTER TABLE `workspace` ADD `primary` integer DEFAULT false NOT NULL;--> statement-breakpoint
CREATE UNIQUE INDEX `workspace_project_primary_idx` ON `workspace` (`project_id`) WHERE "workspace"."primary" = 1;
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "1.15.11",
"version": "1.15.12",
"name": "opencode",
"type": "module",
"license": "MIT",
+10 -2
View File
@@ -11,6 +11,7 @@ import type {
} from "@opencode-ai/sdk/v2"
import { Effect } from "effect"
import { ACPNextSession } from "./session"
import { ACPNextPermission } from "./permission"
import {
duplicateRunningToolUpdate,
errorToolUpdate,
@@ -22,7 +23,8 @@ import {
const log = Log.create({ service: "acp-next-event" })
type Connection = Pick<AgentSideConnection, "sessionUpdate">
type Connection = Pick<AgentSideConnection, "sessionUpdate"> &
Partial<Pick<AgentSideConnection, "requestPermission" | "writeTextFile">>
type GlobalEventEnvelope = {
payload?: Event
}
@@ -40,6 +42,7 @@ export class Subscription {
private readonly abort = new AbortController()
private readonly shellSnapshots = new Map<string, string>()
private readonly toolStarts = new Set<string>()
private readonly permission: ACPNextPermission.Handler
private started = false
constructor(
@@ -48,7 +51,9 @@ export class Subscription {
connection: Connection
session: ACPNextSession.Interface
},
) {}
) {
this.permission = new ACPNextPermission.Handler(input)
}
start() {
if (this.started) return
@@ -65,6 +70,9 @@ export class Subscription {
async handle(event: Event) {
switch (event.type) {
case "permission.asked":
this.permission.handle(event)
return
case "message.part.updated":
return this.handlePartUpdated(event)
case "message.part.delta":
@@ -0,0 +1,145 @@
import type { AgentSideConnection, PermissionOption, RequestPermissionResponse } from "@agentclientprotocol/sdk"
import * as Log from "@opencode-ai/core/util/log"
import type { Event, OpencodeClient } from "@opencode-ai/sdk/v2"
import { applyPatch } from "diff"
import { exists, readText } from "@/util/filesystem"
import type { ACPNextSession } from "./session"
import { toLocations, toToolKind, type ToolInput } from "./tool"
import { Effect } from "effect"
const log = Log.create({ service: "acp-next-permission" })
type PermissionEvent = Extract<Event, { type: "permission.asked" }>
type Reply = "once" | "always" | "reject"
type Connection = Partial<Pick<AgentSideConnection, "requestPermission" | "writeTextFile">>
const permissionOptions: PermissionOption[] = [
{ optionId: "once", kind: "allow_once", name: "Allow once" },
{ optionId: "always", kind: "allow_always", name: "Always allow" },
{ optionId: "reject", kind: "reject_once", name: "Reject" },
]
export class Handler {
private readonly queues = new Map<string, Promise<void>>()
constructor(
private readonly input: {
sdk: OpencodeClient
connection: Connection
session: ACPNextSession.Interface
},
) {}
handle(event: PermissionEvent) {
const permission = event.properties
const previous = this.queues.get(permission.sessionID) ?? Promise.resolve()
const next = previous
.then(() => this.process(event))
.catch((error: unknown) => {
log.error("failed to handle permission", { error, permissionID: permission.id })
})
.finally(() => {
if (this.queues.get(permission.sessionID) === next) {
this.queues.delete(permission.sessionID)
}
})
this.queues.set(permission.sessionID, next)
}
private async process(event: PermissionEvent) {
const permission = event.properties
const session = await Effect.runPromise(this.input.session.tryGet(permission.sessionID))
if (!session) return
if (!this.input.connection.requestPermission) {
log.error("ACP connection cannot request permission", {
permissionID: permission.id,
sessionID: permission.sessionID,
})
await this.reply(permission.id, "reject", session.cwd)
return
}
const result = await this.input.connection
.requestPermission({
sessionId: permission.sessionID,
toolCall: {
toolCallId: permission.tool?.callID ?? permission.id,
status: "pending",
title: permission.permission,
rawInput: permission.metadata,
kind: toToolKind(permission.permission),
locations: toLocations(permission.permission, permission.metadata),
},
options: permissionOptions,
})
.catch(async (error: unknown) => {
log.error("failed to request permission from ACP", {
error,
permissionID: permission.id,
sessionID: permission.sessionID,
})
await this.reply(permission.id, "reject", session.cwd)
return undefined
})
if (!result) return
const reply = selectedReply(result)
if (reply !== "once" && reply !== "always") {
await this.reply(permission.id, "reject", session.cwd)
return
}
if (permission.permission === "edit") {
await this.writeProposedEdit(session.id, permission.metadata).catch((error: unknown) => {
log.error("failed to write proposed edit through ACP", {
error,
permissionID: permission.id,
sessionID: permission.sessionID,
})
})
}
await this.reply(permission.id, reply, session.cwd)
}
private async reply(requestID: string, reply: Reply, directory: string) {
await this.input.sdk.permission.reply({
requestID,
reply,
directory,
})
}
private async writeProposedEdit(sessionId: string, metadata: ToolInput) {
const filepath = stringValue(metadata.filepath)
const diff = stringValue(metadata.diff)
if (!filepath || !diff || !this.input.connection.writeTextFile) return
const content = (await exists(filepath)) ? await readText(filepath) : ""
const next = applyPatch(content, diff)
if (next === false) {
log.error("Failed to apply unified diff (context mismatch)")
return
}
void this.input.connection.writeTextFile({
sessionId,
path: filepath,
content: next,
})
}
}
function selectedReply(result: RequestPermissionResponse): Reply {
if (result.outcome.outcome !== "selected") return "reject"
if (result.outcome.optionId === "once" || result.outcome.optionId === "always") return result.outcome.optionId
return "reject"
}
function stringValue(value: unknown) {
return typeof value === "string" ? value : undefined
}
export * as ACPNextPermission from "./permission"
+42
View File
@@ -0,0 +1,42 @@
const enabled = process.env.OPENCODE_ACP_PROFILE === "1"
const started = performance.now()
export function mark(name: string, fields?: Record<string, string | number | boolean | undefined>) {
if (!enabled) return
write(`${name}.mark`, performance.now() - started, fields)
}
export function duration(
name: string,
startedAt: number,
fields?: Record<string, string | number | boolean | undefined>,
) {
if (!enabled) return
write(name, performance.now() - startedAt, fields)
}
export async function measure<T>(
name: string,
fn: () => Promise<T>,
fields?: Record<string, string | number | boolean | undefined>,
) {
if (!enabled) return fn()
const start = performance.now()
try {
return await fn()
} finally {
write(name, performance.now() - start, fields)
}
}
function write(name: string, durationMs: number, fields?: Record<string, string | number | boolean | undefined>) {
const extra = fields
? Object.entries(fields)
.filter((entry): entry is [string, string | number | boolean] => entry[1] !== undefined)
.map(([key, value]) => `${key}=${value}`)
.join(" ")
: ""
console.error(`[acp-profile] ${name} ${Math.round(durationMs)}ms${extra ? ` ${extra}` : ""}`)
}
export * as ACPNextProfile from "./profile"
+339 -99
View File
@@ -35,9 +35,12 @@ import type { Message, OpencodeClient, SessionMessageResponse } from "@opencode-
import { Context, Effect, Layer, ManagedRuntime } from "effect"
import * as ACPNextError from "./error"
import { buildConfigOptions, parseModelSelection } from "./config-option"
import { promptContentToParts } from "./content"
import { Directory } from "./directory"
import { ACPNextEvent } from "./event"
import { ACPNextSession } from "./session"
import { UsageService } from "./usage"
import { ACPNextProfile } from "./profile"
import { ModelID, ProviderID } from "@/provider/schema"
import { Provider } from "@/provider/provider"
import type { Command } from "@/command"
@@ -46,6 +49,8 @@ export const AuthMethodID = "opencode-login"
const log = Log.create({ service: "acp-next-service" })
export type Error = ACPNextError.Error
type ServiceConnection = Pick<AgentSideConnection, "sessionUpdate"> &
Partial<Pick<AgentSideConnection, "requestPermission" | "writeTextFile">>
export type Interface = {
readonly initialize: (input: InitializeRequest) => Effect.Effect<InitializeResponse, Error>
@@ -69,20 +74,23 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/AC
export function make(input: {
sdk: OpencodeClient
connection?: Pick<AgentSideConnection, "sessionUpdate">
connection?: ServiceConnection
directory?: Directory.Interface
session?: ACPNextSession.Interface
usage?: UsageService.Interface
eventSubscription?: (subscription: ACPNextEvent.Subscription) => void
}): Interface {
const session = input.session ?? makeSessionService()
const directoryService = input.directory ?? makeDirectoryService(input.sdk)
const registeredMcp = new Map<string, Set<string>>()
const sessionSnapshots = new Map<string, Directory.Snapshot>()
const events = input.connection
? ACPNextEvent.start({ sdk: input.sdk, connection: input.connection, session })
: undefined
if (events) input.eventSubscription?.(events)
const initialize = Effect.fn("ACPNext.initialize")(function* (params: InitializeRequest) {
const started = performance.now()
const authMethod: AuthMethod = {
description: "Run `opencode auth login` in the terminal",
name: "Login with opencode",
@@ -99,7 +107,7 @@ export function make(input: {
}
}
return {
const response = {
protocolVersion: 1,
agentCapabilities: {
loadSession: true,
@@ -124,6 +132,8 @@ export function make(input: {
version: InstallationVersion,
},
}
ACPNextProfile.duration("acp.initialize", started)
return response
})
const authenticate = Effect.fn("ACPNext.authenticate")(function* (params: AuthenticateRequest) {
@@ -134,15 +144,28 @@ export function make(input: {
})
const directorySnapshot = Effect.fn("ACPNext.directorySnapshot")(function* (cwd: string) {
return yield* directoryService.get(cwd)
const started = performance.now()
const snapshot = yield* directoryService.get(cwd)
ACPNextProfile.duration("acp.directory.snapshot", started)
return snapshot
})
const configSnapshot = Effect.fn("ACPNext.configSnapshot")(function* (state: ACPNextSession.Info) {
const snapshot = sessionSnapshots.get(state.id)
if (snapshot) return snapshot
const loaded = yield* directorySnapshot(state.cwd)
sessionSnapshots.set(state.id, loaded)
return loaded
})
const newSession = Effect.fn("ACPNext.newSession")(function* (params: NewSessionRequest) {
const started = performance.now()
const snapshot = yield* directorySnapshot(params.cwd)
const selected = selectDefaultModel(snapshot)
const variant = selectVariant(snapshot, selected)
const modeId = snapshot.availableModes.length > 0 ? snapshot.defaultModeID : undefined
const created = yield* request(
const created = yield* profiledRequest(
"acp.newSession.session.create",
() =>
input.sdk.session.create(
{
@@ -166,11 +189,12 @@ export function make(input: {
variant,
modeId,
})
sessionSnapshots.set(state.id, snapshot)
yield* registerMcpServers(input.sdk, registeredMcp, params.cwd, state.id, params.mcpServers)
yield* sendAvailableCommands(input.connection, state.id, snapshot)
return {
const response = {
sessionId: state.id,
configOptions: configOptions(snapshot, {
model: state.model ?? selected,
@@ -178,6 +202,8 @@ export function make(input: {
modeId: state.modeId,
}),
}
ACPNextProfile.duration("acp.newSession", started)
return response
})
const loadSession = Effect.fn("ACPNext.loadSession")(function* (params: LoadSessionRequest) {
@@ -204,6 +230,7 @@ export function make(input: {
variant: restored.variant ?? selectVariant(snapshot, model),
modeId: restored.modeId ?? (snapshot.availableModes.length > 0 ? snapshot.defaultModeID : undefined),
})
sessionSnapshots.set(state.id, snapshot)
yield* registerMcpServers(input.sdk, registeredMcp, params.cwd, state.id, params.mcpServers)
yield* sendAvailableCommands(input.connection, state.id, snapshot)
@@ -232,21 +259,35 @@ export function make(input: {
),
"session",
)
const sorted = sessions.toSorted((a, b) => b.time.updated - a.time.updated)
const serverEntries = sessions.map(
(item): SessionInfo => ({
sessionId: item.id,
cwd: item.directory,
title: item.title,
updatedAt: new Date(item.time.updated).toISOString(),
}),
)
const liveEntries = (yield* session.list(params.cwd ?? undefined))
.filter((item) => !serverEntries.some((entry) => entry.sessionId === item.id))
.map(
(item): SessionInfo => ({
sessionId: item.id,
cwd: item.cwd,
updatedAt: item.createdAt.toISOString(),
}),
)
const sorted = [...liveEntries, ...serverEntries].toSorted(
(a, b) => new Date(b.updatedAt ?? 0).getTime() - new Date(a.updatedAt ?? 0).getTime(),
)
const filtered =
cursor === undefined || !Number.isFinite(cursor) ? sorted : sorted.filter((item) => item.time.updated < cursor)
cursor === undefined || !Number.isFinite(cursor)
? sorted
: sorted.filter((item) => new Date(item.updatedAt ?? 0).getTime() < cursor)
const page = filtered.slice(0, limit)
const last = page.at(-1)
return {
sessions: page.map(
(item): SessionInfo => ({
sessionId: item.id,
cwd: item.directory,
title: item.title,
updatedAt: new Date(item.time.updated).toISOString(),
}),
),
...(filtered.length > limit && last ? { nextCursor: String(last.time.updated) } : {}),
sessions: page,
...(filtered.length > limit && last ? { nextCursor: String(new Date(last.updatedAt ?? 0).getTime()) } : {}),
}
})
@@ -274,6 +315,7 @@ export function make(input: {
variant: restored.variant ?? selectVariant(snapshot, model),
modeId: restored.modeId ?? (snapshot.availableModes.length > 0 ? snapshot.defaultModeID : undefined),
})
sessionSnapshots.set(state.id, snapshot)
yield* registerMcpServers(input.sdk, registeredMcp, params.cwd, state.id, params.mcpServers ?? [])
yield* sendAvailableCommands(input.connection, state.id, snapshot)
@@ -291,6 +333,7 @@ export function make(input: {
const closeSession = Effect.fn("ACPNext.closeSession")(function* (params: CloseSessionRequest) {
const removed = yield* session.remove(params.sessionId)
registeredMcp.delete(params.sessionId)
sessionSnapshots.delete(params.sessionId)
if (!removed) return {}
yield* request(
@@ -334,6 +377,7 @@ export function make(input: {
variant: restored.variant ?? selectVariant(snapshot, model),
modeId: restored.modeId ?? (snapshot.availableModes.length > 0 ? snapshot.defaultModeID : undefined),
})
sessionSnapshots.set(state.id, snapshot)
yield* registerMcpServers(input.sdk, registeredMcp, params.cwd, state.id, params.mcpServers ?? [])
yield* sendAvailableCommands(input.connection, state.id, snapshot)
@@ -353,7 +397,7 @@ export function make(input: {
params: SetSessionConfigOptionRequest,
) {
const current = yield* session.get(params.sessionId)
const snapshot = yield* directorySnapshot(current.cwd)
const snapshot = yield* configSnapshot(current)
if (typeof params.value !== "string") {
return yield* new ACPNextError.InvalidConfigOptionError({ configId: params.configId })
}
@@ -408,7 +452,7 @@ export function make(input: {
const setSessionMode = Effect.fn("ACPNext.setSessionMode")(function* (params: SetSessionModeRequest) {
const current = yield* session.get(params.sessionId)
const snapshot = yield* directorySnapshot(current.cwd)
const snapshot = yield* configSnapshot(current)
if (!snapshot.availableModes.some((mode) => mode.id === params.modeId)) {
return yield* new ACPNextError.InvalidModeError({ mode: params.modeId })
}
@@ -418,7 +462,7 @@ export function make(input: {
const setSessionModel = Effect.fn("ACPNext.setSessionModel")(function* (params: SetSessionModelRequest) {
const current = yield* session.get(params.sessionId)
const snapshot = yield* directorySnapshot(current.cwd)
const snapshot = yield* configSnapshot(current)
const selected = yield* parseSelectedModel(snapshot, params.modelId)
yield* session
.setVariant(
@@ -443,8 +487,81 @@ export function make(input: {
setSessionConfigOption,
setSessionMode,
setSessionModel,
prompt: Effect.fn("ACPNext.prompt")(function* (_input: PromptRequest) {
return yield* new ACPNextError.UnsupportedOperationError({ method: "session/prompt" })
prompt: Effect.fn("ACPNext.prompt")(function* (params: PromptRequest) {
const current = yield* session.get(params.sessionId)
const snapshot = yield* directorySnapshot(current.cwd)
const selected = current.model ?? selectDefaultModel(snapshot)
if (!current.model) {
yield* session.setModel(params.sessionId, selected)
}
const variant = current.variant ?? selectVariant(snapshot, selected)
const modeId = current.modeId ?? (snapshot.availableModes.length > 0 ? snapshot.defaultModeID : undefined)
const parts = promptContentToParts(params.prompt)
const command = detectSlashCommand(parts)
if (!command) {
const response = yield* request(
() =>
input.sdk.session.prompt(
{
sessionID: current.id,
model: {
providerID: selected.providerID,
modelID: selected.modelID,
},
...(variant ? { variant } : {}),
parts,
...(modeId ? { agent: modeId } : {}),
directory: current.cwd,
},
{ throwOnError: true },
),
"session",
)
yield* sendUsageUpdate(input.usage, input.sdk, input.connection, current.id, current.cwd)
return promptResponse(response.info, params.messageId)
}
const known = snapshot.availableCommands.find((item) => item.name === command.name)
if (known) {
const response = yield* request(
() =>
input.sdk.session.command(
{
sessionID: current.id,
command: known.name,
arguments: command.args,
model: `${selected.providerID}/${selected.modelID}`,
...(variant ? { variant } : {}),
...(modeId ? { agent: modeId } : {}),
directory: current.cwd,
},
{ throwOnError: true },
),
"session",
)
yield* sendUsageUpdate(input.usage, input.sdk, input.connection, current.id, current.cwd)
return promptResponse(response.info, params.messageId)
}
if (command.name === "compact") {
yield* request(
() =>
input.sdk.session.summarize(
{
sessionID: current.id,
directory: current.cwd,
providerID: selected.providerID,
modelID: selected.modelID,
},
{ throwOnError: true },
),
"session",
)
}
yield* sendUsageUpdate(input.usage, input.sdk, input.connection, current.id, current.cwd)
return promptResponse(undefined, params.messageId)
}),
cancel: Effect.fn("ACPNext.cancel")(function* (_input: CancelNotification) {
return yield* new ACPNextError.UnsupportedOperationError({ method: "session/cancel" })
@@ -473,6 +590,91 @@ function makeDirectoryService(sdk: OpencodeClient) {
).runSync(Directory.Service.use((service) => Effect.succeed(service)))
}
function makeUsageService(sdk: OpencodeClient) {
const limits = new Map<string, Promise<number | undefined>>()
const contextLimit: UsageService.Interface["contextLimit"] = Effect.fn("ACPNext.promptUsage.contextLimit")(
function* (params) {
const key = `${params.directory}\u0000${params.providerID}\u0000${params.modelID}`
const current = limits.get(key)
if (current) return yield* Effect.promise(() => current)
const next = sdk.config
.providers({ directory: params.directory }, { throwOnError: true })
.then((response) => {
const providers = Object.fromEntries(
(response.data?.providers ?? []).map((provider) => [provider.id, provider]),
) as Record<ProviderID, Provider.Info>
return UsageService.findContextLimit(providers, params.providerID, params.modelID)
})
.catch((error: unknown) => {
log.error("failed to get providers for usage context limit", { error })
return undefined
})
limits.set(key, next)
return yield* Effect.promise(() => next)
},
)
const sendUpdate: UsageService.Interface["sendUpdate"] = Effect.fn("ACPNext.promptUsage.sendUpdate")(
function* (params) {
const messages = yield* request(
() =>
sdk.session.messages(
{
sessionID: params.sessionID,
directory: params.directory,
},
{ throwOnError: true },
),
"session",
).pipe(
Effect.map((messages) => messages as readonly UsageService.SessionMessage[]),
Effect.catch((error) =>
Effect.sync(() => {
log.error("failed to fetch messages for usage update", { error })
return undefined
}),
),
)
if (!messages) return
const message = UsageService.latestAssistantMessage(messages)
if (!message?.providerID || !message.modelID) return
const size = yield* contextLimit({
directory: params.directory,
providerID: ProviderID.make(message.providerID),
modelID: ModelID.make(message.modelID),
})
if (!size) return
yield* Effect.promise(() =>
params.connection
.sessionUpdate({
sessionId: params.sessionID,
update: {
sessionUpdate: "usage_update",
used: message.tokens.input + message.tokens.cache.read,
size,
cost: { amount: UsageService.totalSessionCost(messages), currency: "USD" },
},
})
.catch((error) => {
log.error("failed to send usage update", { error })
}),
)
},
)
return UsageService.Service.of({
buildUsage: UsageService.buildUsage,
latestAssistantMessage: UsageService.latestAssistantMessage,
totalSessionCost: UsageService.totalSessionCost,
contextLimit,
sendUpdate,
})
}
function replayMessages(subscription: ACPNextEvent.Subscription | undefined, messages: SessionMessageResponse[]) {
if (!subscription) return Effect.void
return Effect.promise(async () => {
@@ -505,6 +707,8 @@ type MessageInfo = {
readonly agent?: Message["agent"]
}
type AssistantInfo = UsageService.AssistantTokenCost | undefined
function request<T>(fn: () => Promise<T | SdkResponse<T>>, service?: string) {
return Effect.tryPromise({
try: async () => {
@@ -519,66 +723,79 @@ function request<T>(fn: () => Promise<T | SdkResponse<T>>, service?: string) {
})
}
async function loadDirectorySnapshot(sdk: OpencodeClient, directory: string) {
const [providersResponse, agentsResponse, commandsResponse, skillsResponse] = await Promise.all([
sdk.config.providers({ directory }, { throwOnError: true }),
sdk.app.agents({ directory }, { throwOnError: true }),
sdk.command.list({ directory }, { throwOnError: true }),
sdk.app.skills({ directory }, { throwOnError: true }),
])
const providersData = providersResponse.data!
const agents = agentsResponse.data!
const commandsData = commandsResponse.data!
const skills = skillsResponse.data!
const providers = Object.fromEntries(providersData.providers.map((provider) => [provider.id, provider])) as Record<
ProviderID,
Provider.Info
>
const defaultModel = await defaultModelFromSdk(sdk, directory, providers)
const modes = agents
.filter((agent) => agent.mode !== "subagent" && agent.hidden !== true)
.map((agent) => ({
id: agent.name,
name: agent.name,
...(agent.description ? { description: agent.description } : {}),
}))
const commands = [
...commandsData,
...skills
.filter((skill) => !commandsData.some((command) => command.name === skill.name))
.map((skill) => ({
name: skill.name,
description: skill.description,
source: "skill" as const,
template: skill.content,
hints: [],
})),
] as Command.Info[]
function profiledRequest<T>(name: string, fn: () => Promise<T | SdkResponse<T>>, service?: string) {
return request(() => ACPNextProfile.measure(name, fn), service)
}
return Directory.build({
directory,
providers,
modes,
defaultModeID: agents.find((agent) => agent.mode === "primary" && agent.hidden !== true)?.name ?? "build",
commands: commands.toSorted((a, b) => a.name.localeCompare(b.name)),
...(defaultModel ? { defaultModel } : {}),
async function loadDirectorySnapshot(sdk: OpencodeClient, directory: string) {
return ACPNextProfile.measure("acp.directory.load", async () => {
const [providersResponse, agentsResponse, commandsResponse, skillsResponse, configResponse] = await Promise.all([
ACPNextProfile.measure("acp.directory.provider.list", () =>
sdk.config.providers({ directory }, { throwOnError: true }),
),
ACPNextProfile.measure("acp.directory.mode.defaultAgent.load", () =>
sdk.app.agents({ directory }, { throwOnError: true }),
),
ACPNextProfile.measure("acp.directory.command.list", () =>
sdk.command.list({ directory }, { throwOnError: true }),
),
ACPNextProfile.measure("acp.directory.skill.list", () => sdk.app.skills({ directory }, { throwOnError: true })),
ACPNextProfile.measure("acp.directory.defaultModel.config", () =>
sdk.config.get({ directory }, { throwOnError: true }).catch(() => undefined),
),
])
const providersData = providersResponse.data!
const agents = agentsResponse.data!
const commandsData = commandsResponse.data!
const skills = skillsResponse.data!
const providers = Object.fromEntries(providersData.providers.map((provider) => [provider.id, provider])) as Record<
ProviderID,
Provider.Info
>
const defaultModelStarted = performance.now()
const defaultModel = defaultModelFromConfig(configResponse?.data?.model, providers)
ACPNextProfile.duration("acp.directory.defaultModel.resolve", defaultModelStarted, { configured: !!defaultModel })
const modes = agents
.filter((agent) => agent.mode !== "subagent" && agent.hidden !== true)
.map((agent) => ({
id: agent.name,
name: agent.name,
...(agent.description ? { description: agent.description } : {}),
}))
const commands = [
...commandsData,
...skills
.filter((skill) => !commandsData.some((command) => command.name === skill.name))
.map((skill) => ({
name: skill.name,
description: skill.description,
source: "skill" as const,
template: skill.content,
hints: [],
})),
] as Command.Info[]
return Directory.build({
directory,
providers,
modes,
defaultModeID: agents.find((agent) => agent.mode === "primary" && agent.hidden !== true)?.name ?? "build",
commands: commands.toSorted((a, b) => a.name.localeCompare(b.name)),
...(defaultModel ? { defaultModel } : {}),
})
})
}
async function defaultModelFromSdk(
sdk: OpencodeClient,
directory: string,
function defaultModelFromConfig(
configuredModel: string | undefined,
providers: Record<ProviderID, Provider.Info>,
): Promise<Directory.DefaultModel | undefined> {
const configured = await sdk.config
.get({ directory }, { throwOnError: true })
.then((response) => (response.data?.model ? Provider.parseModel(response.data.model) : undefined))
.catch(() => undefined)
): Directory.DefaultModel | undefined {
const configured = configuredModel ? Provider.parseModel(configuredModel) : undefined
if (configured && providers[configured.providerID]?.models[configured.modelID]) return configured
const lastUsed = await lastUsedModel(sdk, directory, providers)
if (lastUsed) return lastUsed
// First-session ACP startup must not scan historical sessions just to infer
// a default. Configured model, opencode provider, then sorted best model keep
// the protocol response deterministic without extra session/message reads.
const opencodeProvider = providers[ProviderID.make("opencode")]
const opencodeModel = opencodeProvider ? Provider.sort(Object.values(opencodeProvider.models))[0] : undefined
if (opencodeProvider && opencodeModel) return { providerID: opencodeProvider.id, modelID: opencodeModel.id }
@@ -588,30 +805,6 @@ async function defaultModelFromSdk(
if (configured) return configured
}
async function lastUsedModel(
sdk: OpencodeClient,
directory: string,
providers: Record<ProviderID, Provider.Info>,
): Promise<Directory.DefaultModel | undefined> {
const session = await sdk.session
.list({ directory, roots: true, limit: 1 }, { throwOnError: true })
.then((response) => response.data?.[0])
.catch(() => undefined)
if (!session) return
const lastUser = await sdk.session
.messages({ directory, sessionID: session.id, limit: 20 }, { throwOnError: true })
.then((response) => response.data?.findLast((message) => message.info.role === "user")?.info)
.catch(() => undefined)
if (lastUser?.role !== "user") return
if (!providers[ProviderID.make(lastUser.model.providerID)]?.models[ModelID.make(lastUser.model.modelID)]) return
return {
providerID: ProviderID.make(lastUser.model.providerID),
modelID: ModelID.make(lastUser.model.modelID),
}
}
function selectDefaultModel(snapshot: Directory.Snapshot) {
if (snapshot.defaultModel) return snapshot.defaultModel
const model = snapshot.modelOptions[0]
@@ -619,6 +812,43 @@ function selectDefaultModel(snapshot: Directory.Snapshot) {
return { providerID: "unknown" as ProviderID, modelID: "unknown" as ModelID }
}
function detectSlashCommand(parts: ReturnType<typeof promptContentToParts>) {
const text = parts
.filter((part): part is Extract<(typeof parts)[number], { type: "text" }> => part.type === "text")
.map((part) => part.text)
.join("")
.trim()
if (!text.startsWith("/")) return
const [name, ...rest] = text.slice(1).split(/\s+/)
if (!name) return
return { name, args: rest.join(" ").trim() }
}
function promptResponse(info: AssistantInfo, messageId: string | null | undefined): PromptResponse {
return {
stopReason: "end_turn",
...(info ? { usage: UsageService.buildUsage(info) } : {}),
...(messageId ? { userMessageId: messageId } : {}),
_meta: {},
}
}
function sendUsageUpdate(
usage: UsageService.Interface | undefined,
sdk: OpencodeClient,
connection: ServiceConnection | undefined,
sessionID: string,
directory: string,
) {
if (!connection) return Effect.void
return (usage ?? makeUsageService(sdk)).sendUpdate({
connection,
sessionID,
directory,
})
}
function selectVariant(snapshot: Directory.Snapshot, model: Directory.DefaultModel) {
const variants = Directory.variants(snapshot, model)
if (!variants) return
@@ -689,6 +919,7 @@ function registerMcpServers(
sessionId: string,
servers: readonly McpServer[],
) {
const started = performance.now()
const current = registered.get(sessionId) ?? new Set<string>()
registered.set(sessionId, current)
const pending = new Set<string>()
@@ -720,7 +951,16 @@ function registerMcpServers(
),
),
{ concurrency: "unbounded" },
).pipe(Effect.asVoid)
).pipe(
Effect.tap(() =>
Effect.sync(() =>
ACPNextProfile.duration("acp.mcp.register", started, {
count: pending.size,
}),
),
),
Effect.asVoid,
)
}
function mcpRegistrationKey(name: string, config: ReturnType<typeof mcpConfig>) {
@@ -60,6 +60,7 @@ export type PartMetadataLookupInput = {
export type Interface = {
readonly create: (input: StoreInput) => Effect.Effect<Info>
readonly load: (input: StoreInput) => Effect.Effect<Info>
readonly list: (cwd?: string) => Effect.Effect<readonly Info[]>
readonly get: (sessionId: string) => Effect.Effect<Info, ACPNextError.SessionNotFoundError>
readonly tryGet: (sessionId: string) => Effect.Effect<Info | undefined>
readonly remove: (sessionId: string) => Effect.Effect<Info | undefined>
@@ -168,6 +169,12 @@ export const layer = Layer.effect(
return Service.of({
create: store,
load: store,
list: Effect.fn("ACPNext.Session.list")(function* (cwd?: string) {
return [...(yield* Ref.get(sessions)).values()]
.filter((session) => !cwd || session.cwd === cwd)
.map(snapshot)
.toSorted((a, b) => b.createdAt.getTime() - a.createdAt.getTime())
}),
get,
tryGet,
remove,
+1 -1
View File
@@ -74,7 +74,7 @@ export function toLocations(toolName: string, input: ToolInput): ToolCallLocatio
case "read":
case "edit":
case "write":
return locationFrom(input.filePath)
return locationFrom(input.filePath ?? input.filepath)
case "grep":
case "glob":
+6 -1
View File
@@ -9,6 +9,7 @@ import { ServerAuth } from "@/server/auth"
import { createOpencodeClient } from "@opencode-ai/sdk/v2"
import { withNetworkOptions, resolveNetworkOptions } from "../network"
import { RuntimeFlags } from "@/effect/runtime-flags"
import { ACPNextProfile } from "@/acp-next/profile"
const log = Log.create({ service: "acp-command" })
@@ -23,10 +24,13 @@ export const AcpCommand = effectCmd({
})
},
handler: Effect.fn("Cli.acp")(function* (args) {
ACPNextProfile.mark("cli.acp.handler")
process.env.OPENCODE_CLIENT = "acp"
const flags = yield* RuntimeFlags.Service
const opts = yield* resolveNetworkOptions(args)
const server = yield* Effect.promise(() => Server.listen(opts))
const server = yield* Effect.promise(() =>
ACPNextProfile.measure("cli.acp.server.listen", () => Server.listen(opts)),
)
const sdk = createOpencodeClient({
baseUrl: `http://${server.hostname}:${server.port}`,
@@ -60,6 +64,7 @@ export const AcpCommand = effectCmd({
const agent = flags.acpNext ? ACPNext.init({ sdk }) : ACP.init({ sdk })
new AgentSideConnection((conn) => {
ACPNextProfile.mark("cli.acp.connection.create", { acpNext: flags.acpNext })
return agent.create(conn, { sdk })
}, stream)
@@ -442,16 +442,25 @@ function ReasoningHeader(props: { toggleable: boolean; open: boolean; done: bool
: theme.warning
return (
<text fg={fg()} wrapMode="none">
<Show when={props.toggleable}>
<span>{props.open ? "- " : "+ "}</span>
</Show>
<span>{props.done ? "Thought" : "Thinking"}</span>
<Show when={props.title}>
<span>: </span>
<span>{props.title}</span>
</Show>
</text>
<Switch>
<Match when={!props.done}>
<box flexDirection="row">
<Spinner color={fg()}>{props.title ? "Thinking: " + props.title : "Thinking"}</Spinner>
</box>
</Match>
<Match when={true}>
<text fg={fg()} wrapMode="none">
<Show when={props.toggleable}>
<span>{props.open ? "- " : "+ "}</span>
</Show>
<span>Thought</span>
<Show when={props.title}>
<span>: </span>
<span>{props.title}</span>
</Show>
</text>
</Match>
</Switch>
)
}
@@ -1595,24 +1595,33 @@ function ReasoningHeader(props: {
: theme.warning
return (
<text fg={fg()} wrapMode="none">
<Show when={props.toggleable}>
<span>{props.open ? "- " : "+ "}</span>
</Show>
<span>{props.done ? "Thought" : "Thinking"}</span>
<Show when={props.title || props.duration}>
<span>: </span>
</Show>
<Show when={props.title}>
<span>{props.title}</span>
</Show>
<Show when={props.duration}>
<span>
{props.title ? " · " : ""}
{props.duration}
</span>
</Show>
</text>
<Switch>
<Match when={!props.done}>
<box flexDirection="row">
<Spinner color={fg()}>{props.title ? "Thinking: " + props.title : "Thinking"}</Spinner>
</box>
</Match>
<Match when={true}>
<text fg={fg()} wrapMode="none">
<Show when={props.toggleable}>
<span>{props.open ? "- " : "+ "}</span>
</Show>
<span>Thought</span>
<Show when={props.title || props.duration}>
<span>: </span>
</Show>
<Show when={props.title}>
<span>{props.title}</span>
</Show>
<Show when={props.duration}>
<span>
{props.title ? " · " : ""}
{props.duration}
</span>
</Show>
</text>
</Match>
</Switch>
)
}
@@ -1,5 +1,4 @@
import { sql } from "drizzle-orm"
import { sqliteTable, text, integer, uniqueIndex } from "drizzle-orm/sqlite-core"
import { sqliteTable, text, integer } from "drizzle-orm/sqlite-core"
import { ProjectTable } from "../project/project.sql"
import type { ProjectID } from "../project/schema"
import type { WorkspaceID } from "./schema"
@@ -11,7 +10,6 @@ export const WorkspaceTable = sqliteTable("workspace", {
branch: text(),
directory: text(),
extra: text({ mode: "json" }),
primary: integer({ mode: "boolean" }).notNull().default(false),
project_id: text()
.$type<ProjectID>()
.notNull()
@@ -19,8 +17,4 @@ export const WorkspaceTable = sqliteTable("workspace", {
time_used: integer()
.notNull()
.$default(() => Date.now()),
}, (table) => [
uniqueIndex("workspace_project_primary_idx")
.on(table.project_id)
.where(sql`${table.primary} = 1`),
])
})
@@ -2,7 +2,9 @@ import { Context, Effect, FiberMap, Iterable, Layer, Schema, Stream } from "effe
import { serviceUse } from "@opencode-ai/core/effect/service-use"
import { FetchHttpClient, HttpBody, HttpClient, HttpClientError, HttpClientRequest } from "effect/unstable/http"
import { Database } from "@/storage/db"
import { and, asc, eq, inArray, ne } from "drizzle-orm"
import { asc } from "drizzle-orm"
import { eq } from "drizzle-orm"
import { inArray } from "drizzle-orm"
import { Project } from "@/project/project"
import { BusEvent } from "@/bus/bus-event"
import { GlobalBus } from "@/bus/global"
@@ -831,7 +833,7 @@ export const layer = Layer.effect(
db
.select()
.from(WorkspaceTable)
.where(and(eq(WorkspaceTable.project_id, project.id), ne(WorkspaceTable.type, "directory")))
.where(eq(WorkspaceTable.project_id, project.id))
.all()
.map(fromRow)
.sort((a, b) => a.id.localeCompare(b.id)),
@@ -20,9 +20,9 @@ Enabled by default on `local`, `dev`, and `beta`. On `latest` and `prod`, set `O
## Retries
- If WebSocket setup fails or it fails before its first event, replay over HTTP and keep that session on HTTP until idle-pruned.
- If the server returns `websocket_connection_limit_reached` before output, reconnect up to 5 times, then follow the same HTTP fallback.
- If a WebSocket fails after its first event, fail the stream. Do not replay partial output.
- Retry WebSocket stream/setup failures up to 5 times, then use HTTP for that session until the pool entry is idle-pruned.
- `websocket_connection_limit_reached` consumes the same retry budget and HTTP fallback.
- If a WebSocket fails after its first event, fail it as retryable rather than replaying partial output in transport.
- Abort or cancel closes the socket.
## Next Steps
+42 -25
View File
@@ -1,5 +1,6 @@
import WebSocket from "ws"
import * as Log from "@opencode-ai/core/util/log"
import { ProviderError } from "@/provider/error"
import { isRecord } from "@/util/record"
import { OpenAIWebSocket } from "./ws"
@@ -13,7 +14,7 @@ export interface CreateWebSocketFetchOptions {
connectTimeout?: number
idleTimeout?: number
maxConnectionAge?: number
connectionLimitRetries?: number
streamRetries?: number
}
interface PoolEntry {
@@ -22,6 +23,7 @@ interface PoolEntry {
lastUsedAt: number
busy: boolean
fallback: boolean
streamFailures: number
}
const DEFAULT_CONNECT_TIMEOUT = 15_000
@@ -35,7 +37,7 @@ export function createWebSocketFetch(options?: CreateWebSocketFetchOptions) {
const connectTimeout = options?.connectTimeout ?? DEFAULT_CONNECT_TIMEOUT
const idleTimeout = options?.idleTimeout ?? DEFAULT_IDLE_TIMEOUT
const maxConnectionAge = options?.maxConnectionAge ?? DEFAULT_MAX_CONNECTION_AGE
const connectionLimitRetries = options?.connectionLimitRetries ?? 5
const streamRetries = options?.streamRetries ?? 5
const pruneTimer = setInterval(() => prune(), Math.min(idleTimeout, 60_000))
if (typeof pruneTimer === "object" && "unref" in pruneTimer && typeof pruneTimer.unref === "function") {
pruneTimer.unref()
@@ -72,7 +74,7 @@ export function createWebSocketFetch(options?: CreateWebSocketFetchOptions) {
}
const key = `${sessionID}:conversation`
const entry = pool.get(key) ?? { lastUsedAt: Date.now(), busy: false, fallback: false }
const entry = pool.get(key) ?? { lastUsedAt: Date.now(), busy: false, fallback: false, streamFailures: 0 }
pool.set(key, entry)
if (entry.fallback) {
@@ -87,7 +89,6 @@ export function createWebSocketFetch(options?: CreateWebSocketFetchOptions) {
entry.busy = true
entry.lastUsedAt = Date.now()
try {
let connectionLimitAttempts = 0
entry.socket = await socket(
entry,
options?.url ?? url,
@@ -111,15 +112,16 @@ export function createWebSocketFetch(options?: CreateWebSocketFetchOptions) {
onTerminal: (event) => {
entry.busy = false
entry.lastUsedAt = Date.now()
entry.streamFailures = 0
if (event.type !== "response.completed" && event.type !== "response.done") {
log.warn("websocket terminal failure", { key, type: event.type })
invalidate(entry)
}
},
onConnectionInvalid: (error) => {
log.warn("websocket invalidated", { key, error: error instanceof Error ? error.message : String(error) })
log.warn("websocket invalidated", { key, error: error.message })
entry.busy = false
entry.fallback = true
if (!entry.fallback) recordStreamFailure(entry)
invalidate(entry)
resolveFirstEvent(false)
},
@@ -127,51 +129,52 @@ export function createWebSocketFetch(options?: CreateWebSocketFetchOptions) {
log.debug("websocket aborted", { key })
entry.busy = false
entry.lastUsedAt = Date.now()
entry.streamFailures = 0
invalidate(entry)
rejectFirstEvent(error)
},
onRetryableTerminal: async (event) => {
const error = connectionLimitError(event)
if (!error) return undefined
if (connectionLimitAttempts >= connectionLimitRetries) throw error
connectionLimitAttempts++
log.warn("websocket connection limit reached", { key, attempt: connectionLimitAttempts })
invalidate(entry)
entry.socket = await socket(
entry,
options?.url ?? url,
OpenAIWebSocket.normalizeHeaders(httpInit?.headers),
connectTimeout,
maxConnectionAge,
init?.signal,
)
entry.lastUsedAt = Date.now()
return entry.socket
log.warn("websocket connection limit reached", { key })
throw error
},
})
if (await firstEvent) return response
log.debug("http fallback", { key, reason: "websocket_failed_before_first_event" })
if (!entry.fallback) return response
log.debug("http fallback", { key, reason: "websocket_retries_exhausted" })
return httpFetch(input, httpInit)
} catch (error) {
entry.busy = false
entry.lastUsedAt = Date.now()
if (OpenAIWebSocket.isAbortError(error)) {
entry.streamFailures = 0
invalidate(entry)
throw error
}
entry.fallback = true
recordStreamFailure(entry)
log.warn("websocket setup failed", {
key,
error: error instanceof Error ? error.message : String(error),
fallback: "http",
fallback: entry.fallback ? "http" : undefined,
})
invalidate(entry)
return httpFetch(input, httpInit)
if (entry.fallback) return httpFetch(input, httpInit)
return failedResponse(
new ProviderError.ResponseStreamError(error instanceof Error ? error.message : String(error), {
cause: error,
}),
)
}
}
function recordStreamFailure(entry: PoolEntry) {
entry.streamFailures++
// Codex counts retries after the initial failed WebSocket attempt.
if (entry.streamFailures > streamRetries) entry.fallback = true
}
function prune() {
const now = Date.now()
for (const [key, entry] of pool) {
@@ -198,6 +201,20 @@ function connectionLimitError(event: Record<string, unknown>) {
return new Error(typeof event.error.message === "string" ? event.error.message : CONNECTION_LIMIT_REACHED_CODE)
}
function failedResponse(error: ProviderError.ResponseStreamError) {
return new Response(
new ReadableStream({
start(controller) {
controller.error(error)
},
}),
{
status: 200,
headers: { "content-type": "text/event-stream" },
},
)
}
async function socket(
entry: PoolEntry,
url: string,
+18 -14
View File
@@ -2,6 +2,7 @@
// fallback, and continuation state intentionally live above this file.
import WebSocket from "ws"
import { ProviderError } from "@/provider/error"
export const PROTOCOL_HEADER = "responses_websockets=2026-02-06"
@@ -21,7 +22,7 @@ export interface StreamResponsesWebSocketOptions {
onComplete?: (event: Record<string, unknown>) => void
onTerminal?: (event: Record<string, unknown>) => void
onRetryableTerminal?: (event: Record<string, unknown>) => Promise<WebSocket | undefined>
onConnectionInvalid?: (error: Error) => void
onConnectionInvalid?: (error: ProviderError.ResponseStreamError) => void
onAbort?: (error: Error) => void
}
@@ -101,7 +102,7 @@ export function connectResponsesWebSocket(options: ConnectResponsesWebSocketOpti
function onClose(code: number, reason: Buffer) {
cleanup()
reject(closeError("WebSocket closed before open", code, reason))
reject(new Error(closeMessage("WebSocket closed before open", code, reason)))
}
function onAbort() {
@@ -145,7 +146,7 @@ export function streamResponsesWebSocket(options: StreamResponsesWebSocketOption
controller?.close()
}
function invalidate(error: Error) {
function invalidate(error: ProviderError.ResponseStreamError) {
if (completed) return
completed = true
cleanup()
@@ -157,16 +158,13 @@ export function streamResponsesWebSocket(options: StreamResponsesWebSocketOption
if (completed) return
if (!options.idleTimeout) return
if (idleTimer) clearTimeout(idleTimer)
idleTimer = setTimeout(() => invalidate(new Error(message)), options.idleTimeout)
if (typeof idleTimer === "object" && "unref" in idleTimer && typeof idleTimer.unref === "function") {
idleTimer.unref()
}
idleTimer = setTimeout(() => invalidate(new ProviderError.ResponseStreamError(message)), options.idleTimeout)
}
async function onMessage(data: WebSocket.RawData, isBinary: boolean) {
if (completed) return
if (isBinary) {
invalidate(new Error("Unexpected binary WebSocket frame"))
invalidate(new ProviderError.ResponseStreamError("Unexpected binary WebSocket frame"))
return
}
@@ -195,7 +193,11 @@ export function streamResponsesWebSocket(options: StreamResponsesWebSocketOption
return
}
} catch (error) {
invalidate(error instanceof Error ? error : new Error(String(error)))
invalidate(
new ProviderError.ResponseStreamError(error instanceof Error ? error.message : String(error), {
cause: error,
}),
)
return
}
}
@@ -230,12 +232,14 @@ export function streamResponsesWebSocket(options: StreamResponsesWebSocketOption
}
function onError(error: Error) {
invalidate(error)
invalidate(new ProviderError.ResponseStreamError(error.message, { cause: error }))
}
function onClose(code: number, reason: Buffer) {
if (completed) return
invalidate(closeError("WebSocket closed before response.completed", code, reason))
invalidate(
new ProviderError.ResponseStreamError(closeMessage("WebSocket closed before response.completed", code, reason)),
)
}
function onAbort() {
@@ -272,7 +276,7 @@ export function streamResponsesWebSocket(options: StreamResponsesWebSocketOption
socket.send(JSON.stringify({ type: "response.create", ...payload }), (error) => {
if (completed) return
resetIdleTimeout("idle timeout waiting for websocket")
if (error) invalidate(error)
if (error) invalidate(new ProviderError.ResponseStreamError(error.message, { cause: error }))
})
}
@@ -312,11 +316,11 @@ function abortError(signal: AbortSignal | undefined) {
return new DOMException(reason instanceof Error ? reason.message : "Aborted", "AbortError")
}
function closeError(message: string, code: number, reason: Buffer) {
function closeMessage(message: string, code: number, reason: Buffer) {
const details = [`code ${code}`]
if (code === 1009) details.push("message too big")
if (reason.length > 0) details.push(reason.toString())
return new Error(`${message} (${details.join(": ")})`)
return `${message} (${details.join(": ")})`
}
export * as OpenAIWebSocket from "./ws"
+1 -64
View File
@@ -1,10 +1,8 @@
import { and, eq, inArray, sql } from "drizzle-orm"
import { and, eq, sql } from "drizzle-orm"
import { Database } from "@/storage/db"
import { ProjectTable } from "./project.sql"
import { PermissionTable, SessionTable } from "../session/session.sql"
import { WorkspaceTable } from "../control-plane/workspace.sql"
import { WorkspaceID } from "../control-plane/schema"
import path from "path"
import * as Log from "@opencode-ai/core/util/log"
import { Flag } from "@opencode-ai/core/flag/flag"
import { BusEvent } from "@/bus/bus-event"
@@ -234,61 +232,6 @@ export const layer = Layer.effect(
)
})
const rememberWorkspaceV2 = Effect.fn("Project.rememberWorkspaceV2")(function* (input: {
projectID: ProjectID
directory: string
type: "directory" | "worktree"
}) {
if (input.projectID === ProjectID.global) return
yield* Effect.sync(() =>
Database.transaction(
(d) => {
const existing = d
.select()
.from(WorkspaceTable)
.where(
and(
eq(WorkspaceTable.project_id, input.projectID),
eq(WorkspaceTable.directory, input.directory),
inArray(WorkspaceTable.type, ["directory", "worktree"]),
),
)
.get()
const primary = d
.select({ id: WorkspaceTable.id })
.from(WorkspaceTable)
.where(and(eq(WorkspaceTable.project_id, input.projectID), eq(WorkspaceTable.primary, true)))
.get()
if (existing) {
if (!primary) {
d.update(WorkspaceTable).set({ primary: true }).where(eq(WorkspaceTable.id, existing.id)).run()
}
return
}
d.insert(WorkspaceTable)
.values({
id: WorkspaceID.ascending(),
type: input.type,
name: path.basename(input.directory),
directory: input.directory,
primary: !primary,
project_id: input.projectID,
time_used: Date.now(),
})
.run()
},
{ behavior: "immediate" },
),
).pipe(
Effect.catchCause((cause) =>
Effect.sync(() => log.warn("workspace v2 persistence failed", { projectID: input.projectID, cause })),
),
)
})
const fromDirectory = Effect.fn("Project.fromDirectory")(function* (directory: string) {
log.info("fromDirectory", { directory })
@@ -378,12 +321,6 @@ export const layer = Layer.effect(
)
}
yield* rememberWorkspaceV2({
projectID,
directory: data.directory,
type: data.vcs && path.resolve(data.vcs.store) !== path.join(data.directory, ".git") ? "worktree" : "directory",
})
yield* emitUpdated(result)
if (projectID !== ProjectID.global && data.vcs?.type === "git") {
yield* projectV2.commit({ store: data.vcs.store, id: data.id })
+8
View File
@@ -11,6 +11,14 @@ export class HeaderTimeoutError extends Error {
}
}
export class ResponseStreamError extends Error {
public override readonly name = "ProviderResponseStreamError"
constructor(message: string, options?: ErrorOptions) {
super(message, options)
}
}
// Adapted from overflow detection patterns in:
// https://github.com/badlogic/pi-mono/blob/main/packages/ai/src/utils/overflow.ts
const OVERFLOW_PATTERNS = [
+12 -7
View File
@@ -596,8 +596,16 @@ function openaiCompatibleReasoningEfforts(id: string) {
return gpt5CodexReasoningEfforts(apiId) ?? versionedGpt5ReasoningEfforts(apiId) ?? OPENAI_EFFORTS
}
function anthropicOpus47OrLater(apiId: string) {
const version = /opus-(\d+)[.-](\d+)(?:[.-]|$)/i.exec(apiId)
if (!version) return false
const major = Number(version[1])
const minor = Number(version[2])
return major > 4 || (major === 4 && minor >= 7)
}
function anthropicAdaptiveEfforts(apiId: string): string[] | null {
if (["opus-4-7", "opus-4.7"].some((v) => apiId.includes(v))) {
if (anthropicOpus47OrLater(apiId)) {
return ["low", "medium", "high", "xhigh", "max"]
}
if (["opus-4-6", "opus-4.6", "sonnet-4-6", "sonnet-4.6"].some((v) => apiId.includes(v))) {
@@ -625,6 +633,7 @@ export function variants(model: Provider.Model): Record<string, Record<string, a
if (!model.capabilities.reasoning) return {}
const id = model.id.toLowerCase()
const adaptiveOpus = anthropicOpus47OrLater(model.api.id)
const adaptiveEfforts = anthropicAdaptiveEfforts(model.api.id)
if (
id.includes("deepseek-chat") ||
@@ -833,9 +842,7 @@ export function variants(model: Provider.Model): Record<string, Record<string, a
{
thinking: {
type: "adaptive",
...(model.api.id.includes("opus-4-7") || model.api.id.includes("opus-4.7")
? { display: "summarized" }
: {}),
...(adaptiveOpus ? { display: "summarized" } : {}),
},
effort,
},
@@ -872,9 +879,7 @@ export function variants(model: Provider.Model): Record<string, Record<string, a
reasoningConfig: {
type: "adaptive",
maxReasoningEffort: effort,
...(model.api.id.includes("opus-4-7") || model.api.id.includes("opus-4.7")
? { display: "summarized" }
: {}),
...(adaptiveOpus ? { display: "summarized" } : {}),
},
},
]),
@@ -63,11 +63,11 @@ function decodeCredential(input: string) {
Effect.match({
onFailure: emptyCredential,
onSuccess: (header) => {
const parts = header.split(":")
if (parts.length !== 2) return emptyCredential()
const separator = header.indexOf(":")
if (separator === -1) return emptyCredential()
return {
username: parts[0],
password: Redacted.make(parts[1]),
username: header.slice(0, separator),
password: Redacted.make(header.slice(separator + 1)),
}
},
}),
@@ -159,14 +159,14 @@ function planWorkspaceRequest(
function planRequest(
request: HttpServerRequest.HttpServerRequest,
sessionWorkspaceID?: WorkspaceID,
session?: Session.Info,
): Effect.Effect<RequestPlan, never, Workspace.Service> {
return Effect.gen(function* () {
const url = requestURL(request)
const envWorkspaceID = configuredWorkspaceID()
const workspaceID = url.pathname.startsWith("/api/")
? selectedV2WorkspaceID(url, sessionWorkspaceID)
: selectedWorkspaceID(url, sessionWorkspaceID)
? selectedV2WorkspaceID(url, session?.workspaceID)
: selectedWorkspaceID(url, session?.workspaceID)
if (workspaceID === InvalidWorkspaceID) return RequestPlan.InvalidWorkspace()
const workspace = yield* resolveWorkspace(workspaceID, envWorkspaceID)
@@ -178,7 +178,10 @@ function planRequest(
return yield* planWorkspaceRequest(request, url, workspace)
}
return RequestPlan.Local({ directory: defaultDirectory(request, url), workspaceID: envWorkspaceID ?? workspaceID })
return RequestPlan.Local({
directory: session?.directory || defaultDirectory(request, url),
workspaceID: envWorkspaceID ?? workspaceID,
})
})
}
@@ -226,7 +229,7 @@ function routeHttpApiWorkspace<E>(
Effect.catchDefect(() => Effect.succeed(undefined)),
)
: undefined
const plan = yield* planRequest(request, session?.workspaceID)
const plan = yield* planRequest(request, session)
return yield* routeWorkspace(client, effect, plan)
})
}
@@ -1155,6 +1155,17 @@ export function fromError(
},
{ cause: e },
).toObject()
case e instanceof ProviderError.ResponseStreamError:
return new APIError(
{
message: e.message,
isRetryable: true,
metadata: {
code: e.name,
},
},
{ cause: e },
).toObject()
case APICallError.isInstance(e):
const parsed = ProviderError.parseAPICallError({
providerID: ctx.providerID,
@@ -0,0 +1,237 @@
import { describe, expect, it } from "bun:test"
import type {
AgentSideConnection,
RequestPermissionRequest,
RequestPermissionResponse,
SessionUpdate,
} from "@agentclientprotocol/sdk"
import type { Event, OpencodeClient } from "@opencode-ai/sdk/v2"
import { Effect, ManagedRuntime } from "effect"
import { ACPNextEvent } from "@/acp-next/event"
import { ACPNextSession } from "@/acp-next/session"
type PermissionEvent = Extract<Event, { type: "permission.asked" }>
type PermissionReplyParams = Parameters<OpencodeClient["permission"]["reply"]>[0]
type SessionUpdateParams = Parameters<AgentSideConnection["sessionUpdate"]>[0]
const pollUntil = async (
check: () => boolean | Promise<boolean>,
message: string,
opts?: { timeoutMs?: number; intervalMs?: number },
) => {
const started = Date.now()
while (true) {
if (await check()) return
if (Date.now() - started > (opts?.timeoutMs ?? 2000)) throw new Error(message)
await new Promise((resolve) => setTimeout(resolve, opts?.intervalMs ?? 5))
}
}
function makeSessionService() {
return ManagedRuntime.make(ACPNextSession.defaultLayer).runSync(
ACPNextSession.Service.use((service) => Effect.succeed(service)),
)
}
function createHarness(
requestPermission: (params: RequestPermissionRequest) => Promise<RequestPermissionResponse> = () =>
Promise.resolve({ outcome: { outcome: "selected", optionId: "once" } }),
) {
const replies: PermissionReplyParams[] = []
const requests: RequestPermissionRequest[] = []
const updates: SessionUpdateParams[] = []
const session = makeSessionService()
const sdk = {
permission: {
reply: (params: PermissionReplyParams) => {
replies.push(params)
return Promise.resolve({ data: true })
},
},
session: {
message: () => Promise.resolve({ data: undefined }),
},
} as unknown as OpencodeClient
const connection = {
requestPermission: (params: RequestPermissionRequest) => {
requests.push(params)
return requestPermission(params)
},
sessionUpdate: (params: SessionUpdateParams) => {
updates.push(params)
return Promise.resolve()
},
} satisfies Pick<AgentSideConnection, "requestPermission" | "sessionUpdate">
const subscription = new ACPNextEvent.Subscription({ sdk, connection, session })
return { connection, replies, requests, sdk, session, subscription, updates }
}
async function createSession(session: ACPNextSession.Interface, sessionId: string, cwd = "/workspace") {
await Effect.runPromise(session.create({ id: sessionId, cwd }))
}
async function createKnownTextPart(
session: ACPNextSession.Interface,
sessionId: string,
messageId: string,
partId: string,
) {
await Effect.runPromise(
session.recordPartMetadata({
sessionId,
messageId,
partId,
partType: "text",
role: "assistant",
}),
)
}
function permissionAsked(
sessionID: string,
id: string,
input: {
permission?: string
metadata?: Record<string, unknown>
tool?: { messageID: string; callID: string }
} = {},
) {
return {
id: `evt_${id}`,
type: "permission.asked",
properties: {
id,
sessionID,
permission: input.permission ?? "bash",
patterns: ["*"],
metadata: input.metadata ?? { command: "printf hello" },
always: [],
...(input.tool ? { tool: input.tool } : {}),
},
} as PermissionEvent
}
function textDelta(sessionID: string, messageID: string, partID: string, delta: string) {
return {
id: `evt_${sessionID}_${messageID}_${partID}`,
type: "message.part.delta",
properties: {
sessionID,
messageID,
partID,
field: "text",
delta,
},
} as Event
}
function textFromUpdates(updates: SessionUpdateParams[], sessionId: string) {
return updates
.filter((item) => item.sessionId === sessionId)
.map((item) => item.update)
.filter((update): update is Extract<SessionUpdate, { sessionUpdate: "agent_message_chunk" }> => {
return update.sessionUpdate === "agent_message_chunk"
})
.map((update) => (update.content.type === "text" ? update.content.text : ""))
.join("")
}
describe("acp-next permissions", () => {
it("sends requestPermission and replies with the selected outcome", async () => {
const harness = createHarness()
await createSession(harness.session, "ses_a")
harness.subscription.handle(permissionAsked("ses_a", "perm_1", { tool: { messageID: "msg_1", callID: "call_1" } }))
await pollUntil(() => harness.replies.length === 1, "permission was never replied")
expect(harness.requests[0]).toMatchObject({
sessionId: "ses_a",
toolCall: {
toolCallId: "call_1",
status: "pending",
title: "bash",
rawInput: { command: "printf hello" },
kind: "execute",
locations: [],
},
options: [
{ optionId: "once", kind: "allow_once", name: "Allow once" },
{ optionId: "always", kind: "allow_always", name: "Always allow" },
{ optionId: "reject", kind: "reject_once", name: "Reject" },
],
})
expect(harness.replies).toEqual([{ requestID: "perm_1", reply: "once", directory: "/workspace" }])
})
it("rejects non-selected outcomes", async () => {
const harness = createHarness(() => Promise.resolve({ outcome: { outcome: "cancelled" } }))
await createSession(harness.session, "ses_a")
harness.subscription.handle(permissionAsked("ses_a", "perm_cancelled"))
await pollUntil(() => harness.replies.length === 1, "cancelled permission was never replied")
expect(harness.replies[0]).toMatchObject({ requestID: "perm_cancelled", reply: "reject" })
})
it("rejects when requestPermission fails", async () => {
const harness = createHarness(() => Promise.reject(new Error("client permission UI failed")))
await createSession(harness.session, "ses_a")
harness.subscription.handle(permissionAsked("ses_a", "perm_failed"))
await pollUntil(() => harness.replies.length === 1, "failed permission was never rejected")
expect(harness.replies[0]).toMatchObject({ requestID: "perm_failed", reply: "reject" })
})
it("does not let a blocked session A permission block session B message updates", async () => {
let releasePermission: (() => void) | undefined
const blocked = new Promise<RequestPermissionResponse>((resolve) => {
releasePermission = () => resolve({ outcome: { outcome: "selected", optionId: "once" } })
})
const harness = createHarness(() => blocked)
await createSession(harness.session, "ses_a")
await createSession(harness.session, "ses_b")
await createKnownTextPart(harness.session, "ses_b", "msg_b", "part_b")
harness.subscription.handle(permissionAsked("ses_a", "perm_blocked"))
await pollUntil(() => harness.requests.length === 1, "blocked permission was never requested")
await harness.subscription.handle(textDelta("ses_b", "msg_b", "part_b", "session_b_message"))
expect(textFromUpdates(harness.updates, "ses_b")).toBe("session_b_message")
expect(harness.replies).toHaveLength(0)
releasePermission?.()
await pollUntil(() => harness.replies.length === 1, "blocked permission was never replied after release")
})
it("serializes permission requests per session", async () => {
let releaseFirst: (() => void) | undefined
const first = new Promise<RequestPermissionResponse>((resolve) => {
releaseFirst = () => resolve({ outcome: { outcome: "selected", optionId: "once" } })
})
const harness = createHarness(() =>
harness.requests.length === 1 ? first : Promise.resolve({ outcome: { outcome: "selected", optionId: "always" } }),
)
await createSession(harness.session, "ses_a")
harness.subscription.handle(permissionAsked("ses_a", "perm_1"))
harness.subscription.handle(permissionAsked("ses_a", "perm_2"))
await pollUntil(() => harness.requests.length === 1, "first permission was never requested")
expect(harness.requests.map((request) => request.toolCall.toolCallId)).toEqual(["perm_1"])
releaseFirst?.()
await pollUntil(() => harness.requests.length === 2, "second permission was not requested after first resolved")
await pollUntil(() => harness.replies.length === 2, "serialized permissions were not both replied")
expect(harness.replies.map((reply) => [reply.requestID, reply.reply])).toEqual([
["perm_1", "once"],
["perm_2", "always"],
])
})
})
@@ -4,6 +4,7 @@ import type {
ForkSessionResponse,
LoadSessionResponse,
NewSessionResponse,
SessionNotification,
ResumeSessionResponse,
SessionConfigOption,
SessionConfigSelectOption,
@@ -14,6 +15,7 @@ import { Effect, ManagedRuntime } from "effect"
import * as ACPNextService from "@/acp-next/service"
import * as ACPNextError from "@/acp-next/error"
import { ACPNextSession } from "@/acp-next/session"
import { UsageService } from "@/acp-next/usage"
import { ModelID, ProviderID } from "@/provider/schema"
import type { Provider } from "@/provider/provider"
@@ -141,10 +143,14 @@ const provider: Provider.Info = {
describe("ACP next service sessions", () => {
const makeService = (messages: readonly { info: unknown; parts: readonly unknown[] }[] = []) => {
const updates: unknown[] = []
const updates: SessionNotification[] = []
const mcpAdds: string[] = []
const aborts: string[] = []
const forks: string[] = []
const prompts: unknown[] = []
const commands: unknown[] = []
const summarizes: unknown[] = []
const usageUpdates: string[] = []
const sessions = Array.from({ length: 102 }, (_, index) => ({
id: `ses_${index + 1}`,
directory: index % 2 === 0 ? "/workspace" : "/other",
@@ -184,6 +190,36 @@ describe("ACP next service sessions", () => {
data: input.directory ? sessions.filter((session) => session.directory === input.directory) : sessions,
}),
messages: () => Promise.resolve({ data: messages }),
prompt: (input: unknown) => {
prompts.push(input)
return Promise.resolve({
data: {
info: assistantInfo({
input: 100,
output: 40,
reasoning: 7,
cache: { read: 11, write: 13 },
}),
},
})
},
command: (input: unknown) => {
commands.push(input)
return Promise.resolve({
data: {
info: assistantInfo({
input: 3,
output: 4,
reasoning: 0,
cache: { read: 0, write: 0 },
}),
},
})
},
summarize: (input: unknown) => {
summarizes.push(input)
return Promise.resolve({ data: true })
},
abort: (input: { sessionID: string }) => {
aborts.push(input.sessionID)
return Promise.resolve({ data: true })
@@ -201,13 +237,33 @@ describe("ACP next service sessions", () => {
},
} as unknown as OpencodeClient
const connection = {
sessionUpdate: (update: unknown) => {
sessionUpdate: (update: SessionNotification) => {
updates.push(update)
return Promise.resolve()
},
} as Pick<AgentSideConnection, "sessionUpdate">
const usage = UsageService.Service.of({
buildUsage: UsageService.buildUsage,
latestAssistantMessage: UsageService.latestAssistantMessage,
totalSessionCost: UsageService.totalSessionCost,
contextLimit: () => Effect.succeed(128000),
sendUpdate: (input) =>
Effect.sync(() => {
usageUpdates.push(input.sessionID)
}),
})
return { service: ACPNextService.make({ sdk, connection }), updates, mcpAdds, aborts, forks }
return {
service: ACPNextService.make({ sdk, connection, usage }),
updates,
mcpAdds,
aborts,
forks,
prompts,
commands,
summarizes,
usageUpdates,
}
}
it("creates a backed session with config options and command update", async () => {
@@ -267,6 +323,15 @@ describe("ACP next service sessions", () => {
expect(second.sessions).toEqual(first.sessions)
})
it("includes live ACP sessions before they appear in server-backed session list", async () => {
const { service } = makeService()
const created = await Effect.runPromise(service.newSession({ cwd: "/workspace", mcpServers: [] }))
const listed = await Effect.runPromise(service.listSessions({ cwd: "/workspace" }))
expect(listed.sessions[0]?.sessionId).toBe(created.sessionId)
expect(listed.sessions[0]?.cwd).toBe("/workspace")
})
it("lists all sessions with next cursor when the first page is full", async () => {
const { service } = makeService()
const first = await Effect.runPromise(service.listSessions({}))
@@ -546,6 +611,46 @@ describe("ACP next service sessions", () => {
expect(result.configOptions?.find((option) => option.id === "model")?.currentValue).toBe("test/configured-model")
})
it("does not scan last-used sessions when resolving the new session default", async () => {
const historyCalls: string[] = []
const sdk = {
config: {
providers: () => Promise.resolve({ data: { providers: [provider], default: { test: modelID } } }),
get: () => Promise.resolve({ data: {} }),
},
app: {
agents: () => Promise.resolve({ data: [{ name: "build", mode: "primary", permission: [], options: {} }] }),
skills: () => Promise.resolve({ data: [] }),
},
command: {
list: () => Promise.resolve({ data: [] }),
},
session: {
create: (input: { model?: { id?: string } }) => Promise.resolve({ data: { id: input.model?.id } }),
list: () => {
historyCalls.push("list")
return Promise.resolve({ data: [{ id: "ses_recent" }] })
},
messages: () => {
historyCalls.push("messages")
return Promise.resolve({
data: [{ info: { role: "user", model: { providerID: "test", modelID: "second-model" } } }],
})
},
},
mcp: {
add: () => Promise.resolve({ data: {} }),
},
} as unknown as OpencodeClient
const service = ACPNextService.make({ sdk })
const result = await Effect.runPromise(service.newSession({ cwd: "/workspace", mcpServers: [] }))
expect(result.sessionId).toBe("test-model")
expect(result.configOptions?.find((option) => option.id === "model")?.currentValue).toBe("test/test-model")
expect(historyCalls).toEqual([])
})
it("switches model and returns updated model and effort options", async () => {
const { service } = makeService()
const session = await Effect.runPromise(service.newSession({ cwd: "/workspace", mcpServers: [] }))
@@ -611,24 +716,35 @@ describe("ACP next service sessions", () => {
expect(results.map((error) => error.code)).toEqual([-32602, -32602, -32602, -32602])
})
it("does not reload providers or commands when switching effort from a warm snapshot", async () => {
let providersCalls = 0
let commandCalls = 0
it("does not refetch providers modes or commands when switching effort from session snapshot", async () => {
const calls = {
providers: 0,
agents: 0,
commands: 0,
skills: 0,
mcpAdds: 0,
}
const sdk = {
config: {
providers: () => {
providersCalls++
calls.providers++
return Promise.resolve({ data: { providers: [provider], default: { test: modelID } } })
},
get: () => Promise.resolve({ data: {} }),
},
app: {
agents: () => Promise.resolve({ data: [{ name: "build", mode: "primary", permission: [], options: {} }] }),
skills: () => Promise.resolve({ data: [] }),
agents: () => {
calls.agents++
return Promise.resolve({ data: [{ name: "build", mode: "primary", permission: [], options: {} }] })
},
skills: () => {
calls.skills++
return Promise.resolve({ data: [] })
},
},
command: {
list: () => {
commandCalls++
calls.commands++
return Promise.resolve({ data: [] })
},
},
@@ -637,14 +753,16 @@ describe("ACP next service sessions", () => {
list: () => Promise.resolve({ data: [] }),
},
mcp: {
add: () => Promise.resolve({ data: {} }),
add: () => {
calls.mcpAdds++
return Promise.resolve({ data: {} })
},
},
} as unknown as OpencodeClient
const service = ACPNextService.make({ sdk })
const session = await Effect.runPromise(service.newSession({ cwd: "/workspace", mcpServers: [] }))
expect(providersCalls).toBe(1)
expect(commandCalls).toBe(1)
expect(calls).toEqual({ providers: 1, agents: 1, commands: 1, skills: 1, mcpAdds: 0 })
await Effect.runPromise(
service.setSessionConfigOption({
@@ -654,11 +772,350 @@ describe("ACP next service sessions", () => {
}),
)
expect(providersCalls).toBe(1)
expect(commandCalls).toBe(1)
expect(calls).toEqual({ providers: 1, agents: 1, commands: 1, skills: 1, mcpAdds: 0 })
})
it("switches model against the warm provider snapshot without refetching", async () => {
const calls = {
providers: 0,
agents: 0,
commands: 0,
skills: 0,
}
const sdk = {
config: {
providers: () => {
calls.providers++
return Promise.resolve({ data: { providers: [provider], default: { test: modelID } } })
},
get: () => Promise.resolve({ data: {} }),
},
app: {
agents: () => {
calls.agents++
return Promise.resolve({ data: [{ name: "build", mode: "primary", permission: [], options: {} }] })
},
skills: () => {
calls.skills++
return Promise.resolve({ data: [] })
},
},
command: {
list: () => {
calls.commands++
return Promise.resolve({ data: [] })
},
},
session: {
create: () => Promise.resolve({ data: { id: "ses_model_fast" } }),
list: () => Promise.resolve({ data: [] }),
},
mcp: {
add: () => Promise.resolve({ data: {} }),
},
} as unknown as OpencodeClient
const service = ACPNextService.make({ sdk })
const session = await Effect.runPromise(service.newSession({ cwd: "/workspace", mcpServers: [] }))
const updated = await Effect.runPromise(
service.setSessionConfigOption({
sessionId: session.sessionId,
configId: "model",
value: "test/second-model",
}),
)
expect(select(updated, "model")?.currentValue).toBe("test/second-model")
expect(calls).toEqual({ providers: 1, agents: 1, commands: 1, skills: 1 })
})
it("reuses the warm directory snapshot for a second new session in the same cwd", async () => {
const calls = {
providers: 0,
config: 0,
agents: 0,
commands: 0,
skills: 0,
sessionList: 0,
messages: 0,
creates: 0,
}
const sdk = {
config: {
providers: () => {
calls.providers++
return Promise.resolve({ data: { providers: [provider], default: { test: modelID } } })
},
get: () => {
calls.config++
return Promise.resolve({ data: {} })
},
},
app: {
agents: () => {
calls.agents++
return Promise.resolve({ data: [{ name: "build", mode: "primary", permission: [], options: {} }] })
},
skills: () => {
calls.skills++
return Promise.resolve({ data: [] })
},
},
command: {
list: () => {
calls.commands++
return Promise.resolve({ data: [] })
},
},
session: {
create: () => {
calls.creates++
return Promise.resolve({ data: { id: `ses_warm_${calls.creates}` } })
},
list: () => {
calls.sessionList++
return Promise.resolve({ data: [] })
},
messages: () => {
calls.messages++
return Promise.resolve({ data: [] })
},
},
mcp: {
add: () => Promise.resolve({ data: {} }),
},
} as unknown as OpencodeClient
const service = ACPNextService.make({ sdk })
const first = await Effect.runPromise(service.newSession({ cwd: "/workspace", mcpServers: [] }))
const second = await Effect.runPromise(service.newSession({ cwd: "/workspace", mcpServers: [] }))
expect(first.sessionId).toBe("ses_warm_1")
expect(second.sessionId).toBe("ses_warm_2")
expect(calls).toEqual({
providers: 1,
config: 1,
agents: 1,
commands: 1,
skills: 1,
sessionList: 0,
messages: 0,
creates: 2,
})
})
it("normal text prompt sends model variant mode and converted parts", async () => {
const { service, prompts, usageUpdates } = makeService()
const session = await Effect.runPromise(service.newSession({ cwd: "/workspace", mcpServers: [] }))
await Effect.runPromise(
service.setSessionConfigOption({
sessionId: session.sessionId,
configId: "effort",
value: "high",
}),
)
await Effect.runPromise(
service.setSessionConfigOption({
sessionId: session.sessionId,
configId: "mode",
value: "plan",
}),
)
const result = await Effect.runPromise(
service.prompt({
sessionId: session.sessionId,
messageId: "00000000-0000-4000-8000-000000000001",
prompt: [{ type: "text", text: "hello" }],
}),
)
expect(prompts).toEqual([
{
sessionID: session.sessionId,
model: { providerID, modelID },
variant: "high",
parts: [{ type: "text", text: "hello" }],
agent: "plan",
directory: "/workspace",
},
])
expect(result).toEqual({
stopReason: "end_turn",
usage: {
inputTokens: 100,
outputTokens: 40,
thoughtTokens: 7,
cachedReadTokens: 11,
cachedWriteTokens: 13,
totalTokens: 171,
},
userMessageId: "00000000-0000-4000-8000-000000000001",
_meta: {},
})
expect(usageUpdates).toEqual([session.sessionId])
})
it("prompt maps assistant and user audience annotations", async () => {
const { service, prompts } = makeService()
const session = await Effect.runPromise(service.newSession({ cwd: "/workspace", mcpServers: [] }))
await Effect.runPromise(
service.prompt({
sessionId: session.sessionId,
prompt: [
{ type: "text", text: "assistant context", annotations: { audience: ["assistant"] } },
{ type: "text", text: "user context", annotations: { audience: ["user"] } },
],
}),
)
expect(prompts).toContainEqual({
sessionID: session.sessionId,
model: { providerID, modelID },
variant: "default",
parts: [
{ type: "text", text: "assistant context", synthetic: true },
{ type: "text", text: "user context", ignored: true },
],
agent: "build",
directory: "/workspace",
})
})
it("prompt sends image and resource parts", async () => {
const { service, prompts } = makeService()
const session = await Effect.runPromise(service.newSession({ cwd: "/workspace", mcpServers: [] }))
await Effect.runPromise(
service.prompt({
sessionId: session.sessionId,
prompt: [
{ type: "image", data: "AAAA", mimeType: "image/png", uri: "file:///tmp/screenshot.png" },
{
type: "resource",
resource: {
uri: "file:///tmp/report.pdf",
mimeType: "application/pdf",
blob: "JVBERg==",
},
},
],
}),
)
expect((prompts[0] as { parts?: unknown }).parts).toEqual([
{
type: "file",
url: "data:image/png;base64,AAAA",
filename: "screenshot.png",
mime: "image/png",
},
{
type: "file",
url: "data:application/pdf;base64,JVBERg==",
filename: "report.pdf",
mime: "application/pdf",
},
])
})
it("slash command prompt calls session command", async () => {
const { service, prompts, commands } = makeService()
const session = await Effect.runPromise(service.newSession({ cwd: "/workspace", mcpServers: [] }))
const result = await Effect.runPromise(
service.prompt({ sessionId: session.sessionId, prompt: [{ type: "text", text: "/init now" }] }),
)
expect(prompts).toEqual([])
expect(commands).toEqual([
{
sessionID: session.sessionId,
command: "init",
arguments: "now",
model: "test/test-model",
variant: "default",
agent: "build",
directory: "/workspace",
},
])
expect(result.usage).toEqual({ inputTokens: 3, outputTokens: 4, totalTokens: 7 })
})
it("compact slash command calls summarize path", async () => {
const { service, prompts, commands, summarizes } = makeService()
const session = await Effect.runPromise(service.newSession({ cwd: "/workspace", mcpServers: [] }))
await Effect.runPromise(
service.prompt({ sessionId: session.sessionId, prompt: [{ type: "text", text: "/compact" }] }),
)
expect(prompts).toEqual([])
expect(commands).toEqual([])
expect(summarizes).toEqual([
{
sessionID: session.sessionId,
directory: "/workspace",
providerID,
modelID,
},
])
})
it("maps prompt auth failures to auth-required request errors", async () => {
const { service } = makeService()
const session = await Effect.runPromise(service.newSession({ cwd: "/workspace", mcpServers: [] }))
const failing = ACPNextService.make({
sdk: {
config: {
providers: () => Promise.resolve({ data: { providers: [provider], default: { test: modelID } } }),
get: () => Promise.resolve({ data: {} }),
},
app: {
agents: () => Promise.resolve({ data: [{ name: "build", mode: "primary", permission: [], options: {} }] }),
skills: () => Promise.resolve({ data: [] }),
},
command: {
list: () => Promise.resolve({ data: [] }),
},
session: {
create: () => Promise.resolve({ data: { id: session.sessionId } }),
list: () => Promise.resolve({ data: [] }),
prompt: () => Promise.reject({ name: "ProviderAuthError", data: { providerID: "test" } }),
},
mcp: {
add: () => Promise.resolve({ data: {} }),
},
} as unknown as OpencodeClient,
usage: UsageService.Service.of({
buildUsage: UsageService.buildUsage,
latestAssistantMessage: UsageService.latestAssistantMessage,
totalSessionCost: UsageService.totalSessionCost,
contextLimit: () => Effect.succeed(128000),
sendUpdate: () => Effect.void,
}),
})
await Effect.runPromise(failing.newSession({ cwd: "/workspace", mcpServers: [] }))
const error = await Effect.runPromise(
failing
.prompt({ sessionId: session.sessionId, prompt: [{ type: "text", text: "hello" }] })
.pipe(Effect.mapError(ACPNextError.toRequestError), Effect.flip),
)
expect(error.code).toBe(-32000)
})
})
function assistantInfo(tokens: UsageService.AssistantTokenCost["tokens"]): UsageService.AssistantMessage {
return {
role: "assistant",
providerID: "test",
modelID: "test-model",
cost: 0,
tokens,
}
}
function categories(result: NewSessionResponse | LoadSessionResponse) {
return result.configOptions?.map((option) => option.category) ?? []
}
@@ -1,282 +0,0 @@
import { describe, expect } from "bun:test"
import type {
AuthenticateResponse,
CloseSessionResponse,
InitializeResponse,
LoadSessionResponse,
NewSessionResponse,
ResumeSessionResponse,
SessionNotification,
SetSessionConfigOptionResponse,
} from "@agentclientprotocol/sdk"
import { Effect } from "effect"
import { cliIt } from "../../lib/cli-process"
import { testProviderConfig } from "../../lib/test-provider"
import { createAcpClient, expectOk, firstAlternateValue, selectConfigOption } from "../acp/acp-test-client"
describe("opencode acp-next (subprocess)", () => {
cliIt.live(
"responds to initialize behind OPENCODE_ACP_NEXT",
({ opencode }) =>
Effect.gen(function* () {
const acp = createAcpClient(yield* opencode.acp({ env: { OPENCODE_ACP_NEXT: "1" } }))
const initialized = expectOk(
yield* acp.request<InitializeResponse>("initialize", {
protocolVersion: 1,
clientCapabilities: { _meta: { "terminal-auth": true } },
}),
)
expect(initialized.protocolVersion).toBe(1)
expect(initialized.agentCapabilities?.promptCapabilities?.embeddedContext).toBe(true)
expect(initialized.agentCapabilities?.promptCapabilities?.image).toBe(true)
expect(initialized.agentCapabilities?.mcpCapabilities?.http).toBe(true)
expect(initialized.agentCapabilities?.mcpCapabilities?.sse).toBe(true)
expect(initialized.agentCapabilities?.loadSession).toBe(true)
expect(initialized.agentCapabilities?.sessionCapabilities?.close).toEqual({})
expect(initialized.agentCapabilities?.sessionCapabilities?.fork).toEqual({})
expect(initialized.agentCapabilities?.sessionCapabilities?.list).toEqual({})
expect(initialized.agentCapabilities?.sessionCapabilities?.resume).toEqual({})
expect(initialized.agentInfo?.name).toBe("OpenCode")
expect(initialized.authMethods?.[0]?.id).toBe("opencode-login")
expect(initialized.authMethods?.[0]?._meta?.["terminal-auth"]).toBeDefined()
}),
60_000,
)
cliIt.live(
"authenticate succeeds for the advertised auth method and rejects unknown methods safely",
({ opencode }) =>
Effect.gen(function* () {
const acp = createAcpClient(yield* opencode.acp({ env: { OPENCODE_ACP_NEXT: "1" } }))
const initialized = expectOk(yield* acp.request<InitializeResponse>("initialize", { protocolVersion: 1 }))
const methodId = initialized.authMethods?.[0]?.id
expect(methodId).toBe("opencode-login")
expectOk(yield* acp.request<AuthenticateResponse>("authenticate", { methodId }))
const rejected = yield* acp.request<AuthenticateResponse>("authenticate", { methodId: "missing-auth-method" })
expect(errorCode(rejected.error)).toBe(-32602)
}),
60_000,
)
cliIt.live(
"creates and loads sessions behind OPENCODE_ACP_NEXT",
({ home, llm, opencode }) =>
Effect.gen(function* () {
const acp = createAcpClient(
yield* opencode.acp({
env: {
OPENCODE_ACP_NEXT: "1",
OPENCODE_CONFIG_CONTENT: JSON.stringify(testProviderConfig(llm.url)),
},
}),
)
yield* acp.request<InitializeResponse>("initialize", { protocolVersion: 1 })
const session = expectOk(yield* acp.request<NewSessionResponse>("session/new", { cwd: home, mcpServers: [] }))
expect(typeof session.sessionId).toBe("string")
expect(selectConfigOption(session.configOptions, "model")?.category).toBe("model")
const update = yield* acp.waitForNotification<SessionNotification>(
"session/update",
(params) =>
params.sessionId === session.sessionId && params.update.sessionUpdate === "available_commands_update",
)
expect(update.params?.sessionId).toBe(session.sessionId)
const loaded = expectOk(
yield* acp.request<LoadSessionResponse>("session/load", {
cwd: home,
sessionId: session.sessionId,
mcpServers: [],
}),
)
expect(selectConfigOption(loaded.configOptions, "model")?.category).toBe("model")
const prompt = yield* acp.request("session/prompt", {
sessionId: "ses_missing",
prompt: [{ type: "text", text: "hello" }],
})
expect(errorCode(prompt.error)).toBe(-32601)
}),
60_000,
)
cliIt.live(
"switches model through config options behind OPENCODE_ACP_NEXT",
({ home, llm, opencode }) =>
Effect.gen(function* () {
const acp = createAcpClient(
yield* opencode.acp({
env: {
OPENCODE_ACP_NEXT: "1",
OPENCODE_CONFIG_CONTENT: JSON.stringify(verifierConfig(llm.url)),
},
}),
)
yield* acp.request<InitializeResponse>("initialize", { protocolVersion: 1 })
const session = expectOk(yield* acp.request<NewSessionResponse>("session/new", { cwd: home, mcpServers: [] }))
const updated = expectOk(
yield* acp.request<SetSessionConfigOptionResponse>("session/set_config_option", {
sessionId: session.sessionId,
configId: "model",
value: "test/second-model",
}),
)
expect(selectConfigOption(updated.configOptions, "model")?.currentValue).toBe("test/second-model")
}),
60_000,
)
cliIt.live(
"switches effort through config options behind OPENCODE_ACP_NEXT",
({ home, llm, opencode }) =>
Effect.gen(function* () {
const acp = createAcpClient(
yield* opencode.acp({
env: {
OPENCODE_ACP_NEXT: "1",
OPENCODE_CONFIG_CONTENT: JSON.stringify(verifierConfig(llm.url)),
},
}),
)
yield* acp.request<InitializeResponse>("initialize", { protocolVersion: 1 })
const session = expectOk(yield* acp.request<NewSessionResponse>("session/new", { cwd: home, mcpServers: [] }))
const effort = selectConfigOption(session.configOptions, "effort")
expect(effort?.category).toBe("thought_level")
const nextEffort = effort ? firstAlternateValue(effort) : undefined
expect(nextEffort).toBe("high")
const updated = expectOk(
yield* acp.request<SetSessionConfigOptionResponse>("session/set_config_option", {
sessionId: session.sessionId,
configId: "effort",
value: nextEffort,
}),
)
expect(selectConfigOption(updated.configOptions, "effort")?.currentValue).toBe(nextEffort)
}),
60_000,
)
cliIt.live(
"advertises and supports close behind OPENCODE_ACP_NEXT",
({ home, llm, opencode }) =>
Effect.gen(function* () {
const acp = createAcpClient(
yield* opencode.acp({
env: {
OPENCODE_ACP_NEXT: "1",
OPENCODE_CONFIG_CONTENT: JSON.stringify(verifierConfig(llm.url)),
},
}),
)
const initialized = expectOk(yield* acp.request<InitializeResponse>("initialize", { protocolVersion: 1 }))
expect(initialized.agentCapabilities?.sessionCapabilities?.close).toEqual({})
const session = expectOk(yield* acp.request<NewSessionResponse>("session/new", { cwd: home, mcpServers: [] }))
expectOk(yield* acp.request<CloseSessionResponse>("session/close", { sessionId: session.sessionId }))
}),
60_000,
)
cliIt.live(
"advertises and supports resume behind OPENCODE_ACP_NEXT",
({ home, llm, opencode }) =>
Effect.gen(function* () {
const acp = createAcpClient(
yield* opencode.acp({
env: {
OPENCODE_ACP_NEXT: "1",
OPENCODE_CONFIG_CONTENT: JSON.stringify(verifierConfig(llm.url)),
},
}),
)
const initialized = expectOk(yield* acp.request<InitializeResponse>("initialize", { protocolVersion: 1 }))
expect(initialized.agentCapabilities?.sessionCapabilities?.resume).toEqual({})
const session = expectOk(yield* acp.request<NewSessionResponse>("session/new", { cwd: home, mcpServers: [] }))
const resumed = expectOk(
yield* acp.request<ResumeSessionResponse>("session/resume", {
cwd: home,
sessionId: session.sessionId,
mcpServers: [],
}),
)
expect(selectConfigOption(resumed.configOptions, "model")?.category).toBe("model")
}),
60_000,
)
cliIt.live(
"exits cleanly when flagged stdin is closed",
({ opencode }) =>
Effect.gen(function* () {
const exitedPromise = yield* Effect.scoped(
Effect.gen(function* () {
const acp = yield* opencode.acp({ env: { OPENCODE_ACP_NEXT: "1" } })
return acp.exited
}),
)
const code = yield* Effect.promise(() => exitedPromise)
expect(typeof code === "number" || code === null).toBe(true)
}),
60_000,
)
cliIt.live(
"default unflagged path still uses production ACP",
({ opencode }) =>
Effect.gen(function* () {
const acp = createAcpClient(yield* opencode.acp())
const initialized = expectOk(yield* acp.request<InitializeResponse>("initialize", { protocolVersion: 1 }))
expect(initialized.agentCapabilities?.sessionCapabilities?.close).toEqual({})
expect(initialized.agentCapabilities?.sessionCapabilities?.resume).toEqual({})
}),
60_000,
)
})
function errorCode(error: unknown) {
if (!error || typeof error !== "object") return undefined
if (!("code" in error)) return undefined
return typeof error.code === "number" ? error.code : undefined
}
function verifierConfig(llmUrl: string) {
const config = testProviderConfig(llmUrl)
return {
...config,
model: "test/test-model",
provider: {
test: {
...config.provider.test,
models: {
"test-model": {
...config.provider.test.models["test-model"],
variants: {
low: {},
high: {},
},
},
"second-model": {
...config.provider.test.models["test-model"],
id: "second-model",
name: "Second Test Model",
variants: {
medium: {},
max: {},
},
},
},
},
},
}
}
@@ -0,0 +1,103 @@
import { describe, expect } from "bun:test"
import type { SetSessionConfigOptionResponse } from "@agentclientprotocol/sdk"
import { Effect } from "effect"
import { cliIt } from "../../lib/cli-process"
import { expectOk, flattenSelectOptions, selectConfigOption } from "../acp/acp-test-client"
import {
createAcpNextClient,
expectAlternateValue,
expectSelectOption,
initialize,
newSession,
verifierConfig,
} from "./helpers"
describe("opencode acp-next config option subprocess", () => {
cliIt.live(
'model option is listed with category "model"',
({ home, llm, opencode }) =>
Effect.gen(function* () {
const acp = yield* createAcpNextClient(
{ opencode },
{ OPENCODE_CONFIG_CONTENT: JSON.stringify(verifierConfig(llm.url)) },
)
yield* initialize(acp)
const model = expectSelectOption((yield* newSession(acp, home)).configOptions, "model")
expect(model.category).toBe("model")
expect(model.currentValue).toBe("test/test-model")
expect(flattenSelectOptions(model).length).toBeGreaterThanOrEqual(2)
}),
60_000,
)
cliIt.live(
"model switch updates currentValue",
({ home, llm, opencode }) =>
Effect.gen(function* () {
const acp = yield* createAcpNextClient(
{ opencode },
{ OPENCODE_CONFIG_CONTENT: JSON.stringify(verifierConfig(llm.url)) },
)
yield* initialize(acp)
const session = yield* newSession(acp, home)
const model = expectSelectOption(session.configOptions, "model")
const nextModel = flattenSelectOptions(model).find((option) => option.value === "test/second-model")?.value
expect(nextModel).toBe("test/second-model")
const updated = expectOk(
yield* acp.request<SetSessionConfigOptionResponse>("session/set_config_option", {
sessionId: session.sessionId,
configId: "model",
value: nextModel,
}),
)
expect(selectConfigOption(updated.configOptions, "model")?.currentValue).toBe(nextModel)
}),
60_000,
)
cliIt.live(
'effort option is listed with category "thought_level" when selected model supports variants',
({ home, llm, opencode }) =>
Effect.gen(function* () {
const acp = yield* createAcpNextClient(
{ opencode },
{ OPENCODE_CONFIG_CONTENT: JSON.stringify(verifierConfig(llm.url)) },
)
yield* initialize(acp)
const effort = expectSelectOption((yield* newSession(acp, home)).configOptions, "effort")
expect(effort.category).toBe("thought_level")
expect(effort.currentValue).toBe("low")
expect(flattenSelectOptions(effort).map((option) => option.value)).toEqual(["low", "high"])
}),
60_000,
)
cliIt.live(
"effort switch updates currentValue",
({ home, llm, opencode }) =>
Effect.gen(function* () {
const acp = yield* createAcpNextClient(
{ opencode },
{ OPENCODE_CONFIG_CONTENT: JSON.stringify(verifierConfig(llm.url)) },
)
yield* initialize(acp)
const session = yield* newSession(acp, home)
const nextEffort = expectAlternateValue(expectSelectOption(session.configOptions, "effort"))
const updated = expectOk(
yield* acp.request<SetSessionConfigOptionResponse>("session/set_config_option", {
sessionId: session.sessionId,
configId: "effort",
value: nextEffort,
}),
)
expect(selectConfigOption(updated.configOptions, "effort")?.currentValue).toBe(nextEffort)
}),
60_000,
)
})
@@ -0,0 +1,103 @@
import { expect } from "bun:test"
import type { InitializeResponse, NewSessionResponse, SessionConfigOption } from "@agentclientprotocol/sdk"
import { Effect } from "effect"
import type { CliFixture } from "../../lib/cli-process"
import { testProviderConfig } from "../../lib/test-provider"
import {
createAcpClient,
expectOk,
flattenSelectOptions,
selectConfigOption,
type AcpClient,
} from "../acp/acp-test-client"
export function createAcpNextClient(input: Pick<CliFixture, "opencode">, env?: Record<string, string>) {
return Effect.gen(function* () {
return createAcpClient(
yield* input.opencode.acp({
env: {
OPENCODE_ACP_NEXT: "1",
...env,
},
}),
)
})
}
export function initialize(acp: AcpClient) {
return Effect.gen(function* () {
return expectOk(
yield* acp.request<InitializeResponse>("initialize", {
protocolVersion: 1,
clientCapabilities: { _meta: { "terminal-auth": true } },
clientInfo: { name: "opencode-local-acp-next", version: "0.1.0" },
}),
)
})
}
export function newSession(acp: AcpClient, cwd: string) {
return Effect.gen(function* () {
return expectOk(yield* acp.request<NewSessionResponse>("session/new", { cwd, mcpServers: [] }))
})
}
export function verifierConfig(llmUrl: string, skills?: string) {
const config = testProviderConfig(llmUrl)
return {
...config,
model: "test/test-model",
...(skills ? { skills: { paths: [skills] } } : {}),
provider: {
test: {
...config.provider.test,
models: {
"test-model": {
...config.provider.test.models["test-model"],
variants: {
low: {},
high: {},
},
},
"second-model": {
...config.provider.test.models["test-model"],
id: "second-model",
name: "Second Test Model",
variants: {
medium: {},
max: {},
},
},
},
},
},
}
}
export function expectErrorCode(error: unknown, code: number) {
if (!error || typeof error !== "object" || !("code" in error)) {
expect(error).toEqual({ code })
return
}
expect(error.code).toBe(code)
}
export function expectSelectOption(options: SessionConfigOption[] | null | undefined, id: string) {
const option = selectConfigOption(options, id)
expect(option).toBeDefined()
return option!
}
export function expectAlternateValue(option: ReturnType<typeof expectSelectOption>) {
const value = flattenSelectOptions(option).find((item) => item.value !== option.currentValue)?.value
expect(value).toBeDefined()
return value!
}
export const verifierSkill = `---
name: verifier-skill
description: Verifier compatibility skill.
---
# Verifier Skill
`
@@ -0,0 +1,61 @@
import { describe, expect } from "bun:test"
import type { AuthenticateResponse, InitializeResponse } from "@agentclientprotocol/sdk"
import { Effect } from "effect"
import { cliIt } from "../../lib/cli-process"
import { createAcpNextClient, expectErrorCode, initialize } from "./helpers"
describe("opencode acp-next initialize/auth subprocess", () => {
cliIt.live(
"initialize responds with capabilities",
({ opencode }) =>
Effect.gen(function* () {
const initialized = yield* initialize(yield* createAcpNextClient({ opencode }))
expect(initialized.protocolVersion).toBe(1)
expect(initialized.agentCapabilities?.promptCapabilities?.embeddedContext).toBe(true)
expect(initialized.agentCapabilities?.promptCapabilities?.image).toBe(true)
expect(initialized.agentCapabilities?.mcpCapabilities?.http).toBe(true)
expect(initialized.agentCapabilities?.mcpCapabilities?.sse).toBe(true)
expect(initialized.agentCapabilities?.loadSession).toBe(true)
expect(initialized.agentCapabilities?.sessionCapabilities?.close).toEqual({})
expect(initialized.agentCapabilities?.sessionCapabilities?.fork).toEqual({})
expect(initialized.agentCapabilities?.sessionCapabilities?.list).toEqual({})
expect(initialized.agentCapabilities?.sessionCapabilities?.resume).toEqual({})
expect(initialized.agentInfo?.name).toBe("OpenCode")
}),
60_000,
)
cliIt.live(
"auth negotiation is explicit and safe",
({ opencode }) =>
Effect.gen(function* () {
const acp = yield* createAcpNextClient({ opencode })
const initialized = yield* initialize(acp)
expect(initialized.authMethods?.[0]?.id).toBe("opencode-login")
expect(initialized.authMethods?.[0]?._meta?.["terminal-auth"]).toBeDefined()
expect(yield* acp.request<AuthenticateResponse>("authenticate", { methodId: "opencode-login" })).toMatchObject({
result: {},
})
const rejected = yield* acp.request<AuthenticateResponse>("authenticate", { methodId: "missing-auth-method" })
expectErrorCode(rejected.error, -32602)
expect(JSON.stringify(rejected.error)).not.toContain(process.env.OPENCODE_AUTH_CONTENT ?? "not-present")
}),
60_000,
)
cliIt.live(
"initialize without terminal-auth metadata keeps auth command implicit",
({ opencode }) =>
Effect.gen(function* () {
const acp = yield* createAcpNextClient({ opencode })
const initialized = yield* acp.request<InitializeResponse>("initialize", { protocolVersion: 1 })
expect(initialized.result?.authMethods?.[0]?.id).toBe("opencode-login")
expect(initialized.result?.authMethods?.[0]?._meta?.["terminal-auth"]).toBeUndefined()
}),
60_000,
)
})
@@ -0,0 +1,118 @@
import { describe, expect } from "bun:test"
import type {
CloseSessionResponse,
ListSessionsResponse,
LoadSessionResponse,
ResumeSessionResponse,
} from "@agentclientprotocol/sdk"
import { Duration, Effect } from "effect"
import { cliIt } from "../../lib/cli-process"
import { expectOk, selectConfigOption } from "../acp/acp-test-client"
import { createAcpNextClient, initialize, newSession, verifierConfig } from "./helpers"
describe("opencode acp-next lifecycle subprocess", () => {
cliIt.live(
"stdin EOF exits cleanly",
({ opencode }) =>
Effect.gen(function* () {
const acp = yield* opencode.acp({ env: { OPENCODE_ACP_NEXT: "1" } })
acp.close()
const code = yield* Effect.promise(() => acp.exited).pipe(Effect.timeout(Duration.seconds(5)))
expect(code).toBe(0)
}),
60_000,
)
cliIt.live(
"close capability and close request",
({ home, llm, opencode }) =>
Effect.gen(function* () {
const acp = yield* createAcpNextClient(
{ opencode },
{ OPENCODE_CONFIG_CONTENT: JSON.stringify(verifierConfig(llm.url)) },
)
const initialized = yield* initialize(acp)
expect(initialized.agentCapabilities?.sessionCapabilities?.close).toEqual({})
const session = yield* newSession(acp, home)
expectOk(yield* acp.request<CloseSessionResponse>("session/close", { sessionId: session.sessionId }))
}),
60_000,
)
cliIt.live(
"loadSession capability and load request return session config options",
({ home, llm, opencode }) =>
Effect.gen(function* () {
const acp = yield* createAcpNextClient(
{ opencode },
{ OPENCODE_CONFIG_CONTENT: JSON.stringify(verifierConfig(llm.url)) },
)
const initialized = yield* initialize(acp)
expect(initialized.agentCapabilities?.loadSession).toBe(true)
const session = yield* newSession(acp, home)
const loaded = expectOk(
yield* acp.request<LoadSessionResponse>("session/load", {
cwd: home,
sessionId: session.sessionId,
mcpServers: [],
}),
)
expect(selectConfigOption(loaded.configOptions, "model")?.category).toBe("model")
}),
60_000,
)
cliIt.live(
"list request includes a live ACP-created session",
({ home, llm, opencode }) =>
Effect.gen(function* () {
const acp = yield* createAcpNextClient(
{ opencode },
{ OPENCODE_CONFIG_CONTENT: JSON.stringify(verifierConfig(llm.url)) },
)
yield* initialize(acp)
const session = yield* newSession(acp, home)
const listed = expectOk(yield* acp.request<ListSessionsResponse>("session/list", { cwd: home }))
expect(listed.sessions.some((item) => item.sessionId === session.sessionId)).toBe(true)
}),
60_000,
)
cliIt.live(
"resume capability advertisement",
({ opencode }) =>
Effect.gen(function* () {
const initialized = yield* initialize(yield* createAcpNextClient({ opencode }))
expect(initialized.agentCapabilities?.sessionCapabilities?.resume).toEqual({})
}),
60_000,
)
cliIt.live(
"resume request returns session config options",
({ home, llm, opencode }) =>
Effect.gen(function* () {
const acp = yield* createAcpNextClient(
{ opencode },
{ OPENCODE_CONFIG_CONTENT: JSON.stringify(verifierConfig(llm.url)) },
)
yield* initialize(acp)
const session = yield* newSession(acp, home)
const resumed = expectOk(
yield* acp.request<ResumeSessionResponse>("session/resume", {
cwd: home,
sessionId: session.sessionId,
mcpServers: [],
}),
)
expect(selectConfigOption(resumed.configOptions, "model")?.category).toBe("model")
}),
60_000,
)
})
@@ -0,0 +1,97 @@
import { describe, expect } from "bun:test"
import type { PromptResponse } from "@agentclientprotocol/sdk"
import { Effect } from "effect"
import { writeFile } from "node:fs/promises"
import path from "node:path"
import { pathToFileURL } from "node:url"
import { cliIt } from "../../lib/cli-process"
import { expectOk } from "../acp/acp-test-client"
import { createAcpNextClient, initialize, newSession, verifierConfig } from "./helpers"
const tinyPng = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+ip1sAAAAASUVORK5CYII="
describe("opencode acp-next prompt content subprocess", () => {
cliIt.live(
"accepts embedded text resource image and file resource link prompt content",
({ home, llm, opencode }) =>
Effect.gen(function* () {
yield* Effect.promise(() => writeFile(path.join(home, "README.md"), "# ACP content smoke\n"))
const acp = yield* createAcpNextClient(
{ opencode },
{ OPENCODE_CONFIG_CONTENT: JSON.stringify(promptContentConfig(llm.url)) },
)
yield* initialize(acp)
const session = yield* newSession(acp, home)
yield* llm.text("embedded resource accepted")
expectOk(
yield* acp.request<PromptResponse>("session/prompt", {
sessionId: session.sessionId,
prompt: [
{ type: "text", text: "Use this embedded resource." },
{
type: "resource",
resource: { uri: "file:///context.txt", mimeType: "text/plain", text: "embedded context" },
},
],
}),
)
yield* llm.text("image accepted")
expectOk(
yield* acp.request<PromptResponse>("session/prompt", {
sessionId: session.sessionId,
prompt: [
{ type: "text", text: "Use this image." },
{
type: "image",
mimeType: "image/png",
data: tinyPng,
},
],
}),
)
yield* llm.text("file link accepted")
const linked = expectOk(
yield* acp.request<PromptResponse>("session/prompt", {
sessionId: session.sessionId,
prompt: [
{ type: "text", text: "Use this linked file." },
{
type: "resource_link",
uri: pathToFileURL(path.join(home, "README.md")).href,
name: "README.md",
mimeType: "text/markdown",
},
],
}),
)
expect(linked.stopReason).toBe("end_turn")
}),
60_000,
)
})
function promptContentConfig(llmUrl: string) {
const config = verifierConfig(llmUrl)
return {
...config,
provider: {
test: {
...config.provider.test,
models: Object.fromEntries(
Object.entries(config.provider.test.models).map(([id, model]) => [
id,
{
...model,
attachment: true,
reasoning: true,
},
]),
),
},
},
}
}
@@ -0,0 +1,38 @@
import { describe, expect } from "bun:test"
import type { SessionNotification } from "@agentclientprotocol/sdk"
import { Effect } from "effect"
import { mkdir } from "node:fs/promises"
import path from "node:path"
import { cliIt } from "../../lib/cli-process"
import { createAcpNextClient, initialize, newSession, verifierConfig, verifierSkill } from "./helpers"
describe("opencode acp-next skills subprocess", () => {
cliIt.live(
"skill slash command appears through available_commands_update",
({ home, llm, opencode }) =>
Effect.gen(function* () {
const skills = path.join(home, "skills")
yield* Effect.promise(() => mkdir(path.join(skills, "verifier-skill"), { recursive: true }))
yield* Effect.promise(() => Bun.write(path.join(skills, "verifier-skill", "SKILL.md"), verifierSkill))
const acp = yield* createAcpNextClient(
{ opencode },
{ OPENCODE_CONFIG_CONTENT: JSON.stringify(verifierConfig(llm.url, skills)) },
)
yield* initialize(acp)
const session = yield* newSession(acp, home)
const update = yield* acp.waitForNotification<SessionNotification>(
"session/update",
(params) =>
params.sessionId === session.sessionId &&
params.update.sessionUpdate === "available_commands_update" &&
params.update.availableCommands.some(
(command) => command.name === "verifier-skill" && command.description.length > 0,
),
)
expect(update.params?.sessionId).toBe(session.sessionId)
}),
60_000,
)
})
@@ -40,7 +40,7 @@ describe("opencode acp verifier compatibility baseline", () => {
)
cliIt.live(
"first session timing diagnostic stays bounded and returns model options",
"first session returns model options",
({ home, llm, opencode }) =>
Effect.gen(function* () {
const acp = createAcpClient(
@@ -50,7 +50,6 @@ describe("opencode acp verifier compatibility baseline", () => {
},
}),
)
const started = Date.now()
yield* acp.request<InitializeResponse>("initialize", {
protocolVersion: 1,
clientCapabilities: {},
@@ -62,9 +61,6 @@ describe("opencode acp verifier compatibility baseline", () => {
mcpServers: [],
}),
)
const durationMs = Date.now() - started
expect(durationMs).toBeLessThan(15_000)
const model = selectConfigOption(session.configOptions, "model")
expect(model?.category).toBe("model")
expect(model?.currentValue).toBe("test/test-model")
@@ -74,7 +70,7 @@ describe("opencode acp verifier compatibility baseline", () => {
)
cliIt.live(
"warm newSession timing diagnostic stays bounded",
"newSession can be called repeatedly",
({ home, llm, opencode }) =>
Effect.gen(function* () {
const acp = createAcpClient(
@@ -87,22 +83,19 @@ describe("opencode acp verifier compatibility baseline", () => {
yield* acp.request<InitializeResponse>("initialize", { protocolVersion: 1 })
yield* acp.request<NewSessionResponse>("session/new", { cwd: home, mcpServers: [] })
const started = Date.now()
const session = expectOk(
yield* acp.request<NewSessionResponse>("session/new", {
cwd: home,
mcpServers: [],
}),
)
const durationMs = Date.now() - started
expect(durationMs).toBeLessThan(15_000)
expect(session.sessionId).toBeTruthy()
}),
60_000,
)
cliIt.live(
"model switch timing diagnostic updates currentValue",
"model switch updates currentValue",
({ home, llm, opencode }) =>
Effect.gen(function* () {
const acp = createAcpClient(
@@ -121,7 +114,6 @@ describe("opencode acp verifier compatibility baseline", () => {
: undefined
expect(nextModel).toBe("test/second-model")
const started = Date.now()
const updated = expectOk(
yield* acp.request<SetSessionConfigOptionResponse>("session/set_config_option", {
sessionId: session.sessionId,
@@ -129,9 +121,7 @@ describe("opencode acp verifier compatibility baseline", () => {
value: nextModel,
}),
)
const durationMs = Date.now() - started
expect(durationMs).toBeLessThan(15_000)
expect(selectConfigOption(updated.configOptions, "model")?.currentValue).toBe(nextModel)
}),
60_000,
@@ -189,7 +179,7 @@ describe("opencode acp verifier compatibility baseline", () => {
)
cliIt.live(
"skill slash command timing diagnostic appears through available_commands_update",
"skill slash command appears through available_commands_update",
({ home, llm, opencode }) =>
Effect.gen(function* () {
const skills = path.join(home, "skills")
@@ -214,20 +204,6 @@ describe("opencode acp verifier compatibility baseline", () => {
)
expect(update.params?.sessionId).toBe(session.sessionId)
const secondSession = expectOk(
yield* acp.request<NewSessionResponse>("session/new", { cwd: home, mcpServers: [] }),
)
const started = Date.now()
yield* acp.waitForNotification<SessionNotification>(
"session/update",
(params) =>
params.sessionId === secondSession.sessionId &&
params.update.sessionUpdate === "available_commands_update" &&
params.update.availableCommands.some((command) => command.name === "verifier-skill"),
)
const durationMs = Date.now() - started
expect(durationMs).toBeLessThan(15_000)
}),
60_000,
)
+229 -151
View File
@@ -1,8 +1,9 @@
import { describe, expect, test } from "bun:test"
import { EventEmitter } from "node:events"
import type { IncomingMessage } from "node:http"
import { createServer, type IncomingMessage, type Server as HttpServer } from "node:http"
import net, { type AddressInfo, type Socket } from "node:net"
import WebSocket, { WebSocketServer } from "ws"
import { ProviderError } from "../../src/provider/error"
import { OpenAIWebSocket } from "../../src/plugin/openai/ws"
import { OpenAIWebSocketPool, TITLE_HEADER } from "../../src/plugin/openai/ws-pool"
@@ -50,7 +51,7 @@ describe("plugin.openai.ws", () => {
onConnectionInvalid: (error) => invalid.push(error.message),
})
await expect(response.text()).rejects.toThrow("idle timeout sending websocket request")
expect((await readTextError(response.text())).message).toContain("idle timeout sending websocket request")
expect(invalid).toEqual(["idle timeout sending websocket request"])
})
@@ -85,7 +86,7 @@ describe("plugin.openai.ws", () => {
})
test("errors the SSE stream when the server closes before a terminal event", async () => {
const invalid: string[] = []
const invalid: Error[] = []
await using server = await createWebSocketServer((socket) => {
socket.once("message", () => {
socket.close(1009, "payload too large")
@@ -96,13 +97,14 @@ describe("plugin.openai.ws", () => {
const response = OpenAIWebSocket.streamResponsesWebSocket({
socket,
body: { stream: true, input: "hi" },
onConnectionInvalid: (error) => invalid.push(error.message),
onConnectionInvalid: (error) => invalid.push(error),
})
await expect(response.text()).rejects.toThrow(
expect((await readTextError(response.text())).message).toContain(
"WebSocket closed before response.completed (code 1009: message too big: payload too large)",
)
expect(invalid).toEqual([
expect(invalid[0]).toBeInstanceOf(ProviderError.ResponseStreamError)
expect(invalid.map((error) => error.message)).toEqual([
"WebSocket closed before response.completed (code 1009: message too big: payload too large)",
])
})
@@ -122,7 +124,7 @@ describe("plugin.openai.ws", () => {
onConnectionInvalid: (error) => invalid.push(error.message),
})
await expect(response.text()).rejects.toThrow("Unexpected binary WebSocket frame")
expect((await readTextError(response.text())).message).toContain("Unexpected binary WebSocket frame")
expect(invalid).toEqual(["Unexpected binary WebSocket frame"])
})
})
@@ -140,13 +142,12 @@ describe("plugin.openai.ws-pool", () => {
})
const fetch = OpenAIWebSocketPool.createWebSocketFetch({
url: server.url,
httpFetch: mockFetch(async () => new Response("http")),
})
const first = await fetch("https://api.openai.com/v1/responses", streamRequest())
const first = await fetch(server.url, streamRequest())
expect(await first.text()).toContain("data: [DONE]")
const second = await fetch("https://api.openai.com/v1/responses", streamRequest())
const second = await fetch(server.url, streamRequest())
expect(await second.text()).toContain("data: [DONE]")
expect(connections).toBe(1)
expect(messages).toBe(2)
@@ -163,41 +164,59 @@ describe("plugin.openai.ws-pool", () => {
})
const fetch = OpenAIWebSocketPool.createWebSocketFetch({
url: server.url,
httpFetch: mockFetch(async () => new Response("http")),
maxConnectionAge: 0,
})
const first = await fetch("https://api.openai.com/v1/responses", streamRequest())
const first = await fetch(server.url, streamRequest())
expect(await first.text()).toContain("data: [DONE]")
const second = await fetch("https://api.openai.com/v1/responses", streamRequest())
const second = await fetch(server.url, streamRequest())
expect(await second.text()).toContain("data: [DONE]")
expect(connections).toBe(2)
fetch.close()
})
test("falls back to HTTP when websocket setup fails and keeps the fallback sticky", async () => {
test("falls back to HTTP after websocket setup retries are exhausted", async () => {
const attempts: string[] = []
await using server = await createRejectingWebSocketServer(() => attempts.push("websocket"))
const httpRequests: Headers[] = []
const fetch = OpenAIWebSocketPool.createWebSocketFetch({
url: server.url,
httpFetch: mockFetch(async (_input, init) => {
httpRequests.push(new Headers(init?.headers))
return new Response("http")
}),
connectTimeout: 100,
streamRetries: 1,
})
const first = await fetch("https://api.openai.com/v1/responses", streamRequest({ [TITLE_HEADER]: "false" }))
const second = await fetch("https://api.openai.com/v1/responses", streamRequest({ [TITLE_HEADER]: "false" }))
const first = await fetch(server.url, streamRequest({ [TITLE_HEADER]: "false" }))
expect(await readTextError(first.text())).toBeInstanceOf(ProviderError.ResponseStreamError)
const second = await fetch(server.url, streamRequest({ [TITLE_HEADER]: "false" }))
const third = await fetch(server.url, streamRequest({ [TITLE_HEADER]: "false" }))
expect(await first.text()).toBe("http")
expect(await second.text()).toBe("http")
expect(attempts).toEqual(["websocket"])
expect(httpRequests).toHaveLength(2)
expect(httpRequests[0]?.get(TITLE_HEADER)).toBeNull()
expect(httpRequests[1]?.get(TITLE_HEADER)).toBeNull()
expect(await third.text()).toBe("http")
expect(attempts).toEqual(["websocket", "websocket"])
expect(server.httpRequests).toHaveLength(2)
expect(server.httpRequests[0]?.headers[TITLE_HEADER]).toBeUndefined()
expect(server.httpRequests[1]?.headers[TITLE_HEADER]).toBeUndefined()
fetch.close()
})
test("prunes HTTP fallback after its idle timeout", async () => {
let websocketAttempts = 0
await using server = await createRejectingWebSocketServer(() => websocketAttempts++)
const fetch = OpenAIWebSocketPool.createWebSocketFetch({
url: server.url,
connectTimeout: 100,
idleTimeout: 20,
streamRetries: 0,
})
const first = await fetch(server.url, streamRequest())
expect(await first.text()).toBe("http")
await new Promise((resolve) => setTimeout(resolve, 50))
const second = await fetch(server.url, streamRequest())
expect(await second.text()).toBe("http")
expect(websocketAttempts).toBe(2)
expect(server.httpRequests).toHaveLength(2)
fetch.close()
})
@@ -209,26 +228,21 @@ describe("plugin.openai.ws-pool", () => {
socket.send(JSON.stringify({ type: connections === 1 ? "response.failed" : "response.completed" }))
})
})
const httpRequests: Headers[] = []
const fetch = OpenAIWebSocketPool.createWebSocketFetch({
url: server.url,
httpFetch: mockFetch(async (_input, init) => {
httpRequests.push(new Headers(init?.headers))
return new Response("http")
}),
})
const first = await fetch("https://api.openai.com/v1/responses", streamRequest())
const first = await fetch(server.url, streamRequest())
expect(await first.text()).toContain('data: {"type":"response.failed"}')
const second = await fetch("https://api.openai.com/v1/responses", streamRequest())
const second = await fetch(server.url, streamRequest())
expect(await second.text()).toContain('data: {"type":"response.completed"}')
expect(connections).toBe(2)
expect(httpRequests).toHaveLength(0)
expect(server.httpRequests).toHaveLength(0)
fetch.close()
})
test("reconnects and replays after websocket connection limit errors", async () => {
test("retries websocket connection limit errors on the next stream attempt", async () => {
let connections = 0
let messages = 0
await using server = await createWebSocketServer((socket) => {
@@ -252,24 +266,21 @@ describe("plugin.openai.ws-pool", () => {
socket.send(JSON.stringify({ type: "response.completed", response: { id: "resp_retry" } }))
})
})
const httpRequests: Headers[] = []
const fetch = OpenAIWebSocketPool.createWebSocketFetch({
url: server.url,
httpFetch: mockFetch(async (_input, init) => {
httpRequests.push(new Headers(init?.headers))
return new Response("http")
}),
})
const response = await fetch("https://api.openai.com/v1/responses", streamRequest())
const text = await response.text()
const first = await fetch(server.url, streamRequest())
expect((await readTextError(first.text())).message).toContain("Responses websocket connection limit reached")
const second = await fetch(server.url, streamRequest())
const text = await second.text()
expect(text).not.toContain("websocket_connection_limit_reached")
expect(text).toContain('data: {"type":"response.completed","response":{"id":"resp_retry"}}')
expect(text).toContain("data: [DONE]")
expect(connections).toBe(2)
expect(messages).toBe(2)
expect(httpRequests).toHaveLength(0)
expect(server.httpRequests).toHaveLength(0)
fetch.close()
})
@@ -291,98 +302,159 @@ describe("plugin.openai.ws-pool", () => {
)
})
})
let httpRequests = 0
const fetch = OpenAIWebSocketPool.createWebSocketFetch({
url: server.url,
connectionLimitRetries: 2,
httpFetch: mockFetch(async () => {
httpRequests += 1
return new Response("http")
}),
streamRetries: 2,
})
const first = await fetch("https://api.openai.com/v1/responses", streamRequest())
const second = await fetch("https://api.openai.com/v1/responses", streamRequest())
const first = await fetch(server.url, streamRequest())
expect((await readTextError(first.text())).message).toContain("Responses websocket connection limit reached")
const second = await fetch(server.url, streamRequest())
expect((await readTextError(second.text())).message).toContain("Responses websocket connection limit reached")
const third = await fetch(server.url, streamRequest())
const fourth = await fetch(server.url, streamRequest())
expect(await first.text()).toBe("http")
expect(await second.text()).toBe("http")
expect(await third.text()).toBe("http")
expect(await fourth.text()).toBe("http")
expect(connections).toBe(3)
expect(httpRequests).toBe(2)
expect(server.httpRequests).toHaveLength(2)
fetch.close()
})
test("replays over HTTP when websocket idles before its first event", async () => {
test("shares the websocket retry budget across stream and connection limit failures", async () => {
let connections = 0
await using server = await createWebSocketServer((socket) => {
connections += 1
socket.once("message", () => {
if (connections === 1) {
socket.send(JSON.stringify({ type: "response.output_text.delta", delta: "started" }))
socket.terminate()
return
}
socket.send(
JSON.stringify({
type: "error",
error: {
code: "websocket_connection_limit_reached",
message: "Responses websocket connection limit reached",
},
}),
)
})
})
const fetch = OpenAIWebSocketPool.createWebSocketFetch({
url: server.url,
streamRetries: 1,
})
const first = await fetch(server.url, streamRequest())
expect((await readTextError(first.text())).message).toContain("WebSocket closed before response.completed")
const second = await fetch(server.url, streamRequest())
expect(await second.text()).toBe("http")
expect(connections).toBe(2)
expect(server.httpRequests).toHaveLength(1)
fetch.close()
})
test("retries websocket idle failures before first event then falls back to HTTP", async () => {
let connections = 0
await using server = await createWebSocketServer((socket) => {
connections += 1
socket.once("message", () => {})
})
const httpRequests: Headers[] = []
const fetch = OpenAIWebSocketPool.createWebSocketFetch({
url: server.url,
idleTimeout: 20,
httpFetch: mockFetch(async (_input, init) => {
httpRequests.push(new Headers(init?.headers))
return new Response("http")
}),
streamRetries: 1,
})
const first = await fetch("https://api.openai.com/v1/responses", streamRequest())
expect(await first.text()).toBe("http")
const second = await fetch("https://api.openai.com/v1/responses", streamRequest())
const first = await fetch(server.url, streamRequest())
expect((await readTextError(first.text())).message).toContain("idle timeout waiting for websocket")
const second = await fetch(server.url, streamRequest())
const third = await fetch(server.url, streamRequest())
expect(await second.text()).toBe("http")
expect(connections).toBe(1)
expect(httpRequests).toHaveLength(2)
expect(await third.text()).toBe("http")
expect(connections).toBe(2)
expect(server.httpRequests).toHaveLength(2)
fetch.close()
})
test("does not replay over HTTP after a websocket event was emitted", async () => {
test("retries failed websocket streams before using HTTP fallback", async () => {
await using server = await createWebSocketServer((socket) => {
socket.once("message", () => {
socket.send(JSON.stringify({ type: "response.output_text.delta", delta: "started" }))
})
})
const httpRequests: Headers[] = []
const fetch = OpenAIWebSocketPool.createWebSocketFetch({
url: server.url,
idleTimeout: 20,
httpFetch: mockFetch(async (_input, init) => {
httpRequests.push(new Headers(init?.headers))
return new Response("http")
}),
streamRetries: 1,
})
const first = await fetch("https://api.openai.com/v1/responses", streamRequest())
await expect(first.text()).rejects.toThrow("idle timeout waiting for websocket")
const second = await fetch("https://api.openai.com/v1/responses", streamRequest())
const first = await fetch(server.url, streamRequest())
expect((await readTextError(first.text())).message).toContain("idle timeout waiting for websocket")
const second = await fetch(server.url, streamRequest())
expect((await readTextError(second.text())).message).toContain("idle timeout waiting for websocket")
const third = await fetch(server.url, streamRequest())
expect(await second.text()).toBe("http")
expect(httpRequests).toHaveLength(1)
expect(await third.text()).toBe("http")
expect(server.httpRequests).toHaveLength(1)
fetch.close()
})
test("resets websocket stream failures after a completed response", async () => {
let connections = 0
let requests = 0
await using server = await createWebSocketServer((socket) => {
connections += 1
socket.on("message", () => {
requests += 1
if (requests === 1 || requests === 3) {
socket.send(JSON.stringify({ type: "response.output_text.delta", delta: "started" }))
socket.terminate()
return
}
socket.send(JSON.stringify({ type: "response.completed", response: { id: `resp_${requests}` } }))
})
})
const fetch = OpenAIWebSocketPool.createWebSocketFetch({
url: server.url,
streamRetries: 1,
})
const first = await fetch(server.url, streamRequest())
expect((await readTextError(first.text())).message).toContain("WebSocket closed before response.completed")
const second = await fetch(server.url, streamRequest())
expect(await second.text()).toContain("data: [DONE]")
const third = await fetch(server.url, streamRequest())
expect((await readTextError(third.text())).message).toContain("WebSocket closed before response.completed")
const fourth = await fetch(server.url, streamRequest())
expect(await fourth.text()).toContain("data: [DONE]")
expect(connections).toBe(3)
expect(requests).toBe(4)
expect(server.httpRequests).toHaveLength(0)
fetch.close()
})
test("falls back to HTTP for missing session and title requests", async () => {
const httpRequests: Headers[] = []
const fetch = OpenAIWebSocketPool.createWebSocketFetch({
httpFetch: mockFetch(async (_input, init) => {
httpRequests.push(new Headers(init?.headers))
return new Response("http")
}),
})
await using server = await createWebSocketServer(() => {})
const fetch = OpenAIWebSocketPool.createWebSocketFetch()
const missingSession = await fetch("https://api.openai.com/v1/responses", {
const missingSession = await fetch(server.url, {
method: "POST",
headers: { [TITLE_HEADER]: "false" },
body: JSON.stringify({ stream: true }),
})
const title = await fetch("https://api.openai.com/v1/responses", streamRequest({ [TITLE_HEADER]: "true" }))
const title = await fetch(server.url, streamRequest({ [TITLE_HEADER]: "true" }))
expect(await missingSession.text()).toBe("http")
expect(await title.text()).toBe("http")
expect(httpRequests).toHaveLength(2)
expect(httpRequests[0]?.get(TITLE_HEADER)).toBeNull()
expect(httpRequests[1]?.get(TITLE_HEADER)).toBeNull()
expect(server.httpRequests).toHaveLength(2)
expect(server.httpRequests[0]?.headers[TITLE_HEADER]).toBeUndefined()
expect(server.httpRequests[1]?.headers[TITLE_HEADER]).toBeUndefined()
fetch.close()
})
@@ -395,52 +467,44 @@ describe("plugin.openai.ws-pool", () => {
})
})
const abort = new AbortController()
const httpRequests: Headers[] = []
const fetch = OpenAIWebSocketPool.createWebSocketFetch({
url: server.url,
httpFetch: mockFetch(async (_input, init) => {
httpRequests.push(new Headers(init?.headers))
return new Response("http")
}),
})
const first = await fetch("https://api.openai.com/v1/responses", streamRequest({}, abort.signal))
const first = await fetch(server.url, streamRequest({}, abort.signal))
const firstText = first.text()
await waitFor(() => connections === 1, "websocket did not connect")
const second = await fetch("https://api.openai.com/v1/responses", streamRequest())
const second = await fetch(server.url, streamRequest())
expect(await second.text()).toBe("http")
expect(httpRequests).toHaveLength(1)
expect(server.httpRequests).toHaveLength(1)
expect(connections).toBe(1)
abort.abort(new Error("stop"))
await expect(firstText).rejects.toThrow("stop")
expect((await readTextError(firstText)).message).toContain("stop")
fetch.close()
})
test("reserves a websocket lane while its socket is connecting", async () => {
await using server = await createHangingTcpServer()
let httpRequests = 0
await using fallback = await createHttpServer()
const fetch = OpenAIWebSocketPool.createWebSocketFetch({
url: server.url,
connectTimeout: 20,
httpFetch: mockFetch(async () => {
httpRequests += 1
return new Response("http")
}),
streamRetries: 0,
})
const first = fetch("https://api.openai.com/v1/responses", streamRequest())
const first = fetch(fallback.url, streamRequest())
await waitFor(() => server.connections() === 1, "first websocket did not begin connecting")
const second = fetch("https://api.openai.com/v1/responses", streamRequest())
const second = fetch(fallback.url, streamRequest())
expect(await (await second).text()).toBe("http")
expect(await (await first).text()).toBe("http")
expect(server.connections()).toBe(1)
expect(httpRequests).toBe(2)
expect(fallback.httpRequests).toHaveLength(2)
fetch.close()
})
test("replays over HTTP after an unexpected close before the first event", async () => {
test("retries unexpected closes before first event then falls back to HTTP", async () => {
let connections = 0
await using server = await createWebSocketServer((socket) => {
connections += 1
@@ -448,22 +512,20 @@ describe("plugin.openai.ws-pool", () => {
socket.close(1001, "server shutdown")
})
})
const httpRequests: Headers[] = []
const fetch = OpenAIWebSocketPool.createWebSocketFetch({
url: server.url,
httpFetch: mockFetch(async (_input, init) => {
httpRequests.push(new Headers(init?.headers))
return new Response("http")
}),
streamRetries: 1,
})
const first = await fetch("https://api.openai.com/v1/responses", streamRequest())
expect(await first.text()).toBe("http")
const second = await fetch("https://api.openai.com/v1/responses", streamRequest())
const first = await fetch(server.url, streamRequest())
expect((await readTextError(first.text())).message).toContain("WebSocket closed before response.completed")
const second = await fetch(server.url, streamRequest())
const third = await fetch(server.url, streamRequest())
expect(await second.text()).toBe("http")
expect(connections).toBe(1)
expect(httpRequests).toHaveLength(2)
expect(await third.text()).toBe("http")
expect(connections).toBe(2)
expect(server.httpRequests).toHaveLength(2)
fetch.close()
})
@@ -479,27 +541,22 @@ describe("plugin.openai.ws-pool", () => {
socket.send(JSON.stringify({ type: "response.completed", response: { id: "resp_456" } }))
})
})
const httpRequests: Headers[] = []
const abort = new AbortController()
const fetch = OpenAIWebSocketPool.createWebSocketFetch({
url: server.url,
httpFetch: mockFetch(async (_input, init) => {
httpRequests.push(new Headers(init?.headers))
return new Response("http")
}),
})
const first = await fetch("https://api.openai.com/v1/responses", streamRequest({}, abort.signal))
const first = await fetch(server.url, streamRequest({}, abort.signal))
const firstText = first.text()
await waitFor(() => connections === 1, "first websocket did not connect")
abort.abort(new Error("stop"))
await expect(firstText).rejects.toThrow("stop")
expect((await readTextError(firstText)).message).toContain("stop")
const second = await fetch("https://api.openai.com/v1/responses", streamRequest())
const second = await fetch(server.url, streamRequest())
expect(await second.text()).toContain("data: [DONE]")
expect(connections).toBe(2)
expect(httpRequests).toHaveLength(0)
expect(server.httpRequests).toHaveLength(0)
fetch.close()
})
@@ -515,24 +572,19 @@ describe("plugin.openai.ws-pool", () => {
socket.send(JSON.stringify({ type: "response.completed", response: { id: "resp_after_cancel" } }))
})
})
const httpRequests: Headers[] = []
const fetch = OpenAIWebSocketPool.createWebSocketFetch({
url: server.url,
httpFetch: mockFetch(async (_input, init) => {
httpRequests.push(new Headers(init?.headers))
return new Response("http")
}),
})
const first = await fetch("https://api.openai.com/v1/responses", streamRequest())
const first = await fetch(server.url, streamRequest())
await waitFor(() => connections === 1, "first websocket did not connect")
await first.body!.cancel("stop")
const second = await fetch("https://api.openai.com/v1/responses", streamRequest())
const second = await fetch(server.url, streamRequest())
expect(await second.text()).toContain("data: [DONE]")
expect(connections).toBe(2)
expect(httpRequests).toHaveLength(0)
expect(server.httpRequests).toHaveLength(0)
fetch.close()
})
})
@@ -550,20 +602,24 @@ function streamRequest(headers?: Record<string, string>, signal?: AbortSignal):
}
}
function mockFetch(
fn: (
input: Parameters<typeof globalThis.fetch>[0],
init: Parameters<typeof globalThis.fetch>[1],
) => ReturnType<typeof globalThis.fetch>,
): typeof globalThis.fetch {
return Object.assign(fn, { preconnect: globalThis.fetch.preconnect })
async function readTextError(promise: Promise<string>) {
// Bun 1.3.14 hangs on expect(response.text()).rejects for streams errored from ws callbacks.
return promise.then(
() => {
throw new Error("Expected response text to reject")
},
(error) => {
expect(error).toBeInstanceOf(Error)
return error as Error
},
)
}
async function createWebSocketServer(onConnection: (socket: WebSocket, request: IncomingMessage) => void) {
const server = new WebSocketServer({ host: "127.0.0.1", port: 0 })
const http = await createHttpServer()
const server = new WebSocketServer({ server: http.server })
server.on("connection", onConnection)
await new Promise<void>((resolve) => server.once("listening", resolve))
return websocketServerHandle(server)
return websocketServerHandle(server, http)
}
async function createHangingTcpServer() {
@@ -588,31 +644,53 @@ async function createHangingTcpServer() {
}
async function createRejectingWebSocketServer(onAttempt: () => void) {
const http = await createHttpServer()
const server = new WebSocketServer({
host: "127.0.0.1",
port: 0,
server: http.server,
verifyClient(_info, callback) {
onAttempt()
callback(false, 401, "denied")
},
})
await new Promise<void>((resolve) => server.once("listening", resolve))
return websocketServerHandle(server)
return websocketServerHandle(server, http)
}
function websocketServerHandle(server: WebSocketServer) {
async function createHttpServer() {
const httpRequests: IncomingMessage[] = []
const server = createServer((request, response) => {
httpRequests.push(request)
response.writeHead(200, { "content-type": "text/plain" })
response.end("http")
})
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve))
const address = server.address() as AddressInfo
const url = `http://127.0.0.1:${address.port}/v1/responses`
return {
url,
wsUrl: url.replace(/^http/, "ws"),
server,
httpRequests,
url: `http://127.0.0.1:${address.port}/v1/responses`,
async [Symbol.asyncDispose]() {
await closeHttpServer(server)
},
}
}
function websocketServerHandle(server: WebSocketServer, http: Awaited<ReturnType<typeof createHttpServer>>) {
return {
url: http.url,
wsUrl: http.url.replace(/^http/, "ws"),
httpRequests: http.httpRequests,
async [Symbol.asyncDispose]() {
for (const socket of server.clients) socket.terminate()
server.close()
http.server.close()
},
}
}
function closeHttpServer(server: HttpServer) {
return new Promise<void>((resolve, reject) => server.close((error) => (error ? reject(error) : resolve())))
}
async function waitFor(predicate: () => boolean, message: string) {
const started = Date.now()
while (!predicate()) {
@@ -147,15 +147,6 @@ describe("Project.fromDirectory", () => {
expect(project.id).not.toBe(ProjectID.global)
expect(project.vcs).toBe("git")
expect(project.worktree).toBe(tmp)
expect(
Database.use((db) => db.select().from(WorkspaceTable).where(eq(WorkspaceTable.project_id, project.id)).all()),
).toEqual([
expect.objectContaining({
type: "directory",
directory: tmp,
primary: true,
}),
])
}),
)
@@ -357,22 +348,6 @@ describe("Project.fromDirectory with worktrees", () => {
const { project: wt } = yield* run((svc) => svc.fromDirectory(worktreePath))
expect(wt.id).toBe(main.id)
expect(
Database.use((db) =>
db
.select()
.from(WorkspaceTable)
.where(eq(WorkspaceTable.project_id, main.id))
.all()
.map((row) => ({ directory: row.directory, primary: row.primary, type: row.type }))
.toSorted((a, b) => String(a.directory).localeCompare(String(b.directory))),
),
).toEqual(
[
{ directory: tmp, primary: true, type: "directory" },
{ directory: worktreePath, primary: false, type: "worktree" },
].toSorted((a, b) => a.directory.localeCompare(b.directory)),
)
const cache = path.join(tmp, ".git", "opencode")
const exists = yield* Effect.promise(() => Bun.file(cache).exists())
@@ -397,22 +372,6 @@ describe("Project.fromDirectory with worktrees", () => {
const { project: b } = yield* run((svc) => svc.fromDirectory(clone))
expect(b.id).toBe(a.id)
expect(
Database.use((db) =>
db
.select()
.from(WorkspaceTable)
.where(eq(WorkspaceTable.project_id, a.id))
.all()
.map((row) => ({ directory: row.directory, primary: row.primary, type: row.type }))
.toSorted((left, right) => String(left.directory).localeCompare(String(right.directory))),
),
).toEqual(
[
{ directory: tmp, primary: true, type: "directory" },
{ directory: clone, primary: false, type: "directory" },
].toSorted((left, right) => left.directory.localeCompare(right.directory)),
)
}),
)
@@ -3223,6 +3223,12 @@ describe("ProviderTransform.variants", () => {
efforts: ["low", "medium", "high", "xhigh", "max"],
expectedHigh: { thinking: { type: "adaptive", display: "summarized" }, effort: "high" },
},
{
name: "opus 4.8",
apiIds: ["claude-opus-4-8", "claude-opus-4.8"],
efforts: ["low", "medium", "high", "xhigh", "max"],
expectedHigh: { thinking: { type: "adaptive", display: "summarized" }, effort: "high" },
},
]) {
for (const apiId of testCase.apiIds) {
test(`${testCase.name} ${apiId} returns supported reasoning efforts`, () => {
@@ -3341,6 +3347,28 @@ describe("ProviderTransform.variants", () => {
})
})
test("anthropic opus 4.8 returns adaptive reasoning options with xhigh", () => {
const result = ProviderTransform.variants(
createMockModel({
id: "bedrock/anthropic-claude-opus-4.8",
providerID: "bedrock",
api: {
id: "anthropic.claude-opus-4.8",
url: "https://bedrock.amazonaws.com",
npm: "@ai-sdk/amazon-bedrock",
},
}),
)
expect(Object.keys(result)).toEqual(["low", "medium", "high", "xhigh", "max"])
expect(result.high).toEqual({
reasoningConfig: {
type: "adaptive",
maxReasoningEffort: "high",
display: "summarized",
},
})
})
test("returns WIDELY_SUPPORTED_EFFORTS with reasoningConfig", () => {
const model = createMockModel({
id: "bedrock/llama-4",
@@ -2,6 +2,7 @@ import { afterEach, describe, expect } from "bun:test"
import { mkdir } from "node:fs/promises"
import path from "node:path"
import { Cause, Effect, Exit, Layer } from "effect"
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import { Flag } from "@opencode-ai/core/flag/flag"
import { registerAdapter } from "../../src/control-plane/adapters"
import type { WorkspaceAdapter } from "../../src/control-plane/types"
@@ -27,7 +28,9 @@ import * as DateTime from "effect/DateTime"
import * as Log from "@opencode-ai/core/util/log"
import { eq } from "drizzle-orm"
import { resetDatabase } from "../fixture/db"
import { disposeAllInstances, TestInstance } from "../fixture/fixture"
import { disposeAllInstances, provideInstanceEffect, TestInstance, tmpdirScoped } from "../fixture/fixture"
import { TestLLMServer } from "../lib/llm-server"
import { testProviderConfig } from "../lib/test-provider"
import { testEffect } from "../lib/effect"
void Log.init({ print: false })
@@ -368,6 +371,45 @@ describe("session HttpApi", () => {
{ git: true, config: { formatter: false, lsp: false } },
)
it.live("uses the persisted session directory for prompt requests", () =>
Effect.gen(function* () {
const llm = yield* TestLLMServer
yield* llm.text("ok", { usage: { input: 1, output: 1 } })
const config = testProviderConfig(llm.url)
const sessionDirectory = yield* tmpdirScoped({ git: true, config })
const requestDirectory = yield* tmpdirScoped({ git: true, config })
const session = yield* createSession({ title: "directory regression" }).pipe(
provideInstanceEffect(sessionDirectory),
)
const response = yield* request(
`${pathFor(SessionPaths.prompt, { sessionID: session.id })}?directory=${encodeURIComponent(requestDirectory)}`,
{
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
agent: "build",
model: { providerID: "test", modelID: "test-model" },
parts: [{ type: "text", text: "which directory?" }],
}),
},
)
expect(response.status).toBe(200)
yield* responseJson(response)
const messages = yield* Session.use
.messages({ sessionID: session.id })
.pipe(provideInstanceEffect(sessionDirectory), Effect.orDie)
const assistant = messages.find((message) => message.info.role === "assistant")
expect(assistant?.info.role === "assistant" ? assistant.info.path : undefined).toEqual({
cwd: sessionDirectory,
root: sessionDirectory,
})
}).pipe(Effect.provide(TestLLMServer.layer), Effect.provide(CrossSpawnSpawner.defaultLayer)),
)
it.instance(
"returns v2 public request errors for cursor and workspace query failures",
() =>
@@ -406,6 +406,20 @@ describe("HttpApi UI fallback", () => {
}),
)
it.live("accepts basic auth passwords containing colons for the web UI", () =>
Effect.gen(function* () {
const response = yield* uiApp({
password: "sec:ret",
username: "opencode",
disableEmbeddedWebUi: true,
}).request("/", {
headers: { authorization: `Basic ${btoa("opencode:sec:ret")}` },
})
expect(response.status).toBe(200)
}),
)
// Regression for #25698 (Ope): the browser fetches the PWA manifest and
// its icons via flows that don't carry app-managed credentials (the
// `<link rel="manifest">` request is not under page-auth control), so the
@@ -172,6 +172,17 @@ describe("session.retry.retryable", () => {
})
})
test("retries websocket stream transport errors", () => {
const request = MessageV2.fromError(
new ProviderError.ResponseStreamError("WebSocket closed before response.completed (code 1006: Connection ended)"),
{ providerID },
)
expect(MessageV2.APIError.isInstance(request)).toBe(true)
expect(SessionRetry.retryable(request, retryProvider)).toEqual({
message: "WebSocket closed before response.completed (code 1006: Connection ended)",
})
})
test("does not retry context overflow errors", () => {
const error = new MessageV2.ContextOverflowError({
message: "Input exceeds context window of this model",
@@ -38,19 +38,6 @@ describe("workspace time migration", () => {
)
expect(() => migrate(db, entries.slice(index))).not.toThrow()
expect(sqlite.query("SELECT time_used, `primary` FROM workspace WHERE id = ?").get("workspace_1")).toEqual({
time_used: 0,
primary: 0,
})
sqlite.run(
"INSERT INTO workspace (id, type, name, directory, `primary`, project_id, time_used) VALUES (?, ?, ?, ?, ?, ?, ?)",
["workspace_primary", "directory", "primary", "/tmp/project", true, "project_1", 1],
)
expect(() =>
sqlite.run(
"INSERT INTO workspace (id, type, name, directory, `primary`, project_id, time_used) VALUES (?, ?, ?, ?, ?, ?, ?)",
["workspace_second_primary", "worktree", "secondary", "/tmp/secondary", true, "project_1", 1],
),
).toThrow()
expect(sqlite.query("SELECT time_used FROM workspace WHERE id = ?").get("workspace_1")).toEqual({ time_used: 0 })
})
})
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/plugin",
"version": "1.15.11",
"version": "1.15.12",
"type": "module",
"license": "MIT",
"scripts": {
@@ -1,139 +0,0 @@
import { createOpencodeClient, type OpencodeClient, type Project, type Workspace } from "@opencode-ai/sdk/v2"
const client = createOpencodeClient({
baseUrl: process.env.OPENCODE_URL ?? "http://localhost:4096",
})
// directory
// dir1 -> resolves to project A
sdk.project.resolve({ directory: dir1 })
// dir2 -> resolves to project A
sdk.project.resolve({ directory: dir2 })
// worktree -> resolves to project A
const project = sdk.project.resolve({ directory: worktree })
// listing
sdk.project.workspaces()
// -> dir1
// -> dir2
// -> worktree
subpaths(host, workspace)
valid(host, workspace, path)
fuzzy(host, workspace)
sdk.files.subpaths()
sdk.files.fuzzy()
session table:
- project id
- host id
- workspace directory
- relative path
sdk.host.list()
sdk.route({ hostID }, () => {
sdk.project.workspaces({ projectID })
})
/**
* The current API has no explicit `project.create()` endpoint. Opening a
* directory through `project.current()` resolves and registers its project.
*/
export async function createProject(
sdk: OpencodeClient,
input: { directory: string; name?: string },
): Promise<Project> {
const project = (await sdk.project.current({ directory: input.directory }, { throwOnError: true })).data
if (!input.name) return project
sdk.project.list()
return (
await sdk.project.update(
{
projectID: project.id,
directory: input.directory,
name: input.name,
},
{ throwOnError: true },
)
).data
}
export async function createWorkspace(
sdk: OpencodeClient,
input: { projectDirectory: string; type: string; branch?: string | null },
): Promise<Workspace> {
return (
await sdk.experimental.workspace.create(
{
directory: input.projectDirectory,
type: input.type,
branch: input.branch ?? null,
},
{ throwOnError: true },
)
).data
}
export async function createWorktreeWorkspace(sdk: OpencodeClient, project: Project): Promise<Workspace> {
return createWorkspace(sdk, {
projectDirectory: project.worktree,
type: "worktree",
branch: null,
})
}
export async function example() {
const project = await createProject(client, {
directory: "/Users/james/projects/opencode",
name: "opencode",
})
const workspace = await createWorktreeWorkspace(client, project)
console.log({ project, workspace })
}
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/sdk",
"version": "1.15.11",
"version": "1.15.12",
"type": "module",
"license": "MIT",
"scripts": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/slack",
"version": "1.15.11",
"version": "1.15.12",
"type": "module",
"license": "MIT",
"scripts": {
+2 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/stats-app",
"version": "1.15.11",
"version": "1.15.12",
"private": true,
"type": "module",
"license": "MIT",
@@ -12,6 +12,7 @@
"start": "vite start"
},
"dependencies": {
"@ibm/plex": "6.4.1",
"@opencode-ai/stats-core": "workspace:*",
"@opencode-ai/ui": "workspace:*",
"@solidjs/meta": "catalog:",
+429 -72
View File
@@ -1,3 +1,47 @@
@font-face {
font-family: "IBM Plex Mono";
font-style: normal;
font-weight: 400;
font-display: swap;
src: url("@ibm/plex/IBM-Plex-Mono/fonts/split/woff2/IBMPlexMono-Regular-Latin1.woff2") format("woff2");
unicode-range:
U+0020-007E, U+00A0-00FF, U+0131, U+0152-0153, U+02C6, U+02DA, U+02DC, U+2013-2014, U+2018-201A, U+201C-201E,
U+2020-2022, U+2026, U+2030, U+2039-203A, U+2044, U+20AC, U+2122, U+2212, U+FB01-FB02;
}
@font-face {
font-family: "IBM Plex Mono";
font-style: normal;
font-weight: 500;
font-display: swap;
src: url("@ibm/plex/IBM-Plex-Mono/fonts/split/woff2/IBMPlexMono-Medium-Latin1.woff2") format("woff2");
unicode-range:
U+0020-007E, U+00A0-00FF, U+0131, U+0152-0153, U+02C6, U+02DA, U+02DC, U+2013-2014, U+2018-201A, U+201C-201E,
U+2020-2022, U+2026, U+2030, U+2039-203A, U+2044, U+20AC, U+2122, U+2212, U+FB01-FB02;
}
@font-face {
font-family: "IBM Plex Mono";
font-style: normal;
font-weight: 600;
font-display: swap;
src: url("@ibm/plex/IBM-Plex-Mono/fonts/split/woff2/IBMPlexMono-SemiBold-Latin1.woff2") format("woff2");
unicode-range:
U+0020-007E, U+00A0-00FF, U+0131, U+0152-0153, U+02C6, U+02DA, U+02DC, U+2013-2014, U+2018-201A, U+201C-201E,
U+2020-2022, U+2026, U+2030, U+2039-203A, U+2044, U+20AC, U+2122, U+2212, U+FB01-FB02;
}
@font-face {
font-family: "IBM Plex Mono";
font-style: normal;
font-weight: 700;
font-display: swap;
src: url("@ibm/plex/IBM-Plex-Mono/fonts/split/woff2/IBMPlexMono-Bold-Latin1.woff2") format("woff2");
unicode-range:
U+0020-007E, U+00A0-00FF, U+0131, U+0152-0153, U+02C6, U+02DA, U+02DC, U+2013-2014, U+2018-201A, U+201C-201E,
U+2020-2022, U+2026, U+2030, U+2039-203A, U+2044, U+20AC, U+2122, U+2212, U+FB01-FB02;
}
[data-page="stats"] {
--color-background: #ffffff;
--color-background-weak: #fafafa;
@@ -21,12 +65,16 @@
--stats-accent-text: #6c7dff;
--stats-bar-idle: #d4d4d4;
--stats-dot: #d4d4d4;
--stats-hero-muted: #5c5c5c;
--stats-hero-pattern: #eeeeee;
--stats-page-padding: 5rem;
--stats-section-padding: 6rem;
min-height: 100vh;
display: flex;
flex-direction: column;
gap: 4rem;
font-synthesis: none;
overflow-x: clip;
padding-bottom: 5rem;
background: var(--stats-bg);
}
@@ -39,7 +87,9 @@
}
[data-page="stats"] [data-component="container"] {
max-width: 67.5rem;
box-sizing: border-box;
width: 100%;
max-width: calc(80rem + 2px);
margin: 0 auto;
border-left: 1px solid var(--stats-line);
border-right: 1px solid var(--stats-line);
@@ -49,74 +99,245 @@
position: sticky;
top: 0;
z-index: 10;
display: flex;
align-items: center;
justify-content: space-between;
height: 80px;
min-height: 80px;
padding: 24px var(--stats-page-padding);
color: var(--stats-muted);
box-sizing: border-box;
width: 100%;
color: var(--stats-text);
background: var(--stats-bg);
border-bottom: 1px solid var(--stats-line);
font-family:
"IBM Plex Mono",
var(--font-mono, ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);
}
[data-page="stats"] [data-component="top"] * {
box-sizing: border-box;
}
[data-page="stats"] [data-slot="header-bar"] {
display: flex;
align-items: center;
gap: 16px;
min-height: 72px;
padding: 20px 20px 20px 24px;
overflow: hidden;
}
[data-page="stats"] [data-component="top"] a {
color: var(--stats-text);
font-size: 14px;
line-height: 18px;
text-decoration: none;
}
[data-page="stats"] [data-component="top"] a:hover {
text-decoration: underline;
text-underline-offset: 4px;
text-decoration: none;
}
[data-page="stats"] [data-slot="brand"] {
flex: 0 0 auto;
min-width: 0;
display: flex;
align-items: center;
margin-right: auto;
color: var(--stats-text);
}
[data-page="stats"] [data-slot="brand"] img {
width: auto;
height: 34px;
[data-page="stats"] [data-slot="stats-wordmark"] {
display: flex;
flex-shrink: 0;
align-items: center;
gap: 12px;
}
[data-page="stats"] [data-slot="logo dark"] {
[data-page="stats"] [data-slot="brand-mark"] {
display: block;
width: 19px;
height: 24px;
}
[data-page="stats"] [data-slot="brand-label"] {
display: block;
width: 50.851px;
height: 14px;
}
[data-page="stats"] [data-component="section-nav"] {
display: none;
align-items: center;
justify-content: center;
min-width: 0;
}
[data-page="stats"] [data-component="nav-desktop"] ul {
[data-page="stats"] [data-component="section-nav"] ul {
display: flex;
align-items: center;
gap: 32px;
margin: 0;
padding: 0;
list-style: none;
}
[data-page="stats"] [data-component="nav-desktop"] a span {
color: var(--stats-faint);
}
[data-page="stats"] [data-component="nav-desktop"] [data-slot="cta-button"] {
[data-page="stats"] [data-component="section-nav"] a {
display: flex;
align-items: center;
justify-content: center;
height: 32px;
padding: 0 15px;
color: var(--stats-muted);
font-size: 13px;
line-height: 1;
white-space: nowrap;
}
[data-page="stats"] [data-component="section-nav"] a:hover {
color: var(--stats-text);
}
[data-page="stats"] [data-slot="header-actions"] {
flex: 0 0 auto;
display: flex;
align-items: center;
justify-content: flex-end;
gap: 8px;
padding: 8px 16px 8px 10px;
border-radius: 4px;
background: var(--color-background-strong);
color: var(--color-text-inverted);
}
[data-page="stats"] [data-slot="header-button"],
[data-page="stats"] [data-slot="menu-button"] {
position: relative;
flex-shrink: 0;
display: inline-flex;
align-items: center;
justify-content: center;
height: 32px;
overflow: hidden;
margin: 0;
border: 0;
border-radius: 0;
appearance: none;
font: inherit;
font-size: 13px;
line-height: 1.1;
cursor: pointer;
}
[data-page="stats"] [data-slot="header-button"]::before,
[data-page="stats"] [data-slot="menu-button"]::before {
position: absolute;
top: 0;
right: 0;
left: 0;
height: 16px;
pointer-events: none;
content: "";
background: linear-gradient(180deg, #ffffff00 0%, #ffffff12 100%);
}
[data-page="stats"] [data-slot="header-button"] {
padding: 0 12px;
}
[data-page="stats"] [data-slot="header-button"] strong,
[data-page="stats"] [data-slot="header-button"] span,
[data-page="stats"] [data-slot="menu-button"] svg {
position: relative;
z-index: 1;
}
[data-page="stats"] [data-slot="header-button"] strong {
font-weight: 500;
white-space: nowrap;
}
[data-page="stats"] [data-component="nav-desktop"] [data-slot="cta-button"]:hover {
background: var(--color-background-strong-hover);
text-decoration: none;
[data-page="stats"] [data-slot="header-button"][data-variant="neutral"] {
display: none;
gap: 6px;
color: #161616;
background: #ffffff;
box-shadow:
0 0 0 0 #00000024,
0 0 0 0.5px #00000024,
0 1px 1.5px 0 #0000001a;
}
[data-page="stats"] [data-slot="header-button"][data-variant="neutral"] span {
color: #5c5c5c;
font-weight: 400;
font-variant-numeric: tabular-nums;
}
[data-page="stats"] [data-slot="header-button"][data-variant="contrast"] {
gap: 8px;
color: #ffffff;
background: #242424;
box-shadow:
0 0 0 0 #00000000,
0 0 0 0.5px #3a3a3a,
0 1px 1.5px 0 #00000033,
inset 0 -1px 2px 0 #0000000f,
inset 0 1px 2px 0 #ffffff24;
}
[data-page="stats"] [data-slot="menu-button"] {
display: inline-flex;
width: 32px;
padding: 0;
color: #3a3a3a;
background: #ffffff;
box-shadow:
0 0 0 0 #00000024,
0 0 0 0.5px #00000024,
0 1px 1.5px 0 #0000001a;
}
[data-page="stats"] [data-slot="header-button"]:focus-visible,
[data-page="stats"] [data-slot="menu-button"]:focus-visible,
[data-page="stats"] [data-component="section-nav"] a:focus-visible,
[data-page="stats"] [data-slot="mobile-menu-item"]:focus-visible,
[data-page="stats"] [data-slot="brand"]:focus-visible {
outline: 2px solid var(--stats-accent);
outline-offset: 2px;
}
[data-page="stats"] [data-slot="mobile-menu"] {
position: fixed;
top: 72px;
right: 0;
bottom: 0;
left: 0;
z-index: 9;
display: none;
overflow: auto;
background: var(--stats-bg);
border-top: 1px solid var(--stats-line);
}
[data-page="stats"] [data-menu-open="true"] [data-slot="mobile-menu"]:not([hidden]) {
display: block;
}
[data-page="stats"] [data-slot="mobile-menu-item"] {
display: flex;
align-items: center;
gap: 12px;
min-height: 65px;
padding: 24px;
color: var(--stats-text);
border-bottom: 1px solid var(--stats-line);
font-size: 13px;
line-height: 16px;
white-space: nowrap;
}
[data-page="stats"] [data-slot="mobile-menu-item"]:hover {
color: var(--stats-text);
background: var(--stats-layer);
}
[data-page="stats"] [data-slot="mobile-menu-item"] strong {
font-weight: 400;
}
[data-page="stats"] [data-slot="mobile-menu-item"] span {
min-width: 0;
overflow: hidden;
color: var(--stats-muted);
font-variant-numeric: tabular-nums;
text-overflow: ellipsis;
}
[data-page="stats"] [data-component="footer"] {
@@ -192,11 +413,16 @@
}
[data-page="stats"] [data-section="hero"] {
min-height: 270px;
display: grid;
grid-template-columns: 1fr 1fr;
gap: 64px;
align-items: start;
box-sizing: border-box;
display: flex;
flex-direction: column;
align-items: flex-start;
justify-content: flex-end;
gap: 24px;
min-height: 0;
overflow: hidden;
padding: 128px 24px 48px;
border-bottom: 0;
}
[data-page="stats"] h1,
@@ -210,6 +436,7 @@
line-height: 1;
letter-spacing: normal;
font-weight: 600;
max-width: none;
}
[data-page="stats"] h2 {
@@ -219,52 +446,146 @@
font-weight: 500;
}
[data-page="stats"] [data-section="hero"] > p,
[data-page="stats"] [data-slot="section-header"] p {
color: var(--stats-muted);
font-size: 16px;
line-height: 1.5;
}
[data-page="stats"] [data-section="hero"] > div {
[data-page="stats"] [data-slot="hero-canvas"] {
display: flex;
flex-direction: column;
gap: 16px;
gap: 24px;
width: 100%;
min-width: 0;
}
[data-page="stats"] [data-slot="meta"] {
[data-page="stats"] [data-section="hero"] h1 {
order: 1;
color: var(--stats-text);
font-size: 64px;
font-weight: 500;
line-height: 1;
letter-spacing: 0;
}
[data-page="stats"] [data-slot="hero-copy"] {
order: 3;
color: var(--stats-hero-muted);
font-size: 16px;
font-weight: 400;
line-height: 1.5;
}
[data-page="stats"] [data-slot="hero-copy-break"] {
display: none;
}
[data-page="stats"] [data-slot="hero-pattern"] {
order: 2;
flex: 0 0 auto;
width: 100%;
height: 16px;
overflow: hidden;
background: var(--stats-hero-pattern);
mask-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 6 6' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M0 0H2V2H0V0Z' fill='black'/%3E%3C/svg%3E");
mask-repeat: repeat;
mask-size: 6px 6px;
-webkit-mask-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 6 6' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M0 0H2V2H0V0Z' fill='black'/%3E%3C/svg%3E");
-webkit-mask-repeat: repeat;
-webkit-mask-size: 6px 6px;
}
[data-page="stats"] [data-slot="hero-meta"] {
display: flex;
align-items: center;
gap: 4px;
width: fit-content;
max-width: 100%;
height: 24px;
padding: 0 8px 0 4px;
background: var(--stats-layer-2);
color: var(--stats-faint);
color: var(--stats-hero-muted);
font-size: 13px;
font-weight: 600;
font-weight: 500;
line-height: 1.1;
overflow: hidden;
white-space: nowrap;
}
[data-page="stats"] [data-slot="meta"] svg {
[data-page="stats"] [data-slot="hero-meta"] svg {
width: 16px;
height: 16px;
color: var(--stats-faint);
flex: 0 0 auto;
}
[data-page="stats"] [data-slot="meta"] span {
color: var(--stats-muted);
[data-page="stats"] [data-slot="hero-meta"] span {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
}
[data-page="stats"] [data-slot="meta"] b,
[data-page="stats"] [data-slot="meta"] em {
color: var(--stats-faint);
font-style: normal;
font-weight: 600;
@media (min-width: 48rem) {
[data-page="stats"] [data-section="hero"] {
gap: 16px;
padding: 128px 32px 32px;
}
[data-page="stats"] [data-slot="hero-canvas"] {
position: relative;
display: block;
height: 232px;
overflow: hidden;
}
[data-page="stats"] [data-slot="hero-pattern"] {
position: absolute;
top: 50%;
left: 50%;
width: 1280px;
height: 351px;
transform: translate(-50%, -50%);
}
[data-page="stats"] [data-section="hero"] h1 {
position: absolute;
top: 0;
left: 0;
z-index: 1;
width: max-content;
max-width: 100%;
padding: 0 12px 12px 0;
background: var(--stats-bg);
white-space: nowrap;
}
[data-page="stats"] [data-slot="hero-copy"] {
position: absolute;
right: 0;
bottom: 0;
z-index: 1;
width: min(563px, 100%);
padding: 12px 0 0 16px;
background: var(--stats-bg);
text-align: right;
}
[data-page="stats"] [data-slot="hero-copy-break"] {
display: block;
}
}
@media (min-width: 75rem) {
[data-page="stats"] [data-section="hero"] {
padding: 128px 40px 24px;
}
}
@media (min-width: 90rem) {
[data-page="stats"] [data-section="hero"] {
padding-right: 0;
padding-left: 0;
}
}
[data-page="stats"] [data-section="chart"] {
@@ -1154,6 +1475,8 @@
--stats-faint: #808080;
--stats-bar-idle: #303030;
--stats-dot: #303030;
--stats-hero-muted: #808080;
--stats-hero-pattern: #303030;
}
[data-page="stats"] [data-component="chart-tooltip"],
@@ -1168,44 +1491,82 @@
0 2px 4px #0000003d;
}
[data-page="stats"] [data-slot="logo light"] {
display: none;
[data-page="stats"] [data-slot="header-button"][data-variant="neutral"],
[data-page="stats"] [data-slot="menu-button"] {
color: #fafafa;
background: #ffffff0f;
box-shadow:
0 -0.5px 0 0 #ffffff33,
0 0 0 0.5px #ffffff33,
0 1px 2px 0 #00000066;
}
[data-page="stats"] [data-slot="logo dark"] {
display: block;
[data-page="stats"] [data-slot="header-button"][data-variant="neutral"] span {
color: #aeaeae;
}
[data-page="stats"] [data-slot="header-button"][data-variant="contrast"] {
color: #ffffff;
background: #5c5c5c;
box-shadow:
0 -0.5px 0 0 #ffffff4d,
0 0 0 0.5px #ffffff66,
0 1px 2px 0 #00000066;
}
}
@media (max-width: 74rem) {
@media (max-width: 80rem) {
[data-page="stats"] [data-component="container"] {
border: 0;
}
}
@media (min-width: 48rem) {
[data-page="stats"] [data-slot="header-button"][data-variant="neutral"] {
display: inline-flex;
}
}
@media (min-width: 75rem) {
[data-page="stats"] [data-slot="header-bar"] {
gap: 32px;
}
[data-page="stats"] [data-slot="brand"],
[data-page="stats"] [data-component="section-nav"],
[data-page="stats"] [data-slot="header-actions"] {
flex: 1 1 0;
}
[data-page="stats"] [data-slot="brand"] {
margin-right: 0;
}
[data-page="stats"] [data-component="section-nav"] {
display: flex;
}
[data-page="stats"] [data-slot="menu-button"] {
display: none;
}
[data-page="stats"] [data-slot="mobile-menu"] {
display: none !important;
}
}
@media (max-width: 58rem) {
[data-page="stats"] {
--stats-page-padding: 24px;
--stats-section-padding: 4rem;
}
[data-page="stats"] [data-component="top"] {
padding-left: 24px;
padding-right: 24px;
}
[data-page="stats"] [data-component="nav-desktop"] ul {
gap: 18px;
}
[data-page="stats"] [data-section="hero"],
[data-page="stats"] [data-section="chart"],
[data-page="stats"] [data-section="newsletter"] {
padding-left: 24px;
padding-right: 24px;
}
[data-page="stats"] [data-section="hero"],
[data-page="stats"] [data-section="newsletter"] {
grid-template-columns: 1fr;
}
@@ -1301,10 +1662,6 @@
}
@media (max-width: 40rem) {
[data-page="stats"] [data-component="nav-desktop"] li:not(:last-child) {
display: none;
}
[data-page="stats"] [data-component="footer"],
[data-page="stats"] [data-component="legal"] {
flex-wrap: wrap;
+180 -61
View File
@@ -1,6 +1,10 @@
import "./index.css"
import { Meta, Title } from "@solidjs/meta"
import { Link, Meta, Title } from "@solidjs/meta"
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
import ibmPlexMonoRegularLatin1 from "@ibm/plex/IBM-Plex-Mono/fonts/split/woff2/IBMPlexMono-Regular-Latin1.woff2?url"
import ibmPlexMonoMediumLatin1 from "@ibm/plex/IBM-Plex-Mono/fonts/split/woff2/IBMPlexMono-Medium-Latin1.woff2?url"
import ibmPlexMonoSemiBoldLatin1 from "@ibm/plex/IBM-Plex-Mono/fonts/split/woff2/IBMPlexMono-SemiBold-Latin1.woff2?url"
import ibmPlexMonoBoldLatin1 from "@ibm/plex/IBM-Plex-Mono/fonts/split/woff2/IBMPlexMono-Bold-Latin1.woff2?url"
import {
type CountryEntry,
getStatsHomeData,
@@ -14,14 +18,19 @@ import {
import { runtime } from "@opencode-ai/stats-core/runtime"
import { createAsync, query } from "@solidjs/router"
import { scaleBand, scaleLinear } from "d3-scale"
import { createMemo, createSignal, For, Show, type JSX } from "solid-js"
import { createEffect, createMemo, createSignal, For, onCleanup, onMount, Show, type JSX } from "solid-js"
import { getRequestEvent } from "solid-js/web"
import logoDark from "../asset/logo-ornate-dark.svg"
import logoLight from "../asset/logo-ornate-light.svg"
const products = ["All Users", "Zen", "Go", "Enterprise"] as const
const tokenProducts = ["Zen", "Go", "Enterprise"] as const
const ranges = ["1D", "1W", "1M", "3M", "YTD", "ALL"] as const
const headerLinks = [
{ href: "#top-models", label: "Top Models" },
{ href: "#leaderboard", label: "Leaderboard" },
{ href: "#market-share", label: "Market Share" },
{ href: "#token-cost", label: "Token Cost" },
{ href: "#session-cost", label: "Session Cost" },
] as const
const usageColors = ["#ff5d64", "#ff8a00", "#8bef00", "#12c8b3", "#18c7dc", "#6c7dff", "#9d73f7"]
const marketColors = ["#ed6aff", "#a684ff", "#7c86ff", "#51a2ff", "#00d3f2", "#00d5be", "#00bc7d", "#9ae600", "#ffb900"]
const countryPositions = [
@@ -59,6 +68,10 @@ export default function StatsHome() {
<main data-page="stats">
<Title>OpenCode Stats</Title>
<Meta name="description" content="OpenCode usage, market share, token cost, and session cost stats." />
<Link rel="preload" href={ibmPlexMonoRegularLatin1} as="font" type="font/woff2" crossorigin="anonymous" />
<Link rel="preload" href={ibmPlexMonoMediumLatin1} as="font" type="font/woff2" crossorigin="anonymous" />
<Link rel="preload" href={ibmPlexMonoSemiBoldLatin1} as="font" type="font/woff2" crossorigin="anonymous" />
<Link rel="preload" href={ibmPlexMonoBoldLatin1} as="font" type="font/woff2" crossorigin="anonymous" />
<div data-component="container">
<Header />
<div data-component="content">
@@ -85,21 +98,30 @@ export default function StatsHome() {
}
function Hero(props: { updatedAt: string | null }) {
const [timeZone, setTimeZone] = createSignal("UTC")
onMount(() => setTimeZone(Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC"))
return (
<section data-section="hero">
<div>
<h1>OpenCode Stats</h1>
<p data-slot="meta">
<svg aria-hidden="true" width="16" height="16" viewBox="0 0 16 16">
<rect x="3" y="3" width="10" height="10" fill="currentColor" />
<rect x="7" y="6.5" width="2" height="4.5" fill="var(--stats-layer-2)" />
<rect x="7" y="5" width="2" height="1" fill="var(--stats-layer-2)" />
</svg>
<span>OpenCode data</span> <b>·</b>{" "}
<em>{props.updatedAt ? `Updated ${formatUpdatedAt(props.updatedAt)}` : "No rows yet"}</em>
<p data-slot="hero-meta">
<svg aria-hidden="true" width="16" height="16" viewBox="0 0 16 16">
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M13 13H3V3H13V13ZM6.46777 6.81641V7.81641H7.5791V11.3721H8.5791V6.81641H6.46777ZM7.30078 4.62891V5.62891H8.85645V4.62891H7.30078Z"
fill="currentColor"
/>
</svg>
<span>{props.updatedAt ? `Updated ${formatUpdatedAt(props.updatedAt, timeZone())}` : "No rows yet"}</span>
</p>
<div data-slot="hero-canvas">
<div data-slot="hero-pattern" aria-hidden="true" />
<h1>Model Stats</h1>
<p data-slot="hero-copy">
See which models are winning real usage, how the mix <br data-slot="hero-copy-break" />
shifts over time, and where momentum is moving each week.
</p>
</div>
<p>See how model usage, provider share, cost, and geography move across OpenCode traffic.</p>
</section>
)
}
@@ -115,9 +137,15 @@ function StatsLoading() {
)
}
function ChartSection(props: { title: string; description?: string; controls?: JSX.Element; children: JSX.Element }) {
function ChartSection(props: {
id?: string
title: string
description?: string
controls?: JSX.Element
children: JSX.Element
}) {
return (
<section data-section="chart">
<section id={props.id} data-section="chart">
<div data-slot="section-header">
<div>
<h2>{props.title}</h2>
@@ -139,7 +167,7 @@ function EmptyState(props: { title: string; description: string }) {
)
}
function formatUpdatedAt(value: string) {
function formatUpdatedAt(value: string, timeZone: string) {
const date = new Date(value)
if (Number.isNaN(date.getTime())) return "just now"
return new Intl.DateTimeFormat("en", {
@@ -147,7 +175,7 @@ function formatUpdatedAt(value: string) {
day: "numeric",
hour: "numeric",
minute: "2-digit",
timeZone: "UTC",
timeZone,
timeZoneName: "short",
}).format(date)
}
@@ -158,7 +186,7 @@ function UsageSection(props: { data: StatsHomeData["usage"] }) {
const data = createMemo(() => props.data[product()][range()])
return (
<ChartSection title="Usage">
<ChartSection id="top-models" title="Usage">
<Show
when={data().some((item) => usageTotal(item) > 0)}
fallback={<EmptyState title="No usage data" description="No model_stat rows matched this product and range." />}
@@ -394,6 +422,7 @@ function LeaderboardSection(props: { data: StatsHomeData["leaderboard"] }) {
return (
<ChartSection
id="leaderboard"
title="Leaderboard"
description="Shown are the sum of prompt and completion tokens per model, including reasoning tokens."
>
@@ -476,7 +505,7 @@ function MarketShareSection(props: { data: StatsHomeData["market"] }) {
const activeDay = createMemo(() => data()[selectedIndex()])
return (
<ChartSection title="Market Share" description="Compare token share by model author.">
<ChartSection id="market-share" title="Market Share" description="Compare token share by model author.">
<Show
when={activeDay()}
fallback={<EmptyState title="No market data" description="No model_stat rows matched this range." />}
@@ -572,7 +601,7 @@ function TokenCostSection(props: { data: StatsHomeData["tokenCost"] }) {
const selectedIndex = createMemo(() => Math.min(activeIndex(), Math.max(data().length - 1, 0)))
return (
<ChartSection title="Token Cost" description="Price per 1M tokens.">
<ChartSection id="token-cost" title="Token Cost" description="Price per 1M tokens.">
<Show
when={data().length > 0}
fallback={
@@ -661,7 +690,7 @@ function SessionCostSection(props: { data: StatsHomeData["sessionCost"] }) {
const selectedIndex = createMemo(() => Math.min(activeIndex(), Math.max(data().length - 1, 0)))
return (
<ChartSection title="Session Cost" description="Average cost per session.">
<ChartSection id="session-cost" title="Session Cost" description="Average cost per session.">
<Show
when={data().length > 0}
fallback={
@@ -886,47 +915,137 @@ function Newsletter() {
}
function Header() {
const [menuOpen, setMenuOpen] = createSignal(false)
const [menuViewport, setMenuViewport] = createSignal(false)
createEffect(() => {
if (typeof window === "undefined") return
const media = window.matchMedia("(max-width: 74.999rem)")
const update = () => setMenuViewport(media.matches)
update()
media.addEventListener("change", update)
onCleanup(() => media.removeEventListener("change", update))
})
createEffect(() => {
if (!menuOpen()) return
if (!menuViewport()) return
if (typeof document === "undefined") return
const page = document.querySelector<HTMLElement>('[data-page="stats"]')
const scrollbarWidth = window.innerWidth - document.documentElement.clientWidth
const htmlOverflow = document.documentElement.style.overflow
const pagePaddingRight = page?.style.paddingRight
const bodyOverflow = document.body.style.overflow
document.documentElement.style.overflow = "hidden"
if (scrollbarWidth > 0 && page) page.style.paddingRight = `${scrollbarWidth}px`
document.body.style.overflow = "hidden"
onCleanup(() => {
document.documentElement.style.overflow = htmlOverflow
if (page && pagePaddingRight !== undefined) page.style.paddingRight = pagePaddingRight
document.body.style.overflow = bodyOverflow
})
})
return (
<section data-component="top">
<a data-slot="brand" href="https://opencode.ai/" aria-label="OpenCode home">
<img data-slot="logo light" src={logoLight} alt="OpenCode" width="234" height="42" />
<img data-slot="logo dark" src={logoDark} alt="OpenCode" width="234" height="42" />
</a>
<nav data-component="nav-desktop" aria-label="Main navigation">
<ul>
<li>
<a href="https://github.com/sst/opencode" target="_blank" rel="noreferrer">
GitHub
<header data-component="top" data-menu-open={menuOpen() ? "true" : undefined}>
<div data-slot="header-bar">
<a data-slot="brand" href="/" aria-label="OpenCode home">
<StatsWordmark />
</a>
<nav data-component="section-nav" aria-label="Stats sections">
<ul>
<For each={headerLinks}>
{(link) => (
<li>
<a href={link.href}>{link.label}</a>
</li>
)}
</For>
</ul>
</nav>
<div data-slot="header-actions">
<a
data-slot="header-button"
data-variant="neutral"
href="https://github.com/sst/opencode"
target="_blank"
rel="noreferrer"
>
<strong>GitHub</strong>
<span>[150K]</span>
</a>
<a data-slot="header-button" data-variant="contrast" href="https://opencode.ai/">
<strong>Try OpenCode</strong>
</a>
<button
data-slot="menu-button"
type="button"
aria-controls="stats-mobile-nav"
aria-expanded={menuOpen() ? "true" : "false"}
aria-label={menuOpen() ? "Close navigation" : "Open navigation"}
onClick={() => setMenuOpen((value) => !value)}
>
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
<Show when={menuOpen()} fallback={<path d="M2 4.72H14M2 8.5H14M2 12.28H14" stroke="currentColor" />}>
<path d="M4.44 4.44L11.56 11.56M11.56 4.44L4.44 11.56" stroke="currentColor" />
</Show>
</svg>
</button>
</div>
</div>
<nav id="stats-mobile-nav" data-slot="mobile-menu" aria-label="Stats sections" hidden={!menuOpen()}>
<a
data-slot="mobile-menu-item"
data-variant="github"
href="https://github.com/sst/opencode"
target="_blank"
rel="noreferrer"
>
<strong>GitHub</strong>
<span>[150K]</span>
</a>
<For each={headerLinks}>
{(link) => (
<a data-slot="mobile-menu-item" href={link.href} onClick={() => setMenuOpen(false)}>
{link.label}
</a>
</li>
<li>
<a href="https://opencode.ai/docs">Docs</a>
</li>
<li>
<a href="https://opencode.ai/zen">Zen</a>
</li>
<li>
<a href="https://opencode.ai/go">Go</a>
</li>
<li>
<a href="https://opencode.ai/enterprise">Enterprise</a>
</li>
<li>
<a href="https://opencode.ai/download" data-slot="cta-button">
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" aria-hidden="true">
<path
d="M12.1875 9.75L9.00001 12.9375L5.8125 9.75M9.00001 2.0625L9 12.375M14.4375 15.9375H3.5625"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="square"
/>
</svg>
Download
</a>
</li>
</ul>
)}
</For>
</nav>
</section>
</header>
)
}
function StatsWordmark() {
return (
<span data-slot="stats-wordmark" aria-hidden="true">
<svg data-slot="brand-mark" width="19" height="24" viewBox="0 0 19 24" fill="none">
<path opacity="0.2" d="M14.25 19.2H4.75V9.6H14.25V19.2Z" fill="currentColor" />
<path d="M14.25 4.8H4.75V19.2H14.25V4.8ZM19 24H0V0H19V24Z" fill="currentColor" />
</svg>
<svg data-slot="brand-label" width="51" height="14" viewBox="0 0 50.8509 14" fill="none">
<path
d="M46.2359 14C45.2276 14 44.3356 13.819 43.56 13.4571C42.7973 13.0822 42.138 12.5328 41.5822 11.8089L43.1722 10.277C43.56 10.807 44.0124 11.2142 44.5295 11.4986C45.0466 11.7701 45.6283 11.9058 46.2747 11.9058C47.7225 11.9058 48.4464 11.2465 48.4464 9.92798C48.4464 9.38504 48.3172 8.97138 48.0586 8.68698C47.8001 8.40259 47.3735 8.19575 46.7788 8.06648L45.596 7.8338C44.3679 7.57525 43.463 7.13573 42.8813 6.51524C42.2996 5.89474 42.0088 5.02862 42.0088 3.9169C42.0088 2.62419 42.3901 1.6482 43.1528 0.98892C43.9284 0.32964 45.0272 0 46.4492 0C47.4187 0 48.2461 0.161588 48.9312 0.484764C49.6293 0.795014 50.2239 1.28624 50.7151 1.95845L49.1251 3.45152C48.789 2.99908 48.4076 2.66297 47.9811 2.44321C47.5545 2.21053 47.0309 2.09418 46.4104 2.09418C45.7253 2.09418 45.2211 2.22992 44.898 2.50139C44.5748 2.77285 44.4132 3.21237 44.4132 3.81995C44.4132 4.3241 44.536 4.71191 44.7816 4.98338C45.0401 5.25485 45.4538 5.45522 46.0226 5.58449L47.2054 5.83656C47.8647 5.97876 48.4206 6.15328 48.873 6.36011C49.3384 6.56694 49.7133 6.82548 49.9977 7.13573C50.295 7.44598 50.5083 7.8144 50.6376 8.241C50.7798 8.65466 50.8509 9.14589 50.8509 9.71468C50.8509 11.1108 50.4501 12.1773 49.6486 12.9141C48.8601 13.638 47.7225 14 46.2359 14Z"
fill="currentColor"
/>
<path
d="M36.9543 2.34643V13.7675H34.5305V2.34643H31.1371V0.232856H40.367V2.34643H36.9543Z"
fill="currentColor"
/>
<path
d="M28.6196 13.7675L27.6695 10.2384H23.3066L22.3565 13.7675H20.0296L23.9853 0.232856H27.049L31.0047 13.7675H28.6196ZM26.0407 4.57635L25.6141 2.42399H25.3426L24.916 4.57635L23.8883 8.27995H27.0878L26.0407 4.57635Z"
fill="currentColor"
/>
<path
d="M16.4849 2.34643V13.7675H14.0611V2.34643H10.6678V0.232856H19.8977V2.34643H16.4849Z"
fill="currentColor"
/>
<path
d="M4.65374 14C3.64543 14 2.75346 13.819 1.97784 13.4571C1.21514 13.0822 0.555863 12.5328 0 11.8089L1.59003 10.277C1.97784 10.807 2.43029 11.2142 2.94737 11.4986C3.46445 11.7701 4.04617 11.9058 4.69252 11.9058C6.14035 11.9058 6.86427 11.2465 6.86427 9.92798C6.86427 9.38504 6.735 8.97138 6.47646 8.68698C6.21791 8.40259 5.79132 8.19575 5.19668 8.06648L4.01385 7.8338C2.78578 7.57525 1.88089 7.13573 1.29917 6.51524C0.717452 5.89474 0.426593 5.02862 0.426593 3.9169C0.426593 2.62419 0.807941 1.6482 1.57064 0.98892C2.34626 0.32964 3.44506 0 4.86704 0C5.83657 0 6.6639 0.161588 7.34903 0.484764C8.04709 0.795014 8.64174 1.28624 9.13297 1.95845L7.54294 3.45152C7.20683 2.99908 6.82549 2.66297 6.39889 2.44321C5.9723 2.21053 5.44875 2.09418 4.82826 2.09418C4.14312 2.09418 3.63897 2.22992 3.31579 2.50139C2.99261 2.77285 2.83103 3.21237 2.83103 3.81995C2.83103 4.3241 2.95383 4.71191 3.19945 4.98338C3.45799 5.25485 3.87165 5.45522 4.44044 5.58449L5.62327 5.83656C6.28255 5.97876 6.83841 6.15328 7.29086 6.36011C7.75623 6.56694 8.13112 6.82548 8.41551 7.13573C8.71284 7.44598 8.92613 7.8144 9.0554 8.241C9.1976 8.65466 9.2687 9.14589 9.2687 9.71468C9.2687 11.1108 8.86796 12.1773 8.06648 12.9141C7.27793 13.638 6.14035 14 4.65374 14Z"
fill="currentColor"
/>
</svg>
</span>
)
}
+2 -1
View File
@@ -17,5 +17,6 @@
"paths": {
"~/*": ["./src/*"]
}
}
},
"include": ["*.ts", "src", "../core/src/resource.d.ts"]
}
@@ -0,0 +1,5 @@
ALTER TABLE `geo_stat` ADD `provider` varchar(128) DEFAULT 'all' NOT NULL;--> statement-breakpoint
ALTER TABLE `geo_stat` ADD `model` varchar(256) DEFAULT 'all' NOT NULL;--> statement-breakpoint
ALTER TABLE `geo_stat` DROP INDEX `uniq_country_period`;--> statement-breakpoint
CREATE UNIQUE INDEX `uniq_country_period` ON `geo_stat` (`grain`,`period_start`,`dataset`,`tier`,`client`,`source`,`provider`,`model`,`country`);--> statement-breakpoint
CREATE INDEX `idx_country_model` ON `geo_stat` (`model`,`country`,`grain`,`period_start`);
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,3 @@
ALTER TABLE `geo_stat` ADD `period_key` varchar(32) NOT NULL;--> statement-breakpoint
ALTER TABLE `model_stat` ADD `period_key` varchar(32) NOT NULL;--> statement-breakpoint
ALTER TABLE `provider_stat` ADD `period_key` varchar(32) NOT NULL;
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,6 @@
ALTER TABLE `geo_stat` DROP COLUMN `period_start`;--> statement-breakpoint
ALTER TABLE `geo_stat` DROP COLUMN `period_end`;--> statement-breakpoint
ALTER TABLE `model_stat` DROP COLUMN `period_start`;--> statement-breakpoint
ALTER TABLE `model_stat` DROP COLUMN `period_end`;--> statement-breakpoint
ALTER TABLE `provider_stat` DROP COLUMN `period_start`;--> statement-breakpoint
ALTER TABLE `provider_stat` DROP COLUMN `period_end`;
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/stats-core",
"version": "1.15.11",
"version": "1.15.12",
"private": true,
"type": "module",
"license": "MIT",
+19 -15
View File
@@ -16,7 +16,7 @@ export const modelStat = mysqlTable(
(table) => [
uniqueIndex("uniq_model_period").on(
table.grain,
table.period_start,
table.period_key,
table.dataset,
table.tier,
table.client,
@@ -24,8 +24,8 @@ export const modelStat = mysqlTable(
table.provider,
table.model,
),
index("idx_leaderboard_tokens").on(table.grain, table.period_start, table.dataset, table.tier, table.total_tokens),
index("idx_model").on(table.model, table.grain, table.period_start),
index("idx_leaderboard_tokens").on(table.grain, table.period_key, table.dataset, table.tier, table.total_tokens),
index("idx_model").on(table.model, table.grain, table.period_key),
],
)
@@ -45,7 +45,7 @@ export const providerStat = mysqlTable(
(table) => [
uniqueIndex("uniq_provider_period").on(
table.grain,
table.period_start,
table.period_key,
table.dataset,
table.tier,
table.client,
@@ -54,20 +54,20 @@ export const providerStat = mysqlTable(
),
index("idx_provider_leaderboard_tokens").on(
table.grain,
table.period_start,
table.period_key,
table.dataset,
table.tier,
table.total_tokens,
),
index("idx_provider_market_share").on(
table.grain,
table.period_start,
table.period_key,
table.dataset,
table.tier,
table.market_share_tokens,
),
index("idx_provider_rank").on(table.grain, table.period_start, table.dataset, table.tier, table.rank_by_tokens),
index("idx_provider").on(table.provider, table.grain, table.period_start),
index("idx_provider_rank").on(table.grain, table.period_key, table.dataset, table.tier, table.rank_by_tokens),
index("idx_provider").on(table.provider, table.grain, table.period_key),
],
)
@@ -75,6 +75,8 @@ export const geoStat = mysqlTable(
"geo_stat",
{
...periodColumns(),
provider: varchar({ length: 128 }).notNull().default("all"),
model: varchar({ length: 256 }).notNull().default("all"),
country: char({ length: 2 }).notNull(),
continent: varchar({ length: 8 }).notNull().default(""),
...metricColumns(),
@@ -88,17 +90,20 @@ export const geoStat = mysqlTable(
(table) => [
uniqueIndex("uniq_country_period").on(
table.grain,
table.period_start,
table.period_key,
table.dataset,
table.tier,
table.client,
table.source,
table.provider,
table.model,
table.country,
),
index("idx_country_map_tokens").on(table.grain, table.period_start, table.dataset, table.tier, table.total_tokens),
index("idx_country_rank").on(table.grain, table.period_start, table.dataset, table.tier, table.rank_by_tokens),
index("idx_country").on(table.country, table.grain, table.period_start),
index("idx_continent").on(table.continent, table.grain, table.period_start),
index("idx_country_map_tokens").on(table.grain, table.period_key, table.dataset, table.tier, table.total_tokens),
index("idx_country_rank").on(table.grain, table.period_key, table.dataset, table.tier, table.rank_by_tokens),
index("idx_country").on(table.country, table.grain, table.period_key),
index("idx_continent").on(table.continent, table.grain, table.period_key),
index("idx_country_model").on(table.model, table.country, table.grain, table.period_key),
],
)
@@ -106,8 +111,7 @@ function periodColumns() {
return {
id: bigint({ mode: "number" }).autoincrement().primaryKey(),
grain: varchar({ length: 16 }).notNull(),
period_start: datetime({ mode: "date" }).notNull(),
period_end: datetime({ mode: "date" }).notNull(),
period_key: varchar({ length: 32 }).notNull(),
dataset: varchar({ length: 64 }).notNull().default("all"),
tier: varchar({ length: 64 }).notNull().default("all"),
client: varchar({ length: 64 }).notNull().default("all"),
+54 -22
View File
@@ -8,6 +8,7 @@ import {
collapseRows,
inserted,
rankRowsWithMarketShare,
statPeriodKey,
synthesizeAllTierRows,
toStatBaseRow,
UPSERT_CHUNK_SIZE,
@@ -15,11 +16,18 @@ import {
} from "./stat"
export type GeoStatRow = typeof geoStat.$inferInsert
export type GeoStatAggregate = StatBaseAggregate & { country: string; continent: string }
export type GeoStatAggregate = StatBaseAggregate & {
provider: string
model: string
country: string
continent: string
}
export type GeoStatMetric = {
periodStart: Date
periodEnd: Date
periodKey: string
updatedAt: Date
tier: string
provider: string
model: string
country: string
continent: string
totalTokens: number
@@ -30,11 +38,13 @@ export declare namespace GeoStatRepo {
readonly listDaily: () => Effect.Effect<GeoStatMetric[], DatabaseError>
readonly listByPeriod: (opts: {
readonly grain: string
readonly periodStart: Date
readonly periodKey: string
readonly dataset?: string
readonly tier?: string
readonly client?: string
readonly source?: string
readonly provider?: string
readonly model?: string
}) => Effect.Effect<GeoStatRow[], DatabaseError>
readonly upsert: (rows: GeoStatRow[]) => Effect.Effect<void, DatabaseError>
}
@@ -51,27 +61,39 @@ export class GeoStatRepo extends Context.Service<GeoStatRepo, GeoStatRepo.Servic
try: () =>
db
.select({
periodStart: geoStat.period_start,
periodEnd: geoStat.period_end,
periodKey: geoStat.period_key,
updatedAt: geoStat.updated_at,
tier: geoStat.tier,
provider: geoStat.provider,
model: geoStat.model,
country: geoStat.country,
continent: geoStat.continent,
totalTokens: geoStat.total_tokens,
})
.from(geoStat)
.where(and(eq(geoStat.grain, "day"), eq(geoStat.client, "all"), eq(geoStat.source, "all")))
.orderBy(asc(geoStat.period_start)),
.where(
and(
eq(geoStat.grain, "day"),
eq(geoStat.client, "all"),
eq(geoStat.source, "all"),
eq(geoStat.provider, "all"),
eq(geoStat.model, "all"),
),
)
.orderBy(asc(geoStat.period_key)),
catch: (cause) => DatabaseError.make({ cause }),
})
})
const listByPeriod = Effect.fn("GeoStatRepo.listByPeriod")(function* (opts: {
readonly grain: string
readonly periodStart: Date
readonly periodKey: string
readonly dataset?: string
readonly tier?: string
readonly client?: string
readonly source?: string
readonly provider?: string
readonly model?: string
}) {
return yield* Effect.tryPromise({
try: () =>
@@ -81,11 +103,13 @@ export class GeoStatRepo extends Context.Service<GeoStatRepo, GeoStatRepo.Servic
.where(
and(
eq(geoStat.grain, opts.grain),
eq(geoStat.period_start, opts.periodStart),
eq(geoStat.period_key, opts.periodKey),
eq(geoStat.dataset, opts.dataset ?? "zen"),
eq(geoStat.tier, opts.tier ?? "all"),
eq(geoStat.client, opts.client ?? "all"),
eq(geoStat.source, opts.source ?? "all"),
eq(geoStat.provider, opts.provider ?? "all"),
eq(geoStat.model, opts.model ?? "all"),
),
),
catch: (cause) => DatabaseError.make({ cause }),
@@ -103,7 +127,6 @@ export class GeoStatRepo extends Context.Service<GeoStatRepo, GeoStatRepo.Servic
.values(chunk)
.onDuplicateKeyUpdate({
set: {
period_end: inserted("period_end"),
continent: inserted("continent"),
sessions: inserted("sessions"),
requests: inserted("requests"),
@@ -146,26 +169,35 @@ export class GeoStatRepo extends Context.Service<GeoStatRepo, GeoStatRepo.Servic
}
export function rowsFromAggregates(aggregates: GeoStatAggregate[]) {
return rankRowsWithMarketShare([
...synthesizeAllTierRows(
collapseRows(aggregates.filter((item) => item.grain === "week").map(toRow), dimensionKey),
dimensionKey,
),
...synthesizeAllTierRows(
collapseRows(aggregates.filter((item) => item.grain === "day").map(toRow), dimensionKey),
dimensionKey,
),
])
return rankRowsWithMarketShare(
[
...synthesizeAllTierRows(
collapseRows(aggregates.filter((item) => item.grain === "week").map(toRow), dimensionKey),
dimensionKey,
),
...synthesizeAllTierRows(
collapseRows(aggregates.filter((item) => item.grain === "day").map(toRow), dimensionKey),
dimensionKey,
),
],
marketShareKey,
)
}
function toRow(data: GeoStatAggregate): GeoStatRow {
return {
...toStatBaseRow(data),
provider: data.provider,
model: data.model,
country: data.country,
continent: data.continent,
}
}
function dimensionKey(row: GeoStatRow) {
return row.country
return [row.provider, row.model, row.country].join("\u0000")
}
function marketShareKey(row: GeoStatRow) {
return [statPeriodKey(row), row.provider, row.model].join("\u0000")
}
+37 -21
View File
@@ -28,17 +28,17 @@ const TOKEN_SCALE = 1_000_000
const DOLLARS_PER_MICROCENT = 1 / 100_000_000
const months = ["JAN", "FEB", "MAR", "APR", "MAY", "JUN", "JUL", "AUG", "SEP", "OCT", "NOV", "DEC"] as const
type StatMetricRow = Omit<ModelStatMetric, "periodStart" | "periodEnd"> & {
type StatMetricRow = Omit<ModelStatMetric, "updatedAt"> & {
periodStart: number
periodEnd: number
updatedAt: number
}
type ProviderMetricRow = Omit<ProviderStatMetric, "periodStart" | "periodEnd"> & {
type ProviderMetricRow = Omit<ProviderStatMetric, "updatedAt"> & {
periodStart: number
periodEnd: number
updatedAt: number
}
type GeoMetricRow = Omit<GeoStatMetric, "periodStart" | "periodEnd"> & {
type GeoMetricRow = Omit<GeoStatMetric, "updatedAt"> & {
periodStart: number
periodEnd: number
updatedAt: number
}
type DateWindow = { start: number; end: number; previousStart: number; previousEnd: number }
@@ -85,10 +85,10 @@ function buildStatsHomeData(
const earliest = Math.min(...periods.map((row) => row.periodStart))
const latest = Math.max(...periods.map((row) => row.periodStart))
const latestEnd = Math.max(...periods.map((row) => row.periodEnd))
const latestUpdate = Math.max(...periods.map((row) => row.updatedAt))
return {
updatedAt: new Date(latestEnd).toISOString(),
updatedAt: new Date(latestUpdate).toISOString(),
usage: createUsageProductRecord((product) =>
createRangeRecord((range) => buildUsagePoints(normalized, product, range, getWindow(range, earliest, latest))),
),
@@ -361,14 +361,14 @@ function createRangeRecord<T>(value: (range: UsageRange) => T): Record<UsageRang
}
function normalizeStatRow(row: ModelStatMetric): StatMetricRow[] {
const periodStart = dateTime(row.periodStart)
const periodEnd = dateTime(row.periodEnd)
if (!Number.isFinite(periodStart) || !Number.isFinite(periodEnd)) return []
const periodStart = periodKeyTime(row.periodKey)
const updatedAt = dateTime(row.updatedAt)
if (!Number.isFinite(periodStart) || !Number.isFinite(updatedAt)) return []
return [
{
...row,
periodStart,
periodEnd,
updatedAt,
tier: normalizeTier(row.tier),
provider: row.provider || "unknown",
model: row.model || "unknown",
@@ -377,14 +377,14 @@ function normalizeStatRow(row: ModelStatMetric): StatMetricRow[] {
}
function normalizeProviderRow(row: ProviderStatMetric): ProviderMetricRow[] {
const periodStart = dateTime(row.periodStart)
const periodEnd = dateTime(row.periodEnd)
if (!Number.isFinite(periodStart) || !Number.isFinite(periodEnd)) return []
const periodStart = periodKeyTime(row.periodKey)
const updatedAt = dateTime(row.updatedAt)
if (!Number.isFinite(periodStart) || !Number.isFinite(updatedAt)) return []
return [
{
...row,
periodStart,
periodEnd,
updatedAt,
tier: normalizeTier(row.tier),
provider: row.provider || "unknown",
},
@@ -392,15 +392,17 @@ function normalizeProviderRow(row: ProviderStatMetric): ProviderMetricRow[] {
}
function normalizeGeoRow(row: GeoStatMetric): GeoMetricRow[] {
const periodStart = dateTime(row.periodStart)
const periodEnd = dateTime(row.periodEnd)
if (!Number.isFinite(periodStart) || !Number.isFinite(periodEnd)) return []
const periodStart = periodKeyTime(row.periodKey)
const updatedAt = dateTime(row.updatedAt)
if (!Number.isFinite(periodStart) || !Number.isFinite(updatedAt)) return []
return [
{
...row,
periodStart,
periodEnd,
updatedAt,
tier: normalizeTier(row.tier),
provider: row.provider || "all",
model: row.model || "all",
country: row.country || "ZZ",
continent: row.continent || "",
},
@@ -420,6 +422,12 @@ function dateTime(value: Date | string) {
return (value instanceof Date ? value : new Date(value)).getTime()
}
function periodKeyTime(value: string) {
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value)
if (!match) return Number.NaN
return Date.UTC(Number(match[1]), Number(match[2]) - 1, Number(match[3]))
}
function formatBucketLabel(value: number, range: UsageRange) {
const date = new Date(value)
if (range === "YTD") return months[date.getUTCMonth()]
@@ -433,11 +441,19 @@ function formatBucketLabel(value: number, range: UsageRange) {
function formatProvider(provider: string) {
const known: Record<string, string> = {
anthropic: "Anthropic",
deepseek: "DeepSeek",
google: "Google",
minimax: "MiniMax",
moonshot: "Moonshot",
moonshotai: "Moonshot",
nvidia: "Nvidia",
nvidia: "NVIDIA",
opencode: "opencode",
openai: "OpenAI",
qwen: "Qwen",
tencent: "Tencent",
xai: "xAI",
xiaomi: "Xiaomi",
zhipu: "Zhipu",
zhipuai: "Zhipu",
}
const normalized = provider.toLowerCase().replace(/[^a-z0-9]/g, "")
@@ -0,0 +1,66 @@
import { describe, expect, test } from "bun:test"
import { toModelAggregate } from "./inference"
import { modelAuthor, normalizeInferenceModel } from "./model-normalization"
describe("inference stat normalization", () => {
test("normalizes model suffixes used by router/provider variants", () => {
expect(normalizeInferenceModel("deepseek-v4-flash-free")).toBe("deepseek-v4-flash")
expect(normalizeInferenceModel("deepseek-v4-flash:global")).toBe("deepseek-v4-flash")
expect(normalizeInferenceModel("mimo-v2.5-free")).toBe("mimo-v2.5")
expect(normalizeInferenceModel("nemotron-3-super-free")).toBe("nemotron-3-super")
expect(normalizeInferenceModel("mimo-v2.5-free:global")).toBe("mimo-v2.5")
})
test("maps normalized model ids to public authors", () => {
expect(modelAuthor("big-pickle")).toBe("opencode")
expect(modelAuthor("claude-sonnet-4-5")).toBe("anthropic")
expect(modelAuthor("deepseek-v4-pro")).toBe("deepseek")
expect(modelAuthor("gemini-3.5-flash")).toBe("google")
expect(modelAuthor("glm-5.1")).toBe("zhipu")
expect(modelAuthor("gpt-5.5-pro")).toBe("openai")
expect(modelAuthor("grok-build-0.1")).toBe("xai")
expect(modelAuthor("hy3-preview")).toBe("tencent")
expect(modelAuthor("kimi-k2.6")).toBe("moonshot")
expect(modelAuthor("mimo-v2-omni")).toBe("xiaomi")
expect(modelAuthor("minimax-m2.7")).toBe("minimax")
expect(modelAuthor("nemotron-3-super-free")).toBe("nvidia")
expect(modelAuthor("qwen3.7-max")).toBe("qwen")
expect(modelAuthor("alpha-gpt-next")).toBeUndefined()
})
test("model aggregates ignore datalake provider and use normalized author/model", () => {
expect(toModelAggregate(aggregate("alpha-gpt-next", "openai"))).toEqual([])
expect(toModelAggregate(aggregate("deepseek-v4-flash-free", "not-public-provider"))).toMatchObject([
{
period_key: "2026-05-20",
provider: "deepseek",
model: "deepseek-v4-flash",
},
])
})
test("model aggregates use ISO week period keys", () => {
expect(
toModelAggregate({
...aggregate("gpt-5.5-pro", "openai"),
grain: "week",
period_key: "2026-W20",
}),
).toMatchObject([{ period_key: "2026-W20" }])
})
})
function aggregate(model: string, provider: string) {
return {
grain: "day",
period_key: "2026-05-20",
dataset: "zen",
tier: "Paid",
provider,
model,
sessions: "1",
requests: "1",
sample_count: "1",
}
}
+86 -46
View File
@@ -2,10 +2,17 @@ import { Resource } from "sst/resource"
import type { AthenaData } from "../athena"
import type { GeoStatAggregate } from "./geo"
import type { ModelStatAggregate } from "./model"
import {
EXCLUDED_MODELS,
MODEL_AUTHOR_OVERRIDES,
MODEL_AUTHOR_RULES,
modelAuthor,
normalizeInferenceModel,
} from "./model-normalization"
import type { ProviderStatAggregate } from "./provider"
import { normalizeCountry, normalizeTier, type StatBaseAggregate } from "./stat"
export type StatDimension = "model" | "provider" | "geo"
export type StatDimension = "model" | "provider" | "geo" | "geo_model"
export function buildStatsQuery(periodStart: Date, periodEnd: Date, dimension: StatDimension) {
const periodStartValue = sqlString(periodStart.toISOString())
@@ -20,8 +27,13 @@ export function buildStatsQuery(periodStart: Date, periodEnd: Date, dimension: S
groupBy: "provider, model",
}
if (dimension === "provider") return { select: "provider", groupBy: "provider" }
if (dimension === "geo_model")
return {
select: "provider, model, country, COALESCE(MAX(NULLIF(continent, '')), '') AS continent",
groupBy: "provider, model, country",
}
return {
select: "country, COALESCE(MAX(NULLIF(continent, '')), '') AS continent",
select: "'all' AS provider, 'all' AS model, country, COALESCE(MAX(NULLIF(continent, '')), '') AS continent",
groupBy: "country",
}
})()
@@ -48,32 +60,56 @@ export function buildStatsQuery(periodStart: Date, periodEnd: Date, dimension: S
COUNT(*) AS sample_count`
return `
WITH filtered AS (
WITH normalized AS (
SELECT
from_iso8601_timestamp(event_timestamp) AS event_time,
CASE
WHEN source = 'lite' THEN 'Go'
WHEN model IN ('gpt-5-nano', 'grok-code', 'big-pickle') OR model LIKE '%-free' THEN 'Free'
ELSE 'Paid'
END AS tier,
COALESCE(NULLIF(
CASE
WHEN starts_with(provider, 'minimax-plan') THEN 'minimax-plan'
WHEN starts_with(provider, 'zai-plan') THEN 'zai-plan'
WHEN starts_with(provider, 'azure-databricks') THEN 'azure-databricks'
WHEN regexp_like(provider, '^azure[0-9]+') THEN 'azure-openai'
ELSE provider
END,
''
), 'unknown') AS provider,
model AS raw_model,
COALESCE(NULLIF(regexp_replace(model, '(-free|:global)+$', ''), ''), 'unknown') AS model,
COALESCE(NULLIF(provider_model, ''), '') AS provider_model,
COALESCE(NULLIF(model, ''), 'unknown') AS model,
UPPER(COALESCE(NULLIF(cf_country, ''), 'ZZ')) AS country,
COALESCE(NULLIF(cf_continent, ''), '') AS continent,
session,
status,
duration AS duration_ms,
time_to_first_byte AS ttfb_ms,
timestamp_first_byte,
timestamp_last_byte,
tokens_input,
tokens_output,
tokens_reasoning,
tokens_cache_read,
tokens_cache_write_5m,
cost_input_microcents,
cost_output_microcents,
cost_total_microcents,
cost_input,
cost_output,
cost_total,
source
FROM ${sourceTable}
WHERE event_type = 'completions'
AND model IS NOT NULL
AND model <> ''
AND (strpos(COALESCE(user_agent, ''), 'ai-sdk') > 0 OR strpos(COALESCE(user_agent, ''), 'opencode') > 0)
AND event_timestamp >= ${periodStartValue}
AND event_timestamp < ${periodEndValue}
), filtered AS (
SELECT
event_time,
CASE
WHEN source = 'lite' THEN 'Go'
WHEN model IN ('gpt-5-nano', 'grok-code', 'big-pickle') OR regexp_like(raw_model, '-free(:global)?$') THEN 'Free'
ELSE 'Paid'
END AS tier,
${modelAuthorSql("model")} AS provider,
provider_model,
model,
country,
continent,
session,
status,
duration_ms,
ttfb_ms,
CASE
WHEN timestamp_last_byte - timestamp_first_byte < 100 THEN null
ELSE CAST(tokens_output AS double) / (timestamp_last_byte - timestamp_first_byte) * 1000
@@ -86,50 +122,47 @@ WITH filtered AS (
COALESCE(cost_input_microcents, cost_input * 1000000) AS cost_input_microcents,
COALESCE(cost_output_microcents, cost_output * 1000000) AS cost_output_microcents,
COALESCE(cost_total_microcents, cost_total * 1000000) AS cost_total_microcents
FROM ${sourceTable}
WHERE event_type = 'completions'
AND model IS NOT NULL
AND model <> ''
AND (strpos(COALESCE(user_agent, ''), 'ai-sdk') > 0 OR strpos(COALESCE(user_agent, ''), 'opencode') > 0)
AND event_timestamp >= ${periodStartValue}
AND event_timestamp < ${periodEndValue}
FROM normalized
WHERE lower(model) NOT IN (${[...EXCLUDED_MODELS].map(sqlString).join(", ")})
), weekly AS (
SELECT
concat(CAST(year_of_week(event_time) AS varchar), '-W', lpad(CAST(week(event_time) AS varchar), 2, '0')) AS week_key,
*
FROM filtered
), daily AS (
SELECT date_trunc('day', event_time) AS day, *
SELECT substr(to_iso8601(date_trunc('day', event_time)), 1, 10) AS day_key, *
FROM filtered
)
SELECT
'week' AS grain,
${periodStartValue} AS period_start,
${periodEndValue} AS period_end,
week_key AS period_key,
${sqlString(Resource.StatsSyncConfig.dataset)} AS dataset,
tier,
${dimensionSql.select},
${aggregateColumns}
FROM filtered
GROUP BY tier, ${dimensionSql.groupBy}
FROM weekly
GROUP BY week_key, tier, ${dimensionSql.groupBy}
UNION ALL
SELECT
'day' AS grain,
to_iso8601(day) AS period_start,
to_iso8601(least(day + INTERVAL '1' DAY, from_iso8601_timestamp(${periodEndValue}))) AS period_end,
day_key AS period_key,
${sqlString(Resource.StatsSyncConfig.dataset)} AS dataset,
tier,
${dimensionSql.select},
${aggregateColumns}
FROM daily
GROUP BY day, tier, ${dimensionSql.groupBy}
ORDER BY grain, period_start, total_tokens DESC
GROUP BY day_key, tier, ${dimensionSql.groupBy}
ORDER BY grain, period_key, total_tokens DESC
`
}
export function toModelAggregate(data: AthenaData): ModelStatAggregate[] {
const model = normalizeInferenceModel(data.model)
const author = modelAuthor(model)
if (!author) return []
return toStatBaseAggregate(data).flatMap((base) => [
{
...base,
provider: data.provider || "unknown",
model: data.model || "unknown",
provider_model: data.provider_model || "",
},
{ ...base, provider: author, model, provider_model: data.provider_model || "" },
])
}
@@ -141,6 +174,8 @@ export function toGeoAggregate(data: AthenaData): GeoStatAggregate[] {
return toStatBaseAggregate(data).flatMap((base) => [
{
...base,
provider: data.provider || "all",
model: normalizeInferenceModel(data.model || "all"),
country: normalizeCountry(data.country),
continent: data.continent || "",
},
@@ -149,15 +184,12 @@ export function toGeoAggregate(data: AthenaData): GeoStatAggregate[] {
function toStatBaseAggregate(data: AthenaData): StatBaseAggregate[] {
const grain = data.grain === "day" || data.grain === "week" ? data.grain : undefined
const periodStart = new Date(data.period_start ?? "")
const periodEnd = new Date(data.period_end ?? "")
if (!grain || Number.isNaN(periodStart.getTime()) || Number.isNaN(periodEnd.getTime())) return []
if (!grain || !data.period_key) return []
return [
{
grain,
period_start: periodStart,
period_end: periodEnd,
period_key: data.period_key,
dataset: data.dataset || Resource.StatsSyncConfig.dataset,
tier: normalizeTier(data.tier || "unknown"),
sessions: integer(data, "sessions"),
@@ -210,3 +242,11 @@ function sqlIdentifier(value: string) {
function sqlString(value: string) {
return `'${value.replace(/'/g, "''")}'`
}
function modelAuthorSql(model: string) {
return `CASE
${MODEL_AUTHOR_OVERRIDES.map((item) => ` WHEN lower(${model}) = ${sqlString(item.model)} THEN ${sqlString(item.author)}`).join("\n")}
${MODEL_AUTHOR_RULES.map((item) => ` WHEN strpos(lower(${model}), ${sqlString(item.match)}) > 0 THEN ${sqlString(item.author)}`).join("\n")}
ELSE 'unknown'
END`
}
@@ -0,0 +1,30 @@
export const MODEL_AUTHOR_OVERRIDES = [{ model: "big-pickle", author: "opencode" }] as const
export const MODEL_AUTHOR_RULES = [
{ match: "claude", author: "anthropic" },
{ match: "gemini", author: "google" },
{ match: "deepseek", author: "deepseek" },
{ match: "glm", author: "zhipu" },
{ match: "gpt", author: "openai" },
{ match: "grok", author: "xai" },
{ match: "hy3", author: "tencent" },
{ match: "kimi", author: "moonshot" },
{ match: "mimo", author: "xiaomi" },
{ match: "minimax", author: "minimax" },
{ match: "nemotron", author: "nvidia" },
{ match: "qwen", author: "qwen" },
] as const
export const EXCLUDED_MODELS = new Set(["alpha-gpt-next"])
export function normalizeInferenceModel(value: string | undefined) {
return (value || "unknown").replace(/(-free|:global)+$/, "") || "unknown"
}
export function modelAuthor(value: string | undefined) {
const model = normalizeInferenceModel(value).toLowerCase()
if (EXCLUDED_MODELS.has(model)) return undefined
const override = MODEL_AUTHOR_OVERRIDES.find((item) => item.model === model)
if (override) return override.author
return MODEL_AUTHOR_RULES.find((item) => model.includes(item.match))?.author ?? "unknown"
}
+5 -6
View File
@@ -19,8 +19,8 @@ export type ModelStatRow = typeof modelStat.$inferInsert
export type ModelStatAggregate = StatBaseAggregate & { provider: string; model: string; provider_model: string }
export type ModelStatMetric = {
periodStart: Date
periodEnd: Date
periodKey: string
updatedAt: Date
tier: string
provider: string
model: string
@@ -55,8 +55,8 @@ export class ModelStatRepo extends Context.Service<ModelStatRepo, ModelStatRepo.
try: () =>
db
.select({
periodStart: modelStat.period_start,
periodEnd: modelStat.period_end,
periodKey: modelStat.period_key,
updatedAt: modelStat.updated_at,
tier: modelStat.tier,
provider: modelStat.provider,
model: modelStat.model,
@@ -72,7 +72,7 @@ export class ModelStatRepo extends Context.Service<ModelStatRepo, ModelStatRepo.
})
.from(modelStat)
.where(and(eq(modelStat.grain, "day"), eq(modelStat.client, "all"), eq(modelStat.source, "all")))
.orderBy(asc(modelStat.period_start)),
.orderBy(asc(modelStat.period_key)),
catch: (cause) => DatabaseError.make({ cause }),
})
})
@@ -88,7 +88,6 @@ export class ModelStatRepo extends Context.Service<ModelStatRepo, ModelStatRepo.
.values(chunk)
.onDuplicateKeyUpdate({
set: {
period_end: inserted("period_end"),
provider_model: inserted("provider_model"),
sessions: inserted("sessions"),
requests: inserted("requests"),
+8 -9
View File
@@ -17,8 +17,8 @@ import {
export type ProviderStatRow = typeof providerStat.$inferInsert
export type ProviderStatAggregate = StatBaseAggregate & { provider: string }
export type ProviderStatMetric = {
periodStart: Date
periodEnd: Date
periodKey: string
updatedAt: Date
tier: string
provider: string
totalTokens: number
@@ -29,7 +29,7 @@ export declare namespace ProviderStatRepo {
readonly listDaily: () => Effect.Effect<ProviderStatMetric[], DatabaseError>
readonly listByPeriod: (opts: {
readonly grain: string
readonly periodStart: Date
readonly periodKey: string
readonly dataset?: string
readonly tier?: string
readonly client?: string
@@ -52,22 +52,22 @@ export class ProviderStatRepo extends Context.Service<ProviderStatRepo, Provider
try: () =>
db
.select({
periodStart: providerStat.period_start,
periodEnd: providerStat.period_end,
periodKey: providerStat.period_key,
updatedAt: providerStat.updated_at,
tier: providerStat.tier,
provider: providerStat.provider,
totalTokens: providerStat.total_tokens,
})
.from(providerStat)
.where(and(eq(providerStat.grain, "day"), eq(providerStat.client, "all"), eq(providerStat.source, "all")))
.orderBy(asc(providerStat.period_start)),
.orderBy(asc(providerStat.period_key)),
catch: (cause) => DatabaseError.make({ cause }),
})
})
const listByPeriod = Effect.fn("ProviderStatRepo.listByPeriod")(function* (opts: {
readonly grain: string
readonly periodStart: Date
readonly periodKey: string
readonly dataset?: string
readonly tier?: string
readonly client?: string
@@ -81,7 +81,7 @@ export class ProviderStatRepo extends Context.Service<ProviderStatRepo, Provider
.where(
and(
eq(providerStat.grain, opts.grain),
eq(providerStat.period_start, opts.periodStart),
eq(providerStat.period_key, opts.periodKey),
eq(providerStat.dataset, opts.dataset ?? "zen"),
eq(providerStat.tier, opts.tier ?? "all"),
eq(providerStat.client, opts.client ?? "all"),
@@ -103,7 +103,6 @@ export class ProviderStatRepo extends Context.Service<ProviderStatRepo, Provider
.values(chunk)
.onDuplicateKeyUpdate({
set: {
period_end: inserted("period_end"),
sessions: inserted("sessions"),
requests: inserted("requests"),
input_tokens: inserted("input_tokens"),
+40 -27
View File
@@ -1,13 +1,13 @@
import { sql } from "drizzle-orm"
export const UPSERT_CHUNK_SIZE = 500
const DAY_MS = 86_400_000
export type StatGrain = "day" | "week"
export type StatBaseAggregate = {
grain: StatGrain
period_start: Date
period_end: Date
period_key: string
dataset: string
tier: string
sessions: number
@@ -34,8 +34,7 @@ export type StatBaseAggregate = {
export type StatBaseRow = {
grain: string
period_start: Date
period_end: Date
period_key: string
dataset?: string
tier?: string
client?: string
@@ -65,8 +64,7 @@ export type StatBaseRow = {
export function toStatBaseRow(data: StatBaseAggregate) {
return {
grain: data.grain,
period_start: data.period_start,
period_end: data.period_end,
period_key: data.period_key,
dataset: data.dataset,
tier: data.tier,
client: "all",
@@ -99,14 +97,7 @@ export function synthesizeAllTierRows<T extends StatBaseRow>(rows: T[], dimensio
...rows,
...Object.values(
rows.reduce<Record<string, T>>((result, row) => {
const key = [
row.grain,
row.period_start.toISOString(),
row.dataset,
row.client,
row.source,
dimensionKey(row),
].join("\u0000")
const key = [row.grain, row.period_key, row.dataset, row.client, row.source, dimensionKey(row)].join("\u0000")
result[key] = result[key] ? combineRows(result[key], row) : { ...row, tier: "all" }
return result
}, {}),
@@ -117,15 +108,9 @@ export function synthesizeAllTierRows<T extends StatBaseRow>(rows: T[], dimensio
export function collapseRows<T extends StatBaseRow>(rows: T[], dimensionKey: (row: T) => string) {
return Object.values(
rows.reduce<Record<string, T>>((result, row) => {
const key = [
row.grain,
row.period_start.toISOString(),
row.dataset,
row.tier,
row.client,
row.source,
dimensionKey(row),
].join("\u0000")
const key = [row.grain, row.period_key, row.dataset, row.tier, row.client, row.source, dimensionKey(row)].join(
"\u0000",
)
result[key] = result[key] ? combineRows(result[key], row) : row
return result
}, {}),
@@ -135,7 +120,6 @@ export function collapseRows<T extends StatBaseRow>(rows: T[], dimensionKey: (ro
export function combineRows<T extends StatBaseRow>(left: T, right: T): T {
return {
...left,
period_end: right.period_end > left.period_end ? right.period_end : left.period_end,
sessions: (left.sessions ?? 0) + (right.sessions ?? 0),
requests: (left.requests ?? 0) + (right.requests ?? 0),
input_tokens: (left.input_tokens ?? 0) + (right.input_tokens ?? 0),
@@ -160,17 +144,46 @@ export function combineRows<T extends StatBaseRow>(left: T, right: T): T {
}
export function statPeriodKey(row: StatBaseRow) {
return [row.grain, row.period_start.toISOString(), row.dataset, row.tier, row.client, row.source].join("\u0000")
return [row.grain, row.period_key, row.dataset, row.tier, row.client, row.source].join("\u0000")
}
export function periodKeyFor(grain: StatGrain, periodStart: Date) {
if (grain === "week") return isoWeekId(periodStart)
return utcDateId(periodStart)
}
export function startOfUtcDay(value: Date) {
return new Date(Date.UTC(value.getUTCFullYear(), value.getUTCMonth(), value.getUTCDate()))
}
export function startOfIsoWeek(value: Date) {
return new Date(
Date.UTC(value.getUTCFullYear(), value.getUTCMonth(), value.getUTCDate() - (value.getUTCDay() || 7) + 1),
)
}
export function isoWeekId(value: Date) {
const thursday = new Date(
Date.UTC(value.getUTCFullYear(), value.getUTCMonth(), value.getUTCDate() + 4 - (value.getUTCDay() || 7)),
)
return `${thursday.getUTCFullYear()}-W${String(Math.ceil(((thursday.getTime() - Date.UTC(thursday.getUTCFullYear(), 0, 1)) / DAY_MS + 1) / 7)).padStart(2, "0")}`
}
function utcDateId(value: Date) {
return `${value.getUTCFullYear()}-${String(value.getUTCMonth() + 1).padStart(2, "0")}-${String(value.getUTCDate()).padStart(2, "0")}`
}
export function rankBy<T extends StatBaseRow>(rows: T[], value: (row: T) => number) {
return new Map(rows.toSorted((a, b) => value(b) - value(a)).map((row, index) => [row, index + 1]))
}
export function rankRowsWithMarketShare<T extends StatBaseRow>(rows: T[]) {
export function rankRowsWithMarketShare<T extends StatBaseRow>(
rows: T[],
groupKey: (row: T) => string = statPeriodKey,
) {
return Object.values(
rows.reduce<Record<string, T[]>>((result, row) => {
const key = statPeriodKey(row)
const key = groupKey(row)
result[key] = [...(result[key] ?? []), row]
return result
}, {}),
File diff suppressed because it is too large Load Diff
+8 -5
View File
@@ -6,8 +6,10 @@ import { GeoStatRepo, rowsFromAggregates as geoRowsFromAggregates } from "./doma
import { buildStatsQuery, toGeoAggregate, toModelAggregate, toProviderAggregate } from "./domain/inference"
import { ModelStatRepo, rowsFromAggregates as modelRowsFromAggregates } from "./domain/model"
import { ProviderStatRepo, rowsFromAggregates as providerRowsFromAggregates } from "./domain/provider"
import { startOfIsoWeek } from "./domain/stat"
const DATALAKE_INGESTION_LAG_MS = 5 * 60_000
const WEEK_MS = 7 * 86_400_000
export type SyncStatsResult = { ok: true; rows: number; startedAt: string; periodStart: string; periodEnd: string }
export type SyncStatsError = AthenaQueryError | AthenaQueryTimeoutError | DatabaseError
@@ -19,9 +21,7 @@ export const syncStats: () => Effect.Effect<
> = Effect.fn("StatSync.sync")(function* () {
const startedAt = yield* DateTime.nowAsDate
const periodEnd = new Date(Math.floor((startedAt.getTime() - DATALAKE_INGESTION_LAG_MS) / 60_000) * 60_000)
const periodStart = new Date(
Date.UTC(periodEnd.getUTCFullYear(), periodEnd.getUTCMonth(), periodEnd.getUTCDate() - 6),
)
const periodStart = new Date(startOfIsoWeek(periodEnd).getTime() - WEEK_MS)
const athena = yield* Athena
const modelStats = yield* ModelStatRepo
const providerStats = yield* ProviderStatRepo
@@ -29,7 +29,7 @@ export const syncStats: () => Effect.Effect<
yield* logRuntimeCheck()
const [modelAggregates, providerAggregates, geoAggregates] = yield* Effect.all(
const [modelAggregates, providerAggregates, geoAggregates, geoModelAggregates] = yield* Effect.all(
[
athena
.query(buildStatsQuery(periodStart, periodEnd, "model"))
@@ -40,12 +40,15 @@ export const syncStats: () => Effect.Effect<
athena
.query(buildStatsQuery(periodStart, periodEnd, "geo"))
.pipe(Effect.map((rows) => rows.flatMap(toGeoAggregate))),
athena
.query(buildStatsQuery(periodStart, periodEnd, "geo_model"))
.pipe(Effect.map((rows) => rows.flatMap(toGeoAggregate))),
],
{ concurrency: "unbounded" },
)
const modelRows = modelRowsFromAggregates(modelAggregates)
const providerRows = providerRowsFromAggregates(providerAggregates)
const geoRows = geoRowsFromAggregates(geoAggregates)
const geoRows = geoRowsFromAggregates([...geoAggregates, ...geoModelAggregates])
yield* Effect.all([modelStats.upsert(modelRows), providerStats.upsert(providerRows), geoStats.upsert(geoRows)], {
concurrency: "unbounded",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/stats-server",
"version": "1.15.11",
"version": "1.15.12",
"private": true,
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/ui",
"version": "1.15.11",
"version": "1.15.12",
"type": "module",
"license": "MIT",
"exports": {
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "@opencode-ai/web",
"type": "module",
"license": "MIT",
"version": "1.15.11",
"version": "1.15.12",
"scripts": {
"dev": "astro dev",
"dev:remote": "VITE_API_URL=https://api.opencode.ai astro dev",
+2
View File
@@ -72,6 +72,7 @@ OpenCode Zen هي بوابة AI تتيح لك الوصول إلى هذه الن
| GPT 5 | gpt-5 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| GPT 5 Codex | gpt-5-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| GPT 5 Nano | gpt-5-nano | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| Claude Opus 4.8 | claude-opus-4-8 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
| Claude Opus 4.7 | claude-opus-4-7 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
| Claude Opus 4.6 | claude-opus-4-6 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
| Claude Opus 4.5 | claude-opus-4-5 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
@@ -132,6 +133,7 @@ https://opencode.ai/zen/v1/models
| Qwen3.6 Plus | $0.50 | $3.00 | $0.05 | $0.625 |
| Qwen3.5 Plus | $0.20 | $1.20 | $0.02 | $0.25 |
| Grok Build 0.1 | 1.00 | $2.00 | $0.20 | - |
| Claude Opus 4.8 | $5.00 | $25.00 | $0.50 | $6.25 |
| Claude Opus 4.7 | $5.00 | $25.00 | $0.50 | $6.25 |
| Claude Opus 4.6 | $5.00 | $25.00 | $0.50 | $6.25 |
| Claude Opus 4.5 | $5.00 | $25.00 | $0.50 | $6.25 |
+2
View File
@@ -77,6 +77,7 @@ Našim modelima možete pristupiti i preko sljedećih API endpointa.
| GPT 5 | gpt-5 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| GPT 5 Codex | gpt-5-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| GPT 5 Nano | gpt-5-nano | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| Claude Opus 4.8 | claude-opus-4-8 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
| Claude Opus 4.7 | claude-opus-4-7 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
| Claude Opus 4.6 | claude-opus-4-6 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
| Claude Opus 4.5 | claude-opus-4-5 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
@@ -139,6 +140,7 @@ Podržavamo pay-as-you-go model. Ispod su cijene **po 1M tokena**.
| Qwen3.6 Plus | $0.50 | $3.00 | $0.05 | $0.625 |
| Qwen3.5 Plus | $0.20 | $1.20 | $0.02 | $0.25 |
| Grok Build 0.1 | 1.00 | $2.00 | $0.20 | - |
| Claude Opus 4.8 | $5.00 | $25.00 | $0.50 | $6.25 |
| Claude Opus 4.7 | $5.00 | $25.00 | $0.50 | $6.25 |
| Claude Opus 4.6 | $5.00 | $25.00 | $0.50 | $6.25 |
| Claude Opus 4.5 | $5.00 | $25.00 | $0.50 | $6.25 |
+1 -1
View File
@@ -116,7 +116,7 @@ For project-specific TUI settings, add `tui.json` alongside it.
Place project specific config in the root of your project.
:::
When OpenCode starts up, it looks for a config file in the current directory or traverse up to the nearest Git directory.
When OpenCode starts up, it first looks for a config file in the current directory, then traverses up to the nearest Git directory.
This is also safe to be checked into Git and uses the same schema as the global one.
+2
View File
@@ -77,6 +77,7 @@ Du kan også få adgang til vores modeller gennem følgende API-endpoints.
| GPT 5 | gpt-5 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| GPT 5 Codex | gpt-5-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| GPT 5 Nano | gpt-5-nano | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| Claude Opus 4.8 | claude-opus-4-8 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
| Claude Opus 4.7 | claude-opus-4-7 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
| Claude Opus 4.6 | claude-opus-4-6 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
| Claude Opus 4.5 | claude-opus-4-5 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
@@ -139,6 +140,7 @@ Vi understøtter en pay-as-you-go-model. Nedenfor er priserne **pr. 1M tokens**.
| Qwen3.6 Plus | $0.50 | $3.00 | $0.05 | $0.625 |
| Qwen3.5 Plus | $0.20 | $1.20 | $0.02 | $0.25 |
| Grok Build 0.1 | 1.00 | $2.00 | $0.20 | - |
| Claude Opus 4.8 | $5.00 | $25.00 | $0.50 | $6.25 |
| Claude Opus 4.7 | $5.00 | $25.00 | $0.50 | $6.25 |
| Claude Opus 4.6 | $5.00 | $25.00 | $0.50 | $6.25 |
| Claude Opus 4.5 | $5.00 | $25.00 | $0.50 | $6.25 |
+2
View File
@@ -68,6 +68,7 @@ Du kannst auch über die folgenden API-Endpunkte auf unsere Modelle zugreifen.
| GPT 5 | gpt-5 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| GPT 5 Codex | gpt-5-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| GPT 5 Nano | gpt-5-nano | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| Claude Opus 4.8 | claude-opus-4-8 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
| Claude Opus 4.7 | claude-opus-4-7 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
| Claude Opus 4.6 | claude-opus-4-6 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
| Claude Opus 4.5 | claude-opus-4-5 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
@@ -128,6 +129,7 @@ Wir unterstützen ein Pay-as-you-go-Modell. Unten findest du die Preise **pro 1M
| Qwen3.6 Plus | $0.50 | $3.00 | $0.05 | $0.625 |
| Qwen3.5 Plus | $0.20 | $1.20 | $0.02 | $0.25 |
| Grok Build 0.1 | 1.00 | $2.00 | $0.20 | - |
| Claude Opus 4.8 | $5.00 | $25.00 | $0.50 | $6.25 |
| Claude Opus 4.7 | $5.00 | $25.00 | $0.50 | $6.25 |
| Claude Opus 4.6 | $5.00 | $25.00 | $0.50 | $6.25 |
| Claude Opus 4.5 | $5.00 | $25.00 | $0.50 | $6.25 |
+2
View File
@@ -77,6 +77,7 @@ También puedes acceder a nuestros modelos a través de los siguientes endpoints
| GPT 5 | gpt-5 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| GPT 5 Codex | gpt-5-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| GPT 5 Nano | gpt-5-nano | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| Claude Opus 4.8 | claude-opus-4-8 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
| Claude Opus 4.7 | claude-opus-4-7 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
| Claude Opus 4.6 | claude-opus-4-6 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
| Claude Opus 4.5 | claude-opus-4-5 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
@@ -139,6 +140,7 @@ Admitimos un modelo de pago por uso. A continuación se muestran los precios **p
| Qwen3.6 Plus | $0.50 | $3.00 | $0.05 | $0.625 |
| Qwen3.5 Plus | $0.20 | $1.20 | $0.02 | $0.25 |
| Grok Build 0.1 | 1.00 | $2.00 | $0.20 | - |
| Claude Opus 4.8 | $5.00 | $25.00 | $0.50 | $6.25 |
| Claude Opus 4.7 | $5.00 | $25.00 | $0.50 | $6.25 |
| Claude Opus 4.6 | $5.00 | $25.00 | $0.50 | $6.25 |
| Claude Opus 4.5 | $5.00 | $25.00 | $0.50 | $6.25 |
+2
View File
@@ -68,6 +68,7 @@ Vous pouvez également accéder à nos modèles via les points de terminaison AP
| GPT 5 | gpt-5 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| GPT 5 Codex | gpt-5-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| GPT 5 Nano | gpt-5-nano | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| Claude Opus 4.8 | claude-opus-4-8 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
| Claude Opus 4.7 | claude-opus-4-7 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
| Claude Opus 4.6 | claude-opus-4-6 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
| Claude Opus 4.5 | claude-opus-4-5 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
@@ -128,6 +129,7 @@ Nous prenons en charge un modèle de paiement à l'utilisation. Vous trouverez c
| Qwen3.6 Plus | $0.50 | $3.00 | $0.05 | $0.625 |
| Qwen3.5 Plus | $0.20 | $1.20 | $0.02 | $0.25 |
| Grok Build 0.1 | 1.00 | $2.00 | $0.20 | - |
| Claude Opus 4.8 | $5.00 | $25.00 | $0.50 | $6.25 |
| Claude Opus 4.7 | $5.00 | $25.00 | $0.50 | $6.25 |
| Claude Opus 4.6 | $5.00 | $25.00 | $0.50 | $6.25 |
| Claude Opus 4.5 | $5.00 | $25.00 | $0.50 | $6.25 |

Some files were not shown because too many files have changed in this diff Show More