fix(ai): delay Copilot device-code token polling

closes #6187
This commit is contained in:
Vegard Stikbakke 2026-07-01 13:47:47 +02:00
parent ba10b60b51
commit e2ccdc8509
5 changed files with 55 additions and 10 deletions

View file

@ -4,6 +4,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 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)).

View file

@ -18,6 +18,7 @@ export type OAuthDeviceCodePollResult<T> = OAuthDeviceCodeIncompletePollResult |
export type OAuthDeviceCodePollOptions<T> = {
intervalSeconds?: number;
expiresInSeconds?: number;
waitBeforeFirstPoll?: boolean;
poll: () => Promise<OAuthDeviceCodePollResult<T>>;
signal?: AbortSignal;
};
@ -53,6 +54,13 @@ export async function pollOAuthDeviceCodeFlow<T>(options: OAuthDeviceCodePollOpt
);
let slowDownResponses = 0;
if (options.waitBeforeFirstPoll) {
const remainingMs = deadline - Date.now();
if (remainingMs > 0) {
await abortableSleep(Math.min(intervalMs, remainingMs), options.signal, CANCEL_MESSAGE);
}
}
while (Date.now() < deadline) {
if (options.signal?.aborted) {
throw new Error(CANCEL_MESSAGE);

View file

@ -207,6 +207,7 @@ async function pollForGitHubAccessToken(
return pollOAuthDeviceCodeFlow<string>({
intervalSeconds: device.interval,
expiresInSeconds: device.expires_in,
waitBeforeFirstPoll: true,
signal,
poll: async () => {
const raw = await fetchJson(urls.accessTokenUrl, {

View file

@ -239,7 +239,7 @@ describe("GitHub Copilot OAuth device flow", () => {
await loginPromise;
});
it("polls immediately and increases the interval after slow_down", async () => {
it("waits before polling and increases the interval after slow_down", async () => {
vi.useFakeTimers();
const startTime = new Date("2026-03-09T00:00:00Z");
vi.setSystemTime(startTime);
@ -315,6 +315,12 @@ describe("GitHub Copilot OAuth device flow", () => {
});
await vi.advanceTimersByTimeAsync(0);
expect(accessTokenPollTimes).toHaveLength(0);
await vi.advanceTimersByTimeAsync(4999);
expect(accessTokenPollTimes).toHaveLength(0);
await vi.advanceTimersByTimeAsync(1);
expect(accessTokenPollTimes).toHaveLength(1);
await vi.advanceTimersByTimeAsync(4999);
@ -330,9 +336,9 @@ describe("GitHub Copilot OAuth device flow", () => {
await loginPromise;
expect(accessTokenPollTimes).toEqual([
startTime.getTime(),
startTime.getTime() + 5000,
startTime.getTime() + 15000,
startTime.getTime() + 10000,
startTime.getTime() + 20000,
]);
});
@ -383,18 +389,24 @@ describe("GitHub Copilot OAuth device flow", () => {
/Device flow timed out after one or more slow_down responses/,
);
await vi.advanceTimersByTimeAsync(5000);
expect(accessTokenPollTimes).toEqual([startTime.getTime()]);
await vi.advanceTimersByTimeAsync(4999);
expect(accessTokenPollTimes).toEqual([]);
await vi.advanceTimersByTimeAsync(5000);
expect(accessTokenPollTimes).toEqual([startTime.getTime(), startTime.getTime() + 10000]);
await vi.advanceTimersByTimeAsync(1);
expect(accessTokenPollTimes).toEqual([startTime.getTime() + 5000]);
await vi.advanceTimersByTimeAsync(14999);
expect(accessTokenPollTimes).toEqual([startTime.getTime(), startTime.getTime() + 10000]);
await vi.advanceTimersByTimeAsync(9999);
expect(accessTokenPollTimes).toEqual([startTime.getTime() + 5000]);
await vi.advanceTimersByTimeAsync(1);
expect(accessTokenPollTimes).toEqual([startTime.getTime() + 5000, startTime.getTime() + 15000]);
await vi.advanceTimersByTimeAsync(9999);
expect(accessTokenPollTimes).toEqual([startTime.getTime() + 5000, startTime.getTime() + 15000]);
await vi.advanceTimersByTimeAsync(1);
await rejection;
expect(accessTokenPollTimes).toEqual([startTime.getTime(), startTime.getTime() + 10000]);
expect(accessTokenPollTimes).toEqual([startTime.getTime() + 5000, startTime.getTime() + 15000]);
});
});

View file

@ -38,6 +38,29 @@ describe("OAuth device-code polling", () => {
]);
});
it("can wait before the first poll", async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-03-09T00:00:00Z"));
const pollTimes: number[] = [];
const resultPromise = pollOAuthDeviceCodeFlow({
intervalSeconds: 2,
expiresInSeconds: 30,
waitBeforeFirstPoll: true,
poll: async () => {
pollTimes.push(Date.now());
return { status: "complete" as const, value: "token" };
},
});
await vi.advanceTimersByTimeAsync(1999);
expect(pollTimes).toEqual([]);
await vi.advanceTimersByTimeAsync(1);
await expect(resultPromise).resolves.toBe("token");
expect(pollTimes).toEqual([new Date("2026-03-09T00:00:02Z").getTime()]);
});
it("cancels an in-flight wait", async () => {
vi.useFakeTimers();
const controller = new AbortController();