@oh-my-pi/pi-ai 15.10.6 → 15.10.7

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 CHANGED
@@ -2,6 +2,14 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [15.10.7] - 2026-06-08
6
+
7
+ ### Fixed
8
+
9
+ - Fixed first-party Anthropic requests returning HTTP 400 "Invalid `signature` in `thinking` block" after interrupting the model during its visible output. `transformMessages` stripped the signature from every `thinking` block of an `aborted`/`error` turn, including blocks that had already finished streaming — Anthropic delivers a block's signature at `content_block_stop` before the next block starts, so a thinking block followed by `text`/`tool_use` is fully signed. The valid signature was then replayed empty (`signature: ""`), which signature-enforcing Anthropic rejects, including when the provider is routed through an LLM gateway baseUrl. Only the single mid-stream block at the abort point is now treated as untrustworthy; completed thinking blocks keep their replayable signatures ([#2144](https://github.com/can1357/oh-my-pi/issues/2144)).
10
+ - Pinned a regression test against issue [#2123](https://github.com/can1357/oh-my-pi/issues/2123): OAuth requests to adaptive-thinking Claude Opus models (4.6+) ship a `context_management.edits[clear_thinking_20251015]` block paired with the `thinking` field, but the eager-todo prelude (and other paths that force `tool_choice` to `tool`/`any` on the first user turn) route through `disableThinkingIfToolChoiceForced`, which would strip `params.thinking` while leaving the orphan `context_management` behind. The Anthropic API then rejected the request with `400 ... clear_thinking_20251015 strategy requires thinking to be enabled or adaptive`. The fix that lands in [15.10.5] now drops both fields together; the new test locks the contract so the strategy can never outlive its enabling `thinking` payload again.
11
+ - Fixed Antigravity usage counters so exhausted Google/Gemini quota renders as `0% free` while separate Anthropic/OpenAI-backed Antigravity model counters remain visible independently, without replaying stale pre-fix cached usage reports.
12
+
5
13
  ## [15.10.6] - 2026-06-08
6
14
 
7
15
  ### Added
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@oh-my-pi/pi-ai",
4
- "version": "15.10.6",
4
+ "version": "15.10.7",
5
5
  "description": "Unified LLM API with automatic model discovery and provider configuration",
6
6
  "homepage": "https://omp.sh",
7
7
  "author": "Can Boluk",
@@ -39,7 +39,7 @@
39
39
  },
40
40
  "dependencies": {
41
41
  "@bufbuild/protobuf": "^2.12.0",
42
- "@oh-my-pi/pi-utils": "15.10.6",
42
+ "@oh-my-pi/pi-utils": "15.10.7",
43
43
  "openai": "^6.39.0",
44
44
  "partial-json": "^0.1.7",
45
45
  "zod": "4.4.3"
@@ -492,6 +492,9 @@ const USAGE_FAILURE_BACKOFF_MS = 10_000;
492
492
  // Bumped from 3s — Claude usage retries up to 3 times with exponential backoff
493
493
  // (~3.5s total worst case); a tight per-request budget aborts retries mid-cycle.
494
494
  const DEFAULT_USAGE_REQUEST_TIMEOUT_MS = 10_000;
495
+ const USAGE_REPORT_CACHE_KEY_VERSION_OVERRIDES: Partial<Record<Provider, number>> = {
496
+ "google-antigravity": 2,
497
+ };
495
498
  const DEFAULT_OAUTH_REFRESH_TIMEOUT_MS = 10_000;
496
499
  /**
497
500
  * Refresh OAuth access tokens this many ms before their stated expiry. The
@@ -1638,15 +1641,19 @@ export class AuthStorage {
1638
1641
  #buildUsageReportCacheKey(request: UsageRequestDescriptor): string {
1639
1642
  const baseUrl = this.#normalizeUsageBaseUrl(request.baseUrl) || "default";
1640
1643
  const identity = this.#buildUsageCacheIdentity(request.credential);
1641
- return `report:${request.provider}:${baseUrl}:${identity}`;
1644
+ const versionOverride = USAGE_REPORT_CACHE_KEY_VERSION_OVERRIDES[request.provider];
1645
+ const providerKey = versionOverride === undefined ? request.provider : `${versionOverride}:${request.provider}`;
1646
+ return `report:${providerKey}:${baseUrl}:${identity}`;
1642
1647
  }
1643
1648
 
1644
1649
  #buildUsageReportsCacheKey(requests: ReadonlyArray<UsageRequestDescriptor>): string {
1645
1650
  const snapshot = requests
1646
- .map(
1647
- request =>
1648
- `${request.provider}:${this.#normalizeUsageBaseUrl(request.baseUrl) || "default"}:${this.#buildUsageCacheIdentity(request.credential)}`,
1649
- )
1651
+ .map(request => {
1652
+ const versionOverride = USAGE_REPORT_CACHE_KEY_VERSION_OVERRIDES[request.provider];
1653
+ const providerKey =
1654
+ versionOverride === undefined ? request.provider : `${versionOverride}:${request.provider}`;
1655
+ return `${providerKey}:${this.#normalizeUsageBaseUrl(request.baseUrl) || "default"}:${this.#buildUsageCacheIdentity(request.credential)}`;
1656
+ })
1650
1657
  .sort()
1651
1658
  .join("\n");
1652
1659
  return `reports:${Bun.hash(snapshot).toString(16)}`;
@@ -173,32 +173,46 @@ export function transformMessages<TApi extends Api>(
173
173
  index === latestSurvivingAssistantIndex &&
174
174
  model.api === "anthropic-messages" &&
175
175
  assistantMsg.api === "anthropic-messages";
176
- // Aborted/errored messages may have partially-streamed thinking signatures.
177
- // A partial signature is invalid and will be rejected by the API, so we must
178
- // strip signatures from thinking blocks in these messages.
176
+ // Thinking signatures can be untrustworthy for two distinct reasons with very
177
+ // different blast radii:
179
178
  //
180
- // Abandoned tool-use turns get the same treatment once they are no longer
181
- // the latest assistant message. When a turn carries toolCall blocks but did
182
- // NOT request tool execution (stopReason !== "toolUse" e.g.
183
- // adaptive-thinking Opus emitting tool calls and then ending the turn on
184
- // `end_turn`/`stop`), the agent loop pairs those calls with placeholder
185
- // tool_results to keep the tool_use/tool_result contract valid. Historical
186
- // abandoned turns cannot safely replay their end_turn-bound signatures in
187
- // that continuation, so stripping downgrades them to plain text downstream.
188
- // Latest abandoned turns are exempt because Anthropic requires thinking
189
- // blocks from its most recent response to remain byte-for-byte unmodified.
179
+ // 1. Aborted/errored turns: the stream stopped mid-block, so only the block
180
+ // that was streaming at the abort point always the FINAL content block —
181
+ // can carry a partially-streamed (invalid) signature. Every earlier block
182
+ // completed: Anthropic delivers a block's signature at its
183
+ // `content_block_stop`, which necessarily fired before the next block began,
184
+ // so those signatures are whole and valid. Stripping them would needlessly
185
+ // discard a replayable thinking chain e.g. interrupting during the visible
186
+ // text output after thinking already finished leaves a fully-signed thinking
187
+ // block that must be kept, or Anthropic rejects the replay with HTTP 400
188
+ // "Invalid `signature` in `thinking` block".
189
+ //
190
+ // 2. Abandoned tool-use turns: a turn that carries toolCall blocks but did NOT
191
+ // request tool execution (stopReason !== "toolUse" — e.g. adaptive-thinking
192
+ // Opus emitting tool calls and then ending on `end_turn`/`stop`). The agent
193
+ // loop pairs those calls with placeholder tool_results to keep the
194
+ // tool_use/tool_result contract valid. The turn completed cleanly, but its
195
+ // signatures are end_turn-bound and cannot be replayed in that synthesized
196
+ // continuation, so EVERY thinking signature is stripped.
197
+ //
198
+ // Latest abandoned turns are exempt because Anthropic requires thinking blocks
199
+ // from its most recent response to remain byte-for-byte unmodified.
190
200
  const invalidStopReason = assistantMsg.stopReason === "aborted" || assistantMsg.stopReason === "error";
191
201
  const abandonedToolUse =
192
202
  !invalidStopReason &&
193
203
  assistantMsg.stopReason !== "toolUse" &&
194
204
  assistantMsg.content.some(b => b.type === "toolCall");
195
- const hasInvalidSignatures = invalidStopReason || abandonedToolUse;
205
+ const lastBlockIndex = assistantMsg.content.length - 1;
196
206
 
197
- const transformedContent = assistantMsg.content.flatMap(block => {
207
+ const transformedContent = assistantMsg.content.flatMap((block, blockIndex) => {
198
208
  if (block.type === "thinking") {
199
- // Strip untrustworthy signatures so the encoder can downgrade to text.
209
+ // Only an aborted/errored turn's final (mid-stream) block can hold a
210
+ // partial signature; abandoned tool-use turns strip all. Drop the
211
+ // untrustworthy signature so the encoder can downgrade the block to text.
212
+ const signatureUntrustworthy =
213
+ abandonedToolUse || (invalidStopReason && blockIndex === lastBlockIndex);
200
214
  const sanitized =
201
- hasInvalidSignatures && block.thinkingSignature
215
+ signatureUntrustworthy && block.thinkingSignature
202
216
  ? { ...block, thinkingSignature: undefined }
203
217
  : block;
204
218
  if (mustPreserveLatestAnthropicThinking) return abandonedToolUse ? block : sanitized;
@@ -18,6 +18,8 @@ interface AntigravityQuotaInfo {
18
18
  tier?: string;
19
19
  windowId?: string;
20
20
  windowLabel?: string;
21
+ apiProvider?: string;
22
+ modelProvider?: string;
21
23
  }
22
24
 
23
25
  interface AntigravityModelInfo {
@@ -25,6 +27,8 @@ interface AntigravityModelInfo {
25
27
  quotaInfo?: AntigravityQuotaInfo | AntigravityQuotaInfo[];
26
28
  quotaInfos?: AntigravityQuotaInfo[];
27
29
  quotaInfoByTier?: Record<string, AntigravityQuotaInfo | AntigravityQuotaInfo[]>;
30
+ apiProvider?: string;
31
+ modelProvider?: string;
28
32
  }
29
33
 
30
34
  interface AntigravityUsageResponse {
@@ -60,22 +64,47 @@ function parseWindow(info: AntigravityQuotaInfo): UsageWindow | undefined {
60
64
  }
61
65
 
62
66
  function buildAmount(info: AntigravityQuotaInfo): UsageAmount {
63
- const remainingFraction = clampFraction(info.remainingFraction);
67
+ const apiRemainingFraction = clampFraction(info.remainingFraction);
68
+ // Observed Antigravity responses omit remainingFraction for exhausted
69
+ // Google/Gemini counters and keep only resetTime. Treat that shape as
70
+ // "blocked until reset" rather than unknown so a healthy sibling backend
71
+ // counter cannot mask it during dedupe.
72
+ const remainingFraction = apiRemainingFraction ?? (info.resetTime ? 0 : undefined);
64
73
  const amount: UsageAmount = { unit: "percent" };
65
74
  if (remainingFraction === undefined) return amount;
66
- const usedFraction = clampFraction(1 - remainingFraction);
75
+ const usedFraction = 1 - remainingFraction;
67
76
  amount.remainingFraction = remainingFraction;
68
77
  amount.usedFraction = usedFraction;
69
78
  amount.remaining = remainingFraction * 100;
70
- amount.used = usedFraction !== undefined ? usedFraction * 100 : undefined;
79
+ amount.used = usedFraction * 100;
71
80
  amount.limit = 100;
72
81
  return amount;
73
82
  }
74
83
 
84
+ function formatCounterName(info: AntigravityQuotaInfo): string | undefined {
85
+ switch (info.modelProvider ?? info.apiProvider) {
86
+ case "MODEL_PROVIDER_ANTHROPIC":
87
+ case "API_PROVIDER_ANTHROPIC_VERTEX":
88
+ return "Anthropic";
89
+ case "MODEL_PROVIDER_GOOGLE":
90
+ case "API_PROVIDER_GOOGLE_GEMINI":
91
+ return "Google";
92
+ case "MODEL_PROVIDER_OPENAI":
93
+ case "API_PROVIDER_OPENAI_VERTEX":
94
+ return "OpenAI";
95
+ default:
96
+ return undefined;
97
+ }
98
+ }
99
+
75
100
  function normalizeQuotaInfos(info: AntigravityModelInfo): AntigravityQuotaInfo[] {
76
101
  const results: AntigravityQuotaInfo[] = [];
102
+ const source = {
103
+ ...(info.apiProvider ? { apiProvider: info.apiProvider } : {}),
104
+ ...(info.modelProvider ? { modelProvider: info.modelProvider } : {}),
105
+ };
77
106
  const addInfo = (value: AntigravityQuotaInfo, tier?: string) => {
78
- results.push({ ...value, ...(tier ? { tier } : {}) });
107
+ results.push({ ...source, ...value, ...(tier ? { tier } : {}) });
79
108
  };
80
109
  const addArray = (values?: AntigravityQuotaInfo[]) => {
81
110
  if (!values) return;
@@ -150,11 +179,20 @@ async function fetchAntigravityUsage(params: UsageFetchParams, ctx: UsageFetchCo
150
179
  const data = (await response.json()) as AntigravityUsageResponse;
151
180
 
152
181
  // The API returns per-model quota entries, but quota is shared across
153
- // models within the same tier. Deduplicate by (tier, windowId) so one
154
- // account doesn't produce 15 redundant bars.
182
+ // models within the same backend counter, tier, and reset window. Keep
183
+ // Google and Anthropic-backed Antigravity models separate so a healthy
184
+ // Claude counter cannot mask an exhausted Gemini counter.
155
185
  const deduped = new Map<
156
186
  string,
157
- { amount: UsageAmount; window: UsageWindow | undefined; tier: string | undefined }
187
+ {
188
+ amount: UsageAmount;
189
+ window: UsageWindow | undefined;
190
+ tier: string | undefined;
191
+ tierKey: string;
192
+ windowId: string;
193
+ counterName: string | undefined;
194
+ counterKey: string;
195
+ }
158
196
  >();
159
197
  let earliestReset: number | undefined;
160
198
 
@@ -166,14 +204,16 @@ async function fetchAntigravityUsage(params: UsageFetchParams, ctx: UsageFetchCo
166
204
  if (window?.resetsAt) {
167
205
  earliestReset = earliestReset ? Math.min(earliestReset, window.resetsAt) : window.resetsAt;
168
206
  }
169
- const tier = (quotaInfo.tier ?? "default").toLowerCase();
207
+ const tierKey = (quotaInfo.tier ?? "default").toLowerCase();
208
+ const counterName = formatCounterName(quotaInfo);
209
+ const counterKey = counterName?.toLowerCase() ?? "default";
170
210
  // Use quotaInfo.windowId even when parseWindow returns undefined
171
211
  // (no resetTime) — separate windows must not collapse to "default".
172
212
  const windowId = quotaInfo.windowId ?? window?.id ?? "default";
173
- const key = `${tier}|${windowId}`;
213
+ const key = `${counterKey}|${tierKey}|${windowId}`;
174
214
  const existing = deduped.get(key);
175
215
  if (!existing) {
176
- deduped.set(key, { amount, window, tier: quotaInfo.tier });
216
+ deduped.set(key, { amount, window, tier: quotaInfo.tier, tierKey, windowId, counterName, counterKey });
177
217
  continue;
178
218
  }
179
219
  // Merge: keep the entry with fraction data for the bar, but
@@ -190,7 +230,7 @@ async function fetchAntigravityUsage(params: UsageFetchParams, ctx: UsageFetchCo
190
230
  if (!eHasFrac && cHasFrac) {
191
231
  bestAmount = amount;
192
232
  bestTier = quotaInfo.tier ?? existing.tier;
193
- } else if (eHasFrac && cHasFrac && cFrac! < eFrac!) {
233
+ } else if (eFrac !== undefined && cFrac !== undefined && cFrac < eFrac) {
194
234
  bestAmount = amount;
195
235
  bestTier = quotaInfo.tier ?? existing.tier;
196
236
  }
@@ -199,23 +239,30 @@ async function fetchAntigravityUsage(params: UsageFetchParams, ctx: UsageFetchCo
199
239
  if (!bestWindow?.resetsAt && window?.resetsAt) {
200
240
  bestWindow = window;
201
241
  }
202
- deduped.set(key, { amount: bestAmount, window: bestWindow, tier: bestTier });
242
+ deduped.set(key, {
243
+ amount: bestAmount,
244
+ window: bestWindow,
245
+ tier: bestTier,
246
+ tierKey: existing.tierKey,
247
+ windowId: existing.windowId,
248
+ counterName: existing.counterName,
249
+ counterKey: existing.counterKey,
250
+ });
203
251
  }
204
252
  }
205
253
 
206
254
  const limits: UsageLimit[] = [];
207
- for (const [key, entry] of deduped) {
208
- const [tier, windowId] = key.split("|") as [string, string];
209
- const label = "Usage";
255
+ for (const entry of deduped.values()) {
256
+ const label = entry.counterName ? `Usage (${entry.counterName})` : "Usage";
210
257
  limits.push({
211
- id: `${params.provider}:${tier}:${windowId}`,
258
+ id: `${params.provider}:${entry.counterKey}:${entry.tierKey}:${entry.windowId}`,
212
259
  label,
213
260
  scope: {
214
261
  provider: params.provider,
215
262
  accountId: credential.accountId,
216
263
  projectId: credential.projectId,
217
264
  tier: entry.tier,
218
- windowId,
265
+ windowId: entry.windowId,
219
266
  },
220
267
  window: entry.window,
221
268
  amount: entry.amount,