fix(chat): expose actionable provider errors
This commit is contained in:
+61
-9
@@ -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<UIMessage>({
|
||||
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<unknown> {
|
||||
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";
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
[],
|
||||
|
||||
+35
-1
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user