Skip to content
Open
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
77 changes: 77 additions & 0 deletions src/handlers/chat.js
Original file line number Diff line number Diff line change
Expand Up @@ -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[<id>] correlate 1:1 with the
// <traceId>/ trace dir. Falls back to a random short id when untraced.
Expand Down Expand Up @@ -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
Expand Down
32 changes: 31 additions & 1 deletion test/responses-chain-scope.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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({
Expand Down