Track session usage totals (#26644)
This commit is contained in:
@@ -1,6 +1,8 @@
|
||||
import { NotFoundError } from "@/storage/storage"
|
||||
import { eq } from "drizzle-orm"
|
||||
import { and } from "drizzle-orm"
|
||||
import { sql } from "drizzle-orm"
|
||||
import type { TxOrDb } from "@/storage/db"
|
||||
import { SyncEvent } from "@/sync"
|
||||
import * as Session from "./session"
|
||||
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
|
||||
|
||||
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>(
|
||||
obj: T,
|
||||
field1: K1,
|
||||
@@ -54,6 +78,12 @@ export function toPartialRow(info: DeepPartial<Session.Info>) {
|
||||
summary_deletions: grab(info, "summary", (v) => grab(v, "deletions")),
|
||||
summary_files: grab(info, "summary", (v) => grab(v, "files")),
|
||||
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"),
|
||||
permission: grab(info, "permission"),
|
||||
time_created: grab(info, "time", (v) => grab(v, "created")),
|
||||
@@ -112,12 +142,28 @@ export default [
|
||||
}),
|
||||
|
||||
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)
|
||||
.where(and(eq(MessageTable.id, data.messageID), eq(MessageTable.session_id, data.sessionID)))
|
||||
.run()
|
||||
}),
|
||||
|
||||
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)
|
||||
.where(and(eq(PartTable.id, data.partID), eq(PartTable.session_id, data.sessionID)))
|
||||
.run()
|
||||
@@ -125,6 +171,7 @@ export default [
|
||||
|
||||
SyncEvent.project(MessageV2.Event.PartUpdated, (db, data) => {
|
||||
const { id, messageID, sessionID, ...rest } = data.part
|
||||
const row = db.select().from(PartTable).where(eq(PartTable.id, id)).get()
|
||||
|
||||
try {
|
||||
db.insert(PartTable)
|
||||
@@ -137,6 +184,10 @@ export default [
|
||||
})
|
||||
.onConflictDoUpdate({ target: PartTable.id, set: { data: rest } })
|
||||
.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) {
|
||||
if (!foreign(err)) throw err
|
||||
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 type { MessageV2 } from "./message-v2"
|
||||
import type { SessionMessage } from "../v2/session-message"
|
||||
@@ -10,7 +10,7 @@ import type { WorkspaceID } from "../control-plane/schema"
|
||||
import { Timestamps } from "../storage/schema.sql"
|
||||
|
||||
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">
|
||||
|
||||
export const SessionTable = sqliteTable(
|
||||
@@ -33,6 +33,12 @@ export const SessionTable = sqliteTable(
|
||||
summary_deletions: integer(),
|
||||
summary_files: integer(),
|
||||
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 }>(),
|
||||
permission: text({ mode: "json" }).$type<Permission.Ruleset>(),
|
||||
agent: text(),
|
||||
|
||||
@@ -87,6 +87,16 @@ export function fromRow(row: SessionRow): Info {
|
||||
: undefined,
|
||||
version: row.version,
|
||||
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,
|
||||
revert,
|
||||
permission: row.permission ?? undefined,
|
||||
@@ -117,6 +127,12 @@ export function toRow(info: Info) {
|
||||
summary_deletions: info.summary?.deletions,
|
||||
summary_files: info.summary?.files,
|
||||
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,
|
||||
permission: info.permission,
|
||||
time_created: info.time.created,
|
||||
@@ -147,6 +163,18 @@ const Summary = Schema.Struct({
|
||||
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({
|
||||
url: Schema.String,
|
||||
})
|
||||
@@ -184,6 +212,8 @@ export const Info = Schema.Struct({
|
||||
path: optionalOmitUndefined(Schema.String),
|
||||
parentID: optionalOmitUndefined(SessionID),
|
||||
summary: optionalOmitUndefined(Summary),
|
||||
cost: optionalOmitUndefined(Schema.Finite),
|
||||
tokens: optionalOmitUndefined(Tokens),
|
||||
share: optionalOmitUndefined(Share),
|
||||
title: Schema.String,
|
||||
agent: optionalOmitUndefined(Schema.String),
|
||||
@@ -281,6 +311,8 @@ const UpdatedInfo = Schema.Struct({
|
||||
path: Schema.optional(Schema.NullOr(Schema.String)),
|
||||
parentID: Schema.optional(Schema.NullOr(SessionID)),
|
||||
summary: Schema.optional(Schema.NullOr(Summary)),
|
||||
cost: Schema.optional(Schema.Finite),
|
||||
tokens: Schema.optional(Tokens),
|
||||
share: Schema.optional(UpdatedShare),
|
||||
title: 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,
|
||||
model: input.model,
|
||||
permission: input.permission,
|
||||
cost: 0,
|
||||
tokens: EmptyTokens,
|
||||
time: {
|
||||
created: Date.now(),
|
||||
updated: Date.now(),
|
||||
|
||||
Reference in New Issue
Block a user