@oh-my-pi/pi-ai 18.8.7 → 18.8.9

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,21 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [18.8.9] - 2026-10-10
6
+
7
+ ### Changed
8
+
9
+ - Updated state field rendering to trim trailing whitespace and normalize empty collection formatting for consistent prompt output
10
+
11
+ ## [18.8.8] - 2026-10-10
12
+
13
+ ### Fixed
14
+
15
+ - Fixed OpenAI Responses and Codex tool calls running with empty `{}` arguments when a compatible host or proxy ends the call with an empty arguments payload after streaming the real ones ([#15099](https://github.com/can1357/oh-my-pi/pull/15099) by [@will-bogusz](https://github.com/will-bogusz))
16
+ - Fixed truncated Factory Droid Gemini responses ending as a hard error or a half-received tool call instead of being retried ([#15103](https://github.com/can1357/oh-my-pi/pull/15103) by [@will-bogusz](https://github.com/will-bogusz))
17
+ - Fixed Factory Droid Gemini ignoring an error the server reports mid-response, which hid its status and retried errors that cannot succeed ([#15103](https://github.com/can1357/oh-my-pi/pull/15103) by [@will-bogusz](https://github.com/will-bogusz))
18
+ - Fixed resumed OpenAI Responses sessions (xAI, Factory Droid, OpenAI and other hosts) dropping all earlier encrypted reasoning on their first request; GitHub Copilot still rebuilds history until its first response ([#15148](https://github.com/can1357/oh-my-pi/pull/15148) by [@will-bogusz](https://github.com/will-bogusz))
19
+
5
20
  ## [18.8.7] - 2026-10-09
6
21
 
7
22
  ### Added
@@ -153,6 +153,13 @@ export declare function pushToolCallEvents(toolCall: ToolCall, contentIndex: num
153
153
  export declare function startTextOrThinkingBlock(isThinking: true, output: AssistantMessage, stream: AssistantMessageEventStream, onBeforeStartEvent?: () => void): ThinkingContent;
154
154
  export declare function startTextOrThinkingBlock(isThinking: false, output: AssistantMessage, stream: AssistantMessageEventStream, onBeforeStartEvent?: () => void): TextContent;
155
155
  export declare function startTextOrThinkingBlock(isThinking: boolean, output: AssistantMessage, stream: AssistantMessageEventStream, onBeforeStartEvent?: () => void): TextContent | ThinkingContent;
156
+ /**
157
+ * Error for an in-band `error` frame in a 200 generateContent stream. Keeps the
158
+ * RPC status alongside the message: an in-band quota failure is classified
159
+ * from this text, and `RESOURCE_EXHAUSTED` is the only account-exhaustion
160
+ * signal some of these chunks carry (#13090).
161
+ */
162
+ export declare function googleStreamChunkError(error: NonNullable<GenerateContentResponse["error"]>, provider: string): Error;
156
163
  /**
157
164
  * Drives the chunked `generateContentStream` iterator into an `AssistantMessage` and
158
165
  * the corresponding `AssistantMessageEventStream`. Shared between `streamGoogle` and
@@ -82,7 +82,14 @@ export declare function pollOpenAIResponsesResultForCompletion(args: {
82
82
  wait?: (ms: number, signal?: AbortSignal) => Promise<void>;
83
83
  }): Promise<Record<string, unknown> | undefined>;
84
84
  interface OpenAIResponsesProviderSessionState extends ProviderSessionState, OpenAIStrictToolsState, OpenAIReasoningEffortFallbackState {
85
+ /**
86
+ * Replay native history items. Starts false only on connection-bound hosts;
87
+ * `close()` clears it so the next request rebuilds history from message
88
+ * content; the first successful response sets it.
89
+ */
85
90
  nativeHistoryReplayWarmed: boolean;
91
+ /** Bumped by `close()`; a response warms the state only if no close happened since its request started. */
92
+ closeCount: number;
86
93
  /** Stateful `previous_response_id` chain baselines, keyed by baseUrl/model/session. */
87
94
  chains: Map<string, OpenAIResponsesChainState>;
88
95
  /** `configuration_update` effort baselines, keyed by baseUrl/model/session. */
@@ -622,7 +622,8 @@ export declare function accumulateToolCallArgumentsDelta(block: ResponsesToolCal
622
622
  * Finalize streamed function-call arguments from the authoritative `.done`
623
623
  * payload. The caller owns the `argumentsDone` flag (generic Responses sets it;
624
624
  * Codex's block shape has no such field), so this only rewrites `arguments` and
625
- * drops the transient accumulation fields.
625
+ * drops the transient accumulation fields. A blank payload is not authoritative:
626
+ * it parses the buffered deltas and keeps them so the final item can still win.
626
627
  */
627
628
  export declare function finalizeToolCallArgumentsDone(block: ResponsesToolCallBlock, args: string): void;
628
629
  export declare function accumulateCustomToolCallInputDelta(block: ResponsesToolCallBlock, delta: string, stream: AssistantMessageEventStream, output: AssistantMessage, contentIndex: number): void;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oh-my-pi/pi-ai",
3
- "version": "18.8.7",
3
+ "version": "18.8.9",
4
4
  "description": "Unified LLM API with automatic model discovery and provider configuration",
5
5
  "keywords": [
6
6
  "ai",
@@ -155,11 +155,11 @@
155
155
  "fmt": "oxfmt --no-error-on-unmatched-pattern 'src/**/*.{ts,tsx}' '{test,bench,examples,scripts}/**/*.ts' '*.ts'"
156
156
  },
157
157
  "dependencies": {
158
- "@oh-my-pi/omptype": "18.8.7",
159
- "@oh-my-pi/pi-catalog": "18.8.7",
160
- "@oh-my-pi/pi-natives": "18.8.7",
161
- "@oh-my-pi/pi-utils": "18.8.7",
162
- "@oh-my-pi/pi-wire": "18.8.7"
158
+ "@oh-my-pi/omptype": "18.8.9",
159
+ "@oh-my-pi/pi-catalog": "18.8.9",
160
+ "@oh-my-pi/pi-natives": "18.8.9",
161
+ "@oh-my-pi/pi-utils": "18.8.9",
162
+ "@oh-my-pi/pi-wire": "18.8.9"
163
163
  },
164
164
  "devDependencies": {
165
165
  "@types/bun": "^1.3.14"
@@ -93,7 +93,15 @@ function renderStateField(key: string, value: JsonValue): string {
93
93
  if (typeof value !== "object" || value === null) {
94
94
  return `${open}${escapeXmlText(value === null ? "null" : String(value))}${close}`;
95
95
  }
96
- const yaml = YAML.stringify(value, null, 2).trimEnd();
96
+ const yaml = YAML.stringify(value, null, 2)
97
+ // Bun <=1.4.2 emits `key: ` before a nested block and puts an empty
98
+ // collection on its own line; 1.4.3 emits `key:` and `key: {}`. Pin the
99
+ // newer form so prompt bytes (and prompt-cache keys) do not depend on the
100
+ // Bun build. Values with edge whitespace are always quoted, so trimming
101
+ // line ends never alters data.
102
+ .replace(/[ \t]+$/gm, "")
103
+ .replace(/:\n +(\{\}|\[\])$/gm, ": $1")
104
+ .trimEnd();
97
105
  return `${open}\n${escapeXmlText(yaml)}\n${close}`;
98
106
  }
99
107
 
@@ -15,6 +15,7 @@ import { notifyProviderResponse } from "../../utils/provider-response";
15
15
  import { dereferenceJsonSchema, normalizeSchemaForFactoryDroid, toolWireSchema } from "../../utils/schema";
16
16
  import {
17
17
  extractGoogleErrorMessage,
18
+ googleStreamChunkError,
18
19
  mapGoogleUsage,
19
20
  mapStopReasonString,
20
21
  nextToolCallId,
@@ -87,22 +88,22 @@ const FACTORY_DROID_BLOCK_REASONS: Record<string, true> = {
87
88
  * "unknown" bucket has no StopReason equivalent, so unknown terminators
88
89
  * surface as errors instead of masquerading as a clean stop.
89
90
  */
90
- function mapFactoryDroidFinishReason(reason: string | undefined): {
91
+ function mapFactoryDroidFinishReason(reason: string): {
91
92
  stopReason: "stop" | "length" | "error";
92
93
  errorMessage?: string;
93
94
  } {
94
95
  // mapStopReasonString already implements the CLI's outcome table
95
96
  // (STOP→stop, MAX_TOKENS→length, everything else→error); the cast narrows
96
97
  // its wide StopReason return to the three values it ever produces.
97
- const stopReason = mapStopReasonString(reason ?? "") as "stop" | "length" | "error";
98
+ const stopReason = mapStopReasonString(reason) as "stop" | "length" | "error";
98
99
  if (stopReason !== "error") return { stopReason };
99
- if (reason && FACTORY_DROID_BLOCK_REASONS[reason]) {
100
+ if (FACTORY_DROID_BLOCK_REASONS[reason]) {
100
101
  return { stopReason: "error", errorMessage: `Generation was blocked by content filters (${reason})` };
101
102
  }
102
103
  if (reason === "MALFORMED_FUNCTION_CALL") {
103
104
  return { stopReason: "error", errorMessage: `Generation failed with finish reason: ${reason}` };
104
105
  }
105
- return { stopReason: "error", errorMessage: `Unknown finish reason: ${reason ?? "none"}` };
106
+ return { stopReason: "error", errorMessage: `Unknown finish reason: ${reason}` };
106
107
  }
107
108
 
108
109
  /** Transports whose outputs carry Gemini-verifiable signatures (Factory's own outputs are stamped google-generative-ai). */
@@ -414,6 +415,9 @@ export function streamFactoryDroidGemini(
414
415
  },
415
416
  );
416
417
  for await (const chunk of chunks) {
418
+ // A server-declared error keeps its status and the same classification
419
+ // as the Google provider; only a stream that just stops is a premature close.
420
+ if (chunk.error) throw googleStreamChunkError(chunk.error, model.provider);
417
421
  if (firstTokenTime === undefined && chunk.candidates?.[0]?.content?.parts?.some(part => part.text)) {
418
422
  firstTokenTime = performance.now();
419
423
  }
@@ -484,6 +488,20 @@ export function streamFactoryDroidGemini(
484
488
  if (firstTokenTime !== undefined) output.ttft = firstTokenTime - startTime;
485
489
 
486
490
  closeBlock();
491
+ // A stream that reaches EOF without a finishReason (and without a
492
+ // promptFeedback block) was truncated, even when it already carried
493
+ // functionCall parts; fail it as retryable instead of finishing the turn.
494
+ if (finishReason === undefined && !blockReason) {
495
+ // Worded like the other incomplete-stream errors so turn recovery
496
+ // continues a stream that already rendered text.
497
+ throw new AIError.ProviderResponseError(
498
+ "Factory Droid Gemini stream closed before a finish_reason was received",
499
+ {
500
+ provider: model.provider,
501
+ kind: "incomplete-stream",
502
+ },
503
+ );
504
+ }
487
505
  for (const contentIndex of toolCallIndices) {
488
506
  const toolCall = output.content[contentIndex] as ToolCall;
489
507
  stream.push({ type: "toolcall_end", contentIndex, toolCall, partial: output });
@@ -499,12 +517,12 @@ export function streamFactoryDroidGemini(
499
517
  output.errorMessage = `Generation was blocked by content filters (${blockReason})`;
500
518
  output.stopDetails = { type: "content_filter", category: blockReason };
501
519
  stream.push({ type: "error", reason: "error", error: output });
502
- } else {
520
+ } else if (finishReason !== undefined) {
503
521
  const mapped = mapFactoryDroidFinishReason(finishReason);
504
522
  output.stopReason = mapped.stopReason;
505
523
  if (mapped.errorMessage) {
506
524
  output.errorMessage = mapped.errorMessage;
507
- if (mapped.stopReason === "error" && finishReason && FACTORY_DROID_BLOCK_REASONS[finishReason]) {
525
+ if (mapped.stopReason === "error" && FACTORY_DROID_BLOCK_REASONS[finishReason]) {
508
526
  output.stopDetails = { type: "content_filter", category: finishReason };
509
527
  }
510
528
  }
@@ -595,6 +595,23 @@ export function startTextOrThinkingBlock(
595
595
  return block;
596
596
  }
597
597
 
598
+ /**
599
+ * Error for an in-band `error` frame in a 200 generateContent stream. Keeps the
600
+ * RPC status alongside the message: an in-band quota failure is classified
601
+ * from this text, and `RESOURCE_EXHAUSTED` is the only account-exhaustion
602
+ * signal some of these chunks carry (#13090).
603
+ */
604
+ export function googleStreamChunkError(error: NonNullable<GenerateContentResponse["error"]>, provider: string): Error {
605
+ const detail =
606
+ error.message && error.status
607
+ ? `${error.message} (${error.status})`
608
+ : error.message || error.status || "unknown error";
609
+ const message = `Google API stream error: ${detail}`;
610
+ return typeof error.code === "number" && error.code >= 400
611
+ ? new AIError.GoogleApiError(message, error.code)
612
+ : new AIError.ProviderResponseError(message, { provider, kind: "output" });
613
+ }
614
+
598
615
  /**
599
616
  * Drives the chunked `generateContentStream` iterator into an `AssistantMessage` and
600
617
  * the corresponding `AssistantMessageEventStream`. Shared between `streamGoogle` and
@@ -645,19 +662,7 @@ export async function consumeGoogleStream<T extends GoogleApiType>(args: {
645
662
  };
646
663
 
647
664
  for await (const chunk of googleStream) {
648
- if (chunk.error) {
649
- // Keep the RPC status alongside the message: an in-band quota failure
650
- // is classified from this text, and `RESOURCE_EXHAUSTED` is the only
651
- // account-exhaustion signal some of these chunks carry (#13090).
652
- const detail =
653
- chunk.error.message && chunk.error.status
654
- ? `${chunk.error.message} (${chunk.error.status})`
655
- : chunk.error.message || chunk.error.status || "unknown error";
656
- const message = `Google API stream error: ${detail}`;
657
- throw typeof chunk.error.code === "number" && chunk.error.code >= 400
658
- ? new AIError.GoogleApiError(message, chunk.error.code)
659
- : new AIError.ProviderResponseError(message, { provider: model.provider, kind: "output" });
660
- }
665
+ if (chunk.error) throw googleStreamChunkError(chunk.error, model.provider);
661
666
  if (!chunk.candidates?.length && chunk.promptFeedback?.blockReason) {
662
667
  const detail = chunk.promptFeedback.blockReasonMessage;
663
668
  throw new AIError.ProviderResponseError(
@@ -2637,11 +2637,17 @@ class CodexStreamProcessor {
2637
2637
  }
2638
2638
 
2639
2639
  if (item.type === "function_call") {
2640
+ const partial = block?.type === "toolCall" ? block[kStreamingPartialJson] : undefined;
2640
2641
  const toolCall: ToolCall = {
2641
2642
  type: "toolCall",
2642
2643
  id: encodeResponsesToolCallId(item.call_id, item.id),
2643
2644
  name: item.name,
2644
- arguments: parseToolCallArguments(item.arguments),
2645
+ // An empty terminal item falls back to what `.delta`/`.done` already delivered.
2646
+ arguments: item.arguments
2647
+ ? parseToolCallArguments(item.arguments)
2648
+ : block?.type === "toolCall" && !partial
2649
+ ? block.arguments
2650
+ : parseToolCallArguments(partial),
2645
2651
  };
2646
2652
  item.arguments = replayableToolCallArguments(item.arguments, toolCall.arguments);
2647
2653
  if (block?.type === "toolCall") {
@@ -395,7 +395,14 @@ async function* resumeOpenAIResponsesEventStream(
395
395
 
396
396
  interface OpenAIResponsesProviderSessionState
397
397
  extends ProviderSessionState, OpenAIStrictToolsState, OpenAIReasoningEffortFallbackState {
398
+ /**
399
+ * Replay native history items. Starts false only on connection-bound hosts;
400
+ * `close()` clears it so the next request rebuilds history from message
401
+ * content; the first successful response sets it.
402
+ */
398
403
  nativeHistoryReplayWarmed: boolean;
404
+ /** Bumped by `close()`; a response warms the state only if no close happened since its request started. */
405
+ closeCount: number;
399
406
  /** Stateful `previous_response_id` chain baselines, keyed by baseUrl/model/session. */
400
407
  chains: Map<string, OpenAIResponsesChainState>;
401
408
  /** `configuration_update` effort baselines, keyed by baseUrl/model/session. */
@@ -423,13 +430,16 @@ interface OpenAIResponsesChainState {
423
430
  disabled: boolean;
424
431
  }
425
432
 
426
- function createOpenAIResponsesProviderSessionState(): OpenAIResponsesProviderSessionState {
433
+ function createOpenAIResponsesProviderSessionState(
434
+ model: Model<"openai-responses">,
435
+ ): OpenAIResponsesProviderSessionState {
427
436
  const strictToolsState = createOpenAIStrictToolsState();
428
437
  const reasoningEffortFallbackState = createOpenAIReasoningEffortFallbackState();
429
438
  const state: OpenAIResponsesProviderSessionState = {
430
439
  ...strictToolsState,
431
440
  ...reasoningEffortFallbackState,
432
- nativeHistoryReplayWarmed: false,
441
+ nativeHistoryReplayWarmed: !model.compat.connectionBoundNativeHistory,
442
+ closeCount: 0,
433
443
  chains: new Map(),
434
444
  effortControls: new Map(),
435
445
  releaseSession: sessionId => {
@@ -442,6 +452,7 @@ function createOpenAIResponsesProviderSessionState(): OpenAIResponsesProviderSes
442
452
  },
443
453
  close: () => {
444
454
  state.nativeHistoryReplayWarmed = false;
455
+ state.closeCount++;
445
456
  state.chains.clear();
446
457
  state.effortControls.clear();
447
458
  clearOpenAIStrictToolsState(state);
@@ -459,7 +470,7 @@ function getOpenAIResponsesProviderSessionState(
459
470
  const key = `${OPENAI_RESPONSES_PROVIDER_SESSION_STATE_PREFIX}${model.provider}`;
460
471
  const existing = providerSessionState.get(key) as OpenAIResponsesProviderSessionState | undefined;
461
472
  if (existing) return existing;
462
- const created = createOpenAIResponsesProviderSessionState();
473
+ const created = createOpenAIResponsesProviderSessionState(model);
463
474
  providerSessionState.set(key, created);
464
475
  return created;
465
476
  }
@@ -733,6 +744,7 @@ const streamOpenAIResponsesOnce = (
733
744
  });
734
745
  const premiumRequestsTotal = copilotPremiumRequests;
735
746
  const providerSessionState = getOpenAIResponsesProviderSessionState(model, options?.providerSessionState);
747
+ const closeCountAtStart = providerSessionState?.closeCount;
736
748
  const strictToolsScope = getOpenAIStrictToolsScope(model, baseUrl);
737
749
  const promptCacheBreakpointPolicy =
738
750
  resolveCacheRetention(options?.cacheRetention) !== "none" && options?.promptCache?.mode === "explicit"
@@ -1250,7 +1262,9 @@ const streamOpenAIResponsesOnce = (
1250
1262
  { supportsImageDetailOriginal: model.compat.supportsImageDetailOriginal },
1251
1263
  );
1252
1264
  if (replayableResponseItems) {
1253
- if (providerSessionState) providerSessionState.nativeHistoryReplayWarmed = true;
1265
+ if (providerSessionState && providerSessionState.closeCount === closeCountAtStart) {
1266
+ providerSessionState.nativeHistoryReplayWarmed = true;
1267
+ }
1254
1268
  if (chainState) {
1255
1269
  chainState.lastParams = cloneJsonTree(
1256
1270
  activeTrailingScaffoldingItems > 0 && Array.isArray(activeParams.input)
@@ -3052,7 +3052,8 @@ export function accumulateToolCallArgumentsDelta(
3052
3052
  contentIndex: number,
3053
3053
  ): void {
3054
3054
  delta = optionalResponsesText(delta, "function call arguments delta") ?? "";
3055
- if (!delta) return;
3055
+ // A non-blank `.done` consumed the buffer; a late delta must not reopen it.
3056
+ if (!delta || block[kStreamingPartialJson] === undefined) return;
3056
3057
  block[kStreamingPartialJson] += delta;
3057
3058
  const throttled = parseStreamingJsonThrottled(block[kStreamingPartialJson], block[kStreamingLastParseLen] ?? 0);
3058
3059
  if (throttled) {
@@ -3066,9 +3067,14 @@ export function accumulateToolCallArgumentsDelta(
3066
3067
  * Finalize streamed function-call arguments from the authoritative `.done`
3067
3068
  * payload. The caller owns the `argumentsDone` flag (generic Responses sets it;
3068
3069
  * Codex's block shape has no such field), so this only rewrites `arguments` and
3069
- * drops the transient accumulation fields.
3070
+ * drops the transient accumulation fields. A blank payload is not authoritative:
3071
+ * it parses the buffered deltas and keeps them so the final item can still win.
3070
3072
  */
3071
3073
  export function finalizeToolCallArgumentsDone(block: ResponsesToolCallBlock, args: string): void {
3074
+ if (!args.trim()) {
3075
+ block.arguments = parseToolCallArguments(block[kStreamingPartialJson]);
3076
+ return;
3077
+ }
3072
3078
  block[kStreamingPartialJson] = args;
3073
3079
  block.arguments = parseToolCallArguments(block[kStreamingPartialJson]);
3074
3080
  clearStreamingPartialJson(block);
@@ -3638,11 +3644,13 @@ export async function processResponsesStream<TApi extends Api>(
3638
3644
  closeOpenItem(event.output_index, item.id, entry);
3639
3645
  } else if (item.type === "function_call") {
3640
3646
  const block = entry?.block.type === "toolCall" ? entry.block : undefined;
3641
- const args = block?.[kStreamingArgumentsDone]
3642
- ? block.arguments
3643
- : item.arguments
3644
- ? parseToolCallArguments(item.arguments)
3645
- : parseToolCallArguments(block?.[kStreamingPartialJson]);
3647
+ // A blank `.done` leaves the delta buffer in place, so the item's own arguments still win.
3648
+ const args =
3649
+ block?.[kStreamingArgumentsDone] && block[kStreamingPartialJson] === undefined
3650
+ ? block.arguments
3651
+ : item.arguments
3652
+ ? parseToolCallArguments(item.arguments)
3653
+ : parseToolCallArguments(block?.[kStreamingPartialJson]);
3646
3654
  item.arguments = replayableToolCallArguments(item.arguments, args);
3647
3655
  const toolCall: ToolCall = {
3648
3656
  type: "toolCall",