All checks were successful
Deploy / deploy (push) Successful in 9m30s
- 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>
158 lines
5.5 KiB
TypeScript
158 lines
5.5 KiB
TypeScript
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;
|
||
}
|