@oh-my-pi/pi-ai 17.3.2 → 17.3.3

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,12 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [17.3.3] - 2026-08-14
6
+
7
+ ### Fixed
8
+
9
+ - Distinguished Gemini thought-only `STOP` responses from empty transports, avoiding repeated identical reasoning requests and duplicate Antigravity endpoint streams while surfacing the missing final output for session-level recovery.
10
+
5
11
  ## [17.3.2] - 2026-08-13
6
12
 
7
13
  ### Fixed
@@ -8,6 +8,7 @@ export declare const Flag: {
8
8
  readonly StaleResponsesItem: 1048576;
9
9
  readonly MalformedFunctionCall: 2097152;
10
10
  readonly ProviderFinishError: 4194304;
11
+ readonly EmptyResponse: 8192;
11
12
  readonly ContentBlocked: 32768;
12
13
  /** Account-scoped provider policy denial that may succeed with another credential. */
13
14
  readonly AccountPolicy: 16384;
@@ -7,6 +7,8 @@ export type ProviderResponseErrorKind =
7
7
  | "output"
8
8
  /** Response body was empty/missing when content was required. */
9
9
  | "empty-body"
10
+ /** Response completed without actionable output (for example, thoughts only). */
11
+ | "empty-output"
10
12
  /** Malformed wire envelope (unexpected message ordering / shape). */
11
13
  | "envelope"
12
14
  /** Content was blocked by a provider safety filter. */
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@oh-my-pi/pi-ai",
4
- "version": "17.3.2",
4
+ "version": "17.3.3",
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,10 +38,10 @@
38
38
  },
39
39
  "dependencies": {
40
40
  "@bufbuild/protobuf": "^2.12.1",
41
- "@oh-my-pi/omptype": "17.3.2",
42
- "@oh-my-pi/pi-catalog": "17.3.2",
43
- "@oh-my-pi/pi-utils": "17.3.2",
44
- "@oh-my-pi/pi-wire": "17.3.2"
41
+ "@oh-my-pi/omptype": "17.3.3",
42
+ "@oh-my-pi/pi-catalog": "17.3.3",
43
+ "@oh-my-pi/pi-utils": "17.3.3",
44
+ "@oh-my-pi/pi-wire": "17.3.3"
45
45
  },
46
46
  "devDependencies": {
47
47
  "@bufbuild/protoc-gen-es": "^2.12.1",
@@ -24,6 +24,7 @@ export const Flag = {
24
24
  StaleResponsesItem: 0x0010_0000,
25
25
  MalformedFunctionCall: 0x0020_0000,
26
26
  ProviderFinishError: 0x0040_0000,
27
+ EmptyResponse: 0x0000_2000,
27
28
  ContentBlocked: 0x0000_8000,
28
29
  /** Account-scoped provider policy denial that may succeed with another credential. */
29
30
  AccountPolicy: 0x0000_4000,
@@ -50,6 +51,7 @@ const KIND_MASK =
50
51
  Flag.StaleResponsesItem |
51
52
  Flag.MalformedFunctionCall |
52
53
  Flag.ProviderFinishError |
54
+ Flag.EmptyResponse |
53
55
  Flag.ContentBlocked |
54
56
  Flag.AccountPolicy |
55
57
  Flag.ContextOverflow |
@@ -62,7 +64,12 @@ const KIND_MASK =
62
64
  Flag.OAuthExpiry;
63
65
 
64
66
  const RETRIABLE_KINDS =
65
- Flag.Transient | Flag.UsageLimit | Flag.ThinkingLoop | Flag.StaleResponsesItem | Flag.ProviderFinishError;
67
+ Flag.Transient |
68
+ Flag.UsageLimit |
69
+ Flag.ThinkingLoop |
70
+ Flag.StaleResponsesItem |
71
+ Flag.ProviderFinishError |
72
+ Flag.EmptyResponse;
66
73
 
67
74
  const OVERFLOW_PATTERNS = [
68
75
  /prompt is too long/i, // Anthropic
@@ -104,6 +111,7 @@ const AUTH_FAILURE_PATTERN =
104
111
  /\b(?:401|403|unauthorized|forbidden|authentication|auth[_ ]?unavailable|no auth available|(?:invalid|no)[_ ]?api[_ ]?key)\b/i;
105
112
  const MALFORMED_FUNCTION_CALL_PATTERN = /\bmalformed.?function.?call\b/i;
106
113
  const PROVIDER_FINISH_ERROR_PATTERN = /\bProvider (?:returned error finish_reason|finish_reason:\s*error)\b/i;
114
+ const EMPTY_RESPONSE_PATTERN = /\bthought-only response without final output\b/i;
107
115
  const CONTENT_FILTER_PATTERN = /\b(?:incomplete:\s*)?content_filter\b/i;
108
116
  const ACCOUNT_POLICY_PATTERN = /\bcyber_policy\b|trusted access for cyber/i;
109
117
  const STALE_RESPONSE_ITEM_PATTERNS = [/\bItem with id ['"][^'"]+['"] not found\.?/i, /previous[ _]?response/i] as const;
@@ -197,6 +205,7 @@ const ERROR_KIND_LABELS: readonly [Flag, string][] = [
197
205
  [Flag.StaleResponsesItem, "stale-responses-item"],
198
206
  [Flag.MalformedFunctionCall, "malformed-function-call"],
199
207
  [Flag.ProviderFinishError, "provider-finish-error"],
208
+ [Flag.EmptyResponse, "empty-response"],
200
209
  [Flag.ContentBlocked, "content-blocked"],
201
210
  [Flag.AccountPolicy, "account-policy"],
202
211
  [Flag.ContextOverflow, "context-overflow"],
@@ -340,6 +349,7 @@ function classifyText(errorMessage: string | undefined, errorStatus: number | un
340
349
  if (matchesOverflowText(errorMessage)) kinds |= Flag.ContextOverflow;
341
350
  if (isMalformedFunctionCallText(errorMessage)) kinds |= Flag.MalformedFunctionCall;
342
351
  if (isProviderFinishErrorText(errorMessage)) kinds |= Flag.ProviderFinishError;
352
+ if (EMPTY_RESPONSE_PATTERN.test(errorMessage)) kinds |= Flag.EmptyResponse | Flag.Transient;
343
353
  if (isContentBlockedText(errorMessage)) kinds |= Flag.ContentBlocked;
344
354
  if (ACCOUNT_POLICY_PATTERN.test(errorMessage)) kinds |= Flag.AccountPolicy | Flag.ContentBlocked;
345
355
  if (isAuthFailureText(errorMessage)) kinds |= Flag.AuthFailed;
@@ -9,6 +9,8 @@ export type ProviderResponseErrorKind =
9
9
  | "output"
10
10
  /** Response body was empty/missing when content was required. */
11
11
  | "empty-body"
12
+ /** Response completed without actionable output (for example, thoughts only). */
13
+ | "empty-output"
12
14
  /** Malformed wire envelope (unexpected message ordering / shape). */
13
15
  | "envelope"
14
16
  /** Content was blocked by a provider safety filter. */
@@ -38,11 +40,10 @@ export class ProviderResponseError extends Error {
38
40
  this.kind = options.kind ?? "output";
39
41
  // A safety filter block is terminal and intentionally non-retryable.
40
42
  if (this.kind === "content-blocked") attach(this, create(Flag.ContentBlocked));
41
- // An incomplete stream (connection dropped / truncated before any terminal
42
- // event) or an empty body never produced any content the request didn't
43
- // complete, so it is safe to retry and eligible for model fallback. The
44
- // retry layer's replay-unsafe guard still blocks a retry when partial tool
45
- // output was already emitted.
43
+ // A logically empty completed output needs a session-level reminder that
44
+ // asks for the missing final answer. Empty bodies and incomplete streams
45
+ // stay on the generic transient retry/model-fallback path.
46
+ else if (this.kind === "empty-output") attach(this, create(Flag.Transient, Flag.EmptyResponse));
46
47
  else if (this.kind === "incomplete-stream" || this.kind === "empty-body") attach(this, create(Flag.Transient));
47
48
  }
48
49
  }
@@ -622,11 +622,8 @@ export const streamGoogleGeminiCli: StreamFunction<"google-gemini-cli"> = (
622
622
  const isFlashLeakModel = model.id.includes("flash");
623
623
 
624
624
  let started = false;
625
- // Tracks whether *visible* content (text delta or tool call) has been
626
- // pushed downstream. `started` alone is a poor failover guard because a
627
- // hidden thought part also flips it (via `ensureStarted`); a thinking-only
628
- // STOP must still fail over to the alternate Antigravity endpoint (#8480).
629
- let emittedVisibleContent = false;
625
+ // Once any stream event starts, the endpoint is committed downstream.
626
+ // Failover remains safe only while `started` is false.
630
627
  let sawFinishReason = false;
631
628
  let lastResponseId: string | undefined;
632
629
  const ensureStarted = () => {
@@ -705,7 +702,6 @@ export const streamGoogleGeminiCli: StreamFunction<"google-gemini-cli"> = (
705
702
 
706
703
  const emitVisibleText = (delta: string, thoughtSignature?: string): void => {
707
704
  if (!delta) return;
708
- emittedVisibleContent = true;
709
705
  const block = startTextBlock();
710
706
  block.text += delta;
711
707
  block.textSignature = retainThoughtSignature(block.textSignature, thoughtSignature);
@@ -864,7 +860,6 @@ export const streamGoogleGeminiCli: StreamFunction<"google-gemini-cli"> = (
864
860
  };
865
861
 
866
862
  output.content.push(toolCall);
867
- emittedVisibleContent = true;
868
863
  ensureStarted();
869
864
  pushToolCallEvents(toolCall, blockIndex(), output, stream);
870
865
  }
@@ -932,13 +927,17 @@ export const streamGoogleGeminiCli: StreamFunction<"google-gemini-cli"> = (
932
927
  };
933
928
 
934
929
  let receivedContent = false;
930
+ const hasThinkingOutput = () =>
931
+ output.content.some(
932
+ block =>
933
+ block.type === "thinking" && (block.thinking.trim().length > 0 || Boolean(block.thinkingSignature)),
934
+ );
935
935
 
936
936
  for (let i = 0; i < endpoints.length; i++) {
937
937
  const endpoint = endpoints[i];
938
938
  const isLastEndpoint = i === endpoints.length - 1;
939
939
  try {
940
940
  started = false;
941
- emittedVisibleContent = false;
942
941
  resetOutput();
943
942
 
944
943
  // Per attempt: arm a pre-response (TTFT) timer, cleared the instant
@@ -1022,17 +1021,26 @@ export const streamGoogleGeminiCli: StreamFunction<"google-gemini-cli"> = (
1022
1021
  }
1023
1022
 
1024
1023
  const streamed = await streamResponse(currentResponse);
1025
- // Only accept an empty STOP as valid silence once every fallback
1026
- // endpoint is exhausted: an earlier endpoint returning empty
1027
- // successful streams must still fail over (Antigravity auto mode)
1028
- // rather than be recorded as a real silent review.
1024
+ // Eventless silence may fail over to the alternate Antigravity
1025
+ // endpoint. Once thinking has streamed, the endpoint is already
1026
+ // committed downstream; Advisor mode may accept that silence,
1027
+ // while normal sessions surface it to final-output recovery.
1028
+ const thoughtOnly = hasThinkingOutput();
1029
1029
  const acceptedSilence =
1030
- options?.acceptEmptyResponse === true && !streamed.strippedPlanningLeak && isLastEndpoint;
1030
+ options?.acceptEmptyResponse === true &&
1031
+ !streamed.strippedPlanningLeak &&
1032
+ (isLastEndpoint || thoughtOnly);
1031
1033
  if (output.stopReason !== "stop" || streamed.meaningful || acceptedSilence) {
1032
1034
  receivedContent = streamed.meaningful || acceptedSilence;
1033
1035
  break;
1034
1036
  }
1035
1037
 
1038
+ // A thought-only STOP is a complete provider response, not a
1039
+ // transiently empty transport. Replaying the identical request
1040
+ // burns another full reasoning pass; let session recovery add
1041
+ // an explicit final-output reminder instead.
1042
+ if (thoughtOnly) break;
1043
+
1036
1044
  if (emptyAttempt < MAX_EMPTY_STREAM_RETRIES) {
1037
1045
  resetOutput();
1038
1046
  }
@@ -1046,10 +1054,16 @@ export const streamGoogleGeminiCli: StreamFunction<"google-gemini-cli"> = (
1046
1054
  }
1047
1055
 
1048
1056
  if (!receivedContent) {
1049
- throw new AIError.ProviderResponseError("Cloud Code Assist API returned an empty response", {
1050
- provider: model.provider,
1051
- kind: "empty-body",
1052
- });
1057
+ const thoughtOnly = hasThinkingOutput();
1058
+ throw new AIError.ProviderResponseError(
1059
+ thoughtOnly
1060
+ ? "Cloud Code Assist API returned a thought-only response without final output"
1061
+ : "Cloud Code Assist API returned an empty response",
1062
+ {
1063
+ provider: model.provider,
1064
+ kind: thoughtOnly ? "empty-output" : "empty-body",
1065
+ },
1066
+ );
1053
1067
  }
1054
1068
 
1055
1069
  if (options?.signal?.aborted) {
@@ -1081,7 +1095,7 @@ export const streamGoogleGeminiCli: StreamFunction<"google-gemini-cli"> = (
1081
1095
  const status = extractHttpStatusFromError(error);
1082
1096
  if (
1083
1097
  !isLastEndpoint &&
1084
- !emittedVisibleContent &&
1098
+ !started &&
1085
1099
  (AIError.isTransientStatus(status) ||
1086
1100
  (status === undefined &&
1087
1101
  !(error instanceof AIError.ProviderResponseError && error.kind === "output") &&