1
40import {
41 AiConfigSchema,
42 AiGateway,
43 AnthropicAdapter,
44 BudgetService,
45 CustomAdapter,
46 GoogleAdapter,
47 MockProvider,
48 OpenAIAdapter,
49 OssAdapter,
50 resolveAiConfig,
51 resolveKeyRef,
52 type ProviderAdapter,
53 type ProviderId,
54 type ResolvedAiConfig,
55 UsageLedger,
56 type UsageRecord,
57} from "@factory/ai";
58import { ApiError as SharedApiError, ErrorCodes as SharedErrorCodes } from "@factory/shared";
59import { z } from "zod";
60
61import type { Request } from "express";
62import type { PrismaClient } from "@prisma/client";
63import type Redis from "ioredis";
64import { ApiError } from "../../common/errors.js";
65import { logger } from "../../common/logger.js";
66import { loadEnv } from "../../config/env.js";
67
68
69export const AI_GATEWAY = "AI_GATEWAY";
70
71export const AI_BUDGET_GUARD = "AI_BUDGET_GUARD";
72
73
80export function embedDimension(): number {
81 const raw = process.env.SEARCH_VECTOR_DIMENSION || process.env.AI_EMBEDDING_DIMENSION;
82 const n = raw ? Number.parseInt(raw, 10) : NaN;
83 return Number.isFinite(n) && n > 0 ? n : 1536;
84}
85
86export interface AiGatewayDeps {
87
93 onBudgetExceeded: (workspaceId: string, used: number, limit: number) => Promise<void> | void;
94
99 ledgerSink: (record: UsageRecord) => Promise<void>;
100}
101
102const envAiProviderSchema = z.enum(["openai", "anthropic", "google", "oss", "custom", "mock"]);
103
104
125const mockOutageArmings = new Map<string, { status: number; deltas: number }>();
126
127
134export function armMockFailures(workspaceId: string, status = 500, deltas = 2): number {
135 mockOutageArmings.set(workspaceId, { status, deltas });
136 return status;
137}
138
139
140export function clearMockOutageArming(workspaceId: string): void {
141 mockOutageArmings.delete(workspaceId);
142}
143
144
145
146
147const mockUnknownIdArmings = new Map<string, string>();
148
149
150export function armMockUnknownBlockId(workspaceId: string, unknownId: string): void {
151 mockUnknownIdArmings.set(workspaceId, unknownId);
152}
153
154
155export function clearMockUnknownBlockId(workspaceId: string): void {
156 mockUnknownIdArmings.delete(workspaceId);
157}
158
159
160
161
162
163
164const mockDbInvalidArmings = new Map<string, string>();
165
166
167export function armMockDbInvalidStructure(workspaceId: string, action: "generate" | "extract" | "formula" = "generate"): void {
168 mockDbInvalidArmings.set(workspaceId, action);
169}
170
171
172export function clearMockDbInvalidStructure(workspaceId: string): void {
173 mockDbInvalidArmings.delete(workspaceId);
174}
175
176
177
178
179
180
181
182
183const mockGenerateArmings = new Map<string, { failStep?: string; slowMs?: string }>();
184
185
186export function armMockGenerateSeam(
187 workspaceId: string,
188 opts: { failStep?: number; slowMs?: number },
189): { failStep?: string; slowMs?: string } {
190 const arming = mockGenerateArmings.get(workspaceId) ?? {};
191 if (opts.failStep !== undefined) arming.failStep = String(opts.failStep);
192 if (opts.slowMs !== undefined) arming.slowMs = String(opts.slowMs);
193 mockGenerateArmings.set(workspaceId, arming);
194 return arming;
195}
196
197
198export function clearMockGenerateSeam(workspaceId: string): void {
199 mockGenerateArmings.delete(workspaceId);
200}
201
202
203
204
205
206
207
208
209const mockAssistantArmings = new Map<string, { hostile: boolean; forgeId: boolean }>();
210
211
212export function armMockAssistantSeam(workspaceId: string, opts: { hostile?: boolean; forgeId?: boolean }): void {
213 const arming = mockAssistantArmings.get(workspaceId) ?? { hostile: false, forgeId: false };
214 if (opts.hostile !== undefined) arming.hostile = opts.hostile;
215 if (opts.forgeId !== undefined) arming.forgeId = opts.forgeId;
216 mockAssistantArmings.set(workspaceId, arming);
217}
218
219
220export function clearMockAssistantSeam(workspaceId: string): void {
221 mockAssistantArmings.delete(workspaceId);
222}
223
224
229function platformMaxOutputTokens(): number {
230 const n = Number(process.env.AI_MAX_OUTPUT_TOKENS);
231 return Number.isFinite(n) && n > 0 ? Math.floor(n) : 4096;
232}
233
234
249function adapterTimeouts(): { timeoutMs?: number; idleMs?: number } {
250 const t: { timeoutMs?: number; idleMs?: number } = {};
251 const to = Number(process.env.AI_STREAM_TIMEOUT_MS);
252 if (Number.isFinite(to) && to > 0) t.timeoutMs = Math.floor(to);
253 const idle = Number(process.env.AI_STREAM_IDLE_MS);
254 if (Number.isFinite(idle) && idle > 0) t.idleMs = Math.floor(idle);
255 return t;
256}
257
258
259function envDefaults(): {
260 provider: "openai" | "anthropic" | "google" | "oss" | "custom";
261 chatModel: string;
262 embedModel: string;
263 baseUrl?: string;
264 apiKeyRef?: string;
265} {
266 const env = loadEnv();
267 const parsed = envAiProviderSchema.safeParse(env.AI_PROVIDER);
268
269
270 const provider = !parsed.success || parsed.data === "mock" ? "custom" : parsed.data;
271 return {
272 provider,
273 chatModel: env.AI_MODEL || "gpt-4o",
274 embedModel: process.env.AI_EMBEDDING_MODEL || "text-embedding-3-small",
275 ...(env.AI_BASE_URL ? { baseUrl: env.AI_BASE_URL } : {}),
276 ...(env.AI_API_KEY ? { apiKeyRef: "env:AI_API_KEY" } : {}),
277 };
278}
279
280
287function apiKeyFor(config: ResolvedAiConfig): string | undefined {
288 const ref = config.secretsRef;
289 if (!ref) return undefined;
290 if (ref.startsWith("env:")) return resolveKeyRef(ref, process.env as Record<string, string | undefined>);
291 return process.env.AI_API_KEY;
292}
293
294export interface WorkspaceAiRow {
295 settings: unknown;
296 usageLimits: unknown;
297 billingPlan: string;
298}
299
300
306export function tokensPerMonthFor(row: WorkspaceAiRow, settingsAi: { budgets?: { tokensPerMonth?: number } } | null): number {
307 const usageLimits = (row.usageLimits ?? {}) as { aiMonthlyTokens?: unknown };
308 const fromLimits =
309 typeof usageLimits.aiMonthlyTokens === "number" && Number.isFinite(usageLimits.aiMonthlyTokens)
310 ? Math.max(0, usageLimits.aiMonthlyTokens)
311 : 0;
312 const fromSettings =
313 typeof settingsAi?.budgets?.tokensPerMonth === "number" ? Math.max(0, settingsAi.budgets.tokensPerMonth) : 0;
314 return Math.max(fromLimits, fromSettings);
315}
316
317
323export function buildAiGateway(deps: AiGatewayDeps): AiGateway {
324 const ledger = new UsageLedger({
325 sink: async (record: UsageRecord) => {
326 try {
327 await deps.ledgerSink(record);
328 } catch (err) {
329
330
331 logger.warn(`ai usage sink failed (${record.workspaceId}): ${(err as Error).message}`);
332 }
333 },
334 });
335
336 const budgets = new BudgetService(ledger);
337
338 const resolveConfig = (workspaceId: string): ResolvedAiConfig => {
339 const row = cachedWorkspaceRow(workspaceId);
340 if (!row) {
341
342
343 throw new SharedApiError(SharedErrorCodes.NOT_FOUND, { message: "Workspace not found.", status: 404 });
344 }
345 const settings = (row.settings ?? {}) as { ai?: unknown };
346 const parsed = AiConfigSchema.safeParse(settings.ai ?? {});
347 const workspaceCfg = parsed.success ? parsed.data : {};
348 const config = resolveAiConfig(workspaceCfg, null, envDefaults());
349
350
351 config.workspaceId = workspaceId;
352 config.budgets = {
353 ...config.budgets,
354 tokensPerMonth: tokensPerMonthFor(row, parsed.success ? parsed.data : null),
355
356
357
358
359
360
361
362
363
364
365
366 maxOutputTokens:
367 parsed.success &&
368 (parsed.data as { budgets?: { maxOutputTokens?: number } }).budgets?.maxOutputTokens != null
369 ? (config.budgets.maxOutputTokens)
370 : platformMaxOutputTokens(),
371 };
372 return config;
373 };
374
375 const registry = {
376 get(providerId: ProviderId, config: ResolvedAiConfig): ProviderAdapter {
377 const env = loadEnv();
378 const key = apiKeyFor(config);
379 const baseUrl = config.endpoint?.baseUrl ?? env.AI_BASE_URL ?? "https://api.openai.com/v1";
380
381
382
383 if (env.AI_PROVIDER === "mock") {
384 const mock = new MockProvider(providerId === "anthropic" ? "anthropic" : "custom", embedDimension());
385
386
387 const delay = Number(process.env.AI_MOCK_DELTA_DELAY_MS);
388 if (Number.isFinite(delay) && delay > 0) mock.deltaDelayMs = delay;
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406 if (config.workspaceId) {
407 const armed = mockOutageArmings.get(config.workspaceId);
408 if (armed && armed.deltas > 0) {
409 mock.failAfterDeltas = armed.deltas;
410 mock.failStatus = armed.status;
411 mockOutageArmings.delete(config.workspaceId);
412 }
413
414
415 const unknownId = mockUnknownIdArmings.get(config.workspaceId);
416 if (unknownId) {
417 mockUnknownIdArmings.delete(config.workspaceId);
418 const originalChat = mock.chat.bind(mock);
419 mock.chat = (req: Parameters<typeof originalChat>[0]) =>
420 originalChat({
421 ...req,
422 metadata: { ...req.metadata, ...(req.metadata?.mode === "writing" ? { forceUnknownId: unknownId } : {}) },
423 });
424 }
425
426
427
428
429
430
431
432
433
434
435
436 const dbInvalidAction = mockDbInvalidArmings.get(config.workspaceId);
437 if (dbInvalidAction) {
438 const originalChat2 = mock.chat.bind(mock);
439 mock.chat = (req: Parameters<typeof originalChat2>[0]) =>
440 originalChat2({
441 ...req,
442 metadata: { ...req.metadata, ...(req.metadata?.mode === "database" ? { hostile: dbInvalidAction } : {}) },
443 });
444 }
445
446
447
448
449 const genSeam = mockGenerateArmings.get(config.workspaceId);
450 if (genSeam) {
451 mockGenerateArmings.delete(config.workspaceId);
452 const originalChat3 = mock.chat.bind(mock);
453 mock.chat = (req: Parameters<typeof originalChat3>[0]) =>
454 originalChat3({
455 ...req,
456 metadata: {
457 ...req.metadata,
458 ...(req.metadata?.mode === "generate"
459 ? { ...(genSeam.failStep ? { generateFailStep: genSeam.failStep } : {}), ...(genSeam.slowMs ? { generateSlowMs: genSeam.slowMs } : {}) }
460 : {}),
461 },
462 });
463 }
464
465
466
467 const assistantSeam = mockAssistantArmings.get(config.workspaceId);
468 if (assistantSeam) {
469 const originalChat4 = mock.chat.bind(mock);
470 mock.chat = (req: Parameters<typeof originalChat4>[0]) =>
471 originalChat4({
472 ...req,
473 metadata: {
474 ...req.metadata,
475 ...(req.metadata?.mode === "assistant"
476 ? {
477 ...(assistantSeam.hostile ? { hostile: "assistant" } : {}),
478 ...(assistantSeam.forgeId ? { forgeId: "1" } : {}),
479 }
480 : {}),
481 },
482 });
483 }
484 }
485 return mock;
486 }
487
488
489 const timeouts = adapterTimeouts();
490 switch (providerId) {
491 case "openai":
492 return new OpenAIAdapter({ baseUrl, ...timeouts, ...(key ? { apiKey: key } : {}) });
493 case "anthropic":
494 return new AnthropicAdapter({ ...timeouts, ...(key ? { apiKey: key } : { apiKey: "" }) });
495 case "google":
496 return new GoogleAdapter({ ...timeouts, ...(key ? { apiKey: key } : { apiKey: "" }) });
497 case "oss":
498 return new OssAdapter({ baseUrl, ...timeouts, ...(key ? { apiKey: key } : {}) });
499 case "custom":
500 return new CustomAdapter({ baseUrl, ...timeouts, ...(key ? { apiKey: key } : {}), extraHeaders: config.endpoint?.extraHeaders });
501 }
502 },
503 };
504
505 return new AiGateway({ resolveConfig, registry, budgets, ledger });
506}
507
508
509
510
511
512const workspaceRowCache = new Map<string, WorkspaceAiRow>();
513const cacheTimestamps = new Map<string, number>();
514const WORKSPACE_CACHE_TTL_MS = 30_000;
515
516
522export async function primeWorkspaceCache(prisma: PrismaClient, workspaceId: string): Promise<WorkspaceAiRow> {
523 const hit = workspaceRowCache.get(workspaceId);
524 if (hit && Date.now() - (cacheTimestamps.get(workspaceId) ?? 0) < WORKSPACE_CACHE_TTL_MS) return hit;
525 const row = await prisma.workspace.findUnique({
526 where: { id: workspaceId },
527 select: { settings: true, usageLimits: true, billingPlan: true },
528 });
529 if (!row) throw ApiError.notFound("Workspace not found.");
530 const wsRow = row as unknown as WorkspaceAiRow;
531 workspaceRowCache.set(workspaceId, wsRow);
532 cacheTimestamps.set(workspaceId, Date.now());
533 return wsRow;
534}
535
536
537export function resetWorkspaceCache(): void {
538 workspaceRowCache.clear();
539 cacheTimestamps.clear();
540 mockOutageArmings.clear();
541 mockUnknownIdArmings.clear();
542 mockDbInvalidArmings.clear();
543 mockGenerateArmings.clear();
544 mockAssistantArmings.clear();
545}
546
547function cachedWorkspaceRow(workspaceId: string): WorkspaceAiRow | null {
548 if (Date.now() - (cacheTimestamps.get(workspaceId) ?? 0) >= WORKSPACE_CACHE_TTL_MS) {
549 workspaceRowCache.delete(workspaceId);
550 cacheTimestamps.delete(workspaceId);
551 }
552 return workspaceRowCache.get(workspaceId) ?? null;
553}
554
555
556
557
558
559export interface AiBudgetGuard {
560
569 check(workspaceId: string, maxOutputTokens?: number, req?: Request): Promise<void>;
570
571 usedTokens(workspaceId: string): Promise<number>;
572
573 monthlyBudget(workspaceId: string): Promise<number>;
574}
575
576export function createAiBudgetGuard(opts: {
577 prisma: PrismaClient;
578 redis: Redis;
579 onBudgetExceeded: (workspaceId: string, used: number, limit: number, req?: Request) => Promise<void> | void;
580}): AiBudgetGuard {
581 const { prisma, redis, onBudgetExceeded } = opts;
582
583 function monthStart(): Date {
584 const d = new Date();
585 return new Date(d.getFullYear(), d.getMonth(), 1);
586 }
587
588 async function usedTokens(workspaceId: string): Promise<number> {
589 const rows = await prisma.aiMessage.aggregate({
590 where: { conversation: { workspaceId }, createdAt: { gte: monthStart() } },
591 _sum: { tokensIn: true, tokensOut: true },
592 });
593 return (rows._sum.tokensIn ?? 0) + (rows._sum.tokensOut ?? 0);
594 }
595
596 async function monthlyBudget(workspaceId: string): Promise<number> {
597 const row = await primeWorkspaceCache(prisma, workspaceId);
598 const settings = (row.settings ?? {}) as { ai?: unknown };
599 const parsed = AiConfigSchema.safeParse(settings.ai ?? {});
600 return tokensPerMonthFor(row, parsed.success ? parsed.data : null);
601 }
602
603 async function check(workspaceId: string, maxOutputTokens = 4096, req?: Request): Promise<void> {
604 const row = await primeWorkspaceCache(prisma, workspaceId);
605 const settings = (row.settings ?? {}) as { ai?: unknown };
606 const parsed = AiConfigSchema.safeParse(settings.ai ?? {});
607 const cfg = parsed.success ? parsed.data : null;
608 const limit = tokensPerMonthFor(row, cfg);
609 const requestsPerMinute = typeof cfg?.budgets?.requestsPerMinute === "number" ? cfg.budgets.requestsPerMinute : 60;
610
611
612
613 if (requestsPerMinute > 0) {
614 const minute = Math.floor(Date.now() / 60_000);
615 const key = `ai:rl:ws:${workspaceId}:${minute}`;
616 let count = 0;
617 try {
618 count = (await redis.incr(key)) as number;
619 if (count === 1) await redis.expire(key, 120);
620 } catch (err) {
621
622
623 logger.warn(`ai rate window unreadable (${workspaceId}): ${(err as Error).message}`);
624 }
625 if (count > requestsPerMinute) {
626 throw new SharedApiError(SharedErrorCodes.RATE_LIMITED, {
627 message: "AI rate limit reached. Try again shortly.",
628 details: { code: "RATE_LIMITED", limit: requestsPerMinute, windowSeconds: 60 },
629 });
630 }
631 }
632
633
634
635
636 if (limit > 0) {
637 const used = await usedTokens(workspaceId);
638 if (used + 2 * maxOutputTokens >= limit) {
639 if (await acquireNotificationSlot(redis, workspaceId)) {
640 try {
641 await onBudgetExceeded(workspaceId, used, limit, req);
642 } catch (err) {
643 logger.warn(`ai budget notification failed (${workspaceId}): ${(err as Error).message}`);
644 }
645 }
646 throw new SharedApiError(SharedErrorCodes.AI_ERROR, {
647 status: 402,
648 message: "Monthly AI token budget reached. Ask an admin.",
649 details: { code: "AI_BUDGET_EXCEEDED", reason: "monthly_token_budget_exceeded", used, limit },
650 });
651 }
652 }
653 }
654
655 return { check, usedTokens, monthlyBudget };
656}
657
658
663async function acquireNotificationSlot(redis: Redis, workspaceId: string): Promise<boolean> {
664 const key = `ai:budget:notified:${workspaceId}`;
665 try {
666 const ok = await redis.set(key, "1", "EX", 24 * 3600, "NX");
667 return ok === "OK";
668 } catch {
669 return true;
670 }
671}
672
673
674
675
676
677const SHARED_CODE_TO_API: Record<string, string> = {
678 VALIDATION_ERROR: "VALIDATION_ERROR",
679 UNAUTHORIZED: "UNAUTHORIZED",
680 FORBIDDEN: "FORBIDDEN",
681 NOT_FOUND: "NOT_FOUND",
682 CONFLICT: "CONFLICT",
683 RATE_LIMITED: "RATE_LIMITED",
684 NOT_IMPLEMENTED: "NOT_IMPLEMENTED",
685 SEARCH_UNAVAILABLE: "SERVICE_UNAVAILABLE",
686 AI_ERROR: "SERVICE_UNAVAILABLE",
687 TIMEOUT: "SERVICE_UNAVAILABLE",
688 INTERNAL: "INTERNAL_ERROR",
689};
690
691
697export function mapAiError(err: unknown): ApiError {
698 if (err instanceof ApiError) return err;
699 const shared = err as Partial<SharedApiError> | null;
700 if (shared && typeof shared.code === "string" && typeof shared.status === "number") {
701 const details =
702 shared.details && typeof shared.details === "object" && !Array.isArray(shared.details)
703 ? [shared.details as Record<string, unknown>]
704 : undefined;
705 const budget = details?.find((d) => d?.code === "AI_BUDGET_EXCEEDED") as
706 | { code: string; reason?: string; used?: number; limit?: number }
707 | undefined;
708 if (budget) {
709 return new ApiError("AI_BUDGET_EXCEEDED", 402, shared.message ?? "AI budget exceeded.", [
710 { reason: budget.reason ?? "monthly_token_budget_exceeded", used: budget.used ?? 0, limit: budget.limit ?? 0 },
711 ]);
712 }
713 const code = SHARED_CODE_TO_API[shared.code];
714 if (!code) {
715 logger.warn(`unmapped AI gateway error code: ${shared.code}`);
716 return ApiError.internal("AI request failed.");
717 }
718 return new ApiError(code as ApiError["code"], shared.status, shared.message ?? "AI request failed.", details);
719 }
720 logger.warn(`unmapped AI gateway error: ${err instanceof Error ? err.message : String(err)}`);
721 return ApiError.internal("AI request failed.");
722}
723