diff --git a/src/handlers/chat.js b/src/handlers/chat.js index d446d658..fad658a4 100644 --- a/src/handlers/chat.js +++ b/src/handlers/chat.js @@ -2887,6 +2887,79 @@ export async function handleChatCompletions(body, context = {}) { return result; } +// Message-shape repair. Callers that replay a stored transcript hand us tails +// the upstream cannot answer, plus one shape it rejects outright: +// * a trailing assistant turn (the Anthropic "prefill" shape) - the upstream +// answers UPSTREAM_INTERNAL, which the pool counts as an account fault; +// * an empty-content user turn - rejected by the answerability check below; +// * a tool/function result whose tool_call_id no earlier assistant turn ever +// declared - upstream invalid_argument (502). +// None of them carries content the upstream could respond to, so the last +// answerable turn is what the request actually means. Repairing here keeps a +// stored transcript servable instead of returning a 400/502 the caller cannot +// act on, and keeps a replay loop from degrading the account pool. +// Never invents content and never drops system turns: a chain with nothing +// answerable left is returned unchanged and still gets the 400 below. +const ANSWERABLE_TAIL_ROLES = new Set(['user', 'tool', 'function']); + +function messageContentIsBlank(content) { + if (typeof content === 'string') return content.trim().length === 0; + if (!Array.isArray(content)) return content == null; + return content.every((p) => { + if (typeof p?.text === 'string') return p.text.trim().length === 0; + // Non-text parts (image_url / input_audio / file / ...) count as content, + // matching the answerability check below. + return !(p && typeof p === 'object' && p.type && p.type !== 'text'); + }); +} + +function repairUnanswerableMessages(messages, reqId = '') { + if (!Array.isArray(messages) || !messages.length) return messages; + const declared = new Set(); + const kept = []; + let orphanTool = 0; + let emptyUser = 0; + for (const m of messages) { + if (m?.role === 'assistant' && Array.isArray(m.tool_calls)) { + for (const tc of m.tool_calls) if (tc?.id) declared.add(tc.id); + } + if ((m?.role === 'tool' || m?.role === 'function') && m.tool_call_id && !declared.has(m.tool_call_id)) { + orphanTool++; + continue; + } + if (m?.role === 'user' && messageContentIsBlank(m.content)) { + emptyUser++; + continue; + } + kept.push(m); + } + // Trailing assistant turns: drop everything after the last answerable turn, + // keeping system turns in place (they hoist to field #2, so they never form + // the chat tail). An unanswerable chain is left as sent. + let lastNonSystem = -1; + for (let i = kept.length - 1; i >= 0; i--) { + if (kept[i]?.role !== 'system') { lastNonSystem = i; break; } + } + let out = kept; + let assistantTail = 0; + if (lastNonSystem >= 0 && kept[lastNonSystem].role === 'assistant') { + let lastAnswerable = -1; + for (let i = lastNonSystem; i >= 0; i--) { + const role = kept[i]?.role; + if (role !== 'system' && ANSWERABLE_TAIL_ROLES.has(role)) { lastAnswerable = i; break; } + } + if (lastAnswerable >= 0) { + out = kept.filter((m, i) => i <= lastAnswerable || m?.role === 'system'); + assistantTail = lastNonSystem - lastAnswerable; + } + } + if (orphanTool || emptyUser || assistantTail) { + log.info(`Repair[${reqId}]: orphanTool=${orphanTool} emptyUser=${emptyUser}` + + ` assistantTail=${assistantTail} turns=${messages.length}->${out.length}`); + } + return out; +} + async function _handleChatCompletionsInner(body, context = {}) { // Reuse the trace id as reqId so log lines Chat[] correlate 1:1 with the // / trace dir. Falls back to a random short id when untraced. @@ -2959,6 +3032,10 @@ async function _handleChatCompletionsInner(body, context = {}) { } } catch {} + // Repair the transcript shape before the answerability check below and the + // wire encoder: see repairUnanswerableMessages for the three tails it absorbs. + messages = repairUnanswerableMessages(messages, reqId); + // Reject pathologically empty user turns. Without this, an empty // `user.content` slips through and the model answers against the // system prompt as if it were the user's prompt, producing nonsense diff --git a/test/responses-chain-scope.test.js b/test/responses-chain-scope.test.js index 161e574c..a5a9de63 100644 --- a/test/responses-chain-scope.test.js +++ b/test/responses-chain-scope.test.js @@ -116,11 +116,15 @@ describe('an unanswerable conversation is rejected locally, never forwarded', () // Verified against the real upstream that an assistant-terminated conversation // (the Anthropic prefill shape) is NOT supported there either, so turning it into // a 400 removes an opaque 503 plus an account penalty without losing anything. + // The repairable tails (trailing assistant turn, empty user turn, orphan tool + // result) are normalized now instead: repairUnanswerableMessages in + // src/handlers/chat.js trims back to the last answerable turn, so the upstream + // never sees the shape it cannot serve. What still lands here is a chain with + // nothing answerable left at all. const unanswerable = [ ['no messages at all', []], ['only a system message', [{ role: 'system', content: 'You are X.' }]], ['only an assistant message', [{ role: 'assistant', content: 'hi' }]], - ['ending on an assistant turn', [{ role: 'user', content: 'a' }, { role: 'assistant', content: 'b' }]], ]; for (const [label, messages] of unanswerable) { @@ -135,6 +139,32 @@ describe('an unanswerable conversation is rejected locally, never forwarded', () }); } + // Tails the upstream cannot answer but that ARE repairable: the tail is trimmed + // back to the last answerable turn (or the orphan result dropped), so these must + // be answered instead of rejected. + const repairable = [ + ['ending on an assistant turn', [{ role: 'user', content: 'a' }, { role: 'assistant', content: 'b' }]], + ['ending on an empty user turn', [ + { role: 'user', content: 'a' }, + { role: 'assistant', content: 'b' }, + { role: 'user', content: '' }, + ]], + ['carrying an orphan tool result', [ + { role: 'user', content: 'a' }, + { role: 'tool', tool_call_id: 'never-declared', content: 'stale' }, + ]], + ]; + + for (const [label, messages] of repairable) { + it(`repairs a conversation ${label} instead of rejecting it`, async () => { + const res = await handleChatCompletions( + { model: 'claude-sonnet-4.6', max_tokens: 8, messages }, + { callerKey: 'api:x:user:u' }, + ); + assert.notEqual(res.status, 400, `${label} must be repaired, not rejected`); + }); + } + it('accepts a conversation ending on a tool result (the agent-loop shape)', async () => { // This must NOT be rejected — every tool round-trip ends here. const res = await handleChatCompletions({