@oh-my-pi/pi-ai 16.1.9 → 16.1.11
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +29 -0
- package/dist/types/utils/json-parse.d.ts +16 -5
- package/package.json +4 -5
- package/src/auth-gateway/server.ts +6 -4
- package/src/providers/cursor.ts +5 -8
- package/src/providers/google-gemini-cli.ts +237 -12
- package/src/providers/openai-codex-responses.ts +66 -1
- package/src/providers/transform-messages.ts +247 -186
- package/src/rate-limit-utils.ts +8 -2
- package/src/utils/json-parse.ts +397 -21
- package/src/utils.ts +19 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,35 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [16.1.11] - 2026-06-21
|
|
6
|
+
|
|
7
|
+
### Fixed
|
|
8
|
+
|
|
9
|
+
- Fixed OpenAI Responses native history replay leaking image generation provider-only fields into the next request, which made OpenAI-compatible proxies reject `pi` tool-calling sessions with `Unknown parameter: input[1].action`. ([#3201](https://github.com/can1357/oh-my-pi/issues/3201))
|
|
10
|
+
- Fixed a stream thought-leakage issue for `gemini-3.5-flash` where the model's internal reasoning JSON could leak into the visible text stream. The stream parser now uses a brace-balanced counting algorithm to accurately slice and discard the leading thought JSON block, with a robust fallback for unescaped double quotes, dynamic tool-name derivation, and preservation of subsequent text deltas without triggering empty-response retries.
|
|
11
|
+
|
|
12
|
+
## [16.1.10] - 2026-06-21
|
|
13
|
+
|
|
14
|
+
### Changed
|
|
15
|
+
|
|
16
|
+
- Improved JSON robustness by replacing external dependency with a custom, high-performance parser
|
|
17
|
+
- Strengthened streaming JSON parsing to prevent non-finite numbers from surfacing as `undefined/NaN`
|
|
18
|
+
- Configured JSON parser to reject JS-specific `NaN` and `Infinity` values for tool arguments
|
|
19
|
+
- Replaced the JSON repair/parse helpers (`parseJsonWithRepair`, `parseStreamingJson`) with a single from-scratch tolerant parser (`RelaxedJson`) that accepts single-quoted strings, unquoted object keys, trailing/stray commas, `//` and `/* */` comments, Python `True`/`False`/`None`, raw control characters, invalid escapes, and unescaped apostrophes (`'it's'`). Final parsing still throws on truncated/garbage input (so a malformed tool call is skipped rather than executed with half-formed args) and rejects JS-only `NaN`/`Infinity`; streaming parsing stays non-throwing and rolls back incomplete trailing tokens instead of surfacing `undefined`/`NaN`. The Cursor provider's ad-hoc regex + JSON5 tool-argument parser now routes through the shared parser.
|
|
20
|
+
|
|
21
|
+
### Fixed
|
|
22
|
+
|
|
23
|
+
- Fixed tool call ID normalization for Anthropic-compatible models
|
|
24
|
+
- Fixed Anthropic Messages replay sanitizing malformed tool-call IDs, including aborted native tool calls with empty IDs, so retries no longer send invalid `tool_use.id` / `tool_result.tool_use_id` pairs.
|
|
25
|
+
- Fixed the Codex Responses WebSocket transport attributing a prior turn's output to the current one on a reused connection: a trailing/duplicate frame from a cleanly-completed previous response that slipped past the queue drain could be consumed as this request's terminal (ending the turn with empty output) or as a stale tool call. Frames are now keyed by `response.id` — a frame carrying the previous response's id is dropped, and one carrying a third id (or a regressed `sequence_number`) fails closed so the turn retries instead of mixing two responses' streams. Idless frames (deltas, the rate-limit/metadata preamble, `response.created`-less streams) still pass through, matching upstream codex-rs.
|
|
26
|
+
- Fixed `transformMessages` pulling an earlier, orphaned tool result onto a later tool call that reused the same id (left behind when compaction folded the originating `tool_use` into a summary). The pending-call flush now pairs each call with a result positioned *after* its assistant turn, so a reused id surfaces its own output rather than a prior turn's.
|
|
27
|
+
- Fixed DashScope 429 rate-limit messages that mention authorization being classified as credential failures, preventing valid API keys from being invalidated after throttling. ([#3172](https://github.com/can1357/oh-my-pi/issues/3172))
|
|
28
|
+
- Fixed OpenCode Go `401 Insufficient balance` quota errors being treated as unknown failures instead of usage-limit errors, restoring credential rotation and fallback chains. ([#3169](https://github.com/can1357/oh-my-pi/issues/3169))
|
|
29
|
+
|
|
30
|
+
### Removed
|
|
31
|
+
|
|
32
|
+
- Removed the `partial-json` dependency; streaming JSON parsing now uses the in-house `RelaxedJson` parser.
|
|
33
|
+
|
|
5
34
|
## [16.1.9] - 2026-06-21
|
|
6
35
|
|
|
7
36
|
### Added
|
|
@@ -1,11 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lightweight string-level repair of the escape/control-char hazards that make
|
|
3
|
+
* otherwise-valid JSON fail `JSON.parse`: raw control characters inside strings
|
|
4
|
+
* are escaped, and invalid `\x` escapes have their backslash escaped. Returns the
|
|
5
|
+
* input unchanged when no repair is needed. Pure string→string; does not parse.
|
|
6
|
+
*/
|
|
1
7
|
export declare function repairJson(json: string): string;
|
|
8
|
+
/**
|
|
9
|
+
* Final-parse a JSON value, repairing the common LLM malformations
|
|
10
|
+
* ({@link RelaxedJson}). Tries strict `JSON.parse` first (fast path, exact JSON
|
|
11
|
+
* semantics), then the relaxed parser. Throws when the input is unrepairable,
|
|
12
|
+
* truncated, or carries trailing garbage — so callers can skip a bad tool call
|
|
13
|
+
* rather than execute a half-formed one.
|
|
14
|
+
*/
|
|
2
15
|
export declare function parseJsonWithRepair<T>(json: string): T;
|
|
3
16
|
/**
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
* @param partialJson The partial JSON string from streaming
|
|
8
|
-
* @returns Parsed object or empty object if parsing fails
|
|
17
|
+
* Parse possibly-incomplete JSON during streaming. Always returns a value, never
|
|
18
|
+
* throws: `{}` for empty/whitespace/unrecoverable buffers, and an auto-closed
|
|
19
|
+
* best-effort object for truncated ones.
|
|
9
20
|
*/
|
|
10
21
|
export declare function parseStreamingJson<T = Record<string, unknown>>(partialJson: string | undefined): T;
|
|
11
22
|
/**
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"type": "module",
|
|
3
3
|
"name": "@oh-my-pi/pi-ai",
|
|
4
|
-
"version": "16.1.
|
|
4
|
+
"version": "16.1.11",
|
|
5
5
|
"description": "Unified LLM API with automatic model discovery and provider configuration",
|
|
6
6
|
"homepage": "https://omp.sh",
|
|
7
7
|
"author": "Can Boluk",
|
|
@@ -38,11 +38,10 @@
|
|
|
38
38
|
},
|
|
39
39
|
"dependencies": {
|
|
40
40
|
"@bufbuild/protobuf": "^2.12.0",
|
|
41
|
-
"@oh-my-pi/pi-catalog": "16.1.
|
|
42
|
-
"@oh-my-pi/pi-utils": "16.1.
|
|
43
|
-
"@oh-my-pi/pi-wire": "16.1.
|
|
41
|
+
"@oh-my-pi/pi-catalog": "16.1.11",
|
|
42
|
+
"@oh-my-pi/pi-utils": "16.1.11",
|
|
43
|
+
"@oh-my-pi/pi-wire": "16.1.11",
|
|
44
44
|
"arktype": "^2.2.0",
|
|
45
|
-
"partial-json": "^0.1.7",
|
|
46
45
|
"zod": "^4"
|
|
47
46
|
},
|
|
48
47
|
"devDependencies": {
|
|
@@ -229,11 +229,10 @@ export function classifyGatewayError(err: unknown): { status: number; type: stri
|
|
|
229
229
|
if (/\baborted\b|\babort signal\b/i.test(message)) {
|
|
230
230
|
return { status: 499, type: "request_aborted", message };
|
|
231
231
|
}
|
|
232
|
-
if (/\b(?:unauthorized|forbidden)\b/i.test(message)) {
|
|
233
|
-
return { status: 401, type: "authentication_error", message };
|
|
234
|
-
}
|
|
235
232
|
if (
|
|
236
|
-
// Match rate-limit phrasings
|
|
233
|
+
// Match rate-limit phrasings before auth wording: some providers
|
|
234
|
+
// describe throttling as "unauthorized due to rate limit".
|
|
235
|
+
// Keep boundaries so this does not collide with
|
|
237
236
|
// `GenerateContentRequest`, `accelerate`, `iterate`, `deprecated`, etc.
|
|
238
237
|
/\brate[- _]?limit(?:s|ed|ing)?\b|\bquota(?:_exceeded| exceeded)?\b|\btoo[- _]many[- _]requests\b/i.test(
|
|
239
238
|
message,
|
|
@@ -249,6 +248,9 @@ export function classifyGatewayError(err: unknown): { status: number; type: stri
|
|
|
249
248
|
) {
|
|
250
249
|
return { status: 429, type: "rate_limit_error", message };
|
|
251
250
|
}
|
|
251
|
+
if (/\b(?:unauthorized|forbidden)\b/i.test(message)) {
|
|
252
|
+
return { status: 401, type: "authentication_error", message };
|
|
253
|
+
}
|
|
252
254
|
if (/\b(?:unsupported|invalid_request|invalid request|bad request|malformed)\b/i.test(message)) {
|
|
253
255
|
return { status: 400, type: "invalid_request_error", message };
|
|
254
256
|
}
|
package/src/providers/cursor.ts
CHANGED
|
@@ -125,7 +125,7 @@ import type {
|
|
|
125
125
|
} from "../types";
|
|
126
126
|
import { normalizeSystemPrompts } from "../utils";
|
|
127
127
|
import { AssistantMessageEventStream } from "../utils/event-stream";
|
|
128
|
-
import { parseStreamingJson } from "../utils/json-parse";
|
|
128
|
+
import { parseJsonWithRepair, parseStreamingJson } from "../utils/json-parse";
|
|
129
129
|
import { createRequestDebugSession, isRequestDebugEnabled, type RequestDebugResponseLog } from "../utils/request-debug";
|
|
130
130
|
import { formatErrorMessageWithRetryAfter } from "../utils/retry-after";
|
|
131
131
|
import { toolWireSchema } from "../utils/schema/wire";
|
|
@@ -1781,13 +1781,10 @@ function parseToolArgsJson(text: string): unknown {
|
|
|
1781
1781
|
return text;
|
|
1782
1782
|
}
|
|
1783
1783
|
try {
|
|
1784
|
-
|
|
1785
|
-
|
|
1786
|
-
|
|
1787
|
-
|
|
1788
|
-
return Bun.JSON5.parse(normalized);
|
|
1789
|
-
} catch {}
|
|
1790
|
-
return text;
|
|
1784
|
+
return parseJsonWithRepair<unknown>(trimmed);
|
|
1785
|
+
} catch {
|
|
1786
|
+
return text;
|
|
1787
|
+
}
|
|
1791
1788
|
}
|
|
1792
1789
|
|
|
1793
1790
|
function decodeMcpArgValue(value: Uint8Array): unknown {
|
|
@@ -64,6 +64,181 @@ export class GeminiCliApiError extends ProviderHttpError {
|
|
|
64
64
|
override readonly name = "GeminiCliApiError";
|
|
65
65
|
}
|
|
66
66
|
|
|
67
|
+
function isPlanningLeakPrefix(text: string): boolean {
|
|
68
|
+
const trimmed = text.trimStart();
|
|
69
|
+
if (!trimmed.startsWith("{")) {
|
|
70
|
+
return false;
|
|
71
|
+
}
|
|
72
|
+
const afterBrace = trimmed.slice(1).trimStart();
|
|
73
|
+
if (afterBrace === "") {
|
|
74
|
+
return trimmed.length <= 100;
|
|
75
|
+
}
|
|
76
|
+
if (afterBrace[0] !== '"') {
|
|
77
|
+
return false;
|
|
78
|
+
}
|
|
79
|
+
const nextQuoteIndex = afterBrace.indexOf('"', 1);
|
|
80
|
+
if (nextQuoteIndex === -1) {
|
|
81
|
+
const keyPrefix = afterBrace.slice(1);
|
|
82
|
+
return "thought".startsWith(keyPrefix) && trimmed.length <= 100;
|
|
83
|
+
}
|
|
84
|
+
const key = afterBrace.slice(1, nextQuoteIndex);
|
|
85
|
+
if (key !== "thought") {
|
|
86
|
+
return false;
|
|
87
|
+
}
|
|
88
|
+
const afterKey = afterBrace.slice(nextQuoteIndex + 1).trimStart();
|
|
89
|
+
if (afterKey === "") {
|
|
90
|
+
return trimmed.length <= 100;
|
|
91
|
+
}
|
|
92
|
+
if (afterKey[0] !== ":") {
|
|
93
|
+
return false;
|
|
94
|
+
}
|
|
95
|
+
return true;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
type BufferedPlanningResult =
|
|
99
|
+
| { kind: "incomplete" }
|
|
100
|
+
| { kind: "plain"; visibleText: string }
|
|
101
|
+
| { kind: "leak"; visibleText: string };
|
|
102
|
+
|
|
103
|
+
function isPlanningLeakObject(parsed: unknown, toolNames: Set<string>): boolean {
|
|
104
|
+
if (!parsed || typeof parsed !== "object") return false;
|
|
105
|
+
const record = parsed as Record<string, unknown>;
|
|
106
|
+
const hasThought = typeof record.thought === "string";
|
|
107
|
+
const isOmpTool = typeof record.call === "string" && toolNames.has(record.call);
|
|
108
|
+
const hasToolSignature =
|
|
109
|
+
"_i" in record || "paths" in record || "command" in record || ("path" in record && "content" in record);
|
|
110
|
+
return hasThought || isOmpTool || hasToolSignature;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function splitLeadingJsonObject(text: string): { prefixLength: number; jsonText: string; rest: string } | undefined {
|
|
114
|
+
const prefixLength = text.length - text.trimStart().length;
|
|
115
|
+
const trimmed = text.slice(prefixLength);
|
|
116
|
+
if (!trimmed.startsWith("{")) return undefined;
|
|
117
|
+
|
|
118
|
+
let depth = 0;
|
|
119
|
+
let inString = false;
|
|
120
|
+
let escaped = false;
|
|
121
|
+
|
|
122
|
+
for (let index = 0; index < trimmed.length; index += 1) {
|
|
123
|
+
const ch = trimmed[index];
|
|
124
|
+
if (inString) {
|
|
125
|
+
if (escaped) {
|
|
126
|
+
escaped = false;
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
if (ch === "\\") {
|
|
130
|
+
escaped = true;
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
if (ch === '"') inString = false;
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
if (ch === '"') {
|
|
137
|
+
inString = true;
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
140
|
+
if (ch === "{") {
|
|
141
|
+
depth += 1;
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
if (ch !== "}") continue;
|
|
145
|
+
depth -= 1;
|
|
146
|
+
if (depth !== 0) continue;
|
|
147
|
+
|
|
148
|
+
const jsonText = trimmed.slice(0, index + 1);
|
|
149
|
+
return {
|
|
150
|
+
prefixLength: prefixLength + index + 1,
|
|
151
|
+
jsonText,
|
|
152
|
+
rest: trimmed.slice(index + 1),
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
return undefined;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function splitLeadingJsonObjectIgnoringQuotes(
|
|
160
|
+
text: string,
|
|
161
|
+
): { prefixLength: number; jsonText: string; rest: string } | undefined {
|
|
162
|
+
const prefixLength = text.length - text.trimStart().length;
|
|
163
|
+
const trimmed = text.slice(prefixLength);
|
|
164
|
+
if (!trimmed.startsWith("{")) return undefined;
|
|
165
|
+
|
|
166
|
+
let depth = 0;
|
|
167
|
+
for (let index = 0; index < trimmed.length; index += 1) {
|
|
168
|
+
const ch = trimmed[index];
|
|
169
|
+
if (ch === "{") {
|
|
170
|
+
depth += 1;
|
|
171
|
+
} else if (ch === "}") {
|
|
172
|
+
depth -= 1;
|
|
173
|
+
if (depth === 0) {
|
|
174
|
+
return {
|
|
175
|
+
prefixLength: prefixLength + index + 1,
|
|
176
|
+
jsonText: trimmed.slice(0, index + 1),
|
|
177
|
+
rest: trimmed.slice(index + 1),
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
return undefined;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function consumePlanningBuffer(text: string, toolNames: Set<string>, isFinal = false): BufferedPlanningResult {
|
|
186
|
+
if (!isPlanningLeakPrefix(text)) {
|
|
187
|
+
return { kind: "plain", visibleText: text };
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// Try standard brace-balanced slicing first (respecting quotes and escapes)
|
|
191
|
+
let leading = splitLeadingJsonObject(text);
|
|
192
|
+
|
|
193
|
+
// If standard parsing fails (e.g. due to unescaped quotes), fall back to quote-ignoring brace-balanced slicing
|
|
194
|
+
if (!leading) {
|
|
195
|
+
leading = splitLeadingJsonObjectIgnoringQuotes(text);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
if (!leading) {
|
|
199
|
+
if (isFinal) {
|
|
200
|
+
// At EOF, if the buffer has a leak signature but no closing brace at all, discard the whole buffer.
|
|
201
|
+
const trimmed = text.trim();
|
|
202
|
+
const hasThoughtKey = trimmed.includes('"thought"');
|
|
203
|
+
const hasToolKey = Array.from(toolNames).some(name => trimmed.includes(`"${name}"`));
|
|
204
|
+
const hasToolSignature =
|
|
205
|
+
trimmed.includes('"_i"') ||
|
|
206
|
+
trimmed.includes('"paths"') ||
|
|
207
|
+
trimmed.includes('"command"') ||
|
|
208
|
+
(trimmed.includes('"path"') && trimmed.includes('"content"'));
|
|
209
|
+
if (hasThoughtKey || hasToolKey || hasToolSignature) {
|
|
210
|
+
return { kind: "leak", visibleText: "" };
|
|
211
|
+
}
|
|
212
|
+
return { kind: "plain", visibleText: text };
|
|
213
|
+
}
|
|
214
|
+
return { kind: "incomplete" };
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
let parsed: unknown;
|
|
218
|
+
try {
|
|
219
|
+
parsed = JSON.parse(leading.jsonText);
|
|
220
|
+
} catch {
|
|
221
|
+
// Fallback to substring matching if JSON parsing fails due to unescaped quotes
|
|
222
|
+
const hasThoughtKey = leading.jsonText.includes('"thought"');
|
|
223
|
+
const hasToolKey = Array.from(toolNames).some(name => leading.jsonText.includes(`"${name}"`));
|
|
224
|
+
const hasToolSignature =
|
|
225
|
+
leading.jsonText.includes('"_i"') ||
|
|
226
|
+
leading.jsonText.includes('"paths"') ||
|
|
227
|
+
leading.jsonText.includes('"command"') ||
|
|
228
|
+
(leading.jsonText.includes('"path"') && leading.jsonText.includes('"content"'));
|
|
229
|
+
const isLeak = hasThoughtKey || hasToolKey || hasToolSignature;
|
|
230
|
+
if (isLeak) {
|
|
231
|
+
return { kind: "leak", visibleText: leading.rest };
|
|
232
|
+
}
|
|
233
|
+
// Unparseable leading object is not safe to strip; release it as normal text.
|
|
234
|
+
return { kind: "plain", visibleText: text };
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
return isPlanningLeakObject(parsed, toolNames)
|
|
238
|
+
? { kind: "leak", visibleText: leading.rest }
|
|
239
|
+
: { kind: "plain", visibleText: text };
|
|
240
|
+
}
|
|
241
|
+
|
|
67
242
|
export interface GoogleGeminiCliOptions extends StreamOptions {
|
|
68
243
|
/**
|
|
69
244
|
* Tool selection mode. String forms map directly to Gemini
|
|
@@ -463,6 +638,8 @@ export const streamGoogleGeminiCli: StreamFunction<"google-gemini-cli"> = (
|
|
|
463
638
|
const firstEventTimeoutMs =
|
|
464
639
|
options?.streamFirstEventTimeoutMs ?? getStreamFirstEventTimeoutMs(undefined, 300_000);
|
|
465
640
|
const callerSignal = options?.signal;
|
|
641
|
+
const toolNames = new Set(context.tools?.map(t => t.name) ?? []);
|
|
642
|
+
const isFlashLeakModel = model.id.includes("flash");
|
|
466
643
|
|
|
467
644
|
let started = false;
|
|
468
645
|
let sawFinishReason = false;
|
|
@@ -504,6 +681,22 @@ export const streamGoogleGeminiCli: StreamFunction<"google-gemini-cli"> = (
|
|
|
504
681
|
const blocks = output.content;
|
|
505
682
|
const blockIndex = () => blocks.length - 1;
|
|
506
683
|
|
|
684
|
+
let isBuffering = false;
|
|
685
|
+
let textBuffer = "";
|
|
686
|
+
let bufferedTextSignature: string | undefined;
|
|
687
|
+
|
|
688
|
+
const emitVisibleText = (delta: string, thoughtSignature?: string) => {
|
|
689
|
+
if (!delta || !currentBlock || currentBlock.type !== "text") return;
|
|
690
|
+
currentBlock.text += delta;
|
|
691
|
+
currentBlock.textSignature = retainThoughtSignature(currentBlock.textSignature, thoughtSignature);
|
|
692
|
+
stream.push({
|
|
693
|
+
type: "text_delta",
|
|
694
|
+
contentIndex: blockIndex(),
|
|
695
|
+
delta,
|
|
696
|
+
partial: output,
|
|
697
|
+
});
|
|
698
|
+
};
|
|
699
|
+
|
|
507
700
|
for await (const chunk of readSseJson<CloudCodeAssistResponseChunk>(
|
|
508
701
|
activeResponse.body!,
|
|
509
702
|
options?.signal,
|
|
@@ -554,17 +747,33 @@ export const streamGoogleGeminiCli: StreamFunction<"google-gemini-cli"> = (
|
|
|
554
747
|
partial: output,
|
|
555
748
|
});
|
|
556
749
|
} else {
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
}
|
|
750
|
+
if (isBuffering) {
|
|
751
|
+
textBuffer += part.text;
|
|
752
|
+
bufferedTextSignature = retainThoughtSignature(
|
|
753
|
+
bufferedTextSignature,
|
|
754
|
+
part.thoughtSignature,
|
|
755
|
+
);
|
|
756
|
+
} else if (isFlashLeakModel && part.text.trimStart().startsWith("{")) {
|
|
757
|
+
isBuffering = true;
|
|
758
|
+
textBuffer = part.text;
|
|
759
|
+
bufferedTextSignature = part.thoughtSignature;
|
|
760
|
+
} else {
|
|
761
|
+
emitVisibleText(part.text, part.thoughtSignature);
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
if (isBuffering) {
|
|
765
|
+
const buffered = consumePlanningBuffer(textBuffer, toolNames);
|
|
766
|
+
if (buffered.kind !== "incomplete") {
|
|
767
|
+
if (buffered.kind === "leak") {
|
|
768
|
+
sawLeak = true;
|
|
769
|
+
}
|
|
770
|
+
const visibleSignature = bufferedTextSignature;
|
|
771
|
+
isBuffering = false;
|
|
772
|
+
textBuffer = "";
|
|
773
|
+
bufferedTextSignature = undefined;
|
|
774
|
+
emitVisibleText(buffered.visibleText, visibleSignature);
|
|
775
|
+
}
|
|
776
|
+
}
|
|
568
777
|
}
|
|
569
778
|
} else if (part.text === "" && part.thoughtSignature && currentBlock && !part.functionCall) {
|
|
570
779
|
if (currentBlock.type === "thinking") {
|
|
@@ -585,6 +794,8 @@ export const streamGoogleGeminiCli: StreamFunction<"google-gemini-cli"> = (
|
|
|
585
794
|
pushBlockEndEvent(currentBlock, blockIndex(), output, stream);
|
|
586
795
|
currentBlock = null;
|
|
587
796
|
}
|
|
797
|
+
isBuffering = false;
|
|
798
|
+
textBuffer = "";
|
|
588
799
|
|
|
589
800
|
const providedId = part.functionCall.id;
|
|
590
801
|
const needsNewId =
|
|
@@ -645,14 +856,28 @@ export const streamGoogleGeminiCli: StreamFunction<"google-gemini-cli"> = (
|
|
|
645
856
|
}
|
|
646
857
|
}
|
|
647
858
|
|
|
859
|
+
if (isBuffering && textBuffer !== "") {
|
|
860
|
+
const buffered = consumePlanningBuffer(textBuffer, toolNames, true);
|
|
861
|
+
if (buffered.kind === "leak") {
|
|
862
|
+
sawLeak = true;
|
|
863
|
+
}
|
|
864
|
+
if (buffered.kind !== "incomplete") {
|
|
865
|
+
emitVisibleText(buffered.visibleText, bufferedTextSignature);
|
|
866
|
+
}
|
|
867
|
+
bufferedTextSignature = undefined;
|
|
868
|
+
isBuffering = false;
|
|
869
|
+
textBuffer = "";
|
|
870
|
+
}
|
|
871
|
+
|
|
648
872
|
if (currentBlock) {
|
|
649
873
|
pushBlockEndEvent(currentBlock, blockIndex(), output, stream);
|
|
650
874
|
}
|
|
651
875
|
|
|
652
|
-
return hasMeaningfulGoogleContent(output);
|
|
876
|
+
return hasMeaningfulGoogleContent(output) || sawLeak;
|
|
653
877
|
};
|
|
654
878
|
|
|
655
879
|
let receivedContent = false;
|
|
880
|
+
let sawLeak = false;
|
|
656
881
|
|
|
657
882
|
for (let i = 0; i < endpoints.length; i++) {
|
|
658
883
|
const endpoint = endpoints[i];
|
|
@@ -205,6 +205,17 @@ function isCodexStreamProgressEvent(event: unknown): boolean {
|
|
|
205
205
|
return typeof type === "string" && CODEX_ADDITIONAL_PROGRESS_EVENT_TYPES.has(type);
|
|
206
206
|
}
|
|
207
207
|
|
|
208
|
+
function extractCodexFrameResponseId(frame: Record<string, unknown>): string | undefined {
|
|
209
|
+
const response = (frame as { response?: { id?: unknown } }).response;
|
|
210
|
+
const id = response?.id;
|
|
211
|
+
return typeof id === "string" && id.length > 0 ? id : undefined;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function extractCodexFrameSequenceNumber(frame: Record<string, unknown>): number | undefined {
|
|
215
|
+
const raw = (frame as { sequence_number?: unknown }).sequence_number;
|
|
216
|
+
return typeof raw === "number" && Number.isFinite(raw) ? Math.trunc(raw) : undefined;
|
|
217
|
+
}
|
|
218
|
+
|
|
208
219
|
type CodexWebSocketTimeoutDetails = {
|
|
209
220
|
lastEventAt: number;
|
|
210
221
|
lastEventType?: string;
|
|
@@ -2403,6 +2414,12 @@ class CodexWebSocketConnection {
|
|
|
2403
2414
|
#lastInboundAt = 0;
|
|
2404
2415
|
/** Wall-clock of the last heartbeat ping we issued; 0 if none yet. */
|
|
2405
2416
|
#lastPingAt = 0;
|
|
2417
|
+
/**
|
|
2418
|
+
* Most recent `response.id` accepted on this socket, retained across
|
|
2419
|
+
* requests. Lets the next request drop a trailing/duplicate frame from the
|
|
2420
|
+
* previous (cleanly-completed) response that outlived the queue drain.
|
|
2421
|
+
*/
|
|
2422
|
+
#lastSeenResponseId?: string;
|
|
2406
2423
|
|
|
2407
2424
|
constructor(url: string, headers: Record<string, string>, options: CodexWebSocketConnectionOptions) {
|
|
2408
2425
|
this.#url = url;
|
|
@@ -2650,6 +2667,11 @@ class CodexWebSocketConnection {
|
|
|
2650
2667
|
let lastProgressEventType: string | undefined;
|
|
2651
2668
|
let lastEventAt = lastProgressAt;
|
|
2652
2669
|
let lastEventType: string | undefined;
|
|
2670
|
+
// Cross-request frame guard: lock onto this response's id and reject
|
|
2671
|
+
// frames belonging to another response interleaved on the reused socket.
|
|
2672
|
+
let activeResponseId: string | undefined;
|
|
2673
|
+
let lastSequence: number | undefined;
|
|
2674
|
+
const priorResponseId = this.#lastSeenResponseId;
|
|
2653
2675
|
while (true) {
|
|
2654
2676
|
let timeoutMs: number | undefined;
|
|
2655
2677
|
let timeoutReason: string;
|
|
@@ -2691,8 +2713,51 @@ class CodexWebSocketConnection {
|
|
|
2691
2713
|
if (next === null) {
|
|
2692
2714
|
throw new CodexWebSocketTransportError(`websocket closed before response completion`);
|
|
2693
2715
|
}
|
|
2694
|
-
sawFirstEvent = true;
|
|
2695
2716
|
const eventType = typeof next.type === "string" ? next.type : "";
|
|
2717
|
+
// Cross-request frame guard. The socket is reused across turns. Upstream
|
|
2718
|
+
// codex-rs leans on the protocol guarantee that nothing follows a
|
|
2719
|
+
// response's terminal event, but our queue can still surface a trailing
|
|
2720
|
+
// or duplicate frame from a cleanly-completed prior response after
|
|
2721
|
+
// #dropStaleFrames() drained the queue at send time. Attaching such a
|
|
2722
|
+
// frame to THIS turn misattributes an earlier turn's output (a stale
|
|
2723
|
+
// `response.completed` ends the turn early; a stale item makes the model
|
|
2724
|
+
// see an unrelated call). Only lifecycle events (created/completed/
|
|
2725
|
+
// failed/incomplete) carry a `response.id` — exactly the harmful ones —
|
|
2726
|
+
// so key the guard on it and let idless frames (deltas, the rate-limit/
|
|
2727
|
+
// metadata preamble, created-less streams) pass through, matching
|
|
2728
|
+
// upstream rather than gating on `response.created`.
|
|
2729
|
+
const frameResponseId = extractCodexFrameResponseId(next);
|
|
2730
|
+
const frameSequence = extractCodexFrameSequenceNumber(next);
|
|
2731
|
+
if (frameResponseId !== undefined) {
|
|
2732
|
+
if (activeResponseId === undefined) {
|
|
2733
|
+
if (priorResponseId !== undefined && frameResponseId === priorResponseId) {
|
|
2734
|
+
// Trailing/duplicate frame of the previous response that
|
|
2735
|
+
// outlived the drain. Drop without locking or advancing the
|
|
2736
|
+
// first-event clocks so our own response can still start.
|
|
2737
|
+
continue;
|
|
2738
|
+
}
|
|
2739
|
+
activeResponseId = frameResponseId;
|
|
2740
|
+
} else if (frameResponseId !== activeResponseId) {
|
|
2741
|
+
// A different response is interleaving on the socket; the idless
|
|
2742
|
+
// deltas that follow are indistinguishable, so fail closed
|
|
2743
|
+
// (retryable) instead of risking misattribution.
|
|
2744
|
+
this.close("stale-frame");
|
|
2745
|
+
throw new CodexWebSocketTransportError(
|
|
2746
|
+
`websocket frame for response ${frameResponseId} interleaved into active response ${activeResponseId}`,
|
|
2747
|
+
);
|
|
2748
|
+
}
|
|
2749
|
+
this.#lastSeenResponseId = frameResponseId;
|
|
2750
|
+
}
|
|
2751
|
+
if (frameSequence !== undefined) {
|
|
2752
|
+
if (activeResponseId !== undefined && lastSequence !== undefined && frameSequence < lastSequence) {
|
|
2753
|
+
this.close("stale-frame");
|
|
2754
|
+
throw new CodexWebSocketTransportError(
|
|
2755
|
+
`websocket sequence_number ${frameSequence} regressed below ${lastSequence} within response ${activeResponseId}`,
|
|
2756
|
+
);
|
|
2757
|
+
}
|
|
2758
|
+
lastSequence = frameSequence;
|
|
2759
|
+
}
|
|
2760
|
+
sawFirstEvent = true;
|
|
2696
2761
|
lastEventAt = Date.now();
|
|
2697
2762
|
lastEventType = eventType || undefined;
|
|
2698
2763
|
if (isCodexStreamProgressEvent(next)) {
|