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.
1 line
122 KiB
JSON
1 line
122 KiB
JSON
{"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"}}}} |