From 17770fb1c3bdbdcf6aa824a6bf23fc5b9cae6ff2 Mon Sep 17 00:00:00 2001 From: jonisjongithub <86072337+jonisjongithub@users.noreply.github.com> Date: Tue, 27 Jan 2026 23:41:36 -0800 Subject: [PATCH] feat: add Venice API balance awareness Adds balance tracking and improved error handling for Venice AI provider: - Created src/providers/venice-balance.ts module for balance tracking - Extracts DIEM/USD/VCU balance from Venice response headers - Provides configurable warning thresholds (low: 10 DIEM, critical: 2 DIEM) - Includes fetch interceptor for automatic balance capture - Formats balance info for /status display - Enhanced error handling for Venice billing errors - Added Venice-specific patterns to billing error detection - Improved error messages with actionable guidance - Links to Venice billing settings for depleted balance - Updated /status output to show Venice balance when using Venice provider - Shows DIEM balance with status indicator (OK/LOW/CRITICAL) - Includes USD balance when available - Respects configurable thresholds - Added veniceBalanceWarning config schema for models.json: - enabled: boolean (default true) - lowDiemThreshold: number (default 10) - criticalDiemThreshold: number (default 2) - showInStatus: boolean (default true) Co-authored-by: jonisjongithub Co-authored-by: Clawdbot --- src/agents/pi-embedded-helpers.ts | 2 + src/agents/pi-embedded-helpers/errors.ts | 68 +++++ src/agents/tools/session-status-tool.ts | 16 + src/auto-reply/status.ts | 8 + src/config/zod-schema.core.ts | 16 + src/providers/venice-balance.test.ts | 268 +++++++++++++++++ src/providers/venice-balance.ts | 356 +++++++++++++++++++++++ 7 files changed, 734 insertions(+) create mode 100644 src/providers/venice-balance.test.ts create mode 100644 src/providers/venice-balance.ts diff --git a/src/agents/pi-embedded-helpers.ts b/src/agents/pi-embedded-helpers.ts index 88443756f..fc453b228 100644 --- a/src/agents/pi-embedded-helpers.ts +++ b/src/agents/pi-embedded-helpers.ts @@ -9,10 +9,12 @@ export { classifyFailoverReason, formatRawAssistantErrorForUi, formatAssistantErrorText, + formatVeniceBillingError, getApiErrorPayloadFingerprint, isAuthAssistantError, isAuthErrorMessage, isBillingAssistantError, + isVeniceBillingError, parseApiErrorInfo, sanitizeUserFacingText, isBillingErrorMessage, diff --git a/src/agents/pi-embedded-helpers/errors.ts b/src/agents/pi-embedded-helpers/errors.ts index 849c4293e..b1c1069ab 100644 --- a/src/agents/pi-embedded-helpers/errors.ts +++ b/src/agents/pi-embedded-helpers/errors.ts @@ -4,6 +4,61 @@ import type { MoltbotConfig } from "../../config/config.js"; import { formatSandboxToolPolicyBlockedMessage } from "../sandbox.js"; import type { FailoverReason } from "./types.js"; +/** + * Venice-specific billing/balance error patterns. + */ +const VENICE_BILLING_PATTERNS = [ + "insufficient balance", + "insufficient_balance", + "spending_cap_exceeded", + "spending cap", + "balance depleted", + "diem balance", +] as const; + +/** + * Check if an error message is a Venice-specific billing/balance error. + */ +export function isVeniceBillingError(errorMessage?: string): boolean { + if (!errorMessage) return false; + const lower = errorMessage.toLowerCase(); + return VENICE_BILLING_PATTERNS.some((pattern) => lower.includes(pattern)); +} + +/** + * Format a Venice billing error with helpful guidance. + */ +export function formatVeniceBillingError(errorMessage: string): string { + const lower = errorMessage.toLowerCase(); + + if (lower.includes("insufficient balance") || lower.includes("insufficient_balance")) { + return ( + "โŒ Venice API error: Insufficient DIEM balance.\n" + + "Top up at: https://venice.ai/settings/billing" + ); + } + + if (lower.includes("spending_cap_exceeded") || lower.includes("spending cap")) { + return ( + "โŒ Venice API error: API key spending cap reached.\n" + + "Increase cap in API key settings or use a different key." + ); + } + + if (lower.includes("balance depleted") || lower.includes("diem balance")) { + return ( + "โŒ Venice API error: DIEM balance depleted.\n" + + "Top up at: https://venice.ai/settings/billing" + ); + } + + // Generic Venice billing error + return ( + "โŒ Venice API billing error.\n" + + "Check your balance at: https://venice.ai/settings/billing" + ); +} + export function isContextOverflowError(errorMessage?: string): boolean { if (!errorMessage) return false; const lower = errorMessage.toLowerCase(); @@ -299,6 +354,11 @@ export function formatAssistantErrorText( return "The AI service is temporarily overloaded. Please try again in a moment."; } + // Venice-specific billing/balance error handling + if (isVeniceBillingError(raw)) { + return formatVeniceBillingError(raw); + } + if (isLikelyHttpErrorText(raw) || isRawApiErrorPayload(raw)) { return formatRawAssistantErrorForUi(raw); } @@ -371,6 +431,14 @@ const ERROR_PATTERNS = { "insufficient credits", "credit balance", "plans & billing", + // Venice-specific balance/billing errors + "insufficient balance", + "insufficient_balance", + "spending_cap_exceeded", + "spending cap", + "balance depleted", + "out of credits", + "diem balance", ], auth: [ /invalid[_ ]?api[_ ]?key/, diff --git a/src/agents/tools/session-status-tool.ts b/src/agents/tools/session-status-tool.ts index 4d47da4d7..b1abf9214 100644 --- a/src/agents/tools/session-status-tool.ts +++ b/src/agents/tools/session-status-tool.ts @@ -19,6 +19,12 @@ import { formatUserTime, resolveUserTimeFormat, resolveUserTimezone } from "../d import { normalizeGroupActivation } from "../../auto-reply/group-activation.js"; import { getFollowupQueueDepth, resolveQueueSettings } from "../../auto-reply/reply/queue.js"; import { buildStatusMessage } from "../../auto-reply/status.js"; +import { + formatVeniceBalanceStatus, + getVeniceBalance, + isVeniceProvider, + resolveVeniceBalanceThresholds, +} from "../../providers/venice-balance.js"; import type { MoltbotConfig } from "../../config/config.js"; import { loadConfig } from "../../config/config.js"; import { @@ -410,6 +416,15 @@ export function createSessionStatusTool(opts?: { typeof agentDefaults.model === "object" && agentDefaults.model ? { ...agentDefaults.model, primary: defaultLabel } : { primary: defaultLabel }; + + // Generate Venice balance line if using Venice provider + let veniceBalanceLine: string | undefined; + if (isVeniceProvider(providerForCard)) { + const balance = getVeniceBalance(); + const thresholds = resolveVeniceBalanceThresholds(cfg); + veniceBalanceLine = formatVeniceBalanceStatus(balance, thresholds) ?? undefined; + } + const statusText = buildStatusMessage({ config: cfg, agent: { @@ -427,6 +442,7 @@ export function createSessionStatusTool(opts?: { }), usageLine, timeLine, + veniceBalanceLine, queue: { mode: queueSettings.mode, depth: queueDepth, diff --git a/src/auto-reply/status.ts b/src/auto-reply/status.ts index e69941cd8..b1bc6ff59 100644 --- a/src/auto-reply/status.ts +++ b/src/auto-reply/status.ts @@ -72,6 +72,8 @@ type StatusArgs = { subagentsLine?: string; includeTranscriptUsage?: boolean; now?: number; + /** Venice balance status line (e.g., "๐Ÿ›๏ธ Venice: DIEM: 44.32 ยท Status: โœ… OK"). */ + veniceBalanceLine?: string; }; function resolveRuntimeLabel( @@ -414,6 +416,11 @@ export function buildStatusMessage(args: StatusArgs): string { const mediaLine = formatMediaUnderstandingLine(args.mediaDecisions); const voiceLine = formatVoiceModeLine(args.config, args.sessionEntry); + // Format Venice balance line with emoji prefix if present + const veniceBalanceLine = args.veniceBalanceLine + ? `๐Ÿ›๏ธ Venice: ${args.veniceBalanceLine}` + : null; + return [ versionLine, args.timeLine, @@ -422,6 +429,7 @@ export function buildStatusMessage(args: StatusArgs): string { `๐Ÿ“š ${contextLine}`, mediaLine, args.usageLine, + veniceBalanceLine, `๐Ÿงต ${sessionLine}`, args.subagentsLine, `โš™๏ธ ${optionsLine}`, diff --git a/src/config/zod-schema.core.ts b/src/config/zod-schema.core.ts index 4a8c80bcc..3fcc95fb0 100644 --- a/src/config/zod-schema.core.ts +++ b/src/config/zod-schema.core.ts @@ -72,11 +72,27 @@ export const BedrockDiscoverySchema = z .strict() .optional(); +export const VeniceBalanceWarningSchema = z + .object({ + /** Enable balance warnings (default: true). */ + enabled: z.boolean().optional(), + /** Warn when DIEM balance falls below this threshold (default: 10). */ + lowDiemThreshold: z.number().nonnegative().optional(), + /** Critical warning when DIEM balance falls below this threshold (default: 2). */ + criticalDiemThreshold: z.number().nonnegative().optional(), + /** Show balance in /status output (default: true). */ + showInStatus: z.boolean().optional(), + }) + .strict() + .optional(); + export const ModelsConfigSchema = z .object({ mode: z.union([z.literal("merge"), z.literal("replace")]).optional(), providers: z.record(z.string(), ModelProviderSchema).optional(), bedrockDiscovery: BedrockDiscoverySchema, + /** Venice-specific balance warning configuration. */ + veniceBalanceWarning: VeniceBalanceWarningSchema, }) .strict() .optional(); diff --git a/src/providers/venice-balance.test.ts b/src/providers/venice-balance.test.ts new file mode 100644 index 000000000..9f205a1c8 --- /dev/null +++ b/src/providers/venice-balance.test.ts @@ -0,0 +1,268 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { + extractVeniceBalance, + updateVeniceBalance, + getVeniceBalance, + clearVeniceBalance, + evaluateBalanceStatus, + formatVeniceBalanceStatus, + generateBalanceWarning, + isVeniceBalanceError, + formatVeniceError, + isVeniceProvider, + isVeniceApiUrl, + DEFAULT_VENICE_BALANCE_THRESHOLDS, + type VeniceBalance, +} from "./venice-balance.js"; + +describe("venice-balance", () => { + beforeEach(() => { + clearVeniceBalance(); + }); + + afterEach(() => { + clearVeniceBalance(); + }); + + describe("extractVeniceBalance", () => { + it("extracts balance from Headers object", () => { + const headers = new Headers(); + headers.set("x-venice-balance-diem", "44.32116826"); + headers.set("x-venice-balance-usd", "10.50"); + headers.set("x-venice-balance-vcu", "1000"); + + const balance = extractVeniceBalance(headers); + + expect(balance).not.toBeNull(); + expect(balance?.diem).toBeCloseTo(44.32116826); + expect(balance?.usd).toBeCloseTo(10.5); + expect(balance?.vcu).toBeCloseTo(1000); + expect(balance?.lastChecked).toBeGreaterThan(0); + }); + + it("extracts balance from plain object headers", () => { + const headers: Record = { + "x-venice-balance-diem": "25.5", + }; + + const balance = extractVeniceBalance(headers); + + expect(balance).not.toBeNull(); + expect(balance?.diem).toBeCloseTo(25.5); + expect(balance?.usd).toBeUndefined(); + }); + + it("returns null when no Venice headers present", () => { + const headers = new Headers(); + headers.set("content-type", "application/json"); + + const balance = extractVeniceBalance(headers); + + expect(balance).toBeNull(); + }); + + it("returns null for invalid DIEM value", () => { + const headers = new Headers(); + headers.set("x-venice-balance-diem", "invalid"); + + const balance = extractVeniceBalance(headers); + + expect(balance).toBeNull(); + }); + }); + + describe("updateVeniceBalance / getVeniceBalance", () => { + it("stores and retrieves balance", () => { + const balance: VeniceBalance = { + diem: 50, + usd: 20, + lastChecked: Date.now(), + }; + + updateVeniceBalance(balance); + + const retrieved = getVeniceBalance(); + expect(retrieved).toEqual(balance); + }); + + it("clearVeniceBalance clears stored balance", () => { + updateVeniceBalance({ diem: 50, lastChecked: Date.now() }); + + clearVeniceBalance(); + + expect(getVeniceBalance()).toBeNull(); + }); + }); + + describe("evaluateBalanceStatus", () => { + it("returns 'ok' for balance above low threshold", () => { + const balance: VeniceBalance = { diem: 50, lastChecked: Date.now() }; + expect(evaluateBalanceStatus(balance)).toBe("ok"); + }); + + it("returns 'low' for balance below low threshold", () => { + const balance: VeniceBalance = { diem: 5, lastChecked: Date.now() }; + expect(evaluateBalanceStatus(balance)).toBe("low"); + }); + + it("returns 'critical' for balance below critical threshold", () => { + const balance: VeniceBalance = { diem: 1, lastChecked: Date.now() }; + expect(evaluateBalanceStatus(balance)).toBe("critical"); + }); + + it("returns 'depleted' for zero balance", () => { + const balance: VeniceBalance = { diem: 0, lastChecked: Date.now() }; + expect(evaluateBalanceStatus(balance)).toBe("depleted"); + }); + + it("returns 'unknown' for null balance", () => { + expect(evaluateBalanceStatus(null)).toBe("unknown"); + }); + + it("respects custom thresholds", () => { + const balance: VeniceBalance = { diem: 15, lastChecked: Date.now() }; + const customThresholds = { + ...DEFAULT_VENICE_BALANCE_THRESHOLDS, + lowDiemThreshold: 20, + }; + expect(evaluateBalanceStatus(balance, customThresholds)).toBe("low"); + }); + }); + + describe("formatVeniceBalanceStatus", () => { + it("returns formatted status for OK balance", () => { + const balance: VeniceBalance = { diem: 50, lastChecked: Date.now() }; + const result = formatVeniceBalanceStatus(balance); + + expect(result).toContain("DIEM: 50.00"); + expect(result).toContain("โœ… OK"); + }); + + it("returns formatted status with USD when present", () => { + const balance: VeniceBalance = { diem: 50, usd: 10.5, lastChecked: Date.now() }; + const result = formatVeniceBalanceStatus(balance); + + expect(result).toContain("USD: $10.50"); + }); + + it("returns null when showInStatus is false", () => { + const balance: VeniceBalance = { diem: 50, lastChecked: Date.now() }; + const thresholds = { ...DEFAULT_VENICE_BALANCE_THRESHOLDS, showInStatus: false }; + + expect(formatVeniceBalanceStatus(balance, thresholds)).toBeNull(); + }); + + it("returns null for null balance", () => { + expect(formatVeniceBalanceStatus(null)).toBeNull(); + }); + }); + + describe("generateBalanceWarning", () => { + it("returns null for OK balance", () => { + const balance: VeniceBalance = { diem: 50, lastChecked: Date.now() }; + expect(generateBalanceWarning(balance)).toBeNull(); + }); + + it("returns warning for low balance", () => { + const balance: VeniceBalance = { diem: 5, lastChecked: Date.now() }; + const warning = generateBalanceWarning(balance); + + expect(warning).toContain("โš ๏ธ"); + expect(warning).toContain("low"); + expect(warning).toContain("5.00"); + }); + + it("returns critical warning for critical balance", () => { + const balance: VeniceBalance = { diem: 1, lastChecked: Date.now() }; + const warning = generateBalanceWarning(balance); + + expect(warning).toContain("๐Ÿšจ"); + expect(warning).toContain("critical"); + }); + + it("returns depleted warning for zero balance", () => { + const balance: VeniceBalance = { diem: 0, lastChecked: Date.now() }; + const warning = generateBalanceWarning(balance); + + expect(warning).toContain("โŒ"); + expect(warning).toContain("depleted"); + }); + + it("returns null when warnings disabled", () => { + const balance: VeniceBalance = { diem: 1, lastChecked: Date.now() }; + const thresholds = { ...DEFAULT_VENICE_BALANCE_THRESHOLDS, enabled: false }; + + expect(generateBalanceWarning(balance, thresholds)).toBeNull(); + }); + }); + + describe("isVeniceBalanceError", () => { + it("detects insufficient balance error", () => { + expect(isVeniceBalanceError("insufficient balance")).toBe(true); + expect(isVeniceBalanceError("Error: insufficient_balance")).toBe(true); + }); + + it("detects spending cap error", () => { + expect(isVeniceBalanceError("spending_cap_exceeded")).toBe(true); + expect(isVeniceBalanceError("Spending cap reached")).toBe(true); + }); + + it("returns false for non-balance errors", () => { + expect(isVeniceBalanceError("rate limit exceeded")).toBe(false); + expect(isVeniceBalanceError("connection timeout")).toBe(false); + }); + }); + + describe("formatVeniceError", () => { + it("formats insufficient balance error", () => { + const result = formatVeniceError("insufficient balance"); + + expect(result).toContain("Insufficient balance"); + expect(result).toContain("https://venice.ai/settings/billing"); + }); + + it("formats spending cap error", () => { + const result = formatVeniceError("spending_cap_exceeded"); + + expect(result).toContain("spending cap"); + expect(result).toContain("API key"); + }); + + it("returns original message for non-Venice errors", () => { + const original = "Generic error message"; + expect(formatVeniceError(original)).toBe(original); + }); + }); + + describe("isVeniceProvider", () => { + it("returns true for venice provider", () => { + expect(isVeniceProvider("venice")).toBe(true); + expect(isVeniceProvider("Venice")).toBe(true); + expect(isVeniceProvider("VENICE")).toBe(true); + }); + + it("returns true for venice/ prefixed provider", () => { + expect(isVeniceProvider("venice/llama-3.3-70b")).toBe(true); + }); + + it("returns false for other providers", () => { + expect(isVeniceProvider("openai")).toBe(false); + expect(isVeniceProvider("anthropic")).toBe(false); + }); + }); + + describe("isVeniceApiUrl", () => { + it("returns true for Venice API URLs", () => { + expect(isVeniceApiUrl("https://api.venice.ai/api/v1/chat")).toBe(true); + expect(isVeniceApiUrl(new URL("https://api.venice.ai/v1/models"))).toBe(true); + }); + + it("returns false for non-Venice URLs", () => { + expect(isVeniceApiUrl("https://api.openai.com/v1/chat")).toBe(false); + }); + + it("handles invalid URLs gracefully", () => { + expect(isVeniceApiUrl("not-a-url")).toBe(false); + }); + }); +}); diff --git a/src/providers/venice-balance.ts b/src/providers/venice-balance.ts new file mode 100644 index 000000000..74af346fb --- /dev/null +++ b/src/providers/venice-balance.ts @@ -0,0 +1,356 @@ +/** + * Venice balance tracking module. + * + * Extracts and tracks Venice API balance from response headers: + * - x-venice-balance-diem: DIEM token balance + * - x-venice-balance-usd: USD credit balance + * - x-venice-balance-vcu: VCU balance + * + * This module provides: + * - Balance extraction from response headers + * - Global balance state management + * - Warning/threshold evaluation + * - Fetch interceptor for automatic balance capture + */ + +export interface VeniceBalance { + diem?: number; + usd?: number; + vcu?: number; + lastChecked: number; + /** Provider identifier (for multi-key scenarios) */ + providerId?: string; +} + +export interface VeniceBalanceThresholds { + enabled: boolean; + lowDiemThreshold: number; + criticalDiemThreshold: number; + showInStatus: boolean; +} + +export const DEFAULT_VENICE_BALANCE_THRESHOLDS: VeniceBalanceThresholds = { + enabled: true, + lowDiemThreshold: 10, + criticalDiemThreshold: 2, + showInStatus: true, +}; + +// Singleton balance state (in-memory, cleared on restart) +let currentBalance: VeniceBalance | null = null; +let balanceUpdateCallbacks: Array<(balance: VeniceBalance) => void> = []; + +/** + * Extract Venice balance from response headers. + * Returns null if no Venice balance headers are present. + */ +export function extractVeniceBalance(headers: Headers | Record): VeniceBalance | null { + const get = (key: string): string | null => { + if (headers instanceof Headers) { + return headers.get(key); + } + // Handle plain object headers + const value = headers[key] ?? headers[key.toLowerCase()]; + return typeof value === "string" ? value : null; + }; + + const diemRaw = get("x-venice-balance-diem"); + if (!diemRaw) return null; + + const diem = parseFloat(diemRaw); + if (isNaN(diem)) return null; + + const usdRaw = get("x-venice-balance-usd"); + const vcuRaw = get("x-venice-balance-vcu"); + + return { + diem, + usd: usdRaw ? parseFloat(usdRaw) : undefined, + vcu: vcuRaw ? parseFloat(vcuRaw) : undefined, + lastChecked: Date.now(), + }; +} + +/** + * Update the current Venice balance state. + * Called by the streaming/fetch layer when Venice headers are detected. + */ +export function updateVeniceBalance(balance: VeniceBalance): void { + currentBalance = balance; + for (const callback of balanceUpdateCallbacks) { + try { + callback(balance); + } catch { + // Ignore callback errors + } + } +} + +/** + * Get the current Venice balance (if known). + */ +export function getVeniceBalance(): VeniceBalance | null { + return currentBalance; +} + +/** + * Clear the current Venice balance state. + * Useful for testing or when switching accounts. + */ +export function clearVeniceBalance(): void { + currentBalance = null; +} + +/** + * Subscribe to balance updates. + * Returns an unsubscribe function. + */ +export function onVeniceBalanceUpdate( + callback: (balance: VeniceBalance) => void, +): () => void { + balanceUpdateCallbacks.push(callback); + return () => { + balanceUpdateCallbacks = balanceUpdateCallbacks.filter((cb) => cb !== callback); + }; +} + +/** + * Evaluate balance status against thresholds. + */ +export type BalanceStatus = "ok" | "low" | "critical" | "depleted" | "unknown"; + +export function evaluateBalanceStatus( + balance: VeniceBalance | null, + thresholds: VeniceBalanceThresholds = DEFAULT_VENICE_BALANCE_THRESHOLDS, +): BalanceStatus { + if (!balance || balance.diem === undefined) return "unknown"; + if (balance.diem <= 0) return "depleted"; + if (balance.diem < thresholds.criticalDiemThreshold) return "critical"; + if (balance.diem < thresholds.lowDiemThreshold) return "low"; + return "ok"; +} + +/** + * Format balance for display in status output. + */ +export function formatVeniceBalanceStatus( + balance: VeniceBalance | null, + thresholds: VeniceBalanceThresholds = DEFAULT_VENICE_BALANCE_THRESHOLDS, +): string | null { + if (!thresholds.showInStatus) return null; + if (!balance) return null; + + const status = evaluateBalanceStatus(balance, thresholds); + const diemLabel = balance.diem !== undefined ? balance.diem.toFixed(2) : "?"; + const ageMs = Date.now() - balance.lastChecked; + const ageLabel = formatAge(ageMs); + + const statusEmoji: Record = { + ok: "โœ…", + low: "โš ๏ธ", + critical: "๐Ÿšจ", + depleted: "โŒ", + unknown: "โ“", + }; + + const parts = [ + `DIEM: ${diemLabel}`, + `Status: ${statusEmoji[status]} ${status.toUpperCase()}`, + `(${ageLabel})`, + ]; + + if (balance.usd !== undefined) { + parts.splice(1, 0, `USD: $${balance.usd.toFixed(2)}`); + } + + return parts.join(" ยท "); +} + +/** + * Generate a user-facing balance warning message. + * Returns null if balance is OK or warnings are disabled. + */ +export function generateBalanceWarning( + balance: VeniceBalance | null, + thresholds: VeniceBalanceThresholds = DEFAULT_VENICE_BALANCE_THRESHOLDS, +): string | null { + if (!thresholds.enabled) return null; + if (!balance || balance.diem === undefined) return null; + + const status = evaluateBalanceStatus(balance, thresholds); + const diemLabel = balance.diem.toFixed(2); + + switch (status) { + case "critical": + return `๐Ÿšจ Venice balance critical: ${diemLabel} DIEM remaining. Consider topping up at https://venice.ai/settings/billing`; + case "low": + return `โš ๏ธ Venice balance low: ${diemLabel} DIEM remaining`; + case "depleted": + return `โŒ Venice balance depleted (0.00 DIEM). Top up at https://venice.ai/settings/billing`; + default: + return null; + } +} + +/** + * Check if an error message indicates Venice balance/billing issues. + */ +export function isVeniceBalanceError(error: string | Error): boolean { + const message = typeof error === "string" ? error : error.message; + const lower = message.toLowerCase(); + return ( + lower.includes("insufficient balance") || + lower.includes("insufficient_balance") || + lower.includes("spending_cap_exceeded") || + lower.includes("spending cap") || + lower.includes("credit limit") || + lower.includes("out of credits") || + lower.includes("balance depleted") + ); +} + +/** + * Format a Venice-specific error with helpful guidance. + */ +export function formatVeniceError(error: string | Error): string { + const message = typeof error === "string" ? error : error.message; + const lower = message.toLowerCase(); + + if (lower.includes("insufficient balance") || lower.includes("insufficient_balance")) { + const balance = getVeniceBalance(); + const balanceNote = balance?.diem !== undefined ? ` (current: ${balance.diem.toFixed(2)} DIEM)` : ""; + return `โŒ Venice API error: Insufficient balance${balanceNote}.\nTop up at: https://venice.ai/settings/billing`; + } + + if (lower.includes("spending_cap_exceeded") || lower.includes("spending cap")) { + return `โŒ Venice API error: API key spending cap reached.\nIncrease cap in API key settings or use a different key.`; + } + + if (lower.includes("rate_limit") || lower.includes("rate limit")) { + return `โณ Venice API error: Rate limit exceeded.\nWait for reset or upgrade your plan.`; + } + + // Return original error if not a known Venice error + return message; +} + +// Helper to format age in human-readable form +function formatAge(ms: number): string { + if (ms < 0) return "unknown"; + const seconds = Math.floor(ms / 1000); + if (seconds < 60) return "just now"; + const minutes = Math.floor(seconds / 60); + if (minutes < 60) return `${minutes}m ago`; + const hours = Math.floor(minutes / 60); + if (hours < 24) return `${hours}h ago`; + const days = Math.floor(hours / 24); + return `${days}d ago`; +} + +/** + * Create a fetch wrapper that extracts Venice balance headers. + * Use this to wrap the global fetch when making Venice API calls. + */ +export function createVeniceFetchWrapper( + baseFetch: typeof fetch = globalThis.fetch, +): typeof fetch { + return async function veniceFetch( + input: RequestInfo | URL, + init?: RequestInit, + ): Promise { + const response = await baseFetch(input, init); + + // Try to extract Venice balance headers from response + const balance = extractVeniceBalance(response.headers); + if (balance) { + updateVeniceBalance(balance); + } + + return response; + }; +} + +/** + * Check if a provider string represents Venice. + */ +export function isVeniceProvider(provider: string): boolean { + const normalized = provider.toLowerCase().trim(); + return normalized === "venice" || normalized.startsWith("venice/"); +} + +/** + * Check if a URL is a Venice API endpoint. + */ +export function isVeniceApiUrl(url: string | URL | Request): boolean { + try { + const urlStr = url instanceof Request ? url.url : url.toString(); + const parsed = new URL(urlStr); + return ( + parsed.hostname === "api.venice.ai" || + parsed.hostname.endsWith(".venice.ai") + ); + } catch { + return false; + } +} + +// Track whether fetch interceptor is installed +let fetchInterceptorInstalled = false; +let originalFetch: typeof fetch | null = null; + +/** + * Install a global fetch interceptor to capture Venice balance headers. + * Safe to call multiple times - will only install once. + * + * @returns Uninstall function + */ +export function installVeniceFetchInterceptor(): () => void { + if (fetchInterceptorInstalled) { + return () => {}; // Already installed + } + + originalFetch = globalThis.fetch; + fetchInterceptorInstalled = true; + + globalThis.fetch = async function interceptedFetch( + input: RequestInfo | URL, + init?: RequestInit, + ): Promise { + const response = await originalFetch!(input, init); + + // Only process Venice API responses + if (isVeniceApiUrl(input)) { + const balance = extractVeniceBalance(response.headers); + if (balance) { + updateVeniceBalance(balance); + } + } + + return response; + }; + + return () => { + if (originalFetch && fetchInterceptorInstalled) { + globalThis.fetch = originalFetch; + originalFetch = null; + fetchInterceptorInstalled = false; + } + }; +} + +/** + * Resolve Venice balance thresholds from config. + */ +export function resolveVeniceBalanceThresholds( + config?: { models?: { veniceBalanceWarning?: Partial } }, +): VeniceBalanceThresholds { + const override = config?.models?.veniceBalanceWarning; + if (!override) return DEFAULT_VENICE_BALANCE_THRESHOLDS; + + return { + enabled: override.enabled ?? DEFAULT_VENICE_BALANCE_THRESHOLDS.enabled, + lowDiemThreshold: override.lowDiemThreshold ?? DEFAULT_VENICE_BALANCE_THRESHOLDS.lowDiemThreshold, + criticalDiemThreshold: override.criticalDiemThreshold ?? DEFAULT_VENICE_BALANCE_THRESHOLDS.criticalDiemThreshold, + showInStatus: override.showInStatus ?? DEFAULT_VENICE_BALANCE_THRESHOLDS.showInStatus, + }; +}