openvibers/openvibe.ai Commits
OpenAI adapter: reasoning models get room to think; an empty answer is retried once (#525)
Changes
@@@ -28,6 +28,12 @@ const TEXT_OPS = ['chat', 'generate', 'summarize', 'classify', 'extract', 'enricfunction 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.fetcconst 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.@@@ -100,6 +100,23 @@ t.test('OpenAI adapter steps down json_schema -> json_object on a gateway that rawait 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' });
@@ -28,6 +28,12 @@ const TEXT_OPS = ['chat', 'generate', 'summarize', 'classify', 'extract', 'enric
@@ -28,6 +28,12 @@ const TEXT_OPS = ['chat', 'generate', 'summarize', 'classify', 'extract', 'enric
function isReasoningModel(m) { return /^(gpt-[56]|o\d)/i.test(m || ''); }
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,
* 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
* 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) {
@@ -57,8 +63,11 @@ function buildBody(req, jsonMode) {
});
});
const body = { model, messages: msgs };
const body = { model, messages: msgs };
if (isReasoningModel(model)) {
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 {
} else {
body.max_tokens = maxTokens;
body.max_tokens = maxTokens;
if (temperature != null) body.temperature = temperature;
if (temperature != null) body.temperature = temperature;
@@ -118,6 +127,13 @@ function createOpenAiProvider(record, { apiKey = '', fetchImpl = globalThis.fetc
@@ -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 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 msg = j.choices && j.choices[0] && j.choices[0].message;
const text = (msg && typeof msg.content === 'string' ? msg.content : '').trim();
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 || {}) };
return { text, json: null, model: j.model || req.model, serviceTier: j.service_tier || null, usage: usageParser.openAiCompatibleUsage(j.usage || {}) };
} catch (e) {
} catch (e) {
// Gateways without structured-output support: schema -> json_object -> prose.
// Gateways without structured-output support: schema -> json_object -> prose.
@@ -100,6 +100,23 @@ t.test('OpenAI adapter steps down json_schema -> json_object on a gateway that r
@@ -100,6 +100,23 @@ t.test('OpenAI adapter steps down json_schema -> json_object on a gateway that r
await srv.close();
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 () => {
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 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' });
const a = createOpenAiProvider({ key: 'o', base_url: srv.url, capabilities: [] }, { apiKey: 'k' });