feat(ai): Z.ai GLM birincil sağlayıcı; OpenRouter yalnız yedek
Quality gates / test-build (push) Canceled after 0s
Quality gates / container (push) Canceled after 0s

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 <noreply@anthropic.com>
This commit is contained in:
2026-09-10 23:11:41 +03:00
co-authored by Claude Fable 5.1
parent be41ec7f4a
commit 7fa71f1a4e
8 changed files with 323 additions and 48 deletions
+138
View File
@@ -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;
}
}