From c097c5c8f3c9afe963b97c4c537c4b3713606606 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 12 Aug 2026 08:14:47 +0000 Subject: [PATCH] =?UTF-8?q?fix(openrouter):=20nettoyer=20la=20cl=C3=A9=20A?= =?UTF-8?q?PI=20souill=C3=A9e=20par=20un=20copier-coller?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OpenRouter répondait « 401 Missing Authentication header », ce qui laisse croire à un en-tête absent alors qu'il était bien envoyé. Vérification faite contre l'API : ce message n'apparaît pas quand la clé est invalide (l'API répond alors « User not found »), mais quand le jeton placé derrière « Bearer » n'a pas la forme d'une clé — préfixe « Bearer » recopié depuis un exemple, guillemets laissés autour de la valeur, ou caractère invisible ramassé lors d'un copier-coller. String.trim() ne retire pas ces derniers. La clé est désormais nettoyée à l'enregistrement et à la lecture ; ce second point remet en marche les clés déjà stockées sans ressaisie. En cas de 401, le message indique en plus la longueur de la clé et si elle commence par « sk-or- », sans jamais en révéler le contenu. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_018uUPZ1GNpEkYB1dpXHYPek --- server/routes.ts | 9 +++++- server/services/openrouter.ts | 53 +++++++++++++++++++++++++++++++---- 2 files changed, 56 insertions(+), 6 deletions(-) diff --git a/server/routes.ts b/server/routes.ts index 40780a0..14de8f1 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, describeGenerationError } from "./services/openrouter"; +import { openRouterService, describeGenerationError, sanitizeApiKey } 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"; @@ -1853,6 +1853,13 @@ export async function registerRoutes(app: Express): Promise { // Check if config already exists const existingConfig = await storage.getOpenrouterConfig(userId); + // Une clé collée depuis un navigateur traîne souvent un « Bearer », des + // guillemets ou un caractère invisible. Stockée telle quelle, elle fait + // répondre « Missing Authentication header » à OpenRouter. + if (typeof req.body?.apiKey === 'string') { + req.body.apiKey = sanitizeApiKey(req.body.apiKey); + } + let config; if (existingConfig) { // Pour les mises à jour, utiliser le schéma qui rend apiKey optionnel diff --git a/server/services/openrouter.ts b/server/services/openrouter.ts index 98d2fc5..38a4fc0 100644 --- a/server/services/openrouter.ts +++ b/server/services/openrouter.ts @@ -15,6 +15,36 @@ interface GeneratedText { import crypto from 'crypto'; import { storage } from '../storage'; +/** + * Nettoie une clé API collée depuis un navigateur ou une documentation. + * + * OpenRouter répond « Missing Authentication header » — et non « User not + * found » — dès que le jeton qui suit « Bearer » n'a pas la forme d'une clé : + * préfixe « Bearer » recopié par mégarde, guillemets autour de la valeur, ou + * espace insécable/de largeur nulle ramassé lors d'un copier-coller. Le message + * laisse croire à un en-tête absent alors que la clé est simplement souillée. + * + * Appliqué à l'enregistrement comme à la lecture, pour rattraper les clés déjà + * stockées sans imposer une ressaisie. + */ +export function sanitizeApiKey(raw: string): string { + let key = (raw || '') + // Caractères invisibles que String.trim() ne retire pas + .replace(/[​-‍]/g, '') + .trim(); + + // « Bearer sk-… » recopié depuis un exemple de documentation + key = key.replace(/^bearer\s+/i, '').trim(); + + // Valeur entourée de guillemets, typique d'un copier-coller depuis un JSON + const quoted = key.match(/^(["'])(.*)\1$/); + if (quoted) { + key = quoted[2].trim(); + } + + return key; +} + /** * 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 @@ -70,7 +100,12 @@ export class OpenRouterService { throw new Error('Configuration OpenRouter non trouvée. Veuillez demander à un administrateur de configurer OpenRouter dans les Paramètres.'); } - if (!config.apiKey || !config.apiKey.trim()) { + // Rattrape les clés déjà stockées avec un « Bearer », des guillemets ou un + // caractère invisible : sans cela OpenRouter répond « Missing Authentication + // header », ce qui laisse croire à tort que la clé est absente. + const apiKey = sanitizeApiKey(config.apiKey); + + if (!apiKey) { throw new Error('Clé API OpenRouter manquante ou vide en base de données. Veuillez la ressaisir dans les Paramètres.'); } @@ -79,14 +114,14 @@ export class OpenRouterService { // Use provided model or fall back to config model const modelToUse = modelOverride || config.model; - const keyFingerprint = crypto.createHash('sha256').update(config.apiKey).digest('hex').slice(0, 8); - console.log(`[OpenRouter] Generating with model="${modelToUse}" keyFingerprint=${keyFingerprint} keyLength=${config.apiKey.length} configUserId=${config.userId}`); + const keyFingerprint = crypto.createHash('sha256').update(apiKey).digest('hex').slice(0, 8); + console.log(`[OpenRouter] Generating with model="${modelToUse}" keyFingerprint=${keyFingerprint} keyLength=${apiKey.length} configUserId=${config.userId}`); try { const response = await fetch(this.baseUrl, { method: "POST", headers: { - "Authorization": `Bearer ${config.apiKey.trim()}`, + "Authorization": `Bearer ${apiKey}`, "Content-Type": "application/json", "HTTP-Referer": process.env.APP_URL || "http://localhost:5555", "X-Title": "Social Flow" @@ -110,9 +145,17 @@ export class OpenRouterService { // 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. + // Sur un refus d'authentification, la forme de la clé est le seul + // indice utile — sans jamais en révéler le contenu. + const hint = response.status === 401 + ? ` La clé enregistrée fait ${apiKey.length} caractères et ` + + `${apiKey.startsWith('sk-or-') ? 'commence' : 'ne commence pas'} par "sk-or-" : ` + + `vérifiez-la dans Paramètres → OpenRouter.` + : ''; + throw new Error( `OpenRouter a refusé la requête (${response.status} ${response.statusText}) ` + - `avec le modèle "${modelToUse}" : ${extractUpstreamMessage(errorText)}` + `avec le modèle "${modelToUse}" : ${extractUpstreamMessage(errorText)}.${hint}` ); }