diff --git a/apps/supercode-cli/server/package.json b/apps/supercode-cli/server/package.json index ee0d8ab..a31a1ac 100644 --- a/apps/supercode-cli/server/package.json +++ b/apps/supercode-cli/server/package.json @@ -1,6 +1,6 @@ { "name": "supercode-cli", - "version": "0.1.86", + "version": "0.1.87", "description": "AI-powered coding agent CLI", "main": "dist/main.js", "bin": { diff --git a/apps/supercode-cli/server/src/cli/ai/concentrate-service.ts b/apps/supercode-cli/server/src/cli/ai/concentrate-service.ts index 0dddf1c..5db29fd 100644 --- a/apps/supercode-cli/server/src/cli/ai/concentrate-service.ts +++ b/apps/supercode-cli/server/src/cli/ai/concentrate-service.ts @@ -4,7 +4,7 @@ import chalk from "chalk" import { recordUsage } from "../../lib/track-usage" import { computeCost } from "../../lib/pricing" import { isEmptyToolResult, isDeniedToolResult, summarizeToolResult, tcName } from "./tool-result" -import { checkDailyOpusLimit, incrementDailyOpusCount, checkDailyTokenBudget } from "../../lib/token-budget" +import { checkDailyOpusLimit, incrementDailyOpusCount } from "../../lib/token-budget" const HIGH_VALUE_MODELS = ["anthropic/claude-fable-5", "anthropic/claude-opus-4-8", "anthropic/claude-opus-4-7", "openai/gpt-5.5"] const OPUS_MODELS = ["anthropic/claude-opus-4-8", "anthropic/claude-opus-4-7"] @@ -118,7 +118,6 @@ export class ConcentrateService { const systemMessages = messages.filter(m => m.role === "system") const nonSystemMessages = messages.filter(m => m.role !== "system") const system = systemMessages.map(m => m.content).join("\n") - await checkDailyTokenBudget() if (OPUS_MODELS.includes(this.modelName)) { await checkDailyOpusLimit() await incrementDailyOpusCount() diff --git a/apps/supercode-cli/server/src/cli/ai/server-proxy-service.ts b/apps/supercode-cli/server/src/cli/ai/server-proxy-service.ts index 2fb851f..18aac11 100644 --- a/apps/supercode-cli/server/src/cli/ai/server-proxy-service.ts +++ b/apps/supercode-cli/server/src/cli/ai/server-proxy-service.ts @@ -2,11 +2,27 @@ import { getStoredToken } from "src/lib/token" import { zodToJsonSchema } from "zod-to-json-schema" import type { ModelMessage, FinishReason, LanguageModelUsage } from "ai" import { isEmptyToolResult, isDeniedToolResult, summarizeToolResult } from "./tool-result" +import { appendProxyUsage } from "src/lib/token-budget" +import prisma from "src/lib/prisma" const BASE_URL = process.env.SUPERCODE_SERVER_URL || "https://supercode-8w7e.onrender.com" const MAX_STEPS = 8 +async function getUserIdFromToken(): Promise { + const token = await getStoredToken() + if (!token?.access_token) return null + try { + const session = await prisma.session.findUnique({ + where: { token: token.access_token as string }, + select: { userId: true }, + }) + return session?.userId ?? null + } catch { + return null + } +} + export class ServerProxyService { readonly modelName: string readonly providerName: string @@ -96,6 +112,9 @@ export class ServerProxyService { if (!res.ok) { const text = await res.text() + if (text.includes("Insufficient Funds") || text.includes("Credit usage at configured limit")) { + throw new Error("Cloud AI is warming up. Please try again in a moment.") + } throw new Error(text || "AI proxy request failed") } @@ -181,6 +200,7 @@ export class ServerProxyService { const seenStepResults: Array<{ toolName: string; result: string }> = [] const deniedCounts = new Map() const toolCallHistory: Array<{ toolName: string; argsKey: string }> = [] + const userId = await getUserIdFromToken() // Convert tools to JSON-schema definitions ONCE (while the Zod schemas // are still intact) so the server can hand valid parameter schemas to @@ -436,6 +456,19 @@ export class ServerProxyService { } } + if (userId && (usage.totalTokens ?? 0) > 0) { + appendProxyUsage({ + provider: this.providerName, + model: this.modelName, + inputTokens: usage.inputTokens ?? 0, + outputTokens: usage.outputTokens ?? 0, + cachedInputTokens: usage.inputTokenDetails?.cacheReadTokens ?? 0, + totalTokens: usage.totalTokens ?? 0, + userId, + timestamp: Date.now(), + }).catch((e) => console.error("[proxy-usage] Failed to record:", e?.message)) + } + return { content: accumulatedContent, finishReason, @@ -474,6 +507,9 @@ export class ServerProxyService { if (!res.ok) { const text = await res.text() + if (text.includes("Insufficient Funds") || text.includes("Credit usage at configured limit")) { + throw new Error("Cloud AI is warming up. Please try again in a moment.") + } throw new Error(text || "AI proxy generate-object request failed") } diff --git a/apps/supercode-cli/server/src/cli/commands/slashCommands/index.ts b/apps/supercode-cli/server/src/cli/commands/slashCommands/index.ts index a3a737d..4b9afd9 100644 --- a/apps/supercode-cli/server/src/cli/commands/slashCommands/index.ts +++ b/apps/supercode-cli/server/src/cli/commands/slashCommands/index.ts @@ -35,7 +35,7 @@ export const COMMANDS = [ { cmd: "/interact", desc: "Browser interaction via Firecrawl" }, { cmd: "/crawl", desc: "Crawl a website via Firecrawl" }, { cmd: "/parse", desc: "Parse a file (PDF, DOC, etc.) via Firecrawl" }, - { cmd: "/usage", desc: "Show daily token usage and budget for Opus 4.8" }, + { cmd: "/usage", desc: "Show your daily token usage across all models" }, { cmd: "/token-limit", desc: "Show token limits and usage per model per day" }, { cmd: "/clear", desc: "Clear current session messages" }, { cmd: "/new", desc: "Start a new conversation" }, diff --git a/apps/supercode-cli/server/src/cli/commands/slashCommands/usage.ts b/apps/supercode-cli/server/src/cli/commands/slashCommands/usage.ts index df2e4b4..bfd0737 100644 --- a/apps/supercode-cli/server/src/cli/commands/slashCommands/usage.ts +++ b/apps/supercode-cli/server/src/cli/commands/slashCommands/usage.ts @@ -1,45 +1,72 @@ import chalk from "chalk" import prisma from "src/lib/prisma" -import { theme, sectionHeader, heavyDivider, formatTokenCount } from "src/cli/utils/tui.ts" -import { getOrCreateDeviceId } from "src/lib/token-budget" - -function todayStart(): Date { - const now = new Date() - return new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate())) -} +import { + theme, + sectionHeader, + heavyDivider, + formatTokenCount, + progressBar, +} from "src/cli/utils/tui.ts" +import { getStoredToken } from "src/lib/token" +import { getUserDailyUsage } from "src/lib/token-budget" export async function usageCommand(): Promise { - const today = todayStart() - const tomorrow = new Date(today.getTime() + 86_400_000) + const token = await getStoredToken() + if (!token?.access_token) { + console.log() + console.log(heavyDivider()) + console.log() + console.log(` ${chalk.hex(theme.muted)("Not authenticated. Run /login first.")}`) + console.log() + console.log(heavyDivider()) + console.log() + return + } + + const session = await prisma.session.findUnique({ + where: { token: token.access_token as string }, + include: { user: true }, + }) + if (!session || session.expiresAt < new Date()) { + console.log() + console.log(heavyDivider()) + console.log() + console.log(` ${chalk.hex(theme.muted)("Session expired. Please re-authenticate.")}`) + console.log() + console.log(heavyDivider()) + console.log() + return + } + + const user = session.user + const usage = await getUserDailyUsage(user.id) const w = Math.min(process.stdout.columns ?? 80, 72) console.log() console.log(heavyDivider()) console.log() - console.log(sectionHeader("Usage History", { accent: "green" })) - - const allUsage = await prisma.usageEvent.groupBy({ - by: ["model"], - where: { - createdAt: { gte: new Date(Date.now() - 30 * 86_400_000) }, - }, - _sum: { totalTokens: true }, - _count: { id: true }, - }) + console.log(sectionHeader("Daily Usage", { accent: "green" })) + console.log(` ${chalk.hex(theme.muted)(`${user.email || user.name} ยท resets at midnight UTC`)}`) + console.log() - if (allUsage.length > 0) { - for (const row of allUsage) { - const tokens = row._sum.totalTokens ?? 0 - const calls = row._count.id - const modelLabel = row.model.length > 30 ? row.model.slice(0, 27) + "..." : row.model - console.log(` ${chalk.hex(theme.greenGlow)(modelLabel.padEnd(32))} ${formatTokenCount(tokens).padStart(8)} ${chalk.hex(theme.muted)(`${calls} calls`)}`) - } - } else { - console.log(` ${chalk.hex(theme.muted)("No usage recorded in the last 30 days.")}`) - } + const pct = Math.min(100, Math.round((usage.used / usage.limit) * 100)) + const color = + usage.remaining <= 10_000 ? theme.red + : usage.remaining <= 50_000 ? theme.amber + : theme.green + console.log( + ` ${chalk.hex(theme.greenGlow)("Used")} ${formatTokenCount(usage.used).padStart(8)} ${progressBar(usage.used, usage.limit, 16)}` + ) + console.log( + ` ${chalk.hex(theme.greenGlow)("Limit")} ${formatTokenCount(usage.limit).padStart(8)}` + ) + console.log( + ` ${chalk.hex(color)(chalk.hex(theme.greenGlow)("Remaining"))} ${formatTokenCount(usage.remaining).padStart(8)}` + ) console.log() + console.log(heavyDivider()) console.log() } diff --git a/apps/supercode-cli/server/src/index.ts b/apps/supercode-cli/server/src/index.ts index 9a7dfa2..551d647 100644 --- a/apps/supercode-cli/server/src/index.ts +++ b/apps/supercode-cli/server/src/index.ts @@ -6,6 +6,7 @@ import prisma from "./lib/prisma" import { loadEnvOnce } from "./lib/load-env" import { recordUsage } from "./lib/track-usage" import { computeCost } from "./lib/pricing" +import { checkDailyTokenBudget } from "./lib/token-budget" import { registerAnalyticsRoutes } from "./routes/analytics" import { transcribeAudio } from "./voice/speech" import { tmpdir } from "os" @@ -301,6 +302,11 @@ app.post("/api/ai/chat", async (req, res) => { return } + const isByok = provider === "concentrateai" && !!req.body.concentrateAiKey + if (!isByok) { + await checkDailyTokenBudget(user.id) + } + const systemMessages = messages.filter((m: any) => m.role === "system") const nonSystemMessages = messages.filter((m: any) => m.role !== "system") const system = systemMessages.map((m: any) => m.content).join("\n") @@ -333,6 +339,7 @@ app.post("/api/ai/chat", async (req, res) => { inputTokens, outputTokens, cachedInputTokens, totalTokens, costUsd: computeCost(modelName, inputTokens, outputTokens, cachedInputTokens), durationMs: Date.now() - googleStart, + userId: user.id, }) res.write(JSON.stringify({ type: "finish", reason: await result.finishReason, usage }) + "\n") res.end() @@ -441,6 +448,7 @@ app.post("/api/ai/chat", async (req, res) => { totalTokens: inputTokens + outputTokens, costUsd: computeCost(modelName, inputTokens, outputTokens, 0), durationMs: Date.now() - orStart, + userId: user.id, }) res.write(JSON.stringify({ type: "finish", reason: "stop", @@ -478,6 +486,7 @@ app.post("/api/ai/chat", async (req, res) => { inputTokens, outputTokens, cachedInputTokens, totalTokens, costUsd: computeCost(modelName, inputTokens, outputTokens, cachedInputTokens), durationMs: Date.now() - mmStart, + userId: user.id, }) res.write(JSON.stringify({ type: "finish", reason: await result.finishReason, usage }) + "\n") res.end() @@ -588,6 +597,7 @@ app.post("/api/ai/chat", async (req, res) => { totalTokens: inputTokens + outputTokens, costUsd: computeCost(modelName, inputTokens, outputTokens, 0), durationMs: Date.now() - nvidiaStart, + userId: user.id, }) res.write(JSON.stringify({ type: "finish", reason: "stop", @@ -698,6 +708,7 @@ app.post("/api/ai/chat", async (req, res) => { totalTokens: inputTokens + outputTokens, costUsd: computeCost(modelName, inputTokens, outputTokens, 0), durationMs: Date.now() - mdStart, + userId: user.id, }) res.write(JSON.stringify({ type: "finish", reason: "stop", @@ -809,6 +820,7 @@ app.post("/api/ai/chat", async (req, res) => { totalTokens: inputTokens + outputTokens, costUsd: computeCost(modelName, inputTokens, outputTokens, 0), durationMs: Date.now() - orStart, + userId: user.id, }) res.write(JSON.stringify({ type: "finish", reason: "stop", @@ -974,6 +986,7 @@ app.post("/api/ai/chat", async (req, res) => { totalTokens: inputTokens + outputTokens, costUsd: computeCost(modelName, inputTokens, outputTokens, 0), durationMs: Date.now() - caStart, + userId: user.id, }) res.write(JSON.stringify({ type: "finish", reason: "stop", @@ -1133,6 +1146,7 @@ app.post("/api/ai/chat", async (req, res) => { totalTokens: inputTokens + outputTokens, costUsd: computeCost(modelName, inputTokens, outputTokens, 0), durationMs: Date.now() - scStart, + userId: user.id, }) res.write(JSON.stringify({ type: "finish", reason: "stop", @@ -1149,6 +1163,8 @@ app.post("/api/ai/chat", async (req, res) => { const msg = String(error) if (msg.includes("insufficient balance") || msg.includes("402")) { res.status(402).json({ error: "MiniMax API: insufficient balance. Top up at https://platform.minimax.ai" }) + } else if (msg.includes("daily limit of 128K tokens")) { + res.status(429).json({ error: msg }) } else { res.status(500).json({ error: msg }) } @@ -1169,6 +1185,11 @@ app.post("/api/ai/generate-object", async (req, res) => { return } + const isByok = provider === "concentrateai" && !!req.body.concentrateAiKey + if (!isByok) { + await checkDailyTokenBudget(user.id) + } + const { generateObject } = await import("ai") switch (provider) { @@ -1327,6 +1348,8 @@ app.post("/api/ai/generate-object", async (req, res) => { const msg = String(error) if (msg.includes("insufficient balance") || msg.includes("402")) { res.status(402).json({ error: "MiniMax API: insufficient balance. Top up at https://platform.minimax.ai" }) + } else if (msg.includes("daily limit of 128K tokens")) { + res.status(429).json({ error: msg }) } else { res.status(500).json({ error: msg }) } diff --git a/apps/supercode-cli/server/src/lib/token-budget.ts b/apps/supercode-cli/server/src/lib/token-budget.ts index 6dc3a68..4cd0efc 100644 --- a/apps/supercode-cli/server/src/lib/token-budget.ts +++ b/apps/supercode-cli/server/src/lib/token-budget.ts @@ -9,6 +9,7 @@ export const OPUS_DAILY_LIMIT = 20 const DEVICE_ID_PATH = join(os.homedir(), ".config", "supercode", "device-id") const OPUS_USAGE_PATH = join(os.homedir(), ".config", "supercode", "opus-usage.json") +const PROXY_USAGE_PATH = join(os.homedir(), ".config", "supercode", "proxy-usage.json") function todayString(): string { const now = new Date() @@ -33,26 +34,37 @@ function todayStart(): Date { return new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate())) } -export async function getDailyTokenUsage(model?: string): Promise { +export async function getDailyTokenUsage(userId?: string): Promise { + let dbUsage = 0 try { const where: Record = { createdAt: { gte: todayStart() } } - if (model) where.model = model + if (userId) where.userId = userId const result = await prisma.usageEvent.aggregate({ _sum: { totalTokens: true }, where: where as any, }) - return result._sum.totalTokens ?? 0 - } catch { - return 0 + dbUsage = result._sum.totalTokens ?? 0 + } catch {} + const proxyUsage = userId ? await getProxyUsageForUser(userId) : 0 + return dbUsage + proxyUsage +} + +export async function getUserDailyUsage(userId: string): Promise<{ used: number; limit: number; remaining: number; resetAt: string }> { + const used = await getDailyTokenUsage(userId) + const resetAt = new Date(todayStart().getTime() + 86_400_000).toISOString() + return { + used, + limit: DAILY_BUDGET_TOKENS, + remaining: Math.max(0, DAILY_BUDGET_TOKENS - used), + resetAt, } } -export async function checkDailyTokenBudget(): Promise { - const used = await getDailyTokenUsage() +export async function checkDailyTokenBudget(userId?: string): Promise { + const used = await getDailyTokenUsage(userId) if (used >= DAILY_BUDGET_TOKENS) { - const maxMb = (DAILY_BUDGET_TOKENS / 1_000_000).toFixed(1) throw new Error( - `Daily token budget reached (${(used / 1_000_000).toFixed(2)}M / ${maxMb}M tokens). Resets at midnight UTC.` + "You've hit your daily limit of 128K tokens. Wait 24hrs for it to reset." ) } } @@ -81,4 +93,42 @@ export async function checkDailyOpusLimit(): Promise { } } +type ProxyUsageEvent = { + provider: string + model: string + inputTokens: number + outputTokens: number + cachedInputTokens: number + totalTokens: number + userId: string + timestamp: number +} + +export async function appendProxyUsage(event: ProxyUsageEvent): Promise { + try { + let events: ProxyUsageEvent[] = [] + try { + events = JSON.parse(await readFile(PROXY_USAGE_PATH, "utf-8")) + } catch {} + events.push(event) + // prune events older than 48h to keep file small + const cutoff = Date.now() - 86_400_000 * 2 + events = events.filter((e) => e.timestamp > cutoff) + await mkdir(join(os.homedir(), ".config", "supercode"), { recursive: true }) + await writeFile(PROXY_USAGE_PATH, JSON.stringify(events), "utf-8") + } catch {} +} + +export async function getProxyUsageForUser(userId: string): Promise { + try { + const events: ProxyUsageEvent[] = JSON.parse(await readFile(PROXY_USAGE_PATH, "utf-8")) + const today = todayStart().getTime() + return events + .filter((e) => e.userId === userId && e.timestamp >= today) + .reduce((sum, e) => sum + e.totalTokens, 0) + } catch { + return 0 + } +} +