Skip to content

Commit 67ccc01

Browse files
authored
Merge pull request #399 from editor-code-assistant/fix-auth-propagation-after-tool-calls
Fix auth refresh propagation during streaming tool calls
2 parents b290604 + a46f23e commit 67ccc01

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)