Skip to content

Commit a46f23e

Browse files
committed
Fix auth refresh propagation during streaming tool calls
Auth tokens were captured in provider closures at prompt start. When tokens expired during long-running tool calls like spawn_agent, the renewed credentials in db* weren't propagated back to the recursive streaming calls, causing mid-session auth failures. on-tools-called! now returns fresh-api-key and provider-auth so providers can use refreshed credentials for subsequent requests. Also renews tokens 60 seconds before expiration to avoid race between check and request.
1 parent b290604 commit a46f23e

File tree

9 files changed

+145
-28
lines changed

9 files changed

+145
-28
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
## 0.124.2
66

77
- Fix OpenAI Responses API tool calls not executing when streaming response returns empty output, and fix spurious retries caused by stale tool-call state with Copilot encrypted IDs. #398
8+
- Fix auth refresh propagation during streaming and `spawn_agent` tool calls, preventing mid-session failures.
89

910
## 0.124.1
1011

src/eca/features/chat/tool_calls.clj

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -748,8 +748,8 @@
748748
:details details
749749
:summary summary
750750
:progress-text (if (= name "spawn_agent")
751-
"Waiting subagent"
752-
"Calling tool")})))
751+
"Waiting subagent"
752+
"Calling tool")})))
753753
(let [tool-call-state (get-tool-call-state @db* chat-id id)
754754
{:keys [code text]} (:decision-reason tool-call-state)
755755
effective-hook-continue (when hook-rejected? hook-continue)
@@ -834,8 +834,15 @@
834834
nil))))
835835
(do
836836
(lifecycle/maybe-renew-auth-token chat-ctx)
837-
(if-let [continue-fn (:continue-fn chat-ctx)]
838-
(continue-fn all-tools user-messages)
839-
{:tools all-tools
840-
:new-messages (shared/messages-after-last-compact-marker
841-
(get-in @db* [:chats chat-id :messages]))}))))))))))
837+
(let [provider-auth (get-in @db* [:auth (:provider chat-ctx)])
838+
[_ fresh-api-key] (llm-util/provider-api-key (:provider chat-ctx)
839+
provider-auth
840+
config)
841+
result (if-let [continue-fn (:continue-fn chat-ctx)]
842+
(continue-fn all-tools user-messages)
843+
{:tools all-tools
844+
:new-messages (get-in @db* [:chats chat-id :messages])})]
845+
(when result
846+
(assoc result
847+
:fresh-api-key fresh-api-key
848+
:provider-auth provider-auth))))))))))))

src/eca/features/login.clj

Lines changed: 13 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -91,24 +91,27 @@
9191

9292
(defn maybe-renew-auth-token! [{:keys [provider on-renewing on-error]} ctx]
9393
(when-let [expires-at (get-in @(:db* ctx) [:auth provider :expires-at])]
94-
(when (<= (long expires-at) (quot (System/currentTimeMillis) 1000))
94+
;; Renew 60 seconds before expiration to avoid race between check and request
95+
(when (<= (long expires-at) (+ 60 (quot (System/currentTimeMillis) 1000)))
9596
(when on-renewing
9697
(on-renewing))
9798
(renew-auth! provider ctx
9899
{:on-error on-error}))))
99100

100101
(defn login-done! [{:keys [chat-id db* messenger metrics provider send-msg!]}
101-
& {:keys [silent?]
102-
:or {silent? false}}]
102+
& {:keys [silent? skip-models-sync?]
103+
:or {silent? false
104+
skip-models-sync? false}}]
103105
(when (get-in @db* [:auth provider])
104106
(db/update-global-cache! @db* metrics))
105-
(models/sync-models! db*
106-
(config/all @db*) ;; force get updated config
107-
(fn [new-models]
108-
(messenger/config-updated
109-
messenger
110-
{:chat
111-
{:models (sort (keys new-models))}})))
107+
(when-not skip-models-sync?
108+
(models/sync-models! db*
109+
(config/all @db*) ;; force get updated config
110+
(fn [new-models]
111+
(messenger/config-updated
112+
messenger
113+
{:chat
114+
{:models (sort (keys new-models))}}))))
112115
(swap! db* assoc-in [:chats chat-id :login-provider] nil)
113116
(swap! db* assoc-in [:chats chat-id :status] :idle)
114117
(when-not silent?

src/eca/llm_providers/anthropic.clj

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -446,7 +446,7 @@
446446
:full-name (:name content-block)
447447
:arguments (json/parse-string (:input-json content-block))}))
448448
(vals @content-block*))]
449-
(when-let [{:keys [new-messages tools]} (on-tools-called tool-calls)]
449+
(when-let [{:keys [new-messages tools fresh-api-key]} (on-tools-called tool-calls)]
450450
(let [messages (-> new-messages
451451
group-parallel-tool-calls
452452
(normalize-messages supports-image?)
@@ -460,7 +460,7 @@
460460
:messages messages
461461
:tools (->tools tools web-search))
462462
:api-url api-url
463-
:api-key api-key
463+
:api-key (or fresh-api-key api-key)
464464
:http-client http-client
465465
:extra-headers extra-headers
466466
:auth-type auth-type
@@ -698,4 +698,4 @@
698698
:refresh-token refresh-token
699699
:api-key access-token
700700
:expires-at expires-at})
701-
(f.login/login-done! ctx :silent? true)))
701+
(f.login/login-done! ctx :silent? true :skip-models-sync? true)))

src/eca/llm_providers/copilot.clj

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -130,4 +130,4 @@
130130
{:keys [api-key expires-at]} (oauth-renew-token access-token)]
131131
(swap! db* update-in [:auth provider] merge {:api-key api-key
132132
:expires-at expires-at})
133-
(f.login/login-done! ctx :silent? true)))
133+
(f.login/login-done! ctx :silent? true :skip-models-sync? true)))

src/eca/llm_providers/openai.clj

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -276,17 +276,18 @@
276276
:output-tokens (-> response :usage :output_tokens)
277277
:input-cache-read-tokens input-cache-read-tokens}))
278278
(if (seq tool-calls)
279-
(when-let [{:keys [new-messages tools]} (on-tools-called tool-calls)]
280-
(reset! tool-call-by-item-id* {})
279+
(when-let [{:keys [new-messages tools fresh-api-key provider-auth]} (on-tools-called tool-calls)]
280+
(doseq [tool-call tool-calls]
281+
(swap! tool-call-by-item-id* dissoc (:item-id tool-call)))
281282
(base-responses-request!
282283
{:rid (llm-util/gen-rid)
283284
:body (assoc body
284285
:input (normalize-messages new-messages supports-image?)
285286
:tools (->tools tools web-search codex?))
286287
:api-url api-url
287288
:url-relative-path url-relative-path
288-
:api-key api-key
289-
:account-id account-id
289+
:api-key (or fresh-api-key api-key)
290+
:account-id (or (:account-id provider-auth) account-id)
290291
:http-client http-client
291292
:extra-headers extra-headers
292293
:auth-type auth-type
@@ -490,4 +491,4 @@
490491
:api-key access-token
491492
:account-id new-account-id
492493
:expires-at expires-at})
493-
(f.login/login-done! ctx :silent? true)))
494+
(f.login/login-done! ctx :silent? true :skip-models-sync? true)))

src/eca/llm_providers/openai_chat.clj

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -500,7 +500,7 @@
500500
k))
501501
tool-calls))
502502
on-tools-called-wrapper (fn on-tools-called-wrapper [tools-to-call on-tools-called handle-response]
503-
(when-let [{:keys [new-messages tools]} (on-tools-called tools-to-call)]
503+
(when-let [{:keys [new-messages tools fresh-api-key]} (on-tools-called tools-to-call)]
504504
(let [pruned-messages (prune-history new-messages reasoning-history)
505505
new-messages-list (vec (concat
506506
system-messages
@@ -516,7 +516,7 @@
516516
:extra-headers extra-headers
517517
:http-client http-client
518518
:api-url api-url
519-
:api-key api-key
519+
:api-key (or fresh-api-key api-key)
520520
:url-relative-path url-relative-path
521521
:on-error wrapped-on-error
522522
:on-stream (when stream? (fn [event data] (handle-response event data tool-calls*)))}))))

test/eca/features/chat/tool_calls_test.clj

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
(ns eca.features.chat.tool-calls-test
22
(:require
33
[clojure.test :refer [deftest is testing]]
4+
[eca.features.chat.lifecycle :as lifecycle]
45
[eca.features.chat.tool-calls :as tc]
56
[eca.features.hooks :as f.hooks]
67
[eca.features.tools :as f.tools]
@@ -276,3 +277,58 @@
276277
:hook-rejected? false
277278
:arguments-modified? true}
278279
plan)))))))
280+
281+
(deftest on-tools-called!-returns-provider-auth-test
282+
(testing "returns refreshed provider auth in result after token is renewed during tool execution"
283+
;; Regression test for: auth captured in LLM provider closure at prompt start.
284+
;; When a token expires mid-stream, maybe-renew-auth-token updates db*,
285+
;; and on-tools-called! must return the refreshed auth so provider-specific
286+
;; continuation metadata (not just the api key) can be reused.
287+
(h/reset-components!)
288+
(let [chat-id "test-chat"
289+
provider "github-copilot"
290+
renewed-provider-auth {:api-key "fresh-token-xyz"
291+
:expires-at 9999999999}
292+
db* (h/db*)
293+
_ (swap! db* #(-> %
294+
(assoc-in [:auth provider :api-key] "stale-token-abc")
295+
(assoc-in [:chats chat-id :status] :running)
296+
(assoc-in [:chats chat-id :messages] [])
297+
(assoc-in [:chats chat-id :tool-calls "call-1" :status] :preparing)))
298+
;; Pre-set tool call to :preparing state, as it would be after on-prepare-tool-call
299+
;; fires during the streaming phase before on-tools-called! is invoked.
300+
chat-ctx {:db* db*
301+
:config (h/config)
302+
:chat-id chat-id
303+
:provider provider
304+
:agent :default
305+
:messenger (h/messenger)
306+
:metrics (h/metrics)}
307+
received-msgs* (atom "")
308+
add-to-history! (fn [msg]
309+
(swap! db* update-in [:chats chat-id :messages] (fnil conj []) msg))
310+
tool-calls [{:id "call-1"
311+
:full-name "eca__test_tool"
312+
:arguments {}
313+
:arguments-text "{}"}]
314+
all-tools [{:name "test_tool"
315+
:full-name "eca__test_tool"
316+
:origin :eca
317+
:server {:name "eca"}}]
318+
expected-provider-auth (merge {:api-key "stale-token-abc"} renewed-provider-auth)]
319+
(with-redefs [f.tools/all-tools (constantly all-tools)
320+
f.tools/approval (constantly :allow)
321+
f.hooks/trigger-if-matches! (fn [_ _ _ _ _] nil)
322+
f.tools/call-tool! (fn [& _] {:contents [{:text "result" :type :text}]})
323+
f.tools/tool-call-details-before-invocation (constantly nil)
324+
f.tools/tool-call-details-after-invocation (constantly nil)
325+
f.tools/tool-call-summary (constantly "Test tool")
326+
lifecycle/maybe-renew-auth-token
327+
(fn [ctx]
328+
(swap! (:db* ctx) update-in [:auth provider] merge renewed-provider-auth))]
329+
(let [result ((tc/on-tools-called! chat-ctx received-msgs* add-to-history! []) tool-calls)]
330+
(is (= (:api-key expected-provider-auth) (:fresh-api-key result))
331+
"fresh-api-key must be returned so the provider can use it in the recursive streaming call")
332+
(is (= expected-provider-auth
333+
(:provider-auth result))
334+
"provider-auth must be returned so providers can reuse refreshed auth metadata"))))))

test/eca/llm_providers/openai_test.clj

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,55 @@
113113
(is (> (:expires-at result) now-seconds)
114114
"expires-at should be computed relative to current time"))))))
115115

116+
(deftest create-response-refreshes-account-id-after-tool-call-test
117+
(testing "uses refreshed provider auth metadata after a long-running tool call"
118+
(let [requests* (atom [])]
119+
(with-redefs [llm-providers.openai/base-responses-request!
120+
(fn [{:keys [api-key account-id on-stream] :as _opts}]
121+
(swap! requests* conj {:api-key api-key
122+
:account-id account-id})
123+
(when (= 1 (count @requests*))
124+
(on-stream "response.completed"
125+
{:response {:output [{:type "function_call"
126+
:id "item-1"
127+
:call_id "call-1"
128+
:name "eca__spawn_agent"
129+
:arguments "{}"}]
130+
:usage {:input_tokens 1
131+
:output_tokens 1}}}))
132+
:ok)]
133+
(llm-providers.openai/create-response!
134+
{:model "gpt-test"
135+
:user-messages [{:role "user" :content [{:type :text :text "hi"}]}]
136+
:instructions "ins"
137+
:reason? false
138+
:supports-image? false
139+
:api-key "stale-token"
140+
:api-url "http://localhost:1"
141+
:past-messages []
142+
:tools [{:full-name "eca__spawn_agent" :description "spawn" :parameters {:type "object"}}]
143+
:web-search false
144+
:extra-payload {}
145+
:extra-headers nil
146+
:auth-type :auth/oauth
147+
:account-id "old-account"}
148+
{:on-message-received (fn [_])
149+
:on-error (fn [e] (throw (ex-info "err" e)))
150+
:on-prepare-tool-call (fn [_])
151+
:on-tools-called (fn [_]
152+
{:new-messages []
153+
:tools []
154+
:fresh-api-key "fresh-token"
155+
:provider-auth {:account-id "new-account"}})
156+
:on-reason (fn [_])
157+
:on-usage-updated (fn [_])
158+
:on-server-web-search (fn [_])})
159+
(is (= [{:api-key "stale-token"
160+
:account-id "old-account"}
161+
{:api-key "fresh-token"
162+
:account-id "new-account"}]
163+
@requests*))))))
164+
116165
(deftest ->normalize-messages-test
117166
(testing "no previous history"
118167
(is (match?

0 commit comments

Comments
 (0)