Skip to content
Open
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
11 changes: 9 additions & 2 deletions apps/server/src/serverSettings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1957,7 +1957,14 @@ it.layer(NodeServices.layer)("server settings", (it) => {
}).pipe(Effect.provide(layerServerSettings())),
);

it.effect("leaves an unreadable settings.json untouched instead of folding over it", () =>
it.effect.each([
{ label: "malformed JSON", broken: '{"defaultAutoPull": tru' },
{ label: "invalid retention", broken: '{"usageHistoryRetentionDays": 0}' },
{
label: "invalid unrelated setting",
broken: '{"defaultAutoPull": "true", "usageHistoryRetentionDays": "unlimited"}',
},
])("leaves settings.json with $label untouched and disables destructive defaults", ({ broken }) =>
Effect.gen(function* () {
const serverConfig = yield* ServerConfig.ServerConfig;
const fileSystem = yield* FileSystem.FileSystem;
Expand All @@ -1972,12 +1979,12 @@ it.layer(NodeServices.layer)("server settings", (it) => {
${"2026-08-25T00:00:00.000Z"}, ${"2026-08-25T00:00:00.000Z"}
)
`;
const broken = '{"defaultAutoPull": tru';
yield* fileSystem.writeFileString(serverConfig.settingsPath, broken);

const settings = yield* serverSettings.getSettings;
assert.isFalse(settings.projectSettingsFolded);
assert.isFalse(settings.continueThreadsAfterServerUpdate);
assert.equal(settings.usageHistoryRetentionDays, "unlimited");
assert.deepEqual(settings.projectSettingsOverrides, {});

assert.equal(yield* fileSystem.readFileString(serverConfig.settingsPath), broken);
Expand Down
6 changes: 5 additions & 1 deletion apps/server/src/serverSettings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -717,7 +717,11 @@ const make = Effect.gen(function* () {
if (decoded._tag === "Failure" || legacySettings._tag === "Failure") {
const failure = decoded._tag === "Failure" ? decoded : legacySettings;
// The file may hold an opt-out we can no longer read; never resume threads on a guess.
settings = { ...DEFAULT_SERVER_SETTINGS, continueThreadsAfterServerUpdate: false };
settings = {
...DEFAULT_SERVER_SETTINGS,
continueThreadsAfterServerUpdate: false,
usageHistoryRetentionDays: "unlimited",
};
if (failure._tag === "Failure") {
parseFailure = Option.some(Cause.squash(failure.cause));
yield* Effect.logWarning("failed to parse settings.json, using defaults", {
Expand Down
308 changes: 281 additions & 27 deletions apps/server/src/usage/UsageService.test.ts

Large diffs are not rendered by default.

23 changes: 18 additions & 5 deletions apps/server/src/usage/UsageService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,8 +69,6 @@ const RATES_REFRESH_FLOOR_MS = 60 * 1000;
const MTIME_SLACK_MS = 36 * 60 * 60 * 1000;
const MAX_HOURLY_WINDOW_MS = 24 * 60 * 60 * 1000;

const CACHE_RETENTION_DAYS = 92;

const TRANSCRIPT_READ_CONCURRENCY = 4;

const transcriptReaders = BUILT_IN_USAGE_DRIVERS.flatMap((driver) =>
Expand Down Expand Up @@ -704,7 +702,10 @@ export const make = Effect.gen(function* () {
const windowStartMs =
(hourlyWindow?.sinceTimeMs ?? DateTime.toEpochMillis(windowStart.value)) - MTIME_SLACK_MS;

const retentionCutoffMs = startedAtMs - CACHE_RETENTION_DAYS * 24 * 60 * 60 * 1000;
const retentionCutoffMs =
settings.usageHistoryRetentionDays === "unlimited"
? 0
: Math.max(0, startedAtMs - settings.usageHistoryRetentionDays * 24 * 60 * 60 * 1000);

const [, scannedDirs] = yield* Effect.all(
[
Expand Down Expand Up @@ -867,8 +868,20 @@ export const make = Effect.gen(function* () {
});
}

const pruned = pruneScanCache(fileCache, retentionCutoffMs);
if (pruned > 0) cacheDirty = true;
yield* settingsService
.withSettingsSnapshot((currentSettings) =>
Effect.sync(() => {
if (settings.usageHistoryRetentionDays === currentSettings.usageHistoryRetentionDays) {
const pruned = pruneScanCache(fileCache, retentionCutoffMs);
if (pruned > 0) cacheDirty = true;
}
}),
)
.pipe(
Effect.catchCause((cause) =>
Effect.logWarning("failed to read settings before pruning the usage cache", { cause }),
),
);

const aggregated = aggregator.finish();
const readAt = yield* DateTime.now;
Expand Down
2 changes: 2 additions & 0 deletions docs/user/usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ Select a model under **Breakdown** to see its trend, cache hit rate, and cost pe
Totals depend on the history available on each server. Grok turns without a saved completed-turn
record are missing from the totals.

The server keeps scanned usage history for 92 days by default. Claude Code deletes its own transcripts after 30 days by default, so for older Claude usage this history is the only record left. To keep it longer, set `usageHistoryRetentionDays` in the environment's `<T3 home>/userdata/settings.json` to a positive number of days such as `365`, or to `"unlimited"`. Usage views currently show up to 90 days; a longer retention keeps the history on the server for later without changing what the app displays. Lowering the value prunes older history on the next refresh, and raising it again does not bring pruned history back unless the provider still has it. If `settings.json` cannot be read, history is kept as if retention were unlimited until the file is valid again. Longer retention uses more disk space and memory.

OpenCode reads its SQLite database and older JSON history. Antigravity reads local conversation
databases, including T3-managed profiles. Set `OPENCODE_DATA_DIR` or `ANTIGRAVITY_DATA_DIR` on the
server to read a different data directory; comma-separated paths read multiple directories.
Expand Down
11 changes: 11 additions & 0 deletions packages/contracts/src/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1178,6 +1178,11 @@ export const StorageCleanupSettings = Schema.Struct({
});
export type StorageCleanupSettings = typeof StorageCleanupSettings.Type;

const UsageHistoryRetentionDays = Schema.Union([
Schema.Int.check(Schema.isGreaterThan(0)),
Schema.Literal("unlimited"),
]);

export const ServerSettings = Schema.Struct({
// Null means unmigrated; empty is authoritative and must not resurrect cleared preferences.
favorites: Schema.NullOr(ModelFavorites).pipe(Schema.withDecodingDefault(Effect.succeed(null))),
Expand Down Expand Up @@ -1354,6 +1359,10 @@ export const ServerSettings = Schema.Struct({
Schema.withDecodingDefault(Effect.succeed(false)),
),

usageHistoryRetentionDays: UsageHistoryRetentionDays.pipe(
Schema.withDecodingDefault(Effect.succeed(92)),
),

usagePriceOverrides: Schema.Record(TrimmedNonEmptyString, UsageModelPriceOverride).pipe(
Schema.withDecodingDefault(Effect.succeed({})),
),
Expand Down Expand Up @@ -1671,6 +1680,8 @@ export const ServerSettingsPatch = Schema.Struct({
),
cursorKeychainUsageEnabled: Schema.optionalKey(Schema.Boolean),

usageHistoryRetentionDays: Schema.optionalKey(UsageHistoryRetentionDays),

usagePriceOverrides: Schema.optionalKey(
Schema.Record(TrimmedNonEmptyString, Schema.NullOr(UsageModelPriceOverride)),
),
Expand Down
14 changes: 11 additions & 3 deletions packages/provider-cursor/src/server/CursorUsageAccounts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -199,11 +199,19 @@ const make = Effect.gen(function* () {
let refreshing = false;
if (
(recentFailure === undefined || nowMs - recentFailure.atMs >= CURSOR_ACCOUNT_TTL_MS) &&
!isCursorCacheFresh(caches.get(credentialKey), nowMs)
!isCursorCacheFresh(caches.get(credentialKey), nowMs, retentionStartMs)
) {
const refresh = yield* startRefresh(credential, credentialKey, retentionStartMs);
if (awaitRefresh) yield* Deferred.await(refresh);
else refreshing = true;
if (awaitRefresh) {
yield* Deferred.await(refresh);
if (
!failures.has(credentialKey) &&
!isCursorCacheFresh(caches.get(credentialKey), nowMs, retentionStartMs)
) {
const retry = yield* startRefresh(credential, credentialKey, retentionStartMs);
yield* Deferred.await(retry);
}
} else refreshing = true;
}

const cache = caches.get(credentialKey);
Expand Down
29 changes: 28 additions & 1 deletion packages/provider-cursor/src/server/accountCache.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { assert, describe, it } from "@effect/vitest";

import { cursorFetchRange, mergeCursorFetch } from "./accountCache.ts";
import { cursorFetchRange, isCursorCacheFresh, mergeCursorFetch } from "./accountCache.ts";
import type { UsageRecord } from "@t3tools/provider-core/server/usage";

const DAY_MS = 24 * 60 * 60 * 1000;
Expand Down Expand Up @@ -31,6 +31,20 @@ describe("cursorAccountCache", () => {
fetchedAtMs: 10 * DAY_MS,
records: [record(5 * DAY_MS)],
};
assert.isTrue(isCursorCacheFresh(cache, 10 * DAY_MS, 0));
const narrow = { ...cache, sinceMs: DAY_MS };
assert.isFalse(isCursorCacheFresh(narrow, 10 * DAY_MS, 0));
const widenedRange = cursorFetchRange(narrow, 0, 10 * DAY_MS);
assert.deepStrictEqual(widenedRange, {
sinceMs: 0,
untilMs: 10 * DAY_MS,
});
const widened = mergeCursorFetch(narrow, "a", widenedRange, cache.records, 10 * DAY_MS, 0);
assert.isTrue(isCursorCacheFresh(widened.cache, 10 * DAY_MS, 0));
assert.deepStrictEqual(cursorFetchRange(widened.cache, 0, 11 * DAY_MS), {
sinceMs: 10 * DAY_MS - 60 * 60 * 1000,
untilMs: 11 * DAY_MS,
});
const nowMs = 100 * DAY_MS;
const range = cursorFetchRange(cache, 10 * DAY_MS, nowMs);
assert.deepStrictEqual(range, { sinceMs: 10 * DAY_MS, untilMs: nowMs });
Expand Down Expand Up @@ -65,6 +79,19 @@ describe("cursorAccountCache", () => {
assert.isFalse(
mergeCursorFetch(cache, "a", range, [record(10 * DAY_MS)], 11 * DAY_MS, 0).changed,
);
assert.isTrue(
mergeCursorFetch(cache, "a", range, [record(10 * DAY_MS)], 11 * DAY_MS, 2 * DAY_MS).changed,
);
assert.isTrue(
mergeCursorFetch(
{ ...cache, sinceMs: DAY_MS, records: [] },
"a",
{ sinceMs: 0, untilMs: 11 * DAY_MS },
[],
11 * DAY_MS,
0,
).changed,
);
assert.isTrue(
mergeCursorFetch(
cache,
Expand Down
9 changes: 8 additions & 1 deletion packages/provider-cursor/src/server/accountCache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,13 @@ interface CursorFetchRange {
export function isCursorCacheFresh(
cache: CursorAccountCache | undefined,
nowMs: number,
retentionStartMs: number,
): cache is CursorAccountCache {
return cache !== undefined && nowMs - cache.fetchedAtMs < CURSOR_ACCOUNT_TTL_MS;
return (
cache !== undefined &&
cache.sinceMs <= retentionStartMs &&
nowMs - cache.fetchedAtMs < CURSOR_ACCOUNT_TTL_MS
);
}

export function cursorFetchRange(
Expand Down Expand Up @@ -56,6 +61,8 @@ export function mergeCursorFetch(
const replacedKeys = new Set(previous.filter(inRange).map((record) => record.dedupeKey));
const changed =
cache === undefined ||
cache.sinceMs > retentionStartMs ||
previous.some((record) => record.timestampMs < retentionStartMs) ||
replacedKeys.size !== added.length ||
added.some((record) => !replacedKeys.has(record.dedupeKey));
return {
Expand Down
Loading