@oh-my-pi/pi-ai 15.11.0 → 15.11.2
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 +17 -0
- package/dist/types/providers/anthropic.d.ts +12 -0
- package/dist/types/providers/mock.d.ts +5 -1
- package/dist/types/types.d.ts +3 -0
- package/dist/types/utils/idle-iterator.d.ts +35 -0
- package/package.json +3 -3
- package/src/providers/anthropic.ts +50 -10
- package/src/providers/mock.ts +7 -0
- package/src/providers/openai-completions.ts +39 -2
- package/src/providers/openai-responses-shared.ts +8 -0
- package/src/types.ts +3 -0
- package/src/utils/idle-iterator.ts +83 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,23 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [15.11.2] - 2026-06-11
|
|
6
|
+
|
|
7
|
+
### Fixed
|
|
8
|
+
|
|
9
|
+
- Fixed Anthropic encoding of error tool results with whitespace-only content so requests no longer 400 with `tool_result: content cannot be empty if is_error is true`
|
|
10
|
+
|
|
11
|
+
## [15.11.1] - 2026-06-11
|
|
12
|
+
|
|
13
|
+
### Changed
|
|
14
|
+
|
|
15
|
+
- 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.
|
|
16
|
+
|
|
17
|
+
### Fixed
|
|
18
|
+
|
|
19
|
+
- 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))
|
|
20
|
+
- 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.
|
|
21
|
+
|
|
5
22
|
## [15.11.0] - 2026-06-10
|
|
6
23
|
|
|
7
24
|
### 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"]>;
|
package/dist/types/types.d.ts
CHANGED
|
@@ -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.
|
|
4
|
+
"version": "15.11.2",
|
|
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.
|
|
42
|
-
"@oh-my-pi/pi-utils": "15.11.
|
|
41
|
+
"@oh-my-pi/pi-catalog": "15.11.2",
|
|
42
|
+
"@oh-my-pi/pi-utils": "15.11.2",
|
|
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
|
-
|
|
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,
|
|
@@ -862,13 +873,7 @@ async function prepareAnthropicManyImageContext(context: Context, supportsImages
|
|
|
862
873
|
return { ...context, messages };
|
|
863
874
|
}
|
|
864
875
|
|
|
865
|
-
|
|
866
|
-
* Convert content blocks to Anthropic API format
|
|
867
|
-
*/
|
|
868
|
-
function convertContentBlocks(
|
|
869
|
-
content: (TextContent | ImageContent)[],
|
|
870
|
-
supportsImages = true,
|
|
871
|
-
):
|
|
876
|
+
type AnthropicToolResultContent =
|
|
872
877
|
| string
|
|
873
878
|
| Array<
|
|
874
879
|
| { type: "text"; text: string }
|
|
@@ -880,7 +885,15 @@ function convertContentBlocks(
|
|
|
880
885
|
data: string;
|
|
881
886
|
};
|
|
882
887
|
}
|
|
883
|
-
|
|
888
|
+
>;
|
|
889
|
+
|
|
890
|
+
/**
|
|
891
|
+
* Convert content blocks to Anthropic API format
|
|
892
|
+
*/
|
|
893
|
+
function convertContentBlocks(
|
|
894
|
+
content: (TextContent | ImageContent)[],
|
|
895
|
+
supportsImages = true,
|
|
896
|
+
): AnthropicToolResultContent {
|
|
884
897
|
const blocks: Array<
|
|
885
898
|
| { type: "text"; text: string }
|
|
886
899
|
| {
|
|
@@ -1992,6 +2005,7 @@ export const streamAnthropic: StreamFunction<"anthropic-messages"> = (
|
|
|
1992
2005
|
}
|
|
1993
2006
|
if (output.stopReason === "error") {
|
|
1994
2007
|
const stopDetails = delta?.stop_details;
|
|
2008
|
+
output.stopDetails = stopDetails ?? (rawStopReason ? { type: rawStopReason } : null);
|
|
1995
2009
|
if (stopDetails?.type === "refusal") {
|
|
1996
2010
|
const explanation = stopDetails.explanation?.trim();
|
|
1997
2011
|
const category = stopDetails.category;
|
|
@@ -2151,6 +2165,7 @@ export const streamAnthropic: StreamFunction<"anthropic-messages"> = (
|
|
|
2151
2165
|
output.content.length = 0;
|
|
2152
2166
|
output.responseId = undefined;
|
|
2153
2167
|
output.errorMessage = undefined;
|
|
2168
|
+
output.stopDetails = undefined;
|
|
2154
2169
|
output.providerPayload = undefined;
|
|
2155
2170
|
output.usage = createEmptyUsage(copilotDynamicHeaders?.premiumRequests);
|
|
2156
2171
|
output.stopReason = "stop";
|
|
@@ -2870,11 +2885,36 @@ function buildParams(
|
|
|
2870
2885
|
return params;
|
|
2871
2886
|
}
|
|
2872
2887
|
|
|
2888
|
+
const EMPTY_ERROR_TOOL_RESULT_TEXT = "Tool failed with no output.";
|
|
2889
|
+
|
|
2890
|
+
function isEmptyToolResultWireContent(content: AnthropicToolResultContent): boolean {
|
|
2891
|
+
if (typeof content === "string") {
|
|
2892
|
+
return content.trim().length === 0;
|
|
2893
|
+
}
|
|
2894
|
+
return content.length === 0;
|
|
2895
|
+
}
|
|
2896
|
+
|
|
2897
|
+
function ensureErrorToolResultWireContent(
|
|
2898
|
+
content: AnthropicToolResultContent,
|
|
2899
|
+
isError: boolean | undefined,
|
|
2900
|
+
): AnthropicToolResultContent {
|
|
2901
|
+
if (!isError || !isEmptyToolResultWireContent(content)) {
|
|
2902
|
+
return content;
|
|
2903
|
+
}
|
|
2904
|
+
return typeof content === "string"
|
|
2905
|
+
? EMPTY_ERROR_TOOL_RESULT_TEXT
|
|
2906
|
+
: [{ type: "text", text: EMPTY_ERROR_TOOL_RESULT_TEXT }];
|
|
2907
|
+
}
|
|
2908
|
+
|
|
2873
2909
|
function buildToolResultBlock(model: Model<"anthropic-messages">, msg: ToolResultMessage): ContentBlockParam {
|
|
2910
|
+
const content = ensureErrorToolResultWireContent(
|
|
2911
|
+
convertContentBlocks(msg.content, model.input.includes("image")),
|
|
2912
|
+
msg.isError,
|
|
2913
|
+
);
|
|
2874
2914
|
const block: ContentBlockParam = {
|
|
2875
2915
|
type: "tool_result",
|
|
2876
2916
|
tool_use_id: msg.toolCallId,
|
|
2877
|
-
content
|
|
2917
|
+
content,
|
|
2878
2918
|
is_error: msg.isError,
|
|
2879
2919
|
};
|
|
2880
2920
|
if (model.compat.requiresToolResultId) {
|
package/src/providers/mock.ts
CHANGED
|
@@ -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
|
-
|
|
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)
|
|
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;
|