Skip to content

Renew Copilot auth token before each LLM API request attempt#1

Merged
itkonen merged 5 commits intomasterfrom
copilot/add-token-renewal-checks
Apr 8, 2026
Merged

Renew Copilot auth token before each LLM API request attempt#1
itkonen merged 5 commits intomasterfrom
copilot/add-token-renewal-checks

Conversation

Copy link
Copy Markdown

Copilot AI commented Apr 8, 2026

Copilot tokens expire during long agentic workflows, causing 401 errors mid-execution. Token renewal was only triggered at prompt start and post-tool-call, leaving retry loops and multi-step sequences unprotected.

Changes

  • lifecycle.clj: Add silent-provider-auth-fn factory — returns a 0-arity closure that silently renews provider auth if expired and returns the current auth. This is the Clojure equivalent of a self-managing token: the closure encapsulates all renewal logic so callers of sync-or-async-prompt! don't need to know about token validity.
  • llm_api.clj: Add optional :provider-auth-fn param to sync-or-async-prompt!; before each attempt (sync and async), (resolve-auth) is bound to current-auth — which invokes provider-auth-fn if provided, otherwise falls back to static provider-auth. Removed f.login require from llm_api.clj — renewal logic stays in lifecycle/callers.
  • login.clj: Guard login-done! with (when chat-id ...) — prevents nil-keyed chat entries when renewal is invoked outside a chat context.
  • chat.clj: Pass :provider-auth-fn (lifecycle/silent-provider-auth-fn chat-ctx).
  • rewrite.clj: Pass :provider-auth-fn as an inline closure using the existing f.login import.
  • rewrite_test.clj: Updated prompt-default-model-and-auth-renew-test to invoke (:provider-auth-fn opts) inside the stubbed sync-or-async-prompt! and assert against the return value, replacing the removed :provider-auth key check.
  • llm_api_test.clj: Added four tests covering :provider-auth-fn behavior — verifying the closure is invoked before the initial attempt and again on each retry, for both sync (:stream false) and async/streaming paths, and that the resolved auth is what gets passed into prompt!.
;; lifecycle.clj — self-managing token factory
(defn silent-provider-auth-fn [{:keys [provider db* config messenger metrics]}]
  (fn []
    (f.login/maybe-renew-auth-token! {:provider provider :on-error log-only} ctx)
    (get-in @db* [:auth provider])))

;; llm_api.clj — fresh auth resolved before each attempt
(let [current-auth (resolve-auth)]
  (prompt! {:provider-auth current-auth ...}))

Note: This fix targets Copilot OAuth tokens specifically — API-key providers are unaffected. 401 errors are classified as :auth (not retryable), so a failed renewal surfaces immediately rather than burning retry budget.

  • I added a entry in changelog under unreleased section.
  • This is not an AI slop.
Original prompt

Problem

Users are experiencing 401 "unauthorized: token expired" errors during long agentic workflows with GitHub Copilot. The error occurs mid-workflow but the session remains intact, allowing users to manually continue by sending another message.

Root Cause

Token renewal (maybe-renew-auth-token) is currently only called at these points:

  1. At the start of each user prompt (src/eca/features/chat.clj:509)
  2. After tool calls complete (src/eca/features/chat/tool_calls.clj:840)
  3. In rewrite/completion features

During long workflows, the GitHub Copilot token can expire:

  • Between multiple tool calls
  • During retry attempts
  • In the middle of agentic loops

When a user manually sends a message, it triggers token renewal at the start of the prompt handler, which is why manually continuing works.

Solution

Add token renewal checks before each LLM API request attempt in sync-or-async-prompt! to ensure tokens are refreshed proactively during:

  • Initial requests
  • Retry attempts (rate limiting, overload, premature stops)
  • Recursive tool call continuations

This ensures tokens remain valid throughout the entire workflow lifecycle, not just at user-initiated prompt boundaries.

Implementation Details

Add maybe-renew-auth-token! call in src/eca/llm_api.clj in the sync-or-async-prompt! function:

  1. Before the async retry loop - to catch expirations during retries
  2. Before the sync retry loop - for synchronous operations

The renewal should:

  • Silently refresh the token if it's expired or about to expire
  • Update the db* atom with the new token and expiration
  • Log errors but not throw (let the subsequent API call fail with proper error handling)

This matches the pattern already used in src/eca/features/chat/tool_calls.clj:840 where token renewal happens before continuing after tool execution.

Testing

To verify the fix works:

  1. Trigger a long agentic workflow with GitHub Copilot
  2. The workflow should complete without 401 errors even if the token expires mid-execution
  3. Check logs to confirm token renewal is happening proactively

Related Code

  • Token renewal logic: src/eca/features/login.clj:90-95
  • GitHub Copilot token management: src/eca/llm_providers/copilot.clj:61-68
  • Current token renewal calls: src/eca/features/chat.clj:509, src/eca/features/chat/tool_calls.clj:840

The following is the prior conversation context from the user's chat exploration (may be truncated):

User: I'm getting this error, and it tends to occur in the middle of long agentic workflows:

LLM response status: 401 body: unauthorized: token expired

It interrupts the task, but the session remains intact so that I can ask the agent to continue. Could it be that token is checked and refreshed on user input but not on intermediate steps during the process?

The main developer said:

Yeah, I mentioend about it here, I believe github has a limit of how much can use the x-initiator as agent until needs to consume other premium request (x-initiator: user), that's why you sending the prompt works because user prompt goes as user.

Can you diagnose whats going wrong? I would like my workflows to finish without the interrupting error.

Assistant: Based on my analysis of the code, I can now diagnose the issue you're experiencing.

Problem Diagnosis

You're getting a 401 "unauthorized: token expired" error during long agentic workflows with GitHub Copilot. The issue is that token renewal is only happening at specific points in the workflow, not during intermediate tool execution steps.

Current Token Renewal Points

Looking at the code, maybe-renew-auth-token is called in these places:

  1. At the start of each user prompt (src/eca/features/chat.clj:509)
  2. After tool calls complete (src/eca/features/chat/tool_calls.clj:840)
  3. In rewrite/completion features

The Problem

However, during long agentic workflows, the token can expire during tool execution or between tool calls, especially when:

  • Multiple tool calls are executing sequentially
  • Individual tool calls take a long time
  • The workflow involves many intermediate steps

The developer's comment confirms this: GitHub Copilot has limits on how many requests can use x-initiator: agent before needing to consume premium requests with x-initiator: user. When you manually send a message, it's treated as x-initiator: user, which refreshes your quota.

Root Cause

The GitHub Copilot token (api-key in the auth state) has an expires-at timestamp. Looking at src/eca/llm_providers/copilot.clj:64-68, tokens are obtained via oauth-renew-token and include an expiration time.

The token renewal check in src/eca/features/login.clj:90-95 compares the expiration time:

(when (<= (long expires-at) (quot (System/currentTimeMillis) 1000))
  ;; renew token
)

But this check only happens at the start of prompts and after tool completion, not during the actual...

This pull request was created from Copilot chat.

Copilot AI changed the title [WIP] Add token renewal checks before LLM API requests Renew Copilot auth token before each LLM API request attempt Apr 8, 2026
Copilot AI requested a review from itkonen April 8, 2026 07:01
Copy link
Copy Markdown
Owner

@itkonen itkonen left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm slightly concerned if passing around db*/messenger/metrics is necessary. The changes touch multiple files, and my intuition was that this could be simply handled internally by the token object/class. I'm not at all familiar with the codebase or clojure, so I'm not sure if the current suggestion is optimal. In my language of preference, I would make the token a class that handles it's freshness internally, so that the users of that token would not need to worry about validity.

Copy link
Copy Markdown
Author

Copilot AI commented Apr 8, 2026

I'm slightly concerned if passing around db*/messenger/metrics is necessary. The changes touch multiple files, and my intuition was that this could be simply handled internally by the token object/cl...

Refactored in b984531. Instead of passing db*/messenger/metrics through the API, sync-or-async-prompt! now accepts a single optional :provider-auth-fn — a 0-arity closure that handles renewal silently and returns fresh auth. This is the Clojure equivalent of a self-managing token.

The factory lifecycle/silent-provider-auth-fn creates this closure. chat.clj passes (lifecycle/silent-provider-auth-fn chat-ctx), and rewrite.clj builds an equivalent inline closure. Callers that don't need renewal (like sync-prompt!) continue passing static :provider-auth unchanged.

Copilot AI requested a review from itkonen April 8, 2026 09:35
@itkonen
Copy link
Copy Markdown
Owner

itkonen commented Apr 8, 2026

@copilot fix what's breaking the tests

Copy link
Copy Markdown
Author

Copilot AI commented Apr 8, 2026

@copilot fix what's breaking the tests

Fixed in 1ddcb61. The failing test prompt-default-model-and-auth-renew-test was checking (:provider-auth @api-opts*) but after the refactoring rewrite.clj passes :provider-auth-fn instead of a static :provider-auth. Updated the test to call (:provider-auth-fn opts) inside the stubbed sync-or-async-prompt! (still within with-redefs scope so the renewal stub fires), capture the result in auth-result*, and assert against that.

@itkonen itkonen marked this pull request as ready for review April 8, 2026 11:01
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR aims to prevent GitHub Copilot auth tokens from expiring mid-workflow by resolving/renewing provider auth before each LLM API request attempt, rather than only at prompt boundaries.

Changes:

  • Added a silent-provider-auth-fn factory in chat lifecycle to encapsulate silent token renewal + returning current auth.
  • Extended llm-api/sync-or-async-prompt! with optional :provider-auth-fn and resolved auth before each retry attempt (sync + async).
  • Updated chat/rewrite call sites and adjusted rewrite test expectations; guarded login-done! against nil chat-id.

Reviewed changes

Copilot reviewed 7 out of 7 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
src/eca/llm_api.clj Adds :provider-auth-fn and resolves auth per retry attempt before calling prompt!.
src/eca/features/chat/lifecycle.clj Introduces silent-provider-auth-fn closure for silent renewal + auth retrieval.
src/eca/features/chat.clj Switches chat prompt to pass :provider-auth-fn from lifecycle.
src/eca/features/rewrite.clj Switches rewrite prompt to pass an inline :provider-auth-fn.
src/eca/features/login.clj Prevents nil-keyed chat updates by guarding login-done! with when chat-id.
test/eca/features/rewrite_test.clj Updates test to invoke/assert :provider-auth-fn result rather than :provider-auth key.
CHANGELOG.md Notes the token-renewal behavior change under Unreleased.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Copy link
Copy Markdown
Author

Copilot AI commented Apr 8, 2026

@copilot fix what's breaking the tests

Tests were already fixed in 1ddcb61. The failing prompt-default-model-and-auth-renew-test was updated to work with the new :provider-auth-fn interface.

Copilot AI requested a review from itkonen April 8, 2026 11:49
@itkonen itkonen merged commit 9faeda6 into master Apr 8, 2026
9 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants