@code-yeongyu/senpi-ai 2026.11.3-1 → 2026.11.3-3

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.
@@ -30,7 +30,8 @@ import { sendWithForcedToolChoiceFallback } from "../utils/tool-choice-fallback.
30
30
  import { resolveRootObjectSchema } from "../utils/tool-schema-compat.js";
31
31
  import { getCurrentTools, getDeclaredTools, getInitialSystemMessage, hasToolRedefinitions, normalizeContext, resolveTranscript, } from "../utils/transcript.js";
32
32
  import { isAnthropicWebSearchReplayBlock, isReplayableAnthropicProviderNativeBlock, } from "./anthropic-native-replay-blocks.js";
33
- import { logAnthropicRequestShape } from "./anthropic-request-shape-log.js";
33
+ import { logAnthropicRecovery, logAnthropicRequestShape } from "./anthropic-request-shape-log.js";
34
+ import { acceptsThinkingBinding, bindReplayedThinking, isThinkingBindingControlRefusedError, isThinkingBindingMismatchError, replaysSignedThinking, THINKING_BINDING_RECOVERY_DIAGNOSTIC, ThinkingBindingRecovery, thinkingBindingUnrecoverableError, } from "./anthropic-thinking-binding.js";
34
35
  import { sanitizeAnthropicToolPairs } from "./anthropic-tool-pairs.js";
35
36
  import { demoteToolReferenceReplay, demoteUnavailableToolReferences } from "./anthropic-tool-references.js";
36
37
  import { demoteExpiredWebSearchReplay, expiredWebSearchReplayIds, isExpiredWebSearchContentError, } from "./anthropic-web-search-expiry.js";
@@ -951,11 +952,11 @@ export const stream = (model, context, options) => {
951
952
  });
952
953
  }
953
954
  const cacheRetention = resolveCacheRetention(options?.cacheRetention ?? model.cacheRetention, options?.env, "short");
954
- const cacheSessionId = cacheRetention === "none" ? undefined : options?.sessionId;
955
+ const affinityKey = cacheRetention === "none" ? undefined : (options?.sessionAffinityKey ?? options?.sessionId);
955
956
  openClient = () => {
956
957
  client = createClient(model, apiKey, options?.interleavedThinking ?? true, shouldUseFineGrainedToolStreamingBeta(model, normalizedContext), options?.refusalFallbacks !== undefined && configuredBetaHeader(model, options) !== null, optionsHeaders, getAnthropicCompat(model).supportsGzipRequestBody
957
958
  ? withGzipRequestBodies(options?.fetch)
958
- : options?.fetch, copilotDynamicHeaders, cacheSessionId, options?.env, federation);
959
+ : options?.fetch, copilotDynamicHeaders, affinityKey, options?.env, federation);
959
960
  };
960
961
  openClient();
961
962
  }
@@ -973,9 +974,14 @@ export const stream = (model, context, options) => {
973
974
  expiredSearchIds.add(key.slice(expiredSearchPrefix.length));
974
975
  }
975
976
  }
977
+ const thinkingRecovery = new ThinkingBindingRecovery(fallbackKey);
976
978
  let lastSentParams;
977
979
  const createRequest = async () => {
978
- let params = buildParams(model, normalizedContext, options, unsignedThinkingReplay);
980
+ const replay = thinkingRecovery.strip(normalizedContext);
981
+ let params = buildParams(model, replay.context, options, unsignedThinkingReplay, {
982
+ bindReplayedThinking: thinkingRecovery.bindingAllowed,
983
+ replayedThinkingRemoved: replay.removed,
984
+ });
979
985
  const nextParams = await options?.onPayload?.(params, model);
980
986
  if (nextParams !== undefined) {
981
987
  params = nextParams;
@@ -1029,6 +1035,30 @@ export const stream = (model, context, options) => {
1029
1035
  }
1030
1036
  return newlyExpired.length > 0;
1031
1037
  };
1038
+ const thinkingBindingFailure = (error) => attachProviderDiagnostic(thinkingBindingUnrecoverableError(error), anthropicProviderDiagnosticFromError(error));
1039
+ // senpi#5587: rebuild once on the same model without the signed thinking the API bound elsewhere.
1040
+ const recoverThinkingBinding = async (error) => {
1041
+ const removed = thinkingRecovery.rejectReplayedThinking(normalizedContext.messages);
1042
+ if (removed === 0)
1043
+ throw thinkingBindingFailure(error);
1044
+ appendAssistantMessageDiagnostic(output, {
1045
+ type: THINKING_BINDING_RECOVERY_DIAGNOSTIC,
1046
+ timestamp: Date.now(),
1047
+ details: {
1048
+ removedThinkingBlocks: removed,
1049
+ message: "Retried without earlier reasoning the provider bound to a different conversation.",
1050
+ },
1051
+ });
1052
+ logAnthropicRecovery(model, "thinking_binding_mismatch", { removedThinkingBlocks: removed });
1053
+ try {
1054
+ return await createRequest();
1055
+ }
1056
+ catch (retryError) {
1057
+ if (isThinkingBindingMismatchError(retryError))
1058
+ throw thinkingBindingFailure(retryError);
1059
+ throw retryError;
1060
+ }
1061
+ };
1032
1062
  const createRequestDemotingExpiredSearches = async () => {
1033
1063
  try {
1034
1064
  return await createRequest();
@@ -1058,6 +1088,14 @@ export const stream = (model, context, options) => {
1058
1088
  toolReferenceReplayFallbacks.add(fallbackKey);
1059
1089
  return createRequest();
1060
1090
  }
1091
+ if (isThinkingBindingControlRefusedError(error) &&
1092
+ thinkingRecovery.bindingAllowed &&
1093
+ model.compat?.supportsMidConvoEffort !== true) {
1094
+ thinkingRecovery.refuseBinding();
1095
+ return createRequest();
1096
+ }
1097
+ if (isThinkingBindingMismatchError(error))
1098
+ return recoverThinkingBinding(error);
1061
1099
  if (markNewlyExpiredSearches(error))
1062
1100
  return createRequestDemotingExpiredSearches();
1063
1101
  throw error;
@@ -1564,7 +1602,7 @@ export const streamSimple = (model, context, options) => {
1564
1602
  thinkingBudgetTokens: Math.min(adjusted.thinkingBudget, Math.max(0, maxTokens - 1024)),
1565
1603
  });
1566
1604
  };
1567
- function createClient(model, apiKey, interleavedThinking, useFineGrainedToolStreamingBeta, useServerSideFallbackBeta, optionsHeaders, fetch, dynamicHeaders, sessionId, env, federation) {
1605
+ function createClient(model, apiKey, interleavedThinking, useFineGrainedToolStreamingBeta, useServerSideFallbackBeta, optionsHeaders, fetch, dynamicHeaders, affinityKey, env, federation) {
1568
1606
  // Adaptive thinking models have interleaved thinking built in, so skip the beta header.
1569
1607
  const needsInterleavedBeta = interleavedThinking && !supportsAdaptiveThinking(model);
1570
1608
  const betaFeatures = [];
@@ -1614,11 +1652,11 @@ function createClient(model, apiKey, interleavedThinking, useFineGrainedToolStre
1614
1652
  // API key, header-owned auth, or workload identity federation.
1615
1653
  const affinityCompat = getAnthropicCompat(model);
1616
1654
  const sessionAffinityHeaders = {};
1617
- if (sessionId && affinityCompat.sendSessionAffinityHeaders) {
1655
+ if (affinityKey && affinityCompat.sendSessionAffinityHeaders) {
1618
1656
  // OpenRouter routes prompt-cache affinity through its own header name and
1619
1657
  // rejects x-session-affinity (earendil-works/pi#9102).
1620
1658
  const header = affinityCompat.sessionAffinityFormat === "openrouter" ? "x-session-id" : "x-session-affinity";
1621
- sessionAffinityHeaders[header] = sessionId;
1659
+ sessionAffinityHeaders[header] = affinityKey;
1622
1660
  }
1623
1661
  const defaultHeaders = sanitizeAdaptiveThinkingHeaders(model, mergeClientHeaders(model, {
1624
1662
  accept: "application/json",
@@ -1715,7 +1753,7 @@ function defaultOptionalBetas(model, context, options) {
1715
1753
  features.push(SERVER_SIDE_FALLBACK_BETA);
1716
1754
  return features;
1717
1755
  }
1718
- function buildParams(model, context, options, unsignedThinkingReplay = getAnthropicCompat(model).unsignedThinkingReplay) {
1756
+ function buildParams(model, context, options, unsignedThinkingReplay = getAnthropicCompat(model).unsignedThinkingReplay, replay = { bindReplayedThinking: false, replayedThinkingRemoved: false }) {
1719
1757
  const compat = getAnthropicCompat(model);
1720
1758
  const { cacheControl } = getCacheControl(model, options?.cacheRetention ?? model.cacheRetention, options?.env);
1721
1759
  const initialSystemMessage = getInitialSystemMessage(context.messages);
@@ -1766,9 +1804,17 @@ function buildParams(model, context, options, unsignedThinkingReplay = getAnthro
1766
1804
  const deferredToolNames = new Set(deferredTools.map((tool) => tool.name));
1767
1805
  const activeEffort = managedEffortForRequest(model, options) ?? "high";
1768
1806
  const sendFallbacks = options?.refusalFallbacks !== undefined && !betasSuppressed;
1807
+ // senpi#5587: any Claude request replaying signed thinking asks the API to drop a block bound elsewhere.
1808
+ const bindsReplayedThinking = !midConvoEffort &&
1809
+ !betasSuppressed &&
1810
+ replay.bindReplayedThinking &&
1811
+ options?.thinkingEnabled === true &&
1812
+ model.reasoning &&
1813
+ acceptsThinkingBinding(model) &&
1814
+ replaysSignedThinking(conversationMessages);
1769
1815
  const betaFeatures = getBetaFeatures(model, context, {
1770
1816
  midConvoEffort,
1771
- thinkingBinding: midConvoEffort && options?.thinkingEnabled !== false,
1817
+ thinkingBinding: (midConvoEffort && options?.thinkingEnabled !== false) || bindsReplayedThinking,
1772
1818
  nativeToolChanges,
1773
1819
  refusalFallbacks: sendFallbacks,
1774
1820
  }, options);
@@ -1882,9 +1928,11 @@ function buildParams(model, context, options, unsignedThinkingReplay = getAnthro
1882
1928
  // cache - degrading here re-wrote the whole cached prefix on each tool
1883
1929
  // continuation (the "cache misses every second prompt" report).
1884
1930
  if (params.thinking?.type === "enabled" &&
1885
- finalAssistantTurnIsForeign(context.messages) &&
1931
+ (finalAssistantTurnIsForeign(context.messages) || replay.replayedThinkingRemoved) &&
1886
1932
  finalAssistantTurnStartsWithToolUse(params.messages))
1887
1933
  disableThinkingForRequest(params, model, compat);
1934
+ if (bindsReplayedThinking)
1935
+ bindReplayedThinking(params);
1888
1936
  if (options?.metadata) {
1889
1937
  const userId = options.metadata.user_id;
1890
1938
  if (typeof userId === "string") {
@@ -20,5 +20,10 @@ export declare function logAnthropicRequestShape(model: {
20
20
  }, params: {
21
21
  readonly messages?: unknown;
22
22
  }, env?: Readonly<Record<string, string | undefined>>, appendFile?: AppendFile | undefined): void;
23
+ /** One line per request rebuilt after a provider rejection, beside the skeletons of both attempts. */
24
+ export declare function logAnthropicRecovery(model: {
25
+ readonly provider: string;
26
+ readonly id: string;
27
+ }, recovery: string, details: Readonly<Record<string, number>>, env?: Readonly<Record<string, string | undefined>>): void;
23
28
  export {};
24
29
  //# sourceMappingURL=anthropic-request-shape-log.d.ts.map
@@ -47,4 +47,15 @@ export function logAnthropicRequestShape(model, params, env = processEnv(), appe
47
47
  };
48
48
  append(path, `${JSON.stringify(record)}\n`);
49
49
  }
50
+ /** One line per request rebuilt after a provider rejection, beside the skeletons of both attempts. */
51
+ export function logAnthropicRecovery(model, recovery, details, env = processEnv()) {
52
+ const path = env[REQUEST_SHAPE_LOG_ENV];
53
+ if (path === undefined || path.length === 0)
54
+ return;
55
+ const append = resolveAppendFile();
56
+ if (append === undefined)
57
+ return;
58
+ const record = { ts: new Date().toISOString(), provider: model.provider, model: model.id, recovery, ...details };
59
+ append(path, `${JSON.stringify(record)}\n`);
60
+ }
50
61
  //# sourceMappingURL=anthropic-request-shape-log.js.map
@@ -0,0 +1,28 @@
1
+ import type { Context, Message, Model } from "../types.ts";
2
+ export declare const THINKING_BINDING_RECOVERY_DIAGNOSTIC = "anthropic_thinking_binding_recovery";
3
+ export declare function isThinkingBindingMismatchError(error: unknown): error is Error;
4
+ export declare function isThinkingBindingMismatchText(text: string): boolean;
5
+ /** A 400 refusing the binding control itself, as opposed to the mismatch it guards against. */
6
+ export declare function isThinkingBindingControlRefusedError(error: unknown): error is Error;
7
+ /** The binding controls are an Anthropic API feature: only Claude models, on any route that serves them. */
8
+ export declare function acceptsThinkingBinding(model: Pick<Model<"anthropic-messages">, "id">): boolean;
9
+ export declare function replaysSignedThinking(messages: readonly Message[]): boolean;
10
+ export declare function bindReplayedThinking<T extends {
11
+ thinking?: unknown;
12
+ }>(params: T): void;
13
+ export declare class ThinkingBindingRecovery {
14
+ private readonly localTurns;
15
+ private localBindingRefused;
16
+ private readonly sessionKey;
17
+ constructor(sessionKey: string | undefined);
18
+ get bindingAllowed(): boolean;
19
+ refuseBinding(): void;
20
+ private isRejected;
21
+ rejectReplayedThinking(messages: readonly Message[]): number;
22
+ strip<C extends Context>(context: C): {
23
+ context: C;
24
+ removed: boolean;
25
+ };
26
+ }
27
+ export declare function thinkingBindingUnrecoverableError(cause: Error): Error;
28
+ //# sourceMappingURL=anthropic-thinking-binding.d.ts.map
@@ -0,0 +1,141 @@
1
+ import { registerSessionResourceCleanup } from "../session-resources.js";
2
+ /**
3
+ * A signed `thinking` block is bound to the conversation prefix it was minted under (senpi#5587). History
4
+ * that carries a block from another prefix (a restored or transplanted transcript, a route that served an
5
+ * earlier turn from another account) is refused before the first token:
6
+ * "messages.1.content.0: Invalid `signature` in `thinking` block. The block is bound to a different
7
+ * conversation." The block is stored in the session, so every replay repeats the refusal.
8
+ *
9
+ * Two layers keep such a session usable:
10
+ * - Where the API accepts it, a request that replays signed thinking asks the API to drop a mismatched
11
+ * block (`thinking.block_binding.prefix_mismatch_behavior: "drop_block"`).
12
+ * - On exactly this 400 the request is rebuilt once, on the same model, without the replayed signed
13
+ * thinking; user text, tool calls and tool results are untouched. The removed turns are remembered per
14
+ * session, base URL and model, so later turns stay consistent with the prefix the retry was answered
15
+ * under and do not hit the 400 again.
16
+ */
17
+ const BOUND_TO_DIFFERENT_CONVERSATION = /Invalid `signature` in `thinking` block\. The block is bound to a different conversation/i;
18
+ // A transport that does not know the binding controls names the field or the beta in its 400.
19
+ const BINDING_CONTROL_NAMED = /block_binding|thinking-binding-controls/i;
20
+ export const THINKING_BINDING_RECOVERY_DIAGNOSTIC = "anthropic_thinking_binding_recovery";
21
+ function statusOf(error) {
22
+ return typeof error === "object" && error !== null && "status" in error
23
+ ? error.status
24
+ : undefined;
25
+ }
26
+ export function isThinkingBindingMismatchError(error) {
27
+ return error instanceof Error && statusOf(error) === 400 && BOUND_TO_DIFFERENT_CONVERSATION.test(error.message);
28
+ }
29
+ export function isThinkingBindingMismatchText(text) {
30
+ return BOUND_TO_DIFFERENT_CONVERSATION.test(text);
31
+ }
32
+ /** A 400 refusing the binding control itself, as opposed to the mismatch it guards against. */
33
+ export function isThinkingBindingControlRefusedError(error) {
34
+ return (error instanceof Error &&
35
+ statusOf(error) === 400 &&
36
+ BINDING_CONTROL_NAMED.test(error.message) &&
37
+ !BOUND_TO_DIFFERENT_CONVERSATION.test(error.message));
38
+ }
39
+ /** The binding controls are an Anthropic API feature: only Claude models, on any route that serves them. */
40
+ export function acceptsThinkingBinding(model) {
41
+ return /claude/i.test(model.id);
42
+ }
43
+ function signedThinkingCount(message) {
44
+ if (message.role !== "assistant")
45
+ return 0;
46
+ let count = 0;
47
+ for (const block of message.content) {
48
+ if (block.type === "thinking" && (block.thinkingSignature?.length ?? 0) > 0)
49
+ count++;
50
+ }
51
+ return count;
52
+ }
53
+ export function replaysSignedThinking(messages) {
54
+ return messages.some((message) => signedThinkingCount(message) > 0);
55
+ }
56
+ export function bindReplayedThinking(params) {
57
+ const thinking = params.thinking;
58
+ if (typeof thinking !== "object" || thinking === null)
59
+ return;
60
+ const config = thinking;
61
+ if (config.type !== "adaptive" && config.type !== "enabled")
62
+ return;
63
+ if (config.block_binding !== undefined)
64
+ return;
65
+ params.thinking = { ...config, block_binding: { prefix_mismatch_behavior: "drop_block" } };
66
+ }
67
+ // `${sessionKey}\u0000${messageKey}` of assistant turns whose signed thinking was removed after this 400.
68
+ const rejectedThinkingTurns = new Set();
69
+ // Session keys whose transport refused the binding controls.
70
+ const bindingRefusedSessions = new Set();
71
+ registerSessionResourceCleanup((sessionId) => {
72
+ if (sessionId === undefined) {
73
+ rejectedThinkingTurns.clear();
74
+ bindingRefusedSessions.clear();
75
+ return;
76
+ }
77
+ const prefix = `${sessionId}\u0000`;
78
+ for (const keys of [rejectedThinkingTurns, bindingRefusedSessions]) {
79
+ for (const key of keys) {
80
+ if (key.startsWith(prefix))
81
+ keys.delete(key);
82
+ }
83
+ }
84
+ });
85
+ function turnKey(message) {
86
+ return message.role === "assistant" ? (message.responseId ?? `${message.model}@${message.timestamp}`) : "";
87
+ }
88
+ export class ThinkingBindingRecovery {
89
+ constructor(sessionKey) {
90
+ this.localTurns = new Set();
91
+ this.localBindingRefused = false;
92
+ this.sessionKey = sessionKey;
93
+ }
94
+ get bindingAllowed() {
95
+ if (this.localBindingRefused)
96
+ return false;
97
+ return this.sessionKey === undefined || !bindingRefusedSessions.has(this.sessionKey);
98
+ }
99
+ refuseBinding() {
100
+ this.localBindingRefused = true;
101
+ if (this.sessionKey !== undefined)
102
+ bindingRefusedSessions.add(this.sessionKey);
103
+ }
104
+ isRejected(message) {
105
+ const key = turnKey(message);
106
+ if (this.localTurns.has(key))
107
+ return true;
108
+ return this.sessionKey !== undefined && rejectedThinkingTurns.has(`${this.sessionKey}\u0000${key}`);
109
+ }
110
+ rejectReplayedThinking(messages) {
111
+ let blocks = 0;
112
+ for (const message of messages) {
113
+ const count = signedThinkingCount(message);
114
+ if (count === 0 || this.isRejected(message))
115
+ continue;
116
+ blocks += count;
117
+ const key = turnKey(message);
118
+ this.localTurns.add(key);
119
+ if (this.sessionKey !== undefined)
120
+ rejectedThinkingTurns.add(`${this.sessionKey}\u0000${key}`);
121
+ }
122
+ return blocks;
123
+ }
124
+ strip(context) {
125
+ let removed = false;
126
+ const messages = context.messages.map((message) => {
127
+ if (message.role !== "assistant" || signedThinkingCount(message) === 0 || !this.isRejected(message)) {
128
+ return message;
129
+ }
130
+ removed = true;
131
+ const content = message.content.filter((block) => !(block.type === "thinking" && (block.thinkingSignature?.length ?? 0) > 0));
132
+ return { ...message, content };
133
+ });
134
+ return removed ? { context: { ...context, messages }, removed } : { context, removed };
135
+ }
136
+ }
137
+ export function thinkingBindingUnrecoverableError(cause) {
138
+ const error = new Error(`Anthropic rejected this session's earlier reasoning as bound to a different conversation, and the request rebuilt without it was rejected too. Start a new session to continue. (${cause.message})`, { cause });
139
+ return Object.assign(error, { status: 400 });
140
+ }
141
+ //# sourceMappingURL=anthropic-thinking-binding.js.map
package/dist/api/lazy.js CHANGED
@@ -1,9 +1,11 @@
1
+ import { readProviderDiagnostic } from "../provider-diagnostic.js";
1
2
  import { assistantEvents, pushAssistantProgress, setAssistantReader } from "../utils/assistant-capture.js";
2
3
  import { AssistantMessageEventStream } from "../utils/event-stream.js";
3
4
  import { isOAuthRefreshUnavailableError } from "../utils/oauth-refresh-error.js";
4
5
  import { describeReplacedInstall } from "../utils/provider-failure-description.js";
5
6
  import { OAUTH_REFRESH_UNAVAILABLE_DIAGNOSTIC } from "../utils/retry.js";
6
7
  function createSetupErrorMessage(model, error) {
8
+ const providerDiagnostic = readProviderDiagnostic(error);
7
9
  return {
8
10
  role: "assistant",
9
11
  content: [],
@@ -20,6 +22,7 @@ function createSetupErrorMessage(model, error) {
20
22
  },
21
23
  stopReason: "error",
22
24
  errorMessage: describeReplacedInstall(error) ?? (error instanceof Error ? error.message : String(error)),
25
+ ...(providerDiagnostic === undefined ? {} : { providerDiagnostic }),
23
26
  ...(isOAuthRefreshUnavailableError(error)
24
27
  ? {
25
28
  diagnostics: [
@@ -118,6 +118,7 @@ export function buildBaseOptions(model, context, options, apiKey) {
118
118
  transport: options?.transport,
119
119
  cacheRetention: options?.cacheRetention ?? model.cacheRetention,
120
120
  sessionId: options?.sessionId,
121
+ sessionAffinityKey: options?.sessionAffinityKey,
121
122
  headers: options?.headers,
122
123
  extraBody: options?.extraBody,
123
124
  onPayload: options?.onPayload,
@@ -4,6 +4,30 @@
4
4
  * This public-client flow uses no client secret and sends the resulting user
5
5
  * access token directly to api.openai.com.
6
6
  */
7
- import type { OAuthAuth } from "../types.ts";
8
- export declare const openaiChatGPTOAuth: OAuthAuth;
7
+ import type { LoginOptions, LogoutOptions, OAuthCredential, OAuthRevocationResult, ProviderAuthInteraction } from "../types.ts";
8
+ declare function refreshAccessToken(credential: OAuthCredential, signal: AbortSignal): Promise<OAuthCredential>;
9
+ declare function revokeSession(credential: OAuthCredential, options?: LogoutOptions): Promise<OAuthRevocationResult>;
10
+ declare function loginOpenAIChatGPT(interaction: ProviderAuthInteraction, options?: LoginOptions & {
11
+ readonly preferredCallbackPort?: number;
12
+ }): Promise<OAuthCredential>;
13
+ export declare const openaiChatGPTOAuth: {
14
+ name: string;
15
+ isSubscription: true;
16
+ loginLabel: string;
17
+ login: typeof loginOpenAIChatGPT;
18
+ refresh: typeof refreshAccessToken;
19
+ revoke: typeof revokeSession;
20
+ check({ credential }: {
21
+ ctx: import("../types.ts").AuthContext;
22
+ credential?: OAuthCredential;
23
+ signal: AbortSignal;
24
+ }): Promise<{
25
+ type: "oauth";
26
+ source: string;
27
+ } | undefined>;
28
+ toAuth(credential: OAuthCredential): Promise<{
29
+ apiKey: string;
30
+ }>;
31
+ };
32
+ export {};
9
33
  //# sourceMappingURL=openai-chatgpt.d.ts.map
@@ -6,9 +6,8 @@
6
6
  */
7
7
  import { randomBytes } from "node:crypto";
8
8
  import { createServer } from "node:http";
9
- import { oauthErrorHtml, oauthSuccessHtml } from "../../utils/oauth-page.js";
9
+ import { configuredOAuthBrandName, oauthErrorHtml, oauthSuccessHtml } from "../../utils/oauth-page.js";
10
10
  import { OAuthTokenEndpointError } from "../../utils/oauth-refresh-error.js";
11
- import { getProviderEnvValue } from "../../utils/provider-env.js";
12
11
  import { getWireIdentity } from "../../wire-identity.js";
13
12
  import { generatePKCE } from "./pkce.js";
14
13
  // every login registers a new client with this ID; OpenAI returns the issued client ID in the callback
@@ -18,7 +17,7 @@ const AUTHORIZE_URL = "https://auth.openai.com/api/accounts/authorize";
18
17
  const TOKEN_URL = "https://auth.openai.com/api/accounts/oauth/token";
19
18
  const DISCOVERY_URL = "https://auth.openai.com/.well-known/openid-configuration";
20
19
  const RESOURCE = "https://api.openai.com/v1";
21
- const CALLBACK_HOST = getProviderEnvValue("PI_OAUTH_CALLBACK_HOST") || "127.0.0.1";
20
+ const CALLBACK_HOST = "127.0.0.1";
22
21
  const CALLBACK_PORT = 1455;
23
22
  const CALLBACK_PATH = "/auth/callback";
24
23
  const REDIRECT_URI = `http://127.0.0.1:${CALLBACK_PORT}${CALLBACK_PATH}`;
@@ -43,7 +42,7 @@ function authorizationResultFromCallback(url, expectedState) {
43
42
  throw new Error("OpenAI OAuth registration callback did not contain an issued client ID");
44
43
  return { code, clientId };
45
44
  }
46
- function authorizationResultFromManualInput(input, expectedState) {
45
+ function authorizationResultFromManualInput(input, expectedState, redirectUri) {
47
46
  let url;
48
47
  try {
49
48
  url = new URL(input.trim());
@@ -51,9 +50,9 @@ function authorizationResultFromManualInput(input, expectedState) {
51
50
  catch {
52
51
  throw new Error("Paste the full callback URL from the browser");
53
52
  }
54
- const expected = new URL(REDIRECT_URI);
53
+ const expected = new URL(redirectUri);
55
54
  if (url.origin !== expected.origin || url.pathname !== expected.pathname) {
56
- throw new Error(`The pasted callback URL must start with ${REDIRECT_URI}`);
55
+ throw new Error(`The pasted callback URL must start with ${redirectUri}`);
57
56
  }
58
57
  const error = url.searchParams.get("error");
59
58
  if (error)
@@ -64,8 +63,9 @@ function sendHtml(response, status, body) {
64
63
  response.writeHead(status, { "Content-Type": "text/html; charset=utf-8" });
65
64
  response.end(body);
66
65
  }
67
- function startCallbackServer(expectedState) {
66
+ function startCallbackServer(expectedState, preferredPort) {
68
67
  return new Promise((resolve, reject) => {
68
+ let redirectUri = REDIRECT_URI;
69
69
  let resolveResult;
70
70
  let rejectResult;
71
71
  const result = new Promise((resolveAuthorization, rejectAuthorization) => {
@@ -74,7 +74,7 @@ function startCallbackServer(expectedState) {
74
74
  });
75
75
  const server = createServer((request, response) => {
76
76
  try {
77
- const url = new URL(request.url || "", REDIRECT_URI);
77
+ const url = new URL(request.url || "", redirectUri);
78
78
  if (url.pathname !== CALLBACK_PATH) {
79
79
  sendHtml(response, 404, oauthErrorHtml("Callback route not found."));
80
80
  return;
@@ -101,12 +101,30 @@ function startCallbackServer(expectedState) {
101
101
  sendHtml(response, 500, oauthErrorHtml("Internal error while processing the callback."));
102
102
  }
103
103
  });
104
- server.once("error", reject);
105
- server.listen(CALLBACK_PORT, CALLBACK_HOST, () => {
104
+ const onListening = () => {
105
+ server.removeListener("error", onBindError);
106
106
  server.removeListener("error", reject);
107
+ const address = server.address();
108
+ if (!address || typeof address === "string") {
109
+ server.close();
110
+ reject(new Error("OpenAI OAuth callback server did not bind to TCP"));
111
+ return;
112
+ }
113
+ redirectUri = `http://${CALLBACK_HOST}:${address.port}${CALLBACK_PATH}`;
107
114
  server.on("error", rejectResult);
108
- resolve({ server, result });
109
- });
115
+ resolve({ server, result, redirectUri });
116
+ };
117
+ const onBindError = (error) => {
118
+ server.removeListener("listening", onListening);
119
+ if (error.code !== "EADDRINUSE") {
120
+ reject(error);
121
+ return;
122
+ }
123
+ server.once("error", reject);
124
+ server.listen(0, CALLBACK_HOST, onListening);
125
+ };
126
+ server.once("error", onBindError);
127
+ server.listen(preferredPort, CALLBACK_HOST, onListening);
110
128
  });
111
129
  }
112
130
  async function requestToken(body, signal) {
@@ -155,13 +173,13 @@ function credentialFromTokenResponse(token, clientId) {
155
173
  scopes,
156
174
  };
157
175
  }
158
- async function exchangeAuthorizationCode(code, verifier, clientId, signal) {
176
+ async function exchangeAuthorizationCode(code, verifier, clientId, redirectUri, signal) {
159
177
  const token = await requestToken(new URLSearchParams({
160
178
  grant_type: "authorization_code",
161
179
  client_id: clientId,
162
180
  code,
163
181
  code_verifier: verifier,
164
- redirect_uri: REDIRECT_URI,
182
+ redirect_uri: redirectUri,
165
183
  resource: RESOURCE,
166
184
  }), signal);
167
185
  // Pi does not use the ID token to identify the user or read profile data.
@@ -267,7 +285,7 @@ async function loginOpenAIChatGPT(interaction, options) {
267
285
  const nonce = randomValue();
268
286
  let callback;
269
287
  try {
270
- callback = await startCallbackServer(state);
288
+ callback = await startCallbackServer(state, options?.preferredCallbackPort ?? CALLBACK_PORT);
271
289
  }
272
290
  catch (error) {
273
291
  interaction.notify({
@@ -275,13 +293,15 @@ async function loginOpenAIChatGPT(interaction, options) {
275
293
  message: `Could not listen on ${REDIRECT_URI}; paste the final redirect URL to continue. ${error instanceof Error ? error.message : String(error)}`,
276
294
  });
277
295
  }
296
+ const redirectUri = callback?.redirectUri ?? REDIRECT_URI;
278
297
  const authorizationUrl = new URL(AUTHORIZE_URL);
279
298
  authorizationUrl.search = new URLSearchParams({
280
299
  client_id: DYNAMIC_CLIENT_ID,
281
- agent_name_hint: options?.appName ?? getWireIdentity(),
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(),
282
302
  ext_agent_host_id: hostId,
283
303
  response_type: "code",
284
- redirect_uri: REDIRECT_URI,
304
+ redirect_uri: redirectUri,
285
305
  resource: RESOURCE,
286
306
  scope: SCOPE,
287
307
  state,
@@ -299,14 +319,14 @@ async function loginOpenAIChatGPT(interaction, options) {
299
319
  .prompt({
300
320
  type: "manual_code",
301
321
  message: "Complete login in your browser, or paste the final redirect URL here:",
302
- placeholder: REDIRECT_URI,
322
+ placeholder: redirectUri,
303
323
  signal: AbortSignal.any([manualAbort.signal, interaction.signal]),
304
324
  })
305
- .then((input) => authorizationResultFromManualInput(input, state));
325
+ .then((input) => authorizationResultFromManualInput(input, state, redirectUri));
306
326
  try {
307
327
  const result = await (callback ? Promise.race([callback.result, manualCode]) : manualCode);
308
328
  interaction.notify({ type: "progress", message: "Exchanging authorization code for tokens..." });
309
- return await exchangeAuthorizationCode(result.code, verifier, result.clientId, interaction.signal);
329
+ return await exchangeAuthorizationCode(result.code, verifier, result.clientId, redirectUri, interaction.signal);
310
330
  }
311
331
  catch (error) {
312
332
  if (interaction.signal.aborted)
@@ -9,7 +9,9 @@
9
9
  * re-acquires the lock and compare-and-swaps on the slot's refresh token, so a
10
10
  * slot rotated meanwhile by another process is adopted instead of overwritten.
11
11
  *
12
- * Within a process, concurrent refreshes of the same slot join one exchange.
12
+ * Persistent stores inject a session-scoped lock around read/exchange/write so
13
+ * two processes cannot consume the same rotating token. Within a process,
14
+ * concurrent refreshes of the same slot join one exchange.
13
15
  * Waiters that own the exchange (per-request resolution) cancel it once none of
14
16
  * them is waiting; the catalog-refresh lane joins without owning it, so a
15
17
  * superseded catalog refresh stops waiting but never aborts the exchange.