Files
kolaytercih/src/lib/ai/cagri.ts
bilalgursen 18d5b42136
All checks were successful
Deploy / deploy (push) Successful in 9m30s
AI katmanını OpenRouter tek kapısına geçir ve rapor üretimini hızlandır
- Tüm AI akışı OpenRouter üzerinden (openai SDK, varsayılan model
  deepseek/deepseek-v4-flash, OPENROUTER_MODEL ile değiştirilebilir);
  Anthropic/Gemini/lokal sağlayıcı seçimi ve SDK'ları kaldırıldı.
- Rapor: aday havuzu dilim başına 20 (60 program, ~8k token prompt);
  AI şeması sabit uzunluklu üç liste (hayal 5 / dengeli 13 / garanti 6 —
  dağılım şema düzeyinde zorlanır), sıra + dilim + riskNotu + trendOzeti
  gerçek sıra geçmişinden deterministik üretilir; 184 sn → ~12 sn.
- Sohbet: akış ortası zaman aşımı artık sessizce yutulmaz (kesik cevap
  tam cevap gibi kaydedilmiyordu, kredi iadesi çalışmıyordu); istemci
  hata anında durumu sunucudan senkronlar, boş balon bırakmaz.
- aiHataMesaji, openai SDK'nın .name'i "Error" kalan abort/timeout
  hatalarını da yakalar; meraklisina şeması yeni akışla senkronlandı.
- CI: OPENROUTER_API_KEY / OPENROUTER_MODEL secret'ları; eski AI
  secret'ları kaldırıldı. next.config serverExternalPackages sadeleşti.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-07 00:37:54 +03:00

158 lines
5.5 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 { 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<string, unknown> {
const donustur = (dugum: unknown): unknown => {
if (Array.isArray(dugum)) return dugum.map(donustur);
if (dugum && typeof dugum === "object") {
const cikti: Record<string, unknown> = {};
for (const [k, v] of Object.entries(dugum as Record<string, unknown>)) {
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<string, unknown>;
}
/**
* 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<T>(
islem: () => Promise<T>,
denemeSayisi = 3,
): Promise<T> {
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<T extends z.ZodType>(opts: {
system: string;
kullanici: string;
sema: T;
maxTokens: number;
}): Promise<z.infer<T> | 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<string> {
// 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;
}