Skip to content

Commit 1600b20

Browse files
committed
Abort in-flight LLM streams promptly on prompt stop for all providers
Extend the stream cancellation watchdog used by Anthropic/Bedrock to the openai, openai-chat and ollama handlers, so stopping a prompt closes the connection within ~500ms even while waiting for the first token, instead of only being noticed on the next received chunk. Closes #554
1 parent 75e06ba commit 1600b20

9 files changed

Lines changed: 220 additions & 34 deletions

File tree

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22

33
## Unreleased
44

5+
- Abort in-flight LLM streaming requests promptly on prompt stop for OpenAI, GitHub Copilot, Google, Ollama and custom OpenAI-compatible providers (previously only Anthropic/Bedrock), including while waiting for the first token. #554
6+
57
- Don't reset a chat's model when changing the agent of an already started chat; the agent's `defaultModel` now only applies to chats without an established model. editor-code-assistant/eca-emacs#282
68
- Fix plugin-provided agents missing from the editor's agent list when model sync finishes before plugin resolution (common with providers using `fetchModels: false`): re-emit the chat config after plugins resolve.
79
- Surface Anthropic `refusal` stop reason (e.g. Fable safety classifiers) as a clear chat message with Anthropic's category/explanation instead of silently ending the prompt.

src/eca/llm_api.clj

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -303,7 +303,9 @@
303303
:api-key api-key
304304
:auth-type auth-type
305305
:account-id (:account-id provider-auth)
306-
:prompt-cache-key prompt-cache-key}
306+
:prompt-cache-key prompt-cache-key
307+
:cancelled? cancelled?
308+
:stream-idle-timeout-seconds (:streamIdleTimeoutSeconds config)}
307309
callbacks)
308310

309311
(= "anthropic" provider)
@@ -338,7 +340,9 @@
338340
:reasoning-history reasoning-history
339341
:api-url api-url
340342
:api-key api-key
341-
:prompt-cache-key prompt-cache-key}]
343+
:prompt-cache-key prompt-cache-key
344+
:cancelled? cancelled?
345+
:stream-idle-timeout-seconds (:streamIdleTimeoutSeconds config)}]
342346
(case (:api api-handler)
343347
:openai-responses
344348
(handler
@@ -384,7 +388,9 @@
384388
extra-payload)
385389
:extra-headers extra-headers
386390
:api-url api-url
387-
:api-key api-key}
391+
:api-key api-key
392+
:cancelled? cancelled?
393+
:stream-idle-timeout-seconds (:streamIdleTimeoutSeconds config)}
388394
callbacks)
389395

390396
(= "ollama" provider)
@@ -399,7 +405,9 @@
399405
:tools tools
400406
:max-output-tokens max-output-tokens
401407
:extra-payload extra-payload
402-
:extra-headers extra-headers}
408+
:extra-headers extra-headers
409+
:cancelled? cancelled?
410+
:stream-idle-timeout-seconds (:streamIdleTimeoutSeconds config)}
403411
callbacks)
404412

405413
(and (or model-config

src/eca/llm_providers/ollama.clj

Lines changed: 33 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@
5555
(logger/warn logger-tag "Error getting model:" (ex-message e))
5656
[])))
5757

58-
(defn ^:private base-chat-request! [{:keys [rid url body on-error on-stream extra-headers]}]
58+
(defn ^:private base-chat-request! [{:keys [rid url body on-error on-stream extra-headers cancelled? stream-idle-timeout-seconds]}]
5959
(let [reason-id (str (random-uuid))
6060
reasoning?* (atom false)
6161
response* (atom nil)
@@ -81,10 +81,33 @@
8181
:status status
8282
:body body-str}))
8383
(if on-stream
84-
(with-open [rdr (io/reader body)]
85-
(doseq [[event data] (llm-util/event-data-seq rdr)]
86-
(llm-util/log-response logger-tag rid event data)
87-
(on-stream rid event data reasoning?* reason-id)))
84+
(let [{:keys [touch-fn set-reading-fn stop-fn reason*]}
85+
(llm-util/start-stream-watchdog! body cancelled?
86+
(when stream-idle-timeout-seconds
87+
{:idle-timeout-ms (* 1000 stream-idle-timeout-seconds)}))]
88+
(try
89+
(with-open [rdr (io/reader body)]
90+
(doseq [[event data] (llm-util/event-data-seq rdr)]
91+
(set-reading-fn false)
92+
(touch-fn)
93+
(llm-util/log-response logger-tag rid event data)
94+
(on-stream rid event data reasoning?* reason-id)
95+
(set-reading-fn true)))
96+
(catch java.io.IOException e
97+
(let [reason @reason*]
98+
(cond
99+
(= :cancelled reason)
100+
(throw (ex-info "Stream cancelled" {:silent? true}))
101+
102+
(= :idle-timeout reason)
103+
(on-error {:message (format "Stream idle timeout: no data received for %d seconds"
104+
(or stream-idle-timeout-seconds 120))
105+
:exception e})
106+
107+
:else
108+
(throw e))))
109+
(finally
110+
(stop-fn))))
88111
(do
89112
(llm-util/log-response logger-tag rid "response" body)
90113
(reset! response*
@@ -139,7 +162,7 @@
139162
messages))
140163

141164
(defn chat! [{:keys [model user-messages reason? instructions api-url past-messages tools max-output-tokens
142-
extra-headers extra-payload]}
165+
extra-headers extra-payload cancelled? stream-idle-timeout-seconds]}
143166
{:keys [on-message-received on-error on-prepare-tool-call on-tools-called
144167
on-reason] :as callbacks}]
145168
(let [messages (concat
@@ -182,6 +205,8 @@
182205
:body (assoc body :messages (normalize-messages new-messages)
183206
:tools (->tools tools))
184207
:extra-headers extra-headers
208+
:cancelled? cancelled?
209+
:stream-idle-timeout-seconds stream-idle-timeout-seconds
185210
:on-error on-error
186211
:on-stream handle-stream})))
187212
(on-message-received {:type :finish
@@ -209,5 +234,7 @@
209234
:url url
210235
:body body
211236
:extra-headers extra-headers
237+
:cancelled? cancelled?
238+
:stream-idle-timeout-seconds stream-idle-timeout-seconds
212239
:on-error on-error
213240
:on-stream on-stream-fn})))

src/eca/llm_providers/openai.clj

Lines changed: 41 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -171,7 +171,7 @@
171171
:request-id request-id
172172
:headers response-headers)))
173173

174-
(defn ^:private base-responses-request! [{:keys [rid body api-url auth-type url-relative-path api-key account-id on-error on-stream http-client extra-headers]}]
174+
(defn ^:private base-responses-request! [{:keys [rid body api-url auth-type url-relative-path api-key account-id on-error on-stream http-client extra-headers cancelled? stream-idle-timeout-seconds]}]
175175
(let [oauth? (= :auth/oauth auth-type)
176176
stream? (and on-stream (not= false (:stream body)))
177177
url (if oauth?
@@ -213,18 +213,41 @@
213213
:body body-str
214214
:headers resp-headers}))
215215
(if stream?
216-
(let [stream-error
217-
(with-open [rdr (io/reader body)]
218-
(loop [events (seq (llm-util/event-data-seq rdr))]
219-
(when-let [[event data] (first events)]
220-
(llm-util/log-response logger-tag rid event data)
221-
(if (= "response.failed" event)
222-
(response-failed->error-data data resp-headers)
223-
(do
224-
(on-stream event data resp-headers)
225-
(recur (next events)))))))]
226-
(when stream-error
227-
(on-error stream-error)))
216+
(let [{:keys [touch-fn set-reading-fn stop-fn reason*]}
217+
(llm-util/start-stream-watchdog! body cancelled?
218+
(when stream-idle-timeout-seconds
219+
{:idle-timeout-ms (* 1000 stream-idle-timeout-seconds)}))]
220+
(try
221+
(let [stream-error
222+
(with-open [rdr (io/reader body)]
223+
(loop [events (seq (llm-util/event-data-seq rdr))]
224+
(when-let [[event data] (first events)]
225+
(set-reading-fn false)
226+
(touch-fn)
227+
(llm-util/log-response logger-tag rid event data)
228+
(if (= "response.failed" event)
229+
(response-failed->error-data data resp-headers)
230+
(do
231+
(on-stream event data resp-headers)
232+
(set-reading-fn true)
233+
(recur (next events)))))))]
234+
(when stream-error
235+
(on-error stream-error)))
236+
(catch java.io.IOException e
237+
(let [reason @reason*]
238+
(cond
239+
(= :cancelled reason)
240+
(throw (ex-info "Stream cancelled" {:silent? true}))
241+
242+
(= :idle-timeout reason)
243+
(on-error {:message (format "Stream idle timeout: no data received for %d seconds"
244+
(or stream-idle-timeout-seconds 120))
245+
:exception e})
246+
247+
:else
248+
(throw e))))
249+
(finally
250+
(stop-fn))))
228251
(do
229252
(llm-util/log-response logger-tag rid "response" body)
230253
(if (= "failed" (:status body))
@@ -335,7 +358,7 @@
335358

336359
(defn create-response! [{:keys [model user-messages instructions reason? supports-image? api-key api-url url-relative-path
337360
max-output-tokens past-messages tools web-search image-generation extra-payload extra-headers
338-
auth-type account-id http-client prompt-cache-key]}
361+
auth-type account-id http-client prompt-cache-key cancelled? stream-idle-timeout-seconds]}
339362
{:keys [on-message-received on-error on-prepare-tool-call on-tools-called on-reason on-usage-updated
340363
on-server-web-search on-server-image-generation] :as callbacks}]
341364
(let [oauth? (= :auth/oauth auth-type)
@@ -501,6 +524,8 @@
501524
:http-client http-client
502525
:extra-headers extra-headers
503526
:auth-type auth-type
527+
:cancelled? cancelled?
528+
:stream-idle-timeout-seconds stream-idle-timeout-seconds
504529
:on-error on-error
505530
:on-stream handle-stream})))
506531
(on-message-received {:type :finish
@@ -525,6 +550,8 @@
525550
:http-client http-client
526551
:extra-headers extra-headers
527552
:auth-type auth-type
553+
:cancelled? cancelled?
554+
:stream-idle-timeout-seconds stream-idle-timeout-seconds
528555
:on-error on-error
529556
:on-stream on-stream-fn})]
530557
(if callbacks

src/eca/llm_providers/openai_chat.clj

Lines changed: 35 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -115,7 +115,7 @@
115115

116116
(defn ^:private base-chat-request!
117117
[{:keys [rid extra-headers body url-relative-path api-url api-key on-error on-stream
118-
on-tools-called-wrapper http-client]}]
118+
on-tools-called-wrapper http-client cancelled? stream-idle-timeout-seconds]}]
119119
(let [url (join-api-url api-url (or url-relative-path chat-completions-path))
120120
extra-headers (if (fn? extra-headers)
121121
(extra-headers {:body body})
@@ -146,11 +146,35 @@
146146
:body body-str
147147
:headers resp-headers}))
148148
(if on-stream
149-
(with-open [rdr (io/reader body)]
150-
(doseq [[event data] (llm-util/event-data-seq rdr)]
151-
(llm-util/log-response logger-tag rid event data)
152-
(on-stream event data))
153-
(on-stream "stream-end" {}))
149+
(let [{:keys [touch-fn set-reading-fn stop-fn reason*]}
150+
(llm-util/start-stream-watchdog! body cancelled?
151+
(when stream-idle-timeout-seconds
152+
{:idle-timeout-ms (* 1000 stream-idle-timeout-seconds)}))]
153+
(try
154+
(with-open [rdr (io/reader body)]
155+
(doseq [[event data] (llm-util/event-data-seq rdr)]
156+
(set-reading-fn false)
157+
(touch-fn)
158+
(llm-util/log-response logger-tag rid event data)
159+
(on-stream event data)
160+
(set-reading-fn true))
161+
(set-reading-fn false)
162+
(on-stream "stream-end" {}))
163+
(catch java.io.IOException e
164+
(let [reason @reason*]
165+
(cond
166+
(= :cancelled reason)
167+
(throw (ex-info "Stream cancelled" {:silent? true}))
168+
169+
(= :idle-timeout reason)
170+
(on-error {:message (format "Stream idle timeout: no data received for %d seconds"
171+
(or stream-idle-timeout-seconds 120))
172+
:exception e})
173+
174+
:else
175+
(throw e))))
176+
(finally
177+
(stop-fn))))
154178
(do
155179
(llm-util/log-response logger-tag rid "full-response" body)
156180
(response-body->result body on-tools-called-wrapper)))))
@@ -524,7 +548,7 @@
524548
Compatible with OpenRouter and other OpenAI-compatible providers."
525549
[{:keys [model user-messages instructions temperature api-key api-url url-relative-path
526550
max-output-tokens past-messages tools extra-payload extra-headers supports-image?
527-
think-tag-start think-tag-end reasoning-history http-client]}
551+
think-tag-start think-tag-end reasoning-history http-client cancelled? stream-idle-timeout-seconds]}
528552
{:keys [on-message-received on-error on-prepare-tool-call on-tools-called on-reason on-usage-updated] :as callbacks}]
529553
(let [think-tag-start (or think-tag-start "<think>")
530554
think-tag-end (or think-tag-end "</think>")
@@ -613,6 +637,8 @@
613637
:api-url api-url
614638
:api-key (or fresh-api-key api-key)
615639
:url-relative-path url-relative-path
640+
:cancelled? cancelled?
641+
:stream-idle-timeout-seconds stream-idle-timeout-seconds
616642
:on-error wrapped-on-error
617643
:on-stream (when stream? (fn [event data] (handle-response event data tool-calls*)))}))))
618644

@@ -734,6 +760,8 @@
734760
:url-relative-path url-relative-path
735761
:tool-calls* tool-calls*
736762
:on-tools-called-wrapper on-tools-called-wrapper
763+
:cancelled? cancelled?
764+
:stream-idle-timeout-seconds stream-idle-timeout-seconds
737765
:on-error wrapped-on-error
738766
:on-stream (when stream?
739767
(fn [event data] (handle-response event data tool-calls*)))})))

test/eca/client_test_helpers.clj

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -174,6 +174,23 @@
174174
(finally
175175
(.abort ^DefaultHttpProxyServer (:px prx#)))))))
176176

177+
(defn blocking-input-stream
178+
"An InputStream whose reads block until `close` is called (10s max),
179+
then throw IOException. Simulates a hung streaming connection (e.g.
180+
waiting for the first LLM token) for stream-watchdog tests."
181+
[]
182+
(let [closed* (promise)
183+
block-then-throw! (fn []
184+
(deref closed* 10000 nil)
185+
(throw (java.io.IOException. "Stream closed")))]
186+
(proxy [java.io.InputStream] []
187+
(read
188+
([] (block-then-throw!))
189+
([_b] (block-then-throw!))
190+
([_b _off _len] (block-then-throw!)))
191+
(close []
192+
(deliver closed* true)))))
193+
177194
(def ^:dynamic *http-client-captures*
178195
"A record of all `eca.client-http/merge-with-global-http-client` merge
179196
requests results done during the call to `with-client-proxied`

test/eca/llm_providers/ollama_test.clj

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,9 @@
22
(:require
33
[cheshire.core :as json]
44
[clojure.test :refer [deftest is testing]]
5-
[eca.client-test-helpers :refer [with-client-proxied]]
5+
[eca.client-test-helpers :refer [blocking-input-stream with-client-proxied]]
66
[eca.llm-providers.ollama :as llm-providers.ollama]
7+
[hato.client :as http]
78
[matcher-combinators.test :refer [match?]]))
89

910
(deftest list-models-test
@@ -134,3 +135,30 @@
134135
:error false
135136
:text "Allowed directories: /foo/bar"}]}}}
136137
{:role "assistant" :content "I see /foo/bar"}])))))
138+
139+
(deftest chat-stream-cancelled-test
140+
(testing "watchdog aborts a hung stream when cancelled, surfacing a silent error"
141+
(let [errors* (atom [])
142+
messages* (atom [])
143+
stream-body (blocking-input-stream)]
144+
(with-redefs [http/post (fn [_url opts]
145+
(is (= :stream (:as opts)))
146+
{:status 200
147+
:body stream-body})]
148+
(llm-providers.ollama/chat!
149+
{:model "test-model"
150+
:instructions "System prompt"
151+
:user-messages [{:role "user" :content "hello"}]
152+
:past-messages []
153+
:tools []
154+
:api-url "http://localhost:1"
155+
:cancelled? (constantly true)}
156+
{:on-message-received (fn [msg] (swap! messages* conj msg))
157+
:on-error (fn [err] (swap! errors* conj err))
158+
:on-prepare-tool-call (fn [_])
159+
:on-tools-called (fn [_] {:new-messages [] :tools []})
160+
:on-reason (fn [_])}))
161+
(is (= 1 (count @errors*)))
162+
(is (= "Stream cancelled" (ex-message (:exception (first @errors*)))))
163+
(is (true? (:silent? (ex-data (:exception (first @errors*))))))
164+
(is (empty? @messages*)))))

0 commit comments

Comments
 (0)