@cortexkit/pi-anthropic-auth 1.22.0 → 2.0.0
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/README.md +60 -54
- package/dist/commands.d.ts +23 -2
- package/dist/commands.js +58 -238
- package/dist/convert.d.ts +5 -0
- package/dist/convert.js +152 -38
- package/dist/custody.d.ts +13 -0
- package/dist/custody.js +33 -0
- package/dist/effort-history.d.ts +15 -0
- package/dist/effort-history.js +40 -0
- package/dist/index.d.ts +4 -1
- package/dist/index.js +188 -37
- package/dist/native-commands.d.ts +2 -0
- package/dist/native-commands.js +347 -0
- package/dist/native.d.ts +71 -0
- package/dist/native.js +308 -0
- package/dist/paths.d.ts +13 -0
- package/dist/paths.js +48 -2
- package/dist/stream.d.ts +17 -1
- package/dist/stream.js +609 -243
- package/dist/transcript.d.ts +19 -0
- package/dist/transcript.js +122 -0
- package/dist/transport.d.ts +5 -0
- package/dist/transport.js +30 -0
- package/package.json +5 -5
package/dist/stream.js
CHANGED
|
@@ -1,16 +1,15 @@
|
|
|
1
|
-
import { applyClaudeCodeHeaders, CACHE_KEEP_EXTENDED_TTL_BETA, CacheKeepManager, CacheKeepSessionRegistry, createStickyNoRouteResponse, decideStickyQuotaFailure, dumpDirectRequest, FAST_MODE_BETA,
|
|
1
|
+
import { applyClaudeCodeHeaders, applyCustomHeaders, CACHE_KEEP_EXTENDED_TTL_BETA, CacheKeepManager, CacheKeepSessionRegistry, createProviderErrorResponseScope, createSseFramer, createStickyNoRouteResponse, decideStickyQuotaFailure, decodeSseFrame, dumpDirectRequest, FAST_MODE_BETA, getCache1hPersistentMode, getClaudeCodeIdentityForVerifiedAccount, getDefaultCacheKeepRegistryDirectory, getFallbackReauthLabels, getQuotaCheckIntervalMs, getRoutingMode, getScopedQuotaWindowForModel, getThinkingPrefixMismatchBehavior, hasThinkingBindingControls, isCache1hPersistentlyEnabled, isCacheKeepHybridActive, isDumpPersistentlyEnabled, isFastModePersistentlyEnabled, isKillswitchEnabled, isNativeTransientAccountError, isOAuthAccount, isPermanentRefreshError, isValidApiBaseURL, killswitchPassesPolicy, logger, MID_CONVERSATION_OUTPUT_CONFIG_BETA, mergeAnthropicBetas, NativeVaultNoAccountError, parseSseFrame, QuotaManager, quotaBackoffActive, quotaSnapshotHasStandardWindows, quotaSnapshotModelScopeIsExhausted, quotaSnapshotPassesModelScope, quotaSnapshotPassesPolicy, STICKY_ROUTING_MAIN_ACCOUNT_ID, StickySessionRouter, sendViaRelay, setDumpEnabled, shouldFallbackStatus, stickyQuotaSnapshotIsFresh, stickyRetryAfterWithJitter, stickyRouteFamilyForModel, THINKING_BINDING_CONTROLS_BETA, usesMidConversationOutputConfig, } from '@cortexkit/anthropic-auth-core';
|
|
2
2
|
import { calculateCost, createAssistantMessageEventStream, } from '@earendil-works/pi-ai';
|
|
3
3
|
import { buildAnthropicRequest, fromClaudeCodeToolName } from './convert.js';
|
|
4
|
+
import { getPiNativeRuntime, piAttemptAccessToken, } from './native.js';
|
|
4
5
|
import { getPiAccountStoragePath } from './paths.js';
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
6
|
+
import { buildPiOAuthMessagesUrl, fetchPiOAuth, requirePiOAuthOrigin, } from './transport.js';
|
|
7
|
+
/** Dump label for a physical send that failed before any response arrived. */
|
|
8
|
+
const PHYSICAL_SEND_FAILED = 'physical send failed before a response';
|
|
8
9
|
let cacheKeepRegistry;
|
|
9
10
|
let cacheKeepRegistryDirectory;
|
|
10
11
|
const stickyRouters = new Map();
|
|
11
12
|
const quotaManagers = new Map();
|
|
12
|
-
const fallbackManagers = new Map();
|
|
13
|
-
const mainAccountIdInitializations = new Map();
|
|
14
13
|
const PI_SERVICE_CACHE_LIMIT = 16;
|
|
15
14
|
function setBoundedService(map, key, value) {
|
|
16
15
|
map.delete(key);
|
|
@@ -22,48 +21,92 @@ function setBoundedService(map, key, value) {
|
|
|
22
21
|
map.delete(oldest);
|
|
23
22
|
}
|
|
24
23
|
}
|
|
25
|
-
function
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
24
|
+
function getPiRoutingServices(storagePath, snapshot) {
|
|
25
|
+
const storage = snapshot.policyStorage;
|
|
26
|
+
const native = getPiNativeRuntime(storagePath);
|
|
27
|
+
const identityKey = JSON.stringify(snapshot.accounts.map((account) => [
|
|
28
|
+
account.id,
|
|
29
|
+
account.accountIdentity,
|
|
30
|
+
account.source,
|
|
31
|
+
account.credentialId,
|
|
32
|
+
account.binding?.rowId,
|
|
33
|
+
account.binding?.credentialEpoch,
|
|
34
|
+
]));
|
|
35
|
+
const existing = quotaManagers.get(storagePath);
|
|
36
|
+
let quotaManager = existing?.identityKey === identityKey ? existing.manager : undefined;
|
|
37
|
+
if (!quotaManager) {
|
|
38
|
+
quotaManager = new QuotaManager({
|
|
39
|
+
storage,
|
|
40
|
+
fetchQuotaSnapshot: async (request, dispatch) => {
|
|
41
|
+
const id = request.kind === 'main' ? 'main' : request.accountId;
|
|
42
|
+
if (!id)
|
|
43
|
+
throw new Error('Native quota request has no account identity');
|
|
44
|
+
return (await native.service()).fetchQuota(id, fetchPiOAuth, undefined, dispatch);
|
|
45
|
+
},
|
|
33
46
|
});
|
|
34
|
-
setBoundedService(
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
}
|
|
38
|
-
function getPiRoutingServices(storagePath, storage) {
|
|
39
|
-
let quotaManager = quotaManagers.get(storagePath);
|
|
40
|
-
let fallbackManager = fallbackManagers.get(storagePath);
|
|
41
|
-
if (!quotaManager || !fallbackManager) {
|
|
42
|
-
quotaManager = new QuotaManager({ storage });
|
|
43
|
-
fallbackManager = new FallbackAccountManager({
|
|
44
|
-
configPath: storagePath,
|
|
45
|
-
quotaManager,
|
|
47
|
+
setBoundedService(quotaManagers, storagePath, {
|
|
48
|
+
identityKey,
|
|
49
|
+
manager: quotaManager,
|
|
46
50
|
});
|
|
47
|
-
setBoundedService(quotaManagers, storagePath, quotaManager);
|
|
48
|
-
setBoundedService(fallbackManagers, storagePath, fallbackManager);
|
|
49
51
|
}
|
|
50
52
|
else {
|
|
51
53
|
quotaManager.updateStorage(storage);
|
|
52
54
|
}
|
|
53
|
-
|
|
55
|
+
const quotas = quotaManager;
|
|
56
|
+
const getUsableFallbackAccounts = async (current, options = {}) => {
|
|
57
|
+
const snapshot = await native.view();
|
|
58
|
+
const accounts = new Map(snapshot.accounts.map((account) => [account.id, account]));
|
|
59
|
+
const usable = [];
|
|
60
|
+
for (const account of current.accounts) {
|
|
61
|
+
const view = accounts.get(account.id);
|
|
62
|
+
if (!isOAuthAccount(account) || !view?.enabled || view.type !== 'oauth')
|
|
63
|
+
continue;
|
|
64
|
+
if (view.source === 'vault' && view.state !== 'active')
|
|
65
|
+
continue;
|
|
66
|
+
if (isPermanentRefreshError(view.lastRefreshError) ||
|
|
67
|
+
(view.source === 'local' &&
|
|
68
|
+
(view.lastRefreshError?.nextRetryAt ?? 0) > Date.now()))
|
|
69
|
+
continue;
|
|
70
|
+
const needsProbe = view.source !== 'vault' ||
|
|
71
|
+
Boolean(current.quota?.minimumRemaining) ||
|
|
72
|
+
isKillswitchEnabled(current);
|
|
73
|
+
if (needsProbe &&
|
|
74
|
+
quotas.isFallbackStale(account.id, undefined, options.modelId, account) &&
|
|
75
|
+
!quotaBackoffActive(account.lastQuotaRefreshError, Date.now())) {
|
|
76
|
+
try {
|
|
77
|
+
await quotas.refreshFallback(account.id, '', account);
|
|
78
|
+
}
|
|
79
|
+
catch (error) {
|
|
80
|
+
// A transient account error means another writer is still saving
|
|
81
|
+
// this fallback. Rethrow it: swallowing it would leave the fallback
|
|
82
|
+
// without a quota reading, which can exclude it below and send the
|
|
83
|
+
// request through a different account. Other probe errors are
|
|
84
|
+
// ignored.
|
|
85
|
+
if (isNativeTransientAccountError(error))
|
|
86
|
+
throw error;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
const quota = quotas.getFallback(account.id, account)?.quota ?? account.quota;
|
|
90
|
+
if ((!needsProbe || quotaSnapshotPassesPolicy(quota, current)) &&
|
|
91
|
+
quotaSnapshotPassesModelScope(quota, options.modelId))
|
|
92
|
+
usable.push({ ...account, quota });
|
|
93
|
+
}
|
|
94
|
+
return usable;
|
|
95
|
+
};
|
|
96
|
+
return { quotaManager, getUsableFallbackAccounts };
|
|
54
97
|
}
|
|
55
|
-
function getPiStickyRouter(storagePath) {
|
|
56
|
-
const path =
|
|
57
|
-
getStickyRoutingStatePath(storagePath);
|
|
98
|
+
async function getPiStickyRouter(storagePath) {
|
|
99
|
+
const { path, vaultSeed } = await getPiNativeRuntime(storagePath).routingStorage();
|
|
58
100
|
let router = stickyRouters.get(path);
|
|
59
101
|
if (!router) {
|
|
60
|
-
router = new StickySessionRouter({ path });
|
|
102
|
+
router = new StickySessionRouter({ path, vaultSeed });
|
|
61
103
|
setBoundedService(stickyRouters, path, router);
|
|
62
104
|
}
|
|
105
|
+
router.setVaultSeed(vaultSeed);
|
|
63
106
|
return router;
|
|
64
107
|
}
|
|
65
108
|
export async function clearPiStickyRoutingSession(storagePath, sessionId) {
|
|
66
|
-
await getPiStickyRouter(storagePath).clear(sessionId);
|
|
109
|
+
await (await getPiStickyRouter(storagePath)).clear(sessionId);
|
|
67
110
|
}
|
|
68
111
|
function getPiCacheKeepRegistry() {
|
|
69
112
|
const directory = process.env.PI_ANTHROPIC_AUTH_CACHEKEEP_REGISTRY_DIR ||
|
|
@@ -74,45 +117,81 @@ function getPiCacheKeepRegistry() {
|
|
|
74
117
|
}
|
|
75
118
|
return cacheKeepRegistry;
|
|
76
119
|
}
|
|
120
|
+
const nativePrewarmAttempts = new Map();
|
|
77
121
|
const cacheKeepManager = new CacheKeepManager({
|
|
78
|
-
|
|
122
|
+
fetchImpl: fetchPiOAuth,
|
|
123
|
+
loadStorage: async () => (await getPiNativeRuntime().view()).policyStorage,
|
|
79
124
|
onTrackedSessionsChanged: (sessions) => getPiCacheKeepRegistry().publish(sessions),
|
|
80
|
-
prepareHeaders: async (headers, target) => {
|
|
81
|
-
|
|
82
|
-
const
|
|
83
|
-
const
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
}
|
|
107
|
-
catch {
|
|
108
|
-
applyClaudeCodeHeaders(headers, accessToken);
|
|
109
|
-
}
|
|
125
|
+
prepareHeaders: async (headers, target, attempt) => {
|
|
126
|
+
requirePiOAuthOrigin(target.url);
|
|
127
|
+
const runtime = getPiNativeRuntime(target.accountStoragePath ?? getPiAccountStoragePath());
|
|
128
|
+
const id = target.oauthAccountId ?? STICKY_ROUTING_MAIN_ACCOUNT_ID;
|
|
129
|
+
const current = await runtime.view();
|
|
130
|
+
const body = JSON.parse(target.bodyText);
|
|
131
|
+
const served = await runtime.authorize(id, attempt.signal, typeof body.model === 'string' ? body.model : undefined);
|
|
132
|
+
if (!target.oauthAccountIdentity ||
|
|
133
|
+
served.accountIdentity !== target.oauthAccountIdentity)
|
|
134
|
+
throw new Error('CacheKeep account identity changed');
|
|
135
|
+
const identity = getClaudeCodeIdentityForVerifiedAccount(id === STICKY_ROUTING_MAIN_ACCOUNT_ID
|
|
136
|
+
? (current.policyStorage.mainAccountId ?? id)
|
|
137
|
+
: id, served.accountIdentity);
|
|
138
|
+
headers.delete('anthropic-beta');
|
|
139
|
+
applyClaudeCodeHeaders(headers, piAttemptAccessToken(served), {
|
|
140
|
+
body,
|
|
141
|
+
identity,
|
|
142
|
+
extraBetas: [
|
|
143
|
+
CACHE_KEEP_EXTENDED_TTL_BETA,
|
|
144
|
+
...(hasThinkingBindingControls(body)
|
|
145
|
+
? [THINKING_BINDING_CONTROLS_BETA]
|
|
146
|
+
: []),
|
|
147
|
+
],
|
|
148
|
+
});
|
|
149
|
+
if (body.speed === 'fast')
|
|
150
|
+
headers.set('anthropic-beta', mergeAnthropicBetas(headers.get('anthropic-beta'), [FAST_MODE_BETA]));
|
|
151
|
+
nativePrewarmAttempts.set(attempt.id, { runtime, served, reported: false });
|
|
110
152
|
return headers;
|
|
111
153
|
},
|
|
154
|
+
retryOnUnauthorized: async ({ target, headers, attempt }) => {
|
|
155
|
+
const entry = nativePrewarmAttempts.get(attempt.id);
|
|
156
|
+
if (!entry)
|
|
157
|
+
return undefined;
|
|
158
|
+
await entry.runtime.report(entry.served, 401, 'direct').catch(() => {
|
|
159
|
+
logger.warn('native-auth', 'Pi CacheKeep auth-failure report unavailable');
|
|
160
|
+
});
|
|
161
|
+
entry.reported = true;
|
|
162
|
+
const retryBody = JSON.parse(target.bodyText);
|
|
163
|
+
const rotated = await entry.runtime
|
|
164
|
+
.retry(target.oauthAccountId ?? STICKY_ROUTING_MAIN_ACCOUNT_ID, entry.served, attempt.signal, typeof retryBody.model === 'string' ? retryBody.model : undefined)
|
|
165
|
+
.catch(() => undefined);
|
|
166
|
+
if (!rotated)
|
|
167
|
+
return undefined;
|
|
168
|
+
const rotatedHeaders = new Headers(headers);
|
|
169
|
+
rotatedHeaders.set('authorization', `Bearer ${piAttemptAccessToken(rotated)}`);
|
|
170
|
+
nativePrewarmAttempts.set(attempt.id, {
|
|
171
|
+
runtime: entry.runtime,
|
|
172
|
+
served: rotated,
|
|
173
|
+
reported: false,
|
|
174
|
+
});
|
|
175
|
+
return rotatedHeaders;
|
|
176
|
+
},
|
|
177
|
+
onResponse: async ({ attempt, status }) => {
|
|
178
|
+
const entry = nativePrewarmAttempts.get(attempt.id);
|
|
179
|
+
if (entry && status === 401 && !entry.reported)
|
|
180
|
+
await entry.runtime.report(entry.served, status, 'direct').catch(() => {
|
|
181
|
+
logger.warn('native-auth', 'Pi CacheKeep auth-failure report unavailable');
|
|
182
|
+
});
|
|
183
|
+
},
|
|
184
|
+
onComplete: ({ attempt }) => {
|
|
185
|
+
nativePrewarmAttempts.delete(attempt.id);
|
|
186
|
+
},
|
|
112
187
|
});
|
|
113
188
|
export async function getPiTrackedCacheKeepSessions() {
|
|
114
189
|
return getPiCacheKeepRegistry().list(cacheKeepManager.trackedSessions());
|
|
115
190
|
}
|
|
191
|
+
/** Call prewarmNow directly to exercise its provider request without clock or timer mocks. */
|
|
192
|
+
export function __prewarmPiCacheKeepForTest(input) {
|
|
193
|
+
return cacheKeepManager.prewarmNow(input);
|
|
194
|
+
}
|
|
116
195
|
function mapStopReason(reason) {
|
|
117
196
|
switch (reason) {
|
|
118
197
|
case 'end_turn':
|
|
@@ -164,7 +243,16 @@ function updateUsage(model, output, usage) {
|
|
|
164
243
|
}
|
|
165
244
|
export function buildExplicitBaseMessagesUrl(baseURL) {
|
|
166
245
|
const url = new URL(baseURL);
|
|
167
|
-
|
|
246
|
+
const basePath = url.pathname.replace(/\/$/, '');
|
|
247
|
+
if (/\/v\d[^/]*\/messages$/.test(basePath)) {
|
|
248
|
+
url.pathname = basePath;
|
|
249
|
+
}
|
|
250
|
+
else if (/\/v\d[^/]*$/.test(basePath)) {
|
|
251
|
+
url.pathname = `${basePath}/messages`;
|
|
252
|
+
}
|
|
253
|
+
else {
|
|
254
|
+
url.pathname = `${basePath}/v1/messages`;
|
|
255
|
+
}
|
|
168
256
|
url.searchParams.set('beta', 'true');
|
|
169
257
|
return url;
|
|
170
258
|
}
|
|
@@ -183,57 +271,66 @@ export function configureApiRouteHeaders(account, fastMode) {
|
|
|
183
271
|
if (fastMode) {
|
|
184
272
|
headers.set('anthropic-beta', mergeAnthropicBetas(headers.get('anthropic-beta'), [FAST_MODE_BETA]));
|
|
185
273
|
}
|
|
274
|
+
applyCustomHeaders(headers);
|
|
186
275
|
return headers;
|
|
187
276
|
}
|
|
188
277
|
export async function* parseSse(response) {
|
|
189
278
|
if (!response.body)
|
|
190
279
|
return;
|
|
191
280
|
const reader = response.body.getReader();
|
|
192
|
-
|
|
193
|
-
|
|
281
|
+
// Core's byte framer ends frames at blank lines with CRLF, LF or CR line
|
|
282
|
+
// endings and keeps a multibyte character split across reads in one frame,
|
|
283
|
+
// so each frame is decoded once, whole. A frame without its closing blank
|
|
284
|
+
// line at end of stream is incomplete and dispatches nothing, as in
|
|
285
|
+
// EventSource.
|
|
286
|
+
const framer = createSseFramer();
|
|
287
|
+
// Only the stream's first frame may drop a leading byte-order mark.
|
|
288
|
+
let streamStart = true;
|
|
194
289
|
try {
|
|
195
290
|
while (true) {
|
|
196
291
|
const { done, value } = await reader.read();
|
|
197
292
|
if (done)
|
|
198
293
|
break;
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
for (const line of frame.split('\n')) {
|
|
206
|
-
if (!line.startsWith('data:'))
|
|
207
|
-
continue;
|
|
208
|
-
const data = line.slice(5).trim();
|
|
209
|
-
if (!data || data === '[DONE]')
|
|
210
|
-
continue;
|
|
211
|
-
yield JSON.parse(data);
|
|
212
|
-
}
|
|
294
|
+
for (const frame of framer.push(value)) {
|
|
295
|
+
const data = parseSseFrame(decodeSseFrame(frame, { streamStart })).data;
|
|
296
|
+
streamStart = false;
|
|
297
|
+
if (!data || data === '[DONE]')
|
|
298
|
+
continue;
|
|
299
|
+
yield JSON.parse(data);
|
|
213
300
|
}
|
|
214
301
|
}
|
|
215
302
|
}
|
|
216
303
|
finally {
|
|
217
|
-
//
|
|
218
|
-
//
|
|
219
|
-
// generator; cancelling the cloned (tee'd) reader tears down the shared
|
|
220
|
-
// underlying body, so the real `parseSse(response)` that streams the reply
|
|
221
|
-
// reads zero events and the assistant message comes back empty. Releasing
|
|
222
|
-
// the lock is enough — the abandoned clone branch is garbage-collected.
|
|
304
|
+
// Only the lock is released when the consumer stops early: cancelling the
|
|
305
|
+
// body on abandonment is the consumer's decision, not the parser's.
|
|
223
306
|
reader.releaseLock();
|
|
224
307
|
}
|
|
225
308
|
}
|
|
226
309
|
async function sendAnthropicRequest(options) {
|
|
227
|
-
|
|
228
|
-
|
|
310
|
+
const native = getPiNativeRuntime(options.storagePath);
|
|
311
|
+
// Capture the serving service while authorization is open. Shutdown rejects
|
|
312
|
+
// new serving work immediately but drains writes for already-served attempts.
|
|
313
|
+
const servingRuntime = await native.service();
|
|
314
|
+
const snapshot = await native.view();
|
|
315
|
+
const storage = snapshot.policyStorage;
|
|
229
316
|
setDumpEnabled(isDumpPersistentlyEnabled(storage));
|
|
230
|
-
const
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
317
|
+
const routeId = options.oauthAccountId ?? STICKY_ROUTING_MAIN_ACCOUNT_ID;
|
|
318
|
+
const selected = snapshot.accounts.find((account) => account.id === routeId);
|
|
319
|
+
let verifiedUuid = options.apiAccount ? undefined : selected?.accountIdentity;
|
|
320
|
+
if (!options.apiAccount && !verifiedUuid) {
|
|
321
|
+
if (selected?.source === 'vault')
|
|
322
|
+
throw new Error('Pi vault route identity is unasserted');
|
|
323
|
+
// Validation can learn the UUID of an account that has no verified identity.
|
|
324
|
+
// This lookup returns only that UUID; the model send authorizes the credential again.
|
|
325
|
+
verifiedUuid = (await native.authorize(routeId, options.streamOptions?.signal, options.model.id)).accountIdentity;
|
|
326
|
+
}
|
|
327
|
+
let nextAttempt;
|
|
328
|
+
const identity = verifiedUuid
|
|
329
|
+
? getClaudeCodeIdentityForVerifiedAccount(routeId === STICKY_ROUTING_MAIN_ACCOUNT_ID
|
|
330
|
+
? (storage.mainAccountId ?? routeId)
|
|
331
|
+
: routeId, verifiedUuid)
|
|
235
332
|
: undefined;
|
|
236
|
-
const { body, bodyText } = await buildAnthropicRequest(options.model.id, options.context, options.streamOptions, {
|
|
333
|
+
const { body, bodyText, hostTools } = await buildAnthropicRequest(options.model.id, options.context, options.streamOptions, {
|
|
237
334
|
enabled: isCache1hPersistentlyEnabled(storage),
|
|
238
335
|
mode: getCache1hPersistentMode(storage),
|
|
239
336
|
}, isFastModePersistentlyEnabled(storage), identity, {
|
|
@@ -242,10 +339,11 @@ async function sendAnthropicRequest(options) {
|
|
|
242
339
|
? 'account-default'
|
|
243
340
|
: getThinkingPrefixMismatchBehavior(storage),
|
|
244
341
|
});
|
|
342
|
+
options.onResolvedTools?.(hostTools);
|
|
245
343
|
const fastMode = body.speed === 'fast';
|
|
246
344
|
const headers = options.apiAccount
|
|
247
|
-
?
|
|
248
|
-
: applyClaudeCodeHeaders(new Headers(),
|
|
345
|
+
? new Headers()
|
|
346
|
+
: applyClaudeCodeHeaders(new Headers(), '', {
|
|
249
347
|
body,
|
|
250
348
|
identity,
|
|
251
349
|
extraBetas: [
|
|
@@ -257,31 +355,122 @@ async function sendAnthropicRequest(options) {
|
|
|
257
355
|
: []),
|
|
258
356
|
],
|
|
259
357
|
});
|
|
260
|
-
if (!options.apiAccount && fastMode)
|
|
358
|
+
if (!options.apiAccount && fastMode)
|
|
261
359
|
headers.set('anthropic-beta', mergeAnthropicBetas(headers.get('anthropic-beta'), [FAST_MODE_BETA]));
|
|
262
|
-
}
|
|
263
360
|
const relayAffinity = options.streamOptions?.sessionId ?? null;
|
|
264
|
-
|
|
265
|
-
?
|
|
266
|
-
:
|
|
361
|
+
let input = options.apiAccount
|
|
362
|
+
? new URL('https://api.anthropic.com/v1/messages')
|
|
363
|
+
: buildPiOAuthMessagesUrl(options.model.baseUrl);
|
|
267
364
|
const init = {
|
|
268
365
|
method: 'POST',
|
|
269
366
|
headers,
|
|
270
367
|
body: bodyText,
|
|
271
368
|
signal: options.streamOptions?.signal,
|
|
369
|
+
...(!options.apiAccount && { redirect: 'error' }),
|
|
370
|
+
};
|
|
371
|
+
if (!options.apiAccount)
|
|
372
|
+
await cacheKeepManager.track({
|
|
373
|
+
sessionId: relayAffinity,
|
|
374
|
+
url: input.toString(),
|
|
375
|
+
headers,
|
|
376
|
+
bodyText,
|
|
377
|
+
storage,
|
|
378
|
+
cacheMode: isCacheKeepHybridActive(storage) ? 'hybrid' : 'disabled',
|
|
379
|
+
oauthAccountId: routeId,
|
|
380
|
+
oauthAccountIdentity: verifiedUuid,
|
|
381
|
+
accountStoragePath: options.storagePath,
|
|
382
|
+
});
|
|
383
|
+
const reported = new Set();
|
|
384
|
+
let retried401 = false;
|
|
385
|
+
let lastTransport;
|
|
386
|
+
const used = new Set();
|
|
387
|
+
const report = async (attempt, status, source) => {
|
|
388
|
+
if (status !== 401 || reported.has(attempt))
|
|
389
|
+
return;
|
|
390
|
+
reported.add(attempt);
|
|
391
|
+
await native.report(attempt, status, source).catch(() => {
|
|
392
|
+
logger.warn('native-auth', 'Pi auth-failure report unavailable');
|
|
393
|
+
});
|
|
394
|
+
};
|
|
395
|
+
const markServed = (attempt) => {
|
|
396
|
+
if (used.has(attempt))
|
|
397
|
+
return;
|
|
398
|
+
used.add(attempt);
|
|
399
|
+
native.usagePublications.publish(() => native.markUsed(attempt, servingRuntime));
|
|
400
|
+
};
|
|
401
|
+
const takeAttempt = async () => {
|
|
402
|
+
const attempt = nextAttempt ??
|
|
403
|
+
(await native.authorize(routeId, init.signal ?? undefined, options.model.id));
|
|
404
|
+
nextAttempt = undefined;
|
|
405
|
+
if (attempt.accountIdentity !== verifiedUuid)
|
|
406
|
+
throw new Error('Pi native OAuth route identity changed');
|
|
407
|
+
return attempt;
|
|
272
408
|
};
|
|
273
|
-
await cacheKeepManager.track({
|
|
274
|
-
sessionId: relayAffinity,
|
|
275
|
-
url: input.toString(),
|
|
276
|
-
headers,
|
|
277
|
-
bodyText,
|
|
278
|
-
storage,
|
|
279
|
-
cacheMode: isCacheKeepHybridActive(storage) ? 'hybrid' : 'disabled',
|
|
280
|
-
oauthAccountId: options.oauthAccountId,
|
|
281
|
-
});
|
|
282
409
|
const directFetch = async () => {
|
|
410
|
+
let attempt;
|
|
411
|
+
let apiSubject;
|
|
412
|
+
// The secret this physical send goes out with; a provider error may repeat it.
|
|
413
|
+
let secret = '';
|
|
414
|
+
if (options.apiAccount) {
|
|
415
|
+
const credential = await (await native.service()).authorizeApi(options.apiAccount.id, init.signal ?? undefined);
|
|
416
|
+
apiSubject = credential.subject;
|
|
417
|
+
secret = credential.apiKey ?? '';
|
|
418
|
+
input = buildExplicitBaseMessagesUrl(credential.baseURL);
|
|
419
|
+
const authorized = configureApiRouteHeaders({
|
|
420
|
+
...credential,
|
|
421
|
+
id: options.apiAccount.id,
|
|
422
|
+
}, fastMode);
|
|
423
|
+
init.headers = authorized;
|
|
424
|
+
}
|
|
425
|
+
else {
|
|
426
|
+
attempt = await takeAttempt();
|
|
427
|
+
secret = piAttemptAccessToken(attempt);
|
|
428
|
+
headers.set('authorization', `Bearer ${secret}`);
|
|
429
|
+
}
|
|
430
|
+
lastTransport = 'direct';
|
|
431
|
+
// The response scope of the latest physical send. Each send gets its own
|
|
432
|
+
// before the request leaves, so a rejected 401 is already scrubbed when
|
|
433
|
+
// it is reported, dumped or cancelled.
|
|
434
|
+
const physical = {};
|
|
435
|
+
const send = async () => {
|
|
436
|
+
const scope = createProviderErrorResponseScope([secret], 'event-stream');
|
|
437
|
+
physical.scope = scope;
|
|
438
|
+
return scope.redactResponse(await fetch(input, init));
|
|
439
|
+
};
|
|
440
|
+
let response;
|
|
283
441
|
try {
|
|
284
|
-
|
|
442
|
+
response = await send();
|
|
443
|
+
if (attempt && response.status === 401) {
|
|
444
|
+
// Report the rejected credential version before retrying, even if its replacement later succeeds.
|
|
445
|
+
await report(attempt, response.status, 'direct');
|
|
446
|
+
const rotated = !retried401 && !init.signal?.aborted
|
|
447
|
+
? await native
|
|
448
|
+
.retry(routeId, attempt, init.signal ?? undefined, options.model.id)
|
|
449
|
+
.catch(() => undefined)
|
|
450
|
+
: undefined;
|
|
451
|
+
if (rotated) {
|
|
452
|
+
retried401 = true;
|
|
453
|
+
await dumpDirectRequest({
|
|
454
|
+
affinity: relayAffinity,
|
|
455
|
+
route: options.route ?? 'oauth',
|
|
456
|
+
status: response.status,
|
|
457
|
+
bodyText,
|
|
458
|
+
url: input.toString(),
|
|
459
|
+
method: init.method,
|
|
460
|
+
headers,
|
|
461
|
+
});
|
|
462
|
+
// Retire the rejected send's credentials before cancelling its
|
|
463
|
+
// body, since that cancellation may never settle.
|
|
464
|
+
physical.scope?.dispose();
|
|
465
|
+
await response.body?.cancel().catch(() => { });
|
|
466
|
+
attempt = rotated;
|
|
467
|
+
secret = piAttemptAccessToken(rotated);
|
|
468
|
+
headers.set('authorization', `Bearer ${secret}`);
|
|
469
|
+
response = await send();
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
if (attempt)
|
|
473
|
+
await report(attempt, response.status, 'direct');
|
|
285
474
|
await dumpDirectRequest({
|
|
286
475
|
affinity: relayAffinity,
|
|
287
476
|
route: options.route ??
|
|
@@ -290,35 +479,95 @@ async function sendAnthropicRequest(options) {
|
|
|
290
479
|
bodyText,
|
|
291
480
|
url: input.toString(),
|
|
292
481
|
method: init.method,
|
|
293
|
-
headers,
|
|
482
|
+
headers: init.headers,
|
|
294
483
|
});
|
|
295
|
-
return response;
|
|
296
484
|
}
|
|
297
|
-
catch (
|
|
485
|
+
catch (caught) {
|
|
486
|
+
// No response leaves this send, so its credentials retire here.
|
|
487
|
+
physical.scope?.dispose();
|
|
488
|
+
const scope = createProviderErrorResponseScope([secret], 'event-stream');
|
|
489
|
+
const error = scope.redactError(caught);
|
|
490
|
+
scope.dispose();
|
|
298
491
|
await dumpDirectRequest({
|
|
299
492
|
affinity: relayAffinity,
|
|
300
493
|
route: options.route ??
|
|
301
494
|
(options.apiAccount ? `api:${options.apiAccount.id}` : 'oauth'),
|
|
302
|
-
|
|
495
|
+
// A fixed label: the error text can repeat provider output or a credential.
|
|
496
|
+
error: PHYSICAL_SEND_FAILED,
|
|
303
497
|
bodyText,
|
|
304
498
|
url: input.toString(),
|
|
305
499
|
method: init.method,
|
|
306
|
-
headers,
|
|
500
|
+
headers: init.headers,
|
|
307
501
|
});
|
|
308
502
|
throw error;
|
|
309
503
|
}
|
|
504
|
+
if (apiSubject && response.ok) {
|
|
505
|
+
const subject = apiSubject;
|
|
506
|
+
native.usagePublications.publish(() => servingRuntime.publishApi(subject, { lastUsed: Date.now() }));
|
|
507
|
+
}
|
|
508
|
+
if (attempt && response.ok)
|
|
509
|
+
markServed(attempt);
|
|
510
|
+
return response;
|
|
310
511
|
};
|
|
311
512
|
if (options.apiAccount)
|
|
312
513
|
return directFetch();
|
|
313
|
-
|
|
314
|
-
|
|
514
|
+
let relay401Attempt;
|
|
515
|
+
let relayAttempt;
|
|
516
|
+
const relayReports = [];
|
|
517
|
+
// The relay scrubs each physical relay attempt's responses and errors with
|
|
518
|
+
// that attempt's own credentials and the relay token; a direct fallback is
|
|
519
|
+
// scrubbed by directFetch.
|
|
520
|
+
const sendRelayAttempt = async () => sendViaRelay({
|
|
521
|
+
config: await (await native.service()).getRelayConfig(),
|
|
315
522
|
input,
|
|
316
523
|
init,
|
|
317
524
|
headers,
|
|
318
525
|
body: bodyText,
|
|
319
526
|
fallback: directFetch,
|
|
320
527
|
affinity: relayAffinity,
|
|
528
|
+
authorizeAttempt: async () => {
|
|
529
|
+
const attempt = await takeAttempt();
|
|
530
|
+
relayAttempt = attempt;
|
|
531
|
+
lastTransport = 'relay';
|
|
532
|
+
const authorizedHeaders = new Headers(headers);
|
|
533
|
+
const access = piAttemptAccessToken(attempt);
|
|
534
|
+
authorizedHeaders.set('authorization', `Bearer ${access}`);
|
|
535
|
+
return {
|
|
536
|
+
headers: authorizedHeaders,
|
|
537
|
+
errorSecrets: [access],
|
|
538
|
+
onUpstreamStatus: (status) => {
|
|
539
|
+
if (status === 401) {
|
|
540
|
+
relay401Attempt = attempt;
|
|
541
|
+
// The relay can report an upstream 401 after returning the streaming response.
|
|
542
|
+
relayReports.push(report(attempt, status, 'relay_status_field'));
|
|
543
|
+
}
|
|
544
|
+
},
|
|
545
|
+
};
|
|
546
|
+
},
|
|
321
547
|
});
|
|
548
|
+
let response = await sendRelayAttempt();
|
|
549
|
+
await Promise.all(relayReports);
|
|
550
|
+
if (relay401Attempt &&
|
|
551
|
+
response.status === 401 &&
|
|
552
|
+
!retried401 &&
|
|
553
|
+
!init.signal?.aborted) {
|
|
554
|
+
const rotated = await native
|
|
555
|
+
.retry(routeId, relay401Attempt, init.signal ?? undefined, options.model.id)
|
|
556
|
+
.catch(() => undefined);
|
|
557
|
+
if (rotated) {
|
|
558
|
+
retried401 = true;
|
|
559
|
+
await response.body?.cancel().catch(() => { });
|
|
560
|
+
// Use the replacement credential only for the retried relay request.
|
|
561
|
+
// A direct request after relay failure obtains its own authorization.
|
|
562
|
+
nextAttempt = rotated;
|
|
563
|
+
relay401Attempt = undefined;
|
|
564
|
+
response = await sendRelayAttempt();
|
|
565
|
+
await Promise.all(relayReports);
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
if (response.ok && lastTransport === 'relay' && relayAttempt)
|
|
569
|
+
markServed(relayAttempt);
|
|
570
|
+
return response;
|
|
322
571
|
}
|
|
323
572
|
function quotaSnapshotIsExhausted(quota) {
|
|
324
573
|
return ['five_hour', 'seven_day'].some((key) => (quota?.[key]?.remainingPercent ?? 1) <= 0);
|
|
@@ -327,50 +576,192 @@ export function primaryResponseAllowsApiFallback(preflight) {
|
|
|
327
576
|
return (preflight === 'rate_limit_error' ||
|
|
328
577
|
(preflight instanceof Response && preflight.status === 429));
|
|
329
578
|
}
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
579
|
+
/**
|
|
580
|
+
* Bytes read while looking for a successful stream's first event. Comments
|
|
581
|
+
* and heartbeats carry no event, so a stream of them alone must not keep the
|
|
582
|
+
* inspection reading (and buffering) without end.
|
|
583
|
+
*/
|
|
584
|
+
const FIRST_EVENT_INSPECTION_BYTES = 65_536;
|
|
585
|
+
/**
|
|
586
|
+
* Classify a successful stream by its first event without a tee: the bytes
|
|
587
|
+
* read are replayed in front of the rest of the body, so cancelling the
|
|
588
|
+
* returned response cancels the provider body itself. `preflight` is
|
|
589
|
+
* `rate_limit_error` for a streamed rate limit (the provider body is then
|
|
590
|
+
* cancelled and only the bytes read are replayed), else the returned
|
|
591
|
+
* response. A stream whose first event does not arrive within the budget, or
|
|
592
|
+
* cannot be read, is returned unclassified.
|
|
593
|
+
*/
|
|
594
|
+
export async function firstStreamingError(response) {
|
|
595
|
+
if (!response.ok || !response.body)
|
|
596
|
+
return { response, preflight: response };
|
|
597
|
+
const reader = response.body.getReader();
|
|
598
|
+
const chunks = [];
|
|
599
|
+
const framer = createSseFramer({
|
|
600
|
+
maxFrameBytes: FIRST_EVENT_INSPECTION_BYTES,
|
|
601
|
+
});
|
|
602
|
+
let bytes = 0;
|
|
603
|
+
let rateLimited = false;
|
|
604
|
+
let decided = false;
|
|
605
|
+
// Only the stream's first frame may drop a leading byte-order mark.
|
|
606
|
+
let streamStart = true;
|
|
334
607
|
try {
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
608
|
+
while (!decided && bytes < FIRST_EVENT_INSPECTION_BYTES) {
|
|
609
|
+
const { done, value } = await reader.read();
|
|
610
|
+
if (done)
|
|
611
|
+
break;
|
|
612
|
+
chunks.push(value);
|
|
613
|
+
bytes += value.byteLength;
|
|
614
|
+
for (const frame of framer.push(value)) {
|
|
615
|
+
const data = parseSseFrame(decodeSseFrame(frame, { streamStart })).data;
|
|
616
|
+
streamStart = false;
|
|
617
|
+
if (!data || data === '[DONE]')
|
|
618
|
+
continue;
|
|
619
|
+
const event = JSON.parse(data);
|
|
620
|
+
// Anthropic reports a streamed rate limit as an `error` event whose
|
|
621
|
+
// `error.type` is `rate_limit_error`; only that event is a rate limit.
|
|
622
|
+
rateLimited =
|
|
623
|
+
event.type === 'error' && event.error?.type === 'rate_limit_error';
|
|
624
|
+
decided = true;
|
|
625
|
+
break;
|
|
340
626
|
}
|
|
341
|
-
return response;
|
|
342
627
|
}
|
|
343
628
|
}
|
|
344
629
|
catch {
|
|
345
|
-
|
|
630
|
+
// An unreadable body, an oversized first frame or a first event that is
|
|
631
|
+
// not JSON is not treated as a rate limit: the response is returned for
|
|
632
|
+
// the consumer to read, and an unreadable body fails that read too.
|
|
346
633
|
}
|
|
347
|
-
|
|
634
|
+
// The provider reader is released as soon as nothing will read it again:
|
|
635
|
+
// at end of stream, after a read failure, or once a cancellation settles.
|
|
636
|
+
const release = () => {
|
|
637
|
+
try {
|
|
638
|
+
reader.releaseLock();
|
|
639
|
+
}
|
|
640
|
+
catch {
|
|
641
|
+
// Already released.
|
|
642
|
+
}
|
|
643
|
+
};
|
|
644
|
+
const replay = (rest) => new Response(new ReadableStream({
|
|
645
|
+
start(controller) {
|
|
646
|
+
for (const chunk of chunks)
|
|
647
|
+
controller.enqueue(chunk);
|
|
648
|
+
if (!rest)
|
|
649
|
+
controller.close();
|
|
650
|
+
},
|
|
651
|
+
async pull(controller) {
|
|
652
|
+
let next;
|
|
653
|
+
try {
|
|
654
|
+
next = await reader.read();
|
|
655
|
+
}
|
|
656
|
+
catch (error) {
|
|
657
|
+
release();
|
|
658
|
+
throw error;
|
|
659
|
+
}
|
|
660
|
+
if (next.done) {
|
|
661
|
+
release();
|
|
662
|
+
controller.close();
|
|
663
|
+
}
|
|
664
|
+
else
|
|
665
|
+
controller.enqueue(next.value);
|
|
666
|
+
},
|
|
667
|
+
async cancel(reason) {
|
|
668
|
+
if (!rest)
|
|
669
|
+
return;
|
|
670
|
+
try {
|
|
671
|
+
await reader.cancel(reason);
|
|
672
|
+
}
|
|
673
|
+
finally {
|
|
674
|
+
release();
|
|
675
|
+
}
|
|
676
|
+
},
|
|
677
|
+
}), {
|
|
678
|
+
status: response.status,
|
|
679
|
+
statusText: response.statusText,
|
|
680
|
+
headers: response.headers,
|
|
681
|
+
});
|
|
682
|
+
if (rateLimited) {
|
|
683
|
+
// Routing replaces this response. Cancel the provider body and clear its
|
|
684
|
+
// copied redaction material; retain the inspected error event for callers
|
|
685
|
+
// that still return the replay response.
|
|
686
|
+
await reader.cancel().catch(() => { });
|
|
687
|
+
release();
|
|
688
|
+
return { response: replay(false), preflight: 'rate_limit_error' };
|
|
689
|
+
}
|
|
690
|
+
const inspected = replay(true);
|
|
691
|
+
return { response: inspected, preflight: inspected };
|
|
348
692
|
}
|
|
349
693
|
async function executeWithFallback(options) {
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
const
|
|
694
|
+
const native = getPiNativeRuntime(options.storagePath);
|
|
695
|
+
let snapshot = await native.requestView(options.streamOptions?.signal);
|
|
696
|
+
let storage = snapshot.policyStorage;
|
|
697
|
+
const accountView = (id) => snapshot.accounts.find((account) => account.id === id);
|
|
698
|
+
const vaultAccountIdentities = () => {
|
|
699
|
+
if (snapshot.mode !== 'claustrum')
|
|
700
|
+
return undefined;
|
|
701
|
+
const identities = new Map();
|
|
702
|
+
for (const account of snapshot.accounts) {
|
|
703
|
+
if (account.source === 'vault' && account.accountIdentity)
|
|
704
|
+
identities.set(account.id, account.accountIdentity);
|
|
705
|
+
}
|
|
706
|
+
return identities;
|
|
707
|
+
};
|
|
708
|
+
const nativeReady = (id) => {
|
|
709
|
+
const account = accountView(id);
|
|
710
|
+
return Boolean(account?.type === 'oauth' &&
|
|
711
|
+
account.enabled &&
|
|
712
|
+
(account.source !== 'vault' || account.state === 'active') &&
|
|
713
|
+
!isPermanentRefreshError(account.lastRefreshError) &&
|
|
714
|
+
(account.source !== 'local' ||
|
|
715
|
+
(account.lastRefreshError?.nextRetryAt ?? 0) <= Date.now()));
|
|
716
|
+
};
|
|
717
|
+
const primaryAvailable = () => nativeReady(STICKY_ROUTING_MAIN_ACCOUNT_ID);
|
|
718
|
+
let { quotaManager, getUsableFallbackAccounts } = getPiRoutingServices(options.storagePath, snapshot);
|
|
719
|
+
let mainAccountId = accountView(STICKY_ROUTING_MAIN_ACCOUNT_ID)?.accountIdentity;
|
|
354
720
|
quotaManager.seedMainFromStorage(storage, mainAccountId);
|
|
355
721
|
quotaManager.seedFallbacksFromAccounts((storage?.accounts ?? []).filter(isOAuthAccount));
|
|
722
|
+
function quotaObservationIsFresh(quota, modelId) {
|
|
723
|
+
if (stickyQuotaSnapshotIsFresh(quota, storage, Date.now(), modelId))
|
|
724
|
+
return true;
|
|
725
|
+
// A successful usage response without quota windows is not a failed poll.
|
|
726
|
+
// Pi can serve that OAuth account without a known remaining capacity.
|
|
727
|
+
if (!quota ||
|
|
728
|
+
quota.five_hour ||
|
|
729
|
+
quota.seven_day ||
|
|
730
|
+
quota.source !== 'poll' ||
|
|
731
|
+
quota.checkedAt === undefined)
|
|
732
|
+
return false;
|
|
733
|
+
const age = getQuotaCheckIntervalMs(storage);
|
|
734
|
+
const scopedWindow = getScopedQuotaWindowForModel(quota, modelId);
|
|
735
|
+
return (Date.now() - quota.checkedAt < age &&
|
|
736
|
+
(!scopedWindow || Date.now() - scopedWindow.checkedAt < age));
|
|
737
|
+
}
|
|
738
|
+
async function refreshRoutingSnapshot() {
|
|
739
|
+
snapshot = await native.view();
|
|
740
|
+
storage = snapshot.policyStorage;
|
|
741
|
+
mainAccountId = accountView(STICKY_ROUTING_MAIN_ACCOUNT_ID)?.accountIdentity;
|
|
742
|
+
({ quotaManager, getUsableFallbackAccounts } = getPiRoutingServices(options.storagePath, snapshot));
|
|
743
|
+
quotaManager.seedMainFromStorage(storage, mainAccountId);
|
|
744
|
+
quotaManager.seedFallbacksFromAccounts(storage.accounts.filter(isOAuthAccount));
|
|
745
|
+
}
|
|
356
746
|
async function buildStickyRoutes(modelId) {
|
|
747
|
+
await refreshRoutingSnapshot();
|
|
357
748
|
const mainEntry = quotaManager.getMain(mainAccountId);
|
|
358
749
|
let mainQuota = mainEntry?.quota;
|
|
359
|
-
if (
|
|
750
|
+
if (primaryAvailable() &&
|
|
751
|
+
!stickyQuotaSnapshotIsFresh(mainEntry?.quota, storage, Date.now(), modelId)) {
|
|
360
752
|
try {
|
|
361
|
-
mainQuota = await quotaManager.refreshMain(mainAccountId,
|
|
753
|
+
mainQuota = await quotaManager.refreshMain(mainAccountId, '');
|
|
362
754
|
}
|
|
363
755
|
catch { }
|
|
364
756
|
}
|
|
365
|
-
const usableFallbacks = await
|
|
757
|
+
const usableFallbacks = await getUsableFallbackAccounts(storage, {
|
|
366
758
|
modelId,
|
|
367
759
|
});
|
|
368
760
|
const usableById = new Map(usableFallbacks.map((account) => [account.id, account]));
|
|
369
761
|
const allRoutes = [];
|
|
370
|
-
if (
|
|
762
|
+
if (primaryAvailable()) {
|
|
371
763
|
allRoutes.push({
|
|
372
764
|
id: STICKY_ROUTING_MAIN_ACCOUNT_ID,
|
|
373
|
-
access: options.primaryAccessToken,
|
|
374
765
|
quota: mainQuota,
|
|
375
766
|
order: 0,
|
|
376
767
|
});
|
|
@@ -379,18 +770,17 @@ async function executeWithFallback(options) {
|
|
|
379
770
|
if (configured.enabled === false || !isOAuthAccount(configured))
|
|
380
771
|
continue;
|
|
381
772
|
const account = usableById.get(configured.id) ?? configured;
|
|
382
|
-
if (!
|
|
773
|
+
if (!nativeReady(account.id))
|
|
383
774
|
continue;
|
|
384
775
|
let accountQuota = quotaManager.getFallback(account.id, account)?.quota ?? account.quota;
|
|
385
776
|
if (!stickyQuotaSnapshotIsFresh(accountQuota, storage, Date.now(), modelId)) {
|
|
386
777
|
try {
|
|
387
|
-
accountQuota = await quotaManager.refreshFallback(account.id,
|
|
778
|
+
accountQuota = await quotaManager.refreshFallback(account.id, '', account);
|
|
388
779
|
}
|
|
389
780
|
catch { }
|
|
390
781
|
}
|
|
391
782
|
allRoutes.push({
|
|
392
783
|
id: account.id,
|
|
393
|
-
access: account.access,
|
|
394
784
|
quota: accountQuota,
|
|
395
785
|
order: index + 1,
|
|
396
786
|
account,
|
|
@@ -443,8 +833,11 @@ async function executeWithFallback(options) {
|
|
|
443
833
|
return { allRoutes, candidates, retainAccountIds };
|
|
444
834
|
}
|
|
445
835
|
async function primaryQuotaRefreshConfirmsExhausted() {
|
|
836
|
+
await refreshRoutingSnapshot();
|
|
837
|
+
if (!primaryAvailable() || !mainAccountId)
|
|
838
|
+
return false;
|
|
446
839
|
try {
|
|
447
|
-
const quota = await quotaManager.refreshMain(mainAccountId,
|
|
840
|
+
const quota = await quotaManager.refreshMain(mainAccountId, '');
|
|
448
841
|
const entry = quotaManager.getMain(mainAccountId);
|
|
449
842
|
return Boolean(entry &&
|
|
450
843
|
entry.refreshAfter > Date.now() &&
|
|
@@ -455,8 +848,11 @@ async function executeWithFallback(options) {
|
|
|
455
848
|
}
|
|
456
849
|
}
|
|
457
850
|
async function primaryQuotaRefreshConfirmsModelScopeExhausted() {
|
|
851
|
+
await refreshRoutingSnapshot();
|
|
852
|
+
if (!primaryAvailable())
|
|
853
|
+
return false;
|
|
458
854
|
try {
|
|
459
|
-
const quota = await quotaManager.refreshMain(mainAccountId,
|
|
855
|
+
const quota = await quotaManager.refreshMain(mainAccountId, '');
|
|
460
856
|
const entry = quotaManager.getMain(mainAccountId);
|
|
461
857
|
return Boolean(entry &&
|
|
462
858
|
entry.refreshAfter > Date.now() &&
|
|
@@ -467,62 +863,51 @@ async function executeWithFallback(options) {
|
|
|
467
863
|
}
|
|
468
864
|
}
|
|
469
865
|
function primaryCachedModelScopeExhausted() {
|
|
866
|
+
if (!primaryAvailable())
|
|
867
|
+
return false;
|
|
470
868
|
const entry = quotaManager.getMain(mainAccountId);
|
|
471
869
|
return Boolean(entry &&
|
|
472
870
|
quotaSnapshotModelScopeIsExhausted(entry.quota, options.model.id));
|
|
473
871
|
}
|
|
474
872
|
function primaryFreshModelScopeExhausted() {
|
|
873
|
+
if (!primaryAvailable())
|
|
874
|
+
return false;
|
|
475
875
|
const entry = quotaManager.getMain(mainAccountId);
|
|
476
876
|
return Boolean(entry &&
|
|
477
877
|
!quotaManager.isMainStale(options.model.id) &&
|
|
478
878
|
quotaSnapshotModelScopeIsExhausted(entry.quota, options.model.id));
|
|
479
879
|
}
|
|
480
880
|
async function tryFallbackAccounts(routeOptions = {}) {
|
|
481
|
-
const usableOAuth = await
|
|
881
|
+
const usableOAuth = await getUsableFallbackAccounts(storage, {
|
|
482
882
|
modelId: options.model.id,
|
|
483
883
|
});
|
|
484
884
|
const usableOAuthById = new Map(usableOAuth.map((account) => [account.id, account]));
|
|
485
|
-
for (const configured of storage
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
if (!isOAuthAccount(configured) ||
|
|
492
|
-
!configured.access ||
|
|
493
|
-
(configured.expires !== undefined &&
|
|
494
|
-
configured.expires <= Date.now()) ||
|
|
495
|
-
isPermanentRefreshError(configured.lastRefreshError) ||
|
|
496
|
-
refreshBackoffActive(configured.lastRefreshError, configured.id, Date.now(), tokenFingerprint(configured.refresh)))
|
|
885
|
+
for (const configured of storage.accounts) {
|
|
886
|
+
if (configured.enabled === false)
|
|
887
|
+
continue;
|
|
888
|
+
let response;
|
|
889
|
+
if (isOAuthAccount(configured)) {
|
|
890
|
+
if (routeOptions.apiOnly || !nativeReady(configured.id))
|
|
497
891
|
continue;
|
|
498
|
-
account = configured;
|
|
499
|
-
const quota = quotaManager.getFallback(
|
|
500
|
-
|
|
501
|
-
|
|
892
|
+
const account = usableOAuthById.get(configured.id) ?? configured;
|
|
893
|
+
const quota = quotaManager.getFallback(account.id, account)?.quota ?? account.quota;
|
|
894
|
+
if (!usableOAuthById.has(account.id) &&
|
|
895
|
+
quotaSnapshotHasStandardWindows(quota))
|
|
502
896
|
continue;
|
|
503
897
|
if (isKillswitchEnabled(storage) &&
|
|
504
|
-
!killswitchPassesPolicy(quota, storage,
|
|
505
|
-
continue;
|
|
506
|
-
}
|
|
507
|
-
else if (isOAuthAccount(account) &&
|
|
508
|
-
isKillswitchEnabled(storage) &&
|
|
509
|
-
!killswitchPassesPolicy(quotaManager.getFallback(account.id, account)?.quota ?? account.quota, storage, account.id, options.model.id)) {
|
|
510
|
-
continue;
|
|
511
|
-
}
|
|
512
|
-
if (isOAuthAccount(account)) {
|
|
513
|
-
if (routeOptions.apiOnly === true || !account.access)
|
|
898
|
+
!killswitchPassesPolicy(quota, storage, account.id, options.model.id))
|
|
514
899
|
continue;
|
|
515
900
|
response = await sendAnthropicRequest({
|
|
516
901
|
...options,
|
|
517
|
-
accessToken: account.access,
|
|
518
902
|
oauthAccountId: account.id,
|
|
519
903
|
});
|
|
520
904
|
}
|
|
521
|
-
else if (routeOptions.includeApiRoutes
|
|
522
|
-
|
|
523
|
-
account
|
|
524
|
-
|
|
525
|
-
|
|
905
|
+
else if (routeOptions.includeApiRoutes) {
|
|
906
|
+
const account = accountView(configured.id);
|
|
907
|
+
if (!account?.enabled ||
|
|
908
|
+
account.type !== 'api' ||
|
|
909
|
+
!isValidApiBaseURL(account.baseURL))
|
|
910
|
+
continue;
|
|
526
911
|
response = await sendAnthropicRequest({
|
|
527
912
|
...options,
|
|
528
913
|
apiAccount: account,
|
|
@@ -530,35 +915,36 @@ async function executeWithFallback(options) {
|
|
|
530
915
|
}
|
|
531
916
|
if (!response)
|
|
532
917
|
continue;
|
|
533
|
-
const
|
|
534
|
-
|
|
535
|
-
|
|
918
|
+
const inspected = await firstStreamingError(response);
|
|
919
|
+
const preflight = inspected.preflight;
|
|
920
|
+
if (preflight instanceof Response && preflight.ok)
|
|
536
921
|
return preflight;
|
|
537
|
-
}
|
|
538
922
|
if (preflight instanceof Response &&
|
|
539
|
-
!shouldFallbackStatus(preflight.status, storage))
|
|
923
|
+
!shouldFallbackStatus(preflight.status, storage))
|
|
540
924
|
return preflight;
|
|
541
|
-
}
|
|
542
|
-
await response.body?.cancel().catch(() => { });
|
|
925
|
+
await inspected.response.body?.cancel().catch(() => { });
|
|
543
926
|
}
|
|
544
927
|
return null;
|
|
545
928
|
}
|
|
546
929
|
const routingMode = getRoutingMode(storage);
|
|
547
930
|
if (routingMode === 'sticky-balanced' && options.streamOptions?.sessionId) {
|
|
548
931
|
const sessionId = options.streamOptions.sessionId;
|
|
549
|
-
const router = getPiStickyRouter(options.storagePath);
|
|
932
|
+
const router = await getPiStickyRouter(options.storagePath);
|
|
550
933
|
const initialInputBytes = Math.max(1, Buffer.byteLength(JSON.stringify(options.context)));
|
|
551
934
|
let routes = await buildStickyRoutes(options.model.id);
|
|
552
|
-
const mainPermanentlyUnavailable =
|
|
935
|
+
const mainPermanentlyUnavailable = !primaryAvailable();
|
|
553
936
|
const incompleteQuotaPool = (routes.allRoutes.length === 0 && !mainPermanentlyUnavailable) ||
|
|
554
|
-
routes.allRoutes.some((candidate) => !candidate.quota
|
|
555
|
-
!stickyQuotaSnapshotIsFresh(candidate.quota, storage, Date.now(), options.model.id));
|
|
937
|
+
routes.allRoutes.some((candidate) => !quotaObservationIsFresh(candidate.quota, options.model.id));
|
|
556
938
|
let resolution = await router.resolve({
|
|
557
939
|
sessionId,
|
|
558
940
|
family: stickyRouteFamilyForModel(options.model.id),
|
|
559
941
|
modelId: options.model.id,
|
|
560
|
-
|
|
942
|
+
affinityModelId: options.model.id,
|
|
943
|
+
// Keep an existing route after a temporary quota poll failure, but do not
|
|
944
|
+
// choose a new balanced route until the account pool has current quota data.
|
|
945
|
+
candidates: incompleteQuotaPool ? [] : routes.candidates,
|
|
561
946
|
retainAccountIds: routes.retainAccountIds,
|
|
947
|
+
accountIdentities: vaultAccountIdentities(),
|
|
562
948
|
storage,
|
|
563
949
|
inputBytes: initialInputBytes,
|
|
564
950
|
});
|
|
@@ -571,6 +957,8 @@ async function executeWithFallback(options) {
|
|
|
571
957
|
throw error;
|
|
572
958
|
}
|
|
573
959
|
if (!resolution) {
|
|
960
|
+
if (snapshot.mode === 'claustrum')
|
|
961
|
+
throw new NativeVaultNoAccountError();
|
|
574
962
|
return createStickyNoRouteResponse({
|
|
575
963
|
mainRefreshError: storage?.refresh?.mainLastRefreshError,
|
|
576
964
|
fallbackReauthLabels: getFallbackReauthLabels(storage),
|
|
@@ -582,15 +970,10 @@ async function executeWithFallback(options) {
|
|
|
582
970
|
if (resolution && route) {
|
|
583
971
|
const sendRoute = (selected) => sendAnthropicRequest({
|
|
584
972
|
...options,
|
|
585
|
-
accessToken: selected.access,
|
|
586
973
|
oauthAccountId: selected.id,
|
|
587
974
|
route: `sticky:${selected.id}`,
|
|
588
975
|
});
|
|
589
|
-
const completeRoute = async (
|
|
590
|
-
if (markUsed && selected.account)
|
|
591
|
-
await manager.markUsed(selected.account);
|
|
592
|
-
return response;
|
|
593
|
-
};
|
|
976
|
+
const completeRoute = async (_selected, response, _markUsed = true) => response;
|
|
594
977
|
const proactiveQuotaDecision = stickyQuotaSnapshotIsFresh(route.quota, storage, Date.now(), options.model.id)
|
|
595
978
|
? decideStickyQuotaFailure({
|
|
596
979
|
quota: route.quota,
|
|
@@ -612,43 +995,11 @@ async function executeWithFallback(options) {
|
|
|
612
995
|
},
|
|
613
996
|
}), false);
|
|
614
997
|
}
|
|
615
|
-
|
|
616
|
-
let preflight = await firstStreamingError(response);
|
|
998
|
+
const { response, preflight } = await firstStreamingError(await sendRoute(route));
|
|
617
999
|
if (preflight instanceof Response && preflight.ok) {
|
|
618
1000
|
return completeRoute(route, preflight);
|
|
619
1001
|
}
|
|
620
|
-
|
|
621
|
-
preflight.status === 401 &&
|
|
622
|
-
route.id === STICKY_ROUTING_MAIN_ACCOUNT_ID;
|
|
623
|
-
if (preflight instanceof Response &&
|
|
624
|
-
preflight.status === 401 &&
|
|
625
|
-
route.account &&
|
|
626
|
-
storage) {
|
|
627
|
-
const authRouteId = route.id;
|
|
628
|
-
try {
|
|
629
|
-
const refreshed = await manager.refreshAccount(route.account, storage, {
|
|
630
|
-
force: true,
|
|
631
|
-
});
|
|
632
|
-
if (refreshed.access) {
|
|
633
|
-
route = { ...route, access: refreshed.access, account: refreshed };
|
|
634
|
-
}
|
|
635
|
-
await preflight.body?.cancel().catch(() => { });
|
|
636
|
-
response = await sendRoute(route);
|
|
637
|
-
preflight = await firstStreamingError(response);
|
|
638
|
-
if (preflight instanceof Response && preflight.ok) {
|
|
639
|
-
return completeRoute(route, preflight);
|
|
640
|
-
}
|
|
641
|
-
permanentAuthFailure =
|
|
642
|
-
preflight instanceof Response && preflight.status === 401;
|
|
643
|
-
}
|
|
644
|
-
catch (error) {
|
|
645
|
-
const latest = await loadAccounts(options.storagePath);
|
|
646
|
-
const refreshError = latest?.accounts.find((account) => account.id === authRouteId && isOAuthAccount(account))?.lastRefreshError;
|
|
647
|
-
if (!isPermanentRefreshError(refreshError))
|
|
648
|
-
throw error;
|
|
649
|
-
permanentAuthFailure = true;
|
|
650
|
-
}
|
|
651
|
-
}
|
|
1002
|
+
const permanentAuthFailure = preflight instanceof Response && preflight.status === 401;
|
|
652
1003
|
let migrate = (preflight instanceof Response && preflight.status === 403) ||
|
|
653
1004
|
permanentAuthFailure;
|
|
654
1005
|
if (primaryResponseAllowsApiFallback(preflight)) {
|
|
@@ -656,8 +1007,8 @@ async function executeWithFallback(options) {
|
|
|
656
1007
|
try {
|
|
657
1008
|
quota =
|
|
658
1009
|
route.id === STICKY_ROUTING_MAIN_ACCOUNT_ID
|
|
659
|
-
? await quotaManager.refreshMain(mainAccountId,
|
|
660
|
-
: await quotaManager.refreshFallback(route.id,
|
|
1010
|
+
? await quotaManager.refreshMain(mainAccountId, '')
|
|
1011
|
+
: await quotaManager.refreshFallback(route.id, '', route.account);
|
|
661
1012
|
}
|
|
662
1013
|
catch {
|
|
663
1014
|
// Retain affinity when the quota probe itself is unavailable.
|
|
@@ -707,6 +1058,7 @@ async function executeWithFallback(options) {
|
|
|
707
1058
|
modelId: options.model.id,
|
|
708
1059
|
candidates: routes.candidates,
|
|
709
1060
|
retainAccountIds: routes.retainAccountIds,
|
|
1061
|
+
accountIdentities: vaultAccountIdentities(),
|
|
710
1062
|
storage,
|
|
711
1063
|
inputBytes: initialInputBytes,
|
|
712
1064
|
excludeAccountIds: new Set([failedRouteId]),
|
|
@@ -749,11 +1101,26 @@ async function executeWithFallback(options) {
|
|
|
749
1101
|
if (fallback)
|
|
750
1102
|
return fallback;
|
|
751
1103
|
}
|
|
1104
|
+
if (!primaryAvailable()) {
|
|
1105
|
+
if (!fallbackFirst) {
|
|
1106
|
+
const fallback = await tryFallbackAccounts();
|
|
1107
|
+
if (fallback)
|
|
1108
|
+
return fallback;
|
|
1109
|
+
}
|
|
1110
|
+
if (snapshot.mode === 'claustrum')
|
|
1111
|
+
throw new NativeVaultNoAccountError();
|
|
1112
|
+
return createStickyNoRouteResponse({
|
|
1113
|
+
routeQuotas: (storage?.accounts ?? [])
|
|
1114
|
+
.filter(isOAuthAccount)
|
|
1115
|
+
.flatMap((account) => (account.quota ? [account.quota] : [])),
|
|
1116
|
+
modelId: options.model.id,
|
|
1117
|
+
});
|
|
1118
|
+
}
|
|
752
1119
|
if (isKillswitchEnabled(storage)) {
|
|
753
1120
|
let mainQuota = quotaManager.getMain(mainAccountId)?.quota;
|
|
754
1121
|
if (!mainQuota || quotaManager.isMainStale(options.model.id)) {
|
|
755
1122
|
try {
|
|
756
|
-
mainQuota = await quotaManager.refreshMain(mainAccountId,
|
|
1123
|
+
mainQuota = await quotaManager.refreshMain(mainAccountId, '');
|
|
757
1124
|
}
|
|
758
1125
|
catch { }
|
|
759
1126
|
}
|
|
@@ -778,12 +1145,10 @@ async function executeWithFallback(options) {
|
|
|
778
1145
|
});
|
|
779
1146
|
}
|
|
780
1147
|
}
|
|
781
|
-
const primary = await sendAnthropicRequest({
|
|
1148
|
+
const { response: primary, preflight: primaryPreflight } = await firstStreamingError(await sendAnthropicRequest({
|
|
782
1149
|
...options,
|
|
783
|
-
accessToken: options.primaryAccessToken,
|
|
784
1150
|
oauthAccountId: STICKY_ROUTING_MAIN_ACCOUNT_ID,
|
|
785
|
-
});
|
|
786
|
-
const primaryPreflight = await firstStreamingError(primary);
|
|
1151
|
+
}));
|
|
787
1152
|
if (primaryPreflight instanceof Response) {
|
|
788
1153
|
if (!shouldFallbackStatus(primaryPreflight.status, storage))
|
|
789
1154
|
return primaryPreflight;
|
|
@@ -811,17 +1176,18 @@ export function streamCortexKitAnthropic(model, context, options, effortTransiti
|
|
|
811
1176
|
const output = createOutput(model);
|
|
812
1177
|
stream.push({ type: 'start', partial: output });
|
|
813
1178
|
try {
|
|
814
|
-
|
|
815
|
-
if (!accessToken)
|
|
816
|
-
throw new Error('Missing Anthropic OAuth access token');
|
|
1179
|
+
buildPiOAuthMessagesUrl(model.baseUrl);
|
|
817
1180
|
const storagePath = getPiAccountStoragePath();
|
|
1181
|
+
let hostTools = [];
|
|
818
1182
|
const response = await executeWithFallback({
|
|
819
1183
|
model,
|
|
820
1184
|
context,
|
|
821
1185
|
streamOptions: options,
|
|
822
|
-
primaryAccessToken: accessToken,
|
|
823
1186
|
storagePath,
|
|
824
1187
|
effortTransitions,
|
|
1188
|
+
onResolvedTools: (tools) => {
|
|
1189
|
+
hostTools = tools;
|
|
1190
|
+
},
|
|
825
1191
|
});
|
|
826
1192
|
if (!response.ok) {
|
|
827
1193
|
throw new Error(`Anthropic request failed: HTTP ${response.status} ${await response.text()}`);
|
|
@@ -876,7 +1242,7 @@ export function streamCortexKitAnthropic(model, context, options, effortTransiti
|
|
|
876
1242
|
output.content.push({
|
|
877
1243
|
type: 'toolCall',
|
|
878
1244
|
id: String(block.id),
|
|
879
|
-
name: fromClaudeCodeToolName(String(block.name),
|
|
1245
|
+
name: fromClaudeCodeToolName(String(block.name), hostTools),
|
|
880
1246
|
arguments: {},
|
|
881
1247
|
partialJson: '',
|
|
882
1248
|
index: event.index,
|