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).
219 lines
8.9 KiB
TypeScript
219 lines
8.9 KiB
TypeScript
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);
|
||
}
|
||
});
|
||
}
|