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
57 changes: 16 additions & 41 deletions open-sse/handlers/chatCore.js
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ import { resolveStreamFlag } from "./chatCore/streamFlag.js";
import { createEmptyRetryStream } from "./chatCore/emptyStreamGuard.js";
import { detectClientTool, isNativePassthrough } from "../utils/clientDetector.js";
import { dedupeTools } from "../utils/toolDeduper.js";
import { stripOrphanedToolResults } from "../translator/concerns/toolCall.js";
import { salvageOrphanedToolResults, fixMissingToolResponses } from "../translator/concerns/toolCall.js";
import { injectCaveman } from "../rtk/caveman.js";
import { injectPonytail } from "../rtk/ponytail.js";
import { compressMessages, formatRtkLog } from "../rtk/index.js";
Expand All @@ -34,13 +34,6 @@ import { getCapabilitiesForModel } from "../providers/capabilities.js";
import { stripUnsupportedModalities } from "../translator/concerns/modality.js";
import { prefetchRemoteImages } from "../translator/concerns/prefetch.js";

const NATIVE_TOOL_RESULT_FORMATS = new Set([
FORMATS.GEMINI,
FORMATS.GEMINI_CLI,
FORMATS.ANTIGRAVITY,
FORMATS.VERTEX,
]);

function isCompactResponsesEndpoint(endpoint) {
const path = String(endpoint || "").split(/[?#]/, 1)[0].replace(/\/+$/, "");
return path.endsWith("/v1/responses/compact");
Expand Down Expand Up @@ -77,17 +70,6 @@ function stripLegacyCompactMarker(body, clientRawRequest) {
return { body: cleanBody, clientRawRequest: cleanRawRequest };
}

/**
* Gemini-family clients legitimately send functionResponse turns without the
* originating functionCall after trimming their local history. Their native
* APIs accept that history and the Gemini translators preserve the content.
* Applying the generic orphan cleaner to those wire formats silently deletes
* user-visible tool output before dispatch.
*/
export function shouldStripOrphanedToolResults(format) {
return !NATIVE_TOOL_RESULT_FORMATS.has(format);
}

/**
* Core chat handler - shared between SSE and Worker
* @param {object} options.body - Request body
Expand Down Expand Up @@ -203,14 +185,13 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred
} catch (e) { log?.warn?.("MODALITY", `image prefetch failed: ${e.message}`); }
}

// Strip orphaned tool results before translation so the translator never sees
// stale call_id references that client-side history truncation left behind.
const preStripped = shouldStripOrphanedToolResults(sourceFormat)
? stripOrphanedToolResults(body)
: 0;
if (preStripped > 0) {
log?.debug?.("TOOLCLEAN", `pre-translation: stripped ${preStripped} orphaned tool result(s)`);
}
// Salvage orphaned tool results before translation so the translator never
// sees stale call_id references that client-side history truncation left
// behind. Salvage folds orphan text into user text (non-lossy) rather than
// deleting it, preserving Kiro's orphan-salvage semantics. Runs
// unconditionally: salvage understands messages[] and contents[].
salvageOrphanedToolResults(body);
fixMissingToolResponses(body);

let translatedBody;
let toolNameMap;
Expand Down Expand Up @@ -288,15 +269,13 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred
}
} else if (headroomEnabled) log?.warn?.("HEADROOM", `skipped: ${headroomDiagnostics.reason || "compression unavailable"}${headroomDiagnostics.endpoint ? ` (${headroomDiagnostics.endpoint})` : ""}`);

// Strip orphaned tool results again after RTK/Headroom compression — both
// Re-run salvage + fixMissing after RTK/Headroom compression — both
// compressors can remove assistant turns containing tool_calls, which would
// otherwise leave dangling tool results that strict providers reject with 400.
const postStripped = shouldStripOrphanedToolResults(finalFormat)
? stripOrphanedToolResults(translatedBody)
: 0;
if (postStripped > 0) {
log?.debug?.("TOOLCLEAN", `post-compression: stripped ${postStripped} orphaned tool result(s)`);
}
// Salvage first (fold orphan text), then fixMissing (re-insert empty results
// for any call that lost its response) to restore the tool-pairing invariant.
salvageOrphanedToolResults(translatedBody);
fixMissingToolResponses(translatedBody);

// Caveman: inject terse-style system prompt
if (cavemanEnabled && cavemanLevel) {
Expand Down Expand Up @@ -327,13 +306,9 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred
try { onPxpipeEvent?.({ provider, model: cleanModel, ...pxpipeSummary }); } catch { /* stats must not break requests */ }
}

// Re-strip after PXPIPE in case compression removed assistant/tool turns.
const pxpipeStripped = shouldStripOrphanedToolResults(finalFormat)
? stripOrphanedToolResults(translatedBody)
: 0;
if (pxpipeStripped > 0) {
log?.debug?.("TOOLCLEAN", `post-pxpipe: stripped ${pxpipeStripped} orphaned tool result(s)`);
}
// Re-salvage + re-fix after PXPIPE in case compression removed assistant/tool turns.
salvageOrphanedToolResults(translatedBody);
fixMissingToolResponses(translatedBody);

const executor = getExecutor(provider);
trackPendingRequest(cleanModel, provider, connectionId, true);
Expand Down
201 changes: 201 additions & 0 deletions open-sse/translator/concerns/toolCall.js
Original file line number Diff line number Diff line change
Expand Up @@ -270,3 +270,204 @@ export function stripOrphanedToolResults(body) {

return stripped;
}

// Extract text content from a tool_result block (Claude-shaped) or tool message (OpenAI-shaped).
// Returns "" when there is no text (e.g. image-only tool_result).
function extractToolResultText(content) {
if (typeof content === "string") return content.trim();
if (!Array.isArray(content)) return "";
return content
.filter(block => block?.type === "text" && typeof block.text === "string")
.map(block => block.text)
.join("\n")
.trim();
}

// Merge consecutive same-role user messages so downstream translators that don't
// merge (notably openai-to-gemini) don't emit adjacent user turns after salvage
// (Gemini rejects consecutive user turns with 400 INVALID_ARGUMENT).
// Only merges string-content user messages; array-content messages are left as-is
// (they may carry structured blocks like tool_result that shouldn't be concatenated).
// Clone-on-merge: concatenating replaces the last entry with a new object instead
// of mutating in place, preserving the caller's original message reference.
function mergeConsecutiveUserMessages(messages) {
const merged = [];
for (const msg of messages) {
const last = merged[merged.length - 1];
if (last && last.role === "user" && msg.role === "user"
&& typeof last.content === "string" && typeof msg.content === "string") {
merged[merged.length - 1] = { ...last, content: `${last.content}\n${msg.content}` };
} else {
merged.push(msg);
}
}
return merged;
}

/**
* Salvage orphaned tool results — results that reference a tool call no longer
* present in the same request. Mirrors fixMissingToolResponses on the result
* side: that helper ensures every call has a result; this one ensures every
* result has a call.
*
* Unlike stripOrphanedToolResults (which deletes orphans), this folds the
* orphan's text content into a user-text block: `[Tool result: <text>]`.
* Non-lossy across all formats and preserves Kiro's
* reconcileOrphanedToolResults salvage semantics (orphan text is kept as user
* text rather than dropped). Image-only results (no text) are still dropped.
*
* Handles three wire envelopes:
* - OpenAI messages[] : orphaned role:"tool" -> user text
* - Anthropic messages[] : orphaned tool_result block -> text block
* - Gemini/Antigravity contents[]: orphaned functionResponse -> text part
*
* Responses API function_call_output is handled separately in
* openai-responses.js (stripOrphanedToolOutputs): Responses items have no text
* representation to salvage — they are structural call/output pairs, not
* content blocks.
*
* After salvaging messages[], consecutive same-role user messages are merged so
* downstream translators that don't merge (notably openai-to-gemini) don't emit
* adjacent user turns that trigger Gemini 400 INVALID_ARGUMENT.
*
* Fail-open: any error returns the body unchanged.
*
* @param {object} body request body (mutated in place; messages[]/contents[])
* @returns {object} the same body reference
*/
export function salvageOrphanedToolResults(body) {
if (!body || typeof body !== "object") return body;

try {
let changed = false;

// ── OpenAI/Claude messages[] ───────────────────────────────────────────
if (Array.isArray(body.messages)) {
const knownCallIds = new Set();
for (const msg of body.messages) {
// Only assistant turns carry tool calls — collecting from other roles
// causes false positives (a user message with tool_use would mask orphans).
if (msg.role !== "assistant") continue;
if (Array.isArray(msg.tool_calls)) {
for (const tc of msg.tool_calls) {
if (typeof tc?.id === "string") knownCallIds.add(tc.id);
}
}
if (Array.isArray(msg.content)) {
for (const block of msg.content) {
if (block?.type === "tool_use" && typeof block.id === "string") {
knownCallIds.add(block.id);
}
}
}
}

const salvagedMessages = [];
for (const msg of body.messages) {
// OpenAI shape: orphaned role:"tool" message -> salvage to user text
if (msg.role === "tool" && msg.tool_call_id) {
if (knownCallIds.has(msg.tool_call_id)) {
salvagedMessages.push(msg);
} else {
const text = extractToolResultText(msg.content);
if (text) salvagedMessages.push({ role: "user", content: `[Tool result: ${text}]` });
changed = true;
}
continue;
}

// Claude shape: orphaned tool_result block in user content -> salvage text
if (Array.isArray(msg.content)) {
let orphanCount = 0;
const rebuiltContent = [];
for (const block of msg.content) {
if (block?.type !== "tool_result") {
rebuiltContent.push(block);
continue;
}
if (typeof block.tool_use_id === "string" && knownCallIds.has(block.tool_use_id)) {
rebuiltContent.push(block);
continue;
}
orphanCount++;
const text = extractToolResultText(block.content);
if (text) rebuiltContent.push({ type: "text", text: `[Tool result: ${text}]` });
}
if (orphanCount > 0) {
changed = true;
if (rebuiltContent.length === 0) continue; // all blocks image-only orphans
msg.content = rebuiltContent;
}
}

salvagedMessages.push(msg);
}

if (changed) body.messages = mergeConsecutiveUserMessages(salvagedMessages);
}

// ── Gemini/Antigravity contents[] ──────────────────────────────────────
if (Array.isArray(body.contents)) {
const knownFnIds = new Set();
for (const turn of body.contents) {
// Only model turns carry functionCall — a malformed user turn with
// functionCall would otherwise mask real orphans.
if (turn.role !== "model") continue;
if (!Array.isArray(turn.parts)) continue;
for (const part of turn.parts) {
if (part?.functionCall) {
const key = part.functionCall.id ?? part.functionCall.name;
if (key) knownFnIds.add(key);
}
}
}

// Only salvage when at least one functionCall exists to match against.
// Gemini-family clients legitimately send functionResponse turns without
// the originating functionCall after trimming local history; with no call
// set, every response would look orphaned and be dropped. Preserving them
// here keeps that standalone tool output visible (matches the previous
// per-format skip for gemini/gemini-cli/antigravity/vertex).
if (knownFnIds.size > 0 && body.contents.some(t => Array.isArray(t.parts) && t.parts.some(p => p.functionResponse))) {
const salvagedContents = [];
for (const turn of body.contents) {
if (!Array.isArray(turn.parts) || !turn.parts.some(p => p.functionResponse)) {
salvagedContents.push(turn);
continue;
}
let orphanCount = 0;
const rebuiltParts = [];
for (const part of turn.parts) {
if (!part.functionResponse) {
rebuiltParts.push(part);
continue;
}
const key = part.functionResponse.id ?? part.functionResponse.name;
if (key && knownFnIds.has(key)) {
rebuiltParts.push(part);
continue;
}
orphanCount++;
const resp = part.functionResponse.response;
const raw = resp?.result ?? resp;
const text = typeof raw === "string" ? raw.trim() : (raw ? JSON.stringify(raw).trim() : "");
if (text) rebuiltParts.push({ text: `[Tool result: ${text}]` });
}
if (orphanCount > 0) {
changed = true;
// Drop the turn entirely if all parts were image-only orphans —
// Gemini rejects turns with empty parts[] (400 INVALID_ARGUMENT).
if (rebuiltParts.length === 0) continue;
turn.parts = rebuiltParts;
}
salvagedContents.push(turn);
}
if (changed) body.contents = salvagedContents;
}
}

return body;
} catch {
return body;
}
}
38 changes: 21 additions & 17 deletions open-sse/translator/index.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { FORMATS } from "./formats.js";
import { ensureToolCallIds, fixMissingToolResponses, stripOrphanedToolResults } from "./concerns/toolCall.js";
import { ensureToolCallIds, fixMissingToolResponses, salvageOrphanedToolResults } from "./concerns/toolCall.js";
import { prepareClaudeRequest } from "./formats/claude.js";
import { cloakClaudeTools } from "../utils/claudeCloaking.js";
import { filterToOpenAIFormat } from "./formats/openai.js";
Expand Down Expand Up @@ -71,22 +71,26 @@ export function translateRequest(sourceFormat, targetFormat, model, body, stream
// Fix missing tool responses (insert empty tool_result if needed)
fixMissingToolResponses(result);

// Strip orphaned tool results (tool_result with no matching tool_call).
// Skip for Kiro: its request translator salvages orphaned tool_results by
// folding their content into user text (reconcileOrphanedToolResults /
// flattenClaudeToolInteractions) rather than dropping it. Stripping here
// first would delete that content before the Kiro translator can preserve it.
// Gemini / Antigravity pre-translation cleanup is handled inside the Gemini
// -> OpenAI request translator (geminiToOpenAIRequestFixed splits
// functionResponse parts into their own contents). Running the generic
// OpenAI/Anthropic orphan stripper here would strip legitimate tool
// messages whose ids don't match a functionCall in the same body.
const skipStrip = sourceFormat === FORMATS.GEMINI
|| sourceFormat === FORMATS.GEMINI_CLI
|| sourceFormat === FORMATS.ANTIGRAVITY
|| sourceFormat === FORMATS.VERTEX;
if (targetFormat !== FORMATS.KIRO && !skipStrip) {
stripOrphanedToolResults(result);
// Salvage orphaned tool results (tool_result with no matching tool_call).
// Folds orphan content into user text (`[Tool result: ...]`) instead of
// deleting — non-lossy for the translated messages[] shape (OpenAI/Claude)
// and preserves Kiro's reconcileOrphanedToolResults salvage semantics.
//
// MUST skip the Gemini family (gemini/gemini-cli/antigravity/vertex): at this
// point the body still carries native contents[] whose functionResponse ids
// are keyed per-part, not against the global functionCall set salvage builds,
// so an unconditional run rewrites legitimate functionResponses into
// `[Tool result: ...]` text before the gemini->openai conversion can read
// them. Those formats are salvaged downstream of their own conversion if at
// all. Responses API function_call_output is structural-stripped separately
// inside openai-responses.js.
const skipSalvage =
sourceFormat === FORMATS.GEMINI ||
sourceFormat === FORMATS.GEMINI_CLI ||
sourceFormat === FORMATS.ANTIGRAVITY ||
sourceFormat === FORMATS.VERTEX;
if (!skipSalvage) {
salvageOrphanedToolResults(result);
}

// Capture thinking intent from the original (pre-translation) body, before any
Expand Down
Loading
Loading