@oh-my-pi/pi-ai 16.3.14 → 16.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/CHANGELOG.md +45 -0
  2. package/README.md +5 -2
  3. package/dist/types/auth-broker/remote-store.d.ts +1 -0
  4. package/dist/types/auth-broker/wire-schemas.d.ts +8 -0
  5. package/dist/types/auth-storage.d.ts +5 -0
  6. package/dist/types/providers/azure-openai-responses.d.ts +1 -1
  7. package/dist/types/providers/ollama.d.ts +1 -1
  8. package/dist/types/providers/openai-chat-server-schema.d.ts +1 -1
  9. package/dist/types/providers/openai-chat-wire.d.ts +1 -1
  10. package/dist/types/providers/openai-codex/request-transformer.d.ts +43 -5
  11. package/dist/types/providers/openai-codex-responses.d.ts +51 -8
  12. package/dist/types/providers/openai-completions.d.ts +1 -1
  13. package/dist/types/providers/openai-responses-wire.d.ts +16 -7
  14. package/dist/types/providers/openai-responses.d.ts +1 -1
  15. package/dist/types/providers/openai-shared.d.ts +38 -8
  16. package/dist/types/registry/novita.d.ts +6 -0
  17. package/dist/types/registry/oauth/device-code.d.ts +25 -0
  18. package/dist/types/registry/oauth/index.d.ts +1 -25
  19. package/dist/types/registry/oauth/xai-oauth.d.ts +9 -25
  20. package/dist/types/registry/registry.d.ts +4 -1
  21. package/dist/types/registry/xai-oauth.d.ts +0 -1
  22. package/dist/types/types.d.ts +26 -3
  23. package/package.json +4 -4
  24. package/src/auth-broker/remote-store.ts +60 -5
  25. package/src/auth-broker/server.ts +1 -0
  26. package/src/auth-broker/wire-schemas.ts +1 -0
  27. package/src/auth-gateway/server.ts +2 -2
  28. package/src/auth-storage.ts +198 -60
  29. package/src/error/flags.ts +4 -1
  30. package/src/error/rate-limit.ts +7 -1
  31. package/src/providers/amazon-bedrock.ts +1 -0
  32. package/src/providers/anthropic-messages-server.ts +18 -0
  33. package/src/providers/azure-openai-responses.ts +3 -3
  34. package/src/providers/ollama.ts +1 -1
  35. package/src/providers/openai-chat-server-schema.ts +1 -1
  36. package/src/providers/openai-chat-server.ts +8 -1
  37. package/src/providers/openai-chat-wire.ts +1 -1
  38. package/src/providers/openai-codex/request-transformer.ts +94 -41
  39. package/src/providers/openai-codex-responses.ts +475 -39
  40. package/src/providers/openai-completions.ts +38 -12
  41. package/src/providers/openai-responses-server.ts +8 -1
  42. package/src/providers/openai-responses-wire.ts +16 -7
  43. package/src/providers/openai-responses.ts +18 -5
  44. package/src/providers/openai-shared.ts +125 -13
  45. package/src/registry/novita.ts +22 -0
  46. package/src/registry/oauth/__tests__/xai-oauth.test.ts +191 -80
  47. package/src/registry/oauth/device-code.ts +92 -0
  48. package/src/registry/oauth/index.ts +1 -91
  49. package/src/registry/oauth/xai-oauth.ts +231 -191
  50. package/src/registry/registry.ts +2 -0
  51. package/src/registry/xai-oauth.ts +0 -1
  52. package/src/stream.ts +7 -1
  53. package/src/types.ts +29 -3
@@ -1,5 +1,5 @@
1
1
  import { afterEach, describe, expect, it, vi } from "bun:test";
2
- import { isXAIAccessTokenExpiring, refreshXAIOAuthToken, validateXAIEndpoint, XAIOAuthFlow } from "../xai-oauth";
2
+ import { isXAIAccessTokenExpiring, loginXAIOAuth, refreshXAIOAuthToken, validateXAIEndpoint } from "../xai-oauth";
3
3
 
4
4
  afterEach(() => {
5
5
  vi.restoreAllMocks();
@@ -11,6 +11,76 @@ function jwtWithExp(exp: number): string {
11
11
  return `${header}.${payload}.sig`;
12
12
  }
13
13
 
14
+ const DISCOVERY_URL = "https://auth.x.ai/.well-known/openid-configuration";
15
+ const DEVICE_CODE_URL = "https://auth.x.ai/oauth2/device/code";
16
+ const TOKEN_ENDPOINT = "https://auth.x.ai/oauth2/token";
17
+ const CLIENT_ID = "b1a00492-073a-47ea-816f-4c329264a828";
18
+ const SCOPE = "openid profile email offline_access grok-cli:access api:access";
19
+
20
+ const DEVICE_AUTHORIZATION = {
21
+ device_code: "device-code-123",
22
+ user_code: "ABCD-EFGH",
23
+ verification_uri: "https://auth.x.ai/activate",
24
+ verification_uri_complete: "https://auth.x.ai/activate?user_code=ABCD-EFGH",
25
+ expires_in: 600,
26
+ interval: 1,
27
+ };
28
+
29
+ type RecordedRequest = {
30
+ url: string;
31
+ init: RequestInit | undefined;
32
+ };
33
+
34
+ type TokenResponse = {
35
+ body: unknown;
36
+ status?: number;
37
+ };
38
+
39
+ function jsonResponse(body: unknown, status: number = 200): Response {
40
+ return new Response(JSON.stringify(body), {
41
+ status,
42
+ headers: { "Content-Type": "application/json" },
43
+ });
44
+ }
45
+
46
+ function createDeviceFlowFetch(tokenResponses: readonly TokenResponse[]) {
47
+ const requests: RecordedRequest[] = [];
48
+ let tokenResponseIndex = 0;
49
+ const fetchMock = vi.fn(async (input: string | URL | Request, init?: RequestInit) => {
50
+ const url = typeof input === "string" ? input : input instanceof Request ? input.url : input.toString();
51
+ requests.push({ url, init });
52
+
53
+ if (url === DISCOVERY_URL) {
54
+ return jsonResponse({ token_endpoint: TOKEN_ENDPOINT });
55
+ }
56
+ if (url === DEVICE_CODE_URL) {
57
+ return jsonResponse(DEVICE_AUTHORIZATION);
58
+ }
59
+ if (url === TOKEN_ENDPOINT) {
60
+ const tokenResponse = tokenResponses[tokenResponseIndex];
61
+ tokenResponseIndex += 1;
62
+ if (!tokenResponse) {
63
+ throw new Error(`Unexpected xAI token poll ${tokenResponseIndex}`);
64
+ }
65
+ return jsonResponse(tokenResponse.body, tokenResponse.status);
66
+ }
67
+ throw new Error(`Unexpected xAI OAuth request: ${url}`);
68
+ });
69
+
70
+ return {
71
+ fetchMock: fetchMock as unknown as typeof fetch,
72
+ requests,
73
+ };
74
+ }
75
+
76
+ function requestForm(request: RecordedRequest | undefined): URLSearchParams {
77
+ const body = request?.init?.body;
78
+ if (!(body instanceof URLSearchParams)) {
79
+ throw new Error("Expected an application/x-www-form-urlencoded request body");
80
+ }
81
+ return body;
82
+ }
83
+
14
84
  describe("isXAIAccessTokenExpiring", () => {
15
85
  it("returns false for an empty string", () => {
16
86
  expect(isXAIAccessTokenExpiring("")).toBe(false);
@@ -63,97 +133,138 @@ describe("refreshXAIOAuthToken", () => {
63
133
  });
64
134
  });
65
135
 
66
- describe("XAIOAuthFlow", () => {
67
- it("pins the redirect URI to xAI's allowlisted loopback port", () => {
68
- const flow = new XAIOAuthFlow({});
69
-
70
- expect(flow.redirectUri).toBe("http://127.0.0.1:56121/callback");
71
- });
72
-
73
- it("uses pasted-code login without starting a callback server", async () => {
74
- const serveSpy = vi.spyOn(Bun, "serve").mockImplementation(() => {
75
- throw new Error("callback server should not start");
76
- });
77
- let authUrl = "";
78
- let tokenRequestBody = "";
79
- const progress: string[] = [];
80
- const fetchMock = vi.fn(async (input: string | URL | Request, init?: RequestInit) => {
81
- const url = typeof input === "string" ? input : input instanceof Request ? input.url : input.toString();
82
- if (url.includes("/.well-known/openid-configuration")) {
83
- return new Response(
84
- JSON.stringify({
85
- authorization_endpoint: "https://auth.x.ai/oauth/authorize",
86
- token_endpoint: "https://auth.x.ai/oauth/token",
87
- }),
88
- { status: 200, headers: { "Content-Type": "application/json" } },
89
- );
90
- }
91
- tokenRequestBody = init?.body instanceof URLSearchParams ? init.body.toString() : String(init?.body ?? "");
92
- return new Response(
93
- JSON.stringify({
136
+ describe("loginXAIOAuth", () => {
137
+ it("performs the RFC 8628 device flow and returns the issued credentials", async () => {
138
+ const now = 1_800_000_000_000;
139
+ vi.spyOn(Date, "now").mockReturnValue(now);
140
+ const { fetchMock, requests } = createDeviceFlowFetch([
141
+ {
142
+ body: {
94
143
  access_token: "access-token",
95
144
  refresh_token: "refresh-token",
96
145
  expires_in: 3600,
97
- }),
98
- { status: 200, headers: { "Content-Type": "application/json" } },
99
- );
146
+ },
147
+ },
148
+ ]);
149
+ const authEvents: Array<{ url: string; instructions?: string }> = [];
150
+ const progress: string[] = [];
151
+ const onAuth = vi.fn((info: { url: string; instructions?: string }) => {
152
+ authEvents.push(info);
153
+ });
154
+ const onProgress = vi.fn((message: string) => {
155
+ progress.push(message);
156
+ });
157
+ const onManualCodeInput = vi.fn(async () => {
158
+ throw new Error("device authorization must not request a pasted code");
100
159
  });
101
160
 
102
- const flow = new XAIOAuthFlow({
103
- fetch: fetchMock as unknown as typeof fetch,
104
- onAuth: info => {
105
- authUrl = info.url;
106
- },
107
- onManualCodeInput: async () => {
108
- const parsed = new URL(authUrl);
109
- const redirectUri = parsed.searchParams.get("redirect_uri") ?? "";
110
- const state = parsed.searchParams.get("state") ?? "";
111
- return `${redirectUri}?code=code-xyz&state=${encodeURIComponent(state)}`;
112
- },
113
- onProgress: message => progress.push(message),
161
+ const credentials = await loginXAIOAuth({
162
+ fetch: fetchMock,
163
+ onAuth,
164
+ onProgress,
165
+ onManualCodeInput,
114
166
  });
115
167
 
116
- const credentials = await flow.login();
117
- const authorizeUrl = new URL(authUrl);
118
- const tokenParams = new URLSearchParams(tokenRequestBody);
168
+ expect(requests.map(request => request.url)).toEqual([DISCOVERY_URL, DEVICE_CODE_URL, TOKEN_ENDPOINT]);
119
169
 
120
- expect(serveSpy).not.toHaveBeenCalled();
121
- expect(authorizeUrl.searchParams.get("redirect_uri")).toBe("http://127.0.0.1:56121/callback");
122
- expect(progress).toContain("Waiting for pasted authorization code...");
123
- expect(tokenParams.get("code")).toBe("code-xyz");
124
- expect(credentials.access).toBe("access-token");
125
- expect(credentials.refresh).toBe("refresh-token");
126
- });
127
- });
170
+ const discoveryRequest = requests[0];
171
+ expect(discoveryRequest?.init?.method).toBe("GET");
172
+ expect(new Headers(discoveryRequest?.init?.headers).get("Accept")).toBe("application/json");
128
173
 
129
- describe("XAIOAuthFlow.exchangeToken", () => {
130
- it("rejects when the token-exchange response is missing access_token", async () => {
131
- const fetchMock = vi.fn(async (input: string | URL) => {
132
- const url = typeof input === "string" ? input : input.toString();
133
- if (url.includes("/.well-known/openid-configuration")) {
134
- return new Response(
135
- JSON.stringify({
136
- authorization_endpoint: "https://auth.x.ai/oauth/authorize",
137
- token_endpoint: "https://auth.x.ai/oauth/token",
138
- }),
139
- { status: 200, headers: { "Content-Type": "application/json" } },
140
- );
141
- }
142
- // Token-exchange response deliberately omits `access_token` to exercise
143
- // the missing-token rejection path. The value of `refresh_token` here is
144
- // a literal test marker, not a real secret — the test verifies
145
- // exchangeToken throws before any token would be persisted.
146
- return new Response(JSON.stringify({ refresh_token: "stub-refresh-token-for-test-only" }), {
147
- status: 200,
148
- headers: { "Content-Type": "application/json" },
149
- });
174
+ const deviceRequest = requests[1];
175
+ expect(deviceRequest?.init?.method).toBe("POST");
176
+ const deviceHeaders = new Headers(deviceRequest?.init?.headers);
177
+ expect(deviceHeaders.get("Content-Type")).toBe("application/x-www-form-urlencoded");
178
+ expect(deviceHeaders.get("Accept")).toBe("application/json");
179
+ const deviceForm = requestForm(deviceRequest);
180
+ expect([...deviceForm.keys()].sort()).toEqual(["client_id", "scope"]);
181
+ expect(Object.fromEntries(deviceForm)).toEqual({
182
+ client_id: CLIENT_ID,
183
+ scope: SCOPE,
150
184
  });
151
185
 
152
- const flow = new XAIOAuthFlow({ fetch: fetchMock as unknown as typeof fetch });
153
- await flow.generateAuthUrl("state-abc", "http://127.0.0.1:56121/callback");
186
+ const tokenRequest = requests[2];
187
+ expect(tokenRequest?.init?.method).toBe("POST");
188
+ const tokenHeaders = new Headers(tokenRequest?.init?.headers);
189
+ expect(tokenHeaders.get("Content-Type")).toBe("application/x-www-form-urlencoded");
190
+ expect(tokenHeaders.get("Accept")).toBe("application/json");
191
+ const tokenForm = requestForm(tokenRequest);
192
+ expect([...tokenForm.keys()].sort()).toEqual(["client_id", "device_code", "grant_type"]);
193
+ expect(Object.fromEntries(tokenForm)).toEqual({
194
+ grant_type: "urn:ietf:params:oauth:grant-type:device_code",
195
+ client_id: CLIENT_ID,
196
+ device_code: DEVICE_AUTHORIZATION.device_code,
197
+ });
198
+
199
+ expect(authEvents).toEqual([
200
+ {
201
+ url: DEVICE_AUTHORIZATION.verification_uri_complete,
202
+ instructions: `Enter code: ${DEVICE_AUTHORIZATION.user_code}`,
203
+ },
204
+ ]);
205
+ expect(authEvents[0]?.instructions).not.toMatch(/hermes/i);
206
+ expect(onManualCodeInput).not.toHaveBeenCalled();
207
+ expect(progress).toEqual(["Waiting for xAI device authorization..."]);
208
+ expect(credentials).toEqual({
209
+ access: "access-token",
210
+ refresh: "refresh-token",
211
+ expires: now + 3_300_000,
212
+ });
213
+ });
214
+
215
+ it("continues through authorization_pending and slow_down responses", async () => {
216
+ const sleepSpy = vi.spyOn(Bun, "sleep").mockResolvedValue(undefined);
217
+ const { fetchMock, requests } = createDeviceFlowFetch([
218
+ { status: 400, body: { error: "authorization_pending" } },
219
+ { status: 400, body: { error: "slow_down" } },
220
+ {
221
+ body: {
222
+ access_token: "eventual-access-token",
223
+ refresh_token: "eventual-refresh-token",
224
+ expires_in: 3600,
225
+ },
226
+ },
227
+ ]);
228
+
229
+ const credentials = await loginXAIOAuth({ fetch: fetchMock });
230
+
231
+ const tokenRequests = requests.filter(request => request.url === TOKEN_ENDPOINT);
232
+ expect(tokenRequests).toHaveLength(3);
233
+ expect(tokenRequests.map(request => Object.fromEntries(requestForm(request)))).toEqual([
234
+ {
235
+ grant_type: "urn:ietf:params:oauth:grant-type:device_code",
236
+ client_id: CLIENT_ID,
237
+ device_code: DEVICE_AUTHORIZATION.device_code,
238
+ },
239
+ {
240
+ grant_type: "urn:ietf:params:oauth:grant-type:device_code",
241
+ client_id: CLIENT_ID,
242
+ device_code: DEVICE_AUTHORIZATION.device_code,
243
+ },
244
+ {
245
+ grant_type: "urn:ietf:params:oauth:grant-type:device_code",
246
+ client_id: CLIENT_ID,
247
+ device_code: DEVICE_AUTHORIZATION.device_code,
248
+ },
249
+ ]);
250
+ expect(sleepSpy.mock.calls).toEqual([[1000], [6000]]);
251
+ expect(credentials.access).toBe("eventual-access-token");
252
+ expect(credentials.refresh).toBe("eventual-refresh-token");
253
+ });
254
+
255
+ it("rejects a token response that omits access_token", async () => {
256
+ const { fetchMock, requests } = createDeviceFlowFetch([
257
+ {
258
+ body: {
259
+ refresh_token: "refresh-token",
260
+ expires_in: 3600,
261
+ },
262
+ },
263
+ ]);
154
264
 
155
- await expect(flow.exchangeToken("code-xyz", "state-abc", "http://127.0.0.1:56121/callback")).rejects.toThrow(
156
- /access_token/,
265
+ await expect(loginXAIOAuth({ fetch: fetchMock })).rejects.toThrow(
266
+ /xAI device-code token response missing access_token/,
157
267
  );
268
+ expect(requests.filter(request => request.url === TOKEN_ENDPOINT)).toHaveLength(1);
158
269
  });
159
270
  });
@@ -0,0 +1,92 @@
1
+ import * as AIError from "../../error";
2
+
3
+ const DEVICE_FLOW_CANCEL_MESSAGE = "Login cancelled";
4
+ const DEVICE_FLOW_TIMEOUT_MESSAGE = "Device flow timed out";
5
+ const DEVICE_FLOW_SLOW_DOWN_TIMEOUT_MESSAGE =
6
+ "Device flow timed out after one or more slow_down responses. This is often caused by clock drift in WSL or VM environments. Please sync or restart the VM clock and try again.";
7
+ const MINIMUM_DEVICE_FLOW_INTERVAL_MS = 1000;
8
+ const DEFAULT_DEVICE_FLOW_INTERVAL_SECONDS = 5;
9
+ const SLOW_DOWN_INTERVAL_INCREMENT_MS = 5000;
10
+
11
+ /** Result returned by one OAuth device-code polling attempt. */
12
+ export type OAuthDeviceCodePollResult<T> =
13
+ | { status: "complete"; value: T }
14
+ | { status: "pending" }
15
+ | { status: "slow_down" }
16
+ | { status: "failed"; message: string };
17
+
18
+ /** Options for polling an RFC 8628-style OAuth device-code flow. */
19
+ export interface OAuthDeviceCodeFlowOptions<T> {
20
+ /** Poll the provider once and classify the response. */
21
+ poll(): OAuthDeviceCodePollResult<T> | Promise<OAuthDeviceCodePollResult<T>>;
22
+ /** Provider-requested polling cadence; defaults to RFC 8628's five seconds. */
23
+ intervalSeconds?: number;
24
+ /** Provider-issued expiry window for the device code. */
25
+ expiresInSeconds?: number;
26
+ /** Cancels the flow with the legacy "Login cancelled" error. */
27
+ signal?: AbortSignal;
28
+ }
29
+
30
+ async function abortableDeviceFlowSleep(ms: number, signal: AbortSignal | undefined): Promise<void> {
31
+ if (!signal) {
32
+ await Bun.sleep(ms);
33
+ return;
34
+ }
35
+ if (signal.aborted) {
36
+ throw new AIError.LoginCancelledError(DEVICE_FLOW_CANCEL_MESSAGE);
37
+ }
38
+
39
+ const { promise, resolve, reject } = Promise.withResolvers<void>();
40
+ let timer: Timer | undefined;
41
+ const onAbort = () => {
42
+ clearTimeout(timer);
43
+ reject(new AIError.LoginCancelledError(DEVICE_FLOW_CANCEL_MESSAGE));
44
+ };
45
+ timer = setTimeout(() => {
46
+ signal.removeEventListener("abort", onAbort);
47
+ resolve();
48
+ }, ms);
49
+ signal.addEventListener("abort", onAbort, { once: true });
50
+ await promise;
51
+ }
52
+
53
+ /** Poll an OAuth device-code flow until completion, provider failure, timeout, or cancellation. */
54
+ export async function pollOAuthDeviceCodeFlow<T>(options: OAuthDeviceCodeFlowOptions<T>): Promise<T> {
55
+ const deadline =
56
+ typeof options.expiresInSeconds === "number"
57
+ ? Date.now() + options.expiresInSeconds * 1000
58
+ : Number.POSITIVE_INFINITY;
59
+ let intervalMs = Math.max(
60
+ MINIMUM_DEVICE_FLOW_INTERVAL_MS,
61
+ Math.floor((options.intervalSeconds ?? DEFAULT_DEVICE_FLOW_INTERVAL_SECONDS) * 1000),
62
+ );
63
+ let slowDownResponses = 0;
64
+
65
+ while (Date.now() < deadline) {
66
+ if (options.signal?.aborted) {
67
+ throw new AIError.LoginCancelledError(DEVICE_FLOW_CANCEL_MESSAGE);
68
+ }
69
+ const result = await options.poll();
70
+ if (result.status === "complete") {
71
+ return result.value;
72
+ }
73
+ if (result.status === "failed") {
74
+ throw new AIError.OAuthError(result.message, { kind: "polling" });
75
+ }
76
+ if (result.status === "slow_down") {
77
+ slowDownResponses += 1;
78
+ intervalMs = Math.max(MINIMUM_DEVICE_FLOW_INTERVAL_MS, intervalMs + SLOW_DOWN_INTERVAL_INCREMENT_MS);
79
+ }
80
+
81
+ const remainingMs = deadline - Date.now();
82
+ if (remainingMs <= 0) {
83
+ break;
84
+ }
85
+ await abortableDeviceFlowSleep(Math.min(intervalMs, remainingMs), options.signal);
86
+ }
87
+
88
+ throw new AIError.OAuthError(
89
+ slowDownResponses > 0 ? DEVICE_FLOW_SLOW_DOWN_TIMEOUT_MESSAGE : DEVICE_FLOW_TIMEOUT_MESSAGE,
90
+ { kind: "timeout" },
91
+ );
92
+ }
@@ -12,99 +12,9 @@ import type {
12
12
  OAuthProviderInterface,
13
13
  } from "./types";
14
14
 
15
+ export * from "./device-code";
15
16
  export type * from "./types";
16
17
 
17
- const DEVICE_FLOW_CANCEL_MESSAGE = "Login cancelled";
18
- const DEVICE_FLOW_TIMEOUT_MESSAGE = "Device flow timed out";
19
- const DEVICE_FLOW_SLOW_DOWN_TIMEOUT_MESSAGE =
20
- "Device flow timed out after one or more slow_down responses. This is often caused by clock drift in WSL or VM environments. Please sync or restart the VM clock and try again.";
21
- const MINIMUM_DEVICE_FLOW_INTERVAL_MS = 1000;
22
- const DEFAULT_DEVICE_FLOW_INTERVAL_SECONDS = 5;
23
- const SLOW_DOWN_INTERVAL_INCREMENT_MS = 5000;
24
-
25
- /** Result returned by one OAuth device-code polling attempt. */
26
- export type OAuthDeviceCodePollResult<T> =
27
- | { status: "complete"; value: T }
28
- | { status: "pending" }
29
- | { status: "slow_down" }
30
- | { status: "failed"; message: string };
31
-
32
- /** Options for polling an RFC 8628-style OAuth device-code flow. */
33
- export interface OAuthDeviceCodeFlowOptions<T> {
34
- /** Poll the provider once and classify the response. */
35
- poll(): OAuthDeviceCodePollResult<T> | Promise<OAuthDeviceCodePollResult<T>>;
36
- /** Provider-requested polling cadence; defaults to RFC 8628's five seconds. */
37
- intervalSeconds?: number;
38
- /** Provider-issued expiry window for the device code. */
39
- expiresInSeconds?: number;
40
- /** Cancels the flow with the legacy "Login cancelled" error. */
41
- signal?: AbortSignal;
42
- }
43
-
44
- async function abortableDeviceFlowSleep(ms: number, signal: AbortSignal | undefined): Promise<void> {
45
- if (!signal) {
46
- await Bun.sleep(ms);
47
- return;
48
- }
49
- if (signal.aborted) {
50
- throw new AIError.LoginCancelledError(DEVICE_FLOW_CANCEL_MESSAGE);
51
- }
52
-
53
- const { promise, resolve, reject } = Promise.withResolvers<void>();
54
- let timer: Timer | undefined;
55
- const onAbort = () => {
56
- if (timer) clearTimeout(timer);
57
- reject(new AIError.LoginCancelledError(DEVICE_FLOW_CANCEL_MESSAGE));
58
- };
59
- timer = setTimeout(() => {
60
- signal.removeEventListener("abort", onAbort);
61
- resolve();
62
- }, ms);
63
- signal.addEventListener("abort", onAbort, { once: true });
64
- await promise;
65
- }
66
-
67
- /** Poll an OAuth device-code flow until completion, provider failure, timeout, or cancellation. */
68
- export async function pollOAuthDeviceCodeFlow<T>(options: OAuthDeviceCodeFlowOptions<T>): Promise<T> {
69
- const deadline =
70
- typeof options.expiresInSeconds === "number"
71
- ? Date.now() + options.expiresInSeconds * 1000
72
- : Number.POSITIVE_INFINITY;
73
- let intervalMs = Math.max(
74
- MINIMUM_DEVICE_FLOW_INTERVAL_MS,
75
- Math.floor((options.intervalSeconds ?? DEFAULT_DEVICE_FLOW_INTERVAL_SECONDS) * 1000),
76
- );
77
- let slowDownResponses = 0;
78
-
79
- while (Date.now() < deadline) {
80
- if (options.signal?.aborted) {
81
- throw new AIError.LoginCancelledError(DEVICE_FLOW_CANCEL_MESSAGE);
82
- }
83
- const result = await options.poll();
84
- if (result.status === "complete") {
85
- return result.value;
86
- }
87
- if (result.status === "failed") {
88
- throw new AIError.OAuthError(result.message, { kind: "polling" });
89
- }
90
- if (result.status === "slow_down") {
91
- slowDownResponses += 1;
92
- intervalMs = Math.max(MINIMUM_DEVICE_FLOW_INTERVAL_MS, intervalMs + SLOW_DOWN_INTERVAL_INCREMENT_MS);
93
- }
94
-
95
- const remainingMs = deadline - Date.now();
96
- if (remainingMs <= 0) {
97
- break;
98
- }
99
- await abortableDeviceFlowSleep(Math.min(intervalMs, remainingMs), options.signal);
100
- }
101
-
102
- throw new AIError.OAuthError(
103
- slowDownResponses > 0 ? DEVICE_FLOW_SLOW_DOWN_TIMEOUT_MESSAGE : DEVICE_FLOW_TIMEOUT_MESSAGE,
104
- { kind: "timeout" },
105
- );
106
- }
107
-
108
18
  const builtInOAuthProviders: OAuthProviderInfo[] = PROVIDER_REGISTRY.filter(
109
19
  provider => provider.login && provider.showInLoginList !== false,
110
20
  ).map(provider => ({