diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 0b56d0805..bfa23f06a 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -5,6 +5,7 @@ ### Fixed - Fixed GitHub Copilot device-code login polling to wait before the first token poll, avoiding incorrect device-code failures for some users after browser authorization ([#6187](https://github.com/earendil-works/pi/issues/6187)). +- Fixed OAuth device-code polling to honor the server-provided `slow_down` interval instead of only applying the RFC 8628 5-second increment, so GitHub Copilot login recovers instead of appearing to hang when polls arrive early (e.g. WSL/VM clock drift) ([#6187](https://github.com/earendil-works/pi/issues/6187)). - Fixed OpenAI Codex user-agent construction to synchronously load Node OS metadata, avoiding a startup race that could report `pi (browser)` in Node/Bun. - Fixed Fireworks GLM 5.2 Fast to use the OpenAI-compatible endpoint and `thinkingLevelMap`, aligning it with GLM 5.2 ([#6195](https://github.com/earendil-works/pi/issues/6195)). - Fixed Amazon Bedrock prompt-cache points for Claude Fable 5 and Claude Sonnet 5 ([#6235](https://github.com/earendil-works/pi/issues/6235)). diff --git a/packages/ai/src/utils/oauth/device-code.ts b/packages/ai/src/utils/oauth/device-code.ts index 3c1de92f8..eac240837 100644 --- a/packages/ai/src/utils/oauth/device-code.ts +++ b/packages/ai/src/utils/oauth/device-code.ts @@ -10,7 +10,7 @@ const SLOW_DOWN_INTERVAL_INCREMENT_MS = 5000; type OAuthDeviceCodeIncompletePollResult = | { status: "pending" } - | { status: "slow_down" } + | { status: "slow_down"; intervalSeconds?: number } | { status: "failed"; message: string }; export type OAuthDeviceCodePollResult = OAuthDeviceCodeIncompletePollResult | { status: "complete"; value: T }; @@ -75,8 +75,15 @@ export async function pollOAuthDeviceCodeFlow(options: OAuthDeviceCodePollOpt } if (result.status === "slow_down") { slowDownResponses += 1; - // RFC 8628 section 3.5: apply this increase to this and all subsequent requests. - intervalMs = Math.max(MINIMUM_INTERVAL_MS, intervalMs + SLOW_DOWN_INTERVAL_INCREMENT_MS); + // Use the server-provided interval when given (GitHub reports the new required minimum + // in `interval`); trusting only a client-tracked value risks polling early forever under + // WSL/VM clock drift. Otherwise apply RFC 8628 section 3.5: increase by 5 seconds. + intervalMs = + typeof result.intervalSeconds === "number" && + Number.isFinite(result.intervalSeconds) && + result.intervalSeconds > 0 + ? Math.max(MINIMUM_INTERVAL_MS, Math.floor(result.intervalSeconds * 1000)) + : Math.max(MINIMUM_INTERVAL_MS, intervalMs + SLOW_DOWN_INTERVAL_INCREMENT_MS); } const remainingMs = deadline - Date.now(); diff --git a/packages/ai/src/utils/oauth/github-copilot.ts b/packages/ai/src/utils/oauth/github-copilot.ts index 9f03a139c..01f869578 100644 --- a/packages/ai/src/utils/oauth/github-copilot.ts +++ b/packages/ai/src/utils/oauth/github-copilot.ts @@ -41,6 +41,7 @@ type DeviceTokenSuccessResponse = { type DeviceTokenErrorResponse = { error: string; error_description?: string; + interval?: number; }; export function normalizeDomain(input: string): string | null { @@ -229,13 +230,13 @@ async function pollForGitHubAccessToken( } if (raw && typeof raw === "object" && typeof (raw as DeviceTokenErrorResponse).error === "string") { - const { error, error_description: description } = raw as DeviceTokenErrorResponse; + const { error, error_description: description, interval } = raw as DeviceTokenErrorResponse; if (error === "authorization_pending") { return { status: "pending" }; } if (error === "slow_down") { - return { status: "slow_down" }; + return { status: "slow_down", intervalSeconds: typeof interval === "number" ? interval : undefined }; } const descriptionSuffix = description ? `: ${description}` : ""; diff --git a/packages/ai/test/github-copilot-oauth.test.ts b/packages/ai/test/github-copilot-oauth.test.ts index 4af187fa5..963eef7d5 100644 --- a/packages/ai/test/github-copilot-oauth.test.ts +++ b/packages/ai/test/github-copilot-oauth.test.ts @@ -247,7 +247,7 @@ describe("GitHub Copilot OAuth device flow", () => { const accessTokenPollTimes: number[] = []; const accessTokenResponses = [ jsonResponse({ error: "authorization_pending", error_description: "pending" }), - jsonResponse({ error: "slow_down", error_description: "slow down" }), + jsonResponse({ error: "slow_down", error_description: "slow down", interval: 7 }), jsonResponse({ access_token: "ghu_refresh_token" }), ]; @@ -329,7 +329,8 @@ describe("GitHub Copilot OAuth device flow", () => { await vi.advanceTimersByTimeAsync(1); expect(accessTokenPollTimes).toHaveLength(2); - await vi.advanceTimersByTimeAsync(9999); + // slow_down carried a server-provided interval of 7 seconds. + await vi.advanceTimersByTimeAsync(6999); expect(accessTokenPollTimes).toHaveLength(2); await vi.advanceTimersByTimeAsync(1); @@ -338,7 +339,7 @@ describe("GitHub Copilot OAuth device flow", () => { expect(accessTokenPollTimes).toEqual([ startTime.getTime() + 5000, startTime.getTime() + 10000, - startTime.getTime() + 20000, + startTime.getTime() + 17000, ]); }); diff --git a/packages/ai/test/oauth-device-code.test.ts b/packages/ai/test/oauth-device-code.test.ts index 7b5126790..23f97042d 100644 --- a/packages/ai/test/oauth-device-code.test.ts +++ b/packages/ai/test/oauth-device-code.test.ts @@ -61,6 +61,67 @@ describe("OAuth device-code polling", () => { expect(pollTimes).toEqual([new Date("2026-03-09T00:00:02Z").getTime()]); }); + it("increases the interval by 5 seconds after slow_down without a server interval", async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-03-09T00:00:00Z")); + const startTime = Date.now(); + + const pollTimes: number[] = []; + const results = [{ status: "slow_down" as const }, { status: "complete" as const, value: "token" }]; + const resultPromise = pollOAuthDeviceCodeFlow({ + intervalSeconds: 2, + expiresInSeconds: 900, + poll: async () => { + pollTimes.push(Date.now()); + const result = results.shift(); + if (!result) throw new Error("Unexpected extra poll"); + return result; + }, + }); + + await vi.advanceTimersByTimeAsync(0); + expect(pollTimes).toEqual([startTime]); + + await vi.advanceTimersByTimeAsync(6999); + expect(pollTimes).toEqual([startTime]); + + await vi.advanceTimersByTimeAsync(1); + await expect(resultPromise).resolves.toBe("token"); + expect(pollTimes).toEqual([startTime, startTime + 7000]); + }); + + it("honors a server-provided slow_down interval", async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-03-09T00:00:00Z")); + const startTime = Date.now(); + + const pollTimes: number[] = []; + const results = [ + { status: "slow_down" as const, intervalSeconds: 30 }, + { status: "complete" as const, value: "token" }, + ]; + const resultPromise = pollOAuthDeviceCodeFlow({ + intervalSeconds: 2, + expiresInSeconds: 900, + poll: async () => { + pollTimes.push(Date.now()); + const result = results.shift(); + if (!result) throw new Error("Unexpected extra poll"); + return result; + }, + }); + + await vi.advanceTimersByTimeAsync(0); + expect(pollTimes).toEqual([startTime]); + + await vi.advanceTimersByTimeAsync(29999); + expect(pollTimes).toEqual([startTime]); + + await vi.advanceTimersByTimeAsync(1); + await expect(resultPromise).resolves.toBe("token"); + expect(pollTimes).toEqual([startTime, startTime + 30000]); + }); + it("cancels an in-flight wait", async () => { vi.useFakeTimers(); const controller = new AbortController();