From aac6ec2158f7ba3fc5721360fddba04f73078e58 Mon Sep 17 00:00:00 2001 From: Georgy Butaev <41178744+g-but@users.noreply.github.com> Date: Wed, 22 Jul 2026 18:30:18 +0200 Subject: [PATCH] =?UTF-8?q?fix(articles):=20AI=20drafts=20failed=20?= =?UTF-8?q?=E2=80=94=20pin=20a=20live=20OpenRouter=20model,=20readable=20e?= =?UTF-8?q?rrors?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Live verification caught two bugs in the AI writer: 1. Article drafts 404'd: the long-form path used `openai/gpt-oss-120b:free`, which OpenRouter returns 404 for (free model IDs rot). Pin the model the offer-engine already proves works on the box — `meta-llama/llama-4-maverick:free` — for both short and long-form. Also retry a JSON-mode call once without `response_format` for models that reject it, and trim long-form max_tokens to a safer 3000. 2. Errors rendered as "[object Object]": the client threw `new Error(json.error)` but the standard API shape is `error: { code, message }`. Extract `.message`. Verified: type-check + lint clean, prod build green. Re-verifying draft live. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/services/articles/ai-client.ts | 7 +++- src/services/cat/platform-llm.ts | 54 ++++++++++++++++++++---------- 2 files changed, 42 insertions(+), 19 deletions(-) diff --git a/src/services/articles/ai-client.ts b/src/services/articles/ai-client.ts index 2a160b53f..15705b493 100644 --- a/src/services/articles/ai-client.ts +++ b/src/services/articles/ai-client.ts @@ -18,7 +18,12 @@ async function postJson(url: string, body: unknown): Promise { }); const json = await res.json().catch(() => null); if (!res.ok || !json?.success) { - throw new Error(json?.error || 'The AI writer is unavailable right now. Please try again.'); + // Standard API errors carry `error: { code, message }`; tolerate a bare string too. + const err = json?.error; + const message = + (typeof err === 'string' ? err : err?.message) || + 'The AI writer is unavailable right now. Please try again.'; + throw new Error(message); } return json.data as T; } diff --git a/src/services/cat/platform-llm.ts b/src/services/cat/platform-llm.ts index 5f959c06e..d32079046 100644 --- a/src/services/cat/platform-llm.ts +++ b/src/services/cat/platform-llm.ts @@ -9,19 +9,19 @@ */ import { logger } from '@/utils/logger'; -import { DEFAULT_FREE_MODEL_ID } from '@/config/ai-models'; // Capable, JSON-reliable defaults. Groq is fast + cheap for short work; the -// OpenRouter free 120B handles long-form with a larger output budget than -// Groq's free tier (which caps output tokens for TPM reasons). +// OpenRouter free Maverick handles long-form with a large output budget. We use +// the SAME OpenRouter model the offer-engine proves works on the box — free +// model IDs rot (a bare gpt-oss-120b:free returned 404 in prod), so we pin the +// one that's verified live rather than trusting DEFAULT_FREE_MODEL_ID. const GROQ_MODEL = 'llama-3.3-70b-versatile'; -const OPENROUTER_SHORT_MODEL = 'meta-llama/llama-4-maverick:free'; -const OPENROUTER_LONGFORM_MODEL = DEFAULT_FREE_MODEL_ID; // 'openai/gpt-oss-120b:free' +const OPENROUTER_MODEL = 'meta-llama/llama-4-maverick:free'; export interface PlatformJsonOpts { temperature?: number; maxTokens?: number; - /** Long-form (article bodies): prefer the OpenRouter 120B for larger output. */ + /** Long-form (article bodies): prefer OpenRouter for a larger output budget. */ longform?: boolean; } @@ -36,9 +36,10 @@ function resolveProvider(longform: boolean): Provider | null { const groqKey = process.env.GROQ_API_KEY; const openRouterKey = process.env.OPENROUTER_API_KEY; - // Long-form prefers OpenRouter's larger free model; short work prefers Groq's speed. + // Long-form prefers OpenRouter (bigger output budget than Groq's free TPM cap); + // short work prefers Groq's speed. Same verified model either way. if (longform && openRouterKey) { - return openRouter(openRouterKey, OPENROUTER_LONGFORM_MODEL); + return openRouter(openRouterKey, OPENROUTER_MODEL); } if (groqKey) { return { @@ -49,7 +50,7 @@ function resolveProvider(longform: boolean): Provider | null { }; } if (openRouterKey) { - return openRouter(openRouterKey, longform ? OPENROUTER_LONGFORM_MODEL : OPENROUTER_SHORT_MODEL); + return openRouter(openRouterKey, OPENROUTER_MODEL); } return null; } @@ -86,21 +87,38 @@ export async function callPlatformJson( headers['HTTP-Referer'] = process.env.NEXT_PUBLIC_APP_URL || 'https://orangecat.ch'; } - try { - const response = await fetch(provider.url, { + const messages = [ + { role: 'system', content: system }, + { role: 'user', content: user }, + ]; + const maxTokens = opts.maxTokens ?? (opts.longform ? 3000 : 1400); + const temperature = opts.temperature ?? 0.6; + + const call = (jsonMode: boolean) => + fetch(provider.url, { method: 'POST', headers, body: JSON.stringify({ model: provider.model, - messages: [ - { role: 'system', content: system }, - { role: 'user', content: user }, - ], - temperature: opts.temperature ?? 0.6, - max_tokens: opts.maxTokens ?? (opts.longform ? 4000 : 1400), - response_format: { type: 'json_object' }, + messages, + temperature, + max_tokens: maxTokens, + ...(jsonMode ? { response_format: { type: 'json_object' } } : {}), }), }); + + try { + // Some free models 400 on response_format — retry once without it and lean + // on parseJsonLoose (the system prompt already demands JSON-only output). + let response = await call(true); + if (!response.ok) { + logger.warn( + 'platform-llm: json-mode call failed, retrying without response_format', + { status: response.status, model: provider.model }, + 'PlatformLLM' + ); + response = await call(false); + } if (!response.ok) { logger.warn('platform-llm: model call failed', { status: response.status }, 'PlatformLLM'); return null;