@code-yeongyu/senpi-ai 2026.11.3-3 → 2026.11.5-1
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/openai-completions.js +19 -4
- package/dist/api/openai-responses-prompt-cache.d.ts +2 -0
- package/dist/api/openai-responses-prompt-cache.js +6 -4
- package/dist/api/openai-responses-shared.d.ts +1 -0
- package/dist/api/openai-responses-shared.js +6 -1
- package/dist/api/openai-responses.js +75 -4
- package/dist/auth/oauth/openai-chatgpt.d.ts +26 -6
- package/dist/auth/oauth/openai-chatgpt.js +175 -62
- package/dist/auth/oauth/openai-id-token.d.ts +16 -0
- package/dist/auth/oauth/openai-id-token.js +105 -0
- package/dist/auth/pool/slots.d.ts +2 -2
- package/dist/auth/pool/slots.js +64 -14
- package/dist/auth/types.d.ts +29 -2
- package/dist/cli.js +2 -1
- package/dist/models.js +8 -1
- package/dist/providers/chatgpt-subscription.d.ts +1 -1
- package/dist/providers/chatgpt-subscription.js +57 -8
- package/dist/providers/data/.manifest.json +1 -1
- package/dist/providers/data/baseten.json +1 -1
- package/dist/providers/data/nvidia.json +1 -1
- package/dist/providers/data/opencode-go.json +1 -1
- package/dist/providers/data/opencode.json +1 -1
- package/dist/providers/data/openrouter.json +1 -1
- package/dist/providers/data/together.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 +3 -2
- 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/package.json +2 -2
|
@@ -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) {
|
|
@@ -9,6 +9,8 @@ export interface OpenAIPromptCacheOptionsPayload {
|
|
|
9
9
|
prewarm?: boolean;
|
|
10
10
|
comparison_response_id?: string;
|
|
11
11
|
}
|
|
12
|
+
/** SIWC plan usage cannot opt into explicit prompt caching through inherited catalog compat. */
|
|
13
|
+
export declare function supportsOpenAIResponsesExplicitPromptCacheMode(model: Model<"openai-responses">): boolean;
|
|
12
14
|
/** Explicit prompt-cache models (GPT-5.6+) on the first-party api.openai.com endpoint. */
|
|
13
15
|
export declare function isOpenAIResponsesPromptCacheModel(model: Model<any>): boolean;
|
|
14
16
|
/**
|
|
@@ -1,9 +1,11 @@
|
|
|
1
|
+
/** SIWC plan usage cannot opt into explicit prompt caching through inherited catalog compat. */
|
|
2
|
+
export function supportsOpenAIResponsesExplicitPromptCacheMode(model) {
|
|
3
|
+
const compat = model.compat;
|
|
4
|
+
return !compat?.siwc && compat?.supportsExplicitPromptCacheMode === true;
|
|
5
|
+
}
|
|
1
6
|
/** Explicit prompt-cache models (GPT-5.6+) on the first-party api.openai.com endpoint. */
|
|
2
7
|
export function isOpenAIResponsesPromptCacheModel(model) {
|
|
3
|
-
if (model.api !== "openai-responses")
|
|
4
|
-
return false;
|
|
5
|
-
const compat = model.compat;
|
|
6
|
-
if (compat?.supportsExplicitPromptCacheMode !== true)
|
|
8
|
+
if (model.api !== "openai-responses" || !supportsOpenAIResponsesExplicitPromptCacheMode(model))
|
|
7
9
|
return false;
|
|
8
10
|
try {
|
|
9
11
|
return new URL(model.baseUrl || "https://api.openai.com/v1").hostname === "api.openai.com";
|
|
@@ -41,6 +41,7 @@ export type ResponsesDeferredToolsMode = "additional-tools" | "tool-search";
|
|
|
41
41
|
export declare function resolveResponsesDeferredToolsMode(compat: {
|
|
42
42
|
supportsAdditionalTools?: boolean;
|
|
43
43
|
supportsToolSearch?: boolean;
|
|
44
|
+
siwc?: boolean;
|
|
44
45
|
} | undefined): ResponsesDeferredToolsMode | undefined;
|
|
45
46
|
export interface ResponsesToolPlacement {
|
|
46
47
|
/** Tools sent in the top-level `tools` field. */
|
|
@@ -122,6 +122,8 @@ function withContextProvenance(item, message, seal) {
|
|
|
122
122
|
export function resolveResponsesDeferredToolsMode(compat) {
|
|
123
123
|
if (compat?.supportsAdditionalTools)
|
|
124
124
|
return "additional-tools";
|
|
125
|
+
if (compat?.siwc)
|
|
126
|
+
return undefined;
|
|
125
127
|
return compat?.supportsToolSearch ? "tool-search" : undefined;
|
|
126
128
|
}
|
|
127
129
|
/**
|
|
@@ -217,7 +219,10 @@ export function convertResponsesMessages(model, context, allowedToolCallProvider
|
|
|
217
219
|
};
|
|
218
220
|
const includeInitialSystemMessage = options?.includeSystemPrompt ?? true;
|
|
219
221
|
const compat = model.compat;
|
|
220
|
-
const instructionRole = model.
|
|
222
|
+
const instructionRole = model.compat?.siwc ||
|
|
223
|
+
(model.reasoning && compat?.supportsDeveloperRole !== false)
|
|
224
|
+
? "developer"
|
|
225
|
+
: "system";
|
|
221
226
|
let msgIndex = 0;
|
|
222
227
|
let sourceIndex = 0;
|
|
223
228
|
for (const msg of transformedMessages) {
|
|
@@ -19,7 +19,7 @@ import { buildCopilotDynamicHeaders, hasCopilotVisionInput } from "./github-copi
|
|
|
19
19
|
import { resolveOpenAIClientAuth } from "./openai-client-auth.js";
|
|
20
20
|
import { deriveOpenAIPromptCacheKey } from "./openai-prompt-cache.js";
|
|
21
21
|
import { applyAllowedToolsChoice, hasRefusedAllowedToolsChoice, isAllowedToolsChoiceRefusal, rememberAllowedToolsChoiceRefusal, restrictToAllowedTools, } from "./openai-responses-allowed-tools.js";
|
|
22
|
-
import { findPromptCacheComparisonResponseId, withPromptCacheComparison, } from "./openai-responses-prompt-cache.js";
|
|
22
|
+
import { findPromptCacheComparisonResponseId, supportsOpenAIResponsesExplicitPromptCacheMode, withPromptCacheComparison, } from "./openai-responses-prompt-cache.js";
|
|
23
23
|
import { convertResponsesMessages, convertResponsesTools, processResponsesStream, resolveResponsesDeferredToolsMode, resolveResponsesToolPlacement, } from "./openai-responses-shared.js";
|
|
24
24
|
import { buildBaseOptions, clampMaxForOpenAI, OPENAI_RESPONSES_RESERVED_BODY_KEYS } from "./simple-options.js";
|
|
25
25
|
import { startWebSocketLiveness } from "./websocket-liveness.js";
|
|
@@ -69,8 +69,8 @@ function getCompat(model, env) {
|
|
|
69
69
|
supportsStrictMode: model.compat?.supportsStrictMode ?? false,
|
|
70
70
|
supportsOpenAIGrammarTools: model.compat?.supportsOpenAIGrammarTools ?? false,
|
|
71
71
|
supportsAdditionalTools: model.compat?.supportsAdditionalTools ?? false,
|
|
72
|
-
supportsToolSearch: model.compat?.supportsToolSearch ?? false,
|
|
73
|
-
supportsExplicitPromptCacheMode: model
|
|
72
|
+
supportsToolSearch: !model.compat?.siwc && (model.compat?.supportsToolSearch ?? false),
|
|
73
|
+
supportsExplicitPromptCacheMode: supportsOpenAIResponsesExplicitPromptCacheMode(model),
|
|
74
74
|
supportsConfigurationUpdate: model.compat?.supportsConfigurationUpdate ?? false,
|
|
75
75
|
supportsMaxOutputTokens: model.compat?.supportsMaxOutputTokens ?? true,
|
|
76
76
|
supportsForcedToolChoice: model.compat?.supportsForcedToolChoice ?? true,
|
|
@@ -160,6 +160,73 @@ function formatOpenAIResponsesError(error, provider, siwc = false) {
|
|
|
160
160
|
? `${errorMessage}\nCheck your ChatGPT usage: ${CHATGPT_USAGE_URL}`
|
|
161
161
|
: errorMessage;
|
|
162
162
|
}
|
|
163
|
+
/**
|
|
164
|
+
* SIWC preview restrictions apply after hooks and sampling overrides too.
|
|
165
|
+
* https://developers.openai.com/siwc/token-sharing-open-source/preview-limitations
|
|
166
|
+
*/
|
|
167
|
+
function shapeSiwcRequest(params) {
|
|
168
|
+
const body = { ...params, store: false, stream: true };
|
|
169
|
+
for (const key of [
|
|
170
|
+
"background",
|
|
171
|
+
"conversation",
|
|
172
|
+
"max_output_tokens",
|
|
173
|
+
"max_tool_calls",
|
|
174
|
+
"metadata",
|
|
175
|
+
"moderation",
|
|
176
|
+
"multi_agent",
|
|
177
|
+
"prompt",
|
|
178
|
+
"prompt_cache_retention",
|
|
179
|
+
"safety_identifier",
|
|
180
|
+
"temperature",
|
|
181
|
+
"top_logprobs",
|
|
182
|
+
"top_p",
|
|
183
|
+
"truncation",
|
|
184
|
+
"user",
|
|
185
|
+
"previous_response_id",
|
|
186
|
+
"prompt_cache_options",
|
|
187
|
+
])
|
|
188
|
+
delete body[key];
|
|
189
|
+
body.input =
|
|
190
|
+
typeof body.input === "string"
|
|
191
|
+
? [{ role: "user", content: body.input }]
|
|
192
|
+
: body.input?.map((item) => "role" in item && item.role === "system" ? { ...item, role: "developer" } : item);
|
|
193
|
+
const tools = body.tools
|
|
194
|
+
?.filter((tool) => tool.type === "function" ||
|
|
195
|
+
tool.type === "custom" ||
|
|
196
|
+
tool.type === "namespace" ||
|
|
197
|
+
tool.type === "web_search")
|
|
198
|
+
.map((tool) => tool.type === "namespace"
|
|
199
|
+
? {
|
|
200
|
+
...tool,
|
|
201
|
+
tools: tool.tools.filter((nested) => nested.type === "function" || nested.type === "custom"),
|
|
202
|
+
}
|
|
203
|
+
: tool);
|
|
204
|
+
// SIWC accepts local tools in namespaces or additional_tools, not flat top-level definitions.
|
|
205
|
+
const eagerTools = tools?.filter((tool) => tool.type === "function" || tool.type === "custom") ?? [];
|
|
206
|
+
body.tools = tools?.filter((tool) => tool.type !== "function" && tool.type !== "custom");
|
|
207
|
+
if (eagerTools.length > 0) {
|
|
208
|
+
Reflect.set(body, "input", [
|
|
209
|
+
{ type: "additional_tools", role: "developer", tools: eagerTools },
|
|
210
|
+
...(body.input ?? []),
|
|
211
|
+
]);
|
|
212
|
+
}
|
|
213
|
+
const choice = body.tool_choice;
|
|
214
|
+
if (isRecord(choice)) {
|
|
215
|
+
if (choice.type === "allowed_tools" && Array.isArray(choice.tools)) {
|
|
216
|
+
const tools = choice.tools.filter((tool) => isRecord(tool) && (tool.type === "function" || tool.type === "custom" || tool.type === "web_search"));
|
|
217
|
+
Reflect.set(body, "tool_choice", tools.length > 0
|
|
218
|
+
? {
|
|
219
|
+
...choice,
|
|
220
|
+
tools,
|
|
221
|
+
}
|
|
222
|
+
: "none");
|
|
223
|
+
}
|
|
224
|
+
else if (choice.type !== "function" && choice.type !== "custom" && choice.type !== "web_search") {
|
|
225
|
+
delete body.tool_choice;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
return body;
|
|
229
|
+
}
|
|
163
230
|
/**
|
|
164
231
|
* Generate function for OpenAI Responses API
|
|
165
232
|
*/
|
|
@@ -206,7 +273,9 @@ export const stream = (model, context, options) => {
|
|
|
206
273
|
params = { ...params, tools: limitedTools.tools };
|
|
207
274
|
recordGitHubCopilotToolLimit(output, limitedTools.omittedCount);
|
|
208
275
|
}
|
|
209
|
-
|
|
276
|
+
if (compat.siwc)
|
|
277
|
+
params = shapeSiwcRequest(params);
|
|
278
|
+
const transport = compat.siwc ? "sse" : (options?.transport ?? "sse");
|
|
210
279
|
let refusedAllowedToolsOverWebSocket = false;
|
|
211
280
|
if (transport !== "sse" && compat.supportsWebSocket) {
|
|
212
281
|
let websocketStarted = false;
|
|
@@ -346,6 +415,8 @@ function resolveSimpleOptions(model, context, options) {
|
|
|
346
415
|
* prefix matches the reasoning effort, service tier, and cache options that turn sends.
|
|
347
416
|
*/
|
|
348
417
|
export async function warmOpenAIResponsesPromptCache(model, context, options) {
|
|
418
|
+
if (model.compat?.siwc)
|
|
419
|
+
throw new Error("SIWC requires streaming inference; prompt-cache prewarm is unsupported");
|
|
349
420
|
const prefix = normalizeContext({ ...context, messages: [] });
|
|
350
421
|
const resolved = resolveSimpleOptions(model, prefix, options);
|
|
351
422
|
const clientAuth = resolveOpenAIClientAuth(model.provider, resolved.apiKey, resolved.headers);
|
|
@@ -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
|
-
|
|
8
|
+
import { type OpenAIIdTokenDependencies } from "./openai-id-token.ts";
|
|
9
9
|
declare function revokeSession(credential: OAuthCredential, options?: LogoutOptions): Promise<OAuthRevocationResult>;
|
|
10
|
-
declare function
|
|
11
|
-
|
|
12
|
-
|
|
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:
|
|
18
|
-
|
|
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
|
-
//
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
131
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
186
|
-
|
|
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
|
-
|
|
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
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
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
|