Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
2 changes: 1 addition & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ _TBD_

### 🔧 Bug Fixes

_TBD_
- **fix(sse):** strip ANSI/VT100 escape codes from gemini-cli stream frames so ANSI-prefixed `data:` lines are no longer silently dropped. (thanks @anki1kr)

### 📝 Maintenance

Expand Down
15 changes: 10 additions & 5 deletions open-sse/translator/response/gemini-to-openai.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
containsTextualToolCallMarker,
} from "../../utils/textualToolCall.ts";
import { normalizeOpenAICompatibleFinishReasonString } from "../../utils/finishReason.ts";
import { stripAnsiCodes } from "../../utils/streamHelpers.ts";

type GeminiToOpenAIState = {
functionIndex: number;
Expand Down Expand Up @@ -401,6 +402,10 @@ export function geminiToOpenAIResponse(chunk, state) {
// Process parts
if (content?.parts) {
for (const part of content.parts) {
// Normalize the part text once: strip ANSI/VT100 escape codes that some
// upstreams (gemini-cli terminal redraws) inject, so the `<thinking>` /
// `[Tool call:]` textual parsers below never see stray control bytes (#2273).
const partText = stripAnsiCodes(part.text);
const hasThoughtSig = part.thoughtSignature || part.thought_signature;
const isThought = part.thought === true;
if (hasThoughtSig && typeof hasThoughtSig === "string") {
Expand All @@ -409,7 +414,7 @@ export function geminiToOpenAIResponse(chunk, state) {

// Handle thought signature (thinking mode) or native gemini thought flag
if (hasThoughtSig || isThought) {
const hasTextContent = part.text !== undefined && part.text !== "";
const hasTextContent = partText !== undefined && partText !== "";
const hasFunctionCall = !!part.functionCall;

// Gemini/Antigravity can emit thoughtSignature as a standalone part
Expand All @@ -433,7 +438,7 @@ export function geminiToOpenAIResponse(chunk, state) {
choices: [
{
index: 0,
delta: isThought ? { reasoning_content: part.text } : { content: part.text },
delta: isThought ? { reasoning_content: partText } : { content: partText },
finish_reason: null,
},
],
Expand Down Expand Up @@ -463,10 +468,10 @@ export function geminiToOpenAIResponse(chunk, state) {
// "[Tool call: ...]" block instead of native functionCall. Convert that
// back to a structured OpenAI tool call so clients/tools do not see it as
// assistant prose.
if (part.text !== undefined && part.text !== "") {
if (partText !== undefined && partText !== "") {
const afterReasoning = parseTextualReasoningTags
? consumeTextualReasoningTags(part.text, state, results)
: part.text;
? consumeTextualReasoningTags(partText, state, results)
: partText;
if (!afterReasoning) continue;

let accumulated = (state.textualToolCallBuffer || "") + afterReasoning;
Expand Down
36 changes: 32 additions & 4 deletions open-sse/utils/streamHelpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,31 @@ function isRecord(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === "object" && !Array.isArray(value);
}

/**
* Matches ANSI/VT100 terminal control sequences plus non-whitespace C0 control
* codes, while preserving `\t` (0x09), `\n` (0x0a), and `\r` (0x0d).
*
* Some upstream CLIs (notably gemini-cli via the `gc/` bridge) prefix SSE frames
* with cursor-movement escapes such as `\x1b[2K\x1b[1A` to redraw the terminal.
* Those bytes are not whitespace, so `line.trimStart().startsWith("data:")` fails
* and the frame is silently dropped, stalling the client SSE parser (issue #2273).
*
* The pattern is strictly bounded (no unbounded quantifiers over overlapping
* alternatives) so it runs in linear time on untrusted input — ReDoS-safe.
*/
// eslint-disable-next-line no-control-regex
const ANSI_ESCAPE_RE =
/\x1b(?:\[[0-9;?]*[A-Za-z]|\][^\x07\x1b]*(?:\x07|\x1b\\)|[A-Z\[\]\\^_`])|[\x00-\x08\x0b\x0c\x0e-\x1f]/g;

/**
* Strip ANSI/VT100 escape sequences (and stray C0 controls) from a string.
* Non-string inputs (null/undefined) are returned unchanged. Preserves \t \n \r.
*/
export function stripAnsiCodes<T>(str: T): T {
if (typeof str !== "string") return str;
return str.replace(ANSI_ESCAPE_RE, "") as T;
}

export function parseSSEDataPayload(
data: unknown,
options: SSEPayloadOptions = {}
Expand Down Expand Up @@ -88,15 +113,18 @@ export function parseSSEDataLines(
export function parseSSELine(line: string): SSEJsonPayload | null {
if (!line) return null;

// Trim leading whitespace before checking field name.
// Trim leading whitespace before checking field name. Also strip ANSI/VT100
// escape codes so terminal-redraw-prefixed frames (e.g. gemini-cli `\x1b[2K\x1b[1A`)
// still resolve to a `data:` line instead of being silently dropped (#2273).
const trimmed = line.trimStart();
if (!trimmed.startsWith("data:")) return null;
const clean = stripAnsiCodes(trimmed);
if (!clean.startsWith("data:")) return null;

return parseSSEDataPayload(trimmed.slice(5));
return parseSSEDataPayload(clean.slice(5));
}

function extractSseDataLine(line: string): string | null {
const trimmed = line.trimStart().replace(/\r$/, "");
const trimmed = stripAnsiCodes(line.trimStart().replace(/\r$/, ""));
if (!trimmed.startsWith("data:")) return null;
return trimmed.slice(5).trimStart();
}
Expand Down
46 changes: 46 additions & 0 deletions tests/unit/gemini-cli-ansi-sanitization.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { test } from "node:test";
import assert from "node:assert/strict";

import { parseSSELine, stripAnsiCodes } from "../../open-sse/utils/streamHelpers.ts";

test("parseSSELine resolves an ANSI/VT100-prefixed data: frame (gemini-cli redraw)", () => {
// gemini-cli prefixes SSE frames with cursor-redraw escapes (\x1b[2K clears the
// line, \x1b[1A moves the cursor up). 0x1b is not whitespace, so before the fix
// trimStart().startsWith("data:") failed and the frame was silently dropped (#2273).
const line = `\x1b[2K\x1b[1Adata: ${JSON.stringify({
choices: [{ delta: { content: "hi" } }],
})}`;
const r = parseSSELine(line);
assert.ok(r, "expected a parsed payload, got null (frame was dropped)");
assert.equal(r?.choices?.[0]?.delta?.content, "hi");
});

test("parseSSELine returns null for a pure-ANSI line (nothing after stripping)", () => {
assert.equal(parseSSELine("\x1b[2K\x1b[1A"), null);
});

test("stripAnsiCodes strips CSI/SGR/OSC/C0 but preserves \\t \\n \\r", () => {
// CSI cursor moves + SGR color codes
assert.equal(stripAnsiCodes("\x1b[2K\x1b[1Ahello"), "hello");
assert.equal(stripAnsiCodes("\x1b[31mred\x1b[0m"), "red");
// OSC sequence terminated by BEL (\x07)
assert.equal(stripAnsiCodes("\x1b]0;title\x07text"), "text");
// OSC sequence terminated by ST (\x1b\\)
assert.equal(stripAnsiCodes("\x1b]8;;https://x\x1b\\link"), "link");
// stray C0 control byte
assert.equal(stripAnsiCodes("a\x00b"), "ab");
// whitespace preserved
assert.equal(stripAnsiCodes("a\tb\nc\rd"), "a\tb\nc\rd");
});

test("stripAnsiCodes passes null/undefined through unchanged", () => {
assert.equal(stripAnsiCodes(null), null);
assert.equal(stripAnsiCodes(undefined), undefined);
});

test("stripAnsiCodes runs in linear time on adversarial input (ReDoS guard)", () => {
const hostile = "\x1b[" + "0;".repeat(50000) + "m";
const start = Date.now();
stripAnsiCodes(hostile);
assert.ok(Date.now() - start < 1000, "stripAnsiCodes should not backtrack catastrophically");
});
Loading