@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
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,34 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [16.3.3] - 2026-07-02
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- Added comprehensive tracking and credential-ranking support for Anthropic per-tier and weekly usage limits, including Claude Fable weekly caps. This prevents a single exhausted model-scoped cap from blocking the entire OAuth credential and improves credential selection based on drain-rate pressure.
|
|
10
|
+
|
|
11
|
+
### Changed
|
|
12
|
+
|
|
13
|
+
- Updated Claude Fable reasoning replay to use bare text instead of wrapped thinking tags
|
|
14
|
+
|
|
15
|
+
### Fixed
|
|
16
|
+
|
|
17
|
+
- Improved robustness of single-argument tool calls by automatically remapping mislabeled string arguments.
|
|
18
|
+
- Fixed Anthropic OAuth usage reporting to stop retrying on 429 rate-limit errors.
|
|
19
|
+
- Fixed usage cache to correctly persist null values during cold-start failure backoff windows.
|
|
20
|
+
- Fixed cursor-agent persisted transcripts losing tool-call structure for native execution tools, ensuring replayed tool results are correctly paired with their corresponding calls.
|
|
21
|
+
- Fixed OpenAI-compatible streaming usage parsing to prefer non-zero nested cached token counts when the root cached_tokens value is zero.
|
|
22
|
+
- Added automatic detection and remediation for custom proxies returning signature errors on Anthropic thinking blocks, allowing the client to automatically retry with unsigned blocks and prompt the user to adjust their configuration.
|
|
23
|
+
- Fixed potential hangs in GitLab Duo Workflow setup by adding proper timeout and abort signal handling to REST fetches.
|
|
24
|
+
- Fixed Cursor proxy tunnel setup hanging indefinitely by adding abort and timeout handling.
|
|
25
|
+
- Fixed Devin Connect streaming reader vulnerability to corrupt frame lengths by capping payloads at 16 MiB and throwing an envelope error immediately.
|
|
26
|
+
|
|
27
|
+
## [16.3.1] - 2026-07-02
|
|
28
|
+
|
|
29
|
+
### Changed
|
|
30
|
+
|
|
31
|
+
- Removed automated injection of reasoning suppression prompts in OpenAI responses
|
|
32
|
+
|
|
5
33
|
## [16.3.0] - 2026-07-02
|
|
6
34
|
|
|
7
35
|
### Added
|
|
@@ -91,12 +91,13 @@ export declare class RemoteAuthCredentialStore implements AuthCredentialStore {
|
|
|
91
91
|
/**
|
|
92
92
|
* Per-credential usage hook consumed by `AuthStorage.#getUsageReport`. Pulls
|
|
93
93
|
* the aggregate broker `/v1/usage` once and serves all callers from the
|
|
94
|
-
* same response (coalesced + cached), then
|
|
95
|
-
*
|
|
94
|
+
* same response (coalesced + cached), then overlays any client-observed
|
|
95
|
+
* header hints for the matching credential.
|
|
96
96
|
*
|
|
97
97
|
* The broker already aggregates with its own 30s TTL on the server side; our
|
|
98
98
|
* 15s client TTL is below that so we usually re-use the broker's cache too.
|
|
99
99
|
*/
|
|
100
100
|
getUsageReport(provider: Provider, credential: OAuthCredential, signal?: AbortSignal): Promise<UsageReport | null>;
|
|
101
|
+
ingestUsageReport(provider: Provider, credential: OAuthCredential, report: UsageReport): boolean;
|
|
101
102
|
close(): void;
|
|
102
103
|
}
|
|
@@ -287,6 +287,13 @@ export interface AuthCredentialStore {
|
|
|
287
287
|
* `signal` propagates the agent's cancel down to the broker fetch.
|
|
288
288
|
*/
|
|
289
289
|
getUsageReport?(provider: Provider, credential: OAuthCredential, signal?: AbortSignal): Promise<UsageReport | null>;
|
|
290
|
+
/**
|
|
291
|
+
* Optional store hook to ingest a parsed provider usage report for one OAuth
|
|
292
|
+
* credential. Remote broker stores use this to overlay header-derived limits
|
|
293
|
+
* onto their cached aggregate `/v1/usage` response without mutating broker
|
|
294
|
+
* state.
|
|
295
|
+
*/
|
|
296
|
+
ingestUsageReport?(provider: Provider, credential: OAuthCredential, report: UsageReport): boolean;
|
|
290
297
|
/**
|
|
291
298
|
* Optional store hook to invalidate a specific credential after the upstream
|
|
292
299
|
* provider returned 401 on a supposedly-fresh key. Remote stores force the
|
|
@@ -5,17 +5,22 @@
|
|
|
5
5
|
* replayed unsigned `thought` part is schema-accepted but silently discarded —
|
|
6
6
|
* neither recalled nor influencing generation).
|
|
7
7
|
*
|
|
8
|
-
* Fable is the exception:
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
8
|
+
* Fable is the exception: Anthropic's `reasoning_extraction` classifier blocks
|
|
9
|
+
* requests that replay prior reasoning inside `<thinking>` / `antml:thinking`
|
|
10
|
+
* tags OR the older `_Hmm. …_` italic envelope — it reads the wrapped
|
|
11
|
+
* chain-of-thought as an attempt to duplicate model outputs and refuses the
|
|
12
|
+
* whole turn. Fable therefore receives prior reasoning as bare assistant prose:
|
|
13
|
+
* no tag, no `_Hmm.` wrapper, no trailing newline.
|
|
14
|
+
* Heat is cumulative (block count and early-conversation position also raise
|
|
15
|
+
* it), so this lowers per-block signal but does not license unbounded replay.
|
|
16
|
+
* Harmony and Gemma are also exceptions: their `renderThinking` emits
|
|
17
|
+
* chat-template control tokens (`<|channel|>analysis`, `<|channel>thought`)
|
|
18
|
+
* that must not appear inside a structured native message, so they fall back to
|
|
19
|
+
* a plain `<think>` block. Every other dialect's thinking form is inline-safe
|
|
20
|
+
* XML tags or a markdown fence.
|
|
16
21
|
*
|
|
17
|
-
* The result
|
|
18
|
-
*
|
|
22
|
+
* The result does not append a delimiter; callers that flatten adjacent blocks
|
|
23
|
+
* into a single string must insert their own separator.
|
|
19
24
|
*
|
|
20
25
|
* Distinct from {@link DialectDefinition.renderThinking}, which targets the
|
|
21
26
|
* owned-dialect *text transport* where those control tokens are legal.
|
|
@@ -204,6 +204,16 @@ export type AnthropicUsageLike = {
|
|
|
204
204
|
* zero-valued objects clear prior extras from earlier stream usage snapshots.
|
|
205
205
|
*/
|
|
206
206
|
export declare function applyAnthropicUsageExtras(usage: Usage, source: AnthropicUsageLike): void;
|
|
207
|
+
export declare function isInvalidThinkingSignatureError(message: string): boolean;
|
|
208
|
+
/**
|
|
209
|
+
* Prepend a pointed remediation to Anthropic's `Invalid signature in thinking
|
|
210
|
+
* block` 400 when the model looks like an unmarked custom signing proxy
|
|
211
|
+
* (opaque baseUrl, `spec.reasoning: true`, no explicit
|
|
212
|
+
* `compat.replayUnsignedThinking` override). The default is native replay for
|
|
213
|
+
* the 3p reasoning majority (#2005); this hint turns the misconfigured-proxy
|
|
214
|
+
* case into a one-line fix instead of a silent retry loop (#4297).
|
|
215
|
+
*/
|
|
216
|
+
export declare function maybeAddReplayUnsignedThinkingHint(model: Model<"anthropic-messages">, message: string): string;
|
|
207
217
|
/**
|
|
208
218
|
* Public entry: wrap the single-attempt streamer with bounded empty-completion
|
|
209
219
|
* retries (a benign terminal stop carrying no content/usage would otherwise
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { type JsonValue } from "@bufbuild/protobuf";
|
|
2
2
|
import type { AssistantMessage, CursorExecHandlerResult, CursorExecHandlers, CursorToolResultHandler, Message, StreamFunction, StreamOptions, TextContent, ThinkingContent, ToolCall, ToolResultMessage } from "../types";
|
|
3
|
-
import { kStreamingBlockIndex, kStreamingBlockKind, kStreamingLastParseLen, kStreamingPartialJson } from "../utils/block-symbols";
|
|
3
|
+
import { kCursorExecResolved, kStreamingBlockIndex, kStreamingBlockKind, kStreamingLastParseLen, kStreamingPartialJson } from "../utils/block-symbols";
|
|
4
4
|
import { AssistantMessageEventStream } from "../utils/event-stream";
|
|
5
5
|
export declare const CURSOR_API_URL = "https://api2.cursor.sh";
|
|
6
6
|
export declare const CURSOR_CLIENT_VERSION = "cli-2026.01.09-231024f";
|
|
@@ -15,7 +15,8 @@ export type ToolCallState = ToolCall & {
|
|
|
15
15
|
[kStreamingBlockIndex]: number;
|
|
16
16
|
[kStreamingPartialJson]?: string;
|
|
17
17
|
[kStreamingLastParseLen]?: number;
|
|
18
|
-
[kStreamingBlockKind]: "mcp" | "todo";
|
|
18
|
+
[kStreamingBlockKind]: "mcp" | "todo" | "cursor-exec";
|
|
19
|
+
[kCursorExecResolved]?: true;
|
|
19
20
|
};
|
|
20
21
|
export interface BlockState {
|
|
21
22
|
currentTextBlock: (TextContent & {
|
|
@@ -60,6 +61,26 @@ export declare function resolveExecHandler<TArgs, TResult>(args: TArgs, handler:
|
|
|
60
61
|
* - otherwise → completion wins.
|
|
61
62
|
*/
|
|
62
63
|
export declare function mergeCursorMcpToolCallArgs(streamed: Record<string, unknown> | undefined, completion: Record<string, unknown> | undefined): Record<string, unknown>;
|
|
64
|
+
/**
|
|
65
|
+
* Synthesize a completed `toolCall` content block for a Cursor exec-channel
|
|
66
|
+
* native tool (`shell`, `read`, `write`, `grep`, `ls`, `delete`, `diagnostics`).
|
|
67
|
+
*
|
|
68
|
+
* Args arrive complete on the exec message, so the block opens and closes in
|
|
69
|
+
* one step — no partial-JSON streaming path. Without this the persisted
|
|
70
|
+
* assistant message carries only text/thinking blocks, and on replay the
|
|
71
|
+
* following `toolResult` messages have no matching `toolCall.id` in
|
|
72
|
+
* `renderSessionContext`, so they render as header-less `⎿` lines beneath the
|
|
73
|
+
* last text block instead of proper tool components (issue #4348).
|
|
74
|
+
*
|
|
75
|
+
* The block is stamped with {@link kCursorExecResolved} so the shared
|
|
76
|
+
* `agent-loop.ts` execution pass skips it — Cursor's server-driven exec
|
|
77
|
+
* channel already ran the tool via the bridge and buffered the result, so
|
|
78
|
+
* treating this block as runnable would re-execute the same side-effecting
|
|
79
|
+
* tool a second time.
|
|
80
|
+
*
|
|
81
|
+
* Exported for tests to exercise ordering with adjacent text/thinking blocks.
|
|
82
|
+
*/
|
|
83
|
+
export declare function synthesizeCursorExecToolCall(output: AssistantMessage, stream: AssistantMessageEventStream, state: BlockState, toolCallId: string, toolName: string, args: Record<string, unknown>): void;
|
|
63
84
|
/** Exported for tests: drives one Cursor interaction update through the streaming state machine. */
|
|
64
85
|
export declare function processInteractionUpdate(update: any, output: AssistantMessage, stream: AssistantMessageEventStream, state: BlockState, usageState: UsageState): void;
|
|
65
86
|
/**
|
|
@@ -64,8 +64,8 @@ interface OpenAIResponsesProviderSessionState extends ProviderSessionState, Open
|
|
|
64
64
|
}
|
|
65
65
|
interface OpenAIResponsesChainState {
|
|
66
66
|
/**
|
|
67
|
-
* Wire params of the last successful turn
|
|
68
|
-
*
|
|
67
|
+
* Wire params of the last successful turn; never carries
|
|
68
|
+
* `previous_response_id`.
|
|
69
69
|
*/
|
|
70
70
|
lastParams?: OpenAIResponsesSamplingParams;
|
|
71
71
|
lastResponseId?: string;
|
|
@@ -108,7 +108,6 @@ type OpenAIResponsesSamplingParams = ResponseCreateParamsStreaming & {
|
|
|
108
108
|
export declare const streamOpenAIResponses: StreamFunction<"openai-responses">;
|
|
109
109
|
export declare function buildParams(model: Model<"openai-responses">, context: Context, options: OpenAIResponsesOptions | undefined, providerSessionState: OpenAIResponsesProviderSessionState | undefined, strictToolsScope?: OpenAIStrictToolsScope, disableStrictToolsOverride?: boolean): {
|
|
110
110
|
params: OpenAIResponsesSamplingParams;
|
|
111
|
-
trailingScaffoldingItems: number;
|
|
112
111
|
strictToolsApplied: boolean;
|
|
113
112
|
};
|
|
114
113
|
/**
|
|
@@ -465,12 +465,12 @@ export interface ApplyResponsesCompatPolicyOptions {
|
|
|
465
465
|
reasoningSummary?: "auto" | "detailed" | "concise" | null;
|
|
466
466
|
mapEffort?: (effort: string) => string;
|
|
467
467
|
}
|
|
468
|
-
export declare function applyResponsesCompatPolicy<P extends ResponseCreateParamsStreaming>(params: P,
|
|
468
|
+
export declare function applyResponsesCompatPolicy<P extends ResponseCreateParamsStreaming>(params: P, policy: OpenAICompatPolicy, options: ApplyResponsesCompatPolicyOptions | undefined): void;
|
|
469
469
|
/**
|
|
470
470
|
* Apply reasoning-related Responses parameters. Default behavior comes from
|
|
471
471
|
* catalog compat; include/omit arguments are explicit adapter-wrapper overrides.
|
|
472
472
|
*/
|
|
473
|
-
export declare function applyResponsesReasoningParams<P extends ResponseCreateParamsStreaming>(params: P, model: Model<"openai-responses" | "azure-openai-responses" | "openai-codex-responses">, options: ReasoningOptions | undefined,
|
|
473
|
+
export declare function applyResponsesReasoningParams<P extends ResponseCreateParamsStreaming>(params: P, model: Model<"openai-responses" | "azure-openai-responses" | "openai-codex-responses">, options: ReasoningOptions | undefined, mapEffort?: (effort: string) => string, includeEncryptedReasoning?: boolean, omitReasoningEffort?: boolean): void;
|
|
474
474
|
/** Populate `output.usage` from a Responses-API `response.usage` payload. Does not invoke `calculateCost`. */
|
|
475
475
|
export declare function populateResponsesUsageFromResponse(output: AssistantMessage, usage: {
|
|
476
476
|
input_tokens?: number | null;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { type CredentialRankingStrategy, type UsageProvider, type UsageReport } from "../usage";
|
|
2
2
|
export declare function parseClaudeRateLimitHeaders(headers: Record<string, string>, now?: number): UsageReport | null;
|
|
3
3
|
export declare const claudeUsageProvider: UsageProvider;
|
|
4
4
|
export declare const claudeRankingStrategy: CredentialRankingStrategy;
|
|
@@ -18,3 +18,19 @@ export declare const kStreamingLastParseLen: unique symbol;
|
|
|
18
18
|
export declare const kStreamingArgumentsDone: unique symbol;
|
|
19
19
|
/** Classifies Cursor's in-flight tool-call kind without leaking provider-private state. */
|
|
20
20
|
export declare const kStreamingBlockKind: unique symbol;
|
|
21
|
+
/**
|
|
22
|
+
* Marks a `toolCall` content block that Cursor's exec channel already
|
|
23
|
+
* executed server-side (via the coding-agent bridge) and whose result is
|
|
24
|
+
* buffered separately for emission via the assistant-loop stream.
|
|
25
|
+
*
|
|
26
|
+
* `agent-loop.ts` MUST skip execution of blocks carrying this marker —
|
|
27
|
+
* treating them as a fresh runnable tool call would run the same
|
|
28
|
+
* side-effecting tool (bash, write, delete, …) a second time. Symbol-keyed
|
|
29
|
+
* so it never persists across the JSONL round-trip, where rebuild instead
|
|
30
|
+
* pairs the block with its already-persisted `toolResult` message by id.
|
|
31
|
+
*/
|
|
32
|
+
export declare const kCursorExecResolved: unique symbol;
|
|
33
|
+
/** Carries the resolved marker without exposing a string-keyed property. */
|
|
34
|
+
export type CursorExecResolvedCarrier = object & {
|
|
35
|
+
[kCursorExecResolved]?: true;
|
|
36
|
+
};
|
|
@@ -22,8 +22,14 @@ export declare function getProxyForProvider(provider: string): string | undefine
|
|
|
22
22
|
* Wraps a fetch implementation to inject proxy options for non-local hosts.
|
|
23
23
|
*/
|
|
24
24
|
export declare function wrapFetchForProxy(fetchImpl: FetchImpl, provider: string): FetchImpl;
|
|
25
|
+
export interface ConnectProxiedSocketOptions {
|
|
26
|
+
/** Caller cancellation for the proxy TCP/TLS handshake and CONNECT tunnel. */
|
|
27
|
+
signal?: AbortSignal;
|
|
28
|
+
/** Maximum wall-clock time to establish the final TLS tunnel. Disabled when absent or non-positive. */
|
|
29
|
+
timeoutMs?: number;
|
|
30
|
+
}
|
|
25
31
|
/**
|
|
26
32
|
* Tunnel a socket connection through an HTTP CONNECT proxy.
|
|
27
33
|
* This is used specifically to wrap Node's `http2.connect(baseUrl, { createConnection })` for Cursor.
|
|
28
34
|
*/
|
|
29
|
-
export declare function connectProxiedSocket(proxyUrlStr: string, targetUrlStr: string): Promise<tls.TLSSocket>;
|
|
35
|
+
export declare function connectProxiedSocket(proxyUrlStr: string, targetUrlStr: string, options?: ConnectProxiedSocketOptions): Promise<tls.TLSSocket>;
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"type": "module",
|
|
3
3
|
"name": "@oh-my-pi/pi-ai",
|
|
4
|
-
"version": "16.3.
|
|
4
|
+
"version": "16.3.3",
|
|
5
5
|
"description": "Unified LLM API with automatic model discovery and provider configuration",
|
|
6
6
|
"homepage": "https://omp.sh",
|
|
7
7
|
"author": "Can Boluk",
|
|
@@ -38,9 +38,9 @@
|
|
|
38
38
|
},
|
|
39
39
|
"dependencies": {
|
|
40
40
|
"@bufbuild/protobuf": "^2.12.0",
|
|
41
|
-
"@oh-my-pi/pi-catalog": "16.3.
|
|
42
|
-
"@oh-my-pi/pi-utils": "16.3.
|
|
43
|
-
"@oh-my-pi/pi-wire": "16.3.
|
|
41
|
+
"@oh-my-pi/pi-catalog": "16.3.3",
|
|
42
|
+
"@oh-my-pi/pi-utils": "16.3.3",
|
|
43
|
+
"@oh-my-pi/pi-wire": "16.3.3",
|
|
44
44
|
"arktype": "^2.2.0",
|
|
45
45
|
"zod": "^4"
|
|
46
46
|
},
|
|
@@ -25,10 +25,11 @@ import { type AuthBrokerClient, AuthBrokerStreamUnsupportedError } from "./clien
|
|
|
25
25
|
import type { RefresherSchedule, SnapshotEntry, SnapshotResponse, SnapshotStreamEvent } from "./types";
|
|
26
26
|
|
|
27
27
|
/**
|
|
28
|
-
* Client-side TTL for the aggregate `/v1/usage` response.
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
28
|
+
* Client-side TTL for the aggregate `/v1/usage` response. The broker dedups
|
|
29
|
+
* upstream `/usage` hits via AuthStorage's 5-minute per-credential cache plus
|
|
30
|
+
* single-flight, so this short client TTL mainly folds the parallel fan-out
|
|
31
|
+
* from `#rankOAuthSelections` into a single round-trip — a ranking pass issues
|
|
32
|
+
* one broker call instead of N.
|
|
32
33
|
*/
|
|
33
34
|
const USAGE_CACHE_TTL_MS = 15_000;
|
|
34
35
|
const WAIT_THRESHOLD_MS = 1_000;
|
|
@@ -68,6 +69,47 @@ interface UsageCacheEntry {
|
|
|
68
69
|
fetchedAt: number;
|
|
69
70
|
}
|
|
70
71
|
|
|
72
|
+
function usageOverlayKey(
|
|
73
|
+
provider: Provider,
|
|
74
|
+
ids: { accountId?: string; email?: string; projectId?: string },
|
|
75
|
+
): string | undefined {
|
|
76
|
+
const accountId = ids.accountId?.trim().toLowerCase();
|
|
77
|
+
if (accountId) return `${provider}\0account:${accountId}`;
|
|
78
|
+
const email = ids.email?.trim().toLowerCase();
|
|
79
|
+
if (email) return `${provider}\0email:${email}`;
|
|
80
|
+
const projectId = ids.projectId?.trim().toLowerCase();
|
|
81
|
+
if (projectId) return `${provider}\0project:${projectId}`;
|
|
82
|
+
return undefined;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function mergeUsageReports(base: UsageReport, overlay: UsageReport): UsageReport {
|
|
86
|
+
const overlayLimitsById = new Map(overlay.limits.map(limit => [limit.id, limit]));
|
|
87
|
+
const limits = [];
|
|
88
|
+
for (const limit of base.limits) {
|
|
89
|
+
const replacement = overlayLimitsById.get(limit.id);
|
|
90
|
+
if (replacement) {
|
|
91
|
+
limits.push(replacement);
|
|
92
|
+
overlayLimitsById.delete(limit.id);
|
|
93
|
+
} else {
|
|
94
|
+
limits.push(limit);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
for (const limit of overlayLimitsById.values()) limits.push(limit);
|
|
98
|
+
const overlayMetadata = (overlay.metadata ?? {}) as Record<string, unknown>;
|
|
99
|
+
return {
|
|
100
|
+
...base,
|
|
101
|
+
fetchedAt: Math.max(base.fetchedAt, overlay.fetchedAt),
|
|
102
|
+
limits,
|
|
103
|
+
metadata: {
|
|
104
|
+
...overlayMetadata,
|
|
105
|
+
...(base.metadata ?? {}),
|
|
106
|
+
...(overlayMetadata.headersUpdatedAt !== undefined
|
|
107
|
+
? { headersUpdatedAt: overlayMetadata.headersUpdatedAt }
|
|
108
|
+
: {}),
|
|
109
|
+
},
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
71
113
|
export interface RemoteAuthCredentialStoreOptions {
|
|
72
114
|
client: AuthBrokerClient;
|
|
73
115
|
/**
|
|
@@ -94,6 +136,7 @@ export class RemoteAuthCredentialStore implements AuthCredentialStore {
|
|
|
94
136
|
#snapshot: SnapshotResponse = emptySnapshot();
|
|
95
137
|
#snapshotReceivedAt = Date.now();
|
|
96
138
|
#generation = 0;
|
|
139
|
+
#usageOverlays: Map<string, UsageReport> = new Map();
|
|
97
140
|
#backgroundAbort = new AbortController();
|
|
98
141
|
#cache: Map<string, CacheEntry> = new Map();
|
|
99
142
|
#usageCache?: UsageCacheEntry;
|
|
@@ -515,14 +558,15 @@ export class RemoteAuthCredentialStore implements AuthCredentialStore {
|
|
|
515
558
|
* residential laptop is, so all credentials surface every cycle.
|
|
516
559
|
*/
|
|
517
560
|
async fetchUsageReports(signal?: AbortSignal): Promise<UsageReport[] | null> {
|
|
518
|
-
|
|
561
|
+
const reports = await this.#raceWithSignal(this.#loadUsageReports(), signal);
|
|
562
|
+
return reports ? this.#applyUsageOverlays(reports) : null;
|
|
519
563
|
}
|
|
520
564
|
|
|
521
565
|
/**
|
|
522
566
|
* Per-credential usage hook consumed by `AuthStorage.#getUsageReport`. Pulls
|
|
523
567
|
* the aggregate broker `/v1/usage` once and serves all callers from the
|
|
524
|
-
* same response (coalesced + cached), then
|
|
525
|
-
*
|
|
568
|
+
* same response (coalesced + cached), then overlays any client-observed
|
|
569
|
+
* header hints for the matching credential.
|
|
526
570
|
*
|
|
527
571
|
* The broker already aggregates with its own 30s TTL on the server side; our
|
|
528
572
|
* 15s client TTL is below that so we usually re-use the broker's cache too.
|
|
@@ -533,8 +577,47 @@ export class RemoteAuthCredentialStore implements AuthCredentialStore {
|
|
|
533
577
|
signal?: AbortSignal,
|
|
534
578
|
): Promise<UsageReport | null> {
|
|
535
579
|
const reports = await this.#raceWithSignal(this.#loadUsageReports(), signal);
|
|
536
|
-
|
|
537
|
-
|
|
580
|
+
const matched = reports ? matchUsageReport(reports, provider, credential) : null;
|
|
581
|
+
const overlay = this.#getActiveUsageOverlay(provider, credential);
|
|
582
|
+
if (matched && overlay) return mergeUsageReports(matched, overlay);
|
|
583
|
+
return overlay ?? matched;
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
ingestUsageReport(provider: Provider, credential: OAuthCredential, report: UsageReport): boolean {
|
|
587
|
+
const key = usageOverlayKey(provider, credential);
|
|
588
|
+
if (!key) return false;
|
|
589
|
+
const activeOverlay = this.#getActiveUsageOverlay(provider, credential);
|
|
590
|
+
this.#usageOverlays.set(key, activeOverlay ? mergeUsageReports(activeOverlay, report) : report);
|
|
591
|
+
return true;
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
#getActiveUsageOverlay(provider: Provider, credential: OAuthCredential): UsageReport | undefined {
|
|
595
|
+
const key = usageOverlayKey(provider, credential);
|
|
596
|
+
if (!key) return undefined;
|
|
597
|
+
const overlay = this.#usageOverlays.get(key);
|
|
598
|
+
if (!overlay) return undefined;
|
|
599
|
+
if (Date.now() - overlay.fetchedAt >= USAGE_CACHE_TTL_MS) {
|
|
600
|
+
this.#usageOverlays.delete(key);
|
|
601
|
+
return undefined;
|
|
602
|
+
}
|
|
603
|
+
return overlay;
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
#applyUsageOverlays(reports: UsageReport[]): UsageReport[] {
|
|
607
|
+
const overlays = [...this.#usageOverlays.values()].filter(
|
|
608
|
+
overlay => Date.now() - overlay.fetchedAt < USAGE_CACHE_TTL_MS,
|
|
609
|
+
);
|
|
610
|
+
if (overlays.length === 0) return reports;
|
|
611
|
+
const merged = [...reports];
|
|
612
|
+
for (const overlay of overlays) {
|
|
613
|
+
const matchIndex = findMatchingReportIndex(merged, overlay);
|
|
614
|
+
if (matchIndex === -1) {
|
|
615
|
+
merged.push(overlay);
|
|
616
|
+
} else {
|
|
617
|
+
merged[matchIndex] = mergeUsageReports(merged[matchIndex]!, overlay);
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
return merged;
|
|
538
621
|
}
|
|
539
622
|
|
|
540
623
|
/**
|
|
@@ -597,6 +680,7 @@ export class RemoteAuthCredentialStore implements AuthCredentialStore {
|
|
|
597
680
|
this.#closed = true;
|
|
598
681
|
this.#backgroundAbort.abort();
|
|
599
682
|
this.#cache.clear();
|
|
683
|
+
this.#usageOverlays.clear();
|
|
600
684
|
}
|
|
601
685
|
}
|
|
602
686
|
|
|
@@ -623,6 +707,22 @@ function matchUsageReport(reports: UsageReport[], provider: Provider, credential
|
|
|
623
707
|
return null;
|
|
624
708
|
}
|
|
625
709
|
|
|
710
|
+
function findMatchingReportIndex(reports: UsageReport[], overlay: UsageReport): number {
|
|
711
|
+
const candidates = reports
|
|
712
|
+
.map((report, index) => ({ report, index }))
|
|
713
|
+
.filter(candidate => candidate.report.provider === overlay.provider);
|
|
714
|
+
if (candidates.length === 0) return -1;
|
|
715
|
+
if (candidates.length === 1) return candidates[0]!.index;
|
|
716
|
+
const metadata = (overlay.metadata ?? {}) as Record<string, unknown>;
|
|
717
|
+
const accountId = readMetadataString(metadata, "accountId")?.toLowerCase();
|
|
718
|
+
const email = readMetadataString(metadata, "email")?.toLowerCase();
|
|
719
|
+
const projectId = readMetadataString(metadata, "projectId")?.toLowerCase();
|
|
720
|
+
for (const candidate of candidates) {
|
|
721
|
+
if (reportMatchesIdentity(candidate.report, accountId, email, projectId)) return candidate.index;
|
|
722
|
+
}
|
|
723
|
+
return -1;
|
|
724
|
+
}
|
|
725
|
+
|
|
626
726
|
function reportMatchesIdentity(
|
|
627
727
|
report: UsageReport,
|
|
628
728
|
accountId: string | undefined,
|
package/src/auth-storage.ts
CHANGED
|
@@ -362,6 +362,13 @@ export interface AuthCredentialStore {
|
|
|
362
362
|
* `signal` propagates the agent's cancel down to the broker fetch.
|
|
363
363
|
*/
|
|
364
364
|
getUsageReport?(provider: Provider, credential: OAuthCredential, signal?: AbortSignal): Promise<UsageReport | null>;
|
|
365
|
+
/**
|
|
366
|
+
* Optional store hook to ingest a parsed provider usage report for one OAuth
|
|
367
|
+
* credential. Remote broker stores use this to overlay header-derived limits
|
|
368
|
+
* onto their cached aggregate `/v1/usage` response without mutating broker
|
|
369
|
+
* state.
|
|
370
|
+
*/
|
|
371
|
+
ingestUsageReport?(provider: Provider, credential: OAuthCredential, report: UsageReport): boolean;
|
|
365
372
|
/**
|
|
366
373
|
* Optional store hook to invalidate a specific credential after the upstream
|
|
367
374
|
* provider returned 401 on a supposedly-fresh key. Remote stores force the
|
|
@@ -2234,16 +2241,15 @@ export class AuthStorage {
|
|
|
2234
2241
|
this.#recordUsageHistory(request, report);
|
|
2235
2242
|
return report;
|
|
2236
2243
|
}
|
|
2237
|
-
// Failure:
|
|
2238
|
-
//
|
|
2239
|
-
//
|
|
2240
|
-
//
|
|
2244
|
+
// Failure: apply a short jittered cool-down so the credential doesn't
|
|
2245
|
+
// re-hit the endpoint on every poll. Serve the last good value when we
|
|
2246
|
+
// have one (keeps the credential in the report); otherwise cache null
|
|
2247
|
+
// so a cold or throttled credential stops re-bursting until the window
|
|
2248
|
+
// expires and the next poll retries.
|
|
2241
2249
|
const lastGood = this.#usageCache.getStale<UsageReport | null>(cacheKey)?.value ?? null;
|
|
2242
|
-
|
|
2243
|
-
|
|
2244
|
-
|
|
2245
|
-
this.#usageCache.set(cacheKey, { value: lastGood, expiresAt: coolDown });
|
|
2246
|
-
}
|
|
2250
|
+
const backoffJitter = USAGE_FAILURE_BACKOFF_MS * (Math.random() * 0.5 - 0.25);
|
|
2251
|
+
const coolDown = Date.now() + USAGE_FAILURE_BACKOFF_MS + backoffJitter;
|
|
2252
|
+
this.#usageCache.set(cacheKey, { value: lastGood, expiresAt: coolDown });
|
|
2247
2253
|
return lastGood;
|
|
2248
2254
|
})().finally(() => {
|
|
2249
2255
|
this.#usageRequestInFlight.delete(cacheKey);
|
|
@@ -2360,7 +2366,7 @@ export class AuthStorage {
|
|
|
2360
2366
|
headers: Record<string, string>,
|
|
2361
2367
|
options?: { sessionId?: string; baseUrl?: string },
|
|
2362
2368
|
): boolean {
|
|
2363
|
-
if (this.#fetchUsageReportsOverride
|
|
2369
|
+
if (this.#fetchUsageReportsOverride) return false;
|
|
2364
2370
|
|
|
2365
2371
|
const credential = this.#resolveActiveOAuthCredential(provider, options?.sessionId);
|
|
2366
2372
|
if (!credential) return false;
|
|
@@ -2380,6 +2386,14 @@ export class AuthStorage {
|
|
|
2380
2386
|
if (credential.projectId && metadata.projectId === undefined) metadata.projectId = credential.projectId;
|
|
2381
2387
|
const report: UsageReport = { ...parsedReport, metadata };
|
|
2382
2388
|
|
|
2389
|
+
const storeIngest = this.#store.ingestUsageReport?.bind(this.#store);
|
|
2390
|
+
if (storeIngest) {
|
|
2391
|
+
const ingested = storeIngest(provider, credential, report);
|
|
2392
|
+
if (ingested) this.#usageHeaderIngestAt.set(cacheKey, now);
|
|
2393
|
+
return ingested;
|
|
2394
|
+
}
|
|
2395
|
+
|
|
2396
|
+
if (this.#fetchUsageReportsOverride || this.#store.fetchUsageReports) return false;
|
|
2383
2397
|
const prior = this.#usageCache.getStale<UsageReport | null>(cacheKey)?.value;
|
|
2384
2398
|
let merged = report;
|
|
2385
2399
|
if (prior && Array.isArray(prior.limits)) {
|
package/src/dialect/demotion.ts
CHANGED
|
@@ -10,17 +10,22 @@ const CLAUDE_FABLE_ID = /(?:^|[./])claude[-.]fable(?:[-.]|$)/i;
|
|
|
10
10
|
* replayed unsigned `thought` part is schema-accepted but silently discarded —
|
|
11
11
|
* neither recalled nor influencing generation).
|
|
12
12
|
*
|
|
13
|
-
* Fable is the exception:
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
13
|
+
* Fable is the exception: Anthropic's `reasoning_extraction` classifier blocks
|
|
14
|
+
* requests that replay prior reasoning inside `<thinking>` / `antml:thinking`
|
|
15
|
+
* tags OR the older `_Hmm. …_` italic envelope — it reads the wrapped
|
|
16
|
+
* chain-of-thought as an attempt to duplicate model outputs and refuses the
|
|
17
|
+
* whole turn. Fable therefore receives prior reasoning as bare assistant prose:
|
|
18
|
+
* no tag, no `_Hmm.` wrapper, no trailing newline.
|
|
19
|
+
* Heat is cumulative (block count and early-conversation position also raise
|
|
20
|
+
* it), so this lowers per-block signal but does not license unbounded replay.
|
|
21
|
+
* Harmony and Gemma are also exceptions: their `renderThinking` emits
|
|
22
|
+
* chat-template control tokens (`<|channel|>analysis`, `<|channel>thought`)
|
|
23
|
+
* that must not appear inside a structured native message, so they fall back to
|
|
24
|
+
* a plain `<think>` block. Every other dialect's thinking form is inline-safe
|
|
25
|
+
* XML tags or a markdown fence.
|
|
21
26
|
*
|
|
22
|
-
* The result
|
|
23
|
-
*
|
|
27
|
+
* The result does not append a delimiter; callers that flatten adjacent blocks
|
|
28
|
+
* into a single string must insert their own separator.
|
|
24
29
|
*
|
|
25
30
|
* Distinct from {@link DialectDefinition.renderThinking}, which targets the
|
|
26
31
|
* owned-dialect *text transport* where those control tokens are legal.
|
|
@@ -30,7 +35,7 @@ export function renderDemotedThinking(modelId: string, text: string): string {
|
|
|
30
35
|
text = text.toWellFormed();
|
|
31
36
|
const canonicalId = bareModelId(modelId);
|
|
32
37
|
const dialect = preferredDialect(modelId);
|
|
33
|
-
if (CLAUDE_FABLE_ID.test(canonicalId)) return
|
|
34
|
-
if (dialect === "harmony" || dialect === "gemma") return `<think>\n${text}\n</think
|
|
35
|
-
return
|
|
38
|
+
if (CLAUDE_FABLE_ID.test(canonicalId)) return text;
|
|
39
|
+
if (dialect === "harmony" || dialect === "gemma") return `<think>\n${text}\n</think>`;
|
|
40
|
+
return getDialectDefinition(dialect).renderThinking(text);
|
|
36
41
|
}
|