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,
|
classifyFailoverReason,
|
||||||
formatRawAssistantErrorForUi,
|
formatRawAssistantErrorForUi,
|
||||||
formatAssistantErrorText,
|
formatAssistantErrorText,
|
||||||
|
formatVeniceBillingError,
|
||||||
getApiErrorPayloadFingerprint,
|
getApiErrorPayloadFingerprint,
|
||||||
isAuthAssistantError,
|
isAuthAssistantError,
|
||||||
isAuthErrorMessage,
|
isAuthErrorMessage,
|
||||||
isBillingAssistantError,
|
isBillingAssistantError,
|
||||||
|
isVeniceBillingError,
|
||||||
parseApiErrorInfo,
|
parseApiErrorInfo,
|
||||||
sanitizeUserFacingText,
|
sanitizeUserFacingText,
|
||||||
isBillingErrorMessage,
|
isBillingErrorMessage,
|
||||||
|
|||||||
@ -4,6 +4,61 @@ import type { MoltbotConfig } from "../../config/config.js";
|
|||||||
import { formatSandboxToolPolicyBlockedMessage } from "../sandbox.js";
|
import { formatSandboxToolPolicyBlockedMessage } from "../sandbox.js";
|
||||||
import type { FailoverReason } from "./types.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 {
|
export function isContextOverflowError(errorMessage?: string): boolean {
|
||||||
if (!errorMessage) return false;
|
if (!errorMessage) return false;
|
||||||
const lower = errorMessage.toLowerCase();
|
const lower = errorMessage.toLowerCase();
|
||||||
@ -299,6 +354,11 @@ export function formatAssistantErrorText(
|
|||||||
return "The AI service is temporarily overloaded. Please try again in a moment.";
|
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)) {
|
if (isLikelyHttpErrorText(raw) || isRawApiErrorPayload(raw)) {
|
||||||
return formatRawAssistantErrorForUi(raw);
|
return formatRawAssistantErrorForUi(raw);
|
||||||
}
|
}
|
||||||
@ -371,6 +431,14 @@ const ERROR_PATTERNS = {
|
|||||||
"insufficient credits",
|
"insufficient credits",
|
||||||
"credit balance",
|
"credit balance",
|
||||||
"plans & billing",
|
"plans & billing",
|
||||||
|
// Venice-specific balance/billing errors
|
||||||
|
"insufficient balance",
|
||||||
|
"insufficient_balance",
|
||||||
|
"spending_cap_exceeded",
|
||||||
|
"spending cap",
|
||||||
|
"balance depleted",
|
||||||
|
"out of credits",
|
||||||
|
"diem balance",
|
||||||
],
|
],
|
||||||
auth: [
|
auth: [
|
||||||
/invalid[_ ]?api[_ ]?key/,
|
/invalid[_ ]?api[_ ]?key/,
|
||||||
|
|||||||
@ -19,6 +19,12 @@ import { formatUserTime, resolveUserTimeFormat, resolveUserTimezone } from "../d
|
|||||||
import { normalizeGroupActivation } from "../../auto-reply/group-activation.js";
|
import { normalizeGroupActivation } from "../../auto-reply/group-activation.js";
|
||||||
import { getFollowupQueueDepth, resolveQueueSettings } from "../../auto-reply/reply/queue.js";
|
import { getFollowupQueueDepth, resolveQueueSettings } from "../../auto-reply/reply/queue.js";
|
||||||
import { buildStatusMessage } from "../../auto-reply/status.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 type { MoltbotConfig } from "../../config/config.js";
|
||||||
import { loadConfig } from "../../config/config.js";
|
import { loadConfig } from "../../config/config.js";
|
||||||
import {
|
import {
|
||||||
@ -410,6 +416,15 @@ export function createSessionStatusTool(opts?: {
|
|||||||
typeof agentDefaults.model === "object" && agentDefaults.model
|
typeof agentDefaults.model === "object" && agentDefaults.model
|
||||||
? { ...agentDefaults.model, primary: defaultLabel }
|
? { ...agentDefaults.model, primary: defaultLabel }
|
||||||
: { 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({
|
const statusText = buildStatusMessage({
|
||||||
config: cfg,
|
config: cfg,
|
||||||
agent: {
|
agent: {
|
||||||
@ -427,6 +442,7 @@ export function createSessionStatusTool(opts?: {
|
|||||||
}),
|
}),
|
||||||
usageLine,
|
usageLine,
|
||||||
timeLine,
|
timeLine,
|
||||||
|
veniceBalanceLine,
|
||||||
queue: {
|
queue: {
|
||||||
mode: queueSettings.mode,
|
mode: queueSettings.mode,
|
||||||
depth: queueDepth,
|
depth: queueDepth,
|
||||||
|
|||||||
@ -72,6 +72,8 @@ type StatusArgs = {
|
|||||||
subagentsLine?: string;
|
subagentsLine?: string;
|
||||||
includeTranscriptUsage?: boolean;
|
includeTranscriptUsage?: boolean;
|
||||||
now?: number;
|
now?: number;
|
||||||
|
/** Venice balance status line (e.g., "🏛️ Venice: DIEM: 44.32 · Status: ✅ OK"). */
|
||||||
|
veniceBalanceLine?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
function resolveRuntimeLabel(
|
function resolveRuntimeLabel(
|
||||||
@ -414,6 +416,11 @@ export function buildStatusMessage(args: StatusArgs): string {
|
|||||||
const mediaLine = formatMediaUnderstandingLine(args.mediaDecisions);
|
const mediaLine = formatMediaUnderstandingLine(args.mediaDecisions);
|
||||||
const voiceLine = formatVoiceModeLine(args.config, args.sessionEntry);
|
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 [
|
return [
|
||||||
versionLine,
|
versionLine,
|
||||||
args.timeLine,
|
args.timeLine,
|
||||||
@ -422,6 +429,7 @@ export function buildStatusMessage(args: StatusArgs): string {
|
|||||||
`📚 ${contextLine}`,
|
`📚 ${contextLine}`,
|
||||||
mediaLine,
|
mediaLine,
|
||||||
args.usageLine,
|
args.usageLine,
|
||||||
|
veniceBalanceLine,
|
||||||
`🧵 ${sessionLine}`,
|
`🧵 ${sessionLine}`,
|
||||||
args.subagentsLine,
|
args.subagentsLine,
|
||||||
`⚙️ ${optionsLine}`,
|
`⚙️ ${optionsLine}`,
|
||||||
|
|||||||
@ -72,11 +72,27 @@ export const BedrockDiscoverySchema = z
|
|||||||
.strict()
|
.strict()
|
||||||
.optional();
|
.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
|
export const ModelsConfigSchema = z
|
||||||
.object({
|
.object({
|
||||||
mode: z.union([z.literal("merge"), z.literal("replace")]).optional(),
|
mode: z.union([z.literal("merge"), z.literal("replace")]).optional(),
|
||||||
providers: z.record(z.string(), ModelProviderSchema).optional(),
|
providers: z.record(z.string(), ModelProviderSchema).optional(),
|
||||||
bedrockDiscovery: BedrockDiscoverySchema,
|
bedrockDiscovery: BedrockDiscoverySchema,
|
||||||
|
/** Venice-specific balance warning configuration. */
|
||||||
|
veniceBalanceWarning: VeniceBalanceWarningSchema,
|
||||||
})
|
})
|
||||||
.strict()
|
.strict()
|
||||||
.optional();
|
.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