From 7ea7b53783deb74e0694e72a5a25a78e67a8ccd8 Mon Sep 17 00:00:00 2001 From: maximhar Date: Tue, 3 Mar 2026 04:26:00 +0200 Subject: [PATCH] feat(copilot): track premium requests with model multipliers (#255) * Track Copilot premium requests with model multipliers * refactor(copilot): source premium multipliers from models.json * feat(copilot): add gpt-5.3-codex bundled model * fix(stats): round premium request totals in summary * fix(stats): round premium requests in sync summary --- packages/ai/src/models.json | 48 +++++++ packages/ai/src/providers/anthropic.ts | 34 ++--- .../src/providers/github-copilot-headers.ts | 43 ++++-- .../ai/src/providers/openai-completions.ts | 36 ++--- packages/ai/src/providers/openai-responses.ts | 36 ++--- packages/ai/src/types.ts | 3 + .../ai/test/github-copilot-headers.test.ts | 77 +++++++++-- packages/ai/test/models-cost.test.ts | 73 +++++++++++ packages/coding-agent/src/cli/stats-cli.ts | 5 + .../coding-agent/src/config/model-registry.ts | 6 + .../src/extensibility/extensions/types.ts | 2 + .../src/modes/components/footer.ts | 14 +- .../src/modes/components/status-line.ts | 1 + .../modes/components/status-line/segments.ts | 17 ++- .../src/modes/components/status-line/types.ts | 1 + .../modes/controllers/command-controller.ts | 14 +- .../coding-agent/src/session/agent-session.ts | 5 + .../src/session/session-manager.ts | 22 +++- .../session-manager/usage-statistics.test.ts | 123 ++++++++++++++++++ .../src/client/components/RequestDetail.tsx | 11 +- .../stats/src/client/components/StatsGrid.tsx | 13 +- packages/stats/src/client/types.ts | 2 + packages/stats/src/db.ts | 18 ++- packages/stats/src/index.ts | 5 + packages/stats/src/types.ts | 2 + 25 files changed, 517 insertions(+), 94 deletions(-) create mode 100644 packages/ai/test/models-cost.test.ts create mode 100644 packages/coding-agent/test/session-manager/usage-statistics.test.ts diff --git a/packages/ai/src/models.json b/packages/ai/src/models.json index bc7e3ad88..b154cbb20 100644 --- a/packages/ai/src/models.json +++ b/packages/ai/src/models.json @@ -3227,6 +3227,7 @@ "name": "Claude Haiku 4.5", "api": "anthropic-messages", "provider": "github-copilot", + "premiumMultiplier": 0.33, "baseUrl": "https://api.individual.githubcopilot.com", "reasoning": true, "input": [ @@ -3253,6 +3254,7 @@ "name": "Claude Opus 4.5", "api": "anthropic-messages", "provider": "github-copilot", + "premiumMultiplier": 3, "baseUrl": "https://api.individual.githubcopilot.com", "reasoning": true, "input": [ @@ -3279,6 +3281,7 @@ "name": "Claude Opus 4.6", "api": "anthropic-messages", "provider": "github-copilot", + "premiumMultiplier": 3, "baseUrl": "https://api.individual.githubcopilot.com", "reasoning": true, "input": [ @@ -3305,6 +3308,7 @@ "name": "Claude Sonnet 4", "api": "anthropic-messages", "provider": "github-copilot", + "premiumMultiplier": 1, "baseUrl": "https://api.individual.githubcopilot.com", "reasoning": true, "input": [ @@ -3331,6 +3335,7 @@ "name": "Claude Sonnet 4.5", "api": "anthropic-messages", "provider": "github-copilot", + "premiumMultiplier": 1, "baseUrl": "https://api.individual.githubcopilot.com", "reasoning": true, "input": [ @@ -3357,6 +3362,7 @@ "name": "Claude Sonnet 4.6", "api": "anthropic-messages", "provider": "github-copilot", + "premiumMultiplier": 1, "baseUrl": "https://api.individual.githubcopilot.com", "reasoning": true, "input": [ @@ -3383,6 +3389,7 @@ "name": "Gemini 2.5 Pro", "api": "openai-completions", "provider": "github-copilot", + "premiumMultiplier": 1, "baseUrl": "https://api.individual.githubcopilot.com", "reasoning": false, "input": [ @@ -3414,6 +3421,7 @@ "name": "Gemini 3 Flash", "api": "openai-completions", "provider": "github-copilot", + "premiumMultiplier": 0.33, "baseUrl": "https://api.individual.githubcopilot.com", "reasoning": true, "input": [ @@ -3445,6 +3453,7 @@ "name": "Gemini 3 Pro Preview", "api": "openai-completions", "provider": "github-copilot", + "premiumMultiplier": 1, "baseUrl": "https://api.individual.githubcopilot.com", "reasoning": true, "input": [ @@ -3476,6 +3485,7 @@ "name": "Gemini 3.1 Pro Preview", "api": "openai-completions", "provider": "github-copilot", + "premiumMultiplier": 1, "baseUrl": "https://api.individual.githubcopilot.com", "reasoning": true, "input": [ @@ -3507,6 +3517,7 @@ "name": "GPT-4.1", "api": "openai-completions", "provider": "github-copilot", + "premiumMultiplier": 0, "baseUrl": "https://api.individual.githubcopilot.com", "reasoning": false, "input": [ @@ -3538,6 +3549,7 @@ "name": "GPT-4o", "api": "openai-completions", "provider": "github-copilot", + "premiumMultiplier": 0, "baseUrl": "https://api.individual.githubcopilot.com", "reasoning": false, "input": [ @@ -3569,6 +3581,7 @@ "name": "GPT-5", "api": "openai-responses", "provider": "github-copilot", + "premiumMultiplier": 1, "baseUrl": "https://api.individual.githubcopilot.com", "reasoning": true, "input": [ @@ -3595,6 +3608,7 @@ "name": "GPT-5-mini", "api": "openai-responses", "provider": "github-copilot", + "premiumMultiplier": 0, "baseUrl": "https://api.individual.githubcopilot.com", "reasoning": true, "input": [ @@ -3621,6 +3635,7 @@ "name": "GPT-5.1", "api": "openai-responses", "provider": "github-copilot", + "premiumMultiplier": 1, "baseUrl": "https://api.individual.githubcopilot.com", "reasoning": true, "input": [ @@ -3647,6 +3662,7 @@ "name": "GPT-5.1-Codex", "api": "openai-responses", "provider": "github-copilot", + "premiumMultiplier": 1, "baseUrl": "https://api.individual.githubcopilot.com", "reasoning": true, "input": [ @@ -3673,6 +3689,7 @@ "name": "GPT-5.1-Codex-max", "api": "openai-responses", "provider": "github-copilot", + "premiumMultiplier": 1, "baseUrl": "https://api.individual.githubcopilot.com", "reasoning": true, "input": [ @@ -3699,6 +3716,7 @@ "name": "GPT-5.1-Codex-mini", "api": "openai-responses", "provider": "github-copilot", + "premiumMultiplier": 0.33, "baseUrl": "https://api.individual.githubcopilot.com", "reasoning": true, "input": [ @@ -3725,6 +3743,7 @@ "name": "GPT-5.2", "api": "openai-responses", "provider": "github-copilot", + "premiumMultiplier": 1, "baseUrl": "https://api.individual.githubcopilot.com", "reasoning": true, "input": [ @@ -3751,6 +3770,34 @@ "name": "GPT-5.2-Codex", "api": "openai-responses", "provider": "github-copilot", + "premiumMultiplier": 1, + "baseUrl": "https://api.individual.githubcopilot.com", + "reasoning": true, + "input": [ + "text", + "image" + ], + "cost": { + "input": 0, + "output": 0, + "cacheRead": 0, + "cacheWrite": 0 + }, + "contextWindow": 272000, + "maxTokens": 128000, + "headers": { + "User-Agent": "GitHubCopilotChat/0.35.0", + "Editor-Version": "vscode/1.107.0", + "Editor-Plugin-Version": "copilot-chat/0.35.0", + "Copilot-Integration-Id": "vscode-chat" + } + }, + "gpt-5.3-codex": { + "id": "gpt-5.3-codex", + "name": "GPT-5.3-Codex", + "api": "openai-responses", + "provider": "github-copilot", + "premiumMultiplier": 1, "baseUrl": "https://api.individual.githubcopilot.com", "reasoning": true, "input": [ @@ -3777,6 +3824,7 @@ "name": "Grok Code Fast 1", "api": "openai-completions", "provider": "github-copilot", + "premiumMultiplier": 0.25, "baseUrl": "https://api.individual.githubcopilot.com", "reasoning": true, "input": [ diff --git a/packages/ai/src/providers/anthropic.ts b/packages/ai/src/providers/anthropic.ts index f5ed3e38c..240b50b8b 100644 --- a/packages/ai/src/providers/anthropic.ts +++ b/packages/ai/src/providers/anthropic.ts @@ -32,11 +32,7 @@ import { isAnthropicOAuthToken, normalizeToolCallId, resolveCacheRetention } fro import { AssistantMessageEventStream } from "../utils/event-stream"; import { finalizeErrorMessage, type RawHttpRequestDump } from "../utils/http-inspector"; import { parseStreamingJson } from "../utils/json-parse"; -import { - buildCopilotDynamicHeaders, - getCopilotInitiatorOverride, - hasCopilotVisionInput, -} from "./github-copilot-headers"; +import { buildCopilotDynamicHeaders, hasCopilotVisionInput } from "./github-copilot-headers"; import { transformMessages } from "./transform-messages"; export type AnthropicHeaderOptions = { @@ -557,6 +553,15 @@ export const streamAnthropic: StreamFunction<"anthropic-messages"> = ( const startTime = Date.now(); let firstTokenTime: number | undefined; + const copilotDynamicHeaders = + model.provider === "github-copilot" + ? buildCopilotDynamicHeaders({ + messages: context.messages, + hasImages: hasCopilotVisionInput(context.messages), + premiumMultiplier: model.premiumMultiplier, + headers: { ...(model.headers ?? {}), ...(options?.headers ?? {}) }, + }) + : undefined; const output: AssistantMessage = { role: "assistant", content: [], @@ -580,20 +585,6 @@ export const streamAnthropic: StreamFunction<"anthropic-messages"> = ( const apiKey = options?.apiKey ?? getEnvApiKey(model.provider) ?? ""; const baseUrl = resolveAnthropicBaseUrl(model) ?? "https://api.anthropic.com"; - let copilotDynamicHeaders: Record | undefined; - if (model.provider === "github-copilot") { - const hasImages = hasCopilotVisionInput(context.messages); - const initiatorOverride = getCopilotInitiatorOverride({ - ...(model.headers ?? {}), - ...(options?.headers ?? {}), - }); - copilotDynamicHeaders = buildCopilotDynamicHeaders({ - messages: context.messages, - hasImages, - initiatorOverride, - }); - } - const { client, isOAuthToken } = createClient(model, { model, apiKey, @@ -601,7 +592,7 @@ export const streamAnthropic: StreamFunction<"anthropic-messages"> = ( stream: true, interleavedThinking: options?.interleavedThinking ?? true, headers: options?.headers, - dynamicHeaders: copilotDynamicHeaders, + dynamicHeaders: copilotDynamicHeaders?.headers, isOAuth: options?.isOAuth, }); const params = buildParams(model, baseUrl, context, isOAuthToken, options); @@ -625,6 +616,9 @@ export const streamAnthropic: StreamFunction<"anthropic-messages"> = ( let started = false; do { const anthropicStream = client.messages.stream({ ...params, stream: true }, { signal: options?.signal }); + if (copilotDynamicHeaders && output.usage.premiumRequests === undefined) { + output.usage.premiumRequests = copilotDynamicHeaders.premiumRequests; + } try { for await (const event of anthropicStream) { diff --git a/packages/ai/src/providers/github-copilot-headers.ts b/packages/ai/src/providers/github-copilot-headers.ts index 9a3c0d3fc..1521442da 100644 --- a/packages/ai/src/providers/github-copilot-headers.ts +++ b/packages/ai/src/providers/github-copilot-headers.ts @@ -1,10 +1,16 @@ import type { Message } from "../types"; - /** * Infer whether the current request to Copilot is user-initiated or agent-initiated. * Accepts `unknown[]` because providers may pass pre-converted message shapes. */ -export function inferCopilotInitiator(messages: unknown[]): "user" | "agent" { +export type CopilotInitiator = "user" | "agent"; +export type CopilotPremiumRequests = number; +export type CopilotDynamicHeaders = { + headers: Record; + initiator: CopilotInitiator; + premiumRequests: CopilotPremiumRequests; +}; +export function inferCopilotInitiator(messages: unknown[]): CopilotInitiator { if (messages.length === 0) return "user"; const last = messages[messages.length - 1] as Record; @@ -50,10 +56,10 @@ export function hasCopilotVisionInput(messages: Message[]): boolean { * Resolve an explicitly configured Copilot initiator header, if present. * Handles case-insensitive X-Initiator keys and returns the last valid value. */ -export function getCopilotInitiatorOverride(headers: Record | undefined): "user" | "agent" | undefined { +export function getCopilotInitiatorOverride(headers: Record | undefined): CopilotInitiator | undefined { if (!headers) return undefined; - let override: "user" | "agent" | undefined; + let override: CopilotInitiator | undefined; for (const [key, value] of Object.entries(headers)) { if (key.toLowerCase() !== "x-initiator") continue; const normalized = value.trim().toLowerCase(); @@ -64,6 +70,19 @@ export function getCopilotInitiatorOverride(headers: Record | un return override; } + +export function getCopilotPremiumMultiplier(premiumMultiplier: number | undefined): number { + return premiumMultiplier ?? 1; +} + +export function getCopilotPremiumRequests(params: { + initiator: CopilotInitiator; + premiumMultiplier?: number; +}): CopilotPremiumRequests { + if (params.initiator === "agent") return 0; + return getCopilotPremiumMultiplier(params.premiumMultiplier); +} + /** * Build dynamic Copilot headers that vary per-request. * Static headers (User-Agent, Editor-Version, etc.) come from model.headers. @@ -71,10 +90,14 @@ export function getCopilotInitiatorOverride(headers: Record | un export function buildCopilotDynamicHeaders(params: { messages: unknown[]; hasImages: boolean; - initiatorOverride?: "user" | "agent"; -}): Record { + premiumMultiplier?: number; + headers?: Record; + initiatorOverride?: CopilotInitiator; +}): CopilotDynamicHeaders { + const initiator = + params.initiatorOverride ?? getCopilotInitiatorOverride(params.headers) ?? inferCopilotInitiator(params.messages); const headers: Record = { - "X-Initiator": params.initiatorOverride ?? inferCopilotInitiator(params.messages), + "X-Initiator": initiator, "Openai-Intent": "conversation-edits", }; @@ -82,5 +105,9 @@ export function buildCopilotDynamicHeaders(params: { headers["Copilot-Vision-Request"] = "true"; } - return headers; + return { + headers, + initiator, + premiumRequests: getCopilotPremiumRequests({ initiator, premiumMultiplier: params.premiumMultiplier }), + }; } diff --git a/packages/ai/src/providers/openai-completions.ts b/packages/ai/src/providers/openai-completions.ts index bfec2f9ce..2e602bdaf 100644 --- a/packages/ai/src/providers/openai-completions.ts +++ b/packages/ai/src/providers/openai-completions.ts @@ -33,11 +33,7 @@ import { parseStreamingJson } from "../utils/json-parse"; import { getKimiCommonHeaders } from "../utils/oauth/kimi"; import { adaptSchemaForStrict, NO_STRICT } from "../utils/schema"; import { mapToOpenAICompletionsToolChoice } from "../utils/tool-choice"; -import { - buildCopilotDynamicHeaders, - getCopilotInitiatorOverride, - hasCopilotVisionInput, -} from "./github-copilot-headers"; +import { buildCopilotDynamicHeaders, hasCopilotVisionInput } from "./github-copilot-headers"; import { transformMessages } from "./transform-messages"; /** @@ -188,7 +184,7 @@ export const streamOpenAICompletions: StreamFunction<"openai-completions"> = ( try { const apiKey = options?.apiKey || getEnvApiKey(model.provider) || ""; - const client = await createClient(model, context, apiKey, options?.headers); + const { client, copilotPremiumRequests } = await createClient(model, context, apiKey, options?.headers); const params = buildParams(model, context, options); options?.onPayload?.(params); rawRequestDump = { @@ -200,6 +196,7 @@ export const streamOpenAICompletions: StreamFunction<"openai-completions"> = ( body: params, }; const openaiStream = await client.chat.completions.create(params, { signal: options?.signal }); + if (copilotPremiumRequests !== undefined) output.usage.premiumRequests = copilotPremiumRequests; stream.push({ type: "start", partial: output }); let currentBlock: TextContent | ThinkingContent | (ToolCall & { partialArgs?: string }) | null = null; @@ -340,6 +337,7 @@ export const streamOpenAICompletions: StreamFunction<"openai-completions"> = ( // Compute totalTokens ourselves since we add reasoning_tokens to output // and some providers (e.g., Groq) don't include them in total_tokens totalTokens: input + outputTokens + cachedTokens, + ...(copilotPremiumRequests !== undefined ? { premiumRequests: copilotPremiumRequests } : {}), cost: { input: 0, output: 0, @@ -510,23 +508,29 @@ async function createClient( if (model.provider === "kimi-code") { headers = { ...(await getKimiCommonHeaders()), ...headers }; } + let copilotPremiumRequests: number | undefined; if (model.provider === "github-copilot") { const hasImages = hasCopilotVisionInput(context.messages); - const copilotHeaders = buildCopilotDynamicHeaders({ + const copilot = buildCopilotDynamicHeaders({ messages: context.messages, hasImages, - initiatorOverride: getCopilotInitiatorOverride(headers), + premiumMultiplier: model.premiumMultiplier, + headers, }); - Object.assign(headers, copilotHeaders); + Object.assign(headers, copilot.headers); + copilotPremiumRequests = copilot.premiumRequests; } - return new OpenAI({ - apiKey, - baseURL: model.baseUrl, - dangerouslyAllowBrowser: true, - maxRetries: 5, - defaultHeaders: headers, - }); + return { + client: new OpenAI({ + apiKey, + baseURL: model.baseUrl, + dangerouslyAllowBrowser: true, + maxRetries: 5, + defaultHeaders: headers, + }), + copilotPremiumRequests, + }; } function buildParams(model: Model<"openai-completions">, context: Context, options?: OpenAICompletionsOptions) { diff --git a/packages/ai/src/providers/openai-responses.ts b/packages/ai/src/providers/openai-responses.ts index 60af103c2..a56301f3d 100644 --- a/packages/ai/src/providers/openai-responses.ts +++ b/packages/ai/src/providers/openai-responses.ts @@ -34,11 +34,7 @@ import { finalizeErrorMessage, type RawHttpRequestDump } from "../utils/http-ins import { parseStreamingJson } from "../utils/json-parse"; import { adaptSchemaForStrict, NO_STRICT } from "../utils/schema"; import { mapToOpenAIResponsesToolChoice } from "../utils/tool-choice"; -import { - buildCopilotDynamicHeaders, - getCopilotInitiatorOverride, - hasCopilotVisionInput, -} from "./github-copilot-headers"; +import { buildCopilotDynamicHeaders, hasCopilotVisionInput } from "./github-copilot-headers"; import { transformMessages } from "./transform-messages"; /** @@ -113,7 +109,7 @@ export const streamOpenAIResponses: StreamFunction<"openai-responses"> = ( try { // Create OpenAI client const apiKey = options?.apiKey || getEnvApiKey(model.provider) || ""; - const client = createClient(model, context, apiKey, options?.headers); + const { client, copilotPremiumRequests } = createClient(model, context, apiKey, options?.headers); const params = buildParams(model, context, options); options?.onPayload?.(params); rawRequestDump = { @@ -128,6 +124,7 @@ export const streamOpenAIResponses: StreamFunction<"openai-responses"> = ( params, options?.signal ? { signal: options.signal } : undefined, ); + if (copilotPremiumRequests !== undefined) output.usage.premiumRequests = copilotPremiumRequests; stream.push({ type: "start", partial: output }); let currentItem: ResponseReasoningItem | ResponseOutputMessage | ResponseFunctionToolCall | null = null; @@ -332,6 +329,7 @@ export const streamOpenAIResponses: StreamFunction<"openai-responses"> = ( cacheRead: cachedTokens, cacheWrite: 0, totalTokens: response.usage.total_tokens || 0, + ...(copilotPremiumRequests !== undefined ? { premiumRequests: copilotPremiumRequests } : {}), cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, }; } @@ -392,23 +390,29 @@ function createClient( } const headers = { ...(model.headers ?? {}), ...(extraHeaders ?? {}) }; + let copilotPremiumRequests: number | undefined; if (model.provider === "github-copilot") { const hasImages = hasCopilotVisionInput(context.messages); - const copilotHeaders = buildCopilotDynamicHeaders({ + const copilot = buildCopilotDynamicHeaders({ messages: context.messages, hasImages, - initiatorOverride: getCopilotInitiatorOverride(headers), + premiumMultiplier: model.premiumMultiplier, + headers, }); - Object.assign(headers, copilotHeaders); + Object.assign(headers, copilot.headers); + copilotPremiumRequests = copilot.premiumRequests; } - return new OpenAI({ - apiKey, - baseURL: model.baseUrl, - dangerouslyAllowBrowser: true, - maxRetries: 5, - defaultHeaders: headers, - }); + return { + client: new OpenAI({ + apiKey, + baseURL: model.baseUrl, + dangerouslyAllowBrowser: true, + maxRetries: 5, + defaultHeaders: headers, + }), + copilotPremiumRequests, + }; } function buildParams(model: Model<"openai-responses">, context: Context, options?: OpenAIResponsesOptions) { diff --git a/packages/ai/src/types.ts b/packages/ai/src/types.ts index feb9eb786..155abbdd4 100644 --- a/packages/ai/src/types.ts +++ b/packages/ai/src/types.ts @@ -243,6 +243,7 @@ export interface Usage { cacheRead: number; cacheWrite: number; totalTokens: number; + premiumRequests?: number; cost: { input: number; output: number; @@ -438,6 +439,8 @@ export interface Model { cacheRead: number; // $/million tokens cacheWrite: number; // $/million tokens }; + /** Premium Copilot requests charged per user-initiated request (defaults to 1). */ + premiumMultiplier?: number; contextWindow: number; maxTokens: number; headers?: Record; diff --git a/packages/ai/test/github-copilot-headers.test.ts b/packages/ai/test/github-copilot-headers.test.ts index cca75457a..dc87385c8 100644 --- a/packages/ai/test/github-copilot-headers.test.ts +++ b/packages/ai/test/github-copilot-headers.test.ts @@ -1,7 +1,9 @@ import { describe, expect, it } from "bun:test"; +import { getBundledModel } from "../src/models"; import { buildCopilotDynamicHeaders, getCopilotInitiatorOverride, + getCopilotPremiumMultiplier, hasCopilotVisionInput, inferCopilotInitiator, } from "../src/providers/github-copilot-headers"; @@ -167,28 +169,75 @@ describe("hasCopilotVisionInput", () => { }); }); -describe("buildCopilotDynamicHeaders", () => { - it("sets X-Initiator and Openai-Intent", () => { - const headers = buildCopilotDynamicHeaders({ messages: [], hasImages: false }); - expect(headers["X-Initiator"]).toBe("user"); - expect(headers["Openai-Intent"]).toBe("conversation-edits"); +describe("getCopilotPremiumMultiplier", () => { + it("returns multiplier metadata from bundled Copilot models", () => { + expect(getCopilotPremiumMultiplier(getBundledModel("github-copilot", "claude-haiku-4.5").premiumMultiplier)).toBe( + 0.33, + ); + expect(getCopilotPremiumMultiplier(getBundledModel("github-copilot", "claude-opus-4.6").premiumMultiplier)).toBe( + 3, + ); + expect(getCopilotPremiumMultiplier(getBundledModel("github-copilot", "gpt-4o").premiumMultiplier)).toBe(0); + expect(getCopilotPremiumMultiplier(getBundledModel("github-copilot", "grok-code-fast-1").premiumMultiplier)).toBe( + 0.25, + ); }); - it("preserves explicit initiator override over inferred value", () => { - const headers = buildCopilotDynamicHeaders({ + it("defaults to 1x when multiplier metadata is missing", () => { + expect(getCopilotPremiumMultiplier(undefined)).toBe(1); + }); +}); + +describe("buildCopilotDynamicHeaders", () => { + it("uses model multiplier for user-initiated requests", () => { + const { headers, premiumRequests } = buildCopilotDynamicHeaders({ + messages: [], + hasImages: false, + premiumMultiplier: 0.33, + }); + expect(headers["X-Initiator"]).toBe("user"); + expect(headers["Openai-Intent"]).toBe("conversation-edits"); + expect(premiumRequests).toBe(0.33); + }); + + it("uses 0x multiplier for included models", () => { + const { premiumRequests } = buildCopilotDynamicHeaders({ + messages: [], + hasImages: false, + premiumMultiplier: 0, + }); + expect(premiumRequests).toBe(0); + }); + + it("preserves explicit initiator override over inferred value and sets 0 premium requests for agent", () => { + const { headers, premiumRequests } = buildCopilotDynamicHeaders({ messages: [{ role: "user", content: "what time is it?" }], hasImages: false, + premiumMultiplier: 3, initiatorOverride: "agent", }); expect(headers["X-Initiator"]).toBe("agent"); - }); - it("sets Copilot-Vision-Request when hasImages is true", () => { - const headers = buildCopilotDynamicHeaders({ messages: [], hasImages: true }); - expect(headers["Copilot-Vision-Request"]).toBe("true"); + expect(headers["Openai-Intent"]).toBe("conversation-edits"); + expect(premiumRequests).toBe(0); }); - it("does not set Copilot-Vision-Request when hasImages is false", () => { - const headers = buildCopilotDynamicHeaders({ messages: [], hasImages: false }); - expect(headers["Copilot-Vision-Request"]).toBeUndefined(); + it("sets Copilot-Vision-Request when hasImages is true", () => { + const { headers, premiumRequests } = buildCopilotDynamicHeaders({ + messages: [], + hasImages: true, + premiumMultiplier: 3, + }); + expect(headers["X-Initiator"]).toBe("user"); + expect(headers["Openai-Intent"]).toBe("conversation-edits"); + expect(headers["Copilot-Vision-Request"]).toBe("true"); + expect(premiumRequests).toBe(3); + }); + + it("defaults to 1x when premium multiplier is not provided", () => { + const { premiumRequests } = buildCopilotDynamicHeaders({ + messages: [], + hasImages: false, + }); + expect(premiumRequests).toBe(1); }); }); diff --git a/packages/ai/test/models-cost.test.ts b/packages/ai/test/models-cost.test.ts new file mode 100644 index 000000000..ab8ec29cf --- /dev/null +++ b/packages/ai/test/models-cost.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it } from "bun:test"; +import { calculateCost, getBundledModel } from "../src/models"; +import type { Usage } from "../src/types"; + +describe("calculateCost", () => { + it("keeps token-based calculation for GitHub Copilot models", () => { + const model = { + ...getBundledModel("github-copilot", "gpt-4o"), + cost: { + input: 1000, + output: 2000, + cacheRead: 500, + cacheWrite: 800, + }, + }; + const usage: Usage = { + input: 1000, + output: 500, + cacheRead: 200, + cacheWrite: 100, + totalTokens: 1800, + cost: { + input: 123, + output: 456, + cacheRead: 789, + cacheWrite: 321, + total: 1689, + }, + }; + + calculateCost(model, usage); + + expect(usage.cost.input).toBeCloseTo(1, 8); + expect(usage.cost.output).toBeCloseTo(1, 8); + expect(usage.cost.cacheRead).toBeCloseTo(0.1, 8); + expect(usage.cost.cacheWrite).toBeCloseTo(0.08, 8); + expect(usage.cost.total).toBeCloseTo(2.18, 8); + }); + + it("keeps token-based calculation for non-Copilot providers", () => { + const model = { + ...getBundledModel("openai", "gpt-4o-mini"), + cost: { + input: 1000, + output: 2000, + cacheRead: 500, + cacheWrite: 800, + }, + }; + const usage: Usage = { + input: 1000, + output: 500, + cacheRead: 200, + cacheWrite: 100, + totalTokens: 1800, + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + total: 0, + }, + }; + + calculateCost(model, usage); + + expect(usage.cost.input).toBeCloseTo(1, 8); + expect(usage.cost.output).toBeCloseTo(1, 8); + expect(usage.cost.cacheRead).toBeCloseTo(0.1, 8); + expect(usage.cost.cacheWrite).toBeCloseTo(0.08, 8); + expect(usage.cost.total).toBeCloseTo(2.18, 8); + }); +}); diff --git a/packages/coding-agent/src/cli/stats-cli.ts b/packages/coding-agent/src/cli/stats-cli.ts index 1c62ef8bb..899ed8db8 100644 --- a/packages/coding-agent/src/cli/stats-cli.ts +++ b/packages/coding-agent/src/cli/stats-cli.ts @@ -59,6 +59,10 @@ function formatCost(n: number): string { return `$${n.toFixed(2)}`; } +function normalizePremiumRequests(n: number): number { + return Math.round((n + Number.EPSILON) * 100) / 100; +} + // ============================================================================= // Command Handler // ============================================================================= @@ -120,6 +124,7 @@ async function printStatsSummary(): Promise { console.log(` Total Tokens: ${formatNumber(overall.totalInputTokens + overall.totalOutputTokens)}`); console.log(` Cache Rate: ${formatPercent(overall.cacheRate)}`); console.log(` Total Cost: ${formatCost(overall.totalCost)}`); + console.log(` Premium Requests: ${formatNumber(normalizePremiumRequests(overall.totalPremiumRequests ?? 0))}`); console.log(` Avg Duration: ${overall.avgDuration !== null ? formatDuration(overall.avgDuration) : "-"}`); console.log(` Avg TTFT: ${overall.avgTtft !== null ? formatDuration(overall.avgTtft) : "-"}`); if (overall.avgTokensPerSecond !== null) { diff --git a/packages/coding-agent/src/config/model-registry.ts b/packages/coding-agent/src/config/model-registry.ts index 2b5f2ab7b..bbcb3677d 100644 --- a/packages/coding-agent/src/config/model-registry.ts +++ b/packages/coding-agent/src/config/model-registry.ts @@ -99,6 +99,7 @@ const ModelDefinitionSchema = Type.Object({ cacheWrite: Type.Number(), }), ), + premiumMultiplier: Type.Optional(Type.Number()), contextWindow: Type.Optional(Type.Number()), maxTokens: Type.Optional(Type.Number()), headers: Type.Optional(Type.Record(Type.String(), Type.String())), @@ -119,6 +120,7 @@ const ModelOverrideSchema = Type.Object({ cacheWrite: Type.Optional(Type.Number()), }), ), + premiumMultiplier: Type.Optional(Type.Number()), contextWindow: Type.Optional(Type.Number()), maxTokens: Type.Optional(Type.Number()), headers: Type.Optional(Type.Record(Type.String(), Type.String())), @@ -378,6 +380,7 @@ function applyModelOverride(model: Model, override: ModelOverride): Model; compat?: Model["compat"]; contextPromotionTarget?: string; + premiumMultiplier?: number; } interface CustomModelBuildOptions { @@ -456,6 +460,7 @@ function buildCustomModel( headers: mergeCustomModelHeaders(providerHeaders, modelDef.headers, authHeader, providerApiKey), compat: modelDef.compat, contextPromotionTarget: modelDef.contextPromotionTarget, + premiumMultiplier: modelDef.premiumMultiplier, } as Model; } @@ -1136,5 +1141,6 @@ export interface ProviderConfigInput { headers?: Record; compat?: Model["compat"]; contextPromotionTarget?: string; + premiumMultiplier?: number; }>; } diff --git a/packages/coding-agent/src/extensibility/extensions/types.ts b/packages/coding-agent/src/extensibility/extensions/types.ts index 389b4c986..464ef242c 100644 --- a/packages/coding-agent/src/extensibility/extensions/types.ts +++ b/packages/coding-agent/src/extensibility/extensions/types.ts @@ -1144,6 +1144,8 @@ export interface ProviderModelConfig { input: ("text" | "image")[]; /** Cost per million tokens. */ cost: { input: number; output: number; cacheRead: number; cacheWrite: number }; + /** Premium Copilot requests charged per user-initiated request. */ + premiumMultiplier?: number; /** Maximum context window size in tokens. */ contextWindow: number; /** Maximum output tokens. */ diff --git a/packages/coding-agent/src/modes/components/footer.ts b/packages/coding-agent/src/modes/components/footer.ts index d2cfded2e..fc014d43b 100644 --- a/packages/coding-agent/src/modes/components/footer.ts +++ b/packages/coding-agent/src/modes/components/footer.ts @@ -131,6 +131,7 @@ export class FooterComponent implements Component { let totalCacheRead = 0; let totalCacheWrite = 0; let totalCost = 0; + let totalPremiumRequests = 0; for (const entry of this.session.sessionManager.getEntries()) { if (entry.type === "message" && entry.message.role === "assistant") { @@ -139,6 +140,7 @@ export class FooterComponent implements Component { totalCacheRead += entry.message.usage.cacheRead; totalCacheWrite += entry.message.usage.cacheWrite; totalCost += entry.message.usage.cost.total; + totalPremiumRequests += entry.message.usage.premiumRequests ?? 0; } } @@ -177,11 +179,15 @@ export class FooterComponent implements Component { if (totalCacheRead) statsParts.push(`R${formatNumber(totalCacheRead)}`); if (totalCacheWrite) statsParts.push(`W${formatNumber(totalCacheWrite)}`); - // Show cost with "(sub)" indicator if using OAuth subscription + // Show billing summary with subscription and premium-request indicators const usingSubscription = state.model ? this.session.modelRegistry.isUsingOAuth(state.model) : false; - if (totalCost || usingSubscription) { - const costStr = `$${totalCost.toFixed(3)}${usingSubscription ? " (sub)" : ""}`; - statsParts.push(costStr); + const normalizedPremiumRequests = Math.round((totalPremiumRequests + Number.EPSILON) * 100) / 100; + if (totalCost || usingSubscription || normalizedPremiumRequests) { + const billingParts: string[] = []; + if (totalCost) billingParts.push(`$${totalCost.toFixed(3)}`); + if (normalizedPremiumRequests) billingParts.push(`★ ${formatNumber(normalizedPremiumRequests)}`); + if (usingSubscription) billingParts.push("(sub)"); + if (billingParts.length > 0) statsParts.push(billingParts.join(" ")); } // Colorize context percentage based on usage diff --git a/packages/coding-agent/src/modes/components/status-line.ts b/packages/coding-agent/src/modes/components/status-line.ts index 8ae35c27d..a32de4aff 100644 --- a/packages/coding-agent/src/modes/components/status-line.ts +++ b/packages/coding-agent/src/modes/components/status-line.ts @@ -290,6 +290,7 @@ export class StatusLineComponent implements Component { output: 0, cacheRead: 0, cacheWrite: 0, + premiumRequests: 0, cost: 0, }; diff --git a/packages/coding-agent/src/modes/components/status-line/segments.ts b/packages/coding-agent/src/modes/components/status-line/segments.ts index 4a979ad71..693fdd18d 100644 --- a/packages/coding-agent/src/modes/components/status-line/segments.ts +++ b/packages/coding-agent/src/modes/components/status-line/segments.ts @@ -15,6 +15,10 @@ function withIcon(icon: string, text: string): string { return icon ? `${icon} ${text}` : text; } +function normalizePremiumRequests(value: number): number { + return Math.round((value + Number.EPSILON) * 100) / 100; +} + // ═══════════════════════════════════════════════════════════════════════════ // Segment Implementations // ═══════════════════════════════════════════════════════════════════════════ @@ -202,16 +206,21 @@ const tokenTotalSegment: StatusLineSegment = { const costSegment: StatusLineSegment = { id: "cost", render(ctx) { - const { cost } = ctx.usageStats; + const { cost, premiumRequests } = ctx.usageStats; + const normalizedPremiumRequests = normalizePremiumRequests(premiumRequests); const state = ctx.session.state; const usingSubscription = state.model ? ctx.session.modelRegistry.isUsingOAuth(state.model) : false; - if (!cost && !usingSubscription) { + if (!cost && !usingSubscription && !normalizedPremiumRequests) { return { content: "", visible: false }; } - const costDisplay = usingSubscription ? "(sub)" : `$${cost.toFixed(2)}`; - return { content: theme.fg("statusLineCost", costDisplay), visible: true }; + const billingParts: string[] = []; + if (cost) billingParts.push(`$${cost.toFixed(2)}`); + if (normalizedPremiumRequests) billingParts.push(`★ ${formatNumber(normalizedPremiumRequests)}`); + if (usingSubscription) billingParts.push("(sub)"); + + return { content: theme.fg("statusLineCost", billingParts.join(" ")), visible: true }; }, }; diff --git a/packages/coding-agent/src/modes/components/status-line/types.ts b/packages/coding-agent/src/modes/components/status-line/types.ts index 5bbc1beee..89c7016e9 100644 --- a/packages/coding-agent/src/modes/components/status-line/types.ts +++ b/packages/coding-agent/src/modes/components/status-line/types.ts @@ -30,6 +30,7 @@ export interface SegmentContext { output: number; cacheRead: number; cacheWrite: number; + premiumRequests: number; cost: number; }; contextPercent: number; diff --git a/packages/coding-agent/src/modes/controllers/command-controller.ts b/packages/coding-agent/src/modes/controllers/command-controller.ts index 13c1e63a2..24a5b95e6 100644 --- a/packages/coding-agent/src/modes/controllers/command-controller.ts +++ b/packages/coding-agent/src/modes/controllers/command-controller.ts @@ -231,6 +231,11 @@ export class CommandController { async handleSessionCommand(): Promise { const stats = this.ctx.session.getSessionStats(); + const premiumRequests = + "premiumRequests" in stats && typeof stats.premiumRequests === "number" + ? stats.premiumRequests + : this.ctx.session.sessionManager.getUsageStatistics().premiumRequests; + const normalizedPremiumRequests = Math.round((premiumRequests + Number.EPSILON) * 100) / 100; let info = `${theme.bold("Session Info")}\n\n`; info += `${theme.fg("dim", "File:")} ${stats.sessionFile ?? "In-memory"}\n`; @@ -271,9 +276,14 @@ export class CommandController { } info += `${theme.fg("dim", "Total:")} ${stats.tokens.total.toLocaleString()}\n`; - if (stats.cost > 0) { + if (stats.cost > 0 || normalizedPremiumRequests > 0) { info += `\n${theme.bold("Cost")}\n`; - info += `${theme.fg("dim", "Total:")} ${stats.cost.toFixed(4)}\n`; + if (stats.cost > 0) { + info += `${theme.fg("dim", "Total:")} ${stats.cost.toFixed(4)}\n`; + } + if (normalizedPremiumRequests > 0) { + info += `${theme.fg("dim", "Premium Requests:")} ${normalizedPremiumRequests.toLocaleString()}\n`; + } } const gateway = await getGatewayStatus(); diff --git a/packages/coding-agent/src/session/agent-session.ts b/packages/coding-agent/src/session/agent-session.ts index e95394c4b..bc3045e9d 100644 --- a/packages/coding-agent/src/session/agent-session.ts +++ b/packages/coding-agent/src/session/agent-session.ts @@ -237,6 +237,7 @@ export interface SessionStats { cacheWrite: number; total: number; }; + premiumRequests: number; cost: number; } @@ -4781,6 +4782,7 @@ Be thorough - include exact file paths, function names, error messages, and tech let totalCacheWrite = 0; let totalCost = 0; + let totalPremiumRequests = 0; const getTaskToolUsage = (details: unknown): Usage | undefined => { if (!details || typeof details !== "object") return undefined; const record = details as Record; @@ -4797,6 +4799,7 @@ Be thorough - include exact file paths, function names, error messages, and tech totalOutput += assistantMsg.usage.output; totalCacheRead += assistantMsg.usage.cacheRead; totalCacheWrite += assistantMsg.usage.cacheWrite; + totalPremiumRequests += assistantMsg.usage.premiumRequests ?? 0; totalCost += assistantMsg.usage.cost.total; } @@ -4807,6 +4810,7 @@ Be thorough - include exact file paths, function names, error messages, and tech totalOutput += usage.output; totalCacheRead += usage.cacheRead; totalCacheWrite += usage.cacheWrite; + totalPremiumRequests += usage.premiumRequests ?? 0; totalCost += usage.cost.total; } } @@ -4828,6 +4832,7 @@ Be thorough - include exact file paths, function names, error messages, and tech total: totalInput + totalOutput + totalCacheRead + totalCacheWrite, }, cost: totalCost, + premiumRequests: totalPremiumRequests, }; } diff --git a/packages/coding-agent/src/session/session-manager.ts b/packages/coding-agent/src/session/session-manager.ts index e4841533e..3de749dd4 100644 --- a/packages/coding-agent/src/session/session-manager.ts +++ b/packages/coding-agent/src/session/session-manager.ts @@ -1049,6 +1049,7 @@ export interface UsageStatistics { output: number; cacheRead: number; cacheWrite: number; + premiumRequests: number; cost: number; } @@ -1144,9 +1145,16 @@ export class SessionManager { #fileEntries: FileEntry[] = []; #byId: Map = new Map(); #labelsById: Map = new Map(); - #leafId: string | null = null; - #usageStatistics: UsageStatistics = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0 }; - #persistWriter: NdjsonFileWriter | undefined; + #leafId = null as string | null; + #usageStatistics = { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + premiumRequests: 0, + cost: 0, + } satisfies UsageStatistics; + #persistWriter = undefined as NdjsonFileWriter | undefined; #persistWriterPath: string | undefined; #persistChain: Promise = Promise.resolve(); #persistError: Error | undefined; @@ -1373,7 +1381,7 @@ export class SessionManager { this.#labelsById.clear(); this.#leafId = null; this.#flushed = false; - this.#usageStatistics = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0 }; + this.#usageStatistics = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, premiumRequests: 0, cost: 0 }; if (this.persist) { const fileTimestamp = timestamp.replace(/[:.]/g, "-"); @@ -1387,7 +1395,7 @@ export class SessionManager { this.#byId.clear(); this.#labelsById.clear(); this.#leafId = null; - this.#usageStatistics = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0 }; + this.#usageStatistics = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, premiumRequests: 0, cost: 0 }; for (const entry of this.#fileEntries) { if (entry.type === "session") continue; this.#byId.set(entry.id, entry); @@ -1405,6 +1413,7 @@ export class SessionManager { this.#usageStatistics.output += usage.output; this.#usageStatistics.cacheRead += usage.cacheRead; this.#usageStatistics.cacheWrite += usage.cacheWrite; + this.#usageStatistics.premiumRequests += usage.premiumRequests ?? 0; this.#usageStatistics.cost += usage.cost.total; } @@ -1415,6 +1424,7 @@ export class SessionManager { this.#usageStatistics.output += usage.output; this.#usageStatistics.cacheRead += usage.cacheRead; this.#usageStatistics.cacheWrite += usage.cacheWrite; + this.#usageStatistics.premiumRequests += usage.premiumRequests ?? 0; this.#usageStatistics.cost += usage.cost.total; } } @@ -1679,6 +1689,7 @@ export class SessionManager { this.#usageStatistics.output += usage.output; this.#usageStatistics.cacheRead += usage.cacheRead; this.#usageStatistics.cacheWrite += usage.cacheWrite; + this.#usageStatistics.premiumRequests += usage.premiumRequests ?? 0; this.#usageStatistics.cost += usage.cost.total; } @@ -1689,6 +1700,7 @@ export class SessionManager { this.#usageStatistics.output += usage.output; this.#usageStatistics.cacheRead += usage.cacheRead; this.#usageStatistics.cacheWrite += usage.cacheWrite; + this.#usageStatistics.premiumRequests += usage.premiumRequests ?? 0; this.#usageStatistics.cost += usage.cost.total; } } diff --git a/packages/coding-agent/test/session-manager/usage-statistics.test.ts b/packages/coding-agent/test/session-manager/usage-statistics.test.ts new file mode 100644 index 000000000..dc1ddf095 --- /dev/null +++ b/packages/coding-agent/test/session-manager/usage-statistics.test.ts @@ -0,0 +1,123 @@ +import { describe, expect, it } from "bun:test"; +import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager"; + +describe("SessionManager usage statistics", () => { + it("accumulates premium requests from assistant messages and task tool results", () => { + const session = SessionManager.inMemory(); + + session.appendMessage({ role: "user", content: "hello", timestamp: 1 }); + session.appendMessage({ + role: "assistant", + content: [{ type: "text", text: "hi" }], + api: "openai-completions", + provider: "github-copilot", + model: "gpt-4o", + usage: { + input: 10, + output: 5, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 15, + premiumRequests: 1, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }, + stopReason: "stop", + timestamp: 2, + }); + session.appendMessage({ + role: "toolResult", + toolCallId: "task_1", + toolName: "task", + content: [{ type: "text", text: "task output" }], + details: { + usage: { + input: 2, + output: 3, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 5, + premiumRequests: 2, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }, + }, + isError: false, + timestamp: 3, + }); + + const usage = session.getUsageStatistics(); + expect(usage.input).toBe(12); + expect(usage.output).toBe(8); + expect(usage.premiumRequests).toBe(3); + }); + + it("preserves fractional premium request multipliers", () => { + const session = SessionManager.inMemory(); + + session.appendMessage({ role: "user", content: "hello", timestamp: 1 }); + session.appendMessage({ + role: "assistant", + content: [{ type: "text", text: "haiku" }], + api: "anthropic-messages", + provider: "github-copilot", + model: "claude-haiku-4.5", + usage: { + input: 10, + output: 5, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 15, + premiumRequests: 0.33, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }, + stopReason: "stop", + timestamp: 2, + }); + session.appendMessage({ + role: "toolResult", + toolCallId: "task_1", + toolName: "task", + content: [{ type: "text", text: "task output" }], + details: { + usage: { + input: 2, + output: 3, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 5, + premiumRequests: 3, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }, + }, + isError: false, + timestamp: 3, + }); + + const usage = session.getUsageStatistics(); + expect(usage.premiumRequests).toBeCloseTo(3.33, 8); + }); + it("defaults premium requests to zero when usage payload omits the field", () => { + const session = SessionManager.inMemory(); + + session.appendMessage({ role: "user", content: "hello", timestamp: 1 }); + session.appendMessage({ + role: "assistant", + content: [{ type: "text", text: "hi" }], + api: "openai-completions", + provider: "openai", + model: "gpt-4o", + usage: { + input: 10, + output: 5, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 15, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }, + stopReason: "stop", + timestamp: 2, + }); + + const usage = session.getUsageStatistics(); + expect(usage.premiumRequests).toBe(0); + }); +}); diff --git a/packages/stats/src/client/components/RequestDetail.tsx b/packages/stats/src/client/components/RequestDetail.tsx index 86d397b1e..73dff894c 100644 --- a/packages/stats/src/client/components/RequestDetail.tsx +++ b/packages/stats/src/client/components/RequestDetail.tsx @@ -1,4 +1,4 @@ -import { Clock, Coins, FileJson, Gauge, Hash, X, Zap } from "lucide-react"; +import { Clock, Coins, FileJson, Gauge, Hash, Star, X, Zap } from "lucide-react"; import { useEffect, useState } from "react"; import { getRequestDetails } from "../api"; import type { RequestDetails } from "../types"; @@ -93,6 +93,15 @@ export function RequestDetail({ id, onClose }: RequestDetailProps) { +
+
+ + Premium Reqs +
+
+ {(details.usage.premiumRequests ?? 0).toLocaleString()} +
+
diff --git a/packages/stats/src/client/components/StatsGrid.tsx b/packages/stats/src/client/components/StatsGrid.tsx index b02924f9d..98eedd352 100644 --- a/packages/stats/src/client/components/StatsGrid.tsx +++ b/packages/stats/src/client/components/StatsGrid.tsx @@ -1,4 +1,4 @@ -import { Activity, AlertCircle, BarChart3, Database, Server, Zap } from "lucide-react"; +import { Activity, AlertCircle, BarChart3, Database, Server, Star, Zap } from "lucide-react"; import type { AggregatedStats } from "../types"; interface StatsGridProps { @@ -24,6 +24,15 @@ const statConfig = [ getDetail: (s: AggregatedStats) => s.totalRequests > 0 ? `$${(s.totalCost / s.totalRequests).toFixed(4)} avg/req` : "-", }, + { + key: "premiumRequests", + title: "Premium Reqs", + icon: Star, + color: "var(--accent-amber)", + getValue: (s: AggregatedStats) => s.totalPremiumRequests.toLocaleString(), + getDetail: (s: AggregatedStats) => + s.totalRequests > 0 ? `${((s.totalPremiumRequests / s.totalRequests) * 100).toFixed(1)}% of requests` : "-", + }, { key: "cache", title: "Cache Rate", @@ -60,7 +69,7 @@ const statConfig = [ export function StatsGrid({ stats }: StatsGridProps) { return ( -
+
{statConfig.map(stat => { const Icon = stat.icon; return ( diff --git a/packages/stats/src/client/types.ts b/packages/stats/src/client/types.ts index 42e147df7..1b2b2c449 100644 --- a/packages/stats/src/client/types.ts +++ b/packages/stats/src/client/types.ts @@ -9,6 +9,7 @@ export interface Usage { cacheRead: number; cacheWrite: number; totalTokens: number; + premiumRequests?: number; cost: { input: number; output: number; @@ -50,6 +51,7 @@ export interface AggregatedStats { totalCacheWriteTokens: number; cacheRate: number; totalCost: number; + totalPremiumRequests: number; avgDuration: number | null; avgTtft: number | null; avgTokensPerSecond: number | null; diff --git a/packages/stats/src/db.ts b/packages/stats/src/db.ts index 5285929e3..2d1444833 100644 --- a/packages/stats/src/db.ts +++ b/packages/stats/src/db.ts @@ -47,6 +47,7 @@ export async function initDb(): Promise { cache_read_tokens INTEGER NOT NULL, cache_write_tokens INTEGER NOT NULL, total_tokens INTEGER NOT NULL, + premium_requests REAL NOT NULL, cost_input REAL NOT NULL, cost_output REAL NOT NULL, cost_cache_read REAL NOT NULL, @@ -67,6 +68,11 @@ export async function initDb(): Promise { ); `); + const messageColumns = db.prepare("PRAGMA table_info(messages)").all() as { name: string }[]; + if (!messageColumns.some(column => column.name === "premium_requests")) { + db.exec("ALTER TABLE messages ADD COLUMN premium_requests REAL NOT NULL DEFAULT 0"); + } + db.exec("UPDATE messages SET premium_requests = 0 WHERE premium_requests IS NULL"); return db; } @@ -105,9 +111,9 @@ export function insertMessageStats(stats: MessageStats[]): number { INSERT OR IGNORE INTO messages ( session_file, entry_id, folder, model, provider, api, timestamp, duration, ttft, stop_reason, error_message, - input_tokens, output_tokens, cache_read_tokens, cache_write_tokens, total_tokens, + input_tokens, output_tokens, cache_read_tokens, cache_write_tokens, total_tokens, premium_requests, cost_input, cost_output, cost_cache_read, cost_cache_write, cost_total - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `); let inserted = 0; @@ -130,6 +136,7 @@ export function insertMessageStats(stats: MessageStats[]): number { s.usage.cacheRead, s.usage.cacheWrite, s.usage.totalTokens, + s.usage.premiumRequests ?? 0, s.usage.cost.input, s.usage.cost.output, s.usage.cost.cacheRead, @@ -160,6 +167,7 @@ function buildAggregatedStats(rows: any[]): AggregatedStats { totalCacheWriteTokens: 0, cacheRate: 0, totalCost: 0, + totalPremiumRequests: 0, avgDuration: null, avgTtft: null, avgTokensPerSecond: null, @@ -174,6 +182,7 @@ function buildAggregatedStats(rows: any[]): AggregatedStats { const successfulRequests = totalRequests - failedRequests; const totalInputTokens = row.total_input_tokens || 0; const totalCacheReadTokens = row.total_cache_read_tokens || 0; + const totalPremiumRequests = row.total_premium_requests || 0; return { totalRequests, @@ -189,6 +198,7 @@ function buildAggregatedStats(rows: any[]): AggregatedStats { ? totalCacheReadTokens / (totalInputTokens + totalCacheReadTokens) : 0, totalCost: row.total_cost || 0, + totalPremiumRequests, avgDuration: row.avg_duration, avgTtft: row.avg_ttft, avgTokensPerSecond: row.avg_tokens_per_second, @@ -211,6 +221,7 @@ export function getOverallStats(): AggregatedStats { SUM(output_tokens) as total_output_tokens, SUM(cache_read_tokens) as total_cache_read_tokens, SUM(cache_write_tokens) as total_cache_write_tokens, + SUM(premium_requests) as total_premium_requests, SUM(cost_total) as total_cost, AVG(duration) as avg_duration, AVG(ttft) as avg_ttft, @@ -240,6 +251,7 @@ export function getStatsByModel(): ModelStats[] { SUM(output_tokens) as total_output_tokens, SUM(cache_read_tokens) as total_cache_read_tokens, SUM(cache_write_tokens) as total_cache_write_tokens, + SUM(premium_requests) as total_premium_requests, SUM(cost_total) as total_cost, AVG(duration) as avg_duration, AVG(ttft) as avg_ttft, @@ -274,6 +286,7 @@ export function getStatsByFolder(): FolderStats[] { SUM(output_tokens) as total_output_tokens, SUM(cache_read_tokens) as total_cache_read_tokens, SUM(cache_write_tokens) as total_cache_write_tokens, + SUM(premium_requests) as total_premium_requests, SUM(cost_total) as total_cost, AVG(duration) as avg_duration, AVG(ttft) as avg_ttft, @@ -428,6 +441,7 @@ function rowToMessageStats(row: any): MessageStats { cacheRead: row.cache_read_tokens, cacheWrite: row.cache_write_tokens, totalTokens: row.total_tokens, + premiumRequests: row.premium_requests ?? 0, cost: { input: row.cost_input, output: row.cost_output, diff --git a/packages/stats/src/index.ts b/packages/stats/src/index.ts index 4ad92a0ba..d72f05127 100755 --- a/packages/stats/src/index.ts +++ b/packages/stats/src/index.ts @@ -29,6 +29,10 @@ function formatCost(n: number): string { return `$${n.toFixed(2)}`; } +function normalizePremiumRequests(n: number): number { + return Math.round((n + Number.EPSILON) * 100) / 100; +} + /** * Print stats summary to console. */ @@ -44,6 +48,7 @@ async function printStats(): Promise { console.log(` Total Tokens: ${formatNumber(overall.totalInputTokens + overall.totalOutputTokens)}`); console.log(` Cache Rate: ${formatPercent(overall.cacheRate)}`); console.log(` Total Cost: ${formatCost(overall.totalCost)}`); + console.log(` Premium Requests: ${formatNumber(normalizePremiumRequests(overall.totalPremiumRequests ?? 0))}`); console.log(` Avg Duration: ${overall.avgDuration !== null ? formatDuration(overall.avgDuration) : "-"}`); console.log(` Avg TTFT: ${overall.avgTtft !== null ? formatDuration(overall.avgTtft) : "-"}`); if (overall.avgTokensPerSecond !== null) { diff --git a/packages/stats/src/types.ts b/packages/stats/src/types.ts index 1f341a5c8..302d10890 100644 --- a/packages/stats/src/types.ts +++ b/packages/stats/src/types.ts @@ -64,6 +64,8 @@ export interface AggregatedStats { cacheRate: number; /** Total cost */ totalCost: number; + /** Total premium requests */ + totalPremiumRequests: number; /** Average duration in ms */ avgDuration: number | null; /** Average TTFT in ms */