diff --git a/packages/client/src/generated-effect/client.ts b/packages/client/src/generated-effect/client.ts index 024c9782806c..c042eb577dd5 100644 --- a/packages/client/src/generated-effect/client.ts +++ b/packages/client/src/generated-effect/client.ts @@ -207,6 +207,12 @@ const Endpoint3_16 = (raw: RawClient["server.session"]) => (input: Endpoint3_16I Effect.map((value) => value.data), ) +const Endpoint3_17 = (raw: RawClient["server.session"]) => () => + raw["session.cost"]({}).pipe( + Effect.mapError(mapClientError), + Effect.map((value) => value.data), + ) + const adaptGroup3 = (raw: RawClient["server.session"]) => ({ list: Endpoint3_0(raw), create: Endpoint3_1(raw), @@ -225,6 +231,7 @@ const adaptGroup3 = (raw: RawClient["server.session"]) => ({ events: Endpoint3_14(raw), interrupt: Endpoint3_15(raw), message: Endpoint3_16(raw), + cost: Endpoint3_17(raw), }) type Endpoint4_0Request = Parameters[0] diff --git a/packages/client/src/generated/client.ts b/packages/client/src/generated/client.ts index 27ec3d81ba2c..f527e77fb6f6 100644 --- a/packages/client/src/generated/client.ts +++ b/packages/client/src/generated/client.ts @@ -37,6 +37,7 @@ import type { SessionsInterruptOutput, SessionsMessageInput, SessionsMessageOutput, + SessionsCostOutput, MessagesListInput, MessagesListOutput, ModelsListInput, @@ -492,6 +493,11 @@ export function make(options: ClientOptions) { }, requestOptions, ).then((value) => value.data), + cost: (requestOptions?: RequestOptions) => + request<{ readonly data: SessionsCostOutput }>( + { method: "GET", path: `/api/session/cost`, successStatus: 200, declaredStatuses: [401, 400], empty: false }, + requestOptions, + ).then((value) => value.data), }, messages: { list: (input: MessagesListInput, requestOptions?: RequestOptions) => diff --git a/packages/client/src/generated/types.ts b/packages/client/src/generated/types.ts index 3b3188c8742a..a70144608c44 100644 --- a/packages/client/src/generated/types.ts +++ b/packages/client/src/generated/types.ts @@ -1753,6 +1753,8 @@ export type SessionsMessageOutput = { } }["data"] +export type SessionsCostOutput = { readonly data: number }["data"] + export type MessagesListInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] readonly limit?: { diff --git a/packages/core/src/session.ts b/packages/core/src/session.ts index 2dabfb2d6fba..9450b31ba265 100644 --- a/packages/core/src/session.ts +++ b/packages/core/src/session.ts @@ -3,7 +3,7 @@ export * from "./session/schema" import { DateTime, Effect, Layer, Schema, Context, Stream } from "effect" import { ListAnchor } from "@opencode-ai/schema/session" -import { and, asc, desc, eq, gt, like, lt, or, type SQL } from "drizzle-orm" +import { and, asc, desc, eq, gt, gte, like, lt, or, type SQL } from "drizzle-orm" import { ProjectV2 } from "./project" import { WorkspaceV2 } from "./workspace" import { ModelV2 } from "./model" @@ -101,6 +101,10 @@ export class OperationUnavailableError extends Schema.TaggedErrorClass total + (session.cost ?? 0), 0) +} + export class PromptConflictError extends Schema.TaggedErrorClass()("Session.PromptConflictError", { sessionID: SessionSchema.ID, messageID: SessionMessage.ID, @@ -112,6 +116,7 @@ export type Error = NotFoundError | MessageDecodeError | OperationUnavailableErr export interface Interface { readonly list: (input?: ListInput) => Effect.Effect + readonly todayCost: Effect.Effect readonly create: (input: CreateInput) => Effect.Effect readonly get: (sessionID: SessionSchema.ID) => Effect.Effect readonly messages: (input: { @@ -205,6 +210,17 @@ const layer = Layer.effect( ) const result = Service.of({ + todayCost: Effect.gen(function* () { + const start = new Date() + start.setHours(0, 0, 0, 0) + const rows = yield* db + .select({ cost: SessionTable.cost }) + .from(SessionTable) + .where(gte(SessionTable.time_updated, start.getTime())) + .all() + .pipe(Effect.orDie) + return sumSessionCosts(rows) + }), create: Effect.fn("V2Session.create")(function* (input) { const sessionID = input.id ?? SessionSchema.ID.create() const recorded = yield* store.get(sessionID) diff --git a/packages/core/test/session-cost.test.ts b/packages/core/test/session-cost.test.ts new file mode 100644 index 000000000000..b329a2984230 --- /dev/null +++ b/packages/core/test/session-cost.test.ts @@ -0,0 +1,6 @@ +import { expect, test } from "bun:test" +import { sumSessionCosts } from "@opencode-ai/core/session" + +test("sums session costs and treats missing costs as zero", () => { + expect(sumSessionCosts([{ cost: 0.05 }, {}, { cost: 10.29 }])).toBeCloseTo(10.34) +}) diff --git a/packages/opencode/src/cli/cmd/stats.ts b/packages/opencode/src/cli/cmd/stats.ts index 22dee14772cd..39601f0a6464 100644 --- a/packages/opencode/src/cli/cmd/stats.ts +++ b/packages/opencode/src/cli/cmd/stats.ts @@ -6,6 +6,7 @@ import { Database } from "@opencode-ai/core/database/database" import { SessionTable } from "@opencode-ai/core/session/sql" import { Project } from "@/project/project" import { InstanceRef } from "@/effect/instance-ref" +import { sumSessionCosts } from "@opencode-ai/core/session" interface SessionStats { totalSessions: number @@ -124,7 +125,7 @@ const aggregateSessionStats = Effect.fn("Cli.stats.aggregate")(function* ( const stats: SessionStats = { totalSessions: filteredSessions.length, totalMessages: 0, - totalCost: 0, + totalCost: sumSessionCosts(filteredSessions), totalTokens: { input: 0, output: 0, @@ -168,7 +169,6 @@ const aggregateSessionStats = Effect.fn("Cli.stats.aggregate")(function* ( .messages({ sessionID: session.id }) .pipe(Effect.catchIf(NotFoundError.isInstance, () => Effect.succeed([]))) - const sessionCost = session.cost ?? 0 const sessionTokens = session.tokens ?? { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } } let sessionToolUsage: Record = {} let sessionModelUsage: Record< @@ -211,7 +211,6 @@ const aggregateSessionStats = Effect.fn("Cli.stats.aggregate")(function* ( return { messageCount: messages.length, - sessionCost, sessionTokens, sessionTotalTokens: sessionTokens.input + @@ -234,7 +233,6 @@ const aggregateSessionStats = Effect.fn("Cli.stats.aggregate")(function* ( sessionTotalTokens.push(result.sessionTotalTokens) stats.totalMessages += result.messageCount - stats.totalCost += result.sessionCost stats.totalTokens.input += result.sessionTokens.input stats.totalTokens.output += result.sessionTokens.output stats.totalTokens.reasoning += result.sessionTokens.reasoning diff --git a/packages/plugin/src/tui.ts b/packages/plugin/src/tui.ts index 67812169475b..697f2ef64d49 100644 --- a/packages/plugin/src/tui.ts +++ b/packages/plugin/src/tui.ts @@ -419,6 +419,7 @@ type TuiAttentionConfigView = { type TuiConfigView = Pick & NonNullable & { leader_timeout: number + show_today_cost: boolean attention: TuiAttentionConfigView plugin_enabled?: Record keybinds: TuiBindingLookupView diff --git a/packages/protocol/src/groups/session.ts b/packages/protocol/src/groups/session.ts index 8ce85ef79686..478eaf40e37c 100644 --- a/packages/protocol/src/groups/session.ts +++ b/packages/protocol/src/groups/session.ts @@ -371,6 +371,17 @@ export const makeSessionGroup = (sessionLo }), ), ) + .add( + HttpApiEndpoint.get("session.cost", "/api/session/cost", { + success: Schema.Struct({ data: Schema.Finite }), + }).annotateMerge( + OpenApi.annotations({ + identifier: "v2.session.cost", + summary: "Get today's session cost", + description: "Get the total cost of sessions updated since local midnight.", + }), + ), + ) .annotateMerge( OpenApi.annotations({ title: "sessions", diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts index 9ed0084aac84..78c231374856 100644 --- a/packages/sdk/js/src/v2/gen/sdk.gen.ts +++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts @@ -339,6 +339,8 @@ import type { V2SessionCompactResponses, V2SessionContextErrors, V2SessionContextResponses, + V2SessionCostErrors, + V2SessionCostResponses, V2SessionCreateErrors, V2SessionCreateResponses, V2SessionEventsErrors, @@ -5466,6 +5468,18 @@ export class Session3 extends HeyApiClient { }) } + /** + * Get today's session cost + * + * Get the total cost of sessions updated since local midnight. + */ + public cost(options?: Options) { + return (options?.client ?? this.client).get({ + url: "/api/session/cost", + ...options, + }) + } + /** * Create session * diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 90c91e9158cc..efa74d3aa307 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -11370,6 +11370,28 @@ export type V2SessionListResponses = { export type V2SessionListResponse = V2SessionListResponses[keyof V2SessionListResponses] +export type V2SessionCostData = { + body?: never + path?: never + query?: never + url: "/api/session/cost" +} + +export type V2SessionCostErrors = { + 400: InvalidRequestError + 401: UnauthorizedError +} + +export type V2SessionCostError = V2SessionCostErrors[keyof V2SessionCostErrors] + +export type V2SessionCostResponses = { + 200: { + data: number + } +} + +export type V2SessionCostResponse = V2SessionCostResponses[keyof V2SessionCostResponses] + export type V2SessionCreateData = { body: { id?: string diff --git a/packages/server/src/handlers/session.ts b/packages/server/src/handlers/session.ts index 5b7d354b04fc..222a56cacd47 100644 --- a/packages/server/src/handlers/session.ts +++ b/packages/server/src/handlers/session.ts @@ -21,6 +21,12 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl const session = yield* SessionV2.Service return handlers + .handle( + "session.cost", + Effect.fn(function* () { + return { data: yield* session.todayCost } + }), + ) .handle( "session.list", Effect.fn(function* (ctx) { diff --git a/packages/tui/src/config/index.tsx b/packages/tui/src/config/index.tsx index df9239763a68..e5dfc208e6b9 100644 --- a/packages/tui/src/config/index.tsx +++ b/packages/tui/src/config/index.tsx @@ -63,10 +63,14 @@ export const Info = Schema.Struct({ scroll_acceleration: Schema.optional(ScrollAcceleration), diff_style: Schema.optional(DiffStyle), mouse: Schema.optional(Schema.Boolean).annotate({ description: "Enable or disable mouse capture (default: true)" }), + show_today_cost: Schema.optional(Schema.Boolean).annotate({ + description: "Show today's session cost in the sidebar Context section (default: false)", + }), }) export type Info = Schema.Schema.Type export type Resolved = Omit & { + show_today_cost: boolean attention: { enabled: boolean notifications: boolean @@ -113,6 +117,7 @@ export function resolve(input: Info, options: ResolveOptions): Resolved { }), leader_timeout: input.leader_timeout ?? LeaderTimeoutDefault, mouse: input.mouse ?? true, + show_today_cost: input.show_today_cost ?? false, } } diff --git a/packages/tui/src/feature-plugins/sidebar/context.tsx b/packages/tui/src/feature-plugins/sidebar/context.tsx index f1c99d9679ce..b605715b431c 100644 --- a/packages/tui/src/feature-plugins/sidebar/context.tsx +++ b/packages/tui/src/feature-plugins/sidebar/context.tsx @@ -1,7 +1,7 @@ import type { AssistantMessage } from "@opencode-ai/sdk/v2" import type { TuiPlugin, TuiPluginApi } from "@opencode-ai/plugin/tui" import type { BuiltinTuiPlugin } from "../builtins" -import { createMemo } from "solid-js" +import { createEffect, createMemo, createSignal, onCleanup, onMount, Show } from "solid-js" const id = "internal:sidebar-context" @@ -14,7 +14,29 @@ function View(props: { api: TuiPluginApi; session_id: string }) { const theme = () => props.api.theme.current const msg = createMemo(() => props.api.state.session.messages(props.session_id)) const session = createMemo(() => props.api.state.session.get(props.session_id)) - const cost = createMemo(() => session()?.cost ?? 0) + const [sessionCost, setSessionCost] = createSignal() + const cost = createMemo(() => sessionCost() ?? session()?.cost ?? 0) + const [todayCost, setTodayCost] = createSignal() + + const refreshCosts = async () => { + const [sessionResult, todayResult] = await Promise.all([ + props.api.client.v2.session.get({ sessionID: props.session_id }).catch(() => undefined), + props.api.tuiConfig.show_today_cost ? props.api.client.v2.session.cost().catch(() => undefined) : undefined, + ]) + const currentSessionCost = sessionResult?.data?.data.cost + if (currentSessionCost !== undefined) setSessionCost(currentSessionCost) + if (todayResult?.data?.data !== undefined) setTodayCost(todayResult.data.data) + } + + createEffect(() => { + session() + void refreshCosts() + }) + + onMount(() => { + const interval = setInterval(refreshCosts, 30_000) + onCleanup(() => clearInterval(interval)) + }) const state = createMemo(() => { const last = msg().findLast((item): item is AssistantMessage => item.role === "assistant" && item.tokens.output > 0) @@ -42,6 +64,9 @@ function View(props: { api: TuiPluginApi; session_id: string }) { {state().tokens.toLocaleString()} tokens {state().percent ?? 0}% used {money.format(cost())} spent + + {money.format(todayCost()!)} today + ) }