Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
6 changes: 5 additions & 1 deletion apps/mobile/src/features/usage/UsageRouteScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -79,17 +79,21 @@ export function UsageRouteScreen() {
window: makeWindow(days, undefined, days === 1 ? "hour" : "day"),
});
};
// An explicit refresh also asks every environment to refetch model pricing,
// so a model released since the last daily fetch stops reading as $0.
// Selecting a range never sets the flag: only the user's refresh does.
const refreshWindow = () => {
const nextWindow = makeWindow(windowDays, undefined, isPast24Hours ? "hour" : "day");
if (
window.refreshRates === true &&
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
Outdated
nextWindow.sinceDay === window.sinceDay &&
nextWindow.untilDay === window.untilDay &&
nextWindow.sinceTime === window.sinceTime &&
nextWindow.untilTime === window.untilTime
) {
refresh();
} else {
setWindowSelection({ days: windowDays, window: nextWindow });
setWindowSelection({ days: windowDays, window: { ...nextWindow, refreshRates: true } });
}
};

Expand Down
2 changes: 2 additions & 0 deletions apps/mobile/src/state/usage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@ export function useUsage(input: UsageSummaryInput): UsageView {
resolution: input.resolution,
sinceTime: input.sinceTime,
untilTime: input.untilTime,
refreshRates: input.refreshRates,
}),
[
input.sinceDay,
Expand All @@ -92,6 +93,7 @@ export function useUsage(input: UsageSummaryInput): UsageView {
input.resolution,
input.sinceTime,
input.untilTime,
input.refreshRates,
],
);
const atom = usageByWindowAtom(windowKey);
Expand Down
43 changes: 42 additions & 1 deletion apps/server/src/usage/UsageService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,13 @@ import { assert, describe, it } from "@effect/vitest";
import * as NodeServices from "@effect/platform-node/NodeServices";
import { HostProcessEnvironment } from "@t3tools/shared/hostProcess";
import { UsageDay, type UsageSummaryInput } from "@t3tools/contracts";
import * as Duration from "effect/Duration";
import * as Effect from "effect/Effect";
import * as Exit from "effect/Exit";
import * as Fiber from "effect/Fiber";
import * as Layer from "effect/Layer";
import * as Scheduler from "effect/Scheduler";
import * as TestClock from "effect/testing/TestClock";
import { HttpClient, HttpClientResponse } from "effect/unstable/http";

import * as ServerConfig from "../config.ts";
Expand Down Expand Up @@ -65,6 +67,8 @@ const serviceLayers = (input: {
readonly home: string;
readonly settings: Parameters<typeof ServerSettings.layerTest>[0];
readonly onRatesFetch?: () => void;
/** Defaults to an unparsable document so every scan retries the fetch. */
readonly ratesDocument?: unknown;
}) =>
ServerConfig.layerTest(process.cwd(), { prefix: input.prefix }).pipe(
Layer.provideMerge(NodeServices.layer),
Expand All @@ -77,7 +81,7 @@ const serviceLayers = (input: {
input.onRatesFetch?.();
// Unparsable rates: every scan retries the fetch, which makes the
// fetch count a boundary-level observation of how many scans ran.
return HttpClientResponse.fromWeb(request, Response.json({}));
return HttpClientResponse.fromWeb(request, Response.json(input.ratesDocument ?? {}));
}),
),
),
Expand Down Expand Up @@ -142,6 +146,43 @@ describe("UsageService", () => {
}).pipe(Effect.scoped),
);

it.live("refetches a rate table inside its TTL only when the client asks", () =>
Effect.gen(function* () {
const { transcript, settings, home } = yield* setup;
yield* Effect.promise(() => NodeFSP.writeFile(transcript, claudeLine(1, 5)));

let ratesFetches = 0;
const service = yield* UsageService.make.pipe(
Effect.provide(
serviceLayers({
prefix: "usage-service-rates-refresh-test",
home,
settings,
ratesDocument: {
"claude-fable-5": { input_cost_per_token: 1e-5, output_cost_per_token: 5e-5 },
},
onRatesFetch: () => {
ratesFetches += 1;
},
}),
),
);

const first = yield* service.readSummary(WINDOW);
assert.strictEqual(ratesFetches, 1);
assert.strictEqual(first.pricing.status, "fresh");

// Inside the daily TTL a plain rescan keeps the cached table.
yield* TestClock.adjust(Duration.minutes(2));
yield* service.readSummary(WINDOW);
assert.strictEqual(ratesFetches, 1);

// An explicit refresh fetches again so a newly listed model gets priced.
yield* service.readSummary({ ...WINDOW, refreshRates: true });
assert.strictEqual(ratesFetches, 2);
}).pipe(Effect.scoped, Effect.provide(TestClock.layer())),
);

it.live("does not orphan an in-flight scan when its first caller is interrupted", () =>
Effect.gen(function* () {
const { settings, home } = yield* setup;
Expand Down
23 changes: 16 additions & 7 deletions apps/server/src/usage/UsageService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,9 @@ const LITELLM_RATES_URL =
/** Rates move rarely; a day-old table keeps the page working offline. */
const RATES_TTL_MS = 24 * 60 * 60 * 1000;

/** An explicit refresh ignores the TTL, but not a table fetched this recently. */
const RATES_REFRESH_FLOOR_MS = 60 * 1000;
Comment thread
macroscopeapp[bot] marked this conversation as resolved.

/**
* Files are filtered by mtime before opening. The slack covers a session whose
* last write lands just before local midnight on the window's first day.
Expand Down Expand Up @@ -141,11 +144,15 @@ export const make = Effect.gen(function* () {
/**
* Loads the LiteLLM rate table, preferring a fresh copy and falling back to
* the on-disk snapshot. With neither, every model reports as unpriced rather
* than the page failing.
* than the page failing. `force` refetches inside the TTL so a model that
* LiteLLM added since the last fetch gets priced now.
*/
const ensureRates = Effect.fn("UsageService.ensureRates")(function* () {
const ensureRates = Effect.fn("UsageService.ensureRates")(function* (force: boolean) {
const now = yield* Clock.currentTimeMillis;
if (ratesFetchedAtMs !== null && now - ratesFetchedAtMs < RATES_TTL_MS) return;
if (ratesFetchedAtMs !== null) {
const age = now - ratesFetchedAtMs;
if (age < (force ? RATES_REFRESH_FLOOR_MS : RATES_TTL_MS)) return;
}

if (ratesFetchedAtMs === null) {
const fromDisk = yield* fileSystem.readFileString(ratesCachePath).pipe(
Expand All @@ -158,7 +165,7 @@ export const make = Effect.gen(function* () {
rates = parsed;
ratesFetchedAtMs = fromDisk.fetchedAtMs;
ratesStatus = "cached";
if (now - fromDisk.fetchedAtMs < RATES_TTL_MS) return;
if (!force && now - fromDisk.fetchedAtMs < RATES_TTL_MS) return;
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
Outdated
}
}
}
Expand Down Expand Up @@ -425,9 +432,10 @@ export const make = Effect.gen(function* () {
// Pricing only matters once records are aggregated, so the rate table
// loads while transcripts stream instead of gating them: a cold rates
// fetch on a slow network no longer delays the scan by its own timeout.
const [, scannedDirs] = yield* Effect.all([ensureRates(), collectDirs(windowStartMs)], {
concurrency: 2,
});
const [, scannedDirs] = yield* Effect.all(
[ensureRates(input.refreshRates === true), collectDirs(windowStartMs)],
{ concurrency: 2 },
);

const aggregator = new UsageAggregator({
timeZone: input.timeZone,
Expand Down Expand Up @@ -539,6 +547,7 @@ export const make = Effect.gen(function* () {
input.resolution ?? "day",
input.sinceTime ?? null,
input.untilTime ?? null,
input.refreshRates === true,
]);

const readSummary = Effect.fn("UsageService.readSummary")(function* (input: UsageSummaryInput) {
Expand Down
9 changes: 9 additions & 0 deletions apps/server/src/usage/usagePricing.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,15 @@ describe("usage pricing", () => {
}
});

it("prices a bracketed context-tier variant at the base model's rate", () => {
const table = parseRateTable({ "claude-fable-5-1": rate(1e-5, 2.5e-7) });

expect(lookupRate(table, "claude-fable-5-1[1m]")).toEqual(
lookupRate(table, "claude-fable-5-1"),
);
expect(lookupRate(table, "anthropic/Claude-Fable-5-1[1m]")).toBeNull();
});

it("adds a bare alias when every qualified entry has the same rate", () => {
const table = parseRateTable({
"provider-a/example-model": rate(1),
Expand Down
12 changes: 11 additions & 1 deletion apps/server/src/usage/usagePricing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,16 @@ function bareModelName(key: string): string {
return slash === -1 ? key : key.slice(slash + 1);
}

/**
* Drops a bracketed variant suffix such as `claude-fable-5-1[1m]`, which
* Claude Code writes for the 1M context tier. The rate table only knows the
* base name, and we price at the base tier anyway.
*/
function stripVariantSuffix(key: string): string {
const bracket = key.indexOf("[");
return bracket === -1 ? key : key.slice(0, bracket);
}

/**
* Models we never price, regardless of the table.
*
Expand All @@ -136,7 +146,7 @@ const UNPRICEABLE_MODELS = new Set([
]);

export function lookupRate(table: RateTable, model: string): ModelRate | null {
const key = normalizeRateKey(model);
const key = stripVariantSuffix(normalizeRateKey(model));
const bareName = bareModelName(key);
if (bareName.length === 0 || UNPRICEABLE_MODELS.has(bareName)) return null;
return table.get(key) ?? null;
Expand Down
6 changes: 5 additions & 1 deletion apps/web/src/components/usage/UsagePage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -92,17 +92,21 @@ export function UsagePage() {
window: makeWindow(days, undefined, days === 1 ? "hour" : "day"),
});
};
// An explicit refresh also asks every environment to refetch model pricing,
// so a model released since the last daily fetch stops reading as $0.
// Selecting a range never sets the flag: only the user's refresh does.
const refreshWindow = () => {
const nextWindow = makeWindow(windowDays, undefined, isPast24Hours ? "hour" : "day");
if (
window.refreshRates === true &&
nextWindow.sinceDay === window.sinceDay &&
nextWindow.untilDay === window.untilDay &&
nextWindow.sinceTime === window.sinceTime &&
nextWindow.untilTime === window.untilTime
) {
refresh();
} else {
setWindowSelection({ days: windowDays, window: nextWindow });
setWindowSelection({ days: windowDays, window: { ...nextWindow, refreshRates: true } });
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
Outdated
Comment thread
t3dotgg marked this conversation as resolved.
Outdated
}
};
const windowLabel =
Expand Down
2 changes: 2 additions & 0 deletions apps/web/src/state/usage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ export function useUsage(input: UsageSummaryInput): UsageView {
resolution: input.resolution,
sinceTime: input.sinceTime,
untilTime: input.untilTime,
refreshRates: input.refreshRates,
}),
[
input.sinceDay,
Expand All @@ -89,6 +90,7 @@ export function useUsage(input: UsageSummaryInput): UsageView {
input.resolution,
input.sinceTime,
input.untilTime,
input.refreshRates,
],
);
const atom = usageByWindowAtom(windowKey);
Expand Down
4 changes: 3 additions & 1 deletion docs/user/usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,6 @@ completed-turn record will not appear.

Use **Past 24h** for an hourly chart covering the exact rolling 24-hour period. The **7 days**,
**30 days**, and **90 days** ranges use daily resolution. Cost and token toggles update both the
headline and chart, and refreshing rescans every connected environment.
headline and chart. Refreshing rescans every connected environment and refetches model pricing on
each of them, so a newly released model that showed $0.00 gets a price without waiting for the daily
pricing update.
6 changes: 6 additions & 0 deletions packages/contracts/src/usage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,12 @@ export const UsageSummaryInput = Schema.Struct({
sinceTime: Schema.optional(TrimmedNonEmptyString),
/** Exclusive UTC instant for an hourly rolling window. */
untilTime: Schema.optional(TrimmedNonEmptyString),
/**
* Refetch the model rate table even if the cached copy is inside its TTL.
* Sent by an explicit refresh so a newly released model gets priced without
* waiting a day.
*/
refreshRates: Schema.optional(Schema.Boolean),
});
export type UsageSummaryInput = typeof UsageSummaryInput.Type;

Expand Down
Loading