fix: correct token costing and add provider subscription detection
This commit is contained in:
parent
36d5891d85
commit
13e91d3d33
|
|
@ -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> {
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -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'
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue