fix(ai): surfaced inactive Claude scoped weekly limits in omp usage

- Stopped dropping /usage limits[] entries with is_active: false. Anthropic marks only the currently binding limit active (an account pinned at a 100% Fable cap reports its 77% shared weekly row as inactive), so is_active signals severity, not bucket existence; filtering on it rendered real utilization as "not reported". Exhaustion gating unchanged: tier rows still hard-block only at confirmed 100% with a future reset.
- Moved the Claude Code fingerprint constants into the leaf module providers/claude-code-fingerprint (star re-exported from providers/anthropic, import paths unchanged). Fixes the providers/anthropic -> stream -> registry -> registry/oauth/anthropic init cycle that TDZ-crashed on claudeCodeVersion whenever providers/anthropic was the first module loaded, which blocked the covering test file.
This commit is contained in:
can1357
2026-07-26 16:09:58 +02:00
parent c4ef6bbaee
commit 5a6334a508
9 changed files with 58 additions and 37 deletions
+6 -1
View File
@@ -33,7 +33,7 @@
- Hardened Cursor todo mirroring against snapshots carrying unresolved `TodoItem.dependencies`. The wire model blocks a row behind other rows by `id`; the local list has no ids and no edges, so an imported dependent row files as plain `pending` and `nextActionableTask` then offers work the server considers blocked. Snapshots with an edge pointing at a row that is not yet `completed`/`abandoned` are now refused like any other that cannot be represented locally. Edges whose blockers already finished constrain nothing and still mirror.
- Extended the Cursor todo `total_count` mismatch guard to `update_todos`. A partial or size-limited merge response is as incomplete as a filtered read, but the check only applied to reads, so an update returning fewer rows than its own count was mirrored as the full list and deleted every task it omitted. An empty update still syncs — it remains the authoritative clear path, unlike an ambiguous empty read.
- Hardened Cursor todo mirroring against rows with empty `content`. `content` is a proto3 string, so a missing or default value arrives as `""`; the local list is keyed by content and rejects a falsy one before lookup, leaving the imported row unreachable to every task-targeted `done`/`drop`/`rm`. Such snapshots are now refused like any other that cannot be represented locally.
- Fixed a deterministic circular-import TDZ that crashed `packages/catalog`'s test process with `ReferenceError: Cannot access 'claudeCodeVersion' before initialization`: `registry/oauth/anthropic.ts` imported `claudeCodeVersion` from `providers/anthropic.ts`, which transitively pulls the registry back in (`providers/anthropic` → `stream` → `registry` → `registry/oauth/anthropic`), so the module-level `claude-code/${claudeCodeVersion}` bootstrap user-agent const read the binding while `providers/anthropic.ts` was still mid-initialization. `claudeCodeVersion` now lives in a zero-import leaf module (`providers/claude-code-version.ts`) that `providers/anthropic.ts`, `registry/oauth/anthropic.ts`, and `usage/claude.ts` all import from, removing the cycle at the source rather than deferring the read.
- Fixed a deterministic circular-import TDZ that crashed `packages/catalog`'s test process with `ReferenceError: Cannot access 'claudeCodeVersion' before initialization`: `registry/oauth/anthropic.ts` imported `claudeCodeVersion` from `providers/anthropic.ts`, which transitively pulls the registry back in (`providers/anthropic` → `stream` → `registry` → `registry/oauth/anthropic`), so the module-level `claude-code/${claudeCodeVersion}` bootstrap user-agent const read the binding while `providers/anthropic.ts` was still mid-initialization. `claudeCodeVersion` now lives in a zero-import leaf module (`providers/claude-code-fingerprint.ts`) that `providers/anthropic.ts`, `registry/oauth/anthropic.ts`, and `usage/claude.ts` all import from, removing the cycle at the source rather than deferring the read.
- Fixed a circular initialization between the Anthropic provider and OAuth registry that could throw before `claudeCodeVersion` was initialized when package tests or consumers loaded modules in parallel ([#6628](https://github.com/can1357/oh-my-pi/pull/6628) by [@anatoli-tsinovoy](https://github.com/anatoli-tsinovoy)).
- Stopped the account-level Codex `rate_limit.limit_reached` flag from being applied to individual chat windows. Codex reports one shared flag for the whole account, so a window with real headroom was marked `exhausted` because a different window (or a separate metered feature) was at its limit, which over-blocked sibling accounts during credential selection. Each window's status now reflects only its own usage
- Scoped Codex reactive backoff per meter: a `usage_limit_reached` from a Spark request no longer persists a block that ordinary chat requests honour, and the reverse. Blocks written before scoping used a shared scope meaning "block everything", so requests still honour it and reconciliation still heals it
@@ -45,6 +45,11 @@
- Fixed QwenCloud Token Plan quota reporting to call the current console usage RPC and document how to capture its optional Cookie during login.
- Fixed Cursor exec-channel MCP calls such as `web_search` omitting `toolCall` blocks when no interaction block arrives, which rendered their tool cards below the final assistant answer or dropped them on transcript replay. ([#6501](https://github.com/can1357/oh-my-pi/issues/6501))
### Fixed
- Fixed Claude scoped weekly limits (e.g. `Claude 7 Day (Fable)`) with `is_active: false` being dropped by the `/usage` parser, rendering as `not reported` in `omp usage` despite carrying real utilization. Live payloads mark only the currently binding limit active — an account pinned at a 100% Fable cap reports its 77% shared weekly row as inactive too — so `is_active` signals severity ranking, not bucket existence, and is now ignored. Exhaustion gating is unchanged: tier rows still hard-block only at confirmed 100% with a future reset.
- Fixed a TDZ crash (`Cannot access 'claudeCodeVersion' before initialization`) when `providers/anthropic` was the first module loaded: `providers/anthropic` → `stream` → `registry` → `registry/oauth/anthropic` circled back into the still-initializing provider module. The Claude Code fingerprint constants now live in the leaf module `providers/claude-code-fingerprint` (star re-exported from `providers/anthropic`, so import paths are unchanged).
## [17.1.3] - 2026-07-24
### Fixed
+11 -12
View File
@@ -85,7 +85,14 @@ import {
type RawMessageStreamEvent,
type TextBlockParam,
} from "./anthropic-wire";
import { claudeCodeVersion } from "./claude-code-version";
import {
CLAUDE_CODE_MAX_OUTPUT_TOKENS,
claudeAgentSdkVersion,
claudeClientVersion,
claudeCodeSystemInstruction,
claudeCodeVersion,
claudeToolPrefix,
} from "./claude-code-fingerprint";
import {
buildCopilotDynamicHeaders,
hasCopilotVisionInput,
@@ -481,17 +488,9 @@ function getCacheControl(
};
}
// Stealth mode: mimic Claude Code's request fingerprint.
// `claudeCodeVersion` is imported from "./claude-code-version" (leaf module) to
// avoid a circular import with registry/oauth/anthropic.ts; see that file for detail.
export const claudeAgentSdkVersion = "0.3.165";
export const claudeClientVersion = "1.11187.4";
export const claudeToolPrefix: string = "_";
export const claudeCodeSystemInstruction = "You are a Claude agent, built on Anthropic's Claude Agent SDK.";
// Claude Code caps requested output at 64k tokens even when the model ceiling is
// higher (e.g. Opus 4.8 supports 128k); OAuth requests clamp to match the wire
// fingerprint. API-key requests keep the full model ceiling.
export const CLAUDE_CODE_MAX_OUTPUT_TOKENS = 64000;
// Stealth mode: mimic Claude Code's request fingerprint. Constants live in the
// leaf module so registry/usage consumers avoid an init cycle through this file.
export * from "./claude-code-fingerprint";
export function mapStainlessOs(platform: string): "MacOS" | "Windows" | "Linux" | "FreeBSD" | `Other::${string}` {
switch (platform.toLowerCase()) {
@@ -0,0 +1,19 @@
/**
* Claude Code stealth-fingerprint constants, kept in a leaf module so
* fingerprint consumers outside the provider (`registry/oauth/anthropic`,
* `usage/claude`) don't import the heavy `providers/anthropic` module.
* That import edge was a live init cycle: `providers/anthropic` → `stream` →
* `registry` → `registry/oauth/anthropic` → back into the still-initializing
* provider module, which threw a TDZ ReferenceError whenever
* `providers/anthropic` was the first module loaded.
*/
export const claudeCodeVersion = "2.1.165";
export const claudeAgentSdkVersion = "0.3.165";
export const claudeClientVersion = "1.11187.4";
export const claudeToolPrefix: string = "_";
export const claudeCodeSystemInstruction = "You are a Claude agent, built on Anthropic's Claude Agent SDK.";
// Claude Code caps requested output at 64k tokens even when the model ceiling is
// higher (e.g. Opus 4.8 supports 128k); OAuth requests clamp to match the wire
// fingerprint. API-key requests keep the full model ceiling.
export const CLAUDE_CODE_MAX_OUTPUT_TOKENS = 64000;
@@ -1,13 +0,0 @@
/**
* Claude Code fingerprint version.
*
* Lives in a leaf module (zero imports) so `registry/oauth/anthropic.ts` and
* `usage/claude.ts` can consume it without dragging in `providers/anthropic.ts`.
* Importing `providers/anthropic.ts` from the registry path creates a cycle —
* `providers/anthropic.ts` → `stream.ts` → `registry` → `registry/oauth/anthropic.ts`
* → back to `providers/anthropic.ts` — and reading this const at module-init time
* then hits the temporal dead zone (`Cannot access 'claudeCodeVersion' before
* initialization`). The other Claude Code fingerprint constants stay in
* `providers/anthropic.ts` because only this one is referenced across that edge.
*/
export const claudeCodeVersion = "2.1.165";
+1 -1
View File
@@ -3,7 +3,7 @@
*/
import * as AIError from "../../error";
import { claudeCodeVersion } from "../../providers/claude-code-version";
import { claudeCodeVersion } from "../../providers/claude-code-fingerprint";
import type { FetchImpl } from "../../types";
import { OAuthCallbackFlow } from "./callback-server";
import { generatePKCE } from "./pkce";
+7 -2
View File
@@ -2,7 +2,7 @@ import { scheduler } from "node:timers/promises";
import { bareModelId, parseAnthropicModel } from "@oh-my-pi/pi-catalog/identity";
import { toNumber } from "@oh-my-pi/pi-catalog/utils";
import * as AIError from "../error";
import { claudeCodeVersion } from "../providers/claude-code-version";
import { claudeCodeVersion } from "../providers/claude-code-fingerprint";
import {
type CredentialRankingContext,
type CredentialRankingStrategy,
@@ -153,6 +153,12 @@ function getApiLimitDisplayName(scope: unknown): string | undefined {
* `seven_day_sonnet`) are permanently null. Model-scoped weekly caps now arrive
* only through generic `limits[]` entries (`kind: "weekly_scoped"`) with the
* model family named by `scope.model.display_name`.
*
* `is_active` is deliberately ignored: live payloads mark only the currently
* binding limit active (an account pinned at a 100% Fable cap reports its 77%
* shared weekly row as `is_active: false`), so it signals severity ranking,
* not bucket existence. Filtering on it hid real utilization — a scoped row
* at 5% with a live reset rendered as `not reported` in `omp usage`.
*/
function parseApiLimitEntries(raw: unknown): ParsedApiLimitEntry[] {
if (!Array.isArray(raw)) return [];
@@ -161,7 +167,6 @@ function parseApiLimitEntries(raw: unknown): ParsedApiLimitEntry[] {
if (!isRecord(rawEntry)) continue;
const entry = rawEntry as ClaudeApiLimitEntry;
if (typeof entry.kind !== "string") continue;
if (entry.is_active === false) continue;
const utilization = toNumber(entry.percent);
const resetsAt = parseIsoTime(typeof entry.resets_at === "string" ? entry.resets_at : undefined);
if (utilization === undefined && resetsAt === undefined) continue;
+1 -1
View File
@@ -21,7 +21,7 @@ import {
stripClaudeToolPrefix,
} from "@oh-my-pi/pi-ai/providers/anthropic";
import type { MessageCreateParamsStreaming } from "@oh-my-pi/pi-ai/providers/anthropic-wire";
import { claudeCodeVersion } from "@oh-my-pi/pi-ai/providers/claude-code-version";
import { claudeCodeVersion } from "@oh-my-pi/pi-ai/providers/claude-code-fingerprint";
import { getEnvApiKey, streamSimple } from "@oh-my-pi/pi-ai/stream";
import type {
AssistantMessage,
+1 -1
View File
@@ -1,5 +1,5 @@
import { afterEach, describe, expect, it, vi } from "bun:test";
import { claudeCodeVersion } from "@oh-my-pi/pi-ai/providers/claude-code-version";
import { claudeCodeVersion } from "@oh-my-pi/pi-ai/providers/claude-code-fingerprint";
import { AnthropicOAuthFlow, refreshAnthropicToken } from "@oh-my-pi/pi-ai/registry/oauth/anthropic";
import {
buildAnthropicAuthConfig,
+12 -6
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from "bun:test";
import { claudeCodeVersion } from "@oh-my-pi/pi-ai/providers/claude-code-version";
import { claudeCodeVersion } from "@oh-my-pi/pi-ai/providers/claude-code-fingerprint";
import type { UsageFetchContext, UsageLimit, UsageReport } from "@oh-my-pi/pi-ai/usage";
import { claudeRankingStrategy, claudeUsageProvider } from "@oh-my-pi/pi-ai/usage/claude";
@@ -239,7 +239,7 @@ describe("claude usage request headers", () => {
expect(weekly?.scope.shared).toBe(true);
});
it("skips inactive and unnamed scoped limits", async () => {
it("surfaces inactive scoped limits and skips unnamed ones", async () => {
const now = Date.now();
const futureReset = new Date(now + 4 * 24 * 60 * 60 * 1000).toISOString();
const fetchMock = (async () => {
@@ -248,11 +248,14 @@ describe("claude usage request headers", () => {
five_hour: { utilization: 16, resets_at: new Date(now + 5 * 60 * 60 * 1000).toISOString() },
limits: [
{
// Anthropic marks only the currently binding limit
// `is_active`; an idle scoped bucket still carries real
// utilization and must render instead of "not reported".
kind: "weekly_scoped",
group: "weekly",
percent: 0,
percent: 5,
severity: "normal",
resets_at: null,
resets_at: futureReset,
scope: { model: { display_name: "Fable", id: null }, surface: null },
is_active: false,
},
@@ -283,8 +286,11 @@ describe("claude usage request headers", () => {
ctx,
);
expect(report?.limits.some(limit => limit.id.includes(":fable"))).toBe(false);
expect(report?.limits.map(limit => limit.id)).toEqual(["anthropic:5h"]);
expect(report?.limits.map(limit => limit.id)).toEqual(["anthropic:5h", "anthropic:7d:fable"]);
const fable = report?.limits.find(limit => limit.id === "anthropic:7d:fable");
expect(fable?.amount.used).toBe(5);
expect(fable?.window?.resetsAt).toBe(Date.parse(futureReset));
expect(fable?.status).toBe("ok");
});
it("falls back to session and weekly_all entries when legacy buckets are absent", async () => {