@code-yeongyu/senpi-ai 2026.11.3-3 → 2026.11.4-2

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.
@@ -239,6 +239,17 @@ function parseLegacyEncryptedReasoningDetail(signature) {
239
239
  return undefined;
240
240
  }
241
241
  }
242
+ /**
243
+ * Replays a thought signature that an OpenAI-compatible proxy returned in a tool call's
244
+ * `provider_specific_fields` (LiteLLM serving Gemini) on the same field, so the replayed call
245
+ * keeps it even though its id is normalized. A legacy encrypted reasoning detail sharing the
246
+ * `thoughtSignature` slot travels through `reasoning_details` instead.
247
+ */
248
+ function withProviderThoughtSignature(call, thoughtSignature) {
249
+ if (!thoughtSignature || parseLegacyEncryptedReasoningDetail(thoughtSignature) !== undefined)
250
+ return call;
251
+ return { ...call, provider_specific_fields: { thought_signature: thoughtSignature } };
252
+ }
242
253
  function fillMissingCommonReasoningDetailFields(target, source) {
243
254
  target.id ??= source.id;
244
255
  target.format ||= source.format;
@@ -713,6 +724,10 @@ export const stream = (model, context, options) => {
713
724
  if (!block.name && name) {
714
725
  block.name = name;
715
726
  }
727
+ const thoughtSignature = toolCall.provider_specific_fields?.thought_signature;
728
+ if (typeof thoughtSignature === "string" && thoughtSignature.length > 0) {
729
+ block.thoughtSignature = thoughtSignature;
730
+ }
716
731
  let delta = "";
717
732
  if (toolCall.function?.arguments) {
718
733
  delta = toolCall.function.arguments;
@@ -1377,23 +1392,23 @@ export function convertMessages(model, context, compat, options = {}) {
1377
1392
  assistantMsg.tool_calls = toolCalls.map((tc) => {
1378
1393
  const customInputProperty = options?.grammarToolInputProperties?.get(tc.name);
1379
1394
  if (customInputProperty !== undefined) {
1380
- return {
1395
+ return withProviderThoughtSignature({
1381
1396
  id: tc.id,
1382
1397
  type: "custom",
1383
1398
  custom: {
1384
1399
  name: tc.name,
1385
1400
  input: sanitizeSurrogates(getGrammarToolInput(tc.name, tc.arguments, customInputProperty)),
1386
1401
  },
1387
- };
1402
+ }, tc.thoughtSignature);
1388
1403
  }
1389
- return {
1404
+ return withProviderThoughtSignature({
1390
1405
  id: tc.id,
1391
1406
  type: "function",
1392
1407
  function: {
1393
1408
  name: tc.name,
1394
1409
  arguments: JSON.stringify(tc.arguments),
1395
1410
  },
1396
- };
1411
+ }, tc.thoughtSignature);
1397
1412
  });
1398
1413
  }
1399
1414
  if (preservedReasoningDetails) {
@@ -5,17 +5,37 @@
5
5
  * access token directly to api.openai.com.
6
6
  */
7
7
  import type { LoginOptions, LogoutOptions, OAuthCredential, OAuthRevocationResult, ProviderAuthInteraction } from "../types.ts";
8
- declare function refreshAccessToken(credential: OAuthCredential, signal: AbortSignal): Promise<OAuthCredential>;
8
+ import { type OpenAIIdTokenDependencies } from "./openai-id-token.ts";
9
9
  declare function revokeSession(credential: OAuthCredential, options?: LogoutOptions): Promise<OAuthRevocationResult>;
10
- declare function loginOpenAIChatGPT(interaction: ProviderAuthInteraction, options?: LoginOptions & {
11
- readonly preferredCallbackPort?: number;
12
- }): Promise<OAuthCredential>;
10
+ export declare function createOpenAIChatGPTOAuth(dependencies?: Partial<OpenAIIdTokenDependencies>): {
11
+ name: string;
12
+ isSubscription: true;
13
+ loginLabel: string;
14
+ login: (interaction: ProviderAuthInteraction, options?: LoginOptions & {
15
+ readonly preferredCallbackPort?: number;
16
+ }) => Promise<OAuthCredential>;
17
+ refresh: (credential: OAuthCredential, signal: AbortSignal) => Promise<OAuthCredential>;
18
+ revoke: typeof revokeSession;
19
+ check({ credential }: {
20
+ ctx: import("../types.ts").AuthContext;
21
+ credential?: OAuthCredential;
22
+ signal: AbortSignal;
23
+ }): Promise<{
24
+ type: "oauth";
25
+ source: string;
26
+ } | undefined>;
27
+ toAuth(credential: OAuthCredential): Promise<{
28
+ apiKey: string;
29
+ }>;
30
+ };
13
31
  export declare const openaiChatGPTOAuth: {
14
32
  name: string;
15
33
  isSubscription: true;
16
34
  loginLabel: string;
17
- login: typeof loginOpenAIChatGPT;
18
- refresh: typeof refreshAccessToken;
35
+ login: (interaction: ProviderAuthInteraction, options?: LoginOptions & {
36
+ readonly preferredCallbackPort?: number;
37
+ }) => Promise<OAuthCredential>;
38
+ refresh: (credential: OAuthCredential, signal: AbortSignal) => Promise<OAuthCredential>;
19
39
  revoke: typeof revokeSession;
20
40
  check({ credential }: {
21
41
  ctx: import("../types.ts").AuthContext;
@@ -7,10 +7,11 @@
7
7
  import { randomBytes } from "node:crypto";
8
8
  import { createServer } from "node:http";
9
9
  import { configuredOAuthBrandName, oauthErrorHtml, oauthSuccessHtml } from "../../utils/oauth-page.js";
10
- import { OAuthTokenEndpointError } from "../../utils/oauth-refresh-error.js";
10
+ import { classifyOAuthRefreshFailure, OAuthClientConfigurationError, OAuthReauthorizationRequiredError, OAuthTokenEndpointError, } from "../../utils/oauth-refresh-error.js";
11
+ import { createOpenAIIdTokenVerifier } from "./openai-id-token.js";
11
12
  import { getWireIdentity } from "../../wire-identity.js";
12
13
  import { generatePKCE } from "./pkce.js";
13
- // every login registers a new client with this ID; OpenAI returns the issued client ID in the callback
14
+ // Only first-time registration uses this entrypoint; returning accounts keep their issued ID.
14
15
  const DYNAMIC_CLIENT_ID = "dynamic_agent_client";
15
16
  const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
16
17
  const AUTHORIZE_URL = "https://auth.openai.com/api/accounts/authorize";
@@ -25,24 +26,65 @@ const DIRECT_TOKEN_SCOPE = "chatgpt.tokens.use.direct";
25
26
  const SCOPE = `openid profile email offline_access resource.invoke ${DIRECT_TOKEN_SCOPE}`;
26
27
  // Refresh this long before the real expiry so a request never starts with a token about to expire.
27
28
  const EXPIRY_MARGIN_MS = 3 * 60 * 1000;
29
+ class OpenAIReauthorizationError extends Error {
30
+ constructor(code) {
31
+ super(code === "invalid_account"
32
+ ? "The selected ChatGPT account needs an issued client ID and verified subject"
33
+ : code === "client_mismatch"
34
+ ? "ChatGPT reauthorization returned a different client ID"
35
+ : "ChatGPT reauthorization returned a different account");
36
+ this.name = "OpenAIReauthorizationError";
37
+ this.code = code;
38
+ }
39
+ }
40
+ class OpenAIConsentDeclinedError extends Error {
41
+ constructor() {
42
+ super("ChatGPT consent declined");
43
+ this.code = "consent_declined";
44
+ this.name = "OpenAIConsentDeclinedError";
45
+ }
46
+ }
47
+ class OpenAIPlanUsageNotEnabledError extends Error {
48
+ constructor() {
49
+ super("ChatGPT plan usage is not enabled. Enable ChatGPT plan usage or use an API key.");
50
+ this.code = "plan_usage_not_enabled";
51
+ this.recovery = ["enable_plan_usage", "use_api_key"];
52
+ this.name = "OpenAIPlanUsageNotEnabledError";
53
+ }
54
+ }
28
55
  function randomValue() {
29
56
  return randomBytes(32).toString("base64url");
30
57
  }
31
- function authorizationResultFromCallback(url, expectedState) {
32
- const code = url.searchParams.get("code");
33
- if (!code)
34
- throw new Error("Missing authorization code");
58
+ function authorizationResultFromCallback(url, expectedState, knownClient) {
35
59
  const state = url.searchParams.get("state");
36
60
  if (!state)
37
61
  throw new Error("Missing OAuth state");
38
62
  if (state !== expectedState)
39
63
  throw new Error("OAuth state mismatch");
40
- const clientId = url.searchParams.get("client_id")?.trim();
64
+ const error = url.searchParams.get("error");
65
+ if (error === "access_denied")
66
+ throw new OpenAIConsentDeclinedError();
67
+ if (error)
68
+ throw new Error(`ChatGPT authorization failed: ${error}`);
69
+ const code = url.searchParams.get("code");
70
+ if (!code)
71
+ throw new Error("Missing authorization code");
72
+ const suppliedClientId = url.searchParams.get("client_id");
73
+ if (knownClient?.purpose === "reauthorize" &&
74
+ suppliedClientId !== null &&
75
+ suppliedClientId !== knownClient.clientId) {
76
+ throw new OpenAIReauthorizationError("client_mismatch");
77
+ }
78
+ const callbackClientId = suppliedClientId?.trim();
79
+ if (knownClient?.purpose === "consent" && callbackClientId && callbackClientId !== knownClient.clientId) {
80
+ throw new Error("ChatGPT consent callback client ID mismatch");
81
+ }
82
+ const clientId = knownClient?.clientId ?? callbackClientId;
41
83
  if (!clientId)
42
84
  throw new Error("OpenAI OAuth registration callback did not contain an issued client ID");
43
85
  return { code, clientId };
44
86
  }
45
- function authorizationResultFromManualInput(input, expectedState, redirectUri) {
87
+ function authorizationResultFromManualInput(input, expectedState, redirectUri, knownClient) {
46
88
  let url;
47
89
  try {
48
90
  url = new URL(input.trim());
@@ -54,16 +96,13 @@ function authorizationResultFromManualInput(input, expectedState, redirectUri) {
54
96
  if (url.origin !== expected.origin || url.pathname !== expected.pathname) {
55
97
  throw new Error(`The pasted callback URL must start with ${redirectUri}`);
56
98
  }
57
- const error = url.searchParams.get("error");
58
- if (error)
59
- throw new Error(`ChatGPT authorization failed: ${error}`);
60
- return authorizationResultFromCallback(url, expectedState);
99
+ return authorizationResultFromCallback(url, expectedState, knownClient);
61
100
  }
62
101
  function sendHtml(response, status, body) {
63
102
  response.writeHead(status, { "Content-Type": "text/html; charset=utf-8" });
64
103
  response.end(body);
65
104
  }
66
- function startCallbackServer(expectedState, preferredPort) {
105
+ function startCallbackServer(expectedState, preferredPort, knownClient) {
67
106
  return new Promise((resolve, reject) => {
68
107
  let redirectUri = REDIRECT_URI;
69
108
  let resolveResult;
@@ -79,19 +118,18 @@ function startCallbackServer(expectedState, preferredPort) {
79
118
  sendHtml(response, 404, oauthErrorHtml("Callback route not found."));
80
119
  return;
81
120
  }
82
- const error = url.searchParams.get("error");
83
- if (error) {
84
- sendHtml(response, 400, oauthErrorHtml("ChatGPT was not connected.", `Error: ${error}`));
85
- rejectResult(new Error(`ChatGPT authorization failed: ${error}`));
86
- return;
87
- }
88
121
  let authorizationResult;
89
122
  try {
90
- authorizationResult = authorizationResultFromCallback(url, expectedState);
123
+ authorizationResult = authorizationResultFromCallback(url, expectedState, knownClient);
91
124
  }
92
125
  catch (error) {
93
126
  const message = error instanceof Error ? error.message : "Invalid callback";
94
127
  sendHtml(response, 400, oauthErrorHtml(message));
128
+ // Unrelated callbacks must not stop the pending login attempt.
129
+ if (error instanceof OpenAIReauthorizationError ||
130
+ (url.searchParams.get("state") === expectedState && url.searchParams.has("error"))) {
131
+ rejectResult(error instanceof Error ? error : new Error(message));
132
+ }
95
133
  return;
96
134
  }
97
135
  sendHtml(response, 200, oauthSuccessHtml("ChatGPT authentication completed. You can close this window."));
@@ -127,8 +165,21 @@ function startCallbackServer(expectedState, preferredPort) {
127
165
  server.listen(preferredPort, CALLBACK_HOST, onListening);
128
166
  });
129
167
  }
130
- async function requestToken(body, signal) {
131
- const response = await fetch(TOKEN_URL, {
168
+ /** Read machine codes only; error_description is diagnostic text, not a recovery signal. */
169
+ function tokenErrorCode(value) {
170
+ if (typeof value !== "object" || value === null || Array.isArray(value))
171
+ return undefined;
172
+ const error = Reflect.get(value, "error");
173
+ if (typeof error === "string")
174
+ return error;
175
+ const code = Reflect.get(value, "code");
176
+ if (typeof code === "string")
177
+ return code;
178
+ const type = Reflect.get(value, "type");
179
+ return tokenErrorCode(error) ?? (typeof type === "string" ? type : undefined);
180
+ }
181
+ async function requestToken(body, signal, fetchToken) {
182
+ const response = await fetchToken(TOKEN_URL, {
132
183
  method: "POST",
133
184
  headers: {
134
185
  accept: "application/json",
@@ -139,7 +190,29 @@ async function requestToken(body, signal) {
139
190
  });
140
191
  if (!response.ok) {
141
192
  const responseBody = await response.text().catch(() => "");
142
- throw new OAuthTokenEndpointError(`OpenAI OAuth token request failed (${response.status}): ${responseBody || response.statusText}`, response.status);
193
+ const error = new OAuthTokenEndpointError(`OpenAI OAuth token request failed (${response.status}): ${responseBody || response.statusText}`, response.status);
194
+ if (body.get("grant_type") === "refresh_token" && classifyOAuthRefreshFailure(error) === "permanent") {
195
+ let code;
196
+ try {
197
+ const parsed = JSON.parse(responseBody);
198
+ code = tokenErrorCode(parsed);
199
+ }
200
+ catch {
201
+ // Non-JSON errors retain the HTTP status and diagnostic below.
202
+ }
203
+ switch (code) {
204
+ case "invalid_grant":
205
+ case "invalid_refresh_token":
206
+ case "token_expired":
207
+ case "refresh_token_expired":
208
+ case "refresh_token_invalidated":
209
+ case "refresh_token_reused":
210
+ throw new OAuthReauthorizationRequiredError(code, response.status);
211
+ case "invalid_client":
212
+ throw new OAuthClientConfigurationError(response.status);
213
+ }
214
+ }
215
+ throw error;
143
216
  }
144
217
  const data = await response.json();
145
218
  if (typeof data !== "object" || data === null || Array.isArray(data)) {
@@ -153,7 +226,7 @@ function requireTokenString(value, field) {
153
226
  }
154
227
  return value;
155
228
  }
156
- function credentialFromTokenResponse(token, clientId) {
229
+ function credentialFromTokenResponse(token, clientId, now) {
157
230
  const access = requireTokenString(token.access_token, "access_token");
158
231
  const refresh = requireTokenString(token.refresh_token, "refresh_token");
159
232
  const scope = requireTokenString(token.scope, "scope");
@@ -161,19 +234,17 @@ function credentialFromTokenResponse(token, clientId) {
161
234
  throw new Error("OpenAI OAuth token response has invalid expires_in");
162
235
  }
163
236
  const scopes = scope.trim().split(/\s+/).filter(Boolean);
164
- if (!scopes.includes(DIRECT_TOKEN_SCOPE)) {
165
- throw new Error(`OpenAI OAuth grant did not include ${DIRECT_TOKEN_SCOPE}`);
166
- }
167
237
  return {
168
238
  type: "oauth",
169
239
  access,
170
240
  refresh,
171
- expires: Date.now() + token.expires_in * 1000 - EXPIRY_MARGIN_MS,
241
+ expires: now() + token.expires_in * 1000 - EXPIRY_MARGIN_MS,
172
242
  clientId,
173
243
  scopes,
244
+ planUsageEnabled: scopes.includes(DIRECT_TOKEN_SCOPE),
174
245
  };
175
246
  }
176
- async function exchangeAuthorizationCode(code, verifier, clientId, redirectUri, signal) {
247
+ async function exchangeAuthorizationCode(code, verifier, clientId, redirectUri, signal, nonce, deps, verifyIdToken) {
177
248
  const token = await requestToken(new URLSearchParams({
178
249
  grant_type: "authorization_code",
179
250
  client_id: clientId,
@@ -181,15 +252,11 @@ async function exchangeAuthorizationCode(code, verifier, clientId, redirectUri,
181
252
  code_verifier: verifier,
182
253
  redirect_uri: redirectUri,
183
254
  resource: RESOURCE,
184
- }), signal);
185
- // Pi does not use the ID token to identify the user or read profile data.
186
- // Keep the presence check as part of the token-response contract.
187
- if (typeof token.id_token !== "string" || token.id_token.trim().length === 0) {
188
- throw new Error("OpenAI OAuth token response did not contain an ID token");
189
- }
190
- return credentialFromTokenResponse(token, clientId);
255
+ }), signal, deps.fetch);
256
+ const identity = await verifyIdToken(token.id_token, clientId, nonce, signal);
257
+ return { ...credentialFromTokenResponse(token, clientId, deps.now), ...identity, idToken: token.id_token };
191
258
  }
192
- async function refreshAccessToken(credential, signal) {
259
+ async function refreshAccessToken(credential, signal, deps) {
193
260
  const clientId = credential.clientId;
194
261
  if (typeof clientId !== "string" || clientId.trim().length === 0) {
195
262
  throw new Error("Stored OpenAI OAuth credential does not contain an issued client ID; reconnect ChatGPT");
@@ -199,8 +266,8 @@ async function refreshAccessToken(credential, signal) {
199
266
  client_id: clientId,
200
267
  refresh_token: credential.refresh,
201
268
  resource: RESOURCE,
202
- }), signal);
203
- return credentialFromTokenResponse(token, clientId);
269
+ }), signal, deps.fetch);
270
+ return { ...credential, ...credentialFromTokenResponse(token, clientId, deps.now) };
204
271
  }
205
272
  async function revokeSession(credential, options) {
206
273
  const unconfirmed = { status: "remote_revocation_unconfirmed" };
@@ -278,14 +345,33 @@ function agentHostId(deviceId) {
278
345
  }
279
346
  return `urn:uuid:${deviceId.toLowerCase()}`;
280
347
  }
281
- async function loginOpenAIChatGPT(interaction, options) {
348
+ async function loginOpenAIChatGPT(interaction, options, deps, verifyIdToken) {
349
+ const existing = options?.reauthorize;
350
+ if (existing &&
351
+ (typeof existing.clientId !== "string" ||
352
+ !existing.clientId.trim() ||
353
+ existing.clientId === DYNAMIC_CLIENT_ID ||
354
+ typeof existing.subject !== "string" ||
355
+ !existing.subject.trim())) {
356
+ throw new OpenAIReauthorizationError("invalid_account");
357
+ }
358
+ const pendingClientId = existing?.clientId;
282
359
  const hostId = agentHostId(options?.getDeviceId?.());
360
+ const consentClientId = options?.planUsageConsent?.clientId;
361
+ if (options?.planUsageConsent && (!consentClientId?.trim() || consentClientId === DYNAMIC_CLIENT_ID)) {
362
+ throw new Error("Enabling ChatGPT plan usage requires the saved issued client ID");
363
+ }
364
+ const knownClient = pendingClientId
365
+ ? { clientId: pendingClientId, purpose: "reauthorize" }
366
+ : consentClientId
367
+ ? { clientId: consentClientId, purpose: "consent" }
368
+ : undefined;
283
369
  const { verifier, challenge } = await generatePKCE();
284
370
  const state = randomValue();
285
371
  const nonce = randomValue();
286
372
  let callback;
287
373
  try {
288
- callback = await startCallbackServer(state, options?.preferredCallbackPort ?? CALLBACK_PORT);
374
+ callback = await startCallbackServer(state, options?.preferredCallbackPort ?? CALLBACK_PORT, knownClient);
289
375
  }
290
376
  catch (error) {
291
377
  interaction.notify({
@@ -296,9 +382,7 @@ async function loginOpenAIChatGPT(interaction, options) {
296
382
  const redirectUri = callback?.redirectUri ?? REDIRECT_URI;
297
383
  const authorizationUrl = new URL(AUTHORIZE_URL);
298
384
  authorizationUrl.search = new URLSearchParams({
299
- client_id: DYNAMIC_CLIENT_ID,
300
- // Display metadata on OpenAI's consent screen (the user can edit it); not tied to the issued client ID.
301
- agent_name_hint: options?.appName ?? configuredOAuthBrandName() ?? getWireIdentity(),
385
+ client_id: pendingClientId ?? DYNAMIC_CLIENT_ID,
302
386
  ext_agent_host_id: hostId,
303
387
  response_type: "code",
304
388
  redirect_uri: redirectUri,
@@ -309,6 +393,20 @@ async function loginOpenAIChatGPT(interaction, options) {
309
393
  code_challenge_method: "S256",
310
394
  nonce,
311
395
  }).toString();
396
+ if (existing) {
397
+ if (existing.idToken)
398
+ authorizationUrl.searchParams.set("id_token_hint", existing.idToken);
399
+ if (existing.email)
400
+ authorizationUrl.searchParams.set("login_hint", existing.email);
401
+ }
402
+ else {
403
+ // Display metadata on OpenAI's consent screen (the user can edit it); not tied to the issued client ID.
404
+ authorizationUrl.searchParams.set("agent_name_hint", options?.appName ?? configuredOAuthBrandName() ?? getWireIdentity());
405
+ }
406
+ if (consentClientId) {
407
+ authorizationUrl.searchParams.set("client_id", consentClientId);
408
+ authorizationUrl.searchParams.set("prompt", "consent");
409
+ }
312
410
  interaction.notify({
313
411
  type: "auth_url",
314
412
  url: authorizationUrl.toString(),
@@ -322,11 +420,15 @@ async function loginOpenAIChatGPT(interaction, options) {
322
420
  placeholder: redirectUri,
323
421
  signal: AbortSignal.any([manualAbort.signal, interaction.signal]),
324
422
  })
325
- .then((input) => authorizationResultFromManualInput(input, state, redirectUri));
423
+ .then((input) => authorizationResultFromManualInput(input, state, redirectUri, knownClient));
326
424
  try {
327
425
  const result = await (callback ? Promise.race([callback.result, manualCode]) : manualCode);
328
426
  interaction.notify({ type: "progress", message: "Exchanging authorization code for tokens..." });
329
- return await exchangeAuthorizationCode(result.code, verifier, result.clientId, redirectUri, interaction.signal);
427
+ const credential = await exchangeAuthorizationCode(result.code, verifier, result.clientId, redirectUri, interaction.signal, nonce, deps, verifyIdToken);
428
+ if (existing && credential.subject !== existing.subject) {
429
+ throw new OpenAIReauthorizationError("subject_mismatch");
430
+ }
431
+ return credential;
330
432
  }
331
433
  catch (error) {
332
434
  if (interaction.signal.aborted)
@@ -344,20 +446,31 @@ async function loginOpenAIChatGPT(interaction, options) {
344
446
  callback?.server.closeAllConnections();
345
447
  }
346
448
  }
347
- export const openaiChatGPTOAuth = {
348
- name: "OpenAI (ChatGPT subscription)",
349
- isSubscription: true,
350
- loginLabel: "Sign in with ChatGPT",
351
- login: loginOpenAIChatGPT,
352
- refresh: refreshAccessToken,
353
- revoke: revokeSession,
354
- async check({ credential }) {
355
- if (!credential?.access || !credential.refresh || credential.signedOut === true)
356
- return undefined;
357
- return { type: "oauth", source: "OAuth" };
358
- },
359
- async toAuth(credential) {
360
- return { apiKey: credential.access };
361
- },
362
- };
449
+ export function createOpenAIChatGPTOAuth(dependencies = {}) {
450
+ const deps = {
451
+ fetch: dependencies.fetch ?? ((url, init) => fetch(url, init)),
452
+ now: dependencies.now ?? (() => Date.now()),
453
+ };
454
+ const verifyIdToken = createOpenAIIdTokenVerifier(deps);
455
+ return {
456
+ name: "OpenAI (ChatGPT subscription)",
457
+ isSubscription: true,
458
+ loginLabel: "Sign in with ChatGPT",
459
+ login: (interaction, options) => loginOpenAIChatGPT(interaction, options, deps, verifyIdToken),
460
+ refresh: (credential, signal) => refreshAccessToken(credential, signal, deps),
461
+ revoke: revokeSession,
462
+ async check({ credential }) {
463
+ if (!credential?.access || !credential.refresh || credential.signedOut === true)
464
+ return undefined;
465
+ return { type: "oauth", source: "OAuth" };
466
+ },
467
+ async toAuth(credential) {
468
+ if (credential.planUsageEnabled === false || !credential.scopes?.includes(DIRECT_TOKEN_SCOPE)) {
469
+ throw new OpenAIPlanUsageNotEnabledError();
470
+ }
471
+ return { apiKey: credential.access };
472
+ },
473
+ };
474
+ }
475
+ export const openaiChatGPTOAuth = createOpenAIChatGPTOAuth();
363
476
  //# sourceMappingURL=openai-chatgpt.js.map
@@ -0,0 +1,16 @@
1
+ export interface OpenAIIdentity {
2
+ subject: string;
3
+ /** Display metadata only; never an account identity or workspace identifier. */
4
+ email?: string;
5
+ issuer: string;
6
+ }
7
+ export interface OpenAIIdTokenDependencies {
8
+ fetch: (url: string, init?: RequestInit) => Promise<Response>;
9
+ now: () => number;
10
+ }
11
+ export declare class OpenAIIdTokenValidationError extends Error {
12
+ constructor(message: string);
13
+ }
14
+ /** Each flow instance caches discovery and keys, without caching identity claims. */
15
+ export declare function createOpenAIIdTokenVerifier(deps: OpenAIIdTokenDependencies): (idToken: unknown, clientId: string, nonce: string, signal: AbortSignal) => Promise<OpenAIIdentity>;
16
+ //# sourceMappingURL=openai-id-token.d.ts.map
@@ -0,0 +1,105 @@
1
+ const ISSUER = "https://auth.openai.com";
2
+ const DISCOVERY_URL = `${ISSUER}/.well-known/openid-configuration`;
3
+ const CACHE_MS = 5 * 60 * 1000;
4
+ export class OpenAIIdTokenValidationError extends Error {
5
+ constructor(message) {
6
+ super(`OpenAI ID token validation failed: ${message}`);
7
+ this.name = "OpenAIIdTokenValidationError";
8
+ }
9
+ }
10
+ function object(value) {
11
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
12
+ throw new OpenAIIdTokenValidationError("expected an object");
13
+ }
14
+ return value;
15
+ }
16
+ function decode(value) {
17
+ if (!/^[A-Za-z0-9_-]+$/.test(value))
18
+ throw new OpenAIIdTokenValidationError("invalid JWT encoding");
19
+ return Uint8Array.from(atob(value.replace(/-/g, "+").replace(/_/g, "/")), (char) => char.charCodeAt(0));
20
+ }
21
+ /** Each flow instance caches discovery and keys, without caching identity claims. */
22
+ export function createOpenAIIdTokenVerifier(deps) {
23
+ let discovery;
24
+ let jwks;
25
+ async function loadKeys(signal, force = false) {
26
+ if (!discovery || discovery.expires <= deps.now()) {
27
+ const response = await deps.fetch(DISCOVERY_URL, { signal });
28
+ if (!response.ok)
29
+ throw new OpenAIIdTokenValidationError("discovery request failed");
30
+ const config = object(await response.json());
31
+ if (typeof config.jwks_uri !== "string" || new URL(config.jwks_uri).protocol !== "https:") {
32
+ throw new OpenAIIdTokenValidationError("invalid discovery JWKS URI");
33
+ }
34
+ discovery = { jwksUri: config.jwks_uri, expires: deps.now() + CACHE_MS };
35
+ jwks = undefined;
36
+ }
37
+ if (force || !jwks || jwks.expires <= deps.now()) {
38
+ const response = await deps.fetch(discovery.jwksUri, { signal });
39
+ if (!response.ok)
40
+ throw new OpenAIIdTokenValidationError("JWKS request failed");
41
+ const data = object(await response.json());
42
+ if (!Array.isArray(data.keys))
43
+ throw new OpenAIIdTokenValidationError("invalid JWKS");
44
+ jwks = { keys: data.keys.map(object), expires: deps.now() + CACHE_MS };
45
+ }
46
+ return jwks.keys;
47
+ }
48
+ return async (idToken, clientId, nonce, signal) => {
49
+ try {
50
+ if (typeof idToken !== "string")
51
+ throw new OpenAIIdTokenValidationError("missing ID token");
52
+ const parts = idToken.split(".");
53
+ if (parts.length !== 3)
54
+ throw new OpenAIIdTokenValidationError("invalid JWT");
55
+ const [headerPart, payloadPart, signaturePart] = parts;
56
+ const header = object(JSON.parse(new TextDecoder().decode(decode(headerPart))));
57
+ if (header.alg !== "RS256" || typeof header.kid !== "string" || !header.kid || header.crit !== undefined) {
58
+ throw new OpenAIIdTokenValidationError("unsupported signing header");
59
+ }
60
+ const matchingKey = (key) => key.kid === header.kid &&
61
+ key.kty === "RSA" &&
62
+ (key.alg === undefined || key.alg === "RS256") &&
63
+ (key.use === undefined || key.use === "sig");
64
+ let keys = await loadKeys(signal);
65
+ if (!keys.some(matchingKey))
66
+ keys = await loadKeys(signal, true);
67
+ const matches = keys.filter(matchingKey);
68
+ if (matches.length !== 1)
69
+ throw new OpenAIIdTokenValidationError("signing key not found or ambiguous");
70
+ const key = await crypto.subtle.importKey("jwk", matches[0], { name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" }, false, ["verify"]);
71
+ const valid = await crypto.subtle.verify("RSASSA-PKCS1-v1_5", key, decode(signaturePart), new TextEncoder().encode(`${headerPart}.${payloadPart}`));
72
+ if (!valid)
73
+ throw new OpenAIIdTokenValidationError("invalid signature");
74
+ const claims = object(JSON.parse(new TextDecoder().decode(decode(payloadPart))));
75
+ if (claims.iss !== ISSUER)
76
+ throw new OpenAIIdTokenValidationError("issuer mismatch");
77
+ const audiences = typeof claims.aud === "string" ? [claims.aud] : claims.aud;
78
+ if (!Array.isArray(audiences) ||
79
+ !audiences.every((aud) => typeof aud === "string") ||
80
+ !audiences.includes(clientId)) {
81
+ throw new OpenAIIdTokenValidationError("audience mismatch");
82
+ }
83
+ if (typeof claims.exp !== "number" || !Number.isFinite(claims.exp) || claims.exp <= deps.now() / 1000) {
84
+ throw new OpenAIIdTokenValidationError("expired or missing expiration");
85
+ }
86
+ if (claims.nonce !== nonce)
87
+ throw new OpenAIIdTokenValidationError("nonce mismatch");
88
+ if (typeof claims.sub !== "string" || !claims.sub.trim()) {
89
+ throw new OpenAIIdTokenValidationError("missing subject");
90
+ }
91
+ return {
92
+ subject: claims.sub,
93
+ issuer: ISSUER,
94
+ ...(typeof claims.email === "string" ? { email: claims.email } : {}),
95
+ };
96
+ }
97
+ catch (error) {
98
+ if (error instanceof OpenAIIdTokenValidationError)
99
+ throw error;
100
+ // Do not expose tokens, key material, or remote response bodies in login errors.
101
+ throw new OpenAIIdTokenValidationError("could not verify ID token");
102
+ }
103
+ };
104
+ }
105
+ //# sourceMappingURL=openai-id-token.js.map
@@ -29,6 +29,18 @@ export interface OAuthCredentials {
29
29
  /** Stored canonical OAuth credential. */
30
30
  export interface OAuthCredential extends OAuthCredentials {
31
31
  type: "oauth";
32
+ /** Verified account identity, retained only by flows that validate an ID token. */
33
+ subject?: string;
34
+ /** Display metadata only; not proof of identity or workspace membership. */
35
+ email?: string;
36
+ issuer?: string;
37
+ /** Issued OAuth client ID, not the dynamic registration entrypoint. */
38
+ clientId?: string;
39
+ /** Retained for a later id_token_hint; treat as secret credential material. */
40
+ idToken?: string;
41
+ scopes?: string[];
42
+ /** SIWC plan permission; absent on credentials saved before permission tracking. */
43
+ planUsageEnabled?: boolean;
32
44
  /** Random id the last sign-in wrote (`stampLogin`); a refresh keeps or drops it, never mints one. Not key material. */
33
45
  loginId?: string;
34
46
  }
@@ -251,12 +263,21 @@ export interface ApiKeyAuth {
251
263
  export interface LoginOptions {
252
264
  /** Configured product display name, consistent across installations of this app. */
253
265
  appName?: string;
266
+ /**
267
+ * The explicitly selected saved account for reauthorization. Omit when adding
268
+ * an account; callers must not infer this from the provider's active credential.
269
+ */
270
+ reauthorize?: OAuthCredential;
254
271
  /**
255
272
  * Returns the stable ID of this app installation, e.g. sent to OpenAI as its
256
273
  * agent host ID. Called only by login flows that need it, so apps can create
257
274
  * the ID on first use and must return the same ID on every later call.
258
275
  */
259
276
  getDeviceId?: () => string;
277
+ /** Explicitly enable ChatGPT plan usage for a saved SIWC registration. */
278
+ planUsageConsent?: {
279
+ clientId: string;
280
+ };
260
281
  }
261
282
  /**
262
283
  * OAuth auth. The `refresh`/`toAuth` split lets `Models` own the locked
package/dist/cli.js CHANGED
@@ -5,6 +5,7 @@ import { homedir } from "node:os";
5
5
  import { join } from "node:path";
6
6
  import { createInterface } from "node:readline";
7
7
  import { builtinProviders } from "./providers/all.js";
8
+ import { redactOAuthUrl } from "./utils/oauth-url.js";
8
9
  const AUTH_FILE = "auth.json";
9
10
  const PROVIDERS = builtinProviders().filter((provider) => provider.auth.oauth !== undefined);
10
11
  function prompt(rl, question) {
@@ -64,7 +65,7 @@ async function login(providerId) {
64
65
  notify: (event) => {
65
66
  switch (event.type) {
66
67
  case "auth_url":
67
- console.log(`\nOpen this URL in your browser:\n${event.url}`);
68
+ console.log(`\nOpen this URL in your browser:\n${redactOAuthUrl(event.url)}`);
68
69
  if (event.instructions)
69
70
  console.log(event.instructions);
70
71
  break;