@oh-my-pi/pi-ai 16.3.0 → 16.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/CHANGELOG.md +28 -0
- package/dist/types/auth-broker/remote-store.d.ts +3 -2
- package/dist/types/auth-storage.d.ts +7 -0
- package/dist/types/dialect/demotion.d.ts +15 -10
- package/dist/types/providers/anthropic.d.ts +10 -0
- package/dist/types/providers/cursor.d.ts +23 -2
- package/dist/types/providers/openai-responses.d.ts +2 -3
- package/dist/types/providers/openai-shared.d.ts +2 -2
- package/dist/types/usage/claude.d.ts +1 -1
- package/dist/types/utils/block-symbols.d.ts +16 -0
- package/dist/types/utils/proxy.d.ts +7 -1
- package/package.json +4 -4
- package/src/auth-broker/remote-store.ts +109 -9
- package/src/auth-storage.ts +24 -10
- package/src/dialect/demotion.ts +18 -13
- package/src/providers/anthropic.ts +86 -2
- package/src/providers/azure-openai-responses.ts +1 -1
- package/src/providers/cursor.ts +108 -2
- package/src/providers/devin.ts +15 -0
- package/src/providers/gitlab-duo-workflow.ts +87 -14
- package/src/providers/openai-completions.ts +10 -7
- package/src/providers/openai-responses.ts +12 -40
- package/src/providers/openai-shared.ts +8 -30
- package/src/providers/transform-messages.ts +16 -11
- package/src/usage/claude.ts +213 -20
- package/src/utils/block-symbols.ts +16 -0
- package/src/utils/proxy.ts +104 -44
- package/src/utils/validation.ts +64 -0
- package/src/providers/openai-responses-reasoning-suppression.md +0 -1
|
@@ -148,6 +148,13 @@ type OpenAICompletionsCompletionTokenDetails = {
|
|
|
148
148
|
reasoning_tokens?: unknown;
|
|
149
149
|
};
|
|
150
150
|
|
|
151
|
+
function firstPositiveNumber(...values: unknown[]): number {
|
|
152
|
+
for (const value of values) {
|
|
153
|
+
if (typeof value === "number" && value > 0) return value;
|
|
154
|
+
}
|
|
155
|
+
return 0;
|
|
156
|
+
}
|
|
157
|
+
|
|
151
158
|
/**
|
|
152
159
|
* Normalize tool call ID for Mistral.
|
|
153
160
|
* Mistral requires tool IDs to be exactly 9 alphanumeric characters (a-z, A-Z, 0-9).
|
|
@@ -1558,11 +1565,7 @@ export function parseChunkUsage(
|
|
|
1558
1565
|
const accounting = calculateOpenAIUsageAccounting({
|
|
1559
1566
|
promptTokens: typeof promptTokens === "number" ? promptTokens : 0,
|
|
1560
1567
|
outputTokens,
|
|
1561
|
-
cachedTokens:
|
|
1562
|
-
(typeof cachedTokens === "number" ? cachedTokens : undefined) ??
|
|
1563
|
-
(typeof promptCacheHitTokens === "number" ? promptCacheHitTokens : undefined) ??
|
|
1564
|
-
(typeof promptTokenCachedTokens === "number" ? promptTokenCachedTokens : undefined) ??
|
|
1565
|
-
0,
|
|
1568
|
+
cachedTokens: firstPositiveNumber(cachedTokens, promptCacheHitTokens, promptTokenCachedTokens),
|
|
1566
1569
|
reasoningTokens: typeof completionReasoningTokens === "number" ? completionReasoningTokens : 0,
|
|
1567
1570
|
cacheWriteOpenRouter: typeof cacheWriteTokens === "number" ? cacheWriteTokens : undefined,
|
|
1568
1571
|
cacheWriteDeepSeek: typeof promptCacheMissTokens === "number" ? promptCacheMissTokens : undefined,
|
|
@@ -1786,12 +1789,12 @@ export function convertMessages(
|
|
|
1786
1789
|
if (compat.requiresThinkingAsText) {
|
|
1787
1790
|
const thinkingText = nonEmptyThinkingBlocks
|
|
1788
1791
|
.map(b => renderDemotedThinking(model.id, b.thinking))
|
|
1789
|
-
.join("");
|
|
1792
|
+
.join(" ");
|
|
1790
1793
|
// `content` is a plain string at this point (set above) or null —
|
|
1791
1794
|
// never an array. Prepend the demoted thinking to the string form.
|
|
1792
1795
|
assistantMsg.content =
|
|
1793
1796
|
typeof assistantMsg.content === "string" && assistantMsg.content.length > 0
|
|
1794
|
-
? `${thinkingText}${assistantMsg.content}`
|
|
1797
|
+
? `${thinkingText} ${assistantMsg.content}`
|
|
1795
1798
|
: thinkingText;
|
|
1796
1799
|
} else if (compat.requiresReasoningContentForToolCalls) {
|
|
1797
1800
|
// Use the streamed signature when the backend accepts whichever
|
|
@@ -166,8 +166,8 @@ interface OpenAIResponsesProviderSessionState
|
|
|
166
166
|
|
|
167
167
|
interface OpenAIResponsesChainState {
|
|
168
168
|
/**
|
|
169
|
-
* Wire params of the last successful turn
|
|
170
|
-
*
|
|
169
|
+
* Wire params of the last successful turn; never carries
|
|
170
|
+
* `previous_response_id`.
|
|
171
171
|
*/
|
|
172
172
|
lastParams?: OpenAIResponsesSamplingParams;
|
|
173
173
|
lastResponseId?: string;
|
|
@@ -256,30 +256,19 @@ interface OpenAIResponsesChainedParams {
|
|
|
256
256
|
* (same options, strict history prefix), chain via `previous_response_id` +
|
|
257
257
|
* delta-only `input`; otherwise break the chain and replay the full transcript.
|
|
258
258
|
*
|
|
259
|
-
* The prefix check runs on the wire form of the conversation arguments
|
|
260
|
-
*
|
|
261
|
-
* the delta, so a decoration that trails every request can never masquerade as
|
|
262
|
-
* a history mutation.
|
|
259
|
+
* The prefix check runs on the wire form of the conversation arguments, so
|
|
260
|
+
* history mutations or option changes force a full replay.
|
|
263
261
|
*/
|
|
264
262
|
function buildOpenAIResponsesChainedParams(
|
|
265
263
|
params: OpenAIResponsesSamplingParams,
|
|
266
|
-
trailingScaffoldingItems: number,
|
|
267
264
|
chain: OpenAIResponsesChainState,
|
|
268
265
|
): OpenAIResponsesChainedParams {
|
|
269
|
-
const historyParams =
|
|
270
|
-
trailingScaffoldingItems > 0 && Array.isArray(params.input)
|
|
271
|
-
? { ...params, input: params.input.slice(0, params.input.length - trailingScaffoldingItems) }
|
|
272
|
-
: params;
|
|
273
266
|
const deltaInput = chain.canAppend
|
|
274
|
-
? buildResponsesDeltaInput(chain.lastParams, chain.lastResponseItems,
|
|
267
|
+
? buildResponsesDeltaInput(chain.lastParams, chain.lastResponseItems, params)
|
|
275
268
|
: null;
|
|
276
269
|
if (deltaInput && deltaInput.length > 0 && chain.lastResponseId) {
|
|
277
|
-
const scaffolding =
|
|
278
|
-
historyParams !== params && Array.isArray(params.input)
|
|
279
|
-
? params.input.slice(params.input.length - trailingScaffoldingItems)
|
|
280
|
-
: [];
|
|
281
270
|
return {
|
|
282
|
-
params: { ...params, previous_response_id: chain.lastResponseId, input:
|
|
271
|
+
params: { ...params, previous_response_id: chain.lastResponseId, input: deltaInput },
|
|
283
272
|
previousResponseId: chain.lastResponseId,
|
|
284
273
|
};
|
|
285
274
|
}
|
|
@@ -416,9 +405,7 @@ const streamOpenAIResponsesOnce = (
|
|
|
416
405
|
const strictToolsScope = getOpenAIStrictToolsScope(model, baseUrl);
|
|
417
406
|
const builtParams = buildParams(model, context, options, providerSessionState, strictToolsScope);
|
|
418
407
|
const params = builtParams.params;
|
|
419
|
-
const { trailingScaffoldingItems } = builtParams;
|
|
420
408
|
let activeParams = params;
|
|
421
|
-
let activeTrailingScaffoldingItems = trailingScaffoldingItems;
|
|
422
409
|
const resolvedBaseUrl = (baseUrl ?? "https://api.openai.com/v1").replace(/\/+$/, "");
|
|
423
410
|
const requestReasoningEffortFallbacks = new Map<string, OpenAIReasoningEffortFallback>();
|
|
424
411
|
const attemptedReasoningEffortFallbacks = new Set<string>();
|
|
@@ -448,9 +435,7 @@ const streamOpenAIResponsesOnce = (
|
|
|
448
435
|
}
|
|
449
436
|
applyReasoningEffortFallbackForRequest(params);
|
|
450
437
|
let chained: OpenAIResponsesChainedParams =
|
|
451
|
-
chainState && !chainState.disabled
|
|
452
|
-
? buildOpenAIResponsesChainedParams(params, trailingScaffoldingItems, chainState)
|
|
453
|
-
: { params };
|
|
438
|
+
chainState && !chainState.disabled ? buildOpenAIResponsesChainedParams(params, chainState) : { params };
|
|
454
439
|
sentPreviousResponseId = chained.previousResponseId;
|
|
455
440
|
const idleTimeoutMs =
|
|
456
441
|
options?.streamIdleTimeoutMs ?? getOpenAIStreamIdleTimeoutMs(model.compat.streamIdleTimeoutMs);
|
|
@@ -589,11 +574,7 @@ const streamOpenAIResponsesOnce = (
|
|
|
589
574
|
if (chainState && !chainState.disabled) fallbackParams.store = true;
|
|
590
575
|
let fallbackChained: OpenAIResponsesChainedParams =
|
|
591
576
|
chainState && !chainState.disabled
|
|
592
|
-
? buildOpenAIResponsesChainedParams(
|
|
593
|
-
fallbackParams,
|
|
594
|
-
fallbackBuilt.trailingScaffoldingItems,
|
|
595
|
-
chainState,
|
|
596
|
-
)
|
|
577
|
+
? buildOpenAIResponsesChainedParams(fallbackParams, chainState)
|
|
597
578
|
: { params: fallbackParams };
|
|
598
579
|
sentPreviousResponseId = fallbackChained.previousResponseId;
|
|
599
580
|
fallbackChained = {
|
|
@@ -603,7 +584,6 @@ const streamOpenAIResponsesOnce = (
|
|
|
603
584
|
chained = fallbackChained;
|
|
604
585
|
rawRequestDump.body = chained.params;
|
|
605
586
|
activeParams = fallbackParams;
|
|
606
|
-
activeTrailingScaffoldingItems = fallbackBuilt.trailingScaffoldingItems;
|
|
607
587
|
activeStrictToolsApplied = fallbackBuilt.strictToolsApplied;
|
|
608
588
|
continue;
|
|
609
589
|
}
|
|
@@ -646,7 +626,6 @@ const streamOpenAIResponsesOnce = (
|
|
|
646
626
|
chained = { params: retryParams };
|
|
647
627
|
rawRequestDump.body = retryParams;
|
|
648
628
|
activeParams = currentParams;
|
|
649
|
-
activeTrailingScaffoldingItems = currentBuilt.trailingScaffoldingItems;
|
|
650
629
|
activeStrictToolsApplied = currentBuilt.strictToolsApplied;
|
|
651
630
|
}
|
|
652
631
|
}
|
|
@@ -710,14 +689,7 @@ const streamOpenAIResponsesOnce = (
|
|
|
710
689
|
output.providerPayload = createOpenAIResponsesHistoryPayload(model.provider, nativeOutputItems);
|
|
711
690
|
if (providerSessionState) providerSessionState.nativeHistoryReplayWarmed = true;
|
|
712
691
|
if (chainState) {
|
|
713
|
-
chainState.lastParams = structuredCloneJSON(
|
|
714
|
-
activeTrailingScaffoldingItems > 0 && Array.isArray(activeParams.input)
|
|
715
|
-
? {
|
|
716
|
-
...activeParams,
|
|
717
|
-
input: activeParams.input.slice(0, activeParams.input.length - activeTrailingScaffoldingItems),
|
|
718
|
-
}
|
|
719
|
-
: activeParams,
|
|
720
|
-
);
|
|
692
|
+
chainState.lastParams = structuredCloneJSON(activeParams);
|
|
721
693
|
if (output.responseId) {
|
|
722
694
|
chainState.lastResponseId = output.responseId;
|
|
723
695
|
chainState.lastResponseItems = sanitizeOpenAIResponsesHistoryItemsForReplay(
|
|
@@ -790,7 +762,7 @@ export function buildParams(
|
|
|
790
762
|
providerSessionState: OpenAIResponsesProviderSessionState | undefined,
|
|
791
763
|
strictToolsScope?: OpenAIStrictToolsScope,
|
|
792
764
|
disableStrictToolsOverride = false,
|
|
793
|
-
): { params: OpenAIResponsesSamplingParams;
|
|
765
|
+
): { params: OpenAIResponsesSamplingParams; strictToolsApplied: boolean } {
|
|
794
766
|
const policy = resolveOpenAICompatPolicy(model, {
|
|
795
767
|
endpoint: "responses",
|
|
796
768
|
reasoning: options?.reasoning,
|
|
@@ -914,7 +886,7 @@ export function buildParams(
|
|
|
914
886
|
filterReasoningHistory: options?.filterReasoningHistory,
|
|
915
887
|
omitReasoningEffort: options?.omitReasoningEffort,
|
|
916
888
|
});
|
|
917
|
-
|
|
889
|
+
applyResponsesCompatPolicy(params, reasoningPolicy, {
|
|
918
890
|
reasoningSummary: options?.reasoningSummary,
|
|
919
891
|
mapEffort: effort =>
|
|
920
892
|
model.compat.reasoningEffortMap?.[effort as NonNullable<OpenAIResponsesOptions["reasoning"]>] ??
|
|
@@ -926,7 +898,7 @@ export function buildParams(
|
|
|
926
898
|
|
|
927
899
|
applyOpenAIExtraBody(params, options?.extraBody);
|
|
928
900
|
|
|
929
|
-
return { params,
|
|
901
|
+
return { params, strictToolsApplied };
|
|
930
902
|
}
|
|
931
903
|
|
|
932
904
|
/**
|
|
@@ -76,7 +76,6 @@ import {
|
|
|
76
76
|
} from "./github-copilot-headers";
|
|
77
77
|
import type { ChatCompletionCreateParamsStreaming } from "./openai-chat-wire";
|
|
78
78
|
import type { InputItem } from "./openai-codex/request-transformer";
|
|
79
|
-
import responsesReasoningSuppressionPrompt from "./openai-responses-reasoning-suppression.md" with { type: "text" };
|
|
80
79
|
import type {
|
|
81
80
|
ResponseContentPartAddedEvent,
|
|
82
81
|
ResponseCreateParamsStreaming,
|
|
@@ -2404,8 +2403,6 @@ export function applyCommonResponsesSamplingParams<P extends CommonResponsesPara
|
|
|
2404
2403
|
applyOpenAIServiceTier(params, options?.serviceTier, model.provider);
|
|
2405
2404
|
}
|
|
2406
2405
|
|
|
2407
|
-
const RESPONSES_REASONING_SUPPRESSION_PROMPT = responsesReasoningSuppressionPrompt.trim();
|
|
2408
|
-
|
|
2409
2406
|
type ReasoningOptions = {
|
|
2410
2407
|
reasoning?: string;
|
|
2411
2408
|
reasoningSummary?: "auto" | "detailed" | "concise" | null;
|
|
@@ -2420,12 +2417,11 @@ export interface ApplyResponsesCompatPolicyOptions {
|
|
|
2420
2417
|
|
|
2421
2418
|
export function applyResponsesCompatPolicy<P extends ResponseCreateParamsStreaming>(
|
|
2422
2419
|
params: P,
|
|
2423
|
-
messages: ResponseInput,
|
|
2424
2420
|
policy: OpenAICompatPolicy,
|
|
2425
2421
|
options: ApplyResponsesCompatPolicyOptions | undefined,
|
|
2426
|
-
):
|
|
2422
|
+
): void {
|
|
2427
2423
|
const reasoning = policy.reasoning;
|
|
2428
|
-
if (!reasoning.modelSupported) return
|
|
2424
|
+
if (!reasoning.modelSupported) return;
|
|
2429
2425
|
if (reasoning.includeEncryptedReasoning) {
|
|
2430
2426
|
const include = params.include ?? [];
|
|
2431
2427
|
if (!include.includes("reasoning.encrypted_content")) include.push("reasoning.encrypted_content");
|
|
@@ -2435,7 +2431,7 @@ export function applyResponsesCompatPolicy<P extends ResponseCreateParamsStreami
|
|
|
2435
2431
|
if (reasoning.disabled) {
|
|
2436
2432
|
if (reasoning.disableMode === "openrouter-enabled-false") {
|
|
2437
2433
|
params.reasoning = { enabled: false } as P["reasoning"];
|
|
2438
|
-
return
|
|
2434
|
+
return;
|
|
2439
2435
|
}
|
|
2440
2436
|
if (
|
|
2441
2437
|
reasoning.disableMode === "lowest-effort" &&
|
|
@@ -2445,16 +2441,9 @@ export function applyResponsesCompatPolicy<P extends ResponseCreateParamsStreami
|
|
|
2445
2441
|
type ReasoningParam = NonNullable<ResponseCreateParamsStreaming["reasoning"]>;
|
|
2446
2442
|
params.reasoning = { effort: reasoning.wireEffort as ReasoningParam["effort"] } as P["reasoning"] &
|
|
2447
2443
|
ReasoningParam;
|
|
2448
|
-
return
|
|
2449
|
-
}
|
|
2450
|
-
if (policy.compat.requiresReasoningSuppressionPrompt && reasoning.requestedEffort === undefined) {
|
|
2451
|
-
messages.push({
|
|
2452
|
-
role: "developer",
|
|
2453
|
-
content: [{ type: "input_text", text: RESPONSES_REASONING_SUPPRESSION_PROMPT }],
|
|
2454
|
-
});
|
|
2455
|
-
return 1;
|
|
2444
|
+
return;
|
|
2456
2445
|
}
|
|
2457
|
-
return
|
|
2446
|
+
return;
|
|
2458
2447
|
}
|
|
2459
2448
|
|
|
2460
2449
|
if (reasoning.requestedEffort !== undefined || options?.reasoningSummary !== undefined) {
|
|
@@ -2463,7 +2452,7 @@ export function applyResponsesCompatPolicy<P extends ResponseCreateParamsStreami
|
|
|
2463
2452
|
type ReasoningParam = NonNullable<ResponseCreateParamsStreaming["reasoning"]>;
|
|
2464
2453
|
params.reasoning = { summary: options.reasoningSummary || "auto" } as P["reasoning"] & ReasoningParam;
|
|
2465
2454
|
}
|
|
2466
|
-
return
|
|
2455
|
+
return;
|
|
2467
2456
|
}
|
|
2468
2457
|
|
|
2469
2458
|
const requested = reasoning.requestedEffort ?? "medium";
|
|
@@ -2476,17 +2465,8 @@ export function applyResponsesCompatPolicy<P extends ResponseCreateParamsStreami
|
|
|
2476
2465
|
reasoningParams.summary = options?.reasoningSummary || "auto";
|
|
2477
2466
|
}
|
|
2478
2467
|
params.reasoning = reasoningParams as P["reasoning"];
|
|
2479
|
-
return
|
|
2480
|
-
}
|
|
2481
|
-
|
|
2482
|
-
if (policy.compat.requiresReasoningSuppressionPrompt) {
|
|
2483
|
-
messages.push({
|
|
2484
|
-
role: "developer",
|
|
2485
|
-
content: [{ type: "input_text", text: RESPONSES_REASONING_SUPPRESSION_PROMPT }],
|
|
2486
|
-
});
|
|
2487
|
-
return 1;
|
|
2468
|
+
return;
|
|
2488
2469
|
}
|
|
2489
|
-
return 0;
|
|
2490
2470
|
}
|
|
2491
2471
|
|
|
2492
2472
|
/**
|
|
@@ -2497,14 +2477,12 @@ export function applyResponsesReasoningParams<P extends ResponseCreateParamsStre
|
|
|
2497
2477
|
params: P,
|
|
2498
2478
|
model: Model<"openai-responses" | "azure-openai-responses" | "openai-codex-responses">,
|
|
2499
2479
|
options: ReasoningOptions | undefined,
|
|
2500
|
-
messages: ResponseInput,
|
|
2501
2480
|
mapEffort?: (effort: string) => string,
|
|
2502
2481
|
includeEncryptedReasoning?: boolean,
|
|
2503
2482
|
omitReasoningEffort?: boolean,
|
|
2504
|
-
):
|
|
2483
|
+
): void {
|
|
2505
2484
|
return applyResponsesCompatPolicy(
|
|
2506
2485
|
params,
|
|
2507
|
-
messages,
|
|
2508
2486
|
resolveOpenAICompatPolicy(model, {
|
|
2509
2487
|
endpoint: "responses",
|
|
2510
2488
|
reasoning: options?.reasoning,
|
|
@@ -341,13 +341,18 @@ export function transformMessages<TApi extends Api>(
|
|
|
341
341
|
const isLatestSurvivingAssistant = index === latestSurvivingAssistantIndex;
|
|
342
342
|
// Signature policy is a second axis. Anthropic cryptographically
|
|
343
343
|
// binds reasoning signatures to its key+session+model, so cross-model
|
|
344
|
-
// signatures must be stripped whenever
|
|
345
|
-
// either end of the replay:
|
|
346
|
-
// * official
|
|
347
|
-
// keeping it leaks
|
|
348
|
-
//
|
|
349
|
-
// *
|
|
350
|
-
//
|
|
344
|
+
// signatures must be stripped whenever a signing Anthropic endpoint
|
|
345
|
+
// is on either end of the replay:
|
|
346
|
+
// * official Anthropic (source): the 3p target can't reverify a
|
|
347
|
+
// foreign signature and keeping it leaks continuation metadata
|
|
348
|
+
// for no benefit.
|
|
349
|
+
// * signing Anthropic (target): official Anthropic, GitHub Copilot,
|
|
350
|
+
// ZenMux, Cloudflare AI Gateway `/anthropic`, and Google Vertex
|
|
351
|
+
// `publishers/anthropic/…` all forward to signature-enforcing
|
|
352
|
+
// Anthropic. Any stale/cross-model signature on the wire triggers
|
|
353
|
+
// `400 Invalid signature in thinking block` — same failure class
|
|
354
|
+
// whether `officialEndpoint` is true or the endpoint is one of
|
|
355
|
+
// the known signing proxies (#4297).
|
|
351
356
|
// 3p ↔ 3p replays preserve signatures because compatible providers
|
|
352
357
|
// (Z.AI, DeepSeek, custom `models.yaml` providers) treat them as
|
|
353
358
|
// opaque continuation hints rather than verified material; stripping
|
|
@@ -358,8 +363,8 @@ export function transformMessages<TApi extends Api>(
|
|
|
358
363
|
// a custom proxy via `models.yaml` will see signatures stripped, the
|
|
359
364
|
// conservative direction (degraded reasoning, not broken requests).
|
|
360
365
|
const isOfficialAnthropicSource = isAnthropicReplay && assistantMsg.provider === "anthropic";
|
|
361
|
-
const
|
|
362
|
-
const
|
|
366
|
+
const isSigningAnthropicTarget = isAnthropicTarget && model.compat.signingEndpoint;
|
|
367
|
+
const signingAnthropicInvolved = isOfficialAnthropicSource || isSigningAnthropicTarget;
|
|
363
368
|
// Compatible Anthropic-messages reasoning targets that accept
|
|
364
369
|
// unsigned thinking natively (Z.AI, DeepSeek, the generic
|
|
365
370
|
// `reasoning && !official` case in the compat builder). Used to keep
|
|
@@ -421,7 +426,7 @@ export function transformMessages<TApi extends Api>(
|
|
|
421
426
|
if (
|
|
422
427
|
!isLatestSurvivingAssistant &&
|
|
423
428
|
!isSameModel &&
|
|
424
|
-
|
|
429
|
+
signingAnthropicInvolved &&
|
|
425
430
|
sanitized.thinkingSignature
|
|
426
431
|
) {
|
|
427
432
|
sanitized = { ...sanitized, thinkingSignature: undefined };
|
|
@@ -438,7 +443,7 @@ export function transformMessages<TApi extends Api>(
|
|
|
438
443
|
// textual thinking dialect; keep demotion for signatures stripped
|
|
439
444
|
// by the untrustworthy-turn recovery above and for literal thinking
|
|
440
445
|
// envelopes that never carried a signature field.
|
|
441
|
-
if (isSameModel &&
|
|
446
|
+
if (isSameModel && isSigningAnthropicTarget && sanitized.thinkingSignature?.trim() === "") {
|
|
442
447
|
return [];
|
|
443
448
|
}
|
|
444
449
|
return sanitized;
|
package/src/usage/claude.ts
CHANGED
|
@@ -1,17 +1,20 @@
|
|
|
1
1
|
import { scheduler } from "node:timers/promises";
|
|
2
|
+
import { bareModelId, parseAnthropicModel } from "@oh-my-pi/pi-catalog/identity";
|
|
2
3
|
import { toNumber } from "@oh-my-pi/pi-catalog/utils";
|
|
3
4
|
import * as AIError from "../error";
|
|
4
5
|
import { claudeCodeVersion } from "../providers/anthropic";
|
|
5
|
-
import
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
6
|
+
import {
|
|
7
|
+
type CredentialRankingContext,
|
|
8
|
+
type CredentialRankingStrategy,
|
|
9
|
+
resolveUsedFraction,
|
|
10
|
+
type UsageAmount,
|
|
11
|
+
type UsageFetchContext,
|
|
12
|
+
type UsageFetchParams,
|
|
13
|
+
type UsageLimit,
|
|
14
|
+
type UsageProvider,
|
|
15
|
+
type UsageReport,
|
|
16
|
+
type UsageStatus,
|
|
17
|
+
type UsageWindow,
|
|
15
18
|
} from "../usage";
|
|
16
19
|
import { isRecord } from "../utils";
|
|
17
20
|
|
|
@@ -60,13 +63,37 @@ interface ParsedUsageBucket {
|
|
|
60
63
|
utilization?: number;
|
|
61
64
|
resetsAt?: number;
|
|
62
65
|
}
|
|
63
|
-
type ClaudeUnifiedWindow = "5h" | "7d";
|
|
66
|
+
type ClaudeUnifiedWindow = "5h" | "7d" | "7d_oi";
|
|
67
|
+
type ClaudeModelKind = "opus" | "sonnet" | "fable" | "mythos";
|
|
64
68
|
|
|
65
69
|
interface ClaudeUsageResponse {
|
|
66
70
|
five_hour?: ClaudeUsageBucket | null;
|
|
67
71
|
seven_day?: ClaudeUsageBucket | null;
|
|
68
72
|
seven_day_opus?: ClaudeUsageBucket | null;
|
|
69
73
|
seven_day_sonnet?: ClaudeUsageBucket | null;
|
|
74
|
+
limits?: unknown;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
interface ClaudeApiLimitModelScope {
|
|
78
|
+
display_name?: string | null;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
interface ClaudeApiLimitScope {
|
|
82
|
+
model?: ClaudeApiLimitModelScope | null;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
interface ClaudeApiLimitEntry {
|
|
86
|
+
kind?: string;
|
|
87
|
+
percent?: unknown;
|
|
88
|
+
resets_at?: string | null;
|
|
89
|
+
scope?: ClaudeApiLimitScope | null;
|
|
90
|
+
is_active?: boolean;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
interface ParsedApiLimitEntry {
|
|
94
|
+
kind: string;
|
|
95
|
+
bucket: ParsedUsageBucket;
|
|
96
|
+
displayName?: string;
|
|
70
97
|
}
|
|
71
98
|
|
|
72
99
|
type ClaudeUsagePayload = {
|
|
@@ -89,6 +116,43 @@ function parseBucket(bucket: unknown): ParsedUsageBucket | undefined {
|
|
|
89
116
|
}
|
|
90
117
|
return { utilization, resetsAt };
|
|
91
118
|
}
|
|
119
|
+
|
|
120
|
+
function getApiLimitDisplayName(scope: unknown): string | undefined {
|
|
121
|
+
if (!isRecord(scope)) return undefined;
|
|
122
|
+
const model = scope.model;
|
|
123
|
+
if (!isRecord(model)) return undefined;
|
|
124
|
+
const displayName = model.display_name;
|
|
125
|
+
return typeof displayName === "string" && displayName.trim() ? displayName.trim() : undefined;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Anthropic kept the legacy account-wide buckets populated, but as of
|
|
130
|
+
* 2026-07-02 the legacy per-model weekly buckets (`seven_day_opus` /
|
|
131
|
+
* `seven_day_sonnet`) are permanently null. Model-scoped weekly caps now arrive
|
|
132
|
+
* only through generic `limits[]` entries (`kind: "weekly_scoped"`) with the
|
|
133
|
+
* model family named by `scope.model.display_name`.
|
|
134
|
+
*/
|
|
135
|
+
function parseApiLimitEntries(raw: unknown): ParsedApiLimitEntry[] {
|
|
136
|
+
if (!Array.isArray(raw)) return [];
|
|
137
|
+
const entries: ParsedApiLimitEntry[] = [];
|
|
138
|
+
for (const rawEntry of raw) {
|
|
139
|
+
if (!isRecord(rawEntry)) continue;
|
|
140
|
+
const entry = rawEntry as ClaudeApiLimitEntry;
|
|
141
|
+
if (typeof entry.kind !== "string") continue;
|
|
142
|
+
if (entry.is_active === false) continue;
|
|
143
|
+
const utilization = toNumber(entry.percent);
|
|
144
|
+
const resetsAt = parseIsoTime(typeof entry.resets_at === "string" ? entry.resets_at : undefined);
|
|
145
|
+
if (utilization === undefined && resetsAt === undefined) continue;
|
|
146
|
+
const displayName = getApiLimitDisplayName(entry.scope);
|
|
147
|
+
entries.push({
|
|
148
|
+
kind: entry.kind,
|
|
149
|
+
bucket: { utilization, resetsAt },
|
|
150
|
+
...(displayName ? { displayName } : {}),
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
return entries;
|
|
154
|
+
}
|
|
155
|
+
|
|
92
156
|
function parseUnifiedWindow(
|
|
93
157
|
headers: Record<string, string>,
|
|
94
158
|
window: ClaudeUnifiedWindow,
|
|
@@ -144,12 +208,17 @@ function hasUsageData(payload: ClaudeUsageResponse): boolean {
|
|
|
144
208
|
parseBucket(payload.five_hour)?.utilization !== undefined ||
|
|
145
209
|
parseBucket(payload.seven_day)?.utilization !== undefined ||
|
|
146
210
|
parseBucket(payload.seven_day_opus)?.utilization !== undefined ||
|
|
147
|
-
parseBucket(payload.seven_day_sonnet)?.utilization !== undefined
|
|
211
|
+
parseBucket(payload.seven_day_sonnet)?.utilization !== undefined ||
|
|
212
|
+
parseApiLimitEntries(payload.limits).some(entry => entry.bucket.utilization !== undefined)
|
|
148
213
|
);
|
|
149
214
|
}
|
|
150
215
|
|
|
151
216
|
function isRetryableStatus(status: number): boolean {
|
|
152
|
-
|
|
217
|
+
// Exclude 429: the usage endpoint is informational and rate-limited per
|
|
218
|
+
// source IP, so retrying a rate_limit_error inside a single fetch can't
|
|
219
|
+
// succeed and only deepens the throttle (3 attempts per poll). Fall through
|
|
220
|
+
// to the caller's failure cool-down and retry on the next poll instead.
|
|
221
|
+
return AIError.isTransientStatus(status) && status !== 429;
|
|
153
222
|
}
|
|
154
223
|
|
|
155
224
|
function isAbortError(error: unknown, signal?: AbortSignal): boolean {
|
|
@@ -334,8 +403,8 @@ function buildUsageLimit(args: {
|
|
|
334
403
|
scope: {
|
|
335
404
|
provider: args.provider,
|
|
336
405
|
windowId: args.windowId,
|
|
337
|
-
tier: args.tier,
|
|
338
|
-
shared: args.shared,
|
|
406
|
+
...(args.tier !== undefined ? { tier: args.tier } : {}),
|
|
407
|
+
...(args.shared !== undefined ? { shared: args.shared } : {}),
|
|
339
408
|
},
|
|
340
409
|
window,
|
|
341
410
|
amount,
|
|
@@ -343,9 +412,47 @@ function buildUsageLimit(args: {
|
|
|
343
412
|
};
|
|
344
413
|
}
|
|
345
414
|
|
|
415
|
+
function slugifyClaudeLimitDisplayName(displayName: string): string {
|
|
416
|
+
return displayName
|
|
417
|
+
.trim()
|
|
418
|
+
.toLowerCase()
|
|
419
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
420
|
+
.replace(/^-+|-+$/g, "");
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
/**
|
|
424
|
+
* Scoped weekly rows are per-model-family counters, not account-wide windows.
|
|
425
|
+
* They deliberately leave `scope.shared` unset so credential-wide exhaustion
|
|
426
|
+
* gating only considers the shared umbrella windows; an exhausted Fable weekly
|
|
427
|
+
* cap must not block Opus or Sonnet requests on the same credential.
|
|
428
|
+
*/
|
|
429
|
+
function buildScopedWeeklyUsageLimits(entries: readonly ParsedApiLimitEntry[]): UsageLimit[] {
|
|
430
|
+
const seenSlugs = new Set<string>();
|
|
431
|
+
const limits: UsageLimit[] = [];
|
|
432
|
+
for (const entry of entries) {
|
|
433
|
+
if (entry.kind !== "weekly_scoped" || !entry.displayName) continue;
|
|
434
|
+
const slug = slugifyClaudeLimitDisplayName(entry.displayName);
|
|
435
|
+
if (!slug || seenSlugs.has(slug)) continue;
|
|
436
|
+
seenSlugs.add(slug);
|
|
437
|
+
const limit = buildUsageLimit({
|
|
438
|
+
id: `anthropic:7d:${slug}`,
|
|
439
|
+
label: `Claude 7 Day (${entry.displayName})`,
|
|
440
|
+
windowId: "7d",
|
|
441
|
+
windowLabel: "7 Day",
|
|
442
|
+
durationMs: SEVEN_DAYS_MS,
|
|
443
|
+
bucket: entry.bucket,
|
|
444
|
+
provider: "anthropic",
|
|
445
|
+
tier: slug,
|
|
446
|
+
});
|
|
447
|
+
if (limit) limits.push(limit);
|
|
448
|
+
}
|
|
449
|
+
return limits;
|
|
450
|
+
}
|
|
451
|
+
|
|
346
452
|
export function parseClaudeRateLimitHeaders(headers: Record<string, string>, now = Date.now()): UsageReport | null {
|
|
347
453
|
const fiveHour = parseUnifiedWindow(headers, "5h");
|
|
348
454
|
const sevenDay = parseUnifiedWindow(headers, "7d");
|
|
455
|
+
const modelScopedSevenDay = parseUnifiedWindow(headers, "7d_oi");
|
|
349
456
|
const limits = [
|
|
350
457
|
buildUsageLimit({
|
|
351
458
|
id: "anthropic:5h",
|
|
@@ -367,6 +474,16 @@ export function parseClaudeRateLimitHeaders(headers: Record<string, string>, now
|
|
|
367
474
|
provider: "anthropic",
|
|
368
475
|
shared: true,
|
|
369
476
|
}),
|
|
477
|
+
buildUsageLimit({
|
|
478
|
+
id: "anthropic:7d:fable",
|
|
479
|
+
label: "Claude 7 Day (Fable)",
|
|
480
|
+
windowId: "7d",
|
|
481
|
+
windowLabel: "7 Day",
|
|
482
|
+
durationMs: SEVEN_DAYS_MS,
|
|
483
|
+
bucket: modelScopedSevenDay,
|
|
484
|
+
provider: "anthropic",
|
|
485
|
+
tier: "fable",
|
|
486
|
+
}),
|
|
370
487
|
].filter((limit): limit is UsageLimit => limit !== null);
|
|
371
488
|
|
|
372
489
|
if (limits.length === 0) return null;
|
|
@@ -394,8 +511,10 @@ async function fetchClaudeUsage(params: UsageFetchParams, ctx: UsageFetchContext
|
|
|
394
511
|
if (!payloadResult || !isRecord(payloadResult.payload)) return null;
|
|
395
512
|
const { payload, orgId } = payloadResult;
|
|
396
513
|
|
|
397
|
-
const
|
|
398
|
-
const
|
|
514
|
+
const apiLimitEntries = parseApiLimitEntries(payload.limits);
|
|
515
|
+
const fiveHour = parseBucket(payload.five_hour) ?? apiLimitEntries.find(entry => entry.kind === "session")?.bucket;
|
|
516
|
+
const sevenDay =
|
|
517
|
+
parseBucket(payload.seven_day) ?? apiLimitEntries.find(entry => entry.kind === "weekly_all")?.bucket;
|
|
399
518
|
const sevenDayOpus = parseBucket(payload.seven_day_opus);
|
|
400
519
|
const sevenDaySonnet = parseBucket(payload.seven_day_sonnet);
|
|
401
520
|
|
|
@@ -440,6 +559,7 @@ async function fetchClaudeUsage(params: UsageFetchParams, ctx: UsageFetchContext
|
|
|
440
559
|
provider: "anthropic",
|
|
441
560
|
tier: "sonnet",
|
|
442
561
|
}),
|
|
562
|
+
...buildScopedWeeklyUsageLimits(apiLimitEntries),
|
|
443
563
|
].filter((limit): limit is UsageLimit => limit !== null);
|
|
444
564
|
|
|
445
565
|
if (limits.length === 0) return null;
|
|
@@ -475,11 +595,84 @@ export const claudeUsageProvider: UsageProvider = {
|
|
|
475
595
|
supports: params => params.provider === "anthropic" && params.credential.type === "oauth",
|
|
476
596
|
};
|
|
477
597
|
|
|
598
|
+
function getClaudeModelKind(context: CredentialRankingContext | undefined): ClaudeModelKind | undefined {
|
|
599
|
+
const modelId = context?.modelId;
|
|
600
|
+
if (!modelId) return undefined;
|
|
601
|
+
return parseAnthropicModel(bareModelId(modelId))?.kind;
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
/**
|
|
605
|
+
* Claude model-scoped rows are only relevant to the matching model family.
|
|
606
|
+
* Credential-wide exhaustion checks stay on shared umbrella windows unless the
|
|
607
|
+
* request model parses to a concrete Anthropic kind, preventing a Fable cap from
|
|
608
|
+
* suppressing unrelated Opus/Sonnet traffic.
|
|
609
|
+
*/
|
|
610
|
+
function scopeClaudeLimitsForModel(report: UsageReport, context: CredentialRankingContext | undefined): UsageLimit[] {
|
|
611
|
+
const kind = getClaudeModelKind(context);
|
|
612
|
+
return report.limits.filter(
|
|
613
|
+
limit => limit.scope.shared === true || (kind !== undefined && limit.scope.tier === kind),
|
|
614
|
+
);
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
function rankingUsedFraction(limit: UsageLimit): number {
|
|
618
|
+
const fraction = resolveUsedFraction(limit);
|
|
619
|
+
if (typeof fraction !== "number" || !Number.isFinite(fraction)) return 0.5;
|
|
620
|
+
return Math.min(Math.max(fraction, 0), 1);
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
function rankingDrainRate(limit: UsageLimit, nowMs: number): number {
|
|
624
|
+
const usedFraction = rankingUsedFraction(limit);
|
|
625
|
+
const durationMs = limit.window?.durationMs ?? SEVEN_DAYS_MS;
|
|
626
|
+
if (!Number.isFinite(durationMs) || durationMs <= 0) return usedFraction;
|
|
627
|
+
const resetAt = limit.window?.resetsAt;
|
|
628
|
+
if (typeof resetAt !== "number" || !Number.isFinite(resetAt)) return usedFraction;
|
|
629
|
+
const remainingWindowMs = resetAt - nowMs;
|
|
630
|
+
const clampedRemainingWindowMs = Math.min(Math.max(remainingWindowMs, 0), durationMs);
|
|
631
|
+
const elapsedMs = durationMs - clampedRemainingWindowMs;
|
|
632
|
+
if (elapsedMs <= 0) return usedFraction;
|
|
633
|
+
const elapsedHours = elapsedMs / (60 * 60 * 1000);
|
|
634
|
+
if (!Number.isFinite(elapsedHours) || elapsedHours <= 0) return usedFraction;
|
|
635
|
+
return usedFraction / elapsedHours;
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
function morePressuredLimit(
|
|
639
|
+
left: UsageLimit | undefined,
|
|
640
|
+
right: UsageLimit | undefined,
|
|
641
|
+
nowMs: number,
|
|
642
|
+
): UsageLimit | undefined {
|
|
643
|
+
if (!left) return right;
|
|
644
|
+
if (!right) return left;
|
|
645
|
+
const leftDrainRate = rankingDrainRate(left, nowMs);
|
|
646
|
+
const rightDrainRate = rankingDrainRate(right, nowMs);
|
|
647
|
+
if (rightDrainRate !== leftDrainRate) return rightDrainRate > leftDrainRate ? right : left;
|
|
648
|
+
return rankingUsedFraction(right) > rankingUsedFraction(left) ? right : left;
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
function findClaudeSecondaryLimit(
|
|
652
|
+
report: UsageReport,
|
|
653
|
+
context: CredentialRankingContext | undefined,
|
|
654
|
+
): UsageLimit | undefined {
|
|
655
|
+
const nowMs = Date.now();
|
|
656
|
+
return scopeClaudeLimitsForModel(report, context)
|
|
657
|
+
.filter(limit => limit.scope.windowId === "7d" || limit.window?.id === "7d")
|
|
658
|
+
.reduce<UsageLimit | undefined>((selected, limit) => morePressuredLimit(selected, limit, nowMs), undefined);
|
|
659
|
+
}
|
|
660
|
+
|
|
478
661
|
export const claudeRankingStrategy: CredentialRankingStrategy = {
|
|
479
|
-
findWindowLimits(report) {
|
|
480
|
-
const primary = report.limits.find(
|
|
481
|
-
const secondary = report
|
|
662
|
+
findWindowLimits(report, context) {
|
|
663
|
+
const primary = report.limits.find(limit => limit.id === "anthropic:5h");
|
|
664
|
+
const secondary = findClaudeSecondaryLimit(report, context);
|
|
482
665
|
return { primary, secondary };
|
|
483
666
|
},
|
|
667
|
+
scopeLimits: scopeClaudeLimitsForModel,
|
|
668
|
+
/**
|
|
669
|
+
* Fable/Mythos usage-limit errors map to tier-local weekly counters. Scope
|
|
670
|
+
* reactive backoff blocks for those tiers, mirroring the per-counter
|
|
671
|
+
* precedent in packages/ai/src/usage/google-antigravity.ts:466-497.
|
|
672
|
+
*/
|
|
673
|
+
blockScope(context) {
|
|
674
|
+
const kind = getClaudeModelKind(context);
|
|
675
|
+
return kind === "fable" || kind === "mythos" ? `tier:${kind}` : undefined;
|
|
676
|
+
},
|
|
484
677
|
windowDefaults: { primaryMs: 5 * 60 * 60 * 1000, secondaryMs: 7 * 24 * 60 * 60 * 1000 },
|
|
485
678
|
};
|
|
@@ -30,3 +30,19 @@ export const kStreamingArgumentsDone = Symbol("provider.block.argumentsDone");
|
|
|
30
30
|
|
|
31
31
|
/** Classifies Cursor's in-flight tool-call kind without leaking provider-private state. */
|
|
32
32
|
export const kStreamingBlockKind = Symbol("provider.block.kind");
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Marks a `toolCall` content block that Cursor's exec channel already
|
|
36
|
+
* executed server-side (via the coding-agent bridge) and whose result is
|
|
37
|
+
* buffered separately for emission via the assistant-loop stream.
|
|
38
|
+
*
|
|
39
|
+
* `agent-loop.ts` MUST skip execution of blocks carrying this marker —
|
|
40
|
+
* treating them as a fresh runnable tool call would run the same
|
|
41
|
+
* side-effecting tool (bash, write, delete, …) a second time. Symbol-keyed
|
|
42
|
+
* so it never persists across the JSONL round-trip, where rebuild instead
|
|
43
|
+
* pairs the block with its already-persisted `toolResult` message by id.
|
|
44
|
+
*/
|
|
45
|
+
export const kCursorExecResolved = Symbol("provider.block.cursorExecResolved");
|
|
46
|
+
|
|
47
|
+
/** Carries the resolved marker without exposing a string-keyed property. */
|
|
48
|
+
export type CursorExecResolvedCarrier = object & { [kCursorExecResolved]?: true };
|