@oh-my-pi/pi-ai 18.3.4 → 18.4.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/CHANGELOG.md +35 -48
- package/dist/types/auth/blocks.d.ts +10 -19
- package/dist/types/auth/cascade.d.ts +1 -1
- package/dist/types/auth/rotation.d.ts +6 -3
- package/dist/types/auth/types.d.ts +23 -3
- package/dist/types/auth/usage-cache.d.ts +14 -2
- package/dist/types/auth/usage.d.ts +2 -2
- package/dist/types/auth-broker/client.d.ts +2 -1
- package/dist/types/auth-broker/remote-store.d.ts +5 -6
- package/dist/types/auth-broker/types.d.ts +3 -1
- package/dist/types/auth-broker/wire-schemas.d.ts +2 -1
- package/dist/types/auth-retry.d.ts +22 -13
- package/dist/types/images/types.d.ts +9 -0
- package/dist/types/index.d.ts +1 -0
- package/dist/types/judgment/types.d.ts +3 -2
- package/dist/types/providers/cursor/exec-modern.d.ts +1 -1
- package/dist/types/providers/cursor-pi-args.d.ts +10 -0
- package/dist/types/providers/cursor.d.ts +6 -2
- package/dist/types/providers/google-shared.d.ts +13 -2
- package/dist/types/types.d.ts +0 -10
- package/dist/types/usage/claude-reset.d.ts +6 -0
- package/dist/types/usage/claude.d.ts +6 -0
- package/dist/types/usage.d.ts +2 -0
- package/dist/types/utils/proxy.d.ts +2 -2
- package/package.json +6 -6
- package/src/auth/blocks.ts +79 -37
- package/src/auth/cascade.ts +5 -4
- package/src/auth/resets.ts +104 -40
- package/src/auth/rotation.ts +62 -21
- package/src/auth/select.ts +9 -2
- package/src/auth/types.ts +28 -3
- package/src/auth/usage-cache.ts +45 -2
- package/src/auth/usage.ts +52 -25
- package/src/auth-broker/client.ts +13 -0
- package/src/auth-broker/remote-store.ts +128 -27
- package/src/auth-broker/server.ts +20 -0
- package/src/auth-broker/types.ts +4 -1
- package/src/auth-broker/wire-schemas.ts +7 -0
- package/src/auth-retry.ts +50 -28
- package/src/images/openai-hosted.ts +18 -3
- package/src/images/types.ts +9 -0
- package/src/index.ts +1 -0
- package/src/judgment/types.ts +10 -6
- package/src/judgment/typesafe.ts +2 -2
- package/src/providers/anthropic.ts +54 -123
- package/src/providers/cursor/exec-modern.ts +1 -0
- package/src/providers/cursor/proto/agent.proto +5 -0
- package/src/providers/cursor-pi-args.ts +14 -0
- package/src/providers/cursor.ts +66 -28
- package/src/providers/google-gemini-cli.ts +2 -19
- package/src/providers/google-shared.ts +30 -22
- package/src/providers/ollama.ts +2 -0
- package/src/registry/oauth/oauth.html +206 -185
- package/src/stream.ts +2 -284
- package/src/types.ts +0 -10
- package/src/usage/claude-reset.ts +45 -11
- package/src/usage/claude.ts +79 -49
- package/src/usage.ts +2 -0
- package/src/utils/proxy.ts +3 -3
- package/src/utils/schema/CONSTRAINTS.md +30 -28
- package/src/utils/schema/normalize.ts +1 -1
- package/src/utils/schema/wire.ts +3 -3
package/src/stream.ts
CHANGED
|
@@ -24,7 +24,6 @@ import { isConcurrencyCapExclusion, isUsageLimitOutcome } from "./error/rate-lim
|
|
|
24
24
|
import type { BedrockOptions } from "./providers/amazon-bedrock";
|
|
25
25
|
import type { AnthropicOptions } from "./providers/anthropic";
|
|
26
26
|
import type { AppleFoundationModelsOptions } from "./providers/apple-foundation-models";
|
|
27
|
-
import type { MessageCreateParamsStreaming } from "./providers/anthropic-wire";
|
|
28
27
|
import type { CursorOptions } from "./providers/cursor";
|
|
29
28
|
import type { DevinOptions } from "./providers/devin";
|
|
30
29
|
import { streamGitLabDuo } from "./providers/gitlab-duo";
|
|
@@ -62,7 +61,6 @@ import type {
|
|
|
62
61
|
FetchImpl,
|
|
63
62
|
Model,
|
|
64
63
|
OptionsForApi,
|
|
65
|
-
ProviderSessionState,
|
|
66
64
|
SimpleStreamOptions,
|
|
67
65
|
StreamOptions,
|
|
68
66
|
ThinkingBudgets,
|
|
@@ -1205,285 +1203,6 @@ function emitBufferedEvents(stream: AssistantMessageEventStream, events: Assista
|
|
|
1205
1203
|
stream.push(event);
|
|
1206
1204
|
}
|
|
1207
1205
|
}
|
|
1208
|
-
|
|
1209
|
-
const ANTHROPIC_CACHE_TTL_MS = 5 * 60_000;
|
|
1210
|
-
const ANTHROPIC_CACHE_REFRESH_LEAD_MS = 15_000;
|
|
1211
|
-
const ANTHROPIC_CACHE_REFRESH_LIMIT = 3;
|
|
1212
|
-
const ANTHROPIC_CACHE_REFRESH_STATE_KEY = "anthropic-cache-refresh";
|
|
1213
|
-
|
|
1214
|
-
interface AnthropicCacheRefreshPlan {
|
|
1215
|
-
refresh(controller: AbortController): Promise<number | undefined>;
|
|
1216
|
-
}
|
|
1217
|
-
|
|
1218
|
-
class AnthropicCacheRefreshState implements ProviderSessionState {
|
|
1219
|
-
#controller: AbortController | undefined;
|
|
1220
|
-
#generation = 0;
|
|
1221
|
-
#plan: AnthropicCacheRefreshPlan | undefined;
|
|
1222
|
-
#refreshesRemaining = 0;
|
|
1223
|
-
#timer: NodeJS.Timeout | undefined;
|
|
1224
|
-
|
|
1225
|
-
cancel(): void {
|
|
1226
|
-
this.#generation++;
|
|
1227
|
-
if (this.#timer !== undefined) {
|
|
1228
|
-
clearTimeout(this.#timer);
|
|
1229
|
-
this.#timer = undefined;
|
|
1230
|
-
}
|
|
1231
|
-
this.#controller?.abort();
|
|
1232
|
-
this.#controller = undefined;
|
|
1233
|
-
this.#plan = undefined;
|
|
1234
|
-
this.#refreshesRemaining = 0;
|
|
1235
|
-
}
|
|
1236
|
-
|
|
1237
|
-
arm(plan: AnthropicCacheRefreshPlan, cacheTouchedAtMs: number): void {
|
|
1238
|
-
this.cancel();
|
|
1239
|
-
this.#plan = plan;
|
|
1240
|
-
this.#refreshesRemaining = ANTHROPIC_CACHE_REFRESH_LIMIT;
|
|
1241
|
-
this.#schedule(cacheTouchedAtMs, this.#generation);
|
|
1242
|
-
}
|
|
1243
|
-
|
|
1244
|
-
close(): void {
|
|
1245
|
-
this.cancel();
|
|
1246
|
-
}
|
|
1247
|
-
|
|
1248
|
-
#schedule(cacheTouchedAtMs: number, generation: number): void {
|
|
1249
|
-
const refreshAtMs = cacheTouchedAtMs + ANTHROPIC_CACHE_TTL_MS - ANTHROPIC_CACHE_REFRESH_LEAD_MS;
|
|
1250
|
-
this.#timer = setTimeout(
|
|
1251
|
-
() => {
|
|
1252
|
-
this.#timer = undefined;
|
|
1253
|
-
void this.#refresh(generation);
|
|
1254
|
-
},
|
|
1255
|
-
Math.max(0, refreshAtMs - Date.now()),
|
|
1256
|
-
);
|
|
1257
|
-
this.#timer.unref?.();
|
|
1258
|
-
}
|
|
1259
|
-
|
|
1260
|
-
async #refresh(generation: number): Promise<void> {
|
|
1261
|
-
const plan = this.#plan;
|
|
1262
|
-
if (generation !== this.#generation || !plan || this.#refreshesRemaining <= 0) return;
|
|
1263
|
-
|
|
1264
|
-
const controller = new AbortController();
|
|
1265
|
-
this.#controller = controller;
|
|
1266
|
-
let cacheTouchedAtMs: number | undefined;
|
|
1267
|
-
try {
|
|
1268
|
-
cacheTouchedAtMs = await plan.refresh(controller);
|
|
1269
|
-
} catch (error) {
|
|
1270
|
-
if (generation === this.#generation && !controller.signal.aborted) {
|
|
1271
|
-
logger.debug("Anthropic prompt-cache refresh failed", { error: String(error) });
|
|
1272
|
-
}
|
|
1273
|
-
}
|
|
1274
|
-
if (generation !== this.#generation) return;
|
|
1275
|
-
|
|
1276
|
-
this.#controller = undefined;
|
|
1277
|
-
if (cacheTouchedAtMs === undefined) {
|
|
1278
|
-
this.#plan = undefined;
|
|
1279
|
-
this.#refreshesRemaining = 0;
|
|
1280
|
-
return;
|
|
1281
|
-
}
|
|
1282
|
-
|
|
1283
|
-
this.#refreshesRemaining--;
|
|
1284
|
-
if (this.#refreshesRemaining <= 0) {
|
|
1285
|
-
this.#plan = undefined;
|
|
1286
|
-
return;
|
|
1287
|
-
}
|
|
1288
|
-
this.#schedule(cacheTouchedAtMs, generation);
|
|
1289
|
-
}
|
|
1290
|
-
}
|
|
1291
|
-
|
|
1292
|
-
function supportsAnthropicCacheRefresh<TApi extends Api>(model: Model<TApi>): boolean {
|
|
1293
|
-
return (
|
|
1294
|
-
model.api === "anthropic-messages" &&
|
|
1295
|
-
model.provider === "anthropic" &&
|
|
1296
|
-
model.transport !== "pi-native" &&
|
|
1297
|
-
isLeakedThinkingHealExempt(model)
|
|
1298
|
-
);
|
|
1299
|
-
}
|
|
1300
|
-
|
|
1301
|
-
function isAnthropicRefreshPayload(payload: unknown): payload is MessageCreateParamsStreaming {
|
|
1302
|
-
return (
|
|
1303
|
-
typeof payload === "object" &&
|
|
1304
|
-
payload !== null &&
|
|
1305
|
-
"messages" in payload &&
|
|
1306
|
-
Array.isArray(payload.messages) &&
|
|
1307
|
-
"max_tokens" in payload &&
|
|
1308
|
-
typeof payload.max_tokens === "number"
|
|
1309
|
-
);
|
|
1310
|
-
}
|
|
1311
|
-
|
|
1312
|
-
function isShortAnthropicCacheControl(cacheControl: unknown): boolean {
|
|
1313
|
-
return (
|
|
1314
|
-
typeof cacheControl === "object" &&
|
|
1315
|
-
cacheControl !== null &&
|
|
1316
|
-
"type" in cacheControl &&
|
|
1317
|
-
cacheControl.type === "ephemeral" &&
|
|
1318
|
-
(!("ttl" in cacheControl) || cacheControl.ttl !== "1h")
|
|
1319
|
-
);
|
|
1320
|
-
}
|
|
1321
|
-
|
|
1322
|
-
function hasShortAnthropicMessageBreakpoint(payload: MessageCreateParamsStreaming): boolean {
|
|
1323
|
-
for (const message of payload.messages) {
|
|
1324
|
-
if (!Array.isArray(message.content)) continue;
|
|
1325
|
-
for (const block of message.content) {
|
|
1326
|
-
if ("cache_control" in block && isShortAnthropicCacheControl(block.cache_control)) return true;
|
|
1327
|
-
}
|
|
1328
|
-
}
|
|
1329
|
-
return false;
|
|
1330
|
-
}
|
|
1331
|
-
|
|
1332
|
-
function isAnthropicGenerationEvent(event: AssistantMessageEvent): boolean {
|
|
1333
|
-
switch (event.type) {
|
|
1334
|
-
case "text_start":
|
|
1335
|
-
case "thinking_start":
|
|
1336
|
-
case "toolcall_start":
|
|
1337
|
-
case "image_end":
|
|
1338
|
-
return true;
|
|
1339
|
-
case "text_delta":
|
|
1340
|
-
case "thinking_delta":
|
|
1341
|
-
case "toolcall_delta":
|
|
1342
|
-
return event.delta.length > 0;
|
|
1343
|
-
default:
|
|
1344
|
-
return false;
|
|
1345
|
-
}
|
|
1346
|
-
}
|
|
1347
|
-
|
|
1348
|
-
function isAnthropicThinkingActive(model: Model<Api>, payload: MessageCreateParamsStreaming): boolean {
|
|
1349
|
-
if (payload.thinking) return payload.thinking.type !== "disabled";
|
|
1350
|
-
return model.thinking?.mode === "anthropic-adaptive" && payload.output_config?.effort != null;
|
|
1351
|
-
}
|
|
1352
|
-
|
|
1353
|
-
function createAnthropicCacheRefreshPlan<TApi extends Api>(
|
|
1354
|
-
model: Model<TApi>,
|
|
1355
|
-
context: Context,
|
|
1356
|
-
options: SimpleStreamOptions | undefined,
|
|
1357
|
-
payload: MessageCreateParamsStreaming,
|
|
1358
|
-
): AnthropicCacheRefreshPlan {
|
|
1359
|
-
const thinkingEnabled = isAnthropicThinkingActive(model, payload);
|
|
1360
|
-
return {
|
|
1361
|
-
async refresh(controller) {
|
|
1362
|
-
let cacheRead = 0;
|
|
1363
|
-
let cacheWrite = 0;
|
|
1364
|
-
let cacheTouchedAtMs: number | undefined;
|
|
1365
|
-
let canceledAfterGenerationStarted = false;
|
|
1366
|
-
const response = streamSimpleRequest(model, context, {
|
|
1367
|
-
...options,
|
|
1368
|
-
acceptEmptyResponse: true,
|
|
1369
|
-
anthropicCacheRefreshRequest: !thinkingEnabled,
|
|
1370
|
-
cacheRetention: "short",
|
|
1371
|
-
maxTokens: thinkingEnabled ? options?.maxTokens : 0,
|
|
1372
|
-
onPayload: () => ({
|
|
1373
|
-
...payload,
|
|
1374
|
-
max_tokens: thinkingEnabled ? payload.max_tokens : 0,
|
|
1375
|
-
}),
|
|
1376
|
-
onResponse: () => {
|
|
1377
|
-
cacheTouchedAtMs = Date.now();
|
|
1378
|
-
},
|
|
1379
|
-
onSseEvent: undefined,
|
|
1380
|
-
signal: controller.signal,
|
|
1381
|
-
});
|
|
1382
|
-
|
|
1383
|
-
for await (const event of response) {
|
|
1384
|
-
if ("partial" in event) {
|
|
1385
|
-
cacheRead = event.partial.usage.cacheRead;
|
|
1386
|
-
cacheWrite = event.partial.usage.cacheWrite;
|
|
1387
|
-
}
|
|
1388
|
-
if (event.type === "error") return undefined;
|
|
1389
|
-
if (event.type === "done") {
|
|
1390
|
-
cacheRead = event.message.usage.cacheRead;
|
|
1391
|
-
cacheWrite = event.message.usage.cacheWrite;
|
|
1392
|
-
return cacheTouchedAtMs !== undefined && cacheRead > 0 && cacheWrite === 0
|
|
1393
|
-
? cacheTouchedAtMs
|
|
1394
|
-
: undefined;
|
|
1395
|
-
}
|
|
1396
|
-
if (thinkingEnabled && isAnthropicGenerationEvent(event)) {
|
|
1397
|
-
canceledAfterGenerationStarted = true;
|
|
1398
|
-
controller.abort();
|
|
1399
|
-
break;
|
|
1400
|
-
}
|
|
1401
|
-
}
|
|
1402
|
-
|
|
1403
|
-
if (canceledAfterGenerationStarted) {
|
|
1404
|
-
try {
|
|
1405
|
-
await response.result();
|
|
1406
|
-
} catch (error) {
|
|
1407
|
-
if (!controller.signal.aborted) throw error;
|
|
1408
|
-
}
|
|
1409
|
-
}
|
|
1410
|
-
return cacheTouchedAtMs !== undefined && cacheRead > 0 && cacheWrite === 0 ? cacheTouchedAtMs : undefined;
|
|
1411
|
-
},
|
|
1412
|
-
};
|
|
1413
|
-
}
|
|
1414
|
-
|
|
1415
|
-
function streamSimpleWithAnthropicCacheRefresh<TApi extends Api>(
|
|
1416
|
-
model: Model<TApi>,
|
|
1417
|
-
context: Context,
|
|
1418
|
-
options: SimpleStreamOptions | undefined,
|
|
1419
|
-
): AssistantMessageEventStream {
|
|
1420
|
-
const providerSessionState = options?.providerSessionState;
|
|
1421
|
-
if (!options?.anthropicCacheRefresh || !providerSessionState) {
|
|
1422
|
-
return streamSimpleRequest(model, context, options);
|
|
1423
|
-
}
|
|
1424
|
-
|
|
1425
|
-
const existingState = providerSessionState.get(ANTHROPIC_CACHE_REFRESH_STATE_KEY);
|
|
1426
|
-
if (existingState instanceof AnthropicCacheRefreshState) {
|
|
1427
|
-
existingState.cancel();
|
|
1428
|
-
} else if (existingState) {
|
|
1429
|
-
return streamSimpleRequest(model, context, options);
|
|
1430
|
-
}
|
|
1431
|
-
if (!supportsAnthropicCacheRefresh(model) || resolveCacheRetention(options.cacheRetention) !== "short") {
|
|
1432
|
-
return streamSimpleRequest(model, context, options);
|
|
1433
|
-
}
|
|
1434
|
-
|
|
1435
|
-
const refreshState = existingState ?? new AnthropicCacheRefreshState();
|
|
1436
|
-
if (!existingState) providerSessionState.set(ANTHROPIC_CACHE_REFRESH_STATE_KEY, refreshState);
|
|
1437
|
-
|
|
1438
|
-
let cacheTouchedAtMs: number | undefined;
|
|
1439
|
-
let capturedPayload: MessageCreateParamsStreaming | undefined;
|
|
1440
|
-
const inner = streamSimpleRequest(model, context, {
|
|
1441
|
-
...options,
|
|
1442
|
-
onPayload: async (payload, payloadModel) => {
|
|
1443
|
-
const replacement = await options?.onPayload?.(payload, payloadModel);
|
|
1444
|
-
const finalPayload = replacement ?? payload;
|
|
1445
|
-
if (isAnthropicRefreshPayload(finalPayload)) capturedPayload = finalPayload;
|
|
1446
|
-
return replacement;
|
|
1447
|
-
},
|
|
1448
|
-
onResponse: async (response, responseModel) => {
|
|
1449
|
-
cacheTouchedAtMs = Date.now();
|
|
1450
|
-
await options?.onResponse?.(response, responseModel);
|
|
1451
|
-
},
|
|
1452
|
-
});
|
|
1453
|
-
const outer = new AssistantMessageEventStream();
|
|
1454
|
-
const armRefresh = (message: AssistantMessage): void => {
|
|
1455
|
-
if (
|
|
1456
|
-
message.stopReason === "error" ||
|
|
1457
|
-
message.stopReason === "aborted" ||
|
|
1458
|
-
message.usage.cacheRead + message.usage.cacheWrite <= 0 ||
|
|
1459
|
-
cacheTouchedAtMs === undefined ||
|
|
1460
|
-
capturedPayload === undefined ||
|
|
1461
|
-
!hasShortAnthropicMessageBreakpoint(capturedPayload)
|
|
1462
|
-
) {
|
|
1463
|
-
return;
|
|
1464
|
-
}
|
|
1465
|
-
refreshState.arm(createAnthropicCacheRefreshPlan(model, context, options, capturedPayload), cacheTouchedAtMs);
|
|
1466
|
-
};
|
|
1467
|
-
|
|
1468
|
-
void (async () => {
|
|
1469
|
-
try {
|
|
1470
|
-
for await (const event of inner) {
|
|
1471
|
-
if (event.type === "done") armRefresh(event.message);
|
|
1472
|
-
outer.push(event);
|
|
1473
|
-
if (outer.done) return;
|
|
1474
|
-
}
|
|
1475
|
-
if (!outer.done) {
|
|
1476
|
-
const result = await inner.result();
|
|
1477
|
-
armRefresh(result);
|
|
1478
|
-
outer.end(result);
|
|
1479
|
-
}
|
|
1480
|
-
} catch (error) {
|
|
1481
|
-
outer.fail(error);
|
|
1482
|
-
}
|
|
1483
|
-
})();
|
|
1484
|
-
return outer;
|
|
1485
|
-
}
|
|
1486
|
-
|
|
1487
1206
|
function withInferenceSessionId(options?: SimpleStreamOptions): SimpleStreamOptions {
|
|
1488
1207
|
if (options?.sessionId) return options;
|
|
1489
1208
|
return { ...options, sessionId: crypto.randomUUID() };
|
|
@@ -1496,7 +1215,7 @@ export function streamSimple<TApi extends Api>(
|
|
|
1496
1215
|
): AssistantMessageEventStream {
|
|
1497
1216
|
const sessionOptions = withInferenceSessionId(options);
|
|
1498
1217
|
if (!model.requiresGlyphTokenization) {
|
|
1499
|
-
return
|
|
1218
|
+
return streamSimpleRequest(model, context, sessionOptions);
|
|
1500
1219
|
}
|
|
1501
1220
|
const codec = applyGlyphCodec(context);
|
|
1502
1221
|
const execHandlers = sessionOptions.cursorExecHandlers ?? sessionOptions.execHandlers;
|
|
@@ -1509,7 +1228,7 @@ export function streamSimple<TApi extends Api>(
|
|
|
1509
1228
|
execHandlers: wrappedExecHandlers,
|
|
1510
1229
|
cursorExecHandlers: wrappedExecHandlers,
|
|
1511
1230
|
};
|
|
1512
|
-
return codec.wrap(
|
|
1231
|
+
return codec.wrap(streamSimpleRequest(model, codec.context, wireOptions));
|
|
1513
1232
|
}
|
|
1514
1233
|
|
|
1515
1234
|
/**
|
|
@@ -2093,7 +1812,6 @@ function mapOptionsForApi<TApi extends Api>(
|
|
|
2093
1812
|
fetch: options?.fetch,
|
|
2094
1813
|
fallbacks: options?.fallbacks,
|
|
2095
1814
|
acceptEmptyResponse: options?.acceptEmptyResponse,
|
|
2096
|
-
anthropicCacheRefreshRequest: options?.anthropicCacheRefreshRequest,
|
|
2097
1815
|
anthropicPrefixMismatchBehavior: options?.anthropicPrefixMismatchBehavior,
|
|
2098
1816
|
anthropicCompaction: options?.anthropicCompaction,
|
|
2099
1817
|
anthropicSlowMode: options?.anthropicSlowMode,
|
package/src/types.ts
CHANGED
|
@@ -428,21 +428,11 @@ export interface StreamOptions {
|
|
|
428
428
|
/** @internal Stored credential row serving this request, when known. */
|
|
429
429
|
credentialId?: number;
|
|
430
430
|
cacheRetention?: CacheRetention;
|
|
431
|
-
/**
|
|
432
|
-
* Keep Anthropic's 5-minute prompt cache warm across bounded idle gaps.
|
|
433
|
-
*
|
|
434
|
-
* This is an ownership flag, not a general provider default: exactly one
|
|
435
|
-
* primary agent loop sharing `providerSessionState` should enable it.
|
|
436
|
-
* Side-channel and advisor requests must leave it unset.
|
|
437
|
-
*/
|
|
438
|
-
anthropicCacheRefresh?: boolean;
|
|
439
431
|
/**
|
|
440
432
|
* Anthropic preserved-thinking behavior when a signed block no longer matches
|
|
441
433
|
* its conversation prefix. Binding-capable models default to `"drop_block"`.
|
|
442
434
|
*/
|
|
443
435
|
anthropicPrefixMismatchBehavior?: "drop_block" | "error";
|
|
444
|
-
/** @internal Marks a replay-only Anthropic request that must use non-streaming `max_tokens: 0`. */
|
|
445
|
-
anthropicCacheRefreshRequest?: boolean;
|
|
446
436
|
/**
|
|
447
437
|
* Anthropic on-demand compaction (`compact-2026-09-04` beta). Sends a
|
|
448
438
|
* top-level `compaction: { type: "summarize", instructions? }` request; the
|
|
@@ -1,6 +1,7 @@
|
|
|
1
|
+
import { extractRetryHint } from "@oh-my-pi/pi-utils/fetch-retry";
|
|
1
2
|
import type { UsageReport, UsageResetCredit, UsageResetCredits } from "../usage";
|
|
2
3
|
import { isUsageLimitReached } from "../auth/usage-report";
|
|
3
|
-
import { claudeRankingStrategy } from "./claude";
|
|
4
|
+
import { claudeRankingStrategy, parseClaudeUsagePayload } from "./claude";
|
|
4
5
|
import type { FetchImpl } from "../types";
|
|
5
6
|
import { isRecord } from "../utils";
|
|
6
7
|
import {
|
|
@@ -30,12 +31,20 @@ const CLAUDE_RESET_WINDOW_IDS: Readonly<Record<string, string>> = {
|
|
|
30
31
|
export function claudeResetClearedBlockScopes(cleared: readonly string[], report: UsageReport): (string | undefined)[] {
|
|
31
32
|
const shared = report.limits.filter(limit => limit.scope.shared);
|
|
32
33
|
if (!["anthropic:5h", "anthropic:7d"].every(id => shared.some(limit => limit.id === id))) return [];
|
|
33
|
-
const unscoped = report.limits.filter(
|
|
34
|
-
|
|
35
|
-
|
|
34
|
+
const unscoped = report.limits.filter(limit => {
|
|
35
|
+
if (limit.scope.shared) return true;
|
|
36
|
+
if (!limit.scope.tier) return false;
|
|
37
|
+
// Fable/Mythos have independent block scopes. A live deadline lets
|
|
38
|
+
// redemption retain their uncovered exhaustion in that exact scope.
|
|
39
|
+
if (limit.scope.tier === "fable" || limit.scope.tier === "mythos") {
|
|
40
|
+
const reset = limit.window?.resetsAt;
|
|
41
|
+
return typeof reset !== "number" || !Number.isFinite(reset) || reset <= Date.now();
|
|
42
|
+
}
|
|
43
|
+
return true;
|
|
44
|
+
});
|
|
36
45
|
const scopes = [
|
|
37
46
|
{ blockScope: undefined, limits: unscoped },
|
|
38
|
-
...(claudeRankingStrategy.healableBlockScopes?.(report) ?? []),
|
|
47
|
+
...(claudeRankingStrategy.healableBlockScopes?.(report) ?? []).filter(scope => scope.blockScope.length > 0),
|
|
39
48
|
];
|
|
40
49
|
return scopes
|
|
41
50
|
.filter(scope => scope.limits.some(limit => cleared.includes(limit.id)))
|
|
@@ -46,6 +55,10 @@ export function claudeResetClearedBlockScopes(cleared: readonly string[], report
|
|
|
46
55
|
/** OAuth credential and transport used for an account's Claude reset operations. */
|
|
47
56
|
export interface ClaudeResetAuth {
|
|
48
57
|
accessToken: string;
|
|
58
|
+
/** Observe a safe discovery read's throttle without retrying any mutation. */
|
|
59
|
+
onRateLimited?: (retryAfterMs: number | undefined) => void;
|
|
60
|
+
accountId?: string;
|
|
61
|
+
email?: string;
|
|
49
62
|
orgId?: string;
|
|
50
63
|
baseUrl?: string;
|
|
51
64
|
fetch: FetchImpl;
|
|
@@ -55,6 +68,8 @@ export interface ClaudeResetAuth {
|
|
|
55
68
|
/** Live reset eligibility plus the exact organization and host used for redemption. */
|
|
56
69
|
export interface ClaudeResetCreditList extends UsageResetCredits {
|
|
57
70
|
credits: UsageResetCredit[];
|
|
71
|
+
/** Quota evidence parsed from the same successful discovery response. */
|
|
72
|
+
report?: UsageReport;
|
|
58
73
|
orgId?: string;
|
|
59
74
|
/** OAuth API base that answered discovery; pass back as consume `baseUrl`. */
|
|
60
75
|
baseUrl?: string;
|
|
@@ -102,7 +117,7 @@ interface JuniperStatus {
|
|
|
102
117
|
}
|
|
103
118
|
|
|
104
119
|
type ParsedStatus<T> = { ok: true; status: T | null } | { ok: false };
|
|
105
|
-
type Discovery<T> = { kind: "answered"; status: T | null; baseUrl: string } | { kind: "failed" };
|
|
120
|
+
type Discovery<T> = { kind: "answered"; status: T | null; baseUrl: string; report?: UsageReport } | { kind: "failed" };
|
|
106
121
|
|
|
107
122
|
function optionalString(value: unknown): string | undefined | null {
|
|
108
123
|
if (value === undefined || value === null) return undefined;
|
|
@@ -290,6 +305,7 @@ async function discoverStatus<T>(
|
|
|
290
305
|
return { kind: "failed" };
|
|
291
306
|
}
|
|
292
307
|
if (!response.ok) {
|
|
308
|
+
if (response.status === 429) auth.onRateLimited?.(extractRetryHint(response));
|
|
293
309
|
const canFallback =
|
|
294
310
|
ENDPOINT_ABSENT_STATUSES[response.status] === true && baseUrl !== DEFAULT_CLAUDE_OAUTH_BASE_URL;
|
|
295
311
|
if (canFallback) continue;
|
|
@@ -302,11 +318,12 @@ async function discoverStatus<T>(
|
|
|
302
318
|
return { kind: "failed" };
|
|
303
319
|
}
|
|
304
320
|
if (!isRecord(payload)) return { kind: "failed" };
|
|
321
|
+
const report = parseClaudeUsagePayload(payload, auth, `${baseUrl}/usage?${query}`) ?? undefined;
|
|
305
322
|
if (field in payload) {
|
|
306
323
|
const parsed = parse(payload[field]);
|
|
307
|
-
return parsed.ok ? { kind: "answered", status: parsed.status, baseUrl } : { kind: "failed" };
|
|
324
|
+
return parsed.ok ? { kind: "answered", status: parsed.status, baseUrl, report } : { kind: "failed" };
|
|
308
325
|
}
|
|
309
|
-
if (isUsageEnvelope(payload)) return { kind: "answered", status: null, baseUrl };
|
|
326
|
+
if (isUsageEnvelope(payload)) return { kind: "answered", status: null, baseUrl, report };
|
|
310
327
|
const canFallback = baseUrl !== DEFAULT_CLAUDE_OAUTH_BASE_URL;
|
|
311
328
|
if (!canFallback) return { kind: "failed" };
|
|
312
329
|
}
|
|
@@ -463,7 +480,14 @@ async function withResolvedOrganization(
|
|
|
463
480
|
): Promise<ClaudeResetCreditList> {
|
|
464
481
|
if (list.orgId || list.credits.length === 0) return list;
|
|
465
482
|
const resolution = await resolveOrganization({ ...auth, baseUrl: list.baseUrl ?? auth.baseUrl });
|
|
466
|
-
|
|
483
|
+
if (!resolution.orgId) return list;
|
|
484
|
+
return {
|
|
485
|
+
...list,
|
|
486
|
+
orgId: resolution.orgId,
|
|
487
|
+
...(list.report
|
|
488
|
+
? { report: { ...list.report, metadata: { ...list.report.metadata, orgId: resolution.orgId } } }
|
|
489
|
+
: {}),
|
|
490
|
+
};
|
|
467
491
|
}
|
|
468
492
|
|
|
469
493
|
/**
|
|
@@ -514,7 +538,10 @@ export async function listClaudeResetCredits(auth: ClaudeResetAuth): Promise<Cla
|
|
|
514
538
|
if (cedar.kind === "failed") return null;
|
|
515
539
|
let cedarList: ClaudeResetCreditList | undefined;
|
|
516
540
|
if (cedar.status) {
|
|
517
|
-
cedarList =
|
|
541
|
+
cedarList = {
|
|
542
|
+
...normalizeCedarList(cedar.status, orgId, cedar.baseUrl),
|
|
543
|
+
...(cedar.report ? { report: cedar.report } : {}),
|
|
544
|
+
};
|
|
518
545
|
if (cedarList.availableCount > 0 || cedarList.nextCreditId) {
|
|
519
546
|
return withResolvedOrganization(cedarList, auth);
|
|
520
547
|
}
|
|
@@ -522,7 +549,13 @@ export async function listClaudeResetCredits(auth: ClaudeResetAuth): Promise<Cla
|
|
|
522
549
|
|
|
523
550
|
const juniper = await discoverStatus(auth, "at_wall=1&skip_spend=1", JUNIPER_PROGRAM, parseJuniperStatus);
|
|
524
551
|
if (juniper.kind === "answered" && juniper.status) {
|
|
525
|
-
return withResolvedOrganization(
|
|
552
|
+
return withResolvedOrganization(
|
|
553
|
+
{
|
|
554
|
+
...normalizeJuniperList(juniper.status, orgId, juniper.baseUrl),
|
|
555
|
+
...(juniper.report ? { report: juniper.report } : {}),
|
|
556
|
+
},
|
|
557
|
+
auth,
|
|
558
|
+
);
|
|
526
559
|
}
|
|
527
560
|
if (cedarList && juniper.kind === "answered") return cedarList;
|
|
528
561
|
if (juniper.kind === "answered") {
|
|
@@ -533,6 +566,7 @@ export async function listClaudeResetCredits(auth: ClaudeResetAuth): Promise<Cla
|
|
|
533
566
|
credits: [],
|
|
534
567
|
...(orgId ? { orgId } : {}),
|
|
535
568
|
baseUrl: juniper.baseUrl,
|
|
569
|
+
...(juniper.report ? { report: juniper.report } : {}),
|
|
536
570
|
};
|
|
537
571
|
}
|
|
538
572
|
return null;
|
package/src/usage/claude.ts
CHANGED
|
@@ -12,7 +12,6 @@ import {
|
|
|
12
12
|
type UsageLimit,
|
|
13
13
|
type UsageProvider,
|
|
14
14
|
type UsageReport,
|
|
15
|
-
type UsageResetCredits,
|
|
16
15
|
type UsageStatus,
|
|
17
16
|
type UsageWindow,
|
|
18
17
|
} from "../usage";
|
|
@@ -667,8 +666,61 @@ async function fetchClaudeUsage(params: UsageFetchParams, ctx: UsageFetchContext
|
|
|
667
666
|
if (!result.endpointAbsent) break;
|
|
668
667
|
}
|
|
669
668
|
if (!payload || baseUrl === undefined) return null;
|
|
670
|
-
const
|
|
671
|
-
|
|
669
|
+
const report = parseClaudeUsagePayload(payload, credential, `${baseUrl}/usage`);
|
|
670
|
+
const resetCreditList =
|
|
671
|
+
parseClaudeResetCreditsFromUsagePayload(payload, credential.orgId, baseUrl) ??
|
|
672
|
+
(await listClaudeResetCredits({
|
|
673
|
+
accessToken: credential.accessToken,
|
|
674
|
+
accountId: credential.accountId,
|
|
675
|
+
email: credential.email,
|
|
676
|
+
orgId: credential.orgId,
|
|
677
|
+
baseUrl: params.baseUrl,
|
|
678
|
+
fetch: ctx.fetch,
|
|
679
|
+
signal: params.signal,
|
|
680
|
+
}));
|
|
681
|
+
const hasResetInventory =
|
|
682
|
+
resetCreditList !== null &&
|
|
683
|
+
(resetCreditList.availableCount > 0 ||
|
|
684
|
+
resetCreditList.nextCreditId !== undefined ||
|
|
685
|
+
resetCreditList.credits.length > 0);
|
|
686
|
+
if (!report && !hasResetInventory) return null;
|
|
687
|
+
const result: UsageReport = report ?? {
|
|
688
|
+
provider: "anthropic",
|
|
689
|
+
fetchedAt: Date.now(),
|
|
690
|
+
limits: [],
|
|
691
|
+
metadata: { endpoint: `${baseUrl}/usage`, ...extractUsageIdentity(payload) },
|
|
692
|
+
raw: payload,
|
|
693
|
+
};
|
|
694
|
+
let accountId = result.metadata?.accountId ?? credential.accountId;
|
|
695
|
+
let email = result.metadata?.email ?? credential.email;
|
|
696
|
+
if ((!accountId || !email) && !params.signal?.aborted) {
|
|
697
|
+
const profileIdentity = extractProfileIdentity(await fetchProfile(baseUrl, headers, ctx, params.signal));
|
|
698
|
+
accountId ??= profileIdentity.accountId;
|
|
699
|
+
email ??= profileIdentity.email;
|
|
700
|
+
}
|
|
701
|
+
if (resetCreditList) {
|
|
702
|
+
const { orgId, baseUrl: _baseUrl, report: _report, ...resetCredits } = resetCreditList;
|
|
703
|
+
result.resetCredits = resetCredits;
|
|
704
|
+
if (orgId) result.metadata = { ...result.metadata, orgId };
|
|
705
|
+
}
|
|
706
|
+
result.metadata = {
|
|
707
|
+
...result.metadata,
|
|
708
|
+
...(accountId ? { accountId } : {}),
|
|
709
|
+
...(email ? { email } : {}),
|
|
710
|
+
...(credential.orgId ? { orgId: credential.orgId } : {}),
|
|
711
|
+
};
|
|
712
|
+
return result;
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
/** Parse the quota evidence in one live usage response, without any additional requests. */
|
|
716
|
+
export function parseClaudeUsagePayload(
|
|
717
|
+
raw: unknown,
|
|
718
|
+
identity: { accountId?: string; email?: string; orgId?: string } = {},
|
|
719
|
+
endpoint?: string,
|
|
720
|
+
fetchedAt = Date.now(),
|
|
721
|
+
): UsageReport | null {
|
|
722
|
+
if (!isRecord(raw)) return null;
|
|
723
|
+
const payload: ClaudeUsageResponse = raw;
|
|
672
724
|
const apiLimitEntries = parseApiLimitEntries(payload.limits);
|
|
673
725
|
const fiveHour = parseBucket(payload.five_hour) ?? apiLimitEntries.find(entry => entry.kind === "session")?.bucket;
|
|
674
726
|
const sevenDay =
|
|
@@ -721,53 +773,23 @@ async function fetchClaudeUsage(params: UsageFetchParams, ctx: UsageFetchContext
|
|
|
721
773
|
buildClaudeExtraUsageLimit(payload),
|
|
722
774
|
].filter((limit): limit is UsageLimit => limit !== null);
|
|
723
775
|
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
}));
|
|
733
|
-
const hasResetInventory =
|
|
734
|
-
resetCreditList !== null &&
|
|
735
|
-
(resetCreditList.availableCount > 0 ||
|
|
736
|
-
resetCreditList.nextCreditId !== undefined ||
|
|
737
|
-
resetCreditList.credits.length > 0);
|
|
738
|
-
if (limits.length === 0 && !hasResetInventory) return null;
|
|
739
|
-
|
|
740
|
-
const identity = extractUsageIdentity(payload);
|
|
741
|
-
let accountId = identity.accountId ?? credential.accountId;
|
|
742
|
-
let email = identity.email ?? credential.email;
|
|
743
|
-
if ((!accountId || !email) && !params.signal?.aborted) {
|
|
744
|
-
const profileIdentity = extractProfileIdentity(await fetchProfile(baseUrl, headers, ctx, params.signal));
|
|
745
|
-
accountId = accountId ?? profileIdentity.accountId;
|
|
746
|
-
email = email ?? profileIdentity.email;
|
|
747
|
-
}
|
|
748
|
-
let resetCredits: UsageResetCredits | undefined;
|
|
749
|
-
let reportOrgId = credential.orgId;
|
|
750
|
-
if (resetCreditList) {
|
|
751
|
-
const { orgId, baseUrl: _baseUrl, ...usageResetCredits } = resetCreditList;
|
|
752
|
-
resetCredits = usageResetCredits;
|
|
753
|
-
reportOrgId ??= orgId;
|
|
754
|
-
}
|
|
755
|
-
|
|
756
|
-
const report: UsageReport = {
|
|
757
|
-
provider: params.provider,
|
|
758
|
-
fetchedAt: Date.now(),
|
|
776
|
+
if (limits.length === 0) return null;
|
|
777
|
+
// The response speaks for the token that fetched it; stored metadata may be stale.
|
|
778
|
+
const payloadIdentity = extractUsageIdentity(payload);
|
|
779
|
+
const accountId = payloadIdentity.accountId ?? identity.accountId;
|
|
780
|
+
const email = payloadIdentity.email ?? identity.email;
|
|
781
|
+
return {
|
|
782
|
+
provider: "anthropic",
|
|
783
|
+
fetchedAt,
|
|
759
784
|
limits,
|
|
760
|
-
...(resetCredits ? { resetCredits } : {}),
|
|
761
785
|
metadata: {
|
|
762
|
-
endpoint:
|
|
786
|
+
...(endpoint ? { endpoint } : {}),
|
|
763
787
|
...(accountId ? { accountId } : {}),
|
|
764
788
|
...(email ? { email } : {}),
|
|
765
|
-
...(
|
|
789
|
+
...(identity.orgId ? { orgId: identity.orgId } : {}),
|
|
766
790
|
},
|
|
767
791
|
raw: payload,
|
|
768
792
|
};
|
|
769
|
-
|
|
770
|
-
return report;
|
|
771
793
|
}
|
|
772
794
|
|
|
773
795
|
export const claudeUsageProvider: UsageProvider = {
|
|
@@ -886,6 +908,7 @@ function findClaudeSecondaryLimit(
|
|
|
886
908
|
}
|
|
887
909
|
|
|
888
910
|
export const claudeRankingStrategy: CredentialRankingStrategy = {
|
|
911
|
+
healsGlobalBlocks: true,
|
|
889
912
|
/**
|
|
890
913
|
* Anthropic-only idle window after which a session's pinned credential no
|
|
891
914
|
* longer suppresses usage-based re-ranking. Anthropic caps OAuth prompt-cache
|
|
@@ -923,8 +946,8 @@ export const claudeRankingStrategy: CredentialRankingStrategy = {
|
|
|
923
946
|
* weekly row plus the shared umbrella windows — so a healthy report lifts
|
|
924
947
|
* the block while a spent shared 5-hour wall keeps it.
|
|
925
948
|
*
|
|
926
|
-
*
|
|
927
|
-
*
|
|
949
|
+
* Legacy and shared quota errors also leave account-wide blocks. Only a
|
|
950
|
+
* complete report with every reported quota healthy can heal that scope.
|
|
928
951
|
*/
|
|
929
952
|
healableBlockScopes(report) {
|
|
930
953
|
const sharedLimits = report.limits.filter(limit => limit.scope.shared === true);
|
|
@@ -941,10 +964,17 @@ export const claudeRankingStrategy: CredentialRankingStrategy = {
|
|
|
941
964
|
const tier = limit.scope.tier;
|
|
942
965
|
if (tier === "fable" || tier === "mythos") tiers.add(tier);
|
|
943
966
|
}
|
|
944
|
-
return [
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
967
|
+
return [
|
|
968
|
+
{
|
|
969
|
+
blockScope: "",
|
|
970
|
+
limits: report.limits.filter(limit => limit.scope.shared === true || limit.scope.tier !== undefined),
|
|
971
|
+
healthy: report.metadata?.source === "ratelimit-headers" ? false : undefined,
|
|
972
|
+
},
|
|
973
|
+
...[...tiers].map(tier => ({
|
|
974
|
+
blockScope: `tier:${tier}`,
|
|
975
|
+
limits: [...sharedLimits, ...report.limits.filter(limit => limit.scope.tier === tier)],
|
|
976
|
+
})),
|
|
977
|
+
];
|
|
948
978
|
},
|
|
949
979
|
windowDefaults: { primaryMs: 5 * 60 * 60 * 1000, secondaryMs: 7 * 24 * 60 * 60 * 1000 },
|
|
950
980
|
};
|
package/src/usage.ts
CHANGED
|
@@ -490,6 +490,8 @@ export interface CredentialRankingStrategy {
|
|
|
490
490
|
* non-empty limits with none exhausted.
|
|
491
491
|
*/
|
|
492
492
|
healableBlockScopes?(report: UsageReport): { blockScope: string; limits: UsageLimit[]; healthy?: boolean }[];
|
|
493
|
+
/** Whether fresh reports can heal legacy account-wide quota backoffs. */
|
|
494
|
+
healsGlobalBlocks?: boolean;
|
|
493
495
|
/** Fallback window durations (ms) when limits don't specify durationMs. */
|
|
494
496
|
windowDefaults: {
|
|
495
497
|
primaryMs: number;
|
package/src/utils/proxy.ts
CHANGED
|
@@ -164,15 +164,15 @@ export function getProxyForUrl(provider: string, url: URL): string | undefined {
|
|
|
164
164
|
/**
|
|
165
165
|
* Return `init` with `proxy: proxyUrl` set when the request should tunnel.
|
|
166
166
|
* A caller-supplied `init.proxy` always wins (the innermost, most specific
|
|
167
|
-
* decision); local/metadata hosts, NO_PROXY matches, and
|
|
168
|
-
* pass through unchanged.
|
|
167
|
+
* decision); Unix sockets, local/metadata hosts, NO_PROXY matches, and
|
|
168
|
+
* unparseable URLs pass through unchanged.
|
|
169
169
|
*/
|
|
170
170
|
export function withProxyInit(
|
|
171
171
|
input: string | URL | Request,
|
|
172
172
|
init: RequestInit | undefined,
|
|
173
173
|
proxyUrl: string,
|
|
174
174
|
): RequestInit | undefined {
|
|
175
|
-
if ((init
|
|
175
|
+
if (init && (("unix" in init && init.unix) || ("proxy" in init && init.proxy))) return init;
|
|
176
176
|
const urlStr = input instanceof Request ? input.url : input.toString();
|
|
177
177
|
let urlObj: URL;
|
|
178
178
|
try {
|