mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-09-20 13:52:28 +03:00
fix(sse): fix double-escaped tabs in Codex JSON tool call arguments (#12841)
Fixes #12831. When the Codex upstream model produces string values in tool arguments that contain double-escaped tabs (\t inside the JSON string instead of \t), the parser outputs literal backslash-t characters. This breaks editor patches that rely on proper indentation. This commit adds a fixDoubleEscapedTabs sanitization step before parsing to restore them to single tabs. Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
This commit is contained in:
@@ -0,0 +1 @@
|
||||
- **fix(sse):** stop over-escaped tabs from `gpt-5.6-luna-xhigh` corrupting Codex tool-call arguments — `\\t` is now collapsed back to a real tab instead of a literal `\t` text ([#12841](https://github.com/diegosouzapw/OmniRoute/pull/12841)) — thanks @rafacpti23
|
||||
@@ -114,6 +114,49 @@ function escapeJsonStringValues(json: string, escapeState: JsonStringEscapeState
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Collapse double-escaped tab sequences inside JSON string values.
|
||||
* Some providers (e.g. gpt-5.6-luna-xhigh, #12831) over-escape a tab when
|
||||
* emitting tool call argument JSON: instead of the single valid JSON escape
|
||||
* `\t` (backslash + t), they emit `\\t` (backslash + backslash + t) inside
|
||||
* the string value. JSON.parse then decodes that to a literal two-character
|
||||
* `\t` text (backslash followed by the letter t) instead of an actual tab
|
||||
* character, which breaks consumers (e.g. editor patches) expecting real
|
||||
* tabs. This only rewrites the over-escaped form and leaves an
|
||||
* already-correct single escape untouched.
|
||||
*/
|
||||
function fixDoubleEscapedTabs(json: string): string {
|
||||
let result = "";
|
||||
let inString = false;
|
||||
|
||||
for (let i = 0; i < json.length; i++) {
|
||||
const ch = json[i];
|
||||
|
||||
if (inString && ch === "\\" && json[i + 1] === "\\" && json[i + 2] === "t") {
|
||||
result += "\\t";
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Inside a string, leave any other escape sequence untouched.
|
||||
if (inString && ch === "\\") {
|
||||
result += ch + (json[i + 1] ?? "");
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ch === '"') {
|
||||
result += ch;
|
||||
inString = !inString;
|
||||
continue;
|
||||
}
|
||||
|
||||
result += ch;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Translate OpenAI chunk to Responses API events
|
||||
* @returns {Array} Array of events with { event, data } structure
|
||||
@@ -589,7 +632,7 @@ function emitToolCall(state, emit, tc) {
|
||||
state.funcArgsEscapeState[tcIdx] = createJsonStringEscapeState();
|
||||
}
|
||||
const sanitized = escapeJsonStringValues(
|
||||
tc.function.arguments,
|
||||
fixDoubleEscapedTabs(tc.function.arguments),
|
||||
state.funcArgsEscapeState[tcIdx]
|
||||
);
|
||||
const nextArgs = appendToolCallArgumentDelta(existingArgs, sanitized);
|
||||
|
||||
90
tests/unit/bug-12831.test.ts
Normal file
90
tests/unit/bug-12831.test.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { openaiToOpenAIResponsesResponse } from "../../open-sse/translator/response/openai-responses.ts";
|
||||
|
||||
test("Issue #12831: fixes double-escaped tabs in Codex JSON tool call arguments", () => {
|
||||
const events = [];
|
||||
const emit = (_name, payload) => events.push(payload);
|
||||
const state = {
|
||||
responseId: "res_123",
|
||||
funcCallIds: {},
|
||||
funcNames: {},
|
||||
funcArgsBuf: {},
|
||||
funcArgsDone: {},
|
||||
funcItemAdded: {},
|
||||
funcItemDone: {},
|
||||
msgItemAdded: {},
|
||||
msgContentAdded: {},
|
||||
msgTextBuf: {},
|
||||
msgItemDone: {},
|
||||
};
|
||||
|
||||
const chunk1 = {
|
||||
choices: [
|
||||
{
|
||||
index: 0,
|
||||
delta: {
|
||||
tool_calls: [
|
||||
{
|
||||
index: 0,
|
||||
id: "call_123",
|
||||
function: {
|
||||
name: "_edit",
|
||||
// gpt-5.6-luna-xhigh emits literally \ followed by t in the JSON string
|
||||
// to represent a tab, instead of a JSON escape for tab or a raw tab.
|
||||
// Wait, in JSON, a tab in a string is encoded as "\t" (two characters: \ and t).
|
||||
// If it's double-escaped, it emits "\t" (four characters: \, \, t in JSON string? No, two backslashes and a t: "\t")
|
||||
// Let's assume the string is: {"input": "some code\twith tabs"}
|
||||
arguments: '{\n "input": "some code\\twith tabs"',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const chunk2 = {
|
||||
choices: [
|
||||
{
|
||||
index: 0,
|
||||
delta: {
|
||||
tool_calls: [
|
||||
{
|
||||
index: 0,
|
||||
function: {
|
||||
arguments: "\n}",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
finish_reason: "tool_calls",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const chunk3 = {
|
||||
usage: { prompt_tokens: 10, completion_tokens: 10 },
|
||||
};
|
||||
|
||||
function processChunk(chunk) {
|
||||
const chunkEvents = openaiToOpenAIResponsesResponse(chunk, state);
|
||||
for (const ev of chunkEvents) {
|
||||
emit(ev.event, ev.data);
|
||||
}
|
||||
}
|
||||
|
||||
processChunk(chunk1);
|
||||
processChunk(chunk2);
|
||||
processChunk(chunk3);
|
||||
|
||||
const doneEvent = events.find((e) => e.type === "response.function_call_arguments.done");
|
||||
|
||||
// Try parsing the arguments
|
||||
const parsed = JSON.parse(doneEvent.arguments);
|
||||
assert.strictEqual(
|
||||
parsed.input,
|
||||
"some code\twith tabs",
|
||||
"The double-escaped tab should be unescaped to a single tab character"
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user