From 13e91d3d332a90d45ed2d7b218d092e4ef17c4de Mon Sep 17 00:00:00 2001 From: Nyk <0xnykcd@googlemail.com> Date: Thu, 5 Mar 2026 00:05:14 +0700 Subject: [PATCH] fix: correct token costing and add provider subscription detection --- src/app/api/status/route.ts | 28 ++-- src/app/api/tokens/route.ts | 70 +++----- src/lib/__tests__/token-pricing.test.ts | 39 +++++ src/lib/provider-subscriptions.ts | 204 ++++++++++++++++++++++++ src/lib/token-pricing.ts | 75 +++++++++ 5 files changed, 351 insertions(+), 65 deletions(-) create mode 100644 src/lib/__tests__/token-pricing.test.ts create mode 100644 src/lib/provider-subscriptions.ts create mode 100644 src/lib/token-pricing.ts diff --git a/src/app/api/status/route.ts b/src/app/api/status/route.ts index d26886b..1be21a1 100644 --- a/src/app/api/status/route.ts +++ b/src/app/api/status/route.ts @@ -1,6 +1,6 @@ import { NextRequest, NextResponse } from 'next/server' import net from 'node:net' -import { existsSync, readFileSync, statSync } from 'node:fs' +import { existsSync, statSync } from 'node:fs' import path from 'node:path' import { runCommand, runOpenClaw, runClawdbot } from '@/lib/command' import { config } from '@/lib/config' @@ -9,6 +9,7 @@ import { getAllGatewaySessions, getAgentLiveStatuses } from '@/lib/sessions' import { requireRole } from '@/lib/auth' import { MODEL_CATALOG } from '@/lib/models' import { logger } from '@/lib/logger' +import { detectProviderSubscriptions, getPrimarySubscription } from '@/lib/provider-subscriptions' export async function GET(request: NextRequest) { const auth = requireRole(request, 'viewer') @@ -494,25 +495,14 @@ async function getCapabilities() { // claude_sessions table may not exist } - // Detect Claude subscription type from credentials - let subscription: { type: string; rateLimitTier?: string } | null = null - try { - const credsPath = path.join(config.claudeHome, '.credentials.json') - if (existsSync(credsPath)) { - const creds = JSON.parse(readFileSync(credsPath, 'utf-8')) - const oauth = creds.claudeAiOauth - if (oauth?.subscriptionType) { - subscription = { - type: oauth.subscriptionType, - rateLimitTier: oauth.rateLimitTier || undefined, - } - } - } - } catch { - // credentials file may not exist or be unreadable - } + const subscriptions = detectProviderSubscriptions().active + const primary = getPrimarySubscription() + const subscription = primary ? { + type: primary.type, + provider: primary.provider, + } : null - return { gateway, openclawHome, claudeHome, claudeSessions, subscription } + return { gateway, openclawHome, claudeHome, claudeSessions, subscription, subscriptions } } function isPortOpen(host: string, port: number): Promise { diff --git a/src/app/api/tokens/route.ts b/src/app/api/tokens/route.ts index 24fefd5..bce462b 100644 --- a/src/app/api/tokens/route.ts +++ b/src/app/api/tokens/route.ts @@ -6,6 +6,8 @@ import { requireRole } from '@/lib/auth' import { getAllGatewaySessions } from '@/lib/sessions' import { logger } from '@/lib/logger' import { getDatabase } from '@/lib/db' +import { calculateTokenCost } from '@/lib/token-pricing' +import { getProviderSubscriptionFlags } from '@/lib/provider-subscriptions' const DATA_PATH = config.tokensPath @@ -38,23 +40,6 @@ interface ExportData { sessions: Record } -// Model pricing (cost per 1K tokens) -const MODEL_PRICING: Record = { - 'anthropic/claude-3-5-haiku-latest': 0.25, - 'claude-3-5-haiku': 0.25, - 'anthropic/claude-sonnet-4-20250514': 3.0, - 'claude-sonnet-4': 3.0, - 'anthropic/claude-opus-4-5': 15.0, - 'claude-opus-4-5': 15.0, - 'groq/llama-3.1-8b-instant': 0.05, - 'groq/llama-3.3-70b-versatile': 0.59, - 'moonshot/kimi-k2.5': 1.0, - 'minimax/minimax-m2.1': 0.3, - 'ollama/deepseek-r1:14b': 0.0, - 'ollama/qwen2.5-coder:7b': 0.0, - 'ollama/qwen2.5-coder:14b': 0.0, -} - function extractAgentName(sessionId: string): string { const trimmed = sessionId.trim() if (!trimmed) return 'unknown' @@ -62,14 +47,6 @@ function extractAgentName(sessionId: string): string { return agent?.trim() || 'unknown' } -function getModelCost(modelName: string): number { - if (MODEL_PRICING[modelName] !== undefined) return MODEL_PRICING[modelName] - for (const [model, cost] of Object.entries(MODEL_PRICING)) { - if (modelName.includes(model.split('/').pop() || '')) return cost - } - return 1.0 -} - interface DbTokenUsageRow { id: number model: string @@ -79,7 +56,7 @@ interface DbTokenUsageRow { created_at: number } -function loadTokenDataFromDb(): TokenUsageRecord[] { +function loadTokenDataFromDb(providerSubscriptions: Record): TokenUsageRecord[] { try { const db = getDatabase() const rows = db.prepare(` @@ -91,7 +68,6 @@ function loadTokenDataFromDb(): TokenUsageRecord[] { return rows.map((row) => { const totalTokens = row.input_tokens + row.output_tokens - const costPer1k = getModelCost(row.model) return { id: `db-${row.id}`, model: row.model, @@ -101,7 +77,7 @@ function loadTokenDataFromDb(): TokenUsageRecord[] { inputTokens: row.input_tokens, outputTokens: row.output_tokens, totalTokens, - cost: (totalTokens / 1000) * costPer1k, + cost: calculateTokenCost(row.model, row.input_tokens, row.output_tokens, { providerSubscriptions }), operation: 'heartbeat', } }) @@ -111,7 +87,10 @@ function loadTokenDataFromDb(): TokenUsageRecord[] { } } -function normalizeTokenRecord(record: Partial): TokenUsageRecord | null { +function normalizeTokenRecord( + record: Partial, + providerSubscriptions: Record, +): TokenUsageRecord | null { if (!record.model || !record.sessionId) return null const inputTokens = Number(record.inputTokens ?? 0) const outputTokens = Number(record.outputTokens ?? 0) @@ -126,7 +105,7 @@ function normalizeTokenRecord(record: Partial): TokenUsageReco inputTokens, outputTokens, totalTokens, - cost: Number(record.cost ?? (totalTokens / 1000) * getModelCost(model)), + cost: Number(record.cost ?? calculateTokenCost(model, inputTokens, outputTokens, { providerSubscriptions })), operation: String(record.operation ?? 'chat_completion'), duration: record.duration, } @@ -155,7 +134,7 @@ function dedupeTokenRecords(records: TokenUsageRecord[]): TokenUsageRecord[] { return deduped } -async function loadTokenDataFromFile(): Promise { +async function loadTokenDataFromFile(providerSubscriptions: Record): Promise { try { ensureDirExists(dirname(DATA_PATH)) await access(DATA_PATH) @@ -164,7 +143,7 @@ async function loadTokenDataFromFile(): Promise { if (!Array.isArray(parsed)) return [] return parsed - .map((record: Partial) => normalizeTokenRecord(record)) + .map((record: Partial) => normalizeTokenRecord(record, providerSubscriptions)) .filter((record): record is TokenUsageRecord => record !== null) } catch { return [] @@ -175,33 +154,32 @@ async function loadTokenDataFromFile(): Promise { * Load token data from persistent file, falling back to deriving from session stores. */ async function loadTokenData(): Promise { - const dbRecords = loadTokenDataFromDb() - const fileRecords = await loadTokenDataFromFile() + const providerSubscriptions = getProviderSubscriptionFlags() + const dbRecords = loadTokenDataFromDb(providerSubscriptions) + const fileRecords = await loadTokenDataFromFile(providerSubscriptions) const combined = dedupeTokenRecords([...dbRecords, ...fileRecords]).sort((a, b) => b.timestamp - a.timestamp) if (combined.length > 0) { return combined } // Final fallback: derive from in-memory sessions - return deriveFromSessions() + return deriveFromSessions(providerSubscriptions) } /** * Derive token usage records from OpenClaw session stores. * Each session has totalTokens, inputTokens, outputTokens, model, etc. */ -function deriveFromSessions(): TokenUsageRecord[] { +function deriveFromSessions(providerSubscriptions: Record): TokenUsageRecord[] { const sessions = getAllGatewaySessions(Infinity) // Get ALL sessions regardless of age const records: TokenUsageRecord[] = [] for (const session of sessions) { - if (!session.totalTokens && !session.model) continue // Skip empty sessions - - const totalTokens = session.totalTokens || 0 - const inputTokens = session.inputTokens || Math.round(totalTokens * 0.7) - const outputTokens = session.outputTokens || totalTokens - inputTokens - const costPer1k = getModelCost(session.model || '') - const cost = (totalTokens / 1000) * costPer1k + const inputTokens = session.inputTokens || 0 + const outputTokens = session.outputTokens || 0 + const totalTokens = inputTokens + outputTokens + if (totalTokens <= 0 && !session.model) continue // Skip empty sessions + const cost = calculateTokenCost(session.model || '', inputTokens, outputTokens, { providerSubscriptions }) records.push({ id: `session-${session.agent}-${session.key}`, @@ -510,8 +488,8 @@ export async function POST(request: NextRequest) { } const totalTokens = inputTokens + outputTokens - const costPer1k = getModelCost(model) - const cost = (totalTokens / 1000) * costPer1k + const providerSubscriptions = getProviderSubscriptionFlags() + const cost = calculateTokenCost(model, inputTokens, outputTokens, { providerSubscriptions }) const record: TokenUsageRecord = { id: `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`, @@ -528,7 +506,7 @@ export async function POST(request: NextRequest) { } // Persist only manually posted usage records in the JSON file. - const existingData = await loadTokenDataFromFile() + const existingData = await loadTokenDataFromFile(providerSubscriptions) existingData.unshift(record) if (existingData.length > 10000) { diff --git a/src/lib/__tests__/token-pricing.test.ts b/src/lib/__tests__/token-pricing.test.ts new file mode 100644 index 0000000..73de005 --- /dev/null +++ b/src/lib/__tests__/token-pricing.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from 'vitest' +import { calculateTokenCost, getModelPricing } from '@/lib/token-pricing' +import { getProviderFromModel } from '@/lib/provider-subscriptions' + +describe('token pricing', () => { + it('uses separate input/output rates for Claude Sonnet 4.5', () => { + const cost = calculateTokenCost('anthropic/claude-sonnet-4-5', 10, 185) + expect(cost).toBeCloseTo(0.002805, 9) + }) + + it('matches model aliases by short model name', () => { + const pricing = getModelPricing('gateway::claude-opus-4-6') + expect(pricing.inputPerMTok).toBe(15.0) + expect(pricing.outputPerMTok).toBe(75.0) + }) + + it('falls back to conservative default pricing for unknown models', () => { + const cost = calculateTokenCost('unknown/model', 1_000_000, 1_000_000) + expect(cost).toBe(18) + }) + + it('keeps local models at zero cost', () => { + const cost = calculateTokenCost('ollama/qwen2.5-coder:14b', 50_000, 50_000) + expect(cost).toBe(0) + }) + + it('returns zero cost for subscribed providers', () => { + const cost = calculateTokenCost('anthropic/claude-sonnet-4-5', 2000, 2000, { + providerSubscriptions: { anthropic: true }, + }) + expect(cost).toBe(0) + }) + + it('maps providers from model prefixes and names', () => { + expect(getProviderFromModel('openai/gpt-4.1')).toBe('openai') + expect(getProviderFromModel('anthropic/claude-sonnet-4-5')).toBe('anthropic') + expect(getProviderFromModel('gateway::codex-mini')).toBe('openai') + }) +}) diff --git a/src/lib/provider-subscriptions.ts b/src/lib/provider-subscriptions.ts new file mode 100644 index 0000000..91a44e6 --- /dev/null +++ b/src/lib/provider-subscriptions.ts @@ -0,0 +1,204 @@ +import { existsSync, readFileSync } from 'node:fs' +import path from 'node:path' +import os from 'node:os' +import { config } from '@/lib/config' + +interface ProviderSubscription { + provider: string + type: string + source: 'env' | 'file' +} + +interface SubscriptionDetectionResult { + active: Record +} + +const NEGATIVE_TYPES = new Set(['none', 'no', 'false', 'free', 'unknown', 'api_key', 'apikey']) + +const OPENAI_CREDENTIAL_PATHS = [ + path.join(os.homedir(), '.config', 'openai', 'auth.json'), + path.join(os.homedir(), '.openai', 'auth.json'), + path.join(os.homedir(), '.codex', 'auth.json'), +] + +let detectionCache: { ts: number; value: SubscriptionDetectionResult } | null = null +const CACHE_TTL_MS = 30_000 + +function normalizeProvider(provider: string): string { + return provider.trim().toLowerCase() +} + +function normalizeType(value: string): string { + return value.trim().toLowerCase() +} + +function isPositiveSubscription(type: string): boolean { + if (!type) return false + return !NEGATIVE_TYPES.has(normalizeType(type)) +} + +function parseJsonFile(filePath: string): unknown | null { + try { + if (!existsSync(filePath)) return null + return JSON.parse(readFileSync(filePath, 'utf-8')) + } catch { + return null + } +} + +function findNestedString(root: unknown, keys: string[]): string | null { + const queue: unknown[] = [root] + const wanted = new Set(keys.map(k => k.toLowerCase())) + + while (queue.length > 0) { + const current = queue.shift() + if (!current || typeof current !== 'object') continue + if (Array.isArray(current)) { + for (const item of current) queue.push(item) + continue + } + + for (const [rawKey, value] of Object.entries(current)) { + const key = rawKey.toLowerCase() + if (wanted.has(key) && typeof value === 'string' && value.trim()) { + return value.trim() + } + if (value && typeof value === 'object') queue.push(value) + } + } + + return null +} + +function detectAnthropicFromFile(): ProviderSubscription | null { + const credsPath = path.join(config.claudeHome, '.credentials.json') + const creds = parseJsonFile(credsPath) as Record | null + if (!creds || typeof creds !== 'object') return null + + const oauth = creds.claudeAiOauth as Record | undefined + const subscriptionType = typeof oauth?.subscriptionType === 'string' ? oauth.subscriptionType : '' + if (!isPositiveSubscription(subscriptionType)) return null + + return { + provider: 'anthropic', + type: normalizeType(subscriptionType), + source: 'file', + } +} + +function detectOpenAIFromFile(): ProviderSubscription | null { + for (const credsPath of OPENAI_CREDENTIAL_PATHS) { + const creds = parseJsonFile(credsPath) + if (!creds) continue + const plan = findNestedString(creds, [ + 'subscriptionType', + 'subscription_type', + 'accountPlan', + 'account_plan', + 'plan', + 'tier', + ]) + if (!plan || !isPositiveSubscription(plan)) continue + return { + provider: 'openai', + type: normalizeType(plan), + source: 'file', + } + } + return null +} + +function detectFromEnv(): Record { + const active: Record = {} + + const allProvidersRaw = process.env.MC_SUBSCRIBED_PROVIDERS || '' + if (allProvidersRaw.trim()) { + for (const raw of allProvidersRaw.split(',')) { + const provider = normalizeProvider(raw) + if (!provider) continue + active[provider] = { + provider, + type: 'subscription', + source: 'env', + } + } + } + + for (const [key, value] of Object.entries(process.env)) { + if (!value) continue + + const explicitMatch = key.match(/^MC_([A-Z0-9_]+)_SUBSCRIPTION(?:_TYPE)?$/) + if (explicitMatch) { + const provider = normalizeProvider(explicitMatch[1].replace(/_/g, '-')) + const type = normalizeType(value) + if (isPositiveSubscription(type)) { + active[provider] = { provider, type, source: 'env' } + } else { + delete active[provider] + } + continue + } + + const providerMatch = key.match(/^([A-Z0-9_]+)_SUBSCRIPTION_TYPE$/) + if (providerMatch) { + const provider = normalizeProvider(providerMatch[1].replace(/_/g, '-')) + const type = normalizeType(value) + if (isPositiveSubscription(type)) { + active[provider] = { provider, type, source: 'env' } + } else { + delete active[provider] + } + } + } + + return active +} + +export function detectProviderSubscriptions(forceRefresh = false): SubscriptionDetectionResult { + const now = Date.now() + if (!forceRefresh && detectionCache && (now - detectionCache.ts) < CACHE_TTL_MS) { + return detectionCache.value + } + + const active = detectFromEnv() + + const anthropic = detectAnthropicFromFile() + if (anthropic) active.anthropic = anthropic + + const openai = detectOpenAIFromFile() + if (openai) active.openai = openai + + const value = { active } + detectionCache = { ts: now, value } + return value +} + +export function getProviderSubscriptionFlags(forceRefresh = false): Record { + const detected = detectProviderSubscriptions(forceRefresh) + return Object.fromEntries( + Object.keys(detected.active).map((provider) => [provider, true]) + ) +} + +export function getPrimarySubscription(forceRefresh = false): ProviderSubscription | null { + const detected = detectProviderSubscriptions(forceRefresh).active + return detected.anthropic || detected.openai || Object.values(detected)[0] || null +} + +export function getProviderFromModel(modelName: string): string { + const normalized = modelName.trim().toLowerCase() + if (!normalized) return 'unknown' + + const [prefix] = normalized.split('/') + if (prefix && !prefix.includes(':')) { + // Most models are provider-prefixed, e.g., "anthropic/claude-sonnet-4-5". + if (prefix === 'claude') return 'anthropic' + if (prefix === 'gpt' || prefix === 'o1' || prefix === 'o3') return 'openai' + return prefix + } + + if (normalized.includes('claude')) return 'anthropic' + if (normalized.includes('gpt') || normalized.includes('codex') || normalized.includes('o1') || normalized.includes('o3')) return 'openai' + return 'unknown' +} + diff --git a/src/lib/token-pricing.ts b/src/lib/token-pricing.ts new file mode 100644 index 0000000..fc7a0b4 --- /dev/null +++ b/src/lib/token-pricing.ts @@ -0,0 +1,75 @@ +import { getProviderFromModel } from '@/lib/provider-subscriptions' + +interface ModelPricing { + inputPerMTok: number + outputPerMTok: number +} + +const DEFAULT_MODEL_PRICING: ModelPricing = { + inputPerMTok: 3.0, + outputPerMTok: 15.0, +} + +const MODEL_PRICING: Record = { + 'anthropic/claude-3-5-haiku-latest': { inputPerMTok: 0.8, outputPerMTok: 4.0 }, + 'claude-3-5-haiku': { inputPerMTok: 0.8, outputPerMTok: 4.0 }, + 'anthropic/claude-haiku-4-5': { inputPerMTok: 0.8, outputPerMTok: 4.0 }, + 'claude-haiku-4-5': { inputPerMTok: 0.8, outputPerMTok: 4.0 }, + + 'anthropic/claude-sonnet-4-20250514': { inputPerMTok: 3.0, outputPerMTok: 15.0 }, + 'claude-sonnet-4': { inputPerMTok: 3.0, outputPerMTok: 15.0 }, + 'anthropic/claude-sonnet-4-5': { inputPerMTok: 3.0, outputPerMTok: 15.0 }, + 'claude-sonnet-4-5': { inputPerMTok: 3.0, outputPerMTok: 15.0 }, + 'anthropic/claude-sonnet-4-6': { inputPerMTok: 3.0, outputPerMTok: 15.0 }, + 'claude-sonnet-4-6': { inputPerMTok: 3.0, outputPerMTok: 15.0 }, + + 'anthropic/claude-opus-4-5': { inputPerMTok: 15.0, outputPerMTok: 75.0 }, + 'claude-opus-4-5': { inputPerMTok: 15.0, outputPerMTok: 75.0 }, + 'anthropic/claude-opus-4-6': { inputPerMTok: 15.0, outputPerMTok: 75.0 }, + 'claude-opus-4-6': { inputPerMTok: 15.0, outputPerMTok: 75.0 }, + + // For non-Anthropic models where we only have one published blended estimate, + // apply the same rate for both input and output. + 'groq/llama-3.1-8b-instant': { inputPerMTok: 0.05, outputPerMTok: 0.05 }, + 'groq/llama-3.3-70b-versatile': { inputPerMTok: 0.59, outputPerMTok: 0.59 }, + 'moonshot/kimi-k2.5': { inputPerMTok: 1.0, outputPerMTok: 1.0 }, + 'minimax/minimax-m2.1': { inputPerMTok: 0.3, outputPerMTok: 0.3 }, + 'ollama/deepseek-r1:14b': { inputPerMTok: 0.0, outputPerMTok: 0.0 }, + 'ollama/qwen2.5-coder:7b': { inputPerMTok: 0.0, outputPerMTok: 0.0 }, + 'ollama/qwen2.5-coder:14b': { inputPerMTok: 0.0, outputPerMTok: 0.0 }, +} + +function normalizedModelName(modelName: string): string { + return modelName.trim().toLowerCase() +} + +export function getModelPricing(modelName: string): ModelPricing { + const normalized = normalizedModelName(modelName) + if (MODEL_PRICING[normalized] !== undefined) return MODEL_PRICING[normalized] + + for (const [model, pricing] of Object.entries(MODEL_PRICING)) { + const shortName = model.split('/').pop() || model + if (normalized.includes(shortName)) return pricing + } + + return DEFAULT_MODEL_PRICING +} + +interface CostOptions { + providerSubscriptions?: Record +} + +export function calculateTokenCost( + modelName: string, + inputTokens: number, + outputTokens: number, + options?: CostOptions, +): number { + const provider = getProviderFromModel(modelName) + if (provider !== 'unknown' && options?.providerSubscriptions?.[provider]) { + return 0 + } + + const pricing = getModelPricing(modelName) + return ((inputTokens * pricing.inputPerMTok) + (outputTokens * pricing.outputPerMTok)) / 1_000_000 +}