@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.
- package/dist/api/anthropic-messages.js +58 -10
- package/dist/api/anthropic-request-shape-log.d.ts +5 -0
- package/dist/api/anthropic-request-shape-log.js +11 -0
- package/dist/api/anthropic-thinking-binding.d.ts +28 -0
- package/dist/api/anthropic-thinking-binding.js +141 -0
- package/dist/api/lazy.js +3 -0
- package/dist/api/simple-options.js +1 -0
- package/dist/auth/oauth/openai-chatgpt.d.ts +26 -2
- package/dist/auth/oauth/openai-chatgpt.js +40 -20
- package/dist/auth/oauth-refresh.d.ts +3 -1
- package/dist/auth/oauth-refresh.js +49 -11
- package/dist/auth/pool/login-id.d.ts +9 -0
- package/dist/auth/pool/login-id.js +10 -0
- package/dist/auth/resolve.js +7 -0
- package/dist/auth/types.d.ts +16 -3
- package/dist/models.js +3 -2
- package/dist/providers/data/.manifest.json +1 -1
- package/dist/providers/data/opencode-go.json +1 -1
- package/dist/providers/data/opengateway.json +1 -1
- package/dist/providers/data/openrouter.json +1 -1
- package/dist/providers/data/radius.json +1 -1
- package/dist/providers/data/venice.json +1 -1
- package/dist/providers/data/vercel-ai-gateway.json +1 -1
- package/dist/types.d.ts +6 -0
- package/dist/utils/oauth-page.d.ts +19 -0
- package/dist/utils/oauth-page.js +38 -2
- package/dist/utils/provider-diagnostic-vocabulary.d.ts +4 -0
- package/dist/utils/provider-diagnostic-vocabulary.js +18 -0
- package/dist/utils/provider-failure-description.d.ts +2 -0
- package/dist/utils/provider-failure-description.js +1 -1
- package/package.json +2 -2
|
@@ -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
|
|
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,
|
|
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
|
-
|
|
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,
|
|
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 (
|
|
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] =
|
|
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 {
|
|
8
|
-
|
|
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 =
|
|
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(
|
|
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 ${
|
|
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 || "",
|
|
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
|
-
|
|
105
|
-
|
|
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:
|
|
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
|
-
|
|
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:
|
|
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:
|
|
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
|
-
*
|
|
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.
|