From 4d15c2c481dc3b9976368758989e1f5a9e7bef12 Mon Sep 17 00:00:00 2001 From: poyrazavsever Date: Fri, 17 Jul 2026 22:51:45 +0300 Subject: [PATCH] fix(chat): expose actionable provider errors --- app/api/chat/route.ts | 70 +++++++++++++++++++++++++---- scripts/phase1-auth-smoke.mjs | 31 +++++++++++++ scripts/phase7-backend-boundary.mjs | 38 ++++++++++++++++ server/ai/provider.ts | 36 ++++++++++++++- 4 files changed, 165 insertions(+), 10 deletions(-) diff --git a/app/api/chat/route.ts b/app/api/chat/route.ts index 16841d6..5578232 100644 --- a/app/api/chat/route.ts +++ b/app/api/chat/route.ts @@ -17,7 +17,10 @@ export const maxDuration = 120; const requestSchema = z.object({ sessionId: z.string().trim().min(1).max(160), messages: z.array(z.unknown()).min(1).max(100), -}).strict(); + id: z.string().trim().min(1).max(160).optional(), + trigger: z.enum(["submit-message", "regenerate-message"]).optional(), + messageId: z.string().trim().min(1).max(160).optional(), +}); export async function POST(request: Request) { try { @@ -34,16 +37,29 @@ export async function POST(request: Request) { throw new DomainError("FORBIDDEN", "Bu işlem yalnızca freelancer hesabına açıktır."); } - const parsed = requestSchema.safeParse(await request.json()); + const requestBody = await readJsonBody(request); + const parsed = requestSchema.safeParse(requestBody); if (!parsed.success) { - throw new DomainError("VALIDATION_ERROR", "Sohbet isteği geçersiz."); + throw new DomainError( + "VALIDATION_ERROR", + `Sohbet isteği geçersiz: ${describeRequestIssues(parsed.error.issues)}`, + { + issues: parsed.error.issues.map((issue) => ({ + code: issue.code, + path: issue.path.join(".") || "body", + })), + }, + ); } const validated = await safeValidateUIMessages({ messages: parsed.data.messages, }); if (!validated.success) { - throw new DomainError("VALIDATION_ERROR", "Mesaj biçimi geçersiz."); + throw new DomainError( + "VALIDATION_ERROR", + "Mesaj biçimi geçersiz: her mesaj id, role ve parts alanlarını içermelidir.", + ); } const latestMessage = validated.data.at(-1); @@ -97,20 +113,56 @@ ${userContext}`, }); } }, - onError: ({ error }) => { - normalizeAiError(error); - }, }); return result.toUIMessageStreamResponse({ - onError: () => "AI sağlayıcısı yanıt üretirken bir hata oluştu.", + onError: (error) => normalizeAiError(error).message, }); } catch (error) { const normalized = normalizeAiError(error); - return new Response(normalized.message, { status: normalized.status }); + return new Response(normalized.message, { + status: normalized.status, + headers: { + "cache-control": "no-store", + "content-type": "text/plain; charset=utf-8", + "x-neta-error-code": normalized.code, + }, + }); } } +async function readJsonBody(request: Request): Promise { + try { + return await request.json(); + } catch { + throw new DomainError( + "VALIDATION_ERROR", + "Sohbet isteği geçerli bir JSON gövdesi içermiyor.", + ); + } +} + +function describeRequestIssues(issues: z.core.$ZodIssue[]): string { + return issues + .slice(0, 3) + .map((issue) => { + const field = issue.path.join(".") || "body"; + switch (issue.code) { + case "invalid_type": + return `"${field}" alanı eksik veya beklenen türde değil`; + case "too_small": + return `"${field}" alanı boş olamaz`; + case "too_big": + return `"${field}" alanı izin verilen sınırı aşıyor`; + case "invalid_value": + return `"${field}" desteklenmeyen bir değer içeriyor`; + default: + return `"${field}" alanı doğrulanamadı`; + } + }) + .join("; "); +} + function toUiMessage(message: { id: string; role: "user" | "assistant"; diff --git a/scripts/phase1-auth-smoke.mjs b/scripts/phase1-auth-smoke.mjs index d73d757..eeed3c1 100644 --- a/scripts/phase1-auth-smoke.mjs +++ b/scripts/phase1-auth-smoke.mjs @@ -302,7 +302,10 @@ try { assert.equal(missingAiSettings.response.status, 400, `Missing AI key must fail: ${pathname}`); } const chatBody = { + id: "phase7-client-chat", sessionId: "phase7-chat", + trigger: "submit-message", + messageId: "phase7-user-message", messages: [{ id: "phase7-user-message", role: "user", @@ -315,6 +318,29 @@ try { body: JSON.stringify(chatBody), }); assert.equal(anonymousChat.status, 401, "Anonymous chat request must fail"); + + const invalidChatRequest = await fetch(`${baseUrl}/api/chat`, { + method: "POST", + headers: { + "content-type": "application/json", + cookie: ownerCookie, + origin: baseUrl, + }, + body: JSON.stringify({ + id: "phase7-invalid-chat", + sessionId: "phase7-chat", + trigger: "submit-message", + messages: [], + }), + }); + assert.equal(invalidChatRequest.status, 400); + assert.equal(invalidChatRequest.headers.get("x-neta-error-code"), "VALIDATION_ERROR"); + assert.match( + await invalidChatRequest.text(), + /"messages" alanı boş olamaz/, + "Chat validation errors must identify the invalid request field", + ); + const missingChatSettings = await fetch(`${baseUrl}/api/chat`, { method: "POST", headers: { @@ -325,6 +351,11 @@ try { body: JSON.stringify(chatBody), }); assert.equal(missingChatSettings.status, 400, "Missing AI key must fail: /api/chat"); + assert.match( + await missingChatSettings.text(), + /AI sağlayıcısı ve API anahtarı/, + "The AI SDK v6 transport envelope must pass request validation and reach provider settings", + ); assert.equal( db.prepare("select count(*) as value from chat_messages where session_id = ?") .get("phase7-chat").value, diff --git a/scripts/phase7-backend-boundary.mjs b/scripts/phase7-backend-boundary.mjs index 4139447..dd21b85 100644 --- a/scripts/phase7-backend-boundary.mjs +++ b/scripts/phase7-backend-boundary.mjs @@ -46,6 +46,44 @@ if (/\bapiKey\b|\bprovider\b/.test(chatPage)) { violations.push("app/(dashboard)/chat/page.tsx: AI secret/provider leaked to browser code"); } +const chatRoute = fs.readFileSync( + path.join(process.cwd(), "app/api/chat/route.ts"), + "utf8", +); +for (const transportField of ['id:', 'trigger:', 'messageId:']) { + assert.ok( + chatRoute.includes(transportField), + `Chat request schema must accept AI SDK v6 transport field ${transportField}`, + ); +} +for (const diagnosticMarker of [ + "describeRequestIssues", + "x-neta-error-code", + "geçerli bir JSON gövdesi", + "her mesaj id, role ve parts", +]) { + assert.ok( + chatRoute.includes(diagnosticMarker), + `Chat route is missing diagnostic marker: ${diagnosticMarker}`, + ); +} + +const aiProvider = fs.readFileSync( + path.join(process.cwd(), "server/ai/provider.ts"), + "utf8", +); +for (const providerDiagnostic of [ + "APICallError", + "API anahtarını reddetti", + "kullanım limiti aşıldı", + "modeli sağlayıcıda bulunamadı", +]) { + assert.ok( + aiProvider.includes(providerDiagnostic), + `AI provider diagnostics are missing: ${providerDiagnostic}`, + ); +} + assert.deepEqual( violations, [], diff --git a/server/ai/provider.ts b/server/ai/provider.ts index 57a4bc9..66d18b2 100644 --- a/server/ai/provider.ts +++ b/server/ai/provider.ts @@ -3,7 +3,7 @@ import "server-only"; import { createGoogleGenerativeAI } from "@ai-sdk/google"; import { createGroq } from "@ai-sdk/groq"; import { createOpenAI } from "@ai-sdk/openai"; -import type { LanguageModel } from "ai"; +import { APICallError, NoSuchModelError, type LanguageModel } from "ai"; import { getServerConfig } from "../config"; import type { DomainActor } from "../domain/actor"; import { DomainError } from "../domain/errors"; @@ -72,6 +72,40 @@ export function normalizeAiError(error: unknown): DomainError { ); } + if (NoSuchModelError.isInstance(error)) { + return new DomainError( + "VALIDATION_ERROR", + "Seçili AI modeli kullanılamıyor. Ayarlardaki model adını kontrol edin.", + ); + } + + if (APICallError.isInstance(error)) { + if (error.statusCode === 401 || error.statusCode === 403) { + return new DomainError( + "VALIDATION_ERROR", + "AI sağlayıcısı API anahtarını reddetti. Ayarlardaki anahtarı kontrol edin.", + ); + } + if (error.statusCode === 404) { + return new DomainError( + "VALIDATION_ERROR", + "Seçili AI modeli sağlayıcıda bulunamadı. Model adını kontrol edin.", + ); + } + if (error.statusCode === 429) { + return new DomainError( + "SERVICE_UNAVAILABLE", + "AI sağlayıcısının kullanım limiti aşıldı. Kısa süre sonra tekrar deneyin.", + ); + } + if (error.statusCode && error.statusCode >= 500) { + return new DomainError( + "UPSTREAM_ERROR", + "AI sağlayıcısı geçici bir sunucu hatası döndürdü. Biraz sonra tekrar deneyin.", + ); + } + } + console.error("AI provider request failed", error); return new DomainError( "UPSTREAM_ERROR",