From 7fa71f1a4ef3ddbff673922ca7eb0ff303211949 Mon Sep 17 00:00:00 2001 From: ht Date: Thu, 10 Sep 2026 23:11:41 +0300 Subject: [PATCH] =?UTF-8?q?feat(ai):=20Z.ai=20GLM=20birincil=20sa=C4=9Flay?= =?UTF-8?q?=C4=B1c=C4=B1;=20OpenRouter=20yaln=C4=B1z=20yedek?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OpenRouter anahtarı iptal olup bakiye eksiye düşünce şartname analizi, TED çevirisi ve site taraması durmuştu. Artık ZAI_API_KEY ile GLM 5.3 ve GLM 5.3 Flash doğrudan Z.ai Coding Plan üzerinden çağrılır; kalan modeller OpenRouter anahtarı varsa yedek olarak denenir. - server/zai.mjs: doğrudan istemci; glm-5.x için reasoning_effort:low, glm-4.x için thinking kapalı; süreç genelinde en fazla 2 eşzamanlı istek (kullanıcı istekleri arka plan çevirisinden öncelikli); geçici 429'da aynı modelle yeniden deneme, 1113 bakiye hatasında hemen sıradaki model. - ai.mjs: 'zai' (GLM 5.3) sağlayıcısı eklendi, Z.ai yolu yeni istemciyi kullanıyor; varsayılan sıra zai → glmflash → gemini → … - ted-ceviri.mjs: önce ZAI_TRANSLATE_MODELS (glm-5.3-flash, glm-5.3), sonra OpenRouter; 2'li eşzamanlı parça işleme. - Admin model kataloğuna GLM 5.3 eklendi, kayıtlı zincirler sürüm 4'e taşınır. - tests/zai.test.mjs: düşünme ayarı, 429/1113 davranışı, eşzamanlılık, analyzeSpec ve TED çevirisinin Z.ai'ye gittiği. Co-Authored-By: Claude Fable 5.1 --- docs/DEVIR.md | 7 +- server/ai.mjs | 34 ++++-- server/site-scan.mjs | 2 +- server/ted-ceviri.mjs | 73 +++++++----- server/zai.mjs | 138 +++++++++++++++++++++++ src/components/tools/AiModelSettings.jsx | 8 +- src/context/AppContext.jsx | 15 +-- tests/zai.test.mjs | 94 +++++++++++++++ 8 files changed, 323 insertions(+), 48 deletions(-) create mode 100644 server/zai.mjs create mode 100644 tests/zai.test.mjs diff --git a/docs/DEVIR.md b/docs/DEVIR.md index 6cec79a..c720514 100644 --- a/docs/DEVIR.md +++ b/docs/DEVIR.md @@ -134,7 +134,12 @@ düğmesiyle metni çıkarılıp şartname analizine gidiyor. DT kayıtlarında - **Volume:** `/app/server/data` → `history.sqlite`, `dt.sqlite`, `users.sqlite` - **Girilmiş sırlar:** `STRIPE_SECRET_KEY`, `STRIPE_WEBHOOK_SECRET`, `RESEND_API_KEY`, - `OPENROUTER_API_KEY`, `GOOGLE_CLIENT_ID` + `ZAI_API_KEY`, `OPENROUTER_API_KEY` (yalnız yedek), `GOOGLE_CLIENT_ID` +- **Yapay zekâ (2026-09-10):** birincil sağlayıcı Z.ai Coding Plan. `ZAI_BASE_URL` + = `https://api.z.ai/api/coding/paas/v4/chat/completions`; aynı anda en fazla 2 istek + (`ZAI_MAX_CONCURRENCY`), GLM 5.x'te `reasoning_effort: low`. Şartname analizi + GLM 5.3 → GLM 5.3 Flash, TED çevirisi GLM 5.3 Flash → GLM 5.3; ikisi de bitince + OpenRouter zincirine düşer. Ayrıntı: `server/zai.mjs`. - **E-posta:** `bildirim@ihaleist.com` (Resend, eu-west-1, doğrulanmış) - **Zamanlayıcı:** geçmiş tarama 6 sa · DT 4 sa · bildirim 6 sa diff --git a/server/ai.mjs b/server/ai.mjs index 2eea7aa..1700abd 100644 --- a/server/ai.mjs +++ b/server/ai.mjs @@ -1,16 +1,20 @@ import { analyzeInParts } from './analysis-parts.mjs'; import { qualifyAnalysis } from './analysis-quality.mjs'; +import { zaiChat } from './zai.mjs'; /** - * Gerçek AI şartname analizi — OpenRouter öncelikli, çoklu sağlayıcı fallback. + * Gerçek AI şartname analizi — Z.ai (GLM) birincil, ç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. + * BİRİNCİL KURULUM (2026-09'dan beri): + * ZAI_API_KEY=... GLM 5.3 ve GLM 5.3 Flash doğrudan Z.ai'den + * ZAI_BASE_URL=... Coding Plan ucu (bkz. zai.mjs) * - * İ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 + * YEDEK — tek anahtar, kalan tüm modeller: + * OPENROUTER_API_KEY=sk-or-v1-... + * Doğrudan anahtarı olmayan sağlayıcılar OpenRouter üzerinden çağrılır. + * + * İsteğe bağlı diğer doğrudan anahtarlar (OpenRouter'ı ezer): + * 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). */ @@ -18,11 +22,17 @@ import { qualifyAnalysis } from './analysis-quality.mjs'; const OPENROUTER_URL = 'https://openrouter.ai/api/v1/chat/completions'; export const PROVIDERS = { + zai: { + label: 'GLM 5.3', 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', + style: 'zai', + }, glmflash: { label: 'GLM 5.3 Flash', provider: 'Z.ai', envKey: 'ZAI_API_KEY', model: () => process.env.GLM_FLASH_MODEL || 'glm-5.3-flash', or: () => process.env.GLM_FLASH_OR_MODEL || 'z-ai/glm-5.3-flash', - url: 'https://api.z.ai/api/paas/v4/chat/completions', style: 'openai', + style: 'zai', }, gemini: { label: 'Gemini', provider: 'Google', envKey: 'GEMINI_API_KEY', @@ -331,7 +341,7 @@ export async function analyzeSpec({ text, fileName, tender, chain, systemOverrid // modeller (ör. ücretsiz/limitli olanlar) tıkanınca analiz boş kalmasın; // yalnız üsttekilerin hepsi başarısız olursa devreye girer. const YEDEK = 'gemini'; - const secim = (Array.isArray(chain) && chain.length ? chain : ['glmflash', 'gemini', 'deepseek', 'grok', 'gpt', 'claude']) + const secim = (Array.isArray(chain) && chain.length ? chain : ['zai', 'glmflash', 'gemini', 'deepseek', 'grok', 'gpt', 'claude']) .filter((id) => PROVIDERS[id]); const order = secim.includes(YEDEK) ? secim : [...secim, YEDEK]; // Derin analiz hem daha çok metin okur hem daha uzun JSON üretir; çıktı @@ -358,6 +368,12 @@ export async function analyzeSpec({ text, fileName, tender, chain, systemOverrid let raw; if (route.yol === 'openrouter') { raw = await callOpenRouter(route.model, route.key, userPrompt, ac.signal, sistem, maxTokens); + } else if (p.style === 'zai') { + // Kullanıcı bekliyor: eşzamanlılık sırasında arka plan çevirisinin önüne geçer. + raw = await zaiChat(route.model, [ + { role: 'system', content: sistem }, + { role: 'user', content: userPrompt }, + ], { signal: ac.signal, maxTokens, temperature: 0.2, json: true, oncelik: 'yuksek' }); } else if (p.style === 'gemini') { raw = await callGemini(p, route.key, route.model, userPrompt, ac.signal, sistem, maxTokens); } else if (p.style === 'anthropic') { diff --git a/server/site-scan.mjs b/server/site-scan.mjs index 698b564..dcfb9df 100644 --- a/server/site-scan.mjs +++ b/server/site-scan.mjs @@ -92,7 +92,7 @@ KURALLAR: */ export async function scanSite(inputUrl) { if (!aiConfigured()) { - throw new Error('Site analizi için AI yapılandırılmamış (OPENROUTER_API_KEY gerekli).'); + throw new Error('Site analizi için AI yapılandırılmamış (ZAI_API_KEY veya OPENROUTER_API_KEY gerekli).'); } const url = normalizeUrl(inputUrl); const ana = await fetchPage(url.href); diff --git a/server/ted-ceviri.mjs b/server/ted-ceviri.mjs index 889723d..88532ea 100644 --- a/server/ted-ceviri.mjs +++ b/server/ted-ceviri.mjs @@ -1,7 +1,8 @@ /** * TED ilan başlık/açıklamalarının Türkçesi. * 1) TED çok dilli alanında Türkçe varsa (tur) doğrudan o kullanılır — model gerekmez. - * 2) Yoksa OpenRouter ile toplu çevrilir (bir sayfa = tek istek), sonuç + * 2) Yoksa model ile toplu çevrilir (6 ilan = tek istek): önce Z.ai GLM + * (ZAI_TRANSLATE_MODELS), o da olmazsa OpenRouter. Sonuç * server/data/ted.sqlite'ta ilan numarasına göre saklanır; ikinci görüntüleme ücretsizdir. * Çeviri başarısız olursa özgün metin döner; liste asla bekletilmez (üst sınır ~40 sn). */ @@ -9,6 +10,7 @@ import path from 'node:path'; import fs from 'node:fs'; import { fileURLToPath } from 'node:url'; import { openRouterKey } from './ai.mjs'; +import { zaiKey, zaiChat, zaiTranslateModels } from './zai.mjs'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const DATA_DIR = path.join(__dirname, 'data'); @@ -44,33 +46,51 @@ function yaz(ikn, baslik_tr, aciklama_tr, kaynak) { else bellek.set(ikn, { baslik_tr, aciklama_tr }); } -// Ölçüm (6 ilan): deepseek-v4-flash 10 sn/851 token; glm-5.3-flash 71 sn boyunca -// 3000 "düşünme" tokenı tüketip boş dönüyor; gemini-flash-latest OpenRouter'da -// geçersiz kimlik. Bu yüzden sıra: deepseek → gemini 3.7 flash → glm. -const MODELLER = ['deepseek/deepseek-v4-flash', 'google/gemini-3.7-flash', 'z-ai/glm-5.3-flash']; +// OpenRouter yedek sırası (yalnız Z.ai başarısız olursa ve OPENROUTER_API_KEY +// varsa). Ölçüm (6 ilan, OpenRouter): deepseek-v4-flash 10 sn/851 token; +// glm-5.3-flash OpenRouter üzerinden 71 sn düşünüp boş dönüyor (doğrudan +// Z.ai'de reasoning_effort:low ile bu sorun yok, bkz. zai.mjs). +const OR_MODELLER = ['deepseek/deepseek-v4-flash', 'google/gemini-3.7-flash', 'z-ai/glm-5.3-flash']; const SISTEM = 'Sen kamu ihale terminolojisine hâkim bir çevirmensin. Verilen AB (TED) ihale başlık ve açıklamalarını doğal, resmî Türkçeye çevir. Başlık "Ülke – Konu – İlan adı" biçimindedir: ülke adını da Türkçeleştir (Austria→Avusturya). Kurum adlarını çevirme, sayıları/tarihleri aynen koru. Yalnızca JSON döndür: {"ceviriler":[{"id":"...","baslik":"...","aciklama":"..."}]} — id alanını aynen kopyala; açıklama yoksa boş string.'; +/** Çeviri yapılabilir mi: Z.ai ya da OpenRouter anahtarından biri yeter. */ +export const ceviriMumkun = () => Boolean(zaiKey() || openRouterKey()); + +function cevirileriAyikla(icerik) { + const s = icerik.slice(icerik.indexOf('{'), icerik.lastIndexOf('}') + 1); + const out = JSON.parse(s); + if (!Array.isArray(out?.ceviriler)) throw new Error('beklenen JSON değil'); + return out.ceviriler; +} + +async function openRouterdanCevir(model, key, mesajlar, signal) { + const res = await fetch('https://openrouter.ai/api/v1/chat/completions', { + method: 'POST', signal, + headers: { Authorization: `Bearer ${key}`, 'Content-Type': 'application/json', 'HTTP-Referer': 'https://ihaleist.com', 'X-Title': 'ihaleist' }, + body: JSON.stringify({ model, temperature: 0.1, max_tokens: 2500, response_format: { type: 'json_object' }, messages: mesajlar }), + }); + const data = await res.json().catch(() => ({})); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + return data?.choices?.[0]?.message?.content || ''; +} + +/** Sırayla dener: Z.ai modelleri → OpenRouter modelleri. İlk geçerli JSON döner. */ async function modeldenCevir(kalemler, signal) { - const key = openRouterKey(); - if (!key) throw new Error('OPENROUTER_API_KEY yok'); + if (!ceviriMumkun()) throw new Error('ZAI_API_KEY veya OPENROUTER_API_KEY yok'); const girdi = JSON.stringify({ ilanlar: kalemler.map((k) => ({ id: k.id, baslik: k.baslik, aciklama: (k.aciklama || '').slice(0, 600) })) }); + const mesajlar = [{ role: 'system', content: SISTEM }, { role: 'user', content: girdi }]; + const adimlar = []; + if (zaiKey()) for (const model of zaiTranslateModels()) { + // Arka plan işi: kullanıcının beklediği analiz istekleri sırada öne geçer. + adimlar.push(() => zaiChat(model, mesajlar, { signal, temperature: 0.1, maxTokens: 2500, json: true, oncelik: 'dusuk' })); + } + if (openRouterKey()) for (const model of OR_MODELLER) { + adimlar.push(() => openRouterdanCevir(model, openRouterKey(), mesajlar, signal)); + } let sonHata = null; - for (const model of MODELLER) { - try { - const res = await fetch('https://openrouter.ai/api/v1/chat/completions', { - method: 'POST', signal, - headers: { Authorization: `Bearer ${key}`, 'Content-Type': 'application/json', 'HTTP-Referer': 'https://ihaleist.com', 'X-Title': 'ihaleist' }, - body: JSON.stringify({ model, temperature: 0.1, max_tokens: 2500, response_format: { type: 'json_object' }, - messages: [{ role: 'system', content: SISTEM }, { role: 'user', content: girdi }] }), - }); - const data = await res.json().catch(() => ({})); - if (!res.ok) throw new Error(`HTTP ${res.status}`); - const icerik = data?.choices?.[0]?.message?.content || ''; - const s = icerik.slice(icerik.indexOf('{'), icerik.lastIndexOf('}') + 1); - const out = JSON.parse(s); - if (!Array.isArray(out?.ceviriler)) throw new Error('beklenen JSON değil'); - return out.ceviriler; - } catch (e) { sonHata = e; } + for (const adim of adimlar) { + signal?.throwIfAborted(); + try { return cevirileriAyikla(await adim()); } catch (e) { if (e?.name === 'AbortError') throw e; sonHata = e; } } throw sonHata || new Error('çeviri başarısız'); } @@ -111,12 +131,13 @@ export async function tedCevir(tenders, { blockMs = 12000 } = {}) { if (c && c.baslik_tr) { t.baslikTr = c.baslik_tr; t.aciklamaTr = c.aciklama_tr || null; t.ceviriKaynagi = 'onbellek'; continue; } if (!devamEden.has(t.ikn)) bekleyen.push(t); } - if (!openRouterKey()) return 0; + if (!ceviriMumkun()) return 0; if (!bekleyen.length) return tenders.filter((t) => !t.baslikTr).length; for (const t of bekleyen) devamEden.add(t.ikn); const parcalar = []; for (let i = 0; i < bekleyen.length; i += 6) parcalar.push(bekleyen.slice(i, i + 6)); - // Parçalar 3'lü eşzamanlı işlenir: 30 ilanlık liste ~5 yerine ~2 model turunda biter. + // Parçalar 2'li eşzamanlı işlenir: Z.ai Coding Plan aynı anda en fazla 2 + // isteğe izin veriyor; fazlası zaten zai.mjs'teki sınırda beklerdi. const is = (async () => { const kuyruk = [...parcalar]; const isci = async () => { @@ -125,7 +146,7 @@ export async function tedCevir(tenders, { blockMs = 12000 } = {}) { try { await parcayiCevir(parca); } catch (e) { console.warn('[ted-ceviri] parça atlandı:', e.message); } } }; - await Promise.all(Array.from({ length: Math.min(3, parcalar.length) }, isci)); + await Promise.all(Array.from({ length: Math.min(2, parcalar.length) }, isci)); })(); await Promise.race([is, new Promise((r) => setTimeout(r, blockMs))]); return tenders.filter((t) => !t.baslikTr).length; diff --git a/server/zai.mjs b/server/zai.mjs new file mode 100644 index 0000000..8435f0a --- /dev/null +++ b/server/zai.mjs @@ -0,0 +1,138 @@ +/** + * Z.ai (GLM) doğrudan istemcisi — uygulamanın BİRİNCİL yapay zekâ sağlayıcısı. + * + * Neden: 2026-09'da OpenRouter anahtarı iptal olup hesap bakiyesi eksiye + * düşünce şartname analizi, TED çevirisi ve site taraması durdu. Z.ai'nin + * kendi API'si (Coding Plan aboneliği) ayrı faturalanır; OpenRouter'daki bir + * sorun artık uygulamayı durdurmaz. OpenRouter yalnız yedek olarak kalır. + * + * Ortam değişkenleri: + * ZAI_API_KEY zorunlu (yoksa Z.ai devre dışı) + * ZAI_BASE_URL varsayılan genel API; Coding Plan için + * https://api.z.ai/api/coding/paas/v4/chat/completions + * ZAI_REASONING_EFFORT glm-5.x düşünme seviyesi (varsayılan low) + * ZAI_MAX_CONCURRENCY eşzamanlı istek sınırı (varsayılan 2) + * ZAI_RETRIES_429 oran sınırında aynı modelle yeniden deneme (varsayılan 4) + * ZAI_RETRY_BASE_MS yeniden deneme taban beklemesi (varsayılan 3000) + * ZAI_TRANSLATE_MODELS çeviri model listesi (virgülle; varsayılan glm-5.3-flash,glm-5.3) + */ + +const VARSAYILAN_URL = 'https://api.z.ai/api/paas/v4/chat/completions'; +export const DEFAULT_ZAI_TRANSLATE_MODELS = ['glm-5.3-flash', 'glm-5.3']; + +/** Anahtar çağrı anında okunur; süreç yeniden başlatılmadan da ayar değişebilir. */ +export const zaiKey = () => (process.env.ZAI_API_KEY || '').trim() || null; +export const zaiUrl = () => (process.env.ZAI_BASE_URL || '').trim() || VARSAYILAN_URL; + +export function zaiTranslateModels() { + const liste = (process.env.ZAI_TRANSLATE_MODELS || '').split(',').map((m) => m.trim()).filter(Boolean); + return liste.length ? liste : DEFAULT_ZAI_TRANSLATE_MODELS; +} + +/** + * Düşünme (reasoning) ayarı modele göre değişir — 2026-09-10 canlı ölçümü: + * - glm-4.x: `thinking: {type:"disabled"}` kabul edilir. + * - glm-5.x: düşünme KAPATILAMAZ (hata 1210). `reasoning_effort: "low"` ile + * 0 düşünme tokenı ve kusursuz kaliteyle, varsayılan ayarın ~5 katı hızda + * çalışır. null → sağlayıcı varsayılanı (en yavaş). + */ +export function dusunmeAyari(model, effort) { + if (/^glm-5/i.test(model)) { + if (effort === null) return {}; + return { reasoning_effort: effort || (process.env.ZAI_REASONING_EFFORT || '').trim() || 'low' }; + } + return { thinking: { type: 'disabled' } }; +} + +/** + * Z.ai 429 yanıtını ikiye ayırır: 1113 (bakiye/abonelik yetersiz) beklemekle + * düzelmez; 1302/1305 (oran sınırı / sunucu yoğun) kısa beklemeyle geçebilir. + */ +export const gecici429 = (govde) => !/"code"\s*:\s*"?1113"?/.test(String(govde || '')); + +/** + * Süreç genelinde eşzamanlı Z.ai isteği sınırı. Coding Plan aynı anda en + * fazla 2 isteğe izin veriyor (3. istek 1302 alıyor). TED çevirisi, analiz ve + * site taraması aynı anda çalışabildiği için sınır tek noktada uygulanır. + * Öncelik: boşalan yer önce kullanıcının beklediği isteklere ("yuksek") + * verilir; arka plan çevirisi ("dusuk") en fazla bir istek süresi bekler. + */ +let aktifIstek = 0; +const kuyruk = { yuksek: [], dusuk: [] }; +const eszamanliSinir = () => Math.max(1, Number(process.env.ZAI_MAX_CONCURRENCY ?? 2) || 2); +async function yerAl(oncelik) { + if (aktifIstek < eszamanliSinir()) { aktifIstek++; return; } + await new Promise((r) => (kuyruk[oncelik] || kuyruk.dusuk).push(r)); +} +function yerBirak() { + const siradaki = kuyruk.yuksek.shift() ?? kuyruk.dusuk.shift(); + if (siradaki) siradaki(); // yer doğrudan sıradakine devredilir + else aktifIstek = Math.max(0, aktifIstek - 1); +} +/** Test ve gözlem için: o an açık istek sayısı. */ +export const zaiAktifIstek = () => aktifIstek; + +export class ZaiError extends Error { + constructor(message, status) { super(message); this.name = 'ZaiError'; this.status = status; } +} + +const bekle = (ms, signal) => new Promise((resolve, reject) => { + if (signal?.aborted) return reject(signal.reason ?? new DOMException('Aborted', 'AbortError')); + const t = setTimeout(() => { signal?.removeEventListener('abort', iptal); resolve(); }, ms); + const iptal = () => { clearTimeout(t); reject(signal.reason ?? new DOMException('Aborted', 'AbortError')); }; + signal?.addEventListener('abort', iptal, { once: true }); +}); + +/** + * Tek bir modele istek atar, yanıt metnini döndürür. + * - Geçici 429'da artan beklemeyle AYNI modelle yeniden dener; 1113'te + * beklemeden hata fırlatır (çağıran sıradaki modele geçer). + * - Yer yalnız HTTP isteği sürerken tutulur; 429 beklemesinde bırakılır. + * - `signal` iptal edilirse sırada beklerken bile istek atılmaz. + */ +export async function zaiChat(model, messages, opts = {}) { + const { + temperature = 0.2, json = true, maxTokens = 4000, signal, reasoningEffort, + oncelik = 'yuksek', + retries429 = Math.max(0, Number(process.env.ZAI_RETRIES_429 ?? 4) || 0), + } = opts; + const key = zaiKey(); + if (!key) throw new ZaiError('ZAI_API_KEY yok'); + + for (let deneme = 0; ; deneme++) { + signal?.throwIfAborted(); + let durum = 0, govde = ''; + await yerAl(oncelik); + try { + signal?.throwIfAborted(); + const res = await fetch(zaiUrl(), { + method: 'POST', + headers: { Authorization: `Bearer ${key}`, 'Content-Type': 'application/json' }, + body: JSON.stringify({ + model, messages, temperature, max_tokens: maxTokens, stream: false, + ...dusunmeAyari(model, reasoningEffort), + ...(json ? { response_format: { type: 'json_object' } } : {}), + }), + signal, + }); + durum = res.status; + govde = await res.text(); + } finally { + yerBirak(); + } + if (durum < 200 || durum >= 300) { + const kisa = govde.slice(0, 200); + if (durum === 429 && deneme < retries429 && gecici429(kisa)) { + const taban = Math.max(0, Number(process.env.ZAI_RETRY_BASE_MS ?? 3000) || 0); + await bekle(taban * (deneme + 1) + Math.floor(Math.random() * (taban / 3)), signal); + continue; + } + throw new ZaiError(`${model}: HTTP ${durum} ${kisa}`, durum); + } + let mesaj; + try { mesaj = JSON.parse(govde)?.choices?.[0]?.message; } catch { throw new ZaiError(`${model}: yanıt JSON değil`); } + const icerik = typeof mesaj?.content === 'string' ? mesaj.content : ''; + if (!icerik.trim()) throw new ZaiError(`${model}: boş içerik`); + return icerik; + } +} diff --git a/src/components/tools/AiModelSettings.jsx b/src/components/tools/AiModelSettings.jsx index 07b8906..129f10a 100644 --- a/src/components/tools/AiModelSettings.jsx +++ b/src/components/tools/AiModelSettings.jsx @@ -70,7 +70,7 @@ export const AiModelSettings = () => {
AI aktif — {cfg.providers.length} model kullanılabilir - {aktifYol === 'openrouter' ? ' (OpenRouter üzerinden tek anahtarla)' : ' (doğrudan sağlayıcı anahtarlarıyla)'}. + {aktifYol === 'openrouter' ? ' (OpenRouter üzerinden tek anahtarla)' : ' (birincil sağlayıcı doğrudan, kalanlar yedek)'}.
{cfg.providers.map((p) => `${p.label}: ${p.model}`).join(' · ')}
@@ -80,9 +80,9 @@ export const AiModelSettings = () => {

- 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. + AI anahtarı yok. Sunucuya ZAI_API_KEY (birincil, GLM modelleri) + ve isteğe bağlı yedek OPENROUTER_API_KEY ekleyin. + Aşağıdaki sıralama yalnızca tercih kaydeder; çağrılar anahtar eklendiğinde başlar.

) diff --git a/src/context/AppContext.jsx b/src/context/AppContext.jsx index a38b6a3..f9860b4 100644 --- a/src/context/AppContext.jsx +++ b/src/context/AppContext.jsx @@ -465,6 +465,7 @@ export const AppProvider = ({ children }) => { // AI Model Yönetimi (admin panelden seçilebilir + fallback zinciri) // --------------------------------------------------------------------------- const AI_MODEL_CATALOG = [ + { id: 'zai', label: 'GLM 5.3', provider: 'Z.ai', or: '~z-ai/glm-latest', model: 'glm-5.3', renk: 'blue' }, { id: 'glmflash', label: 'GLM 5.3 Flash', provider: 'Z.ai', or: 'z-ai/glm-5.3-flash', model: 'glm-5.3-flash', renk: 'blue' }, { id: 'gemini', label: 'Gemini Flash Latest', provider: 'Google', or: '~google/gemini-flash-latest', model: 'gemini-flash-latest', renk: 'amber' }, { id: 'deepseek', label: 'DeepSeek V4 Flash Latest', provider: 'DeepSeek', or: '~deepseek/deepseek-v4-flash-latest', model: 'deepseek-chat', renk: 'indigo' }, @@ -473,21 +474,21 @@ export const AppProvider = ({ children }) => { { id: 'claude', label: 'Claude Opus Latest', provider: 'Anthropic', or: '~anthropic/claude-opus-latest', model: 'claude-opus-5', renk: 'orange' } ]; - // Varsayılan fallback zinciri: GLM 5.3 Flash (birincil) → Gemini → DeepSeek - // (glm-latest kaldırıldı: birincil ile aynı modele çözünüyordu, gerçek yedek değildi) - const VARSAYILAN_ZINCIR = ['glmflash', 'gemini', 'deepseek']; - const ZINCIR_SURUM = 3; // artırıldığında kayıtlı zincir yeni düzene taşınır + // Varsayılan fallback zinciri (2026-09-10, Z.ai birincil): GLM 5.3 → GLM 5.3 Flash + // → Gemini → DeepSeek. İlk ikisi doğrudan Z.ai'den, kalanlar OpenRouter yedeği. + const VARSAYILAN_ZINCIR = ['zai', 'glmflash', 'gemini', 'deepseek']; + const ZINCIR_SURUM = 4; // artırıldığında kayıtlı zincir yeni düzene taşınır const aiModelAnahtari = (email) => `ihaleist_ai_model_config:${encodeURIComponent(String(email || '').toLowerCase())}`; const normalizeAiModelConfig = (saved) => { if (saved) { try { const cfg = JSON.parse(saved); - // Geçiş: kayıtlı zinciri yeni düzene taşı (glm çıkar, glmflash → gemini başa) + // Geçiş: kayıtlı zinciri yeni düzene taşı (Z.ai GLM 5.3 ve Flash başa, eski glm çıkar) if ((cfg.surum || 1) < ZINCIR_SURUM) { const chain = Array.isArray(cfg.chain) ? cfg.chain : []; - const kalan = chain.filter((id) => !['glmflash', 'gemini', 'glm'].includes(id)); - return { ...cfg, surum: ZINCIR_SURUM, chain: ['glmflash', 'gemini', ...kalan] }; + const kalan = chain.filter((id) => !['zai', 'glmflash', 'gemini', 'glm'].includes(id)); + return { ...cfg, surum: ZINCIR_SURUM, chain: ['zai', 'glmflash', 'gemini', ...kalan] }; } // Katalogdan çıkarılmış modelleri kayıtlı zincirden ayıkla if (Array.isArray(cfg.chain)) { diff --git a/tests/zai.test.mjs b/tests/zai.test.mjs new file mode 100644 index 0000000..c273c48 --- /dev/null +++ b/tests/zai.test.mjs @@ -0,0 +1,94 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { dusunmeAyari, zaiChat, gecici429, zaiAktifIstek } from '../server/zai.mjs'; +import { analyzeSpec, routeFor } from '../server/ai.mjs'; +import { tedCevir } from '../server/ted-ceviri.mjs'; + +const ZAI_URL = 'https://api.z.ai/api/coding/paas/v4/chat/completions'; +const ok = (content) => new Response(JSON.stringify({ choices: [{ message: { content } }] }), { status: 200 }); +const hata429 = (code) => new Response(JSON.stringify({ error: { code, message: 'x' } }), { status: 429 }); + +function ortam(vars) { + const eski = {}; + for (const [k, v] of Object.entries(vars)) { eski[k] = process.env[k]; if (v === undefined) delete process.env[k]; else process.env[k] = v; } + return () => { for (const [k, v] of Object.entries(eski)) { if (v === undefined) delete process.env[k]; else process.env[k] = v; } }; +} +const TEMEL = { ZAI_API_KEY: 'synthetic-test-only', ZAI_BASE_URL: ZAI_URL, ZAI_RETRY_BASE_MS: '0', ZAI_MAX_CONCURRENCY: '2', OPENROUTER_API_KEY: undefined, GEMINI_API_KEY: undefined }; + +test('düşünme ayarı modele göre: glm-5 reasoning_effort low, glm-4 thinking kapalı', () => { + const geri = ortam({ ZAI_REASONING_EFFORT: undefined }); + try { + assert.deepEqual(dusunmeAyari('glm-5.3'), { reasoning_effort: 'low' }); + assert.deepEqual(dusunmeAyari('glm-5.3-flash'), { reasoning_effort: 'low' }); + assert.deepEqual(dusunmeAyari('glm-5.3', null), {}); + assert.deepEqual(dusunmeAyari('glm-4.7-flash'), { thinking: { type: 'disabled' } }); + } finally { geri(); } +}); + +test('geçici 429 aynı modelle yeniden denenir, bakiye hatası (1113) hemen fırlatılır', async () => { + const geri = ortam(TEMEL); const orijinal = globalThis.fetch; + try { + const govdeler = []; + globalThis.fetch = async (url, { body }) => { govdeler.push(JSON.parse(body)); return govdeler.length === 1 ? hata429('1302') : ok('{"a":1}'); }; + const sonuc = await zaiChat('glm-5.3-flash', [{ role: 'user', content: 'x' }], { retries429: 2 }); + assert.equal(sonuc, '{"a":1}'); + assert.equal(govdeler.length, 2); + assert.equal(govdeler[1].model, 'glm-5.3-flash'); + assert.equal(govdeler[1].reasoning_effort, 'low'); + assert.deepEqual(govdeler[1].response_format, { type: 'json_object' }); + + let cagri = 0; + globalThis.fetch = async () => { cagri++; return hata429('1113'); }; + await assert.rejects(zaiChat('glm-5.3', [{ role: 'user', content: 'x' }], { retries429: 3 }), /1113/); + assert.equal(cagri, 1); + assert.equal(gecici429('{"error":{"code":"1302"}}'), true); + } finally { globalThis.fetch = orijinal; geri(); } +}); + +test('eşzamanlı Z.ai isteği sınırı süreç genelinde 2 ile sınırlı', async () => { + const geri = ortam(TEMEL); const orijinal = globalThis.fetch; + try { + let acik = 0, tepe = 0; + globalThis.fetch = async () => { acik++; tepe = Math.max(tepe, acik); await new Promise((r) => setTimeout(r, 20)); acik--; return ok('{}'); }; + await Promise.all([1, 2, 3, 4].map(() => zaiChat('glm-5.3-flash', [{ role: 'user', content: 'x' }]))); + assert.equal(tepe, 2); + assert.equal(zaiAktifIstek(), 0); + } finally { globalThis.fetch = orijinal; geri(); } +}); + +test('analyzeSpec Z.ai anahtarı varken GLM 5.3\'ü doğrudan Z.ai\'den çağırır', async () => { + const geri = ortam(TEMEL); const orijinal = globalThis.fetch; + try { + assert.deepEqual(routeFor('zai'), { yol: 'direct', key: 'synthetic-test-only', model: 'glm-5.3' }); + const istekler = []; + globalThis.fetch = async (url, { body }) => { istekler.push({ url, body: JSON.parse(body) }); return ok(JSON.stringify({ summary: 'özet', qualificationCriteria: [], criticalRequirements: [], risksAndPenalties: [], bidAnalysis: {} })); }; + const out = await analyzeSpec({ text: 'Sentetik şartname metni', chain: ['zai'] }); + assert.equal(istekler.length, 1); + assert.equal(istekler[0].url, ZAI_URL); + assert.equal(istekler[0].body.model, 'glm-5.3'); + assert.equal(istekler[0].body.reasoning_effort, 'low'); + assert.equal(out.model.provider, 'Z.ai'); + assert.equal(out.model.yol, 'direct'); + assert.equal(out.result.summary, 'özet'); + } finally { globalThis.fetch = orijinal; geri(); } +}); + +test('TED çevirisi OpenRouter anahtarı olmadan Z.ai GLM 5.3 Flash ile çalışır', async () => { + const geri = ortam(TEMEL); const orijinal = globalThis.fetch; + try { + const istekler = []; + globalThis.fetch = async (url, { body }) => { + const b = JSON.parse(body); istekler.push({ url, model: b.model }); + const ilanlar = JSON.parse(b.messages[1].content).ilanlar; + return ok(JSON.stringify({ ceviriler: ilanlar.map((i) => ({ id: i.id, baslik: `TR ${i.baslik}`, aciklama: '' })) })); + }; + const tenders = [{ id: 't1', baslik: 'Austria – Works' }, { id: 't2', baslik: 'Poland – Supplies' }]; + const bekleyen = await tedCevir(tenders, { blockMs: 2000 }); + assert.equal(bekleyen, 0); + assert.equal(istekler.length, 1); + assert.equal(istekler[0].url, ZAI_URL); + assert.equal(istekler[0].model, 'glm-5.3-flash'); + assert.equal(tenders[0].baslikTr, 'TR Austria – Works'); + assert.equal(tenders[1].ceviriKaynagi, 'model'); + } finally { globalThis.fetch = orijinal; geri(); } +});