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 <jonisjongithub@users.noreply.github.com> Co-authored-by: Clawdbot <bot@clawd.bot>
This commit is contained in:
parent
9688454a30
commit
17770fb1c3
@ -9,10 +9,12 @@ export {
|
||||
classifyFailoverReason,
|
||||
formatRawAssistantErrorForUi,
|
||||
formatAssistantErrorText,
|
||||
formatVeniceBillingError,
|
||||
getApiErrorPayloadFingerprint,
|
||||
isAuthAssistantError,
|
||||
isAuthErrorMessage,
|
||||
isBillingAssistantError,
|
||||
isVeniceBillingError,
|
||||
parseApiErrorInfo,
|
||||
sanitizeUserFacingText,
|
||||
isBillingErrorMessage,
|
||||
|
||||
@ -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/,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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}`,
|
||||
|
||||
@ -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();
|
||||
|
||||
268
src/providers/venice-balance.test.ts
Normal file
268
src/providers/venice-balance.test.ts
Normal file
@ -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<string, string> = {
|
||||
"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);
|
||||
});
|
||||
});
|
||||
});
|
||||
356
src/providers/venice-balance.ts
Normal file
356
src/providers/venice-balance.ts
Normal file
@ -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<string, string>): 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<BalanceStatus, string> = {
|
||||
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<Response> {
|
||||
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<Response> {
|
||||
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> } },
|
||||
): 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,
|
||||
};
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user