Files
ihaleist/server/ai.mjs
T
hikmettan dd2bfa12e6 feat: OpenRouter entegrasyonu — tek anahtarla tüm modeller, latest alias'lar
- server/ai.mjs: OpenRouter öncelikli yönlendirme (routeFor). OPENROUTER_API_KEY
  tanımlıysa zincirdeki tüm modeller OpenRouter üzerinden çağrılır; doğrudan
  sağlayıcı anahtarı varsa o ezer
- Tüm modeller OpenRouter floating 'latest' alias'larına çevrildi (sabit sürüm
  yerine otomatik güncel kalır):
    ~z-ai/glm-latest · ~deepseek/deepseek-v4-flash-latest
    ~google/gemini-flash-latest · ~x-ai/grok-latest
    openai/gpt-chat-latest · ~anthropic/claude-opus-latest
- /api/ai/config artık her model için kullanılan yolu (openrouter|direct) döner
- AI Model Ayarları: canlı durum bandı (aktif modeller + yol) ve OpenRouter
  kurulum yönergesi; eski 'alias' alanı gerçek OpenRouter kimliğiyle değişti
- Analiz modali: tek OPENROUTER_API_KEY önerisi
2026-08-28 13:37:40 +03:00

258 lines
10 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Gerçek AI şartname analizi — OpenRouter öncelikli, çoklu sağlayıcı fallback.
* Bağımlılık yok (Node 18+ built-in fetch).
*
* 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.
*
* İ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.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.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-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.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() {
return availableProviders().length > 0;
}
const SYSTEM_PROMPT = `Sen Türkiye kamu ihale mevzuatı (4734/4735 sayılı kanunlar) konusunda uzman bir ihale danışmanısın.
Sana verilen teknik şartname / ihale ilanı metnini analiz et.
YALNIZCA geçerli JSON döndür, başka hiçbir metin yazma. Şema:
{
"summary": "şartnamenin 2-3 cümlelik özeti",
"qualificationCriteria": ["istenen yeterlik belgeleri, madde madde"],
"criticalRequirements": ["dikkat edilmesi gereken kritik teknik/idari şartlar"],
"risksAndPenalties": [{"level":"Yüksek|Orta|Düşük","clause":"madde no veya bölüm","text":"risk açıklaması"}],
"bidAnalysis": {
"estimatedCost": sayı_veya_null,
"recommendedBid": sayı_veya_null,
"minBidLimit": sayı_veya_null,
"maxBidLimit": sayı_veya_null,
"winProbability": 0-100_arası_sayı_veya_null,
"competitionLevel": "rekabet seviyesi yorumu",
"notes": "teklif stratejisi notu"
}
}
Metinde olmayan bilgiyi UYDURMA — bilinmiyorsa null veya boş dizi kullan.`;
function buildUserPrompt({ text, fileName, tender }) {
const parts = [];
if (fileName) parts.push(`Dosya: ${fileName}`);
if (tender) {
parts.push(`İhale başlığı: ${tender.baslik || '-'}`);
parts.push(`İdare: ${tender.kurum || '-'}`);
parts.push(`Şehir: ${tender.sehir || '-'}`);
parts.push(`Usul: ${tender.usul || tender.alimTuru || '-'}`);
if (tender.yaklasikMaliyet) parts.push(`Yaklaşık maliyet: ${tender.yaklasikMaliyet}`);
if (Array.isArray(tender.items) && tender.items.length) {
parts.push('Kalemler: ' + tender.items.map((i) => `${i.ad} (${i.miktar} ${i.birim})`).join('; '));
}
}
parts.push('\n--- ŞARTNAME / İLAN METNİ ---\n' + String(text || '').slice(0, 60000));
return parts.join('\n');
}
// --- 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,
messages: [
{ role: 'system', content: SYSTEM_PROMPT },
{ role: 'user', content: userPrompt },
],
temperature: 0.2,
max_tokens: 4000,
}),
signal,
});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data?.error?.message || `HTTP ${res.status}`);
return data?.choices?.[0]?.message?.content || '';
}
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' },
body: JSON.stringify({
systemInstruction: { parts: [{ text: SYSTEM_PROMPT }] },
contents: [{ role: 'user', parts: [{ text: userPrompt }] }],
generationConfig: { temperature: 0.2, maxOutputTokens: 4000, responseMimeType: 'application/json' },
}),
signal,
});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data?.error?.message || `HTTP ${res.status}`);
return data?.candidates?.[0]?.content?.parts?.map((x) => x.text).join('') || '';
}
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' },
body: JSON.stringify({
model,
max_tokens: 4000,
system: SYSTEM_PROMPT,
messages: [{ role: 'user', content: userPrompt }],
}),
signal,
});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data?.error?.message || `HTTP ${res.status}`);
return data?.content?.map((c) => c.text || '').join('') || '';
}
function extractJson(raw) {
if (!raw) throw new Error('Model boş yanıt döndürdü');
let s = String(raw).trim();
const fence = s.match(/```(?:json)?\s*([\s\S]*?)```/);
if (fence) s = fence[1].trim();
const start = s.indexOf('{');
const end = s.lastIndexOf('}');
if (start === -1 || end === -1) throw new Error('Yanıtta JSON bulunamadı');
return JSON.parse(s.slice(start, end + 1));
}
/** 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]);
const userPrompt = buildUserPrompt({ text, fileName, tender });
const attempts = [];
for (const id of order) {
const p = PROVIDERS[id];
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 (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, yol: route.yol });
return {
result,
model: { id, label: p.label, provider: p.provider, model: route.model, yol: route.yol },
attempts,
};
} catch (e) {
attempts.push({ id, ok: false, yol: route.yol, error: e.name === 'AbortError' ? 'zaman aşımı' : e.message });
} finally {
clearTimeout(timer);
}
}
const err = new Error('Tüm AI sağlayıcıları başarısız oldu: ' + attempts.map((a) => `${a.id}(${a.error})`).join(', '));
err.attempts = attempts;
throw err;
}