Skip to content
Closed
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
3 changes: 3 additions & 0 deletions apps/mobile/src/features/usage/usageProviders.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export const PROVIDER_ORDER: readonly UsageProviderKind[] = [
"antigravity",
"pi",
"muse",
"devin",
];

export function usageProviderKind(driver: string): UsageProviderKind | null {
Expand All @@ -26,6 +27,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 +41,7 @@ export function useProviderColors(): Record<UsageProviderKind, string> {
antigravity: "#8c7bd1",
pi: "#4d9f8a",
muse: "#0866ff",
devin: "#3fb68b",
};
}

Expand Down
4 changes: 3 additions & 1 deletion apps/server/src/provider/builtInDrivers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,8 @@ export type BuiltInUsageReadersEnv =
| ProviderUsageReaderEnv<typeof AntigravityDriver>
| ProviderUsageReaderEnv<typeof CursorDriver>
| ProviderUsageReaderEnv<typeof PiDriver>
| ProviderUsageReaderEnv<typeof MuseDriver>;
| ProviderUsageReaderEnv<typeof MuseDriver>
| ProviderUsageReaderEnv<typeof AcpRegistryDriver>;

/** Order matters: aggregation keeps the first duplicate record. */
export const BUILT_IN_USAGE_DRIVERS: ReadonlyArray<
Expand All @@ -63,4 +64,5 @@ export const BUILT_IN_USAGE_DRIVERS: ReadonlyArray<
MuseDriver,
AntigravityDriver,
CursorDriver,
AcpRegistryDriver,
];
1 change: 1 addition & 0 deletions apps/web/src/components/usage/UsageProviderChart.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ describe("buildPeriodColumns", () => {
{ provider: "antigravity", value: 0 },
{ provider: "pi", value: 5 },
{ provider: "muse", value: 7 },
{ provider: "devin", value: 0 },
]);
});
});
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 @@ -45,6 +45,11 @@ export const PROVIDER_PRESENTATION = {
color: "#0866ff",
driverKind: ProviderDriverKind.make("muse"),
},
devin: {
label: "Devin",
color: "#3fb68b",
driverKind: ProviderDriverKind.make("acpRegistry"),
},
} satisfies Record<UsageProviderKind, UsageProviderPresentation>;

export const PROVIDER_ORDER = Object.keys(PROVIDER_PRESENTATION) as UsageProviderKind[];
Expand Down
6 changes: 4 additions & 2 deletions docs/user/usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ On mobile, choose **Usage** in the workspace navigation.

## Understand your usage

**Usage** combines Codex, Claude Code, Grok Build, OpenCode, Antigravity, Pi, Muse Code, and Cursor history
**Usage** combines Codex, Claude Code, Grok Build, OpenCode, Antigravity, Pi, Muse Code, Devin, and Cursor history
from your connected environments. It shows token use, cache savings, model breakdowns, and estimated API-equivalent
cost, split by token type and by speed. These estimates are not your subscription bill.
**Premium** is what Fast and Ultrafast requests cost above standard rates. Cost that cannot be
Expand All @@ -23,6 +23,8 @@ the totals for Codex or Claude Code 2.0.63 and newer, because those runs save no
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.
Devin reads the Devin CLI's `sessions.db`, honoring `DEVIN_SESSIONS_DIR` like the CLI does. Devin
bills in ACUs, so its cost is an estimate from the underlying model's API rates.

Cursor reads account usage from Cursor's API for the account you signed in with in T3 or set as
`CURSOR_API_KEY`, or otherwise the CLI login saved on the server.
Expand Down Expand Up @@ -103,7 +105,7 @@ To count only some models, or to leave some out, add `usageModelFilter` to the s
```

`include` keeps only the listed models and `exclude` leaves the listed models out. `provider` is
one of `claude`, `codex`, `cursor`, `grok`, `opencode`, `antigravity`, `pi`, or `muse`; omit it
one of `claude`, `codex`, `cursor`, `grok`, `opencode`, `antigravity`, `pi`, `muse`, or `devin`; omit it
to match a model ID from any provider. Use the model ID that Usage shows without **Map to**, such
as a provider-prefixed ID for Pi. Filters apply before **Map to**, so a model you leave out is not
counted under its mapped name. Usage applies each environment's own filter. Remove the setting to
Expand Down
1 change: 1 addition & 0 deletions packages/contracts/src/usage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ export const UsageProviderKind = Schema.Literals([
"antigravity",
"pi",
"muse",
"devin",
]);
export type UsageProviderKind = typeof UsageProviderKind.Type;

Expand Down
143 changes: 143 additions & 0 deletions packages/provider-acp-registry/src/server/devinUsage.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
import * as NodeServices from "@effect/platform-node/NodeServices";
import { assert, describe, it } from "@effect/vitest";
import { DEFAULT_SERVER_SETTINGS, ProviderInstanceId } from "@t3tools/contracts";
import * as HostProcess from "@t3tools/shared/HostProcess";
import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient";
import * as Context from "effect/Context";
import * as Effect from "effect/Effect";
import * as FileSystem from "effect/FileSystem";
import * as Layer from "effect/Layer";
import * as Path from "effect/Path";
import * as Schema from "effect/Schema";
import * as SqlClient from "effect/sql/SqlClient";

import { AcpRegistrySettings } from "../settings.ts";
import { devinUsageReader, readDevinUsage } from "./devinUsage.ts";

const openDatabase = (filename: string) =>
Layer.build(NodeSqliteClient.layer({ filename })).pipe(
Effect.map(Context.get(SqlClient.SqlClient)),
);

describe("readDevinUsage", () => {
it.effect("counts each Devin inference once in the window and prices its base model", () =>
Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const dir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "devin-usage-test-" });
const filename = path.join(dir, "sessions.db");
const db = yield* openDatabase(filename);
yield* db.unsafe(
"CREATE TABLE message_nodes (row_id INTEGER PRIMARY KEY, session_id TEXT, node_id INTEGER, parent_node_id INTEGER, chat_message TEXT, created_at INTEGER, metadata TEXT)",
);
const sinceMs = Date.parse("2026-11-02T20:00:00Z");
const assistant = (requestId: string, createdAt: string, model: string) =>
JSON.stringify({
role: "assistant",
content: "",
metadata: {
request_id: requestId,
created_at: createdAt,
generation_model: model,
metrics: {
input_tokens: 2,
output_tokens: 358,
cache_read_tokens: 50794,
cache_creation_tokens: 6031,
},
},
});
const insert = (chatMessage: string, createdAt: string) =>
db.unsafe(
"INSERT INTO message_nodes (session_id, chat_message, created_at) VALUES (?, ?, ?)",
["piquant-tungsten", chatMessage, Math.floor(Date.parse(createdAt) / 1000)],
);
const at = "2026-11-02T20:05:13.769583414Z";
yield* insert(assistant("req-1", at, "claude-opus-5-5-high-fast"), at);
// Devin stores one inference on several nodes with identical metrics.
yield* insert(assistant("req-1", at, "claude-opus-5-5-high-fast"), at);
yield* insert(assistant("req-2", at, "gpt-6-astra-medium"), at);
yield* insert(
assistant("req-old", "2026-11-01T10:00:00Z", "gpt-6-astra-medium"),
"2026-11-01T10:00:00Z",
);
yield* insert(JSON.stringify({ role: "user", content: "hi" }), at);
yield* insert("{invalid", at);

const result = yield* readDevinUsage(filename, sinceMs);
assert.isFalse(result.error);
assert.deepStrictEqual(
result.records.map(({ dedupeKey, model, rateModel, speed }) => ({
dedupeKey,
model,
rateModel,
speed,
})),
[
{
dedupeKey: "devin:req-1",
model: "claude-opus-5-5-high-fast",
rateModel: "claude-opus-5-5",
speed: "fast",
},
{
dedupeKey: "devin:req-2",
model: "gpt-6-astra-medium",
rateModel: "gpt-6-astra",
speed: "standard",
},
],
);
assert.deepStrictEqual(result.records[0]?.totals, {
uncachedInputTokens: 2,
cachedInputTokens: 50794,
cacheCreationTokens: 6031,
outputTokens: 358,
reasoningTokens: 0,
});
assert.strictEqual(result.records[0]?.timestampMs, Date.parse(at));

const missing = yield* readDevinUsage(path.join(dir, "absent.db"), sinceMs);
assert.isTrue(missing.missing);
}).pipe(Effect.provide(NodeServices.layer)),
);
});

const decode = Schema.decodeUnknownSync(AcpRegistrySettings);

describe("devinUsageReader", () => {
it.effect("scans only registry Devin instances", () =>
Effect.gen(function* () {
const path = yield* Path.Path;
const instances = [
{ id: "acpRegistry", config: decode({}), dir: "/default" },
{ id: "acpRegistry_kimi", config: decode({ agentId: "kimi" }), dir: "/kimi" },
{
id: "acpRegistry_local",
config: decode({ source: "local", agentId: "devin", commandPath: "devin" }),
dir: "/local",
},
{ id: "acpRegistry_devin", config: decode({ agentId: "devin" }), dir: "/devin" },
];
if (devinUsageReader.kind !== "scan") throw new Error("Expected a Devin scan reader");
const sources = yield* devinUsageReader
.scan({
instances: instances.map(({ id, config, dir }) => ({
instanceId: ProviderInstanceId.make(id),
config,
environment: { DEVIN_SESSIONS_DIR: dir },
configured: true,
})),
settings: DEFAULT_SERVER_SETTINGS,
windowStartMs: 0,
retentionCutoffMs: 0,
awaitRefresh: true,
})
.pipe(Effect.provideService(HostProcess.HomeDirectory, "/home/test"));
assert.deepStrictEqual(
sources.map((source) => source.dir),
[path.resolve("/devin")],
);
}).pipe(Effect.provide(NodeServices.layer)),
);
});
Loading
Loading