From aa0448f7e2fc533d9db91238ad58b8027b5d4f96 Mon Sep 17 00:00:00 2001 From: decolua Date: Sat, 25 Jul 2026 17:30:11 +0700 Subject: [PATCH] fix(refresh): rotate refresh_token between retry attempts Rotating-RT providers (xAI/grok-cli) issue a new refresh_token on every refresh; mutate credentials in-place so refreshWithRetry reuses the fresh RT instead of the already-consumed one. Co-Authored-By: Claude Fable 5 --- open-sse/handlers/chatCore.js | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/open-sse/handlers/chatCore.js b/open-sse/handlers/chatCore.js index 4e3475f4..4f91e020 100644 --- a/open-sse/handlers/chatCore.js +++ b/open-sse/handlers/chatCore.js @@ -330,7 +330,18 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred // Handle 401/403 - try token refresh (skip for noAuth providers) if (!executor.noAuth && (providerResponse.status === HTTP_STATUS.UNAUTHORIZED || providerResponse.status === HTTP_STATUS.FORBIDDEN)) { try { - const newCredentials = await refreshWithRetry(() => executor.refreshCredentials(credentials, log), 3, log); + // Mutate credentials after each successful refresh: rotating refresh_token + // providers (xAI/grok-cli) issue a new RT on every refresh; without this, + // refreshWithRetry's 2nd/3rd attempt reuses the already-consumed RT โ†’ + // invalid_grant โ†’ auth_failed retryable=false. + const newCredentials = await refreshWithRetry(async () => { + const result = await executor.refreshCredentials(credentials, log); + if (result?.refreshToken && result.refreshToken !== credentials.refreshToken) { + if (result.accessToken) credentials.accessToken = result.accessToken; + credentials.refreshToken = result.refreshToken; + } + return result; + }, 3, log); if (newCredentials?.accessToken || newCredentials?.copilotToken) { if (log?.line) log.line(reqTag, "๐Ÿ”‘", `TOKEN REFRESHED ยท ${provider}/${model}`); Object.assign(credentials, newCredentials);