fix: correct token costing and add provider subscription detection

This commit is contained in:
Nyk 2026-03-05 00:05:14 +07:00
parent 36d5891d85
commit 13e91d3d33
5 changed files with 351 additions and 65 deletions

View File

@ -1,6 +1,6 @@
import { NextRequest, NextResponse } from 'next/server' import { NextRequest, NextResponse } from 'next/server'
import net from 'node:net' import net from 'node:net'
import { existsSync, readFileSync, statSync } from 'node:fs' import { existsSync, statSync } from 'node:fs'
import path from 'node:path' import path from 'node:path'
import { runCommand, runOpenClaw, runClawdbot } from '@/lib/command' import { runCommand, runOpenClaw, runClawdbot } from '@/lib/command'
import { config } from '@/lib/config' import { config } from '@/lib/config'
@ -9,6 +9,7 @@ import { getAllGatewaySessions, getAgentLiveStatuses } from '@/lib/sessions'
import { requireRole } from '@/lib/auth' import { requireRole } from '@/lib/auth'
import { MODEL_CATALOG } from '@/lib/models' import { MODEL_CATALOG } from '@/lib/models'
import { logger } from '@/lib/logger' import { logger } from '@/lib/logger'
import { detectProviderSubscriptions, getPrimarySubscription } from '@/lib/provider-subscriptions'
export async function GET(request: NextRequest) { export async function GET(request: NextRequest) {
const auth = requireRole(request, 'viewer') const auth = requireRole(request, 'viewer')
@ -494,25 +495,14 @@ async function getCapabilities() {
// claude_sessions table may not exist // claude_sessions table may not exist
} }
// Detect Claude subscription type from credentials const subscriptions = detectProviderSubscriptions().active
let subscription: { type: string; rateLimitTier?: string } | null = null const primary = getPrimarySubscription()
try { const subscription = primary ? {
const credsPath = path.join(config.claudeHome, '.credentials.json') type: primary.type,
if (existsSync(credsPath)) { provider: primary.provider,
const creds = JSON.parse(readFileSync(credsPath, 'utf-8')) } : null
const oauth = creds.claudeAiOauth
if (oauth?.subscriptionType) {
subscription = {
type: oauth.subscriptionType,
rateLimitTier: oauth.rateLimitTier || undefined,
}
}
}
} catch {
// credentials file may not exist or be unreadable
}
return { gateway, openclawHome, claudeHome, claudeSessions, subscription } return { gateway, openclawHome, claudeHome, claudeSessions, subscription, subscriptions }
} }
function isPortOpen(host: string, port: number): Promise<boolean> { function isPortOpen(host: string, port: number): Promise<boolean> {

View File

@ -6,6 +6,8 @@ import { requireRole } from '@/lib/auth'
import { getAllGatewaySessions } from '@/lib/sessions' import { getAllGatewaySessions } from '@/lib/sessions'
import { logger } from '@/lib/logger' import { logger } from '@/lib/logger'
import { getDatabase } from '@/lib/db' import { getDatabase } from '@/lib/db'
import { calculateTokenCost } from '@/lib/token-pricing'
import { getProviderSubscriptionFlags } from '@/lib/provider-subscriptions'
const DATA_PATH = config.tokensPath const DATA_PATH = config.tokensPath
@ -38,23 +40,6 @@ interface ExportData {
sessions: Record<string, TokenStats> sessions: Record<string, TokenStats>
} }
// Model pricing (cost per 1K tokens)
const MODEL_PRICING: Record<string, number> = {
'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 { function extractAgentName(sessionId: string): string {
const trimmed = sessionId.trim() const trimmed = sessionId.trim()
if (!trimmed) return 'unknown' if (!trimmed) return 'unknown'
@ -62,14 +47,6 @@ function extractAgentName(sessionId: string): string {
return agent?.trim() || 'unknown' 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 { interface DbTokenUsageRow {
id: number id: number
model: string model: string
@ -79,7 +56,7 @@ interface DbTokenUsageRow {
created_at: number created_at: number
} }
function loadTokenDataFromDb(): TokenUsageRecord[] { function loadTokenDataFromDb(providerSubscriptions: Record<string, boolean>): TokenUsageRecord[] {
try { try {
const db = getDatabase() const db = getDatabase()
const rows = db.prepare(` const rows = db.prepare(`
@ -91,7 +68,6 @@ function loadTokenDataFromDb(): TokenUsageRecord[] {
return rows.map((row) => { return rows.map((row) => {
const totalTokens = row.input_tokens + row.output_tokens const totalTokens = row.input_tokens + row.output_tokens
const costPer1k = getModelCost(row.model)
return { return {
id: `db-${row.id}`, id: `db-${row.id}`,
model: row.model, model: row.model,
@ -101,7 +77,7 @@ function loadTokenDataFromDb(): TokenUsageRecord[] {
inputTokens: row.input_tokens, inputTokens: row.input_tokens,
outputTokens: row.output_tokens, outputTokens: row.output_tokens,
totalTokens, totalTokens,
cost: (totalTokens / 1000) * costPer1k, cost: calculateTokenCost(row.model, row.input_tokens, row.output_tokens, { providerSubscriptions }),
operation: 'heartbeat', operation: 'heartbeat',
} }
}) })
@ -111,7 +87,10 @@ function loadTokenDataFromDb(): TokenUsageRecord[] {
} }
} }
function normalizeTokenRecord(record: Partial<TokenUsageRecord>): TokenUsageRecord | null { function normalizeTokenRecord(
record: Partial<TokenUsageRecord>,
providerSubscriptions: Record<string, boolean>,
): TokenUsageRecord | null {
if (!record.model || !record.sessionId) return null if (!record.model || !record.sessionId) return null
const inputTokens = Number(record.inputTokens ?? 0) const inputTokens = Number(record.inputTokens ?? 0)
const outputTokens = Number(record.outputTokens ?? 0) const outputTokens = Number(record.outputTokens ?? 0)
@ -126,7 +105,7 @@ function normalizeTokenRecord(record: Partial<TokenUsageRecord>): TokenUsageReco
inputTokens, inputTokens,
outputTokens, outputTokens,
totalTokens, totalTokens,
cost: Number(record.cost ?? (totalTokens / 1000) * getModelCost(model)), cost: Number(record.cost ?? calculateTokenCost(model, inputTokens, outputTokens, { providerSubscriptions })),
operation: String(record.operation ?? 'chat_completion'), operation: String(record.operation ?? 'chat_completion'),
duration: record.duration, duration: record.duration,
} }
@ -155,7 +134,7 @@ function dedupeTokenRecords(records: TokenUsageRecord[]): TokenUsageRecord[] {
return deduped return deduped
} }
async function loadTokenDataFromFile(): Promise<TokenUsageRecord[]> { async function loadTokenDataFromFile(providerSubscriptions: Record<string, boolean>): Promise<TokenUsageRecord[]> {
try { try {
ensureDirExists(dirname(DATA_PATH)) ensureDirExists(dirname(DATA_PATH))
await access(DATA_PATH) await access(DATA_PATH)
@ -164,7 +143,7 @@ async function loadTokenDataFromFile(): Promise<TokenUsageRecord[]> {
if (!Array.isArray(parsed)) return [] if (!Array.isArray(parsed)) return []
return parsed return parsed
.map((record: Partial<TokenUsageRecord>) => normalizeTokenRecord(record)) .map((record: Partial<TokenUsageRecord>) => normalizeTokenRecord(record, providerSubscriptions))
.filter((record): record is TokenUsageRecord => record !== null) .filter((record): record is TokenUsageRecord => record !== null)
} catch { } catch {
return [] return []
@ -175,33 +154,32 @@ async function loadTokenDataFromFile(): Promise<TokenUsageRecord[]> {
* Load token data from persistent file, falling back to deriving from session stores. * Load token data from persistent file, falling back to deriving from session stores.
*/ */
async function loadTokenData(): Promise<TokenUsageRecord[]> { async function loadTokenData(): Promise<TokenUsageRecord[]> {
const dbRecords = loadTokenDataFromDb() const providerSubscriptions = getProviderSubscriptionFlags()
const fileRecords = await loadTokenDataFromFile() const dbRecords = loadTokenDataFromDb(providerSubscriptions)
const fileRecords = await loadTokenDataFromFile(providerSubscriptions)
const combined = dedupeTokenRecords([...dbRecords, ...fileRecords]).sort((a, b) => b.timestamp - a.timestamp) const combined = dedupeTokenRecords([...dbRecords, ...fileRecords]).sort((a, b) => b.timestamp - a.timestamp)
if (combined.length > 0) { if (combined.length > 0) {
return combined return combined
} }
// Final fallback: derive from in-memory sessions // Final fallback: derive from in-memory sessions
return deriveFromSessions() return deriveFromSessions(providerSubscriptions)
} }
/** /**
* Derive token usage records from OpenClaw session stores. * Derive token usage records from OpenClaw session stores.
* Each session has totalTokens, inputTokens, outputTokens, model, etc. * Each session has totalTokens, inputTokens, outputTokens, model, etc.
*/ */
function deriveFromSessions(): TokenUsageRecord[] { function deriveFromSessions(providerSubscriptions: Record<string, boolean>): TokenUsageRecord[] {
const sessions = getAllGatewaySessions(Infinity) // Get ALL sessions regardless of age const sessions = getAllGatewaySessions(Infinity) // Get ALL sessions regardless of age
const records: TokenUsageRecord[] = [] const records: TokenUsageRecord[] = []
for (const session of sessions) { for (const session of sessions) {
if (!session.totalTokens && !session.model) continue // Skip empty sessions const inputTokens = session.inputTokens || 0
const outputTokens = session.outputTokens || 0
const totalTokens = session.totalTokens || 0 const totalTokens = inputTokens + outputTokens
const inputTokens = session.inputTokens || Math.round(totalTokens * 0.7) if (totalTokens <= 0 && !session.model) continue // Skip empty sessions
const outputTokens = session.outputTokens || totalTokens - inputTokens const cost = calculateTokenCost(session.model || '', inputTokens, outputTokens, { providerSubscriptions })
const costPer1k = getModelCost(session.model || '')
const cost = (totalTokens / 1000) * costPer1k
records.push({ records.push({
id: `session-${session.agent}-${session.key}`, id: `session-${session.agent}-${session.key}`,
@ -510,8 +488,8 @@ export async function POST(request: NextRequest) {
} }
const totalTokens = inputTokens + outputTokens const totalTokens = inputTokens + outputTokens
const costPer1k = getModelCost(model) const providerSubscriptions = getProviderSubscriptionFlags()
const cost = (totalTokens / 1000) * costPer1k const cost = calculateTokenCost(model, inputTokens, outputTokens, { providerSubscriptions })
const record: TokenUsageRecord = { const record: TokenUsageRecord = {
id: `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`, 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. // Persist only manually posted usage records in the JSON file.
const existingData = await loadTokenDataFromFile() const existingData = await loadTokenDataFromFile(providerSubscriptions)
existingData.unshift(record) existingData.unshift(record)
if (existingData.length > 10000) { if (existingData.length > 10000) {

View File

@ -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')
})
})

View File

@ -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<string, ProviderSubscription>
}
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<string, unknown> | null
if (!creds || typeof creds !== 'object') return null
const oauth = creds.claudeAiOauth as Record<string, unknown> | 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<string, ProviderSubscription> {
const active: Record<string, ProviderSubscription> = {}
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<string, boolean> {
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'
}

75
src/lib/token-pricing.ts Normal file
View File

@ -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<string, ModelPricing> = {
'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<string, boolean>
}
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
}