@code-yeongyu/senpi-ai 2026.10.31-2 → 2026.11.2-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.
Files changed (45) hide show
  1. package/dist/api/openai-responses-shared.d.ts +2 -0
  2. package/dist/api/openai-responses-shared.js +19 -3
  3. package/dist/api/openai-responses.js +30 -9
  4. package/dist/auth/credential-store.d.ts +3 -1
  5. package/dist/auth/credential-store.js +78 -0
  6. package/dist/auth/oauth/kimi-coding.js +6 -4
  7. package/dist/auth/oauth/openai-chatgpt-model-catalog.d.ts +36 -0
  8. package/dist/auth/oauth/openai-chatgpt-model-catalog.js +104 -0
  9. package/dist/auth/oauth/openai-chatgpt.js +76 -0
  10. package/dist/auth/oauth/radius.js +1 -2
  11. package/dist/auth/oauth-refresh.d.ts +2 -0
  12. package/dist/auth/oauth-refresh.js +53 -7
  13. package/dist/auth/pool/failover.d.ts +0 -40
  14. package/dist/auth/pool/failover.js +0 -76
  15. package/dist/auth/pool/slots.js +10 -0
  16. package/dist/auth/resolve.js +3 -0
  17. package/dist/auth/types.d.ts +11 -0
  18. package/dist/models.d.ts +4 -5
  19. package/dist/models.js +36 -1
  20. package/dist/provider-diagnostic.d.ts +2 -0
  21. package/dist/provider-diagnostic.js +6 -1
  22. package/dist/providers/data/.manifest.json +1 -1
  23. package/dist/providers/data/opengateway.json +1 -1
  24. package/dist/providers/data/openrouter.json +1 -1
  25. package/dist/providers/data/vercel-ai-gateway.json +1 -1
  26. package/dist/types.d.ts +6 -0
  27. package/dist/utils/empty-response-errors.d.ts +1 -2
  28. package/dist/utils/empty-response-errors.js +1 -2
  29. package/dist/utils/oauth-refresh-error.d.ts +8 -1
  30. package/dist/utils/oauth-refresh-error.js +3 -1
  31. package/dist/utils/provider-diagnostic-carrier.d.ts +10 -1
  32. package/dist/utils/provider-diagnostic-carrier.js +35 -0
  33. package/dist/utils/provider-diagnostic-sources.d.ts +2 -0
  34. package/dist/utils/provider-diagnostic-sources.js +5 -0
  35. package/dist/utils/provider-diagnostic-vocabulary.d.ts +1 -0
  36. package/dist/utils/provider-diagnostic-vocabulary.js +9 -0
  37. package/dist/utils/provider-retry.js +4 -1
  38. package/dist/utils/retry.d.ts +2 -0
  39. package/dist/utils/retry.js +14 -3
  40. package/dist/utils/siwc-errors.d.ts +2 -0
  41. package/dist/utils/siwc-errors.js +2 -0
  42. package/dist/utils/tool-name-match.js +46 -1
  43. package/package.json +2 -2
  44. package/dist/auth/pool/classify.d.ts +0 -16
  45. 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
- throw new Error(code == null ? message : `Error Code ${code}: ${message}`);
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
- throw new Error(msg);
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
- const errorMessage = formatProviderError(normalizeProviderError(error), `${provider === "openai" ? "OpenAI" : provider} API error`);
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) ?? openAICompatibleProviderDiagnosticFromError(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
- return new OpenAI({
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
- // Unauthorized: the stored credential is dead; Models clears it and prompts re-login.
210
- if (response.status === 401 || response.status === 403 || json?.error === "invalid_grant") {
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
- return slotName === undefined ? credential : projectOAuthSlot(credential, slotName);
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
- signal.throwIfAborted();
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
- return await credentials.modify(providerId, async (stored) => {
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 (!slot || slot.refresh !== current.refresh)
126
- return undefined; // rotated meanwhile: adopt the stored value
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