From a85c389610b7b0fbfa9b521e890061a201ff4f2e Mon Sep 17 00:00:00 2001 From: ekko Date: Tue, 29 Sep 2026 20:56:51 +0800 Subject: [PATCH 1/8] record usage costs and support model pricing estimates --- docs/harness/usage-cost.md | 28 ++ docs/openapi.json | 242 ++++++++++++++++++ packages/client/src/api/studio/sessions.ts | 2 + .../components/hermes/usage/DailyTrend.vue | 14 +- .../src/components/hermes/usage/StatCards.vue | 13 +- .../components/hermes/usage/UsagePricing.vue | 76 ++++++ packages/client/src/i18n/locales/ar.ts | 18 ++ packages/client/src/i18n/locales/de.ts | 18 ++ packages/client/src/i18n/locales/en.ts | 18 ++ packages/client/src/i18n/locales/es.ts | 18 ++ packages/client/src/i18n/locales/fr.ts | 18 ++ packages/client/src/i18n/locales/ja.ts | 18 ++ packages/client/src/i18n/locales/ko.ts | 18 ++ packages/client/src/i18n/locales/pt.ts | 18 ++ packages/client/src/i18n/locales/ru.ts | 18 ++ packages/client/src/i18n/locales/zh-TW.ts | 18 ++ packages/client/src/i18n/locales/zh.ts | 18 ++ packages/client/src/stores/hermes/usage.ts | 1 + packages/client/src/utils/usage-cost.ts | 17 ++ .../client/src/views/hermes/UsageView.vue | 2 + packages/ekko-agent/docs/API.md | 12 +- .../ekko-agent/src/model/providers/gemini.ts | 6 +- .../src/model/providers/openai-compatible.ts | 2 + .../src/model/providers/openai-responses.ts | 2 + packages/ekko-agent/src/model/types.ts | 3 + packages/ekko-agent/src/runtime/events.ts | 2 + packages/ekko-agent/src/runtime/runtime.ts | 9 + .../protocol/adapters/responses-stream.ts | 1 + .../protocol/adapters/responses.ts | 2 + .../services/runtime/native-usage.ts | 38 ++- .../services/runtime/run-manager.ts | 5 +- .../hermes/services/history/sessions-db.ts | 26 +- .../modules/studio/contracts/runs/usage.ts | 8 + .../modules/studio/controllers/sessions.ts | 35 ++- .../studio/infrastructure/database/schemas.ts | 9 + .../server/src/modules/studio/public/usage.ts | 1 + .../repositories/usage-pricing-store.ts | 20 ++ .../studio/repositories/usage-store.ts | 50 +++- .../src/modules/studio/routes/sessions.ts | 2 + .../chat-run/handle-ekko-agent-run.ts | 2 + .../services/usage/hermes-cost-fallback.ts | 31 +++ .../studio/services/usage/usage-cost.ts | 70 +++++ .../studio/services/usage/usage-pricing.ts | 24 ++ .../studio/services/usage/usage-recorder.ts | 14 + scripts/generate-openapi.mjs | 14 + tests/client/usage-cost.test.ts | 19 ++ tests/client/usage-view-period.test.ts | 4 + tests/e2e/usage-cost.spec.ts | 40 +++ tests/ekko-agent/model-request.test.ts | 13 + tests/ekko-agent/runtime.test.ts | 3 + tests/server/native-agent-usage.test.ts | 10 +- tests/server/sessions-controller.test.ts | 5 +- tests/server/sessions-routes.test.ts | 2 + tests/server/usage-analytics-db.test.ts | 17 ++ tests/server/usage-cost.test.ts | 116 +++++++++ .../server/usage-store-agent-stats-db.test.ts | 2 + tests/server/usage-store.test.ts | 7 +- 57 files changed, 1181 insertions(+), 38 deletions(-) create mode 100644 docs/harness/usage-cost.md create mode 100644 packages/client/src/components/hermes/usage/UsagePricing.vue create mode 100644 packages/client/src/utils/usage-cost.ts create mode 100644 packages/server/src/modules/studio/repositories/usage-pricing-store.ts create mode 100644 packages/server/src/modules/studio/services/usage/hermes-cost-fallback.ts create mode 100644 packages/server/src/modules/studio/services/usage/usage-cost.ts create mode 100644 packages/server/src/modules/studio/services/usage/usage-pricing.ts create mode 100644 tests/client/usage-cost.test.ts create mode 100644 tests/e2e/usage-cost.spec.ts create mode 100644 tests/server/usage-cost.test.ts diff --git a/docs/harness/usage-cost.md b/docs/harness/usage-cost.md new file mode 100644 index 0000000000..1529351c27 --- /dev/null +++ b/docs/harness/usage-cost.md @@ -0,0 +1,28 @@ +# Usage cost accounting + +`session_usage` stores nullable `cost_usd` and `cost_source` (`reported`, +`estimated`, or `unknown`) alongside each deduplicated call/run. Missing costs +stay NULL, including pre-migration rows. Explicit provider USD zero is known +free usage; zero from an unpriced native CLI catalog is not proof of free usage. + +Studio keeps `total_cost` and daily `cost` numeric for older clients. New +`cost_coverage` counts reported, estimated and unknown records. Clients show +"Not recorded" for wholly unknown usage and label partial totals. Empty days +still show zero. A provider report is not a reconciled account invoice. + +The Usage page's Model pricing dialog stores Profile-scoped USD prices per +million ordinary input, output, cache read and cache write tokens. Provider and +model IDs must match exactly; no cross-provider model-name fallback is used. +Native runs without provider metadata use `global`. Missing cache rates leave +cached usage unpriced. Reasoning is already included in output tokens. Reported +cost wins over configured rates. Prices apply at recording time, so price edits +do not reprice historical rows or duplicate run IDs. + +Hermes token deduplication and cost recovery are separate. A native session bill +can supplement a local session only if all its local rows are unpriced and the +whole session falls inside the period. It cannot be added to a partly priced +session. Native aggregate costs retain Hermes's session-start date; for sessions +spanning days, daily coverage stays unknown where charges cannot be attributed. + +Validation: `usage-cost.test.ts`, `usage-analytics-db.test.ts`, native usage and +model adapter tests, and `tests/e2e/usage-cost.spec.ts` cover accounting and UI. diff --git a/docs/openapi.json b/docs/openapi.json index f10ae038ca..7a17268b94 100644 --- a/docs/openapi.json +++ b/docs/openapi.json @@ -23117,6 +23117,248 @@ "x-session-share-permission": "upload" } }, + "/api/studio/usage/pricing": { + "get": { + "tags": [ + "Sessions" + ], + "summary": "Get pricing", + "description": "Profile-scoped model pricing in USD per million tokens. Exact provider/model match. Applies only to future calls without reported cost; never reprices historical usage.", + "operationId": "usagePricing", + "security": [ + { + "BearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Saved pricing", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "rates" + ], + "properties": { + "rates": { + "type": "array", + "maxItems": 200, + "items": { + "type": "object", + "required": [ + "provider", + "model", + "input", + "output" + ], + "properties": { + "provider": { + "type": "string", + "minLength": 1, + "maxLength": 200 + }, + "model": { + "type": "string", + "minLength": 1, + "maxLength": 300 + }, + "input": { + "type": "number", + "minimum": 0, + "maximum": 1000000, + "description": "USD per million tokens" + }, + "output": { + "type": "number", + "minimum": 0, + "maximum": 1000000, + "description": "USD per million tokens" + }, + "cacheRead": { + "type": "number", + "minimum": 0, + "maximum": 1000000, + "description": "USD per million tokens", + "nullable": true + }, + "cacheWrite": { + "type": "number", + "minimum": 0, + "maximum": 1000000, + "description": "USD per million tokens", + "nullable": true + } + } + } + } + } + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "description": "Not found" + } + } + }, + "put": { + "tags": [ + "Sessions" + ], + "summary": "Update pricing", + "description": "Profile-scoped model pricing in USD per million tokens. Exact provider/model match. Applies only to future calls without reported cost; never reprices historical usage.", + "operationId": "updateUsagePricing", + "security": [ + { + "BearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Saved pricing", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "rates" + ], + "properties": { + "rates": { + "type": "array", + "maxItems": 200, + "items": { + "type": "object", + "required": [ + "provider", + "model", + "input", + "output" + ], + "properties": { + "provider": { + "type": "string", + "minLength": 1, + "maxLength": 200 + }, + "model": { + "type": "string", + "minLength": 1, + "maxLength": 300 + }, + "input": { + "type": "number", + "minimum": 0, + "maximum": 1000000, + "description": "USD per million tokens" + }, + "output": { + "type": "number", + "minimum": 0, + "maximum": 1000000, + "description": "USD per million tokens" + }, + "cacheRead": { + "type": "number", + "minimum": 0, + "maximum": 1000000, + "description": "USD per million tokens", + "nullable": true + }, + "cacheWrite": { + "type": "number", + "minimum": 0, + "maximum": 1000000, + "description": "USD per million tokens", + "nullable": true + } + } + } + } + } + } + } + } + }, + "400": { + "description": "Invalid or duplicate provider/model pricing" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + } + }, + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "rates" + ], + "properties": { + "rates": { + "type": "array", + "maxItems": 200, + "items": { + "type": "object", + "required": [ + "provider", + "model", + "input", + "output" + ], + "properties": { + "provider": { + "type": "string", + "minLength": 1, + "maxLength": 200 + }, + "model": { + "type": "string", + "minLength": 1, + "maxLength": 300 + }, + "input": { + "type": "number", + "minimum": 0, + "maximum": 1000000, + "description": "USD per million tokens" + }, + "output": { + "type": "number", + "minimum": 0, + "maximum": 1000000, + "description": "USD per million tokens" + }, + "cacheRead": { + "type": "number", + "minimum": 0, + "maximum": 1000000, + "description": "USD per million tokens", + "nullable": true + }, + "cacheWrite": { + "type": "number", + "minimum": 0, + "maximum": 1000000, + "description": "USD per million tokens", + "nullable": true + } + } + } + } + } + } + } + } + } + } + }, "/api/studio/usage/stats": { "get": { "tags": [ diff --git a/packages/client/src/api/studio/sessions.ts b/packages/client/src/api/studio/sessions.ts index 7c4f0f28f8..435213132b 100644 --- a/packages/client/src/api/studio/sessions.ts +++ b/packages/client/src/api/studio/sessions.ts @@ -628,6 +628,7 @@ export async function exportSession(id: string, mode: 'full' | 'compressed' = 'f } export interface UsageStatsResponse { + cost_coverage?: import('@/utils/usage-cost').UsageCostCoverage total_input_tokens: number total_output_tokens: number total_cache_read_tokens: number @@ -664,6 +665,7 @@ export interface UsageStatsResponse { sessions: number errors: number cost: number + cost_coverage?: import('@/utils/usage-cost').UsageCostCoverage }> } diff --git a/packages/client/src/components/hermes/usage/DailyTrend.vue b/packages/client/src/components/hermes/usage/DailyTrend.vue index 268d1e71a9..8fd3e98592 100644 --- a/packages/client/src/components/hermes/usage/DailyTrend.vue +++ b/packages/client/src/components/hermes/usage/DailyTrend.vue @@ -2,6 +2,7 @@ import { computed } from 'vue' import { useI18n } from 'vue-i18n' import { useUsageStore } from '@/stores/hermes/usage' +import { formatUsageCost, usageCostState, type UsageCostCoverage } from '@/utils/usage-cost' const { t } = useI18n() const usageStore = useUsageStore() @@ -12,10 +13,11 @@ function formatTokens(n: number): string { return String(n) } -function formatCost(n: number): string { - if (n === 0) return '$0.00' - if (n < 0.01) return '<$0.01' - return '$' + n.toFixed(2) +function formatCost(day: { cost: number; cost_coverage?: UsageCostCoverage; sessions: number }): string { + const amount = formatUsageCost(day.cost, day.cost_coverage, day.sessions > 0) + if (amount === null) return t('usage.costStates.unknown') + const state = usageCostState(day.cost, day.cost_coverage, day.sessions > 0) + return state ? `${amount} · ${t(`usage.costStates.${state}`)}` : amount } function cacheHitRate(d: { input_tokens: number; cache_read_tokens: number }): string { @@ -69,7 +71,7 @@ const maxTokens = computed(() =>
{{ t('usage.cacheWrite') }}: {{ formatTokens(d.cache_write_tokens) }}
{{ t('usage.cacheHitRate') }}: {{ cacheHitRate(d) }}
{{ t('usage.sessions') }}: {{ d.sessions }}
-
{{ t('usage.cost') }}: {{ formatCost(d.cost) }}
+
{{ t('usage.cost') }}: {{ formatCost(d) }}
@@ -107,7 +109,7 @@ const maxTokens = computed(() => {{ formatTokens(d.cache_write_tokens) }} {{ cacheHitRate(d) }} {{ d.sessions }} - {{ formatCost(d.cost) }} + {{ formatCost(d) }} diff --git a/packages/client/src/components/hermes/usage/StatCards.vue b/packages/client/src/components/hermes/usage/StatCards.vue index f200957f2c..bbae819bdb 100644 --- a/packages/client/src/components/hermes/usage/StatCards.vue +++ b/packages/client/src/components/hermes/usage/StatCards.vue @@ -1,9 +1,12 @@