Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/funcs/call-model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@ export function callModel<
sharedContextSchema,
onTurnStart,
onTurnEnd,
timeout,
...apiRequest
} = request;

Expand Down Expand Up @@ -128,5 +129,6 @@ export function callModel<
...(sharedContextSchema !== undefined && { sharedContextSchema }),
...(onTurnStart !== undefined && { onTurnStart }),
...(onTurnEnd !== undefined && { onTurnEnd }),
...(timeout !== undefined && { timeout }),
} as GetResponseOptions<TTools, TShared>);
}
19 changes: 19 additions & 0 deletions src/lib/async-params.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type * as models from '../models/index.js';
import type { ToolContextMapWithShared, ParsedToolCall, StateAccessor, StopWhen, Tool, TurnContext } from './tool-types.js';
import type { OpenResponsesResult } from '../models/index.js';
import type { ContextInput } from './tool-context.js';
import type { StreamTimeoutOptions } from './stream-watchdog.js';

// Re-export Tool type for convenience
export type { Tool } from './tool-types.js';
Expand Down Expand Up @@ -46,6 +47,23 @@ type BaseCallModelInput<
} & {
tools?: TTools;
stopWhen?: StopWhen<TTools>;
/**
* Opt-in stalled-stream detection (fail fast instead of hanging on a
* stream that returns headers but never produces content).
*
* - `firstContentMs`: max milliseconds between a turn's response stream
* starting and its first content-bearing event (text/reasoning delta,
* tool-call arguments, ...). Keep-alives and metadata events do not
* satisfy or reset it.
* - `contentIntervalMs`: max gap between content-bearing events once
* content has started.
*
* Deadlines re-arm for every turn in multi-turn tool loops. On expiry the
* in-flight request is aborted and all consumers reject with
* `StreamStalledError` (its `retryable` getter is true only when no
* content had been received). Unset by default: no watchdog runs.
*/
timeout?: StreamTimeoutOptions;
/** Typed context data passed to tools via contextSchema. Includes optional `shared` key. */
context?: ContextInput<ToolContextMapWithShared<TTools, TShared>>;
/**
Expand Down Expand Up @@ -162,6 +180,7 @@ export async function resolveAsyncFunctions<TTools extends readonly Tool[] = rea
'sharedContextSchema', // Client-side schema for shared context validation
'onTurnStart', // Client-side turn start callback
'onTurnEnd', // Client-side turn end callback
'timeout', // Client-side stalled-stream watchdog config
]);

// Iterate over all keys in the input
Expand Down
99 changes: 90 additions & 9 deletions src/lib/model-result.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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.
*
Expand Down Expand Up @@ -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) {
Expand All @@ -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);
Expand Down Expand Up @@ -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;
Comment on lines +979 to +981

Copy link
Copy Markdown
Contributor

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) at src/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._createRequest only records context.timeoutMs when no signal was supplied (src/lib/sdks.ts:230-232), and ClientSDK._do then creates a fresh AbortSignal.timeout(timeoutMs) inside the retry callback, i.e. once per attempt (src/lib/sdks.ts:282-289).

createTurnWatchContext always supplies a signal when the watchdog is active, so context.timeoutMs stays unset and _do's per-attempt timer never runs. The replicated AbortSignal.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 without timeout configured the retry would have received a full fresh budget.

A secondary, smaller divergence: responsesSend computes the effective value as options?.timeoutMs || client._options.timeoutMs || -1 (src/funcs/responsesSend.ts:282) while the new code uses ??, so an explicit timeoutMs: 0 in the caller's options no longer falls back to the client-level value.

Prompt for agents
In ModelResult.createTurnWatchContext (src/lib/model-result.ts), the code replicates the SDK's timeoutMs behaviour by creating a single AbortSignal.timeout up front and merging it into the request signal. However, the generated client applies its timeout per retry attempt: ClientSDK._createRequest only sets context.timeoutMs when no signal is present (src/lib/sdks.ts:230-232) and ClientSDK._do constructs a fresh AbortSignal.timeout inside the retry callback (src/lib/sdks.ts:282-289). Because the watchdog always supplies a signal, the per-attempt timer is disabled and the replicated one becomes a cumulative deadline spanning all retries of the turn, which can abort a retry that would otherwise have had a full budget. Consider a mechanism that preserves per-attempt semantics — e.g. passing the turn abort signal through a path that still lets _do arm its own per-attempt timeout, or merging the turn signal inside a request hook rather than as options.signal. Also note the effective-value computation uses ?? whereas responsesSend uses || (src/funcs/responsesSend.ts:282), which differs when timeoutMs is 0.
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion: per-attempt timeout becomes cumulative across retries

The SDK's _do method creates a fresh AbortSignal.timeout(timeoutMs) inside the retry callback (sdks.ts:284-288), so each retry attempt gets a full timeout budget. When the watchdog is active, createTurnWatchContext supplies options.signal, which causes _createRequest to skip context.timeoutMs (sdks.ts:230-231), disabling _do's per-attempt timer. The AbortSignal.timeout(timeoutMs) created here is a single timer started before the first request — it counts down across ALL retry attempts of the turn.

With the default backoff retry on 5XX, a request that gets a 502 after most of the budget has elapsed will have its retry cut off by this cumulative timeout, whereas without the watchdog the retry would have gotten a fresh budget.

Consider one of:

  • Let _do arm its own per-attempt timeout (e.g., pass the turn signal through a request hook instead of options.signal, so _createRequest still sets context.timeoutMs).
  • Document that timeoutMs becomes a per-turn cumulative budget when the watchdog is enabled.

Minor note on the same line: ?? here diverges from responsesSend's || (responsesSend.ts:282) for timeoutMs: 0 — an explicit 0 stays 0 (no timeout signal) here but falls back to the client default in responsesSend. Consider aligning the operator.

▶ Prompt for agents: In ModelResult.createTurnWatchContext, the replicated AbortSignal.timeout(timeoutMs) is created once and merged into the request signal, but the SDK's _do normally creates a fresh timeout per retry attempt. Because the watchdog always supplies a signal, _do's per-attempt timer is disabled. Consider a mechanism that preserves per-attempt semantics — e.g. passing the turn abort signal through a request hook rather than as options.signal, or documenting the cumulative-budget behavior. Also align the ?? operator with responsesSend's || for timeoutMs: 0.


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.
Expand All @@ -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;
}

Expand Down Expand Up @@ -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) {
Expand All @@ -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;
Expand Down Expand Up @@ -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) {
Expand All @@ -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 {
Expand Down
15 changes: 15 additions & 0 deletions src/lib/stream-watchdog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -283,6 +283,21 @@ export function applyResponsesStreamWatchdog(
});
}

/**
* True when at least one watchdog deadline is enabled (set, finite, > 0).
* Callers use this to skip per-turn abort plumbing entirely when the
* watchdog would be a no-op.
*/
export function hasActiveStreamTimeouts(timeouts: StreamTimeoutOptions | undefined): boolean {
if (!timeouts) {
return false;
}
return (
normalizeTimeout(timeouts.firstContentMs) !== undefined ||
normalizeTimeout(timeouts.contentIntervalMs) !== undefined
);
}

/**
* Treat non-finite and non-positive values as "disabled" so callers can
* pass raw user input without pre-validating.
Expand Down
10 changes: 10 additions & 0 deletions src/lib/tool-event-broadcaster.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,18 @@ export class ToolEventBroadcaster<T> {
* Mark the broadcaster as complete - no more events will be pushed.
* Optionally pass an error to signal failure to all consumers.
* Cleans up buffer and consumers after completion.
*
* Idempotent: the first call wins. This matters when a stream error
* completes the broadcaster with an error (e.g. a stalled-stream abort
* from the pipe's catch handler) and an unconditional `.finally()`
* completion runs afterwards - the later error-less call must not wipe
* the recorded failure, or late consumers would see a clean close
* instead of the error.
*/
complete(error?: Error): void {
if (this.isComplete) {
return;
}
this.isComplete = true;
this.completionError = error ?? null;
this.notifyWaitingConsumers();
Expand Down
2 changes: 2 additions & 0 deletions src/sdk/sdk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,8 @@ import type { RequestOptions } from "../lib/sdks.js";
import { type Tool, ToolType } from "../lib/tool-types.js";

export { ToolType };
export { StreamStalledError, type StreamStallPhase } from "../lib/stream-errors.js";
export type { StreamTimeoutOptions } from "../lib/stream-watchdog.js";
// #endregion imports

export class OpenRouter extends ClientSDK {
Expand Down
Loading
Loading