-
Notifications
You must be signed in to change notification settings - Fork 69
feat: callModel per-turn stall timeouts (DEV-723 2/5) #773
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -70,6 +70,12 @@ import { | |
| isReasoningDeltaEvent, | ||
| hasTypeProperty, | ||
| } from './stream-type-guards.js'; | ||
| import { | ||
| applyResponsesStreamWatchdog, | ||
| hasActiveStreamTimeouts, | ||
| type StreamTimeoutOptions, | ||
| } from './stream-watchdog.js'; | ||
| import { combineSignals } from './primitives.js'; | ||
|
|
||
| /** | ||
| * Default maximum number of tool execution steps if no stopWhen is specified. | ||
|
|
@@ -129,8 +135,28 @@ export interface GetResponseOptions< | |
| onTurnStart?: (context: TurnContext) => void | Promise<void>; | ||
| /** Callback invoked at the end of each tool execution turn */ | ||
| onTurnEnd?: (context: TurnContext, response: models.OpenResponsesResult) => void | Promise<void>; | ||
|
|
||
| /** | ||
| * Opt-in stalled-stream detection. Deadlines are armed per turn; on | ||
| * expiry the turn's HTTP request is aborted and consumers reject with | ||
| * `StreamStalledError`. See `StreamTimeoutOptions`. | ||
| */ | ||
| timeout?: StreamTimeoutOptions; | ||
| } | ||
|
|
||
| /** | ||
| * Per-turn stall-detection context: request options carrying the merged | ||
| * abort signal, and a wrapper that arms the watchdog on the turn's stream. | ||
| * When no stall timeout is active, `requestOptions` is the caller's | ||
| * options unchanged and `watch` is the identity function. | ||
| */ | ||
| type TurnWatchContext = { | ||
| requestOptions: RequestOptions | undefined; | ||
| watch: ( | ||
| stream: ReadableStream<models.StreamEvents>, | ||
| ) => ReadableStream<models.StreamEvents>; | ||
| }; | ||
|
|
||
| /** | ||
| * A wrapper around a streaming response that provides multiple consumption patterns. | ||
| * | ||
|
|
@@ -875,10 +901,12 @@ export class ModelResult< | |
| stream: true, | ||
| }; | ||
|
|
||
| // Stall deadlines re-arm independently for every turn. | ||
| const turnWatch = this.createTurnWatchContext(); | ||
| const newResult = await responsesSend( | ||
| this.options.client, | ||
| { responsesRequest: newRequest }, | ||
| this.options.options, | ||
| turnWatch.requestOptions, | ||
| ); | ||
|
|
||
| if (!newResult.ok) { | ||
|
|
@@ -888,7 +916,7 @@ export class ModelResult< | |
| // Handle streaming or non-streaming response | ||
| const value = newResult.value; | ||
| if (isEventStream(value)) { | ||
| const followUpStream = new ReusableReadableStream(value); | ||
| const followUpStream = new ReusableReadableStream(turnWatch.watch(value)); | ||
|
|
||
| if (this.turnBroadcaster) { | ||
| return this.pipeAndConsumeStream(followUpStream, turnNumber); | ||
|
|
@@ -919,6 +947,57 @@ export class ModelResult< | |
| } | ||
| } | ||
|
|
||
| /** | ||
| * Build the stall-detection context for one turn. | ||
| * | ||
| * When a stall timeout is configured, the turn gets its own | ||
| * AbortController (merged with the caller's signal so neither is lost). | ||
| * The returned `watch` wrapper arms the watchdog on the turn's parsed | ||
| * event stream; on expiry the watchdog aborts the turn's HTTP request, | ||
| * tearing down the hung connection, and the stream errors with | ||
| * `StreamStalledError`. | ||
| */ | ||
| private createTurnWatchContext(): TurnWatchContext { | ||
| const timeouts = this.options.timeout; | ||
| if (!timeouts || !hasActiveStreamTimeouts(timeouts)) { | ||
| return { | ||
| requestOptions: this.options.options, | ||
| watch: (stream) => stream, | ||
| }; | ||
| } | ||
|
|
||
| const turnAbort = new AbortController(); | ||
| const baseOptions = this.options.options; | ||
| const callerSignal = baseOptions?.signal ?? baseOptions?.fetchOptions?.signal ?? null; | ||
|
|
||
| /* | ||
| * The generated request builder only applies `timeoutMs` (via | ||
| * AbortSignal.timeout) when no signal is provided. Since we are about | ||
| * to provide one, replicate that behavior here so configuring a stall | ||
| * watchdog never silently disables the caller's overall timeout. | ||
| */ | ||
| const timeoutMs = baseOptions?.timeoutMs ?? this.options.client._options.timeoutMs; | ||
| const timeoutSignal = | ||
| !callerSignal && timeoutMs && timeoutMs > 0 ? AbortSignal.timeout(timeoutMs) : null; | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. ▶ Suggestion: per-attempt timeout becomes cumulative across retries The SDK's With the default Consider one of:
Minor note on the same line: ▶ Prompt for agents: In |
||
|
|
||
| const mergedSignal = combineSignals(turnAbort.signal, callerSignal, timeoutSignal); | ||
|
|
||
| const requestOptions: RequestOptions = { | ||
| ...baseOptions, | ||
| ...(mergedSignal ? { signal: mergedSignal } : {}), | ||
| }; | ||
|
|
||
| return { | ||
| requestOptions, | ||
| watch: (stream) => | ||
| applyResponsesStreamWatchdog(stream, timeouts, { | ||
| onStall: (error) => { | ||
| turnAbort.abort(error); | ||
| }, | ||
| }), | ||
| }; | ||
| } | ||
|
|
||
| /** | ||
| * Resolve async functions in the request for a given turn context. | ||
| * Extracts non-function fields and resolves any async parameter functions. | ||
|
|
@@ -932,7 +1011,7 @@ export class ModelResult< | |
| } | ||
| // Already resolved, extract non-function fields | ||
| // Filter out stopWhen and state-related fields that aren't part of the API request | ||
| const { stopWhen: _, state: _s, requireApproval: _r, approveToolCalls: _a, rejectToolCalls: _rj, context: _c, ...rest } = this.options.request; | ||
| const { stopWhen: _, state: _s, requireApproval: _r, approveToolCalls: _a, rejectToolCalls: _rj, context: _c, timeout: _t, ...rest } = this.options.request; | ||
| return rest as ResolvedCallModelInput; | ||
| } | ||
|
|
||
|
|
@@ -1066,11 +1145,12 @@ export class ModelResult< | |
| // Force stream mode for initial request | ||
| const request = this.resolvedRequest; | ||
|
|
||
| // Make the API request | ||
| // Make the API request (with per-turn stall detection when configured) | ||
| const turnWatch = this.createTurnWatchContext(); | ||
| const apiResult = await responsesSend( | ||
| this.options.client, | ||
| { responsesRequest: request }, | ||
| this.options.options, | ||
| turnWatch.requestOptions, | ||
| ); | ||
|
|
||
| if (!apiResult.ok) { | ||
|
|
@@ -1080,7 +1160,7 @@ export class ModelResult< | |
| // Handle both streaming and non-streaming responses | ||
| // The API may return a non-streaming response even when stream: true is requested | ||
| if (isEventStream(apiResult.value)) { | ||
| this.reusableStream = new ReusableReadableStream(apiResult.value); | ||
| this.reusableStream = new ReusableReadableStream(turnWatch.watch(apiResult.value)); | ||
| } else if (this.isNonStreamingResponse(apiResult.value)) { | ||
| // API returned a complete response directly - use it as the final response | ||
| this.finalResponse = apiResult.value; | ||
|
|
@@ -1212,11 +1292,12 @@ export class ModelResult< | |
|
|
||
| this.resolvedRequest = request; | ||
|
|
||
| // Make the API request | ||
| // Make the API request (stall deadlines re-arm for the resumed turn) | ||
| const turnWatch = this.createTurnWatchContext(); | ||
| const apiResult = await responsesSend( | ||
| this.options.client, | ||
| { responsesRequest: request }, | ||
| this.options.options, | ||
| turnWatch.requestOptions, | ||
| ); | ||
|
|
||
| if (!apiResult.ok) { | ||
|
|
@@ -1225,7 +1306,7 @@ export class ModelResult< | |
|
|
||
| // Handle both streaming and non-streaming responses | ||
| if (isEventStream(apiResult.value)) { | ||
| this.reusableStream = new ReusableReadableStream(apiResult.value); | ||
| this.reusableStream = new ReusableReadableStream(turnWatch.watch(apiResult.value)); | ||
| } else if (this.isNonStreamingResponse(apiResult.value)) { | ||
| this.finalResponse = apiResult.value; | ||
| } else { | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🟡 Overall request time limit is no longer refreshed for retry attempts once stall detection is enabled
The per-attempt request time limit is replaced by a single shared countdown started once (
AbortSignal.timeout(timeoutMs)atsrc/lib/model-result.ts:980-981) instead of being restarted for each retry, so a retried request can be cut off almost immediately.Impact: Users who enable stall detection and rely on automatic retries can see requests fail with a timeout even though each individual attempt was well within the allowed time.
Why the replicated timeout diverges from the generated retry loop
ClientSDK._createRequestonly recordscontext.timeoutMswhen no signal was supplied (src/lib/sdks.ts:230-232), andClientSDK._dothen creates a freshAbortSignal.timeout(timeoutMs)inside the retry callback, i.e. once per attempt (src/lib/sdks.ts:282-289).createTurnWatchContextalways supplies a signal when the watchdog is active, socontext.timeoutMsstays unset and_do's per-attempt timer never runs. The replicatedAbortSignal.timeout(timeoutMs)is created a single time, before the request is issued, and is merged into the signal used for every retry attempt of that turn. With the default retry config (backoff, retry on 5XX) a turn that gets a 502 after most of the budget has elapsed will have its retry aborted with a timeout error, whereas withouttimeoutconfigured the retry would have received a full fresh budget.A secondary, smaller divergence:
responsesSendcomputes the effective value asoptions?.timeoutMs || client._options.timeoutMs || -1(src/funcs/responsesSend.ts:282) while the new code uses??, so an explicittimeoutMs: 0in the caller's options no longer falls back to the client-level value.Prompt for agents
Was this helpful? React with 👍 or 👎 to provide feedback.