@oh-my-pi/pi-ai 15.11.0 → 15.11.1

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
+ ## [15.11.1] - 2026-06-11
6
+
7
+ ### Changed
8
+
9
+ - Exported `resolveAnthropicMetadataUserId` so non-streaming Anthropic Messages consumers (e.g. the coding-agent web search provider) can produce the same Claude-Code-shaped `metadata.user_id` as the main streaming path.
10
+
11
+ ### Fixed
12
+
13
+ - Preserved Anthropic `stop_details` on assistant messages so refusal and sensitive classifier stops remain structurally visible to callers. ([#2290](https://github.com/can1357/oh-my-pi/issues/2290))
14
+ - Fixed OpenAI Responses, Azure OpenAI Responses, and OpenAI Completions streams hanging until the 120s idle watchdog errored the turn when a provider delivers the terminal frame but never sends `[DONE]` nor closes the connection. `processResponsesStream` now breaks out of the event loop on `response.completed`/`response.incomplete` (mirroring the Codex websocket/SSE terminal break), and the completions consumer breaks once `finish_reason` plus a usage payload arrived — or, for hosts that never send usage, ends the stream cleanly via a short post-finish grace window (`iterateWithTerminalGrace`) that aborts the transport to release the socket.
15
+
5
16
  ## [15.11.0] - 2026-06-10
6
17
 
7
18
  ### Added
@@ -54,6 +54,18 @@ export declare function wrapFetchForCch(base: FetchImpl): FetchImpl;
54
54
  export declare function isClaudeCloakingUserId(userId: string): boolean;
55
55
  export declare function generateClaudeCloakingUserId(): string;
56
56
  export declare function deriveClaudeDeviceId(installId: string, accountId?: string): string;
57
+ /**
58
+ * Resolve the `metadata.user_id` field for an Anthropic Messages request.
59
+ *
60
+ * For API-key tokens, an explicit caller-supplied `userId` is forwarded
61
+ * verbatim and `undefined` yields no metadata. For OAuth tokens the value
62
+ * must match the Claude Code attribution shape (`isClaudeCloakingUserId` or
63
+ * the `{session_id, account_uuid?, device_id?}` JSON envelope) — anything
64
+ * else is dropped and a fresh Claude-Code-style JSON id is generated from
65
+ * `sessionId`/`accountId` so attribution stays consistent across the main
66
+ * streaming path and provider-specific request builders (e.g. web search).
67
+ */
68
+ export declare function resolveAnthropicMetadataUserId(userId: unknown, isOAuthToken: boolean, sessionId?: string, accountId?: string): string | undefined;
57
69
  export declare const applyClaudeToolPrefix: (name: string) => string;
58
70
  export declare const stripClaudeToolPrefix: (name: string) => string;
59
71
  export type AnthropicEffort = "low" | "medium" | "high" | "xhigh" | "max";
@@ -41,7 +41,7 @@
41
41
  * // Inspect calls afterwards.
42
42
  * expect(mock.calls).toHaveLength(2);
43
43
  */
44
- import type { Api, Context, Model, SimpleStreamOptions, StopReason, Usage } from "../types";
44
+ import type { Api, Context, Model, SimpleStreamOptions, StopDetails, StopReason, Usage } from "../types";
45
45
  import { AssistantMessageEventStream } from "../utils/event-stream";
46
46
  /** The API string this provider serves. */
47
47
  export declare const MOCK_API: "mock";
@@ -67,6 +67,10 @@ export interface MockResponse {
67
67
  content?: ReadonlyArray<MockContent>;
68
68
  /** Stop reason. Defaults to `"toolUse"` when content has tool calls, else `"stop"`. */
69
69
  stopReason?: StopReason;
70
+ /** Structured terminal stop classification, e.g. Anthropic refusal metadata. */
71
+ stopDetails?: StopDetails | null;
72
+ /** Error text paired with an explicit `"error"` stop reason. */
73
+ errorMessage?: string;
70
74
  /** Usage stats. Missing fields default to 0; missing `cost.total` is recomputed from components. */
71
75
  usage?: Partial<Omit<Usage, "cost">> & {
72
76
  cost?: Partial<Usage["cost"]>;
@@ -7,6 +7,7 @@ import type { ZodType, z } from "zod/v4";
7
7
  import type { ApiKey } from "./auth-retry";
8
8
  import type { BedrockOptions } from "./providers/amazon-bedrock";
9
9
  import type { AnthropicOptions } from "./providers/anthropic";
10
+ import type { StopDetails } from "./providers/anthropic-wire";
10
11
  import type { AzureOpenAIResponsesOptions } from "./providers/azure-openai-responses";
11
12
  import type { CursorOptions } from "./providers/cursor";
12
13
  import type { GoogleOptions } from "./providers/google";
@@ -17,6 +18,7 @@ import type { OpenAICodexResponsesOptions } from "./providers/openai-codex-respo
17
18
  import type { OpenAICompletionsOptions } from "./providers/openai-completions";
18
19
  import type { OpenAIResponsesOptions } from "./providers/openai-responses";
19
20
  import type { AssistantMessageEventStream } from "./utils/event-stream";
21
+ export type { StopDetails } from "./providers/anthropic-wire";
20
22
  export type { AssistantMessageEventStream } from "./utils/event-stream";
21
23
  /**
22
24
  * Ceiling on the output-token count omp requests from any OpenAI-family endpoint
@@ -398,6 +400,7 @@ export interface AssistantMessage {
398
400
  upstreamProvider?: string;
399
401
  usage: Usage;
400
402
  stopReason: StopReason;
403
+ stopDetails?: StopDetails | null;
401
404
  errorMessage?: string;
402
405
  /** HTTP status surfaced by the provider when the request failed. Populated by every provider's catch block alongside `errorMessage` so consumers (auth retry, telemetry, UI) can branch without regex-scraping the message. */
403
406
  errorStatus?: number;
@@ -76,3 +76,38 @@ export interface IdleTimeoutIteratorOptions {
76
76
  * before any user-visible content has streamed.
77
77
  */
78
78
  export declare function iterateWithIdleTimeout<T>(iterable: AsyncIterable<T>, options: IdleTimeoutIteratorOptions): AsyncGenerator<T>;
79
+ export interface TerminalGraceIteratorOptions {
80
+ /**
81
+ * Epoch-ms timestamp at which the consumer observed a logically terminal
82
+ * item (e.g. a chat-completions chunk carrying `finish_reason`), or
83
+ * `undefined` while the stream is still mid-response. Read before every
84
+ * pull, so the consumer can flip it between yields.
85
+ */
86
+ finishedAtMs: () => number | undefined;
87
+ /**
88
+ * Post-terminal budget: how long after `finishedAtMs()` to keep draining
89
+ * trailing items (e.g. a usage-only chunk or the `[DONE]` sentinel) before
90
+ * ending the iteration cleanly. The deadline is fixed at
91
+ * `finishedAtMs() + graceMs`; trailing items do not extend it, so
92
+ * keepalive-only servers cannot hold the stream open.
93
+ */
94
+ graceMs: number;
95
+ /**
96
+ * Invoked when the grace window closes with the source still open. Use it
97
+ * to abort the underlying request: the source generator is typically parked
98
+ * mid-`next()` (not at a yield), so a queued `.return()` alone cannot reach
99
+ * the transport until that pending read settles.
100
+ */
101
+ onGraceEnd?: () => void;
102
+ }
103
+ /**
104
+ * Yields items from an async iterable until the consumer marks the stream
105
+ * logically finished AND the source stays silent past a short grace window.
106
+ *
107
+ * Misbehaving OpenAI-compatible servers deliver the terminal chunk but never
108
+ * send `[DONE]` nor close the connection; without this guard the consumer
109
+ * hangs on `iterator.next()` until the idle watchdog converts an
110
+ * already-successful turn into a timeout error. Grace expiry is a clean end
111
+ * of iteration, never an error.
112
+ */
113
+ export declare function iterateWithTerminalGrace<T>(iterable: AsyncIterable<T>, options: TerminalGraceIteratorOptions): AsyncGenerator<T>;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@oh-my-pi/pi-ai",
4
- "version": "15.11.0",
4
+ "version": "15.11.1",
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,8 +38,8 @@
38
38
  },
39
39
  "dependencies": {
40
40
  "@bufbuild/protobuf": "^2.12.0",
41
- "@oh-my-pi/pi-catalog": "15.11.0",
42
- "@oh-my-pi/pi-utils": "15.11.0",
41
+ "@oh-my-pi/pi-catalog": "15.11.1",
42
+ "@oh-my-pi/pi-utils": "15.11.1",
43
43
  "openai": "^6.39.0",
44
44
  "partial-json": "^0.1.7",
45
45
  "zod": "4.4.3"
@@ -677,7 +677,18 @@ function generateClaudeJsonUserId(sessionId?: string, accountId?: string): strin
677
677
  return JSON.stringify(userId);
678
678
  }
679
679
 
680
- function resolveAnthropicMetadataUserId(
680
+ /**
681
+ * Resolve the `metadata.user_id` field for an Anthropic Messages request.
682
+ *
683
+ * For API-key tokens, an explicit caller-supplied `userId` is forwarded
684
+ * verbatim and `undefined` yields no metadata. For OAuth tokens the value
685
+ * must match the Claude Code attribution shape (`isClaudeCloakingUserId` or
686
+ * the `{session_id, account_uuid?, device_id?}` JSON envelope) — anything
687
+ * else is dropped and a fresh Claude-Code-style JSON id is generated from
688
+ * `sessionId`/`accountId` so attribution stays consistent across the main
689
+ * streaming path and provider-specific request builders (e.g. web search).
690
+ */
691
+ export function resolveAnthropicMetadataUserId(
681
692
  userId: unknown,
682
693
  isOAuthToken: boolean,
683
694
  sessionId?: string,
@@ -1992,6 +2003,7 @@ export const streamAnthropic: StreamFunction<"anthropic-messages"> = (
1992
2003
  }
1993
2004
  if (output.stopReason === "error") {
1994
2005
  const stopDetails = delta?.stop_details;
2006
+ output.stopDetails = stopDetails ?? (rawStopReason ? { type: rawStopReason } : null);
1995
2007
  if (stopDetails?.type === "refusal") {
1996
2008
  const explanation = stopDetails.explanation?.trim();
1997
2009
  const category = stopDetails.category;
@@ -2151,6 +2163,7 @@ export const streamAnthropic: StreamFunction<"anthropic-messages"> = (
2151
2163
  output.content.length = 0;
2152
2164
  output.responseId = undefined;
2153
2165
  output.errorMessage = undefined;
2166
+ output.stopDetails = undefined;
2154
2167
  output.providerPayload = undefined;
2155
2168
  output.usage = createEmptyUsage(copilotDynamicHeaders?.premiumRequests);
2156
2169
  output.stopReason = "stop";
@@ -49,6 +49,7 @@ import type {
49
49
  Context,
50
50
  Model,
51
51
  SimpleStreamOptions,
52
+ StopDetails,
52
53
  StopReason,
53
54
  TextContent,
54
55
  ThinkingContent,
@@ -81,6 +82,10 @@ export interface MockResponse {
81
82
  content?: ReadonlyArray<MockContent>;
82
83
  /** Stop reason. Defaults to `"toolUse"` when content has tool calls, else `"stop"`. */
83
84
  stopReason?: StopReason;
85
+ /** Structured terminal stop classification, e.g. Anthropic refusal metadata. */
86
+ stopDetails?: StopDetails | null;
87
+ /** Error text paired with an explicit `"error"` stop reason. */
88
+ errorMessage?: string;
84
89
  /** Usage stats. Missing fields default to 0; missing `cost.total` is recomputed from components. */
85
90
  usage?: Partial<Omit<Usage, "cost">> & { cost?: Partial<Usage["cost"]> };
86
91
  /** Pre-set responseId. */
@@ -389,6 +394,8 @@ async function runMock(
389
394
  const reason: StopReason = response.stopReason ?? (hasToolCall ? ("toolUse" as StopReason) : ("stop" as StopReason));
390
395
 
391
396
  partial.stopReason = reason;
397
+ partial.stopDetails = response.stopDetails;
398
+ partial.errorMessage = response.errorMessage;
392
399
  partial.usage = mergeUsage(response.usage);
393
400
  partial.duration = Date.now() - startedAt;
394
401
 
@@ -56,6 +56,7 @@ import {
56
56
  getOpenAIStreamFirstEventTimeoutMs,
57
57
  getOpenAIStreamIdleTimeoutMs,
58
58
  iterateWithIdleTimeout,
59
+ iterateWithTerminalGrace,
59
60
  } from "../utils/idle-iterator";
60
61
  import { parseStreamingJson, parseStreamingJsonThrottled } from "../utils/json-parse";
61
62
  import { notifyProviderResponse } from "../utils/provider-response";
@@ -392,6 +393,13 @@ function getTrailingPartialDeepseekToken(text: string): string {
392
393
  }
393
394
  const OPENAI_COMPLETIONS_FIRST_EVENT_TIMEOUT_MESSAGE =
394
395
  "OpenAI completions stream timed out while waiting for the first event";
396
+ // How long to keep draining the stream after a `finish_reason` chunk arrived.
397
+ // Compliant hosts follow it (almost) immediately with an optional usage-only
398
+ // chunk and the `[DONE]` sentinel, so the window only ever elapses on hosts
399
+ // that hold the connection open after the response logically completed —
400
+ // without it the turn parks on `iterator.next()` until the idle watchdog
401
+ // converts the already-successful response into a timeout error.
402
+ const OPENAI_COMPLETIONS_POST_FINISH_GRACE_MS = 2_500;
395
403
 
396
404
  async function* observeDecodedOpenAICompletionChunks(
397
405
  chunks: AsyncIterable<ChatCompletionChunk>,
@@ -748,6 +756,11 @@ export const streamOpenAICompletions: StreamFunction<"openai-completions"> = (
748
756
  for (const call of calls) emitHealedToolCall(call);
749
757
  };
750
758
 
759
+ // Terminal-chunk bookkeeping for the post-finish grace window below.
760
+ // `streamFinishedAt` flips when a chunk carries `finish_reason`;
761
+ // `sawUsagePayload` flips when any usage payload was parsed.
762
+ let streamFinishedAt: number | undefined;
763
+ let sawUsagePayload = false;
751
764
  const timedOpenaiStream = iterateWithIdleTimeout(openaiStream, {
752
765
  idleTimeoutMs,
753
766
  firstItemTimeoutMs: firstEventTimeoutMs,
@@ -761,7 +774,16 @@ export const streamOpenAICompletions: StreamFunction<"openai-completions"> = (
761
774
  const observedOpenaiStream = rawSseObserver
762
775
  ? observeDecodedOpenAICompletionChunks(timedOpenaiStream, rawSseObserver)
763
776
  : timedOpenaiStream;
764
- for await (const chunk of observedOpenaiStream) {
777
+ const terminalAwareStream = iterateWithTerminalGrace(observedOpenaiStream, {
778
+ finishedAtMs: () => streamFinishedAt,
779
+ graceMs: OPENAI_COMPLETIONS_POST_FINISH_GRACE_MS,
780
+ // The inner idle-timeout generator is parked mid-`next()` when the
781
+ // grace window closes, so abort the transport to settle that read
782
+ // and release the socket immediately (a queued `.return()` alone
783
+ // would wait on the never-arriving next chunk).
784
+ onGraceEnd: () => requestAbortController.abort(),
785
+ });
786
+ for await (const chunk of terminalAwareStream) {
765
787
  if (!chunk || typeof chunk !== "object") continue;
766
788
 
767
789
  // OpenAI documents ChatCompletionChunk.id as the unique chat completion identifier,
@@ -776,15 +798,23 @@ export const streamOpenAICompletions: StreamFunction<"openai-completions"> = (
776
798
 
777
799
  if (chunk.usage) {
778
800
  output.usage = parseChunkUsage(chunk.usage, model, premiumRequestsTotal);
801
+ sawUsagePayload = true;
779
802
  }
780
803
 
781
804
  const choice = Array.isArray(chunk.choices) ? chunk.choices[0] : undefined;
782
- if (!choice) continue;
805
+ if (!choice) {
806
+ // Trailing usage-only chunk (`stream_options.include_usage`) after
807
+ // `finish_reason`: the response is complete — stop pulling instead
808
+ // of waiting for `[DONE]`/close from hosts that never send either.
809
+ if (streamFinishedAt !== undefined && sawUsagePayload) break;
810
+ continue;
811
+ }
783
812
 
784
813
  if (!chunk.usage) {
785
814
  const choiceUsage = getChoiceUsage(choice);
786
815
  if (choiceUsage) {
787
816
  output.usage = parseChunkUsage(choiceUsage, model, premiumRequestsTotal);
817
+ sawUsagePayload = true;
788
818
  }
789
819
  }
790
820
 
@@ -794,6 +824,7 @@ export const streamOpenAICompletions: StreamFunction<"openai-completions"> = (
794
824
  if (finishReasonResult.errorMessage) {
795
825
  output.errorMessage = finishReasonResult.errorMessage;
796
826
  }
827
+ streamFinishedAt ??= Date.now();
797
828
  }
798
829
 
799
830
  if (choice.delta) {
@@ -967,6 +998,12 @@ export const streamOpenAICompletions: StreamFunction<"openai-completions"> = (
967
998
  }
968
999
  }
969
1000
  }
1001
+
1002
+ // `finish_reason` + usage both observed: the chat-completions
1003
+ // contract has nothing left to deliver. Break instead of waiting
1004
+ // for `[DONE]`/connection close so hosts that hold the socket open
1005
+ // can't park the turn until the idle watchdog errors it out.
1006
+ if (streamFinishedAt !== undefined && sawUsagePayload) break;
970
1007
  }
971
1008
 
972
1009
  if (streamMarkupHealing) {
@@ -914,6 +914,14 @@ export async function processResponsesStream<TApi extends Api>(
914
914
  output.stopReason = "toolUse";
915
915
  }
916
916
  options?.onCompleted?.();
917
+ // `response.completed`/`response.incomplete` is the last event of a
918
+ // Responses stream. Stop pulling instead of waiting for the server to
919
+ // close the connection: misbehaving providers keep the socket open
920
+ // after the terminal event, which would park this loop until the idle
921
+ // watchdog converts an already-successful turn into a timeout error.
922
+ // Breaking unwinds the iterator chain (the consumer's `.return()`
923
+ // reaches the SDK stream), actively releasing the connection.
924
+ break;
917
925
  } else if (event.type === "error") {
918
926
  throw new Error(`Error Code ${event.code}: ${event.message}`);
919
927
  } else if (event.type === "response.failed") {
package/src/types.ts CHANGED
@@ -24,6 +24,7 @@ import type { ZodType, z } from "zod/v4";
24
24
  import type { ApiKey } from "./auth-retry";
25
25
  import type { BedrockOptions } from "./providers/amazon-bedrock";
26
26
  import type { AnthropicOptions } from "./providers/anthropic";
27
+ import type { StopDetails } from "./providers/anthropic-wire";
27
28
  import type { AzureOpenAIResponsesOptions } from "./providers/azure-openai-responses";
28
29
  import type { CursorOptions } from "./providers/cursor";
29
30
  import type { GoogleOptions } from "./providers/google";
@@ -35,6 +36,7 @@ import type { OpenAICompletionsOptions } from "./providers/openai-completions";
35
36
  import type { OpenAIResponsesOptions } from "./providers/openai-responses";
36
37
  import type { AssistantMessageEventStream } from "./utils/event-stream";
37
38
 
39
+ export type { StopDetails } from "./providers/anthropic-wire";
38
40
  export type { AssistantMessageEventStream } from "./utils/event-stream";
39
41
 
40
42
  /**
@@ -486,6 +488,7 @@ export interface AssistantMessage {
486
488
  upstreamProvider?: string;
487
489
  usage: Usage;
488
490
  stopReason: StopReason;
491
+ stopDetails?: StopDetails | null;
489
492
  errorMessage?: string;
490
493
  /** HTTP status surfaced by the provider when the request failed. Populated by every provider's catch block alongside `errorMessage` so consumers (auth retry, telemetry, UI) can branch without regex-scraping the message. */
491
494
  errorStatus?: number;
@@ -347,6 +347,89 @@ export async function* iterateWithIdleTimeout<T>(
347
347
  }
348
348
  }
349
349
 
350
+ export interface TerminalGraceIteratorOptions {
351
+ /**
352
+ * Epoch-ms timestamp at which the consumer observed a logically terminal
353
+ * item (e.g. a chat-completions chunk carrying `finish_reason`), or
354
+ * `undefined` while the stream is still mid-response. Read before every
355
+ * pull, so the consumer can flip it between yields.
356
+ */
357
+ finishedAtMs: () => number | undefined;
358
+ /**
359
+ * Post-terminal budget: how long after `finishedAtMs()` to keep draining
360
+ * trailing items (e.g. a usage-only chunk or the `[DONE]` sentinel) before
361
+ * ending the iteration cleanly. The deadline is fixed at
362
+ * `finishedAtMs() + graceMs`; trailing items do not extend it, so
363
+ * keepalive-only servers cannot hold the stream open.
364
+ */
365
+ graceMs: number;
366
+ /**
367
+ * Invoked when the grace window closes with the source still open. Use it
368
+ * to abort the underlying request: the source generator is typically parked
369
+ * mid-`next()` (not at a yield), so a queued `.return()` alone cannot reach
370
+ * the transport until that pending read settles.
371
+ */
372
+ onGraceEnd?: () => void;
373
+ }
374
+
375
+ /**
376
+ * Yields items from an async iterable until the consumer marks the stream
377
+ * logically finished AND the source stays silent past a short grace window.
378
+ *
379
+ * Misbehaving OpenAI-compatible servers deliver the terminal chunk but never
380
+ * send `[DONE]` nor close the connection; without this guard the consumer
381
+ * hangs on `iterator.next()` until the idle watchdog converts an
382
+ * already-successful turn into a timeout error. Grace expiry is a clean end
383
+ * of iteration, never an error.
384
+ */
385
+ export async function* iterateWithTerminalGrace<T>(
386
+ iterable: AsyncIterable<T>,
387
+ options: TerminalGraceIteratorOptions,
388
+ ): AsyncGenerator<T> {
389
+ const iterator = iterable[Symbol.asyncIterator]();
390
+ try {
391
+ while (true) {
392
+ const finishedAtMs = options.finishedAtMs();
393
+ if (finishedAtMs === undefined) {
394
+ const result = await iterator.next();
395
+ if (result.done) return;
396
+ yield result.value;
397
+ continue;
398
+ }
399
+ const remainingMs = finishedAtMs + options.graceMs - Date.now();
400
+ if (remainingMs <= 0) {
401
+ options.onGraceEnd?.();
402
+ return;
403
+ }
404
+ const nextPromise = iterator.next();
405
+ let timer: NodeJS.Timeout | undefined;
406
+ const timeoutPromise = new Promise<"timeout">(resolve => {
407
+ timer = setTimeout(() => resolve("timeout"), remainingMs);
408
+ });
409
+ try {
410
+ const outcome = await Promise.race([nextPromise, timeoutPromise]);
411
+ if (outcome === "timeout") {
412
+ // The abandoned read settles (likely rejects) once onGraceEnd
413
+ // aborts the transport — mark it handled so it cannot surface
414
+ // as an unhandled rejection.
415
+ nextPromise.catch(() => {});
416
+ options.onGraceEnd?.();
417
+ return;
418
+ }
419
+ if (outcome.done) return;
420
+ yield outcome.value;
421
+ } finally {
422
+ if (timer !== undefined) clearTimeout(timer);
423
+ }
424
+ }
425
+ } finally {
426
+ const returnPromise = iterator.return?.();
427
+ if (returnPromise) {
428
+ void Promise.resolve(returnPromise).catch(() => {});
429
+ }
430
+ }
431
+ }
432
+
350
433
  function abortReason(signal: AbortSignal): Error {
351
434
  const reason = signal.reason;
352
435
  if (reason instanceof Error) return reason;