Compare commits

...
Author SHA1 Message Date
Claude c097c5c8f3 fix(openrouter): nettoyer la clé API souillée par un copier-coller
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018uUPZ1GNpEkYB1dpXHYPek
2026-08-12 08:14:47 +00:00
Claude 26cddb4d01 fix(client): tracer le motif des erreurs d'API dans la console
Le navigateur ne journalise qu'une ligne « POST … 502 » : le motif renvoyé
par le serveur n'apparaît que dans un bandeau qui disparaît. Sans accès
aux logs du serveur, il devenait impossible de rapporter la cause d'un
échec.

Chaque réponse en erreur est désormais tracée avec son message décodé.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018uUPZ1GNpEkYB1dpXHYPek
2026-08-12 08:10:23 +00:00
3 changed files with 64 additions and 6 deletions

No files matched your search

+8
View File
@@ -49,6 +49,14 @@ async function throwIfResNotOk(res: Response, url: string) {
handleUnauthorized(url);
}
const text = (await res.text()) || res.statusText;
// Le navigateur ne journalise que « POST … 502 » : le motif renvoyé par le
// serveur n'apparaît que dans un bandeau éphémère. On le trace donc dans la
// console, seul endroit consultable sans accès aux logs du serveur.
console.error(
`[API] ${res.status} sur ${url} → ${getErrorMessage(new Error(`${res.status}: ${text}`), text)}`
);
throw new Error(`${res.status}: ${text}`);
}
}
+8 -1
View File
@@ -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<Server> {
// 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
+48 -5
View File
@@ -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}`
);
}