@code-yeongyu/senpi-ai 2026.11.3-2 → 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.
- package/dist/api/anthropic-messages.js +5 -5
- package/dist/api/lazy.js +3 -0
- package/dist/api/openai-completions.js +19 -4
- package/dist/api/simple-options.js +1 -0
- package/dist/auth/oauth/openai-chatgpt.d.ts +46 -2
- package/dist/auth/oauth/openai-chatgpt.js +206 -74
- package/dist/auth/oauth/openai-id-token.d.ts +16 -0
- package/dist/auth/oauth/openai-id-token.js +105 -0
- package/dist/auth/oauth-refresh.d.ts +3 -1
- package/dist/auth/oauth-refresh.js +49 -11
- package/dist/auth/types.d.ts +33 -3
- package/dist/cli.js +2 -1
- package/dist/providers/data/.manifest.json +1 -1
- package/dist/providers/data/opencode-go.json +1 -1
- package/dist/providers/data/opencode.json +1 -1
- package/dist/providers/data/opengateway.json +1 -1
- package/dist/providers/data/openrouter.json +1 -1
- package/dist/providers/data/radius.json +1 -1
- package/dist/providers/data/venice.json +1 -1
- package/dist/providers/data/vercel-ai-gateway.json +1 -1
- package/dist/types.d.ts +6 -0
- package/dist/utils/oauth-refresh-error.d.ts +11 -0
- package/dist/utils/oauth-refresh-error.js +18 -0
- package/dist/utils/oauth-url.d.ts +7 -0
- package/dist/utils/oauth-url.js +13 -0
- package/dist/utils/provider-diagnostic-vocabulary.d.ts +4 -0
- package/dist/utils/provider-diagnostic-vocabulary.js +14 -0
- package/dist/utils/provider-failure-description.d.ts +2 -0
- package/dist/utils/provider-failure-description.js +1 -1
- package/package.json +2 -2
|
@@ -952,11 +952,11 @@ export const stream = (model, context, options) => {
|
|
|
952
952
|
});
|
|
953
953
|
}
|
|
954
954
|
const cacheRetention = resolveCacheRetention(options?.cacheRetention ?? model.cacheRetention, options?.env, "short");
|
|
955
|
-
const
|
|
955
|
+
const affinityKey = cacheRetention === "none" ? undefined : (options?.sessionAffinityKey ?? options?.sessionId);
|
|
956
956
|
openClient = () => {
|
|
957
957
|
client = createClient(model, apiKey, options?.interleavedThinking ?? true, shouldUseFineGrainedToolStreamingBeta(model, normalizedContext), options?.refusalFallbacks !== undefined && configuredBetaHeader(model, options) !== null, optionsHeaders, getAnthropicCompat(model).supportsGzipRequestBody
|
|
958
958
|
? withGzipRequestBodies(options?.fetch)
|
|
959
|
-
: options?.fetch, copilotDynamicHeaders,
|
|
959
|
+
: options?.fetch, copilotDynamicHeaders, affinityKey, options?.env, federation);
|
|
960
960
|
};
|
|
961
961
|
openClient();
|
|
962
962
|
}
|
|
@@ -1602,7 +1602,7 @@ export const streamSimple = (model, context, options) => {
|
|
|
1602
1602
|
thinkingBudgetTokens: Math.min(adjusted.thinkingBudget, Math.max(0, maxTokens - 1024)),
|
|
1603
1603
|
});
|
|
1604
1604
|
};
|
|
1605
|
-
function createClient(model, apiKey, interleavedThinking, useFineGrainedToolStreamingBeta, useServerSideFallbackBeta, optionsHeaders, fetch, dynamicHeaders,
|
|
1605
|
+
function createClient(model, apiKey, interleavedThinking, useFineGrainedToolStreamingBeta, useServerSideFallbackBeta, optionsHeaders, fetch, dynamicHeaders, affinityKey, env, federation) {
|
|
1606
1606
|
// Adaptive thinking models have interleaved thinking built in, so skip the beta header.
|
|
1607
1607
|
const needsInterleavedBeta = interleavedThinking && !supportsAdaptiveThinking(model);
|
|
1608
1608
|
const betaFeatures = [];
|
|
@@ -1652,11 +1652,11 @@ function createClient(model, apiKey, interleavedThinking, useFineGrainedToolStre
|
|
|
1652
1652
|
// API key, header-owned auth, or workload identity federation.
|
|
1653
1653
|
const affinityCompat = getAnthropicCompat(model);
|
|
1654
1654
|
const sessionAffinityHeaders = {};
|
|
1655
|
-
if (
|
|
1655
|
+
if (affinityKey && affinityCompat.sendSessionAffinityHeaders) {
|
|
1656
1656
|
// OpenRouter routes prompt-cache affinity through its own header name and
|
|
1657
1657
|
// rejects x-session-affinity (earendil-works/pi#9102).
|
|
1658
1658
|
const header = affinityCompat.sessionAffinityFormat === "openrouter" ? "x-session-id" : "x-session-affinity";
|
|
1659
|
-
sessionAffinityHeaders[header] =
|
|
1659
|
+
sessionAffinityHeaders[header] = affinityKey;
|
|
1660
1660
|
}
|
|
1661
1661
|
const defaultHeaders = sanitizeAdaptiveThinkingHeaders(model, mergeClientHeaders(model, {
|
|
1662
1662
|
accept: "application/json",
|
package/dist/api/lazy.js
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
|
+
import { readProviderDiagnostic } from "../provider-diagnostic.js";
|
|
1
2
|
import { assistantEvents, pushAssistantProgress, setAssistantReader } from "../utils/assistant-capture.js";
|
|
2
3
|
import { AssistantMessageEventStream } from "../utils/event-stream.js";
|
|
3
4
|
import { isOAuthRefreshUnavailableError } from "../utils/oauth-refresh-error.js";
|
|
4
5
|
import { describeReplacedInstall } from "../utils/provider-failure-description.js";
|
|
5
6
|
import { OAUTH_REFRESH_UNAVAILABLE_DIAGNOSTIC } from "../utils/retry.js";
|
|
6
7
|
function createSetupErrorMessage(model, error) {
|
|
8
|
+
const providerDiagnostic = readProviderDiagnostic(error);
|
|
7
9
|
return {
|
|
8
10
|
role: "assistant",
|
|
9
11
|
content: [],
|
|
@@ -20,6 +22,7 @@ function createSetupErrorMessage(model, error) {
|
|
|
20
22
|
},
|
|
21
23
|
stopReason: "error",
|
|
22
24
|
errorMessage: describeReplacedInstall(error) ?? (error instanceof Error ? error.message : String(error)),
|
|
25
|
+
...(providerDiagnostic === undefined ? {} : { providerDiagnostic }),
|
|
23
26
|
...(isOAuthRefreshUnavailableError(error)
|
|
24
27
|
? {
|
|
25
28
|
diagnostics: [
|
|
@@ -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) {
|
|
@@ -118,6 +118,7 @@ export function buildBaseOptions(model, context, options, apiKey) {
|
|
|
118
118
|
transport: options?.transport,
|
|
119
119
|
cacheRetention: options?.cacheRetention ?? model.cacheRetention,
|
|
120
120
|
sessionId: options?.sessionId,
|
|
121
|
+
sessionAffinityKey: options?.sessionAffinityKey,
|
|
121
122
|
headers: options?.headers,
|
|
122
123
|
extraBody: options?.extraBody,
|
|
123
124
|
onPayload: options?.onPayload,
|
|
@@ -4,6 +4,50 @@
|
|
|
4
4
|
* This public-client flow uses no client secret and sends the resulting user
|
|
5
5
|
* access token directly to api.openai.com.
|
|
6
6
|
*/
|
|
7
|
-
import type {
|
|
8
|
-
|
|
7
|
+
import type { LoginOptions, LogoutOptions, OAuthCredential, OAuthRevocationResult, ProviderAuthInteraction } from "../types.ts";
|
|
8
|
+
import { type OpenAIIdTokenDependencies } from "./openai-id-token.ts";
|
|
9
|
+
declare function revokeSession(credential: OAuthCredential, options?: LogoutOptions): Promise<OAuthRevocationResult>;
|
|
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
|
+
};
|
|
31
|
+
export declare const openaiChatGPTOAuth: {
|
|
32
|
+
name: string;
|
|
33
|
+
isSubscription: true;
|
|
34
|
+
loginLabel: string;
|
|
35
|
+
login: (interaction: ProviderAuthInteraction, options?: LoginOptions & {
|
|
36
|
+
readonly preferredCallbackPort?: number;
|
|
37
|
+
}) => Promise<OAuthCredential>;
|
|
38
|
+
refresh: (credential: OAuthCredential, signal: AbortSignal) => Promise<OAuthCredential>;
|
|
39
|
+
revoke: typeof revokeSession;
|
|
40
|
+
check({ credential }: {
|
|
41
|
+
ctx: import("../types.ts").AuthContext;
|
|
42
|
+
credential?: OAuthCredential;
|
|
43
|
+
signal: AbortSignal;
|
|
44
|
+
}): Promise<{
|
|
45
|
+
type: "oauth";
|
|
46
|
+
source: string;
|
|
47
|
+
} | undefined>;
|
|
48
|
+
toAuth(credential: OAuthCredential): Promise<{
|
|
49
|
+
apiKey: string;
|
|
50
|
+
}>;
|
|
51
|
+
};
|
|
52
|
+
export {};
|
|
9
53
|
//# sourceMappingURL=openai-chatgpt.d.ts.map
|
|
@@ -7,18 +7,18 @@
|
|
|
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";
|
|
11
|
-
import {
|
|
10
|
+
import { classifyOAuthRefreshFailure, OAuthClientConfigurationError, OAuthReauthorizationRequiredError, OAuthTokenEndpointError, } from "../../utils/oauth-refresh-error.js";
|
|
11
|
+
import { createOpenAIIdTokenVerifier } from "./openai-id-token.js";
|
|
12
12
|
import { getWireIdentity } from "../../wire-identity.js";
|
|
13
13
|
import { generatePKCE } from "./pkce.js";
|
|
14
|
-
//
|
|
14
|
+
// Only first-time registration uses this entrypoint; returning accounts keep their issued ID.
|
|
15
15
|
const DYNAMIC_CLIENT_ID = "dynamic_agent_client";
|
|
16
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;
|
|
17
17
|
const AUTHORIZE_URL = "https://auth.openai.com/api/accounts/authorize";
|
|
18
18
|
const TOKEN_URL = "https://auth.openai.com/api/accounts/oauth/token";
|
|
19
19
|
const DISCOVERY_URL = "https://auth.openai.com/.well-known/openid-configuration";
|
|
20
20
|
const RESOURCE = "https://api.openai.com/v1";
|
|
21
|
-
const CALLBACK_HOST =
|
|
21
|
+
const CALLBACK_HOST = "127.0.0.1";
|
|
22
22
|
const CALLBACK_PORT = 1455;
|
|
23
23
|
const CALLBACK_PATH = "/auth/callback";
|
|
24
24
|
const REDIRECT_URI = `http://127.0.0.1:${CALLBACK_PORT}${CALLBACK_PATH}`;
|
|
@@ -26,24 +26,65 @@ const DIRECT_TOKEN_SCOPE = "chatgpt.tokens.use.direct";
|
|
|
26
26
|
const SCOPE = `openid profile email offline_access resource.invoke ${DIRECT_TOKEN_SCOPE}`;
|
|
27
27
|
// Refresh this long before the real expiry so a request never starts with a token about to expire.
|
|
28
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
|
+
}
|
|
29
55
|
function randomValue() {
|
|
30
56
|
return randomBytes(32).toString("base64url");
|
|
31
57
|
}
|
|
32
|
-
function authorizationResultFromCallback(url, expectedState) {
|
|
33
|
-
const code = url.searchParams.get("code");
|
|
34
|
-
if (!code)
|
|
35
|
-
throw new Error("Missing authorization code");
|
|
58
|
+
function authorizationResultFromCallback(url, expectedState, knownClient) {
|
|
36
59
|
const state = url.searchParams.get("state");
|
|
37
60
|
if (!state)
|
|
38
61
|
throw new Error("Missing OAuth state");
|
|
39
62
|
if (state !== expectedState)
|
|
40
63
|
throw new Error("OAuth state mismatch");
|
|
41
|
-
const
|
|
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;
|
|
42
83
|
if (!clientId)
|
|
43
84
|
throw new Error("OpenAI OAuth registration callback did not contain an issued client ID");
|
|
44
85
|
return { code, clientId };
|
|
45
86
|
}
|
|
46
|
-
function authorizationResultFromManualInput(input, expectedState) {
|
|
87
|
+
function authorizationResultFromManualInput(input, expectedState, redirectUri, knownClient) {
|
|
47
88
|
let url;
|
|
48
89
|
try {
|
|
49
90
|
url = new URL(input.trim());
|
|
@@ -51,21 +92,19 @@ function authorizationResultFromManualInput(input, expectedState) {
|
|
|
51
92
|
catch {
|
|
52
93
|
throw new Error("Paste the full callback URL from the browser");
|
|
53
94
|
}
|
|
54
|
-
const expected = new URL(
|
|
95
|
+
const expected = new URL(redirectUri);
|
|
55
96
|
if (url.origin !== expected.origin || url.pathname !== expected.pathname) {
|
|
56
|
-
throw new Error(`The pasted callback URL must start with ${
|
|
97
|
+
throw new Error(`The pasted callback URL must start with ${redirectUri}`);
|
|
57
98
|
}
|
|
58
|
-
|
|
59
|
-
if (error)
|
|
60
|
-
throw new Error(`ChatGPT authorization failed: ${error}`);
|
|
61
|
-
return authorizationResultFromCallback(url, expectedState);
|
|
99
|
+
return authorizationResultFromCallback(url, expectedState, knownClient);
|
|
62
100
|
}
|
|
63
101
|
function sendHtml(response, status, body) {
|
|
64
102
|
response.writeHead(status, { "Content-Type": "text/html; charset=utf-8" });
|
|
65
103
|
response.end(body);
|
|
66
104
|
}
|
|
67
|
-
function startCallbackServer(expectedState) {
|
|
105
|
+
function startCallbackServer(expectedState, preferredPort, knownClient) {
|
|
68
106
|
return new Promise((resolve, reject) => {
|
|
107
|
+
let redirectUri = REDIRECT_URI;
|
|
69
108
|
let resolveResult;
|
|
70
109
|
let rejectResult;
|
|
71
110
|
const result = new Promise((resolveAuthorization, rejectAuthorization) => {
|
|
@@ -74,24 +113,23 @@ function startCallbackServer(expectedState) {
|
|
|
74
113
|
});
|
|
75
114
|
const server = createServer((request, response) => {
|
|
76
115
|
try {
|
|
77
|
-
const url = new URL(request.url || "",
|
|
116
|
+
const url = new URL(request.url || "", redirectUri);
|
|
78
117
|
if (url.pathname !== CALLBACK_PATH) {
|
|
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."));
|
|
@@ -101,16 +139,47 @@ function startCallbackServer(expectedState) {
|
|
|
101
139
|
sendHtml(response, 500, oauthErrorHtml("Internal error while processing the callback."));
|
|
102
140
|
}
|
|
103
141
|
});
|
|
104
|
-
|
|
105
|
-
|
|
142
|
+
const onListening = () => {
|
|
143
|
+
server.removeListener("error", onBindError);
|
|
106
144
|
server.removeListener("error", reject);
|
|
145
|
+
const address = server.address();
|
|
146
|
+
if (!address || typeof address === "string") {
|
|
147
|
+
server.close();
|
|
148
|
+
reject(new Error("OpenAI OAuth callback server did not bind to TCP"));
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
redirectUri = `http://${CALLBACK_HOST}:${address.port}${CALLBACK_PATH}`;
|
|
107
152
|
server.on("error", rejectResult);
|
|
108
|
-
resolve({ server, result });
|
|
109
|
-
}
|
|
153
|
+
resolve({ server, result, redirectUri });
|
|
154
|
+
};
|
|
155
|
+
const onBindError = (error) => {
|
|
156
|
+
server.removeListener("listening", onListening);
|
|
157
|
+
if (error.code !== "EADDRINUSE") {
|
|
158
|
+
reject(error);
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
server.once("error", reject);
|
|
162
|
+
server.listen(0, CALLBACK_HOST, onListening);
|
|
163
|
+
};
|
|
164
|
+
server.once("error", onBindError);
|
|
165
|
+
server.listen(preferredPort, CALLBACK_HOST, onListening);
|
|
110
166
|
});
|
|
111
167
|
}
|
|
112
|
-
|
|
113
|
-
|
|
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, {
|
|
114
183
|
method: "POST",
|
|
115
184
|
headers: {
|
|
116
185
|
accept: "application/json",
|
|
@@ -121,7 +190,29 @@ async function requestToken(body, signal) {
|
|
|
121
190
|
});
|
|
122
191
|
if (!response.ok) {
|
|
123
192
|
const responseBody = await response.text().catch(() => "");
|
|
124
|
-
|
|
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;
|
|
125
216
|
}
|
|
126
217
|
const data = await response.json();
|
|
127
218
|
if (typeof data !== "object" || data === null || Array.isArray(data)) {
|
|
@@ -135,7 +226,7 @@ function requireTokenString(value, field) {
|
|
|
135
226
|
}
|
|
136
227
|
return value;
|
|
137
228
|
}
|
|
138
|
-
function credentialFromTokenResponse(token, clientId) {
|
|
229
|
+
function credentialFromTokenResponse(token, clientId, now) {
|
|
139
230
|
const access = requireTokenString(token.access_token, "access_token");
|
|
140
231
|
const refresh = requireTokenString(token.refresh_token, "refresh_token");
|
|
141
232
|
const scope = requireTokenString(token.scope, "scope");
|
|
@@ -143,35 +234,29 @@ function credentialFromTokenResponse(token, clientId) {
|
|
|
143
234
|
throw new Error("OpenAI OAuth token response has invalid expires_in");
|
|
144
235
|
}
|
|
145
236
|
const scopes = scope.trim().split(/\s+/).filter(Boolean);
|
|
146
|
-
if (!scopes.includes(DIRECT_TOKEN_SCOPE)) {
|
|
147
|
-
throw new Error(`OpenAI OAuth grant did not include ${DIRECT_TOKEN_SCOPE}`);
|
|
148
|
-
}
|
|
149
237
|
return {
|
|
150
238
|
type: "oauth",
|
|
151
239
|
access,
|
|
152
240
|
refresh,
|
|
153
|
-
expires:
|
|
241
|
+
expires: now() + token.expires_in * 1000 - EXPIRY_MARGIN_MS,
|
|
154
242
|
clientId,
|
|
155
243
|
scopes,
|
|
244
|
+
planUsageEnabled: scopes.includes(DIRECT_TOKEN_SCOPE),
|
|
156
245
|
};
|
|
157
246
|
}
|
|
158
|
-
async function exchangeAuthorizationCode(code, verifier, clientId, signal) {
|
|
247
|
+
async function exchangeAuthorizationCode(code, verifier, clientId, redirectUri, signal, nonce, deps, verifyIdToken) {
|
|
159
248
|
const token = await requestToken(new URLSearchParams({
|
|
160
249
|
grant_type: "authorization_code",
|
|
161
250
|
client_id: clientId,
|
|
162
251
|
code,
|
|
163
252
|
code_verifier: verifier,
|
|
164
|
-
redirect_uri:
|
|
253
|
+
redirect_uri: redirectUri,
|
|
165
254
|
resource: RESOURCE,
|
|
166
|
-
}), signal);
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
if (typeof token.id_token !== "string" || token.id_token.trim().length === 0) {
|
|
170
|
-
throw new Error("OpenAI OAuth token response did not contain an ID token");
|
|
171
|
-
}
|
|
172
|
-
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 };
|
|
173
258
|
}
|
|
174
|
-
async function refreshAccessToken(credential, signal) {
|
|
259
|
+
async function refreshAccessToken(credential, signal, deps) {
|
|
175
260
|
const clientId = credential.clientId;
|
|
176
261
|
if (typeof clientId !== "string" || clientId.trim().length === 0) {
|
|
177
262
|
throw new Error("Stored OpenAI OAuth credential does not contain an issued client ID; reconnect ChatGPT");
|
|
@@ -181,8 +266,8 @@ async function refreshAccessToken(credential, signal) {
|
|
|
181
266
|
client_id: clientId,
|
|
182
267
|
refresh_token: credential.refresh,
|
|
183
268
|
resource: RESOURCE,
|
|
184
|
-
}), signal);
|
|
185
|
-
return credentialFromTokenResponse(token, clientId);
|
|
269
|
+
}), signal, deps.fetch);
|
|
270
|
+
return { ...credential, ...credentialFromTokenResponse(token, clientId, deps.now) };
|
|
186
271
|
}
|
|
187
272
|
async function revokeSession(credential, options) {
|
|
188
273
|
const unconfirmed = { status: "remote_revocation_unconfirmed" };
|
|
@@ -260,14 +345,33 @@ function agentHostId(deviceId) {
|
|
|
260
345
|
}
|
|
261
346
|
return `urn:uuid:${deviceId.toLowerCase()}`;
|
|
262
347
|
}
|
|
263
|
-
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;
|
|
264
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;
|
|
265
369
|
const { verifier, challenge } = await generatePKCE();
|
|
266
370
|
const state = randomValue();
|
|
267
371
|
const nonce = randomValue();
|
|
268
372
|
let callback;
|
|
269
373
|
try {
|
|
270
|
-
callback = await startCallbackServer(state);
|
|
374
|
+
callback = await startCallbackServer(state, options?.preferredCallbackPort ?? CALLBACK_PORT, knownClient);
|
|
271
375
|
}
|
|
272
376
|
catch (error) {
|
|
273
377
|
interaction.notify({
|
|
@@ -275,14 +379,13 @@ async function loginOpenAIChatGPT(interaction, options) {
|
|
|
275
379
|
message: `Could not listen on ${REDIRECT_URI}; paste the final redirect URL to continue. ${error instanceof Error ? error.message : String(error)}`,
|
|
276
380
|
});
|
|
277
381
|
}
|
|
382
|
+
const redirectUri = callback?.redirectUri ?? REDIRECT_URI;
|
|
278
383
|
const authorizationUrl = new URL(AUTHORIZE_URL);
|
|
279
384
|
authorizationUrl.search = new URLSearchParams({
|
|
280
|
-
client_id: DYNAMIC_CLIENT_ID,
|
|
281
|
-
// Display metadata on OpenAI's consent screen (the user can edit it); not tied to the issued client ID.
|
|
282
|
-
agent_name_hint: options?.appName ?? configuredOAuthBrandName() ?? getWireIdentity(),
|
|
385
|
+
client_id: pendingClientId ?? DYNAMIC_CLIENT_ID,
|
|
283
386
|
ext_agent_host_id: hostId,
|
|
284
387
|
response_type: "code",
|
|
285
|
-
redirect_uri:
|
|
388
|
+
redirect_uri: redirectUri,
|
|
286
389
|
resource: RESOURCE,
|
|
287
390
|
scope: SCOPE,
|
|
288
391
|
state,
|
|
@@ -290,6 +393,20 @@ async function loginOpenAIChatGPT(interaction, options) {
|
|
|
290
393
|
code_challenge_method: "S256",
|
|
291
394
|
nonce,
|
|
292
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
|
+
}
|
|
293
410
|
interaction.notify({
|
|
294
411
|
type: "auth_url",
|
|
295
412
|
url: authorizationUrl.toString(),
|
|
@@ -300,14 +417,18 @@ async function loginOpenAIChatGPT(interaction, options) {
|
|
|
300
417
|
.prompt({
|
|
301
418
|
type: "manual_code",
|
|
302
419
|
message: "Complete login in your browser, or paste the final redirect URL here:",
|
|
303
|
-
placeholder:
|
|
420
|
+
placeholder: redirectUri,
|
|
304
421
|
signal: AbortSignal.any([manualAbort.signal, interaction.signal]),
|
|
305
422
|
})
|
|
306
|
-
.then((input) => authorizationResultFromManualInput(input, state));
|
|
423
|
+
.then((input) => authorizationResultFromManualInput(input, state, redirectUri, knownClient));
|
|
307
424
|
try {
|
|
308
425
|
const result = await (callback ? Promise.race([callback.result, manualCode]) : manualCode);
|
|
309
426
|
interaction.notify({ type: "progress", message: "Exchanging authorization code for tokens..." });
|
|
310
|
-
|
|
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;
|
|
311
432
|
}
|
|
312
433
|
catch (error) {
|
|
313
434
|
if (interaction.signal.aborted)
|
|
@@ -325,20 +446,31 @@ async function loginOpenAIChatGPT(interaction, options) {
|
|
|
325
446
|
callback?.server.closeAllConnections();
|
|
326
447
|
}
|
|
327
448
|
}
|
|
328
|
-
export
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
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();
|
|
344
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
|