Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion src/services/articles/ai-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,12 @@ async function postJson<T>(url: string, body: unknown): Promise<T> {
});
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;
}
Expand Down
54 changes: 36 additions & 18 deletions src/services/cat/platform-llm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand All @@ -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 {
Expand All @@ -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;
}
Expand Down Expand Up @@ -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;
Expand Down
Loading