diff --git a/server/ai.mjs b/server/ai.mjs index 696b9eb..a8095d4 100644 --- a/server/ai.mjs +++ b/server/ai.mjs @@ -1,55 +1,79 @@ /** - * Gerçek AI şartname analizi — çoklu sağlayıcı + fallback zinciri. + * Gerçek AI şartname analizi — OpenRouter öncelikli, çoklu sağlayıcı fallback. * Bağımlılık yok (Node 18+ built-in fetch). * - * Env (hangisi varsa o sağlayıcı kullanılabilir olur): - * ZAI_API_KEY → GLM (Z.ai) - * DEEPSEEK_API_KEY → DeepSeek - * GEMINI_API_KEY → Gemini (Google) - * XAI_API_KEY → Grok (xAI) - * OPENAI_API_KEY → GPT (OpenAI) - * ANTHROPIC_API_KEY → Claude (Anthropic) + * EN KOLAY KURULUM — tek anahtar, tüm modeller: + * OPENROUTER_API_KEY=sk-or-v1-... + * Bu tanımlıysa zincirdeki tüm modeller OpenRouter üzerinden çağrılır. * - * Model kimlikleri env ile değiştirilebilir (ör. GLM_MODEL=glm-5.3). + * İsteğe bağlı doğrudan sağlayıcı anahtarları (OpenRouter'ı ezer): + * ZAI_API_KEY, DEEPSEEK_API_KEY, GEMINI_API_KEY, + * XAI_API_KEY, OPENAI_API_KEY, ANTHROPIC_API_KEY + * + * Model kimlikleri env ile değiştirilebilir (ör. GLM_MODEL, GLM_OR_MODEL). */ +const OPENROUTER_URL = 'https://openrouter.ai/api/v1/chat/completions'; + export const PROVIDERS = { glm: { label: 'GLM', provider: 'Z.ai', envKey: 'ZAI_API_KEY', model: () => process.env.GLM_MODEL || 'glm-5.3', + or: () => process.env.GLM_OR_MODEL || '~z-ai/glm-latest', url: 'https://api.z.ai/api/paas/v4/chat/completions', style: 'openai', }, deepseek: { label: 'DeepSeek', provider: 'DeepSeek', envKey: 'DEEPSEEK_API_KEY', model: () => process.env.DEEPSEEK_MODEL || 'deepseek-chat', + or: () => process.env.DEEPSEEK_OR_MODEL || '~deepseek/deepseek-v4-flash-latest', url: 'https://api.deepseek.com/chat/completions', style: 'openai', }, gemini: { label: 'Gemini', provider: 'Google', envKey: 'GEMINI_API_KEY', model: () => process.env.GEMINI_MODEL || 'gemini-flash-latest', + or: () => process.env.GEMINI_OR_MODEL || '~google/gemini-flash-latest', style: 'gemini', }, grok: { label: 'Grok', provider: 'xAI', envKey: 'XAI_API_KEY', - model: () => process.env.XAI_MODEL || 'grok-4', + model: () => process.env.XAI_MODEL || 'grok-4.6', + or: () => process.env.XAI_OR_MODEL || '~x-ai/grok-latest', url: 'https://api.x.ai/v1/chat/completions', style: 'openai', }, gpt: { label: 'GPT', provider: 'OpenAI', envKey: 'OPENAI_API_KEY', - model: () => process.env.OPENAI_MODEL || 'gpt-5.2', + model: () => process.env.OPENAI_MODEL || 'gpt-5.6-sol', + or: () => process.env.OPENAI_OR_MODEL || 'openai/gpt-chat-latest', url: 'https://api.openai.com/v1/chat/completions', style: 'openai', }, claude: { label: 'Claude', provider: 'Anthropic', envKey: 'ANTHROPIC_API_KEY', - model: () => process.env.ANTHROPIC_MODEL || 'claude-sonnet-5', + model: () => process.env.ANTHROPIC_MODEL || 'claude-opus-5', + or: () => process.env.ANTHROPIC_OR_MODEL || '~anthropic/claude-opus-latest', url: 'https://api.anthropic.com/v1/messages', style: 'anthropic', }, }; +export const openRouterKey = () => process.env.OPENROUTER_API_KEY || null; + +/** Bir model için hangi yol kullanılacak: doğrudan sağlayıcı mı, OpenRouter mı? */ +export function routeFor(id) { + const p = PROVIDERS[id]; + if (!p) return null; + if (process.env[p.envKey]) return { yol: 'direct', key: process.env[p.envKey], model: p.model() }; + if (openRouterKey()) return { yol: 'openrouter', key: openRouterKey(), model: p.or() }; + return null; +} + export function availableProviders() { - return Object.entries(PROVIDERS) - .filter(([, p]) => Boolean(process.env[p.envKey])) - .map(([id, p]) => ({ id, label: p.label, provider: p.provider, model: p.model() })); + return Object.keys(PROVIDERS) + .map((id) => { + const r = routeFor(id); + if (!r) return null; + const p = PROVIDERS[id]; + return { id, label: p.label, provider: p.provider, model: r.model, yol: r.yol }; + }) + .filter(Boolean); } export function aiConfigured() { @@ -93,12 +117,41 @@ function buildUserPrompt({ text, fileName, tender }) { return parts.join('\n'); } -async function callOpenAiStyle(p, key, userPrompt, signal) { +// --- OpenRouter (OpenAI uyumlu) --- +async function callOpenRouter(model, key, userPrompt, signal) { + const res = await fetch(OPENROUTER_URL, { + method: 'POST', + headers: { + Authorization: `Bearer ${key}`, + 'Content-Type': 'application/json', + // OpenRouter sıralamaları için önerilen başlıklar + 'HTTP-Referer': process.env.APP_URL || 'https://ihaleist.seymata.com', + 'X-Title': 'Ihaleist', + }, + body: JSON.stringify({ + model, + messages: [ + { role: 'system', content: SYSTEM_PROMPT }, + { role: 'user', content: userPrompt }, + ], + temperature: 0.2, + max_tokens: 4000, + response_format: { type: 'json_object' }, + }), + signal, + }); + const data = await res.json().catch(() => ({})); + if (!res.ok) throw new Error(data?.error?.message || `HTTP ${res.status}`); + if (data?.error) throw new Error(data.error.message || 'OpenRouter hatası'); + return data?.choices?.[0]?.message?.content || ''; +} + +async function callOpenAiStyle(p, key, model, userPrompt, signal) { const res = await fetch(p.url, { method: 'POST', headers: { Authorization: `Bearer ${key}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ - model: p.model(), + model, messages: [ { role: 'system', content: SYSTEM_PROMPT }, { role: 'user', content: userPrompt }, @@ -113,8 +166,8 @@ async function callOpenAiStyle(p, key, userPrompt, signal) { return data?.choices?.[0]?.message?.content || ''; } -async function callGemini(p, key, userPrompt, signal) { - const url = `https://generativelanguage.googleapis.com/v1beta/models/${p.model()}:generateContent?key=${encodeURIComponent(key)}`; +async function callGemini(p, key, model, userPrompt, signal) { + const url = `https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent?key=${encodeURIComponent(key)}`; const res = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -130,16 +183,12 @@ async function callGemini(p, key, userPrompt, signal) { return data?.candidates?.[0]?.content?.parts?.map((x) => x.text).join('') || ''; } -async function callAnthropic(p, key, userPrompt, signal) { +async function callAnthropic(p, key, model, userPrompt, signal) { const res = await fetch(p.url, { method: 'POST', - headers: { - 'x-api-key': key, - 'anthropic-version': '2023-06-01', - 'Content-Type': 'application/json', - }, + headers: { 'x-api-key': key, 'anthropic-version': '2023-06-01', 'Content-Type': 'application/json' }, body: JSON.stringify({ - model: p.model(), + model, max_tokens: 4000, system: SYSTEM_PROMPT, messages: [{ role: 'user', content: userPrompt }], @@ -154,7 +203,6 @@ async function callAnthropic(p, key, userPrompt, signal) { function extractJson(raw) { if (!raw) throw new Error('Model boş yanıt döndürdü'); let s = String(raw).trim(); - // ```json ... ``` sarmalını temizle const fence = s.match(/```(?:json)?\s*([\s\S]*?)```/); if (fence) s = fence[1].trim(); const start = s.indexOf('{'); @@ -163,10 +211,7 @@ function extractJson(raw) { return JSON.parse(s.slice(start, end + 1)); } -/** - * Zinciri sırayla dener; ilk başarılı sonucu döndürür. - * @returns {{result:object, model:object, attempts:Array}} - */ +/** Zinciri sırayla dener; ilk başarılı sonucu döndürür. */ export async function analyzeSpec({ text, fileName, tender, chain }) { const order = (Array.isArray(chain) && chain.length ? chain : ['glm', 'deepseek', 'gemini', 'grok', 'gpt', 'claude']) .filter((id) => PROVIDERS[id]); @@ -175,27 +220,33 @@ export async function analyzeSpec({ text, fileName, tender, chain }) { for (const id of order) { const p = PROVIDERS[id]; - const key = process.env[p.envKey]; - if (!key) { - attempts.push({ id, ok: false, error: `${p.envKey} tanımlı değil` }); + const route = routeFor(id); + if (!route) { + attempts.push({ id, ok: false, error: `anahtar yok (${p.envKey} veya OPENROUTER_API_KEY)` }); continue; } const ac = new AbortController(); const timer = setTimeout(() => ac.abort(), 90000); try { let raw; - if (p.style === 'gemini') raw = await callGemini(p, key, userPrompt, ac.signal); - else if (p.style === 'anthropic') raw = await callAnthropic(p, key, userPrompt, ac.signal); - else raw = await callOpenAiStyle(p, key, userPrompt, ac.signal); + if (route.yol === 'openrouter') { + raw = await callOpenRouter(route.model, route.key, userPrompt, ac.signal); + } else if (p.style === 'gemini') { + raw = await callGemini(p, route.key, route.model, userPrompt, ac.signal); + } else if (p.style === 'anthropic') { + raw = await callAnthropic(p, route.key, route.model, userPrompt, ac.signal); + } else { + raw = await callOpenAiStyle(p, route.key, route.model, userPrompt, ac.signal); + } const result = extractJson(raw); - attempts.push({ id, ok: true }); + attempts.push({ id, ok: true, yol: route.yol }); return { result, - model: { id, label: p.label, provider: p.provider, model: p.model() }, + model: { id, label: p.label, provider: p.provider, model: route.model, yol: route.yol }, attempts, }; } catch (e) { - attempts.push({ id, ok: false, error: e.name === 'AbortError' ? 'zaman aşımı' : e.message }); + attempts.push({ id, ok: false, yol: route.yol, error: e.name === 'AbortError' ? 'zaman aşımı' : e.message }); } finally { clearTimeout(timer); } diff --git a/src/components/AiSpecAnalyzerModal.jsx b/src/components/AiSpecAnalyzerModal.jsx index ccd7326..e0c06ff 100644 --- a/src/components/AiSpecAnalyzerModal.jsx +++ b/src/components/AiSpecAnalyzerModal.jsx @@ -115,11 +115,11 @@ export const AiSpecAnalyzerModal = () => {

- AI henüz yapılandırılmamış. Analizin çalışması için sunucuya en az bir sağlayıcı anahtarı ekleyin - (ZAI_API_KEY, DEEPSEEK_API_KEY, - {' '}GEMINI_API_KEY …) ve yeniden dağıtın. - Model sırası, Admin → AI Model Ayarları'ndan belirlenir. -

+ AI henüz yapılandırılmamış. En kolayı: sunucuya tek bir{' '} + OPENROUTER_API_KEY ekleyin — + zincirdeki tüm modeller (GLM, DeepSeek, Gemini, Grok, GPT, Claude) bu tek anahtarla çalışır. + Dilerseniz tek tek sağlayıcı anahtarı da verebilirsiniz; doğrudan anahtar OpenRouter'ı ezer. + Model sırası Admin → AI Model Ayarları'ndan belirlenir.

)} diff --git a/src/components/tools/AiModelSettings.jsx b/src/components/tools/AiModelSettings.jsx index 7c6e214..ebdac96 100644 --- a/src/components/tools/AiModelSettings.jsx +++ b/src/components/tools/AiModelSettings.jsx @@ -1,6 +1,7 @@ -import React, { useState } from 'react'; +import React, { useState, useEffect } from 'react'; import { useApp } from '../../context/AppContext'; -import { Cpu, Check, ArrowUp, ArrowDown, X, Plus, Sparkles, Save, Shield } from 'lucide-react'; +import { Cpu, Check, ArrowUp, ArrowDown, X, Plus, Sparkles, Save, Shield, Zap, AlertTriangle } from 'lucide-react'; +import { getAiConfig } from '../../services/ai'; import confetti from 'canvas-confetti'; const RENK = { @@ -17,6 +18,10 @@ const SIRA_ETIKET = ['Birincil (Primary)', '2. Yedek (Fallback)', '3. Yedek (Fal export const AiModelSettings = () => { const { AI_MODEL_CATALOG, aiModelConfig, setAiModelConfig, getModelById } = useApp(); const [saved, setSaved] = useState(false); + const [cfg, setCfg] = useState(null); + + useEffect(() => { getAiConfig().then(setCfg); }, []); + const aktifYol = cfg?.providers?.[0]?.yol; const chain = aiModelConfig.chain; const available = AI_MODEL_CATALOG.filter(m => !chain.includes(m.id)); @@ -58,6 +63,30 @@ export const AiModelSettings = () => { return (
+ {/* Canlı AI durumu */} + {cfg && ( + cfg.configured ? ( +
+ +
+ AI aktif — {cfg.providers.length} model kullanılabilir + {aktifYol === 'openrouter' ? ' (OpenRouter üzerinden tek anahtarla)' : ' (doğrudan sağlayıcı anahtarlarıyla)'}. +
+ {cfg.providers.map((p) => `${p.label}: ${p.model}`).join(' · ')} +
+
+
+ ) : ( +
+ +

+ AI anahtarı yok. Sunucuya tek bir OPENROUTER_API_KEY ekleyin — + aşağıdaki tüm modeller bu tek anahtarla çalışır. Aşağıdaki sıralama yalnızca tercih kaydeder; + çağrılar anahtar eklendiğinde başlar. +

+
+ ) + )} {/* Header */}
@@ -117,7 +146,10 @@ export const AiModelSettings = () => { )}
-
{m.alias} → {m.model}
+
+ {m.or} + · doğrudan: {m.model} +
{SIRA_ETIKET[idx] || `${idx + 1}. sırada`}
diff --git a/src/context/AppContext.jsx b/src/context/AppContext.jsx index f157b2e..0fe370c 100644 --- a/src/context/AppContext.jsx +++ b/src/context/AppContext.jsx @@ -192,12 +192,12 @@ export const AppProvider = ({ children }) => { // AI Model Yönetimi (admin panelden seçilebilir + fallback zinciri) // --------------------------------------------------------------------------- const AI_MODEL_CATALOG = [ - { id: 'glm', label: 'GLM 5.3', provider: 'Z.ai', alias: '~z-ai/glm-latest', model: 'glm-5.3', renk: 'blue' }, - { id: 'deepseek', label: 'DeepSeek V4 Flash', provider: 'DeepSeek', alias: '~deepseek/deepseek-v4-flash-latest', model: 'deepseek-v4-flash-0731', renk: 'indigo' }, - { id: 'gemini', label: 'Gemini 3.7 Flash', provider: 'Google', alias: '~google/gemini-flash-latest', model: 'gemini-3.7-flash', renk: 'amber' }, - { id: 'grok', label: 'Grok 4.6', provider: 'xAI', alias: '~x-ai/grok-latest', model: 'grok-4.6', renk: 'slate' }, - { id: 'gpt', label: 'GPT-5.6 Sol', provider: 'OpenAI', alias: '~openai/gpt-latest', model: 'gpt-5.6-sol', renk: 'emerald' }, - { id: 'claude', label: 'Claude Opus 5', provider: 'Anthropic', alias: '~anthropic/claude-opus-latest', model: 'claude-opus-5', renk: 'orange' } + { id: 'glm', label: 'GLM Latest', provider: 'Z.ai', or: '~z-ai/glm-latest', model: 'glm-5.3', renk: 'blue' }, + { id: 'deepseek', label: 'DeepSeek V4 Flash Latest', provider: 'DeepSeek', or: '~deepseek/deepseek-v4-flash-latest', model: 'deepseek-chat', renk: 'indigo' }, + { id: 'gemini', label: 'Gemini Flash Latest', provider: 'Google', or: '~google/gemini-flash-latest', model: 'gemini-flash-latest', renk: 'amber' }, + { id: 'grok', label: 'Grok Latest', provider: 'xAI', or: '~x-ai/grok-latest', model: 'grok-4.6', renk: 'slate' }, + { id: 'gpt', label: 'GPT Chat Latest', provider: 'OpenAI', or: 'openai/gpt-chat-latest', model: 'gpt-5.6-sol', renk: 'emerald' }, + { id: 'claude', label: 'Claude Opus Latest', provider: 'Anthropic', or: '~anthropic/claude-opus-latest', model: 'claude-opus-5', renk: 'orange' } ]; // Varsayılan fallback zinciri: GLM (birincil) → DeepSeek → Gemini