From c69fe4e462a5a67d9f4823708e14da32888eb042 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 12 Aug 2026 07:49:23 +0000 Subject: [PATCH] =?UTF-8?q?fix(ia):=20afficher=20la=20cause=20r=C3=A9elle?= =?UTF-8?q?=20d'un=20=C3=A9chec=20de=20g=C3=A9n=C3=A9ration=20de=20texte?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit La route /api/ai/generate connaissait la cause exacte de l'échec (configuration absente, clé invalide, crédits épuisés, modèle inconnu) mais la remplaçait par un « Failed to generate text » en 500, et l'interface affichait de son côté un message figé. Sans accès aux logs du serveur, l'utilisateur n'avait donc aucun moyen de savoir quoi corriger. Le motif renvoyé par OpenRouter est désormais extrait, tronqué et propagé jusqu'au bandeau d'erreur, avec un code HTTP qui distingue un problème de configuration (400) d'un refus du service tiers (502). La clé API n'apparaît à aucun moment dans ces messages. Même traitement pour /api/reels/generate-text, qui souffrait du même masquage. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_018uUPZ1GNpEkYB1dpXHYPek --- client/src/lib/queryClient.ts | 23 ++++++++++ client/src/pages/mobile/new-post.tsx | 6 +-- client/src/pages/mobile/remotion-video.tsx | 4 +- client/src/pages/new-post.tsx | 6 +-- client/src/pages/new-reel.tsx | 6 +-- client/src/pages/remotion-video.tsx | 4 +- server/routes.ts | 5 ++- server/routes/reels.ts | 5 ++- server/services/openrouter.ts | 51 +++++++++++++++++++++- 9 files changed, 92 insertions(+), 18 deletions(-) diff --git a/client/src/lib/queryClient.ts b/client/src/lib/queryClient.ts index 56f2fae..aac81aa 100644 --- a/client/src/lib/queryClient.ts +++ b/client/src/lib/queryClient.ts @@ -20,6 +20,29 @@ export function handleUnauthorized(url: string) { window.location.href = "/login"; } +/** + * Extrait un message lisible d'une erreur d'API. + * apiRequest lève des erreurs de la forme `500: {"error":"…"}` : sans ce + * décodage, l'interface ne peut afficher qu'un message générique alors que le + * serveur a renvoyé la cause exacte. + */ +export function getErrorMessage(error: unknown, fallback: string): string { + if (!(error instanceof Error) || !error.message) return fallback; + + const withoutStatus = error.message.replace(/^\d{3}:\s*/, '').trim(); + if (!withoutStatus) return fallback; + + try { + const parsed = JSON.parse(withoutStatus); + const message = parsed?.error || parsed?.message; + if (typeof message === 'string' && message.trim()) return message.trim(); + } catch { + // Corps non-JSON : on affiche le texte tel quel + } + + return withoutStatus.startsWith('<') ? fallback : withoutStatus; +} + async function throwIfResNotOk(res: Response, url: string) { if (!res.ok) { if (res.status === 401) { diff --git a/client/src/pages/mobile/new-post.tsx b/client/src/pages/mobile/new-post.tsx index 985d2a2..9cbf6a1 100644 --- a/client/src/pages/mobile/new-post.tsx +++ b/client/src/pages/mobile/new-post.tsx @@ -32,7 +32,7 @@ import { Checkbox } from "@/components/ui/checkbox"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Dialog, DialogContent } from "@/components/ui/dialog"; import { useToast } from "@/hooks/use-toast"; -import { apiRequest, queryClient, handleUnauthorized } from "@/lib/queryClient"; +import { apiRequest, queryClient, handleUnauthorized, getErrorMessage } from "@/lib/queryClient"; import type { SocialPage, Media, ScheduledPost } from "@shared/schema"; import { PreviewModal } from "@/components/preview-modal"; import { DateTimePicker } from "@/components/datetime-picker"; @@ -230,10 +230,10 @@ export default function NewPostMobile() { }); queryClient.invalidateQueries({ queryKey: ['/api/ai/generations'] }); }, - onError: () => { + onError: (error: unknown) => { toast({ title: "Erreur", - description: "Impossible de générer le texte", + description: getErrorMessage(error, "Impossible de générer le texte"), variant: "destructive", }); }, diff --git a/client/src/pages/mobile/remotion-video.tsx b/client/src/pages/mobile/remotion-video.tsx index 90a7043..c3d09ed 100644 --- a/client/src/pages/mobile/remotion-video.tsx +++ b/client/src/pages/mobile/remotion-video.tsx @@ -13,7 +13,7 @@ import Sidebar from "@/components/sidebar"; import { Checkbox } from "@/components/ui/checkbox"; import { DateTimePicker } from "@/components/datetime-picker"; import { useQuery, useMutation } from "@tanstack/react-query"; -import { apiRequest, queryClient, handleUnauthorized } from "@/lib/queryClient"; +import { apiRequest, queryClient, handleUnauthorized, getErrorMessage } from "@/lib/queryClient"; import type { Media, SocialPage } from "@shared/schema"; import { SiFacebook, SiTiktok } from "react-icons/si"; @@ -91,7 +91,7 @@ export default function MobileRemotionVideoPage() { const generateTextMutation = useMutation({ mutationFn: async (text: string) => (await apiRequest('POST', '/api/reels/generate-text', { productInfo: text })).json(), onSuccess: (data: any) => { setGeneratedVariants(data.variants || []); toast({ title: "Textes générés" }); }, - onError: () => toast({ title: "Erreur IA", variant: "destructive" }), + onError: (error: unknown) => toast({ title: "Erreur IA", description: getErrorMessage(error, "Impossible de générer le texte"), variant: "destructive" }), }); const handleFileChange = (e: React.ChangeEvent) => { diff --git a/client/src/pages/new-post.tsx b/client/src/pages/new-post.tsx index 80af7be..3cec2e6 100644 --- a/client/src/pages/new-post.tsx +++ b/client/src/pages/new-post.tsx @@ -31,7 +31,7 @@ import { Checkbox } from "@/components/ui/checkbox"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Dialog, DialogContent } from "@/components/ui/dialog"; import { useToast } from "@/hooks/use-toast"; -import { apiRequest, queryClient, handleUnauthorized } from "@/lib/queryClient"; +import { apiRequest, queryClient, handleUnauthorized, getErrorMessage } from "@/lib/queryClient"; import { MediaThumbnail } from "@/components/media-thumbnail"; import type { SocialPage, Media, ScheduledPost } from "@shared/schema"; import { PreviewModal } from "@/components/preview-modal"; @@ -226,10 +226,10 @@ export default function NewPost() { }); queryClient.invalidateQueries({ queryKey: ['/api/ai/generations'] }); }, - onError: () => { + onError: (error: unknown) => { toast({ title: "Erreur", - description: "Impossible de générer le texte", + description: getErrorMessage(error, "Impossible de générer le texte"), variant: "destructive", }); }, diff --git a/client/src/pages/new-reel.tsx b/client/src/pages/new-reel.tsx index adb0753..e72e897 100644 --- a/client/src/pages/new-reel.tsx +++ b/client/src/pages/new-reel.tsx @@ -25,7 +25,7 @@ import { SelectValue, } from "@/components/ui/select"; import { useToast } from "@/hooks/use-toast"; -import { apiRequest, queryClient, handleUnauthorized } from "@/lib/queryClient"; +import { apiRequest, queryClient, handleUnauthorized, getErrorMessage } from "@/lib/queryClient"; import type { SocialPage, Media } from "@shared/schema"; import { SiFacebook, SiTiktok } from "react-icons/si"; import { MediaThumbnail } from "@/components/media-thumbnail"; @@ -235,10 +235,10 @@ export default function NewReel() { description: `${variants.length} variations créées`, }); }, - onError: () => { + onError: (error: unknown) => { toast({ title: "Erreur", - description: "Impossible de générer le texte", + description: getErrorMessage(error, "Impossible de générer le texte"), variant: "destructive", }); }, diff --git a/client/src/pages/remotion-video.tsx b/client/src/pages/remotion-video.tsx index 3df0661..6371bec 100644 --- a/client/src/pages/remotion-video.tsx +++ b/client/src/pages/remotion-video.tsx @@ -8,7 +8,7 @@ import { Textarea } from "@/components/ui/textarea"; import { UploadCloud, Video, Loader2, Check, Sparkles, Mic, Volume2, Music, Play, Pause, Send } from "lucide-react"; import { Slider } from "@/components/ui/slider"; import { useQuery, useMutation } from "@tanstack/react-query"; -import { apiRequest, queryClient, handleUnauthorized } from "@/lib/queryClient"; +import { apiRequest, queryClient, handleUnauthorized, getErrorMessage } from "@/lib/queryClient"; import { Label } from "@/components/ui/label"; import { Switch } from "@/components/ui/switch"; import { Checkbox } from "@/components/ui/checkbox"; @@ -93,7 +93,7 @@ export default function RemotionVideoPage() { setGeneratedVariants(data.variants || []); toast({ title: "Textes générés", description: "Cliquez pour appliquer." }); }, - onError: () => toast({ title: "Erreur IA", variant: "destructive" }), + onError: (error: unknown) => toast({ title: "Erreur IA", description: getErrorMessage(error, "Impossible de générer le texte"), variant: "destructive" }), }); const handleFileChange = (e: React.ChangeEvent) => { diff --git a/server/routes.ts b/server/routes.ts index dabb47e..40780a0 100644 --- a/server/routes.ts +++ b/server/routes.ts @@ -9,7 +9,7 @@ import multer from "multer"; import bcrypt from "bcrypt"; import passport from "./auth"; import { z } from "zod"; -import { openRouterService } from "./services/openrouter"; +import { openRouterService, describeGenerationError } from "./services/openrouter"; import { minioService as cloudinaryService, buildMinioUrl } from "./services/minio"; import { insertPostSchema, insertScheduledPostSchema, insertSocialPageSchema, insertAiGenerationSchema, insertCloudinaryConfigSchema, updateCloudinaryConfigSchema, insertOpenrouterConfigSchema, updateOpenrouterConfigSchema, insertUserSchema, postMedia, type SocialPage } from "@shared/schema"; import type { User, InsertUser, ScheduledPost } from "@shared/schema"; @@ -631,7 +631,8 @@ export async function registerRoutes(app: Express): Promise { res.json({ variants: generatedTexts }); } catch (error) { console.error("Error generating text:", error); - res.status(500).json({ error: "Failed to generate text" }); + const { status, message } = describeGenerationError(error); + res.status(status).json({ error: message }); } }); diff --git a/server/routes/reels.ts b/server/routes/reels.ts index eeca4fa..5522aa0 100644 --- a/server/routes/reels.ts +++ b/server/routes/reels.ts @@ -9,7 +9,7 @@ import { ffmpegService } from '../services/ffmpeg'; import { facebookService } from '../services/facebook'; import { tiktokService } from '../services/tiktok'; import { minioService as cloudinaryService, buildMinioUrl, resolveInternalUrl } from '../services/minio'; -import { openRouterService } from '../services/openrouter'; +import { openRouterService, describeGenerationError } from '../services/openrouter'; import { db } from '../db'; import { cloudinaryConfig } from '@shared/schema'; import { eq } from 'drizzle-orm'; @@ -254,7 +254,8 @@ reelsRouter.post('/reels/generate-text', async (req: Request, res: Response) => res.json({ variants: generatedTexts }); } catch (error) { console.error('❌ Error generating Reel text:', error); - res.status(500).json({ error: 'Erreur lors de la génération du texte' }); + const { status, message } = describeGenerationError(error); + res.status(status).json({ error: message }); } }); diff --git a/server/services/openrouter.ts b/server/services/openrouter.ts index 2137458..98d2fc5 100644 --- a/server/services/openrouter.ts +++ b/server/services/openrouter.ts @@ -15,6 +15,49 @@ interface GeneratedText { import crypto from 'crypto'; import { storage } from '../storage'; +/** + * Extrait le message d'erreur d'une réponse OpenRouter, qu'elle soit au format + * JSON ({ error: { message } }) ou en texte brut. Tronqué pour éviter de + * déverser une page HTML entière dans l'interface. + */ +function extractUpstreamMessage(body: string): string { + const trimmed = (body || '').trim(); + if (!trimmed) return 'aucun détail fourni'; + + try { + const parsed = JSON.parse(trimmed); + const message = parsed?.error?.message || parsed?.message || parsed?.error; + if (typeof message === 'string' && message.trim()) { + return message.trim().slice(0, 300); + } + } catch { + // Réponse non-JSON (page d'erreur HTML d'un proxy, par exemple) + } + + return trimmed.replace(/\s+/g, ' ').slice(0, 300); +} + +/** + * Traduit une erreur de génération en réponse HTTP exploitable côté interface. + * Sans cela, l'utilisateur ne voit qu'un 500 générique alors que la cause + * (configuration absente, clé invalide, crédits épuisés) est connue du serveur. + */ +export function describeGenerationError(error: unknown): { status: number; message: string } { + const message = error instanceof Error ? error.message : 'Erreur inconnue lors de la génération'; + + // Problème de configuration : l'administrateur peut le corriger lui-même + if (/Configuration OpenRouter non trouvée|Clé API OpenRouter manquante/.test(message)) { + return { status: 400, message }; + } + + // Refus du service tiers + if (/OpenRouter a refusé la requête|Réponse invalide de l'API OpenRouter/.test(message)) { + return { status: 502, message }; + } + + return { status: 500, message }; +} + export class OpenRouterService { private baseUrl = "https://openrouter.ai/api/v1/chat/completions"; @@ -64,7 +107,13 @@ export class OpenRouterService { if (!response.ok) { const errorText = await response.text(); console.error('OpenRouter API Error Response:', errorText); - throw new Error(`OpenRouter API error: ${response.status} ${response.statusText}`); + // Le motif renvoyé par OpenRouter (clé invalide, crédits épuisés, modèle + // inconnu…) est la seule information exploitable : on le fait remonter + // jusqu'à l'utilisateur, qui n'a pas accès aux logs du serveur. + throw new Error( + `OpenRouter a refusé la requête (${response.status} ${response.statusText}) ` + + `avec le modèle "${modelToUse}" : ${extractUpstreamMessage(errorText)}` + ); } const data = await response.json();