chore: merge dev into database wrapper branch
This commit is contained in:
@@ -26,6 +26,7 @@
|
||||
"@solidjs/router": "catalog:",
|
||||
"@solidjs/start": "catalog:",
|
||||
"@stripe/stripe-js": "8.6.1",
|
||||
"@upstash/redis": "1.38.0",
|
||||
"chart.js": "4.5.1",
|
||||
"nitro": "3.0.1-alpha.1",
|
||||
"solid-js": "catalog:",
|
||||
|
||||
@@ -249,8 +249,9 @@ export async function handler(
|
||||
if (!isStream || [400, 404, 429].includes(res.status)) {
|
||||
const json = await res.json()
|
||||
await rateLimiter?.track()
|
||||
if (json.usage) {
|
||||
const usageInfo = providerInfo.normalizeUsage(json.usage)
|
||||
const usage = providerInfo.extractUsage(json)
|
||||
if (usage) {
|
||||
const usageInfo = providerInfo.normalizeUsage(usage)
|
||||
const costInfo = calculateCost(modelInfo, usageInfo)
|
||||
await trialLimiter?.track(usageInfo)
|
||||
await modelTpmLimiter?.track(providerInfo.id, providerInfo.model, usageInfo)
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Database, eq, and, sql, inArray } from "@opencode-ai/console-core/drizz
|
||||
import { IpRateLimitTable } from "@opencode-ai/console-core/schema/ip.sql.js"
|
||||
import { FreeUsageLimitError } from "./error"
|
||||
import { logger } from "./logger"
|
||||
import { buildRateLimitKey, getRedis } from "./redis"
|
||||
import { i18n } from "~/i18n"
|
||||
import { localeFromRequest } from "~/lib/language"
|
||||
import { Subscription } from "@opencode-ai/console-core/subscription.js"
|
||||
@@ -23,43 +24,62 @@ export function createRateLimiter(modelId: string, rateLimit: number | undefined
|
||||
const now = Date.now()
|
||||
const lifetimeInterval = ""
|
||||
const dailyInterval = rateLimit ? `${buildYYYYMMDD(now)}${modelId.substring(0, 2)}` : buildYYYYMMDD(now)
|
||||
|
||||
let _isNew: boolean
|
||||
const retryAfter = getRetryAfterDay(now)
|
||||
const redis = getRedis()
|
||||
const lifetimeKey = buildRateLimitKey("ip", ip)
|
||||
const dailyKey = buildRateLimitKey("ip", ip, dailyInterval)
|
||||
let isNew = false
|
||||
|
||||
return {
|
||||
check: async () => {
|
||||
const rows = await Database.use((tx) =>
|
||||
tx
|
||||
.select({ interval: IpRateLimitTable.interval, count: IpRateLimitTable.count })
|
||||
.from(IpRateLimitTable)
|
||||
.where(
|
||||
and(
|
||||
eq(IpRateLimitTable.ip, ip),
|
||||
isDefaultModel
|
||||
? inArray(IpRateLimitTable.interval, [lifetimeInterval, dailyInterval])
|
||||
: inArray(IpRateLimitTable.interval, [dailyInterval]),
|
||||
const [counts, rows] = await Promise.all([
|
||||
redis.mget<(string | number | null)[]>(isDefaultModel ? [lifetimeKey, dailyKey] : [dailyKey]).catch(() => []),
|
||||
Database.use((tx) =>
|
||||
tx
|
||||
.select({ interval: IpRateLimitTable.interval, count: IpRateLimitTable.count })
|
||||
.from(IpRateLimitTable)
|
||||
.where(
|
||||
and(
|
||||
eq(IpRateLimitTable.ip, ip),
|
||||
isDefaultModel
|
||||
? inArray(IpRateLimitTable.interval, [lifetimeInterval, dailyInterval])
|
||||
: inArray(IpRateLimitTable.interval, [dailyInterval]),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
const lifetimeCount = rows.find((r) => r.interval === lifetimeInterval)?.count ?? 0
|
||||
const dailyCount = rows.find((r) => r.interval === dailyInterval)?.count ?? 0
|
||||
),
|
||||
])
|
||||
const redisLifetimeCount = isDefaultModel ? Number(counts[0] ?? 0) : 0
|
||||
const redisDailyCount = Number(counts[isDefaultModel ? 1 : 0] ?? 0)
|
||||
const databaseLifetimeCount = rows.find((r) => r.interval === lifetimeInterval)?.count ?? 0
|
||||
const databaseDailyCount = rows.find((r) => r.interval === dailyInterval)?.count ?? 0
|
||||
const lifetimeCount = Math.max(redisLifetimeCount, databaseLifetimeCount)
|
||||
const dailyCount = Math.max(redisDailyCount, databaseDailyCount)
|
||||
logger.debug(`rate limit lifetime: ${lifetimeCount}, daily: ${dailyCount}`)
|
||||
|
||||
_isNew = isDefaultModel && lifetimeCount < dailyLimit * 7
|
||||
isNew = isDefaultModel && lifetimeCount < dailyLimit * 7
|
||||
if (isDefaultModel && databaseLifetimeCount > redisLifetimeCount)
|
||||
await redis.set(lifetimeKey, databaseLifetimeCount).catch(() => {})
|
||||
|
||||
if ((_isNew && dailyCount >= dailyLimit * 2) || (!_isNew && dailyCount >= dailyLimit))
|
||||
throw new FreeUsageLimitError(dict["zen.api.error.rateLimitExceeded"], getRetryAfterDay(now))
|
||||
if ((isNew && dailyCount >= dailyLimit * 2) || (!isNew && dailyCount >= dailyLimit))
|
||||
throw new FreeUsageLimitError(dict["zen.api.error.rateLimitExceeded"], retryAfter)
|
||||
},
|
||||
track: async () => {
|
||||
await Database.use((tx) =>
|
||||
tx
|
||||
.insert(IpRateLimitTable)
|
||||
.values([
|
||||
{ ip, interval: dailyInterval, count: 1 },
|
||||
...(_isNew ? [{ ip, interval: lifetimeInterval, count: 1 }] : []),
|
||||
])
|
||||
.onDuplicateKeyUpdate({ set: { count: sql`${IpRateLimitTable.count} + 1` } }),
|
||||
)
|
||||
const pipeline = redis.pipeline()
|
||||
pipeline.incr(dailyKey)
|
||||
pipeline.expire(dailyKey, retryAfter)
|
||||
if (isNew) pipeline.incr(lifetimeKey)
|
||||
await Promise.all([
|
||||
pipeline.exec().catch(() => {}),
|
||||
Database.use((tx) =>
|
||||
tx
|
||||
.insert(IpRateLimitTable)
|
||||
.values([
|
||||
{ ip, interval: dailyInterval, count: 1 },
|
||||
...(isNew ? [{ ip, interval: lifetimeInterval, count: 1 }] : []),
|
||||
])
|
||||
.onDuplicateKeyUpdate({ set: { count: sql`${IpRateLimitTable.count} + 1` } }),
|
||||
),
|
||||
])
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -175,6 +175,7 @@ export const anthropicHelper: ProviderHelper = ({ reqModel, providerModel }) =>
|
||||
retrieve: () => usage,
|
||||
}
|
||||
},
|
||||
extractUsage: (response: any) => response.usage,
|
||||
normalizeUsage: (usage: Usage) => ({
|
||||
inputTokens: usage.input_tokens ?? 0,
|
||||
outputTokens: usage.output_tokens ?? 0,
|
||||
|
||||
@@ -58,6 +58,7 @@ export const googleHelper: ProviderHelper = ({ providerModel }) => ({
|
||||
retrieve: () => usage,
|
||||
}
|
||||
},
|
||||
extractUsage: (response: any) => response.usageMetadata,
|
||||
normalizeUsage: (usage: Usage) => {
|
||||
const inputTokens = usage.promptTokenCount ?? 0
|
||||
const outputTokens = usage.candidatesTokenCount ?? 0
|
||||
|
||||
@@ -58,6 +58,7 @@ export const oaCompatHelper: ProviderHelper = ({ adjustCacheUsage }) => ({
|
||||
retrieve: () => usage,
|
||||
}
|
||||
},
|
||||
extractUsage: (response: any) => response.usage,
|
||||
normalizeUsage: (usage: Usage) => {
|
||||
let inputTokens = usage.prompt_tokens ?? 0
|
||||
const outputTokens = usage.completion_tokens ?? 0
|
||||
|
||||
@@ -43,6 +43,7 @@ export const openaiHelper: ProviderHelper = ({ workspaceID }) => ({
|
||||
retrieve: () => usage,
|
||||
}
|
||||
},
|
||||
extractUsage: (response: any) => response.usage ?? response.response?.usage,
|
||||
normalizeUsage: (usage: Usage) => {
|
||||
const inputTokens = usage.input_tokens ?? 0
|
||||
const outputTokens = usage.output_tokens ?? 0
|
||||
|
||||
@@ -49,6 +49,7 @@ export type ProviderHelper = (input: {
|
||||
parse: (chunk: string) => void
|
||||
retrieve: () => any
|
||||
}
|
||||
extractUsage: (response: any) => any
|
||||
normalizeUsage: (usage: any) => UsageInfo
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import { Resource } from "@opencode-ai/console-resource"
|
||||
import { Redis } from "@upstash/redis/cloudflare"
|
||||
|
||||
let redis: Redis | undefined
|
||||
|
||||
export function getRedis() {
|
||||
if (redis) return redis
|
||||
redis = new Redis({
|
||||
url: Resource.UpstashRedisRestUrl.value,
|
||||
token: Resource.UpstashRedisRestToken.value,
|
||||
enableTelemetry: false,
|
||||
})
|
||||
return redis
|
||||
}
|
||||
|
||||
export function buildRateLimitKey(kind: string, identifier: string, interval?: string) {
|
||||
return `${Resource.App.stage}:ratelimit:${kind}:${identifier}${interval ? `:${interval}` : ""}`
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { ZenData } from "@opencode-ai/console-core/model.js"
|
||||
import type { ProviderHelper } from "../src/routes/zen/util/provider/provider"
|
||||
import { anthropicHelper } from "../src/routes/zen/util/provider/anthropic"
|
||||
import { googleHelper } from "../src/routes/zen/util/provider/google"
|
||||
import { oaCompatHelper } from "../src/routes/zen/util/provider/openai-compatible"
|
||||
import { openaiHelper } from "../src/routes/zen/util/provider/openai"
|
||||
|
||||
const providers = {
|
||||
anthropic: anthropicHelper({ reqModel: "claude-haiku-4-5", providerModel: "claude-haiku-4-5" }),
|
||||
google: googleHelper({ reqModel: "gemini-3-flash", providerModel: "gemini-3-flash" }),
|
||||
openai: openaiHelper({ reqModel: "gpt-5", providerModel: "gpt-5" }),
|
||||
"oa-compat": oaCompatHelper({ reqModel: "gpt-5-nano", providerModel: "gpt-5-nano" }),
|
||||
} satisfies Record<ZenData.Format, ReturnType<ProviderHelper>>
|
||||
|
||||
describe("provider usage extraction", () => {
|
||||
test("extracts Google non-stream usage metadata", () => {
|
||||
const usage = providers.google.extractUsage({
|
||||
usageMetadata: {
|
||||
promptTokenCount: 10,
|
||||
candidatesTokenCount: 3,
|
||||
thoughtsTokenCount: 2,
|
||||
cachedContentTokenCount: 4,
|
||||
},
|
||||
})
|
||||
|
||||
expect(providers.google.normalizeUsage(usage)).toEqual({
|
||||
inputTokens: 6,
|
||||
outputTokens: 3,
|
||||
reasoningTokens: 2,
|
||||
cacheReadTokens: 4,
|
||||
cacheWrite5mTokens: undefined,
|
||||
cacheWrite1hTokens: undefined,
|
||||
})
|
||||
})
|
||||
|
||||
test("parses Google stream usage metadata", () => {
|
||||
const usageParser = providers.google.createUsageParser()
|
||||
usageParser.parse(
|
||||
'data: {"usageMetadata":{"promptTokenCount":10,"candidatesTokenCount":3,"thoughtsTokenCount":2,"cachedContentTokenCount":4}}',
|
||||
)
|
||||
|
||||
expect(providers.google.normalizeUsage(usageParser.retrieve())).toEqual({
|
||||
inputTokens: 6,
|
||||
outputTokens: 3,
|
||||
reasoningTokens: 2,
|
||||
cacheReadTokens: 4,
|
||||
cacheWrite5mTokens: undefined,
|
||||
cacheWrite1hTokens: undefined,
|
||||
})
|
||||
})
|
||||
|
||||
test("extracts nested OpenAI Responses usage", () => {
|
||||
expect(
|
||||
providers.openai.extractUsage({
|
||||
response: {
|
||||
usage: {
|
||||
input_tokens: 5,
|
||||
output_tokens: 7,
|
||||
},
|
||||
},
|
||||
}),
|
||||
).toEqual({
|
||||
input_tokens: 5,
|
||||
output_tokens: 7,
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -9,7 +9,7 @@ export default defineConfig({
|
||||
}) as PluginOption,
|
||||
nitro({
|
||||
compatibilityDate: "2024-09-19",
|
||||
preset: "cloudflare_module",
|
||||
preset: "cloudflare-module",
|
||||
cloudflare: {
|
||||
nodeCompat: true,
|
||||
},
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
import { Resource } from "@opencode-ai/console-resource"
|
||||
import { and, Database, eq, isNull } from "../src/drizzle/index.js"
|
||||
import { Identifier } from "../src/identifier.js"
|
||||
import { AccountTable } from "../src/schema/account.sql.js"
|
||||
import { AuthTable } from "../src/schema/auth.sql.js"
|
||||
import { BillingTable } from "../src/schema/billing.sql.js"
|
||||
import { KeyTable } from "../src/schema/key.sql.js"
|
||||
import { UserTable } from "../src/schema/user.sql.js"
|
||||
import { WorkspaceTable } from "../src/schema/workspace.sql.js"
|
||||
import { centsToMicroCents } from "../src/util/price.js"
|
||||
|
||||
const args = parseArgs(process.argv.slice(2))
|
||||
if (!args.email) {
|
||||
console.error(
|
||||
"Usage: bun script/create-api-key.ts --email <email> [--workspace-id <wrk_...>] [--workspace-name <name>] [--key-name <name>] [--balance-dollars <amount>] [--allow-production]",
|
||||
)
|
||||
process.exit(1)
|
||||
}
|
||||
if (Resource.App.stage === "production" && !args.allowProduction) {
|
||||
throw new Error("Refusing to create a production API key without --allow-production")
|
||||
}
|
||||
|
||||
const result = await Database.transaction(async (tx) => {
|
||||
const auth = await tx
|
||||
.select()
|
||||
.from(AuthTable)
|
||||
.where(and(eq(AuthTable.provider, "email"), eq(AuthTable.subject, args.email)))
|
||||
.then((rows) => rows[0])
|
||||
const accountID = auth?.accountID ?? Identifier.create("account")
|
||||
if (!auth) {
|
||||
await tx.insert(AccountTable).values({ id: accountID })
|
||||
await tx.insert(AuthTable).values({
|
||||
id: Identifier.create("auth"),
|
||||
provider: "email",
|
||||
subject: args.email,
|
||||
accountID,
|
||||
})
|
||||
}
|
||||
|
||||
const workspace = args.workspaceID
|
||||
? await tx
|
||||
.select()
|
||||
.from(WorkspaceTable)
|
||||
.where(eq(WorkspaceTable.id, args.workspaceID))
|
||||
.then((rows) => rows[0])
|
||||
: await tx
|
||||
.select({ workspace: WorkspaceTable })
|
||||
.from(UserTable)
|
||||
.innerJoin(WorkspaceTable, eq(WorkspaceTable.id, UserTable.workspaceID))
|
||||
.where(and(eq(UserTable.accountID, accountID), isNull(UserTable.timeDeleted)))
|
||||
.then((rows) => rows[0]?.workspace)
|
||||
if (args.workspaceID && !workspace) throw new Error(`Workspace not found: ${args.workspaceID}`)
|
||||
const workspaceID = workspace?.id ?? Identifier.create("workspace")
|
||||
if (!workspace) {
|
||||
await tx.insert(WorkspaceTable).values({
|
||||
id: workspaceID,
|
||||
slug: null,
|
||||
name: args.workspaceName ?? `${args.email} manual`,
|
||||
})
|
||||
}
|
||||
|
||||
const user = await tx
|
||||
.select()
|
||||
.from(UserTable)
|
||||
.where(
|
||||
and(eq(UserTable.workspaceID, workspaceID), eq(UserTable.accountID, accountID), isNull(UserTable.timeDeleted)),
|
||||
)
|
||||
.then((rows) => rows[0])
|
||||
const userID = user?.id ?? Identifier.create("user")
|
||||
if (!user) {
|
||||
await tx.insert(UserTable).values({
|
||||
id: userID,
|
||||
workspaceID,
|
||||
accountID,
|
||||
email: args.email,
|
||||
name: args.email,
|
||||
role: "admin",
|
||||
})
|
||||
}
|
||||
|
||||
const balance = centsToMicroCents(args.balanceDollars * 100)
|
||||
const billing = await tx
|
||||
.select()
|
||||
.from(BillingTable)
|
||||
.where(eq(BillingTable.workspaceID, workspaceID))
|
||||
.then((rows) => rows[0])
|
||||
if (!billing) {
|
||||
await tx.insert(BillingTable).values({
|
||||
id: Identifier.create("billing"),
|
||||
workspaceID,
|
||||
balance,
|
||||
})
|
||||
} else if (billing.balance < balance) {
|
||||
await tx.update(BillingTable).set({ balance }).where(eq(BillingTable.workspaceID, workspaceID))
|
||||
}
|
||||
|
||||
const secretKey = createSecretKey()
|
||||
const keyID = Identifier.create("key")
|
||||
await tx.insert(KeyTable).values({
|
||||
id: keyID,
|
||||
workspaceID,
|
||||
userID,
|
||||
name: args.keyName ?? "Manual API Key",
|
||||
key: secretKey,
|
||||
timeUsed: null,
|
||||
})
|
||||
|
||||
return { accountID, workspaceID, userID, keyID, secretKey }
|
||||
})
|
||||
|
||||
console.log(JSON.stringify({ stage: Resource.App.stage, ...result }, null, 2))
|
||||
|
||||
function createSecretKey() {
|
||||
const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"
|
||||
const values = new Uint32Array(64)
|
||||
crypto.getRandomValues(values)
|
||||
return `sk-${Array.from(values, (value) => chars[value % chars.length]).join("")}`
|
||||
}
|
||||
|
||||
function parseArgs(argv: string[]) {
|
||||
const parsed = {
|
||||
email: "",
|
||||
workspaceID: "",
|
||||
workspaceName: "",
|
||||
keyName: "",
|
||||
balanceDollars: 100,
|
||||
allowProduction: false,
|
||||
}
|
||||
for (let index = 0; index < argv.length; index++) {
|
||||
const arg = argv[index]
|
||||
if (arg === "--email") parsed.email = requiredValue(argv, ++index, arg)
|
||||
if (arg === "--workspace-id") parsed.workspaceID = requiredValue(argv, ++index, arg)
|
||||
if (arg === "--workspace-name") parsed.workspaceName = requiredValue(argv, ++index, arg)
|
||||
if (arg === "--key-name") parsed.keyName = requiredValue(argv, ++index, arg)
|
||||
if (arg === "--balance-dollars") parsed.balanceDollars = Number(requiredValue(argv, ++index, arg))
|
||||
if (arg === "--allow-production") parsed.allowProduction = true
|
||||
}
|
||||
if (!Number.isFinite(parsed.balanceDollars) || parsed.balanceDollars < 0) throw new Error("Invalid --balance-dollars")
|
||||
return parsed
|
||||
}
|
||||
|
||||
function requiredValue(argv: string[], index: number, arg: string) {
|
||||
const value = argv[index]
|
||||
if (!value || value.startsWith("--")) throw new Error(`Missing value for ${arg}`)
|
||||
return value
|
||||
}
|
||||
@@ -21,7 +21,7 @@ export default {
|
||||
)
|
||||
continue
|
||||
|
||||
let data = {
|
||||
let data: Record<string, unknown> = {
|
||||
"cf.continent": event.event.request.cf?.continent,
|
||||
"cf.country": event.event.request.cf?.country,
|
||||
"cf.city": event.event.request.cf?.city,
|
||||
@@ -35,30 +35,152 @@ export default {
|
||||
ip: event.event.request.headers["x-real-ip"],
|
||||
}
|
||||
const time = new Date(event.eventTimestamp ?? Date.now()).toISOString()
|
||||
const events = []
|
||||
for (const log of event.logs) {
|
||||
for (const message of log.message) {
|
||||
if (!message.startsWith("_metric:")) continue
|
||||
const json = JSON.parse(message.slice(8))
|
||||
data = { ...data, ...json }
|
||||
if ("llm.error.code" in json) {
|
||||
events.push({ time, data: { ...data, event_type: "llm.error" } })
|
||||
}
|
||||
}
|
||||
}
|
||||
events.push({ time, data: { ...data, event_type: "completions" } })
|
||||
const events = [
|
||||
...event.logs.flatMap((log) =>
|
||||
log.message.flatMap((message: string) => {
|
||||
if (!message.startsWith("_metric:")) return []
|
||||
const json = JSON.parse(message.slice(8)) as Record<string, unknown>
|
||||
data = { ...data, ...json }
|
||||
if ("llm.error.code" in json) {
|
||||
return [{ time, data: { ...data, event_type: "llm.error" } }]
|
||||
}
|
||||
return []
|
||||
}),
|
||||
),
|
||||
{ time, data: { ...data, event_type: "completions" } },
|
||||
]
|
||||
console.log(JSON.stringify(data, null, 2))
|
||||
|
||||
const ret = await fetch("https://api.honeycomb.io/1/batch/zen", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-Honeycomb-Team": Resource.HONEYCOMB_API_KEY.value,
|
||||
},
|
||||
body: JSON.stringify(events),
|
||||
})
|
||||
console.log(ret.status)
|
||||
console.log(await ret.text())
|
||||
const lakeIngest = getLakeIngest()
|
||||
const [honeycomb, lake] = await Promise.all([
|
||||
fetch("https://api.honeycomb.io/1/batch/zen", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-Honeycomb-Team": Resource.HONEYCOMB_API_KEY.value,
|
||||
},
|
||||
body: JSON.stringify(events),
|
||||
}),
|
||||
...(lakeIngest
|
||||
? [
|
||||
fetch(lakeIngest.url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${lakeIngest.secret}`,
|
||||
},
|
||||
body: JSON.stringify({ events: events.map((event) => toLakeEvent(event.time, event.data)) }),
|
||||
}),
|
||||
]
|
||||
: []),
|
||||
])
|
||||
console.log(honeycomb.status)
|
||||
console.log(await honeycomb.text())
|
||||
if (lake) {
|
||||
console.log(lake.status)
|
||||
console.log(await lake.text())
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
function getLakeIngest(): { url: string; secret: string } | undefined {
|
||||
try {
|
||||
return Resource.LakeIngest
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
function toLakeEvent(time: string, data: Record<string, unknown>) {
|
||||
return {
|
||||
_datalake_key: "inference.event",
|
||||
event_timestamp: time,
|
||||
event_date: time.slice(0, 10),
|
||||
event_type: string(data, "event_type"),
|
||||
dataset: "zen",
|
||||
cf_continent: string(data, "cf.continent"),
|
||||
cf_country: string(data, "cf.country"),
|
||||
cf_city: string(data, "cf.city"),
|
||||
cf_region: string(data, "cf.region"),
|
||||
cf_latitude: number(data, "cf.latitude"),
|
||||
cf_longitude: number(data, "cf.longitude"),
|
||||
cf_timezone: string(data, "cf.timezone"),
|
||||
duration: number(data, "duration"),
|
||||
request_length: integer(data, "request_length"),
|
||||
status: integer(data, "status"),
|
||||
ip: string(data, "ip"),
|
||||
is_stream: boolean(data, "is_stream"),
|
||||
session: string(data, "session"),
|
||||
request: string(data, "request"),
|
||||
client: string(data, "client"),
|
||||
user_agent: string(data, "user_agent"),
|
||||
model_variant: string(data, "model.variant"),
|
||||
source: string(data, "source"),
|
||||
provider: string(data, "provider"),
|
||||
provider_model: string(data, "provider.model"),
|
||||
model: string(data, "model"),
|
||||
llm_error_code: integer(data, "llm.error.code"),
|
||||
llm_error_message: string(data, "llm.error.message"),
|
||||
error_response: string(data, "error.response"),
|
||||
error_type: string(data, "error.type"),
|
||||
error_message: string(data, "error.message"),
|
||||
error_cause: string(data, "error.cause"),
|
||||
error_cause2: string(data, "error.cause2"),
|
||||
api_key: string(data, "api_key"),
|
||||
workspace: string(data, "workspace"),
|
||||
is_subscription: boolean(data, "isSubscription"),
|
||||
subscription: string(data, "subscription"),
|
||||
response_length: integer(data, "response_length"),
|
||||
time_to_first_byte: integer(data, "time_to_first_byte"),
|
||||
timestamp_first_byte: integer(data, "timestamp.first_byte"),
|
||||
timestamp_last_byte: integer(data, "timestamp.last_byte"),
|
||||
tokens_input: integer(data, "tokens.input"),
|
||||
tokens_output: integer(data, "tokens.output"),
|
||||
tokens_reasoning: integer(data, "tokens.reasoning"),
|
||||
tokens_cache_read: integer(data, "tokens.cache_read"),
|
||||
tokens_cache_write_5m: integer(data, "tokens.cache_write_5m"),
|
||||
tokens_cache_write_1h: integer(data, "tokens.cache_write_1h"),
|
||||
cost_input_microcents: integer(data, "cost.input.microcents"),
|
||||
cost_output_microcents: integer(data, "cost.output.microcents"),
|
||||
cost_cache_read_microcents: integer(data, "cost.cache_read.microcents"),
|
||||
cost_cache_write_microcents: integer(data, "cost.cache_write.microcents"),
|
||||
cost_total_microcents: integer(data, "cost.total.microcents"),
|
||||
cost_input: integer(data, "cost.input"),
|
||||
cost_output: integer(data, "cost.output"),
|
||||
cost_cache_read: integer(data, "cost.cache_read"),
|
||||
cost_cache_write_5m: integer(data, "cost.cache_write_5m"),
|
||||
cost_cache_write_1h: integer(data, "cost.cache_write_1h"),
|
||||
cost_total: integer(data, "cost.total"),
|
||||
}
|
||||
}
|
||||
|
||||
function string(data: Record<string, unknown>, key: string) {
|
||||
const value = data[key]
|
||||
if (typeof value === "string") return value
|
||||
if (typeof value === "number" || typeof value === "boolean") return String(value)
|
||||
return undefined
|
||||
}
|
||||
|
||||
function boolean(data: Record<string, unknown>, key: string) {
|
||||
const value = data[key]
|
||||
if (typeof value === "boolean") return value
|
||||
if (typeof value === "string") return value === "true" ? true : value === "false" ? false : undefined
|
||||
return undefined
|
||||
}
|
||||
|
||||
function integer(data: Record<string, unknown>, key: string) {
|
||||
const value = number(data, key)
|
||||
if (value === undefined) return undefined
|
||||
return Math.round(value)
|
||||
}
|
||||
|
||||
function number(data: Record<string, unknown>, key: string) {
|
||||
const value = data[key]
|
||||
if (typeof value === "number") return Number.isFinite(value) ? value : undefined
|
||||
if (typeof value === "string") {
|
||||
const parsed = Number(value)
|
||||
return Number.isFinite(parsed) ? parsed : undefined
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ export const Resource = new Proxy(
|
||||
{
|
||||
get(_target, prop: keyof typeof ResourceBase) {
|
||||
const value = ResourceBase[prop]
|
||||
const secrets = ResourceBase as unknown as Record<string, { value: string }>
|
||||
if ("type" in value) {
|
||||
// @ts-ignore
|
||||
if (value.type === "sst.cloudflare.Bucket") {
|
||||
@@ -21,11 +22,11 @@ export const Resource = new Proxy(
|
||||
// @ts-ignore
|
||||
if (value.type === "sst.cloudflare.Kv") {
|
||||
const client = new Cloudflare({
|
||||
apiToken: ResourceBase.CLOUDFLARE_API_TOKEN.value,
|
||||
apiToken: secrets.CLOUDFLARE_API_TOKEN.value,
|
||||
})
|
||||
// @ts-ignore
|
||||
const namespaceId = value.namespaceId
|
||||
const accountId = ResourceBase.CLOUDFLARE_DEFAULT_ACCOUNT_ID.value
|
||||
const accountId = secrets.CLOUDFLARE_DEFAULT_ACCOUNT_ID.value
|
||||
return {
|
||||
get: (k: string | string[]) => {
|
||||
const isMulti = Array.isArray(k)
|
||||
|
||||
@@ -8,6 +8,10 @@ function truthy(key: string) {
|
||||
const OPENCODE_EXPERIMENTAL = truthy("OPENCODE_EXPERIMENTAL")
|
||||
const copy = process.env["OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT"]
|
||||
|
||||
function enabledByExperimental(key: string) {
|
||||
return process.env[key] === undefined ? OPENCODE_EXPERIMENTAL : truthy(key)
|
||||
}
|
||||
|
||||
export const Flag = {
|
||||
OTEL_EXPORTER_OTLP_ENDPOINT: process.env["OTEL_EXPORTER_OTLP_ENDPOINT"],
|
||||
OTEL_EXPORTER_OTLP_HEADERS: process.env["OTEL_EXPORTER_OTLP_HEADERS"],
|
||||
@@ -42,7 +46,7 @@ export const Flag = {
|
||||
OPENCODE_DB: process.env["OPENCODE_DB"],
|
||||
|
||||
OPENCODE_WORKSPACE_ID: process.env["OPENCODE_WORKSPACE_ID"],
|
||||
OPENCODE_EXPERIMENTAL_WORKSPACES: OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_WORKSPACES"),
|
||||
OPENCODE_EXPERIMENTAL_WORKSPACES: enabledByExperimental("OPENCODE_EXPERIMENTAL_WORKSPACES"),
|
||||
|
||||
// Evaluated at access time (not module load) because tests, the CLI, and
|
||||
// external tooling set these env vars at runtime.
|
||||
|
||||
@@ -8,7 +8,7 @@ const nitroConfig: any = (() => {
|
||||
if (target === "cloudflare") {
|
||||
return {
|
||||
compatibilityDate: "2024-09-19",
|
||||
preset: "cloudflare_module",
|
||||
preset: "cloudflare-module",
|
||||
cloudflare: {
|
||||
nodeCompat: true,
|
||||
},
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import {
|
||||
RequestError,
|
||||
type Agent as ACPAgent,
|
||||
type AgentSideConnection,
|
||||
type AuthenticateRequest,
|
||||
type CancelNotification,
|
||||
type InitializeRequest,
|
||||
type LoadSessionRequest,
|
||||
type NewSessionRequest,
|
||||
type PromptRequest,
|
||||
type SetSessionConfigOptionRequest,
|
||||
type SetSessionModelRequest,
|
||||
type SetSessionModeRequest,
|
||||
} from "@agentclientprotocol/sdk"
|
||||
import { Effect } from "effect"
|
||||
import type { OpencodeClient } from "@opencode-ai/sdk/v2"
|
||||
import * as ACPNextError from "./error"
|
||||
import * as ACPNextService from "./service"
|
||||
|
||||
export function init({ sdk: _sdk }: { sdk: OpencodeClient }) {
|
||||
return {
|
||||
create: (connection: AgentSideConnection) => {
|
||||
return new Agent(ACPNextService.make({ sdk: _sdk, connection }))
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export class Agent implements ACPAgent {
|
||||
constructor(private readonly service: ACPNextService.Interface) {}
|
||||
|
||||
initialize(params: InitializeRequest) {
|
||||
return run(this.service.initialize(params))
|
||||
}
|
||||
|
||||
authenticate(params: AuthenticateRequest) {
|
||||
return run(this.service.authenticate(params))
|
||||
}
|
||||
|
||||
newSession(params: NewSessionRequest) {
|
||||
return run(this.service.newSession(params))
|
||||
}
|
||||
|
||||
loadSession(params: LoadSessionRequest) {
|
||||
return run(this.service.loadSession(params))
|
||||
}
|
||||
|
||||
setSessionConfigOption(params: SetSessionConfigOptionRequest) {
|
||||
return run(this.service.setSessionConfigOption(params))
|
||||
}
|
||||
|
||||
setSessionMode(params: SetSessionModeRequest) {
|
||||
return run(this.service.setSessionMode(params))
|
||||
}
|
||||
|
||||
unstable_setSessionModel(params: SetSessionModelRequest) {
|
||||
return run(this.service.setSessionModel(params))
|
||||
}
|
||||
|
||||
prompt(params: PromptRequest) {
|
||||
return run(this.service.prompt(params))
|
||||
}
|
||||
|
||||
cancel(params: CancelNotification) {
|
||||
return run(this.service.cancel(params))
|
||||
}
|
||||
}
|
||||
|
||||
function run<A>(effect: Effect.Effect<A, ACPNextService.Error>) {
|
||||
return Effect.runPromise(effect.pipe(Effect.mapError(ACPNextError.toRequestError))).catch((defect: unknown) => {
|
||||
if (defect instanceof RequestError) throw defect
|
||||
throw ACPNextError.toRequestError(ACPNextError.fromUnknownDefect(defect))
|
||||
})
|
||||
}
|
||||
|
||||
export * as ACPNext from "./agent"
|
||||
@@ -0,0 +1,203 @@
|
||||
import type { SessionConfigOption } from "@agentclientprotocol/sdk"
|
||||
|
||||
export const DEFAULT_VARIANT_VALUE = "default"
|
||||
|
||||
export type ConfigOptionModel = {
|
||||
id: string
|
||||
name: string
|
||||
variants?: Record<string, Record<string, unknown>>
|
||||
}
|
||||
|
||||
export type ConfigOptionProvider = {
|
||||
id: string
|
||||
name: string
|
||||
models: Record<string, ConfigOptionModel>
|
||||
}
|
||||
|
||||
export type ConfigOptionMode = {
|
||||
id: string
|
||||
name: string
|
||||
description?: string
|
||||
}
|
||||
|
||||
export type ModelSelection = {
|
||||
model: {
|
||||
providerID: string
|
||||
modelID: string
|
||||
}
|
||||
variant?: string
|
||||
}
|
||||
|
||||
export function buildModelSelectOption(input: {
|
||||
providers: readonly ConfigOptionProvider[]
|
||||
currentModel: ModelSelection["model"]
|
||||
currentVariant?: string
|
||||
includeVariants?: boolean
|
||||
}): SessionConfigOption {
|
||||
return {
|
||||
id: "model",
|
||||
name: "Model",
|
||||
category: "model",
|
||||
type: "select",
|
||||
currentValue: formatCurrentModelId({
|
||||
model: input.currentModel,
|
||||
variant: input.currentVariant,
|
||||
variants: variantsForModel(input.providers, input.currentModel),
|
||||
includeVariant: input.includeVariants ?? false,
|
||||
}),
|
||||
options: buildModelSelectOptions(input.providers, { includeVariants: input.includeVariants ?? false }),
|
||||
}
|
||||
}
|
||||
|
||||
export function buildEffortSelectOption(input: {
|
||||
variants: readonly string[]
|
||||
currentVariant?: string
|
||||
}): SessionConfigOption | undefined {
|
||||
if (input.variants.length === 0) return undefined
|
||||
|
||||
return {
|
||||
id: "effort",
|
||||
name: "Effort",
|
||||
description: "Available effort levels for this model",
|
||||
category: "thought_level",
|
||||
type: "select",
|
||||
currentValue: selectVariant(input.currentVariant, input.variants),
|
||||
options: input.variants.map((variant) => ({
|
||||
value: variant,
|
||||
name: formatVariantName(variant),
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
export function buildModeSelectOption(input: {
|
||||
modes: readonly ConfigOptionMode[]
|
||||
currentModeId: string
|
||||
}): SessionConfigOption {
|
||||
return {
|
||||
id: "mode",
|
||||
name: "Session Mode",
|
||||
category: "mode",
|
||||
type: "select",
|
||||
currentValue: input.currentModeId,
|
||||
options: input.modes.map((mode) => ({
|
||||
value: mode.id,
|
||||
name: mode.name,
|
||||
...(mode.description ? { description: mode.description } : {}),
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
export function buildConfigOptions(input: {
|
||||
providers: readonly ConfigOptionProvider[]
|
||||
currentModel: ModelSelection["model"]
|
||||
currentVariant?: string
|
||||
includeModelVariants?: boolean
|
||||
modes?: readonly ConfigOptionMode[]
|
||||
currentModeId?: string
|
||||
}): SessionConfigOption[] {
|
||||
const variants = variantsForModel(input.providers, input.currentModel)
|
||||
const effort = buildEffortSelectOption({ variants, currentVariant: input.currentVariant })
|
||||
|
||||
return [
|
||||
buildModelSelectOption({
|
||||
providers: input.providers,
|
||||
currentModel: input.currentModel,
|
||||
currentVariant: input.currentVariant,
|
||||
includeVariants: input.includeModelVariants ?? false,
|
||||
}),
|
||||
...(effort ? [effort] : []),
|
||||
...(input.modes && input.currentModeId
|
||||
? [buildModeSelectOption({ modes: input.modes, currentModeId: input.currentModeId })]
|
||||
: []),
|
||||
]
|
||||
}
|
||||
|
||||
export function parseModelSelection(modelId: string, providers: readonly ConfigOptionProvider[]): ModelSelection {
|
||||
const provider = providers.find((item) => modelId.startsWith(`${item.id}/`))
|
||||
if (provider) {
|
||||
const modelID = modelId.slice(provider.id.length + 1)
|
||||
if (provider.models[modelID]) {
|
||||
return { model: { providerID: provider.id, modelID } }
|
||||
}
|
||||
|
||||
const separator = modelID.lastIndexOf("/")
|
||||
if (separator > -1) {
|
||||
const baseModelID = modelID.slice(0, separator)
|
||||
const variant = modelID.slice(separator + 1)
|
||||
if (provider.models[baseModelID]?.variants?.[variant]) {
|
||||
return { model: { providerID: provider.id, modelID: baseModelID }, variant }
|
||||
}
|
||||
}
|
||||
|
||||
return { model: { providerID: provider.id, modelID } }
|
||||
}
|
||||
|
||||
const separator = modelId.indexOf("/")
|
||||
if (separator === -1) {
|
||||
return { model: { providerID: modelId, modelID: "" } }
|
||||
}
|
||||
|
||||
return {
|
||||
model: {
|
||||
providerID: modelId.slice(0, separator),
|
||||
modelID: modelId.slice(separator + 1),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export function formatCurrentModelId(input: {
|
||||
model: ModelSelection["model"]
|
||||
variant?: string
|
||||
variants?: readonly string[]
|
||||
includeVariant?: boolean
|
||||
}) {
|
||||
const base = `${input.model.providerID}/${input.model.modelID}`
|
||||
if (!input.includeVariant || !input.variants?.length) return base
|
||||
return `${base}/${selectVariant(input.variant, input.variants)}`
|
||||
}
|
||||
|
||||
export function formatVariantName(variant: string) {
|
||||
return variant
|
||||
.split(/[_-]/)
|
||||
.map((part) => (part ? part.charAt(0).toUpperCase() + part.slice(1) : part))
|
||||
.join(" ")
|
||||
}
|
||||
|
||||
function buildModelSelectOptions(
|
||||
providers: readonly ConfigOptionProvider[],
|
||||
options: { includeVariants: boolean },
|
||||
): Array<{ value: string; name: string }> {
|
||||
return providers.flatMap((provider) =>
|
||||
Object.values(provider.models)
|
||||
.sort((a, b) => a.name.localeCompare(b.name))
|
||||
.flatMap((model) => {
|
||||
const base = {
|
||||
value: `${provider.id}/${model.id}`,
|
||||
name: `${provider.name}/${model.name}`,
|
||||
}
|
||||
if (!options.includeVariants || !model.variants) return [base]
|
||||
|
||||
return [
|
||||
base,
|
||||
...Object.keys(model.variants)
|
||||
.filter((variant) => variant !== DEFAULT_VARIANT_VALUE)
|
||||
.map((variant) => ({
|
||||
value: `${provider.id}/${model.id}/${variant}`,
|
||||
name: `${provider.name}/${model.name} (${formatVariantName(variant)})`,
|
||||
})),
|
||||
]
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
function variantsForModel(providers: readonly ConfigOptionProvider[], model: ModelSelection["model"]) {
|
||||
return Object.keys(
|
||||
providers.find((provider) => provider.id === model.providerID)?.models[model.modelID]?.variants ?? {},
|
||||
)
|
||||
}
|
||||
|
||||
function selectVariant(variant: string | undefined, variants: readonly string[]) {
|
||||
if (variant && variants.includes(variant)) return variant
|
||||
if (variants.includes(DEFAULT_VARIANT_VALUE)) return DEFAULT_VARIANT_VALUE
|
||||
return variants[0]
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
import type { ContentBlock, ContentChunk, ResourceLink, Role } from "@agentclientprotocol/sdk"
|
||||
import path from "node:path"
|
||||
import { pathToFileURL } from "node:url"
|
||||
import { SessionLegacy } from "@opencode-ai/core/session/legacy"
|
||||
|
||||
export type PromptPart = SessionLegacy.TextPartInput | SessionLegacy.FilePartInput
|
||||
|
||||
export type ReplayPart =
|
||||
| {
|
||||
type: "text"
|
||||
text: string
|
||||
synthetic?: boolean
|
||||
ignored?: boolean
|
||||
}
|
||||
| {
|
||||
type: "file"
|
||||
url: string
|
||||
mime: string
|
||||
filename?: string
|
||||
}
|
||||
| {
|
||||
type: "reasoning"
|
||||
text: string
|
||||
}
|
||||
|
||||
export function promptContentToParts(content: readonly ContentBlock[]): PromptPart[] {
|
||||
return content.flatMap(contentBlockToParts)
|
||||
}
|
||||
|
||||
export function contentBlockToParts(block: ContentBlock): PromptPart[] {
|
||||
switch (block.type) {
|
||||
case "text":
|
||||
return [
|
||||
{
|
||||
type: "text",
|
||||
text: block.text,
|
||||
...audienceFlags(block.annotations?.audience ?? undefined),
|
||||
},
|
||||
]
|
||||
|
||||
case "image":
|
||||
if (block.data) {
|
||||
return [
|
||||
{
|
||||
type: "file",
|
||||
url: `data:${block.mimeType};base64,${block.data}`,
|
||||
filename: filenameFromUri(block.uri ?? undefined) ?? "image",
|
||||
mime: block.mimeType,
|
||||
},
|
||||
]
|
||||
}
|
||||
if (block.uri?.startsWith("data:")) {
|
||||
return [
|
||||
{
|
||||
type: "file",
|
||||
url: block.uri,
|
||||
filename: filenameFromUri(block.uri) ?? "image",
|
||||
mime: block.mimeType,
|
||||
},
|
||||
]
|
||||
}
|
||||
if (block.uri?.startsWith("http://") || block.uri?.startsWith("https://")) {
|
||||
return [
|
||||
{
|
||||
type: "file",
|
||||
url: block.uri,
|
||||
filename: filenameFromUri(block.uri) ?? "image",
|
||||
mime: block.mimeType,
|
||||
},
|
||||
]
|
||||
}
|
||||
return []
|
||||
|
||||
case "resource_link":
|
||||
return [resourceLinkToPart(block)]
|
||||
|
||||
case "resource":
|
||||
if ("text" in block.resource) {
|
||||
return [{ type: "text", text: block.resource.text }]
|
||||
}
|
||||
if (block.resource.mimeType) {
|
||||
return [
|
||||
{
|
||||
type: "file",
|
||||
url: block.resource.uri.startsWith("data:")
|
||||
? block.resource.uri
|
||||
: `data:${block.resource.mimeType};base64,${block.resource.blob}`,
|
||||
filename: filenameFromUri(block.resource.uri) ?? "file",
|
||||
mime: block.resource.mimeType,
|
||||
},
|
||||
]
|
||||
}
|
||||
return []
|
||||
|
||||
default:
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
export function partsToContentChunks(parts: readonly ReplayPart[]): ContentChunk[] {
|
||||
return parts.flatMap(partToContentChunks)
|
||||
}
|
||||
|
||||
export function partToContentChunks(part: ReplayPart): ContentChunk[] {
|
||||
switch (part.type) {
|
||||
case "text":
|
||||
if (!part.text) return []
|
||||
return [
|
||||
{
|
||||
content: {
|
||||
type: "text",
|
||||
text: part.text,
|
||||
...partAudience(part),
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
case "file":
|
||||
return filePartToContentChunks(part)
|
||||
|
||||
case "reasoning":
|
||||
if (!part.text) return []
|
||||
return [
|
||||
{
|
||||
content: {
|
||||
type: "text",
|
||||
text: part.text,
|
||||
},
|
||||
},
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
function resourceLinkToPart(link: ResourceLink): PromptPart {
|
||||
const parsed = uriToFilePart(link.uri, link.mimeType ?? "text/plain", link.name)
|
||||
if (parsed.type === "file") return parsed
|
||||
return { type: "text", text: parsed.text }
|
||||
}
|
||||
|
||||
function uriToFilePart(
|
||||
uri: string,
|
||||
mime: string,
|
||||
filename?: string,
|
||||
): SessionLegacy.FilePartInput | SessionLegacy.TextPartInput {
|
||||
try {
|
||||
if (uri.startsWith("file://")) {
|
||||
return {
|
||||
type: "file",
|
||||
url: uri,
|
||||
filename: filename ?? filenameFromUri(uri) ?? "file",
|
||||
mime,
|
||||
}
|
||||
}
|
||||
if (uri.startsWith("zed://")) {
|
||||
const pathname = new URL(uri).searchParams.get("path")
|
||||
if (pathname) {
|
||||
return {
|
||||
type: "file",
|
||||
url: pathToFileURL(pathname).href,
|
||||
filename: filename ?? (path.basename(pathname) || "file"),
|
||||
mime,
|
||||
}
|
||||
}
|
||||
}
|
||||
return { type: "text", text: uri }
|
||||
} catch {
|
||||
return { type: "text", text: uri }
|
||||
}
|
||||
}
|
||||
|
||||
function filePartToContentChunks(part: Extract<ReplayPart, { type: "file" }>): ContentChunk[] {
|
||||
if (part.url.startsWith("file://")) {
|
||||
return [
|
||||
{
|
||||
content: {
|
||||
type: "resource_link",
|
||||
uri: part.url,
|
||||
name: part.filename ?? "file",
|
||||
mimeType: part.mime,
|
||||
},
|
||||
},
|
||||
]
|
||||
}
|
||||
if (!part.url.startsWith("data:")) return []
|
||||
|
||||
const data = decodeDataUrl(part.url)
|
||||
if (!data) return []
|
||||
if (data.mime.startsWith("image/")) {
|
||||
return [
|
||||
{
|
||||
content: {
|
||||
type: "image",
|
||||
mimeType: data.mime,
|
||||
data: data.base64,
|
||||
uri: pathToFileURL(part.filename ?? "image").href,
|
||||
},
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
content: {
|
||||
type: "resource",
|
||||
resource:
|
||||
data.mime.startsWith("text/") || data.mime === "application/json"
|
||||
? {
|
||||
uri: pathToFileURL(part.filename ?? "file").href,
|
||||
mimeType: data.mime,
|
||||
text: Buffer.from(data.base64, "base64").toString("utf8"),
|
||||
}
|
||||
: {
|
||||
uri: pathToFileURL(part.filename ?? "file").href,
|
||||
mimeType: data.mime,
|
||||
blob: data.base64,
|
||||
},
|
||||
},
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
function decodeDataUrl(url: string) {
|
||||
const match = /^data:([^;]+);base64,(.*)$/.exec(url)
|
||||
if (!match) return
|
||||
return { mime: match[1], base64: match[2] }
|
||||
}
|
||||
|
||||
function audienceFlags(audience: readonly Role[] | null | undefined) {
|
||||
if (audience?.length === 1 && audience[0] === "assistant") return { synthetic: true }
|
||||
if (audience?.length === 1 && audience[0] === "user") return { ignored: true }
|
||||
return {}
|
||||
}
|
||||
|
||||
function partAudience(part: Extract<ReplayPart, { type: "text" }>) {
|
||||
const audience: Role[] | undefined = part.synthetic ? ["assistant"] : part.ignored ? ["user"] : undefined
|
||||
if (!audience) return {}
|
||||
return { annotations: { audience } }
|
||||
}
|
||||
|
||||
function filenameFromUri(uri: string | undefined) {
|
||||
if (!uri) return
|
||||
if (uri.startsWith("data:")) return
|
||||
try {
|
||||
const parsed = new URL(uri)
|
||||
const name = path.basename(parsed.pathname)
|
||||
return name || undefined
|
||||
} catch {
|
||||
return path.basename(uri) || undefined
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
import { Agent } from "@/agent/agent"
|
||||
import { Command } from "@/command"
|
||||
import { InstanceRef } from "@/effect/instance-ref"
|
||||
import { InstanceStore } from "@/project/instance-store"
|
||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
import { Provider } from "@/provider/provider"
|
||||
import { Context, Effect, Layer, SynchronizedRef } from "effect"
|
||||
import type * as ACPNextError from "./error"
|
||||
|
||||
export type ModelOption = {
|
||||
readonly providerID: ProviderV2.ID
|
||||
readonly providerName: string
|
||||
readonly modelID: ProviderV2.ModelID
|
||||
readonly modelName: string
|
||||
}
|
||||
|
||||
export type ModeOption = {
|
||||
readonly id: string
|
||||
readonly name: string
|
||||
readonly description?: string
|
||||
}
|
||||
|
||||
export type ModelVariants = NonNullable<Provider.Model["variants"]>
|
||||
|
||||
export type DefaultModel = {
|
||||
readonly providerID: ProviderV2.ID
|
||||
readonly modelID: ProviderV2.ModelID
|
||||
}
|
||||
|
||||
export type Snapshot = {
|
||||
readonly directory: string
|
||||
readonly providers: Record<ProviderV2.ID, Provider.Info>
|
||||
readonly modelOptions: readonly ModelOption[]
|
||||
readonly variantsByModel: Readonly<Record<string, ModelVariants>>
|
||||
readonly availableModes: readonly ModeOption[]
|
||||
readonly defaultModeID: string
|
||||
readonly availableCommands: readonly Command.Info[]
|
||||
readonly defaultModel?: DefaultModel
|
||||
}
|
||||
|
||||
export interface LoaderInterface {
|
||||
readonly load: (directory: string) => Effect.Effect<Snapshot, ACPNextError.Error>
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
readonly get: (directory: string) => Effect.Effect<Snapshot, ACPNextError.Error>
|
||||
readonly refresh: (directory: string) => Effect.Effect<Snapshot, ACPNextError.Error>
|
||||
readonly variants: (snapshot: Snapshot, model: DefaultModel) => ModelVariants | undefined
|
||||
}
|
||||
|
||||
export class Loader extends Context.Service<Loader, LoaderInterface>()("@opencode/ACPNextDirectoryLoader") {}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/ACPNextDirectory") {}
|
||||
|
||||
export const modelKey = (model: DefaultModel) => `${model.providerID}/${model.modelID}`
|
||||
|
||||
export const variants = (snapshot: Snapshot, model: DefaultModel) => snapshot.variantsByModel[modelKey(model)]
|
||||
|
||||
export const build = (input: {
|
||||
readonly directory: string
|
||||
readonly providers: Record<ProviderV2.ID, Provider.Info>
|
||||
readonly modes: readonly ModeOption[]
|
||||
readonly defaultModeID: string
|
||||
readonly commands: readonly Command.Info[]
|
||||
readonly defaultModel?: DefaultModel
|
||||
}): Snapshot => {
|
||||
const modelOptions = Provider.sort(
|
||||
Object.values(input.providers).flatMap((provider) =>
|
||||
Object.values(provider.models).map((model) => ({
|
||||
id: model.id,
|
||||
providerID: provider.id,
|
||||
providerName: provider.name,
|
||||
modelID: model.id,
|
||||
modelName: model.name,
|
||||
})),
|
||||
),
|
||||
).map((model) => ({
|
||||
providerID: model.providerID,
|
||||
providerName: model.providerName,
|
||||
modelID: model.modelID,
|
||||
modelName: model.modelName,
|
||||
}))
|
||||
|
||||
return {
|
||||
directory: input.directory,
|
||||
providers: input.providers,
|
||||
modelOptions,
|
||||
variantsByModel: Object.fromEntries(
|
||||
Object.values(input.providers).flatMap((provider) =>
|
||||
Object.values(provider.models).flatMap((model) =>
|
||||
model.variants ? [[modelKey({ providerID: provider.id, modelID: model.id }), model.variants]] : [],
|
||||
),
|
||||
),
|
||||
),
|
||||
availableModes: input.modes,
|
||||
defaultModeID: input.modes.some((mode) => mode.id === input.defaultModeID)
|
||||
? input.defaultModeID
|
||||
: (input.modes[0]?.id ?? input.defaultModeID),
|
||||
availableCommands: input.commands,
|
||||
...(input.defaultModel ? { defaultModel: input.defaultModel } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
export const loaderLayer = Layer.effect(
|
||||
Loader,
|
||||
Effect.gen(function* () {
|
||||
const store = yield* InstanceStore.Service
|
||||
const provider = yield* Provider.Service
|
||||
const agent = yield* Agent.Service
|
||||
const command = yield* Command.Service
|
||||
|
||||
return Loader.of({
|
||||
load: Effect.fn("ACPNextDirectoryLoader.load")(function* (directory) {
|
||||
const ctx = yield* store.load({ directory })
|
||||
return yield* Effect.gen(function* () {
|
||||
const providers = yield* provider.list()
|
||||
const [agents, defaultAgent, commands, defaultModel] = yield* Effect.all(
|
||||
[agent.list(), agent.defaultInfo(), command.list(), provider.defaultModel().pipe(Effect.option)],
|
||||
{ concurrency: "unbounded" },
|
||||
)
|
||||
return build({
|
||||
directory,
|
||||
providers,
|
||||
modes: agents
|
||||
.filter((item) => item.mode !== "subagent" && item.hidden !== true)
|
||||
.map((item) => ({
|
||||
id: item.name,
|
||||
name: item.name,
|
||||
...(item.description ? { description: item.description } : {}),
|
||||
})),
|
||||
defaultModeID: defaultAgent.name,
|
||||
commands: commands.toSorted((a, b) => a.name.localeCompare(b.name)),
|
||||
...(defaultModel._tag === "Some" ? { defaultModel: defaultModel.value } : {}),
|
||||
})
|
||||
}).pipe(Effect.provideService(InstanceRef, ctx))
|
||||
}),
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const loader = yield* Loader
|
||||
const snapshots = yield* SynchronizedRef.make(new Map<string, Effect.Effect<Snapshot, ACPNextError.Error>>())
|
||||
|
||||
const cached = Effect.fnUntraced(function* (directory: string) {
|
||||
return yield* SynchronizedRef.modifyEffect(
|
||||
snapshots,
|
||||
Effect.fnUntraced(function* (items) {
|
||||
const current = items.get(directory)
|
||||
if (current) return [current, items] as const
|
||||
const next = yield* Effect.cached(
|
||||
loader.load(directory).pipe(
|
||||
Effect.tapError(() =>
|
||||
SynchronizedRef.update(snapshots, (state) => {
|
||||
const next = new Map(state)
|
||||
next.delete(directory)
|
||||
return next
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
return [next, new Map(items).set(directory, next)] as const
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
const get = Effect.fn("ACPNextDirectory.get")(function* (directory: string) {
|
||||
return yield* yield* cached(directory)
|
||||
})
|
||||
|
||||
const refresh = Effect.fn("ACPNextDirectory.refresh")(function* (directory: string) {
|
||||
return yield* SynchronizedRef.modifyEffect(
|
||||
snapshots,
|
||||
Effect.fnUntraced(function* (items) {
|
||||
const next = yield* Effect.cached(
|
||||
loader.load(directory).pipe(
|
||||
Effect.tapError(() =>
|
||||
SynchronizedRef.update(snapshots, (state) => {
|
||||
const next = new Map(state)
|
||||
next.delete(directory)
|
||||
return next
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
return [next, new Map(items).set(directory, next)] as const
|
||||
}),
|
||||
).pipe(Effect.flatten)
|
||||
})
|
||||
|
||||
return Service.of({
|
||||
get,
|
||||
refresh,
|
||||
variants,
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
export const defaultLayer = layer.pipe(
|
||||
Layer.provide(loaderLayer),
|
||||
Layer.provide(Provider.defaultLayer),
|
||||
Layer.provide(Agent.defaultLayer),
|
||||
Layer.provide(Command.defaultLayer),
|
||||
Layer.provide(InstanceStore.defaultLayer),
|
||||
)
|
||||
|
||||
export * as Directory from "./directory"
|
||||
@@ -0,0 +1,93 @@
|
||||
import { RequestError } from "@agentclientprotocol/sdk"
|
||||
import { Schema } from "effect"
|
||||
|
||||
export class SessionNotFoundError extends Schema.TaggedErrorClass<SessionNotFoundError>()(
|
||||
"ACPNextSessionNotFoundError",
|
||||
{
|
||||
sessionId: Schema.String,
|
||||
},
|
||||
) {}
|
||||
|
||||
export class InvalidConfigOptionError extends Schema.TaggedErrorClass<InvalidConfigOptionError>()(
|
||||
"ACPNextInvalidConfigOptionError",
|
||||
{
|
||||
configId: Schema.String,
|
||||
},
|
||||
) {}
|
||||
|
||||
export class InvalidModelError extends Schema.TaggedErrorClass<InvalidModelError>()("ACPNextInvalidModelError", {
|
||||
modelId: Schema.String,
|
||||
providerId: Schema.optional(Schema.String),
|
||||
}) {}
|
||||
|
||||
export class InvalidEffortError extends Schema.TaggedErrorClass<InvalidEffortError>()("ACPNextInvalidEffortError", {
|
||||
effort: Schema.String,
|
||||
}) {}
|
||||
|
||||
export class InvalidModeError extends Schema.TaggedErrorClass<InvalidModeError>()("ACPNextInvalidModeError", {
|
||||
mode: Schema.String,
|
||||
}) {}
|
||||
|
||||
export class AuthRequiredError extends Schema.TaggedErrorClass<AuthRequiredError>()("ACPNextAuthRequiredError", {
|
||||
providerId: Schema.optional(Schema.String),
|
||||
}) {}
|
||||
|
||||
export class UnknownAuthMethodError extends Schema.TaggedErrorClass<UnknownAuthMethodError>()(
|
||||
"ACPNextUnknownAuthMethodError",
|
||||
{
|
||||
methodId: Schema.String,
|
||||
},
|
||||
) {}
|
||||
|
||||
export class UnsupportedOperationError extends Schema.TaggedErrorClass<UnsupportedOperationError>()(
|
||||
"ACPNextUnsupportedOperationError",
|
||||
{
|
||||
method: Schema.String,
|
||||
},
|
||||
) {}
|
||||
|
||||
export class ServiceFailureError extends Schema.TaggedErrorClass<ServiceFailureError>()("ACPNextServiceFailureError", {
|
||||
safeMessage: Schema.String,
|
||||
service: Schema.optional(Schema.String),
|
||||
}) {}
|
||||
|
||||
export type Error =
|
||||
| SessionNotFoundError
|
||||
| InvalidConfigOptionError
|
||||
| InvalidModelError
|
||||
| InvalidEffortError
|
||||
| InvalidModeError
|
||||
| AuthRequiredError
|
||||
| UnknownAuthMethodError
|
||||
| UnsupportedOperationError
|
||||
| ServiceFailureError
|
||||
|
||||
export function toRequestError(error: Error) {
|
||||
switch (error._tag) {
|
||||
case "ACPNextSessionNotFoundError":
|
||||
return RequestError.invalidParams({ sessionId: error.sessionId }, `session not found: ${error.sessionId}`)
|
||||
case "ACPNextInvalidConfigOptionError":
|
||||
return RequestError.invalidParams({ configId: error.configId }, `unknown config option: ${error.configId}`)
|
||||
case "ACPNextInvalidModelError":
|
||||
return RequestError.invalidParams(
|
||||
{ providerId: error.providerId, modelId: error.modelId },
|
||||
`model not found: ${error.modelId}`,
|
||||
)
|
||||
case "ACPNextInvalidEffortError":
|
||||
return RequestError.invalidParams({ effort: error.effort }, `effort not found: ${error.effort}`)
|
||||
case "ACPNextInvalidModeError":
|
||||
return RequestError.invalidParams({ mode: error.mode }, `mode not found: ${error.mode}`)
|
||||
case "ACPNextAuthRequiredError":
|
||||
return RequestError.authRequired({ providerId: error.providerId }, "provider authentication required")
|
||||
case "ACPNextUnknownAuthMethodError":
|
||||
return RequestError.invalidParams({ methodId: error.methodId }, `unknown auth method: ${error.methodId}`)
|
||||
case "ACPNextUnsupportedOperationError":
|
||||
return RequestError.methodNotFound(error.method)
|
||||
case "ACPNextServiceFailureError":
|
||||
return RequestError.internalError({ service: error.service }, error.safeMessage)
|
||||
}
|
||||
}
|
||||
|
||||
export function fromUnknownDefect(_defect: unknown, safeMessage = "Internal service failure") {
|
||||
return new ServiceFailureError({ safeMessage })
|
||||
}
|
||||
@@ -0,0 +1,654 @@
|
||||
import {
|
||||
type AgentSideConnection,
|
||||
type AuthenticateRequest,
|
||||
type AuthenticateResponse,
|
||||
type AuthMethod,
|
||||
type CancelNotification,
|
||||
type InitializeRequest,
|
||||
type InitializeResponse,
|
||||
type LoadSessionRequest,
|
||||
type LoadSessionResponse,
|
||||
type McpServer,
|
||||
type NewSessionRequest,
|
||||
type NewSessionResponse,
|
||||
type PromptRequest,
|
||||
type PromptResponse,
|
||||
type SetSessionConfigOptionRequest,
|
||||
type SetSessionConfigOptionResponse,
|
||||
type SetSessionModelRequest,
|
||||
type SetSessionModelResponse,
|
||||
type SetSessionModeRequest,
|
||||
type SetSessionModeResponse,
|
||||
} from "@agentclientprotocol/sdk"
|
||||
import { InstallationVersion } from "@opencode-ai/core/installation/version"
|
||||
import type { OpencodeClient } from "@opencode-ai/sdk/v2"
|
||||
import { Context, Effect, Layer, ManagedRuntime } from "effect"
|
||||
import * as ACPNextError from "./error"
|
||||
import { buildConfigOptions, parseModelSelection } from "./config-option"
|
||||
import { Directory } from "./directory"
|
||||
import { ACPNextSession } from "./session"
|
||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
import { Provider } from "@/provider/provider"
|
||||
import type { Command } from "@/command"
|
||||
|
||||
|
||||
export const AuthMethodID = "opencode-login"
|
||||
|
||||
export type Error = ACPNextError.Error
|
||||
|
||||
export type Interface = {
|
||||
readonly initialize: (input: InitializeRequest) => Effect.Effect<InitializeResponse, Error>
|
||||
readonly authenticate: (input: AuthenticateRequest) => Effect.Effect<AuthenticateResponse, Error>
|
||||
readonly newSession: (input: NewSessionRequest) => Effect.Effect<NewSessionResponse, Error>
|
||||
readonly loadSession: (input: LoadSessionRequest) => Effect.Effect<LoadSessionResponse, Error>
|
||||
readonly setSessionConfigOption: (
|
||||
input: SetSessionConfigOptionRequest,
|
||||
) => Effect.Effect<SetSessionConfigOptionResponse, Error>
|
||||
readonly setSessionMode: (input: SetSessionModeRequest) => Effect.Effect<SetSessionModeResponse, Error>
|
||||
readonly setSessionModel: (input: SetSessionModelRequest) => Effect.Effect<SetSessionModelResponse, Error>
|
||||
readonly prompt: (input: PromptRequest) => Effect.Effect<PromptResponse, Error>
|
||||
readonly cancel: (input: CancelNotification) => Effect.Effect<void, Error>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/ACPNext/Service") {}
|
||||
|
||||
export function make(input: {
|
||||
sdk: OpencodeClient
|
||||
connection?: Pick<AgentSideConnection, "sessionUpdate">
|
||||
directory?: Directory.Interface
|
||||
session?: ACPNextSession.Interface
|
||||
}): Interface {
|
||||
const session = input.session ?? makeSessionService()
|
||||
const directoryService = input.directory ?? makeDirectoryService(input.sdk)
|
||||
const registeredMcp = new Map<string, Set<string>>()
|
||||
|
||||
const initialize = Effect.fn("ACPNext.initialize")(function* (params: InitializeRequest) {
|
||||
const authMethod: AuthMethod = {
|
||||
description: "Run `opencode auth login` in the terminal",
|
||||
name: "Login with opencode",
|
||||
id: AuthMethodID,
|
||||
}
|
||||
|
||||
if (params.clientCapabilities?._meta?.["terminal-auth"] === true) {
|
||||
authMethod._meta = {
|
||||
"terminal-auth": {
|
||||
command: "opencode",
|
||||
args: ["auth", "login"],
|
||||
label: "OpenCode Login",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
protocolVersion: 1,
|
||||
agentCapabilities: {
|
||||
loadSession: true,
|
||||
mcpCapabilities: {
|
||||
http: true,
|
||||
sse: true,
|
||||
},
|
||||
promptCapabilities: {
|
||||
embeddedContext: true,
|
||||
image: true,
|
||||
},
|
||||
},
|
||||
authMethods: [authMethod],
|
||||
agentInfo: {
|
||||
name: "OpenCode",
|
||||
version: InstallationVersion,
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
const authenticate = Effect.fn("ACPNext.authenticate")(function* (params: AuthenticateRequest) {
|
||||
if (params.methodId !== AuthMethodID) {
|
||||
return yield* new ACPNextError.UnknownAuthMethodError({ methodId: params.methodId })
|
||||
}
|
||||
return {}
|
||||
})
|
||||
|
||||
const directorySnapshot = Effect.fn("ACPNext.directorySnapshot")(function* (cwd: string) {
|
||||
return yield* directoryService.get(cwd)
|
||||
})
|
||||
|
||||
const newSession = Effect.fn("ACPNext.newSession")(function* (params: NewSessionRequest) {
|
||||
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(
|
||||
() =>
|
||||
input.sdk.session.create(
|
||||
{
|
||||
directory: params.cwd,
|
||||
...(modeId ? { agent: modeId } : {}),
|
||||
model: {
|
||||
providerID: selected.providerID,
|
||||
id: selected.modelID,
|
||||
...(variant ? { variant } : {}),
|
||||
},
|
||||
},
|
||||
{ throwOnError: true },
|
||||
),
|
||||
"session",
|
||||
)
|
||||
const state = yield* session.create({
|
||||
id: created.id,
|
||||
cwd: params.cwd,
|
||||
mcpServers: params.mcpServers,
|
||||
model: selected,
|
||||
variant,
|
||||
modeId,
|
||||
})
|
||||
|
||||
yield* registerMcpServers(input.sdk, registeredMcp, params.cwd, state.id, params.mcpServers)
|
||||
yield* sendAvailableCommands(input.connection, state.id, snapshot)
|
||||
|
||||
return {
|
||||
sessionId: state.id,
|
||||
configOptions: configOptions(snapshot, {
|
||||
model: state.model ?? selected,
|
||||
variant: state.variant,
|
||||
modeId: state.modeId,
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
const loadSession = Effect.fn("ACPNext.loadSession")(function* (params: LoadSessionRequest) {
|
||||
const snapshot = yield* directorySnapshot(params.cwd)
|
||||
yield* request(
|
||||
() => input.sdk.session.get({ directory: params.cwd, sessionID: params.sessionId }, { throwOnError: true }),
|
||||
"session",
|
||||
)
|
||||
const messages = yield* request(
|
||||
() =>
|
||||
input.sdk.session.messages(
|
||||
{ directory: params.cwd, sessionID: params.sessionId, limit: 100 },
|
||||
{ throwOnError: true },
|
||||
),
|
||||
"session",
|
||||
)
|
||||
const restored = restoreFromMessages(messages.map((item) => item.info))
|
||||
const model = restored.model ?? selectDefaultModel(snapshot)
|
||||
const state = yield* session.load({
|
||||
id: params.sessionId,
|
||||
cwd: params.cwd,
|
||||
mcpServers: params.mcpServers,
|
||||
model,
|
||||
variant: restored.variant ?? selectVariant(snapshot, model),
|
||||
modeId: restored.modeId ?? (snapshot.availableModes.length > 0 ? snapshot.defaultModeID : undefined),
|
||||
})
|
||||
|
||||
yield* registerMcpServers(input.sdk, registeredMcp, params.cwd, state.id, params.mcpServers)
|
||||
yield* sendAvailableCommands(input.connection, state.id, snapshot)
|
||||
|
||||
return {
|
||||
sessionId: state.id,
|
||||
configOptions: configOptions(snapshot, {
|
||||
model: state.model ?? model,
|
||||
variant: state.variant,
|
||||
modeId: state.modeId,
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
const setSessionConfigOption = Effect.fn("ACPNext.setSessionConfigOption")(function* (
|
||||
params: SetSessionConfigOptionRequest,
|
||||
) {
|
||||
const current = yield* session.get(params.sessionId)
|
||||
const snapshot = yield* directorySnapshot(current.cwd)
|
||||
if (typeof params.value !== "string") {
|
||||
return yield* new ACPNextError.InvalidConfigOptionError({ configId: params.configId })
|
||||
}
|
||||
|
||||
if (params.configId === "model") {
|
||||
const selected = yield* parseSelectedModel(snapshot, params.value)
|
||||
const variant = selected.variant ?? selectVariant(snapshot, selected.model)
|
||||
const state = yield* session
|
||||
.setVariant(params.sessionId, Directory.variants(snapshot, selected.model) ? variant : undefined)
|
||||
.pipe(Effect.andThen(session.setModel(params.sessionId, selected.model)))
|
||||
return {
|
||||
configOptions: configOptions(snapshot, {
|
||||
model: state.model ?? selected.model,
|
||||
variant: state.variant,
|
||||
modeId: state.modeId,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
if (params.configId === "effort") {
|
||||
const model = current.model ?? selectDefaultModel(snapshot)
|
||||
const variants = Directory.variants(snapshot, model)
|
||||
if (!variants || !Object.keys(variants).includes(params.value)) {
|
||||
return yield* new ACPNextError.InvalidEffortError({ effort: params.value })
|
||||
}
|
||||
const state = yield* session.setVariant(params.sessionId, params.value)
|
||||
return {
|
||||
configOptions: configOptions(snapshot, {
|
||||
model: state.model ?? model,
|
||||
variant: state.variant,
|
||||
modeId: state.modeId,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
if (params.configId === "mode") {
|
||||
if (!snapshot.availableModes.some((mode) => mode.id === params.value)) {
|
||||
return yield* new ACPNextError.InvalidModeError({ mode: params.value })
|
||||
}
|
||||
const state = yield* session.setMode(params.sessionId, params.value)
|
||||
return {
|
||||
configOptions: configOptions(snapshot, {
|
||||
model: state.model ?? selectDefaultModel(snapshot),
|
||||
variant: state.variant,
|
||||
modeId: state.modeId,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
return yield* new ACPNextError.InvalidConfigOptionError({ configId: params.configId })
|
||||
})
|
||||
|
||||
const setSessionMode = Effect.fn("ACPNext.setSessionMode")(function* (params: SetSessionModeRequest) {
|
||||
const current = yield* session.get(params.sessionId)
|
||||
const snapshot = yield* directorySnapshot(current.cwd)
|
||||
if (!snapshot.availableModes.some((mode) => mode.id === params.modeId)) {
|
||||
return yield* new ACPNextError.InvalidModeError({ mode: params.modeId })
|
||||
}
|
||||
yield* session.setMode(params.sessionId, params.modeId)
|
||||
return {}
|
||||
})
|
||||
|
||||
const setSessionModel = Effect.fn("ACPNext.setSessionModel")(function* (params: SetSessionModelRequest) {
|
||||
const current = yield* session.get(params.sessionId)
|
||||
const snapshot = yield* directorySnapshot(current.cwd)
|
||||
const selected = yield* parseSelectedModel(snapshot, params.modelId)
|
||||
yield* session
|
||||
.setVariant(
|
||||
params.sessionId,
|
||||
Directory.variants(snapshot, selected.model)
|
||||
? (selected.variant ?? selectVariant(snapshot, selected.model))
|
||||
: undefined,
|
||||
)
|
||||
.pipe(Effect.andThen(session.setModel(params.sessionId, selected.model)))
|
||||
return {}
|
||||
})
|
||||
|
||||
return {
|
||||
initialize,
|
||||
authenticate,
|
||||
newSession,
|
||||
loadSession,
|
||||
setSessionConfigOption,
|
||||
setSessionMode,
|
||||
setSessionModel,
|
||||
prompt: Effect.fn("ACPNext.prompt")(function* (_input: PromptRequest) {
|
||||
return yield* new ACPNextError.UnsupportedOperationError({ method: "session/prompt" })
|
||||
}),
|
||||
cancel: Effect.fn("ACPNext.cancel")(function* (_input: CancelNotification) {
|
||||
return yield* new ACPNextError.UnsupportedOperationError({ method: "session/cancel" })
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
function makeSessionService() {
|
||||
return ManagedRuntime.make(ACPNextSession.defaultLayer).runSync(
|
||||
ACPNextSession.Service.use((service) => Effect.succeed(service)),
|
||||
)
|
||||
}
|
||||
|
||||
function makeDirectoryService(sdk: OpencodeClient) {
|
||||
return ManagedRuntime.make(
|
||||
Directory.layer.pipe(
|
||||
Layer.provide(
|
||||
Layer.succeed(
|
||||
Directory.Loader,
|
||||
Directory.Loader.of({
|
||||
load: (directory) => request(() => loadDirectorySnapshot(sdk, directory), "directory"),
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
).runSync(Directory.Service.use((service) => Effect.succeed(service)))
|
||||
}
|
||||
|
||||
type ConfigState = {
|
||||
readonly model: Directory.DefaultModel
|
||||
readonly variant?: string
|
||||
readonly modeId?: string
|
||||
}
|
||||
|
||||
type SdkResponse<T> = {
|
||||
readonly data?: T
|
||||
readonly error?: unknown
|
||||
}
|
||||
|
||||
type MessageInfo = {
|
||||
readonly role?: string
|
||||
readonly model?: {
|
||||
readonly providerID?: string
|
||||
readonly modelID?: string
|
||||
readonly variant?: string
|
||||
}
|
||||
readonly providerID?: string
|
||||
readonly modelID?: string
|
||||
readonly variant?: string
|
||||
readonly mode?: string
|
||||
readonly agent?: string
|
||||
}
|
||||
|
||||
function request<T>(fn: () => Promise<T | SdkResponse<T>>, service?: string) {
|
||||
return Effect.tryPromise({
|
||||
try: async () => {
|
||||
const result = await fn()
|
||||
if (isSdkResponse<T>(result)) {
|
||||
if (result.error) throw result.error
|
||||
if (result.data !== undefined) return result.data
|
||||
}
|
||||
return result as T
|
||||
},
|
||||
catch: (error) => fromUnknownError(error, service),
|
||||
})
|
||||
}
|
||||
|
||||
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<
|
||||
ProviderV2.ID,
|
||||
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[]
|
||||
|
||||
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,
|
||||
providers: Record<ProviderV2.ID, 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)
|
||||
if (configured && providers[configured.providerID]?.models[configured.modelID]) return configured
|
||||
|
||||
const lastUsed = await lastUsedModel(sdk, directory, providers)
|
||||
if (lastUsed) return lastUsed
|
||||
|
||||
const opencodeProvider = providers[ProviderV2.ID.make("opencode")]
|
||||
const opencodeModel = opencodeProvider ? Provider.sort(Object.values(opencodeProvider.models))[0] : undefined
|
||||
if (opencodeProvider && opencodeModel) return { providerID: opencodeProvider.id, modelID: opencodeModel.id }
|
||||
|
||||
const best = Provider.sort(Object.values(providers).flatMap((provider) => Object.values(provider.models)))[0]
|
||||
if (best) return { providerID: best.providerID, modelID: best.id }
|
||||
if (configured) return configured
|
||||
}
|
||||
|
||||
async function lastUsedModel(
|
||||
sdk: OpencodeClient,
|
||||
directory: string,
|
||||
providers: Record<ProviderV2.ID, 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[ProviderV2.ID.make(lastUser.model.providerID)]?.models[ProviderV2.ModelID.make(lastUser.model.modelID)]) return
|
||||
|
||||
return {
|
||||
providerID: ProviderV2.ID.make(lastUser.model.providerID),
|
||||
modelID: ProviderV2.ModelID.make(lastUser.model.modelID),
|
||||
}
|
||||
}
|
||||
|
||||
function selectDefaultModel(snapshot: Directory.Snapshot) {
|
||||
if (snapshot.defaultModel) return snapshot.defaultModel
|
||||
const model = snapshot.modelOptions[0]
|
||||
if (model) return { providerID: model.providerID, modelID: model.modelID }
|
||||
return { providerID: "unknown" as ProviderV2.ID, modelID: "unknown" as ProviderV2.ModelID }
|
||||
}
|
||||
|
||||
function selectVariant(snapshot: Directory.Snapshot, model: Directory.DefaultModel) {
|
||||
const variants = Directory.variants(snapshot, model)
|
||||
if (!variants) return
|
||||
if (variants.default) return "default"
|
||||
return Object.keys(variants)[0]
|
||||
}
|
||||
|
||||
function configOptions(snapshot: Directory.Snapshot, session: ConfigState) {
|
||||
return buildConfigOptions({
|
||||
providers: Object.values(snapshot.providers),
|
||||
currentModel: session.model,
|
||||
currentVariant: session.variant,
|
||||
modes: snapshot.availableModes,
|
||||
currentModeId: session.modeId,
|
||||
})
|
||||
}
|
||||
|
||||
function parseSelectedModel(snapshot: Directory.Snapshot, modelId: string) {
|
||||
const selected = parseModelSelection(modelId, Object.values(snapshot.providers))
|
||||
const provider = snapshot.providers[ProviderV2.ID.make(selected.model.providerID)]
|
||||
const model = provider?.models[ProviderV2.ModelID.make(selected.model.modelID)]
|
||||
if (!model) {
|
||||
return Effect.fail(
|
||||
new ACPNextError.InvalidModelError({
|
||||
providerId: selected.model.providerID,
|
||||
modelId,
|
||||
}),
|
||||
)
|
||||
}
|
||||
if (selected.variant && !model.variants?.[selected.variant]) {
|
||||
return Effect.fail(new ACPNextError.InvalidEffortError({ effort: selected.variant }))
|
||||
}
|
||||
return Effect.succeed({
|
||||
model: {
|
||||
providerID: provider.id,
|
||||
modelID: model.id,
|
||||
},
|
||||
variant: selected.variant,
|
||||
})
|
||||
}
|
||||
|
||||
function sendAvailableCommands(
|
||||
connection: Pick<AgentSideConnection, "sessionUpdate"> | undefined,
|
||||
sessionId: string,
|
||||
snapshot: Directory.Snapshot,
|
||||
) {
|
||||
if (!connection) return Effect.void
|
||||
return Effect.sync(() => {
|
||||
setTimeout(() => {
|
||||
void connection.sessionUpdate({
|
||||
sessionId,
|
||||
update: {
|
||||
sessionUpdate: "available_commands_update",
|
||||
availableCommands: snapshot.availableCommands.map((command) => ({
|
||||
name: command.name,
|
||||
description: command.description ?? "",
|
||||
})),
|
||||
},
|
||||
})
|
||||
}, 0)
|
||||
})
|
||||
}
|
||||
|
||||
function registerMcpServers(
|
||||
sdk: OpencodeClient,
|
||||
registered: Map<string, Set<string>>,
|
||||
directory: string,
|
||||
sessionId: string,
|
||||
servers: readonly McpServer[],
|
||||
) {
|
||||
const current = registered.get(sessionId) ?? new Set<string>()
|
||||
registered.set(sessionId, current)
|
||||
const pending = new Set<string>()
|
||||
|
||||
return Effect.all(
|
||||
servers
|
||||
.map((server) => ({ server, config: mcpConfig(server) }))
|
||||
.filter((entry) => {
|
||||
const key = mcpRegistrationKey(entry.server.name, entry.config)
|
||||
if (current.has(key) || pending.has(key)) return false
|
||||
pending.add(key)
|
||||
return true
|
||||
})
|
||||
.map((entry) =>
|
||||
request(
|
||||
() =>
|
||||
sdk.mcp.add(
|
||||
{
|
||||
directory,
|
||||
name: entry.server.name,
|
||||
config: entry.config,
|
||||
},
|
||||
{ throwOnError: true },
|
||||
),
|
||||
"mcp",
|
||||
).pipe(
|
||||
Effect.tap(() => Effect.sync(() => current.add(mcpRegistrationKey(entry.server.name, entry.config)))),
|
||||
Effect.ignore,
|
||||
),
|
||||
),
|
||||
{ concurrency: "unbounded" },
|
||||
).pipe(Effect.asVoid)
|
||||
}
|
||||
|
||||
function mcpRegistrationKey(name: string, config: ReturnType<typeof mcpConfig>) {
|
||||
return `${name}:${stableStringify(config)}`
|
||||
}
|
||||
|
||||
function mcpConfig(server: McpServer) {
|
||||
if ("type" in server) {
|
||||
return {
|
||||
type: "remote" as const,
|
||||
url: server.url,
|
||||
headers: Object.fromEntries(server.headers.map((header) => [header.name, header.value])),
|
||||
}
|
||||
}
|
||||
return {
|
||||
type: "local" as const,
|
||||
command: [server.command, ...server.args],
|
||||
environment: Object.fromEntries(server.env.map((entry) => [entry.name, entry.value])),
|
||||
}
|
||||
}
|
||||
|
||||
function stableStringify(value: unknown): string {
|
||||
if (Array.isArray(value)) return `[${value.map(stableStringify).join(",")}]`
|
||||
if (!value || typeof value !== "object") return JSON.stringify(value)
|
||||
return `{${Object.entries(value)
|
||||
.toSorted(([a], [b]) => a.localeCompare(b))
|
||||
.map(([key, item]) => `${JSON.stringify(key)}:${stableStringify(item)}`)
|
||||
.join(",")}}`
|
||||
}
|
||||
|
||||
function restoreFromMessages(messages: readonly MessageInfo[]) {
|
||||
const user = messages.findLast(
|
||||
(message) => message.role === "user" && message.model?.providerID && message.model.modelID,
|
||||
)
|
||||
if (user?.model?.providerID && user.model.modelID) {
|
||||
return {
|
||||
model: { providerID: user.model.providerID as ProviderV2.ID, modelID: user.model.modelID as ProviderV2.ModelID },
|
||||
variant: user.model.variant,
|
||||
modeId: user.agent,
|
||||
}
|
||||
}
|
||||
|
||||
const assistant = messages.findLast((message) => message.providerID && message.modelID)
|
||||
if (assistant?.providerID && assistant.modelID) {
|
||||
return {
|
||||
model: { providerID: assistant.providerID as ProviderV2.ID, modelID: assistant.modelID as ProviderV2.ModelID },
|
||||
variant: assistant.variant,
|
||||
modeId: assistant.mode ?? assistant.agent,
|
||||
}
|
||||
}
|
||||
|
||||
return {}
|
||||
}
|
||||
|
||||
function isSdkResponse<T>(value: T | SdkResponse<T>): value is SdkResponse<T> {
|
||||
return typeof value === "object" && value !== null && ("data" in value || "error" in value)
|
||||
}
|
||||
|
||||
function fromUnknownError(error: unknown, service?: string): Error {
|
||||
if (isACPNextError(error)) return error
|
||||
if (isAuthRequired(error)) {
|
||||
return new ACPNextError.AuthRequiredError({ providerId: findProviderID(error) })
|
||||
}
|
||||
return new ACPNextError.ServiceFailureError({ safeMessage: "OpenCode service failure", service })
|
||||
}
|
||||
|
||||
function isACPNextError(error: unknown): error is Error {
|
||||
return (
|
||||
typeof error === "object" &&
|
||||
error !== null &&
|
||||
"_tag" in error &&
|
||||
typeof error._tag === "string" &&
|
||||
error._tag.startsWith("ACPNext")
|
||||
)
|
||||
}
|
||||
|
||||
function isAuthRequired(value: unknown): boolean {
|
||||
if (typeof value !== "object" || value === null) return false
|
||||
if (value instanceof Error && (value.name === "ProviderAuthError" || value.name === "LoadAPIKeyError")) return true
|
||||
if (
|
||||
value instanceof Error &&
|
||||
(value.message.includes("ProviderAuthError") || value.message.includes("LoadAPIKeyError"))
|
||||
) {
|
||||
return true
|
||||
}
|
||||
if ("name" in value && (value.name === "ProviderAuthError" || value.name === "LoadAPIKeyError")) return true
|
||||
if ("_tag" in value && (value._tag === "ProviderAuthError" || value._tag === "LoadAPIKeyError")) return true
|
||||
if ("error" in value && isAuthRequired(value.error)) return true
|
||||
if ("data" in value && isAuthRequired(value.data)) return true
|
||||
return false
|
||||
}
|
||||
|
||||
function findProviderID(value: unknown): string | undefined {
|
||||
if (typeof value !== "object" || value === null) return
|
||||
if ("providerID" in value && typeof value.providerID === "string") return value.providerID
|
||||
if ("providerId" in value && typeof value.providerId === "string") return value.providerId
|
||||
if ("data" in value) return findProviderID(value.data)
|
||||
if ("error" in value) return findProviderID(value.error)
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
import type { McpServer } from "@agentclientprotocol/sdk"
|
||||
import { Context, Effect, Layer, Ref } from "effect"
|
||||
import type { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
import * as ACPNextError from "./error"
|
||||
|
||||
|
||||
export type SelectedModel = {
|
||||
providerID: ProviderV2.ID
|
||||
modelID: ProviderV2.ModelID
|
||||
}
|
||||
|
||||
export type KnownMessagePartMetadata = {
|
||||
messageId: string
|
||||
partId: string
|
||||
toolCallId?: string
|
||||
metadata?: unknown
|
||||
}
|
||||
|
||||
export type Info = {
|
||||
id: string
|
||||
cwd: string
|
||||
mcpServers: readonly McpServer[]
|
||||
createdAt: Date
|
||||
model?: SelectedModel
|
||||
variant?: string
|
||||
modeId?: string
|
||||
knownParts: ReadonlyMap<string, KnownMessagePartMetadata>
|
||||
}
|
||||
|
||||
export type StoreInput = {
|
||||
id: string
|
||||
cwd: string
|
||||
mcpServers?: readonly McpServer[]
|
||||
createdAt?: Date
|
||||
model?: SelectedModel
|
||||
variant?: string
|
||||
modeId?: string
|
||||
}
|
||||
|
||||
export type RecordPartMetadataInput = {
|
||||
sessionId: string
|
||||
messageId: string
|
||||
partId: string
|
||||
toolCallId?: string
|
||||
metadata?: unknown
|
||||
}
|
||||
|
||||
export type PartMetadataLookupInput = {
|
||||
sessionId: string
|
||||
messageId: string
|
||||
partId: string
|
||||
}
|
||||
|
||||
export type Interface = {
|
||||
readonly create: (input: StoreInput) => Effect.Effect<Info>
|
||||
readonly load: (input: StoreInput) => Effect.Effect<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>
|
||||
readonly setModel: (
|
||||
sessionId: string,
|
||||
model: SelectedModel | undefined,
|
||||
) => Effect.Effect<Info, ACPNextError.SessionNotFoundError>
|
||||
readonly getModel: (sessionId: string) => Effect.Effect<SelectedModel | undefined, ACPNextError.SessionNotFoundError>
|
||||
readonly setVariant: (
|
||||
sessionId: string,
|
||||
variant: string | undefined,
|
||||
) => Effect.Effect<Info, ACPNextError.SessionNotFoundError>
|
||||
readonly getVariant: (sessionId: string) => Effect.Effect<string | undefined, ACPNextError.SessionNotFoundError>
|
||||
readonly setMode: (
|
||||
sessionId: string,
|
||||
modeId: string | undefined,
|
||||
) => Effect.Effect<Info, ACPNextError.SessionNotFoundError>
|
||||
readonly getMode: (sessionId: string) => Effect.Effect<string | undefined, ACPNextError.SessionNotFoundError>
|
||||
readonly recordPartMetadata: (
|
||||
input: RecordPartMetadataInput,
|
||||
) => Effect.Effect<KnownMessagePartMetadata, ACPNextError.SessionNotFoundError>
|
||||
readonly getPartMetadata: (
|
||||
input: PartMetadataLookupInput,
|
||||
) => Effect.Effect<KnownMessagePartMetadata | undefined, ACPNextError.SessionNotFoundError>
|
||||
readonly tryGetPartMetadata: (input: PartMetadataLookupInput) => Effect.Effect<KnownMessagePartMetadata | undefined>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/ACPNext/Session") {}
|
||||
|
||||
type State = Map<string, Info>
|
||||
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const sessions = yield* Ref.make<State>(new Map())
|
||||
|
||||
const store = Effect.fn("ACPNext.Session.store")(function* (input: StoreInput) {
|
||||
const session = makeSession(input)
|
||||
yield* Ref.update(sessions, (state) => new Map(state).set(session.id, session))
|
||||
return snapshot(session)
|
||||
})
|
||||
|
||||
const tryGet = Effect.fn("ACPNext.Session.tryGet")(function* (sessionId: string) {
|
||||
const session = (yield* Ref.get(sessions)).get(sessionId)
|
||||
if (!session) return
|
||||
return snapshot(session)
|
||||
})
|
||||
|
||||
const get = Effect.fn("ACPNext.Session.get")(function* (sessionId: string) {
|
||||
const session = yield* tryGet(sessionId)
|
||||
if (session) return session
|
||||
return yield* new ACPNextError.SessionNotFoundError({ sessionId })
|
||||
})
|
||||
|
||||
const update = Effect.fn("ACPNext.Session.update")(function* (sessionId: string, fn: (session: Info) => Info) {
|
||||
const result = yield* Ref.modify(sessions, (state) => {
|
||||
const session = state.get(sessionId)
|
||||
if (!session) return [undefined, state] as const
|
||||
const next = fn(session)
|
||||
return [snapshot(next), new Map(state).set(sessionId, next)] as const
|
||||
})
|
||||
if (result) return result
|
||||
return yield* new ACPNextError.SessionNotFoundError({ sessionId })
|
||||
})
|
||||
|
||||
const remove = Effect.fn("ACPNext.Session.remove")(function* (sessionId: string) {
|
||||
return yield* Ref.modify(sessions, (state) => {
|
||||
const session = state.get(sessionId)
|
||||
if (!session) return [undefined, state] as const
|
||||
const next = new Map(state)
|
||||
next.delete(sessionId)
|
||||
return [snapshot(session), next] as const
|
||||
})
|
||||
})
|
||||
|
||||
const setModel: Interface["setModel"] = Effect.fn("ACPNext.Session.setModel")((sessionId, model) =>
|
||||
update(sessionId, (session) => ({ ...session, model })),
|
||||
)
|
||||
|
||||
const setVariant: Interface["setVariant"] = Effect.fn("ACPNext.Session.setVariant")((sessionId, variant) =>
|
||||
update(sessionId, (session) => ({ ...session, variant })),
|
||||
)
|
||||
|
||||
const setMode: Interface["setMode"] = Effect.fn("ACPNext.Session.setMode")((sessionId, modeId) =>
|
||||
update(sessionId, (session) => ({ ...session, modeId })),
|
||||
)
|
||||
|
||||
const recordPartMetadata: Interface["recordPartMetadata"] = Effect.fn("ACPNext.Session.recordPartMetadata")((
|
||||
input,
|
||||
) => {
|
||||
const metadata = {
|
||||
messageId: input.messageId,
|
||||
partId: input.partId,
|
||||
toolCallId: input.toolCallId,
|
||||
metadata: input.metadata,
|
||||
}
|
||||
return update(input.sessionId, (session) => ({
|
||||
...session,
|
||||
knownParts: new Map(session.knownParts).set(partMetadataKey(input), metadata),
|
||||
})).pipe(Effect.as(metadata))
|
||||
})
|
||||
|
||||
return Service.of({
|
||||
create: store,
|
||||
load: store,
|
||||
get,
|
||||
tryGet,
|
||||
remove,
|
||||
setModel,
|
||||
getModel: Effect.fn("ACPNext.Session.getModel")(function* (sessionId) {
|
||||
return (yield* get(sessionId)).model
|
||||
}),
|
||||
setVariant,
|
||||
getVariant: Effect.fn("ACPNext.Session.getVariant")(function* (sessionId) {
|
||||
return (yield* get(sessionId)).variant
|
||||
}),
|
||||
setMode,
|
||||
getMode: Effect.fn("ACPNext.Session.getMode")(function* (sessionId) {
|
||||
return (yield* get(sessionId)).modeId
|
||||
}),
|
||||
recordPartMetadata,
|
||||
getPartMetadata: Effect.fn("ACPNext.Session.getPartMetadata")(function* (input) {
|
||||
return (yield* get(input.sessionId)).knownParts.get(partMetadataKey(input))
|
||||
}),
|
||||
tryGetPartMetadata: Effect.fn("ACPNext.Session.tryGetPartMetadata")(function* (input) {
|
||||
return (yield* tryGet(input.sessionId))?.knownParts.get(partMetadataKey(input))
|
||||
}),
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
export const defaultLayer = layer
|
||||
|
||||
function makeSession(input: StoreInput): Info {
|
||||
return {
|
||||
id: input.id,
|
||||
cwd: input.cwd,
|
||||
mcpServers: [...(input.mcpServers ?? [])],
|
||||
createdAt: input.createdAt ? new Date(input.createdAt) : new Date(),
|
||||
model: input.model,
|
||||
variant: input.variant,
|
||||
modeId: input.modeId,
|
||||
knownParts: new Map(),
|
||||
}
|
||||
}
|
||||
|
||||
function snapshot(session: Info): Info {
|
||||
return {
|
||||
...session,
|
||||
mcpServers: [...session.mcpServers],
|
||||
createdAt: new Date(session.createdAt),
|
||||
knownParts: new Map(session.knownParts),
|
||||
}
|
||||
}
|
||||
|
||||
function partMetadataKey(input: { messageId: string; partId: string }) {
|
||||
return `${input.messageId}:${input.partId}`
|
||||
}
|
||||
|
||||
export * as ACPNextSession from "./session"
|
||||
@@ -0,0 +1,174 @@
|
||||
import type { ToolCallContent, ToolCallLocation, ToolKind } from "@agentclientprotocol/sdk"
|
||||
|
||||
export type ToolInput = Record<string, unknown>
|
||||
|
||||
export type ToolAttachment = {
|
||||
readonly mime?: string
|
||||
readonly url?: string
|
||||
readonly [key: string]: unknown
|
||||
}
|
||||
|
||||
export type CompletedToolState = {
|
||||
readonly status: "completed"
|
||||
readonly input: ToolInput
|
||||
readonly output: string
|
||||
readonly metadata?: unknown
|
||||
readonly attachments?: ReadonlyArray<ToolAttachment>
|
||||
}
|
||||
|
||||
export type ImageAttachment = {
|
||||
readonly mimeType: string
|
||||
readonly data: string
|
||||
}
|
||||
|
||||
export function toToolKind(toolName: string): ToolKind {
|
||||
const tool = toolName.toLocaleLowerCase()
|
||||
|
||||
switch (tool) {
|
||||
case "bash":
|
||||
case "shell":
|
||||
return "execute"
|
||||
|
||||
case "webfetch":
|
||||
return "fetch"
|
||||
|
||||
case "edit":
|
||||
case "patch":
|
||||
case "write":
|
||||
return "edit"
|
||||
|
||||
case "grep":
|
||||
case "glob":
|
||||
case "repo_clone":
|
||||
case "repo_overview":
|
||||
case "context":
|
||||
case "context7_resolve_library_id":
|
||||
case "context7_get_library_docs":
|
||||
return "search"
|
||||
|
||||
case "read":
|
||||
return "read"
|
||||
|
||||
default:
|
||||
return "other"
|
||||
}
|
||||
}
|
||||
|
||||
export function toLocations(toolName: string, input: ToolInput): ToolCallLocation[] {
|
||||
const tool = toolName.toLocaleLowerCase()
|
||||
|
||||
switch (tool) {
|
||||
case "read":
|
||||
case "edit":
|
||||
case "write":
|
||||
return locationFrom(input.filePath)
|
||||
|
||||
case "grep":
|
||||
case "glob":
|
||||
case "repo_clone":
|
||||
case "repo_overview":
|
||||
case "context":
|
||||
case "context7_resolve_library_id":
|
||||
case "context7_get_library_docs":
|
||||
return locationFrom(input.path)
|
||||
|
||||
case "bash":
|
||||
case "shell":
|
||||
return []
|
||||
|
||||
default:
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
export function completedToolContent(toolName: string, state: CompletedToolState): ToolCallContent[] {
|
||||
const content: ToolCallContent[] = [
|
||||
{
|
||||
type: "content",
|
||||
content: {
|
||||
type: "text",
|
||||
text: state.output,
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
if (toToolKind(toolName) === "edit") {
|
||||
content.push(...diffContent(state.input))
|
||||
}
|
||||
|
||||
content.push(...imageContents(state.attachments ?? []))
|
||||
return content
|
||||
}
|
||||
|
||||
export function completedToolRawOutput(state: CompletedToolState) {
|
||||
return {
|
||||
output: state.output,
|
||||
...(state.metadata !== undefined ? { metadata: state.metadata } : {}),
|
||||
...(state.attachments?.length ? { attachments: state.attachments } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
export function imageContents(attachments: ReadonlyArray<ToolAttachment>): ToolCallContent[] {
|
||||
return extractImageAttachments(attachments).map((attachment): ToolCallContent => {
|
||||
return {
|
||||
type: "content",
|
||||
content: {
|
||||
type: "image",
|
||||
mimeType: attachment.mimeType,
|
||||
data: attachment.data,
|
||||
},
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export function extractImageAttachments(attachments: ReadonlyArray<ToolAttachment>): ImageAttachment[] {
|
||||
return attachments.flatMap((attachment): ImageAttachment[] => {
|
||||
const data = dataUrlImage(attachment)
|
||||
return data ? [data] : []
|
||||
})
|
||||
}
|
||||
|
||||
export function shellOutputSnapshot(state: { readonly metadata?: unknown }) {
|
||||
if (!state.metadata || typeof state.metadata !== "object") return undefined
|
||||
return stringValue((state.metadata as Record<string, unknown>).output)
|
||||
}
|
||||
|
||||
export const mapToolKind = toToolKind
|
||||
export const extractLocations = toLocations
|
||||
export const buildCompletedToolContent = completedToolContent
|
||||
export const buildCompletedRawOutput = completedToolRawOutput
|
||||
export const extractShellOutputSnapshot = shellOutputSnapshot
|
||||
|
||||
function locationFrom(value: unknown): ToolCallLocation[] {
|
||||
const path = stringValue(value)
|
||||
return path ? [{ path }] : []
|
||||
}
|
||||
|
||||
function diffContent(input: ToolInput): ToolCallContent[] {
|
||||
const oldText = stringValue(input.oldString)
|
||||
const newText = stringValue(input.newString) ?? stringValue(input.content)
|
||||
if (oldText === undefined || newText === undefined) return []
|
||||
|
||||
return [
|
||||
{
|
||||
type: "diff",
|
||||
path: stringValue(input.filePath) ?? "",
|
||||
oldText,
|
||||
newText,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
function dataUrlImage(attachment: ToolAttachment) {
|
||||
const match = stringValue(attachment.url)?.match(/^data:([^;,]+)(?:;[^,]*)*;base64,(.*)$/)
|
||||
const mime = match?.[1] ?? stringValue(attachment.mime)
|
||||
if (!mime?.startsWith("image/")) return undefined
|
||||
|
||||
const data = match?.[2]
|
||||
if (data === undefined) return undefined
|
||||
return { mimeType: mime, data }
|
||||
}
|
||||
|
||||
function stringValue(value: unknown) {
|
||||
return typeof value === "string" ? value : undefined
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
import type { AgentSideConnection, Usage } from "@agentclientprotocol/sdk"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
import { InstanceRef } from "@/effect/instance-ref"
|
||||
import { InstanceStore } from "@/project/instance-store"
|
||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
import { Provider } from "@/provider/provider"
|
||||
import { Context, Effect, Layer, SynchronizedRef } from "effect"
|
||||
|
||||
const log = Log.create({ service: "acp-next-usage" })
|
||||
|
||||
export type AssistantTokenCost = {
|
||||
readonly cost: number
|
||||
readonly tokens: {
|
||||
readonly input: number
|
||||
readonly output: number
|
||||
readonly reasoning: number
|
||||
readonly cache: {
|
||||
readonly read: number
|
||||
readonly write: number
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export type AssistantMessage = AssistantTokenCost & {
|
||||
readonly role: "assistant"
|
||||
readonly providerID?: string
|
||||
readonly modelID?: string
|
||||
}
|
||||
|
||||
export type SessionMessage = {
|
||||
readonly info: { readonly role: string } | AssistantMessage
|
||||
}
|
||||
|
||||
export type MessagesInput = {
|
||||
readonly sessionID: string
|
||||
readonly directory: string
|
||||
}
|
||||
|
||||
export type SDK = {
|
||||
readonly session: {
|
||||
readonly messages: (
|
||||
parameters: { readonly sessionID: string; readonly directory: string },
|
||||
options: { readonly throwOnError: true },
|
||||
) => Promise<{ readonly data?: readonly SessionMessage[] | null }>
|
||||
}
|
||||
}
|
||||
|
||||
export interface MessageLoaderInterface {
|
||||
readonly messages: (input: MessagesInput) => Effect.Effect<readonly SessionMessage[], unknown>
|
||||
}
|
||||
|
||||
export interface ContextLimitLoaderInterface {
|
||||
readonly providers: (directory: string) => Effect.Effect<Record<ProviderV2.ID, Provider.Info>, unknown>
|
||||
}
|
||||
|
||||
export type UsageConnection = Pick<AgentSideConnection, "sessionUpdate">
|
||||
|
||||
export interface Interface {
|
||||
readonly buildUsage: (message: AssistantTokenCost) => Usage
|
||||
readonly latestAssistantMessage: (messages: readonly SessionMessage[]) => AssistantMessage | undefined
|
||||
readonly totalSessionCost: (messages: readonly SessionMessage[]) => number
|
||||
readonly contextLimit: (input: {
|
||||
readonly directory: string
|
||||
readonly providerID: ProviderV2.ID
|
||||
readonly modelID: ProviderV2.ModelID
|
||||
}) => Effect.Effect<number | undefined>
|
||||
readonly sendUpdate: (input: {
|
||||
readonly connection: UsageConnection
|
||||
readonly sessionID: string
|
||||
readonly directory: string
|
||||
}) => Effect.Effect<void>
|
||||
}
|
||||
|
||||
export class MessageLoader extends Context.Service<MessageLoader, MessageLoaderInterface>()(
|
||||
"@opencode/ACPNextUsageMessageLoader",
|
||||
) {}
|
||||
|
||||
export class ContextLimitLoader extends Context.Service<ContextLimitLoader, ContextLimitLoaderInterface>()(
|
||||
"@opencode/ACPNextUsageContextLimitLoader",
|
||||
) {}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/ACPNextUsage") {}
|
||||
|
||||
export function messageLoaderFromSDK(sdk: SDK): MessageLoaderInterface {
|
||||
return MessageLoader.of({
|
||||
messages: (input) =>
|
||||
Effect.promise(() =>
|
||||
sdk.session
|
||||
.messages({ sessionID: input.sessionID, directory: input.directory }, { throwOnError: true })
|
||||
.then((response) => response.data ?? []),
|
||||
),
|
||||
})
|
||||
}
|
||||
|
||||
export const messageLoaderLayer = (sdk: SDK) => Layer.succeed(MessageLoader, messageLoaderFromSDK(sdk))
|
||||
|
||||
export function buildUsage(message: AssistantTokenCost): Usage {
|
||||
const cachedReadTokens = message.tokens.cache.read
|
||||
const cachedWriteTokens = message.tokens.cache.write
|
||||
const thoughtTokens = message.tokens.reasoning
|
||||
|
||||
return {
|
||||
inputTokens: message.tokens.input,
|
||||
outputTokens: message.tokens.output,
|
||||
totalTokens: message.tokens.input + message.tokens.output + thoughtTokens + cachedReadTokens + cachedWriteTokens,
|
||||
...(thoughtTokens > 0 ? { thoughtTokens } : {}),
|
||||
...(cachedReadTokens > 0 ? { cachedReadTokens } : {}),
|
||||
...(cachedWriteTokens > 0 ? { cachedWriteTokens } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
export function latestAssistantMessage(messages: readonly SessionMessage[]): AssistantMessage | undefined {
|
||||
return messages
|
||||
.filter((message): message is { readonly info: AssistantMessage } => message.info.role === "assistant")
|
||||
.at(-1)?.info
|
||||
}
|
||||
|
||||
export function totalSessionCost(messages: readonly SessionMessage[]): number {
|
||||
return messages
|
||||
.filter((message): message is { readonly info: AssistantMessage } => message.info.role === "assistant")
|
||||
.reduce((sum, message) => sum + message.info.cost, 0)
|
||||
}
|
||||
|
||||
export function findContextLimit(
|
||||
providers: Record<ProviderV2.ID, Provider.Info>,
|
||||
providerID: ProviderV2.ID,
|
||||
modelID: ProviderV2.ModelID,
|
||||
): number | undefined {
|
||||
return providers[providerID]?.models[modelID]?.limit.context
|
||||
}
|
||||
|
||||
export const contextLimitLoaderLayer = Layer.effect(
|
||||
ContextLimitLoader,
|
||||
Effect.gen(function* () {
|
||||
const store = yield* InstanceStore.Service
|
||||
const provider = yield* Provider.Service
|
||||
|
||||
return ContextLimitLoader.of({
|
||||
providers: Effect.fn("ACPNextUsageContextLimitLoader.providers")(function* (directory) {
|
||||
const ctx = yield* store.load({ directory })
|
||||
return yield* Effect.gen(function* () {
|
||||
return yield* provider.list()
|
||||
}).pipe(Effect.provideService(InstanceRef, ctx))
|
||||
}),
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const messageLoader = yield* MessageLoader
|
||||
const contextLimitLoader = yield* ContextLimitLoader
|
||||
const limits = yield* SynchronizedRef.make(new Map<string, Effect.Effect<number | undefined>>())
|
||||
|
||||
const cachedLimit = Effect.fnUntraced(function* (input: {
|
||||
readonly directory: string
|
||||
readonly providerID: ProviderV2.ID
|
||||
readonly modelID: ProviderV2.ModelID
|
||||
}) {
|
||||
return yield* SynchronizedRef.modifyEffect(
|
||||
limits,
|
||||
Effect.fnUntraced(function* (items) {
|
||||
const key = `${input.directory}\u0000${input.providerID}\u0000${input.modelID}`
|
||||
const current = items.get(key)
|
||||
if (current) return [current, items] as const
|
||||
const next = yield* Effect.cached(
|
||||
contextLimitLoader.providers(input.directory).pipe(
|
||||
Effect.map((providers) => findContextLimit(providers, input.providerID, input.modelID)),
|
||||
Effect.catch((error) =>
|
||||
Effect.sync(() => {
|
||||
log.error("failed to get providers for usage context limit", { error })
|
||||
return undefined
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
return [next, new Map(items).set(key, next)] as const
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
const contextLimit = Effect.fn("ACPNextUsage.contextLimit")(function* (input: {
|
||||
readonly directory: string
|
||||
readonly providerID: ProviderV2.ID
|
||||
readonly modelID: ProviderV2.ModelID
|
||||
}) {
|
||||
return yield* yield* cachedLimit(input)
|
||||
})
|
||||
|
||||
const sendUpdate = Effect.fn("ACPNextUsage.sendUpdate")(function* (input: {
|
||||
readonly connection: UsageConnection
|
||||
readonly sessionID: string
|
||||
readonly directory: string
|
||||
}) {
|
||||
const messages = yield* messageLoader.messages({ sessionID: input.sessionID, directory: input.directory }).pipe(
|
||||
Effect.catch((error) =>
|
||||
Effect.sync(() => {
|
||||
log.error("failed to fetch messages for usage update", { error })
|
||||
return undefined
|
||||
}),
|
||||
),
|
||||
)
|
||||
if (!messages) return
|
||||
|
||||
const message = latestAssistantMessage(messages)
|
||||
if (!message) return
|
||||
if (!message.providerID || !message.modelID) return
|
||||
|
||||
const size = yield* contextLimit({
|
||||
directory: input.directory,
|
||||
providerID: ProviderV2.ID.make(message.providerID),
|
||||
modelID: ProviderV2.ModelID.make(message.modelID),
|
||||
})
|
||||
if (!size) return
|
||||
|
||||
yield* Effect.promise(() =>
|
||||
input.connection
|
||||
.sessionUpdate({
|
||||
sessionId: input.sessionID,
|
||||
update: {
|
||||
sessionUpdate: "usage_update",
|
||||
used: message.tokens.input + message.tokens.cache.read,
|
||||
size,
|
||||
cost: { amount: totalSessionCost(messages), currency: "USD" },
|
||||
},
|
||||
})
|
||||
.catch((error) => {
|
||||
log.error("failed to send usage update", { error })
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
return Service.of({
|
||||
buildUsage,
|
||||
latestAssistantMessage,
|
||||
totalSessionCost,
|
||||
contextLimit,
|
||||
sendUpdate,
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
export const defaultLayer = layer.pipe(
|
||||
Layer.provide(contextLimitLoaderLayer),
|
||||
Layer.provide(Provider.defaultLayer),
|
||||
Layer.provide(InstanceStore.defaultLayer),
|
||||
)
|
||||
|
||||
export * as UsageService from "./usage"
|
||||
@@ -3,10 +3,12 @@ import { Effect } from "effect"
|
||||
import { effectCmd } from "../effect-cmd"
|
||||
import { AgentSideConnection, ndJsonStream } from "@agentclientprotocol/sdk"
|
||||
import { ACP } from "@/acp/agent"
|
||||
import { ACPNext } from "@/acp-next/agent"
|
||||
import { Server } from "@/server/server"
|
||||
import { ServerAuth } from "@/server/auth"
|
||||
import { createOpencodeClient } from "@opencode-ai/sdk/v2"
|
||||
import { withNetworkOptions, resolveNetworkOptions } from "../network"
|
||||
import { RuntimeFlags } from "@/effect/runtime-flags"
|
||||
|
||||
const log = Log.create({ service: "acp-command" })
|
||||
|
||||
@@ -22,6 +24,7 @@ export const AcpCommand = effectCmd({
|
||||
},
|
||||
handler: Effect.fn("Cli.acp")(function* (args) {
|
||||
process.env.OPENCODE_CLIENT = "acp"
|
||||
const flags = yield* RuntimeFlags.Service
|
||||
const opts = yield* resolveNetworkOptions(args)
|
||||
const server = yield* Effect.promise(() => Server.listen(opts))
|
||||
|
||||
@@ -54,7 +57,7 @@ export const AcpCommand = effectCmd({
|
||||
})
|
||||
|
||||
const stream = ndJsonStream(input, output)
|
||||
const agent = ACP.init({ sdk })
|
||||
const agent = flags.acpNext ? ACPNext.init({ sdk }) : ACP.init({ sdk })
|
||||
|
||||
new AgentSideConnection((conn) => {
|
||||
return agent.create(conn, { sdk })
|
||||
|
||||
@@ -1464,11 +1464,13 @@ export function Prompt(props: PromptProps) {
|
||||
}),
|
||||
}
|
||||
})
|
||||
const maxHeight = createMemo(() => tuiConfig.prompt?.max_height ?? Math.max(6, Math.floor(dimensions().height / 3)))
|
||||
|
||||
return (
|
||||
<>
|
||||
<box ref={(r: BoxRenderable) => (anchor = r)} visible={props.visible !== false}>
|
||||
<box ref={(r: BoxRenderable) => (anchor = r)} visible={props.visible !== false} width="100%">
|
||||
<box
|
||||
width="100%"
|
||||
border={["left"]}
|
||||
borderColor={borderHighlight()}
|
||||
customBorderChars={{
|
||||
@@ -1483,14 +1485,16 @@ export function Prompt(props: PromptProps) {
|
||||
flexShrink={0}
|
||||
backgroundColor={theme.backgroundElement}
|
||||
flexGrow={1}
|
||||
width="100%"
|
||||
>
|
||||
<textarea
|
||||
width="100%"
|
||||
placeholder={placeholderText()}
|
||||
placeholderColor={theme.textMuted}
|
||||
textColor={leader() ? theme.textMuted : theme.text}
|
||||
focusedTextColor={leader() ? theme.textMuted : theme.text}
|
||||
minHeight={1}
|
||||
maxHeight={6}
|
||||
maxHeight={maxHeight()}
|
||||
onContentChange={() => {
|
||||
const value = input.plainText
|
||||
setStore("prompt", "input", value)
|
||||
|
||||
@@ -61,6 +61,15 @@ export const Attention = Schema.Struct({
|
||||
sounds: Schema.optional(TuiAttentionSounds),
|
||||
}).annotate({ description: "Attention notification and sound settings" })
|
||||
|
||||
const PromptSize = Schema.Int.check(Schema.isGreaterThan(0))
|
||||
|
||||
export const Prompt = Schema.Struct({
|
||||
max_height: Schema.optional(PromptSize).annotate({ description: "Prompt textarea max height" }),
|
||||
max_width: Schema.optional(Schema.Union([PromptSize, Schema.Literal("auto")])).annotate({
|
||||
description: "Home prompt max width: a positive integer for a fixed cap, or 'auto' to scale with terminal width",
|
||||
}),
|
||||
}).annotate({ description: "Prompt size settings" })
|
||||
|
||||
export const TuiInfo = Schema.Struct({
|
||||
$schema: Schema.optional(Schema.String),
|
||||
theme: Schema.optional(Schema.String),
|
||||
@@ -69,6 +78,7 @@ export const TuiInfo = Schema.Struct({
|
||||
plugin_enabled: Schema.optional(Schema.Record(Schema.String, Schema.Boolean)),
|
||||
leader_timeout: Schema.optional(KeymapLeaderTimeout),
|
||||
attention: Schema.optional(Attention),
|
||||
prompt: Schema.optional(Prompt),
|
||||
scroll_speed: Schema.optional(ScrollSpeed).annotate({
|
||||
description: "TUI scroll speed",
|
||||
}),
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Prompt, type PromptRef } from "@tui/component/prompt"
|
||||
import { createEffect, createSignal, onMount } from "solid-js"
|
||||
import { createEffect, createMemo, createSignal, onMount } from "solid-js"
|
||||
import { Logo } from "../component/logo"
|
||||
import { useSync } from "../context/sync"
|
||||
import { Toast } from "../ui/toast"
|
||||
@@ -9,6 +9,8 @@ import { usePromptRef } from "../context/prompt"
|
||||
import { useLocal } from "../context/local"
|
||||
import { TuiPluginRuntime } from "@/cli/cmd/tui/plugin/runtime"
|
||||
import { useEditorContext } from "@tui/context/editor"
|
||||
import { useTerminalDimensions } from "@opentui/solid"
|
||||
import { useTuiConfig } from "../context/tui-config"
|
||||
|
||||
let once = false
|
||||
const placeholder = {
|
||||
@@ -24,6 +26,13 @@ export function Home() {
|
||||
const args = useArgs()
|
||||
const local = useLocal()
|
||||
const editor = useEditorContext()
|
||||
const dimensions = useTerminalDimensions()
|
||||
const tuiConfig = useTuiConfig()
|
||||
const promptMaxWidth = createMemo(() => {
|
||||
const configured = tuiConfig.prompt?.max_width
|
||||
if (configured === "auto") return Math.max(75, Math.floor(dimensions().width * 0.7))
|
||||
return configured ?? 75
|
||||
})
|
||||
let sent = false
|
||||
|
||||
onMount(() => {
|
||||
@@ -67,7 +76,7 @@ export function Home() {
|
||||
</TuiPluginRuntime.Slot>
|
||||
</box>
|
||||
<box height={1} minHeight={0} flexShrink={1} />
|
||||
<box width="100%" maxWidth={75} zIndex={1000} paddingTop={1} flexShrink={0}>
|
||||
<box width="100%" maxWidth={promptMaxWidth()} zIndex={1000} paddingTop={1} flexShrink={0}>
|
||||
<TuiPluginRuntime.Slot name="home_prompt" mode="replace" ref={bind}>
|
||||
<Prompt ref={bind} right={<TuiPluginRuntime.Slot name="home_prompt_right" />} placeholders={placeholder} />
|
||||
</TuiPluginRuntime.Slot>
|
||||
|
||||
@@ -44,8 +44,10 @@ export const Model = Schema.Struct({
|
||||
),
|
||||
modalities: Schema.optional(
|
||||
Schema.Struct({
|
||||
input: Schema.mutable(Schema.Array(Schema.Literals(["text", "audio", "image", "video", "pdf"]))),
|
||||
output: Schema.mutable(Schema.Array(Schema.Literals(["text", "audio", "image", "video", "pdf"]))),
|
||||
input: Schema.optional(Schema.mutable(Schema.Array(Schema.Literals(["text", "audio", "image", "video", "pdf"])))),
|
||||
output: Schema.optional(
|
||||
Schema.mutable(Schema.Array(Schema.Literals(["text", "audio", "image", "video", "pdf"]))),
|
||||
),
|
||||
}),
|
||||
),
|
||||
experimental: Schema.optional(Schema.Boolean),
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Config, ConfigProvider, Context, Effect, Layer } from "effect"
|
||||
import { Config, ConfigProvider, Context, Effect, Layer, Option } from "effect"
|
||||
import { ConfigService } from "@/effect/config-service"
|
||||
|
||||
const bool = (name: string) => Config.boolean(name).pipe(Config.withDefault(false))
|
||||
@@ -9,7 +9,9 @@ const positiveInteger = (name: string) =>
|
||||
)
|
||||
const experimental = bool("OPENCODE_EXPERIMENTAL")
|
||||
const enabledByExperimental = (name: string) =>
|
||||
Config.all({ experimental, enabled: bool(name) }).pipe(Config.map((flags) => flags.experimental || flags.enabled))
|
||||
Config.all({ experimental, enabled: Config.boolean(name).pipe(Config.option) }).pipe(
|
||||
Config.map((flags) => Option.getOrElse(flags.enabled, () => flags.experimental)),
|
||||
)
|
||||
|
||||
export class Service extends ConfigService.Service<Service>()("@opencode/RuntimeFlags", {
|
||||
autoShare: bool("OPENCODE_AUTO_SHARE"),
|
||||
@@ -46,6 +48,7 @@ export class Service extends ConfigService.Service<Service>()("@opencode/Runtime
|
||||
experimentalEventSystem: enabledByExperimental("OPENCODE_EXPERIMENTAL_EVENT_SYSTEM"),
|
||||
experimentalWorkspaces: enabledByExperimental("OPENCODE_EXPERIMENTAL_WORKSPACES"),
|
||||
experimentalIconDiscovery: enabledByExperimental("OPENCODE_EXPERIMENTAL_ICON_DISCOVERY"),
|
||||
acpNext: bool("OPENCODE_ACP_NEXT"),
|
||||
outputTokenMax: positiveInteger("OPENCODE_EXPERIMENTAL_OUTPUT_TOKEN_MAX"),
|
||||
bashDefaultTimeoutMs: positiveInteger("OPENCODE_EXPERIMENTAL_BASH_DEFAULT_TIMEOUT_MS"),
|
||||
experimentalNativeLlm: bool("OPENCODE_EXPERIMENTAL_NATIVE_LLM"),
|
||||
|
||||
@@ -5,7 +5,7 @@ import { Session } from "@/session/session"
|
||||
import { Worktree } from "@/worktree"
|
||||
import { NonNegativeInt } from "@opencode-ai/core/schema"
|
||||
import { Schema } from "effect"
|
||||
import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
|
||||
import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi"
|
||||
import { Authorization } from "../middleware/authorization"
|
||||
import { InstanceContextMiddleware } from "../middleware/instance-context"
|
||||
import {
|
||||
@@ -169,7 +169,7 @@ export const ExperimentalApi = HttpApi.make("experimental")
|
||||
HttpApiEndpoint.post("worktreeCreate", ExperimentalPaths.worktree, {
|
||||
disableCodecs: true,
|
||||
query: WorkspaceRoutingQuery,
|
||||
payload: Schema.UndefinedOr(Worktree.CreateInput),
|
||||
payload: [HttpApiSchema.NoContent, Worktree.CreateInput],
|
||||
success: described(Worktree.Info, "Worktree created"),
|
||||
error: WorktreeApiError,
|
||||
}).annotateMerge(
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Config } from "@/config/config"
|
||||
import { BusEvent } from "@/bus/bus-event"
|
||||
import "@/server/event"
|
||||
import { Schema } from "effect"
|
||||
import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
|
||||
import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi"
|
||||
import { described } from "./metadata"
|
||||
|
||||
const GlobalHealth = Schema.Struct({
|
||||
@@ -91,7 +91,7 @@ export const GlobalApi = HttpApi.make("global").add(
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.post("upgrade", GlobalPaths.upgrade, {
|
||||
payload: GlobalUpgradeInput,
|
||||
payload: [HttpApiSchema.NoContent, GlobalUpgradeInput],
|
||||
success: described(GlobalUpgradeResult, "Upgrade result"),
|
||||
error: HttpApiError.BadRequest,
|
||||
}).annotateMerge(
|
||||
|
||||
@@ -238,7 +238,7 @@ export const SessionApi = HttpApi.make("session")
|
||||
HttpApiEndpoint.post("fork", SessionPaths.fork, {
|
||||
params: { sessionID: SessionID },
|
||||
query: WorkspaceRoutingQuery,
|
||||
payload: Schema.optional(ForkPayload),
|
||||
payload: [HttpApiSchema.NoContent, ForkPayload],
|
||||
success: described(Session.Info, "200"),
|
||||
error: [HttpApiError.BadRequest, ApiNotFoundError],
|
||||
}).annotateMerge(
|
||||
|
||||
@@ -104,9 +104,9 @@ export const experimentalHandlers = HttpApiBuilder.group(InstanceHttpApi, "exper
|
||||
})
|
||||
|
||||
const worktreeCreate = Effect.fn("ExperimentalHttpApi.worktreeCreate")(function* (ctx: {
|
||||
payload: Worktree.CreateInput | undefined
|
||||
payload: typeof Worktree.CreateInput.Type | void
|
||||
}) {
|
||||
return yield* mapWorktreeError(worktreeSvc.create(ctx.payload))
|
||||
return yield* mapWorktreeError(worktreeSvc.create(ctx.payload ?? undefined))
|
||||
})
|
||||
|
||||
const worktreeRemove = Effect.fn("ExperimentalHttpApi.worktreeRemove")(function* (input: {
|
||||
|
||||
@@ -82,6 +82,12 @@ const STRUCTURED_OUTPUT_SYSTEM_PROMPT = `IMPORTANT: The user has requested struc
|
||||
const log = Log.create({ service: "session.prompt" })
|
||||
const elog = EffectLogger.create({ service: "session.prompt" })
|
||||
|
||||
function isOrphanedInterruptedTool(part: SessionLegacy.ToolPart) {
|
||||
// cleanup() marks abandoned tool_use blocks this way after retries/aborts.
|
||||
// They are not pending work and must not trigger an assistant-prefill request.
|
||||
return part.state.status === "error" && part.state.metadata?.interrupted === true
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
readonly cancel: (sessionID: SessionID) => Effect.Effect<void>
|
||||
readonly prompt: (input: PromptInput) => Effect.Effect<SessionLegacy.WithParts, Image.Error>
|
||||
@@ -131,7 +137,6 @@ export const layer = Layer.effect(
|
||||
cancel: (sessionID: SessionID) => cancel(sessionID),
|
||||
resolvePromptParts: (template: string) => resolvePromptParts(template),
|
||||
prompt: (input: PromptInput) => prompt(input).pipe(Effect.catch(Effect.die)),
|
||||
loop: (input: LoopInput) => loop(input),
|
||||
} satisfies TaskPromptOps
|
||||
})
|
||||
|
||||
@@ -1264,12 +1269,13 @@ export const layer = Layer.effect(
|
||||
const lastAssistantMsg = msgs.findLast(
|
||||
(msg) => msg.info.role === "assistant" && msg.info.id === lastAssistant?.id,
|
||||
)
|
||||
// Some providers return "stop" even when the assistant message contains tool calls.
|
||||
// Keep the loop running so tool results can be sent back to the model.
|
||||
// Skip provider-executed tool parts — those were fully handled within the
|
||||
// provider's stream (e.g. DWS Agent Platform) and don't need a re-loop.
|
||||
// Some providers return "stop" even when the assistant message contains
|
||||
// tool calls. Keep the loop running so tool results can be sent back to
|
||||
// the model, but ignore cleanup-marked interrupted orphans.
|
||||
const hasToolCalls =
|
||||
lastAssistantMsg?.parts.some((part) => part.type === "tool" && !part.metadata?.providerExecuted) ?? false
|
||||
lastAssistantMsg?.parts.some(
|
||||
(part) => part.type === "tool" && !part.metadata?.providerExecuted && !isOrphanedInterruptedTool(part),
|
||||
) ?? false
|
||||
|
||||
if (
|
||||
lastAssistant?.finish &&
|
||||
@@ -1277,6 +1283,16 @@ export const layer = Layer.effect(
|
||||
!hasToolCalls &&
|
||||
lastUser.id < lastAssistant.id
|
||||
) {
|
||||
const orphan = lastAssistantMsg?.parts.find(
|
||||
(part): part is SessionLegacy.ToolPart => part.type === "tool" && isOrphanedInterruptedTool(part),
|
||||
)
|
||||
if (orphan) {
|
||||
yield* slog.warn("loop exit with orphaned interrupted tool", {
|
||||
messageID: lastAssistant.id,
|
||||
tool: orphan.tool,
|
||||
callID: orphan.callID,
|
||||
})
|
||||
}
|
||||
yield* slog.info("exiting loop")
|
||||
break
|
||||
}
|
||||
|
||||
@@ -8,7 +8,6 @@ import { GrepTool } from "./grep"
|
||||
import { ReadTool } from "./read"
|
||||
import { TaskTool } from "./task"
|
||||
import { Database } from "@opencode-ai/core/database/database"
|
||||
import { TaskStatusTool } from "./task_status"
|
||||
import { TodoWriteTool } from "./todo"
|
||||
import { WebFetchTool } from "./webfetch"
|
||||
import { WriteTool } from "./write"
|
||||
@@ -54,7 +53,6 @@ import { Skill } from "../skill"
|
||||
import { Permission } from "@/permission"
|
||||
import { Reference } from "@/reference/reference"
|
||||
import { BackgroundJob } from "@/background/job"
|
||||
import { SessionStatus } from "@/session/status"
|
||||
import { RuntimeFlags } from "@/effect/runtime-flags"
|
||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
|
||||
@@ -93,7 +91,6 @@ export const layer: Layer.Layer<
|
||||
| Agent.Service
|
||||
| Skill.Service
|
||||
| Session.Service
|
||||
| SessionStatus.Service
|
||||
| BackgroundJob.Service
|
||||
| Provider.Service
|
||||
| Git.Service
|
||||
@@ -122,7 +119,6 @@ export const layer: Layer.Layer<
|
||||
|
||||
const invalid = yield* InvalidTool
|
||||
const task = yield* TaskTool
|
||||
const taskStatus = yield* TaskStatusTool
|
||||
const read = yield* ReadTool
|
||||
const question = yield* QuestionTool
|
||||
const todo = yield* TodoWriteTool
|
||||
@@ -238,7 +234,6 @@ export const layer: Layer.Layer<
|
||||
edit: Tool.init(edit),
|
||||
write: Tool.init(writetool),
|
||||
task: Tool.init(task),
|
||||
task_status: Tool.init(taskStatus),
|
||||
fetch: Tool.init(webfetch),
|
||||
todo: Tool.init(todo),
|
||||
search: Tool.init(websearch),
|
||||
@@ -263,7 +258,6 @@ export const layer: Layer.Layer<
|
||||
tool.edit,
|
||||
tool.write,
|
||||
tool.task,
|
||||
...(flags.experimentalBackgroundSubagents ? [tool.task_status] : []),
|
||||
tool.fetch,
|
||||
tool.todo,
|
||||
tool.search,
|
||||
@@ -388,7 +382,7 @@ export const defaultLayer = Layer.suspend(() =>
|
||||
Layer.provide(Skill.defaultLayer),
|
||||
Layer.provide(Agent.defaultLayer),
|
||||
Layer.provide(Session.defaultLayer),
|
||||
Layer.provide(Layer.mergeAll(SessionStatus.defaultLayer, BackgroundJob.defaultLayer)),
|
||||
Layer.provide(BackgroundJob.defaultLayer),
|
||||
Layer.provide(Provider.defaultLayer),
|
||||
Layer.provide(Layer.mergeAll(Git.defaultLayer, RepositoryCache.defaultLayer)),
|
||||
Layer.provide(Reference.defaultLayer),
|
||||
|
||||
@@ -1,19 +1,16 @@
|
||||
import * as Tool from "./tool"
|
||||
import { SessionLegacy } from "@opencode-ai/core/session/legacy"
|
||||
import DESCRIPTION from "./task.txt"
|
||||
import { ToolJsonSchema } from "./json-schema"
|
||||
import { SessionLegacy } from "@opencode-ai/core/session/legacy"
|
||||
import { BackgroundJob } from "@/background/job"
|
||||
import { Bus } from "@/bus"
|
||||
import { Session } from "@/session/session"
|
||||
import { SessionID, MessageID } from "../session/schema"
|
||||
import { MessageV2 } from "../session/message-v2"
|
||||
import { Agent } from "../agent/agent"
|
||||
import { deriveSubagentSessionPermission } from "../agent/subagent-permissions"
|
||||
import type { SessionPrompt } from "../session/prompt"
|
||||
import { SessionStatus } from "@/session/status"
|
||||
import { Config } from "@/config/config"
|
||||
import { TuiEvent } from "@/cli/cmd/tui/event"
|
||||
import { Cause, Effect, Exit, Option, Schema, Scope } from "effect"
|
||||
import { Cause, Effect, Exit, Schema, Scope } from "effect"
|
||||
import { EffectBridge } from "@/effect/bridge"
|
||||
import { RuntimeFlags } from "@/effect/runtime-flags"
|
||||
import { Database } from "@opencode-ai/core/database/database"
|
||||
@@ -22,7 +19,6 @@ export interface TaskPromptOps {
|
||||
cancel(sessionID: SessionID): Effect.Effect<void>
|
||||
resolvePromptParts(template: string): Effect.Effect<SessionPrompt.PromptInput["parts"]>
|
||||
prompt(input: SessionPrompt.PromptInput): Effect.Effect<SessionLegacy.WithParts>
|
||||
loop(input: SessionPrompt.LoopInput): Effect.Effect<SessionLegacy.WithParts>
|
||||
}
|
||||
|
||||
const id = "task"
|
||||
@@ -30,12 +26,14 @@ const BACKGROUND_DESCRIPTION = [
|
||||
"",
|
||||
"",
|
||||
[
|
||||
"Background mode: background=true launches the subagent asynchronously.",
|
||||
"Use task_status(task_id=..., wait=false) to poll, or wait=true to block until done.",
|
||||
"Background mode: background=true launches the subagent asynchronously and returns immediately.",
|
||||
"Foreground is the default; use it when you need the result before continuing.",
|
||||
"Use background only for independent work that can run while you continue elsewhere.",
|
||||
"You will be notified automatically when it finishes.",
|
||||
].join(" "),
|
||||
].join("\n")
|
||||
|
||||
const BaseParameters = Schema.Struct({
|
||||
const BaseParameterFields = {
|
||||
description: Schema.String.annotate({ description: "A short (3-5 words) description of the task" }),
|
||||
prompt: Schema.String.annotate({ description: "The task for the agent to perform" }),
|
||||
subagent_type: Schema.String.annotate({ description: "The type of specialized agent to use for this task" }),
|
||||
@@ -44,40 +42,30 @@ const BaseParameters = Schema.Struct({
|
||||
"This should only be set if you mean to resume a previous task (you can pass a prior task_id and the task will continue the same subagent session as before instead of creating a fresh one)",
|
||||
}),
|
||||
command: Schema.optional(Schema.String).annotate({ description: "The command that triggered this task" }),
|
||||
})
|
||||
}
|
||||
|
||||
const BaseParameters = Schema.Struct(BaseParameterFields)
|
||||
|
||||
export const Parameters = Schema.Struct({
|
||||
description: Schema.String.annotate({ description: "A short (3-5 words) description of the task" }),
|
||||
prompt: Schema.String.annotate({ description: "The task for the agent to perform" }),
|
||||
subagent_type: Schema.String.annotate({ description: "The type of specialized agent to use for this task" }),
|
||||
task_id: Schema.optional(Schema.String).annotate({
|
||||
description:
|
||||
"This should only be set if you mean to resume a previous task (you can pass a prior task_id and the task will continue the same subagent session as before instead of creating a fresh one)",
|
||||
}),
|
||||
command: Schema.optional(Schema.String).annotate({ description: "The command that triggered this task" }),
|
||||
...BaseParameterFields,
|
||||
background: Schema.optional(Schema.Boolean).annotate({
|
||||
description: "When true, launch the subagent in the background and return immediately",
|
||||
description: "Run the agent in the background. You will be notified when it completes.",
|
||||
}),
|
||||
})
|
||||
|
||||
function output(sessionID: SessionID, text: string) {
|
||||
return [
|
||||
`task_id: ${sessionID} (for resuming to continue this task if needed)`,
|
||||
"",
|
||||
"<task_result>",
|
||||
text,
|
||||
"</task_result>",
|
||||
].join("\n")
|
||||
return [`<task id="${sessionID}" state="completed">`, "<task_result>", text, "</task_result>", "</task>"].join("\n")
|
||||
}
|
||||
|
||||
function backgroundOutput(sessionID: SessionID) {
|
||||
return [
|
||||
`task_id: ${sessionID} (for polling this task with task_status)`,
|
||||
"state: running",
|
||||
"",
|
||||
`<task id="${sessionID}" state="running">`,
|
||||
"<summary>Background task started</summary>",
|
||||
"<task_result>",
|
||||
"Background task started. Continue your current work and call task_status when you need the result.",
|
||||
"Background task started. You will be notified automatically when it finishes; do not poll for progress.",
|
||||
"Do not duplicate its work. Continue only with non-overlapping work, or stop if there is nothing else useful to do.",
|
||||
"</task_result>",
|
||||
"</task>",
|
||||
].join("\n")
|
||||
}
|
||||
|
||||
@@ -92,9 +80,14 @@ function backgroundMessage(input: {
|
||||
input.state === "completed"
|
||||
? `Background task completed: ${input.description}`
|
||||
: `Background task failed: ${input.description}`
|
||||
return [title, `task_id: ${input.sessionID}`, `state: ${input.state}`, "", `<${tag}>`, input.text, `</${tag}>`].join(
|
||||
"\n",
|
||||
)
|
||||
return [
|
||||
`<task id="${input.sessionID}" state="${input.state}">`,
|
||||
`<summary>${title}</summary>`,
|
||||
`<${tag}>`,
|
||||
input.text,
|
||||
`</${tag}>`,
|
||||
"</task>",
|
||||
].join("\n")
|
||||
}
|
||||
|
||||
function errorText(error: unknown) {
|
||||
@@ -107,11 +100,9 @@ export const TaskTool = Tool.define(
|
||||
Effect.gen(function* () {
|
||||
const agent = yield* Agent.Service
|
||||
const background = yield* BackgroundJob.Service
|
||||
const bus = yield* Bus.Service
|
||||
const config = yield* Config.Service
|
||||
const sessions = yield* Session.Service
|
||||
const scope = yield* Scope.Scope
|
||||
const status = yield* SessionStatus.Service
|
||||
const flags = yield* RuntimeFlags.Service
|
||||
const database = yield* Database.Service
|
||||
|
||||
@@ -144,9 +135,8 @@ export const TaskTool = Tool.define(
|
||||
return yield* Effect.fail(new Error(`Unknown agent type: ${params.subagent_type} is not a valid agent type`))
|
||||
}
|
||||
|
||||
const taskID = params.task_id
|
||||
const session = taskID
|
||||
? yield* sessions.get(SessionID.make(taskID)).pipe(Effect.catchCause(() => Effect.succeed(undefined)))
|
||||
const session = params.task_id
|
||||
? yield* sessions.get(SessionID.make(params.task_id)).pipe(Effect.catchCause(() => Effect.succeed(undefined)))
|
||||
: undefined
|
||||
const parent = yield* sessions.get(ctx.sessionID)
|
||||
const parentAgent = parent.agent
|
||||
@@ -195,7 +185,6 @@ export const TaskTool = Tool.define(
|
||||
|
||||
const ops = ctx.extra?.promptOps as TaskPromptOps
|
||||
if (!ops) return yield* Effect.fail(new Error("TaskTool requires promptOps in ctx.extra"))
|
||||
const runCancel = yield* EffectBridge.make()
|
||||
|
||||
const runTask = Effect.fn("TaskTool.runTask")(function* () {
|
||||
const parts = yield* ops.resolvePromptParts(params.prompt)
|
||||
@@ -217,68 +206,34 @@ export const TaskTool = Tool.define(
|
||||
return result.parts.findLast((item) => item.type === "text")?.text ?? ""
|
||||
})
|
||||
|
||||
const resumeWhenIdle: (input: { userID: MessageID; state: "completed" | "error" }) => Effect.Effect<void> =
|
||||
Effect.fn("TaskTool.resumeWhenIdle")(function* (input: { userID: MessageID; state: "completed" | "error" }) {
|
||||
const latest = yield* sessions
|
||||
.findMessage(ctx.sessionID, (item) => item.info.role === "user")
|
||||
.pipe(Effect.orDie)
|
||||
if (Option.isNone(latest)) return
|
||||
if (latest.value.info.id !== input.userID) return
|
||||
if ((yield* status.get(ctx.sessionID)).type !== "idle") {
|
||||
yield* Effect.sleep("300 millis")
|
||||
return yield* resumeWhenIdle(input)
|
||||
}
|
||||
yield* bus.publish(TuiEvent.ToastShow, {
|
||||
title: input.state === "completed" ? "Background task complete" : "Background task failed",
|
||||
message:
|
||||
input.state === "completed"
|
||||
? `Background task "${params.description}" finished. Resuming the main thread.`
|
||||
: `Background task "${params.description}" failed. Resuming the main thread.`,
|
||||
variant: input.state === "completed" ? "success" : "error",
|
||||
duration: 5000,
|
||||
})
|
||||
yield* ops
|
||||
.loop({ sessionID: ctx.sessionID })
|
||||
.pipe(Effect.ignore, Effect.forkIn(scope, { startImmediately: true }))
|
||||
})
|
||||
|
||||
const continueIfIdle = Effect.fn("TaskTool.continueIfIdle")(function* (input: {
|
||||
userID: MessageID
|
||||
state: "completed" | "error"
|
||||
}) {
|
||||
yield* resumeWhenIdle(input).pipe(Effect.ignore, Effect.forkIn(scope, { startImmediately: true }))
|
||||
})
|
||||
|
||||
const inject = Effect.fn("TaskTool.injectBackgroundResult")(function* (
|
||||
state: "completed" | "error",
|
||||
text: string,
|
||||
) {
|
||||
const currentParent = yield* sessions.get(ctx.sessionID)
|
||||
const message = yield* ops.prompt({
|
||||
sessionID: ctx.sessionID,
|
||||
noReply: true,
|
||||
agent: currentParent.agent ?? ctx.agent,
|
||||
parts: [
|
||||
{
|
||||
type: "text",
|
||||
synthetic: true,
|
||||
text: backgroundMessage({
|
||||
sessionID: nextSession.id,
|
||||
description: params.description,
|
||||
state,
|
||||
text,
|
||||
}),
|
||||
},
|
||||
],
|
||||
})
|
||||
yield* continueIfIdle({ userID: message.info.id, state })
|
||||
yield* ops
|
||||
.prompt({
|
||||
sessionID: ctx.sessionID,
|
||||
agent: currentParent.agent ?? ctx.agent,
|
||||
parts: [
|
||||
{
|
||||
type: "text",
|
||||
synthetic: true,
|
||||
text: backgroundMessage({
|
||||
sessionID: nextSession.id,
|
||||
description: params.description,
|
||||
state,
|
||||
text,
|
||||
}),
|
||||
},
|
||||
],
|
||||
})
|
||||
.pipe(Effect.ignore, Effect.forkIn(scope, { startImmediately: true }))
|
||||
})
|
||||
|
||||
const existing = yield* background.get(nextSession.id)
|
||||
if (existing?.status === "running") {
|
||||
return yield* Effect.fail(
|
||||
new Error(`Task ${nextSession.id} is already running. Use task_status to check progress.`),
|
||||
)
|
||||
return yield* Effect.fail(new Error(`Task ${nextSession.id} is already running.`))
|
||||
}
|
||||
|
||||
if (runInBackground) {
|
||||
@@ -308,6 +263,7 @@ export const TaskTool = Tool.define(
|
||||
}
|
||||
}
|
||||
|
||||
const runCancel = yield* EffectBridge.make()
|
||||
const cancel = ops.cancel(nextSession.id)
|
||||
|
||||
function onAbort() {
|
||||
|
||||
@@ -1,180 +0,0 @@
|
||||
import * as Tool from "./tool"
|
||||
import { SessionLegacy } from "@opencode-ai/core/session/legacy"
|
||||
import DESCRIPTION from "./task_status.txt"
|
||||
import { BackgroundJob } from "@/background/job"
|
||||
import { Session } from "@/session/session"
|
||||
import { MessageV2 } from "@/session/message-v2"
|
||||
import { SessionID } from "@/session/schema"
|
||||
import { SessionStatus } from "@/session/status"
|
||||
import { PositiveInt } from "@opencode-ai/core/schema"
|
||||
import { RuntimeFlags } from "@/effect/runtime-flags"
|
||||
import { Effect, Option, Schema } from "effect"
|
||||
|
||||
const DEFAULT_TIMEOUT = 60_000
|
||||
const POLL_MS = 300
|
||||
|
||||
const Parameters = Schema.Struct({
|
||||
task_id: SessionID.annotate({ description: "The task_id returned by the task tool" }),
|
||||
wait: Schema.optional(Schema.Boolean).annotate({
|
||||
description: "When true, wait until the task reaches a terminal state or timeout",
|
||||
}),
|
||||
timeout_ms: Schema.optional(PositiveInt).annotate({
|
||||
description: "Maximum milliseconds to wait when wait=true (default: 60000)",
|
||||
}),
|
||||
})
|
||||
|
||||
type State = BackgroundJob.Status
|
||||
type InspectResult = { state: State; text: string }
|
||||
|
||||
function format(input: { taskID: SessionID; state: State; text: string }) {
|
||||
const tag = input.state === "completed" || input.state === "running" ? "task_result" : "task_error"
|
||||
return [`task_id: ${input.taskID}`, `state: ${input.state}`, "", `<${tag}>`, input.text, `</${tag}>`].join("\n")
|
||||
}
|
||||
|
||||
function errorText(error: NonNullable<SessionLegacy.Assistant["error"]>) {
|
||||
const data = Reflect.get(error, "data")
|
||||
const message = data && typeof data === "object" ? Reflect.get(data, "message") : undefined
|
||||
if (typeof message === "string" && message) return message
|
||||
return error.name
|
||||
}
|
||||
|
||||
function inspectMessage(message: SessionLegacy.WithParts): InspectResult | undefined {
|
||||
if (message.info.role !== "assistant") return
|
||||
const text = message.parts.findLast((part) => part.type === "text")?.text ?? ""
|
||||
if (message.info.error) return { state: "error", text: text || errorText(message.info.error) }
|
||||
if (message.info.finish && !["tool-calls", "unknown"].includes(message.info.finish))
|
||||
return { state: "completed", text }
|
||||
return { state: "running", text: text || "Task is still running." }
|
||||
}
|
||||
|
||||
export const TaskStatusTool = Tool.define(
|
||||
"task_status",
|
||||
Effect.gen(function* () {
|
||||
const jobs = yield* BackgroundJob.Service
|
||||
const sessions = yield* Session.Service
|
||||
const status = yield* SessionStatus.Service
|
||||
const flags = yield* RuntimeFlags.Service
|
||||
|
||||
const inspect: (taskID: SessionID) => Effect.Effect<InspectResult> = Effect.fn("TaskStatusTool.inspect")(function* (
|
||||
taskID: SessionID,
|
||||
) {
|
||||
const job = yield* jobs.get(taskID)
|
||||
if (job) {
|
||||
return {
|
||||
state: job.status,
|
||||
text:
|
||||
job.output ??
|
||||
job.error ??
|
||||
(job.status === "running"
|
||||
? "Task is still running."
|
||||
: job.status === "cancelled"
|
||||
? "Task was cancelled."
|
||||
: ""),
|
||||
}
|
||||
}
|
||||
|
||||
const current = yield* status.get(taskID)
|
||||
if (current.type === "busy" || current.type === "retry") {
|
||||
return {
|
||||
state: "running",
|
||||
text: current.type === "retry" ? `Task is retrying: ${current.message}` : "Task is still running.",
|
||||
}
|
||||
}
|
||||
|
||||
const latestAssistant = yield* sessions
|
||||
.findMessage(taskID, (item) => item.info.role === "assistant")
|
||||
.pipe(Effect.orDie)
|
||||
if (Option.isSome(latestAssistant)) {
|
||||
const latest = inspectMessage(latestAssistant.value)
|
||||
if (!latest) return { state: "error", text: "Task is not running in this process." }
|
||||
if (latest.state === "running")
|
||||
return { state: "error", text: "Task is not running in this process and has no final output." }
|
||||
return latest
|
||||
}
|
||||
return { state: "error", text: "Task is not running in this process and has not produced output." }
|
||||
})
|
||||
|
||||
const waitForTerminal: (
|
||||
taskID: SessionID,
|
||||
timeout: number,
|
||||
) => Effect.Effect<{ result: InspectResult; timedOut: boolean }> = Effect.fn("TaskStatusTool.waitForTerminal")(
|
||||
function* (taskID: SessionID, timeout: number) {
|
||||
const result = yield* inspect(taskID)
|
||||
if (result.state !== "running") return { result, timedOut: false }
|
||||
if (timeout <= 0) return { result, timedOut: true }
|
||||
const sleep = Math.min(POLL_MS, timeout)
|
||||
yield* Effect.sleep(`${sleep} millis`)
|
||||
return yield* waitForTerminal(taskID, timeout - sleep)
|
||||
},
|
||||
)
|
||||
|
||||
const run = Effect.fn("TaskStatusTool.execute")(function* (
|
||||
params: Schema.Schema.Type<typeof Parameters>,
|
||||
_ctx: Tool.Context,
|
||||
) {
|
||||
if (!flags.experimentalBackgroundSubagents) {
|
||||
return yield* Effect.fail(new Error("task_status requires OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS=true"))
|
||||
}
|
||||
|
||||
const session = yield* sessions.get(params.task_id).pipe(Effect.catchCause(() => Effect.succeed(undefined)))
|
||||
if (!session) {
|
||||
return {
|
||||
title: "Task status",
|
||||
metadata: {
|
||||
task_id: params.task_id,
|
||||
state: "error" as const,
|
||||
timed_out: false,
|
||||
},
|
||||
output: format({
|
||||
taskID: params.task_id,
|
||||
state: "error",
|
||||
text: `Task not found: ${params.task_id}`,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
const waited =
|
||||
params.wait === true
|
||||
? yield* jobs.wait({ id: params.task_id, timeout: params.timeout_ms ?? DEFAULT_TIMEOUT })
|
||||
: { info: yield* jobs.get(params.task_id), timedOut: false }
|
||||
const inspected = waited.info
|
||||
? {
|
||||
result: {
|
||||
state: waited.info.status,
|
||||
text:
|
||||
waited.info.output ??
|
||||
waited.info.error ??
|
||||
(waited.info.status === "running" ? "Task is still running." : ""),
|
||||
},
|
||||
timedOut: waited.timedOut,
|
||||
}
|
||||
: params.wait === true
|
||||
? yield* waitForTerminal(params.task_id, params.timeout_ms ?? DEFAULT_TIMEOUT)
|
||||
: { result: yield* inspect(params.task_id), timedOut: false }
|
||||
const text = inspected.timedOut
|
||||
? `Timed out after ${params.timeout_ms ?? DEFAULT_TIMEOUT}ms while waiting for task completion.`
|
||||
: inspected.result.text
|
||||
|
||||
return {
|
||||
title: "Task status",
|
||||
metadata: {
|
||||
task_id: params.task_id,
|
||||
state: inspected.result.state,
|
||||
timed_out: inspected.timedOut,
|
||||
},
|
||||
output: format({
|
||||
taskID: params.task_id,
|
||||
state: inspected.result.state,
|
||||
text,
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
description: DESCRIPTION,
|
||||
parameters: Parameters,
|
||||
execute: (params: Schema.Schema.Type<typeof Parameters>, ctx: Tool.Context) =>
|
||||
run(params, ctx).pipe(Effect.orDie),
|
||||
}
|
||||
}),
|
||||
)
|
||||
@@ -1,13 +0,0 @@
|
||||
Poll the status of a background subagent task launched with the task tool.
|
||||
|
||||
Use this for tasks started with `task(background=true)`.
|
||||
|
||||
Parameters:
|
||||
- `task_id` (required): the task session id returned by the task tool
|
||||
- `wait` (optional): when true, wait for completion
|
||||
- `timeout_ms` (optional): max wait duration in milliseconds when `wait=true`
|
||||
|
||||
Returns compact, parseable output:
|
||||
- `task_id`
|
||||
- `state` (`running`, `completed`, `error`, or `cancelled`)
|
||||
- `<task_result>...</task_result>` or `<task_error>...</task_error>` containing final output, error summary, or current progress text
|
||||
@@ -0,0 +1,229 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import {
|
||||
buildConfigOptions,
|
||||
buildEffortSelectOption,
|
||||
buildModeSelectOption,
|
||||
buildModelSelectOption,
|
||||
formatCurrentModelId,
|
||||
formatVariantName,
|
||||
parseModelSelection,
|
||||
type ConfigOptionProvider,
|
||||
} from "@/acp-next/config-option"
|
||||
|
||||
const providers: ConfigOptionProvider[] = [
|
||||
{
|
||||
id: "anthropic",
|
||||
name: "Anthropic",
|
||||
models: {
|
||||
"claude/sonnet-4": {
|
||||
id: "claude/sonnet-4",
|
||||
name: "Claude Sonnet 4",
|
||||
variants: {
|
||||
default: {},
|
||||
high: {},
|
||||
"very-high": {},
|
||||
},
|
||||
},
|
||||
"claude-haiku": {
|
||||
id: "claude-haiku",
|
||||
name: "Claude Haiku",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "openai",
|
||||
name: "OpenAI",
|
||||
models: {
|
||||
"gpt-5": {
|
||||
id: "gpt-5",
|
||||
name: "GPT-5",
|
||||
variants: {
|
||||
minimal: {},
|
||||
low: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
describe("acp-next config options", () => {
|
||||
test("builds the model select option with ACP verifier category", () => {
|
||||
expect(
|
||||
buildModelSelectOption({
|
||||
providers,
|
||||
currentModel: { providerID: "anthropic", modelID: "claude/sonnet-4" },
|
||||
currentVariant: "high",
|
||||
}),
|
||||
).toEqual({
|
||||
id: "model",
|
||||
name: "Model",
|
||||
category: "model",
|
||||
type: "select",
|
||||
currentValue: "anthropic/claude/sonnet-4",
|
||||
options: [
|
||||
{ value: "anthropic/claude-haiku", name: "Anthropic/Claude Haiku" },
|
||||
{ value: "anthropic/claude/sonnet-4", name: "Anthropic/Claude Sonnet 4" },
|
||||
{ value: "openai/gpt-5", name: "OpenAI/GPT-5" },
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
test("includes variant ids in the model option only when requested", () => {
|
||||
const option = buildModelSelectOption({
|
||||
providers,
|
||||
currentModel: { providerID: "anthropic", modelID: "claude/sonnet-4" },
|
||||
currentVariant: "high",
|
||||
includeVariants: true,
|
||||
})
|
||||
|
||||
expect(option.currentValue).toBe("anthropic/claude/sonnet-4/high")
|
||||
if (option.type !== "select") throw new Error("expected select option")
|
||||
expect(option.options).toContainEqual({
|
||||
value: "anthropic/claude/sonnet-4/high",
|
||||
name: "Anthropic/Claude Sonnet 4 (High)",
|
||||
})
|
||||
expect(option.options).not.toContainEqual({
|
||||
value: "anthropic/claude/sonnet-4/default",
|
||||
name: "Anthropic/Claude Sonnet 4 (Default)",
|
||||
})
|
||||
})
|
||||
|
||||
test("builds effort option from variants and falls back to default when current variant is invalid", () => {
|
||||
expect(buildEffortSelectOption({ variants: ["low", "default", "high"], currentVariant: "missing" })).toEqual({
|
||||
id: "effort",
|
||||
name: "Effort",
|
||||
description: "Available effort levels for this model",
|
||||
category: "thought_level",
|
||||
type: "select",
|
||||
currentValue: "default",
|
||||
options: [
|
||||
{ value: "low", name: "Low" },
|
||||
{ value: "default", name: "Default" },
|
||||
{ value: "high", name: "High" },
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
test("effort fallback uses the first variant when default is absent", () => {
|
||||
expect(buildEffortSelectOption({ variants: ["minimal", "low"], currentVariant: "missing" })?.currentValue).toBe(
|
||||
"minimal",
|
||||
)
|
||||
})
|
||||
|
||||
test("omits effort option when there are no variants", () => {
|
||||
expect(buildEffortSelectOption({ variants: [] })).toBeUndefined()
|
||||
})
|
||||
|
||||
test("builds the mode select option with descriptions when present", () => {
|
||||
expect(
|
||||
buildModeSelectOption({
|
||||
currentModeId: "build",
|
||||
modes: [
|
||||
{ id: "build", name: "Build", description: "Make code changes" },
|
||||
{ id: "plan", name: "Plan" },
|
||||
],
|
||||
}),
|
||||
).toEqual({
|
||||
id: "mode",
|
||||
name: "Session Mode",
|
||||
category: "mode",
|
||||
type: "select",
|
||||
currentValue: "build",
|
||||
options: [
|
||||
{ value: "build", name: "Build", description: "Make code changes" },
|
||||
{ value: "plan", name: "Plan" },
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
test("builds full config options with model, effort, and mode in stable order", () => {
|
||||
const options = buildConfigOptions({
|
||||
providers,
|
||||
currentModel: { providerID: "anthropic", modelID: "claude/sonnet-4" },
|
||||
currentVariant: "very-high",
|
||||
modes: [
|
||||
{ id: "build", name: "Build" },
|
||||
{ id: "plan", name: "Plan" },
|
||||
],
|
||||
currentModeId: "plan",
|
||||
})
|
||||
|
||||
expect(options.map((option) => option.id)).toEqual(["model", "effort", "mode"])
|
||||
expect(options.map((option) => option.category)).toEqual(["model", "thought_level", "mode"])
|
||||
expect(options[1]?.currentValue).toBe("very-high")
|
||||
})
|
||||
|
||||
test("full config options omit effort for models without variants", () => {
|
||||
expect(
|
||||
buildConfigOptions({
|
||||
providers,
|
||||
currentModel: { providerID: "anthropic", modelID: "claude-haiku" },
|
||||
}).map((option) => option.id),
|
||||
).toEqual(["model"])
|
||||
})
|
||||
|
||||
test("parses provider/model selections", () => {
|
||||
expect(parseModelSelection("openai/gpt-5", providers)).toEqual({
|
||||
model: { providerID: "openai", modelID: "gpt-5" },
|
||||
})
|
||||
})
|
||||
|
||||
test("parses provider/model/variant selections when the base model exposes that variant", () => {
|
||||
expect(parseModelSelection("openai/gpt-5/low", providers)).toEqual({
|
||||
model: { providerID: "openai", modelID: "gpt-5" },
|
||||
variant: "low",
|
||||
})
|
||||
})
|
||||
|
||||
test("prefers exact slash-containing model ids before treating the tail as a variant", () => {
|
||||
expect(parseModelSelection("anthropic/claude/sonnet-4", providers)).toEqual({
|
||||
model: { providerID: "anthropic", modelID: "claude/sonnet-4" },
|
||||
})
|
||||
})
|
||||
|
||||
test("parses trailing variants for slash-containing model ids", () => {
|
||||
expect(parseModelSelection("anthropic/claude/sonnet-4/high", providers)).toEqual({
|
||||
model: { providerID: "anthropic", modelID: "claude/sonnet-4" },
|
||||
variant: "high",
|
||||
})
|
||||
})
|
||||
|
||||
test("keeps unknown trailing segments in the model id when they are not valid variants", () => {
|
||||
expect(parseModelSelection("anthropic/claude/sonnet-4/missing", providers)).toEqual({
|
||||
model: { providerID: "anthropic", modelID: "claude/sonnet-4/missing" },
|
||||
})
|
||||
})
|
||||
|
||||
test("formats current model ids with and without selected variants", () => {
|
||||
expect(
|
||||
formatCurrentModelId({
|
||||
model: { providerID: "openai", modelID: "gpt-5" },
|
||||
variant: "low",
|
||||
variants: ["minimal", "low"],
|
||||
}),
|
||||
).toBe("openai/gpt-5")
|
||||
expect(
|
||||
formatCurrentModelId({
|
||||
model: { providerID: "openai", modelID: "gpt-5" },
|
||||
variant: "low",
|
||||
variants: ["minimal", "low"],
|
||||
includeVariant: true,
|
||||
}),
|
||||
).toBe("openai/gpt-5/low")
|
||||
})
|
||||
|
||||
test("formats current model ids with variant fallback", () => {
|
||||
expect(
|
||||
formatCurrentModelId({
|
||||
model: { providerID: "anthropic", modelID: "claude/sonnet-4" },
|
||||
variant: "missing",
|
||||
variants: ["default", "high"],
|
||||
includeVariant: true,
|
||||
}),
|
||||
).toBe("anthropic/claude/sonnet-4/default")
|
||||
})
|
||||
|
||||
test("formats variant names for display", () => {
|
||||
expect(formatVariantName("very_high-effort")).toBe("Very High Effort")
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,201 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { ContentBlock } from "@agentclientprotocol/sdk"
|
||||
import { pathToFileURL } from "node:url"
|
||||
import { contentBlockToParts, partsToContentChunks, promptContentToParts } from "../../src/acp-next/content"
|
||||
|
||||
describe("acp-next content conversion", () => {
|
||||
test("plain text block becomes a text part", () => {
|
||||
expect(contentBlockToParts({ type: "text", text: "hello" })).toEqual([{ type: "text", text: "hello" }])
|
||||
})
|
||||
|
||||
test("assistant-only text audience becomes synthetic", () => {
|
||||
expect(
|
||||
contentBlockToParts({
|
||||
type: "text",
|
||||
text: "internal",
|
||||
annotations: { audience: ["assistant"] },
|
||||
}),
|
||||
).toEqual([{ type: "text", text: "internal", synthetic: true }])
|
||||
})
|
||||
|
||||
test("user-only text audience becomes ignored", () => {
|
||||
expect(
|
||||
contentBlockToParts({
|
||||
type: "text",
|
||||
text: "visible to user",
|
||||
annotations: { audience: ["user"] },
|
||||
}),
|
||||
).toEqual([{ type: "text", text: "visible to user", ignored: true }])
|
||||
})
|
||||
|
||||
test("image block with base64 data becomes a data URL file part", () => {
|
||||
expect(
|
||||
contentBlockToParts({
|
||||
type: "image",
|
||||
data: "AAAA",
|
||||
mimeType: "image/png",
|
||||
uri: "file:///tmp/screenshot.png",
|
||||
}),
|
||||
).toEqual([
|
||||
{
|
||||
type: "file",
|
||||
url: "data:image/png;base64,AAAA",
|
||||
filename: "screenshot.png",
|
||||
mime: "image/png",
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
test("image block with http URI becomes a file part", () => {
|
||||
expect(
|
||||
contentBlockToParts({
|
||||
type: "image",
|
||||
data: "",
|
||||
mimeType: "image/jpeg",
|
||||
uri: "http://example.com/assets/photo.jpg",
|
||||
}),
|
||||
).toEqual([
|
||||
{
|
||||
type: "file",
|
||||
url: "http://example.com/assets/photo.jpg",
|
||||
filename: "photo.jpg",
|
||||
mime: "image/jpeg",
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
test("resource_link file URL becomes a file part with name and fallback mime", () => {
|
||||
expect(
|
||||
contentBlockToParts({
|
||||
type: "resource_link",
|
||||
uri: "file:///tmp/notes.txt",
|
||||
name: "client-notes.txt",
|
||||
}),
|
||||
).toEqual([
|
||||
{
|
||||
type: "file",
|
||||
url: "file:///tmp/notes.txt",
|
||||
filename: "client-notes.txt",
|
||||
mime: "text/plain",
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
test("resource_link zed path becomes a file URL part", () => {
|
||||
expect(
|
||||
contentBlockToParts({
|
||||
type: "resource_link",
|
||||
uri: "zed://workspace?path=/tmp/project/src/app.ts",
|
||||
name: "app.ts",
|
||||
mimeType: "text/typescript",
|
||||
}),
|
||||
).toEqual([
|
||||
{
|
||||
type: "file",
|
||||
url: pathToFileURL("/tmp/project/src/app.ts").href,
|
||||
filename: "app.ts",
|
||||
mime: "text/typescript",
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
test("resource with text becomes a text part", () => {
|
||||
expect(
|
||||
contentBlockToParts({
|
||||
type: "resource",
|
||||
resource: {
|
||||
uri: "file:///tmp/context.txt",
|
||||
mimeType: "text/plain",
|
||||
text: "context",
|
||||
},
|
||||
}),
|
||||
).toEqual([{ type: "text", text: "context" }])
|
||||
})
|
||||
|
||||
test("resource with blob and mimeType becomes a data URL file part", () => {
|
||||
expect(
|
||||
contentBlockToParts({
|
||||
type: "resource",
|
||||
resource: {
|
||||
uri: "file:///tmp/report.pdf",
|
||||
mimeType: "application/pdf",
|
||||
blob: "JVBERg==",
|
||||
},
|
||||
}),
|
||||
).toEqual([
|
||||
{
|
||||
type: "file",
|
||||
url: "data:application/pdf;base64,JVBERg==",
|
||||
filename: "report.pdf",
|
||||
mime: "application/pdf",
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
test("data URL resource is preserved as a file part", () => {
|
||||
expect(
|
||||
contentBlockToParts({
|
||||
type: "resource",
|
||||
resource: {
|
||||
uri: "data:text/plain;base64,aGVsbG8=",
|
||||
mimeType: "text/plain",
|
||||
blob: "ignored",
|
||||
},
|
||||
}),
|
||||
).toEqual([
|
||||
{
|
||||
type: "file",
|
||||
url: "data:text/plain;base64,aGVsbG8=",
|
||||
filename: "file",
|
||||
mime: "text/plain",
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
test("unsupported blocks are ignored", () => {
|
||||
expect(promptContentToParts([{ type: "audio", data: "AAAA", mimeType: "audio/wav" }])).toEqual([])
|
||||
expect(promptContentToParts([{ type: "unknown", text: "skip" } as unknown as ContentBlock])).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe("acp-next replay conversion", () => {
|
||||
test("replays text audience annotations", () => {
|
||||
expect(partsToContentChunks([{ type: "text", text: "cached", synthetic: true }])).toEqual([
|
||||
{
|
||||
content: {
|
||||
type: "text",
|
||||
text: "cached",
|
||||
annotations: { audience: ["assistant"] },
|
||||
},
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
test("replays file and data URL parts as ACP content", () => {
|
||||
expect(
|
||||
partsToContentChunks([
|
||||
{ type: "file", url: "file:///tmp/readme.md", filename: "readme.md", mime: "text/markdown" },
|
||||
{ type: "file", url: "data:text/plain;base64,aGVsbG8=", filename: "note.txt", mime: "text/plain" },
|
||||
]),
|
||||
).toEqual([
|
||||
{
|
||||
content: {
|
||||
type: "resource_link",
|
||||
uri: "file:///tmp/readme.md",
|
||||
name: "readme.md",
|
||||
mimeType: "text/markdown",
|
||||
},
|
||||
},
|
||||
{
|
||||
content: {
|
||||
type: "resource",
|
||||
resource: {
|
||||
uri: pathToFileURL("note.txt").href,
|
||||
mimeType: "text/plain",
|
||||
text: "hello",
|
||||
},
|
||||
},
|
||||
},
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,185 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Directory } from "@/acp-next/directory"
|
||||
import { Command } from "@/command"
|
||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
import { Provider } from "@/provider/provider"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { it } from "../lib/effect"
|
||||
|
||||
const command = (name: string): Command.Info => ({
|
||||
name,
|
||||
source: "command",
|
||||
template: `run ${name}`,
|
||||
hints: [],
|
||||
})
|
||||
|
||||
const model = (providerID: ProviderV2.ID, id: string, variants?: Directory.ModelVariants): Provider.Model => ({
|
||||
id: ProviderV2.ModelID.make(id),
|
||||
providerID,
|
||||
api: {
|
||||
id,
|
||||
url: "https://example.com",
|
||||
npm: "@ai-sdk/openai-compatible",
|
||||
},
|
||||
name: id,
|
||||
family: "test",
|
||||
capabilities: {
|
||||
temperature: true,
|
||||
reasoning: Boolean(variants),
|
||||
attachment: false,
|
||||
toolcall: true,
|
||||
input: { text: true, audio: false, image: false, video: false, pdf: false },
|
||||
output: { text: true, audio: false, image: false, video: false, pdf: false },
|
||||
interleaved: false,
|
||||
},
|
||||
cost: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cache: { read: 0, write: 0 },
|
||||
},
|
||||
limit: {
|
||||
context: 128000,
|
||||
output: 4096,
|
||||
},
|
||||
status: "active",
|
||||
options: {},
|
||||
headers: {},
|
||||
release_date: "2026-01-01",
|
||||
...(variants ? { variants } : {}),
|
||||
})
|
||||
|
||||
const snapshot = (directory: string) => {
|
||||
const providerID = ProviderV2.ID.make(`provider-${directory}`)
|
||||
const modelID = ProviderV2.ModelID.make(`model-${directory}`)
|
||||
const providers = {
|
||||
[providerID]: {
|
||||
id: providerID,
|
||||
name: `Provider ${directory}`,
|
||||
source: "config",
|
||||
env: [],
|
||||
options: {},
|
||||
models: {
|
||||
[modelID]: model(providerID, modelID, {
|
||||
low: { reasoningEffort: "low" },
|
||||
high: { reasoningEffort: "high" },
|
||||
}),
|
||||
[ProviderV2.ModelID.make(`plain-${directory}`)]: model(providerID, `plain-${directory}`),
|
||||
},
|
||||
},
|
||||
} satisfies Record<ProviderV2.ID, Provider.Info>
|
||||
|
||||
return Directory.build({
|
||||
directory,
|
||||
providers,
|
||||
modes: [
|
||||
{ id: "build", name: `build-${directory}` },
|
||||
{ id: "plan", name: `plan-${directory}`, description: "plan first" },
|
||||
],
|
||||
defaultModeID: "build",
|
||||
commands: [command(`init-${directory}`), command(`review-${directory}`)],
|
||||
defaultModel: { providerID, modelID },
|
||||
})
|
||||
}
|
||||
|
||||
const fakeLayer = (calls: string[]) =>
|
||||
Directory.layer.pipe(
|
||||
Layer.provide(
|
||||
Layer.succeed(
|
||||
Directory.Loader,
|
||||
Directory.Loader.of({
|
||||
load: (directory) =>
|
||||
Effect.sync(() => {
|
||||
calls.push(directory)
|
||||
return snapshot(directory)
|
||||
}),
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
describe("ACP next directory snapshot", () => {
|
||||
it.effect("two concurrent callers share one load", () => {
|
||||
const calls: string[] = []
|
||||
return Effect.gen(function* () {
|
||||
const directory = yield* Directory.Service
|
||||
const [first, second] = yield* Effect.all([directory.get("alpha"), directory.get("alpha")], {
|
||||
concurrency: "unbounded",
|
||||
})
|
||||
|
||||
expect(calls).toEqual(["alpha"])
|
||||
expect(first).toBe(second)
|
||||
}).pipe(Effect.provide(fakeLayer(calls)))
|
||||
})
|
||||
|
||||
it.effect("warm calls use cached data", () => {
|
||||
const calls: string[] = []
|
||||
return Effect.gen(function* () {
|
||||
const directory = yield* Directory.Service
|
||||
const first = yield* directory.get("alpha")
|
||||
const second = yield* directory.get("alpha")
|
||||
|
||||
expect(calls).toEqual(["alpha"])
|
||||
expect(first).toBe(second)
|
||||
}).pipe(Effect.provide(fakeLayer(calls)))
|
||||
})
|
||||
|
||||
it.effect("different directories get different snapshots", () => {
|
||||
const calls: string[] = []
|
||||
return Effect.gen(function* () {
|
||||
const directory = yield* Directory.Service
|
||||
const [alpha, beta] = yield* Effect.all([directory.get("alpha"), directory.get("beta")], {
|
||||
concurrency: "unbounded",
|
||||
})
|
||||
|
||||
expect(calls.toSorted()).toEqual(["alpha", "beta"])
|
||||
expect(alpha.directory).toBe("alpha")
|
||||
expect(beta.directory).toBe("beta")
|
||||
expect(alpha.defaultModel?.providerID).not.toBe(beta.defaultModel?.providerID)
|
||||
}).pipe(Effect.provide(fakeLayer(calls)))
|
||||
})
|
||||
|
||||
it.effect("model variant lookup works", () =>
|
||||
Effect.gen(function* () {
|
||||
const directory = yield* Directory.Service
|
||||
const alpha = yield* directory.get("alpha")
|
||||
const model = alpha.defaultModel!
|
||||
|
||||
expect(directory.variants(alpha, model)).toEqual({
|
||||
low: { reasoningEffort: "low" },
|
||||
high: { reasoningEffort: "high" },
|
||||
})
|
||||
expect(directory.variants(alpha, { ...model, modelID: ProviderV2.ModelID.make("missing") })).toBeUndefined()
|
||||
}).pipe(Effect.provide(fakeLayer([]))),
|
||||
)
|
||||
|
||||
it.effect("commands and modes are included", () =>
|
||||
Effect.gen(function* () {
|
||||
const directory = yield* Directory.Service
|
||||
const alpha = yield* directory.get("alpha")
|
||||
|
||||
expect(alpha.availableCommands.map((item) => item.name)).toEqual(["init-alpha", "review-alpha"])
|
||||
expect(alpha.availableModes).toEqual([
|
||||
{ id: "build", name: "build-alpha" },
|
||||
{ id: "plan", name: "plan-alpha", description: "plan first" },
|
||||
])
|
||||
expect(alpha.defaultModeID).toBe("build")
|
||||
}).pipe(Effect.provide(fakeLayer([]))),
|
||||
)
|
||||
|
||||
it.effect("falls back when the default mode is not available", () =>
|
||||
Effect.sync(() => {
|
||||
expect(
|
||||
Directory.build({
|
||||
directory: "alpha",
|
||||
providers: {},
|
||||
modes: [
|
||||
{ id: "build", name: "Build" },
|
||||
{ id: "plan", name: "Plan" },
|
||||
],
|
||||
defaultModeID: "hidden",
|
||||
commands: [],
|
||||
}).defaultModeID,
|
||||
).toBe("build")
|
||||
}),
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,71 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { RequestError } from "@agentclientprotocol/sdk"
|
||||
import * as ACPNextError from "../../src/acp-next/error"
|
||||
|
||||
describe("acp-next.error", () => {
|
||||
test("maps validation failures to invalid params", () => {
|
||||
const cases: ACPNextError.Error[] = [
|
||||
new ACPNextError.SessionNotFoundError({ sessionId: "ses_missing" }),
|
||||
new ACPNextError.InvalidConfigOptionError({ configId: "temperature" }),
|
||||
new ACPNextError.InvalidModelError({ providerId: "anthropic", modelId: "claude-missing" }),
|
||||
new ACPNextError.InvalidEffortError({ effort: "extreme" }),
|
||||
new ACPNextError.InvalidModeError({ mode: "turbo" }),
|
||||
]
|
||||
|
||||
expect(cases.map((error) => ACPNextError.toRequestError(error).code)).toEqual([
|
||||
-32602, -32602, -32602, -32602, -32602,
|
||||
])
|
||||
})
|
||||
|
||||
test("includes safe validation details", () => {
|
||||
expect(ACPNextError.toRequestError(new ACPNextError.SessionNotFoundError({ sessionId: "ses_123" }))).toMatchObject({
|
||||
code: -32602,
|
||||
data: { sessionId: "ses_123" },
|
||||
})
|
||||
expect(ACPNextError.toRequestError(new ACPNextError.InvalidModelError({ modelId: "gpt-missing" }))).toMatchObject({
|
||||
code: -32602,
|
||||
data: { modelId: "gpt-missing" },
|
||||
})
|
||||
})
|
||||
|
||||
test("maps auth required to the SDK auth error", () => {
|
||||
const requestError = ACPNextError.toRequestError(new ACPNextError.AuthRequiredError({ providerId: "anthropic" }))
|
||||
|
||||
expect(requestError).toBeInstanceOf(RequestError)
|
||||
expect(requestError.code).toBe(-32000)
|
||||
expect(requestError.message).toBe("Authentication required: provider authentication required")
|
||||
expect(requestError.data).toEqual({ providerId: "anthropic" })
|
||||
})
|
||||
|
||||
test("maps unsupported operations to method not found", () => {
|
||||
const requestError = ACPNextError.toRequestError(
|
||||
new ACPNextError.UnsupportedOperationError({ method: "session/new" }),
|
||||
)
|
||||
|
||||
expect(requestError.code).toBe(-32601)
|
||||
expect(requestError.data).toEqual({ method: "session/new" })
|
||||
})
|
||||
|
||||
test("maps service failures to safe internal errors", () => {
|
||||
const requestError = ACPNextError.toRequestError(
|
||||
new ACPNextError.ServiceFailureError({ service: "provider", safeMessage: "Provider request failed" }),
|
||||
)
|
||||
|
||||
expect(requestError.code).toBe(-32603)
|
||||
expect(requestError.message).toBe("Internal error: Provider request failed")
|
||||
expect(requestError.data).toEqual({ service: "provider" })
|
||||
})
|
||||
|
||||
test("wraps unknown defects without leaking raw details", () => {
|
||||
const requestError = ACPNextError.toRequestError(
|
||||
ACPNextError.fromUnknownDefect(new Error("stack has sk-ant-secret and oauth refresh token")),
|
||||
)
|
||||
const serialized = JSON.stringify(requestError.toErrorResponse())
|
||||
|
||||
expect(requestError.code).toBe(-32603)
|
||||
expect(requestError.message).toBe("Internal error: Internal service failure")
|
||||
expect(serialized).not.toContain("sk-ant-secret")
|
||||
expect(serialized).not.toContain("oauth refresh token")
|
||||
expect(serialized).not.toContain("stack")
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,534 @@
|
||||
import { describe, expect, it } from "bun:test"
|
||||
import type {
|
||||
AgentSideConnection,
|
||||
LoadSessionResponse,
|
||||
NewSessionResponse,
|
||||
SessionConfigOption,
|
||||
SessionConfigSelectOption,
|
||||
SetSessionConfigOptionResponse,
|
||||
} from "@agentclientprotocol/sdk"
|
||||
import type { OpencodeClient } from "@opencode-ai/sdk/v2"
|
||||
import { Effect } from "effect"
|
||||
import * as ACPNextService from "@/acp-next/service"
|
||||
import * as ACPNextError from "@/acp-next/error"
|
||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
import type { Provider } from "@/provider/provider"
|
||||
|
||||
const providerID = ProviderV2.ID.make("test")
|
||||
const modelID = ProviderV2.ModelID.make("test-model")
|
||||
const configuredModelID = ProviderV2.ModelID.make("configured-model")
|
||||
const secondModelID = ProviderV2.ModelID.make("second-model")
|
||||
|
||||
const provider: Provider.Info = {
|
||||
id: providerID,
|
||||
name: "Test",
|
||||
source: "config",
|
||||
env: [],
|
||||
options: {},
|
||||
models: {
|
||||
[modelID]: {
|
||||
id: modelID,
|
||||
providerID,
|
||||
api: {
|
||||
id: modelID,
|
||||
url: "https://example.com",
|
||||
npm: "@ai-sdk/openai-compatible",
|
||||
},
|
||||
name: "Test Model",
|
||||
family: "test",
|
||||
capabilities: {
|
||||
temperature: true,
|
||||
reasoning: true,
|
||||
attachment: false,
|
||||
toolcall: true,
|
||||
input: { text: true, audio: false, image: false, video: false, pdf: false },
|
||||
output: { text: true, audio: false, image: false, video: false, pdf: false },
|
||||
interleaved: false,
|
||||
},
|
||||
cost: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cache: { read: 0, write: 0 },
|
||||
},
|
||||
limit: {
|
||||
context: 128000,
|
||||
output: 4096,
|
||||
},
|
||||
status: "active",
|
||||
options: {},
|
||||
headers: {},
|
||||
release_date: "2026-01-01",
|
||||
variants: {
|
||||
default: {},
|
||||
high: { reasoningEffort: "high" },
|
||||
},
|
||||
},
|
||||
[configuredModelID]: {
|
||||
id: configuredModelID,
|
||||
providerID,
|
||||
api: {
|
||||
id: configuredModelID,
|
||||
url: "https://example.com",
|
||||
npm: "@ai-sdk/openai-compatible",
|
||||
},
|
||||
name: "Configured Model",
|
||||
family: "test",
|
||||
capabilities: {
|
||||
temperature: true,
|
||||
reasoning: false,
|
||||
attachment: false,
|
||||
toolcall: true,
|
||||
input: { text: true, audio: false, image: false, video: false, pdf: false },
|
||||
output: { text: true, audio: false, image: false, video: false, pdf: false },
|
||||
interleaved: false,
|
||||
},
|
||||
cost: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cache: { read: 0, write: 0 },
|
||||
},
|
||||
limit: {
|
||||
context: 128000,
|
||||
output: 4096,
|
||||
},
|
||||
status: "active",
|
||||
options: {},
|
||||
headers: {},
|
||||
release_date: "2026-01-01",
|
||||
},
|
||||
[secondModelID]: {
|
||||
id: secondModelID,
|
||||
providerID,
|
||||
api: {
|
||||
id: secondModelID,
|
||||
url: "https://example.com",
|
||||
npm: "@ai-sdk/openai-compatible",
|
||||
},
|
||||
name: "Second Model",
|
||||
family: "test",
|
||||
capabilities: {
|
||||
temperature: true,
|
||||
reasoning: true,
|
||||
attachment: false,
|
||||
toolcall: true,
|
||||
input: { text: true, audio: false, image: false, video: false, pdf: false },
|
||||
output: { text: true, audio: false, image: false, video: false, pdf: false },
|
||||
interleaved: false,
|
||||
},
|
||||
cost: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cache: { read: 0, write: 0 },
|
||||
},
|
||||
limit: {
|
||||
context: 128000,
|
||||
output: 4096,
|
||||
},
|
||||
status: "active",
|
||||
options: {},
|
||||
headers: {},
|
||||
release_date: "2026-01-01",
|
||||
variants: {
|
||||
low: { reasoningEffort: "low" },
|
||||
medium: { reasoningEffort: "medium" },
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
describe("ACP next service sessions", () => {
|
||||
const makeService = (messages: readonly { info: unknown; parts: readonly unknown[] }[] = []) => {
|
||||
const updates: unknown[] = []
|
||||
const mcpAdds: 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: {} },
|
||||
{ name: "plan", mode: "primary", description: "Plan first", permission: [], options: {} },
|
||||
{ name: "hidden", mode: "primary", hidden: true, permission: [], options: {} },
|
||||
],
|
||||
}),
|
||||
skills: () =>
|
||||
Promise.resolve({
|
||||
data: [{ name: "review-skill", description: "Review", location: "/skills/review", content: "review" }],
|
||||
}),
|
||||
},
|
||||
command: {
|
||||
list: () =>
|
||||
Promise.resolve({
|
||||
data: [{ name: "init", description: "Initialize", source: "command", template: "init", hints: [] }],
|
||||
}),
|
||||
},
|
||||
session: {
|
||||
create: () => Promise.resolve({ data: { id: "ses_new" } }),
|
||||
get: () => Promise.resolve({ data: { id: "ses_loaded" } }),
|
||||
list: () => Promise.resolve({ data: [] }),
|
||||
messages: () => Promise.resolve({ data: messages }),
|
||||
},
|
||||
mcp: {
|
||||
add: (input: { name?: string }) => {
|
||||
if (input.name) mcpAdds.push(input.name)
|
||||
return Promise.resolve({ data: {} })
|
||||
},
|
||||
},
|
||||
} as unknown as OpencodeClient
|
||||
const connection = {
|
||||
sessionUpdate: (update: unknown) => {
|
||||
updates.push(update)
|
||||
return Promise.resolve()
|
||||
},
|
||||
} as Pick<AgentSideConnection, "sessionUpdate">
|
||||
|
||||
return { service: ACPNextService.make({ sdk, connection }), updates, mcpAdds }
|
||||
}
|
||||
|
||||
it("creates a backed session with config options and command update", async () => {
|
||||
const { service, updates, mcpAdds } = makeService()
|
||||
const result = await Effect.runPromise(
|
||||
service.newSession({
|
||||
cwd: "/workspace",
|
||||
mcpServers: [
|
||||
{ name: "tools", command: "node", args: ["server.js"], env: [] },
|
||||
{ name: "tools", command: "node", args: ["server.js"], env: [] },
|
||||
],
|
||||
}),
|
||||
)
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 5))
|
||||
|
||||
expect(result.sessionId).toBe("ses_new")
|
||||
expect(categories(result)).toContain("model")
|
||||
expect(categories(result)).toContain("thought_level")
|
||||
expect(categories(result)).toContain("mode")
|
||||
expect(updates).toHaveLength(1)
|
||||
expect(JSON.stringify(updates[0])).toContain("available_commands_update")
|
||||
expect(JSON.stringify(updates[0])).toContain("review-skill")
|
||||
expect(mcpAdds).toEqual(["tools"])
|
||||
})
|
||||
|
||||
it("loads a session and restores model variant and mode from messages", async () => {
|
||||
const { service } = makeService([
|
||||
{
|
||||
info: {
|
||||
role: "assistant",
|
||||
providerID: "test",
|
||||
modelID: "test-model",
|
||||
variant: "high",
|
||||
mode: "plan",
|
||||
},
|
||||
parts: [],
|
||||
},
|
||||
])
|
||||
const result = await Effect.runPromise(
|
||||
service.loadSession({ cwd: "/workspace", sessionId: "ses_loaded", mcpServers: [] }),
|
||||
)
|
||||
|
||||
expect(result.configOptions?.find((option) => option.id === "effort")?.currentValue).toBe("high")
|
||||
expect(result.configOptions?.find((option) => option.id === "mode")?.currentValue).toBe("plan")
|
||||
})
|
||||
|
||||
it("restores model variant and mode from the latest user message", async () => {
|
||||
const { service } = makeService([
|
||||
{
|
||||
info: {
|
||||
role: "user",
|
||||
model: { providerID: "test", modelID: "test-model", variant: "default" },
|
||||
agent: "build",
|
||||
},
|
||||
parts: [],
|
||||
},
|
||||
{
|
||||
info: {
|
||||
role: "user",
|
||||
model: { providerID: "test", modelID: "test-model", variant: "high" },
|
||||
agent: "plan",
|
||||
},
|
||||
parts: [],
|
||||
},
|
||||
])
|
||||
const result = await Effect.runPromise(
|
||||
service.loadSession({ cwd: "/workspace", sessionId: "ses_loaded", mcpServers: [] }),
|
||||
)
|
||||
|
||||
expect(result.configOptions?.find((option) => option.id === "effort")?.currentValue).toBe("high")
|
||||
expect(result.configOptions?.find((option) => option.id === "mode")?.currentValue).toBe("plan")
|
||||
})
|
||||
|
||||
it("maps provider auth failures to auth-required request errors", async () => {
|
||||
const service = ACPNextService.make({
|
||||
sdk: {
|
||||
config: {
|
||||
providers: () => Promise.reject({ name: "ProviderAuthError", data: { providerID: "test" } }),
|
||||
get: () => Promise.resolve({ data: {} }),
|
||||
},
|
||||
app: {
|
||||
agents: () => Promise.resolve({ data: [] }),
|
||||
skills: () => Promise.resolve({ data: [] }),
|
||||
},
|
||||
command: {
|
||||
list: () => Promise.resolve({ data: [] }),
|
||||
},
|
||||
} as unknown as OpencodeClient,
|
||||
})
|
||||
const error = await Effect.runPromise(
|
||||
service
|
||||
.newSession({ cwd: "/workspace", mcpServers: [] })
|
||||
.pipe(Effect.mapError(ACPNextError.toRequestError), Effect.flip),
|
||||
)
|
||||
|
||||
expect(error.code).toBe(-32000)
|
||||
})
|
||||
|
||||
it("does not cache failed directory snapshots", async () => {
|
||||
let providersCalls = 0
|
||||
const sdk = {
|
||||
config: {
|
||||
providers: () => {
|
||||
providersCalls++
|
||||
if (providersCalls === 1) {
|
||||
return Promise.reject({ name: "ProviderAuthError", data: { providerID: "test" } })
|
||||
}
|
||||
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: [] }),
|
||||
},
|
||||
command: {
|
||||
list: () => Promise.resolve({ data: [] }),
|
||||
},
|
||||
session: {
|
||||
create: () => Promise.resolve({ data: { id: "ses_retry" } }),
|
||||
list: () => 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: [] })
|
||||
.pipe(Effect.mapError(ACPNextError.toRequestError), Effect.flip),
|
||||
)
|
||||
const second = await Effect.runPromise(service.newSession({ cwd: "/workspace", mcpServers: [] }))
|
||||
|
||||
expect(first.code).toBe(-32000)
|
||||
expect(second.sessionId).toBe("ses_retry")
|
||||
expect(providersCalls).toBe(2)
|
||||
})
|
||||
|
||||
it("registers same-name MCP servers again for different sessions or configs", async () => {
|
||||
const adds: unknown[] = []
|
||||
let nextSession = 0
|
||||
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: () => {
|
||||
nextSession++
|
||||
return Promise.resolve({ data: { id: `ses_${nextSession}` } })
|
||||
},
|
||||
list: () => Promise.resolve({ data: [] }),
|
||||
},
|
||||
mcp: {
|
||||
add: (input: unknown) => {
|
||||
adds.push(input)
|
||||
return Promise.resolve({ data: {} })
|
||||
},
|
||||
},
|
||||
} as unknown as OpencodeClient
|
||||
const service = ACPNextService.make({ sdk })
|
||||
|
||||
await Effect.runPromise(
|
||||
service.newSession({
|
||||
cwd: "/workspace",
|
||||
mcpServers: [{ name: "tools", command: "node", args: ["one.js"], env: [] }],
|
||||
}),
|
||||
)
|
||||
await Effect.runPromise(
|
||||
service.newSession({
|
||||
cwd: "/workspace",
|
||||
mcpServers: [{ name: "tools", command: "node", args: ["two.js"], env: [] }],
|
||||
}),
|
||||
)
|
||||
|
||||
expect(adds).toHaveLength(2)
|
||||
expect(JSON.stringify(adds[0])).toContain("one.js")
|
||||
expect(JSON.stringify(adds[1])).toContain("two.js")
|
||||
})
|
||||
|
||||
it("uses the configured model as the new session default", async () => {
|
||||
const sdk = {
|
||||
config: {
|
||||
providers: () => Promise.resolve({ data: { providers: [provider], default: { test: modelID } } }),
|
||||
get: () => Promise.resolve({ data: { model: "test/configured-model" } }),
|
||||
},
|
||||
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: () => Promise.resolve({ data: [] }),
|
||||
},
|
||||
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("configured-model")
|
||||
expect(result.configOptions?.find((option) => option.id === "model")?.currentValue).toBe("test/configured-model")
|
||||
})
|
||||
|
||||
it("switches model and returns updated model and effort options", async () => {
|
||||
const { service } = makeService()
|
||||
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(select(updated, "effort")?.currentValue).toBe("low")
|
||||
expect(flattenSelectOptions(select(updated, "effort")).map((option) => option.value)).toEqual(["low", "medium"])
|
||||
})
|
||||
|
||||
it("switches effort and returns the updated effort current value", async () => {
|
||||
const { service } = makeService()
|
||||
const session = await Effect.runPromise(service.newSession({ cwd: "/workspace", mcpServers: [] }))
|
||||
const updated = await Effect.runPromise(
|
||||
service.setSessionConfigOption({
|
||||
sessionId: session.sessionId,
|
||||
configId: "effort",
|
||||
value: "high",
|
||||
}),
|
||||
)
|
||||
|
||||
expect(select(updated, "effort")?.currentValue).toBe("high")
|
||||
})
|
||||
|
||||
it("switches mode and returns the updated mode current value", async () => {
|
||||
const { service } = makeService()
|
||||
const session = await Effect.runPromise(service.newSession({ cwd: "/workspace", mcpServers: [] }))
|
||||
const updated = await Effect.runPromise(
|
||||
service.setSessionConfigOption({
|
||||
sessionId: session.sessionId,
|
||||
configId: "mode",
|
||||
value: "plan",
|
||||
}),
|
||||
)
|
||||
|
||||
expect(select(updated, "mode")?.currentValue).toBe("plan")
|
||||
})
|
||||
|
||||
it("maps invalid model effort mode and config id to invalid params", async () => {
|
||||
const { service } = makeService()
|
||||
const session = await Effect.runPromise(service.newSession({ cwd: "/workspace", mcpServers: [] }))
|
||||
|
||||
const results = await Promise.all(
|
||||
[
|
||||
{ configId: "model", value: "test/missing-model" },
|
||||
{ configId: "effort", value: "max" },
|
||||
{ configId: "mode", value: "missing-mode" },
|
||||
{ configId: "missing", value: "value" },
|
||||
].map((input) =>
|
||||
Effect.runPromise(
|
||||
service
|
||||
.setSessionConfigOption({ sessionId: session.sessionId, ...input })
|
||||
.pipe(Effect.mapError(ACPNextError.toRequestError), Effect.flip),
|
||||
),
|
||||
),
|
||||
)
|
||||
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
|
||||
const sdk = {
|
||||
config: {
|
||||
providers: () => {
|
||||
providersCalls++
|
||||
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: [] }),
|
||||
},
|
||||
command: {
|
||||
list: () => {
|
||||
commandCalls++
|
||||
return Promise.resolve({ data: [] })
|
||||
},
|
||||
},
|
||||
session: {
|
||||
create: () => Promise.resolve({ data: { id: "ses_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: [] }))
|
||||
|
||||
expect(providersCalls).toBe(1)
|
||||
expect(commandCalls).toBe(1)
|
||||
|
||||
await Effect.runPromise(
|
||||
service.setSessionConfigOption({
|
||||
sessionId: session.sessionId,
|
||||
configId: "effort",
|
||||
value: "high",
|
||||
}),
|
||||
)
|
||||
|
||||
expect(providersCalls).toBe(1)
|
||||
expect(commandCalls).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
function categories(result: NewSessionResponse | LoadSessionResponse) {
|
||||
return result.configOptions?.map((option) => option.category) ?? []
|
||||
}
|
||||
|
||||
function select(result: SetSessionConfigOptionResponse, id: string) {
|
||||
return result.configOptions.find(
|
||||
(option): option is Extract<SessionConfigOption, { type: "select" }> =>
|
||||
option.id === id && option.type === "select",
|
||||
)
|
||||
}
|
||||
|
||||
function flattenSelectOptions(option: Extract<SessionConfigOption, { type: "select" }> | undefined) {
|
||||
return option?.options.flatMap((item): SessionConfigSelectOption[] => ("value" in item ? [item] : item.options)) ?? []
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import type { McpServer } from "@agentclientprotocol/sdk"
|
||||
import { Effect } from "effect"
|
||||
import * as ACPNextError from "@/acp-next/error"
|
||||
import * as ACPNextSession from "@/acp-next/session"
|
||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
import { testEffect } from "../lib/effect"
|
||||
|
||||
const sessionTest = testEffect(ACPNextSession.defaultLayer)
|
||||
|
||||
const model = (providerID: string, modelID: string): ACPNextSession.SelectedModel => ({
|
||||
providerID: ProviderV2.ID.make(providerID),
|
||||
modelID: ProviderV2.ModelID.make(modelID),
|
||||
})
|
||||
|
||||
const mcpServer: McpServer = {
|
||||
name: "local-tools",
|
||||
command: "node",
|
||||
args: ["server.js"],
|
||||
env: [],
|
||||
}
|
||||
|
||||
describe("acp-next session state", () => {
|
||||
sessionTest.effect("creates and retrieves session state", () =>
|
||||
Effect.gen(function* () {
|
||||
const createdAt = new Date("2026-05-25T00:00:00.000Z")
|
||||
const created = yield* ACPNextSession.Service.use((session) =>
|
||||
session.create({
|
||||
id: "ses_1",
|
||||
cwd: "/workspace",
|
||||
mcpServers: [mcpServer],
|
||||
createdAt,
|
||||
model: model("anthropic", "claude-sonnet"),
|
||||
variant: "high",
|
||||
modeId: "build",
|
||||
}),
|
||||
)
|
||||
const loaded = yield* ACPNextSession.Service.use((session) => session.get("ses_1"))
|
||||
|
||||
expect(created).toMatchObject({
|
||||
id: "ses_1",
|
||||
cwd: "/workspace",
|
||||
mcpServers: [mcpServer],
|
||||
model: model("anthropic", "claude-sonnet"),
|
||||
variant: "high",
|
||||
modeId: "build",
|
||||
})
|
||||
expect(loaded.createdAt).toEqual(createdAt)
|
||||
expect(loaded.knownParts.size).toBe(0)
|
||||
}),
|
||||
)
|
||||
|
||||
sessionTest.effect("fails required lookups with typed SessionNotFound", () =>
|
||||
Effect.gen(function* () {
|
||||
const error = yield* ACPNextSession.Service.use((session) => session.get("ses_missing")).pipe(Effect.flip)
|
||||
|
||||
expect(error).toBeInstanceOf(ACPNextError.SessionNotFoundError)
|
||||
expect(error.sessionId).toBe("ses_missing")
|
||||
}),
|
||||
)
|
||||
|
||||
sessionTest.effect("tryGet lets event routing ignore unknown sessions", () =>
|
||||
Effect.gen(function* () {
|
||||
const missing = yield* ACPNextSession.Service.use((session) => session.tryGet("ses_missing"))
|
||||
const missingPart = yield* ACPNextSession.Service.use((session) =>
|
||||
session.tryGetPartMetadata({ sessionId: "ses_missing", messageId: "msg_1", partId: "part_1" }),
|
||||
)
|
||||
|
||||
expect(missing).toBeUndefined()
|
||||
expect(missingPart).toBeUndefined()
|
||||
}),
|
||||
)
|
||||
|
||||
sessionTest.effect("updates selected model while preserving session identity and inputs", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* ACPNextSession.Service.use((session) =>
|
||||
session.create({
|
||||
id: "ses_model",
|
||||
cwd: "/workspace",
|
||||
mcpServers: [mcpServer],
|
||||
model: model("anthropic", "claude-sonnet"),
|
||||
variant: "high",
|
||||
modeId: "build",
|
||||
}),
|
||||
)
|
||||
|
||||
const updated = yield* ACPNextSession.Service.use((session) =>
|
||||
session.setModel("ses_model", model("openai", "gpt-5")),
|
||||
)
|
||||
|
||||
expect(updated.id).toBe("ses_model")
|
||||
expect(updated.cwd).toBe("/workspace")
|
||||
expect(updated.mcpServers).toEqual([mcpServer])
|
||||
expect(updated.model).toEqual(model("openai", "gpt-5"))
|
||||
expect(updated.variant).toBe("high")
|
||||
expect(updated.modeId).toBe("build")
|
||||
}),
|
||||
)
|
||||
|
||||
sessionTest.effect("updates selected variant and mode independently", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* ACPNextSession.Service.use((session) =>
|
||||
session.load({
|
||||
id: "ses_config",
|
||||
cwd: "/workspace",
|
||||
model: model("anthropic", "claude-sonnet"),
|
||||
variant: "low",
|
||||
modeId: "plan",
|
||||
}),
|
||||
)
|
||||
|
||||
yield* ACPNextSession.Service.use((session) => session.setVariant("ses_config", "high"))
|
||||
expect(yield* ACPNextSession.Service.use((session) => session.getVariant("ses_config"))).toBe("high")
|
||||
expect(yield* ACPNextSession.Service.use((session) => session.getMode("ses_config"))).toBe("plan")
|
||||
|
||||
yield* ACPNextSession.Service.use((session) => session.setMode("ses_config", "build"))
|
||||
expect(yield* ACPNextSession.Service.use((session) => session.getVariant("ses_config"))).toBe("high")
|
||||
expect(yield* ACPNextSession.Service.use((session) => session.getMode("ses_config"))).toBe("build")
|
||||
}),
|
||||
)
|
||||
|
||||
sessionTest.effect("records known message part metadata for delta routing", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* ACPNextSession.Service.use((session) => session.create({ id: "ses_parts", cwd: "/workspace" }))
|
||||
|
||||
const metadata = yield* ACPNextSession.Service.use((session) =>
|
||||
session.recordPartMetadata({
|
||||
sessionId: "ses_parts",
|
||||
messageId: "msg_1",
|
||||
partId: "part_1",
|
||||
toolCallId: "tool_1",
|
||||
metadata: { output: "first chunk" },
|
||||
}),
|
||||
)
|
||||
const routed = yield* ACPNextSession.Service.use((session) =>
|
||||
session.getPartMetadata({ sessionId: "ses_parts", messageId: "msg_1", partId: "part_1" }),
|
||||
)
|
||||
|
||||
expect(metadata).toEqual({
|
||||
messageId: "msg_1",
|
||||
partId: "part_1",
|
||||
toolCallId: "tool_1",
|
||||
metadata: { output: "first chunk" },
|
||||
})
|
||||
expect(routed).toEqual(metadata)
|
||||
}),
|
||||
)
|
||||
|
||||
sessionTest.effect("keeps repeated part ids distinct across messages", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* ACPNextSession.Service.use((session) => session.create({ id: "ses_duplicate_parts", cwd: "/workspace" }))
|
||||
yield* ACPNextSession.Service.use((session) =>
|
||||
session.recordPartMetadata({
|
||||
sessionId: "ses_duplicate_parts",
|
||||
messageId: "msg_1",
|
||||
partId: "part_1",
|
||||
metadata: { output: "from first message" },
|
||||
}),
|
||||
)
|
||||
yield* ACPNextSession.Service.use((session) =>
|
||||
session.recordPartMetadata({
|
||||
sessionId: "ses_duplicate_parts",
|
||||
messageId: "msg_2",
|
||||
partId: "part_1",
|
||||
metadata: { output: "from second message" },
|
||||
}),
|
||||
)
|
||||
|
||||
const first = yield* ACPNextSession.Service.use((session) =>
|
||||
session.getPartMetadata({ sessionId: "ses_duplicate_parts", messageId: "msg_1", partId: "part_1" }),
|
||||
)
|
||||
const second = yield* ACPNextSession.Service.use((session) =>
|
||||
session.getPartMetadata({ sessionId: "ses_duplicate_parts", messageId: "msg_2", partId: "part_1" }),
|
||||
)
|
||||
|
||||
expect(first?.metadata).toEqual({ output: "from first message" })
|
||||
expect(second?.metadata).toEqual({ output: "from second message" })
|
||||
}),
|
||||
)
|
||||
|
||||
sessionTest.effect("removing a session clears its known part metadata", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* ACPNextSession.Service.use((session) => session.create({ id: "ses_remove", cwd: "/workspace" }))
|
||||
yield* ACPNextSession.Service.use((session) =>
|
||||
session.recordPartMetadata({ sessionId: "ses_remove", messageId: "msg_1", partId: "part_1" }),
|
||||
)
|
||||
|
||||
const removed = yield* ACPNextSession.Service.use((session) => session.remove("ses_remove"))
|
||||
const missing = yield* ACPNextSession.Service.use((session) => session.tryGet("ses_remove"))
|
||||
const missingPart = yield* ACPNextSession.Service.use((session) =>
|
||||
session.tryGetPartMetadata({ sessionId: "ses_remove", messageId: "msg_1", partId: "part_1" }),
|
||||
)
|
||||
|
||||
expect(removed?.knownParts.size).toBe(1)
|
||||
expect(missing).toBeUndefined()
|
||||
expect(missingPart).toBeUndefined()
|
||||
}),
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,169 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import {
|
||||
completedToolContent,
|
||||
completedToolRawOutput,
|
||||
extractImageAttachments,
|
||||
imageContents,
|
||||
shellOutputSnapshot,
|
||||
toLocations,
|
||||
toToolKind,
|
||||
} from "../../src/acp-next/tool"
|
||||
|
||||
describe("acp-next tool conversion", () => {
|
||||
test("maps OpenCode tool ids to ACP tool kinds", () => {
|
||||
expect(toToolKind("bash")).toBe("execute")
|
||||
expect(toToolKind("shell")).toBe("execute")
|
||||
expect(toToolKind("webfetch")).toBe("fetch")
|
||||
expect(toToolKind("edit")).toBe("edit")
|
||||
expect(toToolKind("patch")).toBe("edit")
|
||||
expect(toToolKind("write")).toBe("edit")
|
||||
expect(toToolKind("grep")).toBe("search")
|
||||
expect(toToolKind("glob")).toBe("search")
|
||||
expect(toToolKind("repo_clone")).toBe("search")
|
||||
expect(toToolKind("repo_overview")).toBe("search")
|
||||
expect(toToolKind("context7_resolve_library_id")).toBe("search")
|
||||
expect(toToolKind("context7_get_library_docs")).toBe("search")
|
||||
expect(toToolKind("read")).toBe("read")
|
||||
expect(toToolKind("custom_tool")).toBe("other")
|
||||
})
|
||||
|
||||
test("extracts file locations from tool input", () => {
|
||||
expect(toLocations("read", { filePath: "/tmp/a.ts" })).toEqual([{ path: "/tmp/a.ts" }])
|
||||
expect(toLocations("edit", { filePath: "/tmp/b.ts" })).toEqual([{ path: "/tmp/b.ts" }])
|
||||
expect(toLocations("write", { filePath: "/tmp/c.ts" })).toEqual([{ path: "/tmp/c.ts" }])
|
||||
expect(toLocations("grep", { path: "/repo/src" })).toEqual([{ path: "/repo/src" }])
|
||||
expect(toLocations("glob", { path: "/repo/test" })).toEqual([{ path: "/repo/test" }])
|
||||
expect(toLocations("repo_clone", { path: "/repo" })).toEqual([{ path: "/repo" }])
|
||||
expect(toLocations("repo_overview", { path: "/repo" })).toEqual([{ path: "/repo" }])
|
||||
expect(toLocations("context7_get_library_docs", { path: "/docs" })).toEqual([{ path: "/docs" }])
|
||||
expect(toLocations("bash", { filePath: "/tmp/nope.ts", path: "/tmp" })).toEqual([])
|
||||
expect(toLocations("read", { path: "/tmp/missing-file-path.ts" })).toEqual([])
|
||||
})
|
||||
|
||||
test("builds completed content with text, edit diffs, and image attachments", () => {
|
||||
const image = Buffer.from("image-data").toString("base64")
|
||||
|
||||
expect(
|
||||
completedToolContent("edit", {
|
||||
status: "completed",
|
||||
input: {
|
||||
filePath: "/tmp/file.ts",
|
||||
oldString: "before",
|
||||
newString: "after",
|
||||
},
|
||||
output: "edited /tmp/file.ts",
|
||||
attachments: [
|
||||
{
|
||||
type: "file",
|
||||
mime: "image/png",
|
||||
filename: "image.png",
|
||||
url: `data:image/png;base64,${image}`,
|
||||
},
|
||||
{
|
||||
type: "file",
|
||||
mime: "text/plain",
|
||||
filename: "note.txt",
|
||||
url: "data:text/plain;base64,bm90ZQ==",
|
||||
},
|
||||
],
|
||||
}),
|
||||
).toEqual([
|
||||
{
|
||||
type: "content",
|
||||
content: { type: "text", text: "edited /tmp/file.ts" },
|
||||
},
|
||||
{
|
||||
type: "diff",
|
||||
path: "/tmp/file.ts",
|
||||
oldText: "before",
|
||||
newText: "after",
|
||||
},
|
||||
{
|
||||
type: "content",
|
||||
content: { type: "image", mimeType: "image/png", data: image },
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
test("omits edit diffs until old and new text fields exist", () => {
|
||||
expect(
|
||||
completedToolContent("write", {
|
||||
status: "completed",
|
||||
input: {
|
||||
filePath: "/tmp/file.ts",
|
||||
content: "created",
|
||||
},
|
||||
output: "wrote /tmp/file.ts",
|
||||
}),
|
||||
).toEqual([
|
||||
{
|
||||
type: "content",
|
||||
content: { type: "text", text: "wrote /tmp/file.ts" },
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
test("builds completed raw output with optional metadata and attachments", () => {
|
||||
const attachments = [
|
||||
{
|
||||
type: "file",
|
||||
mime: "image/jpeg",
|
||||
filename: "photo.jpg",
|
||||
url: "data:image/jpeg;base64,AAAA",
|
||||
},
|
||||
]
|
||||
|
||||
expect(
|
||||
completedToolRawOutput({
|
||||
status: "completed",
|
||||
input: {},
|
||||
output: "done",
|
||||
metadata: { exit: 0 },
|
||||
attachments,
|
||||
}),
|
||||
).toEqual({
|
||||
output: "done",
|
||||
metadata: { exit: 0 },
|
||||
attachments,
|
||||
})
|
||||
|
||||
expect(
|
||||
completedToolRawOutput({
|
||||
status: "completed",
|
||||
input: {},
|
||||
output: "done",
|
||||
}),
|
||||
).toEqual({ output: "done" })
|
||||
})
|
||||
|
||||
test("extracts image attachments only from data URLs", () => {
|
||||
const attachments = [
|
||||
{
|
||||
mime: "image/webp",
|
||||
url: "data:image/webp;charset=utf-8;base64,AAAA",
|
||||
},
|
||||
{
|
||||
mime: "image/png",
|
||||
url: "https://example.com/image.png",
|
||||
},
|
||||
{
|
||||
mime: "text/plain",
|
||||
url: "data:text/plain;base64,BBBB",
|
||||
},
|
||||
]
|
||||
|
||||
expect(extractImageAttachments(attachments)).toEqual([{ mimeType: "image/webp", data: "AAAA" }])
|
||||
expect(imageContents(attachments)).toEqual([
|
||||
{
|
||||
type: "content",
|
||||
content: { type: "image", mimeType: "image/webp", data: "AAAA" },
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
test("reads shell output snapshot from string metadata output", () => {
|
||||
expect(shellOutputSnapshot({ metadata: { output: "line 1\nline 2" } })).toBe("line 1\nline 2")
|
||||
expect(shellOutputSnapshot({ metadata: { output: 42 } })).toBeUndefined()
|
||||
expect(shellOutputSnapshot({ metadata: undefined })).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,314 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { SessionNotification } from "@agentclientprotocol/sdk"
|
||||
import { UsageService } from "@/acp-next/usage"
|
||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
import { Provider } from "@/provider/provider"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { it } from "../lib/effect"
|
||||
|
||||
const assistant = (
|
||||
input: Partial<UsageService.AssistantMessage> & Pick<UsageService.AssistantMessage, "cost">,
|
||||
): UsageService.SessionMessage => ({
|
||||
info: {
|
||||
role: "assistant",
|
||||
providerID: "anthropic",
|
||||
modelID: "claude-sonnet",
|
||||
tokens: {
|
||||
input: 10,
|
||||
output: 20,
|
||||
reasoning: 0,
|
||||
cache: { read: 0, write: 0 },
|
||||
},
|
||||
...input,
|
||||
},
|
||||
})
|
||||
|
||||
const user = (): UsageService.SessionMessage => ({
|
||||
info: { role: "user" },
|
||||
})
|
||||
|
||||
const assistantWithoutProvider = (): UsageService.SessionMessage => ({
|
||||
info: {
|
||||
role: "assistant",
|
||||
modelID: "claude-sonnet",
|
||||
cost: 1,
|
||||
tokens: {
|
||||
input: 10,
|
||||
output: 20,
|
||||
reasoning: 0,
|
||||
cache: { read: 0, write: 0 },
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const model = (providerID: ProviderV2.ID, modelID: ProviderV2.ModelID, context: number): Provider.Model => ({
|
||||
id: modelID,
|
||||
providerID,
|
||||
api: {
|
||||
id: modelID,
|
||||
url: "https://example.com",
|
||||
npm: "@ai-sdk/openai-compatible",
|
||||
},
|
||||
name: modelID,
|
||||
family: "test",
|
||||
capabilities: {
|
||||
temperature: true,
|
||||
reasoning: false,
|
||||
attachment: false,
|
||||
toolcall: true,
|
||||
input: { text: true, audio: false, image: false, video: false, pdf: false },
|
||||
output: { text: true, audio: false, image: false, video: false, pdf: false },
|
||||
interleaved: false,
|
||||
},
|
||||
cost: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cache: { read: 0, write: 0 },
|
||||
},
|
||||
limit: {
|
||||
context,
|
||||
output: 4096,
|
||||
},
|
||||
status: "active",
|
||||
options: {},
|
||||
headers: {},
|
||||
release_date: "2026-01-01",
|
||||
})
|
||||
|
||||
const providers = (context = 128_000): Record<ProviderV2.ID, Provider.Info> => {
|
||||
const providerID = ProviderV2.ID.make("anthropic")
|
||||
const modelID = ProviderV2.ModelID.make("claude-sonnet")
|
||||
return {
|
||||
[providerID]: {
|
||||
id: providerID,
|
||||
name: "Anthropic",
|
||||
source: "config",
|
||||
env: [],
|
||||
options: {},
|
||||
models: {
|
||||
[modelID]: model(providerID, modelID, context),
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const fakeLayer = (input: {
|
||||
readonly messages?: Effect.Effect<readonly UsageService.SessionMessage[], unknown>
|
||||
readonly providers?: (directory: string) => Effect.Effect<Record<ProviderV2.ID, Provider.Info>, unknown>
|
||||
}) =>
|
||||
UsageService.layer.pipe(
|
||||
Layer.provide(
|
||||
Layer.mergeAll(
|
||||
Layer.succeed(
|
||||
UsageService.MessageLoader,
|
||||
UsageService.MessageLoader.of({
|
||||
messages: () => input.messages ?? Effect.succeed([]),
|
||||
}),
|
||||
),
|
||||
Layer.succeed(
|
||||
UsageService.ContextLimitLoader,
|
||||
UsageService.ContextLimitLoader.of({
|
||||
providers: input.providers ?? (() => Effect.succeed(providers())),
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
const connection = (updates: SessionNotification[]) => ({
|
||||
sessionUpdate(params: SessionNotification) {
|
||||
updates.push(params)
|
||||
return Promise.resolve()
|
||||
},
|
||||
})
|
||||
|
||||
describe("acp-next usage", () => {
|
||||
test("builds ACP Usage from assistant token shape", () => {
|
||||
expect(
|
||||
UsageService.buildUsage({
|
||||
cost: 0.02,
|
||||
tokens: {
|
||||
input: 100,
|
||||
output: 40,
|
||||
reasoning: 7,
|
||||
cache: { read: 11, write: 13 },
|
||||
},
|
||||
}),
|
||||
).toEqual({
|
||||
inputTokens: 100,
|
||||
outputTokens: 40,
|
||||
thoughtTokens: 7,
|
||||
cachedReadTokens: 11,
|
||||
cachedWriteTokens: 13,
|
||||
totalTokens: 171,
|
||||
})
|
||||
})
|
||||
|
||||
test("omits optional token fields when they are zero", () => {
|
||||
expect(
|
||||
UsageService.buildUsage({
|
||||
cost: 0,
|
||||
tokens: {
|
||||
input: 3,
|
||||
output: 4,
|
||||
reasoning: 0,
|
||||
cache: { read: 0, write: 0 },
|
||||
},
|
||||
}),
|
||||
).toEqual({
|
||||
inputTokens: 3,
|
||||
outputTokens: 4,
|
||||
totalTokens: 7,
|
||||
})
|
||||
})
|
||||
|
||||
test("finds the latest assistant message", () => {
|
||||
expect(
|
||||
UsageService.latestAssistantMessage([assistant({ cost: 1, modelID: "older" }), user(), assistant({ cost: 2 })]),
|
||||
).toMatchObject({ cost: 2 })
|
||||
})
|
||||
|
||||
test("calculates total session cost from assistant messages", () => {
|
||||
expect(UsageService.totalSessionCost([assistant({ cost: 1.25 }), user(), assistant({ cost: 2.5 })])).toBe(3.75)
|
||||
})
|
||||
|
||||
it.effect("loads context limits from providers and caches by directory/provider/model", () => {
|
||||
const calls: string[] = []
|
||||
return Effect.gen(function* () {
|
||||
const usage = yield* UsageService.Service
|
||||
const first = yield* usage.contextLimit({
|
||||
directory: "/workspace",
|
||||
providerID: ProviderV2.ID.make("anthropic"),
|
||||
modelID: ProviderV2.ModelID.make("claude-sonnet"),
|
||||
})
|
||||
const second = yield* usage.contextLimit({
|
||||
directory: "/workspace",
|
||||
providerID: ProviderV2.ID.make("anthropic"),
|
||||
modelID: ProviderV2.ModelID.make("claude-sonnet"),
|
||||
})
|
||||
|
||||
expect(first).toBe(200_000)
|
||||
expect(second).toBe(200_000)
|
||||
expect(calls).toEqual(["/workspace"])
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
fakeLayer({
|
||||
providers: (directory) =>
|
||||
Effect.sync(() => {
|
||||
calls.push(directory)
|
||||
return providers(200_000)
|
||||
}),
|
||||
}),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
it.effect("sends ACP usage_update with context size and cumulative assistant cost", () => {
|
||||
const updates: SessionNotification[] = []
|
||||
return Effect.gen(function* () {
|
||||
const usage = yield* UsageService.Service
|
||||
yield* usage.sendUpdate({
|
||||
connection: connection(updates),
|
||||
sessionID: "ses_1",
|
||||
directory: "/workspace",
|
||||
})
|
||||
|
||||
expect(updates).toEqual([
|
||||
{
|
||||
sessionId: "ses_1",
|
||||
update: {
|
||||
sessionUpdate: "usage_update",
|
||||
used: 15,
|
||||
size: 128_000,
|
||||
cost: { amount: 3, currency: "USD" },
|
||||
},
|
||||
},
|
||||
])
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
fakeLayer({
|
||||
messages: Effect.succeed([
|
||||
assistant({ cost: 1 }),
|
||||
assistant({
|
||||
cost: 2,
|
||||
tokens: {
|
||||
input: 10,
|
||||
output: 20,
|
||||
reasoning: 0,
|
||||
cache: { read: 5, write: 0 },
|
||||
},
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
it.effect("skips usage update when messages cannot be fetched", () => {
|
||||
const updates: SessionNotification[] = []
|
||||
return Effect.gen(function* () {
|
||||
const usage = yield* UsageService.Service
|
||||
yield* usage.sendUpdate({
|
||||
connection: connection(updates),
|
||||
sessionID: "ses_1",
|
||||
directory: "/workspace",
|
||||
})
|
||||
|
||||
expect(updates).toEqual([])
|
||||
}).pipe(Effect.provide(fakeLayer({ messages: Effect.fail(new Error("boom")) })))
|
||||
})
|
||||
|
||||
it.effect("skips usage update when no assistant message exists", () => {
|
||||
const updates: SessionNotification[] = []
|
||||
return Effect.gen(function* () {
|
||||
const usage = yield* UsageService.Service
|
||||
yield* usage.sendUpdate({
|
||||
connection: connection(updates),
|
||||
sessionID: "ses_1",
|
||||
directory: "/workspace",
|
||||
})
|
||||
|
||||
expect(updates).toEqual([])
|
||||
}).pipe(Effect.provide(fakeLayer({ messages: Effect.succeed([user()]) })))
|
||||
})
|
||||
|
||||
it.effect("skips usage update when assistant message has no provider or model", () => {
|
||||
const updates: SessionNotification[] = []
|
||||
return Effect.gen(function* () {
|
||||
const usage = yield* UsageService.Service
|
||||
yield* usage.sendUpdate({
|
||||
connection: connection(updates),
|
||||
sessionID: "ses_1",
|
||||
directory: "/workspace",
|
||||
})
|
||||
|
||||
expect(updates).toEqual([])
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
fakeLayer({
|
||||
messages: Effect.succeed([assistantWithoutProvider()]),
|
||||
}),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
it.effect("skips usage update when context size is unknown", () => {
|
||||
const updates: SessionNotification[] = []
|
||||
return Effect.gen(function* () {
|
||||
const usage = yield* UsageService.Service
|
||||
yield* usage.sendUpdate({
|
||||
connection: connection(updates),
|
||||
sessionID: "ses_1",
|
||||
directory: "/workspace",
|
||||
})
|
||||
|
||||
expect(updates).toEqual([])
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
fakeLayer({
|
||||
messages: Effect.succeed([assistant({ cost: 1, providerID: "missing" })]),
|
||||
}),
|
||||
),
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,228 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import type {
|
||||
AuthenticateResponse,
|
||||
InitializeResponse,
|
||||
LoadSessionResponse,
|
||||
NewSessionResponse,
|
||||
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).toBeUndefined()
|
||||
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(
|
||||
"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,322 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import type {
|
||||
CloseSessionResponse,
|
||||
InitializeResponse,
|
||||
NewSessionResponse,
|
||||
ResumeSessionResponse,
|
||||
SessionNotification,
|
||||
SetSessionConfigOptionResponse,
|
||||
} from "@agentclientprotocol/sdk"
|
||||
import { Effect } from "effect"
|
||||
import { mkdir } from "node:fs/promises"
|
||||
import path from "node:path"
|
||||
import { cliIt } from "../../lib/cli-process"
|
||||
import { testProviderConfig } from "../../lib/test-provider"
|
||||
import {
|
||||
createAcpClient,
|
||||
expectOk,
|
||||
firstAlternateValue,
|
||||
flattenSelectOptions,
|
||||
selectConfigOption,
|
||||
} from "./acp-test-client"
|
||||
|
||||
describe("opencode acp verifier compatibility baseline", () => {
|
||||
cliIt.live(
|
||||
"initialize advertises close and resume capabilities",
|
||||
({ opencode }) =>
|
||||
Effect.gen(function* () {
|
||||
const acp = createAcpClient(yield* opencode.acp())
|
||||
const initialized = expectOk(
|
||||
yield* acp.request<InitializeResponse>("initialize", {
|
||||
protocolVersion: 1,
|
||||
}),
|
||||
)
|
||||
|
||||
expect(initialized.protocolVersion).toBe(1)
|
||||
expect(initialized.agentCapabilities?.sessionCapabilities?.close).toEqual({})
|
||||
expect(initialized.agentCapabilities?.sessionCapabilities?.resume).toEqual({})
|
||||
}),
|
||||
60_000,
|
||||
)
|
||||
|
||||
cliIt.live(
|
||||
"first session timing diagnostic stays bounded and returns model options",
|
||||
({ home, llm, opencode }) =>
|
||||
Effect.gen(function* () {
|
||||
const acp = createAcpClient(
|
||||
yield* opencode.acp({
|
||||
env: {
|
||||
OPENCODE_CONFIG_CONTENT: JSON.stringify(verifierConfig(llm.url)),
|
||||
},
|
||||
}),
|
||||
)
|
||||
const started = Date.now()
|
||||
yield* acp.request<InitializeResponse>("initialize", {
|
||||
protocolVersion: 1,
|
||||
clientCapabilities: {},
|
||||
clientInfo: { name: "opencode-local-acp-baseline", version: "0.1.0" },
|
||||
})
|
||||
const session = expectOk(
|
||||
yield* acp.request<NewSessionResponse>("session/new", {
|
||||
cwd: home,
|
||||
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")
|
||||
expect(model ? flattenSelectOptions(model).length : 0).toBeGreaterThanOrEqual(2)
|
||||
}),
|
||||
60_000,
|
||||
)
|
||||
|
||||
cliIt.live(
|
||||
"warm newSession timing diagnostic stays bounded",
|
||||
({ home, llm, opencode }) =>
|
||||
Effect.gen(function* () {
|
||||
const acp = createAcpClient(
|
||||
yield* opencode.acp({
|
||||
env: {
|
||||
OPENCODE_CONFIG_CONTENT: JSON.stringify(verifierConfig(llm.url)),
|
||||
},
|
||||
}),
|
||||
)
|
||||
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",
|
||||
({ home, llm, opencode }) =>
|
||||
Effect.gen(function* () {
|
||||
const acp = createAcpClient(
|
||||
yield* opencode.acp({
|
||||
env: {
|
||||
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 model = selectConfigOption(session.configOptions, "model")
|
||||
expect(model).toBeDefined()
|
||||
const nextModel = model
|
||||
? flattenSelectOptions(model).find((option) => option.value === "test/second-model")?.value
|
||||
: 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,
|
||||
configId: "model",
|
||||
value: nextModel,
|
||||
}),
|
||||
)
|
||||
const durationMs = Date.now() - started
|
||||
|
||||
expect(durationMs).toBeLessThan(15_000)
|
||||
expect(selectConfigOption(updated.configOptions, "model")?.currentValue).toBe(nextModel)
|
||||
}),
|
||||
60_000,
|
||||
)
|
||||
|
||||
cliIt.live(
|
||||
"effort option is listed for variant-capable models and can switch",
|
||||
({ home, llm, opencode }) =>
|
||||
Effect.gen(function* () {
|
||||
const acp = createAcpClient(
|
||||
yield* opencode.acp({
|
||||
env: {
|
||||
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(
|
||||
"default test provider documents missing effort option when the model has no variants",
|
||||
({ home, llm, opencode }) =>
|
||||
Effect.gen(function* () {
|
||||
const acp = createAcpClient(
|
||||
yield* opencode.acp({
|
||||
env: {
|
||||
OPENCODE_CONFIG_CONTENT: JSON.stringify(noVariantConfig(llm.url)),
|
||||
},
|
||||
}),
|
||||
)
|
||||
yield* acp.request<InitializeResponse>("initialize", { protocolVersion: 1 })
|
||||
const session = expectOk(yield* acp.request<NewSessionResponse>("session/new", { cwd: home, mcpServers: [] }))
|
||||
|
||||
expect(selectConfigOption(session.configOptions, "model")?.currentValue).toBe("test/test-model")
|
||||
expect(selectConfigOption(session.configOptions, "effort")).toBeUndefined()
|
||||
}),
|
||||
60_000,
|
||||
)
|
||||
|
||||
cliIt.live(
|
||||
"skill slash command timing diagnostic 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 = createAcpClient(
|
||||
yield* opencode.acp({
|
||||
env: {
|
||||
OPENCODE_CONFIG_CONTENT: JSON.stringify(verifierConfig(llm.url, skills)),
|
||||
},
|
||||
}),
|
||||
)
|
||||
yield* acp.request<InitializeResponse>("initialize", { protocolVersion: 1 })
|
||||
const session = expectOk(yield* acp.request<NewSessionResponse>("session/new", { cwd: home, mcpServers: [] }))
|
||||
|
||||
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"),
|
||||
)
|
||||
|
||||
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,
|
||||
)
|
||||
|
||||
cliIt.live(
|
||||
"close request succeeds for a live session",
|
||||
({ home, opencode }) =>
|
||||
Effect.gen(function* () {
|
||||
const acp = createAcpClient(yield* opencode.acp())
|
||||
yield* acp.request<InitializeResponse>("initialize", { protocolVersion: 1 })
|
||||
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(
|
||||
"resume request succeeds for a created session",
|
||||
({ home, opencode }) =>
|
||||
Effect.gen(function* () {
|
||||
const acp = createAcpClient(yield* opencode.acp())
|
||||
yield* acp.request<InitializeResponse>("initialize", { protocolVersion: 1 })
|
||||
const session = expectOk(yield* acp.request<NewSessionResponse>("session/new", { cwd: home, mcpServers: [] }))
|
||||
|
||||
const resumed = expectOk(
|
||||
yield* acp.request<ResumeSessionResponse>("session/resume", {
|
||||
sessionId: session.sessionId,
|
||||
cwd: home,
|
||||
mcpServers: [],
|
||||
}),
|
||||
)
|
||||
expect(resumed.configOptions?.length).toBeGreaterThan(0)
|
||||
}),
|
||||
60_000,
|
||||
)
|
||||
})
|
||||
|
||||
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",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function noVariantConfig(llmUrl: string) {
|
||||
const config = verifierConfig(llmUrl)
|
||||
return {
|
||||
...config,
|
||||
provider: {
|
||||
test: {
|
||||
...config.provider.test,
|
||||
models: {
|
||||
"test-model": {
|
||||
...config.provider.test.models["test-model"],
|
||||
variants: undefined,
|
||||
},
|
||||
"second-model": config.provider.test.models["second-model"],
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const verifierSkill = `---
|
||||
name: verifier-skill
|
||||
description: Verifier compatibility skill.
|
||||
---
|
||||
|
||||
# Verifier Skill
|
||||
`
|
||||
@@ -0,0 +1,97 @@
|
||||
import { expect } from "bun:test"
|
||||
import type { SessionConfigOption, SessionConfigSelectOption } from "@agentclientprotocol/sdk"
|
||||
import { Duration, Effect } from "effect"
|
||||
import type { AcpHandle } from "../../lib/cli-process"
|
||||
|
||||
type JsonRpcRequest = {
|
||||
readonly jsonrpc: "2.0"
|
||||
readonly id: number
|
||||
readonly method: string
|
||||
readonly params?: unknown
|
||||
}
|
||||
|
||||
type JsonRpcResponse<T = unknown> = {
|
||||
readonly jsonrpc: "2.0"
|
||||
readonly id: number
|
||||
readonly result?: T
|
||||
readonly error?: unknown
|
||||
}
|
||||
|
||||
type JsonRpcNotification<T = unknown> = {
|
||||
readonly jsonrpc: "2.0"
|
||||
readonly method: string
|
||||
readonly params?: T
|
||||
}
|
||||
|
||||
export type AcpClient = {
|
||||
readonly request: <T>(method: string, params?: unknown) => Effect.Effect<JsonRpcResponse<T>, unknown>
|
||||
readonly receive: Effect.Effect<unknown>
|
||||
readonly waitForNotification: <T>(
|
||||
method: string,
|
||||
predicate: (params: T) => boolean,
|
||||
timeoutMs?: number,
|
||||
) => Effect.Effect<JsonRpcNotification<T>, unknown>
|
||||
}
|
||||
|
||||
export function createAcpClient(acp: AcpHandle): AcpClient {
|
||||
const state = { nextId: 1 }
|
||||
|
||||
const request = <T>(method: string, params?: unknown) =>
|
||||
Effect.gen(function* () {
|
||||
const id = state.nextId++
|
||||
const message: JsonRpcRequest =
|
||||
params === undefined ? { jsonrpc: "2.0", id, method } : { jsonrpc: "2.0", id, method, params }
|
||||
yield* acp.send(message)
|
||||
|
||||
while (true) {
|
||||
const received = yield* acp.receive.pipe(Effect.timeout(Duration.seconds(15)))
|
||||
if (isJsonRpcResponse<T>(received) && received.id === id) return received
|
||||
}
|
||||
})
|
||||
|
||||
const waitForNotification = <T>(method: string, predicate: (params: T) => boolean, timeoutMs = 15_000) =>
|
||||
Effect.gen(function* () {
|
||||
while (true) {
|
||||
const received = yield* acp.receive.pipe(Effect.timeout(Duration.millis(timeoutMs)))
|
||||
if (!isJsonRpcNotification<T>(received)) continue
|
||||
if (received.method === method && predicate(received.params as T)) return received
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
request,
|
||||
receive: acp.receive,
|
||||
waitForNotification,
|
||||
}
|
||||
}
|
||||
|
||||
export function expectOk<T>(response: JsonRpcResponse<T>) {
|
||||
expect(response.error).toBeUndefined()
|
||||
expect(response.result).toBeDefined()
|
||||
return response.result as T
|
||||
}
|
||||
|
||||
export function selectConfigOption(options: SessionConfigOption[] | null | undefined, id: string) {
|
||||
return options?.find(
|
||||
(option): option is Extract<SessionConfigOption, { type: "select" }> =>
|
||||
option.id === id && option.type === "select",
|
||||
)
|
||||
}
|
||||
|
||||
export function firstAlternateValue(option: Extract<SessionConfigOption, { type: "select" }>) {
|
||||
return flattenSelectOptions(option).find((item) => item.value !== option.currentValue)?.value
|
||||
}
|
||||
|
||||
export function flattenSelectOptions(option: Extract<SessionConfigOption, { type: "select" }>) {
|
||||
return option.options.flatMap((item): SessionConfigSelectOption[] => ("value" in item ? [item] : item.options))
|
||||
}
|
||||
|
||||
function isJsonRpcResponse<T>(input: unknown): input is JsonRpcResponse<T> {
|
||||
if (!input || typeof input !== "object") return false
|
||||
return "id" in input && "jsonrpc" in input
|
||||
}
|
||||
|
||||
function isJsonRpcNotification<T>(input: unknown): input is JsonRpcNotification<T> {
|
||||
if (!input || typeof input !== "object") return false
|
||||
return "method" in input && !("id" in input) && "jsonrpc" in input
|
||||
}
|
||||
@@ -235,11 +235,11 @@ describe("run entry body", () => {
|
||||
},
|
||||
title: "",
|
||||
output: [
|
||||
"task_id: child-1 (for resuming to continue this task if needed)",
|
||||
"",
|
||||
'<task id="child-1" state="completed">',
|
||||
"<task_result>",
|
||||
"# Findings\n\n- Footer stays live",
|
||||
"</task_result>",
|
||||
"</task>",
|
||||
].join("\n"),
|
||||
metadata: {
|
||||
sessionId: "child-1",
|
||||
@@ -264,13 +264,9 @@ describe("run entry body", () => {
|
||||
subagent_type: "explore",
|
||||
},
|
||||
title: "",
|
||||
output: [
|
||||
"task_id: child-1 (for resuming to continue this task if needed)",
|
||||
"",
|
||||
"<task_result>",
|
||||
"",
|
||||
"</task_result>",
|
||||
].join("\n"),
|
||||
output: ['<task id="child-1" state="completed">', "<task_result>", "", "</task_result>", "</task>"].join(
|
||||
"\n",
|
||||
),
|
||||
metadata: {
|
||||
sessionId: "child-1",
|
||||
},
|
||||
|
||||
@@ -938,8 +938,7 @@ test("renders promoted task markdown without a leading blank row", async () => {
|
||||
subagent_type: "explore",
|
||||
},
|
||||
output: [
|
||||
"task_id: child-1 (for resuming to continue this task if needed)",
|
||||
"",
|
||||
'<task id="child-1" state="completed">',
|
||||
"<task_result>",
|
||||
"Location: `/tmp/run.ts`",
|
||||
"",
|
||||
@@ -947,6 +946,7 @@ test("renders promoted task markdown without a leading blank row", async () => {
|
||||
"- Local interactive mode",
|
||||
"- Attach mode",
|
||||
"</task_result>",
|
||||
"</task>",
|
||||
].join("\n"),
|
||||
metadata: {
|
||||
sessionId: "child-1",
|
||||
|
||||
@@ -191,6 +191,21 @@ describe("RuntimeFlags", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("specific experimental flags override OPENCODE_EXPERIMENTAL", () =>
|
||||
Effect.gen(function* () {
|
||||
const flags = yield* readFlags.pipe(
|
||||
Effect.provide(
|
||||
fromConfig({
|
||||
OPENCODE_EXPERIMENTAL: "true",
|
||||
OPENCODE_EXPERIMENTAL_ICON_DISCOVERY: "false",
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
expect(flags.experimentalIconDiscovery).toBe(false)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("experimentalOxfmt defaults to false", () =>
|
||||
Effect.gen(function* () {
|
||||
const flags = yield* readFlags.pipe(Effect.provide(fromConfig({})))
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { NodeHttpServer } from "@effect/platform-node"
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Context, Effect, Layer, Option } from "effect"
|
||||
import { HttpBody, HttpClient, HttpClientRequest, HttpRouter } from "effect/unstable/http"
|
||||
import { HttpApiBuilder } from "effect/unstable/httpapi"
|
||||
import { Auth } from "../../src/auth"
|
||||
import { Config } from "../../src/config/config"
|
||||
import { Installation } from "../../src/installation"
|
||||
import { ServerAuth } from "../../src/server/auth"
|
||||
import { RootHttpApi } from "../../src/server/routes/instance/httpapi/api"
|
||||
import { GlobalPaths } from "../../src/server/routes/instance/httpapi/groups/global"
|
||||
import { controlHandlers } from "../../src/server/routes/instance/httpapi/handlers/control"
|
||||
import { globalHandlers } from "../../src/server/routes/instance/httpapi/handlers/global"
|
||||
import { authorizationLayer } from "../../src/server/routes/instance/httpapi/middleware/authorization"
|
||||
import { schemaErrorLayer } from "../../src/server/routes/instance/httpapi/middleware/schema-error"
|
||||
import { testEffect } from "../lib/effect"
|
||||
|
||||
const apiLayer = HttpRouter.serve(
|
||||
HttpApiBuilder.layer(RootHttpApi).pipe(
|
||||
Layer.provide([controlHandlers, globalHandlers]),
|
||||
Layer.provide([authorizationLayer, schemaErrorLayer]),
|
||||
),
|
||||
{ disableListenLog: true, disableLogger: true },
|
||||
).pipe(
|
||||
Layer.provideMerge(NodeHttpServer.layerTest),
|
||||
Layer.provide(Layer.mock(Auth.Service)({})),
|
||||
Layer.provide(Layer.mock(Config.Service)({})),
|
||||
Layer.provide(
|
||||
Layer.mock(Installation.Service)({
|
||||
method: () => Effect.succeed("npm"),
|
||||
latest: () => Effect.succeed("9.9.9"),
|
||||
upgrade: () => Effect.void,
|
||||
}),
|
||||
),
|
||||
Layer.provide(ServerAuth.Config.layer({ password: Option.none(), username: "opencode" })),
|
||||
// Raw HttpApi routes expose an opaque handler context at the web boundary.
|
||||
// oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion
|
||||
Layer.provide(Layer.succeedContext(Context.empty() as Context.Context<unknown>)),
|
||||
)
|
||||
const it = testEffect(apiLayer)
|
||||
|
||||
describe("global HttpApi", () => {
|
||||
it.live("upgrades to latest when the request body is omitted", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* HttpClient.post(GlobalPaths.upgrade)
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(yield* response.json).toEqual({ success: true, version: "9.9.9" })
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("rejects malformed upgrade payloads", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* HttpClientRequest.post(GlobalPaths.upgrade).pipe(
|
||||
HttpClientRequest.setBody(HttpBody.text("{", "application/json")),
|
||||
HttpClient.execute,
|
||||
)
|
||||
|
||||
expect(response.status).toBe(400)
|
||||
expect(yield* response.json).toEqual({ success: false, error: "Invalid request body" })
|
||||
}),
|
||||
)
|
||||
})
|
||||
@@ -601,6 +601,32 @@ describe("session HttpApi", () => {
|
||||
})
|
||||
expect(forked.id).not.toBe(created.id)
|
||||
|
||||
const forkedWithoutContentType = yield* requestJson<Session.Info>(
|
||||
pathFor(SessionPaths.fork, { sessionID: created.id }),
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "x-opencode-directory": test.directory },
|
||||
},
|
||||
)
|
||||
expect(forkedWithoutContentType.id).not.toBe(created.id)
|
||||
|
||||
const invalidFork = yield* request(pathFor(SessionPaths.fork, { sessionID: created.id }), {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: "{",
|
||||
})
|
||||
expect(invalidFork.status).toBe(400)
|
||||
|
||||
const forkedWhitespace = yield* requestJson<Session.Info>(
|
||||
pathFor(SessionPaths.fork, { sessionID: created.id }),
|
||||
{
|
||||
method: "POST",
|
||||
headers,
|
||||
body: " \n",
|
||||
},
|
||||
)
|
||||
expect(forkedWhitespace.id).not.toBe(created.id)
|
||||
|
||||
expect(
|
||||
yield* requestJson<boolean>(pathFor(SessionPaths.abort, { sessionID: created.id }), {
|
||||
method: "POST",
|
||||
|
||||
@@ -228,6 +228,28 @@ describe("worktree endpoint reproduction", () => {
|
||||
{ git: true },
|
||||
)
|
||||
|
||||
worktreeTest(
|
||||
"direct HttpApi worktree create rejects explicit null payload",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
const server = yield* serverScoped()
|
||||
|
||||
const response = yield* request(
|
||||
server,
|
||||
`${ExperimentalPaths.worktree}?directory=${encodeURIComponent(test.directory)}`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: "null",
|
||||
},
|
||||
)
|
||||
|
||||
expect(response.status).toBe(400)
|
||||
}),
|
||||
{ git: true },
|
||||
)
|
||||
|
||||
worktreeTest(
|
||||
"workspace worktree create does not hang",
|
||||
() =>
|
||||
|
||||
@@ -459,6 +459,35 @@ noLLMServer.instance(
|
||||
{ config: cfg },
|
||||
)
|
||||
|
||||
it.instance("loop exits without an LLM request for interrupted orphan tool calls", () =>
|
||||
Effect.gen(function* () {
|
||||
const { llm } = yield* useServerConfig(providerCfg)
|
||||
const prompt = yield* SessionPrompt.Service
|
||||
const sessions = yield* Session.Service
|
||||
const chat = yield* sessions.create({ title: "Pinned" })
|
||||
const seeded = yield* seed(chat.id, { finish: "stop" })
|
||||
yield* sessions.updatePart({
|
||||
id: PartID.ascending(),
|
||||
messageID: seeded.assistant.id,
|
||||
sessionID: chat.id,
|
||||
type: "tool",
|
||||
callID: "interrupted-call",
|
||||
tool: "edit",
|
||||
state: {
|
||||
status: "error",
|
||||
input: {},
|
||||
error: "Tool execution aborted",
|
||||
metadata: { interrupted: true },
|
||||
time: { start: 1, end: 2 },
|
||||
},
|
||||
})
|
||||
|
||||
const result = yield* prompt.loop({ sessionID: chat.id })
|
||||
expect(result.info.id).toBe(seeded.assistant.id)
|
||||
expect(yield* llm.hits).toHaveLength(0)
|
||||
}),
|
||||
)
|
||||
|
||||
it.instance("loop calls LLM and returns assistant message", () =>
|
||||
Effect.gen(function* () {
|
||||
const { llm } = yield* useServerConfig(providerCfg)
|
||||
|
||||
@@ -320,7 +320,7 @@ exports[`tool parameters JSON Schema (wire shape) task 1`] = `
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"properties": {
|
||||
"background": {
|
||||
"description": "When true, launch the subagent in the background and return immediately",
|
||||
"description": "Run the agent in the background. You will be notified when it completes.",
|
||||
"type": "boolean",
|
||||
},
|
||||
"command": {
|
||||
|
||||
@@ -101,9 +101,6 @@ const it = testEffect(Layer.mergeAll(registryLayer(), node, Agent.defaultLayer))
|
||||
const scout = testEffect(
|
||||
Layer.mergeAll(registryLayer({ flags: { experimentalScout: true } }), node, Agent.defaultLayer),
|
||||
)
|
||||
const background = testEffect(
|
||||
Layer.mergeAll(registryLayer({ flags: { experimentalBackgroundSubagents: true } }), node, Agent.defaultLayer),
|
||||
)
|
||||
const withBrokenPlugin = testEffect(
|
||||
Layer.mergeAll(registryLayer({ plugin: brokenPluginLayer }), node, Agent.defaultLayer),
|
||||
)
|
||||
@@ -133,7 +130,7 @@ describe("tool.registry", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.instance("hides task_status unless experimental background subagents are enabled", () =>
|
||||
it.instance("does not expose task_status", () =>
|
||||
Effect.gen(function* () {
|
||||
const registry = yield* ToolRegistry.Service
|
||||
const ids = yield* registry.ids()
|
||||
@@ -159,15 +156,6 @@ describe("tool.registry", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
background.instance("shows task_status when experimental background subagents are enabled", () =>
|
||||
Effect.gen(function* () {
|
||||
const registry = yield* ToolRegistry.Service
|
||||
const ids = yield* registry.ids()
|
||||
|
||||
expect(ids).toContain("task_status")
|
||||
}),
|
||||
)
|
||||
|
||||
it.instance("loads tools from .opencode/tool (singular)", () =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
|
||||
@@ -96,7 +96,6 @@ function stubOps(opts?: { onPrompt?: (input: SessionPrompt.PromptInput) => void;
|
||||
opts?.onPrompt?.(input)
|
||||
return reply(input, opts?.text ?? "done")
|
||||
}),
|
||||
loop: (input) => Effect.succeed(reply({ sessionID: input.sessionID, parts: [] }, opts?.text ?? "done")),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -241,7 +240,7 @@ describe("tool.task", () => {
|
||||
expect(kids).toHaveLength(1)
|
||||
expect(kids[0]?.id).toBe(child.id)
|
||||
expect(result.metadata.sessionId).toBe(child.id)
|
||||
expect(result.output).toContain(`task_id: ${child.id}`)
|
||||
expect(result.output).toContain(`<task id="${child.id}" state="completed">`)
|
||||
expect(seen?.sessionID).toBe(child.id)
|
||||
}),
|
||||
)
|
||||
@@ -311,7 +310,6 @@ describe("tool.task", () => {
|
||||
ready.resolve(input)
|
||||
return cancelled.promise
|
||||
}).pipe(Effect.as(reply(input, "cancelled"))),
|
||||
loop: (input) => Effect.succeed(reply({ sessionID: input.sessionID, parts: [] }, "done")),
|
||||
}
|
||||
|
||||
const fiber = yield* def
|
||||
@@ -375,7 +373,7 @@ describe("tool.task", () => {
|
||||
expect(kids).toHaveLength(1)
|
||||
expect(kids[0]?.id).toBe(result.metadata.sessionId)
|
||||
expect(result.metadata.sessionId).not.toBe("ses_missing")
|
||||
expect(result.output).toContain(`task_id: ${result.metadata.sessionId}`)
|
||||
expect(result.output).toContain(`<task id="${result.metadata.sessionId}" state="completed">`)
|
||||
expect(seen?.sessionID).toBe(result.metadata.sessionId)
|
||||
}),
|
||||
)
|
||||
@@ -515,7 +513,7 @@ describe("tool.task", () => {
|
||||
|
||||
const job = yield* jobs.get(result.metadata.sessionId)
|
||||
expect(result.metadata.background).toBe(true)
|
||||
expect(result.output).toContain("state: running")
|
||||
expect(result.output).toContain(`state="running"`)
|
||||
expect(job?.status).toBe("running")
|
||||
}),
|
||||
)
|
||||
@@ -553,10 +551,9 @@ describe("tool.task", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
background.instance("background task completion does not wait for the parent resume loop", () =>
|
||||
background.instance("background task completion does not wait for the parent async prompt", () =>
|
||||
Effect.gen(function* () {
|
||||
const jobs = yield* BackgroundJob.Service
|
||||
const sessions = yield* Session.Service
|
||||
const { chat, assistant } = yield* seed()
|
||||
const tool = yield* TaskTool
|
||||
const def = yield* tool.init()
|
||||
@@ -577,27 +574,7 @@ describe("tool.task", () => {
|
||||
promptOps: {
|
||||
...stubOps({ text: "background done" }),
|
||||
prompt: (input) =>
|
||||
input.noReply
|
||||
? Effect.gen(function* () {
|
||||
const user = yield* sessions.updateMessage({
|
||||
id: input.messageID ?? MessageID.ascending(),
|
||||
role: "user",
|
||||
sessionID: input.sessionID,
|
||||
agent: input.agent ?? "build",
|
||||
model: input.model ?? ref,
|
||||
time: { created: Date.now() },
|
||||
})
|
||||
const parts = input.parts.map((part) => ({
|
||||
...part,
|
||||
id: part.id ?? PartID.ascending(),
|
||||
messageID: user.id,
|
||||
sessionID: input.sessionID,
|
||||
}))
|
||||
yield* Effect.forEach(parts, (part) => sessions.updatePart(part), { discard: true })
|
||||
return { info: user, parts }
|
||||
})
|
||||
: Effect.succeed(reply(input, "background done")),
|
||||
loop: () => Effect.never,
|
||||
input.sessionID === chat.id ? Effect.never : Effect.succeed(reply(input, "background done")),
|
||||
} satisfies TaskPromptOps,
|
||||
},
|
||||
messages: [],
|
||||
|
||||
@@ -1,92 +0,0 @@
|
||||
import { afterEach, describe, expect } from "bun:test"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { Agent } from "@/agent/agent"
|
||||
import { BackgroundJob } from "@/background/job"
|
||||
import { Bus } from "@/bus"
|
||||
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
||||
import { Session } from "@/session/session"
|
||||
import { MessageID } from "@/session/schema"
|
||||
import { SessionStatus } from "@/session/status"
|
||||
import { TaskStatusTool } from "@/tool/task_status"
|
||||
import { Truncate } from "@/tool/truncate"
|
||||
import { RuntimeFlags } from "@/effect/runtime-flags"
|
||||
import { disposeAllInstances } from "../fixture/fixture"
|
||||
import { testEffect } from "../lib/effect"
|
||||
|
||||
afterEach(async () => {
|
||||
await disposeAllInstances()
|
||||
})
|
||||
|
||||
const layer = (flags: Partial<RuntimeFlags.Info> = {}) =>
|
||||
Layer.mergeAll(
|
||||
Agent.defaultLayer,
|
||||
BackgroundJob.defaultLayer,
|
||||
Bus.defaultLayer,
|
||||
CrossSpawnSpawner.defaultLayer,
|
||||
Session.defaultLayer,
|
||||
SessionStatus.defaultLayer,
|
||||
Truncate.defaultLayer,
|
||||
RuntimeFlags.layer(flags),
|
||||
)
|
||||
|
||||
const it = testEffect(layer({ experimentalBackgroundSubagents: true }))
|
||||
|
||||
describe("tool.task_status", () => {
|
||||
it.instance("returns completed background job output", () =>
|
||||
Effect.gen(function* () {
|
||||
const jobs = yield* BackgroundJob.Service
|
||||
const sessions = yield* Session.Service
|
||||
const tool = yield* TaskStatusTool
|
||||
const def = yield* tool.init()
|
||||
const chat = yield* sessions.create({})
|
||||
|
||||
yield* jobs.start({ id: chat.id, type: "task", run: Effect.succeed("all done") })
|
||||
|
||||
const result = yield* def.execute(
|
||||
{ task_id: chat.id, wait: true, timeout_ms: 1_000 },
|
||||
{
|
||||
sessionID: chat.id,
|
||||
messageID: MessageID.ascending(),
|
||||
agent: "build",
|
||||
abort: new AbortController().signal,
|
||||
messages: [],
|
||||
metadata: () => Effect.void,
|
||||
ask: () => Effect.void,
|
||||
},
|
||||
)
|
||||
|
||||
expect(result.output).toContain("state: completed")
|
||||
expect(result.output).toContain("all done")
|
||||
expect(result.metadata.timed_out).toBe(false)
|
||||
}),
|
||||
)
|
||||
|
||||
it.instance("wait=true times out while the background job is running", () =>
|
||||
Effect.gen(function* () {
|
||||
const jobs = yield* BackgroundJob.Service
|
||||
const sessions = yield* Session.Service
|
||||
const tool = yield* TaskStatusTool
|
||||
const def = yield* tool.init()
|
||||
const chat = yield* sessions.create({})
|
||||
|
||||
yield* jobs.start({ id: chat.id, type: "task", run: Effect.never })
|
||||
|
||||
const result = yield* def.execute(
|
||||
{ task_id: chat.id, wait: true, timeout_ms: 50 },
|
||||
{
|
||||
sessionID: chat.id,
|
||||
messageID: MessageID.ascending(),
|
||||
agent: "build",
|
||||
abort: new AbortController().signal,
|
||||
messages: [],
|
||||
metadata: () => Effect.void,
|
||||
ask: () => Effect.void,
|
||||
},
|
||||
)
|
||||
|
||||
expect(result.output).toContain("state: running")
|
||||
expect(result.output).toContain("Timed out after 50ms")
|
||||
expect(result.metadata.timed_out).toBe(true)
|
||||
}),
|
||||
)
|
||||
})
|
||||
@@ -1088,8 +1088,8 @@ export type ProviderConfig = {
|
||||
output: number
|
||||
}
|
||||
modalities?: {
|
||||
input: Array<"text" | "audio" | "image" | "video" | "pdf">
|
||||
output: Array<"text" | "audio" | "image" | "video" | "pdf">
|
||||
input?: Array<"text" | "audio" | "image" | "video" | "pdf">
|
||||
output?: Array<"text" | "audio" | "image" | "video" | "pdf">
|
||||
}
|
||||
experimental?: boolean
|
||||
status?: "alpha" | "beta" | "deprecated" | "active"
|
||||
|
||||
@@ -13717,7 +13717,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": ["input", "output"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"experimental": {
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
# OpenCode Stats
|
||||
|
||||
Stats is a separate site from the console. Runtime, database, and domain services live in `core`; the SolidStart website lives in `app`; deployable Lambda entrypoints live in `function`.
|
||||
|
||||
## Packages
|
||||
|
||||
- `app`: SolidStart frontend/site.
|
||||
- `core`: Effect services, app config, Drizzle schema/migrations, and stats domains.
|
||||
- `function`: Lambda handlers that call into `core` services.
|
||||
|
||||
## Commands
|
||||
|
||||
- `bun run dev:stats` from the repo root starts the SolidStart app.
|
||||
- `bun run --cwd packages/stats/app typecheck` typechecks the site.
|
||||
- `bun run --cwd packages/stats/core typecheck` typechecks the Effect/database package.
|
||||
- `bun run --cwd packages/stats/function typecheck` typechecks Lambda entrypoints.
|
||||
@@ -0,0 +1,17 @@
|
||||
dist
|
||||
.wrangler
|
||||
.output
|
||||
.vercel
|
||||
.netlify
|
||||
app.config.timestamp_*.js
|
||||
|
||||
# Environment
|
||||
.env
|
||||
.env*.local
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
|
||||
# System Files
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
@@ -0,0 +1,5 @@
|
||||
export default {
|
||||
server: {
|
||||
preset: "cloudflare-module",
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode-ai/stats-app",
|
||||
"version": "1.14.50",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
"typecheck": "tsgo --noEmit",
|
||||
"dev": "vite dev --host 0.0.0.0",
|
||||
"build": "vite build",
|
||||
"start": "vite start"
|
||||
},
|
||||
"dependencies": {
|
||||
"@opencode-ai/stats-core": "workspace:*",
|
||||
"@opencode-ai/ui": "workspace:*",
|
||||
"@solidjs/meta": "catalog:",
|
||||
"@solidjs/router": "catalog:",
|
||||
"@solidjs/start": "catalog:",
|
||||
"d3-scale": "4.0.2",
|
||||
"effect": "catalog:",
|
||||
"nitro": "3.0.1-alpha.1",
|
||||
"solid-js": "catalog:",
|
||||
"vite": "catalog:"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@cloudflare/workers-types": "catalog:",
|
||||
"@types/bun": "catalog:",
|
||||
"@types/d3-scale": "4.0.9",
|
||||
"@typescript/native-preview": "catalog:",
|
||||
"typescript": "catalog:"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
:root {
|
||||
color-scheme: light dark;
|
||||
--stats-bg: #f8f5ee;
|
||||
--stats-ink: #16110d;
|
||||
--stats-muted: #6d6257;
|
||||
--stats-line: #ded5c9;
|
||||
--stats-panel: #fffaf1;
|
||||
--stats-accent: #2357ff;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--stats-bg: #11100e;
|
||||
--stats-ink: #f7efe4;
|
||||
--stats-muted: #b8aa99;
|
||||
--stats-line: #322d27;
|
||||
--stats-panel: #1a1714;
|
||||
--stats-accent: #86a2ff;
|
||||
}
|
||||
}
|
||||
|
||||
html {
|
||||
line-height: 1;
|
||||
background: var(--stats-bg);
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-width: 320px;
|
||||
background:
|
||||
radial-gradient(circle at top left, color-mix(in srgb, var(--stats-accent) 16%, transparent), transparent 32rem),
|
||||
var(--stats-bg);
|
||||
color: var(--stats-ink);
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.shell {
|
||||
box-sizing: border-box;
|
||||
min-height: 100vh;
|
||||
padding: 2rem clamp(1rem, 4vw, 4rem);
|
||||
}
|
||||
|
||||
.panel {
|
||||
display: grid;
|
||||
gap: clamp(2rem, 8vw, 5rem);
|
||||
box-sizing: border-box;
|
||||
width: min(100%, 72rem);
|
||||
margin: 0 auto;
|
||||
padding: clamp(1.25rem, 4vw, 3rem);
|
||||
border: 1px solid var(--stats-line);
|
||||
border-radius: 1.5rem;
|
||||
background: color-mix(in srgb, var(--stats-panel) 88%, transparent);
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
margin: 0 0 1rem;
|
||||
color: var(--stats-muted);
|
||||
font-size: 0.75rem;
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
h1 {
|
||||
max-width: 11ch;
|
||||
margin: 0;
|
||||
font-size: clamp(3rem, 14vw, 9rem);
|
||||
line-height: 0.85;
|
||||
letter-spacing: -0.08em;
|
||||
}
|
||||
|
||||
.summary {
|
||||
max-width: 42rem;
|
||||
margin: 1.5rem 0 0;
|
||||
color: var(--stats-muted);
|
||||
font-size: clamp(1rem, 2vw, 1.25rem);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 1px;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--stats-line);
|
||||
border-radius: 1rem;
|
||||
background: var(--stats-line);
|
||||
}
|
||||
|
||||
.metric {
|
||||
padding: 1rem;
|
||||
background: var(--stats-panel);
|
||||
}
|
||||
|
||||
.metric b {
|
||||
display: block;
|
||||
margin-bottom: 0.5rem;
|
||||
font-size: clamp(1.5rem, 4vw, 3rem);
|
||||
letter-spacing: -0.05em;
|
||||
}
|
||||
|
||||
.metric span {
|
||||
color: var(--stats-muted);
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
.link {
|
||||
display: inline-flex;
|
||||
width: fit-content;
|
||||
margin-top: 1.5rem;
|
||||
color: var(--stats-accent);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { MetaProvider, Meta, Title } from "@solidjs/meta"
|
||||
import { Router } from "@solidjs/router"
|
||||
import { FileRoutes } from "@solidjs/start/router"
|
||||
import { Suspense } from "solid-js"
|
||||
import "./app.css"
|
||||
|
||||
function AppMeta() {
|
||||
return (
|
||||
<>
|
||||
<Title>opencode stats</Title>
|
||||
<Meta name="description" content="OpenCode usage and stats." />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<Router
|
||||
explicitLinks={true}
|
||||
root={(props) => (
|
||||
<MetaProvider>
|
||||
<AppMeta />
|
||||
<Suspense>{props.children}</Suspense>
|
||||
</MetaProvider>
|
||||
)}
|
||||
>
|
||||
<FileRoutes />
|
||||
</Router>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
<svg width="234" height="42" viewBox="0 0 234 42" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M18 30H6V18H18V30Z" fill="#4B4646"/>
|
||||
<path d="M18 12H6V30H18V12ZM24 36H0V6H24V36Z" fill="#B7B1B1"/>
|
||||
<path d="M48 30H36V18H48V30Z" fill="#4B4646"/>
|
||||
<path d="M36 30H48V12H36V30ZM54 36H36V42H30V6H54V36Z" fill="#B7B1B1"/>
|
||||
<path d="M84 24V30H66V24H84Z" fill="#4B4646"/>
|
||||
<path d="M84 24H66V30H84V36H60V6H84V24ZM66 18H78V12H66V18Z" fill="#B7B1B1"/>
|
||||
<path d="M108 36H96V18H108V36Z" fill="#4B4646"/>
|
||||
<path d="M108 12H96V36H90V6H108V12ZM114 36H108V12H114V36Z" fill="#B7B1B1"/>
|
||||
<path d="M144 30H126V18H144V30Z" fill="#4B4646"/>
|
||||
<path d="M144 12H126V30H144V36H120V6H144V12Z" fill="#F1ECEC"/>
|
||||
<path d="M168 30H156V18H168V30Z" fill="#4B4646"/>
|
||||
<path d="M168 12H156V30H168V12ZM174 36H150V6H174V36Z" fill="#F1ECEC"/>
|
||||
<path d="M198 30H186V18H198V30Z" fill="#4B4646"/>
|
||||
<path d="M198 12H186V30H198V12ZM204 36H180V6H198V0H204V36Z" fill="#F1ECEC"/>
|
||||
<path d="M234 24V30H216V24H234Z" fill="#4B4646"/>
|
||||
<path d="M216 12V18H228V12H216ZM234 24H216V30H234V36H210V6H234V24Z" fill="#F1ECEC"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
@@ -0,0 +1,18 @@
|
||||
<svg width="234" height="42" viewBox="0 0 234 42" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M18 30H6V18H18V30Z" fill="#CFCECD"/>
|
||||
<path d="M18 12H6V30H18V12ZM24 36H0V6H24V36Z" fill="#656363"/>
|
||||
<path d="M48 30H36V18H48V30Z" fill="#CFCECD"/>
|
||||
<path d="M36 30H48V12H36V30ZM54 36H36V42H30V6H54V36Z" fill="#656363"/>
|
||||
<path d="M84 24V30H66V24H84Z" fill="#CFCECD"/>
|
||||
<path d="M84 24H66V30H84V36H60V6H84V24ZM66 18H78V12H66V18Z" fill="#656363"/>
|
||||
<path d="M108 36H96V18H108V36Z" fill="#CFCECD"/>
|
||||
<path d="M108 12H96V36H90V6H108V12ZM114 36H108V12H114V36Z" fill="#656363"/>
|
||||
<path d="M144 30H126V18H144V30Z" fill="#CFCECD"/>
|
||||
<path d="M144 12H126V30H144V36H120V6H144V12Z" fill="#211E1E"/>
|
||||
<path d="M168 30H156V18H168V30Z" fill="#CFCECD"/>
|
||||
<path d="M168 12H156V30H168V12ZM174 36H150V6H174V36Z" fill="#211E1E"/>
|
||||
<path d="M198 30H186V18H198V30Z" fill="#CFCECD"/>
|
||||
<path d="M198 12H186V30H198V12ZM204 36H180V6H198V0H204V36Z" fill="#211E1E"/>
|
||||
<path d="M234 24V30H216V24H234Z" fill="#CFCECD"/>
|
||||
<path d="M216 12V18H228V12H216ZM234 24H216V30H234V36H210V6H234V24Z" fill="#211E1E"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
@@ -0,0 +1,7 @@
|
||||
// @refresh reload
|
||||
import { mount, StartClient } from "@solidjs/start/client"
|
||||
|
||||
const root = document.getElementById("app")
|
||||
if (!root) throw new Error("Root element #app not found")
|
||||
|
||||
mount(() => <StartClient />, root)
|
||||
@@ -0,0 +1,25 @@
|
||||
// @refresh reload
|
||||
import { createHandler, StartServer } from "@solidjs/start/server"
|
||||
|
||||
export default createHandler(
|
||||
() => (
|
||||
<StartServer
|
||||
document={({ assets, children, scripts }) => (
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
{assets}
|
||||
</head>
|
||||
<body>
|
||||
<div id="app">{children}</div>
|
||||
{scripts}
|
||||
</body>
|
||||
</html>
|
||||
)}
|
||||
/>
|
||||
),
|
||||
{
|
||||
mode: "async",
|
||||
},
|
||||
)
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
/// <reference types="@solidjs/start/env" />
|
||||
@@ -0,0 +1,19 @@
|
||||
import { AppConfig } from "@opencode-ai/stats-core/config"
|
||||
import { runtime } from "@opencode-ai/stats-core/runtime"
|
||||
import { Effect } from "effect"
|
||||
|
||||
export async function GET() {
|
||||
return Response.json(
|
||||
await runtime.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const config = yield* AppConfig
|
||||
return {
|
||||
ok: true,
|
||||
app: "stats",
|
||||
stage: config.stage,
|
||||
publicUrl: config.publicUrl,
|
||||
}
|
||||
}),
|
||||
),
|
||||
)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,971 @@
|
||||
import "./index.css"
|
||||
import { Meta, Title } from "@solidjs/meta"
|
||||
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
|
||||
import {
|
||||
type CountryEntry,
|
||||
getStatsHomeData,
|
||||
type LeaderboardEntry,
|
||||
type MarketDay,
|
||||
type StatsHomeData,
|
||||
type SessionCostEntry,
|
||||
type TokenCostEntry,
|
||||
type UsagePoint,
|
||||
} from "@opencode-ai/stats-core/domain/home"
|
||||
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 { 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 usageColors = ["#ff5d64", "#ff8a00", "#8bef00", "#12c8b3", "#18c7dc", "#6c7dff", "#9d73f7"]
|
||||
const marketColors = ["#ed6aff", "#a684ff", "#7c86ff", "#51a2ff", "#00d3f2", "#00d5be", "#00bc7d", "#9ae600", "#ffb900"]
|
||||
const countryPositions = [
|
||||
{ x: 112, y: 96 },
|
||||
{ x: 284, y: 144 },
|
||||
{ x: 472, y: 92 },
|
||||
{ x: 642, y: 154 },
|
||||
{ x: 800, y: 96 },
|
||||
{ x: 172, y: 234 },
|
||||
{ x: 362, y: 250 },
|
||||
{ x: 552, y: 236 },
|
||||
{ x: 744, y: 252 },
|
||||
{ x: 48, y: 184 },
|
||||
{ x: 892, y: 198 },
|
||||
{ x: 456, y: 176 },
|
||||
] as const
|
||||
|
||||
type UsageProduct = (typeof products)[number]
|
||||
type TokenProduct = (typeof tokenProducts)[number]
|
||||
type UsageRange = (typeof ranges)[number]
|
||||
|
||||
const getData = query(async () => {
|
||||
"use server"
|
||||
return runtime.runPromise(getStatsHomeData())
|
||||
}, "getStatsHomeData")
|
||||
|
||||
export default function StatsHome() {
|
||||
getRequestEvent()?.response.headers.set(
|
||||
"Cache-Control",
|
||||
"public, max-age=60, s-maxage=300, stale-while-revalidate=86400",
|
||||
)
|
||||
const data = createAsync(() => getData())
|
||||
|
||||
return (
|
||||
<main data-page="stats">
|
||||
<Title>OpenCode Stats</Title>
|
||||
<Meta name="description" content="OpenCode usage, market share, token cost, and session cost stats." />
|
||||
<div data-component="container">
|
||||
<Header />
|
||||
<div data-component="content">
|
||||
<Show when={data()} fallback={<StatsLoading />}>
|
||||
{(stats) => (
|
||||
<>
|
||||
<Hero updatedAt={stats().updatedAt} />
|
||||
<UsageSection data={stats().usage} />
|
||||
<LeaderboardSection data={stats().leaderboard} />
|
||||
<MarketShareSection data={stats().market} />
|
||||
<TokenCostSection data={stats().tokenCost} />
|
||||
<SessionCostSection data={stats().sessionCost} />
|
||||
<CountrySection data={stats().country} />
|
||||
<Newsletter />
|
||||
</>
|
||||
)}
|
||||
</Show>
|
||||
</div>
|
||||
<Footer />
|
||||
</div>
|
||||
<Legal />
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
function Hero(props: { updatedAt: string | null }) {
|
||||
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>
|
||||
</div>
|
||||
<p>See how model usage, provider share, cost, and geography move across OpenCode traffic.</p>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function StatsLoading() {
|
||||
return (
|
||||
<>
|
||||
<Hero updatedAt={null} />
|
||||
<ChartSection title="Usage">
|
||||
<EmptyState title="Loading stats" description="Reading model aggregates from model_stat." />
|
||||
</ChartSection>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function ChartSection(props: { title: string; description?: string; controls?: JSX.Element; children: JSX.Element }) {
|
||||
return (
|
||||
<section data-section="chart">
|
||||
<div data-slot="section-header">
|
||||
<div>
|
||||
<h2>{props.title}</h2>
|
||||
{props.description && <p>{props.description}</p>}
|
||||
</div>
|
||||
{props.controls}
|
||||
</div>
|
||||
{props.children}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function EmptyState(props: { title: string; description: string }) {
|
||||
return (
|
||||
<div data-component="empty-state">
|
||||
<strong>{props.title}</strong>
|
||||
<p>{props.description}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function formatUpdatedAt(value: string) {
|
||||
const date = new Date(value)
|
||||
if (Number.isNaN(date.getTime())) return "just now"
|
||||
return new Intl.DateTimeFormat("en", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
timeZone: "UTC",
|
||||
timeZoneName: "short",
|
||||
}).format(date)
|
||||
}
|
||||
|
||||
function UsageSection(props: { data: StatsHomeData["usage"] }) {
|
||||
const [product, setProduct] = createSignal<UsageProduct>("All Users")
|
||||
const [range, setRange] = createSignal<UsageRange>("1W")
|
||||
const data = createMemo(() => props.data[product()][range()])
|
||||
|
||||
return (
|
||||
<ChartSection 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." />}
|
||||
>
|
||||
<UsageChart data={data()} />
|
||||
</Show>
|
||||
<div data-slot="chart-footer">
|
||||
<StatsFilters product={product()} range={range()} onProductSelect={setProduct} onRangeSelect={setRange} />
|
||||
</div>
|
||||
</ChartSection>
|
||||
)
|
||||
}
|
||||
|
||||
function StatsFilters(props: {
|
||||
product: UsageProduct
|
||||
range: UsageRange
|
||||
onProductSelect: (product: UsageProduct) => void
|
||||
onRangeSelect: (range: UsageRange) => void
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<FilterPills
|
||||
items={products}
|
||||
selected={props.product}
|
||||
label="Product filter"
|
||||
variant="product"
|
||||
onSelect={props.onProductSelect}
|
||||
/>
|
||||
<FilterPills
|
||||
items={ranges}
|
||||
selected={props.range}
|
||||
label="Date range"
|
||||
variant="range"
|
||||
onSelect={props.onRangeSelect}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function FilterPills<T extends string>(props: {
|
||||
items: readonly T[]
|
||||
selected: T
|
||||
label: string
|
||||
variant: "product" | "range"
|
||||
onSelect: (item: T) => void
|
||||
}) {
|
||||
return (
|
||||
<div data-component="usage-filter" data-variant={props.variant} role="radiogroup" aria-label={props.label}>
|
||||
<For each={props.items}>
|
||||
{(item) => (
|
||||
<button
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={props.selected === item}
|
||||
data-active={props.selected === item ? "true" : undefined}
|
||||
onClick={() => props.onSelect(item)}
|
||||
>
|
||||
{item}
|
||||
</button>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function UsageChart(props: { data: UsagePoint[] }) {
|
||||
const [activeIndex, setActiveIndex] = createSignal<number>()
|
||||
const [activeSegment, setActiveSegment] = createSignal<number>()
|
||||
const height = 434
|
||||
const width = 920
|
||||
const headerOffset = 46
|
||||
const segmentGap = 2
|
||||
const maxTotal = createMemo(() => Math.max(1, Math.max(...props.data.map((item) => usageTotal(item))) * 1.02))
|
||||
const activePoint = createMemo(() => props.data[activeIndex() ?? -1])
|
||||
const y = createMemo(() => scaleLinear([0, maxTotal()], [height, 0]))
|
||||
const x = createMemo(() =>
|
||||
scaleBand(
|
||||
props.data.map((_, index) => String(index)),
|
||||
[0, width],
|
||||
).paddingInner(0.08),
|
||||
)
|
||||
const activeBar = createMemo(() => {
|
||||
const index = activeIndex()
|
||||
const point = activePoint()
|
||||
if (index === undefined) return
|
||||
if (!point) return
|
||||
return {
|
||||
point,
|
||||
x: x()(String(index)) ?? 0,
|
||||
width: x().bandwidth(),
|
||||
}
|
||||
})
|
||||
|
||||
return (
|
||||
<div data-component="usage-chart">
|
||||
<svg viewBox={`0 0 ${width} ${height + headerOffset}`} role="img" aria-label="Stacked usage chart">
|
||||
<defs>
|
||||
<pattern id="stats-usage-dot-grid" width="6" height="6" patternUnits="userSpaceOnUse">
|
||||
<rect x="1" y="1" width="2" height="2" fill="var(--stats-dot)" />
|
||||
</pattern>
|
||||
</defs>
|
||||
<For each={props.data}>
|
||||
{(day, dayIndex) => {
|
||||
const barX = x()(String(dayIndex())) ?? 0
|
||||
const barWidth = x().bandwidth()
|
||||
const stackTop = y()(usageTotal(day))
|
||||
return (
|
||||
<g
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={`${day.date} ${formatTokens(usageTotal(day))}`}
|
||||
data-active={activeIndex() === dayIndex() ? "true" : undefined}
|
||||
onPointerEnter={() => {
|
||||
setActiveIndex(dayIndex())
|
||||
setActiveSegment(undefined)
|
||||
}}
|
||||
onPointerLeave={(event) => {
|
||||
if (event.pointerType === "touch") return
|
||||
setActiveIndex(undefined)
|
||||
setActiveSegment(undefined)
|
||||
}}
|
||||
onClick={() => setActiveIndex(dayIndex())}
|
||||
onFocus={() => {
|
||||
setActiveIndex(dayIndex())
|
||||
setActiveSegment(undefined)
|
||||
}}
|
||||
onBlur={() => {
|
||||
setActiveIndex(undefined)
|
||||
setActiveSegment(undefined)
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key !== "Enter" && event.key !== " ") return
|
||||
event.preventDefault()
|
||||
setActiveIndex(dayIndex())
|
||||
}}
|
||||
>
|
||||
<rect
|
||||
x={barX}
|
||||
y="0"
|
||||
width={barWidth}
|
||||
height={height + headerOffset}
|
||||
fill="transparent"
|
||||
pointer-events="all"
|
||||
/>
|
||||
<text x={barX} y="17" class="chart-total">
|
||||
{formatTokens(usageTotal(day))}
|
||||
</text>
|
||||
<text x={barX} y="34" class="chart-date">
|
||||
{day.date}
|
||||
</text>
|
||||
<rect x={barX} y={headerOffset} width={barWidth} height={stackTop} fill="url(#stats-usage-dot-grid)" />
|
||||
<For each={day.segments}>
|
||||
{(segment, index) => {
|
||||
const previous = day.segments.slice(0, index()).reduce((sum, item) => sum + item.value, 0)
|
||||
const segmentHeight = y()(previous) - y()(previous + segment.value)
|
||||
const segmentInset = index() === day.segments.length - 1 ? 0 : segmentGap
|
||||
return (
|
||||
<rect
|
||||
x={barX}
|
||||
y={headerOffset + y()(previous + segment.value) + segmentInset}
|
||||
width={barWidth}
|
||||
height={Math.max(segmentHeight - segmentInset, 0)}
|
||||
data-segment-active={
|
||||
activeIndex() === dayIndex() && activeSegment() === index() ? "true" : undefined
|
||||
}
|
||||
opacity={getUsageSegmentOpacity(activeIndex() === dayIndex(), activeSegment(), index())}
|
||||
fill={activeIndex() === dayIndex() ? usageColors[index()] : "var(--stats-bar-idle)"}
|
||||
onPointerEnter={(event) => {
|
||||
event.stopPropagation()
|
||||
setActiveIndex(dayIndex())
|
||||
setActiveSegment(index())
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</g>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</svg>
|
||||
<Show when={activeBar()}>
|
||||
{(bar) => (
|
||||
<div
|
||||
data-component="chart-tooltip"
|
||||
data-placement={bar().x > width * 0.62 ? "left" : "right"}
|
||||
style={getUsageTooltipStyle(bar().x, bar().width, width)}
|
||||
>
|
||||
<strong>{bar().point.date}</strong>
|
||||
<span>{formatTokens(usageTotal(bar().point))} total</span>
|
||||
<div data-slot="tooltip-divider" />
|
||||
<For each={bar().point.segments}>
|
||||
{(segment, index) => (
|
||||
<p data-active={activeSegment() === index() ? "true" : undefined}>
|
||||
<span data-slot="tooltip-label">
|
||||
<i style={{ background: usageColors[index()] }} /> {segment.model}
|
||||
</span>
|
||||
<b>{formatTokens(segment.value)}</b>
|
||||
</p>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function getUsageTooltipStyle(barX: number, barWidth: number, width: number) {
|
||||
if (barX > width * 0.62) return { left: "auto", right: `${((width - barX + 12) / width) * 100}%` }
|
||||
return { left: `${((barX + barWidth + 12) / width) * 100}%`, right: "auto" }
|
||||
}
|
||||
|
||||
function getUsageSegmentOpacity(isActiveBar: boolean, activeSegment: number | undefined, index: number) {
|
||||
if (!isActiveBar) return 1
|
||||
if (activeSegment === undefined) return 1
|
||||
return activeSegment === index ? 1 : 0.38
|
||||
}
|
||||
|
||||
function usageTotal(point: UsagePoint) {
|
||||
return point.segments.reduce((sum, item) => sum + item.value, 0)
|
||||
}
|
||||
|
||||
function formatTokens(value: number) {
|
||||
if (value >= 1) return `${value.toFixed(value >= 10 ? 0 : 1)}T`
|
||||
return `${Math.round(value * 1000)}B`
|
||||
}
|
||||
|
||||
function LeaderboardSection(props: { data: StatsHomeData["leaderboard"] }) {
|
||||
const [product, setProduct] = createSignal<UsageProduct>("All Users")
|
||||
const [range, setRange] = createSignal<UsageRange>("1W")
|
||||
const data = createMemo(() => props.data[product()][range()])
|
||||
|
||||
return (
|
||||
<ChartSection
|
||||
title="Leaderboard"
|
||||
description="Shown are the sum of prompt and completion tokens per model, including reasoning tokens."
|
||||
>
|
||||
<Show
|
||||
when={data().length > 0}
|
||||
fallback={
|
||||
<EmptyState title="No leaderboard data" description="No model_stat rows matched this product and range." />
|
||||
}
|
||||
>
|
||||
<Leaderboard data={data()} />
|
||||
</Show>
|
||||
<div data-slot="chart-footer">
|
||||
<StatsFilters product={product()} range={range()} onProductSelect={setProduct} onRangeSelect={setRange} />
|
||||
</div>
|
||||
</ChartSection>
|
||||
)
|
||||
}
|
||||
|
||||
function Leaderboard(props: { data: LeaderboardEntry[] }) {
|
||||
return (
|
||||
<div data-component="leaderboard" aria-label="Model token leaderboard">
|
||||
<div data-slot="leaderboard-grid">
|
||||
<div data-slot="leaderboard-featured">
|
||||
<For each={props.data.slice(0, 3)}>{(entry) => <LeaderboardCard entry={entry} size="featured" />}</For>
|
||||
</div>
|
||||
<div data-slot="leaderboard-compact">
|
||||
<For each={props.data.slice(3)}>{(entry) => <LeaderboardCard entry={entry} size="compact" />}</For>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function LeaderboardCard(props: { entry: LeaderboardEntry; size: "featured" | "compact" }) {
|
||||
return (
|
||||
<article data-component="leader-card" data-size={props.size}>
|
||||
<span data-slot="rank">{String(props.entry.rank).padStart(2, "0")}</span>
|
||||
<ProviderIcon data-slot="leader-watermark" aria-hidden="true" id={getProviderIconId(props.entry.author)} />
|
||||
<div data-slot="leader-body">
|
||||
<ProviderIcon data-slot="leader-avatar" aria-hidden="true" id={getProviderIconId(props.entry.author)} />
|
||||
<div data-slot="leader-copy">
|
||||
<div>
|
||||
<strong>{props.entry.model}</strong>
|
||||
<span>{formatBillions(props.entry.tokens)}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span>{props.entry.author}</span>
|
||||
<span data-slot="delta" data-negative={props.entry.change < 0 ? "true" : undefined}>
|
||||
{formatChange(props.entry.change)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
)
|
||||
}
|
||||
|
||||
function getProviderIconId(author: string) {
|
||||
if (author === "MiniMax") return "minimax"
|
||||
if (author === "Moonshot") return "moonshotai"
|
||||
if (author === "Zhipu") return "zhipuai"
|
||||
return author.toLowerCase()
|
||||
}
|
||||
|
||||
function formatBillions(value: number) {
|
||||
if (value >= 1000) return `${(value / 1000).toFixed(value >= 10000 ? 0 : 1)}T`
|
||||
return `${value}B`
|
||||
}
|
||||
|
||||
function formatChange(value: number) {
|
||||
if (value > 0) return `+${value}%`
|
||||
return `${value}%`
|
||||
}
|
||||
|
||||
function MarketShareSection(props: { data: StatsHomeData["market"] }) {
|
||||
const [range, setRange] = createSignal<UsageRange>("1W")
|
||||
const [activeIndex, setActiveIndex] = createSignal(2)
|
||||
const data = createMemo(() => props.data[range()])
|
||||
const selectedIndex = createMemo(() => Math.min(activeIndex(), Math.max(data().length - 1, 0)))
|
||||
const activeDay = createMemo(() => data()[selectedIndex()])
|
||||
|
||||
return (
|
||||
<ChartSection 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." />}
|
||||
>
|
||||
{(day) => (
|
||||
<>
|
||||
<MarketShare data={data()} activeIndex={selectedIndex()} onActiveIndexChange={setActiveIndex} />
|
||||
<MarketShareList data={day().authors} />
|
||||
</>
|
||||
)}
|
||||
</Show>
|
||||
<div data-slot="market-footer">
|
||||
<p>
|
||||
<span>[*]</span>
|
||||
<strong>{activeDay()?.date ?? "No data"}</strong>
|
||||
</p>
|
||||
<FilterPills items={ranges} selected={range()} label="Date range" variant="range" onSelect={setRange} />
|
||||
</div>
|
||||
</ChartSection>
|
||||
)
|
||||
}
|
||||
|
||||
function MarketShare(props: { data: MarketDay[]; activeIndex: number; onActiveIndexChange: (index: number) => void }) {
|
||||
return (
|
||||
<div data-component="market-share" role="img" aria-label="Market share by model author">
|
||||
<div data-slot="market-labels">
|
||||
<For each={props.data}>
|
||||
{(day, index) => (
|
||||
<button
|
||||
type="button"
|
||||
data-active={props.activeIndex === index() ? "true" : undefined}
|
||||
onClick={() => props.onActiveIndexChange(index())}
|
||||
>
|
||||
<span>{formatTrillions(day.total)}</span>
|
||||
<span>{day.date}</span>
|
||||
</button>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
<div data-slot="market-bars">
|
||||
<For each={props.data}>
|
||||
{(day, index) => (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`${day.date} ${formatTrillions(day.total)}`}
|
||||
data-active={props.activeIndex === index() ? "true" : undefined}
|
||||
onClick={() => props.onActiveIndexChange(index())}
|
||||
>
|
||||
<For each={day.authors}>
|
||||
{(author, authorIndex) => (
|
||||
<span
|
||||
style={{
|
||||
"background-color": props.activeIndex === index() ? marketColors[authorIndex()] : undefined,
|
||||
"flex-grow": author.share,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
</button>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function MarketShareList(props: { data: MarketDay["authors"] }) {
|
||||
return (
|
||||
<ol data-component="market-share-list">
|
||||
<For each={props.data}>
|
||||
{(item, index) => (
|
||||
<li>
|
||||
<span>{String(index() + 1).padStart(2, "0")}</span>
|
||||
<i style={{ background: marketColors[index()] }} />
|
||||
<strong>{item.author}</strong>
|
||||
<em>{formatTrillions(item.tokens)}</em>
|
||||
<b>{item.share.toFixed(1)}%</b>
|
||||
</li>
|
||||
)}
|
||||
</For>
|
||||
</ol>
|
||||
)
|
||||
}
|
||||
|
||||
function formatTrillions(value: number) {
|
||||
return `${value.toFixed(value >= 10 ? 0 : 1)}T`
|
||||
}
|
||||
|
||||
function TokenCostSection(props: { data: StatsHomeData["tokenCost"] }) {
|
||||
const [product, setProduct] = createSignal<TokenProduct>("Zen")
|
||||
const [activeIndex, setActiveIndex] = createSignal(2)
|
||||
const data = createMemo(() => props.data[product()])
|
||||
const selectedIndex = createMemo(() => Math.min(activeIndex(), Math.max(data().length - 1, 0)))
|
||||
|
||||
return (
|
||||
<ChartSection title="Token Cost" description="Price per 1M tokens.">
|
||||
<Show
|
||||
when={data().length > 0}
|
||||
fallback={
|
||||
<EmptyState title="No token cost data" description="No cost-bearing model_stat rows matched this product." />
|
||||
}
|
||||
>
|
||||
<TokenCostChart data={data()} activeIndex={selectedIndex()} onActiveIndexChange={setActiveIndex} />
|
||||
</Show>
|
||||
<div data-slot="token-footer">
|
||||
<FilterPills
|
||||
items={tokenProducts}
|
||||
selected={product()}
|
||||
label="Product filter"
|
||||
variant="product"
|
||||
onSelect={setProduct}
|
||||
/>
|
||||
</div>
|
||||
</ChartSection>
|
||||
)
|
||||
}
|
||||
|
||||
function TokenCostChart(props: {
|
||||
data: TokenCostEntry[]
|
||||
activeIndex: number
|
||||
onActiveIndexChange: (index: number) => void
|
||||
}) {
|
||||
const max = createMemo(() => Math.max(1, ...props.data.map((item) => item.total)))
|
||||
const active = createMemo(() => props.data[props.activeIndex] ?? props.data[0])
|
||||
|
||||
return (
|
||||
<div data-component="token-cost">
|
||||
<For each={props.data}>
|
||||
{(item, index) => (
|
||||
<button
|
||||
type="button"
|
||||
data-component="token-row"
|
||||
data-active={props.activeIndex === index() ? "true" : undefined}
|
||||
onClick={() => props.onActiveIndexChange(index())}
|
||||
onPointerEnter={() => props.onActiveIndexChange(index())}
|
||||
>
|
||||
<strong>{formatDollars(item.total)}</strong>
|
||||
<span>{item.model}</span>
|
||||
<MetricBar value={item.total} max={max()} active={props.activeIndex === index()} />
|
||||
</button>
|
||||
)}
|
||||
</For>
|
||||
<Show when={active()}>
|
||||
{(item) => (
|
||||
<div data-component="token-tooltip" style={{ top: `${props.activeIndex * 28 + 2}px` }}>
|
||||
<p>
|
||||
<span>Input</span>
|
||||
<strong>{formatDollars(item().input)}</strong>
|
||||
</p>
|
||||
<p>
|
||||
<span>Output</span>
|
||||
<strong>{formatDollars(item().output)}</strong>
|
||||
</p>
|
||||
<p>
|
||||
<span>Cached</span>
|
||||
<strong>{formatDollars(item().cached)}</strong>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function formatDollars(value: number) {
|
||||
return `$${value.toFixed(2)}`
|
||||
}
|
||||
|
||||
function MetricBar(props: { value: number; max: number; active: boolean }) {
|
||||
return (
|
||||
<i data-component="metric-bar" data-active={props.active ? "true" : undefined}>
|
||||
<b style={{ "flex-grow": Math.max(props.value / Math.max(props.max, 1), 0.05) }} />
|
||||
<em />
|
||||
</i>
|
||||
)
|
||||
}
|
||||
|
||||
function SessionCostSection(props: { data: StatsHomeData["sessionCost"] }) {
|
||||
const [product, setProduct] = createSignal<TokenProduct>("Zen")
|
||||
const [activeIndex, setActiveIndex] = createSignal(2)
|
||||
const data = createMemo(() => props.data[product()])
|
||||
const selectedIndex = createMemo(() => Math.min(activeIndex(), Math.max(data().length - 1, 0)))
|
||||
|
||||
return (
|
||||
<ChartSection title="Session Cost" description="Average cost per session.">
|
||||
<Show
|
||||
when={data().length > 0}
|
||||
fallback={
|
||||
<EmptyState
|
||||
title="No session cost data"
|
||||
description="No session-bearing model_stat rows matched this product."
|
||||
/>
|
||||
}
|
||||
>
|
||||
<SessionCostChart data={data()} activeIndex={selectedIndex()} onActiveIndexChange={setActiveIndex} />
|
||||
</Show>
|
||||
<div data-slot="token-footer">
|
||||
<FilterPills
|
||||
items={tokenProducts}
|
||||
selected={product()}
|
||||
label="Product filter"
|
||||
variant="product"
|
||||
onSelect={setProduct}
|
||||
/>
|
||||
</div>
|
||||
</ChartSection>
|
||||
)
|
||||
}
|
||||
|
||||
function SessionCostChart(props: {
|
||||
data: SessionCostEntry[]
|
||||
activeIndex: number
|
||||
onActiveIndexChange: (index: number) => void
|
||||
}) {
|
||||
const maxCost = createMemo(() => Math.max(1, ...props.data.map((item) => item.cost)))
|
||||
const maxTokens = createMemo(() => Math.max(1, ...props.data.map((item) => item.tokens)))
|
||||
const active = createMemo(() => props.data[props.activeIndex] ?? props.data[0])
|
||||
|
||||
return (
|
||||
<div data-component="session-cost">
|
||||
<div data-slot="session-heading">
|
||||
<span />
|
||||
<p>COST / SESSION</p>
|
||||
<p>TOKENS / SESSIONS</p>
|
||||
</div>
|
||||
<For each={props.data}>
|
||||
{(item, index) => (
|
||||
<button
|
||||
type="button"
|
||||
data-component="token-row"
|
||||
data-variant="session"
|
||||
data-active={props.activeIndex === index() ? "true" : undefined}
|
||||
onClick={() => props.onActiveIndexChange(index())}
|
||||
onPointerEnter={() => props.onActiveIndexChange(index())}
|
||||
>
|
||||
<strong>{formatSessionCost(item.cost)}</strong>
|
||||
<span>{item.model}</span>
|
||||
<MetricBar value={item.cost} max={maxCost()} active={props.activeIndex === index()} />
|
||||
<MetricBar value={item.tokens} max={maxTokens()} active={props.activeIndex === index()} />
|
||||
</button>
|
||||
)}
|
||||
</For>
|
||||
<Show when={active()}>
|
||||
{(item) => (
|
||||
<div
|
||||
data-component="token-tooltip"
|
||||
data-variant="session"
|
||||
style={{ top: `${props.activeIndex * 28 + 21}px` }}
|
||||
>
|
||||
<p>
|
||||
<span>Cost/Session</span>
|
||||
<strong>{formatSessionCost(item().cost)}</strong>
|
||||
</p>
|
||||
<p>
|
||||
<span>Tokens/Session</span>
|
||||
<strong>{formatTokenCount(item().tokens)}</strong>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function formatTokenCount(value: number) {
|
||||
if (value >= 1_000_000) return `${Number((value / 1_000_000).toFixed(1))}M`
|
||||
return `${Math.round(value / 1_000)}K`
|
||||
}
|
||||
|
||||
function formatSessionCost(value: number) {
|
||||
return `$${value.toFixed(4)}`
|
||||
}
|
||||
|
||||
function CountrySection(props: { data: StatsHomeData["country"] }) {
|
||||
const [range, setRange] = createSignal<UsageRange>("1W")
|
||||
const data = createMemo(() => props.data[range()])
|
||||
|
||||
return (
|
||||
<ChartSection title="Token by Country" description="Country-level token totals from geo_stat.">
|
||||
<Show
|
||||
when={data().length > 0}
|
||||
fallback={<EmptyState title="No country data" description="No geo_stat rows matched this range." />}
|
||||
>
|
||||
<CountryChart data={data()} />
|
||||
</Show>
|
||||
<div data-slot="country-footer">
|
||||
<p>
|
||||
<span>[*]</span>
|
||||
<strong>Top countries by tokens</strong>
|
||||
</p>
|
||||
<FilterPills items={ranges} selected={range()} label="Date range" variant="range" onSelect={setRange} />
|
||||
</div>
|
||||
</ChartSection>
|
||||
)
|
||||
}
|
||||
|
||||
function CountryChart(props: { data: CountryEntry[] }) {
|
||||
const [activeIndex, setActiveIndex] = createSignal(0)
|
||||
const selectedIndex = createMemo(() => Math.min(activeIndex(), Math.max(props.data.length - 1, 0)))
|
||||
const active = createMemo(() => props.data[selectedIndex()])
|
||||
const max = createMemo(() => Math.max(0.0001, ...props.data.map((item) => item.tokens)))
|
||||
|
||||
return (
|
||||
<div data-component="country-map">
|
||||
<svg viewBox="0 0 920 320" role="img" aria-label="Country token share bubble chart">
|
||||
<For each={props.data.slice(0, countryPositions.length)}>
|
||||
{(item, index) => {
|
||||
const position = countryPositions[index()]
|
||||
const radius = 18 + Math.sqrt(item.tokens / max()) * 58
|
||||
return (
|
||||
<g
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={`${formatCountry(item.country)} ${formatTokens(item.tokens)}`}
|
||||
data-active={selectedIndex() === index() ? "true" : undefined}
|
||||
onPointerEnter={() => setActiveIndex(index())}
|
||||
onClick={() => setActiveIndex(index())}
|
||||
onFocus={() => setActiveIndex(index())}
|
||||
>
|
||||
<circle cx={position.x} cy={position.y} r={radius} />
|
||||
<text x={position.x} y={position.y + 4} text-anchor="middle">
|
||||
{item.country}
|
||||
</text>
|
||||
</g>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</svg>
|
||||
<Show when={active()}>
|
||||
{(item) => (
|
||||
<div data-component="map-tooltip">
|
||||
<strong>{formatCountry(item().country)}</strong>
|
||||
<span>{item().continent || "Unknown region"}</span>
|
||||
<p>
|
||||
<b>{formatTokens(item().tokens)}</b>
|
||||
<em>{item().share.toFixed(1)}%</em>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
<CountryList data={props.data.slice(0, 8)} activeIndex={selectedIndex()} onActiveIndexChange={setActiveIndex} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CountryList(props: {
|
||||
data: CountryEntry[]
|
||||
activeIndex: number
|
||||
onActiveIndexChange: (index: number) => void
|
||||
}) {
|
||||
return (
|
||||
<ol data-component="country-list">
|
||||
<For each={props.data}>
|
||||
{(item, index) => (
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
data-active={props.activeIndex === index() ? "true" : undefined}
|
||||
onClick={() => props.onActiveIndexChange(index())}
|
||||
onPointerEnter={() => props.onActiveIndexChange(index())}
|
||||
>
|
||||
<span>{String(item.rank).padStart(2, "0")}</span>
|
||||
<strong>{formatCountry(item.country)}</strong>
|
||||
<em>{formatTokens(item.tokens)}</em>
|
||||
<b>{item.share.toFixed(1)}%</b>
|
||||
</button>
|
||||
</li>
|
||||
)}
|
||||
</For>
|
||||
</ol>
|
||||
)
|
||||
}
|
||||
|
||||
function formatCountry(country: string) {
|
||||
const known: Record<string, string> = {
|
||||
AU: "Australia",
|
||||
BR: "Brazil",
|
||||
CA: "Canada",
|
||||
CN: "China",
|
||||
DE: "Germany",
|
||||
FR: "France",
|
||||
GB: "United Kingdom",
|
||||
IN: "India",
|
||||
JP: "Japan",
|
||||
KR: "South Korea",
|
||||
NL: "Netherlands",
|
||||
SG: "Singapore",
|
||||
US: "United States",
|
||||
ZZ: "Unknown",
|
||||
}
|
||||
return known[country] ?? country
|
||||
}
|
||||
|
||||
function Newsletter() {
|
||||
return (
|
||||
<section data-section="newsletter">
|
||||
<div>
|
||||
<h2>Be the first to know when we release new products</h2>
|
||||
<p>Join the waitlist for early access.</p>
|
||||
</div>
|
||||
<form>
|
||||
<input type="email" placeholder="Email address" />
|
||||
<button>Subscribe</button>
|
||||
</form>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function Header() {
|
||||
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
|
||||
</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>
|
||||
</nav>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function Footer() {
|
||||
return (
|
||||
<footer data-component="footer">
|
||||
<div data-slot="cell">
|
||||
<a href="https://github.com/sst/opencode" target="_blank" rel="noreferrer">
|
||||
GitHub
|
||||
</a>
|
||||
</div>
|
||||
<div data-slot="cell">
|
||||
<a href="https://opencode.ai/docs">Docs</a>
|
||||
</div>
|
||||
<div data-slot="cell">
|
||||
<a href="https://opencode.ai/changelog">Changelog</a>
|
||||
</div>
|
||||
<div data-slot="cell">
|
||||
<a href="https://x.com/opencode_ai">X</a>
|
||||
</div>
|
||||
</footer>
|
||||
)
|
||||
}
|
||||
|
||||
function Legal() {
|
||||
return (
|
||||
<div data-component="legal">
|
||||
<span>
|
||||
©{new Date().getFullYear()} <a href="https://anoma.ly">Anomaly</a>
|
||||
</span>
|
||||
<span>
|
||||
<a href="https://opencode.ai/brand">Brand</a>
|
||||
</span>
|
||||
<span>
|
||||
<a href="https://opencode.ai/legal/privacy-policy">Privacy</a>
|
||||
</span>
|
||||
<span>
|
||||
<a href="https://opencode.ai/legal/terms-of-service">Terms</a>
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Vendored
+10
@@ -0,0 +1,10 @@
|
||||
/* This file is auto-generated by SST. Do not edit. */
|
||||
/* tslint:disable */
|
||||
/* eslint-disable */
|
||||
/* deno-fmt-ignore-file */
|
||||
/* biome-ignore-all lint: auto-generated */
|
||||
|
||||
/// <reference path="../../../sst-env.d.ts" />
|
||||
|
||||
import "sst"
|
||||
export {}
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"compilerOptions": {
|
||||
"target": "ESNext",
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"esModuleInterop": true,
|
||||
"jsx": "preserve",
|
||||
"jsxImportSource": "solid-js",
|
||||
"allowJs": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"types": ["vite/client", "bun"],
|
||||
"isolatedModules": true,
|
||||
"paths": {
|
||||
"~/*": ["./src/*"]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { solidStart } from "@solidjs/start/config"
|
||||
import { nitro } from "nitro/vite"
|
||||
import { defineConfig, type PluginOption } from "vite"
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
solidStart() as PluginOption,
|
||||
nitro({
|
||||
compatibilityDate: "2024-09-19",
|
||||
preset: "cloudflare-module",
|
||||
cloudflare: {
|
||||
nodeCompat: true,
|
||||
},
|
||||
}),
|
||||
],
|
||||
server: {
|
||||
allowedHosts: true,
|
||||
},
|
||||
build: {
|
||||
minify: false,
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,21 @@
|
||||
import { Resource } from "sst/resource"
|
||||
import { defineConfig } from "drizzle-kit"
|
||||
|
||||
export default defineConfig({
|
||||
dialect: "mysql",
|
||||
schema: ["./src/database/schema.ts"],
|
||||
// schema: ["./src/**/*.sql.ts"],
|
||||
out: "./migrations/",
|
||||
strict: true,
|
||||
verbose: true,
|
||||
dbCredentials: {
|
||||
database: Resource.StatsDatabase.database,
|
||||
host: Resource.StatsDatabase.host,
|
||||
user: Resource.StatsDatabase.username,
|
||||
password: Resource.StatsDatabase.password,
|
||||
port: Resource.StatsDatabase.port,
|
||||
ssl: {
|
||||
rejectUnauthorized: false,
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,42 @@
|
||||
CREATE TABLE `stat` (
|
||||
`id` bigint AUTO_INCREMENT PRIMARY KEY,
|
||||
`grain` varchar(16) NOT NULL,
|
||||
`period_start` datetime NOT NULL,
|
||||
`period_end` datetime NOT NULL,
|
||||
`dataset` varchar(64) NOT NULL DEFAULT 'all',
|
||||
`tier` varchar(64) NOT NULL DEFAULT 'all',
|
||||
`client` varchar(64) NOT NULL DEFAULT 'all',
|
||||
`source` varchar(64) NOT NULL DEFAULT 'all',
|
||||
`provider` varchar(128) NOT NULL,
|
||||
`model` varchar(256) NOT NULL,
|
||||
`provider_model` varchar(256) NOT NULL DEFAULT '',
|
||||
`sessions` bigint NOT NULL DEFAULT 0,
|
||||
`requests` bigint NOT NULL DEFAULT 0,
|
||||
`input_tokens` bigint NOT NULL DEFAULT 0,
|
||||
`output_tokens` bigint NOT NULL DEFAULT 0,
|
||||
`reasoning_tokens` bigint NOT NULL DEFAULT 0,
|
||||
`cache_read_tokens` bigint NOT NULL DEFAULT 0,
|
||||
`total_tokens` bigint NOT NULL DEFAULT 0,
|
||||
`input_cost_microcents` bigint NOT NULL DEFAULT 0,
|
||||
`output_cost_microcents` bigint NOT NULL DEFAULT 0,
|
||||
`total_cost_microcents` bigint NOT NULL DEFAULT 0,
|
||||
`avg_duration_ms` decimal(12,2),
|
||||
`p50_duration_ms` int,
|
||||
`p95_duration_ms` int,
|
||||
`avg_ttfb_ms` decimal(12,2),
|
||||
`p50_ttfb_ms` int,
|
||||
`p95_ttfb_ms` int,
|
||||
`avg_output_tps` decimal(12,4),
|
||||
`success_count` bigint NOT NULL DEFAULT 0,
|
||||
`error_count` bigint NOT NULL DEFAULT 0,
|
||||
`sample_count` bigint NOT NULL DEFAULT 0,
|
||||
`rank_by_tokens` int,
|
||||
`rank_by_requests` int,
|
||||
`rank_by_cost` int,
|
||||
`created_at` datetime NOT NULL DEFAULT (now()),
|
||||
`updated_at` datetime NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP,
|
||||
CONSTRAINT `uniq_model_period` UNIQUE INDEX(`grain`,`period_start`,`dataset`,`tier`,`client`,`source`,`provider`,`model`)
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX `idx_leaderboard_tokens` ON `stat` (`grain`,`period_start`,`dataset`,`tier`,`total_tokens`);--> statement-breakpoint
|
||||
CREATE INDEX `idx_model` ON `stat` (`model`,`grain`,`period_start`);
|
||||
@@ -0,0 +1,623 @@
|
||||
{
|
||||
"version": "6",
|
||||
"dialect": "mysql",
|
||||
"id": "72655266-65da-408e-bfd8-9f3a4ad817a5",
|
||||
"prevIds": ["00000000-0000-0000-0000-000000000000"],
|
||||
"ddl": [
|
||||
{
|
||||
"name": "stat",
|
||||
"entityType": "tables"
|
||||
},
|
||||
{
|
||||
"type": "bigint",
|
||||
"notNull": true,
|
||||
"autoIncrement": true,
|
||||
"default": null,
|
||||
"onUpdateNow": false,
|
||||
"onUpdateNowFsp": null,
|
||||
"charSet": null,
|
||||
"collation": null,
|
||||
"generated": null,
|
||||
"name": "id",
|
||||
"entityType": "columns",
|
||||
"table": "stat"
|
||||
},
|
||||
{
|
||||
"type": "varchar(16)",
|
||||
"notNull": true,
|
||||
"autoIncrement": false,
|
||||
"default": null,
|
||||
"onUpdateNow": false,
|
||||
"onUpdateNowFsp": null,
|
||||
"charSet": null,
|
||||
"collation": null,
|
||||
"generated": null,
|
||||
"name": "grain",
|
||||
"entityType": "columns",
|
||||
"table": "stat"
|
||||
},
|
||||
{
|
||||
"type": "datetime",
|
||||
"notNull": true,
|
||||
"autoIncrement": false,
|
||||
"default": null,
|
||||
"onUpdateNow": false,
|
||||
"onUpdateNowFsp": null,
|
||||
"charSet": null,
|
||||
"collation": null,
|
||||
"generated": null,
|
||||
"name": "period_start",
|
||||
"entityType": "columns",
|
||||
"table": "stat"
|
||||
},
|
||||
{
|
||||
"type": "datetime",
|
||||
"notNull": true,
|
||||
"autoIncrement": false,
|
||||
"default": null,
|
||||
"onUpdateNow": false,
|
||||
"onUpdateNowFsp": null,
|
||||
"charSet": null,
|
||||
"collation": null,
|
||||
"generated": null,
|
||||
"name": "period_end",
|
||||
"entityType": "columns",
|
||||
"table": "stat"
|
||||
},
|
||||
{
|
||||
"type": "varchar(64)",
|
||||
"notNull": true,
|
||||
"autoIncrement": false,
|
||||
"default": "'all'",
|
||||
"onUpdateNow": false,
|
||||
"onUpdateNowFsp": null,
|
||||
"charSet": null,
|
||||
"collation": null,
|
||||
"generated": null,
|
||||
"name": "dataset",
|
||||
"entityType": "columns",
|
||||
"table": "stat"
|
||||
},
|
||||
{
|
||||
"type": "varchar(64)",
|
||||
"notNull": true,
|
||||
"autoIncrement": false,
|
||||
"default": "'all'",
|
||||
"onUpdateNow": false,
|
||||
"onUpdateNowFsp": null,
|
||||
"charSet": null,
|
||||
"collation": null,
|
||||
"generated": null,
|
||||
"name": "tier",
|
||||
"entityType": "columns",
|
||||
"table": "stat"
|
||||
},
|
||||
{
|
||||
"type": "varchar(64)",
|
||||
"notNull": true,
|
||||
"autoIncrement": false,
|
||||
"default": "'all'",
|
||||
"onUpdateNow": false,
|
||||
"onUpdateNowFsp": null,
|
||||
"charSet": null,
|
||||
"collation": null,
|
||||
"generated": null,
|
||||
"name": "client",
|
||||
"entityType": "columns",
|
||||
"table": "stat"
|
||||
},
|
||||
{
|
||||
"type": "varchar(64)",
|
||||
"notNull": true,
|
||||
"autoIncrement": false,
|
||||
"default": "'all'",
|
||||
"onUpdateNow": false,
|
||||
"onUpdateNowFsp": null,
|
||||
"charSet": null,
|
||||
"collation": null,
|
||||
"generated": null,
|
||||
"name": "source",
|
||||
"entityType": "columns",
|
||||
"table": "stat"
|
||||
},
|
||||
{
|
||||
"type": "varchar(128)",
|
||||
"notNull": true,
|
||||
"autoIncrement": false,
|
||||
"default": null,
|
||||
"onUpdateNow": false,
|
||||
"onUpdateNowFsp": null,
|
||||
"charSet": null,
|
||||
"collation": null,
|
||||
"generated": null,
|
||||
"name": "provider",
|
||||
"entityType": "columns",
|
||||
"table": "stat"
|
||||
},
|
||||
{
|
||||
"type": "varchar(256)",
|
||||
"notNull": true,
|
||||
"autoIncrement": false,
|
||||
"default": null,
|
||||
"onUpdateNow": false,
|
||||
"onUpdateNowFsp": null,
|
||||
"charSet": null,
|
||||
"collation": null,
|
||||
"generated": null,
|
||||
"name": "model",
|
||||
"entityType": "columns",
|
||||
"table": "stat"
|
||||
},
|
||||
{
|
||||
"type": "varchar(256)",
|
||||
"notNull": true,
|
||||
"autoIncrement": false,
|
||||
"default": "''",
|
||||
"onUpdateNow": false,
|
||||
"onUpdateNowFsp": null,
|
||||
"charSet": null,
|
||||
"collation": null,
|
||||
"generated": null,
|
||||
"name": "provider_model",
|
||||
"entityType": "columns",
|
||||
"table": "stat"
|
||||
},
|
||||
{
|
||||
"type": "bigint",
|
||||
"notNull": true,
|
||||
"autoIncrement": false,
|
||||
"default": "0",
|
||||
"onUpdateNow": false,
|
||||
"onUpdateNowFsp": null,
|
||||
"charSet": null,
|
||||
"collation": null,
|
||||
"generated": null,
|
||||
"name": "sessions",
|
||||
"entityType": "columns",
|
||||
"table": "stat"
|
||||
},
|
||||
{
|
||||
"type": "bigint",
|
||||
"notNull": true,
|
||||
"autoIncrement": false,
|
||||
"default": "0",
|
||||
"onUpdateNow": false,
|
||||
"onUpdateNowFsp": null,
|
||||
"charSet": null,
|
||||
"collation": null,
|
||||
"generated": null,
|
||||
"name": "requests",
|
||||
"entityType": "columns",
|
||||
"table": "stat"
|
||||
},
|
||||
{
|
||||
"type": "bigint",
|
||||
"notNull": true,
|
||||
"autoIncrement": false,
|
||||
"default": "0",
|
||||
"onUpdateNow": false,
|
||||
"onUpdateNowFsp": null,
|
||||
"charSet": null,
|
||||
"collation": null,
|
||||
"generated": null,
|
||||
"name": "input_tokens",
|
||||
"entityType": "columns",
|
||||
"table": "stat"
|
||||
},
|
||||
{
|
||||
"type": "bigint",
|
||||
"notNull": true,
|
||||
"autoIncrement": false,
|
||||
"default": "0",
|
||||
"onUpdateNow": false,
|
||||
"onUpdateNowFsp": null,
|
||||
"charSet": null,
|
||||
"collation": null,
|
||||
"generated": null,
|
||||
"name": "output_tokens",
|
||||
"entityType": "columns",
|
||||
"table": "stat"
|
||||
},
|
||||
{
|
||||
"type": "bigint",
|
||||
"notNull": true,
|
||||
"autoIncrement": false,
|
||||
"default": "0",
|
||||
"onUpdateNow": false,
|
||||
"onUpdateNowFsp": null,
|
||||
"charSet": null,
|
||||
"collation": null,
|
||||
"generated": null,
|
||||
"name": "reasoning_tokens",
|
||||
"entityType": "columns",
|
||||
"table": "stat"
|
||||
},
|
||||
{
|
||||
"type": "bigint",
|
||||
"notNull": true,
|
||||
"autoIncrement": false,
|
||||
"default": "0",
|
||||
"onUpdateNow": false,
|
||||
"onUpdateNowFsp": null,
|
||||
"charSet": null,
|
||||
"collation": null,
|
||||
"generated": null,
|
||||
"name": "cache_read_tokens",
|
||||
"entityType": "columns",
|
||||
"table": "stat"
|
||||
},
|
||||
{
|
||||
"type": "bigint",
|
||||
"notNull": true,
|
||||
"autoIncrement": false,
|
||||
"default": "0",
|
||||
"onUpdateNow": false,
|
||||
"onUpdateNowFsp": null,
|
||||
"charSet": null,
|
||||
"collation": null,
|
||||
"generated": null,
|
||||
"name": "total_tokens",
|
||||
"entityType": "columns",
|
||||
"table": "stat"
|
||||
},
|
||||
{
|
||||
"type": "bigint",
|
||||
"notNull": true,
|
||||
"autoIncrement": false,
|
||||
"default": "0",
|
||||
"onUpdateNow": false,
|
||||
"onUpdateNowFsp": null,
|
||||
"charSet": null,
|
||||
"collation": null,
|
||||
"generated": null,
|
||||
"name": "input_cost_microcents",
|
||||
"entityType": "columns",
|
||||
"table": "stat"
|
||||
},
|
||||
{
|
||||
"type": "bigint",
|
||||
"notNull": true,
|
||||
"autoIncrement": false,
|
||||
"default": "0",
|
||||
"onUpdateNow": false,
|
||||
"onUpdateNowFsp": null,
|
||||
"charSet": null,
|
||||
"collation": null,
|
||||
"generated": null,
|
||||
"name": "output_cost_microcents",
|
||||
"entityType": "columns",
|
||||
"table": "stat"
|
||||
},
|
||||
{
|
||||
"type": "bigint",
|
||||
"notNull": true,
|
||||
"autoIncrement": false,
|
||||
"default": "0",
|
||||
"onUpdateNow": false,
|
||||
"onUpdateNowFsp": null,
|
||||
"charSet": null,
|
||||
"collation": null,
|
||||
"generated": null,
|
||||
"name": "total_cost_microcents",
|
||||
"entityType": "columns",
|
||||
"table": "stat"
|
||||
},
|
||||
{
|
||||
"type": "decimal(12,2)",
|
||||
"notNull": false,
|
||||
"autoIncrement": false,
|
||||
"default": null,
|
||||
"onUpdateNow": false,
|
||||
"onUpdateNowFsp": null,
|
||||
"charSet": null,
|
||||
"collation": null,
|
||||
"generated": null,
|
||||
"name": "avg_duration_ms",
|
||||
"entityType": "columns",
|
||||
"table": "stat"
|
||||
},
|
||||
{
|
||||
"type": "int",
|
||||
"notNull": false,
|
||||
"autoIncrement": false,
|
||||
"default": null,
|
||||
"onUpdateNow": false,
|
||||
"onUpdateNowFsp": null,
|
||||
"charSet": null,
|
||||
"collation": null,
|
||||
"generated": null,
|
||||
"name": "p50_duration_ms",
|
||||
"entityType": "columns",
|
||||
"table": "stat"
|
||||
},
|
||||
{
|
||||
"type": "int",
|
||||
"notNull": false,
|
||||
"autoIncrement": false,
|
||||
"default": null,
|
||||
"onUpdateNow": false,
|
||||
"onUpdateNowFsp": null,
|
||||
"charSet": null,
|
||||
"collation": null,
|
||||
"generated": null,
|
||||
"name": "p95_duration_ms",
|
||||
"entityType": "columns",
|
||||
"table": "stat"
|
||||
},
|
||||
{
|
||||
"type": "decimal(12,2)",
|
||||
"notNull": false,
|
||||
"autoIncrement": false,
|
||||
"default": null,
|
||||
"onUpdateNow": false,
|
||||
"onUpdateNowFsp": null,
|
||||
"charSet": null,
|
||||
"collation": null,
|
||||
"generated": null,
|
||||
"name": "avg_ttfb_ms",
|
||||
"entityType": "columns",
|
||||
"table": "stat"
|
||||
},
|
||||
{
|
||||
"type": "int",
|
||||
"notNull": false,
|
||||
"autoIncrement": false,
|
||||
"default": null,
|
||||
"onUpdateNow": false,
|
||||
"onUpdateNowFsp": null,
|
||||
"charSet": null,
|
||||
"collation": null,
|
||||
"generated": null,
|
||||
"name": "p50_ttfb_ms",
|
||||
"entityType": "columns",
|
||||
"table": "stat"
|
||||
},
|
||||
{
|
||||
"type": "int",
|
||||
"notNull": false,
|
||||
"autoIncrement": false,
|
||||
"default": null,
|
||||
"onUpdateNow": false,
|
||||
"onUpdateNowFsp": null,
|
||||
"charSet": null,
|
||||
"collation": null,
|
||||
"generated": null,
|
||||
"name": "p95_ttfb_ms",
|
||||
"entityType": "columns",
|
||||
"table": "stat"
|
||||
},
|
||||
{
|
||||
"type": "decimal(12,4)",
|
||||
"notNull": false,
|
||||
"autoIncrement": false,
|
||||
"default": null,
|
||||
"onUpdateNow": false,
|
||||
"onUpdateNowFsp": null,
|
||||
"charSet": null,
|
||||
"collation": null,
|
||||
"generated": null,
|
||||
"name": "avg_output_tps",
|
||||
"entityType": "columns",
|
||||
"table": "stat"
|
||||
},
|
||||
{
|
||||
"type": "bigint",
|
||||
"notNull": true,
|
||||
"autoIncrement": false,
|
||||
"default": "0",
|
||||
"onUpdateNow": false,
|
||||
"onUpdateNowFsp": null,
|
||||
"charSet": null,
|
||||
"collation": null,
|
||||
"generated": null,
|
||||
"name": "success_count",
|
||||
"entityType": "columns",
|
||||
"table": "stat"
|
||||
},
|
||||
{
|
||||
"type": "bigint",
|
||||
"notNull": true,
|
||||
"autoIncrement": false,
|
||||
"default": "0",
|
||||
"onUpdateNow": false,
|
||||
"onUpdateNowFsp": null,
|
||||
"charSet": null,
|
||||
"collation": null,
|
||||
"generated": null,
|
||||
"name": "error_count",
|
||||
"entityType": "columns",
|
||||
"table": "stat"
|
||||
},
|
||||
{
|
||||
"type": "bigint",
|
||||
"notNull": true,
|
||||
"autoIncrement": false,
|
||||
"default": "0",
|
||||
"onUpdateNow": false,
|
||||
"onUpdateNowFsp": null,
|
||||
"charSet": null,
|
||||
"collation": null,
|
||||
"generated": null,
|
||||
"name": "sample_count",
|
||||
"entityType": "columns",
|
||||
"table": "stat"
|
||||
},
|
||||
{
|
||||
"type": "int",
|
||||
"notNull": false,
|
||||
"autoIncrement": false,
|
||||
"default": null,
|
||||
"onUpdateNow": false,
|
||||
"onUpdateNowFsp": null,
|
||||
"charSet": null,
|
||||
"collation": null,
|
||||
"generated": null,
|
||||
"name": "rank_by_tokens",
|
||||
"entityType": "columns",
|
||||
"table": "stat"
|
||||
},
|
||||
{
|
||||
"type": "int",
|
||||
"notNull": false,
|
||||
"autoIncrement": false,
|
||||
"default": null,
|
||||
"onUpdateNow": false,
|
||||
"onUpdateNowFsp": null,
|
||||
"charSet": null,
|
||||
"collation": null,
|
||||
"generated": null,
|
||||
"name": "rank_by_requests",
|
||||
"entityType": "columns",
|
||||
"table": "stat"
|
||||
},
|
||||
{
|
||||
"type": "int",
|
||||
"notNull": false,
|
||||
"autoIncrement": false,
|
||||
"default": null,
|
||||
"onUpdateNow": false,
|
||||
"onUpdateNowFsp": null,
|
||||
"charSet": null,
|
||||
"collation": null,
|
||||
"generated": null,
|
||||
"name": "rank_by_cost",
|
||||
"entityType": "columns",
|
||||
"table": "stat"
|
||||
},
|
||||
{
|
||||
"type": "datetime",
|
||||
"notNull": true,
|
||||
"autoIncrement": false,
|
||||
"default": "(now())",
|
||||
"onUpdateNow": false,
|
||||
"onUpdateNowFsp": null,
|
||||
"charSet": null,
|
||||
"collation": null,
|
||||
"generated": null,
|
||||
"name": "created_at",
|
||||
"entityType": "columns",
|
||||
"table": "stat"
|
||||
},
|
||||
{
|
||||
"type": "datetime",
|
||||
"notNull": true,
|
||||
"autoIncrement": false,
|
||||
"default": "(now())",
|
||||
"onUpdateNow": true,
|
||||
"onUpdateNowFsp": null,
|
||||
"charSet": null,
|
||||
"collation": null,
|
||||
"generated": null,
|
||||
"name": "updated_at",
|
||||
"entityType": "columns",
|
||||
"table": "stat"
|
||||
},
|
||||
{
|
||||
"columns": ["id"],
|
||||
"name": "PRIMARY",
|
||||
"table": "stat",
|
||||
"entityType": "pks"
|
||||
},
|
||||
{
|
||||
"columns": [
|
||||
{
|
||||
"value": "grain",
|
||||
"isExpression": false
|
||||
},
|
||||
{
|
||||
"value": "period_start",
|
||||
"isExpression": false
|
||||
},
|
||||
{
|
||||
"value": "dataset",
|
||||
"isExpression": false
|
||||
},
|
||||
{
|
||||
"value": "tier",
|
||||
"isExpression": false
|
||||
},
|
||||
{
|
||||
"value": "client",
|
||||
"isExpression": false
|
||||
},
|
||||
{
|
||||
"value": "source",
|
||||
"isExpression": false
|
||||
},
|
||||
{
|
||||
"value": "provider",
|
||||
"isExpression": false
|
||||
},
|
||||
{
|
||||
"value": "model",
|
||||
"isExpression": false
|
||||
}
|
||||
],
|
||||
"isUnique": true,
|
||||
"using": null,
|
||||
"algorithm": null,
|
||||
"lock": null,
|
||||
"nameExplicit": true,
|
||||
"name": "uniq_model_period",
|
||||
"entityType": "indexes",
|
||||
"table": "stat"
|
||||
},
|
||||
{
|
||||
"columns": [
|
||||
{
|
||||
"value": "grain",
|
||||
"isExpression": false
|
||||
},
|
||||
{
|
||||
"value": "period_start",
|
||||
"isExpression": false
|
||||
},
|
||||
{
|
||||
"value": "dataset",
|
||||
"isExpression": false
|
||||
},
|
||||
{
|
||||
"value": "tier",
|
||||
"isExpression": false
|
||||
},
|
||||
{
|
||||
"value": "total_tokens",
|
||||
"isExpression": false
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"using": null,
|
||||
"algorithm": null,
|
||||
"lock": null,
|
||||
"nameExplicit": true,
|
||||
"name": "idx_leaderboard_tokens",
|
||||
"entityType": "indexes",
|
||||
"table": "stat"
|
||||
},
|
||||
{
|
||||
"columns": [
|
||||
{
|
||||
"value": "model",
|
||||
"isExpression": false
|
||||
},
|
||||
{
|
||||
"value": "grain",
|
||||
"isExpression": false
|
||||
},
|
||||
{
|
||||
"value": "period_start",
|
||||
"isExpression": false
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"using": null,
|
||||
"algorithm": null,
|
||||
"lock": null,
|
||||
"nameExplicit": true,
|
||||
"name": "idx_model",
|
||||
"entityType": "indexes",
|
||||
"table": "stat"
|
||||
}
|
||||
],
|
||||
"renames": []
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
CREATE TABLE `geo_stat` (
|
||||
`id` bigint AUTO_INCREMENT PRIMARY KEY,
|
||||
`grain` varchar(16) NOT NULL,
|
||||
`period_start` datetime NOT NULL,
|
||||
`period_end` datetime NOT NULL,
|
||||
`dataset` varchar(64) NOT NULL DEFAULT 'all',
|
||||
`tier` varchar(64) NOT NULL DEFAULT 'all',
|
||||
`client` varchar(64) NOT NULL DEFAULT 'all',
|
||||
`source` varchar(64) NOT NULL DEFAULT 'all',
|
||||
`country` char(2) NOT NULL,
|
||||
`continent` varchar(8) NOT NULL DEFAULT '',
|
||||
`sessions` bigint NOT NULL DEFAULT 0,
|
||||
`requests` bigint NOT NULL DEFAULT 0,
|
||||
`input_tokens` bigint NOT NULL DEFAULT 0,
|
||||
`output_tokens` bigint NOT NULL DEFAULT 0,
|
||||
`reasoning_tokens` bigint NOT NULL DEFAULT 0,
|
||||
`cache_read_tokens` bigint NOT NULL DEFAULT 0,
|
||||
`total_tokens` bigint NOT NULL DEFAULT 0,
|
||||
`input_cost_microcents` bigint NOT NULL DEFAULT 0,
|
||||
`output_cost_microcents` bigint NOT NULL DEFAULT 0,
|
||||
`total_cost_microcents` bigint NOT NULL DEFAULT 0,
|
||||
`avg_duration_ms` decimal(12,2),
|
||||
`p50_duration_ms` int,
|
||||
`p95_duration_ms` int,
|
||||
`avg_ttfb_ms` decimal(12,2),
|
||||
`p50_ttfb_ms` int,
|
||||
`p95_ttfb_ms` int,
|
||||
`avg_output_tps` decimal(12,4),
|
||||
`success_count` bigint NOT NULL DEFAULT 0,
|
||||
`error_count` bigint NOT NULL DEFAULT 0,
|
||||
`sample_count` bigint NOT NULL DEFAULT 0,
|
||||
`market_share_tokens` decimal(10,6),
|
||||
`market_share_requests` decimal(10,6),
|
||||
`market_share_sessions` decimal(10,6),
|
||||
`rank_by_tokens` int,
|
||||
`rank_by_requests` int,
|
||||
`rank_by_sessions` int,
|
||||
`rank_by_cost` int,
|
||||
`created_at` datetime NOT NULL DEFAULT (now()),
|
||||
`updated_at` datetime NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP,
|
||||
CONSTRAINT `uniq_country_period` UNIQUE INDEX(`grain`,`period_start`,`dataset`,`tier`,`client`,`source`,`country`)
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `provider_stat` (
|
||||
`id` bigint AUTO_INCREMENT PRIMARY KEY,
|
||||
`grain` varchar(16) NOT NULL,
|
||||
`period_start` datetime NOT NULL,
|
||||
`period_end` datetime NOT NULL,
|
||||
`dataset` varchar(64) NOT NULL DEFAULT 'all',
|
||||
`tier` varchar(64) NOT NULL DEFAULT 'all',
|
||||
`client` varchar(64) NOT NULL DEFAULT 'all',
|
||||
`source` varchar(64) NOT NULL DEFAULT 'all',
|
||||
`provider` varchar(128) NOT NULL,
|
||||
`sessions` bigint NOT NULL DEFAULT 0,
|
||||
`requests` bigint NOT NULL DEFAULT 0,
|
||||
`input_tokens` bigint NOT NULL DEFAULT 0,
|
||||
`output_tokens` bigint NOT NULL DEFAULT 0,
|
||||
`reasoning_tokens` bigint NOT NULL DEFAULT 0,
|
||||
`cache_read_tokens` bigint NOT NULL DEFAULT 0,
|
||||
`total_tokens` bigint NOT NULL DEFAULT 0,
|
||||
`input_cost_microcents` bigint NOT NULL DEFAULT 0,
|
||||
`output_cost_microcents` bigint NOT NULL DEFAULT 0,
|
||||
`total_cost_microcents` bigint NOT NULL DEFAULT 0,
|
||||
`avg_duration_ms` decimal(12,2),
|
||||
`p50_duration_ms` int,
|
||||
`p95_duration_ms` int,
|
||||
`avg_ttfb_ms` decimal(12,2),
|
||||
`p50_ttfb_ms` int,
|
||||
`p95_ttfb_ms` int,
|
||||
`avg_output_tps` decimal(12,4),
|
||||
`success_count` bigint NOT NULL DEFAULT 0,
|
||||
`error_count` bigint NOT NULL DEFAULT 0,
|
||||
`sample_count` bigint NOT NULL DEFAULT 0,
|
||||
`market_share_tokens` decimal(10,6),
|
||||
`market_share_requests` decimal(10,6),
|
||||
`market_share_sessions` decimal(10,6),
|
||||
`rank_by_tokens` int,
|
||||
`rank_by_requests` int,
|
||||
`rank_by_sessions` int,
|
||||
`rank_by_cost` int,
|
||||
`created_at` datetime NOT NULL DEFAULT (now()),
|
||||
`updated_at` datetime NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP,
|
||||
CONSTRAINT `uniq_provider_period` UNIQUE INDEX(`grain`,`period_start`,`dataset`,`tier`,`client`,`source`,`provider`)
|
||||
);
|
||||
--> statement-breakpoint
|
||||
RENAME TABLE `stat` TO `model_stat`;--> statement-breakpoint
|
||||
CREATE INDEX `idx_country_map_tokens` ON `geo_stat` (`grain`,`period_start`,`dataset`,`tier`,`total_tokens`);--> statement-breakpoint
|
||||
CREATE INDEX `idx_country_rank` ON `geo_stat` (`grain`,`period_start`,`dataset`,`tier`,`rank_by_tokens`);--> statement-breakpoint
|
||||
CREATE INDEX `idx_country` ON `geo_stat` (`country`,`grain`,`period_start`);--> statement-breakpoint
|
||||
CREATE INDEX `idx_continent` ON `geo_stat` (`continent`,`grain`,`period_start`);--> statement-breakpoint
|
||||
CREATE INDEX `idx_provider_leaderboard_tokens` ON `provider_stat` (`grain`,`period_start`,`dataset`,`tier`,`total_tokens`);--> statement-breakpoint
|
||||
CREATE INDEX `idx_provider_market_share` ON `provider_stat` (`grain`,`period_start`,`dataset`,`tier`,`market_share_tokens`);--> statement-breakpoint
|
||||
CREATE INDEX `idx_provider_rank` ON `provider_stat` (`grain`,`period_start`,`dataset`,`tier`,`rank_by_tokens`);--> statement-breakpoint
|
||||
CREATE INDEX `idx_provider` ON `provider_stat` (`provider`,`grain`,`period_start`);
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,43 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode-ai/stats-core",
|
||||
"version": "1.14.50",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./athena": "./src/athena.ts",
|
||||
"./config": "./src/config.ts",
|
||||
"./database": "./src/database.ts",
|
||||
"./database/*": "./src/database/*.ts",
|
||||
"./domain/*": "./src/domain/*.ts",
|
||||
"./runtime": "./src/runtime.ts",
|
||||
"./stat-sync": "./src/stat-sync.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"db:generate": "drizzle-kit generate --config=drizzle.config.ts",
|
||||
"db:migrate": "bun src/migrate.ts",
|
||||
"db:push": "drizzle-kit push --config=drizzle.config.ts",
|
||||
"db:studio": "drizzle-kit studio --config=drizzle.config.ts",
|
||||
"typecheck": "tsgo --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-athena": "3.933.0",
|
||||
"@planetscale/database": "1.19.0",
|
||||
"drizzle-orm": "catalog:",
|
||||
"effect": "catalog:",
|
||||
"sst": "catalog:"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tsconfig/node22": "catalog:",
|
||||
"@types/bun": "catalog:",
|
||||
"@types/node": "catalog:",
|
||||
"@typescript/native-preview": "catalog:",
|
||||
"drizzle-kit": "catalog:",
|
||||
"typescript": "catalog:"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
import {
|
||||
AthenaClient as AwsAthenaClient,
|
||||
GetQueryExecutionCommand,
|
||||
GetQueryResultsCommand,
|
||||
StartQueryExecutionCommand,
|
||||
type Row,
|
||||
} from "@aws-sdk/client-athena"
|
||||
import { Effect, Layer, Schema } from "effect"
|
||||
import * as Context from "effect/Context"
|
||||
import { Resource } from "sst/resource"
|
||||
|
||||
const ATHENA_MAX_POLL_ATTEMPTS = 60
|
||||
const ATHENA_PAGE_SIZE = 1000
|
||||
|
||||
export type AthenaData = Record<string, string>
|
||||
|
||||
export class AthenaQueryError extends Schema.TaggedErrorClass<AthenaQueryError>()("AthenaQueryError", {
|
||||
message: Schema.String,
|
||||
queryExecutionId: Schema.optional(Schema.String),
|
||||
cause: Schema.optional(Schema.Defect),
|
||||
}) {}
|
||||
|
||||
export class AthenaQueryTimeoutError extends Schema.TaggedErrorClass<AthenaQueryTimeoutError>()(
|
||||
"AthenaQueryTimeoutError",
|
||||
{
|
||||
message: Schema.String,
|
||||
queryExecutionId: Schema.String,
|
||||
},
|
||||
) {}
|
||||
|
||||
export declare namespace Athena {
|
||||
export interface Service {
|
||||
readonly query: (query: string) => Effect.Effect<AthenaData[], AthenaQueryError | AthenaQueryTimeoutError>
|
||||
}
|
||||
}
|
||||
|
||||
export class Athena extends Context.Service<Athena, Athena.Service>()("@opencode/stats/Athena") {
|
||||
static readonly layer: Layer.Layer<Athena> = Layer.effect(
|
||||
Athena,
|
||||
Effect.sync(() => {
|
||||
const client = new AwsAthenaClient({ region: Resource.InferenceEvent.region })
|
||||
|
||||
const query = Effect.fn("Athena.query")(function* (query: string) {
|
||||
const started = yield* Effect.tryPromise({
|
||||
try: () =>
|
||||
client.send(
|
||||
new StartQueryExecutionCommand({
|
||||
QueryString: query,
|
||||
WorkGroup: Resource.InferenceEvent.workgroup,
|
||||
QueryExecutionContext: {
|
||||
Catalog: Resource.InferenceEvent.catalog,
|
||||
Database: Resource.InferenceEvent.database,
|
||||
},
|
||||
}),
|
||||
),
|
||||
catch: (cause) => new AthenaQueryError({ message: "Failed to start Athena stats query", cause }),
|
||||
})
|
||||
const queryExecutionId = started.QueryExecutionId
|
||||
if (!queryExecutionId)
|
||||
return yield* new AthenaQueryError({ message: "Athena did not return a query execution id" })
|
||||
|
||||
yield* poll(client, queryExecutionId)
|
||||
return yield* results(client, queryExecutionId)
|
||||
})
|
||||
|
||||
return Athena.of({ query })
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
const poll: (
|
||||
client: AwsAthenaClient,
|
||||
queryExecutionId: string,
|
||||
attempt?: number,
|
||||
) => Effect.Effect<void, AthenaQueryError | AthenaQueryTimeoutError> = Effect.fn("Athena.poll")(function* (
|
||||
client: AwsAthenaClient,
|
||||
queryExecutionId: string,
|
||||
attempt = 0,
|
||||
) {
|
||||
if (attempt > 0) yield* Effect.sleep("2 seconds")
|
||||
|
||||
const result = yield* Effect.tryPromise({
|
||||
try: () => client.send(new GetQueryExecutionCommand({ QueryExecutionId: queryExecutionId })),
|
||||
catch: (cause) => new AthenaQueryError({ message: "Failed to poll Athena stats query", queryExecutionId, cause }),
|
||||
})
|
||||
const status = result.QueryExecution?.Status
|
||||
|
||||
if (status?.State === "SUCCEEDED") return
|
||||
if (status?.State === "FAILED" || status?.State === "CANCELLED")
|
||||
return yield* new AthenaQueryError({
|
||||
message: `Athena stats query ${status.State.toLowerCase()}: ${status.StateChangeReason ?? "unknown reason"}`,
|
||||
queryExecutionId,
|
||||
})
|
||||
|
||||
if (attempt >= ATHENA_MAX_POLL_ATTEMPTS - 1)
|
||||
return yield* new AthenaQueryTimeoutError({
|
||||
message: `Athena stats query ${queryExecutionId} did not complete`,
|
||||
queryExecutionId,
|
||||
})
|
||||
|
||||
return yield* poll(client, queryExecutionId, attempt + 1)
|
||||
})
|
||||
|
||||
const results: (
|
||||
client: AwsAthenaClient,
|
||||
queryExecutionId: string,
|
||||
nextToken?: string,
|
||||
) => Effect.Effect<AthenaData[], AthenaQueryError> = Effect.fn("Athena.results")(function* (
|
||||
client: AwsAthenaClient,
|
||||
queryExecutionId: string,
|
||||
nextToken?: string,
|
||||
) {
|
||||
const result = yield* Effect.tryPromise({
|
||||
try: () =>
|
||||
client.send(
|
||||
new GetQueryResultsCommand({
|
||||
QueryExecutionId: queryExecutionId,
|
||||
NextToken: nextToken,
|
||||
MaxResults: ATHENA_PAGE_SIZE,
|
||||
}),
|
||||
),
|
||||
catch: (cause) => new AthenaQueryError({ message: "Failed to read Athena stats results", queryExecutionId, cause }),
|
||||
})
|
||||
const columns = result.ResultSet?.ResultSetMetadata?.ColumnInfo?.map((item) => item.Name ?? "") ?? []
|
||||
const rows = (result.ResultSet?.Rows ?? []).slice(nextToken ? 0 : 1).map((row) => rowData(columns, row))
|
||||
|
||||
if (!result.NextToken) return rows
|
||||
return [...rows, ...(yield* results(client, queryExecutionId, result.NextToken))]
|
||||
})
|
||||
|
||||
function rowData(columns: string[], row: Row): AthenaData {
|
||||
return Object.fromEntries(
|
||||
columns.flatMap((column, index) => {
|
||||
const value = row.Data?.[index]?.VarCharValue
|
||||
if (!column || value === undefined) return []
|
||||
return [[column, value]]
|
||||
}),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { Config, ConfigProvider, Effect, Layer, Schema } from "effect"
|
||||
import * as Context from "effect/Context"
|
||||
import { Resource } from "sst/resource"
|
||||
|
||||
export class AppConfigValue extends Schema.Class<AppConfigValue>("AppConfigValue")({
|
||||
stage: Schema.NonEmptyString,
|
||||
publicUrl: Schema.NonEmptyString,
|
||||
}) {}
|
||||
|
||||
const decodeAppConfigValue = Schema.decodeUnknownSync(AppConfigValue)
|
||||
|
||||
const config = Config.all({
|
||||
stage: Config.succeed(Resource.App.stage),
|
||||
publicUrl: Config.string("PUBLIC_URL").pipe(Config.withDefault("http://localhost:3000")),
|
||||
}).pipe(Config.map(decodeAppConfigValue))
|
||||
|
||||
export class AppConfig extends Context.Service<AppConfig, AppConfigValue>()("@opencode/stats/AppConfig") {
|
||||
static readonly config = config
|
||||
static readonly layer: Layer.Layer<AppConfig, never, never> = Layer.effect(
|
||||
AppConfig,
|
||||
config.parse(ConfigProvider.fromEnv()).pipe(Effect.orDie),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { Client } from "@planetscale/database"
|
||||
import { drizzle } from "drizzle-orm/planetscale-serverless"
|
||||
import { migrate as drizzleMigrate } from "drizzle-orm/planetscale-serverless/migrator"
|
||||
import { Config, ConfigProvider, Effect, Layer, Schema } from "effect"
|
||||
import * as Context from "effect/Context"
|
||||
import * as schema from "./database/schema"
|
||||
import { Resource } from "sst/resource"
|
||||
|
||||
export const DatabaseUrl = Schema.NonEmptyString.pipe(Schema.brand("DatabaseUrl"))
|
||||
export type DatabaseUrl = typeof DatabaseUrl.Type
|
||||
|
||||
export class DatabaseSettings extends Schema.Class<DatabaseSettings>("DatabaseSettings")({
|
||||
url: DatabaseUrl,
|
||||
migrationsDir: Schema.NonEmptyString,
|
||||
}) {}
|
||||
|
||||
const decodeDatabaseSettings = Schema.decodeUnknownSync(DatabaseSettings)
|
||||
|
||||
const config = Config.all({
|
||||
url: Config.nonEmptyString("DATABASE_URL").pipe(Config.withDefault(Resource.StatsDatabase.url)),
|
||||
migrationsDir: Config.nonEmptyString("DATABASE_MIGRATIONS_DIR").pipe(Config.withDefault("./migrations")),
|
||||
}).pipe(Config.map(decodeDatabaseSettings))
|
||||
|
||||
export class DatabaseConfig extends Context.Service<DatabaseConfig, DatabaseSettings>()(
|
||||
"@opencode/stats/DatabaseConfig",
|
||||
) {
|
||||
static readonly config = config
|
||||
static readonly layer: Layer.Layer<DatabaseConfig, never, never> = Layer.effect(
|
||||
DatabaseConfig,
|
||||
config.parse(ConfigProvider.fromEnv()).pipe(Effect.orDie),
|
||||
)
|
||||
}
|
||||
|
||||
function makeDrizzle(settings: DatabaseSettings) {
|
||||
return drizzle({ client: new Client({ url: settings.url }), schema })
|
||||
}
|
||||
|
||||
export type Drizzle = ReturnType<typeof makeDrizzle>
|
||||
|
||||
export class DrizzleClient extends Context.Service<DrizzleClient, Drizzle>()("@opencode/stats/DrizzleClient") {
|
||||
static readonly layer: Layer.Layer<DrizzleClient, never, DatabaseConfig> = Layer.effect(
|
||||
DrizzleClient,
|
||||
Effect.map(DatabaseConfig, makeDrizzle),
|
||||
)
|
||||
}
|
||||
|
||||
export class DatabaseError extends Schema.TaggedErrorClass<DatabaseError>()("DatabaseError", {
|
||||
cause: Schema.Defect,
|
||||
}) {}
|
||||
|
||||
export const catchDbError = Effect.mapError((cause) => DatabaseError.make({ cause }))
|
||||
|
||||
export class MigrationError extends Schema.TaggedErrorClass<MigrationError>()("MigrationError", {
|
||||
message: Schema.String,
|
||||
cause: Schema.optional(Schema.Defect),
|
||||
}) {}
|
||||
|
||||
export const migrate = Effect.fn("Database.migrate")(function* () {
|
||||
const settings = yield* DatabaseConfig
|
||||
yield* Effect.logInfo("applying database migrations").pipe(
|
||||
Effect.annotateLogs({ migrationsDir: settings.migrationsDir }),
|
||||
)
|
||||
const result = yield* Effect.tryPromise({
|
||||
try: () =>
|
||||
drizzleMigrate(drizzle({ client: new Client({ url: settings.url }) }), {
|
||||
migrationsFolder: settings.migrationsDir,
|
||||
}),
|
||||
catch: (cause) => new MigrationError({ message: "Failed to apply database migrations", cause }),
|
||||
})
|
||||
if (result)
|
||||
return yield* new MigrationError({
|
||||
message: `Failed to initialize database migrations: ${result.exitCode}`,
|
||||
})
|
||||
yield* Effect.logInfo("database migrations complete").pipe(
|
||||
Effect.annotateLogs({ migrationsDir: settings.migrationsDir }),
|
||||
)
|
||||
})
|
||||
|
||||
export const layer = Layer.mergeAll(DatabaseConfig.layer, DrizzleClient.layer.pipe(Layer.provide(DatabaseConfig.layer)))
|
||||
@@ -0,0 +1,156 @@
|
||||
import { bigint, char, datetime, decimal, index, int, mysqlTable, uniqueIndex, varchar } from "drizzle-orm/mysql-core"
|
||||
|
||||
export const modelStat = mysqlTable(
|
||||
"model_stat",
|
||||
{
|
||||
...periodColumns(),
|
||||
provider: varchar({ length: 128 }).notNull(),
|
||||
model: varchar({ length: 256 }).notNull(),
|
||||
provider_model: varchar({ length: 256 }).notNull().default(""),
|
||||
...metricColumns(),
|
||||
rank_by_tokens: int(),
|
||||
rank_by_requests: int(),
|
||||
rank_by_cost: int(),
|
||||
...timestampColumns(),
|
||||
},
|
||||
(table) => [
|
||||
uniqueIndex("uniq_model_period").on(
|
||||
table.grain,
|
||||
table.period_start,
|
||||
table.dataset,
|
||||
table.tier,
|
||||
table.client,
|
||||
table.source,
|
||||
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),
|
||||
],
|
||||
)
|
||||
|
||||
export const providerStat = mysqlTable(
|
||||
"provider_stat",
|
||||
{
|
||||
...periodColumns(),
|
||||
provider: varchar({ length: 128 }).notNull(),
|
||||
...metricColumns(),
|
||||
...marketShareColumns(),
|
||||
rank_by_tokens: int(),
|
||||
rank_by_requests: int(),
|
||||
rank_by_sessions: int(),
|
||||
rank_by_cost: int(),
|
||||
...timestampColumns(),
|
||||
},
|
||||
(table) => [
|
||||
uniqueIndex("uniq_provider_period").on(
|
||||
table.grain,
|
||||
table.period_start,
|
||||
table.dataset,
|
||||
table.tier,
|
||||
table.client,
|
||||
table.source,
|
||||
table.provider,
|
||||
),
|
||||
index("idx_provider_leaderboard_tokens").on(
|
||||
table.grain,
|
||||
table.period_start,
|
||||
table.dataset,
|
||||
table.tier,
|
||||
table.total_tokens,
|
||||
),
|
||||
index("idx_provider_market_share").on(
|
||||
table.grain,
|
||||
table.period_start,
|
||||
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),
|
||||
],
|
||||
)
|
||||
|
||||
export const geoStat = mysqlTable(
|
||||
"geo_stat",
|
||||
{
|
||||
...periodColumns(),
|
||||
country: char({ length: 2 }).notNull(),
|
||||
continent: varchar({ length: 8 }).notNull().default(""),
|
||||
...metricColumns(),
|
||||
...marketShareColumns(),
|
||||
rank_by_tokens: int(),
|
||||
rank_by_requests: int(),
|
||||
rank_by_sessions: int(),
|
||||
rank_by_cost: int(),
|
||||
...timestampColumns(),
|
||||
},
|
||||
(table) => [
|
||||
uniqueIndex("uniq_country_period").on(
|
||||
table.grain,
|
||||
table.period_start,
|
||||
table.dataset,
|
||||
table.tier,
|
||||
table.client,
|
||||
table.source,
|
||||
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),
|
||||
],
|
||||
)
|
||||
|
||||
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(),
|
||||
dataset: varchar({ length: 64 }).notNull().default("all"),
|
||||
tier: varchar({ length: 64 }).notNull().default("all"),
|
||||
client: varchar({ length: 64 }).notNull().default("all"),
|
||||
source: varchar({ length: 64 }).notNull().default("all"),
|
||||
}
|
||||
}
|
||||
|
||||
function metricColumns() {
|
||||
return {
|
||||
sessions: bigint({ mode: "number" }).notNull().default(0),
|
||||
requests: bigint({ mode: "number" }).notNull().default(0),
|
||||
input_tokens: bigint({ mode: "number" }).notNull().default(0),
|
||||
output_tokens: bigint({ mode: "number" }).notNull().default(0),
|
||||
reasoning_tokens: bigint({ mode: "number" }).notNull().default(0),
|
||||
cache_read_tokens: bigint({ mode: "number" }).notNull().default(0),
|
||||
total_tokens: bigint({ mode: "number" }).notNull().default(0),
|
||||
input_cost_microcents: bigint({ mode: "number" }).notNull().default(0),
|
||||
output_cost_microcents: bigint({ mode: "number" }).notNull().default(0),
|
||||
total_cost_microcents: bigint({ mode: "number" }).notNull().default(0),
|
||||
avg_duration_ms: decimal({ precision: 12, scale: 2, mode: "number" }),
|
||||
p50_duration_ms: int(),
|
||||
p95_duration_ms: int(),
|
||||
avg_ttfb_ms: decimal({ precision: 12, scale: 2, mode: "number" }),
|
||||
p50_ttfb_ms: int(),
|
||||
p95_ttfb_ms: int(),
|
||||
avg_output_tps: decimal({ precision: 12, scale: 4, mode: "number" }),
|
||||
success_count: bigint({ mode: "number" }).notNull().default(0),
|
||||
error_count: bigint({ mode: "number" }).notNull().default(0),
|
||||
sample_count: bigint({ mode: "number" }).notNull().default(0),
|
||||
}
|
||||
}
|
||||
|
||||
function marketShareColumns() {
|
||||
return {
|
||||
market_share_tokens: decimal({ precision: 10, scale: 6, mode: "number" }),
|
||||
market_share_requests: decimal({ precision: 10, scale: 6, mode: "number" }),
|
||||
market_share_sessions: decimal({ precision: 10, scale: 6, mode: "number" }),
|
||||
}
|
||||
}
|
||||
|
||||
function timestampColumns() {
|
||||
return {
|
||||
created_at: datetime({ mode: "date" }).notNull().defaultNow(),
|
||||
updated_at: datetime({ mode: "date" }).notNull().defaultNow().onUpdateNow(),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
import { and, asc, eq } from "drizzle-orm"
|
||||
import { Effect, Layer } from "effect"
|
||||
import * as Context from "effect/Context"
|
||||
import { DatabaseError, DrizzleClient } from "../database"
|
||||
import { geoStat } from "../database/schema"
|
||||
import {
|
||||
chunks,
|
||||
collapseRows,
|
||||
inserted,
|
||||
rankRowsWithMarketShare,
|
||||
synthesizeAllTierRows,
|
||||
toStatBaseRow,
|
||||
UPSERT_CHUNK_SIZE,
|
||||
type StatBaseAggregate,
|
||||
} from "./stat"
|
||||
|
||||
export type GeoStatRow = typeof geoStat.$inferInsert
|
||||
export type GeoStatAggregate = StatBaseAggregate & { country: string; continent: string }
|
||||
export type GeoStatMetric = {
|
||||
periodStart: Date
|
||||
periodEnd: Date
|
||||
tier: string
|
||||
country: string
|
||||
continent: string
|
||||
totalTokens: number
|
||||
}
|
||||
|
||||
export declare namespace GeoStatRepo {
|
||||
export interface Service {
|
||||
readonly listDaily: () => Effect.Effect<GeoStatMetric[], DatabaseError>
|
||||
readonly listByPeriod: (opts: {
|
||||
readonly grain: string
|
||||
readonly periodStart: Date
|
||||
readonly dataset?: string
|
||||
readonly tier?: string
|
||||
readonly client?: string
|
||||
readonly source?: string
|
||||
}) => Effect.Effect<GeoStatRow[], DatabaseError>
|
||||
readonly upsert: (rows: GeoStatRow[]) => Effect.Effect<void, DatabaseError>
|
||||
}
|
||||
}
|
||||
|
||||
export class GeoStatRepo extends Context.Service<GeoStatRepo, GeoStatRepo.Service>()("@opencode/stats/GeoStatRepo") {
|
||||
static readonly layer: Layer.Layer<GeoStatRepo, never, DrizzleClient> = Layer.effect(
|
||||
GeoStatRepo,
|
||||
Effect.gen(function* () {
|
||||
const db = yield* DrizzleClient
|
||||
|
||||
const listDaily = Effect.fn("GeoStatRepo.listDaily")(function* () {
|
||||
return yield* Effect.tryPromise({
|
||||
try: () =>
|
||||
db
|
||||
.select({
|
||||
periodStart: geoStat.period_start,
|
||||
periodEnd: geoStat.period_end,
|
||||
tier: geoStat.tier,
|
||||
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)),
|
||||
catch: (cause) => DatabaseError.make({ cause }),
|
||||
})
|
||||
})
|
||||
|
||||
const listByPeriod = Effect.fn("GeoStatRepo.listByPeriod")(function* (opts: {
|
||||
readonly grain: string
|
||||
readonly periodStart: Date
|
||||
readonly dataset?: string
|
||||
readonly tier?: string
|
||||
readonly client?: string
|
||||
readonly source?: string
|
||||
}) {
|
||||
return yield* Effect.tryPromise({
|
||||
try: () =>
|
||||
db
|
||||
.select()
|
||||
.from(geoStat)
|
||||
.where(
|
||||
and(
|
||||
eq(geoStat.grain, opts.grain),
|
||||
eq(geoStat.period_start, opts.periodStart),
|
||||
eq(geoStat.dataset, opts.dataset ?? "zen"),
|
||||
eq(geoStat.tier, opts.tier ?? "all"),
|
||||
eq(geoStat.client, opts.client ?? "all"),
|
||||
eq(geoStat.source, opts.source ?? "all"),
|
||||
),
|
||||
),
|
||||
catch: (cause) => DatabaseError.make({ cause }),
|
||||
})
|
||||
})
|
||||
|
||||
const upsert = Effect.fn("GeoStatRepo.upsert")(function* (rows: GeoStatRow[]) {
|
||||
yield* Effect.forEach(
|
||||
chunks(rows, UPSERT_CHUNK_SIZE),
|
||||
(chunk) =>
|
||||
Effect.tryPromise({
|
||||
try: () =>
|
||||
db
|
||||
.insert(geoStat)
|
||||
.values(chunk)
|
||||
.onDuplicateKeyUpdate({
|
||||
set: {
|
||||
period_end: inserted("period_end"),
|
||||
continent: inserted("continent"),
|
||||
sessions: inserted("sessions"),
|
||||
requests: inserted("requests"),
|
||||
input_tokens: inserted("input_tokens"),
|
||||
output_tokens: inserted("output_tokens"),
|
||||
reasoning_tokens: inserted("reasoning_tokens"),
|
||||
cache_read_tokens: inserted("cache_read_tokens"),
|
||||
total_tokens: inserted("total_tokens"),
|
||||
input_cost_microcents: inserted("input_cost_microcents"),
|
||||
output_cost_microcents: inserted("output_cost_microcents"),
|
||||
total_cost_microcents: inserted("total_cost_microcents"),
|
||||
avg_duration_ms: inserted("avg_duration_ms"),
|
||||
p50_duration_ms: inserted("p50_duration_ms"),
|
||||
p95_duration_ms: inserted("p95_duration_ms"),
|
||||
avg_ttfb_ms: inserted("avg_ttfb_ms"),
|
||||
p50_ttfb_ms: inserted("p50_ttfb_ms"),
|
||||
p95_ttfb_ms: inserted("p95_ttfb_ms"),
|
||||
avg_output_tps: inserted("avg_output_tps"),
|
||||
success_count: inserted("success_count"),
|
||||
error_count: inserted("error_count"),
|
||||
sample_count: inserted("sample_count"),
|
||||
market_share_tokens: inserted("market_share_tokens"),
|
||||
market_share_requests: inserted("market_share_requests"),
|
||||
market_share_sessions: inserted("market_share_sessions"),
|
||||
rank_by_tokens: inserted("rank_by_tokens"),
|
||||
rank_by_requests: inserted("rank_by_requests"),
|
||||
rank_by_sessions: inserted("rank_by_sessions"),
|
||||
rank_by_cost: inserted("rank_by_cost"),
|
||||
},
|
||||
}),
|
||||
catch: (cause) => DatabaseError.make({ cause }),
|
||||
}),
|
||||
{ discard: true },
|
||||
)
|
||||
})
|
||||
|
||||
return GeoStatRepo.of({ listDaily, listByPeriod, upsert })
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
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,
|
||||
),
|
||||
])
|
||||
}
|
||||
|
||||
function toRow(data: GeoStatAggregate): GeoStatRow {
|
||||
return {
|
||||
...toStatBaseRow(data),
|
||||
country: data.country,
|
||||
continent: data.continent,
|
||||
}
|
||||
}
|
||||
|
||||
function dimensionKey(row: GeoStatRow) {
|
||||
return row.country
|
||||
}
|
||||
@@ -0,0 +1,467 @@
|
||||
import { Effect } from "effect"
|
||||
import { DatabaseError } from "../database"
|
||||
import { GeoStatRepo, type GeoStatMetric } from "./geo"
|
||||
import { ModelStatRepo, type ModelStatMetric } from "./model"
|
||||
import { ProviderStatRepo, type ProviderStatMetric } from "./provider"
|
||||
|
||||
export type UsageProduct = "All Users" | "Zen" | "Go" | "Enterprise"
|
||||
export type TokenProduct = "Zen" | "Go" | "Enterprise"
|
||||
export type UsageRange = "1D" | "1W" | "1M" | "3M" | "YTD" | "ALL"
|
||||
export type UsagePoint = { date: string; segments: { model: string; value: number }[] }
|
||||
export type MarketDay = { date: string; total: number; authors: { author: string; share: number; tokens: number }[] }
|
||||
export type LeaderboardEntry = { model: string; author: string; tokens: number; change: number; rank: number }
|
||||
export type TokenCostEntry = { model: string; total: number; input: number; output: number; cached: number }
|
||||
export type SessionCostEntry = { model: string; cost: number; tokens: number }
|
||||
export type CountryEntry = { country: string; continent: string; tokens: number; share: number; rank: number }
|
||||
export type StatsHomeData = {
|
||||
updatedAt: string | null
|
||||
usage: Record<UsageProduct, Record<UsageRange, UsagePoint[]>>
|
||||
leaderboard: Record<UsageProduct, Record<UsageRange, LeaderboardEntry[]>>
|
||||
market: Record<UsageRange, MarketDay[]>
|
||||
tokenCost: Record<TokenProduct, TokenCostEntry[]>
|
||||
sessionCost: Record<TokenProduct, SessionCostEntry[]>
|
||||
country: Record<UsageRange, CountryEntry[]>
|
||||
}
|
||||
|
||||
const DAY_MS = 86_400_000
|
||||
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"> & {
|
||||
periodStart: number
|
||||
periodEnd: number
|
||||
}
|
||||
type ProviderMetricRow = Omit<ProviderStatMetric, "periodStart" | "periodEnd"> & {
|
||||
periodStart: number
|
||||
periodEnd: number
|
||||
}
|
||||
type GeoMetricRow = Omit<GeoStatMetric, "periodStart" | "periodEnd"> & {
|
||||
periodStart: number
|
||||
periodEnd: number
|
||||
}
|
||||
|
||||
type DateWindow = { start: number; end: number; previousStart: number; previousEnd: number }
|
||||
type Bucket = { start: number; end: number; label: string }
|
||||
type ModelAggregate = {
|
||||
model: string
|
||||
provider: string
|
||||
sessions: number
|
||||
inputTokens: number
|
||||
outputTokens: number
|
||||
reasoningTokens: number
|
||||
cacheReadTokens: number
|
||||
totalTokens: number
|
||||
inputCostMicrocents: number
|
||||
outputCostMicrocents: number
|
||||
totalCostMicrocents: number
|
||||
}
|
||||
|
||||
export const getStatsHomeData: () => Effect.Effect<
|
||||
StatsHomeData,
|
||||
DatabaseError,
|
||||
ModelStatRepo | ProviderStatRepo | GeoStatRepo
|
||||
> = Effect.fn("StatsHome.getData")(function* () {
|
||||
const modelStats = yield* ModelStatRepo
|
||||
const providerStats = yield* ProviderStatRepo
|
||||
const geoStats = yield* GeoStatRepo
|
||||
const [modelRows, providerRows, geoRows] = yield* Effect.all(
|
||||
[modelStats.listDaily(), providerStats.listDaily(), geoStats.listDaily()],
|
||||
{ concurrency: "unbounded" },
|
||||
)
|
||||
return buildStatsHomeData(modelRows, providerRows, geoRows)
|
||||
})
|
||||
|
||||
function buildStatsHomeData(
|
||||
modelRows: ModelStatMetric[],
|
||||
providerRows: ProviderStatMetric[],
|
||||
geoRows: GeoStatMetric[],
|
||||
): StatsHomeData {
|
||||
const normalized = modelRows.flatMap(normalizeStatRow)
|
||||
const providers = providerRows.flatMap(normalizeProviderRow)
|
||||
const geo = geoRows.flatMap(normalizeGeoRow)
|
||||
const periods = [...normalized, ...providers, ...geo]
|
||||
if (periods.length === 0) return emptyStatsHomeData()
|
||||
|
||||
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))
|
||||
|
||||
return {
|
||||
updatedAt: new Date(latestEnd).toISOString(),
|
||||
usage: createUsageProductRecord((product) =>
|
||||
createRangeRecord((range) => buildUsagePoints(normalized, product, range, getWindow(range, earliest, latest))),
|
||||
),
|
||||
leaderboard: createUsageProductRecord((product) =>
|
||||
createRangeRecord((range) => buildLeaderboard(normalized, product, getWindow(range, earliest, latest))),
|
||||
),
|
||||
market: createRangeRecord((range) => buildMarketShare(providers, range, getWindow(range, earliest, latest))),
|
||||
tokenCost: createTokenProductRecord((product) =>
|
||||
buildTokenCost(normalized, product, getWindow("1W", earliest, latest)),
|
||||
),
|
||||
sessionCost: createTokenProductRecord((product) =>
|
||||
buildSessionCost(normalized, product, getWindow("1W", earliest, latest)),
|
||||
),
|
||||
country: createRangeRecord((range) => buildCountryStats(geo, getWindow(range, earliest, latest))),
|
||||
}
|
||||
}
|
||||
|
||||
function emptyStatsHomeData(): StatsHomeData {
|
||||
return {
|
||||
updatedAt: null,
|
||||
usage: createUsageProductRecord(() => createRangeRecord(() => [])),
|
||||
leaderboard: createUsageProductRecord(() => createRangeRecord(() => [])),
|
||||
market: createRangeRecord(() => []),
|
||||
tokenCost: createTokenProductRecord(() => []),
|
||||
sessionCost: createTokenProductRecord(() => []),
|
||||
country: createRangeRecord(() => []),
|
||||
}
|
||||
}
|
||||
|
||||
function buildUsagePoints(rows: StatMetricRow[], product: UsageProduct, range: UsageRange, window: DateWindow) {
|
||||
const windowRows = rowsForProduct(rows, product, window.start, window.end)
|
||||
const modelOrder = aggregateByModel(windowRows)
|
||||
.toSorted((a, b) => b.totalTokens - a.totalTokens)
|
||||
.slice(0, 6)
|
||||
.map((item) => ({ key: modelKey(item.provider, item.model), model: item.model }))
|
||||
|
||||
return createBuckets(window, range).map((bucket) => {
|
||||
const bucketRows = aggregateByModel(rowsForProduct(rows, product, bucket.start, bucket.end))
|
||||
const byModel = new Map(bucketRows.map((item) => [modelKey(item.provider, item.model), item.totalTokens]))
|
||||
const segmentTokens = modelOrder.map((model) => ({ model: model.model, tokens: byModel.get(model.key) ?? 0 }))
|
||||
const knownTokens = segmentTokens.reduce((sum, item) => sum + item.tokens, 0)
|
||||
const totalTokens = bucketRows.reduce((sum, item) => sum + item.totalTokens, 0)
|
||||
return {
|
||||
date: bucket.label,
|
||||
segments: [
|
||||
...segmentTokens.map((item) => ({ model: item.model, value: round(item.tokens / 1_000_000_000_000, 2) })),
|
||||
{ model: "Other", value: round(Math.max(totalTokens - knownTokens, 0) / 1_000_000_000_000, 2) },
|
||||
].filter((item) => item.value > 0),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function buildLeaderboard(rows: StatMetricRow[], product: UsageProduct, window: DateWindow) {
|
||||
const previous = new Map(
|
||||
aggregateByModel(rowsForProduct(rows, product, window.previousStart, window.previousEnd)).map((item) => [
|
||||
modelKey(item.provider, item.model),
|
||||
item.totalTokens,
|
||||
]),
|
||||
)
|
||||
|
||||
return aggregateByModel(rowsForProduct(rows, product, window.start, window.end))
|
||||
.toSorted((a, b) => b.totalTokens - a.totalTokens)
|
||||
.slice(0, 13)
|
||||
.map((item, index) => ({
|
||||
model: item.model,
|
||||
author: formatProvider(item.provider),
|
||||
tokens: Math.round(item.totalTokens / 1_000_000_000),
|
||||
change: percentChange(item.totalTokens, previous.get(modelKey(item.provider, item.model)) ?? 0),
|
||||
rank: index + 1,
|
||||
}))
|
||||
}
|
||||
|
||||
function buildMarketShare(rows: ProviderMetricRow[], range: UsageRange, window: DateWindow) {
|
||||
return createBuckets(window, range).flatMap((bucket) => {
|
||||
const total = aggregateByProvider(rowsForProduct(rows, "All Users", bucket.start, bucket.end)).toSorted(
|
||||
(a, b) => b.tokens - a.tokens,
|
||||
)
|
||||
const totalTokens = total.reduce((sum, item) => sum + item.tokens, 0)
|
||||
if (totalTokens === 0) return []
|
||||
|
||||
const authors = total.slice(0, 8)
|
||||
const knownTokens = authors.reduce((sum, item) => sum + item.tokens, 0)
|
||||
const withOther = [...authors, { provider: "Other", tokens: Math.max(totalTokens - knownTokens, 0) }].filter(
|
||||
(item) => item.tokens > 0,
|
||||
)
|
||||
|
||||
return [
|
||||
{
|
||||
date: bucket.label,
|
||||
total: round(totalTokens / 1_000_000_000_000, 2),
|
||||
authors: withOther.map((item) => ({
|
||||
author: item.provider === "Other" ? "Other" : formatProvider(item.provider),
|
||||
share: round((item.tokens / totalTokens) * 100, 1),
|
||||
tokens: round(item.tokens / 1_000_000_000_000, 2),
|
||||
})),
|
||||
},
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
function buildCountryStats(rows: GeoMetricRow[], window: DateWindow) {
|
||||
const countries = aggregateByCountry(rowsForProduct(rows, "All Users", window.start, window.end))
|
||||
.filter((item) => item.tokens > 0)
|
||||
.toSorted((a, b) => b.tokens - a.tokens)
|
||||
const totalTokens = countries.reduce((sum, item) => sum + item.tokens, 0)
|
||||
if (totalTokens === 0) return []
|
||||
|
||||
return countries.slice(0, 16).map((item, index) => ({
|
||||
country: item.country,
|
||||
continent: item.continent,
|
||||
tokens: round(item.tokens / 1_000_000_000_000, 4),
|
||||
share: round((item.tokens / totalTokens) * 100, 1),
|
||||
rank: index + 1,
|
||||
}))
|
||||
}
|
||||
|
||||
function buildTokenCost(rows: StatMetricRow[], product: TokenProduct, window: DateWindow) {
|
||||
return aggregateByModel(rowsForProduct(rows, product, window.start, window.end))
|
||||
.flatMap((item) => {
|
||||
const total = costPerMillion(item.totalCostMicrocents, item.totalTokens)
|
||||
if (total === 0) return []
|
||||
return [
|
||||
{
|
||||
model: item.model,
|
||||
total,
|
||||
input: costPerMillion(item.inputCostMicrocents, item.inputTokens),
|
||||
output: costPerMillion(item.outputCostMicrocents, item.outputTokens + item.reasoningTokens),
|
||||
cached: costPerMillion(item.inputCostMicrocents, item.inputTokens + item.cacheReadTokens),
|
||||
},
|
||||
]
|
||||
})
|
||||
.toSorted((a, b) => a.total - b.total)
|
||||
.slice(0, 17)
|
||||
}
|
||||
|
||||
function buildSessionCost(rows: StatMetricRow[], product: TokenProduct, window: DateWindow) {
|
||||
return aggregateByModel(rowsForProduct(rows, product, window.start, window.end))
|
||||
.flatMap((item) => {
|
||||
if (item.sessions === 0) return []
|
||||
const cost = round(microcentsToDollars(item.totalCostMicrocents) / item.sessions, 4)
|
||||
if (cost === 0) return []
|
||||
return [{ model: item.model, cost, tokens: Math.round(item.totalTokens / item.sessions) }]
|
||||
})
|
||||
.toSorted((a, b) => a.cost - b.cost)
|
||||
.slice(0, 17)
|
||||
}
|
||||
|
||||
function rowsForProduct<T extends { periodStart: number; tier: string }>(
|
||||
rows: T[],
|
||||
product: UsageProduct,
|
||||
start: number,
|
||||
end: number,
|
||||
) {
|
||||
const windowRows = rows.filter((row) => row.periodStart >= start && row.periodStart < end)
|
||||
if (product !== "All Users") return windowRows.filter((row) => row.tier === product)
|
||||
|
||||
const allRows = windowRows.filter((row) => row.tier === "all")
|
||||
if (allRows.length > 0) return allRows
|
||||
return windowRows.filter((row) => row.tier !== "all")
|
||||
}
|
||||
|
||||
function aggregateByModel(rows: StatMetricRow[]) {
|
||||
return Object.values(
|
||||
rows.reduce<Record<string, ModelAggregate>>((result, row) => {
|
||||
const key = modelKey(row.provider, row.model)
|
||||
result[key] = combineModelAggregate(result[key], row)
|
||||
return result
|
||||
}, {}),
|
||||
)
|
||||
}
|
||||
|
||||
function aggregateByProvider(rows: ProviderMetricRow[]) {
|
||||
return Object.values(
|
||||
rows.reduce<Record<string, { provider: string; tokens: number }>>((result, row) => {
|
||||
result[row.provider] = {
|
||||
provider: row.provider,
|
||||
tokens: (result[row.provider]?.tokens ?? 0) + row.totalTokens,
|
||||
}
|
||||
return result
|
||||
}, {}),
|
||||
)
|
||||
}
|
||||
|
||||
function aggregateByCountry(rows: GeoMetricRow[]) {
|
||||
return Object.values(
|
||||
rows.reduce<Record<string, { country: string; continent: string; tokens: number }>>((result, row) => {
|
||||
result[row.country] = {
|
||||
country: row.country,
|
||||
continent: result[row.country]?.continent || row.continent,
|
||||
tokens: (result[row.country]?.tokens ?? 0) + row.totalTokens,
|
||||
}
|
||||
return result
|
||||
}, {}),
|
||||
)
|
||||
}
|
||||
|
||||
function combineModelAggregate(current: ModelAggregate | undefined, row: StatMetricRow): ModelAggregate {
|
||||
return {
|
||||
model: row.model,
|
||||
provider: row.provider,
|
||||
sessions: (current?.sessions ?? 0) + row.sessions,
|
||||
inputTokens: (current?.inputTokens ?? 0) + row.inputTokens,
|
||||
outputTokens: (current?.outputTokens ?? 0) + row.outputTokens,
|
||||
reasoningTokens: (current?.reasoningTokens ?? 0) + row.reasoningTokens,
|
||||
cacheReadTokens: (current?.cacheReadTokens ?? 0) + row.cacheReadTokens,
|
||||
totalTokens: (current?.totalTokens ?? 0) + row.totalTokens,
|
||||
inputCostMicrocents: (current?.inputCostMicrocents ?? 0) + row.inputCostMicrocents,
|
||||
outputCostMicrocents: (current?.outputCostMicrocents ?? 0) + row.outputCostMicrocents,
|
||||
totalCostMicrocents: (current?.totalCostMicrocents ?? 0) + row.totalCostMicrocents,
|
||||
}
|
||||
}
|
||||
|
||||
function getWindow(range: UsageRange, earliest: number, latest: number): DateWindow {
|
||||
const end = latest + DAY_MS
|
||||
const start = Math.max(
|
||||
earliest,
|
||||
range === "1D"
|
||||
? latest
|
||||
: range === "1W"
|
||||
? latest - 6 * DAY_MS
|
||||
: range === "1M"
|
||||
? latest - 29 * DAY_MS
|
||||
: range === "3M"
|
||||
? latest - 89 * DAY_MS
|
||||
: range === "YTD"
|
||||
? Date.UTC(new Date(latest).getUTCFullYear(), 0, 1)
|
||||
: earliest,
|
||||
)
|
||||
const duration = end - start
|
||||
return { start, end, previousStart: start - duration, previousEnd: start }
|
||||
}
|
||||
|
||||
function createBuckets(window: DateWindow, range: UsageRange): Bucket[] {
|
||||
const span = Math.max(window.end - window.start, DAY_MS)
|
||||
const count = Math.max(1, Math.min(7, Math.ceil(span / DAY_MS)))
|
||||
const size = span / count
|
||||
return Array.from({ length: count }, (_, index) => {
|
||||
const start = window.start + index * size
|
||||
const end = index === count - 1 ? window.end : window.start + (index + 1) * size
|
||||
return { start, end, label: formatBucketLabel(start, range) }
|
||||
})
|
||||
}
|
||||
|
||||
function createUsageProductRecord<T>(value: (product: UsageProduct) => T): Record<UsageProduct, T> {
|
||||
return {
|
||||
"All Users": value("All Users"),
|
||||
Zen: value("Zen"),
|
||||
Go: value("Go"),
|
||||
Enterprise: value("Enterprise"),
|
||||
}
|
||||
}
|
||||
|
||||
function createTokenProductRecord<T>(value: (product: TokenProduct) => T): Record<TokenProduct, T> {
|
||||
return {
|
||||
Zen: value("Zen"),
|
||||
Go: value("Go"),
|
||||
Enterprise: value("Enterprise"),
|
||||
}
|
||||
}
|
||||
|
||||
function createRangeRecord<T>(value: (range: UsageRange) => T): Record<UsageRange, T> {
|
||||
return {
|
||||
"1D": value("1D"),
|
||||
"1W": value("1W"),
|
||||
"1M": value("1M"),
|
||||
"3M": value("3M"),
|
||||
YTD: value("YTD"),
|
||||
ALL: value("ALL"),
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeStatRow(row: ModelStatMetric): StatMetricRow[] {
|
||||
const periodStart = dateTime(row.periodStart)
|
||||
const periodEnd = dateTime(row.periodEnd)
|
||||
if (!Number.isFinite(periodStart) || !Number.isFinite(periodEnd)) return []
|
||||
return [
|
||||
{
|
||||
...row,
|
||||
periodStart,
|
||||
periodEnd,
|
||||
tier: normalizeTier(row.tier),
|
||||
provider: row.provider || "unknown",
|
||||
model: row.model || "unknown",
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
function normalizeProviderRow(row: ProviderStatMetric): ProviderMetricRow[] {
|
||||
const periodStart = dateTime(row.periodStart)
|
||||
const periodEnd = dateTime(row.periodEnd)
|
||||
if (!Number.isFinite(periodStart) || !Number.isFinite(periodEnd)) return []
|
||||
return [
|
||||
{
|
||||
...row,
|
||||
periodStart,
|
||||
periodEnd,
|
||||
tier: normalizeTier(row.tier),
|
||||
provider: row.provider || "unknown",
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
function normalizeGeoRow(row: GeoStatMetric): GeoMetricRow[] {
|
||||
const periodStart = dateTime(row.periodStart)
|
||||
const periodEnd = dateTime(row.periodEnd)
|
||||
if (!Number.isFinite(periodStart) || !Number.isFinite(periodEnd)) return []
|
||||
return [
|
||||
{
|
||||
...row,
|
||||
periodStart,
|
||||
periodEnd,
|
||||
tier: normalizeTier(row.tier),
|
||||
country: row.country || "ZZ",
|
||||
continent: row.continent || "",
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
function normalizeTier(value: string) {
|
||||
const normalized = value.toLowerCase()
|
||||
if (normalized === "paid" || normalized === "zen") return "Zen"
|
||||
if (normalized === "go") return "Go"
|
||||
if (normalized === "enterprise") return "Enterprise"
|
||||
if (normalized === "all") return "all"
|
||||
return value
|
||||
}
|
||||
|
||||
function dateTime(value: Date | string) {
|
||||
return (value instanceof Date ? value : new Date(value)).getTime()
|
||||
}
|
||||
|
||||
function formatBucketLabel(value: number, range: UsageRange) {
|
||||
const date = new Date(value)
|
||||
if (range === "YTD") return months[date.getUTCMonth()]
|
||||
if (range === "ALL")
|
||||
return date.getUTCFullYear() === new Date().getUTCFullYear()
|
||||
? months[date.getUTCMonth()]
|
||||
: String(date.getUTCFullYear())
|
||||
return `${months[date.getUTCMonth()]} ${date.getUTCDate()}`
|
||||
}
|
||||
|
||||
function formatProvider(provider: string) {
|
||||
const known: Record<string, string> = {
|
||||
anthropic: "Anthropic",
|
||||
google: "Google",
|
||||
minimax: "MiniMax",
|
||||
moonshotai: "Moonshot",
|
||||
nvidia: "Nvidia",
|
||||
openai: "OpenAI",
|
||||
zhipuai: "Zhipu",
|
||||
}
|
||||
const normalized = provider.toLowerCase().replace(/[^a-z0-9]/g, "")
|
||||
return known[normalized] ?? provider.replace(/[-_]/g, " ").replace(/\b\w/g, (letter) => letter.toUpperCase())
|
||||
}
|
||||
|
||||
function modelKey(provider: string, model: string) {
|
||||
return `${provider}\u0000${model}`
|
||||
}
|
||||
|
||||
function costPerMillion(costMicrocents: number, tokens: number) {
|
||||
if (tokens <= 0 || costMicrocents <= 0) return 0
|
||||
return round((microcentsToDollars(costMicrocents) / tokens) * TOKEN_SCALE, 2)
|
||||
}
|
||||
|
||||
function microcentsToDollars(value: number) {
|
||||
return value * DOLLARS_PER_MICROCENT
|
||||
}
|
||||
|
||||
function percentChange(current: number, previous: number) {
|
||||
if (previous <= 0) return current > 0 ? 100 : 0
|
||||
return Math.round(((current - previous) / previous) * 100)
|
||||
}
|
||||
|
||||
function round(value: number, digits: number) {
|
||||
return Number(value.toFixed(digits))
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
import { Resource } from "sst/resource"
|
||||
import type { AthenaData } from "../athena"
|
||||
import type { GeoStatAggregate } from "./geo"
|
||||
import type { ModelStatAggregate } from "./model"
|
||||
import type { ProviderStatAggregate } from "./provider"
|
||||
import { normalizeCountry, normalizeTier, type StatBaseAggregate } from "./stat"
|
||||
|
||||
export type StatDimension = "model" | "provider" | "geo"
|
||||
|
||||
export function buildStatsQuery(periodStart: Date, periodEnd: Date, dimension: StatDimension) {
|
||||
const periodStartValue = sqlString(periodStart.toISOString())
|
||||
const periodEndValue = sqlString(periodEnd.toISOString())
|
||||
const sourceTable = [Resource.InferenceEvent.catalog, Resource.InferenceEvent.database, Resource.InferenceEvent.table]
|
||||
.map(sqlIdentifier)
|
||||
.join(".")
|
||||
const dimensionSql = (() => {
|
||||
if (dimension === "model")
|
||||
return {
|
||||
select: "provider, model, COALESCE(MAX(NULLIF(provider_model, '')), '') AS provider_model",
|
||||
groupBy: "provider, model",
|
||||
}
|
||||
if (dimension === "provider") return { select: "provider", groupBy: "provider" }
|
||||
return {
|
||||
select: "country, COALESCE(MAX(NULLIF(continent, '')), '') AS continent",
|
||||
groupBy: "country",
|
||||
}
|
||||
})()
|
||||
const aggregateColumns = `
|
||||
COUNT(DISTINCT session) AS sessions,
|
||||
COUNT(*) AS requests,
|
||||
COALESCE(SUM(tokens_input), 0) AS input_tokens,
|
||||
COALESCE(SUM(tokens_output), 0) AS output_tokens,
|
||||
COALESCE(SUM(tokens_reasoning), 0) AS reasoning_tokens,
|
||||
COALESCE(SUM(tokens_cache_read), 0) AS cache_read_tokens,
|
||||
COALESCE(SUM(tokens_total), 0) AS total_tokens,
|
||||
COALESCE(SUM(cost_input_microcents), 0) AS input_cost_microcents,
|
||||
COALESCE(SUM(cost_output_microcents), 0) AS output_cost_microcents,
|
||||
COALESCE(SUM(cost_total_microcents), 0) AS total_cost_microcents,
|
||||
AVG(duration_ms) AS avg_duration_ms,
|
||||
approx_percentile(CAST(duration_ms AS double), 0.5) AS p50_duration_ms,
|
||||
approx_percentile(CAST(duration_ms AS double), 0.95) AS p95_duration_ms,
|
||||
AVG(ttfb_ms) AS avg_ttfb_ms,
|
||||
approx_percentile(CAST(ttfb_ms AS double), 0.5) AS p50_ttfb_ms,
|
||||
approx_percentile(CAST(ttfb_ms AS double), 0.95) AS p95_ttfb_ms,
|
||||
AVG(output_tps) AS avg_output_tps,
|
||||
SUM(CASE WHEN status >= 200 AND status < 400 THEN 1 ELSE 0 END) AS success_count,
|
||||
SUM(CASE WHEN status >= 400 THEN 1 ELSE 0 END) AS error_count,
|
||||
COUNT(*) AS sample_count`
|
||||
|
||||
return `
|
||||
WITH filtered 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,
|
||||
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,
|
||||
CASE
|
||||
WHEN timestamp_last_byte - timestamp_first_byte < 100 THEN null
|
||||
ELSE CAST(tokens_output AS double) / (timestamp_last_byte - timestamp_first_byte) * 1000
|
||||
END AS output_tps,
|
||||
tokens_input,
|
||||
tokens_output,
|
||||
tokens_reasoning,
|
||||
tokens_cache_read,
|
||||
COALESCE(tokens_cache_read, 0) + COALESCE(tokens_cache_write_5m, 0) + COALESCE(tokens_input, 0) + COALESCE(tokens_output, 0) AS tokens_total,
|
||||
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}
|
||||
), daily AS (
|
||||
SELECT date_trunc('day', event_time) AS day, *
|
||||
FROM filtered
|
||||
)
|
||||
SELECT
|
||||
'week' AS grain,
|
||||
${periodStartValue} AS period_start,
|
||||
${periodEndValue} AS period_end,
|
||||
${sqlString(Resource.StatsSyncConfig.dataset)} AS dataset,
|
||||
tier,
|
||||
${dimensionSql.select},
|
||||
${aggregateColumns}
|
||||
FROM filtered
|
||||
GROUP BY 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,
|
||||
${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
|
||||
`
|
||||
}
|
||||
|
||||
export function toModelAggregate(data: AthenaData): ModelStatAggregate[] {
|
||||
return toStatBaseAggregate(data).flatMap((base) => [
|
||||
{
|
||||
...base,
|
||||
provider: data.provider || "unknown",
|
||||
model: data.model || "unknown",
|
||||
provider_model: data.provider_model || "",
|
||||
},
|
||||
])
|
||||
}
|
||||
|
||||
export function toProviderAggregate(data: AthenaData): ProviderStatAggregate[] {
|
||||
return toStatBaseAggregate(data).flatMap((base) => [{ ...base, provider: data.provider || "unknown" }])
|
||||
}
|
||||
|
||||
export function toGeoAggregate(data: AthenaData): GeoStatAggregate[] {
|
||||
return toStatBaseAggregate(data).flatMap((base) => [
|
||||
{
|
||||
...base,
|
||||
country: normalizeCountry(data.country),
|
||||
continent: data.continent || "",
|
||||
},
|
||||
])
|
||||
}
|
||||
|
||||
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 []
|
||||
|
||||
return [
|
||||
{
|
||||
grain,
|
||||
period_start: periodStart,
|
||||
period_end: periodEnd,
|
||||
dataset: data.dataset || Resource.StatsSyncConfig.dataset,
|
||||
tier: normalizeTier(data.tier || "unknown"),
|
||||
sessions: integer(data, "sessions"),
|
||||
requests: integer(data, "requests"),
|
||||
input_tokens: integer(data, "input_tokens"),
|
||||
output_tokens: integer(data, "output_tokens"),
|
||||
reasoning_tokens: integer(data, "reasoning_tokens"),
|
||||
cache_read_tokens: integer(data, "cache_read_tokens"),
|
||||
total_tokens: integer(data, "total_tokens"),
|
||||
input_cost_microcents: integer(data, "input_cost_microcents"),
|
||||
output_cost_microcents: integer(data, "output_cost_microcents"),
|
||||
total_cost_microcents: integer(data, "total_cost_microcents"),
|
||||
avg_duration_ms: nullableNumber(data, "avg_duration_ms"),
|
||||
p50_duration_ms: nullableInteger(data, "p50_duration_ms"),
|
||||
p95_duration_ms: nullableInteger(data, "p95_duration_ms"),
|
||||
avg_ttfb_ms: nullableNumber(data, "avg_ttfb_ms"),
|
||||
p50_ttfb_ms: nullableInteger(data, "p50_ttfb_ms"),
|
||||
p95_ttfb_ms: nullableInteger(data, "p95_ttfb_ms"),
|
||||
avg_output_tps: nullableNumber(data, "avg_output_tps"),
|
||||
success_count: integer(data, "success_count"),
|
||||
error_count: integer(data, "error_count"),
|
||||
sample_count: integer(data, "sample_count"),
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
function integer(data: AthenaData, key: string) {
|
||||
return Math.round(number(data, key))
|
||||
}
|
||||
|
||||
function nullableNumber(data: AthenaData, key: string) {
|
||||
if (data[key] === undefined || data[key] === "") return null
|
||||
return Number(number(data, key).toFixed(2))
|
||||
}
|
||||
|
||||
function nullableInteger(data: AthenaData, key: string) {
|
||||
if (data[key] === undefined || data[key] === "") return null
|
||||
return Math.round(number(data, key))
|
||||
}
|
||||
|
||||
function number(data: AthenaData, key: string) {
|
||||
const value = Number(data[key])
|
||||
return Number.isFinite(value) ? value : 0
|
||||
}
|
||||
|
||||
function sqlIdentifier(value: string) {
|
||||
return `"${value.replace(/"/g, '""')}"`
|
||||
}
|
||||
|
||||
function sqlString(value: string) {
|
||||
return `'${value.replace(/'/g, "''")}'`
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
import { and, asc, eq } from "drizzle-orm"
|
||||
import { Effect, Layer } from "effect"
|
||||
import * as Context from "effect/Context"
|
||||
import { DatabaseError, DrizzleClient } from "../database"
|
||||
import { modelStat } from "../database/schema"
|
||||
import {
|
||||
chunks,
|
||||
collapseRows,
|
||||
inserted,
|
||||
rankBy,
|
||||
statPeriodKey,
|
||||
synthesizeAllTierRows,
|
||||
toStatBaseRow,
|
||||
UPSERT_CHUNK_SIZE,
|
||||
type StatBaseAggregate,
|
||||
} from "./stat"
|
||||
|
||||
export type ModelStatRow = typeof modelStat.$inferInsert
|
||||
export type ModelStatAggregate = StatBaseAggregate & { provider: string; model: string; provider_model: string }
|
||||
|
||||
export type ModelStatMetric = {
|
||||
periodStart: Date
|
||||
periodEnd: Date
|
||||
tier: string
|
||||
provider: string
|
||||
model: string
|
||||
sessions: number
|
||||
inputTokens: number
|
||||
outputTokens: number
|
||||
reasoningTokens: number
|
||||
cacheReadTokens: number
|
||||
totalTokens: number
|
||||
inputCostMicrocents: number
|
||||
outputCostMicrocents: number
|
||||
totalCostMicrocents: number
|
||||
}
|
||||
|
||||
export declare namespace ModelStatRepo {
|
||||
export interface Service {
|
||||
readonly listDaily: () => Effect.Effect<ModelStatMetric[], DatabaseError>
|
||||
readonly upsert: (rows: ModelStatRow[]) => Effect.Effect<void, DatabaseError>
|
||||
}
|
||||
}
|
||||
|
||||
export class ModelStatRepo extends Context.Service<ModelStatRepo, ModelStatRepo.Service>()(
|
||||
"@opencode/stats/ModelStatRepo",
|
||||
) {
|
||||
static readonly layer: Layer.Layer<ModelStatRepo, never, DrizzleClient> = Layer.effect(
|
||||
ModelStatRepo,
|
||||
Effect.gen(function* () {
|
||||
const db = yield* DrizzleClient
|
||||
|
||||
const listDaily = Effect.fn("ModelStatRepo.listDaily")(function* () {
|
||||
return yield* Effect.tryPromise({
|
||||
try: () =>
|
||||
db
|
||||
.select({
|
||||
periodStart: modelStat.period_start,
|
||||
periodEnd: modelStat.period_end,
|
||||
tier: modelStat.tier,
|
||||
provider: modelStat.provider,
|
||||
model: modelStat.model,
|
||||
sessions: modelStat.sessions,
|
||||
inputTokens: modelStat.input_tokens,
|
||||
outputTokens: modelStat.output_tokens,
|
||||
reasoningTokens: modelStat.reasoning_tokens,
|
||||
cacheReadTokens: modelStat.cache_read_tokens,
|
||||
totalTokens: modelStat.total_tokens,
|
||||
inputCostMicrocents: modelStat.input_cost_microcents,
|
||||
outputCostMicrocents: modelStat.output_cost_microcents,
|
||||
totalCostMicrocents: modelStat.total_cost_microcents,
|
||||
})
|
||||
.from(modelStat)
|
||||
.where(and(eq(modelStat.grain, "day"), eq(modelStat.client, "all"), eq(modelStat.source, "all")))
|
||||
.orderBy(asc(modelStat.period_start)),
|
||||
catch: (cause) => DatabaseError.make({ cause }),
|
||||
})
|
||||
})
|
||||
|
||||
const upsert = Effect.fn("ModelStatRepo.upsert")(function* (rows: ModelStatRow[]) {
|
||||
yield* Effect.forEach(
|
||||
chunks(rows, UPSERT_CHUNK_SIZE),
|
||||
(chunk) =>
|
||||
Effect.tryPromise({
|
||||
try: () =>
|
||||
db
|
||||
.insert(modelStat)
|
||||
.values(chunk)
|
||||
.onDuplicateKeyUpdate({
|
||||
set: {
|
||||
period_end: inserted("period_end"),
|
||||
provider_model: inserted("provider_model"),
|
||||
sessions: inserted("sessions"),
|
||||
requests: inserted("requests"),
|
||||
input_tokens: inserted("input_tokens"),
|
||||
output_tokens: inserted("output_tokens"),
|
||||
reasoning_tokens: inserted("reasoning_tokens"),
|
||||
cache_read_tokens: inserted("cache_read_tokens"),
|
||||
total_tokens: inserted("total_tokens"),
|
||||
input_cost_microcents: inserted("input_cost_microcents"),
|
||||
output_cost_microcents: inserted("output_cost_microcents"),
|
||||
total_cost_microcents: inserted("total_cost_microcents"),
|
||||
avg_duration_ms: inserted("avg_duration_ms"),
|
||||
p50_duration_ms: inserted("p50_duration_ms"),
|
||||
p95_duration_ms: inserted("p95_duration_ms"),
|
||||
avg_ttfb_ms: inserted("avg_ttfb_ms"),
|
||||
p50_ttfb_ms: inserted("p50_ttfb_ms"),
|
||||
p95_ttfb_ms: inserted("p95_ttfb_ms"),
|
||||
avg_output_tps: inserted("avg_output_tps"),
|
||||
success_count: inserted("success_count"),
|
||||
error_count: inserted("error_count"),
|
||||
sample_count: inserted("sample_count"),
|
||||
rank_by_tokens: inserted("rank_by_tokens"),
|
||||
rank_by_requests: inserted("rank_by_requests"),
|
||||
rank_by_cost: inserted("rank_by_cost"),
|
||||
},
|
||||
}),
|
||||
catch: (cause) => DatabaseError.make({ cause }),
|
||||
}),
|
||||
{ discard: true },
|
||||
)
|
||||
})
|
||||
|
||||
return ModelStatRepo.of({ listDaily, upsert })
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
export function rowsFromAggregates(aggregates: ModelStatAggregate[]) {
|
||||
return rankRows([
|
||||
...synthesizeAllTierRows(
|
||||
collapseRows(aggregates.filter((item) => item.grain === "week").map(toRow), dimensionKey),
|
||||
dimensionKey,
|
||||
),
|
||||
...synthesizeAllTierRows(
|
||||
collapseRows(aggregates.filter((item) => item.grain === "day").map(toRow), dimensionKey),
|
||||
dimensionKey,
|
||||
),
|
||||
])
|
||||
}
|
||||
|
||||
function toRow(data: ModelStatAggregate): ModelStatRow {
|
||||
return {
|
||||
...toStatBaseRow(data),
|
||||
provider: data.provider,
|
||||
model: data.model,
|
||||
provider_model: data.provider_model,
|
||||
}
|
||||
}
|
||||
|
||||
function rankRows(rows: ModelStatRow[]) {
|
||||
return Object.values(
|
||||
rows.reduce<Record<string, ModelStatRow[]>>((result, row) => {
|
||||
const key = statPeriodKey(row)
|
||||
result[key] = [...(result[key] ?? []), row]
|
||||
return result
|
||||
}, {}),
|
||||
).flatMap((group) => {
|
||||
const tokenRanks = rankBy(group, (row) => row.total_tokens ?? 0)
|
||||
const requestRanks = rankBy(group, (row) => row.requests ?? 0)
|
||||
const costRanks = rankBy(group, (row) => row.total_cost_microcents ?? 0)
|
||||
return group.map((row) => ({
|
||||
...row,
|
||||
rank_by_tokens: tokenRanks.get(row) ?? null,
|
||||
rank_by_requests: requestRanks.get(row) ?? null,
|
||||
rank_by_cost: costRanks.get(row) ?? null,
|
||||
}))
|
||||
})
|
||||
}
|
||||
|
||||
function dimensionKey(row: ModelStatRow) {
|
||||
return [row.provider, row.model].join("\u0000")
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
import { and, asc, eq } from "drizzle-orm"
|
||||
import { Effect, Layer } from "effect"
|
||||
import * as Context from "effect/Context"
|
||||
import { DatabaseError, DrizzleClient } from "../database"
|
||||
import { providerStat } from "../database/schema"
|
||||
import {
|
||||
chunks,
|
||||
collapseRows,
|
||||
inserted,
|
||||
rankRowsWithMarketShare,
|
||||
synthesizeAllTierRows,
|
||||
toStatBaseRow,
|
||||
UPSERT_CHUNK_SIZE,
|
||||
type StatBaseAggregate,
|
||||
} from "./stat"
|
||||
|
||||
export type ProviderStatRow = typeof providerStat.$inferInsert
|
||||
export type ProviderStatAggregate = StatBaseAggregate & { provider: string }
|
||||
export type ProviderStatMetric = {
|
||||
periodStart: Date
|
||||
periodEnd: Date
|
||||
tier: string
|
||||
provider: string
|
||||
totalTokens: number
|
||||
}
|
||||
|
||||
export declare namespace ProviderStatRepo {
|
||||
export interface Service {
|
||||
readonly listDaily: () => Effect.Effect<ProviderStatMetric[], DatabaseError>
|
||||
readonly listByPeriod: (opts: {
|
||||
readonly grain: string
|
||||
readonly periodStart: Date
|
||||
readonly dataset?: string
|
||||
readonly tier?: string
|
||||
readonly client?: string
|
||||
readonly source?: string
|
||||
}) => Effect.Effect<ProviderStatRow[], DatabaseError>
|
||||
readonly upsert: (rows: ProviderStatRow[]) => Effect.Effect<void, DatabaseError>
|
||||
}
|
||||
}
|
||||
|
||||
export class ProviderStatRepo extends Context.Service<ProviderStatRepo, ProviderStatRepo.Service>()(
|
||||
"@opencode/stats/ProviderStatRepo",
|
||||
) {
|
||||
static readonly layer: Layer.Layer<ProviderStatRepo, never, DrizzleClient> = Layer.effect(
|
||||
ProviderStatRepo,
|
||||
Effect.gen(function* () {
|
||||
const db = yield* DrizzleClient
|
||||
|
||||
const listDaily = Effect.fn("ProviderStatRepo.listDaily")(function* () {
|
||||
return yield* Effect.tryPromise({
|
||||
try: () =>
|
||||
db
|
||||
.select({
|
||||
periodStart: providerStat.period_start,
|
||||
periodEnd: providerStat.period_end,
|
||||
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)),
|
||||
catch: (cause) => DatabaseError.make({ cause }),
|
||||
})
|
||||
})
|
||||
|
||||
const listByPeriod = Effect.fn("ProviderStatRepo.listByPeriod")(function* (opts: {
|
||||
readonly grain: string
|
||||
readonly periodStart: Date
|
||||
readonly dataset?: string
|
||||
readonly tier?: string
|
||||
readonly client?: string
|
||||
readonly source?: string
|
||||
}) {
|
||||
return yield* Effect.tryPromise({
|
||||
try: () =>
|
||||
db
|
||||
.select()
|
||||
.from(providerStat)
|
||||
.where(
|
||||
and(
|
||||
eq(providerStat.grain, opts.grain),
|
||||
eq(providerStat.period_start, opts.periodStart),
|
||||
eq(providerStat.dataset, opts.dataset ?? "zen"),
|
||||
eq(providerStat.tier, opts.tier ?? "all"),
|
||||
eq(providerStat.client, opts.client ?? "all"),
|
||||
eq(providerStat.source, opts.source ?? "all"),
|
||||
),
|
||||
),
|
||||
catch: (cause) => DatabaseError.make({ cause }),
|
||||
})
|
||||
})
|
||||
|
||||
const upsert = Effect.fn("ProviderStatRepo.upsert")(function* (rows: ProviderStatRow[]) {
|
||||
yield* Effect.forEach(
|
||||
chunks(rows, UPSERT_CHUNK_SIZE),
|
||||
(chunk) =>
|
||||
Effect.tryPromise({
|
||||
try: () =>
|
||||
db
|
||||
.insert(providerStat)
|
||||
.values(chunk)
|
||||
.onDuplicateKeyUpdate({
|
||||
set: {
|
||||
period_end: inserted("period_end"),
|
||||
sessions: inserted("sessions"),
|
||||
requests: inserted("requests"),
|
||||
input_tokens: inserted("input_tokens"),
|
||||
output_tokens: inserted("output_tokens"),
|
||||
reasoning_tokens: inserted("reasoning_tokens"),
|
||||
cache_read_tokens: inserted("cache_read_tokens"),
|
||||
total_tokens: inserted("total_tokens"),
|
||||
input_cost_microcents: inserted("input_cost_microcents"),
|
||||
output_cost_microcents: inserted("output_cost_microcents"),
|
||||
total_cost_microcents: inserted("total_cost_microcents"),
|
||||
avg_duration_ms: inserted("avg_duration_ms"),
|
||||
p50_duration_ms: inserted("p50_duration_ms"),
|
||||
p95_duration_ms: inserted("p95_duration_ms"),
|
||||
avg_ttfb_ms: inserted("avg_ttfb_ms"),
|
||||
p50_ttfb_ms: inserted("p50_ttfb_ms"),
|
||||
p95_ttfb_ms: inserted("p95_ttfb_ms"),
|
||||
avg_output_tps: inserted("avg_output_tps"),
|
||||
success_count: inserted("success_count"),
|
||||
error_count: inserted("error_count"),
|
||||
sample_count: inserted("sample_count"),
|
||||
market_share_tokens: inserted("market_share_tokens"),
|
||||
market_share_requests: inserted("market_share_requests"),
|
||||
market_share_sessions: inserted("market_share_sessions"),
|
||||
rank_by_tokens: inserted("rank_by_tokens"),
|
||||
rank_by_requests: inserted("rank_by_requests"),
|
||||
rank_by_sessions: inserted("rank_by_sessions"),
|
||||
rank_by_cost: inserted("rank_by_cost"),
|
||||
},
|
||||
}),
|
||||
catch: (cause) => DatabaseError.make({ cause }),
|
||||
}),
|
||||
{ discard: true },
|
||||
)
|
||||
})
|
||||
|
||||
return ProviderStatRepo.of({ listDaily, listByPeriod, upsert })
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
export function rowsFromAggregates(aggregates: ProviderStatAggregate[]) {
|
||||
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,
|
||||
),
|
||||
])
|
||||
}
|
||||
|
||||
function toRow(data: ProviderStatAggregate): ProviderStatRow {
|
||||
return {
|
||||
...toStatBaseRow(data),
|
||||
provider: data.provider,
|
||||
}
|
||||
}
|
||||
|
||||
function dimensionKey(row: ProviderStatRow) {
|
||||
return row.provider
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
import { sql } from "drizzle-orm"
|
||||
|
||||
export const UPSERT_CHUNK_SIZE = 500
|
||||
|
||||
export type StatGrain = "day" | "week"
|
||||
|
||||
export type StatBaseAggregate = {
|
||||
grain: StatGrain
|
||||
period_start: Date
|
||||
period_end: Date
|
||||
dataset: string
|
||||
tier: string
|
||||
sessions: number
|
||||
requests: number
|
||||
input_tokens: number
|
||||
output_tokens: number
|
||||
reasoning_tokens: number
|
||||
cache_read_tokens: number
|
||||
total_tokens: number
|
||||
input_cost_microcents: number
|
||||
output_cost_microcents: number
|
||||
total_cost_microcents: number
|
||||
avg_duration_ms: number | null
|
||||
p50_duration_ms: number | null
|
||||
p95_duration_ms: number | null
|
||||
avg_ttfb_ms: number | null
|
||||
p50_ttfb_ms: number | null
|
||||
p95_ttfb_ms: number | null
|
||||
avg_output_tps: number | null
|
||||
success_count: number
|
||||
error_count: number
|
||||
sample_count: number
|
||||
}
|
||||
|
||||
export type StatBaseRow = {
|
||||
grain: string
|
||||
period_start: Date
|
||||
period_end: Date
|
||||
dataset?: string
|
||||
tier?: string
|
||||
client?: string
|
||||
source?: string
|
||||
sessions?: number
|
||||
requests?: number
|
||||
input_tokens?: number
|
||||
output_tokens?: number
|
||||
reasoning_tokens?: number
|
||||
cache_read_tokens?: number
|
||||
total_tokens?: number
|
||||
input_cost_microcents?: number
|
||||
output_cost_microcents?: number
|
||||
total_cost_microcents?: number
|
||||
avg_duration_ms?: number | null
|
||||
p50_duration_ms?: number | null
|
||||
p95_duration_ms?: number | null
|
||||
avg_ttfb_ms?: number | null
|
||||
p50_ttfb_ms?: number | null
|
||||
p95_ttfb_ms?: number | null
|
||||
avg_output_tps?: number | null
|
||||
success_count?: number
|
||||
error_count?: number
|
||||
sample_count?: number
|
||||
}
|
||||
|
||||
export function toStatBaseRow(data: StatBaseAggregate) {
|
||||
return {
|
||||
grain: data.grain,
|
||||
period_start: data.period_start,
|
||||
period_end: data.period_end,
|
||||
dataset: data.dataset,
|
||||
tier: data.tier,
|
||||
client: "all",
|
||||
source: "all",
|
||||
sessions: data.sessions,
|
||||
requests: data.requests,
|
||||
input_tokens: data.input_tokens,
|
||||
output_tokens: data.output_tokens,
|
||||
reasoning_tokens: data.reasoning_tokens,
|
||||
cache_read_tokens: data.cache_read_tokens,
|
||||
total_tokens: data.total_tokens,
|
||||
input_cost_microcents: data.input_cost_microcents,
|
||||
output_cost_microcents: data.output_cost_microcents,
|
||||
total_cost_microcents: data.total_cost_microcents,
|
||||
avg_duration_ms: data.avg_duration_ms,
|
||||
p50_duration_ms: data.p50_duration_ms,
|
||||
p95_duration_ms: data.p95_duration_ms,
|
||||
avg_ttfb_ms: data.avg_ttfb_ms,
|
||||
p50_ttfb_ms: data.p50_ttfb_ms,
|
||||
p95_ttfb_ms: data.p95_ttfb_ms,
|
||||
avg_output_tps: data.avg_output_tps,
|
||||
success_count: data.success_count,
|
||||
error_count: data.error_count,
|
||||
sample_count: data.sample_count,
|
||||
}
|
||||
}
|
||||
|
||||
export function synthesizeAllTierRows<T extends StatBaseRow>(rows: T[], dimensionKey: (row: T) => string) {
|
||||
return [
|
||||
...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")
|
||||
result[key] = result[key] ? combineRows(result[key], row) : { ...row, tier: "all" }
|
||||
return result
|
||||
}, {}),
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
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")
|
||||
result[key] = result[key] ? combineRows(result[key], row) : row
|
||||
return result
|
||||
}, {}),
|
||||
)
|
||||
}
|
||||
|
||||
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),
|
||||
output_tokens: (left.output_tokens ?? 0) + (right.output_tokens ?? 0),
|
||||
reasoning_tokens: (left.reasoning_tokens ?? 0) + (right.reasoning_tokens ?? 0),
|
||||
cache_read_tokens: (left.cache_read_tokens ?? 0) + (right.cache_read_tokens ?? 0),
|
||||
total_tokens: (left.total_tokens ?? 0) + (right.total_tokens ?? 0),
|
||||
input_cost_microcents: (left.input_cost_microcents ?? 0) + (right.input_cost_microcents ?? 0),
|
||||
output_cost_microcents: (left.output_cost_microcents ?? 0) + (right.output_cost_microcents ?? 0),
|
||||
total_cost_microcents: (left.total_cost_microcents ?? 0) + (right.total_cost_microcents ?? 0),
|
||||
avg_duration_ms: weightedAverage(left.avg_duration_ms, left.requests, right.avg_duration_ms, right.requests),
|
||||
p50_duration_ms: null,
|
||||
p95_duration_ms: null,
|
||||
avg_ttfb_ms: weightedAverage(left.avg_ttfb_ms, left.requests, right.avg_ttfb_ms, right.requests),
|
||||
p50_ttfb_ms: null,
|
||||
p95_ttfb_ms: null,
|
||||
avg_output_tps: weightedAverage(left.avg_output_tps, left.requests, right.avg_output_tps, right.requests),
|
||||
success_count: (left.success_count ?? 0) + (right.success_count ?? 0),
|
||||
error_count: (left.error_count ?? 0) + (right.error_count ?? 0),
|
||||
sample_count: (left.sample_count ?? 0) + (right.sample_count ?? 0),
|
||||
}
|
||||
}
|
||||
|
||||
export function statPeriodKey(row: StatBaseRow) {
|
||||
return [row.grain, row.period_start.toISOString(), row.dataset, row.tier, row.client, row.source].join("\u0000")
|
||||
}
|
||||
|
||||
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[]) {
|
||||
return Object.values(
|
||||
rows.reduce<Record<string, T[]>>((result, row) => {
|
||||
const key = statPeriodKey(row)
|
||||
result[key] = [...(result[key] ?? []), row]
|
||||
return result
|
||||
}, {}),
|
||||
).flatMap((group) => {
|
||||
const tokens = group.reduce((sum, row) => sum + (row.total_tokens ?? 0), 0)
|
||||
const requests = group.reduce((sum, row) => sum + (row.requests ?? 0), 0)
|
||||
const sessions = group.reduce((sum, row) => sum + (row.sessions ?? 0), 0)
|
||||
const tokenRanks = rankBy(group, (row) => row.total_tokens ?? 0)
|
||||
const requestRanks = rankBy(group, (row) => row.requests ?? 0)
|
||||
const sessionRanks = rankBy(group, (row) => row.sessions ?? 0)
|
||||
const costRanks = rankBy(group, (row) => row.total_cost_microcents ?? 0)
|
||||
return group.map((row) => ({
|
||||
...row,
|
||||
market_share_tokens: share(row.total_tokens, tokens),
|
||||
market_share_requests: share(row.requests, requests),
|
||||
market_share_sessions: share(row.sessions, sessions),
|
||||
rank_by_tokens: tokenRanks.get(row) ?? null,
|
||||
rank_by_requests: requestRanks.get(row) ?? null,
|
||||
rank_by_sessions: sessionRanks.get(row) ?? null,
|
||||
rank_by_cost: costRanks.get(row) ?? null,
|
||||
}))
|
||||
})
|
||||
}
|
||||
|
||||
export function share(value: number | null | undefined, total: number) {
|
||||
if (total <= 0) return null
|
||||
return Number(((value ?? 0) / total).toFixed(6))
|
||||
}
|
||||
|
||||
export function chunks<T>(items: T[], size: number) {
|
||||
return Array.from({ length: Math.ceil(items.length / size) }, (_, index) =>
|
||||
items.slice(index * size, (index + 1) * size),
|
||||
)
|
||||
}
|
||||
|
||||
export function inserted(column: string) {
|
||||
return sql.raw(`values(\`${column}\`)`)
|
||||
}
|
||||
|
||||
export function weightedAverage(
|
||||
left: number | null | undefined,
|
||||
leftWeight = 0,
|
||||
right: number | null | undefined,
|
||||
rightWeight = 0,
|
||||
) {
|
||||
const totalWeight =
|
||||
(left === null || left === undefined ? 0 : leftWeight) + (right === null || right === undefined ? 0 : rightWeight)
|
||||
if (totalWeight === 0) return null
|
||||
return Number((((left ?? 0) * leftWeight + (right ?? 0) * rightWeight) / totalWeight).toFixed(2))
|
||||
}
|
||||
|
||||
export function normalizeTier(value: string) {
|
||||
if (value === "Paid") return "Zen"
|
||||
return value
|
||||
}
|
||||
|
||||
export function normalizeCountry(value: string | undefined) {
|
||||
if (!value || value.length !== 2) return "ZZ"
|
||||
return value.toUpperCase()
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
export * as Athena from "./athena"
|
||||
export * as AppConfig from "./config"
|
||||
export * as Database from "./database"
|
||||
export * as GeoStat from "./domain/geo"
|
||||
export * as StatsHome from "./domain/home"
|
||||
export * as Inference from "./domain/inference"
|
||||
export * as ModelStat from "./domain/model"
|
||||
export * as ProviderStat from "./domain/provider"
|
||||
export * as Stat from "./domain/stat"
|
||||
export * as Runtime from "./runtime"
|
||||
export * as StatSync from "./stat-sync"
|
||||
@@ -0,0 +1,4 @@
|
||||
import { Effect } from "effect"
|
||||
import { layer, migrate } from "./database"
|
||||
|
||||
await Effect.runPromise(migrate().pipe(Effect.provide(layer)))
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
import "sst/resource"
|
||||
|
||||
declare module "sst/resource" {
|
||||
export interface Resource {
|
||||
InferenceEvent: {
|
||||
catalog: string
|
||||
database: string
|
||||
region: string
|
||||
table: string
|
||||
tableBucket: string
|
||||
type: "sst.sst.Linkable"
|
||||
workgroup: string
|
||||
}
|
||||
StatsSyncConfig: {
|
||||
dataset: string
|
||||
type: "sst.sst.Linkable"
|
||||
}
|
||||
StatsDatabase: {
|
||||
database: string
|
||||
host: string
|
||||
password: string
|
||||
port: number
|
||||
type: "sst.sst.Linkable"
|
||||
url: string
|
||||
username: string
|
||||
}
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user