Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

## Unreleased

- Renew GitHub Copilot auth token before each LLM API request attempt to prevent 401 token expired errors during long agentic workflows.

## 0.124.2

- 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
Expand Down
2 changes: 1 addition & 1 deletion src/eca/features/chat.clj
Original file line number Diff line number Diff line change
Expand Up @@ -556,7 +556,7 @@
(get-in @db* [:chats chat-id :messages] []))
:config config
:tools all-tools
:provider-auth provider-auth
:provider-auth-fn (lifecycle/silent-provider-auth-fn chat-ctx)
:variant (:variant chat-ctx)
:subagent? (some? (get-in @db* [:chats chat-id :subagent]))
:cancelled? (fn []
Expand Down
13 changes: 13 additions & 0 deletions src/eca/features/chat/lifecycle.clj
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,19 @@
(throw (ex-info "Auth token renew failed" {})))}
chat-ctx))

(defn silent-provider-auth-fn
"Returns a 0-arity fn that silently renews provider auth if expired and returns the current auth.
Errors are logged but do not throw, so ongoing requests are not interrupted.
Use as :provider-auth-fn in sync-or-async-prompt! for proactive token refresh."
[{:keys [provider db* config messenger metrics]}]
(fn []
(f.login/maybe-renew-auth-token!
{:provider provider
:on-error (fn [error-msg]
(logger/warn logger-tag "Token renewal failed:" error-msg))}
{:db* db* :config config :messenger messenger :metrics metrics})
(get-in @db* [:auth provider])))

(defn assert-chat-not-stopped! [{:keys [chat-id db* prompt-id] :as chat-ctx}]
(let [chat (get-in @db* [:chats chat-id])
superseded? (and prompt-id (not= prompt-id (:prompt-id chat)))
Expand Down
5 changes: 3 additions & 2 deletions src/eca/features/login.clj
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,8 @@
messenger
{:chat
{:models (sort (keys new-models))}})))
(swap! db* assoc-in [:chats chat-id :login-provider] nil)
(swap! db* assoc-in [:chats chat-id :status] :idle)
(when chat-id
(swap! db* assoc-in [:chats chat-id :login-provider] nil)
(swap! db* assoc-in [:chats chat-id :status] :idle))
(when-not silent?
(send-msg! (format "\nLogin successful! You can now use the '%s' models." provider))))
8 changes: 7 additions & 1 deletion src/eca/features/rewrite.clj
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,13 @@
:config config
:user-messages [{:role "user" :content [{:type :text :text prompt}]}]
:past-messages []
:provider-auth provider-auth
:provider-auth-fn (fn []
(f.login/maybe-renew-auth-token!
{:provider provider
:on-error (fn [error-msg]
(logger/error logger-tag (format "Auth token renew failed: %s" error-msg)))}
ctx)
(get-in @db* [:auth provider]))
:on-first-response-received (fn [& _]
(send-content! ctx {:type :started}))
:on-reason (fn [{:keys [status]}]
Expand Down
119 changes: 61 additions & 58 deletions src/eca/llm_api.clj
Original file line number Diff line number Diff line change
Expand Up @@ -352,7 +352,7 @@
(defn sync-or-async-prompt!
[{:keys [provider model model-capabilities instructions user-messages config on-first-response-received
on-message-received on-error on-prepare-tool-call on-tools-called on-reason on-usage-updated on-server-web-search
past-messages tools provider-auth variant cancelled? on-retry subagent?]
past-messages tools provider-auth provider-auth-fn variant cancelled? on-retry subagent?]
:or {on-first-response-received identity
on-message-received identity
on-error identity
Expand Down Expand Up @@ -419,69 +419,72 @@
extra-payload (extra-payload-considering-variant model-config variant api-handler (:reason? model-capabilities))
stream? (if (not (nil? (:stream extra-payload)))
(:stream extra-payload)
true)]
true)
resolve-auth (or provider-auth-fn (constantly provider-auth))]
(if (not stream?)
(let [sync-prompt-with-retry*
(fn sync-prompt-with-retry [attempt]
(loop [result (prompt!
{:sync? true
:provider provider
:model model
:model-capabilities model-capabilities
:instructions instructions
:tools tools
:provider-auth provider-auth
:past-messages past-messages
:user-messages user-messages
:variant variant
:subagent? subagent?
:on-error on-error-wrapper
:config config})]
(let [{:keys [error output-text reason-text reasoning-content tools-to-call call-tools-fn reason-id usage]} result]
(if error
(maybe-retry error attempt on-error-wrapper sync-prompt-with-retry)
(do
(when reason-text
(on-reason-wrapper {:status :started :id reason-id})
(on-reason-wrapper {:status :thinking :id reason-id :text reason-text})
(on-reason-wrapper {:status :finished
:id reason-id
:delta-reasoning? (some? reasoning-content)}))
(on-message-received-wrapper {:type :text :text output-text})
(some-> usage (on-usage-updated))
(if-let [new-result (when (seq tools-to-call)
(doseq [tool-to-call tools-to-call]
(on-prepare-tool-call tool-to-call))
(call-tools-fn on-tools-called))]
(recur new-result)
(on-message-received-wrapper {:type :finish :finish-reason "stop"})))))))]
(let [current-auth (resolve-auth)]
(loop [result (prompt!
{:sync? true
:provider provider
:model model
:model-capabilities model-capabilities
:instructions instructions
:tools tools
:provider-auth current-auth
:past-messages past-messages
:user-messages user-messages
:variant variant
:subagent? subagent?
:on-error on-error-wrapper
:config config})]
(let [{:keys [error output-text reason-text reasoning-content tools-to-call call-tools-fn reason-id usage]} result]
(if error
(maybe-retry error attempt on-error-wrapper sync-prompt-with-retry)
(do
(when reason-text
(on-reason-wrapper {:status :started :id reason-id})
(on-reason-wrapper {:status :thinking :id reason-id :text reason-text})
(on-reason-wrapper {:status :finished
:id reason-id
:delta-reasoning? (some? reasoning-content)}))
(on-message-received-wrapper {:type :text :text output-text})
(some-> usage (on-usage-updated))
(if-let [new-result (when (seq tools-to-call)
(doseq [tool-to-call tools-to-call]
(on-prepare-tool-call tool-to-call))
(call-tools-fn on-tools-called))]
(recur new-result)
(on-message-received-wrapper {:type :finish :finish-reason "stop"}))))))))]
(sync-prompt-with-retry* 0))
(let [async-prompt-with-retry*
(fn async-prompt-with-retry [attempt]
(prompt!
{:sync? false
:provider provider
:model model
:model-capabilities model-capabilities
:instructions instructions
:tools tools
:provider-auth provider-auth
:past-messages past-messages
:user-messages user-messages
:variant variant
:subagent? subagent?
:cancelled? cancelled?
:on-message-received on-message-received-wrapper
:on-prepare-tool-call on-prepare-tool-call-wrapper
:on-tools-called on-tools-called
:on-usage-updated on-usage-updated
:on-server-web-search on-server-web-search-wrapper
:on-reason on-reason-wrapper
:on-error (fn [error-data]
(if (:silent? (ex-data (:exception error-data)))
(on-error-wrapper error-data)
(maybe-retry error-data attempt on-error-wrapper async-prompt-with-retry)))
:config config}))]
(let [current-auth (resolve-auth)]
(prompt!
{:sync? false
:provider provider
:model model
:model-capabilities model-capabilities
:instructions instructions
:tools tools
:provider-auth current-auth
:past-messages past-messages
:user-messages user-messages
:variant variant
:subagent? subagent?
:cancelled? cancelled?
:on-message-received on-message-received-wrapper
:on-prepare-tool-call on-prepare-tool-call-wrapper
:on-tools-called on-tools-called
:on-usage-updated on-usage-updated
:on-server-web-search on-server-web-search-wrapper
:on-reason on-reason-wrapper
:on-error (fn [error-data]
(if (:silent? (ex-data (:exception error-data)))
(on-error-wrapper error-data)
(maybe-retry error-data attempt on-error-wrapper async-prompt-with-retry)))
:config config})))]
(async-prompt-with-retry* 0)))))

(defn sync-prompt!
Expand Down
6 changes: 5 additions & 1 deletion test/eca/features/rewrite_test.clj
Original file line number Diff line number Diff line change
Expand Up @@ -154,10 +154,14 @@
(swap! (h/db*) assoc :models {"google/gemini-dev" {:reason? true}})
(let [renew-called* (atom nil)
api-opts* (atom nil)
auth-result* (atom nil)
resp
(with-redefs [llm-api/sync-or-async-prompt!
(fn [opts]
(reset! api-opts* opts)
;; Invoke provider-auth-fn to verify renewal is triggered and auth is returned
(when-let [auth-fn (:provider-auth-fn opts)]
(reset! auth-result* (auth-fn)))
((:on-first-response-received opts) {:type :text})
((:on-message-received opts) {:type :finish}))
f.prompt/build-rewrite-instructions (constantly "INSTR")
Expand All @@ -174,4 +178,4 @@
(is (= "google" @renew-called*))
(is (= "google" (:provider @api-opts*)))
(is (= "gemini-dev" (:model @api-opts*)))
(is (= {:token "abc"} (:provider-auth @api-opts*))))))
(is (= {:token "abc"} @auth-result*)))))
85 changes: 85 additions & 0 deletions test/eca/llm_api_test.clj
Original file line number Diff line number Diff line change
Expand Up @@ -454,3 +454,88 @@
:on-message-received identity})))
(is (= 1 @attempt*))
(is (true? @on-error-called*)))))

(deftest sync-provider-auth-fn-initial-request-test
(testing "provider-auth-fn is invoked before the initial sync attempt and its result is passed to prompt!"
(let [auth-fn-call-count* (atom 0)
captured-auth* (atom nil)
fresh-auth {:api-key "fresh-token"}]
(with-redefs [eca.llm-api/prompt! (fn [opts]
(reset! captured-auth* (:provider-auth opts))
{:output-text "done"
:usage {:input-tokens 1 :output-tokens 1}})
eca.llm-api/sleep-with-cancel (fn [_ _] true)]
(llm-api/sync-or-async-prompt!
(-> (make-prompt-opts {:stream false :on-message-received identity})
(dissoc :provider-auth)
(assoc :provider-auth-fn (fn [] (swap! auth-fn-call-count* inc) fresh-auth)))))
(is (= 1 @auth-fn-call-count*))
(is (= fresh-auth @captured-auth*)))))

(deftest sync-provider-auth-fn-called-per-retry-test
(testing "provider-auth-fn is invoked again on each sync retry attempt"
(let [attempt* (atom 0)
auth-fn-call-count* (atom 0)
captured-auths* (atom [])]
(with-redefs [eca.llm-api/prompt! (fn [opts]
(let [n (swap! attempt* inc)]
(swap! captured-auths* conj (:provider-auth opts))
(if (= 1 n)
{:error {:status 429
:body "Rate limited"
:message "LLM response status: 429"}}
{:output-text "ok"
:usage {:input-tokens 1 :output-tokens 1}})))
eca.llm-api/sleep-with-cancel (fn [_ cancelled?] (not (cancelled?)))]
(llm-api/sync-or-async-prompt!
(-> (make-prompt-opts {:stream false :on-message-received identity})
(dissoc :provider-auth)
(assoc :provider-auth-fn (fn []
(let [n (swap! auth-fn-call-count* inc)]
{:api-key (str "token-" n)}))))))
(is (= 2 @attempt*))
(is (= 2 @auth-fn-call-count*))
(is (= [{:api-key "token-1"} {:api-key "token-2"}] @captured-auths*)))))

(deftest async-provider-auth-fn-initial-request-test
(testing "provider-auth-fn is invoked before the initial async attempt and its result is passed to prompt!"
(let [auth-fn-call-count* (atom 0)
captured-auth* (atom nil)
fresh-auth {:api-key "fresh-token"}]
(with-redefs [eca.llm-api/prompt! (fn [{:keys [on-message-received] :as opts}]
(reset! captured-auth* (:provider-auth opts))
(on-message-received {:type :text :text "hi"})
(on-message-received {:type :finish :finish-reason "stop"}))
eca.llm-api/sleep-with-cancel (fn [_ _] true)]
(llm-api/sync-or-async-prompt!
(-> (make-prompt-opts {:on-message-received identity})
(dissoc :provider-auth)
(assoc :provider-auth-fn (fn [] (swap! auth-fn-call-count* inc) fresh-auth)))))
(is (= 1 @auth-fn-call-count*))
(is (= fresh-auth @captured-auth*)))))

(deftest async-provider-auth-fn-called-per-retry-test
(testing "provider-auth-fn is invoked again on each async retry attempt"
(let [attempt* (atom 0)
auth-fn-call-count* (atom 0)
captured-auths* (atom [])]
(with-redefs [eca.llm-api/prompt! (fn [{:keys [on-message-received on-error] :as opts}]
(let [n (swap! attempt* inc)]
(swap! captured-auths* conj (:provider-auth opts))
(if (= 1 n)
(on-error {:status 503
:body "Overloaded"
:message "LLM response status: 503"})
(do
(on-message-received {:type :text :text "ok"})
(on-message-received {:type :finish :finish-reason "stop"})))))
eca.llm-api/sleep-with-cancel (fn [_ cancelled?] (not (cancelled?)))]
(llm-api/sync-or-async-prompt!
(-> (make-prompt-opts {:on-message-received identity})
(dissoc :provider-auth)
(assoc :provider-auth-fn (fn []
(let [n (swap! auth-fn-call-count* inc)]
{:api-key (str "token-" n)}))))))
(is (= 2 @attempt*))
(is (= 2 @auth-fn-call-count*))
(is (= [{:api-key "token-1"} {:api-key "token-2"}] @captured-auths*)))))
Loading