Summary
timeoutMs, whether set on the client or per request, stops applying as soon as the request also carries an AbortSignal. The timeout isn't composed with the signal; it's skipped entirely.
Where
Installed @openrouter/sdk 0.13.20, esm/lib/sdks.js L125:
if (!fetchOptions?.signal && conf.timeoutMs && conf.timeoutMs > 0) {
const timeoutSignal = AbortSignal.timeout(conf.timeoutMs);
fetchOptions.signal = timeoutSignal;
}
The same guard is still on main (64d6db7) in src/lib/sdks.ts L230:
if (!fetchOptions?.signal && conf.timeoutMs != null && conf.timeoutMs > 0) {
context.timeoutMs = conf.timeoutMs;
}
The timeoutMs doc comment says that fetchOptions.signal "will take precedence over this option", so this is the documented behaviour. The problem is that "precedence" here means the timeout is dropped altogether.
Why it matters
Framework adapters forward their own cancellation signal on every request. For example, @tanstack/ai-openrouter passes { signal } to chat.send / responses.send whenever its caller cancels, which is typical for server code that aborts on client disconnect. Anyone using the SDK through such an adapter can't use timeoutMs, because a hung request is bounded only by whatever eventually fires that signal.
Expected
When both are given, compose them. The request aborts on whichever fires first, and a timeout surfaces as a timeout (RequestTimeoutError), distinguishable from a caller abort (RequestAbortedError).
On main most of this already exists: _do builds a per-attempt AbortSignal.timeout(timeoutMs) and runs it through combineSignals(cloned.signal, timeoutSignal) (L284-L289). So dropping the !fetchOptions?.signal condition at L230 looks like enough to get "first to fire wins". The abort reason of AbortSignal.timeout is a TimeoutError DOMException, which the existing isTimeoutError branch already maps to RequestTimeoutError. (If this file is generator-owned, the change may belong in the Speakeasy template or a persistent edit.)
For comparison, #773 already merges the caller signal with replicated timeoutMs behaviour inside callModel. This request would give chat.send, responses.send and the other raw methods the same guarantee.
Repro
const client = new OpenRouter({ apiKey, timeoutMs: 5_000 })
const controller = new AbortController() // never aborted
// Against an endpoint that accepts the connection but never responds,
// this waits indefinitely instead of failing after ~5s.
await client.chat.send({ chatRequest }, { signal: controller.signal })
Summary
timeoutMs, whether set on the client or per request, stops applying as soon as the request also carries anAbortSignal. The timeout isn't composed with the signal; it's skipped entirely.Where
Installed
@openrouter/sdk0.13.20,esm/lib/sdks.jsL125:The same guard is still on
main(64d6db7) insrc/lib/sdks.tsL230:The
timeoutMsdoc comment says thatfetchOptions.signal"will take precedence over this option", so this is the documented behaviour. The problem is that "precedence" here means the timeout is dropped altogether.Why it matters
Framework adapters forward their own cancellation signal on every request. For example,
@tanstack/ai-openrouterpasses{ signal }tochat.send/responses.sendwhenever its caller cancels, which is typical for server code that aborts on client disconnect. Anyone using the SDK through such an adapter can't usetimeoutMs, because a hung request is bounded only by whatever eventually fires that signal.Expected
When both are given, compose them. The request aborts on whichever fires first, and a timeout surfaces as a timeout (
RequestTimeoutError), distinguishable from a caller abort (RequestAbortedError).On
mainmost of this already exists:_dobuilds a per-attemptAbortSignal.timeout(timeoutMs)and runs it throughcombineSignals(cloned.signal, timeoutSignal)(L284-L289). So dropping the!fetchOptions?.signalcondition at L230 looks like enough to get "first to fire wins". The abort reason ofAbortSignal.timeoutis aTimeoutErrorDOMException, which the existingisTimeoutErrorbranch already maps toRequestTimeoutError. (If this file is generator-owned, the change may belong in the Speakeasy template or a persistent edit.)For comparison, #773 already merges the caller signal with replicated
timeoutMsbehaviour insidecallModel. This request would givechat.send,responses.sendand the other raw methods the same guarantee.Repro