@oh-my-pi/pi-ai 16.1.10 → 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 +7 -0
- package/package.json +4 -4
- package/src/providers/google-gemini-cli.ts +237 -12
- package/src/utils.ts +19 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,13 @@
|
|
|
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
|
+
|
|
5
12
|
## [16.1.10] - 2026-06-21
|
|
6
13
|
|
|
7
14
|
### Changed
|
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,9 +38,9 @@
|
|
|
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
45
|
"zod": "^4"
|
|
46
46
|
},
|
|
@@ -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];
|
package/src/utils.ts
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { $env } from "@oh-my-pi/pi-utils";
|
|
2
|
-
import type { ResponseInput } from "./providers/openai-responses-wire";
|
|
2
|
+
import type { ResponseInput, ResponseInputItem } from "./providers/openai-responses-wire";
|
|
3
3
|
import type { CacheRetention, OpenAIResponsesHistoryPayload, ProviderPayload } from "./types";
|
|
4
4
|
|
|
5
5
|
type OpenAIResponsesReplayItem = ResponseInput[number];
|
|
6
|
+
const IMAGE_GENERATION_CALL_STATUSES = new Set<string>(["in_progress", "completed", "generating", "failed"]);
|
|
6
7
|
|
|
7
8
|
export { isRecord } from "@oh-my-pi/pi-utils";
|
|
8
9
|
export function normalizeSystemPrompts(systemPrompt: readonly string[] | string | undefined | null): string[] {
|
|
@@ -77,6 +78,7 @@ function sanitizeOpenAIResponsesHistoryItemForReplay(
|
|
|
77
78
|
normalizedCallIds: Map<string, string>,
|
|
78
79
|
): OpenAIResponsesReplayItem | undefined {
|
|
79
80
|
if (item.type === "item_reference") return undefined;
|
|
81
|
+
if (item.type === "image_generation_call") return sanitizeOpenAIResponsesImageGenerationCallForReplay(item);
|
|
80
82
|
|
|
81
83
|
// providerPayload stores raw output items; replay strips item ids and keeps only normalized call_id.
|
|
82
84
|
const { id: _id, ...sanitizedItem } = item;
|
|
@@ -87,6 +89,22 @@ function sanitizeOpenAIResponsesHistoryItemForReplay(
|
|
|
87
89
|
return sanitizedItem as unknown as OpenAIResponsesReplayItem;
|
|
88
90
|
}
|
|
89
91
|
|
|
92
|
+
function sanitizeOpenAIResponsesImageGenerationCallForReplay(
|
|
93
|
+
item: Record<string, unknown>,
|
|
94
|
+
): ResponseInputItem.ImageGenerationCall | undefined {
|
|
95
|
+
if (typeof item.id !== "string" || !isImageGenerationCallStatus(item.status)) return undefined;
|
|
96
|
+
return {
|
|
97
|
+
id: truncateResponseItemId(item.id, "ig"),
|
|
98
|
+
type: "image_generation_call",
|
|
99
|
+
status: item.status,
|
|
100
|
+
result: typeof item.result === "string" ? item.result : null,
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function isImageGenerationCallStatus(status: unknown): status is ResponseInputItem.ImageGenerationCall["status"] {
|
|
105
|
+
return typeof status === "string" && IMAGE_GENERATION_CALL_STATUSES.has(status);
|
|
106
|
+
}
|
|
107
|
+
|
|
90
108
|
function normalizeReplayedResponsesHistoryCallId(value: string, normalizedValues: Map<string, string>): string {
|
|
91
109
|
const normalized = normalizedValues.get(value);
|
|
92
110
|
if (normalized) return normalized;
|