Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/coding-agent/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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/<session-id>.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`.
Expand Down
46 changes: 25 additions & 21 deletions packages/coding-agent/src/session/account-inventory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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"
Expand All @@ -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;
Expand Down Expand Up @@ -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 };
Expand All @@ -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]));
Expand All @@ -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,
Expand All @@ -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),
Expand Down Expand Up @@ -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;
}
}
Expand Down
103 changes: 103 additions & 0 deletions packages/coding-agent/test/account-inventory-usage.test.ts
Original file line number Diff line number Diff line change
@@ -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> = {}): 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);
});
});
Loading