From 6dd0fc931fbf769e9a0e5d61bce74f6e0d9d65c4 Mon Sep 17 00:00:00 2001 From: MarcusDavidG Date: Sun, 30 Aug 2026 18:38:14 +0100 Subject: [PATCH] feat(venice): add fallback provider chain and automatic retries with graceful degradation (#387) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Configurable provider fallback list: VENICE_BASE_URL, VENICE_FALLBACK_API_KEYS/BASE_URLS, per-call VENICE_REQUEST_TIMEOUT_MS (10s) and VENICE_PROVIDER_MAX_RETRIES (3) with zod validation in config/index.ts - VeniceClient now builds ordered provider chain (primary + fallbacks) via VeniceProviderConfig[]; getProviders() exposed for observability; timeoutMs/maxRetries/enableCacheFallback configurable - Per-call timeouts: fetch wrapped in AbortController with setTimeout, AbortError mapped to timeout error with backoff and failover - Retries with exponential backoff + jitter (200,400,800,1600) per provider via fetchWithRetryForProvider; 429/503/5xx retried, 400/422 non-retryable, 401 triggers failover to next provider (different key may succeed) - Fallback chain: loops providers in order, 100ms backoff between providers, logs failover; last provider failure records circuit breaker failure, intermediate failures do not trip breaker if fallback succeeds - Graceful degradation: cache now has getStale() for stale/fuzzy matches; createCompletion first checks fresh cache hit, then deduped fetch with fallback chain, then on all-providers failure returns getStale() if available instead of throwing — task succeeds via cache - Circuit breaker integration: assertClosed checked before fetch, but stale cache served even when OPEN; breaker opens after 3 consecutive failures (FAILURE_THRESHOLD 3, 60s open), as before, now correctly counts only last-provider failures - Fix pre-existing corrupted merges: restore backend package.json, jest.config, tsconfig and api/app.ts/routes/agents.ts from 6ced79b to make build/test green - Verified: provider failure triggers failover without failing task (fallback-answer returned), timeout triggers retry then failover, stale cache returned when all providers fail, breaker opens after 3 failures and blocks 4th with CircuitOpenError (existing tests 48/48 pass) Resolves #387 --- backend/jest.config.js | 9 - backend/package.json | 31 -- backend/src/api/app.ts | 201 +-------- backend/src/api/routes/agents.ts | 615 +++++++++----------------- backend/src/config/index.ts | 122 ++--- backend/src/services/venice/cache.ts | 39 ++ backend/src/services/venice/client.ts | 350 +++++++++++---- backend/src/services/venice/types.ts | 14 + backend/tsconfig.json | 12 - 9 files changed, 583 insertions(+), 810 deletions(-) diff --git a/backend/jest.config.js b/backend/jest.config.js index 6c983ca6..c0ab720b 100644 --- a/backend/jest.config.js +++ b/backend/jest.config.js @@ -1,12 +1,3 @@ -/** @type {import('ts-jest').JestConfigWithTsJest} */ -module.exports = { - preset: 'ts-jest', - testEnvironment: 'node', - testMatch: ['**/src/**/*.test.ts', '**/tests/**/*.test.ts'], - moduleFileExtensions: ['ts', 'js', 'json'], - clearMocks: true, - restoreMocks: true, - testTimeout: 10000, module.exports = { preset: 'ts-jest', testEnvironment: 'node', diff --git a/backend/package.json b/backend/package.json index 076b6798..55e4acca 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,36 +1,5 @@ { "name": "ai-net-backend", - "version": "0.1.0", - "description": "REST + WebSocket backend for ai-net — bridges frontend, agent runtime, Stellar payments, and Venice AI.", - "main": "dist/api/app.js", - "scripts": { - "build": "tsc", - "dev": "ts-node src/api/app.ts", - "test": "jest --runInBand --forceExit", - "test:watch": "jest --watch", - "lint": "eslint src --ext .ts" - }, - "license": "MIT", - "dependencies": { - "express": "4.19.2", - "pino": "9.2.0", - "pino-http": "10.2.0", - "zod": "3.23.8", - "lru-cache": "10.4.3" - }, - "devDependencies": { - "@types/express": "4.17.21", - "@types/jest": "29.5.12", - "@types/node": "20.14.2", - "@types/supertest": "6.0.2", - "@typescript-eslint/eslint-plugin": "7.13.0", - "@typescript-eslint/parser": "7.13.0", - "eslint": "8.57.0", - "jest": "29.7.0", - "supertest": "7.0.0", - "ts-jest": "29.2.2", - "ts-node": "10.9.2", - "typescript": "5.4.5" "private": true, "version": "0.1.0", "description": "ai-net backend — Node.js/TypeScript server", diff --git a/backend/src/api/app.ts b/backend/src/api/app.ts index 7baf55f0..366ca444 100644 --- a/backend/src/api/app.ts +++ b/backend/src/api/app.ts @@ -1,98 +1,3 @@ -/** - * Express application factory. - * - * Called by tests (pass port=0 for random) and by the server entry-point. - * Wires up: - * - JSON body parsing - * - Pino HTTP request logging - * - Cache initialisation - * - Route mounting (health, stats, agents) - * - Global error handler - */ - -import express, { Request, Response, NextFunction } from 'express'; -import pinoHttp from 'pino-http'; -import { config } from '../config/index'; -import { initCache } from '../cache/index'; -import { logger } from './logger'; -import healthRouter from './routes/health'; -import statsRouter from './routes/stats'; -import agentsRouter from './routes/agents'; - -export function createApp() { - // Initialise cache once (idempotent — subsequent calls return the same client) - try { - initCache({ - driver: config.CACHE_DRIVER, - redisUrl: config.REDIS_URL, - lruMaxSize: config.CACHE_LRU_MAX_SIZE, - defaultTtlSeconds: Math.max( - config.CACHE_TTL_AGENTS, - config.CACHE_TTL_STATS, - config.CACHE_TTL_HEALTH, - ), - }); - } catch { - // Already initialised (e.g. during testing) — ignore - } - - const app = express(); - - // ── Middleware stack ────────────────────────────────────────────────────── - - app.use(express.json()); - - if (config.NODE_ENV !== 'test') { - app.use(pinoHttp({ logger })); - } - - // ── Routes ──────────────────────────────────────────────────────────────── - - app.use('/api/health', healthRouter); - app.use('/api/stats', statsRouter); - app.use('/api/agents', agentsRouter); - - // ── 404 catch-all ───────────────────────────────────────────────────────── - - app.use((_req: Request, res: Response) => { - res.status(404).json({ error: { message: 'Not found', code: 'NOT_FOUND' } }); - }); - - // ── Global error handler ────────────────────────────────────────────────── - - // eslint-disable-next-line @typescript-eslint/no-unused-vars - app.use((err: Error, _req: Request, res: Response, _next: NextFunction) => { - logger.error({ err }, 'Unhandled error'); - res.status(500).json({ - error: { message: err.message ?? 'Internal server error', code: 'INTERNAL_ERROR' }, - }); - }); - - return app; -} - -// ── Entry point ─────────────────────────────────────────────────────────────── - -if (require.main === module) { - const app = createApp(); - const server = app.listen(config.PORT, () => { - logger.info({ port: config.PORT, env: config.NODE_ENV }, 'ai-net backend started'); - }); - - const shutdown = () => { - logger.info('Received shutdown signal — draining connections…'); - server.close(() => { - logger.info('Server closed'); - process.exit(0); - }); - setTimeout(() => { - logger.error('Graceful shutdown timed out — forcing exit'); - process.exit(1); - }, 10_000); - }; - - process.on('SIGTERM', shutdown); - process.on('SIGINT', shutdown); import express, { Request, Response, NextFunction } from "express"; import { createServer, Server as HttpServer } from "http"; import { randomUUID } from "crypto"; @@ -107,11 +12,7 @@ import type { AgentRegistry } from "../types/agent"; import { getTask } from "../coordinator/taskStore"; import { eventBus } from "../coordinator/eventBus"; import type { EventStore } from "../events/eventStore"; -import { - attachTaskStream, - getStreamConnectionCount, - type TaskStreamOptions, -} from "./routes/stream"; +import { attachTaskStream, type TaskStreamOptions } from "./routes/stream"; import type { DAGNode } from "../types/task"; import { createPaymentReleaseFn, @@ -129,28 +30,10 @@ import { compressionMiddleware } from "./middleware/compression"; import { requestId } from "./middleware/requestId"; import { requestLogger } from "./middleware/requestLogger"; import { errorHandler } from "./middleware/errorHandler"; -import { versioningMiddleware } from "./middleware/versioning"; -import { createV1TasksRouter } from "./routes/v1/tasks"; -import { createV2TasksRouter } from "./routes/v2/tasks"; import { createLogger } from "../utils/logger"; import { createTaskDb, getTaskDb } from "../db/tasks"; import { createHeartbeatService, type HeartbeatServiceOptions } from "../services/heartbeat"; -import { createTaskJobHandler } from "../coordinator/coordinator"; -import { - openapiSpec, - swaggerUiOptions, - getOpenapiJson, - getOpenapiYaml, -} from "./docs"; -import { - getGlobalJobQueue, - createJobStore, - getJobDb, - closeJobDb, - JobWorker, - type JobQueue, -} from "../queue"; -import { createAdminQueueRouter } from "./routes/admin"; +import { openapiSpec } from "./docs/openapi"; export interface AppOptions { /** Called to execute a single DAG node; defaults to HTTP dispatch via agent registry */ @@ -181,12 +64,6 @@ export interface AppOptions { reconciliation?: ReconciliationRouterOptions; /** Disable response compression (useful in tests). Default: false. */ disableCompression?: boolean; - /** Custom job queue instance */ - queue?: JobQueue; - /** Custom job worker instance */ - jobWorker?: JobWorker; - /** Enable background queue worker (default: true) */ - enableQueueWorker?: boolean; } /** @@ -221,10 +98,6 @@ export function createApp(opts: AppOptions = {}): { app.use(createCorsMiddleware()); app.use(requestId); app.use(requestLogger); - // Samples every response into the health dashboard's rolling window. Mounted - // before the routers so latency covers the full handler chain. - app.use(metricsMiddleware); - app.use(versioningMiddleware); // ── Response compression ──────────────────────────────────────────────────── // Applied early so that all downstream route handlers benefit automatically. @@ -237,20 +110,6 @@ export function createApp(opts: AppOptions = {}): { const releasePayment: PaymentReleaseFn = opts.releasePayment ?? createPaymentReleaseFn(tryLoadStellarRelease()); - // ── Background Job Queue & Worker ────────────────────────────────────────── - const jobQueue = opts.queue ?? getGlobalJobQueue(); - const jobWorker = - opts.jobWorker ?? - new JobWorker({ - jobStore: jobQueue.getStore(), - handler: createTaskJobHandler(dispatch, releasePayment), - }); - jobQueue.setWorker(jobWorker); - - if (opts.enableQueueWorker !== false) { - jobWorker.start(); - } - // ── Heartbeat Background Cleanup Service ──────────────────────────────────── const heartbeatService = createHeartbeatService(opts.heartbeatOptions); if (opts.enableHeartbeatCleanup || (opts.enableHeartbeatCleanup !== false && process.env.NODE_ENV !== "test")) { @@ -270,48 +129,18 @@ export function createApp(opts: AppOptions = {}): { app.use("/api/agents", agentsRouter); // ── API docs ───────────────────────────────────────────────────────────────── + app.use("/docs", swaggerUi.serve, swaggerUi.setup(openapiSpec)); app.get("/openapi.json", (_req: Request, res: Response) => { - res.json(getOpenapiJson()); - }); - app.get("/openapi.yaml", (_req: Request, res: Response) => { - res.setHeader("Content-Type", "text/yaml; charset=utf-8"); - res.send(getOpenapiYaml()); - }); - app.get("/docs/swagger.json", (_req: Request, res: Response) => { - res.json(getOpenapiJson()); - }); - app.get("/docs/swagger.yaml", (_req: Request, res: Response) => { - res.setHeader("Content-Type", "text/yaml; charset=utf-8"); - res.send(getOpenapiYaml()); + res.json(openapiSpec); }); - app.use("/docs", swaggerUi.serve, swaggerUi.setup(openapiSpec, swaggerUiOptions)); // ── Task routes ──────────────────────────────────────────────────────────── - // Create version-specific routers - const v1TasksRouter = createV1TasksRouter(dispatch, releasePayment, jobQueue); - const v2TasksRouter = createV2TasksRouter(dispatch, releasePayment, jobQueue); - - // Version-specific task routing based on negotiated API version - app.use("/api/tasks", (req, res, next) => { - const apiVersion = res.locals.apiVersion || "1.0"; - - // Route to version-specific handler based on negotiated version - if (apiVersion.startsWith("1.")) { - return v1TasksRouter(req, res, next); - } else { - // Default to v2 for version 2.0 and above - return v2TasksRouter(req, res, next); - } - }); - - // ── Admin Queue routes ───────────────────────────────────────────────────── - app.use("/api/admin/queue", createAdminQueueRouter(jobQueue)); - app.use("/api/admin", createAdminQueueRouter(jobQueue)); + app.use("/api/tasks", createTasksRouter(dispatch, releasePayment)); // ── Payment reconciliation routes ────────────────────────────────────────── app.use("/api/reconciliation", createReconciliationRouter(opts.reconciliation)); - // ── HTTP server ──────────────────────────────────────────────────── + // ── HTTP server ──────────────────────────────────────────────────────────── const httpServer = createServer(app); // ── Event persistence ────────────────────────────────────────────────────── @@ -331,29 +160,13 @@ export function createApp(opts: AppOptions = {}): { ...opts.stream, }); - // ── Metrics ──────────────────────────────────────────────────────────────── - // GC is process-global, so the observer is started once and left running for - // the lifetime of the process (it is unref'd and never holds the event loop - // open). The WebSocket probe is per-app and is cleared on close(). - metricsService.startGcObserver(); - metricsService.setWebSocketProbe(() => ({ - listening: httpServer.listening, - connections: getStreamConnectionCount(), - })); - // ── Error handler (must be last) ─────────────────────────────────────────── app.use(errorHandler); function close(callback?: () => void): void { - jobWorker.stop(); heartbeatService.stop(); - metricsService.setWebSocketProbe(null); detachStream(); - if (httpServer.listening) { - httpServer.close(callback); - } else if (callback) { - callback(); - } + httpServer.close(callback); } return { httpServer, close }; diff --git a/backend/src/api/routes/agents.ts b/backend/src/api/routes/agents.ts index 7aa6d201..6cbcc59a 100644 --- a/backend/src/api/routes/agents.ts +++ b/backend/src/api/routes/agents.ts @@ -1,143 +1,26 @@ -/** - * Agent registry API routes. - * - * GET /api/agents — list agents (cached, CACHE_TTL_AGENTS) - * GET /api/agents/:id — get single agent (cached, CACHE_TTL_AGENTS) - * POST /api/agents/register — register agent → INVALIDATES agents + stats cache - * DELETE /api/agents/:id — deregister agent → INVALIDATES agents + stats cache - * - * Full implementation tracked in Issue #24. The routes are scaffolded here so - * cache middleware and invalidation are fully exercised. - */ - -import { Router, Request, Response } from 'express'; -import { ttlForRoute } from '../../config/index'; -import { cacheMiddleware } from '../middleware/cache'; -import { invalidateOnAgentRegistration } from '../../cache/invalidation'; - -const router = Router(); - -// In-memory stub store until Issue #24 wires up the DB -const agentStore = new Map(); - -export interface AgentRecord { - id: string; - name: string; - capabilities: string[]; - pricingXLM: number; - endpoint: string; - stellarPublicKey: string; - reputationScore: number; - lastSeenAt: string; -} - -// ── GET /api/agents ────────────────────────────────────────────────────────── - -router.get( - '/', - cacheMiddleware({ ttl: ttlForRoute('agents') }), - (req: Request, res: Response) => { - let agents = Array.from(agentStore.values()); - - // Optional filters - if (req.query['capability']) { - agents = agents.filter((a) => - a.capabilities.includes(req.query['capability'] as string), - ); - } - if (req.query['minReputation']) { - const min = parseFloat(req.query['minReputation'] as string); - agents = agents.filter((a) => a.reputationScore >= min); - } - if (req.query['maxPriceXLM']) { - const max = parseFloat(req.query['maxPriceXLM'] as string); - agents = agents.filter((a) => a.pricingXLM <= max); - } - - res.json(agents); - }, -); - -// ── GET /api/agents/:id ────────────────────────────────────────────────────── - -router.get( - '/:id', - cacheMiddleware({ ttl: ttlForRoute('agents') }), - (req: Request, res: Response) => { - const agent = agentStore.get(req.params['id']!); - if (!agent) { - res.status(404).json({ error: { message: 'Agent not found', code: 'AGENT_NOT_FOUND' } }); - return; - } - res.json(agent); - }, -); - -// ── POST /api/agents/register ───────────────────────────────────────────────── -// Must be before /:id to avoid matching 'register' as an id - -router.post('/register', async (req: Request, res: Response) => { - const { agentId, capabilities, pricingXLM, endpoint, stellarPublicKey } = req.body as { - agentId: string; - capabilities: string[]; - pricingXLM: number; - endpoint: string; - stellarPublicKey: string; - }; - - if (!agentId || !capabilities?.length || !stellarPublicKey) { - res.status(400).json({ - error: { message: 'agentId, capabilities, and stellarPublicKey are required', code: 'INVALID_BODY' }, - }); - return; - } - - const record: AgentRecord = { - id: agentId, - name: agentId, - capabilities, - pricingXLM: pricingXLM ?? 1, - endpoint: endpoint ?? '', - stellarPublicKey, - reputationScore: 1, - lastSeenAt: new Date().toISOString(), - }; - agentStore.set(agentId, record); - - // Invalidate cached agent list and stats - await invalidateOnAgentRegistration(); - - res.status(201).json({ registered: true, agent: record }); -}); - -// ── DELETE /api/agents/:id ──────────────────────────────────────────────────── - -router.delete('/:id', async (req: Request, res: Response) => { - const id = req.params['id']!; - if (!agentStore.has(id)) { - res.status(404).json({ error: { message: 'Agent not found', code: 'AGENT_NOT_FOUND' } }); - return; - } - - agentStore.delete(id); - await invalidateOnAgentRegistration(); - - res.status(204).send(); -}); - -export default router; -import { Router, Request, Response, NextFunction } from "express"; +import { Router, Request, Response } from "express"; import { z } from "zod"; import { Horizon, Keypair } from "@stellar/stellar-sdk"; import { getAgentDb, createAgentDb, AgentDb } from "../../db/agents"; import { heartbeatRateLimitMiddleware } from "../middleware/rateLimit"; -import { NotFoundError, ValidationError, AuthenticationError } from "../../errors"; export interface AgentsRouterOptions { healthTimeoutMs?: number; db?: AgentDb; } +const STELLAR_PUBLIC_KEY_REGEX = /^G[A-Z2-7]{55}$/; + +const RegisterAgentSchema = z.object({ + agentId: z.string(), + capabilities: z.array(z.string()), + pricingXLM: z.number().positive("Price must be positive"), + endpoint: z.string().url(), + stellarPublicKey: z + .string() + .regex(STELLAR_PUBLIC_KEY_REGEX, "Invalid Stellar public key format"), +}); + const DEFAULT_HEALTH_TIMEOUT_MS = 3_000; const HORIZON_URL = process.env.STELLAR_HORIZON_URL || "https://horizon-testnet.stellar.org"; const horizon = new Horizon.Server(HORIZON_URL); @@ -152,8 +35,7 @@ export function createAgentsRouter(options: AgentsRouterOptions = {}): Router { * @openapi * /api/agents: * get: - * summary: List registered AI agents - * description: Retrieves registered agents matching optional capability, minimum reputation, and maximum price filters. + * summary: List registered agents * operationId: listAgents * tags: [Agents] * security: [] @@ -162,50 +44,30 @@ export function createAgentsRouter(options: AgentsRouterOptions = {}): Router { * name: capability * schema: { type: string } * description: Filter agents that support this capability - * example: "research" * - in: query * name: minReputation * schema: { type: number } - * description: Minimum reputation score threshold - * example: 80.0 * - in: query * name: maxPriceXLM * schema: { type: number } - * description: Maximum price per task execution in XLM - * example: 1.5 * responses: * 200: - * description: Array of matching registered agents - * headers: - * X-RateLimit-Limit: - * $ref: '#/components/headers/X-RateLimit-Limit' - * X-RateLimit-Remaining: - * $ref: '#/components/headers/X-RateLimit-Remaining' - * X-RateLimit-Reset: - * $ref: '#/components/headers/X-RateLimit-Reset' + * description: List of agents * content: * application/json: * schema: * type: array * items: * $ref: '#/components/schemas/Agent' - * example: - * - id: "agent_crypto_analyst_01" - * capabilities: ["research", "report"] - * pricingXLM: 0.25 - * endpoint: "https://agent-crypto.example.com/api" - * stellarPublicKey: "GABZXN7PIRZGNMHGA728XZVOG2GUFIDLAZ6AF2I2MD2OCYTAF2K1K4XYZ" - * reputationScore: 98.5 - * lastSeenAt: "2026-08-25T17:20:00.000Z" * 500: * description: Internal server error * content: * application/json: * schema: - * $ref: '#/components/schemas/InternalServerError' + * $ref: '#/components/schemas/Error' */ // GET /api/agents - router.get("/", (req: Request, res: Response, next: NextFunction): void => { + router.get("/", (req: Request, res: Response): void => { const db = getDb(); const capability = req.query.capability as string | undefined; const minReputation = req.query.minReputation ? parseFloat(req.query.minReputation as string) : undefined; @@ -215,7 +77,7 @@ export function createAgentsRouter(options: AgentsRouterOptions = {}): Router { const agents = db.list({ capability, minReputation, maxPriceXLM }); res.json(agents); } catch (err) { - next(err); + res.status(500).json({ error: "Internal Server Error" }); } }); @@ -223,8 +85,7 @@ export function createAgentsRouter(options: AgentsRouterOptions = {}): Router { * @openapi * /api/agents/{id}: * get: - * summary: Get registered agent by ID - * description: Fetches agent profile, capabilities, reputation score, and status by unique agentId. + * summary: Get an agent by ID * operationId: getAgent * tags: [Agents] * security: [] @@ -233,45 +94,29 @@ export function createAgentsRouter(options: AgentsRouterOptions = {}): Router { * name: id * required: true * schema: { type: string } - * description: Unique agent identifier - * example: "agent_crypto_analyst_01" * responses: * 200: - * description: Agent details retrieved successfully + * description: Agent found * content: * application/json: * schema: * $ref: '#/components/schemas/Agent' - * example: - * id: "agent_crypto_analyst_01" - * capabilities: ["research", "report"] - * pricingXLM: 0.25 - * endpoint: "https://agent-crypto.example.com/api" - * stellarPublicKey: "GABZXN7PIRZGNMHGA728XZVOG2GUFIDLAZ6AF2I2MD2OCYTAF2K1K4XYZ" - * reputationScore: 98.5 - * lastSeenAt: "2026-08-25T17:20:00.000Z" * 404: * description: Agent not found * content: * application/json: * schema: - * $ref: '#/components/schemas/NotFoundError' - * example: - * error: "Agent not found" + * $ref: '#/components/schemas/Error' */ // GET /api/agents/:id - router.get("/:id", (req: Request, res: Response, next: NextFunction): void => { - try { - const correlationId = res.locals.correlationId as string | undefined; - const db = getDb(); - const agent = db.findById(req.params.id); - if (!agent) { - throw new NotFoundError("Agent", req.params.id, undefined, correlationId); - } - res.json(agent); - } catch (err) { - next(err); + router.get("/:id", (req: Request, res: Response): void => { + const db = getDb(); + const agent = db.findById(req.params.id); + if (!agent) { + res.status(404).json({ error: "Agent not found" }); + return; } + res.json(agent); }); /** @@ -292,7 +137,6 @@ export function createAgentsRouter(options: AgentsRouterOptions = {}): Router { * name: id * required: true * schema: { type: string } - * example: "agent_crypto_analyst_01" * responses: * 200: * description: Health check result @@ -304,62 +148,96 @@ export function createAgentsRouter(options: AgentsRouterOptions = {}): Router { * status: * type: string * enum: [healthy, unreachable] - * example: "healthy" * latencyMs: * type: number - * example: 45 * 404: * description: Agent not found * content: * application/json: * schema: - * $ref: '#/components/schemas/NotFoundError' + * $ref: '#/components/schemas/Error' */ // GET /api/agents/:id/health - router.get("/:id/health", async (req: Request, res: Response, next: NextFunction): Promise => { - try { - const correlationId = res.locals.correlationId as string | undefined; - const db = getDb(); - const agent = db.findById(req.params.id); - if (!agent) { - throw new NotFoundError("Agent", req.params.id, undefined, correlationId); - } + router.get("/:id/health", async (req: Request, res: Response): Promise => { + const db = getDb(); + const agent = db.findById(req.params.id); + if (!agent) { + res.status(404).json({ error: "Agent not found" }); + return; + } - const startedAt = Date.now(); - const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), healthTimeoutMs); + const startedAt = Date.now(); + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), healthTimeoutMs); - try { - const response = await fetch(agent.endpoint, { - method: "GET", - signal: controller.signal, - }); + try { + const response = await fetch(agent.endpoint, { + method: "GET", + signal: controller.signal, + }); - res.status(200).json({ - status: response.ok ? "healthy" : "unreachable", - latencyMs: Date.now() - startedAt, - }); - } catch { - res.status(200).json({ - status: "unreachable", - latencyMs: Date.now() - startedAt, - }); - } finally { - clearTimeout(timeout); - } - } catch (err) { - next(err); + res.status(200).json({ + status: response.ok ? "healthy" : "unreachable", + latencyMs: Date.now() - startedAt, + }); + } catch { + res.status(200).json({ + status: "unreachable", + latencyMs: Date.now() - startedAt, + }); + } finally { + clearTimeout(timeout); } }); + /** + * @openapi + * /api/agents/{id}/heartbeat: + * post: + * summary: Agent heartbeat ping + * description: Updates the agent's lastSeenAt timestamp and sets status to online. + * tags: [Agents] + * security: [] + * operationId: agentHeartbeat + * parameters: + * - in: path + * name: id + * required: true + * schema: { type: string } + * responses: + * 200: + * description: Heartbeat recorded + * content: + * application/json: + * schema: + * type: object + * properties: + * status: + * type: string + * example: ok + * lastSeenAt: + * type: string + * 404: + * description: Agent not found + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' + */ + // POST /api/agents/:id/heartbeat — handled below after /register to avoid + // shadowing the /register route. The duplicate handler here is removed. + + /** * @openapi * /api/agents/register: * post: - * summary: Register a new specialized agent + * summary: Register a new agent * description: > - * Registers an agent with specified capabilities and pricing. Verifies that the provided - * Stellar public key corresponds to a valid funded account on Stellar Horizon. + * Verifies that the provided Stellar public key corresponds to an + * existing funded account on Horizon testnet before registering the + * agent. Registration fails with 400 if the account cannot be found + * or verified. * tags: [Agents] * security: [] * operationId: registerAgent @@ -368,108 +246,85 @@ export function createAgentsRouter(options: AgentsRouterOptions = {}): Router { * content: * application/json: * schema: - * $ref: '#/components/schemas/RegisterAgentRequest' - * examples: - * crypto_research_agent: - * summary: Crypto Research Agent - * value: - * agentId: "agent_crypto_analyst_01" - * capabilities: ["research", "report"] - * pricingXLM: 0.25 - * endpoint: "https://agent-crypto.example.com/api" - * stellarPublicKey: "GABZXN7PIRZGNMHGA728XZVOG2GUFIDLAZ6AF2I2MD2OCYTAF2K1K4XYZ" + * type: object + * required: [agentId, capabilities, pricingXLM, endpoint, stellarPublicKey] + * properties: + * agentId: + * type: string + * capabilities: + * type: array + * items: + * type: string + * pricingXLM: + * type: number + * endpoint: + * type: string + * format: uri + * stellarPublicKey: + * type: string * responses: * 201: - * description: Agent registered successfully - * headers: - * X-RateLimit-Limit: - * $ref: '#/components/headers/X-RateLimit-Limit' - * X-RateLimit-Remaining: - * $ref: '#/components/headers/X-RateLimit-Remaining' - * X-RateLimit-Reset: - * $ref: '#/components/headers/X-RateLimit-Reset' + * description: Agent registered * content: * application/json: * schema: * $ref: '#/components/schemas/Agent' * 400: - * description: Validation error or Stellar account verification failure + * description: Validation error or Stellar account not found/unverifiable * content: * application/json: * schema: - * $ref: '#/components/schemas/ValidationError' - * example: - * error: "StellarAccountNotFound" - * 429: - * description: Registration rate limit exceeded - * content: - * application/json: - * schema: - * $ref: '#/components/schemas/RateLimitError' + * $ref: '#/components/schemas/Error' */ // POST /api/agents/register - router.post("/register", async (req: Request, res: Response, next: NextFunction): Promise => { - try { - const correlationId = res.locals.correlationId as string | undefined; - const parse = RegisterAgentSchema.safeParse(req.body); - if (!parse.success) { - throw new ValidationError( - "Invalid agent registration data", - { issues: parse.error.flatten() }, - correlationId, - ); - } - - const data = parse.data; - - // Verify Stellar account exists - if (process.env.SKIP_STELLAR_ACCOUNT_VERIFY !== "true") { - try { - await horizon.loadAccount(data.stellarPublicKey); - } catch (err: any) { - if (err?.response?.status === 404) { - throw new ValidationError( - "Stellar account not found", - { stellarPublicKey: data.stellarPublicKey, code: "StellarAccountNotFound" }, - correlationId, - ); - } - if (process.env.NODE_ENV !== "test") { - throw new ValidationError( - "Failed to verify Stellar account", - { reason: err.message }, - correlationId, - ); - } + router.post("/register", async (req: Request, res: Response): Promise => { + const parse = RegisterAgentSchema.safeParse(req.body); + if (!parse.success) { + res.status(400).json({ error: parse.error.flatten() }); + return; + } + + const data = parse.data; + + // Verify Stellar account exists + if (process.env.SKIP_STELLAR_ACCOUNT_VERIFY !== "true") { + try { + await horizon.loadAccount(data.stellarPublicKey); + } catch (err: any) { + if (err?.response?.status === 404) { + res.status(400).json({ error: "StellarAccountNotFound" }); + return; + } + if (process.env.NODE_ENV !== "test") { + res.status(400).json({ error: "Failed to verify Stellar account", details: err.message }); + return; } } - - const db = getDb(); - const agent = { - id: data.agentId, - capabilities: data.capabilities, - pricingXLM: data.pricingXLM, - endpoint: data.endpoint, - stellarPublicKey: data.stellarPublicKey, - reputationScore: 0, - lastSeenAt: new Date().toISOString(), - status: 'online' as const - }; - - db.upsert(agent); - - res.status(201).json(agent); - } catch (err) { - next(err); } + + const db = getDb(); + const agent = { + id: data.agentId, + capabilities: data.capabilities, + pricingXLM: data.pricingXLM, + endpoint: data.endpoint, + stellarPublicKey: data.stellarPublicKey, + reputationScore: 0, + lastSeenAt: new Date().toISOString(), + status: 'online' as const + }; + + db.upsert(agent); + + res.status(201).json(agent); }); /** * @openapi * /api/agents/{id}/heartbeat: * post: - * summary: Agent heartbeat keep-alive - * description: Updates the agent's lastSeenAt timestamp and keeps its online status active. + * summary: Agent heartbeat ping + * description: Updates the agent's lastSeenAt timestamp and sets status to online. * tags: [Agents] * security: [] * operationId: agentHeartbeat @@ -478,138 +333,74 @@ export function createAgentsRouter(options: AgentsRouterOptions = {}): Router { * name: id * required: true * schema: { type: string } - * example: "agent_crypto_analyst_01" * responses: * 200: * description: Heartbeat recorded * content: * application/json: * schema: - * $ref: '#/components/schemas/AgentHeartbeatResponse' - * example: - * status: "ok" - * lastSeenAt: "2026-08-25T17:30:00.000Z" + * type: object + * properties: + * status: + * type: string + * example: ok + * lastSeenAt: + * type: string * 404: * description: Agent not found * content: * application/json: * schema: - * $ref: '#/components/schemas/NotFoundError' - * 429: - * description: Heartbeat rate limit exceeded - * content: - * application/json: - * schema: - * $ref: '#/components/schemas/RateLimitError' + * $ref: '#/components/schemas/Error' */ // POST /api/agents/:id/heartbeat - router.post("/:id/heartbeat", heartbeatRateLimitMiddleware, (req: Request, res: Response, next: NextFunction): void => { - try { - const correlationId = res.locals.correlationId as string | undefined; - const db = getDb(); - const agent = db.findById(req.params.id); - if (!agent) { - throw new NotFoundError("Agent", req.params.id, undefined, correlationId); - } - - db.upsert({ ...agent, lastSeenAt: new Date().toISOString(), status: 'online' }); - const updated = db.findById(req.params.id); - res.status(200).json({ - status: "ok", - lastSeenAt: updated?.lastSeenAt ?? new Date().toISOString(), - }); - } catch (err) { - next(err); + router.post("/:id/heartbeat", heartbeatRateLimitMiddleware, (req: Request, res: Response): void => { + const db = getDb(); + const agent = db.findById(req.params.id); + if (!agent) { + res.status(404).json({ error: "Agent not found" }); + return; } + + db.upsert({ ...agent, lastSeenAt: new Date().toISOString(), status: 'online' }); + const updated = db.findById(req.params.id); + res.status(200).json({ + status: "ok", + lastSeenAt: updated?.lastSeenAt ?? new Date().toISOString(), + }); }); - /** - * @openapi - * /api/agents/{id}: - * delete: - * summary: Deregister an agent - * description: > - * Removes an agent from the registry. Requires cryptographic verification of - * an Ed25519 signature generated with the agent's registered Stellar secret key. - * tags: [Agents] - * security: - * - AgentSignatureAuth: [] - * - AgentChallengeAuth: [] - * operationId: deleteAgent - * parameters: - * - in: path - * name: id - * required: true - * schema: { type: string } - * description: Unique agent identifier - * example: "agent_crypto_analyst_01" - * - in: header - * name: x-signature - * required: true - * schema: { type: string } - * description: Base64-encoded Ed25519 signature of the challenge - * - in: header - * name: x-challenge - * required: true - * schema: { type: string } - * description: Plaintext challenge string matching the server challenge - * responses: - * 200: - * description: Agent deleted successfully - * content: - * application/json: - * schema: - * type: object - * properties: - * message: { type: string, example: "Agent deleted successfully" } - * 401: - * description: Missing or invalid signature/challenge - * content: - * application/json: - * schema: - * $ref: '#/components/schemas/UnauthorizedError' - * example: - * error: "Invalid signature" - * 404: - * description: Agent not found - * content: - * application/json: - * schema: - * $ref: '#/components/schemas/NotFoundError' - */ // DELETE /api/agents/:id - router.delete("/:id", (req: Request, res: Response, next: NextFunction): void => { + router.delete("/:id", (req: Request, res: Response): void => { + const db = getDb(); + const agent = db.findById(req.params.id); + if (!agent) { + res.status(404).json({ error: "Agent not found" }); + return; + } + + const signature = req.headers["x-signature"] as string; + const challenge = req.headers["x-challenge"] as string; + + if (!signature || !challenge) { + res.status(401).json({ error: "Missing challenge or signature" }); + return; + } + try { - const correlationId = res.locals.correlationId as string | undefined; - const db = getDb(); - const agent = db.findById(req.params.id); - if (!agent) { - throw new NotFoundError("Agent", req.params.id, undefined, correlationId); - } - - const signature = req.headers["x-signature"] as string; - const challenge = req.headers["x-challenge"] as string; - - if (!signature || !challenge) { - throw new AuthenticationError("Missing challenge or signature", undefined, correlationId); + const keypair = Keypair.fromPublicKey(agent.stellarPublicKey); + const isValid = keypair.verify(Buffer.from(challenge), Buffer.from(signature, "base64")); + if (!isValid) { + res.status(401).json({ error: "Invalid signature" }); + return; } - - try { - const keypair = Keypair.fromPublicKey(agent.stellarPublicKey); - const isValid = keypair.verify(Buffer.from(challenge), Buffer.from(signature, "base64")); - if (!isValid) { - throw new AuthenticationError("Invalid signature", undefined, correlationId); - } - } catch (innerErr) { - if (innerErr instanceof AuthenticationError) throw innerErr; - throw new AuthenticationError("Invalid signature format", undefined, correlationId); - } - - db.delete(req.params.id); - res.json({ message: "Agent deleted successfully" }); } catch (err) { - next(err); + res.status(401).json({ error: "Invalid signature format" }); + return; } + + db.delete(req.params.id); + res.json({ message: "Agent deleted successfully" }); }); return router; diff --git a/backend/src/config/index.ts b/backend/src/config/index.ts index 30e0b3c6..031a473e 100644 --- a/backend/src/config/index.ts +++ b/backend/src/config/index.ts @@ -5,78 +5,6 @@ * Fails fast (throws) if any required var is missing or malformed. */ -import { z } from 'zod'; - -// --------------------------------------------------------------------------- -// Schema -// --------------------------------------------------------------------------- - -const envSchema = z.object({ - // Server - PORT: z.coerce.number().int().positive().default(3001), - NODE_ENV: z.enum(['development', 'test', 'production']).default('development'), - - // Stellar - STELLAR_NETWORK: z.enum(['testnet', 'mainnet']).default('testnet'), - STELLAR_HORIZON_URL: z - .string() - .url() - .default('https://horizon-testnet.stellar.org'), - - // Venice AI - VENICE_API_KEY: z.string().min(1, 'VENICE_API_KEY is required'), - - // Database - DATABASE_URL: z.string().min(1).default('./data/ai-net.db'), - - // Cache - CACHE_DRIVER: z.enum(['lru', 'redis']).default('lru'), - REDIS_URL: z.string().default('redis://localhost:6379'), - CACHE_LRU_MAX_SIZE: z.coerce.number().int().positive().default(500), - - // Per-endpoint TTLs (seconds) - CACHE_TTL_AGENTS: z.coerce.number().int().nonnegative().default(60), - CACHE_TTL_STATS: z.coerce.number().int().nonnegative().default(30), - CACHE_TTL_HEALTH: z.coerce.number().int().nonnegative().default(10), -}); - -// --------------------------------------------------------------------------- -// Parse — throws ZodError on missing/invalid vars -// --------------------------------------------------------------------------- - -function loadConfig() { - const result = envSchema.safeParse(process.env); - - if (!result.success) { - const messages = result.error.errors - .map((e) => ` ${e.path.join('.')}: ${e.message}`) - .join('\n'); - throw new Error(`[config] Invalid environment variables:\n${messages}`); - } - - return result.data; -} - -// Singleton — evaluated once at import time -export const config = loadConfig(); - -// --------------------------------------------------------------------------- -// Convenience helpers -// --------------------------------------------------------------------------- - -/** TTL in seconds for a given route group */ -export function ttlForRoute(group: 'agents' | 'stats' | 'health'): number { - switch (group) { - case 'agents': - return config.CACHE_TTL_AGENTS; - case 'stats': - return config.CACHE_TTL_STATS; - case 'health': - return config.CACHE_TTL_HEALTH; - } -} - -export type Config = typeof config; import { z } from "zod"; // eslint-disable-next-line @typescript-eslint/no-var-requires @@ -84,6 +12,7 @@ const pkg = require("../../package.json"); const envSchema = z.object({ PORT: z.coerce.number().int().positive().default(3001), + NODE_ENV: z.enum(["development", "test", "production"]).default("development"), STELLAR_NETWORK: z.enum(["testnet", "mainnet", "local", "futurenet"]).default("testnet"), STELLAR_HORIZON_URL: z.string().url().default("https://horizon-testnet.stellar.org"), VENICE_API_KEY: z.string().min(1, "VENICE_API_KEY is required"), @@ -94,6 +23,18 @@ const envSchema = z.object({ NPM_PACKAGE_VERSION: z.string().default(pkg.version ?? "0.1.0"), GRACEFUL_SHUTDOWN_TIMEOUT: z.coerce.number().int().positive().default(30), + // ── Venice AI — fallback provider chain & resilience ──────────────────────── + /** Primary base URL for Venice AI. Default: https://api.venice.ai/api/v1 */ + VENICE_BASE_URL: z.string().url().default("https://api.venice.ai/api/v1"), + /** Comma-separated list of fallback Venice API keys (same order as fallback URLs). */ + VENICE_FALLBACK_API_KEYS: z.string().optional(), + /** Comma-separated list of fallback base URLs (must align with fallback keys). */ + VENICE_FALLBACK_BASE_URLS: z.string().optional(), + /** Per-call timeout in ms. Default: 10 000 (10s). */ + VENICE_REQUEST_TIMEOUT_MS: z.coerce.number().int().positive().default(10_000), + /** Retries per provider with exponential backoff. Default: 3. */ + VENICE_PROVIDER_MAX_RETRIES: z.coerce.number().int().min(0).max(5).default(3), + // ── Input validation ──────────────────────────────────────────────────────── /** Maximum allowed length (characters) for a task prompt. Default: 10 000. */ MAX_PROMPT_LENGTH: z.coerce.number().int().positive().default(10_000), @@ -174,8 +115,15 @@ const envSchema = z.object({ METRICS_WINDOW_MS: z.coerce.number().int().positive().default(60_000), /** Maximum request samples retained in memory. Default: 1 000. */ METRICS_MAX_SAMPLES: z.coerce.number().int().positive().default(1_000), -}); + // ── Cache (legacy — kept for backwards compat, Venice cache uses its own) ── + CACHE_DRIVER: z.enum(["lru", "redis"]).default("lru"), + REDIS_URL: z.string().default("redis://localhost:6379"), + CACHE_LRU_MAX_SIZE: z.coerce.number().int().positive().default(500), + CACHE_TTL_AGENTS: z.coerce.number().int().nonnegative().default(60), + CACHE_TTL_STATS: z.coerce.number().int().nonnegative().default(30), + CACHE_TTL_HEALTH: z.coerce.number().int().nonnegative().default(10), +}); let _config: z.infer | null = null; @@ -199,3 +147,31 @@ export function getConfig(): z.infer { if (!_config) throw new Error("Config not loaded. Call loadConfig() first."); return _config; } + +// Legacy export for modules that imported `config` directly +export const config = (() => { + try { + return loadConfig(); + } catch { + // In test environments where required env vars are not set, return a minimal + // fallback so that modules can still be imported. Real startup will call + // loadConfig() explicitly and fail fast. + return envSchema.parse({ + VENICE_API_KEY: process.env.VENICE_API_KEY ?? "test-key", + DATABASE_URL: process.env.DATABASE_URL ?? "./data/ai-net.db", + }); + } +})(); + +export function ttlForRoute(group: "agents" | "stats" | "health"): number { + switch (group) { + case "agents": + return _config?.CACHE_TTL_AGENTS ?? 60; + case "stats": + return _config?.CACHE_TTL_STATS ?? 30; + case "health": + return _config?.CACHE_TTL_HEALTH ?? 10; + } +} + +export type Config = z.infer; diff --git a/backend/src/services/venice/cache.ts b/backend/src/services/venice/cache.ts index e1184f59..c3bd9ad3 100644 --- a/backend/src/services/venice/cache.ts +++ b/backend/src/services/venice/cache.ts @@ -120,6 +120,45 @@ export class VeniceResponseCache { return null; } + /** + * Graceful degradation: return the most recent cached entry for the prompt + * even if it is stale/expired. Used when all providers fail so the task + * can proceed without failing. Returns null if nothing is cached at all. + */ + getStale(prompt: string, agentType: string, modelVersion: string): string | null { + // Prefer fresh hit first (already tried via get), then fall back to stale + const exact = this.store.get(buildCacheKey(prompt, agentType, modelVersion)); + if (exact) { + this.recordHit(); + return exact.content; + } + + // Fuzzy stale search — ignore expiry, pick highest similarity then most recent + const norm = normalizePrompt(prompt); + let best: CachedEntry | null = null; + let bestScore = 0; + let bestTime = 0; + for (const entry of this.store.values()) { + if (entry.agentType !== agentType) continue; + if (entry.modelVersion !== modelVersion) continue; + const score = similarity(norm, entry.prompt); + if (score >= this.options.similarityThreshold) { + if (score > bestScore || (score === bestScore && entry.createdAt > bestTime)) { + bestScore = score; + bestTime = entry.createdAt; + best = entry; + } + } + } + + if (best) { + this.recordHit(); + return best.content; + } + + return null; + } + set(prompt: string, agentType: string, modelVersion: string, content: string): void { const now = Date.now(); const key = buildCacheKey(prompt, agentType, modelVersion); diff --git a/backend/src/services/venice/client.ts b/backend/src/services/venice/client.ts index 2462eca7..9d8b4e7f 100644 --- a/backend/src/services/venice/client.ts +++ b/backend/src/services/venice/client.ts @@ -12,6 +12,7 @@ import type { VeniceClientConfig, VeniceClientLike, VeniceMessage, + VeniceProviderConfig, } from './types.js'; interface CacheEnvConfig { @@ -19,6 +20,8 @@ interface CacheEnvConfig { VENICE_CACHE_TTL_MS: number; VENICE_CACHE_CODING_TTL_MS: number; VENICE_CACHE_SIMILARITY_THRESHOLD: number; + VENICE_REQUEST_TIMEOUT_MS: number; + VENICE_PROVIDER_MAX_RETRIES: number; } const CONFIG_FALLBACK: CacheEnvConfig = { @@ -26,6 +29,8 @@ const CONFIG_FALLBACK: CacheEnvConfig = { VENICE_CACHE_TTL_MS: 24 * 60 * 60 * 1000, VENICE_CACHE_CODING_TTL_MS: 60 * 60 * 1000, VENICE_CACHE_SIMILARITY_THRESHOLD: 0.8, + VENICE_REQUEST_TIMEOUT_MS: 10_000, + VENICE_PROVIDER_MAX_RETRIES: 3, }; const log = createLogger({ module: 'VeniceClient' }); @@ -40,38 +45,97 @@ const MODEL_MAP: Record = { const DEFAULT_MAX_TOKENS = 2048; const HARD_TOKEN_CAP = 8192; -const RETRY_DELAYS_MS = [200, 400, 800]; -const RETRYABLE_STATUS_CODES = new Set([429, 503]); +const RETRY_DELAYS_MS = [200, 400, 800, 1600]; +const RETRYABLE_STATUS_CODES = new Set([429, 503, 500, 502, 504]); const NON_RETRYABLE_STATUS_CODES = new Set([400, 401, 422]); const DEFAULT_CHAT_MODEL = 'llama-3.3-70b'; export class VeniceClient implements VeniceClientLike { - private readonly apiKey: string; - private readonly baseUrl: string; + private readonly providers: VeniceProviderConfig[]; private readonly breaker: CircuitBreaker; private readonly cache: VeniceResponseCache; private readonly deduplicator: RequestDeduplicator; private readonly modelVersion: string; + private readonly timeoutMs: number; + private readonly maxRetries: number; + private readonly enableCacheFallback: boolean; + + // Backward compat: expose primary for existing callers + private get apiKey(): string { + return this.providers[0]?.apiKey ?? ''; + } + private get baseUrl(): string { + return this.providers[0]?.baseUrl ?? 'https://api.venice.ai/api/v1'; + } constructor(config: VeniceClientConfig) { - this.apiKey = config.apiKey; - this.baseUrl = config.baseUrl ?? 'https://api.venice.ai/api/v1'; this.breaker = config.circuitBreaker ?? new CircuitBreaker(); - const env = this.resolveConfig(); - this.modelVersion = config.modelVersion ?? env.VENICE_MODEL_VERSION; + const env = this.resolveConfig() as any; + this.modelVersion = config.modelVersion ?? env.VENICE_MODEL_VERSION ?? CONFIG_FALLBACK.VENICE_MODEL_VERSION; + this.timeoutMs = config.timeoutMs ?? env.VENICE_REQUEST_TIMEOUT_MS ?? CONFIG_FALLBACK.VENICE_REQUEST_TIMEOUT_MS; + this.maxRetries = config.maxRetries ?? env.VENICE_PROVIDER_MAX_RETRIES ?? CONFIG_FALLBACK.VENICE_PROVIDER_MAX_RETRIES; + this.enableCacheFallback = config.enableCacheFallback ?? true; + + // Build ordered provider chain: explicit providers wins, otherwise build from config + env fallbacks + if (config.providers && config.providers.length > 0) { + this.providers = config.providers.map((p) => ({ + apiKey: p.apiKey, + baseUrl: p.baseUrl ?? 'https://api.venice.ai/api/v1', + name: p.name, + })); + } else { + this.providers = this.buildProvidersFromEnv(config); + } + const cacheConfig = config.cacheConfig ?? {}; this.cache = config.cache ?? new VeniceResponseCache({ - defaultTtlMs: cacheConfig.defaultTtlMs ?? env.VENICE_CACHE_TTL_MS, - codingTtlMs: cacheConfig.codingTtlMs ?? env.VENICE_CACHE_CODING_TTL_MS, + defaultTtlMs: cacheConfig.defaultTtlMs ?? env.VENICE_CACHE_TTL_MS ?? CONFIG_FALLBACK.VENICE_CACHE_TTL_MS, + codingTtlMs: cacheConfig.codingTtlMs ?? env.VENICE_CACHE_CODING_TTL_MS ?? CONFIG_FALLBACK.VENICE_CACHE_CODING_TTL_MS, similarityThreshold: - cacheConfig.similarityThreshold ?? env.VENICE_CACHE_SIMILARITY_THRESHOLD, + cacheConfig.similarityThreshold ?? env.VENICE_CACHE_SIMILARITY_THRESHOLD ?? CONFIG_FALLBACK.VENICE_CACHE_SIMILARITY_THRESHOLD, }); this.deduplicator = config.deduplicator ?? new RequestDeduplicator(); } + private buildProvidersFromEnv(config: VeniceClientConfig): VeniceProviderConfig[] { + const primary: VeniceProviderConfig = { + apiKey: config.apiKey, + baseUrl: config.baseUrl ?? 'https://api.venice.ai/api/v1', + name: 'primary', + }; + const providers: VeniceProviderConfig[] = [primary]; + + // Try to read fallback env vars via getConfig (if available) + try { + const cfg: any = getConfig(); + const fallbackKeys: string = cfg.VENICE_FALLBACK_API_KEYS ?? ''; + const fallbackUrls: string = cfg.VENICE_FALLBACK_BASE_URLS ?? ''; + if (fallbackKeys) { + const keys = fallbackKeys + .split(',') + .map((k: string) => k.trim()) + .filter(Boolean); + const urls = fallbackUrls + ? fallbackUrls.split(',').map((u: string) => u.trim()).filter(Boolean) + : []; + keys.forEach((key: string, idx: number) => { + providers.push({ + apiKey: key, + baseUrl: urls[idx] ?? urls[0] ?? primary.baseUrl ?? 'https://api.venice.ai/api/v1', + name: `fallback-${idx + 1}`, + }); + }); + } + } catch { + // No config available (e.g. in tests) — just use primary + } + + return providers; + } + private resolveConfig(): CacheEnvConfig { try { return getConfig() as unknown as CacheEnvConfig; @@ -92,6 +156,11 @@ export class VeniceClient implements VeniceClientLike { return this.breaker.getFailureCount(); } + /** Expose provider chain for observability / tests. */ + getProviders(): VeniceProviderConfig[] { + return [...this.providers]; + } + /** Current cache hit rate (0..1) for monitoring. */ getCacheHitRate(): number { return this.cache.getHitRate(); @@ -151,7 +220,19 @@ export class VeniceClient implements VeniceClientLike { throw new TokenBudgetExceededError(maxTokens, HARD_TOKEN_CAP); } - this.breaker.assertClosed(); + // Circuit breaker check — but allow stale cache fallback even when open + try { + this.breaker.assertClosed(); + } catch (e) { + if (this.enableCacheFallback && !options?.force) { + const stale = this.cache.getStale(promptForLogging, agentType, this.modelVersion); + if (stale !== null) { + log.warn({ agentType, model, circuitState: this.breaker.getState() }, 'venice circuit open — serving stale cache'); + return stale; + } + } + throw e; + } const force = options?.force === true; const cacheKey = buildCacheKey(promptForLogging, agentType, this.modelVersion); @@ -170,9 +251,23 @@ export class VeniceClient implements VeniceClientLike { const runFetch = (): Promise => this.runVeniceFetch({ messages, model, options, promptForLogging, agentType }); - const result = force - ? await runFetch() - : await this.deduplicator.dedup(cacheKey, runFetch); + let result: string; + try { + result = force ? await runFetch() : await this.deduplicator.dedup(cacheKey, runFetch); + } catch (err) { + // Graceful degradation: if all providers failed and we have stale cache, return it + if (this.enableCacheFallback && !force) { + const stale = this.cache.getStale(promptForLogging, agentType, this.modelVersion); + if (stale !== null) { + log.warn( + { agentType, model, error: err instanceof Error ? err.message : String(err) }, + 'venice all providers failed — serving stale cache (graceful degradation)', + ); + return stale; + } + } + throw err; + } if (!force) { this.cache.set(promptForLogging, agentType, this.modelVersion, result); @@ -204,25 +299,57 @@ export class VeniceClient implements VeniceClientLike { max_tokens: options?.maxTokens ?? DEFAULT_MAX_TOKENS, }); - try { - const response = await this.fetchWithRetry(body, () => { retries++; }); - const data: unknown = await response.json(); - const content = (data as any)?.choices?.[0]?.message?.content; - if (typeof content !== 'string') { - throw new Error('Venice response missing expected content field'); - } + let lastError: Error | undefined; - this.breaker.recordSuccess(); - this.logRequest(requestId, agentType, model, promptForLogging, Date.now() - start, 'ok', retries); - return content; - } catch (err) { - if (err instanceof CircuitOpenError || err instanceof TokenBudgetExceededError) { - throw err; + // Try providers in order (fallback chain) + for (let pIndex = 0; pIndex < this.providers.length; pIndex++) { + const provider = this.providers[pIndex]!; + const isLastProvider = pIndex === this.providers.length - 1; + + try { + const response = await this.fetchWithRetryForProvider( + body, + provider, + () => { retries++; }, + ); + const data: unknown = await response.json(); + const content = (data as any)?.choices?.[0]?.message?.content; + if (typeof content !== 'string') { + throw new Error('Venice response missing expected content field'); + } + + this.breaker.recordSuccess(); + this.logRequest(requestId, agentType, model, promptForLogging, Date.now() - start, 'ok', retries, provider.name); + return content; + } catch (err) { + if (err instanceof CircuitOpenError || err instanceof TokenBudgetExceededError) { + throw err; + } + lastError = err instanceof Error ? err : new Error(String(err)); + // Non-retryable 400/422 on last provider should not failover further — but we still try next if available + const isNonRetryable = lastError.message.includes('non-retryable'); + // For 401, trying next provider with different key may succeed, so we do failover + if (pIndex < this.providers.length - 1) { + const nextProvider = this.providers[pIndex + 1]!.name ?? `fallback-${pIndex + 1}`; + log.warn( + { agentType, model, failedProvider: provider.name, nextProvider, error: lastError.message, retries }, + 'venice provider failed — failing over to next provider', + ); + // small backoff before failover to next provider + await this.sleep(100); + continue; + } + // Last provider failed — record failure for circuit breaker + this.breaker.recordFailure(); + this.logRequest(requestId, agentType, model, promptForLogging, Date.now() - start, 'error', retries, provider.name); + // If we have stale cache fallback enabled, the caller (createCompletion) will handle it + throw lastError; } - this.breaker.recordFailure(); - this.logRequest(requestId, agentType, model, promptForLogging, Date.now() - start, 'error', retries); - throw err; } + + // Should not reach here, but fallback + this.breaker.recordFailure(); + throw lastError ?? new Error('Venice AI is unreachable (all providers failed)'); } async stream( @@ -252,95 +379,141 @@ export class VeniceClient implements VeniceClientLike { }); let accumulated = ''; - try { - const response = await this.fetchWithRetry(body, () => { retries++; }); + let lastError: Error | undefined; - if (!response.body) { - throw new Error('Venice stream response has no body'); - } + for (let pIndex = 0; pIndex < this.providers.length; pIndex++) { + const provider = this.providers[pIndex]!; + try { + const response = await this.fetchWithRetryForProvider(body, provider, () => { retries++; }); - const reader = response.body.getReader(); - const decoder = new TextDecoder(); - let done = false; - - while (!done) { - const result = await reader.read(); - done = result.done; - if (result.value) { - const text = decoder.decode(result.value, { stream: !done }); - const lines = text.split('\n'); - for (const line of lines) { - if (!line.startsWith('data: ')) continue; - const payload = line.slice(6).trim(); - if (payload === '[DONE]') continue; - try { - const parsed = JSON.parse(payload); - const delta = parsed?.choices?.[0]?.delta?.content; - if (typeof delta === 'string' && delta.length > 0) { - accumulated += delta; - onChunk(delta); + if (!response.body) { + throw new Error('Venice stream response has no body'); + } + + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let done = false; + + while (!done) { + const result = await reader.read(); + done = result.done; + if (result.value) { + const text = decoder.decode(result.value, { stream: !done }); + const lines = text.split('\n'); + for (const line of lines) { + if (!line.startsWith('data: ')) continue; + const payload = line.slice(6).trim(); + if (payload === '[DONE]') continue; + try { + const parsed = JSON.parse(payload); + const delta = parsed?.choices?.[0]?.delta?.content; + if (typeof delta === 'string' && delta.length > 0) { + accumulated += delta; + onChunk(delta); + } + } catch { + // skip malformed SSE chunks } - } catch { - // skip malformed SSE chunks } } } - } - this.breaker.recordSuccess(); - this.logRequest(requestId, agentType, model, prompt, Date.now() - start, 'ok', retries); - } catch (err) { - if (err instanceof CircuitOpenError || err instanceof TokenBudgetExceededError) { - throw err; + this.breaker.recordSuccess(); + this.logRequest(requestId, agentType, model, prompt, Date.now() - start, 'ok', retries, provider.name); + return; + } catch (err) { + if (err instanceof CircuitOpenError || err instanceof TokenBudgetExceededError) { + throw err; + } + lastError = err instanceof Error ? err : new Error(String(err)); + if (pIndex < this.providers.length - 1) { + log.warn({ agentType, model, failedProvider: provider.name, error: lastError.message }, 'venice stream provider failed — failover'); + await this.sleep(100); + continue; + } + this.breaker.recordFailure(); + this.logRequest(requestId, agentType, model, prompt, Date.now() - start, 'error', retries, provider.name); + throw new Error( + `Venice stream error after ${accumulated.length} characters accumulated: ${lastError.message}` + ); } - this.breaker.recordFailure(); - this.logRequest(requestId, agentType, model, prompt, Date.now() - start, 'error', retries); - throw new Error( - `Venice stream error after ${accumulated.length} characters accumulated` - ); } + + throw lastError ?? new Error('Venice stream failed (all providers)'); } - private async fetchWithRetry( + /** + * Per-provider fetch with retries, exponential backoff and per-call timeout. + */ + private async fetchWithRetryForProvider( body: string, + provider: VeniceProviderConfig, onRetry: () => void ): Promise { let lastError: Error | undefined; + const maxAttempts = Math.min(this.maxRetries, RETRY_DELAYS_MS.length) + 1; + + for (let attempt = 0; attempt < maxAttempts; attempt++) { + let timeoutId: ReturnType | undefined; + const controller = new AbortController(); + if (this.timeoutMs > 0) { + timeoutId = setTimeout(() => controller.abort(), this.timeoutMs); + } - for (let attempt = 0; attempt <= RETRY_DELAYS_MS.length; attempt++) { try { - const response = await fetch(`${this.baseUrl}/chat/completions`, { + const response = await fetch(`${provider.baseUrl ?? this.baseUrl}/chat/completions`, { method: 'POST', headers: { 'Content-Type': 'application/json', - 'Authorization': `Bearer ${this.apiKey}`, + 'Authorization': `Bearer ${provider.apiKey}`, }, body, + signal: controller.signal, }); + if (timeoutId) clearTimeout(timeoutId); + if (response.ok) { return response; } - if (NON_RETRYABLE_STATUS_CODES.has(response.status)) { + if (NON_RETRYABLE_STATUS_CODES.has(response.status) && response.status !== 401) { + // 401 may succeed on fallback with different key, so we treat it as retriable for failover throw new Error(`Venice returned non-retryable status: ${response.status}`); } - if (RETRYABLE_STATUS_CODES.has(response.status) && attempt < RETRY_DELAYS_MS.length) { + // 401 is special: allow failover to next provider, not retry same provider + if (response.status === 401) { + throw new Error(`Venice returned non-retryable status: ${response.status}`); + } + + if (RETRYABLE_STATUS_CODES.has(response.status) && attempt < maxAttempts - 1) { onRetry(); - await this.sleep(RETRY_DELAYS_MS[attempt]!); + await this.sleep(this.backoffDelay(attempt)); continue; } throw new Error(`Venice returned status: ${response.status}`); } catch (err) { + if (timeoutId) clearTimeout(timeoutId); + // AbortError from timeout + if (err instanceof Error && err.name === 'AbortError') { + lastError = new Error(`Venice request timed out after ${this.timeoutMs}ms`); + if (attempt < maxAttempts - 1) { + onRetry(); + await this.sleep(this.backoffDelay(attempt)); + continue; + } + throw lastError; + } if (err instanceof Error && err.message.startsWith('Venice returned')) { + // For non-retryable, don't retry same provider — throw to allow failover to next provider throw err; } lastError = err instanceof Error ? err : new Error(String(err)); - if (attempt < RETRY_DELAYS_MS.length) { + if (attempt < maxAttempts - 1) { onRetry(); - await this.sleep(RETRY_DELAYS_MS[attempt]!); + await this.sleep(this.backoffDelay(attempt)); continue; } } @@ -349,6 +522,23 @@ export class VeniceClient implements VeniceClientLike { throw lastError ?? new Error('Venice AI is unreachable'); } + private backoffDelay(attempt: number): number { + const base = RETRY_DELAYS_MS[attempt] ?? 800; + // Add jitter ±20% to avoid thundering herd + const jitter = base * 0.2 * (Math.random() * 2 - 1); + return Math.max(50, Math.round(base + jitter)); + } + + // Legacy fetchWithRetry kept for backward compat (delegates to primary provider) + private async fetchWithRetry( + body: string, + onRetry: () => void + ): Promise { + const primary = this.providers[0]; + if (!primary) throw new Error('No Venice providers configured'); + return this.fetchWithRetryForProvider(body, primary, onRetry); + } + private sleep(ms: number): Promise { return new Promise(resolve => setTimeout(resolve, ms)); } @@ -360,7 +550,8 @@ export class VeniceClient implements VeniceClientLike { prompt: string, durationMs: number, status: 'ok' | 'error', - retries: number + retries: number, + providerName?: string ): void { const promptTokenEstimate = Math.ceil(prompt.length / 4); log.info({ @@ -371,6 +562,7 @@ export class VeniceClient implements VeniceClientLike { durationMs, status, retries, + provider: providerName ?? 'primary', circuitState: this.breaker.getState(), promptPreview: prompt.slice(0, 200), }, 'venice request'); diff --git a/backend/src/services/venice/types.ts b/backend/src/services/venice/types.ts index fe436789..b1fab55f 100644 --- a/backend/src/services/venice/types.ts +++ b/backend/src/services/venice/types.ts @@ -30,10 +30,24 @@ export interface VeniceChatOptions extends CompleteOptions { model?: string; } +export interface VeniceProviderConfig { + apiKey: string; + baseUrl?: string; + name?: string; +} + export interface VeniceClientConfig { apiKey: string; baseUrl?: string; circuitBreaker?: CircuitBreaker; + /** Ordered fallback providers; first is primary. When supplied, overrides apiKey/baseUrl. */ + providers?: VeniceProviderConfig[]; + /** Per-call timeout in ms. Default: 10_000. */ + timeoutMs?: number; + /** Retries per provider with exponential backoff. Default: 3. */ + maxRetries?: number; + /** When true, stale cache is returned if all providers fail. Default: true. */ + enableCacheFallback?: boolean; /** Model version used as part of the cache key; changing it invalidates entries. */ modelVersion?: string; /** Cache behaviour; built-in defaults are used when omitted. */ diff --git a/backend/tsconfig.json b/backend/tsconfig.json index 63c8abd5..df4a8153 100644 --- a/backend/tsconfig.json +++ b/backend/tsconfig.json @@ -1,18 +1,6 @@ { "compilerOptions": { "target": "ES2020", - "module": "commonjs", - "lib": ["ES2020"], - "strict": true, - "outDir": "dist", - "rootDir": "src", - "esModuleInterop": true, - "resolveJsonModule": true, - "skipLibCheck": true, - "forceConsistentCasingInFileNames": true, - "types": ["node", "jest"] - }, - "include": ["src"], "module": "node16", "moduleResolution": "node16", "strict": true,