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
This commit is contained in:
2026-08-28 13:37:40 +03:00
parent 7a396ddeb6
commit dd2bfa12e6
4 changed files with 137 additions and 54 deletions
+91 -40
View File
@@ -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);
}