diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 30c901440f..096d37942d 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -1,6 +1,7 @@ # Changelog ## [Unreleased] +- Fixed `/usage` and `/usage check` omitting provider limits for stored OAuth accounts. Cache-only snapshots now use the provider's resolved base URL when reading usage, and explicit checks render the successful probe report directly instead of depending on a cache-key-identical readback (#4634). - Fixed resume listing scaling its read-syscall count with total transcript bytes. The trailing `header_patch` scan walks back to BOF whenever `cwd`/`title` stay unresolved (#3633), which is the common case because only `/rename` and workspace moves ever emit a patch; because the scan borrowed the caller's 4 KiB prefix buffer, that walk cost one `read` per 4 KiB of every candidate transcript on each `--resume`, `--continue`, and picker open. The scan now owns a 64 KiB buffer, so the same bytes are covered in ~16x fewer syscalls. Measured on a real 31-session workspace holding 105 MB of transcripts (largest 41 MB): 25,715 reads / 61.9 s before, 1,652 reads / 0.5 s after, with all 24 recovered titles unchanged. Buried-title recovery, the bytes examined, the `header_patch` marker prefilter, and listing results are unchanged. - `gjc team` worker auto-checkpoints no longer commit and merge root-level worker runtime state (`.gjc/state/**`, e.g. SDK broker endpoints like `.gjc/state/sdk/.json` and settings migration markers) into the leader repo's default branch. The checkpoint classifier's protected prefixes now cover both GJC runtime roots — `.gjc/_session-*/` and `.gjc/state/` — while user-owned `.gjc/` content (config, agents, skills) stays eligible as reviewable worker work. The worker-runtime-state e2e guard now asserts absence at the actual leader merge-target path instead of an unrelated session-scoped path, and matcher boundary cases (`.gjc/state` bare entry vs `.gjc/state-*` siblings) are pinned (#4603). - Discovered oMLX models now keep thinking metadata (`reasoning: true`, `supportsReasoningEffort`, `thinkingFormat: qwen-chat-template`) so `macos-omlx-*` role suffixes (`:low`/`:medium`/`:high`) survive clamp and reach oMLX as `chat_template_kwargs.reasoning_effort`. diff --git a/packages/coding-agent/src/session/account-inventory.ts b/packages/coding-agent/src/session/account-inventory.ts index b73b9d5ac8..ad331d5383 100644 --- a/packages/coding-agent/src/session/account-inventory.ts +++ b/packages/coding-agent/src/session/account-inventory.ts @@ -204,8 +204,12 @@ function storedHealth(authStorage: AuthStorage, row: CredentialInventoryRecord): }; } -function storedUsage(authStorage: AuthStorage, row: CredentialInventoryRecord): AccountUsageCache | undefined { - return authStorage.getCachedUsageReport(row.provider as Provider, row.id); +function storedUsage( + authStorage: AuthStorage, + row: CredentialInventoryRecord, + baseUrl?: string, +): AccountUsageCache | undefined { + return authStorage.getCachedUsageReport(row.provider as Provider, row.id, baseUrl); } function safeUsageUnit(value: unknown): UsageUnit { @@ -308,6 +312,17 @@ function redactUsageCache(usage: CachedUsageReport | undefined): AccountUsageCac }; } +function freshUsageCache(report: CachedUsageReport["report"]): AccountUsageCache { + const now = Date.now(); + return { + report: redactUsageReport(report), + fetchedAt: finiteNumber(report.fetchedAt) ?? now, + freshUntil: now + 15 * 60_000, + retainUntil: now + 24 * 60 * 60_000, + freshness: "fresh", + }; +} + function canPinStoredOAuth(authStorage: AuthStorage, provider: string): boolean { if (authStorage.hasRuntimeApiKey(provider) || authStorage.hasConfigApiKey(provider)) return false; return !getEnvApiKey(provider); @@ -352,6 +367,7 @@ function addStoredRows( authStorage: AuthStorage, inventory: CredentialInventoryRecord[], sessionId: string | undefined, + baseUrlResolver?: (provider: string) => string | undefined, ): void { const removalTargetIds = new Set( (typeof authStorage.listCredentialRemovalTargets === "function" @@ -361,7 +377,7 @@ function addStoredRows( ); for (const record of inventory) { const identityLabel = asSafeLabel(record.identityLabel); - const safeUsage = redactUsageCache(storedUsage(authStorage, record)); + const safeUsage = redactUsageCache(storedUsage(authStorage, record, baseUrlResolver?.(record.provider))); const active = typeof record.provider === "string" && authStorage.getSessionCredentialRowId(record.provider, sessionId) === record.id; @@ -453,7 +469,9 @@ export function buildAccountInventorySnapshot(input: AccountInventoryInput): Acc const nowMs = input.nowMs ?? Date.now(); const inventory = input.authStorage.listCredentialInventory(); const rows: AccountInventoryRow[] = []; - addStoredRows(rows, input.authStorage, inventory, input.sessionId); + addStoredRows(rows, input.authStorage, inventory, input.sessionId, provider => + input.modelRegistry?.getProviderBaseUrl?.(provider), + ); addSyntheticRows(rows, input.authStorage, providerSet(input, inventory), input.sessionId); rows.sort((left, right) => left.id.localeCompare(right.id)); return { generatedAt: nowMs, generation: input.authStorage.getGeneration(), rows }; @@ -464,7 +482,6 @@ export const buildAccountInventory = buildAccountInventorySnapshot; function applyStoredCheck( rows: AccountInventoryRow[], - authStorage: AuthStorage, results: CredentialHealthResult[], ): AccountInventoryCheckResult[] { const byId = new Map(rows.filter(row => row.credentialId !== undefined).map(row => [row.credentialId!, row])); @@ -477,14 +494,8 @@ function applyStoredCheck( reason: asSafeLabel(result.reason), }; if (result.report) { - const cached = - row.credentialId === undefined - ? undefined - : authStorage.getCachedUsageReport(row.provider as Provider, row.credentialId); - if (cached) { - row.usage = redactUsageCache(cached); - row.capabilities.hasCachedUsage = true; - } + row.usage = freshUsageCache(result.report); + row.capabilities.hasCachedUsage = true; } checked.push({ rowId: row.id, @@ -509,7 +520,6 @@ export async function checkAccountInventory(input: AccountInventoryInput): Promi const authStorage = input.authStorage; applyStoredCheck( rows, - authStorage, await authStorage.checkCredentials({ provider: input.provider, baseUrlResolver: provider => input.modelRegistry?.getProviderBaseUrl?.(provider), @@ -539,13 +549,7 @@ export async function checkAccountInventory(input: AccountInventoryInput): Promi }; recordSourceHealth(authStorage, row.provider, row.source as SyntheticAccountSource, result.ok, result.reason); if (result.report) { - row.usage = { - report: redactUsageReport(result.report), - fetchedAt: finiteNumber(result.report.fetchedAt) ?? 0, - freshUntil: Date.now() + 15 * 60_000, - retainUntil: Date.now() + 24 * 60 * 60_000, - freshness: "fresh", - }; + row.usage = freshUsageCache(result.report); row.capabilities.hasCachedUsage = true; } } diff --git a/packages/coding-agent/test/account-inventory-usage.test.ts b/packages/coding-agent/test/account-inventory-usage.test.ts new file mode 100644 index 0000000000..b6aba5c1e6 --- /dev/null +++ b/packages/coding-agent/test/account-inventory-usage.test.ts @@ -0,0 +1,103 @@ +import { describe, expect, it } from "bun:test"; +import type { + AuthStorage, + CachedUsageReport, + CredentialHealthResult, + CredentialInventoryRecord, +} from "@gajae-code/ai/core"; +import { buildAccountInventorySnapshot, checkAccountInventory } from "../src/session/account-inventory"; + +const NOW = 1_700_000_000_000; +const BASE_URL = "https://chatgpt.com/backend-api"; + +const inventory: CredentialInventoryRecord[] = [ + { + id: 1, + provider: "openai-codex", + credentialKind: "oauth", + identityLabel: "user@example.com", + disabled: false, + disabledCause: null, + }, +]; + +function usageReport() { + return { + provider: "openai-codex", + fetchedAt: NOW, + limits: [ + { + id: "openai-codex:secondary", + label: "7 days", + scope: { provider: "openai-codex", windowId: "7d" }, + window: { id: "7d", label: "7 days", resetsAt: NOW + 86_400_000 }, + amount: { used: 24, usedFraction: 0.24, remainingFraction: 0.76, unit: "percent" as const }, + status: "ok" as const, + }, + ], + }; +} + +function makeAuthStorage(overrides: Partial = {}): AuthStorage { + return { + listCredentialInventory: () => inventory, + listCredentialRemovalTargets: () => [], + getCachedCredentialHealth: () => ({ status: "unknown", reason: null }), + getCachedUsageReport: () => undefined, + getSessionCredentialRowId: () => 1, + hasRuntimeApiKey: () => false, + hasConfigApiKey: () => false, + getEffectiveCredentialType: () => "oauth", + getGeneration: () => 1, + ...overrides, + } as unknown as AuthStorage; +} + +const modelRegistry = { + getAvailable: () => [{ provider: "openai-codex" }], + getProviderBaseUrl: () => BASE_URL, +}; + +describe("account inventory usage", () => { + it("uses the provider base URL to retrieve cached usage for a stored credential", () => { + let receivedBaseUrl: string | undefined; + const cached: CachedUsageReport = { + report: usageReport(), + fetchedAt: NOW, + freshUntil: NOW + 60_000, + retainUntil: NOW + 120_000, + freshness: "fresh", + }; + const authStorage = makeAuthStorage({ + getCachedUsageReport: (_provider, _credentialId, baseUrl) => { + receivedBaseUrl = baseUrl; + return cached; + }, + }); + + const snapshot = buildAccountInventorySnapshot({ authStorage, modelRegistry, nowMs: NOW }); + + expect(receivedBaseUrl).toBe(BASE_URL); + expect(snapshot.rows[0]?.usage?.report.limits[0]?.label).toBe("7 days"); + }); + + it("attaches a fresh check report directly when the persistent cache cannot be read back", async () => { + const result: CredentialHealthResult = { + id: 1, + provider: "openai-codex", + type: "oauth", + ok: true, + report: usageReport(), + }; + const authStorage = makeAuthStorage({ + checkCredentials: async () => [result], + getCachedUsageReport: () => undefined, + }); + + const snapshot = await checkAccountInventory({ authStorage, modelRegistry, nowMs: NOW }); + + expect(snapshot.rows[0]?.health.status).toBe("ok"); + expect(snapshot.rows[0]?.capabilities.hasCachedUsage).toBe(true); + expect(snapshot.rows[0]?.usage?.report.limits[0]?.amount.used).toBe(24); + }); +});