mirror of
https://github.com/anomalyco/opencode.git
synced 2026-05-13 15:44:56 +00:00
Track session usage totals (#26644)
This commit is contained in:
@@ -1,11 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://opencode.ai/config.json",
|
"$schema": "https://opencode.ai/config.json",
|
||||||
"provider": {},
|
"provider": {},
|
||||||
"permission": {
|
"permission": {},
|
||||||
"edit": {
|
|
||||||
"packages/opencode/migration/*": "ask",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"mcp": {},
|
"mcp": {},
|
||||||
"tools": {
|
"tools": {
|
||||||
"github-triage": false,
|
"github-triage": false,
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
ALTER TABLE `session` ADD `cost` real DEFAULT 0 NOT NULL;--> statement-breakpoint
|
||||||
|
ALTER TABLE `session` ADD `tokens_input` integer DEFAULT 0 NOT NULL;--> statement-breakpoint
|
||||||
|
ALTER TABLE `session` ADD `tokens_output` integer DEFAULT 0 NOT NULL;--> statement-breakpoint
|
||||||
|
ALTER TABLE `session` ADD `tokens_reasoning` integer DEFAULT 0 NOT NULL;--> statement-breakpoint
|
||||||
|
ALTER TABLE `session` ADD `tokens_cache_read` integer DEFAULT 0 NOT NULL;--> statement-breakpoint
|
||||||
|
ALTER TABLE `session` ADD `tokens_cache_write` integer DEFAULT 0 NOT NULL;
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -164,8 +164,8 @@ const aggregateSessionStats = Effect.fn("Cli.stats.aggregate")(function* (
|
|||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const messages = yield* svc.messages({ sessionID: session.id })
|
const messages = yield* svc.messages({ sessionID: session.id })
|
||||||
|
|
||||||
let sessionCost = 0
|
const sessionCost = session.cost ?? 0
|
||||||
let sessionTokens = { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }
|
const sessionTokens = session.tokens ?? { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }
|
||||||
let sessionToolUsage: Record<string, number> = {}
|
let sessionToolUsage: Record<string, number> = {}
|
||||||
let sessionModelUsage: Record<
|
let sessionModelUsage: Record<
|
||||||
string,
|
string,
|
||||||
@@ -178,8 +178,6 @@ const aggregateSessionStats = Effect.fn("Cli.stats.aggregate")(function* (
|
|||||||
|
|
||||||
for (const message of messages) {
|
for (const message of messages) {
|
||||||
if (message.info.role === "assistant") {
|
if (message.info.role === "assistant") {
|
||||||
sessionCost += message.info.cost || 0
|
|
||||||
|
|
||||||
const modelKey = `${message.info.providerID}/${message.info.modelID}`
|
const modelKey = `${message.info.providerID}/${message.info.modelID}`
|
||||||
if (!sessionModelUsage[modelKey]) {
|
if (!sessionModelUsage[modelKey]) {
|
||||||
sessionModelUsage[modelKey] = {
|
sessionModelUsage[modelKey] = {
|
||||||
@@ -192,12 +190,6 @@ const aggregateSessionStats = Effect.fn("Cli.stats.aggregate")(function* (
|
|||||||
sessionModelUsage[modelKey].cost += message.info.cost || 0
|
sessionModelUsage[modelKey].cost += message.info.cost || 0
|
||||||
|
|
||||||
if (message.info.tokens) {
|
if (message.info.tokens) {
|
||||||
sessionTokens.input += message.info.tokens.input || 0
|
|
||||||
sessionTokens.output += message.info.tokens.output || 0
|
|
||||||
sessionTokens.reasoning += message.info.tokens.reasoning || 0
|
|
||||||
sessionTokens.cache.read += message.info.tokens.cache?.read || 0
|
|
||||||
sessionTokens.cache.write += message.info.tokens.cache?.write || 0
|
|
||||||
|
|
||||||
sessionModelUsage[modelKey].tokens.input += message.info.tokens.input || 0
|
sessionModelUsage[modelKey].tokens.input += message.info.tokens.input || 0
|
||||||
sessionModelUsage[modelKey].tokens.output +=
|
sessionModelUsage[modelKey].tokens.output +=
|
||||||
(message.info.tokens.output || 0) + (message.info.tokens.reasoning || 0)
|
(message.info.tokens.output || 0) + (message.info.tokens.reasoning || 0)
|
||||||
|
|||||||
@@ -337,6 +337,7 @@ export function Prompt(props: PromptProps) {
|
|||||||
|
|
||||||
const usage = createMemo(() => {
|
const usage = createMemo(() => {
|
||||||
if (!props.sessionID) return
|
if (!props.sessionID) return
|
||||||
|
const session = sync.session.get(props.sessionID)
|
||||||
const msg = sync.data.message[props.sessionID] ?? []
|
const msg = sync.data.message[props.sessionID] ?? []
|
||||||
const last = msg.findLast((item): item is AssistantMessage => item.role === "assistant" && item.tokens.output > 0)
|
const last = msg.findLast((item): item is AssistantMessage => item.role === "assistant" && item.tokens.output > 0)
|
||||||
if (!last) return
|
if (!last) return
|
||||||
@@ -347,7 +348,7 @@ export function Prompt(props: PromptProps) {
|
|||||||
|
|
||||||
const model = sync.data.provider.find((item) => item.id === last.providerID)?.models[last.modelID]
|
const model = sync.data.provider.find((item) => item.id === last.providerID)?.models[last.modelID]
|
||||||
const pct = model?.limit.context ? `${Math.round((tokens / model.limit.context) * 100)}%` : undefined
|
const pct = model?.limit.context ? `${Math.round((tokens / model.limit.context) * 100)}%` : undefined
|
||||||
const cost = msg.reduce((sum, item) => sum + (item.role === "assistant" ? item.cost : 0), 0)
|
const cost = session?.cost ?? 0
|
||||||
return {
|
return {
|
||||||
context: pct ? `${Locale.number(tokens)} (${pct})` : Locale.number(tokens),
|
context: pct ? `${Locale.number(tokens)} (${pct})` : Locale.number(tokens),
|
||||||
cost: cost > 0 ? money.format(cost) : undefined,
|
cost: cost > 0 ? money.format(cost) : undefined,
|
||||||
|
|||||||
@@ -345,7 +345,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
|||||||
case "message.part.removed": {
|
case "message.part.removed": {
|
||||||
const parts = store.part[event.properties.messageID]
|
const parts = store.part[event.properties.messageID]
|
||||||
const result = Binary.search(parts, event.properties.partID, (p) => p.id)
|
const result = Binary.search(parts, event.properties.partID, (p) => p.id)
|
||||||
if (result.found)
|
if (result.found) {
|
||||||
setStore(
|
setStore(
|
||||||
"part",
|
"part",
|
||||||
event.properties.messageID,
|
event.properties.messageID,
|
||||||
@@ -353,6 +353,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
|||||||
draft.splice(result.index, 1)
|
draft.splice(result.index, 1)
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
}
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,8 @@ const money = new Intl.NumberFormat("en-US", {
|
|||||||
function View(props: { api: TuiPluginApi; session_id: string }) {
|
function View(props: { api: TuiPluginApi; session_id: string }) {
|
||||||
const theme = () => props.api.theme.current
|
const theme = () => props.api.theme.current
|
||||||
const msg = createMemo(() => props.api.state.session.messages(props.session_id))
|
const msg = createMemo(() => props.api.state.session.messages(props.session_id))
|
||||||
const cost = createMemo(() => msg().reduce((sum, item) => sum + (item.role === "assistant" ? item.cost : 0), 0))
|
const session = createMemo(() => props.api.state.session.get(props.session_id))
|
||||||
|
const cost = createMemo(() => session()?.cost ?? 0)
|
||||||
|
|
||||||
const state = createMemo(() => {
|
const state = createMemo(() => {
|
||||||
const last = msg().findLast((item): item is AssistantMessage => item.role === "assistant" && item.tokens.output > 0)
|
const last = msg().findLast((item): item is AssistantMessage => item.role === "assistant" && item.tokens.output > 0)
|
||||||
|
|||||||
@@ -147,6 +147,9 @@ function stateApi(sync: ReturnType<typeof useSync>): TuiPluginApi["state"] {
|
|||||||
count() {
|
count() {
|
||||||
return sync.data.session.length
|
return sync.data.session.length
|
||||||
},
|
},
|
||||||
|
get(sessionID) {
|
||||||
|
return sync.session.get(sessionID)
|
||||||
|
},
|
||||||
diff(sessionID) {
|
diff(sessionID) {
|
||||||
return (sync.data.session_diff[sessionID] ?? []).flatMap((item) =>
|
return (sync.data.session_diff[sessionID] ?? []).flatMap((item) =>
|
||||||
item.file === undefined ? [] : [{ ...item, file: item.file }],
|
item.file === undefined ? [] : [{ ...item, file: item.file }],
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ export function SubagentFooter() {
|
|||||||
|
|
||||||
const model = sync.data.provider.find((item) => item.id === last.providerID)?.models[last.modelID]
|
const model = sync.data.provider.find((item) => item.id === last.providerID)?.models[last.modelID]
|
||||||
const pct = model?.limit.context ? `${Math.round((tokens / model.limit.context) * 100)}%` : undefined
|
const pct = model?.limit.context ? `${Math.round((tokens / model.limit.context) * 100)}%` : undefined
|
||||||
const cost = msg.reduce((sum, item) => sum + (item.role === "assistant" ? item.cost : 0), 0)
|
const cost = session()?.cost ?? 0
|
||||||
|
|
||||||
const money = new Intl.NumberFormat("en-US", {
|
const money = new Intl.NumberFormat("en-US", {
|
||||||
style: "currency",
|
style: "currency",
|
||||||
|
|||||||
@@ -2,7 +2,9 @@ import { Context, Effect, Layer } from "effect"
|
|||||||
import { Database } from "./storage/db"
|
import { Database } from "./storage/db"
|
||||||
import { DataMigrationTable } from "./data-migration.sql"
|
import { DataMigrationTable } from "./data-migration.sql"
|
||||||
import * as Log from "@opencode-ai/core/util/log"
|
import * as Log from "@opencode-ai/core/util/log"
|
||||||
import { eq } from "drizzle-orm"
|
import { and, asc, eq, gt, inArray, sql } from "drizzle-orm"
|
||||||
|
import { MessageTable, SessionTable } from "./session/session.sql"
|
||||||
|
import type { SessionID } from "./session/schema"
|
||||||
|
|
||||||
export type Migration<R = never> = {
|
export type Migration<R = never> = {
|
||||||
name: string
|
name: string
|
||||||
@@ -18,7 +20,101 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/Da
|
|||||||
export const layer = Layer.effect(
|
export const layer = Layer.effect(
|
||||||
Service,
|
Service,
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const migrations: Migration[] = []
|
const migrations: Migration[] = [
|
||||||
|
{
|
||||||
|
name: "session_usage_from_messages",
|
||||||
|
run: Effect.gen(function* () {
|
||||||
|
type Usage = {
|
||||||
|
cost: number
|
||||||
|
tokens: { input: number; output: number; reasoning: number; cache: { read: number; write: number } }
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let cursor: SessionID | undefined, page = 1; ; page++) {
|
||||||
|
const next = yield* Effect.gen(function* () {
|
||||||
|
const sessions = yield* Effect.sync(() =>
|
||||||
|
Database.use((db) =>
|
||||||
|
db
|
||||||
|
.select({ id: SessionTable.id })
|
||||||
|
.from(SessionTable)
|
||||||
|
.where(cursor ? gt(SessionTable.id, cursor) : undefined)
|
||||||
|
.orderBy(asc(SessionTable.id))
|
||||||
|
.limit(100)
|
||||||
|
.all(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if (sessions.length === 0) return
|
||||||
|
|
||||||
|
yield* Effect.sync(() =>
|
||||||
|
Database.transaction((db) => {
|
||||||
|
const usageBySession = new Map<SessionID, Usage>(
|
||||||
|
sessions.map((session) => [
|
||||||
|
session.id,
|
||||||
|
{ cost: 0, tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } } },
|
||||||
|
]),
|
||||||
|
)
|
||||||
|
|
||||||
|
for (const row of db
|
||||||
|
.select({
|
||||||
|
session_id: MessageTable.session_id,
|
||||||
|
cost: sql<number>`coalesce(sum(coalesce(json_extract(${MessageTable.data}, '$.cost'), 0)), 0)`,
|
||||||
|
tokens_input: sql<number>`coalesce(sum(coalesce(json_extract(${MessageTable.data}, '$.tokens.input'), 0)), 0)`,
|
||||||
|
tokens_output: sql<number>`coalesce(sum(coalesce(json_extract(${MessageTable.data}, '$.tokens.output'), 0)), 0)`,
|
||||||
|
tokens_reasoning: sql<number>`coalesce(sum(coalesce(json_extract(${MessageTable.data}, '$.tokens.reasoning'), 0)), 0)`,
|
||||||
|
tokens_cache_read: sql<number>`coalesce(sum(coalesce(json_extract(${MessageTable.data}, '$.tokens.cache.read'), 0)), 0)`,
|
||||||
|
tokens_cache_write: sql<number>`coalesce(sum(coalesce(json_extract(${MessageTable.data}, '$.tokens.cache.write'), 0)), 0)`,
|
||||||
|
})
|
||||||
|
.from(MessageTable)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
inArray(MessageTable.session_id, sessions.map((session) => session.id)),
|
||||||
|
sql`json_extract(${MessageTable.data}, '$.role') = 'assistant'`,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.groupBy(MessageTable.session_id)
|
||||||
|
.all()) {
|
||||||
|
const current = usageBySession.get(row.session_id)
|
||||||
|
if (!current) continue
|
||||||
|
current.cost = row.cost
|
||||||
|
current.tokens.input = row.tokens_input
|
||||||
|
current.tokens.output = row.tokens_output
|
||||||
|
current.tokens.reasoning = row.tokens_reasoning
|
||||||
|
current.tokens.cache.read = row.tokens_cache_read
|
||||||
|
current.tokens.cache.write = row.tokens_cache_write
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [sessionID, value] of usageBySession) {
|
||||||
|
db.update(SessionTable)
|
||||||
|
.set({
|
||||||
|
cost: value.cost,
|
||||||
|
tokens_input: value.tokens.input,
|
||||||
|
tokens_output: value.tokens.output,
|
||||||
|
tokens_reasoning: value.tokens.reasoning,
|
||||||
|
tokens_cache_read: value.tokens.cache.read,
|
||||||
|
tokens_cache_write: value.tokens.cache.write,
|
||||||
|
})
|
||||||
|
.where(eq(SessionTable.id, sessionID))
|
||||||
|
.run()
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
return sessions.at(-1)?.id
|
||||||
|
}).pipe(
|
||||||
|
Effect.withSpan("DataMigration.sessionUsage.page", {
|
||||||
|
attributes: {
|
||||||
|
"data_migration.name": "session_usage_from_messages",
|
||||||
|
"data_migration.page": page,
|
||||||
|
"data_migration.cursor": cursor ?? "",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
if (!next) return
|
||||||
|
cursor = next
|
||||||
|
yield* Effect.sleep("10 millis")
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
yield* Effect.gen(function* () {
|
yield* Effect.gen(function* () {
|
||||||
if (migrations.length === 0) return
|
if (migrations.length === 0) return
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { NotFoundError } from "@/storage/storage"
|
import { NotFoundError } from "@/storage/storage"
|
||||||
import { eq } from "drizzle-orm"
|
import { eq } from "drizzle-orm"
|
||||||
import { and } from "drizzle-orm"
|
import { and } from "drizzle-orm"
|
||||||
|
import { sql } from "drizzle-orm"
|
||||||
|
import type { TxOrDb } from "@/storage/db"
|
||||||
import { SyncEvent } from "@/sync"
|
import { SyncEvent } from "@/sync"
|
||||||
import * as Session from "./session"
|
import * as Session from "./session"
|
||||||
import { MessageV2 } from "./message-v2"
|
import { MessageV2 } from "./message-v2"
|
||||||
@@ -19,6 +21,28 @@ function foreign(err: unknown) {
|
|||||||
|
|
||||||
export type DeepPartial<T> = T extends object ? { [K in keyof T]?: DeepPartial<T[K]> | null } : T
|
export type DeepPartial<T> = T extends object ? { [K in keyof T]?: DeepPartial<T[K]> | null } : T
|
||||||
|
|
||||||
|
type Usage = Pick<MessageV2.StepFinishPart, "cost" | "tokens">
|
||||||
|
|
||||||
|
function usage(part: MessageV2.Part | (typeof PartTable.$inferSelect)["data"]): Usage | undefined {
|
||||||
|
if (part.type !== "step-finish") return undefined
|
||||||
|
if (!("cost" in part) || !("tokens" in part)) return undefined
|
||||||
|
return { cost: part.cost, tokens: part.tokens }
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyUsage(db: TxOrDb, sessionID: Session.Info["id"], value: Usage, sign = 1) {
|
||||||
|
db.update(SessionTable)
|
||||||
|
.set({
|
||||||
|
cost: sql`${SessionTable.cost} + ${value.cost * sign}`,
|
||||||
|
tokens_input: sql`${SessionTable.tokens_input} + ${value.tokens.input * sign}`,
|
||||||
|
tokens_output: sql`${SessionTable.tokens_output} + ${value.tokens.output * sign}`,
|
||||||
|
tokens_reasoning: sql`${SessionTable.tokens_reasoning} + ${value.tokens.reasoning * sign}`,
|
||||||
|
tokens_cache_read: sql`${SessionTable.tokens_cache_read} + ${value.tokens.cache.read * sign}`,
|
||||||
|
tokens_cache_write: sql`${SessionTable.tokens_cache_write} + ${value.tokens.cache.write * sign}`,
|
||||||
|
})
|
||||||
|
.where(eq(SessionTable.id, sessionID))
|
||||||
|
.run()
|
||||||
|
}
|
||||||
|
|
||||||
function grab<T extends object, K1 extends keyof T, X>(
|
function grab<T extends object, K1 extends keyof T, X>(
|
||||||
obj: T,
|
obj: T,
|
||||||
field1: K1,
|
field1: K1,
|
||||||
@@ -54,6 +78,12 @@ export function toPartialRow(info: DeepPartial<Session.Info>) {
|
|||||||
summary_deletions: grab(info, "summary", (v) => grab(v, "deletions")),
|
summary_deletions: grab(info, "summary", (v) => grab(v, "deletions")),
|
||||||
summary_files: grab(info, "summary", (v) => grab(v, "files")),
|
summary_files: grab(info, "summary", (v) => grab(v, "files")),
|
||||||
summary_diffs: grab(info, "summary", (v) => grab(v, "diffs")),
|
summary_diffs: grab(info, "summary", (v) => grab(v, "diffs")),
|
||||||
|
cost: grab(info, "cost"),
|
||||||
|
tokens_input: grab(info, "tokens", (v) => grab(v, "input")),
|
||||||
|
tokens_output: grab(info, "tokens", (v) => grab(v, "output")),
|
||||||
|
tokens_reasoning: grab(info, "tokens", (v) => grab(v, "reasoning")),
|
||||||
|
tokens_cache_read: grab(info, "tokens", (v) => grab(v, "cache", (cache) => grab(cache, "read"))),
|
||||||
|
tokens_cache_write: grab(info, "tokens", (v) => grab(v, "cache", (cache) => grab(cache, "write"))),
|
||||||
revert: grab(info, "revert"),
|
revert: grab(info, "revert"),
|
||||||
permission: grab(info, "permission"),
|
permission: grab(info, "permission"),
|
||||||
time_created: grab(info, "time", (v) => grab(v, "created")),
|
time_created: grab(info, "time", (v) => grab(v, "created")),
|
||||||
@@ -112,12 +142,28 @@ export default [
|
|||||||
}),
|
}),
|
||||||
|
|
||||||
SyncEvent.project(MessageV2.Event.Removed, (db, data) => {
|
SyncEvent.project(MessageV2.Event.Removed, (db, data) => {
|
||||||
|
for (const row of db
|
||||||
|
.select()
|
||||||
|
.from(PartTable)
|
||||||
|
.where(and(eq(PartTable.message_id, data.messageID), eq(PartTable.session_id, data.sessionID)))
|
||||||
|
.all()) {
|
||||||
|
const previous = usage(row.data)
|
||||||
|
if (previous) applyUsage(db, data.sessionID, previous, -1)
|
||||||
|
}
|
||||||
db.delete(MessageTable)
|
db.delete(MessageTable)
|
||||||
.where(and(eq(MessageTable.id, data.messageID), eq(MessageTable.session_id, data.sessionID)))
|
.where(and(eq(MessageTable.id, data.messageID), eq(MessageTable.session_id, data.sessionID)))
|
||||||
.run()
|
.run()
|
||||||
}),
|
}),
|
||||||
|
|
||||||
SyncEvent.project(MessageV2.Event.PartRemoved, (db, data) => {
|
SyncEvent.project(MessageV2.Event.PartRemoved, (db, data) => {
|
||||||
|
const row = db
|
||||||
|
.select()
|
||||||
|
.from(PartTable)
|
||||||
|
.where(and(eq(PartTable.id, data.partID), eq(PartTable.session_id, data.sessionID)))
|
||||||
|
.get()
|
||||||
|
const previous = row && usage(row.data)
|
||||||
|
if (previous) applyUsage(db, data.sessionID, previous, -1)
|
||||||
|
|
||||||
db.delete(PartTable)
|
db.delete(PartTable)
|
||||||
.where(and(eq(PartTable.id, data.partID), eq(PartTable.session_id, data.sessionID)))
|
.where(and(eq(PartTable.id, data.partID), eq(PartTable.session_id, data.sessionID)))
|
||||||
.run()
|
.run()
|
||||||
@@ -125,6 +171,7 @@ export default [
|
|||||||
|
|
||||||
SyncEvent.project(MessageV2.Event.PartUpdated, (db, data) => {
|
SyncEvent.project(MessageV2.Event.PartUpdated, (db, data) => {
|
||||||
const { id, messageID, sessionID, ...rest } = data.part
|
const { id, messageID, sessionID, ...rest } = data.part
|
||||||
|
const row = db.select().from(PartTable).where(eq(PartTable.id, id)).get()
|
||||||
|
|
||||||
try {
|
try {
|
||||||
db.insert(PartTable)
|
db.insert(PartTable)
|
||||||
@@ -137,6 +184,10 @@ export default [
|
|||||||
})
|
})
|
||||||
.onConflictDoUpdate({ target: PartTable.id, set: { data: rest } })
|
.onConflictDoUpdate({ target: PartTable.id, set: { data: rest } })
|
||||||
.run()
|
.run()
|
||||||
|
const previous = row && usage(row.data)
|
||||||
|
const next = usage(data.part)
|
||||||
|
if (previous) applyUsage(db, row.session_id, previous, -1)
|
||||||
|
if (next) applyUsage(db, sessionID, next)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (!foreign(err)) throw err
|
if (!foreign(err)) throw err
|
||||||
log.warn("ignored late part update", { partID: id, messageID, sessionID })
|
log.warn("ignored late part update", { partID: id, messageID, sessionID })
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { sqliteTable, text, integer, index, primaryKey } from "drizzle-orm/sqlite-core"
|
import { sqliteTable, text, integer, index, primaryKey, real } from "drizzle-orm/sqlite-core"
|
||||||
import { ProjectTable } from "../project/project.sql"
|
import { ProjectTable } from "../project/project.sql"
|
||||||
import type { MessageV2 } from "./message-v2"
|
import type { MessageV2 } from "./message-v2"
|
||||||
import type { SessionMessage } from "../v2/session-message"
|
import type { SessionMessage } from "../v2/session-message"
|
||||||
@@ -10,7 +10,7 @@ import type { WorkspaceID } from "../control-plane/schema"
|
|||||||
import { Timestamps } from "../storage/schema.sql"
|
import { Timestamps } from "../storage/schema.sql"
|
||||||
|
|
||||||
type PartData = Omit<MessageV2.Part, "id" | "sessionID" | "messageID">
|
type PartData = Omit<MessageV2.Part, "id" | "sessionID" | "messageID">
|
||||||
type InfoData = Omit<MessageV2.Info, "id" | "sessionID">
|
type InfoData<T extends MessageV2.Info = MessageV2.Info> = T extends unknown ? Omit<T, "id" | "sessionID"> : never
|
||||||
type SessionMessageData = Omit<(typeof SessionMessage.Message)["Encoded"], "type" | "id">
|
type SessionMessageData = Omit<(typeof SessionMessage.Message)["Encoded"], "type" | "id">
|
||||||
|
|
||||||
export const SessionTable = sqliteTable(
|
export const SessionTable = sqliteTable(
|
||||||
@@ -33,6 +33,12 @@ export const SessionTable = sqliteTable(
|
|||||||
summary_deletions: integer(),
|
summary_deletions: integer(),
|
||||||
summary_files: integer(),
|
summary_files: integer(),
|
||||||
summary_diffs: text({ mode: "json" }).$type<Snapshot.FileDiff[]>(),
|
summary_diffs: text({ mode: "json" }).$type<Snapshot.FileDiff[]>(),
|
||||||
|
cost: real().notNull().default(0),
|
||||||
|
tokens_input: integer().notNull().default(0),
|
||||||
|
tokens_output: integer().notNull().default(0),
|
||||||
|
tokens_reasoning: integer().notNull().default(0),
|
||||||
|
tokens_cache_read: integer().notNull().default(0),
|
||||||
|
tokens_cache_write: integer().notNull().default(0),
|
||||||
revert: text({ mode: "json" }).$type<{ messageID: MessageID; partID?: PartID; snapshot?: string; diff?: string }>(),
|
revert: text({ mode: "json" }).$type<{ messageID: MessageID; partID?: PartID; snapshot?: string; diff?: string }>(),
|
||||||
permission: text({ mode: "json" }).$type<Permission.Ruleset>(),
|
permission: text({ mode: "json" }).$type<Permission.Ruleset>(),
|
||||||
agent: text(),
|
agent: text(),
|
||||||
|
|||||||
@@ -87,6 +87,16 @@ export function fromRow(row: SessionRow): Info {
|
|||||||
: undefined,
|
: undefined,
|
||||||
version: row.version,
|
version: row.version,
|
||||||
summary,
|
summary,
|
||||||
|
cost: row.cost,
|
||||||
|
tokens: {
|
||||||
|
input: row.tokens_input,
|
||||||
|
output: row.tokens_output,
|
||||||
|
reasoning: row.tokens_reasoning,
|
||||||
|
cache: {
|
||||||
|
read: row.tokens_cache_read,
|
||||||
|
write: row.tokens_cache_write,
|
||||||
|
},
|
||||||
|
},
|
||||||
share,
|
share,
|
||||||
revert,
|
revert,
|
||||||
permission: row.permission ?? undefined,
|
permission: row.permission ?? undefined,
|
||||||
@@ -117,6 +127,12 @@ export function toRow(info: Info) {
|
|||||||
summary_deletions: info.summary?.deletions,
|
summary_deletions: info.summary?.deletions,
|
||||||
summary_files: info.summary?.files,
|
summary_files: info.summary?.files,
|
||||||
summary_diffs: info.summary?.diffs,
|
summary_diffs: info.summary?.diffs,
|
||||||
|
cost: info.cost ?? 0,
|
||||||
|
tokens_input: (info.tokens ?? EmptyTokens).input,
|
||||||
|
tokens_output: (info.tokens ?? EmptyTokens).output,
|
||||||
|
tokens_reasoning: (info.tokens ?? EmptyTokens).reasoning,
|
||||||
|
tokens_cache_read: (info.tokens ?? EmptyTokens).cache.read,
|
||||||
|
tokens_cache_write: (info.tokens ?? EmptyTokens).cache.write,
|
||||||
revert: info.revert ?? null,
|
revert: info.revert ?? null,
|
||||||
permission: info.permission,
|
permission: info.permission,
|
||||||
time_created: info.time.created,
|
time_created: info.time.created,
|
||||||
@@ -147,6 +163,18 @@ const Summary = Schema.Struct({
|
|||||||
diffs: optionalOmitUndefined(Schema.Array(Snapshot.FileDiff)),
|
diffs: optionalOmitUndefined(Schema.Array(Snapshot.FileDiff)),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const Tokens = Schema.Struct({
|
||||||
|
input: Schema.Finite,
|
||||||
|
output: Schema.Finite,
|
||||||
|
reasoning: Schema.Finite,
|
||||||
|
cache: Schema.Struct({
|
||||||
|
read: Schema.Finite,
|
||||||
|
write: Schema.Finite,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
const EmptyTokens = { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }
|
||||||
|
|
||||||
const Share = Schema.Struct({
|
const Share = Schema.Struct({
|
||||||
url: Schema.String,
|
url: Schema.String,
|
||||||
})
|
})
|
||||||
@@ -184,6 +212,8 @@ export const Info = Schema.Struct({
|
|||||||
path: optionalOmitUndefined(Schema.String),
|
path: optionalOmitUndefined(Schema.String),
|
||||||
parentID: optionalOmitUndefined(SessionID),
|
parentID: optionalOmitUndefined(SessionID),
|
||||||
summary: optionalOmitUndefined(Summary),
|
summary: optionalOmitUndefined(Summary),
|
||||||
|
cost: optionalOmitUndefined(Schema.Finite),
|
||||||
|
tokens: optionalOmitUndefined(Tokens),
|
||||||
share: optionalOmitUndefined(Share),
|
share: optionalOmitUndefined(Share),
|
||||||
title: Schema.String,
|
title: Schema.String,
|
||||||
agent: optionalOmitUndefined(Schema.String),
|
agent: optionalOmitUndefined(Schema.String),
|
||||||
@@ -281,6 +311,8 @@ const UpdatedInfo = Schema.Struct({
|
|||||||
path: Schema.optional(Schema.NullOr(Schema.String)),
|
path: Schema.optional(Schema.NullOr(Schema.String)),
|
||||||
parentID: Schema.optional(Schema.NullOr(SessionID)),
|
parentID: Schema.optional(Schema.NullOr(SessionID)),
|
||||||
summary: Schema.optional(Schema.NullOr(Summary)),
|
summary: Schema.optional(Schema.NullOr(Summary)),
|
||||||
|
cost: Schema.optional(Schema.Finite),
|
||||||
|
tokens: Schema.optional(Tokens),
|
||||||
share: Schema.optional(UpdatedShare),
|
share: Schema.optional(UpdatedShare),
|
||||||
title: Schema.optional(Schema.NullOr(Schema.String)),
|
title: Schema.optional(Schema.NullOr(Schema.String)),
|
||||||
agent: Schema.optional(Schema.NullOr(Schema.String)),
|
agent: Schema.optional(Schema.NullOr(Schema.String)),
|
||||||
@@ -503,6 +535,8 @@ export const layer: Layer.Layer<Service, never, Bus.Service | Storage.Service |
|
|||||||
agent: input.agent,
|
agent: input.agent,
|
||||||
model: input.model,
|
model: input.model,
|
||||||
permission: input.permission,
|
permission: input.permission,
|
||||||
|
cost: 0,
|
||||||
|
tokens: EmptyTokens,
|
||||||
time: {
|
time: {
|
||||||
created: Date.now(),
|
created: Date.now(),
|
||||||
updated: Date.now(),
|
updated: Date.now(),
|
||||||
|
|||||||
@@ -216,6 +216,12 @@ export async function run(db: SQLiteBunDatabase<any, any> | NodeSQLiteDatabase<a
|
|||||||
summary_deletions: data.summary?.deletions ?? null,
|
summary_deletions: data.summary?.deletions ?? null,
|
||||||
summary_files: data.summary?.files ?? null,
|
summary_files: data.summary?.files ?? null,
|
||||||
summary_diffs: data.summary?.diffs ?? null,
|
summary_diffs: data.summary?.diffs ?? null,
|
||||||
|
cost: 0,
|
||||||
|
tokens_input: 0,
|
||||||
|
tokens_output: 0,
|
||||||
|
tokens_reasoning: 0,
|
||||||
|
tokens_cache_read: 0,
|
||||||
|
tokens_cache_write: 0,
|
||||||
revert: data.revert ?? null,
|
revert: data.revert ?? null,
|
||||||
permission: data.permission ?? null,
|
permission: data.permission ?? null,
|
||||||
time_created: data.time?.created ?? now,
|
time_created: data.time?.created ?? now,
|
||||||
|
|||||||
@@ -29,6 +29,16 @@ export class Info extends Schema.Class<Info>("Session.Info")({
|
|||||||
path: optionalOmitUndefined(Schema.String),
|
path: optionalOmitUndefined(Schema.String),
|
||||||
agent: optionalOmitUndefined(Schema.String),
|
agent: optionalOmitUndefined(Schema.String),
|
||||||
model: Modelv2.Ref.pipe(optionalOmitUndefined),
|
model: Modelv2.Ref.pipe(optionalOmitUndefined),
|
||||||
|
cost: Schema.Finite,
|
||||||
|
tokens: Schema.Struct({
|
||||||
|
input: Schema.Finite,
|
||||||
|
output: Schema.Finite,
|
||||||
|
reasoning: Schema.Finite,
|
||||||
|
cache: Schema.Struct({
|
||||||
|
read: Schema.Finite,
|
||||||
|
write: Schema.Finite,
|
||||||
|
}),
|
||||||
|
}),
|
||||||
time: Schema.Struct({
|
time: Schema.Struct({
|
||||||
created: V2Schema.DateTimeUtcFromMillis,
|
created: V2Schema.DateTimeUtcFromMillis,
|
||||||
updated: V2Schema.DateTimeUtcFromMillis,
|
updated: V2Schema.DateTimeUtcFromMillis,
|
||||||
@@ -136,6 +146,16 @@ export const layer = Layer.effect(
|
|||||||
variant: Modelv2.VariantID.make(row.model.variant ?? "default"),
|
variant: Modelv2.VariantID.make(row.model.variant ?? "default"),
|
||||||
}
|
}
|
||||||
: undefined,
|
: undefined,
|
||||||
|
cost: row.cost,
|
||||||
|
tokens: {
|
||||||
|
input: row.tokens_input,
|
||||||
|
output: row.tokens_output,
|
||||||
|
reasoning: row.tokens_reasoning,
|
||||||
|
cache: {
|
||||||
|
read: row.tokens_cache_read,
|
||||||
|
write: row.tokens_cache_write,
|
||||||
|
},
|
||||||
|
},
|
||||||
time: {
|
time: {
|
||||||
created: DateTime.makeUnsafe(row.time_created),
|
created: DateTime.makeUnsafe(row.time_created),
|
||||||
updated: DateTime.makeUnsafe(row.time_updated),
|
updated: DateTime.makeUnsafe(row.time_updated),
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, expect } from "bun:test"
|
import { describe, expect } from "bun:test"
|
||||||
import { Deferred, Effect, Exit, Fiber, Ref, Scope } from "effect"
|
import { Deferred, Effect, Exit, Fiber, Latch, Ref, Scope } from "effect"
|
||||||
import { Runner } from "@/effect/runner"
|
import { Runner } from "@/effect/runner"
|
||||||
import { it } from "../lib/effect"
|
import { it } from "../lib/effect"
|
||||||
|
|
||||||
@@ -352,11 +352,18 @@ describe("Runner", () => {
|
|||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const s = yield* Scope.Scope
|
const s = yield* Scope.Scope
|
||||||
const runner = Runner.make<string>(s, { onInterrupt: Effect.succeed("interrupted") })
|
const runner = Runner.make<string>(s, { onInterrupt: Effect.succeed("interrupted") })
|
||||||
|
const ready = yield* Latch.make()
|
||||||
|
|
||||||
const sh = yield* runner
|
const sh = yield* runner
|
||||||
.startShell(Effect.never.pipe(Effect.ensuring(Effect.die("boom")), Effect.as("ignored")))
|
.startShell(
|
||||||
|
Effect.gen(function* () {
|
||||||
|
yield* ready.open
|
||||||
|
return yield* Effect.never.pipe(Effect.as("ignored"))
|
||||||
|
}).pipe(Effect.ensuring(Effect.die("boom"))),
|
||||||
|
ready,
|
||||||
|
)
|
||||||
.pipe(Effect.forkChild)
|
.pipe(Effect.forkChild)
|
||||||
yield* Effect.sleep("10 millis")
|
yield* ready.await.pipe(Effect.timeout("250 millis"))
|
||||||
|
|
||||||
yield* runner.cancel
|
yield* runner.cancel
|
||||||
expect(Exit.isFailure(yield* Fiber.await(sh))).toBe(true)
|
expect(Exit.isFailure(yield* Fiber.await(sh))).toBe(true)
|
||||||
|
|||||||
@@ -292,6 +292,7 @@ export function createTuiPluginApi(opts: Opts = {}): HostPluginApi {
|
|||||||
},
|
},
|
||||||
session: {
|
session: {
|
||||||
count: opts.state?.session?.count ?? (() => 0),
|
count: opts.state?.session?.count ?? (() => 0),
|
||||||
|
get: opts.state?.session?.get ?? (() => undefined),
|
||||||
diff: opts.state?.session?.diff ?? (() => []),
|
diff: opts.state?.session?.diff ?? (() => []),
|
||||||
todo: opts.state?.session?.todo ?? (() => []),
|
todo: opts.state?.session?.todo ?? (() => []),
|
||||||
messages: opts.state?.session?.messages ?? (() => []),
|
messages: opts.state?.session?.messages ?? (() => []),
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ const info = {
|
|||||||
directory: "/tmp/opencode",
|
directory: "/tmp/opencode",
|
||||||
parentID: undefined,
|
parentID: undefined,
|
||||||
summary: undefined,
|
summary: undefined,
|
||||||
|
cost: 0,
|
||||||
|
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||||
share: undefined,
|
share: undefined,
|
||||||
title: "Test session",
|
title: "Test session",
|
||||||
version: "1.0.0",
|
version: "1.0.0",
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import type {
|
|||||||
Provider,
|
Provider,
|
||||||
PermissionRequest,
|
PermissionRequest,
|
||||||
QuestionRequest,
|
QuestionRequest,
|
||||||
|
Session,
|
||||||
SessionStatus,
|
SessionStatus,
|
||||||
TextPart,
|
TextPart,
|
||||||
Config as SdkConfig,
|
Config as SdkConfig,
|
||||||
@@ -310,6 +311,7 @@ export type TuiState = {
|
|||||||
readonly vcs: { branch?: string } | undefined
|
readonly vcs: { branch?: string } | undefined
|
||||||
session: {
|
session: {
|
||||||
count: () => number
|
count: () => number
|
||||||
|
get: (sessionID: string) => Session | undefined
|
||||||
diff: (sessionID: string) => ReadonlyArray<TuiSidebarFileItem>
|
diff: (sessionID: string) => ReadonlyArray<TuiSidebarFileItem>
|
||||||
todo: (sessionID: string) => ReadonlyArray<TuiSidebarTodoItem>
|
todo: (sessionID: string) => ReadonlyArray<TuiSidebarTodoItem>
|
||||||
messages: (sessionID: string) => ReadonlyArray<Message>
|
messages: (sessionID: string) => ReadonlyArray<Message>
|
||||||
|
|||||||
@@ -741,6 +741,16 @@ export type Session = {
|
|||||||
files: number
|
files: number
|
||||||
diffs?: Array<SnapshotFileDiff>
|
diffs?: Array<SnapshotFileDiff>
|
||||||
}
|
}
|
||||||
|
cost?: number
|
||||||
|
tokens?: {
|
||||||
|
input: number
|
||||||
|
output: number
|
||||||
|
reasoning: number
|
||||||
|
cache: {
|
||||||
|
read: number
|
||||||
|
write: number
|
||||||
|
}
|
||||||
|
}
|
||||||
share?: {
|
share?: {
|
||||||
url: string
|
url: string
|
||||||
}
|
}
|
||||||
@@ -1430,6 +1440,16 @@ export type GlobalSession = {
|
|||||||
files: number
|
files: number
|
||||||
diffs?: Array<SnapshotFileDiff>
|
diffs?: Array<SnapshotFileDiff>
|
||||||
}
|
}
|
||||||
|
cost?: number
|
||||||
|
tokens?: {
|
||||||
|
input: number
|
||||||
|
output: number
|
||||||
|
reasoning: number
|
||||||
|
cache: {
|
||||||
|
read: number
|
||||||
|
write: number
|
||||||
|
}
|
||||||
|
}
|
||||||
share?: {
|
share?: {
|
||||||
url: string
|
url: string
|
||||||
}
|
}
|
||||||
@@ -1893,6 +1913,16 @@ export type SyncEventSessionUpdated = {
|
|||||||
files: number
|
files: number
|
||||||
diffs?: Array<SnapshotFileDiff>
|
diffs?: Array<SnapshotFileDiff>
|
||||||
} | null
|
} | null
|
||||||
|
cost?: number | null
|
||||||
|
tokens?: {
|
||||||
|
input: number
|
||||||
|
output: number
|
||||||
|
reasoning: number
|
||||||
|
cache: {
|
||||||
|
read: number
|
||||||
|
write: number
|
||||||
|
}
|
||||||
|
} | null
|
||||||
share?: {
|
share?: {
|
||||||
url?: string | null
|
url?: string | null
|
||||||
}
|
}
|
||||||
@@ -3085,6 +3115,16 @@ export type SessionInfo = {
|
|||||||
providerID: string
|
providerID: string
|
||||||
variant: string
|
variant: string
|
||||||
}
|
}
|
||||||
|
cost: number
|
||||||
|
tokens: {
|
||||||
|
input: number
|
||||||
|
output: number
|
||||||
|
reasoning: number
|
||||||
|
cache: {
|
||||||
|
read: number
|
||||||
|
write: number
|
||||||
|
}
|
||||||
|
}
|
||||||
time: {
|
time: {
|
||||||
created: number
|
created: number
|
||||||
updated: number
|
updated: number
|
||||||
|
|||||||
Reference in New Issue
Block a user