openvibers/openvibe.ai Commits

OpenAI adapter: reasoning models get room to think; an empty answer is retried once (#525)

OP OpenVibers committed 8 h ago · b07233df11 · 2 files, +35 −2

From 2026-11-10 to 11-13 gpt-5-nano answered 'ok' with no text on most calls of background.json, background.legacy,
background.vision and default.chat (838 of 841 on background.json since 11-12 12:00): ai.summarize, Trade's market
context, Live's frame descriptions, summaries, overviews, the Rundown, News, the Blog digest and the hero slogans all
failed with output.empty. A reasoning model thinks out of the same allowance it answers from, and the allowance was the
answer plus 512 tokens. Now it is the answer plus 4096 (a cap: only used tokens are billed), and an answer that comes
back empty after thinking is tried once more with three times the room at low effort, with one log line a minute
naming the finish reason and the reasoning tokens.

Changes

ddiff --git a/server/providers/openai.js b/server/providers/openai.js
iindex 62dd653..78cd865 100644
---- a/server/providers/openai.js
++++ b/server/providers/openai.js
@@@ -28,6 +28,12 @@ const TEXT_OPS = ['chat', 'generate', 'summarize', 'classify', 'extract', 'enric
function isReasoningModel(m) { return /^(gpt-[56]|o\d)/i.test(m || ''); }
++/** The thinking room a reasoning model gets on top of the answer's own allowance. */
++let lastEmptyLog = 0;
++/** At most one line a minute about empty answers (they came by the thousand when it went wrong). */
++function emptyLog(line) { const now = Date.now(); if (now - lastEmptyLog > 60000) { lastEmptyLog = now; console.warn(line); } }
++const REASONING_HEADROOM = 4096;
++
/**
* The provider routing object of an OpenRouter offer (2026-11-10): only the fields OpenRouter documents,
* copied from the model offer's own settings. It lets an offer pin one endpoint exactly (only/order, no
@@@ -57,8 +63,11 @@ function buildBody(req, jsonMode) {
});
const body = { model, messages: msgs };
if (isReasoningModel(model)) {
-- body.max_completion_tokens = Math.max(maxTokens, 256) + 512;
-- body.reasoning_effort = /^gpt-5/i.test(model) ? 'minimal' : 'low';
++ // A reasoning model thinks out of the same allowance it answers from. 512 tokens of headroom left nothing to
++ // answer with on a longer prompt (2026-11-10 to 11-13: gpt-5-nano answered "ok" with no text on ~80% of the
++ // background routes' calls). The allowance is a cap: only the tokens used are billed.
++ body.max_completion_tokens = Math.max(maxTokens, 256) + (req.thinkRoom || REASONING_HEADROOM);
++ body.reasoning_effort = req.retryEffort || (/^gpt-5/i.test(model) ? 'minimal' : 'low');
} else {
body.max_tokens = maxTokens;
if (temperature != null) body.temperature = temperature;
@@@ -118,6 +127,13 @@ function createOpenAiProvider(record, { apiKey = '', fetchImpl = globalThis.fetc
const j = await postJson(`${baseUrl}/chat/completions`, headers(), body, { signal: req.signal, timeoutMs: req.timeoutMs, fetchImpl });
const msg = j.choices && j.choices[0] && j.choices[0].message;
const text = (msg && typeof msg.content === 'string' ? msg.content : '').trim();
++ const finish = j.choices && j.choices[0] && j.choices[0].finish_reason;
++ if (!text && isReasoningModel(req.model) && !req.thinkRoom) {
++ // Nothing to show: say why once, then give the model room to finish its thinking and answer.
++ const reasoning = (j.usage && j.usage.completion_tokens_details && j.usage.completion_tokens_details.reasoning_tokens) || 0;
++ emptyLog(`[ai] ${req.model}: an empty answer (finish ${finish || '?'}, ${reasoning} reasoning of ${(j.usage && j.usage.completion_tokens) || 0} completion tokens); trying once with more room`);
++ if (finish === 'length' || reasoning > 0) return chat({ ...req, thinkRoom: REASONING_HEADROOM * 3, retryEffort: 'low' });
++ }
return { text, json: null, model: j.model || req.model, serviceTier: j.service_tier || null, usage: usageParser.openAiCompatibleUsage(j.usage || {}) };
} catch (e) {
// Gateways without structured-output support: schema -> json_object -> prose.
ddiff --git a/test/providers.test.js b/test/providers.test.js
iindex 24d5bd7..1bdddb1 100644
---- a/test/providers.test.js
++++ b/test/providers.test.js
@@@ -100,6 +100,23 @@ t.test('OpenAI adapter steps down json_schema -> json_object on a gateway that r
await srv.close();
});
++t.test('OpenAI adapter: a reasoning model that thinks its whole allowance away gets room to answer, once', async () => {
++ // 2026-11-10 to 11-13: gpt-5-nano answered "ok" with no text on most background calls (512 tokens of headroom).
++ const bodies = [];
++ const srv = await seamServer((body) => {
++ bodies.push({ max: body.max_completion_tokens, effort: body.reasoning_effort });
++ if (bodies.length === 1) return { choices: [{ message: { content: '' }, finish_reason: 'length' }], usage: { prompt_tokens: 900, completion_tokens: body.max_completion_tokens, completion_tokens_details: { reasoning_tokens: body.max_completion_tokens } }, model: 'gpt-5-nano-2025-08-07' };
++ return { choices: [{ message: { content: '{"ok":true}' }, finish_reason: 'stop' }], usage: { prompt_tokens: 900, completion_tokens: 300, completion_tokens_details: { reasoning_tokens: 200 } }, model: 'gpt-5-nano-2025-08-07' };
++ });
++ const a = createOpenAiProvider({ key: 'shared', base_url: srv.url, capabilities: [] }, { apiKey: 'k' });
++ const r = await a.chat(req({ model: 'gpt-5-nano', maxTokens: 1700 }));
++ assert.strictEqual(r.text, '{"ok":true}', 'the second try answers');
++ assert.strictEqual(bodies.length, 2, 'one retry, no more');
++ assert.ok(bodies[0].max >= 1700 + 4096, `the first try already has room to think (${bodies[0].max})`);
++ assert.ok(bodies[1].max > bodies[0].max * 2 && bodies[1].effort === 'low', 'the retry has three times the room at low effort');
++ await srv.close();
++});
++
t.test('OpenAI adapter: auth failures and timeouts are typed errors', async () => {
const srv = await seamServer((b) => (b.messages[b.messages.length - 1].content === 'slow' ? new Promise(r => setTimeout(() => r({}), 1500)) : { status: 401, body: { error: { message: 'bad key' } } }));
const a = createOpenAiProvider({ key: 'o', base_url: srv.url, capabilities: [] }, { apiKey: 'k' });