Files
lagos-back/src/lib/aiClient.ts
T
Mustafa Yildiz 66a3960d90 fix: Ollama KEEP_ALIVE=-1 ve AbortError mesaj netliği, callAiModelRaw'a opsiyonel timeoutMs
KEEP_ALIVE '30m' -> -1: GPU'da bolca boş VRAM var, modeli sürekli bellekte
tutmanın maliyeti yok, 30dk sınırı gereksiz soğuk-başlangıç gecikmelerine
sebep oluyordu (bkz. 2026-08-14 log: 44GB boş / 17.5GB kullanılan).
2026-08-17 00:46:10 +03:00

219 lines
8.9 KiB
TypeScript
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.
import { getAiConfig } from './aiConfig';
import { withAiQueue } from './aiQueue';
export interface ChatMsg { role: string; content: string; }
// Model her istekte bellekten atılıp yeniden yüklenmesin diye (varsayılan Ollama
// davranışı: 5 dakika kullanılmazsa boşalır, sıradaki istek soğuk başlangıç
// gecikmesi yaşar) worker'ı bu süre boyunca bellekte tutmasını istiyoruz.
// -1: Ollama'ya özel değer, "asla otomatik atma" anlamına gelir — sadece Ollama
// sunucusu yeniden başlarsa model tekrar yüklenir. GPU'da bolca boş VRAM var
// (bkz. 2026-08-14 log: 44GB boş / 17.5GB kullanılan), bu yüzden modeli sürekli
// bellekte tutmanın maliyeti yok; 30dk'lık sınır sadece gereksiz soğuk başlangıç
// gecikmelerine (~90sn, bazen backend timeout'undan uzun) sebep oluyordu.
const KEEP_ALIVE = -1;
// 800 pratikte çok düşük çıktı — çok bölümlü hukuki cevaplar (kira feshi, tazminat
// hesabı gibi) bu sınıra çarpıp cümlenin ortasında kesiliyordu. 2048 hem çoğu cevabı
// tam bitirmeye yetiyor hem de modelin sonsuz uzayıp gitmesine karşı hâlâ bir üst sınır.
const DEFAULT_NUM_PREDICT = 2048;
const DEFAULT_TIMEOUT_MS = 90_000;
const DEFAULT_STALL_TIMEOUT_MS = 60_000;
function buildBody(messages: ChatMsg[], model: string, stream: boolean, numPredict: number) {
return {
model,
messages,
think: false,
stream,
keep_alive: KEEP_ALIVE,
// num_predict: modelin üretebileceği maksimum token sayısı — öngörülemez
// uzunlukta "düşünme"/üretim sürelerine karşı üst sınır.
options: { num_predict: numPredict },
};
}
// Tek seferlik (streaming olmayan) çağrı — başlık üretimi, belge analizi, dilekçe
// taslağı, içtihat sorgu optimizasyonu gibi kullanıcının token token izlemediği,
// arka planda tamamlanması yeterli olan işler için. Ollama'nın native /api/chat
// uç noktasını kullanır — think:false burada güvenilir çalışıyor (OpenAI-uyumluluk
// katmanı /v1/chat/completions'ta güvenilir değil).
export async function callOllama(
messages: ChatMsg[],
opts: { numPredict?: number; timeoutMs?: number } = {}
): Promise<string> {
// withAiQueue'nun İÇİNDE: zaman aşımı sayacı, istek GPU sırasında beklerken
// değil, gerçekten gönderilmeye başladığında işlemeye başlasın — yoksa kuyrukta
// uzun bekleyen bir istek, sırası gelir gelmez anında "zaman aşımı" olurdu.
return withAiQueue(async () => {
const { runpodUrl, aiModel } = getAiConfig();
const ctrl = new AbortController();
const timeoutId = setTimeout(() => ctrl.abort(), opts.timeoutMs ?? DEFAULT_TIMEOUT_MS);
try {
const response = await fetch(`${runpodUrl}/api/chat`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(buildBody(messages, aiModel, false, opts.numPredict ?? DEFAULT_NUM_PREDICT)),
signal: ctrl.signal,
});
if (!response.ok) {
throw new Error(`AI Model request failed: ${response.statusText}`);
}
const data = await response.json();
return (data.message?.content || '').toString().trim();
} catch (err: any) {
if (err?.name === 'AbortError' || (err?.message && String(err.message).toLowerCase().includes('aborted'))) {
throw new Error('Yapay zeka yanıt verme süresi aşıldı (Zaman Aşımı). Lütfen tekrar deneyin.');
}
throw err;
} finally {
clearTimeout(timeoutId);
}
});
}
// Akışlı (streaming) çağrı — kullanıcının yanıtı token token canlı gördüğü
// etkileşimli sohbet uç noktaları için. Ollama /api/chat stream:true ile
// newline-delimited JSON döner (her satır { message: { content }, done } içerir).
// onToken her parça geldiğinde çağrılır; dönüş değeri tüm yanıtın birleştirilmiş hali.
// Sabit bir toplam süre sınırı YOK (kullanıcı üretilmekte olan metni zaten görüyor) —
// bunun yerine art arda gelen parçalar arasında stallTimeoutMs kadar sessizlik olursa
// (model takıldı / bağlantı koptu) isteği iptal ediyoruz.
export async function streamOllama(
messages: ChatMsg[],
onToken: (delta: string) => void,
opts: { numPredict?: number; stallTimeoutMs?: number } = {}
): Promise<string> {
return withAiQueue(() => streamOllamaInner(messages, onToken, opts));
}
async function streamOllamaInner(
messages: ChatMsg[],
onToken: (delta: string) => void,
opts: { numPredict?: number; stallTimeoutMs?: number } = {}
): Promise<string> {
const { runpodUrl, aiModel } = getAiConfig();
const stallMs = opts.stallTimeoutMs ?? DEFAULT_STALL_TIMEOUT_MS;
const ctrl = new AbortController();
let stallTimer: ReturnType<typeof setTimeout>;
const resetStall = () => {
clearTimeout(stallTimer);
stallTimer = setTimeout(() => ctrl.abort(), stallMs);
};
resetStall();
try {
const response = await fetch(`${runpodUrl}/api/chat`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(buildBody(messages, aiModel, true, opts.numPredict ?? DEFAULT_NUM_PREDICT)),
signal: ctrl.signal,
});
if (!response.ok || !response.body) {
throw new Error(`AI Model request failed: ${response.statusText}`);
}
let full = '';
let buffer = '';
const reader = response.body.getReader();
const decoder = new TextDecoder();
const consumeLine = (line: string) => {
const trimmed = line.trim();
if (!trimmed) return;
let json: any;
try {
json = JSON.parse(trimmed);
} catch {
return;
}
const delta = json.message?.content || '';
if (delta) {
full += delta;
onToken(delta);
}
// Ollama son satırda done_reason:"length" döndürürse yanıt num_predict
// sınırına çarpıp yarım kesilmiş demektir — sessizce geçmek yerine loglayıp
// ileride benzer bir "cevaplar yarım kalıyor" şikayetini hızlı teşhis edebilelim.
if (json.done && json.done_reason === 'length') {
console.warn('[aiClient] Yanıt num_predict sınırına çarpıp kesildi.');
}
};
while (true) {
const { done, value } = await reader.read();
if (done) break;
resetStall();
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() || '';
lines.forEach(consumeLine);
}
if (buffer.trim()) consumeLine(buffer);
return full.trim();
} finally {
clearTimeout(stallTimer!);
}
}
const DEFAULT_OCR_NUM_PREDICT = 1200;
const DEFAULT_OCR_TIMEOUT_MS = 60_000;
// DeepSeek-OCR ("deepseek-ocr-fixed") ile bir görseli metne çevirir. Mizan'dan
// (sohbet/analiz) tamamen ayrı bir model — aynı Ollama sunucusunda, farklı
// model adıyla çağrılıyor, withAiQueue ikisini de aynı GPU sırasında bekletiyor.
//
// NOT — "deepseek-ocr-fixed" modeli Ollama kütüphanesindeki resmi paketten
// FARKLI: resmi paketin şablonu ("template": "{{ .Prompt }}") görsel için hiçbir
// yer tutucu içermiyordu, bu yüzden model gönderilen görseli hiç görmüyordu.
// Sunucuda elle şu şekilde düzeltilmiş bir kopya oluşturuldu:
//
// curl -X POST $RUNPOD_API_BASE_URL/api/create -d '{
// "model": "deepseek-ocr-fixed",
// "from": "deepseek-ocr",
// "template": "{{ if .Images }}{{ range .Images }}[img-0]{{ end }}\n{{ end }}{{ .Prompt }}",
// "parameters": { "temperature": 0 }
// }'
//
// Ollama'nın model verisi silinir/sıfırlanırsa (örn. volume resetlenirse) bu
// komut tekrar çalıştırılmalı — yoksa OCR istekleri sessizce boş metin döner.
export async function callOllamaVisionOcr(
imageBase64: string,
opts: { numPredict?: number; timeoutMs?: number; prompt?: string } = {}
): Promise<string> {
return withAiQueue(async () => {
const { runpodUrl, ocrModel } = getAiConfig();
const ctrl = new AbortController();
const timeoutId = setTimeout(() => ctrl.abort(), opts.timeoutMs ?? DEFAULT_OCR_TIMEOUT_MS);
try {
const response = await fetch(`${runpodUrl}/api/chat`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model: ocrModel,
messages: [
{
role: 'user',
content: opts.prompt || '<image>\nConvert the document to markdown.',
images: [imageBase64],
},
],
think: false,
stream: false,
keep_alive: KEEP_ALIVE,
options: { num_predict: opts.numPredict ?? DEFAULT_OCR_NUM_PREDICT },
}),
signal: ctrl.signal,
});
if (!response.ok) {
const errText = await response.text().catch(() => '');
throw new Error(`OCR model request failed: ${response.statusText} ${errText}`.trim());
}
const data = await response.json();
return (data.message?.content || '').toString().trim();
} finally {
clearTimeout(timeoutId);
}
});
}