@code-yeongyu/senpi-ai 2026.10.31-2 → 2026.11.2-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-responses-shared.d.ts +2 -0
- package/dist/api/openai-responses-shared.js +19 -3
- package/dist/api/openai-responses.js +30 -9
- package/dist/auth/credential-store.d.ts +3 -1
- package/dist/auth/credential-store.js +78 -0
- package/dist/auth/oauth/kimi-coding.js +6 -4
- package/dist/auth/oauth/openai-chatgpt-model-catalog.d.ts +36 -0
- package/dist/auth/oauth/openai-chatgpt-model-catalog.js +104 -0
- package/dist/auth/oauth/openai-chatgpt.js +76 -0
- package/dist/auth/oauth/radius.js +1 -2
- package/dist/auth/oauth-refresh.d.ts +2 -0
- package/dist/auth/oauth-refresh.js +53 -7
- package/dist/auth/pool/failover.d.ts +0 -40
- package/dist/auth/pool/failover.js +0 -76
- package/dist/auth/pool/slots.js +10 -0
- package/dist/auth/resolve.js +3 -0
- package/dist/auth/types.d.ts +11 -0
- package/dist/models.d.ts +4 -5
- package/dist/models.js +36 -1
- package/dist/provider-diagnostic.d.ts +2 -0
- package/dist/provider-diagnostic.js +6 -1
- package/dist/providers/data/.manifest.json +1 -1
- package/dist/providers/data/opengateway.json +1 -1
- package/dist/providers/data/openrouter.json +1 -1
- package/dist/providers/data/vercel-ai-gateway.json +1 -1
- package/dist/types.d.ts +6 -0
- package/dist/utils/empty-response-errors.d.ts +1 -2
- package/dist/utils/empty-response-errors.js +1 -2
- package/dist/utils/oauth-refresh-error.d.ts +8 -1
- package/dist/utils/oauth-refresh-error.js +3 -1
- package/dist/utils/provider-diagnostic-carrier.d.ts +10 -1
- package/dist/utils/provider-diagnostic-carrier.js +35 -0
- package/dist/utils/provider-diagnostic-sources.d.ts +2 -0
- package/dist/utils/provider-diagnostic-sources.js +5 -0
- package/dist/utils/provider-diagnostic-vocabulary.d.ts +1 -0
- package/dist/utils/provider-diagnostic-vocabulary.js +9 -0
- package/dist/utils/provider-retry.js +4 -1
- package/dist/utils/retry.d.ts +2 -0
- package/dist/utils/retry.js +14 -3
- package/dist/utils/siwc-errors.d.ts +2 -0
- package/dist/utils/siwc-errors.js +2 -0
- package/dist/utils/tool-name-match.js +46 -1
- package/package.json +2 -2
- package/dist/auth/pool/classify.d.ts +0 -16
- package/dist/auth/pool/classify.js +0 -75
|
@@ -2,6 +2,8 @@ import type { Tool as OpenAITool, ResponseCreateParamsStreaming, ResponseInput,
|
|
|
2
2
|
import type { Api, AssistantMessage, Message, Model, StreamOptions, Tool, TranscriptContext, Usage } from "../types.ts";
|
|
3
3
|
import type { AssistantMessageEventStream } from "../utils/event-stream.ts";
|
|
4
4
|
export interface OpenAIResponsesStreamOptions {
|
|
5
|
+
/** SIWC succeeds only on response.completed, never on a finished tool alone. */
|
|
6
|
+
siwc?: boolean;
|
|
5
7
|
onProviderStreamEvent?: StreamOptions["onProviderStreamEvent"];
|
|
6
8
|
serviceTier?: ResponseCreateParamsStreaming["service_tier"] | "fast" | "ultrafast";
|
|
7
9
|
grammarToolInputProperties?: ReadonlyMap<string, string>;
|
|
@@ -1029,12 +1029,22 @@ export async function processResponsesStream(openaiStream, output, stream, model
|
|
|
1029
1029
|
}
|
|
1030
1030
|
else if (event.type === "response.completed" || event.type === "response.incomplete") {
|
|
1031
1031
|
finalizeResponse(event.response);
|
|
1032
|
+
if (options?.siwc && event.type === "response.incomplete") {
|
|
1033
|
+
throw new Error(`Response incomplete: ${event.response.incomplete_details?.reason ?? "unknown reason"}`);
|
|
1034
|
+
}
|
|
1032
1035
|
}
|
|
1033
1036
|
else if (event.type === "error") {
|
|
1034
1037
|
const errorEvent = event;
|
|
1035
1038
|
const code = errorEvent.error?.code ?? errorEvent.code ?? errorEvent.status;
|
|
1036
1039
|
const message = errorEvent.error?.message || errorEvent.message || "Unknown error";
|
|
1037
|
-
|
|
1040
|
+
const failure = new Error(code == null ? message : `Error Code ${code}: ${message}`);
|
|
1041
|
+
throw options?.siwc
|
|
1042
|
+
? Object.assign(failure, {
|
|
1043
|
+
code: typeof code === "string" ? code : undefined,
|
|
1044
|
+
status: errorEvent.status,
|
|
1045
|
+
param: errorEvent.error?.param ?? errorEvent.param,
|
|
1046
|
+
})
|
|
1047
|
+
: failure;
|
|
1038
1048
|
}
|
|
1039
1049
|
else if (event.type === "response.failed") {
|
|
1040
1050
|
sawTerminalResponseEvent = true;
|
|
@@ -1046,11 +1056,17 @@ export async function processResponsesStream(openaiStream, output, stream, model
|
|
|
1046
1056
|
: details?.reason
|
|
1047
1057
|
? `incomplete: ${details.reason}`
|
|
1048
1058
|
: "Unknown error (no error details in response)";
|
|
1049
|
-
|
|
1059
|
+
const failure = new Error(msg);
|
|
1060
|
+
throw options?.siwc
|
|
1061
|
+
? Object.assign(failure, {
|
|
1062
|
+
code: error?.code,
|
|
1063
|
+
param: error && "param" in error ? error.param : undefined,
|
|
1064
|
+
})
|
|
1065
|
+
: failure;
|
|
1050
1066
|
}
|
|
1051
1067
|
}
|
|
1052
1068
|
const hasFinalizedToolCall = output.content.some((block) => block.type === "toolCall" && !("partialJson" in block));
|
|
1053
|
-
if (!sawTerminalResponseEvent && !hasFinalizedToolCall) {
|
|
1069
|
+
if (!sawTerminalResponseEvent && (options?.siwc || !hasFinalizedToolCall)) {
|
|
1054
1070
|
throw new Error("OpenAI Responses stream ended before a terminal response event");
|
|
1055
1071
|
}
|
|
1056
1072
|
// The agent runs every tool call in the final message. Refuse to hand over calls whose
|
|
@@ -7,7 +7,7 @@ import { AssistantMessageEventStream } from "../utils/event-stream.js";
|
|
|
7
7
|
import { formatGitHubCopilotToolLimitError, limitGitHubCopilotTools, recordGitHubCopilotToolLimit, } from "../utils/github-copilot-tool-limit.js";
|
|
8
8
|
import { headersToRecord } from "../utils/headers.js";
|
|
9
9
|
import { getPiUserAgent } from "../utils/pi-user-agent.js";
|
|
10
|
-
import { awaitProviderTransport, openAICompatibleProviderDiagnosticFromError, } from "../utils/provider-diagnostic-sources.js";
|
|
10
|
+
import { awaitProviderTransport, openAICompatibleProviderDiagnosticFromError, siwcProviderDiagnosticFromError, } from "../utils/provider-diagnostic-sources.js";
|
|
11
11
|
import { getProviderEnvValue } from "../utils/provider-env.js";
|
|
12
12
|
import { retryProviderRequest } from "../utils/provider-retry.js";
|
|
13
13
|
import { sendWithForcedToolChoiceFallback } from "../utils/tool-choice-fallback.js";
|
|
@@ -56,6 +56,7 @@ function resolveCacheRetention(cacheRetention, env) {
|
|
|
56
56
|
function getCompat(model, env) {
|
|
57
57
|
const isNativeEndpoint = isOpenAIResponsesNativeEndpoint(model, env);
|
|
58
58
|
return {
|
|
59
|
+
siwc: model.compat?.siwc ?? false,
|
|
59
60
|
supportsDeveloperRole: model.compat?.supportsDeveloperRole ?? true,
|
|
60
61
|
supportsMidConvoSystemMessages: model.compat?.supportsMidConvoSystemMessages ?? false,
|
|
61
62
|
sessionAffinityFormat: model.compat?.sessionAffinityFormat ?? detectSessionAffinityFormat(model),
|
|
@@ -147,8 +148,13 @@ function sanitizeUnsupportedNativeTools(params, compat) {
|
|
|
147
148
|
}
|
|
148
149
|
return sanitized ? sanitized : params;
|
|
149
150
|
}
|
|
150
|
-
function formatOpenAIResponsesError(error, provider) {
|
|
151
|
-
|
|
151
|
+
function formatOpenAIResponsesError(error, provider, siwc = false) {
|
|
152
|
+
let errorMessage = formatProviderError(normalizeProviderError(error), `${provider === "openai" ? "OpenAI" : provider} API error`);
|
|
153
|
+
if (siwc && error instanceof Error && Reflect.get(error, "code") === "subscription_sharing_unsupported_capability") {
|
|
154
|
+
const param = Reflect.get(error, "param");
|
|
155
|
+
if (typeof param === "string")
|
|
156
|
+
errorMessage += `\nUnsupported parameter: ${param}`;
|
|
157
|
+
}
|
|
152
158
|
// Sign in with ChatGPT shares the subscription's usage limit with other apps.
|
|
153
159
|
return errorMessage.includes("subscription_sharing_usage_limit_exceeded")
|
|
154
160
|
? `${errorMessage}\nCheck your ChatGPT usage: ${CHATGPT_USAGE_URL}`
|
|
@@ -239,8 +245,8 @@ export const stream = (model, context, options) => {
|
|
|
239
245
|
params: body,
|
|
240
246
|
acceptsForcedToolChoice: compat.supportsForcedToolChoice,
|
|
241
247
|
isForced: isForcedOpenAIResponsesToolChoice,
|
|
242
|
-
send: (request) => model.provider === "github-copilot"
|
|
243
|
-
? awaitProviderTransport(() => client.responses.create(request, requestOptions).withResponse(), openAICompatibleProviderDiagnosticFromError)
|
|
248
|
+
send: (request) => model.provider === "github-copilot" || compat.siwc
|
|
249
|
+
? awaitProviderTransport(() => client.responses.create(request, requestOptions).withResponse(), compat.siwc ? siwcProviderDiagnosticFromError : openAICompatibleProviderDiagnosticFromError)
|
|
244
250
|
: client.responses.create(request, requestOptions).withResponse(),
|
|
245
251
|
});
|
|
246
252
|
params = sent.params;
|
|
@@ -274,6 +280,7 @@ export const stream = (model, context, options) => {
|
|
|
274
280
|
if (!refusedAllowedToolsOverWebSocket)
|
|
275
281
|
stream.push({ type: "start", partial: output });
|
|
276
282
|
await processResponsesStream(openaiStream, output, stream, model, {
|
|
283
|
+
siwc: compat.siwc,
|
|
277
284
|
onProviderStreamEvent: options?.onProviderStreamEvent,
|
|
278
285
|
serviceTier: options?.serviceTier,
|
|
279
286
|
grammarToolInputProperties,
|
|
@@ -299,12 +306,15 @@ export const stream = (model, context, options) => {
|
|
|
299
306
|
delete block.customInput;
|
|
300
307
|
}
|
|
301
308
|
output.stopReason = options?.signal?.aborted ? "aborted" : "error";
|
|
302
|
-
const providerDiagnostic = model.provider === "github-copilot" && output.stopReason === "error"
|
|
303
|
-
? (readProviderDiagnostic(error) ??
|
|
309
|
+
const providerDiagnostic = (model.provider === "github-copilot" || model.compat?.siwc) && output.stopReason === "error"
|
|
310
|
+
? (readProviderDiagnostic(error) ??
|
|
311
|
+
(model.compat?.siwc
|
|
312
|
+
? siwcProviderDiagnosticFromError(error)
|
|
313
|
+
: openAICompatibleProviderDiagnosticFromError(error)))
|
|
304
314
|
: undefined;
|
|
305
315
|
if (providerDiagnostic !== undefined)
|
|
306
316
|
output.providerDiagnostic = providerDiagnostic;
|
|
307
|
-
output.errorMessage = withGitHubCopilotFailureNote(formatGitHubCopilotToolLimitError(output, formatOpenAIResponsesError(error, model.provider)), model.provider, error);
|
|
317
|
+
output.errorMessage = withGitHubCopilotFailureNote(formatGitHubCopilotToolLimitError(output, formatOpenAIResponsesError(error, model.provider, model.compat?.siwc)), model.provider, error);
|
|
308
318
|
stream.push({ type: "error", reason: output.stopReason, error: output });
|
|
309
319
|
stream.end();
|
|
310
320
|
}
|
|
@@ -377,6 +387,15 @@ export async function warmOpenAIResponsesPromptCache(model, context, options) {
|
|
|
377
387
|
applyServiceTierPricing(usage, response.service_tier ?? resolved.serviceTier, model);
|
|
378
388
|
return { usage, usageRaw };
|
|
379
389
|
}
|
|
390
|
+
/** The SDK discards nonstandard admission bodies when it selects errorResponse.error. */
|
|
391
|
+
class SiwcOpenAI extends OpenAI {
|
|
392
|
+
makeStatusError(status, body, message, headers) {
|
|
393
|
+
if (isRecord(body) && Object.hasOwn(body, "detail") && !Object.hasOwn(body, "error")) {
|
|
394
|
+
return new OpenAI.APIError(status, body, message, headers);
|
|
395
|
+
}
|
|
396
|
+
return super.makeStatusError(status, body, message, headers);
|
|
397
|
+
}
|
|
398
|
+
}
|
|
380
399
|
function createClient(model, context, apiKey, optionsHeaders, fetch, sessionId, env) {
|
|
381
400
|
const compat = getCompat(model, env);
|
|
382
401
|
const headers = { "User-Agent": getPiUserAgent(), ...model.headers };
|
|
@@ -403,7 +422,8 @@ function createClient(model, context, apiKey, optionsHeaders, fetch, sessionId,
|
|
|
403
422
|
if (optionsHeaders) {
|
|
404
423
|
Object.assign(headers, optionsHeaders);
|
|
405
424
|
}
|
|
406
|
-
|
|
425
|
+
const Client = compat.siwc ? SiwcOpenAI : OpenAI;
|
|
426
|
+
return new Client({
|
|
407
427
|
apiKey,
|
|
408
428
|
baseURL: isCloudflareProvider(model.provider) ? resolveCloudflareBaseUrl(model, env) : model.baseUrl,
|
|
409
429
|
dangerouslyAllowBrowser: true,
|
|
@@ -779,6 +799,7 @@ async function processWebSocketStream(url, params, headers, output, stream, mode
|
|
|
779
799
|
await options?.onResponse?.({ status: 101, headers: {} }, model);
|
|
780
800
|
stream.push({ type: "start", partial: output });
|
|
781
801
|
await processResponsesStream(parseWebSocket(socket, options?.signal), output, stream, model, {
|
|
802
|
+
siwc: model.compat?.siwc,
|
|
782
803
|
serviceTier: options?.serviceTier,
|
|
783
804
|
grammarToolInputProperties,
|
|
784
805
|
applyServiceTierPricing: (usage, serviceTier) => applyServiceTierPricing(usage, serviceTier, model),
|
|
@@ -1,4 +1,6 @@
|
|
|
1
|
-
import type { AuthOperationOptions, Credential, CredentialInfo, CredentialStore } from "./types.ts";
|
|
1
|
+
import type { AuthOperationOptions, Credential, CredentialInfo, CredentialStore, LogoutOptions, OAuthAuth, OAuthRevocationResult } from "./types.ts";
|
|
2
|
+
/** Capture and clear atomically before network work, so later logins are not cleared. */
|
|
3
|
+
export declare function revokeStoredOAuth(credentials: CredentialStore, providerId: string, oauth: OAuthAuth, options?: LogoutOptions): Promise<OAuthRevocationResult | undefined>;
|
|
2
4
|
/**
|
|
3
5
|
* Default in-memory credential store. Apps inject persistent stores.
|
|
4
6
|
* Keyed by `Provider.id`, one entry per provider (possibly a pooled one);
|
|
@@ -1,4 +1,82 @@
|
|
|
1
1
|
import { operationSignal, raceWithAbortSignal } from "../utils/abort.js";
|
|
2
|
+
import { captureOAuthRefreshes } from "./oauth-refresh.js";
|
|
3
|
+
import { listSlots, projectSlot } from "./pool/slots.js";
|
|
4
|
+
/** Clear session secrets without discarding the registration's non-secret metadata. */
|
|
5
|
+
function clearTokens(credential) {
|
|
6
|
+
const cleared = { ...credential, access: "", refresh: "", expires: 0, signedOut: true };
|
|
7
|
+
Reflect.deleteProperty(cleared, "idToken");
|
|
8
|
+
return cleared;
|
|
9
|
+
}
|
|
10
|
+
/** Capture and clear atomically before network work, so later logins are not cleared. */
|
|
11
|
+
export async function revokeStoredOAuth(credentials, providerId, oauth, options) {
|
|
12
|
+
const revoke = oauth.revoke;
|
|
13
|
+
if (!revoke)
|
|
14
|
+
return undefined;
|
|
15
|
+
let handled = false;
|
|
16
|
+
const targets = [];
|
|
17
|
+
let refreshes = Promise.resolve(true);
|
|
18
|
+
const settleRefreshes = captureOAuthRefreshes(credentials, providerId);
|
|
19
|
+
// Deliberately not cancellable: even an interrupted remote revoke must clear locally.
|
|
20
|
+
// Slot selection and its token snapshot share the same lock as refresh writes.
|
|
21
|
+
await credentials
|
|
22
|
+
.modify(providerId, async (current) => {
|
|
23
|
+
if (current?.type !== "oauth")
|
|
24
|
+
return undefined;
|
|
25
|
+
handled = true;
|
|
26
|
+
const slots = listSlots(current).filter((slot) => options?.slotId === undefined || slot.name === options.slotId);
|
|
27
|
+
if (!slots.length)
|
|
28
|
+
return undefined;
|
|
29
|
+
for (const slot of slots) {
|
|
30
|
+
const projected = projectSlot(current, slot.name);
|
|
31
|
+
if (projected?.type === "oauth")
|
|
32
|
+
targets.push({ ...projected, ...slot });
|
|
33
|
+
}
|
|
34
|
+
const names = new Set(slots.map((slot) => slot.name));
|
|
35
|
+
const accounts = Array.isArray(current.accounts)
|
|
36
|
+
? current.accounts.map((slot) => (names.has(slot.name) ? clearTokens(slot) : slot))
|
|
37
|
+
: undefined;
|
|
38
|
+
let next = options?.slotId === undefined ||
|
|
39
|
+
slots.some((slot) => slot.refresh === current.refresh && slot.access === current.access)
|
|
40
|
+
? clearTokens(current)
|
|
41
|
+
: { ...current };
|
|
42
|
+
if (accounts) {
|
|
43
|
+
next = { ...next, accounts };
|
|
44
|
+
// Keep the legacy flat projection on a surviving signed-in sibling, if one exists.
|
|
45
|
+
const active = accounts.find((slot) => slot.access && slot.refresh);
|
|
46
|
+
if (next.signedOut === true && active) {
|
|
47
|
+
next = { ...next, ...active, signedOut: false };
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return next;
|
|
51
|
+
})
|
|
52
|
+
.finally(() => {
|
|
53
|
+
// Include exchanges admitted until the store publishes the cleared slot.
|
|
54
|
+
// Later exchanges re-read the clear; earlier ones lose their CAS.
|
|
55
|
+
refreshes = settleRefreshes();
|
|
56
|
+
});
|
|
57
|
+
if (!handled)
|
|
58
|
+
return undefined;
|
|
59
|
+
let result = {
|
|
60
|
+
status: targets.length ? "revoked" : "remote_revocation_unconfirmed",
|
|
61
|
+
};
|
|
62
|
+
for (const target of targets) {
|
|
63
|
+
if (!target.refresh) {
|
|
64
|
+
result = { status: "remote_revocation_unconfirmed" };
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
try {
|
|
68
|
+
const revoked = await revoke(target, options);
|
|
69
|
+
if (revoked.status !== "revoked")
|
|
70
|
+
result = revoked;
|
|
71
|
+
}
|
|
72
|
+
catch {
|
|
73
|
+
result = { status: "remote_revocation_unconfirmed" };
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
if (!(await refreshes))
|
|
77
|
+
result = { status: "remote_revocation_unconfirmed" };
|
|
78
|
+
return result;
|
|
79
|
+
}
|
|
2
80
|
/**
|
|
3
81
|
* Default in-memory credential store. Apps inject persistent stores.
|
|
4
82
|
* Keyed by `Provider.id`, one entry per provider (possibly a pooled one);
|
|
@@ -206,17 +206,19 @@ async function refreshToken(oauthHost, refreshTokenValue, signal) {
|
|
|
206
206
|
if (response.ok) {
|
|
207
207
|
return parseTokenResponse(json, "refresh");
|
|
208
208
|
}
|
|
209
|
-
|
|
210
|
-
|
|
209
|
+
const oauthError = typeof json?.error === "string" ? json.error : undefined;
|
|
210
|
+
// Unauthorized: the stored credential is dead; Models clears it and prompts re-login. The decision
|
|
211
|
+
// and the OAuth code travel on the error, so the credential pool never has to read this prose.
|
|
212
|
+
if (response.status === 401 || response.status === 403 || oauthError === "invalid_grant") {
|
|
211
213
|
const description = typeof json?.error_description === "string" ? `: ${json.error_description}` : "";
|
|
212
|
-
throw new OAuthTokenEndpointError(`Kimi Code token refresh unauthorized (status ${response.status})${description}`, response.status);
|
|
214
|
+
throw new OAuthTokenEndpointError(`Kimi Code token refresh unauthorized (status ${response.status})${description}`, response.status, { oauthError, credentialRefused: true });
|
|
213
215
|
}
|
|
214
216
|
if (isRetryableRefreshFailure(response) && attempt < REFRESH_MAX_RETRIES) {
|
|
215
217
|
lastError = new OAuthTokenEndpointError(`Kimi Code token refresh failed with status ${response.status}`, response.status);
|
|
216
218
|
continue;
|
|
217
219
|
}
|
|
218
220
|
const text = JSON.stringify(json);
|
|
219
|
-
throw new OAuthTokenEndpointError(`Kimi Code token refresh failed with status ${response.status}${text ? `: ${text}` : ""}`, response.status);
|
|
221
|
+
throw new OAuthTokenEndpointError(`Kimi Code token refresh failed with status ${response.status}${text ? `: ${text}` : ""}`, response.status, { oauthError });
|
|
220
222
|
}
|
|
221
223
|
throw lastError ?? new Error("Kimi Code token refresh failed");
|
|
222
224
|
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { Model } from "../../types.ts";
|
|
2
|
+
import { ModelsError } from "../../utils/models-error.ts";
|
|
3
|
+
/** Verified identity, not email, slot label, or a rotating bearer token. */
|
|
4
|
+
export interface OpenAIChatGptModelIdentity {
|
|
5
|
+
sub: string;
|
|
6
|
+
clientId: string;
|
|
7
|
+
}
|
|
8
|
+
export interface OpenAIChatGptModelAccount extends OpenAIChatGptModelIdentity {
|
|
9
|
+
/** Supply the access token after the runtime's shared OAuth refresh has completed. */
|
|
10
|
+
access: string;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Picker entries are not full inference Models: /v1/models supplies no capability
|
|
14
|
+
* metadata for unknown slugs. W11's slot adapter must preserve that distinction.
|
|
15
|
+
*/
|
|
16
|
+
export type OpenAIChatGptCatalogModel = Pick<Model<"openai-responses">, "id" | "name"> & Partial<Pick<Model<"openai-responses">, "contextWindow" | "maxTokens" | "reasoning" | "thinkingLevelMap" | "input" | "cost">>;
|
|
17
|
+
export declare class OpenAIChatGptModelCatalogError extends ModelsError {
|
|
18
|
+
readonly status?: number;
|
|
19
|
+
constructor(message: string, status?: number);
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Last-known, account-scoped catalogs with transactional refresh, like provider
|
|
23
|
+
* refreshModels. This owns neither OAuth refresh nor provider registration.
|
|
24
|
+
* Keep one instance per provider runtime; activate the selected slot on account
|
|
25
|
+
* changes, or call refresh after explicit model refresh/token renewal.
|
|
26
|
+
*/
|
|
27
|
+
export declare class OpenAIChatGptModelCatalog {
|
|
28
|
+
private readonly catalogs;
|
|
29
|
+
private readonly generations;
|
|
30
|
+
private activeKey?;
|
|
31
|
+
private activeNeedsRefresh;
|
|
32
|
+
getModels(identity?: OpenAIChatGptModelIdentity): readonly OpenAIChatGptCatalogModel[];
|
|
33
|
+
activate(account: OpenAIChatGptModelAccount, signal: AbortSignal): Promise<readonly OpenAIChatGptCatalogModel[]>;
|
|
34
|
+
refresh(account: OpenAIChatGptModelAccount, signal: AbortSignal): Promise<readonly OpenAIChatGptCatalogModel[]>;
|
|
35
|
+
}
|
|
36
|
+
//# sourceMappingURL=openai-chatgpt-model-catalog.d.ts.map
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { CHATGPT_SUBSCRIPTION_MODELS } from "../../providers/chatgpt-subscription.models.js";
|
|
2
|
+
import { ModelsError } from "../../utils/models-error.js";
|
|
3
|
+
export class OpenAIChatGptModelCatalogError extends ModelsError {
|
|
4
|
+
constructor(message, status) {
|
|
5
|
+
super("model_source", message);
|
|
6
|
+
this.name = "OpenAIChatGptModelCatalogError";
|
|
7
|
+
this.status = status;
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
function identityKey(identity) {
|
|
11
|
+
return JSON.stringify([identity.sub, identity.clientId]);
|
|
12
|
+
}
|
|
13
|
+
function parseCatalog(raw) {
|
|
14
|
+
const models = raw && typeof raw === "object" && "models" in raw ? raw.models : undefined;
|
|
15
|
+
if (!Array.isArray(models)) {
|
|
16
|
+
throw new OpenAIChatGptModelCatalogError("Invalid SIWC models response");
|
|
17
|
+
}
|
|
18
|
+
return models.flatMap((item) => {
|
|
19
|
+
if (!item || typeof item !== "object" || !("visibility" in item) || item.visibility !== "list")
|
|
20
|
+
return [];
|
|
21
|
+
if (!("slug" in item) ||
|
|
22
|
+
typeof item.slug !== "string" ||
|
|
23
|
+
!item.slug ||
|
|
24
|
+
!("display_name" in item) ||
|
|
25
|
+
typeof item.display_name !== "string" ||
|
|
26
|
+
!item.display_name) {
|
|
27
|
+
throw new OpenAIChatGptModelCatalogError("Invalid listed SIWC model");
|
|
28
|
+
}
|
|
29
|
+
const known = Object.values(CHATGPT_SUBSCRIPTION_MODELS).find((model) => model.id === item.slug);
|
|
30
|
+
return [
|
|
31
|
+
{
|
|
32
|
+
id: item.slug,
|
|
33
|
+
name: item.display_name,
|
|
34
|
+
...(known
|
|
35
|
+
? {
|
|
36
|
+
contextWindow: known.contextWindow,
|
|
37
|
+
maxTokens: known.maxTokens,
|
|
38
|
+
reasoning: known.reasoning,
|
|
39
|
+
...(known.thinkingLevelMap ? { thinkingLevelMap: known.thinkingLevelMap } : {}),
|
|
40
|
+
input: known.input,
|
|
41
|
+
cost: known.cost,
|
|
42
|
+
}
|
|
43
|
+
: {}),
|
|
44
|
+
},
|
|
45
|
+
];
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Last-known, account-scoped catalogs with transactional refresh, like provider
|
|
50
|
+
* refreshModels. This owns neither OAuth refresh nor provider registration.
|
|
51
|
+
* Keep one instance per provider runtime; activate the selected slot on account
|
|
52
|
+
* changes, or call refresh after explicit model refresh/token renewal.
|
|
53
|
+
*/
|
|
54
|
+
export class OpenAIChatGptModelCatalog {
|
|
55
|
+
constructor() {
|
|
56
|
+
this.catalogs = new Map();
|
|
57
|
+
this.generations = new Map();
|
|
58
|
+
this.activeNeedsRefresh = true;
|
|
59
|
+
}
|
|
60
|
+
getModels(identity) {
|
|
61
|
+
const key = identity ? identityKey(identity) : this.activeKey;
|
|
62
|
+
return key === undefined ? [] : (this.catalogs.get(key) ?? []);
|
|
63
|
+
}
|
|
64
|
+
async activate(account, signal) {
|
|
65
|
+
const key = identityKey(account);
|
|
66
|
+
if (key !== this.activeKey) {
|
|
67
|
+
this.activeKey = key;
|
|
68
|
+
this.activeNeedsRefresh = true;
|
|
69
|
+
}
|
|
70
|
+
if (!this.activeNeedsRefresh)
|
|
71
|
+
return this.getModels(account);
|
|
72
|
+
const models = await this.refresh(account, signal);
|
|
73
|
+
if (this.activeKey === key)
|
|
74
|
+
this.activeNeedsRefresh = false;
|
|
75
|
+
return models;
|
|
76
|
+
}
|
|
77
|
+
async refresh(account, signal) {
|
|
78
|
+
signal.throwIfAborted();
|
|
79
|
+
const key = identityKey(account);
|
|
80
|
+
const generation = (this.generations.get(key) ?? 0) + 1;
|
|
81
|
+
this.generations.set(key, generation);
|
|
82
|
+
const response = await fetch("https://api.openai.com/v1/models", {
|
|
83
|
+
method: "GET",
|
|
84
|
+
headers: { Authorization: `Bearer ${account.access}` },
|
|
85
|
+
signal,
|
|
86
|
+
});
|
|
87
|
+
if (!response.ok) {
|
|
88
|
+
throw new OpenAIChatGptModelCatalogError(`SIWC model catalog request failed (HTTP ${response.status})`, response.status);
|
|
89
|
+
}
|
|
90
|
+
let raw;
|
|
91
|
+
try {
|
|
92
|
+
raw = await response.json();
|
|
93
|
+
}
|
|
94
|
+
catch {
|
|
95
|
+
throw new OpenAIChatGptModelCatalogError("Invalid SIWC models response");
|
|
96
|
+
}
|
|
97
|
+
const models = parseCatalog(raw);
|
|
98
|
+
signal.throwIfAborted();
|
|
99
|
+
if (this.generations.get(key) === generation)
|
|
100
|
+
this.catalogs.set(key, models);
|
|
101
|
+
return models;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
//# sourceMappingURL=openai-chatgpt-model-catalog.js.map
|
|
@@ -16,6 +16,7 @@ const AGENT_NAME_HINT = "Pi";
|
|
|
16
16
|
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[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
|
+
const DISCOVERY_URL = "https://auth.openai.com/.well-known/openid-configuration";
|
|
19
20
|
const RESOURCE = "https://api.openai.com/v1";
|
|
20
21
|
const CALLBACK_HOST = getProviderEnvValue("PI_OAUTH_CALLBACK_HOST") || "127.0.0.1";
|
|
21
22
|
const CALLBACK_PORT = 1455;
|
|
@@ -183,6 +184,75 @@ async function refreshAccessToken(credential, signal) {
|
|
|
183
184
|
}), signal);
|
|
184
185
|
return credentialFromTokenResponse(token, clientId);
|
|
185
186
|
}
|
|
187
|
+
async function revokeSession(credential, options) {
|
|
188
|
+
const unconfirmed = { status: "remote_revocation_unconfirmed" };
|
|
189
|
+
if (!credential.refresh || typeof credential.clientId !== "string" || !credential.clientId.trim()) {
|
|
190
|
+
return unconfirmed;
|
|
191
|
+
}
|
|
192
|
+
const signal = AbortSignal.any([...(options?.signal ? [options.signal] : []), AbortSignal.timeout(15_000)]);
|
|
193
|
+
const sleep = options?.sleep ??
|
|
194
|
+
((milliseconds) => new Promise((resolve, reject) => {
|
|
195
|
+
signal.throwIfAborted();
|
|
196
|
+
const onAbort = () => {
|
|
197
|
+
clearTimeout(timer);
|
|
198
|
+
reject(signal.reason);
|
|
199
|
+
};
|
|
200
|
+
const timer = setTimeout(() => {
|
|
201
|
+
signal.removeEventListener("abort", onAbort);
|
|
202
|
+
resolve();
|
|
203
|
+
}, milliseconds);
|
|
204
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
205
|
+
}));
|
|
206
|
+
let endpoint;
|
|
207
|
+
const delays = [250, 1000];
|
|
208
|
+
for (let attempt = 0; attempt <= delays.length; attempt++) {
|
|
209
|
+
try {
|
|
210
|
+
signal.throwIfAborted();
|
|
211
|
+
if (attempt > 0)
|
|
212
|
+
await sleep(delays[attempt - 1]);
|
|
213
|
+
if (!endpoint) {
|
|
214
|
+
const discovery = await fetch(DISCOVERY_URL, { signal, redirect: "error" });
|
|
215
|
+
if (!discovery.ok) {
|
|
216
|
+
if (discovery.status >= 500)
|
|
217
|
+
continue;
|
|
218
|
+
return unconfirmed;
|
|
219
|
+
}
|
|
220
|
+
const metadata = await discovery.json();
|
|
221
|
+
if (typeof metadata !== "object" ||
|
|
222
|
+
metadata === null ||
|
|
223
|
+
!("revocation_endpoint" in metadata) ||
|
|
224
|
+
typeof metadata.revocation_endpoint !== "string") {
|
|
225
|
+
return unconfirmed;
|
|
226
|
+
}
|
|
227
|
+
const url = new URL(metadata.revocation_endpoint);
|
|
228
|
+
if (url.protocol !== "https:" || url.username || url.password)
|
|
229
|
+
return unconfirmed;
|
|
230
|
+
endpoint = url.href;
|
|
231
|
+
}
|
|
232
|
+
const response = await fetch(endpoint, {
|
|
233
|
+
method: "POST",
|
|
234
|
+
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
235
|
+
body: new URLSearchParams({
|
|
236
|
+
token: credential.refresh,
|
|
237
|
+
token_type_hint: "refresh_token",
|
|
238
|
+
client_id: credential.clientId,
|
|
239
|
+
}),
|
|
240
|
+
signal,
|
|
241
|
+
redirect: "error",
|
|
242
|
+
});
|
|
243
|
+
// RFC 7009 returns an empty 200 for both revoked and already-invalid tokens.
|
|
244
|
+
if (response.status === 200)
|
|
245
|
+
return { status: "revoked" };
|
|
246
|
+
if (response.status < 500)
|
|
247
|
+
return unconfirmed;
|
|
248
|
+
}
|
|
249
|
+
catch {
|
|
250
|
+
if (signal.aborted)
|
|
251
|
+
return unconfirmed;
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
return unconfirmed;
|
|
255
|
+
}
|
|
186
256
|
/** OpenAI identifies each installation ("agent host") by a stable URI such as `urn:uuid:<uuid>`. */
|
|
187
257
|
function agentHostId(deviceId) {
|
|
188
258
|
if (!deviceId || !UUID_PATTERN.test(deviceId)) {
|
|
@@ -260,6 +330,12 @@ export const openaiChatGPTOAuth = {
|
|
|
260
330
|
loginLabel: "Sign in with ChatGPT",
|
|
261
331
|
login: loginOpenAIChatGPT,
|
|
262
332
|
refresh: refreshAccessToken,
|
|
333
|
+
revoke: revokeSession,
|
|
334
|
+
async check({ credential }) {
|
|
335
|
+
if (!credential?.access || !credential.refresh || credential.signedOut === true)
|
|
336
|
+
return undefined;
|
|
337
|
+
return { type: "oauth", source: "OAuth" };
|
|
338
|
+
},
|
|
263
339
|
async toAuth(credential) {
|
|
264
340
|
return { apiKey: credential.access };
|
|
265
341
|
},
|
|
@@ -44,8 +44,7 @@ class OAuthResponseError extends OAuthTokenEndpointError {
|
|
|
44
44
|
? `${oauthError}: ${description}`
|
|
45
45
|
: oauthError
|
|
46
46
|
: description || String(status);
|
|
47
|
-
super(`${message}: ${detail}`, status);
|
|
48
|
-
this.oauthError = oauthError;
|
|
47
|
+
super(`${message}: ${detail}`, status, { oauthError });
|
|
49
48
|
}
|
|
50
49
|
}
|
|
51
50
|
async function readOAuthResponseError(response, message) {
|
|
@@ -37,6 +37,8 @@ export declare class OAuthRefreshExchangeError extends Error {
|
|
|
37
37
|
export declare class OAuthRefreshStoreError extends Error {
|
|
38
38
|
constructor(cause: unknown);
|
|
39
39
|
}
|
|
40
|
+
/** Collect exchanges through the clear's publication, then wait for discarded-token revocation too. */
|
|
41
|
+
export declare function captureOAuthRefreshes(credentials: CredentialStore, providerId: string): () => Promise<boolean>;
|
|
40
42
|
export declare function projectOAuthSlot(credential: OAuthCredential, name: string): OAuthCredential | undefined;
|
|
41
43
|
/**
|
|
42
44
|
* Resolves with the post-refresh stored credential (the whole provider entry),
|
|
@@ -33,6 +33,31 @@ export class OAuthRefreshStoreError extends Error {
|
|
|
33
33
|
}
|
|
34
34
|
}
|
|
35
35
|
const inflightByStore = new WeakMap();
|
|
36
|
+
const refreshObserversByStore = new WeakMap();
|
|
37
|
+
/** Collect exchanges through the clear's publication, then wait for discarded-token revocation too. */
|
|
38
|
+
export function captureOAuthRefreshes(credentials, providerId) {
|
|
39
|
+
const pending = [];
|
|
40
|
+
let observers = refreshObserversByStore.get(credentials);
|
|
41
|
+
if (!observers) {
|
|
42
|
+
observers = new Set();
|
|
43
|
+
refreshObserversByStore.set(credentials, observers);
|
|
44
|
+
}
|
|
45
|
+
const observe = (id, promise) => {
|
|
46
|
+
if (id === providerId)
|
|
47
|
+
pending.push(promise.then(() => true, () => false));
|
|
48
|
+
};
|
|
49
|
+
observers.add(observe);
|
|
50
|
+
const prefix = `${providerId}\u0000`;
|
|
51
|
+
for (const [key, refresh] of inflightByStore.get(credentials) ?? []) {
|
|
52
|
+
if (key.startsWith(prefix))
|
|
53
|
+
observe(providerId, refresh.promise);
|
|
54
|
+
}
|
|
55
|
+
return async () => {
|
|
56
|
+
observers.delete(observe);
|
|
57
|
+
const results = await Promise.all(pending);
|
|
58
|
+
return results.every(Boolean);
|
|
59
|
+
};
|
|
60
|
+
}
|
|
36
61
|
export function projectOAuthSlot(credential, name) {
|
|
37
62
|
const projected = projectSlot(credential, name);
|
|
38
63
|
return projected?.type === "oauth" ? projected : undefined;
|
|
@@ -58,6 +83,9 @@ export function refreshOAuthCredential(request) {
|
|
|
58
83
|
registry.delete(key);
|
|
59
84
|
});
|
|
60
85
|
registry.set(key, created);
|
|
86
|
+
for (const observe of refreshObserversByStore.get(request.credentials) ?? []) {
|
|
87
|
+
observe(request.providerId, created.promise);
|
|
88
|
+
}
|
|
61
89
|
inflight = created;
|
|
62
90
|
}
|
|
63
91
|
join(inflight, request.signal, request.owning);
|
|
@@ -82,7 +110,10 @@ async function exchangeAndStore(request, signal) {
|
|
|
82
110
|
const view = (credential) => {
|
|
83
111
|
if (credential?.type !== "oauth")
|
|
84
112
|
return undefined;
|
|
85
|
-
|
|
113
|
+
const projected = slotName === undefined ? credential : projectOAuthSlot(credential, slotName);
|
|
114
|
+
if (projected?.signedOut === true || (!projected?.access && !projected?.refresh))
|
|
115
|
+
return undefined;
|
|
116
|
+
return projected;
|
|
86
117
|
};
|
|
87
118
|
let latest;
|
|
88
119
|
try {
|
|
@@ -101,7 +132,8 @@ async function exchangeAndStore(request, signal) {
|
|
|
101
132
|
const exchangeSignal = AbortSignal.any([signal, AbortSignal.timeout(DEFAULT_OAUTH_REFRESH_TIMEOUT_MS)]);
|
|
102
133
|
refreshed = await oauth.refresh(current, exchangeSignal);
|
|
103
134
|
// A result that arrives after every owning waiter left is not persisted.
|
|
104
|
-
|
|
135
|
+
if (!oauth.revoke)
|
|
136
|
+
signal.throwIfAborted();
|
|
105
137
|
}
|
|
106
138
|
catch (error) {
|
|
107
139
|
signal.throwIfAborted();
|
|
@@ -115,15 +147,20 @@ async function exchangeAndStore(request, signal) {
|
|
|
115
147
|
}
|
|
116
148
|
throw new OAuthRefreshExchangeError(error);
|
|
117
149
|
}
|
|
150
|
+
let discarded = false;
|
|
151
|
+
let post;
|
|
118
152
|
try {
|
|
119
153
|
// The write is not cancellable: the exchange consumed `current.refresh`, so
|
|
120
154
|
// dropping the rotated token here would strand the account.
|
|
121
|
-
|
|
122
|
-
if (stored?.type !== "oauth")
|
|
123
|
-
return undefined; // logged out meanwhile
|
|
155
|
+
post = await credentials.modify(providerId, async (stored) => {
|
|
124
156
|
const slot = view(stored);
|
|
125
|
-
if (
|
|
126
|
-
|
|
157
|
+
if (stored?.type !== "oauth" ||
|
|
158
|
+
!slot ||
|
|
159
|
+
slot.refresh !== current.refresh ||
|
|
160
|
+
(oauth.revoke && signal.aborted)) {
|
|
161
|
+
discarded = true;
|
|
162
|
+
return undefined; // logged out, rotated, or cancelled meanwhile
|
|
163
|
+
}
|
|
127
164
|
return slotName === undefined
|
|
128
165
|
? mergeRefreshed(stored, refreshed)
|
|
129
166
|
: mergeRefreshedSlot(stored, slotName, refreshed);
|
|
@@ -132,5 +169,14 @@ async function exchangeAndStore(request, signal) {
|
|
|
132
169
|
catch (error) {
|
|
133
170
|
throw new OAuthRefreshStoreError(error);
|
|
134
171
|
}
|
|
172
|
+
// A refresh that lost ownership must not leave a renewable remote session.
|
|
173
|
+
// Do not revoke material another writer already stored as the current token.
|
|
174
|
+
if (discarded && oauth.revoke && refreshed.type === "oauth" && view(post)?.refresh !== refreshed.refresh) {
|
|
175
|
+
const result = await oauth.revoke({ ...current, ...refreshed });
|
|
176
|
+
if (result.status !== "revoked") {
|
|
177
|
+
throw new OAuthRefreshExchangeError(new Error("Discarded refresh revocation was not confirmed"));
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
return view(post) ? post : undefined;
|
|
135
181
|
}
|
|
136
182
|
//# sourceMappingURL=oauth-refresh.js.map
|