Skip to content
Closed
4 changes: 4 additions & 0 deletions apps/mobile/src/features/usage/usageProviders.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,12 @@ export const PROVIDER_ORDER: readonly UsageProviderKind[] = [
"antigravity",
"pi",
"muse",
"devin",
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
];

export function usageProviderKind(driver: string): UsageProviderKind | null {
if (driver === "claudeAgent") return "claude";
if (driver === "acpRegistry") return "devin";
return PROVIDER_ORDER.find((kind) => kind === driver) ?? null;
}

Expand All @@ -26,6 +28,7 @@ export const PROVIDER_LABEL: Record<UsageProviderKind, string> = {
antigravity: "Antigravity",
pi: "Pi",
muse: "Muse Code",
devin: "Devin",
};

export function useProviderColors(): Record<UsageProviderKind, string> {
Expand All @@ -39,6 +42,7 @@ export function useProviderColors(): Record<UsageProviderKind, string> {
antigravity: "#8c7bd1",
pi: "#4d9f8a",
muse: "#0866ff",
devin: "#5e9ce6",
};
}

Expand Down
2 changes: 2 additions & 0 deletions apps/server/src/provider/builtInDrivers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ export const BUILT_IN_DRIVERS: ReadonlyArray<AnyProviderDriver<BuiltInDriversEnv
];

export type BuiltInUsageReadersEnv =
| ProviderUsageReaderEnv<typeof AcpRegistryDriver>
| ProviderUsageReaderEnv<typeof ClaudeDriver>
| ProviderUsageReaderEnv<typeof CodexDriver>
| ProviderUsageReaderEnv<typeof GrokDriver>
Expand All @@ -66,4 +67,5 @@ export const BUILT_IN_USAGE_DRIVERS: ReadonlyArray<
MuseDriver,
AntigravityDriver,
CursorDriver,
AcpRegistryDriver,
];
4 changes: 3 additions & 1 deletion apps/server/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,7 @@ import * as ProcessGroupMetrics from "./resourceTelemetry/ProcessGroupMetrics.ts
import * as ResourceTelemetry from "./resourceTelemetry/ResourceTelemetry.ts";
import * as AntigravityUsage from "./provider/Drivers/AntigravityUsage.ts";
import { MuseUsageHome } from "@t3tools/provider-muse/server";
import { DevinUsage } from "@t3tools/provider-acp-registry/server";
import * as CursorAccountReader from "@t3tools/provider-cursor/server/CursorAccountReader";
import * as CursorUsageAccounts from "@t3tools/provider-cursor/server/CursorUsageAccounts";
import * as UsageService from "./usage/UsageService.ts";
Expand Down Expand Up @@ -295,9 +296,10 @@ const layerUsage = UsageService.layer.pipe(
Layer.mergeAll(
AntigravityUsage.layer,
MuseUsageHome.layer,
DevinUsage.layer,
CodexUsageHistory.layer,
CursorUsageAccounts.layer.pipe(Layer.provide(CursorAccountReader.layer)),
).pipe(Layer.provide(ProviderHostLive.layer.pipe(Layer.provide(ServerSecretStore.layer)))),
).pipe(Layer.provideMerge(ProviderHostLive.layer.pipe(Layer.provide(ServerSecretStore.layer)))),
),
Layer.provide(layerServerSettings),
);
Expand Down
144 changes: 144 additions & 0 deletions apps/server/src/usage/UsageService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ import { totalTokens, type UsageRecord } from "@t3tools/provider-core/server/usa
import * as UsageService from "./UsageService.ts";
import * as MuseSdk from "../../../../packages/provider-muse/src/server/sdk.ts";
import { MuseUsageHome } from "@t3tools/provider-muse/server";
import { DevinUsage } from "@t3tools/provider-acp-registry/server";
import { makeScanCacheWriter, SCAN_CACHE_FILE_NAME } from "./usageScanCache.ts";

vi.mock("node:fs/promises", { spy: true });
Expand Down Expand Up @@ -161,6 +162,7 @@ const layerService = (input: {
layerCursorUsageAccounts(CursorAccountReader.layer).pipe(
Layer.provideMerge(AntigravityUsage.layer),
Layer.provideMerge(MuseUsageHome.layer),
Layer.provideMerge(DevinUsage.layer),
Layer.provideMerge(ProviderHostLive.layer),
Layer.provideMerge(CodexUsageHistory.layer.pipe(Layer.provideMerge(Sqlite.layerMemory))),
Layer.provideMerge(Layer.mock(BackgroundPolicy.BackgroundPolicy)({})),
Expand Down Expand Up @@ -483,6 +485,148 @@ describe("Codex usage history", () => {
});

describe("UsageService", () => {
it.live("includes Devin Fusion history with Devin pricing and custom overrides", () =>
Effect.gen(function* () {
const { home, settings } = yield* setup;
const dataHome = NodePath.join(home, "data");
const dir = NodePath.join(dataHome, "devin", "cli");
yield* Effect.promise(() => NodeFSP.mkdir(dir, { recursive: true }));
const db = yield* Effect.acquireRelease(
Effect.sync(() => new NodeSqlite.DatabaseSync(NodePath.join(dir, "sessions.db"))),
(db) => Effect.sync(() => db.close()),
);
db.exec("CREATE TABLE sessions (id TEXT PRIMARY KEY, model TEXT, last_activity_at INTEGER)");
db.exec(
"CREATE TABLE message_nodes (row_id INTEGER PRIMARY KEY AUTOINCREMENT, session_id TEXT, node_id INTEGER, chat_message TEXT, created_at INTEGER, UNIQUE(session_id, node_id))",
);
db.exec("CREATE INDEX idx_message_nodes_session ON message_nodes(session_id)");
const time = Date.parse("2026-08-01T10:00:00Z");
db.prepare("INSERT INTO sessions VALUES (?, ?, ?)").run(
"fusion",
"fusion-opus-swe",
time / 1000,
);
const insert = db.prepare(
"INSERT INTO message_nodes (session_id, node_id, chat_message, created_at) VALUES (?, ?, ?, ?)",
);
const models = [
"claude-opus-5-5-medium",
"claude-opus-5-5-medium",
"swe-2-high",
"compactor",
];
for (const [index, model] of models.entries()) {
insert.run(
"fusion",
index,
encodeUnknownJsonString({
role: "assistant",
metadata: {
request_id: index < 2 ? "opus-request" : `request-${index}`,
generation_model: model,
created_at: "2026-08-01T10:00:00Z",
metrics: {
input_tokens: 100,
output_tokens: 20,
cache_read_tokens: 300,
cache_creation_tokens: 50,
},
},
}),
time / 1000,
);
}
yield* Effect.gen(function* () {
const service = yield* UsageService.make.pipe(
Effect.provideService(DevinUsage.DevinUsage, {
history: ({ dir, sinceMs }) =>
DevinUsage.readDevinUsage({ dir, sinceMs }).pipe(
Effect.map((read) => read.result),
Effect.provide(NodeServices.layer),
),
awaitPersisted: Effect.void,
prices: () =>
Effect.succeed(
DevinUsage.parseDevinModelRates({
families: [
{
variants: [
{
model_uid: "claude-opus-5-5-medium",
cost_summary: "$4 / 1M Input · $0.2 / 1M Cached input · $20 / 1M Output",
},
{ model_uid: "swe-2-high", cost_tier: "Free" },
],
},
],
}),
),
}),
);
const summary = yield* service.readSummary(WINDOW);
const buckets = summary.buckets.filter((bucket) => bucket.provider === "devin");
assert.strictEqual(buckets.length, 3);
const lead = buckets.find((bucket) => bucket.model === "claude-opus-5-5-medium");
assert.strictEqual(lead?.records, 1);
assert.strictEqual(lead?.costSource, "modelPriced");
assert.strictEqual(totalTokens(lead!.totals), 470);
assert.closeTo(lead?.costUsd ?? NaN, 0.00106, 1e-12);
assert.strictEqual(
buckets.find((bucket) => bucket.model === "swe-2-high")?.unpricedRecords,
0,
);
assert.strictEqual(
buckets.find((bucket) => bucket.model === "compactor")?.unpricedRecords,
1,
);
const sources = summary.sources.filter((source) => source.fingerprint.provider === "devin");
assert.strictEqual(sources.length, 1);
assert.strictEqual(sources[0]?.distinctSessions, 1);
const settingsService = yield* ServerSettings.ServerSettingsService;
yield* settingsService.updateSettings({
usagePriceOverrides: {
"claude-opus-5-5-medium": {
inputCostPerMillionTokens: 2,
outputCostPerMillionTokens: 5,
cacheReadCostPerMillionTokens: 0.1,
cacheWriteCostPerMillionTokens: 3,
},
},
});
const repriced = (yield* service.readSummary(WINDOW)).buckets.find(
(bucket) => bucket.provider === "devin" && bucket.model === "claude-opus-5-5-medium",
);
assert.closeTo(repriced?.costUsd ?? NaN, 0.00048, 1e-12);
}).pipe(
Effect.provide(
layerService({
prefix: "usage-devin",
home,
settings: {
...settings,
providerInstances: {
...settings.providerInstances,
[ProviderInstanceId.make("devin-one")]: {
driver: ProviderDriverKind.make("acpRegistry"),
config: { agentId: "devin" },
},
[ProviderInstanceId.make("devin-disabled")]: {
driver: ProviderDriverKind.make("acpRegistry"),
config: { agentId: "devin" },
enabled: false,
},
},
},
environment: { XDG_DATA_HOME: dataHome },
ratesDocument: {
"claude-opus-5-5-medium": { input_cost_per_token: 1, output_cost_per_token: 2 },
},
}),
),
);
}).pipe(Effect.scoped),
);

it.live("counts compressed Codex rollouts across refresh, rewrites, and cache restarts", () =>
Effect.gen(function* () {
const { home, settings } = yield* setup;
Expand Down
11 changes: 10 additions & 1 deletion apps/server/src/usage/UsageService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ import type {
ProviderUsageInstance,
TranscriptUsageFormat,
UsageRecord,
UsageTokenRates,
} from "@t3tools/provider-core/server/usage";
import { canonicalModelFilter, resolveModelAliases, UsageAggregator } from "./usageAggregation.ts";
import { createOverrideRateTable, parseRateTable, type RateTable } from "./usagePricing.ts";
Expand Down Expand Up @@ -554,6 +555,7 @@ export const make = Effect.gen(function* () {
}[]
| null;
readonly refreshing?: true;
readonly modelRates?: ReadonlyMap<string, UsageTokenRates>;
}

const scanTranscriptDir = Effect.fn("UsageService.scanTranscriptDir")(function* (
Expand Down Expand Up @@ -719,13 +721,20 @@ export const make = Effect.gen(function* () {
{ concurrency: 2 },
);

const providerRates = scannedDirs.flatMap((source) =>
Array.from(
source.modelRates ?? [],
([model, rate]) => [model, { ...rate, fast: null, ultrafast: null }] as const,
),
);
const scanRates = providerRates.length === 0 ? rates : new Map([...rates, ...providerRates]);
const aggregator = new UsageAggregator({
timeZone: input.timeZone,
sinceDay: input.sinceDay,
untilDay: input.untilDay,
resolution: input.resolution ?? "day",
...hourlyWindow,
rates,
rates: scanRates,
priceOverrides: createOverrideRateTable(settings.usagePriceOverrides),
modelAliases: resolveModelAliases(settings.usageModelAliases),
...(input.modelFilter === undefined ? {} : { modelFilter: input.modelFilter }),
Expand Down
9 changes: 2 additions & 7 deletions apps/server/src/usage/usagePricing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,9 @@ import type {
UsageTokenTotals,
} from "@t3tools/contracts";

import type { UsageRecord, UsageSpeed } from "@t3tools/provider-core/server/usage";
import type { UsageRecord, UsageSpeed, UsageTokenRates } from "@t3tools/provider-core/server/usage";

export interface TokenRates {
readonly inputCostPerToken: number;
readonly outputCostPerToken: number;
readonly cacheReadCostPerToken: number;
readonly cacheCreationCostPerToken: number;
}
export type TokenRates = UsageTokenRates;

/** Omits LiteLLM's above_272k_tokens, flex and batches variants, which transcripts do not record. */
export interface ModelRate extends TokenRates {
Expand Down
6 changes: 5 additions & 1 deletion apps/web/src/components/usage/UsageLimitsPooled.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ function AccountAvatar({
return (
<ProviderInstanceIcon
driverKind={account.driver}
acpRegistryIconUrl={account.iconUrl}
displayName={
account.displayName ??
providerClients.get(account.driver)?.label ??
Expand Down Expand Up @@ -571,13 +572,16 @@ function PoolWindowCard({

function PoolSection({ pool, now }: { readonly pool: LimitPool; readonly now: number }) {
const color = barColor(pool.driver);
const label = providerClients.get(pool.driver)?.label ?? String(pool.driver);
const registryAccount = pool.driver === "acpRegistry" ? pool.accounts[0] : undefined;
const label =
registryAccount?.displayName ?? providerClients.get(pool.driver)?.label ?? String(pool.driver);
const windows = displayLimitWindows(pool);
return (
<section className="flex flex-col gap-3">
<h2 className="flex items-center gap-2 text-sm font-medium text-foreground">
<ProviderInstanceIcon
driverKind={pool.driver}
acpRegistryIconUrl={registryAccount?.iconUrl}
displayName={label}
indicatorBackground="var(--background)"
className="size-5"
Expand Down
18 changes: 17 additions & 1 deletion apps/web/src/components/usage/UsagePage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ import { primaryServerKeybindingsAtom, serverEnvironment } from "../../state/ser
import { isCommandPaletteOpen } from "../../commandPaletteBus";
import { isModelPickerOpen } from "../../modelPickerVisibility";
import { shortcutLabelForCommand } from "../../keybindings";
import { observeVisibleAnimation } from "~/lib/visibleAnimation";
import { useUsage, type EnvironmentUsageStatus } from "../../state/usage";
import { useAtomCommand } from "../../state/use-atom-command";
import {
Expand All @@ -56,6 +57,7 @@ import {
} from "@t3tools/shared/usageFormat";
import { Button, InlineButton } from "../ui/button";
import { ProviderInstanceIcon } from "../chat/ProviderInstanceIcon";
import { ActivityShimmerOverlay } from "../chat/TimelineRowParts";
import {
Menu,
MenuCheckboxItem,
Expand Down Expand Up @@ -720,7 +722,20 @@ function UsageOverview({
/>
<ProviderMark provider={provider} className="size-4" />
<span className="flex min-w-0 items-baseline gap-1.5">
<span className="truncate">{PROVIDER_PRESENTATION[provider].label}</span>
<span
ref={providerLoading ? observeVisibleAnimation : undefined}
className={cn(
"relative truncate",
providerLoading && "text-muted-foreground",
)}
>
{PROVIDER_PRESENTATION[provider].label}
{providerLoading ? (
<ActivityShimmerOverlay>
{PROVIDER_PRESENTATION[provider].label}
</ActivityShimmerOverlay>
) : null}
</span>
<span
className={cn(
"shrink-0 whitespace-nowrap text-2xs text-muted-foreground tabular-nums",
Expand Down Expand Up @@ -1035,6 +1050,7 @@ function ProviderMark({
return (
<ProviderInstanceIcon
driverKind={presentation.driverKind}
{...(provider === "devin" ? { acpRegistryAgentId: "devin" } : {})}
displayName={presentation.label}
iconClassName={className}
/>
Expand Down
6 changes: 4 additions & 2 deletions apps/web/src/components/usage/UsageProviderChart.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,14 @@ describe("buildPeriodColumns", () => {
"2026-08-01",
{
day: "2026-08-01",
costUsd: 42,
totalTokens: 420,
costUsd: 45,
totalTokens: 450,
byProvider: new Map([
["codex" as const, { costUsd: 10, totalTokens: 100 }],
["claude" as const, { costUsd: 20, totalTokens: 200 }],
["pi" as const, { costUsd: 5, totalTokens: 50 }],
["muse" as const, { costUsd: 7, totalTokens: 70 }],
["devin" as const, { costUsd: 3, totalTokens: 30 }],
]),
},
],
Expand All @@ -42,6 +43,7 @@ describe("buildPeriodColumns", () => {
{ provider: "antigravity", value: 0 },
{ provider: "pi", value: 5 },
{ provider: "muse", value: 7 },
{ provider: "devin", value: 3 },
]);
});
});
5 changes: 5 additions & 0 deletions apps/web/src/components/usage/usageProviders.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,11 @@ export const PROVIDER_PRESENTATION = {
color: "#0866ff",
driverKind: ProviderDriverKind.make("muse"),
},
devin: {
label: "Devin",
color: "#5e9ce6",
driverKind: ProviderDriverKind.make("acpRegistry"),
},
} satisfies Record<UsageProviderKind, UsageProviderPresentation>;

export const PROVIDER_ORDER = Object.keys(PROVIDER_PRESENTATION) as UsageProviderKind[];
Expand Down
Loading
Loading