import { z } from "zod"; import { getOpenRouter, raporModeli, sohbetModeli } from "./client"; /** * AI çağrı katmanı: tüm istekler OpenRouter'ın OpenAI-uyumlu API'sine gider * (tek kapı — kullanıcı kararı, 7 Ağu 2026). Rapor ve sohbet kodu SDK/istek * ayrıntısını görmesin diye tek arayüz burada toplanıyor. */ /** * zod şemasını JSON Schema'ya çevirir: z.toJSONSchema'nın eklediği $schema * alanı ayıklanır, her object düğümüne additionalProperties: false eklenir — * OpenAI-uyumlu strict json_schema doğrulayıcıları bunu şart koşuyor. */ function jsonSemasi(sema: z.ZodType): Record { const donustur = (dugum: unknown): unknown => { if (Array.isArray(dugum)) return dugum.map(donustur); if (dugum && typeof dugum === "object") { const cikti: Record = {}; for (const [k, v] of Object.entries(dugum as Record)) { if (k === "$schema") continue; cikti[k] = donustur(v); } if (cikti.type === "object" && cikti.additionalProperties === undefined) { cikti.additionalProperties = false; } return cikti; } return dugum; }; return donustur(z.toJSONSchema(sema)) as Record; } /** * OpenRouter yoğunlukta/limitte 429, üst sağlayıcı arızasında 5xx dönebiliyor; * bu geçici hatalarda artan bekleme ile yeniden dene. Kalıcı hatalar * (400, 401, 403) olduğu gibi fırlar. */ const GECICI_KODLAR = new Set([429, 500, 502, 503, 504]); async function geciciHatadaTekrarla( islem: () => Promise, denemeSayisi = 3, ): Promise { let sonHata: unknown; for (let i = 0; i < denemeSayisi; i++) { try { return await islem(); } catch (hata) { const kod = (hata as { status?: number })?.status; if (kod == null || !GECICI_KODLAR.has(kod) || i === denemeSayisi - 1) { throw hata; } sonHata = hata; await new Promise((r) => setTimeout(r, 2000 * 2 ** i)); } } throw sonHata; } /** * Tek yapılandırılmış üretim çağrısının üst süre sınırı. Süre, çağıranın * (server action) platform limitinden önce dolmalı ki hata iade kodunun * çalışabildiği pencerede fırlasın — fonksiyon dışarıdan öldürülürse * harcanan kredi iade edilemiyor. Flash sınıfı model tipik ~10-20 sn'de * biter; 60 sn üst sağlayıcı yavaşlamalarına pay bırakır. */ const URETIM_ZAMAN_ASIMI_MS = 60_000; /** * Şemaya uyan tek seferlik çıktı üretir. Şemaya uymayan yanıtta null döner * (çağıran taraf yeniden dener). Zaman aşımında abort hatası fırlar. */ export async function yapilandirilmisUret(opts: { system: string; kullanici: string; sema: T; maxTokens: number; }): Promise | null> { // Tek sinyal tüm iç denemeleri kapsar: süre dolunca bekleyen retry'lar da // anında düşer, toplam süre sınırın üstüne çıkamaz. const sinyal = AbortSignal.timeout(URETIM_ZAMAN_ASIMI_MS); const yanit = await geciciHatadaTekrarla(() => getOpenRouter().chat.completions.create( { model: raporModeli(), max_tokens: opts.maxTokens, // Görev küçük (havuzdan seçim + tek cümlelik gerekce) ve şemaya // uymayan çıktıyı doğrulama döngüsü zaten yakalıyor; hibrit // modellerde düşünme bütçe/süre yemesin. reasoning_effort: "none", messages: [ { role: "system", content: opts.system }, { role: "user", content: opts.kullanici }, ], response_format: { type: "json_schema", json_schema: { name: "yanit", strict: true, schema: jsonSemasi(opts.sema), }, }, }, { signal: sinyal }, ), ); const metin = yanit.choices[0]?.message?.content; if (!metin) return null; try { const parsed = opts.sema.safeParse(JSON.parse(metin)); return parsed.success ? parsed.data : null; } catch { return null; } } export type SohbetMesaji = { role: "user" | "assistant"; content: string }; /** * Sohbet yanıtını parça parça üretir; düz metin parçaları yield eder, çağıran * taraf hem stream'ler hem biriktirip kaydeder. Akış ortasında zaman aşımı * olursa hata fırlar (route'taki kısmi kaydet + iade yolu devreye girer). */ export async function* sohbetAkisi(opts: { system: string; mesajlar: SohbetMesaji[]; maxTokens: number; }): AsyncGenerator { // Asılı kalan üst sağlayıcıya karşı sigorta const sinyal = AbortSignal.timeout(120_000); const akis = await geciciHatadaTekrarla(() => getOpenRouter().chat.completions.create( { model: sohbetModeli(), max_tokens: opts.maxTokens, // Sohbet 1024 token'a sığmalı; düşünme hem bütçeyi hem adayı bekletiyor reasoning_effort: "none", stream: true, messages: [ { role: "system", content: opts.system }, ...opts.mesajlar, ], }, { signal: sinyal }, ), ); for await (const parca of akis) { // Yalnızca delta.content akar; varsa delta.reasoning kullanıcıya sızmaz const metin = parca.choices[0]?.delta?.content; if (metin) yield metin; } // openai SDK akış ortasındaki abort'u yutup iteratörü NORMAL bitiriyor // (core/streaming: isAbortError -> return); kesik cevabın tam cevap // sanılmaması ve iade yolunun çalışması için zaman aşımını elle fırlat. if (sinyal.aborted) throw sinyal.reason; }