@oh-my-pi/pi-ai 16.1.10 → 16.1.12

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 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.10",
4
+ "version": "16.1.12",
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.10",
42
- "@oh-my-pi/pi-utils": "16.1.10",
43
- "@oh-my-pi/pi-wire": "16.1.10",
41
+ "@oh-my-pi/pi-catalog": "16.1.12",
42
+ "@oh-my-pi/pi-utils": "16.1.12",
43
+ "@oh-my-pi/pi-wire": "16.1.12",
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
- currentBlock.text += part.text;
558
- currentBlock.textSignature = retainThoughtSignature(
559
- currentBlock.textSignature,
560
- part.thoughtSignature,
561
- );
562
- stream.push({
563
- type: "text_delta",
564
- contentIndex: blockIndex(),
565
- delta: part.text,
566
- partial: output,
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;