Files
hack/tools/scripts/llm-key-hunter/results/_cache/content/ahmedezzatelsayad_garfix.json
T
OpenCode 68a9071cf1 feat: reconcile local master with origin (exa/hot-platform hunters, leaks ledger, latest results)
Local branch had diverged from origin/master (sibling commits on the same base). Rewrote local history linearly on top of origin/master, folding in all local content: exa/hot-platform discovery hunters, leaks ledger, nightly sweep orchestrator, updated .gitignore and skill docs, plus the latest hunt output and vault state. Remote-only files (vault keys, channel scripts) were restored rather than dropped, so the resulting tree is a full union of both sides.
2026-08-07 12:53:47 +08:00

1 line
122 KiB
JSON
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{"repo": "ahmedezzatelsayad/garfix", "files": {"src/lib/aiProvider.ts": {"50c7ea724fd840362c0bc714c0c5e60cba4c949a": {"t": 1785746412.190035, "n": 22699, "h": "459f8202f4cef834", "c": "/**\n * aiProvider.ts — Unified AI Provider Abstraction Layer.\n *\n * Replaces the hardcoded z-ai-web-dev-sdk calls with a provider-agnostic\n * interface that supports: OpenRouter, Anthropic, OpenAI, Google Gemini,\n * z-ai/GLM, and any OpenAI-compatible endpoint.\n *\n * Features:\n * - Fallback chain: if primary provider fails, try the next\n * - Encrypted API key storage via cryptoVault\n * - Test connection per provider\n * - Config stored in PlatformSetting table (editable by founder)\n *\n * Usage:\n * import { getAiClient, callAI } from \"@/lib/aiProvider\";\n * const result = await callAI({ messages, companySlug });\n */\n\nimport { db } from \"./db\";\nimport { decryptSecret, encryptSecret } from \"./cryptoVault\";\nimport { logger } from \"./logger\";\nimport { z } from \"zod\";\n\n// P0 FIX (audit finding: missing timeout on AI external API calls):\n// All AI provider HTTP calls now go through `fetchWithTimeout`. A hung\n// upstream (slow model, network stall, deepseek-r1 thinking for minutes)\n// used to keep a server connection open indefinitely, exhausting the\n// connection pool under load. 60s is the upper bound — most calls finish\n// in <10s, so 60s gives plenty of slack for slow reasoning models while\n// still bounding the worst case.\nconst DEFAULT_AI_TIMEOUT_MS = 60_000;\n\nasync function fetchWithTimeout(\n url: string,\n init: RequestInit,\n timeoutMs: number = DEFAULT_AI_TIMEOUT_MS,\n): Promise<Response> {\n const controller = new AbortController();\n const timer = setTimeout(() => controller.abort(), timeoutMs);\n try {\n return await fetch(url, { ...init, signal: controller.signal });\n } catch (err) {\n if (err instanceof Error && err.name === \"AbortError\") {\n throw new Error(`AI request timed out after ${timeoutMs}ms — ${url}`);\n }\n throw err;\n } finally {\n clearTimeout(timer);\n }\n}\n\n// SEC-006 FIX: SSRF protection for custom AI endpoints\nfunction validateBaseUrl(url: string): void {\n const parsed = new URL(url);\n if (![\"https:\"].includes(parsed.protocol)) {\n throw new Error(\"Custom AI endpoint must use HTTPS\");\n }\n const blockedHosts = [\"localhost\", \"127.0.0.1\", \"0.0.0.0\", \"169.254.169.254\", \"::1\", \"[::1]\"];\n if (blockedHosts.includes(parsed.hostname)) {\n throw new Error(\"Internal addresses are not allowed for custom AI endpoint\");\n }\n // Block private IP ranges (RFC 1918) — proper CIDR checks\n // FIX: Previously blocked ALL 172.x.x.x and 192.x.x.x, which incorrectly\n // blocked public IPs like 172.5.1.2 (only 172.16-31 is private per RFC 1918)\n const ipMatch = parsed.hostname.match(/^(\\d+)\\.(\\d+)\\.(\\d+)\\.(\\d+)$/);\n if (ipMatch) {\n const [, a, b] = ipMatch;\n const first = parseInt(a), second = parseInt(b);\n // 10.0.0.0/8 — all 10.x.x.x is private\n if (first === 10) throw new Error(\"Private/internal IP addresses are not allowed for custom AI endpoint\");\n // 172.16.0.0/12 — only 172.16-31.x.x is private (172.0-15 and 172.32-255 are PUBLIC)\n if (first === 172 && second >= 16 && second <= 31) throw new Error(\"Private/internal IP addresses are not allowed for custom AI endpoint\");\n // 192.168.0.0/16 — only 192.168.x.x is private (192.0-167 and 192.169-255 are PUBLIC)\n if (first === 192 && second === 168) throw new Error(\"Private/internal IP addresses are not allowed for custom AI endpoint\");\n // 127.0.0.0/8 — loopback\n if (first === 127) throw new Error(\"Loopback addresses are not allowed for custom AI endpoint\");\n }\n}\n\n// ─── Types ──────────────────────────────────────────────────────────────────\n\nexport type ProviderType = \"z-ai\" | \"openrouter\" | \"anthropic\" | \"openai\" | \"gemini\" | \"deepseek\" | \"custom\";\n\nexport interface AiProviderConfig {\n provider: ProviderType;\n apiKey: string | null; // decrypted\n model: string;\n baseUrl?: string; // for custom OpenAI-compatible\n isEnabled: boolean;\n priority: number; // 1 = primary, 2 = first fallback, etc.\n}\n\nexport interface ChatMessage {\n role: \"system\" | \"user\" | \"assistant\";\n content: string | Array<{ type: string; text?: string; image_url?: { url: string } }>;\n}\n\nexport interface ChatOptions {\n messages: ChatMessage[];\n temperature?: number;\n maxTokens?: number;\n companySlug?: string;\n}\n\nexport interface ChatResult {\n content: string;\n usage: { prompt_tokens: number; completion_tokens: number; total_tokens: number };\n provider: ProviderType;\n model: string;\n}\n\nexport interface AiProvider {\n type: ProviderType;\n name: string;\n chat(options: ChatOptions): Promise<ChatResult>;\n testConnection(): Promise<boolean>;\n}\n\n// ─── Provider implementations ───────────────────────────────────────────────\n\n/** z-ai / GLM provider — uses z-ai-web-dev-sdk (no API key needed in sandbox) */\nclass ZaiProvider implements AiProvider {\n type: ProviderType = \"z-ai\";\n name = \"z-ai / GLM\";\n private config: AiProviderConfig;\n\n constructor(config: AiProviderConfig) { this.config = config; }\n\n async chat(options: ChatOptions): Promise<ChatResult> {\n const ZAI = (await import(\"z-ai-web-dev-sdk\")).default;\n const ai = await ZAI.create();\n const completion = await ai.chat.completions.create({\n messages: options.messages.map(m => ({\n role: m.role,\n content: m.content as string,\n })),\n temperature: options.temperature ?? 0.4,\n max_tokens: options.maxTokens ?? 800,\n });\n return {\n content: completion.choices?.[0]?.message?.content || \"\",\n usage: completion.usage || { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 },\n provider: this.type,\n model: this.config.model || \"z-ai-glm\",\n };\n }\n\n async testConnection(): Promise<boolean> {\n try {\n const result = await this.chat({ messages: [{ role: \"user\", content: \"ping\" }] });\n return !!result.content;\n } catch { return false; }\n }\n}\n\n/** OpenAI-compatible provider — works for OpenAI, OpenRouter, DeepSeek, and custom endpoints */\nclass OpenAICompatibleProvider implements AiProvider {\n type: ProviderType;\n name: string;\n private config: AiProviderConfig;\n\n constructor(config: AiProviderConfig, type: ProviderType, name: string) {\n this.config = config;\n this.type = type;\n this.name = name;\n }\n\n private getBaseUrl(): string {\n switch (this.type) {\n case \"openrouter\": return \"https://openrouter.ai/api/v1\";\n case \"openai\": return \"https://api.openai.com/v1\";\n case \"deepseek\": return \"https://api.deepseek.com/v1\";\n case \"gemini\": return \"https://generativelanguage.googleapis.com/v1beta/openai\";\n case \"custom\": {\n // SEC-006 FIX: SSRF protection — validate custom baseUrl\n const url = this.config.baseUrl || \"\";\n if (url) validateBaseUrl(url);\n return url;\n }\n default: return \"\";\n }\n }\n\n async chat(options: ChatOptions): Promise<ChatResult> {\n if (!this.config.apiKey) throw new Error(`No API key for ${this.name}`);\n\n const baseUrl = this.getBaseUrl();\n const headers: Record<string, string> = {\n \"Content-Type\": \"application/json\",\n \"Authorization\": `Bearer ${this.config.apiKey}`,\n };\n\n // OpenRouter-specific headers\n if (this.type === \"openrouter\") {\n headers[\"HTTP-Referer\"] = process.env.APP_URL || \"http://localhost:3000\";\n headers[\"X-Title\"] = \"GarfiX ERP\";\n }\n\n // DeepSeek-specific: supports frequency_penalty and other params\n // (no extra headers needed — uses standard Bearer auth like OpenAI)\n\n const body: Record<string, unknown> = {\n model: this.config.model,\n messages: options.messages.map(m => ({\n role: m.role,\n content: typeof m.content === \"string\" ? m.content : m.content,\n })),\n temperature: options.temperature ?? 0.4,\n };\n if (options.maxTokens) body.max_tokens = options.maxTokens;\n\n const res = await fetchWithTimeout(\n `${baseUrl}/chat/completions`,\n {\n method: \"POST\",\n headers,\n body: JSON.stringify(body),\n },\n 60_000, // P0 FIX (audit finding: missing timeout on AI external calls)\n );\n\n if (!res.ok) {\n const err = await res.text();\n throw new Error(`${this.name} API error ${res.status}: ${err.slice(0, 200)}`);\n }\n\n const data = await res.json();\n return {\n content: data.choices?.[0]?.message?.content || \"\",\n usage: data.usage || { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 },\n provider: this.type,\n model: this.config.model,\n };\n }\n\n async testConnection(): Promise<boolean> {\n try {\n const result = await this.chat({\n messages: [{ role: \"user\", content: \"ping\" }],\n maxTokens: 5,\n });\n return !!result.content;\n } catch { return false; }\n }\n}\n\n/** Anthropic (Claude) provider — uses Anthropic Messages API directly */\nclass AnthropicProvider implements AiProvider {\n type: ProviderType = \"anthropic\";\n name = \"Anthropic (Claude)\";\n private config: AiProviderConfig;\n\n constructor(config: AiProviderConfig) { this.config = config; }\n\n async chat(options: ChatOptions): Promise<ChatResult> {\n if (!this.config.apiKey) throw new Error(\"No Anthropic API key\");\n\n // Extract system message\n const systemMsg = options.messages.find(m => m.role === \"system\");\n const chatMessages = options.messages.filter(m => m.role !== \"system\");\n\n const res = await fetchWithTimeout(\n \"https://api.anthropic.com/v1/messages\",\n {\n method: \"POST\",\n headers: {\n \"Content-Type\": \"application/json\",\n \"x-api-key\": this.config.apiKey,\n \"anthropic-version\": \"2023-06-01\",\n },\n body: JSON.stringify({\n model: this.config.model,\n max_tokens: options.maxTokens ?? 800,\n system: typeof systemMsg?.content === \"string\" ? systemMsg.content : \"\",\n messages: chatMessages.map(m => ({\n role: m.role,\n content: typeof m.content === \"string\" ? m.content : JSON.stringify(m.content),\n })),\n }),\n },\n 60_000, // P0 FIX: timeout on AI external calls\n );\n\n if (!res.ok) throw new Error(`Anthropic API error ${res.status}`);\n\n const data = await res.json();\n const content = data.content?.[0]?.text || \"\";\n return {\n content,\n usage: {\n prompt_tokens: data.usage?.input_tokens || 0,\n completion_tokens: data.usage?.output_tokens || 0,\n total_tokens: (data.usage?.input_tokens || 0) + (data.usage?.output_tokens || 0),\n },\n provider: this.type,\n model: this.config.model,\n };\n }\n\n async testConnection(): Promise<boolean> {\n try {\n const result = await this.chat({ messages: [{ role: \"user\", content: \"ping\" }], maxTokens: 5 });\n return !!result.content;\n } catch { return false; }\n }\n}\n\n// ─── Config resolution ──────────────────────────────────────────────────────\n\nlet cachedConfig: AiProviderConfig[] | null = null;\nlet cacheExpiry = 0;\n\n/**\n * Get the ordered list of AI providers from PlatformSetting.\n * Falls back to z-ai/GLM if no providers configured.\n */\nexport async function getAiProviders(): Promise<AiProviderConfig[]> {\n if (cachedConfig && Date.now() < cacheExpiry) return cachedConfig;\n\n try {\n const settings = await db.platformSettings.findMany({\n where: { key: { startsWith: \"ai.provider.\" } },\n });\n\n if (settings.length === 0) {\n // Default: z-ai only\n return [{\n provider: \"z-ai\",\n apiKey: null,\n model: \"z-ai-glm\",\n isEnabled: true,\n priority: 1,\n }];\n }\n\n // Parse provider configs from settings\n const providers: AiProviderConfig[] = [];\n const providerMap = new Map<string, Record<string, unknown>>();\n\n for (const s of settings) {\n // Keys: ai.provider.{type}.{field} = ai.provider.openrouter.apiKey, etc.\n const parts = s.key.split(\".\");\n if (parts.length < 4) continue;\n const pType = parts[2] as ProviderType;\n const field = parts[3];\n\n if (!providerMap.has(pType)) providerMap.set(pType, { provider: pType, isEnabled: false, priority: 99 });\n const entry = providerMap.get(pType)!;\n try {\n const val = JSON.parse(s.value);\n if (field === \"apiKey\" && typeof val === \"string\") {\n entry.apiKey = decryptSecret(val);\n } else if (field === \"model\") {\n entry.model = val;\n } else if (field === \"baseUrl\") {\n entry.baseUrl = val;\n } else if (field === \"isEnabled\") {\n entry.isEnabled = val === true;\n } else if (field === \"priority\") {\n entry.priority = typeof val === \"number\" ? val : 99;\n }\n } catch { /* skip malformed */ }\n }\n\n for (const [, entry] of providerMap) {\n providers.push(entry as unknown as AiProviderConfig);\n }\n\n // Sort by priority\n providers.sort((a, b) => a.priority - b.priority);\n\n // If no enabled providers, fall back to z-ai\n if (providers.filter(p => p.isEnabled).length === 0) {\n providers.unshift({\n provider: \"z-ai\", apiKey: null, model: \"z-ai-glm\",\n isEnabled: true, priority: 0,\n });\n }\n\n cachedConfig = providers;\n cacheExpiry = Date.now() + 300_000; // 5 min cache (PERF-005 FIX: was 1 min)\n return providers;\n } catch (err) {\n logger.error(\"[aiProvider] failed to load config\", { err: err instanceof Error ? err.message : String(err) });\n return [{\n provider: \"z-ai\", apiKey: null, model: \"z-ai-glm\",\n isEnabled: true, priority: 1,\n }];\n }\n}\n\n/** Invalidate the cached provider config — call after founder edits settings */\nexport function invalidateAiProviderCache(): void {\n cachedConfig = null;\n cacheExpiry = 0;\n}\n\n/** Create the appropriate provider instance from config */\nfunction createProvider(config: AiProviderConfig): AiProvider | null {\n if (!config.isEnabled) return null;\n switch (config.provider) {\n case \"z-ai\": return new ZaiProvider(config);\n case \"openrouter\": return new OpenAICompatibleProvider(config, \"openrouter\", \"OpenRouter\");\n case \"openai\": return new OpenAICompatibleProvider(config, \"openai\", \"OpenAI\");\n case \"anthropic\": return new AnthropicProvider(config);\n case \"gemini\": return new OpenAICompatibleProvider(config, \"gemini\", \"Google Gemini\");\n case \"deepseek\": return new OpenAICompatibleProvider(config, \"deepseek\", \"DeepSeek\");\n case \"custom\": return new OpenAICompatibleProvider(config, \"custom\", \"Custom\");\n default: return null;\n }\n}\n\n/**\n * Call AI with automatic fallback chain.\n * Tries providers in priority order until one succeeds.\n *\n * SAFETY NET: z-ai (sandbox, no API key needed) is ALWAYS appended as the\n * last-resort fallback, even if it's not in the configured providers list.\n * This guarantees AI never goes fully dark — even when OpenRouter /\n * Anthropic / OpenAI are configured but invalid / expired / rate-limited.\n */\nexport async function callAI(options: ChatOptions): Promise<ChatResult> {\n const providers = await getAiProviders();\n\n // Build the effective chain: configured providers + z-ai as last resort\n // (deduped — if z-ai is already in the chain, don't duplicate it)\n const hasZai = providers.some(p => p.provider === \"z-ai\" && p.isEnabled);\n const effectiveChain = hasZai\n ? providers\n : [\n ...providers,\n {\n provider: \"z-ai\" as ProviderType,\n apiKey: null,\n model: \"z-ai-glm\",\n isEnabled: true,\n priority: 999,\n },\n ];\n\n for (const config of effectiveChain) {\n const provider = createProvider(config);\n if (!provider) continue;\n\n try {\n logger.debug(\"[aiProvider] calling provider\", { provider: config.provider, model: config.model });\n const result = await provider.chat(options);\n logger.info(\"[aiProvider] success\", { provider: result.provider, tokens: result.usage.total_tokens });\n return result;\n } catch (err) {\n logger.warn(\"[aiProvider] provider failed — trying fallback\", { provider: config.provider, err: err instanceof Error ? err.message : String(err) });\n continue;\n }\n }\n\n // All providers failed (including z-ai) — this should be extremely rare\n // since z-ai is a sandbox SDK that doesn't require network calls to a paid\n // API. If we get here, the z-ai SDK itself is broken.\n throw new Error(\"جميع مزودي الذكاء الاصطناعي فشلوا. تحقق من الإعدادات.\");\n}\n\n/**\n * Call ONE specific provider — no fallback chain.\n *\n * Used by the AI Orchestration Layer's Smart Router, which manages its own\n * fallback chain across registry models. Throws on failure so the caller can\n * catch and escalate to the next model.\n *\n * The config is fully resolved by the caller (provider + decrypted apiKey +\n * model + baseUrl). This function just instantiates the provider and calls it.\n */\nexport async function callSingleProvider(\n config: AiProviderConfig,\n options: ChatOptions,\n): Promise<ChatResult> {\n const provider = createProvider({ ...config, isEnabled: true });\n if (!provider) {\n throw new Error(`Provider ${config.provider} could not be instantiated`);\n }\n logger.debug(\"[aiProvider] calling single provider\", {\n provider: config.provider,\n model: config.model,\n });\n const result = await provider.chat(options);\n logger.info(\"[aiProvider] single-provider success\", {\n provider: result.provider,\n model: result.model,\n tokens: result.usage.total_tokens,\n });\n return result;\n}\n\n/**\n * Test a specific provider's connection.\n * Used by the admin UI \"test connection\" button.\n *\n * Tests regardless of `isEnabled` state so the founder can validate\n * credentials before activating a provider.\n */\nexport async function testProviderConnection(providerType: ProviderType): Promise<{ ok: boolean; error?: string }> {\n const providers = await getAiProviders();\n const config = providers.find(p => p.provider === providerType);\n\n // If the provider is configured (even disabled), use that config.\n // Otherwise fall back to PROVIDER_INFO defaults so a key can be tested\n // even before any setting has been written.\n let resolved: AiProviderConfig;\n if (config) {\n resolved = { ...config, isEnabled: true };\n } else {\n const info = PROVIDER_INFO.find(p => p.type === providerType);\n if (!info) return { ok: false, error: \"Unknown provider\" };\n resolved = {\n provider: providerType,\n apiKey: null,\n model: info.defaultModel,\n isEnabled: true,\n priority: 99,\n };\n }\n\n const provider = createProvider(resolved);\n if (!provider) return { ok: false, error: \"Provider not configured\" };\n\n try {\n const ok = await provider.testConnection();\n return { ok, error: ok ? undefined : \"Connection failed\" };\n } catch (err) {\n return {\n ok: false,\n error: err instanceof Error ? err.message.slice(0, 200) : \"Connection failed\",\n };\n }\n}\n\n/**\n * Save a provider's API key (encrypted) to PlatformSetting.\n */\nexport async function setProviderApiKey(providerType: ProviderType, apiKey: string): Promise<void> {\n const encrypted = encryptSecret(apiKey);\n const key = `ai.provider.${providerType}.apiKey`;\n\n const existing = await db.platformSettings.findUnique({ where: { key } });\n if (existing) {\n await db.platformSettings.update({ where: { key }, data: { value: JSON.stringify(encrypted), updatedAt: new Date() } });\n } else {\n await db.platformSettings.create({ data: { key, category: \"ai\", valueType: \"string\", value: JSON.stringify(encrypted) } });\n }\n\n // Log to history\n await db.platformSettingsHistory.create({\n data: { settingKey: key, newValue: \"[encrypted]\", changedBy: \"system\" },\n });\n\n invalidateAiProviderCache();\n logger.info(\"[aiProvider] API key updated\", { provider: providerType });\n}\n\n/**\n * Set the provider's model.\n */\nexport async function setProviderModel(providerType: ProviderType, model: string): Promise<void> {\n const key = `ai.provider.${providerType}.model`;\n const existing = await db.platformSettings.findUnique({ where: { key } });\n if (existing) {\n await db.platformSettings.update({ where: { key }, data: { value: JSON.stringify(model), updatedAt: new Date() } });\n } else {\n await db.platformSettings.create({ data: { key, category: \"ai\", valueType: \"string\", value: JSON.stringify(model) } });\n }\n invalidateAiProviderCache();\n}\n\n/**\n * Enable/disable a provider.\n */\nexport async function setProviderEnabled(providerType: ProviderType, isEnabled: boolean): Promise<void> {\n const key = `ai.provider.${providerType}.isEnabled`;\n const existing = await db.platformSettings.findUnique({ where: { key } });\n if (existing) {\n await db.platformSettings.update({ where: { key }, data: { value: JSON.stringify(isEnabled), updatedAt: new Date() } });\n } else {\n await db.platformSettings.create({ data: { key, category: \"ai\", valueType: \"boolean\", value: JSON.stringify(isEnabled) } });\n }\n invalidateAiProviderCache();\n}\n\n/**\n * Set provider priority (for fallback ordering).\n */\nexport async function setProviderPriority(providerType: ProviderType, priority: number): Promise<void> {\n const key = `ai.provider.${providerType}.priority`;\n const existing = await db.platformSettings.findUnique({ where: { key } });\n if (existing) {\n await db.platformSettings.update({ where: { key }, data: { value: JSON.stringify(priority), updatedAt: new Date() } });\n } else {\n await db.platformSettings.create({ data: { key, category: \"ai\", valueType: \"number\", value: JSON.stringify(priority) } });\n }\n invalidateAiProviderCache();\n}\n\nexport const PROVIDER_INFO: Array<{ type: ProviderType; name: string; description: string; defaultModel: string; keyPrefix: string }> = [\n { type: \"z-ai\", name: \"z-ai / GLM\", description: \"المزود الافتراضي — لا يحتاج مفتاح API في بيئة التطوير\", defaultModel: \"z-ai-glm\", keyPrefix: \"\" },\n { type: \"openrouter\", name: \"OpenRouter\", description: \"بوابة لعشرات الموديلات بمفتاح واحد (Claude, GPT, Gemini, Llama...)\", defaultModel: \"anthropic/claude-3.5-haiku\", keyPrefix: \"sk-or-\" },\n { type: \"anthropic\", name: \"Anthropic (Claude)\", description: \"مباشر من Anthropic — أفضل جودة للعربية\", defaultModel: \"claude-3-5-haiku-20241022\", keyPrefix: \"sk-ant-\" },\n { type: \"openai\", name: \"OpenAI (GPT)\", description: \"مباشر من OpenAI\", defaultModel: \"gpt-4o-mini\", keyPrefix: \"sk-\" },\n { type: \"deepseek\", name: \"DeepSeek\", description: \"مباشر من DeepSeek — موديلات V3 و R1 بسعر منخفض جداً وجودة عالية\", defaultModel: \"deepseek-chat\", keyPrefix: \"sk-\" },\n { type: \"gemini\", name: \"Google Gemini\", description: \"مباشر من Google (عبر OpenAI-compatible API)\", defaultModel: \"gemini-1.5-flash\", keyPrefix: \"AIza\" },\n { type: \"custom\", name: \"مزود مخصص\", description: \"أي endpoint متوافق مع OpenAI API (self-hosted, Azure, etc.)\", defaultModel: \"\", keyPrefix: \"\" },\n];\n"}, "8bb76c3bae0c3a91abca31e454e1fa9abd67ace8": {"t": 1785763675.3104417, "n": 22699, "h": "459f8202f4cef834", "c": "/**\n * aiProvider.ts — Unified AI Provider Abstraction Layer.\n *\n * Replaces the hardcoded z-ai-web-dev-sdk calls with a provider-agnostic\n * interface that supports: OpenRouter, Anthropic, OpenAI, Google Gemini,\n * z-ai/GLM, and any OpenAI-compatible endpoint.\n *\n * Features:\n * - Fallback chain: if primary provider fails, try the next\n * - Encrypted API key storage via cryptoVault\n * - Test connection per provider\n * - Config stored in PlatformSetting table (editable by founder)\n *\n * Usage:\n * import { getAiClient, callAI } from \"@/lib/aiProvider\";\n * const result = await callAI({ messages, companySlug });\n */\n\nimport { db } from \"./db\";\nimport { decryptSecret, encryptSecret } from \"./cryptoVault\";\nimport { logger } from \"./logger\";\nimport { z } from \"zod\";\n\n// P0 FIX (audit finding: missing timeout on AI external API calls):\n// All AI provider HTTP calls now go through `fetchWithTimeout`. A hung\n// upstream (slow model, network stall, deepseek-r1 thinking for minutes)\n// used to keep a server connection open indefinitely, exhausting the\n// connection pool under load. 60s is the upper bound — most calls finish\n// in <10s, so 60s gives plenty of slack for slow reasoning models while\n// still bounding the worst case.\nconst DEFAULT_AI_TIMEOUT_MS = 60_000;\n\nasync function fetchWithTimeout(\n url: string,\n init: RequestInit,\n timeoutMs: number = DEFAULT_AI_TIMEOUT_MS,\n): Promise<Response> {\n const controller = new AbortController();\n const timer = setTimeout(() => controller.abort(), timeoutMs);\n try {\n return await fetch(url, { ...init, signal: controller.signal });\n } catch (err) {\n if (err instanceof Error && err.name === \"AbortError\") {\n throw new Error(`AI request timed out after ${timeoutMs}ms — ${url}`);\n }\n throw err;\n } finally {\n clearTimeout(timer);\n }\n}\n\n// SEC-006 FIX: SSRF protection for custom AI endpoints\nfunction validateBaseUrl(url: string): void {\n const parsed = new URL(url);\n if (![\"https:\"].includes(parsed.protocol)) {\n throw new Error(\"Custom AI endpoint must use HTTPS\");\n }\n const blockedHosts = [\"localhost\", \"127.0.0.1\", \"0.0.0.0\", \"169.254.169.254\", \"::1\", \"[::1]\"];\n if (blockedHosts.includes(parsed.hostname)) {\n throw new Error(\"Internal addresses are not allowed for custom AI endpoint\");\n }\n // Block private IP ranges (RFC 1918) — proper CIDR checks\n // FIX: Previously blocked ALL 172.x.x.x and 192.x.x.x, which incorrectly\n // blocked public IPs like 172.5.1.2 (only 172.16-31 is private per RFC 1918)\n const ipMatch = parsed.hostname.match(/^(\\d+)\\.(\\d+)\\.(\\d+)\\.(\\d+)$/);\n if (ipMatch) {\n const [, a, b] = ipMatch;\n const first = parseInt(a), second = parseInt(b);\n // 10.0.0.0/8 — all 10.x.x.x is private\n if (first === 10) throw new Error(\"Private/internal IP addresses are not allowed for custom AI endpoint\");\n // 172.16.0.0/12 — only 172.16-31.x.x is private (172.0-15 and 172.32-255 are PUBLIC)\n if (first === 172 && second >= 16 && second <= 31) throw new Error(\"Private/internal IP addresses are not allowed for custom AI endpoint\");\n // 192.168.0.0/16 — only 192.168.x.x is private (192.0-167 and 192.169-255 are PUBLIC)\n if (first === 192 && second === 168) throw new Error(\"Private/internal IP addresses are not allowed for custom AI endpoint\");\n // 127.0.0.0/8 — loopback\n if (first === 127) throw new Error(\"Loopback addresses are not allowed for custom AI endpoint\");\n }\n}\n\n// ─── Types ──────────────────────────────────────────────────────────────────\n\nexport type ProviderType = \"z-ai\" | \"openrouter\" | \"anthropic\" | \"openai\" | \"gemini\" | \"deepseek\" | \"custom\";\n\nexport interface AiProviderConfig {\n provider: ProviderType;\n apiKey: string | null; // decrypted\n model: string;\n baseUrl?: string; // for custom OpenAI-compatible\n isEnabled: boolean;\n priority: number; // 1 = primary, 2 = first fallback, etc.\n}\n\nexport interface ChatMessage {\n role: \"system\" | \"user\" | \"assistant\";\n content: string | Array<{ type: string; text?: string; image_url?: { url: string } }>;\n}\n\nexport interface ChatOptions {\n messages: ChatMessage[];\n temperature?: number;\n maxTokens?: number;\n companySlug?: string;\n}\n\nexport interface ChatResult {\n content: string;\n usage: { prompt_tokens: number; completion_tokens: number; total_tokens: number };\n provider: ProviderType;\n model: string;\n}\n\nexport interface AiProvider {\n type: ProviderType;\n name: string;\n chat(options: ChatOptions): Promise<ChatResult>;\n testConnection(): Promise<boolean>;\n}\n\n// ─── Provider implementations ───────────────────────────────────────────────\n\n/** z-ai / GLM provider — uses z-ai-web-dev-sdk (no API key needed in sandbox) */\nclass ZaiProvider implements AiProvider {\n type: ProviderType = \"z-ai\";\n name = \"z-ai / GLM\";\n private config: AiProviderConfig;\n\n constructor(config: AiProviderConfig) { this.config = config; }\n\n async chat(options: ChatOptions): Promise<ChatResult> {\n const ZAI = (await import(\"z-ai-web-dev-sdk\")).default;\n const ai = await ZAI.create();\n const completion = await ai.chat.completions.create({\n messages: options.messages.map(m => ({\n role: m.role,\n content: m.content as string,\n })),\n temperature: options.temperature ?? 0.4,\n max_tokens: options.maxTokens ?? 800,\n });\n return {\n content: completion.choices?.[0]?.message?.content || \"\",\n usage: completion.usage || { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 },\n provider: this.type,\n model: this.config.model || \"z-ai-glm\",\n };\n }\n\n async testConnection(): Promise<boolean> {\n try {\n const result = await this.chat({ messages: [{ role: \"user\", content: \"ping\" }] });\n return !!result.content;\n } catch { return false; }\n }\n}\n\n/** OpenAI-compatible provider — works for OpenAI, OpenRouter, DeepSeek, and custom endpoints */\nclass OpenAICompatibleProvider implements AiProvider {\n type: ProviderType;\n name: string;\n private config: AiProviderConfig;\n\n constructor(config: AiProviderConfig, type: ProviderType, name: string) {\n this.config = config;\n this.type = type;\n this.name = name;\n }\n\n private getBaseUrl(): string {\n switch (this.type) {\n case \"openrouter\": return \"https://openrouter.ai/api/v1\";\n case \"openai\": return \"https://api.openai.com/v1\";\n case \"deepseek\": return \"https://api.deepseek.com/v1\";\n case \"gemini\": return \"https://generativelanguage.googleapis.com/v1beta/openai\";\n case \"custom\": {\n // SEC-006 FIX: SSRF protection — validate custom baseUrl\n const url = this.config.baseUrl || \"\";\n if (url) validateBaseUrl(url);\n return url;\n }\n default: return \"\";\n }\n }\n\n async chat(options: ChatOptions): Promise<ChatResult> {\n if (!this.config.apiKey) throw new Error(`No API key for ${this.name}`);\n\n const baseUrl = this.getBaseUrl();\n const headers: Record<string, string> = {\n \"Content-Type\": \"application/json\",\n \"Authorization\": `Bearer ${this.config.apiKey}`,\n };\n\n // OpenRouter-specific headers\n if (this.type === \"openrouter\") {\n headers[\"HTTP-Referer\"] = process.env.APP_URL || \"http://localhost:3000\";\n headers[\"X-Title\"] = \"GarfiX ERP\";\n }\n\n // DeepSeek-specific: supports frequency_penalty and other params\n // (no extra headers needed — uses standard Bearer auth like OpenAI)\n\n const body: Record<string, unknown> = {\n model: this.config.model,\n messages: options.messages.map(m => ({\n role: m.role,\n content: typeof m.content === \"string\" ? m.content : m.content,\n })),\n temperature: options.temperature ?? 0.4,\n };\n if (options.maxTokens) body.max_tokens = options.maxTokens;\n\n const res = await fetchWithTimeout(\n `${baseUrl}/chat/completions`,\n {\n method: \"POST\",\n headers,\n body: JSON.stringify(body),\n },\n 60_000, // P0 FIX (audit finding: missing timeout on AI external calls)\n );\n\n if (!res.ok) {\n const err = await res.text();\n throw new Error(`${this.name} API error ${res.status}: ${err.slice(0, 200)}`);\n }\n\n const data = await res.json();\n return {\n content: data.choices?.[0]?.message?.content || \"\",\n usage: data.usage || { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 },\n provider: this.type,\n model: this.config.model,\n };\n }\n\n async testConnection(): Promise<boolean> {\n try {\n const result = await this.chat({\n messages: [{ role: \"user\", content: \"ping\" }],\n maxTokens: 5,\n });\n return !!result.content;\n } catch { return false; }\n }\n}\n\n/** Anthropic (Claude) provider — uses Anthropic Messages API directly */\nclass AnthropicProvider implements AiProvider {\n type: ProviderType = \"anthropic\";\n name = \"Anthropic (Claude)\";\n private config: AiProviderConfig;\n\n constructor(config: AiProviderConfig) { this.config = config; }\n\n async chat(options: ChatOptions): Promise<ChatResult> {\n if (!this.config.apiKey) throw new Error(\"No Anthropic API key\");\n\n // Extract system message\n const systemMsg = options.messages.find(m => m.role === \"system\");\n const chatMessages = options.messages.filter(m => m.role !== \"system\");\n\n const res = await fetchWithTimeout(\n \"https://api.anthropic.com/v1/messages\",\n {\n method: \"POST\",\n headers: {\n \"Content-Type\": \"application/json\",\n \"x-api-key\": this.config.apiKey,\n \"anthropic-version\": \"2023-06-01\",\n },\n body: JSON.stringify({\n model: this.config.model,\n max_tokens: options.maxTokens ?? 800,\n system: typeof systemMsg?.content === \"string\" ? systemMsg.content : \"\",\n messages: chatMessages.map(m => ({\n role: m.role,\n content: typeof m.content === \"string\" ? m.content : JSON.stringify(m.content),\n })),\n }),\n },\n 60_000, // P0 FIX: timeout on AI external calls\n );\n\n if (!res.ok) throw new Error(`Anthropic API error ${res.status}`);\n\n const data = await res.json();\n const content = data.content?.[0]?.text || \"\";\n return {\n content,\n usage: {\n prompt_tokens: data.usage?.input_tokens || 0,\n completion_tokens: data.usage?.output_tokens || 0,\n total_tokens: (data.usage?.input_tokens || 0) + (data.usage?.output_tokens || 0),\n },\n provider: this.type,\n model: this.config.model,\n };\n }\n\n async testConnection(): Promise<boolean> {\n try {\n const result = await this.chat({ messages: [{ role: \"user\", content: \"ping\" }], maxTokens: 5 });\n return !!result.content;\n } catch { return false; }\n }\n}\n\n// ─── Config resolution ──────────────────────────────────────────────────────\n\nlet cachedConfig: AiProviderConfig[] | null = null;\nlet cacheExpiry = 0;\n\n/**\n * Get the ordered list of AI providers from PlatformSetting.\n * Falls back to z-ai/GLM if no providers configured.\n */\nexport async function getAiProviders(): Promise<AiProviderConfig[]> {\n if (cachedConfig && Date.now() < cacheExpiry) return cachedConfig;\n\n try {\n const settings = await db.platformSettings.findMany({\n where: { key: { startsWith: \"ai.provider.\" } },\n });\n\n if (settings.length === 0) {\n // Default: z-ai only\n return [{\n provider: \"z-ai\",\n apiKey: null,\n model: \"z-ai-glm\",\n isEnabled: true,\n priority: 1,\n }];\n }\n\n // Parse provider configs from settings\n const providers: AiProviderConfig[] = [];\n const providerMap = new Map<string, Record<string, unknown>>();\n\n for (const s of settings) {\n // Keys: ai.provider.{type}.{field} = ai.provider.openrouter.apiKey, etc.\n const parts = s.key.split(\".\");\n if (parts.length < 4) continue;\n const pType = parts[2] as ProviderType;\n const field = parts[3];\n\n if (!providerMap.has(pType)) providerMap.set(pType, { provider: pType, isEnabled: false, priority: 99 });\n const entry = providerMap.get(pType)!;\n try {\n const val = JSON.parse(s.value);\n if (field === \"apiKey\" && typeof val === \"string\") {\n entry.apiKey = decryptSecret(val);\n } else if (field === \"model\") {\n entry.model = val;\n } else if (field === \"baseUrl\") {\n entry.baseUrl = val;\n } else if (field === \"isEnabled\") {\n entry.isEnabled = val === true;\n } else if (field === \"priority\") {\n entry.priority = typeof val === \"number\" ? val : 99;\n }\n } catch { /* skip malformed */ }\n }\n\n for (const [, entry] of providerMap) {\n providers.push(entry as unknown as AiProviderConfig);\n }\n\n // Sort by priority\n providers.sort((a, b) => a.priority - b.priority);\n\n // If no enabled providers, fall back to z-ai\n if (providers.filter(p => p.isEnabled).length === 0) {\n providers.unshift({\n provider: \"z-ai\", apiKey: null, model: \"z-ai-glm\",\n isEnabled: true, priority: 0,\n });\n }\n\n cachedConfig = providers;\n cacheExpiry = Date.now() + 300_000; // 5 min cache (PERF-005 FIX: was 1 min)\n return providers;\n } catch (err) {\n logger.error(\"[aiProvider] failed to load config\", { err: err instanceof Error ? err.message : String(err) });\n return [{\n provider: \"z-ai\", apiKey: null, model: \"z-ai-glm\",\n isEnabled: true, priority: 1,\n }];\n }\n}\n\n/** Invalidate the cached provider config — call after founder edits settings */\nexport function invalidateAiProviderCache(): void {\n cachedConfig = null;\n cacheExpiry = 0;\n}\n\n/** Create the appropriate provider instance from config */\nfunction createProvider(config: AiProviderConfig): AiProvider | null {\n if (!config.isEnabled) return null;\n switch (config.provider) {\n case \"z-ai\": return new ZaiProvider(config);\n case \"openrouter\": return new OpenAICompatibleProvider(config, \"openrouter\", \"OpenRouter\");\n case \"openai\": return new OpenAICompatibleProvider(config, \"openai\", \"OpenAI\");\n case \"anthropic\": return new AnthropicProvider(config);\n case \"gemini\": return new OpenAICompatibleProvider(config, \"gemini\", \"Google Gemini\");\n case \"deepseek\": return new OpenAICompatibleProvider(config, \"deepseek\", \"DeepSeek\");\n case \"custom\": return new OpenAICompatibleProvider(config, \"custom\", \"Custom\");\n default: return null;\n }\n}\n\n/**\n * Call AI with automatic fallback chain.\n * Tries providers in priority order until one succeeds.\n *\n * SAFETY NET: z-ai (sandbox, no API key needed) is ALWAYS appended as the\n * last-resort fallback, even if it's not in the configured providers list.\n * This guarantees AI never goes fully dark — even when OpenRouter /\n * Anthropic / OpenAI are configured but invalid / expired / rate-limited.\n */\nexport async function callAI(options: ChatOptions): Promise<ChatResult> {\n const providers = await getAiProviders();\n\n // Build the effective chain: configured providers + z-ai as last resort\n // (deduped — if z-ai is already in the chain, don't duplicate it)\n const hasZai = providers.some(p => p.provider === \"z-ai\" && p.isEnabled);\n const effectiveChain = hasZai\n ? providers\n : [\n ...providers,\n {\n provider: \"z-ai\" as ProviderType,\n apiKey: null,\n model: \"z-ai-glm\",\n isEnabled: true,\n priority: 999,\n },\n ];\n\n for (const config of effectiveChain) {\n const provider = createProvider(config);\n if (!provider) continue;\n\n try {\n logger.debug(\"[aiProvider] calling provider\", { provider: config.provider, model: config.model });\n const result = await provider.chat(options);\n logger.info(\"[aiProvider] success\", { provider: result.provider, tokens: result.usage.total_tokens });\n return result;\n } catch (err) {\n logger.warn(\"[aiProvider] provider failed — trying fallback\", { provider: config.provider, err: err instanceof Error ? err.message : String(err) });\n continue;\n }\n }\n\n // All providers failed (including z-ai) — this should be extremely rare\n // since z-ai is a sandbox SDK that doesn't require network calls to a paid\n // API. If we get here, the z-ai SDK itself is broken.\n throw new Error(\"جميع مزودي الذكاء الاصطناعي فشلوا. تحقق من الإعدادات.\");\n}\n\n/**\n * Call ONE specific provider — no fallback chain.\n *\n * Used by the AI Orchestration Layer's Smart Router, which manages its own\n * fallback chain across registry models. Throws on failure so the caller can\n * catch and escalate to the next model.\n *\n * The config is fully resolved by the caller (provider + decrypted apiKey +\n * model + baseUrl). This function just instantiates the provider and calls it.\n */\nexport async function callSingleProvider(\n config: AiProviderConfig,\n options: ChatOptions,\n): Promise<ChatResult> {\n const provider = createProvider({ ...config, isEnabled: true });\n if (!provider) {\n throw new Error(`Provider ${config.provider} could not be instantiated`);\n }\n logger.debug(\"[aiProvider] calling single provider\", {\n provider: config.provider,\n model: config.model,\n });\n const result = await provider.chat(options);\n logger.info(\"[aiProvider] single-provider success\", {\n provider: result.provider,\n model: result.model,\n tokens: result.usage.total_tokens,\n });\n return result;\n}\n\n/**\n * Test a specific provider's connection.\n * Used by the admin UI \"test connection\" button.\n *\n * Tests regardless of `isEnabled` state so the founder can validate\n * credentials before activating a provider.\n */\nexport async function testProviderConnection(providerType: ProviderType): Promise<{ ok: boolean; error?: string }> {\n const providers = await getAiProviders();\n const config = providers.find(p => p.provider === providerType);\n\n // If the provider is configured (even disabled), use that config.\n // Otherwise fall back to PROVIDER_INFO defaults so a key can be tested\n // even before any setting has been written.\n let resolved: AiProviderConfig;\n if (config) {\n resolved = { ...config, isEnabled: true };\n } else {\n const info = PROVIDER_INFO.find(p => p.type === providerType);\n if (!info) return { ok: false, error: \"Unknown provider\" };\n resolved = {\n provider: providerType,\n apiKey: null,\n model: info.defaultModel,\n isEnabled: true,\n priority: 99,\n };\n }\n\n const provider = createProvider(resolved);\n if (!provider) return { ok: false, error: \"Provider not configured\" };\n\n try {\n const ok = await provider.testConnection();\n return { ok, error: ok ? undefined : \"Connection failed\" };\n } catch (err) {\n return {\n ok: false,\n error: err instanceof Error ? err.message.slice(0, 200) : \"Connection failed\",\n };\n }\n}\n\n/**\n * Save a provider's API key (encrypted) to PlatformSetting.\n */\nexport async function setProviderApiKey(providerType: ProviderType, apiKey: string): Promise<void> {\n const encrypted = encryptSecret(apiKey);\n const key = `ai.provider.${providerType}.apiKey`;\n\n const existing = await db.platformSettings.findUnique({ where: { key } });\n if (existing) {\n await db.platformSettings.update({ where: { key }, data: { value: JSON.stringify(encrypted), updatedAt: new Date() } });\n } else {\n await db.platformSettings.create({ data: { key, category: \"ai\", valueType: \"string\", value: JSON.stringify(encrypted) } });\n }\n\n // Log to history\n await db.platformSettingsHistory.create({\n data: { settingKey: key, newValue: \"[encrypted]\", changedBy: \"system\" },\n });\n\n invalidateAiProviderCache();\n logger.info(\"[aiProvider] API key updated\", { provider: providerType });\n}\n\n/**\n * Set the provider's model.\n */\nexport async function setProviderModel(providerType: ProviderType, model: string): Promise<void> {\n const key = `ai.provider.${providerType}.model`;\n const existing = await db.platformSettings.findUnique({ where: { key } });\n if (existing) {\n await db.platformSettings.update({ where: { key }, data: { value: JSON.stringify(model), updatedAt: new Date() } });\n } else {\n await db.platformSettings.create({ data: { key, category: \"ai\", valueType: \"string\", value: JSON.stringify(model) } });\n }\n invalidateAiProviderCache();\n}\n\n/**\n * Enable/disable a provider.\n */\nexport async function setProviderEnabled(providerType: ProviderType, isEnabled: boolean): Promise<void> {\n const key = `ai.provider.${providerType}.isEnabled`;\n const existing = await db.platformSettings.findUnique({ where: { key } });\n if (existing) {\n await db.platformSettings.update({ where: { key }, data: { value: JSON.stringify(isEnabled), updatedAt: new Date() } });\n } else {\n await db.platformSettings.create({ data: { key, category: \"ai\", valueType: \"boolean\", value: JSON.stringify(isEnabled) } });\n }\n invalidateAiProviderCache();\n}\n\n/**\n * Set provider priority (for fallback ordering).\n */\nexport async function setProviderPriority(providerType: ProviderType, priority: number): Promise<void> {\n const key = `ai.provider.${providerType}.priority`;\n const existing = await db.platformSettings.findUnique({ where: { key } });\n if (existing) {\n await db.platformSettings.update({ where: { key }, data: { value: JSON.stringify(priority), updatedAt: new Date() } });\n } else {\n await db.platformSettings.create({ data: { key, category: \"ai\", valueType: \"number\", value: JSON.stringify(priority) } });\n }\n invalidateAiProviderCache();\n}\n\nexport const PROVIDER_INFO: Array<{ type: ProviderType; name: string; description: string; defaultModel: string; keyPrefix: string }> = [\n { type: \"z-ai\", name: \"z-ai / GLM\", description: \"المزود الافتراضي — لا يحتاج مفتاح API في بيئة التطوير\", defaultModel: \"z-ai-glm\", keyPrefix: \"\" },\n { type: \"openrouter\", name: \"OpenRouter\", description: \"بوابة لعشرات الموديلات بمفتاح واحد (Claude, GPT, Gemini, Llama...)\", defaultModel: \"anthropic/claude-3.5-haiku\", keyPrefix: \"sk-or-\" },\n { type: \"anthropic\", name: \"Anthropic (Claude)\", description: \"مباشر من Anthropic — أفضل جودة للعربية\", defaultModel: \"claude-3-5-haiku-20241022\", keyPrefix: \"sk-ant-\" },\n { type: \"openai\", name: \"OpenAI (GPT)\", description: \"مباشر من OpenAI\", defaultModel: \"gpt-4o-mini\", keyPrefix: \"sk-\" },\n { type: \"deepseek\", name: \"DeepSeek\", description: \"مباشر من DeepSeek — موديلات V3 و R1 بسعر منخفض جداً وجودة عالية\", defaultModel: \"deepseek-chat\", keyPrefix: \"sk-\" },\n { type: \"gemini\", name: \"Google Gemini\", description: \"مباشر من Google (عبر OpenAI-compatible API)\", defaultModel: \"gemini-1.5-flash\", keyPrefix: \"AIza\" },\n { type: \"custom\", name: \"مزود مخصص\", description: \"أي endpoint متوافق مع OpenAI API (self-hosted, Azure, etc.)\", defaultModel: \"\", keyPrefix: \"\" },\n];\n"}, "fe862dbc8e9fbb36adbb04607b9ee785827d8328": {"t": 1785763677.4100056, "n": 21734, "h": "5fd3ae7ceb5c8003", "c": "/**\n * aiProvider.ts — Unified AI Provider Abstraction Layer.\n *\n * Replaces the hardcoded z-ai-web-dev-sdk calls with a provider-agnostic\n * interface that supports: OpenRouter, Anthropic, OpenAI, Google Gemini,\n * z-ai/GLM, and any OpenAI-compatible endpoint.\n *\n * Features:\n * - Fallback chain: if primary provider fails, try the next\n * - Encrypted API key storage via cryptoVault\n * - Test connection per provider\n * - Config stored in PlatformSetting table (editable by founder)\n *\n * Usage:\n * import { getAiClient, callAI } from \"@/lib/aiProvider\";\n * const result = await callAI({ messages, companySlug });\n */\n\nimport { db } from \"./db\";\nimport { decryptSecret, encryptSecret } from \"./cryptoVault\";\nimport { logger } from \"./logger\";\nimport { z } from \"zod\";\n\n// P0 FIX (audit finding: missing timeout on AI external API calls):\n// All AI provider HTTP calls now go through `fetchWithTimeout`. A hung\n// upstream (slow model, network stall, deepseek-r1 thinking for minutes)\n// used to keep a server connection open indefinitely, exhausting the\n// connection pool under load. 60s is the upper bound — most calls finish\n// in <10s, so 60s gives plenty of slack for slow reasoning models while\n// still bounding the worst case.\nconst DEFAULT_AI_TIMEOUT_MS = 60_000;\n\nasync function fetchWithTimeout(\n url: string,\n init: RequestInit,\n timeoutMs: number = DEFAULT_AI_TIMEOUT_MS,\n): Promise<Response> {\n const controller = new AbortController();\n const timer = setTimeout(() => controller.abort(), timeoutMs);\n try {\n return await fetch(url, { ...init, signal: controller.signal });\n } catch (err) {\n if (err instanceof Error && err.name === \"AbortError\") {\n throw new Error(`AI request timed out after ${timeoutMs}ms — ${url}`);\n }\n throw err;\n } finally {\n clearTimeout(timer);\n }\n}\n\n// SEC-006 FIX: SSRF protection for custom AI endpoints\nfunction validateBaseUrl(url: string): void {\n const parsed = new URL(url);\n if (![\"https:\"].includes(parsed.protocol)) {\n throw new Error(\"Custom AI endpoint must use HTTPS\");\n }\n const blockedHosts = [\"localhost\", \"127.0.0.1\", \"0.0.0.0\", \"169.254.169.254\", \"::1\", \"[::1]\"];\n if (blockedHosts.includes(parsed.hostname)) {\n throw new Error(\"Internal addresses are not allowed for custom AI endpoint\");\n }\n // Block private IP ranges (RFC 1918) — proper CIDR checks\n // FIX: Previously blocked ALL 172.x.x.x and 192.x.x.x, which incorrectly\n // blocked public IPs like 172.5.1.2 (only 172.16-31 is private per RFC 1918)\n const ipMatch = parsed.hostname.match(/^(\\d+)\\.(\\d+)\\.(\\d+)\\.(\\d+)$/);\n if (ipMatch) {\n const [, a, b] = ipMatch;\n const first = parseInt(a), second = parseInt(b);\n // 10.0.0.0/8 — all 10.x.x.x is private\n if (first === 10) throw new Error(\"Private/internal IP addresses are not allowed for custom AI endpoint\");\n // 172.16.0.0/12 — only 172.16-31.x.x is private (172.0-15 and 172.32-255 are PUBLIC)\n if (first === 172 && second >= 16 && second <= 31) throw new Error(\"Private/internal IP addresses are not allowed for custom AI endpoint\");\n // 192.168.0.0/16 — only 192.168.x.x is private (192.0-167 and 192.169-255 are PUBLIC)\n if (first === 192 && second === 168) throw new Error(\"Private/internal IP addresses are not allowed for custom AI endpoint\");\n // 127.0.0.0/8 — loopback\n if (first === 127) throw new Error(\"Loopback addresses are not allowed for custom AI endpoint\");\n }\n}\n\n// ─── Types ──────────────────────────────────────────────────────────────────\n\nexport type ProviderType = \"z-ai\" | \"openrouter\" | \"anthropic\" | \"openai\" | \"gemini\" | \"deepseek\" | \"custom\";\n\nexport interface AiProviderConfig {\n provider: ProviderType;\n apiKey: string | null; // decrypted\n model: string;\n baseUrl?: string; // for custom OpenAI-compatible\n isEnabled: boolean;\n priority: number; // 1 = primary, 2 = first fallback, etc.\n}\n\nexport interface ChatMessage {\n role: \"system\" | \"user\" | \"assistant\";\n content: string | Array<{ type: string; text?: string; image_url?: { url: string } }>;\n}\n\nexport interface ChatOptions {\n messages: ChatMessage[];\n temperature?: number;\n maxTokens?: number;\n companySlug?: string;\n}\n\nexport interface ChatResult {\n content: string;\n usage: { prompt_tokens: number; completion_tokens: number; total_tokens: number };\n provider: ProviderType;\n model: string;\n}\n\nexport interface AiProvider {\n type: ProviderType;\n name: string;\n chat(options: ChatOptions): Promise<ChatResult>;\n testConnection(): Promise<boolean>;\n}\n\n// ─── Provider implementations ───────────────────────────────────────────────\n\n/** z-ai / GLM provider — uses z-ai-web-dev-sdk (no API key needed in sandbox) */\nclass ZaiProvider implements AiProvider {\n type: ProviderType = \"z-ai\";\n name = \"z-ai / GLM\";\n private config: AiProviderConfig;\n\n constructor(config: AiProviderConfig) { this.config = config; }\n\n async chat(options: ChatOptions): Promise<ChatResult> {\n const ZAI = (await import(\"z-ai-web-dev-sdk\")).default;\n const ai = await ZAI.create();\n const completion = await ai.chat.completions.create({\n messages: options.messages.map(m => ({\n role: m.role,\n content: m.content as string,\n })),\n temperature: options.temperature ?? 0.4,\n max_tokens: options.maxTokens ?? 800,\n });\n return {\n content: completion.choices?.[0]?.message?.content || \"\",\n usage: completion.usage || { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 },\n provider: this.type,\n model: this.config.model || \"z-ai-glm\",\n };\n }\n\n async testConnection(): Promise<boolean> {\n try {\n const result = await this.chat({ messages: [{ role: \"user\", content: \"ping\" }] });\n return !!result.content;\n } catch { return false; }\n }\n}\n\n/** OpenAI-compatible provider — works for OpenAI, OpenRouter, DeepSeek, and custom endpoints */\nclass OpenAICompatibleProvider implements AiProvider {\n type: ProviderType;\n name: string;\n private config: AiProviderConfig;\n\n constructor(config: AiProviderConfig, type: ProviderType, name: string) {\n this.config = config;\n this.type = type;\n this.name = name;\n }\n\n private getBaseUrl(): string {\n switch (this.type) {\n case \"openrouter\": return \"https://openrouter.ai/api/v1\";\n case \"openai\": return \"https://api.openai.com/v1\";\n case \"deepseek\": return \"https://api.deepseek.com/v1\";\n case \"gemini\": return \"https://generativelanguage.googleapis.com/v1beta/openai\";\n case \"custom\": {\n // SEC-006 FIX: SSRF protection — validate custom baseUrl\n const url = this.config.baseUrl || \"\";\n if (url) validateBaseUrl(url);\n return url;\n }\n default: return \"\";\n }\n }\n\n async chat(options: ChatOptions): Promise<ChatResult> {\n if (!this.config.apiKey) throw new Error(`No API key for ${this.name}`);\n\n const baseUrl = this.getBaseUrl();\n const headers: Record<string, string> = {\n \"Content-Type\": \"application/json\",\n \"Authorization\": `Bearer ${this.config.apiKey}`,\n };\n\n // OpenRouter-specific headers\n if (this.type === \"openrouter\") {\n headers[\"HTTP-Referer\"] = process.env.APP_URL || \"http://localhost:3000\";\n headers[\"X-Title\"] = \"GarfiX ERP\";\n }\n\n // DeepSeek-specific: supports frequency_penalty and other params\n // (no extra headers needed — uses standard Bearer auth like OpenAI)\n\n const body: Record<string, unknown> = {\n model: this.config.model,\n messages: options.messages.map(m => ({\n role: m.role,\n content: typeof m.content === \"string\" ? m.content : m.content,\n })),\n temperature: options.temperature ?? 0.4,\n };\n if (options.maxTokens) body.max_tokens = options.maxTokens;\n\n const res = await fetchWithTimeout(\n `${baseUrl}/chat/completions`,\n {\n method: \"POST\",\n headers,\n body: JSON.stringify(body),\n },\n 60_000, // P0 FIX (audit finding: missing timeout on AI external calls)\n );\n\n if (!res.ok) {\n const err = await res.text();\n throw new Error(`${this.name} API error ${res.status}: ${err.slice(0, 200)}`);\n }\n\n const data = await res.json();\n return {\n content: data.choices?.[0]?.message?.content || \"\",\n usage: data.usage || { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 },\n provider: this.type,\n model: this.config.model,\n };\n }\n\n async testConnection(): Promise<boolean> {\n try {\n const result = await this.chat({\n messages: [{ role: \"user\", content: \"ping\" }],\n maxTokens: 5,\n });\n return !!result.content;\n } catch { return false; }\n }\n}\n\n/** Anthropic (Claude) provider — uses Anthropic Messages API directly */\nclass AnthropicProvider implements AiProvider {\n type: ProviderType = \"anthropic\";\n name = \"Anthropic (Claude)\";\n private config: AiProviderConfig;\n\n constructor(config: AiProviderConfig) { this.config = config; }\n\n async chat(options: ChatOptions): Promise<ChatResult> {\n if (!this.config.apiKey) throw new Error(\"No Anthropic API key\");\n\n // Extract system message\n const systemMsg = options.messages.find(m => m.role === \"system\");\n const chatMessages = options.messages.filter(m => m.role !== \"system\");\n\n const res = await fetchWithTimeout(\n \"https://api.anthropic.com/v1/messages\",\n {\n method: \"POST\",\n headers: {\n \"Content-Type\": \"application/json\",\n \"x-api-key\": this.config.apiKey,\n \"anthropic-version\": \"2023-06-01\",\n },\n body: JSON.stringify({\n model: this.config.model,\n max_tokens: options.maxTokens ?? 800,\n system: typeof systemMsg?.content === \"string\" ? systemMsg.content : \"\",\n messages: chatMessages.map(m => ({\n role: m.role,\n content: typeof m.content === \"string\" ? m.content : JSON.stringify(m.content),\n })),\n }),\n },\n 60_000, // P0 FIX: timeout on AI external calls\n );\n\n if (!res.ok) throw new Error(`Anthropic API error ${res.status}`);\n\n const data = await res.json();\n const content = data.content?.[0]?.text || \"\";\n return {\n content,\n usage: {\n prompt_tokens: data.usage?.input_tokens || 0,\n completion_tokens: data.usage?.output_tokens || 0,\n total_tokens: (data.usage?.input_tokens || 0) + (data.usage?.output_tokens || 0),\n },\n provider: this.type,\n model: this.config.model,\n };\n }\n\n async testConnection(): Promise<boolean> {\n try {\n const result = await this.chat({ messages: [{ role: \"user\", content: \"ping\" }], maxTokens: 5 });\n return !!result.content;\n } catch { return false; }\n }\n}\n\n// ─── Config resolution ──────────────────────────────────────────────────────\n\nlet cachedConfig: AiProviderConfig[] | null = null;\nlet cacheExpiry = 0;\n\n/**\n * Get the ordered list of AI providers from PlatformSetting.\n * Falls back to z-ai/GLM if no providers configured.\n */\nexport async function getAiProviders(): Promise<AiProviderConfig[]> {\n if (cachedConfig && Date.now() < cacheExpiry) return cachedConfig;\n\n try {\n const settings = await db.platformSettings.findMany({\n where: { key: { startsWith: \"ai.provider.\" } },\n });\n\n if (settings.length === 0) {\n // Default: z-ai only\n return [{\n provider: \"z-ai\",\n apiKey: null,\n model: \"z-ai-glm\",\n isEnabled: true,\n priority: 1,\n }];\n }\n\n // Parse provider configs from settings\n const providers: AiProviderConfig[] = [];\n const providerMap = new Map<string, Record<string, unknown>>();\n\n for (const s of settings) {\n // Keys: ai.provider.{type}.{field} = ai.provider.openrouter.apiKey, etc.\n const parts = s.key.split(\".\");\n if (parts.length < 4) continue;\n const pType = parts[2] as ProviderType;\n const field = parts[3];\n\n if (!providerMap.has(pType)) providerMap.set(pType, { provider: pType, isEnabled: false, priority: 99 });\n const entry = providerMap.get(pType)!;\n try {\n const val = JSON.parse(s.value);\n if (field === \"apiKey\" && typeof val === \"string\") {\n entry.apiKey = decryptSecret(val);\n } else if (field === \"model\") {\n entry.model = val;\n } else if (field === \"baseUrl\") {\n entry.baseUrl = val;\n } else if (field === \"isEnabled\") {\n entry.isEnabled = val === true;\n } else if (field === \"priority\") {\n entry.priority = typeof val === \"number\" ? val : 99;\n }\n } catch { /* skip malformed */ }\n }\n\n for (const [, entry] of providerMap) {\n providers.push(entry as unknown as AiProviderConfig);\n }\n\n // Sort by priority\n providers.sort((a, b) => a.priority - b.priority);\n\n // If no enabled providers, fall back to z-ai\n if (providers.filter(p => p.isEnabled).length === 0) {\n providers.unshift({\n provider: \"z-ai\", apiKey: null, model: \"z-ai-glm\",\n isEnabled: true, priority: 0,\n });\n }\n\n cachedConfig = providers;\n cacheExpiry = Date.now() + 300_000; // 5 min cache (PERF-005 FIX: was 1 min)\n return providers;\n } catch (err) {\n logger.error(\"[aiProvider] failed to load config\", { err: err instanceof Error ? err.message : String(err) });\n return [{\n provider: \"z-ai\", apiKey: null, model: \"z-ai-glm\",\n isEnabled: true, priority: 1,\n }];\n }\n}\n\n/** Invalidate the cached provider config — call after founder edits settings */\nexport function invalidateAiProviderCache(): void {\n cachedConfig = null;\n cacheExpiry = 0;\n}\n\n/** Create the appropriate provider instance from config */\nfunction createProvider(config: AiProviderConfig): AiProvider | null {\n if (!config.isEnabled) return null;\n switch (config.provider) {\n case \"z-ai\": return new ZaiProvider(config);\n case \"openrouter\": return new OpenAICompatibleProvider(config, \"openrouter\", \"OpenRouter\");\n case \"openai\": return new OpenAICompatibleProvider(config, \"openai\", \"OpenAI\");\n case \"anthropic\": return new AnthropicProvider(config);\n case \"gemini\": return new OpenAICompatibleProvider(config, \"gemini\", \"Google Gemini\");\n case \"deepseek\": return new OpenAICompatibleProvider(config, \"deepseek\", \"DeepSeek\");\n case \"custom\": return new OpenAICompatibleProvider(config, \"custom\", \"Custom\");\n default: return null;\n }\n}\n\n/**\n * Call AI with automatic fallback chain.\n * Tries providers in priority order until one succeeds.\n */\nexport async function callAI(options: ChatOptions): Promise<ChatResult> {\n const providers = await getAiProviders();\n\n for (const config of providers) {\n const provider = createProvider(config);\n if (!provider) continue;\n\n try {\n logger.debug(\"[aiProvider] calling provider\", { provider: config.provider, model: config.model });\n const result = await provider.chat(options);\n logger.info(\"[aiProvider] success\", { provider: result.provider, tokens: result.usage.total_tokens });\n return result;\n } catch (err) {\n logger.warn(\"[aiProvider] provider failed — trying fallback\", { provider: config.provider, err: err instanceof Error ? err.message : String(err) });\n continue;\n }\n }\n\n // All providers failed\n throw new Error(\"جميع مزودي الذكاء الاصطناعي فشلوا. تحقق من الإعدادات.\");\n}\n\n/**\n * Call ONE specific provider — no fallback chain.\n *\n * Used by the AI Orchestration Layer's Smart Router, which manages its own\n * fallback chain across registry models. Throws on failure so the caller can\n * catch and escalate to the next model.\n *\n * The config is fully resolved by the caller (provider + decrypted apiKey +\n * model + baseUrl). This function just instantiates the provider and calls it.\n */\nexport async function callSingleProvider(\n config: AiProviderConfig,\n options: ChatOptions,\n): Promise<ChatResult> {\n const provider = createProvider({ ...config, isEnabled: true });\n if (!provider) {\n throw new Error(`Provider ${config.provider} could not be instantiated`);\n }\n logger.debug(\"[aiProvider] calling single provider\", {\n provider: config.provider,\n model: config.model,\n });\n const result = await provider.chat(options);\n logger.info(\"[aiProvider] single-provider success\", {\n provider: result.provider,\n model: result.model,\n tokens: result.usage.total_tokens,\n });\n return result;\n}\n\n/**\n * Test a specific provider's connection.\n * Used by the admin UI \"test connection\" button.\n *\n * Tests regardless of `isEnabled` state so the founder can validate\n * credentials before activating a provider.\n */\nexport async function testProviderConnection(providerType: ProviderType): Promise<{ ok: boolean; error?: string }> {\n const providers = await getAiProviders();\n const config = providers.find(p => p.provider === providerType);\n\n // If the provider is configured (even disabled), use that config.\n // Otherwise fall back to PROVIDER_INFO defaults so a key can be tested\n // even before any setting has been written.\n let resolved: AiProviderConfig;\n if (config) {\n resolved = { ...config, isEnabled: true };\n } else {\n const info = PROVIDER_INFO.find(p => p.type === providerType);\n if (!info) return { ok: false, error: \"Unknown provider\" };\n resolved = {\n provider: providerType,\n apiKey: null,\n model: info.defaultModel,\n isEnabled: true,\n priority: 99,\n };\n }\n\n const provider = createProvider(resolved);\n if (!provider) return { ok: false, error: \"Provider not configured\" };\n\n try {\n const ok = await provider.testConnection();\n return { ok, error: ok ? undefined : \"Connection failed\" };\n } catch (err) {\n return {\n ok: false,\n error: err instanceof Error ? err.message.slice(0, 200) : \"Connection failed\",\n };\n }\n}\n\n/**\n * Save a provider's API key (encrypted) to PlatformSetting.\n */\nexport async function setProviderApiKey(providerType: ProviderType, apiKey: string): Promise<void> {\n const encrypted = encryptSecret(apiKey);\n const key = `ai.provider.${providerType}.apiKey`;\n\n const existing = await db.platformSettings.findUnique({ where: { key } });\n if (existing) {\n await db.platformSettings.update({ where: { key }, data: { value: JSON.stringify(encrypted), updatedAt: new Date() } });\n } else {\n await db.platformSettings.create({ data: { key, category: \"ai\", valueType: \"string\", value: JSON.stringify(encrypted) } });\n }\n\n // Log to history\n await db.platformSettingsHistory.create({\n data: { settingKey: key, newValue: \"[encrypted]\", changedBy: \"system\" },\n });\n\n invalidateAiProviderCache();\n logger.info(\"[aiProvider] API key updated\", { provider: providerType });\n}\n\n/**\n * Set the provider's model.\n */\nexport async function setProviderModel(providerType: ProviderType, model: string): Promise<void> {\n const key = `ai.provider.${providerType}.model`;\n const existing = await db.platformSettings.findUnique({ where: { key } });\n if (existing) {\n await db.platformSettings.update({ where: { key }, data: { value: JSON.stringify(model), updatedAt: new Date() } });\n } else {\n await db.platformSettings.create({ data: { key, category: \"ai\", valueType: \"string\", value: JSON.stringify(model) } });\n }\n invalidateAiProviderCache();\n}\n\n/**\n * Enable/disable a provider.\n */\nexport async function setProviderEnabled(providerType: ProviderType, isEnabled: boolean): Promise<void> {\n const key = `ai.provider.${providerType}.isEnabled`;\n const existing = await db.platformSettings.findUnique({ where: { key } });\n if (existing) {\n await db.platformSettings.update({ where: { key }, data: { value: JSON.stringify(isEnabled), updatedAt: new Date() } });\n } else {\n await db.platformSettings.create({ data: { key, category: \"ai\", valueType: \"boolean\", value: JSON.stringify(isEnabled) } });\n }\n invalidateAiProviderCache();\n}\n\n/**\n * Set provider priority (for fallback ordering).\n */\nexport async function setProviderPriority(providerType: ProviderType, priority: number): Promise<void> {\n const key = `ai.provider.${providerType}.priority`;\n const existing = await db.platformSettings.findUnique({ where: { key } });\n if (existing) {\n await db.platformSettings.update({ where: { key }, data: { value: JSON.stringify(priority), updatedAt: new Date() } });\n } else {\n await db.platformSettings.create({ data: { key, category: \"ai\", valueType: \"number\", value: JSON.stringify(priority) } });\n }\n invalidateAiProviderCache();\n}\n\nexport const PROVIDER_INFO: Array<{ type: ProviderType; name: string; description: string; defaultModel: string; keyPrefix: string }> = [\n { type: \"z-ai\", name: \"z-ai / GLM\", description: \"المزود الافتراضي — لا يحتاج مفتاح API في بيئة التطوير\", defaultModel: \"z-ai-glm\", keyPrefix: \"\" },\n { type: \"openrouter\", name: \"OpenRouter\", description: \"بوابة لعشرات الموديلات بمفتاح واحد (Claude, GPT, Gemini, Llama...)\", defaultModel: \"anthropic/claude-3.5-haiku\", keyPrefix: \"sk-or-\" },\n { type: \"anthropic\", name: \"Anthropic (Claude)\", description: \"مباشر من Anthropic — أفضل جودة للعربية\", defaultModel: \"claude-3-5-haiku-20241022\", keyPrefix: \"sk-ant-\" },\n { type: \"openai\", name: \"OpenAI (GPT)\", description: \"مباشر من OpenAI\", defaultModel: \"gpt-4o-mini\", keyPrefix: \"sk-\" },\n { type: \"deepseek\", name: \"DeepSeek\", description: \"مباشر من DeepSeek — موديلات V3 و R1 بسعر منخفض جداً وجودة عالية\", defaultModel: \"deepseek-chat\", keyPrefix: \"sk-\" },\n { type: \"gemini\", name: \"Google Gemini\", description: \"مباشر من Google (عبر OpenAI-compatible API)\", defaultModel: \"gemini-1.5-flash\", keyPrefix: \"AIza\" },\n { type: \"custom\", name: \"مزود مخصص\", description: \"أي endpoint متوافق مع OpenAI API (self-hosted, Azure, etc.)\", defaultModel: \"\", keyPrefix: \"\" },\n];\n"}, "196f6dd9db880dde4970f20632e9711100d812a7": {"t": 1785763679.701956, "n": 21720, "h": "ae25713b362411d0", "c": "/**\n * aiProvider.ts — Unified AI Provider Abstraction Layer.\n *\n * Replaces the hardcoded z-ai-web-dev-sdk calls with a provider-agnostic\n * interface that supports: OpenRouter, Anthropic, OpenAI, Google Gemini,\n * z-ai/GLM, and any OpenAI-compatible endpoint.\n *\n * Features:\n * - Fallback chain: if primary provider fails, try the next\n * - Encrypted API key storage via cryptoVault\n * - Test connection per provider\n * - Config stored in PlatformSetting table (editable by founder)\n *\n * Usage:\n * import { getAiClient, callAI } from \"@/lib/aiProvider\";\n * const result = await callAI({ messages, companySlug });\n */\n\nimport { db } from \"./db\";\nimport { decryptSecret, encryptSecret } from \"./cryptoVault\";\nimport { logger } from \"./logger\";\nimport { z } from \"zod\";\n\n// P0 FIX (audit finding: missing timeout on AI external API calls):\n// All AI provider HTTP calls now go through `fetchWithTimeout`. A hung\n// upstream (slow model, network stall, deepseek-r1 thinking for minutes)\n// used to keep a server connection open indefinitely, exhausting the\n// connection pool under load. 60s is the upper bound — most calls finish\n// in <10s, so 60s gives plenty of slack for slow reasoning models while\n// still bounding the worst case.\nconst DEFAULT_AI_TIMEOUT_MS = 60_000;\n\nasync function fetchWithTimeout(\n url: string,\n init: RequestInit,\n timeoutMs: number = DEFAULT_AI_TIMEOUT_MS,\n): Promise<Response> {\n const controller = new AbortController();\n const timer = setTimeout(() => controller.abort(), timeoutMs);\n try {\n return await fetch(url, { ...init, signal: controller.signal });\n } catch (err) {\n if (err instanceof Error && err.name === \"AbortError\") {\n throw new Error(`AI request timed out after ${timeoutMs}ms — ${url}`);\n }\n throw err;\n } finally {\n clearTimeout(timer);\n }\n}\n\n// SEC-006 FIX: SSRF protection for custom AI endpoints\nfunction validateBaseUrl(url: string): void {\n const parsed = new URL(url);\n if (![\"https:\"].includes(parsed.protocol)) {\n throw new Error(\"Custom AI endpoint must use HTTPS\");\n }\n const blockedHosts = [\"localhost\", \"127.0.0.1\", \"0.0.0.0\", \"169.254.169.254\", \"::1\", \"[::1]\"];\n if (blockedHosts.includes(parsed.hostname)) {\n throw new Error(\"Internal addresses are not allowed for custom AI endpoint\");\n }\n // Block private IP ranges (RFC 1918) — proper CIDR checks\n // FIX: Previously blocked ALL 172.x.x.x and 192.x.x.x, which incorrectly\n // blocked public IPs like 172.5.1.2 (only 172.16-31 is private per RFC 1918)\n const ipMatch = parsed.hostname.match(/^(\\d+)\\.(\\d+)\\.(\\d+)\\.(\\d+)$/);\n if (ipMatch) {\n const [, a, b] = ipMatch;\n const first = parseInt(a), second = parseInt(b);\n // 10.0.0.0/8 — all 10.x.x.x is private\n if (first === 10) throw new Error(\"Private/internal IP addresses are not allowed for custom AI endpoint\");\n // 172.16.0.0/12 — only 172.16-31.x.x is private (172.0-15 and 172.32-255 are PUBLIC)\n if (first === 172 && second >= 16 && second <= 31) throw new Error(\"Private/internal IP addresses are not allowed for custom AI endpoint\");\n // 192.168.0.0/16 — only 192.168.x.x is private (192.0-167 and 192.169-255 are PUBLIC)\n if (first === 192 && second === 168) throw new Error(\"Private/internal IP addresses are not allowed for custom AI endpoint\");\n // 127.0.0.0/8 — loopback\n if (first === 127) throw new Error(\"Loopback addresses are not allowed for custom AI endpoint\");\n }\n}\n\n// ─── Types ──────────────────────────────────────────────────────────────────\n\nexport type ProviderType = \"z-ai\" | \"openrouter\" | \"anthropic\" | \"openai\" | \"gemini\" | \"deepseek\" | \"custom\";\n\nexport interface AiProviderConfig {\n provider: ProviderType;\n apiKey: string | null; // decrypted\n model: string;\n baseUrl?: string; // for custom OpenAI-compatible\n isEnabled: boolean;\n priority: number; // 1 = primary, 2 = first fallback, etc.\n}\n\nexport interface ChatMessage {\n role: \"system\" | \"user\" | \"assistant\";\n content: string | Array<{ type: string; text?: string; image_url?: { url: string } }>;\n}\n\nexport interface ChatOptions {\n messages: ChatMessage[];\n temperature?: number;\n maxTokens?: number;\n companySlug?: string;\n}\n\nexport interface ChatResult {\n content: string;\n usage: { prompt_tokens: number; completion_tokens: number; total_tokens: number };\n provider: ProviderType;\n model: string;\n}\n\nexport interface AiProvider {\n type: ProviderType;\n name: string;\n chat(options: ChatOptions): Promise<ChatResult>;\n testConnection(): Promise<boolean>;\n}\n\n// ─── Provider implementations ───────────────────────────────────────────────\n\n/** z-ai / GLM provider — uses z-ai-web-dev-sdk (no API key needed in sandbox) */\nclass ZaiProvider implements AiProvider {\n type: ProviderType = \"z-ai\";\n name = \"z-ai / GLM\";\n private config: AiProviderConfig;\n\n constructor(config: AiProviderConfig) { this.config = config; }\n\n async chat(options: ChatOptions): Promise<ChatResult> {\n const ZAI = (await import(\"z-ai-web-dev-sdk\")).default;\n const ai = await ZAI.create();\n const completion = await ai.chat.completions.create({\n messages: options.messages.map(m => ({\n role: m.role,\n content: m.content as string,\n })),\n temperature: options.temperature ?? 0.4,\n max_tokens: options.maxTokens ?? 800,\n });\n return {\n content: completion.choices?.[0]?.message?.content || \"\",\n usage: completion.usage || { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 },\n provider: this.type,\n model: this.config.model || \"z-ai-glm\",\n };\n }\n\n async testConnection(): Promise<boolean> {\n try {\n const result = await this.chat({ messages: [{ role: \"user\", content: \"ping\" }] });\n return !!result.content;\n } catch { return false; }\n }\n}\n\n/** OpenAI-compatible provider — works for OpenAI, OpenRouter, DeepSeek, and custom endpoints */\nclass OpenAICompatibleProvider implements AiProvider {\n type: ProviderType;\n name: string;\n private config: AiProviderConfig;\n\n constructor(config: AiProviderConfig, type: ProviderType, name: string) {\n this.config = config;\n this.type = type;\n this.name = name;\n }\n\n private getBaseUrl(): string {\n switch (this.type) {\n case \"openrouter\": return \"https://openrouter.ai/api/v1\";\n case \"openai\": return \"https://api.openai.com/v1\";\n case \"deepseek\": return \"https://api.deepseek.com/v1\";\n case \"gemini\": return \"https://generativelanguage.googleapis.com/v1beta/openai\";\n case \"custom\": {\n // SEC-006 FIX: SSRF protection — validate custom baseUrl\n const url = this.config.baseUrl || \"\";\n if (url) validateBaseUrl(url);\n return url;\n }\n default: return \"\";\n }\n }\n\n async chat(options: ChatOptions): Promise<ChatResult> {\n if (!this.config.apiKey) throw new Error(`No API key for ${this.name}`);\n\n const baseUrl = this.getBaseUrl();\n const headers: Record<string, string> = {\n \"Content-Type\": \"application/json\",\n \"Authorization\": `Bearer ${this.config.apiKey}`,\n };\n\n // OpenRouter-specific headers\n if (this.type === \"openrouter\") {\n headers[\"HTTP-Referer\"] = process.env.APP_URL || \"http://localhost:3000\";\n headers[\"X-Title\"] = \"GarfiX ERP\";\n }\n\n // DeepSeek-specific: supports frequency_penalty and other params\n // (no extra headers needed — uses standard Bearer auth like OpenAI)\n\n const body: Record<string, unknown> = {\n model: this.config.model,\n messages: options.messages.map(m => ({\n role: m.role,\n content: typeof m.content === \"string\" ? m.content : m.content,\n })),\n temperature: options.temperature ?? 0.4,\n };\n if (options.maxTokens) body.max_tokens = options.maxTokens;\n\n const res = await fetchWithTimeout(\n `${baseUrl}/chat/completions`,\n {\n method: \"POST\",\n headers,\n body: JSON.stringify(body),\n },\n 60_000, // P0 FIX (audit finding: missing timeout on AI external calls)\n );\n\n if (!res.ok) {\n const err = await res.text();\n throw new Error(`${this.name} API error ${res.status}: ${err.slice(0, 200)}`);\n }\n\n const data = await res.json();\n return {\n content: data.choices?.[0]?.message?.content || \"\",\n usage: data.usage || { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 },\n provider: this.type,\n model: this.config.model,\n };\n }\n\n async testConnection(): Promise<boolean> {\n try {\n const result = await this.chat({\n messages: [{ role: \"user\", content: \"ping\" }],\n maxTokens: 5,\n });\n return !!result.content;\n } catch { return false; }\n }\n}\n\n/** Anthropic (Claude) provider — uses Anthropic Messages API directly */\nclass AnthropicProvider implements AiProvider {\n type: ProviderType = \"anthropic\";\n name = \"Anthropic (Claude)\";\n private config: AiProviderConfig;\n\n constructor(config: AiProviderConfig) { this.config = config; }\n\n async chat(options: ChatOptions): Promise<ChatResult> {\n if (!this.config.apiKey) throw new Error(\"No Anthropic API key\");\n\n // Extract system message\n const systemMsg = options.messages.find(m => m.role === \"system\");\n const chatMessages = options.messages.filter(m => m.role !== \"system\");\n\n const res = await fetchWithTimeout(\n \"https://api.anthropic.com/v1/messages\",\n {\n method: \"POST\",\n headers: {\n \"Content-Type\": \"application/json\",\n \"x-api-key\": this.config.apiKey,\n \"anthropic-version\": \"2023-06-01\",\n },\n body: JSON.stringify({\n model: this.config.model,\n max_tokens: options.maxTokens ?? 800,\n system: typeof systemMsg?.content === \"string\" ? systemMsg.content : \"\",\n messages: chatMessages.map(m => ({\n role: m.role,\n content: typeof m.content === \"string\" ? m.content : JSON.stringify(m.content),\n })),\n }),\n },\n 60_000, // P0 FIX: timeout on AI external calls\n );\n\n if (!res.ok) throw new Error(`Anthropic API error ${res.status}`);\n\n const data = await res.json();\n const content = data.content?.[0]?.text || \"\";\n return {\n content,\n usage: {\n prompt_tokens: data.usage?.input_tokens || 0,\n completion_tokens: data.usage?.output_tokens || 0,\n total_tokens: (data.usage?.input_tokens || 0) + (data.usage?.output_tokens || 0),\n },\n provider: this.type,\n model: this.config.model,\n };\n }\n\n async testConnection(): Promise<boolean> {\n try {\n const result = await this.chat({ messages: [{ role: \"user\", content: \"ping\" }], maxTokens: 5 });\n return !!result.content;\n } catch { return false; }\n }\n}\n\n// ─── Config resolution ──────────────────────────────────────────────────────\n\nlet cachedConfig: AiProviderConfig[] | null = null;\nlet cacheExpiry = 0;\n\n/**\n * Get the ordered list of AI providers from PlatformSetting.\n * Falls back to z-ai/GLM if no providers configured.\n */\nexport async function getAiProviders(): Promise<AiProviderConfig[]> {\n if (cachedConfig && Date.now() < cacheExpiry) return cachedConfig;\n\n try {\n const settings = await db.platformSetting.findMany({\n where: { key: { startsWith: \"ai.provider.\" } },\n });\n\n if (settings.length === 0) {\n // Default: z-ai only\n return [{\n provider: \"z-ai\",\n apiKey: null,\n model: \"z-ai-glm\",\n isEnabled: true,\n priority: 1,\n }];\n }\n\n // Parse provider configs from settings\n const providers: AiProviderConfig[] = [];\n const providerMap = new Map<string, Record<string, unknown>>();\n\n for (const s of settings) {\n // Keys: ai.provider.{type}.{field} = ai.provider.openrouter.apiKey, etc.\n const parts = s.key.split(\".\");\n if (parts.length < 4) continue;\n const pType = parts[2] as ProviderType;\n const field = parts[3];\n\n if (!providerMap.has(pType)) providerMap.set(pType, { provider: pType, isEnabled: false, priority: 99 });\n const entry = providerMap.get(pType)!;\n try {\n const val = JSON.parse(s.value);\n if (field === \"apiKey\" && typeof val === \"string\") {\n entry.apiKey = decryptSecret(val);\n } else if (field === \"model\") {\n entry.model = val;\n } else if (field === \"baseUrl\") {\n entry.baseUrl = val;\n } else if (field === \"isEnabled\") {\n entry.isEnabled = val === true;\n } else if (field === \"priority\") {\n entry.priority = typeof val === \"number\" ? val : 99;\n }\n } catch { /* skip malformed */ }\n }\n\n for (const [, entry] of providerMap) {\n providers.push(entry as unknown as AiProviderConfig);\n }\n\n // Sort by priority\n providers.sort((a, b) => a.priority - b.priority);\n\n // If no enabled providers, fall back to z-ai\n if (providers.filter(p => p.isEnabled).length === 0) {\n providers.unshift({\n provider: \"z-ai\", apiKey: null, model: \"z-ai-glm\",\n isEnabled: true, priority: 0,\n });\n }\n\n cachedConfig = providers;\n cacheExpiry = Date.now() + 300_000; // 5 min cache (PERF-005 FIX: was 1 min)\n return providers;\n } catch (err) {\n logger.error(\"[aiProvider] failed to load config\", { err: err instanceof Error ? err.message : String(err) });\n return [{\n provider: \"z-ai\", apiKey: null, model: \"z-ai-glm\",\n isEnabled: true, priority: 1,\n }];\n }\n}\n\n/** Invalidate the cached provider config — call after founder edits settings */\nexport function invalidateAiProviderCache(): void {\n cachedConfig = null;\n cacheExpiry = 0;\n}\n\n/** Create the appropriate provider instance from config */\nfunction createProvider(config: AiProviderConfig): AiProvider | null {\n if (!config.isEnabled) return null;\n switch (config.provider) {\n case \"z-ai\": return new ZaiProvider(config);\n case \"openrouter\": return new OpenAICompatibleProvider(config, \"openrouter\", \"OpenRouter\");\n case \"openai\": return new OpenAICompatibleProvider(config, \"openai\", \"OpenAI\");\n case \"anthropic\": return new AnthropicProvider(config);\n case \"gemini\": return new OpenAICompatibleProvider(config, \"gemini\", \"Google Gemini\");\n case \"deepseek\": return new OpenAICompatibleProvider(config, \"deepseek\", \"DeepSeek\");\n case \"custom\": return new OpenAICompatibleProvider(config, \"custom\", \"Custom\");\n default: return null;\n }\n}\n\n/**\n * Call AI with automatic fallback chain.\n * Tries providers in priority order until one succeeds.\n */\nexport async function callAI(options: ChatOptions): Promise<ChatResult> {\n const providers = await getAiProviders();\n\n for (const config of providers) {\n const provider = createProvider(config);\n if (!provider) continue;\n\n try {\n logger.debug(\"[aiProvider] calling provider\", { provider: config.provider, model: config.model });\n const result = await provider.chat(options);\n logger.info(\"[aiProvider] success\", { provider: result.provider, tokens: result.usage.total_tokens });\n return result;\n } catch (err) {\n logger.warn(\"[aiProvider] provider failed — trying fallback\", { provider: config.provider, err: err instanceof Error ? err.message : String(err) });\n continue;\n }\n }\n\n // All providers failed\n throw new Error(\"جميع مزودي الذكاء الاصطناعي فشلوا. تحقق من الإعدادات.\");\n}\n\n/**\n * Call ONE specific provider — no fallback chain.\n *\n * Used by the AI Orchestration Layer's Smart Router, which manages its own\n * fallback chain across registry models. Throws on failure so the caller can\n * catch and escalate to the next model.\n *\n * The config is fully resolved by the caller (provider + decrypted apiKey +\n * model + baseUrl). This function just instantiates the provider and calls it.\n */\nexport async function callSingleProvider(\n config: AiProviderConfig,\n options: ChatOptions,\n): Promise<ChatResult> {\n const provider = createProvider({ ...config, isEnabled: true });\n if (!provider) {\n throw new Error(`Provider ${config.provider} could not be instantiated`);\n }\n logger.debug(\"[aiProvider] calling single provider\", {\n provider: config.provider,\n model: config.model,\n });\n const result = await provider.chat(options);\n logger.info(\"[aiProvider] single-provider success\", {\n provider: result.provider,\n model: result.model,\n tokens: result.usage.total_tokens,\n });\n return result;\n}\n\n/**\n * Test a specific provider's connection.\n * Used by the admin UI \"test connection\" button.\n *\n * Tests regardless of `isEnabled` state so the founder can validate\n * credentials before activating a provider.\n */\nexport async function testProviderConnection(providerType: ProviderType): Promise<{ ok: boolean; error?: string }> {\n const providers = await getAiProviders();\n const config = providers.find(p => p.provider === providerType);\n\n // If the provider is configured (even disabled), use that config.\n // Otherwise fall back to PROVIDER_INFO defaults so a key can be tested\n // even before any setting has been written.\n let resolved: AiProviderConfig;\n if (config) {\n resolved = { ...config, isEnabled: true };\n } else {\n const info = PROVIDER_INFO.find(p => p.type === providerType);\n if (!info) return { ok: false, error: \"Unknown provider\" };\n resolved = {\n provider: providerType,\n apiKey: null,\n model: info.defaultModel,\n isEnabled: true,\n priority: 99,\n };\n }\n\n const provider = createProvider(resolved);\n if (!provider) return { ok: false, error: \"Provider not configured\" };\n\n try {\n const ok = await provider.testConnection();\n return { ok, error: ok ? undefined : \"Connection failed\" };\n } catch (err) {\n return {\n ok: false,\n error: err instanceof Error ? err.message.slice(0, 200) : \"Connection failed\",\n };\n }\n}\n\n/**\n * Save a provider's API key (encrypted) to PlatformSetting.\n */\nexport async function setProviderApiKey(providerType: ProviderType, apiKey: string): Promise<void> {\n const encrypted = encryptSecret(apiKey);\n const key = `ai.provider.${providerType}.apiKey`;\n\n const existing = await db.platformSetting.findUnique({ where: { key } });\n if (existing) {\n await db.platformSetting.update({ where: { key }, data: { value: JSON.stringify(encrypted), updatedAt: new Date() } });\n } else {\n await db.platformSetting.create({ data: { key, category: \"ai\", valueType: \"string\", value: JSON.stringify(encrypted) } });\n }\n\n // Log to history\n await db.platformSettingHistory.create({\n data: { settingKey: key, newValue: \"[encrypted]\", changedBy: \"system\" },\n });\n\n invalidateAiProviderCache();\n logger.info(\"[aiProvider] API key updated\", { provider: providerType });\n}\n\n/**\n * Set the provider's model.\n */\nexport async function setProviderModel(providerType: ProviderType, model: string): Promise<void> {\n const key = `ai.provider.${providerType}.model`;\n const existing = await db.platformSetting.findUnique({ where: { key } });\n if (existing) {\n await db.platformSetting.update({ where: { key }, data: { value: JSON.stringify(model), updatedAt: new Date() } });\n } else {\n await db.platformSetting.create({ data: { key, category: \"ai\", valueType: \"string\", value: JSON.stringify(model) } });\n }\n invalidateAiProviderCache();\n}\n\n/**\n * Enable/disable a provider.\n */\nexport async function setProviderEnabled(providerType: ProviderType, isEnabled: boolean): Promise<void> {\n const key = `ai.provider.${providerType}.isEnabled`;\n const existing = await db.platformSetting.findUnique({ where: { key } });\n if (existing) {\n await db.platformSetting.update({ where: { key }, data: { value: JSON.stringify(isEnabled), updatedAt: new Date() } });\n } else {\n await db.platformSetting.create({ data: { key, category: \"ai\", valueType: \"boolean\", value: JSON.stringify(isEnabled) } });\n }\n invalidateAiProviderCache();\n}\n\n/**\n * Set provider priority (for fallback ordering).\n */\nexport async function setProviderPriority(providerType: ProviderType, priority: number): Promise<void> {\n const key = `ai.provider.${providerType}.priority`;\n const existing = await db.platformSetting.findUnique({ where: { key } });\n if (existing) {\n await db.platformSetting.update({ where: { key }, data: { value: JSON.stringify(priority), updatedAt: new Date() } });\n } else {\n await db.platformSetting.create({ data: { key, category: \"ai\", valueType: \"number\", value: JSON.stringify(priority) } });\n }\n invalidateAiProviderCache();\n}\n\nexport const PROVIDER_INFO: Array<{ type: ProviderType; name: string; description: string; defaultModel: string; keyPrefix: string }> = [\n { type: \"z-ai\", name: \"z-ai / GLM\", description: \"المزود الافتراضي — لا يحتاج مفتاح API في بيئة التطوير\", defaultModel: \"z-ai-glm\", keyPrefix: \"\" },\n { type: \"openrouter\", name: \"OpenRouter\", description: \"بوابة لعشرات الموديلات بمفتاح واحد (Claude, GPT, Gemini, Llama...)\", defaultModel: \"anthropic/claude-3.5-haiku\", keyPrefix: \"sk-or-\" },\n { type: \"anthropic\", name: \"Anthropic (Claude)\", description: \"مباشر من Anthropic — أفضل جودة للعربية\", defaultModel: \"claude-3-5-haiku-20241022\", keyPrefix: \"sk-ant-\" },\n { type: \"openai\", name: \"OpenAI (GPT)\", description: \"مباشر من OpenAI\", defaultModel: \"gpt-4o-mini\", keyPrefix: \"sk-\" },\n { type: \"deepseek\", name: \"DeepSeek\", description: \"مباشر من DeepSeek — موديلات V3 و R1 بسعر منخفض جداً وجودة عالية\", defaultModel: \"deepseek-chat\", keyPrefix: \"sk-\" },\n { type: \"gemini\", name: \"Google Gemini\", description: \"مباشر من Google (عبر OpenAI-compatible API)\", defaultModel: \"gemini-1.5-flash\", keyPrefix: \"AIza\" },\n { type: \"custom\", name: \"مزود مخصص\", description: \"أي endpoint متوافق مع OpenAI API (self-hosted, Azure, etc.)\", defaultModel: \"\", keyPrefix: \"\" },\n];\n"}, "d405b83c054cae0c82faf84bb3a906b8ba0e3c4d": {"t": 1785763681.6802566, "n": 20816, "h": "bec34e6241c2ab22", "c": "/**\n * aiProvider.ts — Unified AI Provider Abstraction Layer.\n *\n * Replaces the hardcoded z-ai-web-dev-sdk calls with a provider-agnostic\n * interface that supports: OpenRouter, Anthropic, OpenAI, Google Gemini,\n * z-ai/GLM, and any OpenAI-compatible endpoint.\n *\n * Features:\n * - Fallback chain: if primary provider fails, try the next\n * - Encrypted API key storage via cryptoVault\n * - Test connection per provider\n * - Config stored in PlatformSetting table (editable by founder)\n *\n * Usage:\n * import { getAiClient, callAI } from \"@/lib/aiProvider\";\n * const result = await callAI({ messages, companySlug });\n */\n\nimport { db } from \"./db\";\nimport { decryptSecret, encryptSecret } from \"./cryptoVault\";\nimport { logger } from \"./logger\";\nimport { z } from \"zod\";\n\n// P0 FIX (audit finding: missing timeout on AI external API calls):\n// All AI provider HTTP calls now go through `fetchWithTimeout`. A hung\n// upstream (slow model, network stall, deepseek-r1 thinking for minutes)\n// used to keep a server connection open indefinitely, exhausting the\n// connection pool under load. 60s is the upper bound — most calls finish\n// in <10s, so 60s gives plenty of slack for slow reasoning models while\n// still bounding the worst case.\nconst DEFAULT_AI_TIMEOUT_MS = 60_000;\n\nasync function fetchWithTimeout(\n url: string,\n init: RequestInit,\n timeoutMs: number = DEFAULT_AI_TIMEOUT_MS,\n): Promise<Response> {\n const controller = new AbortController();\n const timer = setTimeout(() => controller.abort(), timeoutMs);\n try {\n return await fetch(url, { ...init, signal: controller.signal });\n } catch (err) {\n if (err instanceof Error && err.name === \"AbortError\") {\n throw new Error(`AI request timed out after ${timeoutMs}ms — ${url}`);\n }\n throw err;\n } finally {\n clearTimeout(timer);\n }\n}\n\n// SEC-006 FIX: SSRF protection for custom AI endpoints\nfunction validateBaseUrl(url: string): void {\n const parsed = new URL(url);\n if (![\"https:\"].includes(parsed.protocol)) {\n throw new Error(\"Custom AI endpoint must use HTTPS\");\n }\n const blockedHosts = [\"localhost\", \"127.0.0.1\", \"0.0.0.0\", \"169.254.169.254\", \"::1\", \"[::1]\"];\n if (blockedHosts.includes(parsed.hostname)) {\n throw new Error(\"Internal addresses are not allowed for custom AI endpoint\");\n }\n // Block private IP ranges\n const ipMatch = parsed.hostname.match(/^(\\d+)\\.(\\d+)\\.(\\d+)\\.(\\d+)$/);\n if (ipMatch) {\n const [, a] = ipMatch;\n if (a === \"10\" || a === \"172\" || a === \"192\" || a === \"127\") {\n throw new Error(\"Private/internal IP addresses are not allowed for custom AI endpoint\");\n }\n }\n}\n\n// ─── Types ──────────────────────────────────────────────────────────────────\n\nexport type ProviderType = \"z-ai\" | \"openrouter\" | \"anthropic\" | \"openai\" | \"gemini\" | \"deepseek\" | \"custom\";\n\nexport interface AiProviderConfig {\n provider: ProviderType;\n apiKey: string | null; // decrypted\n model: string;\n baseUrl?: string; // for custom OpenAI-compatible\n isEnabled: boolean;\n priority: number; // 1 = primary, 2 = first fallback, etc.\n}\n\nexport interface ChatMessage {\n role: \"system\" | \"user\" | \"assistant\";\n content: string | Array<{ type: string; text?: string; image_url?: { url: string } }>;\n}\n\nexport interface ChatOptions {\n messages: ChatMessage[];\n temperature?: number;\n maxTokens?: number;\n companySlug?: string;\n}\n\nexport interface ChatResult {\n content: string;\n usage: { prompt_tokens: number; completion_tokens: number; total_tokens: number };\n provider: ProviderType;\n model: string;\n}\n\nexport interface AiProvider {\n type: ProviderType;\n name: string;\n chat(options: ChatOptions): Promise<ChatResult>;\n testConnection(): Promise<boolean>;\n}\n\n// ─── Provider implementations ───────────────────────────────────────────────\n\n/** z-ai / GLM provider — uses z-ai-web-dev-sdk (no API key needed in sandbox) */\nclass ZaiProvider implements AiProvider {\n type: ProviderType = \"z-ai\";\n name = \"z-ai / GLM\";\n private config: AiProviderConfig;\n\n constructor(config: AiProviderConfig) { this.config = config; }\n\n async chat(options: ChatOptions): Promise<ChatResult> {\n const ZAI = (await import(\"z-ai-web-dev-sdk\")).default;\n const ai = await ZAI.create();\n const completion = await ai.chat.completions.create({\n messages: options.messages.map(m => ({\n role: m.role,\n content: m.content as string,\n })),\n temperature: options.temperature ?? 0.4,\n max_tokens: options.maxTokens ?? 800,\n });\n return {\n content: completion.choices?.[0]?.message?.content || \"\",\n usage: completion.usage || { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 },\n provider: this.type,\n model: this.config.model || \"z-ai-glm\",\n };\n }\n\n async testConnection(): Promise<boolean> {\n try {\n const result = await this.chat({ messages: [{ role: \"user\", content: \"ping\" }] });\n return !!result.content;\n } catch { return false; }\n }\n}\n\n/** OpenAI-compatible provider — works for OpenAI, OpenRouter, DeepSeek, and custom endpoints */\nclass OpenAICompatibleProvider implements AiProvider {\n type: ProviderType;\n name: string;\n private config: AiProviderConfig;\n\n constructor(config: AiProviderConfig, type: ProviderType, name: string) {\n this.config = config;\n this.type = type;\n this.name = name;\n }\n\n private getBaseUrl(): string {\n switch (this.type) {\n case \"openrouter\": return \"https://openrouter.ai/api/v1\";\n case \"openai\": return \"https://api.openai.com/v1\";\n case \"deepseek\": return \"https://api.deepseek.com/v1\";\n case \"custom\": {\n // SEC-006 FIX: SSRF protection — validate custom baseUrl\n const url = this.config.baseUrl || \"\";\n if (url) validateBaseUrl(url);\n return url;\n }\n default: return \"\";\n }\n }\n\n async chat(options: ChatOptions): Promise<ChatResult> {\n if (!this.config.apiKey) throw new Error(`No API key for ${this.name}`);\n\n const baseUrl = this.getBaseUrl();\n const headers: Record<string, string> = {\n \"Content-Type\": \"application/json\",\n \"Authorization\": `Bearer ${this.config.apiKey}`,\n };\n\n // OpenRouter-specific headers\n if (this.type === \"openrouter\") {\n headers[\"HTTP-Referer\"] = process.env.APP_URL || \"http://localhost:3000\";\n headers[\"X-Title\"] = \"GarfiX ERP\";\n }\n\n // DeepSeek-specific: supports frequency_penalty and other params\n // (no extra headers needed — uses standard Bearer auth like OpenAI)\n\n const body: Record<string, unknown> = {\n model: this.config.model,\n messages: options.messages.map(m => ({\n role: m.role,\n content: typeof m.content === \"string\" ? m.content : m.content,\n })),\n temperature: options.temperature ?? 0.4,\n };\n if (options.maxTokens) body.max_tokens = options.maxTokens;\n\n const res = await fetchWithTimeout(\n `${baseUrl}/chat/completions`,\n {\n method: \"POST\",\n headers,\n body: JSON.stringify(body),\n },\n 60_000, // P0 FIX (audit finding: missing timeout on AI external calls)\n );\n\n if (!res.ok) {\n const err = await res.text();\n throw new Error(`${this.name} API error ${res.status}: ${err.slice(0, 200)}`);\n }\n\n const data = await res.json();\n return {\n content: data.choices?.[0]?.message?.content || \"\",\n usage: data.usage || { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 },\n provider: this.type,\n model: this.config.model,\n };\n }\n\n async testConnection(): Promise<boolean> {\n try {\n const result = await this.chat({\n messages: [{ role: \"user\", content: \"ping\" }],\n maxTokens: 5,\n });\n return !!result.content;\n } catch { return false; }\n }\n}\n\n/** Anthropic (Claude) provider — uses Anthropic Messages API directly */\nclass AnthropicProvider implements AiProvider {\n type: ProviderType = \"anthropic\";\n name = \"Anthropic (Claude)\";\n private config: AiProviderConfig;\n\n constructor(config: AiProviderConfig) { this.config = config; }\n\n async chat(options: ChatOptions): Promise<ChatResult> {\n if (!this.config.apiKey) throw new Error(\"No Anthropic API key\");\n\n // Extract system message\n const systemMsg = options.messages.find(m => m.role === \"system\");\n const chatMessages = options.messages.filter(m => m.role !== \"system\");\n\n const res = await fetchWithTimeout(\n \"https://api.anthropic.com/v1/messages\",\n {\n method: \"POST\",\n headers: {\n \"Content-Type\": \"application/json\",\n \"x-api-key\": this.config.apiKey,\n \"anthropic-version\": \"2023-06-01\",\n },\n body: JSON.stringify({\n model: this.config.model,\n max_tokens: options.maxTokens ?? 800,\n system: typeof systemMsg?.content === \"string\" ? systemMsg.content : \"\",\n messages: chatMessages.map(m => ({\n role: m.role,\n content: typeof m.content === \"string\" ? m.content : JSON.stringify(m.content),\n })),\n }),\n },\n 60_000, // P0 FIX: timeout on AI external calls\n );\n\n if (!res.ok) throw new Error(`Anthropic API error ${res.status}`);\n\n const data = await res.json();\n const content = data.content?.[0]?.text || \"\";\n return {\n content,\n usage: {\n prompt_tokens: data.usage?.input_tokens || 0,\n completion_tokens: data.usage?.output_tokens || 0,\n total_tokens: (data.usage?.input_tokens || 0) + (data.usage?.output_tokens || 0),\n },\n provider: this.type,\n model: this.config.model,\n };\n }\n\n async testConnection(): Promise<boolean> {\n try {\n const result = await this.chat({ messages: [{ role: \"user\", content: \"ping\" }], maxTokens: 5 });\n return !!result.content;\n } catch { return false; }\n }\n}\n\n// ─── Config resolution ──────────────────────────────────────────────────────\n\nlet cachedConfig: AiProviderConfig[] | null = null;\nlet cacheExpiry = 0;\n\n/**\n * Get the ordered list of AI providers from PlatformSetting.\n * Falls back to z-ai/GLM if no providers configured.\n */\nexport async function getAiProviders(): Promise<AiProviderConfig[]> {\n if (cachedConfig && Date.now() < cacheExpiry) return cachedConfig;\n\n try {\n const settings = await db.platformSetting.findMany({\n where: { key: { startsWith: \"ai.provider.\" } },\n });\n\n if (settings.length === 0) {\n // Default: z-ai only\n return [{\n provider: \"z-ai\",\n apiKey: null,\n model: \"z-ai-glm\",\n isEnabled: true,\n priority: 1,\n }];\n }\n\n // Parse provider configs from settings\n const providers: AiProviderConfig[] = [];\n const providerMap = new Map<string, Record<string, unknown>>();\n\n for (const s of settings) {\n // Keys: ai.provider.{type}.{field} = ai.provider.openrouter.apiKey, etc.\n const parts = s.key.split(\".\");\n if (parts.length < 4) continue;\n const pType = parts[2] as ProviderType;\n const field = parts[3];\n\n if (!providerMap.has(pType)) providerMap.set(pType, { provider: pType, isEnabled: false, priority: 99 });\n const entry = providerMap.get(pType)!;\n try {\n const val = JSON.parse(s.value);\n if (field === \"apiKey\" && typeof val === \"string\") {\n entry.apiKey = decryptSecret(val);\n } else if (field === \"model\") {\n entry.model = val;\n } else if (field === \"baseUrl\") {\n entry.baseUrl = val;\n } else if (field === \"isEnabled\") {\n entry.isEnabled = val === true;\n } else if (field === \"priority\") {\n entry.priority = typeof val === \"number\" ? val : 99;\n }\n } catch { /* skip malformed */ }\n }\n\n for (const [, entry] of providerMap) {\n providers.push(entry as unknown as AiProviderConfig);\n }\n\n // Sort by priority\n providers.sort((a, b) => a.priority - b.priority);\n\n // If no enabled providers, fall back to z-ai\n if (providers.filter(p => p.isEnabled).length === 0) {\n providers.unshift({\n provider: \"z-ai\", apiKey: null, model: \"z-ai-glm\",\n isEnabled: true, priority: 0,\n });\n }\n\n cachedConfig = providers;\n cacheExpiry = Date.now() + 300_000; // 5 min cache (PERF-005 FIX: was 1 min)\n return providers;\n } catch (err) {\n logger.error(\"[aiProvider] failed to load config\", { err: err instanceof Error ? err.message : String(err) });\n return [{\n provider: \"z-ai\", apiKey: null, model: \"z-ai-glm\",\n isEnabled: true, priority: 1,\n }];\n }\n}\n\n/** Invalidate the cached provider config — call after founder edits settings */\nexport function invalidateAiProviderCache(): void {\n cachedConfig = null;\n cacheExpiry = 0;\n}\n\n/** Create the appropriate provider instance from config */\nfunction createProvider(config: AiProviderConfig): AiProvider | null {\n if (!config.isEnabled) return null;\n switch (config.provider) {\n case \"z-ai\": return new ZaiProvider(config);\n case \"openrouter\": return new OpenAICompatibleProvider(config, \"openrouter\", \"OpenRouter\");\n case \"openai\": return new OpenAICompatibleProvider(config, \"openai\", \"OpenAI\");\n case \"anthropic\": return new AnthropicProvider(config);\n case \"gemini\": return new OpenAICompatibleProvider(config, \"gemini\", \"Google Gemini\");\n case \"deepseek\": return new OpenAICompatibleProvider(config, \"deepseek\", \"DeepSeek\");\n case \"custom\": return new OpenAICompatibleProvider(config, \"custom\", \"Custom\");\n default: return null;\n }\n}\n\n/**\n * Call AI with automatic fallback chain.\n * Tries providers in priority order until one succeeds.\n */\nexport async function callAI(options: ChatOptions): Promise<ChatResult> {\n const providers = await getAiProviders();\n\n for (const config of providers) {\n const provider = createProvider(config);\n if (!provider) continue;\n\n try {\n logger.debug(\"[aiProvider] calling provider\", { provider: config.provider, model: config.model });\n const result = await provider.chat(options);\n logger.info(\"[aiProvider] success\", { provider: result.provider, tokens: result.usage.total_tokens });\n return result;\n } catch (err) {\n logger.warn(\"[aiProvider] provider failed — trying fallback\", { provider: config.provider, err: err instanceof Error ? err.message : String(err) });\n continue;\n }\n }\n\n // All providers failed\n throw new Error(\"جميع مزودي الذكاء الاصطناعي فشلوا. تحقق من الإعدادات.\");\n}\n\n/**\n * Call ONE specific provider — no fallback chain.\n *\n * Used by the AI Orchestration Layer's Smart Router, which manages its own\n * fallback chain across registry models. Throws on failure so the caller can\n * catch and escalate to the next model.\n *\n * The config is fully resolved by the caller (provider + decrypted apiKey +\n * model + baseUrl). This function just instantiates the provider and calls it.\n */\nexport async function callSingleProvider(\n config: AiProviderConfig,\n options: ChatOptions,\n): Promise<ChatResult> {\n const provider = createProvider({ ...config, isEnabled: true });\n if (!provider) {\n throw new Error(`Provider ${config.provider} could not be instantiated`);\n }\n logger.debug(\"[aiProvider] calling single provider\", {\n provider: config.provider,\n model: config.model,\n });\n const result = await provider.chat(options);\n logger.info(\"[aiProvider] single-provider success\", {\n provider: result.provider,\n model: result.model,\n tokens: result.usage.total_tokens,\n });\n return result;\n}\n\n/**\n * Test a specific provider's connection.\n * Used by the admin UI \"test connection\" button.\n *\n * Tests regardless of `isEnabled` state so the founder can validate\n * credentials before activating a provider.\n */\nexport async function testProviderConnection(providerType: ProviderType): Promise<{ ok: boolean; error?: string }> {\n const providers = await getAiProviders();\n const config = providers.find(p => p.provider === providerType);\n\n // If the provider is configured (even disabled), use that config.\n // Otherwise fall back to PROVIDER_INFO defaults so a key can be tested\n // even before any setting has been written.\n let resolved: AiProviderConfig;\n if (config) {\n resolved = { ...config, isEnabled: true };\n } else {\n const info = PROVIDER_INFO.find(p => p.type === providerType);\n if (!info) return { ok: false, error: \"Unknown provider\" };\n resolved = {\n provider: providerType,\n apiKey: null,\n model: info.defaultModel,\n isEnabled: true,\n priority: 99,\n };\n }\n\n const provider = createProvider(resolved);\n if (!provider) return { ok: false, error: \"Provider not configured\" };\n\n try {\n const ok = await provider.testConnection();\n return { ok, error: ok ? undefined : \"Connection failed\" };\n } catch (err) {\n return {\n ok: false,\n error: err instanceof Error ? err.message.slice(0, 200) : \"Connection failed\",\n };\n }\n}\n\n/**\n * Save a provider's API key (encrypted) to PlatformSetting.\n */\nexport async function setProviderApiKey(providerType: ProviderType, apiKey: string): Promise<void> {\n const encrypted = encryptSecret(apiKey);\n const key = `ai.provider.${providerType}.apiKey`;\n\n const existing = await db.platformSetting.findUnique({ where: { key } });\n if (existing) {\n await db.platformSetting.update({ where: { key }, data: { value: JSON.stringify(encrypted), updatedAt: new Date() } });\n } else {\n await db.platformSetting.create({ data: { key, category: \"ai\", valueType: \"string\", value: JSON.stringify(encrypted) } });\n }\n\n // Log to history\n await db.platformSettingHistory.create({\n data: { settingKey: key, newValue: \"[encrypted]\", changedBy: \"system\" },\n });\n\n invalidateAiProviderCache();\n logger.info(\"[aiProvider] API key updated\", { provider: providerType });\n}\n\n/**\n * Set the provider's model.\n */\nexport async function setProviderModel(providerType: ProviderType, model: string): Promise<void> {\n const key = `ai.provider.${providerType}.model`;\n const existing = await db.platformSetting.findUnique({ where: { key } });\n if (existing) {\n await db.platformSetting.update({ where: { key }, data: { value: JSON.stringify(model), updatedAt: new Date() } });\n } else {\n await db.platformSetting.create({ data: { key, category: \"ai\", valueType: \"string\", value: JSON.stringify(model) } });\n }\n invalidateAiProviderCache();\n}\n\n/**\n * Enable/disable a provider.\n */\nexport async function setProviderEnabled(providerType: ProviderType, isEnabled: boolean): Promise<void> {\n const key = `ai.provider.${providerType}.isEnabled`;\n const existing = await db.platformSetting.findUnique({ where: { key } });\n if (existing) {\n await db.platformSetting.update({ where: { key }, data: { value: JSON.stringify(isEnabled), updatedAt: new Date() } });\n } else {\n await db.platformSetting.create({ data: { key, category: \"ai\", valueType: \"boolean\", value: JSON.stringify(isEnabled) } });\n }\n invalidateAiProviderCache();\n}\n\n/**\n * Set provider priority (for fallback ordering).\n */\nexport async function setProviderPriority(providerType: ProviderType, priority: number): Promise<void> {\n const key = `ai.provider.${providerType}.priority`;\n const existing = await db.platformSetting.findUnique({ where: { key } });\n if (existing) {\n await db.platformSetting.update({ where: { key }, data: { value: JSON.stringify(priority), updatedAt: new Date() } });\n } else {\n await db.platformSetting.create({ data: { key, category: \"ai\", valueType: \"number\", value: JSON.stringify(priority) } });\n }\n invalidateAiProviderCache();\n}\n\nexport const PROVIDER_INFO: Array<{ type: ProviderType; name: string; description: string; defaultModel: string; keyPrefix: string }> = [\n { type: \"z-ai\", name: \"z-ai / GLM\", description: \"المزود الافتراضي — لا يحتاج مفتاح API في بيئة التطوير\", defaultModel: \"z-ai-glm\", keyPrefix: \"\" },\n { type: \"openrouter\", name: \"OpenRouter\", description: \"بوابة لعشرات الموديلات بمفتاح واحد (Claude, GPT, Gemini, Llama...)\", defaultModel: \"anthropic/claude-3.5-haiku\", keyPrefix: \"sk-or-\" },\n { type: \"anthropic\", name: \"Anthropic (Claude)\", description: \"مباشر من Anthropic — أفضل جودة للعربية\", defaultModel: \"claude-3-5-haiku-20241022\", keyPrefix: \"sk-ant-\" },\n { type: \"openai\", name: \"OpenAI (GPT)\", description: \"مباشر من OpenAI\", defaultModel: \"gpt-4o-mini\", keyPrefix: \"sk-\" },\n { type: \"deepseek\", name: \"DeepSeek\", description: \"مباشر من DeepSeek — موديلات V3 و R1 بسعر منخفض جداً وجودة عالية\", defaultModel: \"deepseek-chat\", keyPrefix: \"sk-\" },\n { type: \"gemini\", name: \"Google Gemini\", description: \"مباشر من Google (عبر OpenAI-compatible API)\", defaultModel: \"gemini-1.5-flash\", keyPrefix: \"AIza\" },\n { type: \"custom\", name: \"مزود مخصص\", description: \"أي endpoint متوافق مع OpenAI API (self-hosted, Azure, etc.)\", defaultModel: \"\", keyPrefix: \"\" },\n];\n"}}, ".env.example": {"b2bda255dd45ab495bde8a2c376023516465c4c3": {"t": 1786068612.5003426, "n": 5395, "h": "11953d63090ea8e9", "c": "# ─────────────────────────────────────────────────────────────────────────────\n# GarfiX EOS v12.0 — Environment Configuration TEMPLATE\n# ─────────────────────────────────────────────────────────────────────────────\n# Copy this file to `.env` (which is git-ignored) and fill in real values.\n#\n# IMPORTANT — Secret Hygiene (SEC-C3 hardening):\n# * `.env` is git-ignored via the `.env*` rule in `.gitignore`.\n# * Never commit real secrets. Use `.env.example` for placeholders only.\n# * In production, set these via your hosting provider's secret manager\n# (Vercel/Render/RDS Secrets Manager, Kubernetes secrets, dotenv-vault,\n# Doppler, etc.) — never via committed files.\n# * The startup check refuses to boot in production if any secret matches\n# a known placeholder value.\n# ─────────────────────────────────────────────────────────────────────────────\n\n# Database (PostgreSQL — required in all environments)\nDATABASE_URL=\"postgresql://USER:STRONG_PASSWORD@HOST:5432/DBNAME?schema=public&connection_limit=20&pool_timeout=30\"\nDATABASE_DIRECT_URL=\"postgresql://USER:STRONG_PASSWORD@HOST:5432/DBNAME\"\nDATABASE_POOL_SIZE=20\n\n# JWT Authentication — generate with: openssl rand -hex 64\n# Cycle 2 hardening: secrets must be high-entropy (>= 32 chars, no placeholder\n# patterns). The startup check rejects secrets matching /garfix/i, /change[-_]?me/i,\n# /password/i, etc. JWT_SECRET and JWT_REFRESH_SECRET MUST differ.\nJWT_SECRET=REPLACE_WITH_RANDOM_64_BYTE_HEX_STRING\nJWT_REFRESH_SECRET=REPLACE_WITH_DIFFERENT_RANDOM_64_BYTE_HEX_STRING\nJWT_ACCESS_TTL_SECONDS=1800\nJWT_REFRESH_TTL_SECONDS=2592000\n# HIGH-005 FIX (Cycle 2): OWASP 2025 recommends bcrypt cost >= 12.\n# Existing hashes at lower cost are auto-upgraded on next login.\nBCRYPT_ROUNDS=12\nBCRYPT_MIN_ROUNDS=12\n\n# Payments Encryption Key — generate with: openssl rand -base64 32\nPAYMENTS_ENC_KEY=REPLACE_WITH_RANDOM_32_BYTE_BASE64_STRING\n\n# Founder Account Email\nFOUNDER_EMAIL=founder@example.com\n\n# Valkey/Redis Connection (for BullMQ queues + rate limiting + token blacklist)\n# Required in any multi-instance production deployment.\nVALKEY_URL=valkey://localhost:6379\n\n# Trusted proxy IPs (comma-separated) — set to your load balancer / CDN egress\n# so that rate-limit-by-IP and audit logs see the real client IP.\n# TRUSTED_PROXIES=10.0.0.1,10.0.0.2\n\n# SMTP Configuration (optional — email features disabled if missing)\n# SMTP_HOST=smtp.gmail.com\n# SMTP_PORT=587\n# SMTP_USER=your-email@gmail.com\n# SMTP_PASSWORD=your-app-password\n# SMTP_FROM=noreply@garfix.app\n# Note: code reads SMTP_PASSWORD (not SMTP_PASS). Older docs may reference\n# SMTP_PASS — those are outdated; use SMTP_PASSWORD.\n\n# Public app URL — used for building OAuth callbacks, payment redirects,\n# audit-log references, and email links. MUST be set in production.\n# Example: https://garfix.app\nAPP_URL=http://localhost:3000\n\n# ═════════════════════════════════════════════════════════════\n# GarfiX AI - Multi-Key Gemini Configuration\n# ═════════════════════════════════════════════════════════════\n#\n# OPTION 1: Single Gemini API Key\nGEMINI_API_KEY=your_api_key_here\n#\n# OPTION 2: Multiple Keys for Load Balancing (موصى به ⭐)\n# Format: key1:name1,key2:name2,key3:name3,...\n# This gives you: 5 keys × 15 RPM = 75 RPM total!\n#\n# Example with 5 family accounts:\nGEMINI_API_KEYS=\n API_KEY_1:Account-Ahmed,\n API_KEY_2:Account-Sister1,\n API_KEY_3:Account-Sister2,\n API_KEY_4:Account-Brother,\n API_KEY_5:Account-Friend\n\n# Available models:\n# gemini-2.0-flash → الأسرع والأذكى (موصى به) ⭐\n# gemini-1.5-flash → سريع ونافع\n# gemini-1.5-flash-8b → الأخف والأرخص\n# gemini-1.5-pro → الأقوى للتحليل المعقد\nGEMINI_MODEL=gemini-2.0-flash\nGEMINI_MAX_TOKENS=4096\nGEMINI_TEMPERATURE=0.7\nGEMINI_DEFAULT_LANGUAGE=ar\n\n# Other AI Providers (optional)\n# OPENROUTER_API_KEY=sk-or-v1-...\n# OPENAI_API_KEY=sk-...\n# ANTHROPIC_API_KEY=sk-ant-...\n# DEEPSEEK_API_KEY=sk-...\n\n# Vault encryption key for sensitive PlatformSettings (generate with: openssl rand -base64 32)\nVAULT_ENCRYPTION_KEY=REPLACE_WITH_RANDOM_32_BYTE_BASE64_STRING\n\n# WhatsApp Business API (optional)\n# WHATSAPP_WEBHOOK_VERIFY_TOKEN=your-verify-token\n# WHATSAPP_ALLOWED_SENDERS=+1234567890,+0987654321\n\n# Node Environment\nNODE_ENV=development\n\n# Next.js Configuration\nNEXT_TELEMETRY_DISABLED=1\n\n# Session concurrency limit (optional, default 5)\nMAX_SESSIONS_PER_USER=5\n\n# Cookie sameSite policy (lax | strict | none). Use 'none' only if you\n# serve the API from a different origin than the SPA (requires HTTPS).\nCOOKIE_SAMESITE=lax\n\n# ─── Cycle 3 hardening ────────────────────────────────────────────────────\n# SEC-H4: SessionRegistry enforcement. Set to \"true\" AFTER deploying this\n# code and waiting 30 min (one access-token TTL). See auth.ts comment for\n# the full rollout plan. Default: false (preserves backward compat).\nSESSION_REGISTRY_ENFORCED=false\n\n# LOW-003: Cookie `secure` flag override. By default, secure=true in\n# production only. Set COOKIE_SECURE=true to force on (e.g. dev HTTPS), or\n# COOKIE_SECURE=false to force off (e.g. staging over HTTP). Leave unset\n# for the default production-only behavior.\n# COOKIE_SECURE=true\n\n# LOW-005: Maximum JSON request body size in bytes (default: 1048576 = 1 MiB).\n# Oversize requests are rejected before buffering to prevent memory DoS.\n# Routes that need larger payloads (file uploads) bypass parseJsonBody.\nMAX_JSON_BODY_BYTES=1048576\n"}}}}