Compare commits

...
Author SHA1 Message Date
Claude 5550fb05d6 feat(openrouter): validate API key at save time
The stored key that caused the persistent 401s turned out to be only
9 characters long — an accepted-but-truncated paste. Prevent this class
of problem at the source:
- require a minimum key length in the zod schema
- verify new keys against OpenRouter's /api/v1/key endpoint on save,
  rejecting explicit 401/403 (network errors don't block saving)
- return zod validation messages as 400 instead of a generic 500
- surface the server's error message in the settings toast (desktop
  and mobile) instead of a generic failure text
2026-07-14 06:39:01 +00:00
LogiFlow 75e1d88c26 Merge pull request #109 from R0m1k3/claude/openrouter-api-404-h97d7e
fix(openrouter): resolve AI generation 401/404 errors
2026-07-14 08:33:35 +02:00
Claude 8155b30c63 fix(openrouter): stop logging API key material, use a hash fingerprint
Address Codex review: the debug log previously included the key's
first 10 characters. Replace with a non-reversible SHA-256 fingerprint
so logs remain useful for correlating support reports without ever
exposing key material.
2026-07-14 06:31:18 +00:00
Claude de7fc7d03c fix(openrouter): trim API key and guard against empty key
An OpenRouter key with trailing whitespace/newline (common from
copy-paste) produces "Missing Authentication header" from their API,
which looked identical to a missing key. Trim apiKey on save (schema
level) and on use (defense in depth), fail fast with a clear message
if the stored key is empty, and log a masked key preview + model on
each generation call to make future auth failures diagnosable from
container logs.
2026-07-14 06:25:47 +00:00
LogiFlow 517c786be6 Merge pull request #108 from R0m1k3/claude/openrouter-api-404-h97d7e
fix(openrouter): respect the requesting user's saved model config
2026-07-14 08:12:07 +02:00
Claude 09b4e597ae fix(openrouter): respect the requesting user's saved model config
generatePostText ignored the userId it received and always read an
arbitrary row via getAnyOpenrouterConfig() (LIMIT 1, no ORDER BY),
so a model chosen and saved in Settings could be shadowed by another
stale config row. Now it prefers the requesting user's own config,
falling back to the most recently updated shared one. Also swap the
retired anthropic/claude-3.5-sonnet default for a live OpenRouter slug.
2026-07-14 05:49:31 +00:00
Michael b00d88f6b0 docs: add comprehensive Socialflow API documentation 2026-05-28 19:25:34 +02:00
MichaelandClaude Opus 4.7 c008b41eb7 feat(ffmpeg): Gemini TTS as primary engine, Edge TTS as fallback
Default tts_engine changed from "edge" to "gemini".
Both /process-reel and /preview-tts now try Gemini first,
fall back to Edge TTS on failure.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-19 16:42:45 +02:00
10 changed files with 1306 additions and 37 deletions

No files matched your search

File diff suppressed because it is too large. Load diff
+1 -1
View File
@@ -32,7 +32,7 @@ export default function AiChat() {
}, },
]); ]);
const [input, setInput] = useState(""); const [input, setInput] = useState("");
const [selectedModel, setSelectedModel] = useState("anthropic/claude-3.5-sonnet"); const [selectedModel, setSelectedModel] = useState("anthropic/claude-sonnet-4.5");
const { toast } = useToast(); const { toast } = useToast();
// Fetch available models from OpenRouter // Fetch available models from OpenRouter
+11 -4
View File
@@ -23,7 +23,7 @@ export default function SettingsMobile() {
const [endpointUrl, setEndpointUrl] = useState(""); const [endpointUrl, setEndpointUrl] = useState("");
const [publicUrl, setPublicUrl] = useState(""); const [publicUrl, setPublicUrl] = useState("");
const [openrouterApiKey, setOpenrouterApiKey] = useState(""); const [openrouterApiKey, setOpenrouterApiKey] = useState("");
const [openrouterModel, setOpenrouterModel] = useState("anthropic/claude-3.5-sonnet"); const [openrouterModel, setOpenrouterModel] = useState("anthropic/claude-sonnet-4.5");
const [openrouterSystemPrompt, setOpenrouterSystemPrompt] = useState("Tu es un expert en marketing des réseaux sociaux. Génère 3 variations de textes engageants pour des publications Facebook et Instagram à partir des informations produit fournies. Chaque variation doit être unique, captivante et optimisée pour l'engagement."); const [openrouterSystemPrompt, setOpenrouterSystemPrompt] = useState("Tu es un expert en marketing des réseaux sociaux. Génère 3 variations de textes engageants pour des publications Facebook et Instagram à partir des informations produit fournies. Chaque variation doit être unique, captivante et optimisée pour l'engagement.");
const [logoFile, setLogoFile] = useState<File | null>(null); const [logoFile, setLogoFile] = useState<File | null>(null);
const [logoPreview, setLogoPreview] = useState<string | null>(null); const [logoPreview, setLogoPreview] = useState<string | null>(null);
@@ -64,7 +64,7 @@ export default function SettingsMobile() {
useEffect(() => { useEffect(() => {
if (openrouterConfig) { if (openrouterConfig) {
setOpenrouterModel((openrouterConfig as any).model || "anthropic/claude-3.5-sonnet"); setOpenrouterModel((openrouterConfig as any).model || "anthropic/claude-sonnet-4.5");
setOpenrouterSystemPrompt((openrouterConfig as any).systemPrompt || "Tu es un expert en marketing des réseaux sociaux. Génère 3 variations de textes engageants pour des publications Facebook et Instagram à partir des informations produit fournies. Chaque variation doit être unique, captivante et optimisée pour l'engagement."); setOpenrouterSystemPrompt((openrouterConfig as any).systemPrompt || "Tu es un expert en marketing des réseaux sociaux. Génère 3 variations de textes engageants pour des publications Facebook et Instagram à partir des informations produit fournies. Chaque variation doit être unique, captivante et optimisée pour l'engagement.");
} }
}, [openrouterConfig]); }, [openrouterConfig]);
@@ -157,10 +157,17 @@ export default function SettingsMobile() {
}); });
setOpenrouterApiKey(""); setOpenrouterApiKey("");
}, },
onError: () => { onError: (error: Error) => {
let description = "Impossible de sauvegarder la configuration OpenRouter";
const body = error.message.match(/^\d+:\s*(.+)$/)?.[1];
if (body) {
try {
description = JSON.parse(body).error || description;
} catch { /* garder le message générique */ }
}
toast({ toast({
title: "Erreur", title: "Erreur",
description: "Impossible de sauvegarder la configuration OpenRouter", description,
variant: "destructive", variant: "destructive",
}); });
}, },
+11 -4
View File
@@ -23,7 +23,7 @@ export default function Settings() {
const [endpointUrl, setEndpointUrl] = useState(""); const [endpointUrl, setEndpointUrl] = useState("");
const [publicUrl, setPublicUrl] = useState(""); const [publicUrl, setPublicUrl] = useState("");
const [openrouterApiKey, setOpenrouterApiKey] = useState(""); const [openrouterApiKey, setOpenrouterApiKey] = useState("");
const [openrouterModel, setOpenrouterModel] = useState("anthropic/claude-3.5-sonnet"); const [openrouterModel, setOpenrouterModel] = useState("anthropic/claude-sonnet-4.5");
const [openrouterSystemPrompt, setOpenrouterSystemPrompt] = useState("Tu es un expert en marketing des réseaux sociaux. Génère 3 variations de textes engageants pour des publications Facebook et Instagram à partir des informations produit fournies. Chaque variation doit être unique, captivante et optimisée pour l'engagement."); const [openrouterSystemPrompt, setOpenrouterSystemPrompt] = useState("Tu es un expert en marketing des réseaux sociaux. Génère 3 variations de textes engageants pour des publications Facebook et Instagram à partir des informations produit fournies. Chaque variation doit être unique, captivante et optimisée pour l'engagement.");
const [logoFile, setLogoFile] = useState<File | null>(null); const [logoFile, setLogoFile] = useState<File | null>(null);
const [logoPreview, setLogoPreview] = useState<string | null>(null); const [logoPreview, setLogoPreview] = useState<string | null>(null);
@@ -81,7 +81,7 @@ export default function Settings() {
useEffect(() => { useEffect(() => {
if (openrouterConfig) { if (openrouterConfig) {
setOpenrouterModel((openrouterConfig as any).model || "anthropic/claude-3.5-sonnet"); setOpenrouterModel((openrouterConfig as any).model || "anthropic/claude-sonnet-4.5");
setOpenrouterSystemPrompt((openrouterConfig as any).systemPrompt || "Tu es un expert en marketing des réseaux sociaux. Génère 3 variations de textes engageants pour des publications Facebook et Instagram à partir des informations produit fournies. Chaque variation doit être unique, captivante et optimisée pour l'engagement."); setOpenrouterSystemPrompt((openrouterConfig as any).systemPrompt || "Tu es un expert en marketing des réseaux sociaux. Génère 3 variations de textes engageants pour des publications Facebook et Instagram à partir des informations produit fournies. Chaque variation doit être unique, captivante et optimisée pour l'engagement.");
} }
}, [openrouterConfig]); }, [openrouterConfig]);
@@ -161,10 +161,17 @@ export default function Settings() {
}); });
setOpenrouterApiKey(""); // Clear the API key after saving setOpenrouterApiKey(""); // Clear the API key after saving
}, },
onError: () => { onError: (error: Error) => {
let description = "Impossible de sauvegarder la configuration OpenRouter";
const body = error.message.match(/^\d+:\s*(.+)$/)?.[1];
if (body) {
try {
description = JSON.parse(body).error || description;
} catch { /* garder le message générique */ }
}
toast({ toast({
title: "Erreur", title: "Erreur",
description: "Impossible de sauvegarder la configuration OpenRouter", description,
variant: "destructive", variant: "destructive",
}); });
}, },
+43 -20
View File
@@ -159,7 +159,7 @@ class ReelRequest(BaseModel):
music_volume: float = 0.25 music_volume: float = 0.25
tts_enabled: bool = False tts_enabled: bool = False
tts_voice: str = "fr-FR-VivienneMultilingualNeural" tts_voice: str = "fr-FR-VivienneMultilingualNeural"
tts_engine: str = "edge" # "edge" or "gemini" tts_engine: str = "gemini" # "gemini" or "edge"
gemini_api_key: Optional[str] = None # Google Cloud API key for Gemini TTS gemini_api_key: Optional[str] = None # Google Cloud API key for Gemini TTS
draw_text: bool = True draw_text: bool = True
stabilize: bool = False # Stabilisation vidéo via vidstab stabilize: bool = False # Stabilisation vidéo via vidstab
@@ -924,18 +924,30 @@ async def process_reel(request: ReelRequest, x_api_key: str = Header(None)):
if tts_clean_text: if tts_clean_text:
print(f"🔊 Generating TTS audio to: {tts_audio_path}") print(f"🔊 Generating TTS audio to: {tts_audio_path}")
engine = request.tts_engine or "edge" engine = request.tts_engine or "gemini"
# Primary: Gemini TTS. Fallback: Edge TTS
if engine == "gemini" and request.gemini_api_key: if engine == "gemini" and request.gemini_api_key:
await generate_tts_gemini( try:
tts_clean_text, await generate_tts_gemini(
voice, tts_clean_text,
request.gemini_api_key, voice,
tts_audio_path, request.gemini_api_key,
tts_ass_path, tts_audio_path,
display_text=clean_text_for_display(request.text), tts_ass_path,
delay=2.0, display_text=clean_text_for_display(request.text),
) delay=2.0,
)
except Exception as gemini_err:
print(f"⚠️ Gemini TTS failed ({gemini_err}), falling back to Edge TTS")
await generate_tts_with_subs(
tts_clean_text,
voice,
tts_audio_path,
tts_ass_path,
display_text=clean_text_for_display(request.text),
delay=2.0,
)
else: else:
await generate_tts_with_subs( await generate_tts_with_subs(
tts_clean_text, tts_clean_text,
@@ -1323,17 +1335,28 @@ async def preview_tts(request: ReelRequest, x_api_key: str = Header(None)):
# Note: Gemini voices (fr-FR-Standard-A etc.) are valid Edge voices too, # Note: Gemini voices (fr-FR-Standard-A etc.) are valid Edge voices too,
# so we don't check for "Neural" - let edge_tts handle voice resolution # so we don't check for "Neural" - let edge_tts handle voice resolution
engine = request.tts_engine or "edge" engine = request.tts_engine or "gemini"
# Primary: Gemini TTS. Fallback: Edge TTS
if engine == "gemini" and request.gemini_api_key: if engine == "gemini" and request.gemini_api_key:
await generate_tts_gemini( try:
clean_text, await generate_tts_gemini(
voice, clean_text,
request.gemini_api_key, voice,
tts_audio_path, request.gemini_api_key,
tts_srt_path, tts_audio_path,
display_text=clean_text_for_display(request.text), tts_srt_path,
) display_text=clean_text_for_display(request.text),
)
except Exception as gemini_err:
print(f"⚠️ Gemini TTS failed ({gemini_err}), falling back to Edge TTS")
await generate_tts_with_subs(
clean_text,
voice,
tts_audio_path,
tts_srt_path,
display_text=clean_text_for_display(request.text),
)
else: else:
await generate_tts_with_subs( await generate_tts_with_subs(
clean_text, clean_text,
+1 -1
View File
@@ -88,7 +88,7 @@ CREATE TABLE IF NOT EXISTS openrouter_config (
id VARCHAR PRIMARY KEY DEFAULT gen_random_uuid(), id VARCHAR PRIMARY KEY DEFAULT gen_random_uuid(),
user_id VARCHAR NOT NULL UNIQUE REFERENCES users(id) ON DELETE CASCADE, user_id VARCHAR NOT NULL UNIQUE REFERENCES users(id) ON DELETE CASCADE,
api_key TEXT NOT NULL, api_key TEXT NOT NULL,
model TEXT NOT NULL DEFAULT 'anthropic/claude-3.5-sonnet', model TEXT NOT NULL DEFAULT 'anthropic/claude-sonnet-4.5',
system_prompt TEXT NOT NULL DEFAULT 'Tu es un expert en marketing des réseaux sociaux. Génère 3 variations de textes engageants pour des publications Facebook et Instagram à partir des informations produit fournies. Chaque variation doit être unique, captivante et optimisée pour l''engagement.', system_prompt TEXT NOT NULL DEFAULT 'Tu es un expert en marketing des réseaux sociaux. Génère 3 variations de textes engageants pour des publications Facebook et Instagram à partir des informations produit fournies. Chaque variation doit être unique, captivante et optimisée pour l''engagement.',
created_at TIMESTAMP DEFAULT NOW(), created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW() updated_at TIMESTAMP DEFAULT NOW()
+13 -1
View File
@@ -1844,6 +1844,10 @@ export async function registerRoutes(app: Express): Promise<Server> {
return res.status(400).json({ error: "Clé API OpenRouter manquante. Veuillez entrer une clé API valide." }); return res.status(400).json({ error: "Clé API OpenRouter manquante. Veuillez entrer une clé API valide." });
} }
if (incomingKey && !(await openRouterService.verifyApiKey(incomingKey))) {
return res.status(400).json({ error: "Clé API refusée par OpenRouter. Vérifiez que vous avez collé la clé complète (sk-or-v1-...)." });
}
config = await storage.updateOpenrouterConfig(userId, finalData); config = await storage.updateOpenrouterConfig(userId, finalData);
} else { } else {
// Pour les créations, exiger tous les champs // Pour les créations, exiger tous les champs
@@ -1851,14 +1855,22 @@ export async function registerRoutes(app: Express): Promise<Server> {
...req.body, ...req.body,
userId, userId,
}); });
if (!(await openRouterService.verifyApiKey(configData.apiKey))) {
return res.status(400).json({ error: "Clé API refusée par OpenRouter. Vérifiez que vous avez collé la clé complète (sk-or-v1-...)." });
}
config = await storage.createOpenrouterConfig(configData); config = await storage.createOpenrouterConfig(configData);
} }
// Don't send the API key back // Don't send the API key back
const { apiKey, ...safeConfig } = config; const { apiKey, ...safeConfig } = config;
res.json(safeConfig); res.json(safeConfig);
} catch (error) { } catch (error: any) {
console.error("Error saving OpenRouter config:", error); console.error("Error saving OpenRouter config:", error);
if (error.name === 'ZodError') {
return res.status(400).json({ error: error.errors[0]?.message || "Données invalides" });
}
res.status(500).json({ error: "Failed to save OpenRouter config" }); res.status(500).json({ error: "Failed to save OpenRouter config" });
} }
}); });
+28 -3
View File
@@ -12,29 +12,38 @@ interface GeneratedText {
characterCount: number; characterCount: number;
} }
import crypto from 'crypto';
import { storage } from '../storage'; import { storage } from '../storage';
export class OpenRouterService { export class OpenRouterService {
private baseUrl = "https://openrouter.ai/api/v1/chat/completions"; private baseUrl = "https://openrouter.ai/api/v1/chat/completions";
async generatePostText(productInfo: ProductInfo, userId: string, modelOverride?: string): Promise<GeneratedText[]> { async generatePostText(productInfo: ProductInfo, userId: string, modelOverride?: string): Promise<GeneratedText[]> {
// Get any available OpenRouter configuration (shared across all users) // Prefer the requesting user's own configuration; fall back to the most
const config = await storage.getAnyOpenrouterConfig(); // recently saved shared configuration if this user hasn't set one up.
const config = (await storage.getOpenrouterConfig(userId)) || (await storage.getAnyOpenrouterConfig());
if (!config) { if (!config) {
throw new Error('Configuration OpenRouter non trouvée. Veuillez demander à un administrateur de configurer OpenRouter dans les Paramètres.'); 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()) {
throw new Error('Clé API OpenRouter manquante ou vide en base de données. Veuillez la ressaisir dans les Paramètres.');
}
const prompt = this.buildPrompt(productInfo, config.systemPrompt); const prompt = this.buildPrompt(productInfo, config.systemPrompt);
// Use provided model or fall back to config model // Use provided model or fall back to config model
const modelToUse = modelOverride || 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}`);
try { try {
const response = await fetch(this.baseUrl, { const response = await fetch(this.baseUrl, {
method: "POST", method: "POST",
headers: { headers: {
"Authorization": `Bearer ${config.apiKey}`, "Authorization": `Bearer ${config.apiKey.trim()}`,
"Content-Type": "application/json", "Content-Type": "application/json",
"HTTP-Referer": process.env.APP_URL || "http://localhost:5555", "HTTP-Referer": process.env.APP_URL || "http://localhost:5555",
"X-Title": "Social Flow" "X-Title": "Social Flow"
@@ -206,6 +215,22 @@ VERSION 3 - ÉMOTIONNELLE:
return text.charAt(0).toUpperCase() + text.slice(1).toLowerCase(); return text.charAt(0).toUpperCase() + text.slice(1).toLowerCase();
} }
/**
* Vérifie qu'une clé API est acceptée par OpenRouter.
* Retourne false uniquement sur un rejet d'authentification explicite (401/403) ;
* en cas d'erreur réseau, on laisse passer pour ne pas bloquer la sauvegarde.
*/
async verifyApiKey(apiKey: string): Promise<boolean> {
try {
const response = await fetch("https://openrouter.ai/api/v1/key", {
headers: { "Authorization": `Bearer ${apiKey}` }
});
return response.status !== 401 && response.status !== 403;
} catch {
return true;
}
}
async getAvailableModels(): Promise<any[]> { async getAvailableModels(): Promise<any[]> {
try { try {
const response = await fetch("https://openrouter.ai/api/v1/models", { const response = await fetch("https://openrouter.ai/api/v1/models", {
+1 -1
View File
@@ -461,7 +461,7 @@ export class DatabaseStorage implements IStorage {
} }
async getAnyOpenrouterConfig(): Promise<OpenrouterConfig | undefined> { async getAnyOpenrouterConfig(): Promise<OpenrouterConfig | undefined> {
const [config] = await db.select().from(openrouterConfig).limit(1); const [config] = await db.select().from(openrouterConfig).orderBy(desc(openrouterConfig.updatedAt)).limit(1);
return config || undefined; return config || undefined;
} }
+2 -2
View File
@@ -127,7 +127,7 @@ export const openrouterConfig = pgTable("openrouter_config", {
id: varchar("id").primaryKey().default(sql`gen_random_uuid()`), id: varchar("id").primaryKey().default(sql`gen_random_uuid()`),
userId: varchar("user_id").notNull().unique().references(() => users.id, { onDelete: "cascade" }), userId: varchar("user_id").notNull().unique().references(() => users.id, { onDelete: "cascade" }),
apiKey: text("api_key").notNull(), apiKey: text("api_key").notNull(),
model: text("model").notNull().default("anthropic/claude-3.5-sonnet"), model: text("model").notNull().default("anthropic/claude-sonnet-4.5"),
systemPrompt: text("system_prompt").notNull().default("Tu es un expert en marketing des réseaux sociaux. Génère 3 variations de textes engageants pour des publications Facebook et Instagram à partir des informations produit fournies. Chaque variation doit être unique, captivante et optimisée pour l'engagement."), systemPrompt: text("system_prompt").notNull().default("Tu es un expert en marketing des réseaux sociaux. Génère 3 variations de textes engageants pour des publications Facebook et Instagram à partir des informations produit fournies. Chaque variation doit être unique, captivante et optimisée pour l'engagement."),
createdAt: timestamp("created_at").defaultNow(), createdAt: timestamp("created_at").defaultNow(),
updatedAt: timestamp("updated_at").defaultNow(), updatedAt: timestamp("updated_at").defaultNow(),
@@ -394,7 +394,7 @@ export const insertOpenrouterConfigSchema = createInsertSchema(openrouterConfig)
createdAt: true, createdAt: true,
updatedAt: true, updatedAt: true,
}).extend({ }).extend({
apiKey: z.string().min(1, "La clé API ne peut pas être vide"), apiKey: z.string().trim().min(20, "La clé API OpenRouter semble incomplète. Collez la clé entière (elle commence par sk-or-v1- et fait environ 73 caractères)."),
}); });
export const updateOpenrouterConfigSchema = insertOpenrouterConfigSchema.partial({ export const updateOpenrouterConfigSchema = insertOpenrouterConfigSchema.partial({