Merge pull request #166 from builderz-labs/fix/164-token-cost-subscription-detection

fix: correct token cost math and add subscription-aware provider detection
This commit is contained in:
nyk 2026-03-05 00:14:02 +07:00 committed by GitHub
commit 12b82168e5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 351 additions and 65 deletions

View File

@ -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<boolean> {

View File

@ -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<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 {
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<string, boolean>): 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>): TokenUsageRecord | null {
function normalizeTokenRecord(
record: Partial<TokenUsageRecord>,
providerSubscriptions: Record<string, boolean>,
): 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<TokenUsageRecord>): 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<TokenUsageRecord[]> {
async function loadTokenDataFromFile(providerSubscriptions: Record<string, boolean>): Promise<TokenUsageRecord[]> {
try {
ensureDirExists(dirname(DATA_PATH))
await access(DATA_PATH)
@ -164,7 +143,7 @@ async function loadTokenDataFromFile(): Promise<TokenUsageRecord[]> {
if (!Array.isArray(parsed)) return []
return parsed
.map((record: Partial<TokenUsageRecord>) => normalizeTokenRecord(record))
.map((record: Partial<TokenUsageRecord>) => normalizeTokenRecord(record, providerSubscriptions))
.filter((record): record is TokenUsageRecord => record !== null)
} catch {
return []
@ -175,33 +154,32 @@ async function loadTokenDataFromFile(): Promise<TokenUsageRecord[]> {
* Load token data from persistent file, falling back to deriving from session stores.
*/
async function loadTokenData(): Promise<TokenUsageRecord[]> {
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<string, boolean>): 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) {

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
}