Context
Follow-up to PR #12806 (@tobiu-requested). #12806 gave Ollama.generate an interactive timeoutMs (it had none — it hardcoded a 1h socket timeout). OpenAiCompatible.generate already had its own timeout handling. The two chat providers are parallel implementations, and #12806 — while fixing the Ollama gap — left their timeout contracts diverged. They need to stay in sync.
Live latest-open sweep: checked the latest 20 open issues at 2026-06-09T08:35Z; no equivalent open ticket (the matches — #12803, #12487, #12509, #12742 — are all closed).
The Problem
A caller (SearchService.ask today; a daemon-yield path later) gets different timeout behavior depending on which provider is active. Two confirmed divergences (V-B-A'd at the source):
- Error shape — a caller cannot uniformly detect a provider timeout.
OpenAiCompatible throws a structured error with error.code = 'OPENAI_COMPATIBLE_TIMEOUT' (ai/provider/OpenAiCompatible.mjs:16-18). Ollama (post-#12806) throws a plain Error ([Ollama] … timed out after Nms (host=, model=)) with no .code. A caller checking err.code sees one provider's timeout and not the other's.
options.signal (upstream cancellation). OpenAiCompatible honors it — full AbortSignal wiring (OpenAiCompatible.mjs:257-277: adopts an upstream signal into the AbortController). Ollama strips options.signal (Ollama.mjs payload-prep) and ignores upstream cancellation. (This is the signal-parity item deliberately deferred out of #12806 — see the #12803 AC-correction, IC_kwDODSospM8AAAABFZtiXg.)
The message format is already roughly parallel ([Provider] {label} timed out after {ms}ms (host=, model=)), so the gap is the detectable code + signal support — and, structurally, the absence of a shared contract that would keep the two from drifting further as either evolves.
The Architectural Reality
ai/provider/OpenAiCompatible.mjs — createTimeoutError({operationLabel, timeoutMs, host, modelName}) → error.code = 'OPENAI_COMPATIBLE_TIMEOUT' (:16-18); upstream-signal adoption + timeout AbortController (:257-280); transport is fetch (controller.signal → fetch).
ai/provider/Ollama.mjs (post-#12806) — socket timeout via req.on('timeout') → req.destroy() + a plain Error (no .code); options.signal stripped in payload-prep; transport is node http.request.
- Both consumed via
ai/provider/buildChatModel.mjs → SearchService.ask; a future signal-consumer is the LEAF-2 daemon-yield (#12799).
Contract Ledger Matrix
| Target Surface |
Source of Authority |
Proposed Behavior |
Fallback |
Docs |
Evidence |
Ollama.generate(options.signal) |
this ticket |
honor the upstream AbortSignal (node http.request accepts signal natively → destroy on abort) |
current: stripped/ignored |
ai/provider/Ollama.mjs |
OpenAiCompatible.mjs:257-277 is the reference impl |
{Ollama,OpenAiCompatible}.generate timeout error |
this ticket |
uniform detectable shape — a shared .code (or shared error type) + the structured fields |
current: per-provider (.code only on OpenAiCompatible) |
ai/provider/*.mjs |
OpenAiCompatible.mjs:16-18 ('OPENAI_COMPATIBLE_TIMEOUT') is the precedent to generalize |
Decision Record impact
none (provider-internal contract alignment; no ADR governs the provider abstraction surface today).
The Fix
Align both providers on one interactive-timeout contract:
- Signal parity —
Ollama.generate honors options.signal (wire it to http.request's native signal; destroy + throw the timeout-shaped error on abort).
- Detectable uniform error — a shared timeout-error helper (one
.code, e.g. PROVIDER_TIMEOUT, or a shared error type) so callers detect a provider timeout regardless of which provider served the call. Generalize createTimeoutError rather than duplicate it.
- Sync by construction — house the shared error helper + a documented
generate(options) contract (timeoutMs, signal, operationLabel) in one place so the providers can't silently drift again. The transport mechanism may differ (fetch+AbortController vs http.request); only the observable contract must be uniform.
Acceptance Criteria
Out of Scope
- The
ask_knowledge_base synthesis-timeout wiring (#12806 — done).
- The daemon-yield itself (#12799 LEAF 2 — a future consumer of
signal, not this contract work).
- Other provider-parity dimensions (
keep_alive, streaming) — already aligned or separately tracked.
Avoided Traps
- Not forcing identical mechanisms —
fetch+AbortController vs http.request socket timeout may differ; only the observable contract (error shape + signal honoring) must be uniform.
- Not speculative over-abstraction — a shared helper is justified by a concrete, demonstrated divergence (#12806) and an operator "stay in sync" directive, not by gold-plating a single-provider path.
Related
#12806 (introduced the divergence), #12803 (origin ticket + signal-deferral AC-correction IC_kwDODSospM8AAAABFZtiXg), #12799 (LEAF 2 — likely first signal consumer), #10494, #12740 (local-first provider defaults epic).
Origin Session ID: 728442d6-a2a0-4481-a631-a3112ce6d703
Retrieval Hint: query_raw_memories("Ollama OpenAiCompatible provider interactive timeout contract parity signal error code")
Authored by @neo-opus-vega (Claude Opus 4.8).
Context
Follow-up to PR #12806 (@tobiu-requested). #12806 gave
Ollama.generatean interactivetimeoutMs(it had none — it hardcoded a 1h socket timeout).OpenAiCompatible.generatealready had its own timeout handling. The two chat providers are parallel implementations, and #12806 — while fixing the Ollama gap — left their timeout contracts diverged. They need to stay in sync.Live latest-open sweep: checked the latest 20 open issues at 2026-06-09T08:35Z; no equivalent open ticket (the matches — #12803, #12487, #12509, #12742 — are all closed).
The Problem
A caller (
SearchService.asktoday; a daemon-yield path later) gets different timeout behavior depending on which provider is active. Two confirmed divergences (V-B-A'd at the source):OpenAiCompatiblethrows a structured error witherror.code = 'OPENAI_COMPATIBLE_TIMEOUT'(ai/provider/OpenAiCompatible.mjs:16-18).Ollama(post-#12806) throws a plainError([Ollama] … timed out after Nms (host=, model=)) with no.code. A caller checkingerr.codesees one provider's timeout and not the other's.options.signal(upstream cancellation).OpenAiCompatiblehonors it — full AbortSignal wiring (OpenAiCompatible.mjs:257-277: adopts an upstream signal into theAbortController).Ollamastripsoptions.signal(Ollama.mjspayload-prep) and ignores upstream cancellation. (This is the signal-parity item deliberately deferred out of #12806 — see the #12803 AC-correction,IC_kwDODSospM8AAAABFZtiXg.)The message format is already roughly parallel (
[Provider] {label} timed out after {ms}ms (host=, model=)), so the gap is the detectable code + signal support — and, structurally, the absence of a shared contract that would keep the two from drifting further as either evolves.The Architectural Reality
ai/provider/OpenAiCompatible.mjs—createTimeoutError({operationLabel, timeoutMs, host, modelName})→error.code = 'OPENAI_COMPATIBLE_TIMEOUT'(:16-18); upstream-signal adoption + timeoutAbortController(:257-280); transport isfetch(controller.signal→fetch).ai/provider/Ollama.mjs(post-#12806) — socket timeout viareq.on('timeout')→req.destroy()+ a plainError(no.code);options.signalstripped in payload-prep; transport is nodehttp.request.ai/provider/buildChatModel.mjs→SearchService.ask; a future signal-consumer is the LEAF-2 daemon-yield (#12799).Contract Ledger Matrix
Ollama.generate(options.signal)AbortSignal(nodehttp.requestacceptssignalnatively → destroy on abort)ai/provider/Ollama.mjsOpenAiCompatible.mjs:257-277is the reference impl{Ollama,OpenAiCompatible}.generatetimeout error.code(or shared error type) + the structured fields.codeonly on OpenAiCompatible)ai/provider/*.mjsOpenAiCompatible.mjs:16-18('OPENAI_COMPATIBLE_TIMEOUT') is the precedent to generalizeDecision Record impact
none(provider-internal contract alignment; no ADR governs the provider abstraction surface today).The Fix
Align both providers on one interactive-timeout contract:
Ollama.generatehonorsoptions.signal(wire it tohttp.request's nativesignal; destroy + throw the timeout-shaped error on abort)..code, e.g.PROVIDER_TIMEOUT, or a shared error type) so callers detect a provider timeout regardless of which provider served the call. GeneralizecreateTimeoutErrorrather than duplicate it.generate(options)contract (timeoutMs,signal,operationLabel) in one place so the providers can't silently drift again. The transport mechanism may differ (fetch+AbortControllervshttp.request); only the observable contract must be uniform.Acceptance Criteria
Ollama.generatehonorsoptions.signal(upstream abort → request destroyed → timeout-shaped error), matchingOpenAiCompatible..codeor shared type), soSearchService(and future consumers) can branch on a provider timeout without per-provider knowledge.createTimeoutError); thegenerate(options)timeout/signal contract is documented at the shared site.signalabort + the uniform error shape) — extendKeepAlive.spec.mjs(hung loopback for Ollama;fetch-mock for OpenAiCompatible).Out of Scope
ask_knowledge_basesynthesis-timeout wiring (#12806 — done).signal, not this contract work).keep_alive, streaming) — already aligned or separately tracked.Avoided Traps
fetch+AbortControllervshttp.requestsocket timeout may differ; only the observable contract (error shape + signal honoring) must be uniform.Related
#12806 (introduced the divergence), #12803 (origin ticket + signal-deferral AC-correction
IC_kwDODSospM8AAAABFZtiXg), #12799 (LEAF 2 — likely firstsignalconsumer), #10494, #12740 (local-first provider defaults epic).Origin Session ID: 728442d6-a2a0-4481-a631-a3112ce6d703
Retrieval Hint:
query_raw_memories("Ollama OpenAiCompatible provider interactive timeout contract parity signal error code")Authored by @neo-opus-vega (Claude Opus 4.8).