AI katmanını OpenRouter tek kapısına geçir ve rapor üretimini hızlandır
All checks were successful
Deploy / deploy (push) Successful in 9m30s
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>
This commit is contained in:
@@ -1,43 +1,40 @@
|
||||
import { z } from "zod";
|
||||
import { zodOutputFormat } from "@anthropic-ai/sdk/helpers/zod";
|
||||
import { ThinkingLevel } from "@google/genai";
|
||||
import {
|
||||
getAnthropic,
|
||||
getGemini,
|
||||
raporModeli,
|
||||
saglayici,
|
||||
sohbetModeli,
|
||||
} from "./client";
|
||||
import { getOpenRouter, raporModeli, sohbetModeli } from "./client";
|
||||
|
||||
/**
|
||||
* Sağlayıcıdan bağımsız AI çağrı katmanı. Anthropic ve Gemini'nin SDK'ları
|
||||
* yapılandırılmış çıktı ve stream için farklı şekiller kullanıyor; rapor ve
|
||||
* sohbet kodu bu farkı görmesin diye tek arayüz burada toplanıyor.
|
||||
* 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.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Gemini JSON Schema'nın yalnızca bir alt kümesini destekliyor; zod'un ürettiği
|
||||
* $schema ve additionalProperties alanları isteği reddettiriyor, ayıklanı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 geminiSemasi(sema: z.ZodType): unknown {
|
||||
const temizle = (dugum: unknown): unknown => {
|
||||
if (Array.isArray(dugum)) return dugum.map(temizle);
|
||||
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" || k === "additionalProperties") continue;
|
||||
cikti[k] = temizle(v);
|
||||
if (k === "$schema") continue;
|
||||
cikti[k] = donustur(v);
|
||||
}
|
||||
if (cikti.type === "object" && cikti.additionalProperties === undefined) {
|
||||
cikti.additionalProperties = false;
|
||||
}
|
||||
return cikti;
|
||||
}
|
||||
return dugum;
|
||||
};
|
||||
return temizle(z.toJSONSchema(sema));
|
||||
return donustur(z.toJSONSchema(sema)) as Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gemini ücretsiz katmanı yoğunlukta 503/429 döndürüyor; bu geçici hatalarda
|
||||
* artan bekleme ile yeniden dene. Kalıcı hatalar (400, 403) olduğu gibi fırlar.
|
||||
* 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]);
|
||||
|
||||
@@ -65,9 +62,10 @@ async function geciciHatadaTekrarla<T>(
|
||||
* 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.
|
||||
* 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 = 50_000;
|
||||
const URETIM_ZAMAN_ASIMI_MS = 60_000;
|
||||
|
||||
/**
|
||||
* Şemaya uyan tek seferlik çıktı üretir. Şemaya uymayan yanıtta null döner
|
||||
@@ -82,92 +80,78 @@ export async function yapilandirilmisUret<T extends z.ZodType>(opts: {
|
||||
// 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);
|
||||
if (saglayici() === "gemini") {
|
||||
const yanit = await geciciHatadaTekrarla(() =>
|
||||
getGemini().models.generateContent({
|
||||
const yanit = await geciciHatadaTekrarla(() =>
|
||||
getOpenRouter().chat.completions.create(
|
||||
{
|
||||
model: raporModeli(),
|
||||
contents: opts.kullanici,
|
||||
config: {
|
||||
abortSignal: sinyal,
|
||||
systemInstruction: opts.system,
|
||||
maxOutputTokens: opts.maxTokens,
|
||||
responseMimeType: "application/json",
|
||||
responseJsonSchema: geminiSemasi(opts.sema),
|
||||
thinkingConfig: { thinkingLevel: ThinkingLevel.HIGH },
|
||||
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),
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
const metin = yanit.text;
|
||||
if (!metin) return null;
|
||||
try {
|
||||
const parsed = opts.sema.safeParse(JSON.parse(metin));
|
||||
return parsed.success ? parsed.data : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const yanit = await getAnthropic().messages.parse(
|
||||
{
|
||||
model: raporModeli(),
|
||||
max_tokens: opts.maxTokens,
|
||||
thinking: { type: "adaptive" },
|
||||
system: opts.system,
|
||||
messages: [{ role: "user", content: opts.kullanici }],
|
||||
output_config: { format: zodOutputFormat(opts.sema) },
|
||||
},
|
||||
{ signal: sinyal },
|
||||
},
|
||||
{ signal: sinyal },
|
||||
),
|
||||
);
|
||||
return yanit.parsed_output ?? null;
|
||||
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. Sağlayıcıya bakmaksızın düz metin
|
||||
* parçaları yield eder; çağıran taraf hem stream'ler hem biriktirip kaydeder.
|
||||
* 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> {
|
||||
if (saglayici() === "gemini") {
|
||||
const akis = await geciciHatadaTekrarla(() =>
|
||||
getGemini().models.generateContentStream({
|
||||
// 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(),
|
||||
contents: opts.mesajlar.map((m) => ({
|
||||
// Gemini "assistant" yerine "model" rolünü kullanır
|
||||
role: m.role === "assistant" ? "model" : "user",
|
||||
parts: [{ text: m.content }],
|
||||
})),
|
||||
config: {
|
||||
systemInstruction: opts.system,
|
||||
maxOutputTokens: opts.maxTokens,
|
||||
// Sohbet kısa ve hızlı olmalı; uzun düşünme adayı bekletiyor
|
||||
thinkingConfig: { thinkingLevel: ThinkingLevel.LOW },
|
||||
},
|
||||
}),
|
||||
);
|
||||
for await (const parca of akis) {
|
||||
const metin = parca.text;
|
||||
if (metin) yield metin;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const akis = getAnthropic().messages.stream({
|
||||
model: sohbetModeli(),
|
||||
max_tokens: opts.maxTokens,
|
||||
system: opts.system,
|
||||
messages: opts.mesajlar,
|
||||
});
|
||||
for await (const olay of akis) {
|
||||
if (
|
||||
olay.type === "content_block_delta" &&
|
||||
olay.delta.type === "text_delta"
|
||||
) {
|
||||
yield olay.delta.text;
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user