@oh-my-pi/pi-ai 16.1.3 → 16.1.5

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,17 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [16.1.4] - 2026-06-19
6
+
7
+ ### Added
8
+
9
+ - Added bounded auto-retry for empty assistant completions specifically to the OpenAI Responses provider
10
+ - Added bounded auto-retry for empty assistant completions across the OpenAI Chat Completions, OpenAI Responses, and Anthropic Messages providers. A benign terminal stop that streamed no content and billed no output tokens — the signature of a flaky OpenAI-/Anthropic-compatible gateway that intermittently 200s with an empty body — is now retried up to twice with exponential backoff (honoring `providerRetryWait`) before being surfaced, instead of silently stalling the agent loop. Retries fire only before any content streams, so live streaming (including thinking) is never delayed, retried, or duplicated.
11
+
12
+ ### Fixed
13
+
14
+ - Fixed the Antigravity (`google-antigravity`) request builder dropping `labels.model_enum` when the wire profile does not declare one. Required for Claude 4.6 ids whose `AntigravityModelWireProfile` carries only `maxOutputTokens` (no captured `model_enum`); the label is now emitted only when the catalog defines it. ([#3067](https://github.com/can1357/oh-my-pi/issues/3067))
15
+
5
16
  ## [16.1.3] - 2026-06-19
6
17
 
7
18
  ### Added
@@ -190,6 +190,13 @@ export type AnthropicUsageLike = {
190
190
  * zero-valued objects clear prior extras from earlier stream usage snapshots.
191
191
  */
192
192
  export declare function applyAnthropicUsageExtras(usage: Usage, source: AnthropicUsageLike): void;
193
+ /**
194
+ * Public entry: wrap the single-attempt streamer with bounded empty-completion
195
+ * retries (a benign terminal stop carrying no content/usage would otherwise
196
+ * stall the agent loop). The inner attempt keeps its own provider-failure retry
197
+ * loop; this layer only re-issues a fresh request on an empty success. Shared
198
+ * with the OpenAI-completions provider via `withEmptyCompletionRetry`.
199
+ */
193
200
  export declare const streamAnthropic: StreamFunction<"anthropic-messages">;
194
201
  export type AnthropicSystemBlock = {
195
202
  type: "text";
@@ -34,6 +34,12 @@ export interface OpenAICompletionsOptions extends StreamOptions {
34
34
  */
35
35
  openrouterVariant?: string;
36
36
  }
37
+ /**
38
+ * Public entry: wrap the single-attempt streamer with bounded empty-completion
39
+ * retries — flaky gateways occasionally 200 with `delta: {}` + `finish_reason:
40
+ * "stop"` and no usage, which would otherwise stall the agent loop. Shared with
41
+ * the Anthropic provider via `withEmptyCompletionRetry`.
42
+ */
37
43
  export declare const streamOpenAICompletions: StreamFunction<"openai-completions">;
38
44
  export declare function parseChunkUsage(rawUsage: object, model: Model<"openai-completions">, premiumRequests: number | undefined): AssistantMessage["usage"];
39
45
  export declare function convertMessages(model: Model<"openai-completions">, context: Context, compat: ResolvedOpenAICompat): ChatCompletionMessageParam[];
@@ -94,7 +94,10 @@ type OpenAIResponsesSamplingParams = ResponseCreateParamsStreaming & {
94
94
  };
95
95
  };
96
96
  /**
97
- * Generate function for OpenAI Responses API
97
+ * Public entry: wrap the single-attempt Responses streamer with bounded
98
+ * empty-completion retries — a `response.completed` carrying no content/usage
99
+ * would otherwise stall the agent loop. Shared with the OpenAI-completions and
100
+ * Anthropic providers via `withEmptyCompletionRetry`.
98
101
  */
99
102
  export declare const streamOpenAIResponses: StreamFunction<"openai-responses">;
100
103
  export declare function buildParams(model: Model<"openai-responses">, context: Context, options: OpenAIResponsesOptions | undefined, providerSessionState: OpenAIResponsesProviderSessionState | undefined, strictToolsScope?: OpenAIStrictToolsScope, disableStrictToolsOverride?: boolean): {
@@ -0,0 +1,21 @@
1
+ import type { AssistantMessage, Context } from "../types";
2
+ import { AssistantMessageEventStream } from "./event-stream";
3
+ export declare const MAX_EMPTY_COMPLETION_RETRIES = 2;
4
+ export declare const EMPTY_COMPLETION_BASE_DELAY_MS = 500;
5
+ /**
6
+ * Whether a completed assistant message carries content worth delivering: a tool
7
+ * call or any non-whitespace text. An empty/whitespace-only message — or one
8
+ * that only ever produced thinking — is the "empty response" failure.
9
+ */
10
+ export declare function hasVisibleAssistantContent(message: AssistantMessage): boolean;
11
+ interface EmptyCompletionRetryOptions {
12
+ signal?: AbortSignal;
13
+ providerRetryWait?: (delayMs: number, signal?: AbortSignal) => Promise<void>;
14
+ }
15
+ /**
16
+ * Wrap a single-attempt provider stream with bounded empty-completion retries.
17
+ * `attempt` MUST create a fresh request (and its own output message) on each
18
+ * call so a retry never inherits stale metadata from an empty attempt.
19
+ */
20
+ export declare function withEmptyCompletionRetry<M, O extends EmptyCompletionRetryOptions>(model: M, context: Context, options: O | undefined, attempt: (model: M, context: Context, options?: O) => AssistantMessageEventStream): AssistantMessageEventStream;
21
+ export {};
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.3",
4
+ "version": "16.1.5",
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.3",
42
- "@oh-my-pi/pi-utils": "16.1.3",
43
- "@oh-my-pi/pi-wire": "16.1.3",
41
+ "@oh-my-pi/pi-catalog": "16.1.5",
42
+ "@oh-my-pi/pi-utils": "16.1.5",
43
+ "@oh-my-pi/pi-wire": "16.1.5",
44
44
  "arktype": "^2.2.0",
45
45
  "partial-json": "^0.1.7",
46
46
  "zod": "^4"
@@ -46,6 +46,7 @@ import type {
46
46
  import { resolveServiceTier } from "../types";
47
47
  import { isRecord, normalizeSystemPrompts, normalizeToolCallId, resolveCacheRetention } from "../utils";
48
48
  import { createAbortSourceTracker } from "../utils/abort";
49
+ import { withEmptyCompletionRetry } from "../utils/empty-completion-retry";
49
50
  import { AssistantMessageEventStream } from "../utils/event-stream";
50
51
  import { isFoundryEnabled } from "../utils/foundry";
51
52
  import { finalizeErrorMessage, type RawHttpRequestDump, rewriteCopilotError } from "../utils/http-inspector";
@@ -1575,7 +1576,7 @@ export function applyAnthropicUsageExtras(usage: Usage, source: AnthropicUsageLi
1575
1576
  }
1576
1577
  }
1577
1578
 
1578
- export const streamAnthropic: StreamFunction<"anthropic-messages"> = (
1579
+ const streamAnthropicOnce = (
1579
1580
  model: Model<"anthropic-messages">,
1580
1581
  context: Context,
1581
1582
  options?: AnthropicOptions,
@@ -2309,6 +2310,16 @@ export const streamAnthropic: StreamFunction<"anthropic-messages"> = (
2309
2310
  return stream;
2310
2311
  };
2311
2312
 
2313
+ /**
2314
+ * Public entry: wrap the single-attempt streamer with bounded empty-completion
2315
+ * retries (a benign terminal stop carrying no content/usage would otherwise
2316
+ * stall the agent loop). The inner attempt keeps its own provider-failure retry
2317
+ * loop; this layer only re-issues a fresh request on an empty success. Shared
2318
+ * with the OpenAI-completions provider via `withEmptyCompletionRetry`.
2319
+ */
2320
+ export const streamAnthropic: StreamFunction<"anthropic-messages"> = (model, context, options) =>
2321
+ withEmptyCompletionRetry(model, context, options, streamAnthropicOnce);
2322
+
2312
2323
  export type AnthropicSystemBlock = {
2313
2324
  type: "text";
2314
2325
  text: string;
@@ -940,7 +940,7 @@ function buildAntigravityRequestEnvelope(
940
940
  const labels: Record<string, string> = {};
941
941
  if (state?.lastExecutionId) labels.last_execution_id = state.lastExecutionId;
942
942
  labels.last_step_index = String(step - 1);
943
- if (profile) labels.model_enum = profile.modelEnum;
943
+ if (profile?.modelEnum !== undefined) labels.model_enum = profile.modelEnum;
944
944
  labels.trajectory_id = trajectoryId;
945
945
  labels.used_claude = String(isClaude);
946
946
  labels.used_claude_conservative = String(isClaude);
@@ -27,6 +27,7 @@ import type {
27
27
  } from "../types";
28
28
  import { normalizeSystemPrompts } from "../utils";
29
29
  import { createAbortSourceTracker } from "../utils/abort";
30
+ import { hasVisibleAssistantContent, withEmptyCompletionRetry } from "../utils/empty-completion-retry";
30
31
  import { AssistantMessageEventStream } from "../utils/event-stream";
31
32
  import { finalizeErrorMessage, type RawHttpRequestDump, rewriteCopilotError } from "../utils/http-inspector";
32
33
  import {
@@ -537,7 +538,7 @@ const OPENAI_COMPLETIONS_FIRST_EVENT_TIMEOUT_MESSAGE =
537
538
  // converts the already-successful response into a timeout error.
538
539
  const OPENAI_COMPLETIONS_POST_FINISH_GRACE_MS = 2_500;
539
540
 
540
- export const streamOpenAICompletions: StreamFunction<"openai-completions"> = (
541
+ const streamOpenAICompletionsOnce = (
541
542
  model: Model<"openai-completions">,
542
543
  context: Context,
543
544
  options?: OpenAICompletionsOptions,
@@ -1234,7 +1235,7 @@ export const streamOpenAICompletions: StreamFunction<"openai-completions"> = (
1234
1235
  if (
1235
1236
  policy.stream.emptyLengthFinishIsContextError &&
1236
1237
  output.stopReason === "length" &&
1237
- !hasVisibleCompletionContent(output)
1238
+ !hasVisibleAssistantContent(output)
1238
1239
  ) {
1239
1240
  output.stopReason = "error";
1240
1241
  output.errorMessage = EMPTY_OLLAMA_LENGTH_COMPLETION_MESSAGE;
@@ -1288,6 +1289,15 @@ export const streamOpenAICompletions: StreamFunction<"openai-completions"> = (
1288
1289
  return stream;
1289
1290
  };
1290
1291
 
1292
+ /**
1293
+ * Public entry: wrap the single-attempt streamer with bounded empty-completion
1294
+ * retries — flaky gateways occasionally 200 with `delta: {}` + `finish_reason:
1295
+ * "stop"` and no usage, which would otherwise stall the agent loop. Shared with
1296
+ * the Anthropic provider via `withEmptyCompletionRetry`.
1297
+ */
1298
+ export const streamOpenAICompletions: StreamFunction<"openai-completions"> = (model, context, options) =>
1299
+ withEmptyCompletionRetry(model, context, options, streamOpenAICompletionsOnce);
1300
+
1291
1301
  function createRequestSetup(
1292
1302
  model: Model<"openai-completions">,
1293
1303
  context: Context,
@@ -2061,16 +2071,6 @@ function convertTools(
2061
2071
  };
2062
2072
  }
2063
2073
 
2064
- const NON_WHITESPACE_RE = /\S/;
2065
-
2066
- function hasVisibleCompletionContent(message: AssistantMessage): boolean {
2067
- for (const block of message.content) {
2068
- if (block.type === "toolCall") return true;
2069
- if (block.type === "text" && NON_WHITESPACE_RE.test(block.text)) return true;
2070
- }
2071
- return false;
2072
- }
2073
-
2074
2074
  const EMPTY_OLLAMA_LENGTH_COMPLETION_MESSAGE =
2075
2075
  "Model returned no content: prompt filled the context window; raise Ollama num_ctx or shorten the prompt.";
2076
2076
 
@@ -21,6 +21,7 @@ import {
21
21
  sanitizeOpenAIResponsesHistoryItemsForReplay,
22
22
  } from "../utils";
23
23
  import { createAbortSourceTracker } from "../utils/abort";
24
+ import { withEmptyCompletionRetry } from "../utils/empty-completion-retry";
24
25
  import { AssistantMessageEventStream } from "../utils/event-stream";
25
26
  import { finalizeErrorMessage, type RawHttpRequestDump, rewriteCopilotError } from "../utils/http-inspector";
26
27
  import {
@@ -338,7 +339,7 @@ type OpenAIResponsesSamplingParams = ResponseCreateParamsStreaming & {
338
339
  /**
339
340
  * Generate function for OpenAI Responses API
340
341
  */
341
- export const streamOpenAIResponses: StreamFunction<"openai-responses"> = (
342
+ const streamOpenAIResponsesOnce = (
342
343
  model: Model<"openai-responses">,
343
344
  context: Context,
344
345
  options?: OpenAIResponsesOptions,
@@ -737,6 +738,15 @@ export const streamOpenAIResponses: StreamFunction<"openai-responses"> = (
737
738
  return stream;
738
739
  };
739
740
 
741
+ /**
742
+ * Public entry: wrap the single-attempt Responses streamer with bounded
743
+ * empty-completion retries — a `response.completed` carrying no content/usage
744
+ * would otherwise stall the agent loop. Shared with the OpenAI-completions and
745
+ * Anthropic providers via `withEmptyCompletionRetry`.
746
+ */
747
+ export const streamOpenAIResponses: StreamFunction<"openai-responses"> = (model, context, options) =>
748
+ withEmptyCompletionRetry(model, context, options, streamOpenAIResponsesOnce);
749
+
740
750
  export function buildParams(
741
751
  model: Model<"openai-responses">,
742
752
  context: Context,
@@ -0,0 +1,159 @@
1
+ /**
2
+ * Bounded retries for an empty assistant completion.
3
+ *
4
+ * Some providers — and especially flaky OpenAI-/Anthropic-compatible gateways —
5
+ * intermittently return a benign terminal stop carrying no content and no usage
6
+ * (e.g. a single OpenAI `delta: {}` + `finish_reason: "stop"` chunk). Delivered
7
+ * as-is the agent loop has nothing to act on and silently halts mid-task, so the
8
+ * request must be retried instead of surfaced.
9
+ *
10
+ * This wraps a single-attempt provider stream and re-invokes it (a fresh request
11
+ * with its own message state) when an attempt produces no meaningful content.
12
+ * Only a stream that streamed nothing meaningful is retried: the moment any
13
+ * text/thinking/tool delta is forwarded the attempt is committed, so live
14
+ * streaming (including thinking) is never delayed, retried, or duplicated.
15
+ *
16
+ * Mirrors the Gemini empty-response policy in `google-shared` (which keeps its
17
+ * own integrated loop) and is shared by the OpenAI-completions and
18
+ * Anthropic-messages providers.
19
+ */
20
+ import { scheduler } from "node:timers/promises";
21
+ import type { AssistantMessage, AssistantMessageEvent, Context } from "../types";
22
+ import { AssistantMessageEventStream } from "./event-stream";
23
+
24
+ export const MAX_EMPTY_COMPLETION_RETRIES = 2;
25
+ export const EMPTY_COMPLETION_BASE_DELAY_MS = 500;
26
+
27
+ const NON_WHITESPACE_RE = /\S/;
28
+
29
+ /**
30
+ * Whether a completed assistant message carries content worth delivering: a tool
31
+ * call or any non-whitespace text. An empty/whitespace-only message — or one
32
+ * that only ever produced thinking — is the "empty response" failure.
33
+ */
34
+ export function hasVisibleAssistantContent(message: AssistantMessage): boolean {
35
+ for (const block of message.content) {
36
+ if (block.type === "toolCall") return true;
37
+ if (block.type === "text" && NON_WHITESPACE_RE.test(block.text)) return true;
38
+ }
39
+ return false;
40
+ }
41
+
42
+ /** A streamed event that delivers content worth committing the attempt for. */
43
+ function isMeaningfulCompletionEvent(event: AssistantMessageEvent): boolean {
44
+ switch (event.type) {
45
+ case "text_delta":
46
+ case "thinking_delta":
47
+ case "toolcall_delta":
48
+ return event.delta.length > 0;
49
+ case "text_end":
50
+ case "thinking_end":
51
+ return event.content.length > 0;
52
+ case "toolcall_start":
53
+ case "toolcall_end":
54
+ return true;
55
+ default:
56
+ return false;
57
+ }
58
+ }
59
+
60
+ interface EmptyCompletionRetryOptions {
61
+ signal?: AbortSignal;
62
+ providerRetryWait?: (delayMs: number, signal?: AbortSignal) => Promise<void>;
63
+ }
64
+
65
+ /**
66
+ * Wrap a single-attempt provider stream with bounded empty-completion retries.
67
+ * `attempt` MUST create a fresh request (and its own output message) on each
68
+ * call so a retry never inherits stale metadata from an empty attempt.
69
+ */
70
+ export function withEmptyCompletionRetry<M, O extends EmptyCompletionRetryOptions>(
71
+ model: M,
72
+ context: Context,
73
+ options: O | undefined,
74
+ attempt: (model: M, context: Context, options?: O) => AssistantMessageEventStream,
75
+ ): AssistantMessageEventStream {
76
+ const outer = new AssistantMessageEventStream();
77
+ const signal = options?.signal;
78
+ void (async () => {
79
+ for (let emptyAttempt = 0; ; emptyAttempt++) {
80
+ const inner = attempt(model, context, options);
81
+ const buffered: AssistantMessageEvent[] = [];
82
+ let committed = false;
83
+ let terminal: AssistantMessageEvent | undefined;
84
+ const flush = (): void => {
85
+ for (const event of buffered) outer.push(event);
86
+ buffered.length = 0;
87
+ };
88
+ try {
89
+ for await (const event of inner) {
90
+ if (event.type === "done" || event.type === "error") {
91
+ terminal = event;
92
+ break;
93
+ }
94
+ // Buffer pre-content events (start/*_start) so an empty attempt can
95
+ // be discarded; commit the moment real content streams.
96
+ if (!committed && !isMeaningfulCompletionEvent(event)) {
97
+ buffered.push(event);
98
+ continue;
99
+ }
100
+ committed = true;
101
+ flush();
102
+ outer.push(event);
103
+ if (outer.done) return;
104
+ }
105
+ } catch (error) {
106
+ flush();
107
+ outer.fail(error);
108
+ return;
109
+ }
110
+
111
+ // Retry only a genuinely degenerate completion: a normal stop that
112
+ // produced no visible content AND billed no output tokens (the flaky
113
+ // gateway signature — charged nothing, returned nothing). A stop that
114
+ // reports output tokens spent its budget somewhere (e.g. thinking) and
115
+ // is left alone.
116
+ const message = terminal?.type === "done" ? terminal.message : undefined;
117
+ const isRetryableEmpty =
118
+ !committed &&
119
+ message !== undefined &&
120
+ message.stopReason === "stop" &&
121
+ !message.errorMessage &&
122
+ (message.usage?.output ?? 0) <= 0 &&
123
+ !hasVisibleAssistantContent(message);
124
+
125
+ if (isRetryableEmpty && emptyAttempt < MAX_EMPTY_COMPLETION_RETRIES && !signal?.aborted) {
126
+ const delayMs = EMPTY_COMPLETION_BASE_DELAY_MS * 2 ** emptyAttempt;
127
+ try {
128
+ if (options?.providerRetryWait) await options.providerRetryWait(delayMs, signal);
129
+ else await scheduler.wait(delayMs, { signal });
130
+ } catch (waitError) {
131
+ // Aborted during backoff: deliver the empty result rather than hang.
132
+ // Any other wait failure is a real error and must surface.
133
+ flush();
134
+ if (signal?.aborted) {
135
+ if (terminal) outer.push(terminal);
136
+ } else {
137
+ outer.fail(waitError);
138
+ }
139
+ return;
140
+ }
141
+ // Discard the buffered `start` from this empty attempt and retry.
142
+ continue;
143
+ }
144
+
145
+ flush();
146
+ if (terminal) {
147
+ outer.push(terminal);
148
+ } else if (!outer.done) {
149
+ try {
150
+ outer.end(await inner.result());
151
+ } catch (error) {
152
+ outer.fail(error);
153
+ }
154
+ }
155
+ return;
156
+ }
157
+ })();
158
+ return outer;
159
+ }