devin-usage 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +67 -0
- package/extensions/devin-usage.ts +560 -0
- package/package.json +28 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 betim-hodza
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# devin-usage
|
|
2
|
+
|
|
3
|
+
Account plan, quota and credit usage for the [Devin](https://devin.ai) (Cognition) provider
|
|
4
|
+
in [pi](https://github.com/mariozechner/pi) — quota bars in a transcript card
|
|
5
|
+
plus a compact footer status.
|
|
6
|
+
|
|
7
|
+
```
|
|
8
|
+
/devin-usage # full report card (plan, daily/weekly quota, credit buckets)
|
|
9
|
+
/devin-models # per-model credit multipliers (burn rate vs 1×)
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
A summary also lands in the footer status on session start
|
|
13
|
+
(`devin: Pro · day 82% · wk 91%`).
|
|
14
|
+
|
|
15
|
+
## What you get
|
|
16
|
+
|
|
17
|
+
- **Plan**: Devin Pro / Teams / Free, org, billing window
|
|
18
|
+
- **Daily & weekly quota**: % remaining with reset countdowns
|
|
19
|
+
- **Credit buckets**: prompt / flow / flex credits used and left
|
|
20
|
+
- **Model burn rates**: every registered Devin model's `credit_multiplier`
|
|
21
|
+
(×2 cheap, ×230 premium) sorted cheapest-first, with cost tier and pricing
|
|
22
|
+
type, flagging your active model
|
|
23
|
+
- **Overage**: any overage balance surfaced when present
|
|
24
|
+
|
|
25
|
+
## How it works
|
|
26
|
+
|
|
27
|
+
Devin ships no REST usage endpoint; everything comes from one unary Connect
|
|
28
|
+
RPC the native CLI issues at startup, ported here from oh-my-pi:
|
|
29
|
+
|
|
30
|
+
```
|
|
31
|
+
POST https://server.codeium.com/exa.seat_management_pb.SeatManagementService/GetUserStatus
|
|
32
|
+
Content-Type: application/proto
|
|
33
|
+
Connect-Protocol-Version: 1
|
|
34
|
+
Body: raw (unframed) GetUserStatusRequest protobuf
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
The backend gates the response on the `Metadata` identity tuple — the request
|
|
38
|
+
must announce itself as the released Devin CLI ("chisel"), not the Windsurf
|
|
39
|
+
identity `pi-devin-auth` uses for chat:
|
|
40
|
+
|
|
41
|
+
```
|
|
42
|
+
ide_name "devin-cli" · ide_type "chisel" · ide_version "3000.6.2"
|
|
43
|
+
extension_name "chisel" · extension_version "3000.6.2"
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
**Caveat:** this is an undocumented internal endpoint guarded by a client
|
|
47
|
+
identity check. It works today; Cognition could change or gate it at any
|
|
48
|
+
time, at which point the command will fail loudly until updated.
|
|
49
|
+
|
|
50
|
+
Requires the Devin provider to be signed in (`/login devin`) — the API key
|
|
51
|
+
comes from pi's own model registry, nothing is stored by this extension.
|
|
52
|
+
|
|
53
|
+
## Install
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
pi install git:github.com/betim-hodza/devin-usage
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
or try it once without installing:
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
pi -e git:github.com/betim-hodza/devin-usage
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
## License
|
|
66
|
+
|
|
67
|
+
MIT
|
|
@@ -0,0 +1,560 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* devin-usage — account plan/quota/credit usage for the Devin provider.
|
|
3
|
+
*
|
|
4
|
+
* Ported from oh-my-pi's `packages/ai/src/usage/devin.ts`. Devin ships no REST
|
|
5
|
+
* usage endpoint; everything comes from one unary Connect RPC that the native
|
|
6
|
+
* CLI issues at startup:
|
|
7
|
+
*
|
|
8
|
+
* POST https://server.codeium.com/exa.seat_management_pb.SeatManagementService/GetUserStatus
|
|
9
|
+
* Content-Type: application/proto
|
|
10
|
+
* Connect-Protocol-Version: 1
|
|
11
|
+
* Body: raw (unframed) GetUserStatusRequest protobuf
|
|
12
|
+
*
|
|
13
|
+
* The backend gates the response on the Metadata identity tuple — it must
|
|
14
|
+
* announce itself as the released Devin CLI ("chisel" client), not the
|
|
15
|
+
* Windsurf identity pi-devin-auth uses for chat:
|
|
16
|
+
*
|
|
17
|
+
* ide_name "devin-cli", ide_type "chisel", ide_version "3000.6.2",
|
|
18
|
+
* extension_name "chisel", extension_version "3000.6.2"
|
|
19
|
+
*
|
|
20
|
+
* Adds:
|
|
21
|
+
* /devin-usage — full report as a transcript card + footer summary
|
|
22
|
+
* session_start — refreshes the footer status (best effort)
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
|
26
|
+
import { Box, Text } from "@mariozechner/pi-tui";
|
|
27
|
+
import { gunzipSync } from "node:zlib";
|
|
28
|
+
|
|
29
|
+
// ── minimal proto wire helpers (same hand-rolled approach as pi-devin-auth) ──
|
|
30
|
+
|
|
31
|
+
function encodeVarint(value: number | bigint): Buffer {
|
|
32
|
+
let v = BigInt(value);
|
|
33
|
+
if (v < 0n) v = BigInt.asUintN(64, v); // int64 two's-complement
|
|
34
|
+
const bytes: number[] = [];
|
|
35
|
+
while (v > 127n) {
|
|
36
|
+
bytes.push(Number(v & 0x7fn) | 0x80);
|
|
37
|
+
v >>= 7n;
|
|
38
|
+
}
|
|
39
|
+
bytes.push(Number(v));
|
|
40
|
+
return Buffer.from(bytes);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function encodeTag(fieldNum: number, wire: number): Buffer {
|
|
44
|
+
return encodeVarint((fieldNum << 3) | wire);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function encodeString(fieldNum: number, s: string): Buffer {
|
|
48
|
+
const buf = Buffer.from(s, "utf8");
|
|
49
|
+
return Buffer.concat([encodeTag(fieldNum, 2), encodeVarint(buf.length), buf]);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function encodeMessage(fieldNum: number, body: Buffer): Buffer {
|
|
53
|
+
return Buffer.concat([encodeTag(fieldNum, 2), encodeVarint(body.length), body]);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
interface ProtoField {
|
|
57
|
+
num: number;
|
|
58
|
+
wire: number;
|
|
59
|
+
value: bigint | Buffer;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function decodeVarint(buf: Buffer, offset: number): [bigint, number] {
|
|
63
|
+
let res = 0n;
|
|
64
|
+
let shift = 0n;
|
|
65
|
+
let i = offset;
|
|
66
|
+
while (i < buf.length) {
|
|
67
|
+
const b = buf[i++];
|
|
68
|
+
res |= BigInt(b & 0x7f) << shift;
|
|
69
|
+
if (!(b & 0x80)) return [res, i];
|
|
70
|
+
shift += 7n;
|
|
71
|
+
}
|
|
72
|
+
throw new Error("truncated varint");
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function* iterFields(buf: Buffer): Generator<ProtoField> {
|
|
76
|
+
let i = 0;
|
|
77
|
+
while (i < buf.length) {
|
|
78
|
+
const [tagBig, ai] = decodeVarint(buf, i);
|
|
79
|
+
i = ai;
|
|
80
|
+
const num = Number(tagBig >> 3n);
|
|
81
|
+
const wire = Number(tagBig & 7n);
|
|
82
|
+
if (wire === 0) {
|
|
83
|
+
const [v, bi] = decodeVarint(buf, i);
|
|
84
|
+
i = bi;
|
|
85
|
+
yield { num, wire, value: v };
|
|
86
|
+
} else if (wire === 1) {
|
|
87
|
+
if (i + 8 > buf.length) return;
|
|
88
|
+
yield { num, wire, value: buf.subarray(i, i + 8) };
|
|
89
|
+
i += 8;
|
|
90
|
+
} else if (wire === 2) {
|
|
91
|
+
const [n, ci] = decodeVarint(buf, i);
|
|
92
|
+
i = ci;
|
|
93
|
+
const len = Number(n);
|
|
94
|
+
if (len < 0 || i + len > buf.length) return;
|
|
95
|
+
yield { num, wire, value: buf.subarray(i, i + len) };
|
|
96
|
+
i += len;
|
|
97
|
+
} else if (wire === 5) {
|
|
98
|
+
if (i + 4 > buf.length) return;
|
|
99
|
+
yield { num, wire, value: buf.subarray(i, i + 4) };
|
|
100
|
+
i += 4;
|
|
101
|
+
} else {
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function fieldMsg(buf: Buffer, num: number): Buffer | undefined {
|
|
108
|
+
for (const f of iterFields(buf)) if (f.num === num && f.wire === 2) return f.value as Buffer;
|
|
109
|
+
return undefined;
|
|
110
|
+
}
|
|
111
|
+
function fieldStr(buf: Buffer, num: number): string | undefined {
|
|
112
|
+
for (const f of iterFields(buf))
|
|
113
|
+
if (f.num === num && f.wire === 2) return (f.value as Buffer).toString("utf8");
|
|
114
|
+
return undefined;
|
|
115
|
+
}
|
|
116
|
+
/** Signed decode: proto int32/int64 negatives arrive sign-extended to 64 bits. */
|
|
117
|
+
function fieldInt(buf: Buffer, num: number): number | undefined {
|
|
118
|
+
for (const f of iterFields(buf))
|
|
119
|
+
if (f.num === num && f.wire === 0) return Number(BigInt.asIntN(64, f.value as bigint));
|
|
120
|
+
return undefined;
|
|
121
|
+
}
|
|
122
|
+
function fieldBool(buf: Buffer, num: number): boolean | undefined {
|
|
123
|
+
const v = fieldInt(buf, num);
|
|
124
|
+
return v === undefined ? undefined : v !== 0;
|
|
125
|
+
}
|
|
126
|
+
function fieldFloat(buf: Buffer, num: number): number | undefined {
|
|
127
|
+
for (const f of iterFields(buf)) if (f.num === num && f.wire === 5) return (f.value as Buffer).readFloatLE(0);
|
|
128
|
+
return undefined;
|
|
129
|
+
}
|
|
130
|
+
/** All length-delimited occurrences of `num` (repeated message fields). */
|
|
131
|
+
function fieldMsgs(buf: Buffer, num: number): Buffer[] {
|
|
132
|
+
const out: Buffer[] = [];
|
|
133
|
+
for (const f of iterFields(buf)) if (f.num === num && f.wire === 2) out.push(f.value as Buffer);
|
|
134
|
+
return out;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// ── Devin RPC ────────────────────────────────────────────────────────────────
|
|
138
|
+
|
|
139
|
+
const HOST = "https://server.codeium.com";
|
|
140
|
+
const PATH = "/exa.seat_management_pb.SeatManagementService/GetUserStatus";
|
|
141
|
+
const TOKEN_PREFIX = "devin-session-token$";
|
|
142
|
+
|
|
143
|
+
function osString(): string {
|
|
144
|
+
switch (process.platform) {
|
|
145
|
+
case "darwin": return "darwin";
|
|
146
|
+
case "win32": return "windows";
|
|
147
|
+
default: return "linux";
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** Metadata message carrying the released-CLI ("chisel") identity. */
|
|
152
|
+
function buildCliMetadata(apiKey: string): Buffer {
|
|
153
|
+
const token = apiKey.startsWith(TOKEN_PREFIX) ? apiKey : `${TOKEN_PREFIX}${apiKey}`;
|
|
154
|
+
return Buffer.concat([
|
|
155
|
+
encodeString(1, "devin-cli"), // ide_name
|
|
156
|
+
encodeString(28, "chisel"), // ide_type — unlocks the CLI surface
|
|
157
|
+
encodeString(7, "3000.6.2"), // ide_version
|
|
158
|
+
encodeString(12, "chisel"), // extension_name
|
|
159
|
+
encodeString(2, "3000.6.2"), // extension_version
|
|
160
|
+
encodeString(3, token), // api_key (scheme prefix required)
|
|
161
|
+
encodeString(4, "en"), // locale
|
|
162
|
+
encodeString(5, osString()), // os
|
|
163
|
+
]);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
interface Timestamp { seconds: bigint; nanos: number }
|
|
167
|
+
function parseTimestamp(buf: Buffer): Timestamp | undefined {
|
|
168
|
+
let seconds = 0n, nanos = 0;
|
|
169
|
+
for (const f of iterFields(buf)) {
|
|
170
|
+
if (f.num === 1 && f.wire === 0) seconds = f.value as bigint;
|
|
171
|
+
if (f.num === 2 && f.wire === 0) nanos = Number(f.value);
|
|
172
|
+
}
|
|
173
|
+
return { seconds, nanos };
|
|
174
|
+
}
|
|
175
|
+
const tsMs = (t?: Timestamp) => (t ? Number(t.seconds) * 1000 + t.nanos / 1e6 : undefined);
|
|
176
|
+
|
|
177
|
+
const TEAMS_TIER: Record<number, string> = {
|
|
178
|
+
1: "Teams", 2: "Pro", 3: "Enterprise Saas", 4: "Hybrid", 5: "Enterprise Self Hosted",
|
|
179
|
+
6: "Waitlist Pro", 7: "Teams Ultimate", 9: "Trial", 10: "Enterprise Self Serve",
|
|
180
|
+
12: "Devin Enterprise", 14: "Devin Teams", 15: "Devin Teams V2", 16: "Devin Pro",
|
|
181
|
+
17: "Devin Max", 18: "Max", 19: "Devin Free", 20: "Devin Trial",
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
interface UsageReport {
|
|
185
|
+
email?: string;
|
|
186
|
+
userId?: string;
|
|
187
|
+
orgName?: string;
|
|
188
|
+
orgId?: string;
|
|
189
|
+
planName?: string;
|
|
190
|
+
billingStrategy?: number;
|
|
191
|
+
planStartMs?: number;
|
|
192
|
+
planEndMs?: number;
|
|
193
|
+
prompt?: { limit: number; used: number; available: number };
|
|
194
|
+
flow?: { limit: number; used: number; available: number };
|
|
195
|
+
flex?: { limit: number; used: number; available: number };
|
|
196
|
+
dailyQuotaPercent?: number; // remaining 0..100
|
|
197
|
+
dailyResetUnix?: number;
|
|
198
|
+
weeklyQuotaPercent?: number;
|
|
199
|
+
weeklyResetUnix?: number;
|
|
200
|
+
overageUsd?: number;
|
|
201
|
+
models?: ModelCost[];
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
interface ModelCost {
|
|
205
|
+
label: string;
|
|
206
|
+
uid: string;
|
|
207
|
+
creditMultiplier?: number;
|
|
208
|
+
pricingType?: number; // ModelPricingType enum
|
|
209
|
+
costTier?: number; // ModelCostTier enum
|
|
210
|
+
premium: boolean;
|
|
211
|
+
disabled: boolean;
|
|
212
|
+
/** Set when this is the session's active model. */
|
|
213
|
+
active?: boolean;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const PRICING_TYPE: Record<number, string> = {
|
|
217
|
+
1: "static-credit", 2: "api", 3: "byok", 4: "acu-token", 5: "acu-credit",
|
|
218
|
+
};
|
|
219
|
+
const COST_TIER: Record<number, string> = { 1: "low", 2: "medium", 3: "high", 4: "free" };
|
|
220
|
+
|
|
221
|
+
/** user_status.cascade_model_config_data (field 33) → client_model_configs (field 1). */
|
|
222
|
+
function parseModelConfigs(userStatusBuf: Buffer): ModelCost[] {
|
|
223
|
+
const data = fieldMsg(userStatusBuf, 33);
|
|
224
|
+
if (!data) return [];
|
|
225
|
+
const out: ModelCost[] = [];
|
|
226
|
+
for (const cfg of fieldMsgs(data, 1)) {
|
|
227
|
+
const label = fieldStr(cfg, 1)?.trim();
|
|
228
|
+
const uid = fieldStr(cfg, 22)?.trim();
|
|
229
|
+
if (!label && !uid) continue;
|
|
230
|
+
out.push({
|
|
231
|
+
label: label || uid || "?",
|
|
232
|
+
uid: uid || "?",
|
|
233
|
+
creditMultiplier: fieldFloat(cfg, 3),
|
|
234
|
+
pricingType: fieldInt(cfg, 13),
|
|
235
|
+
costTier: fieldInt(cfg, 24),
|
|
236
|
+
premium: fieldBool(cfg, 7) === true,
|
|
237
|
+
disabled: fieldBool(cfg, 4) === true,
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
return out;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
async function fetchDevinUsage(apiKey: string, signal?: AbortSignal): Promise<UsageReport> {
|
|
244
|
+
const body = encodeMessage(1, buildCliMetadata(apiKey)); // GetUserStatusRequest.metadata
|
|
245
|
+
const res = await fetch(`${HOST}${PATH}`, {
|
|
246
|
+
method: "POST",
|
|
247
|
+
headers: {
|
|
248
|
+
"content-type": "application/proto",
|
|
249
|
+
"connect-protocol-version": "1",
|
|
250
|
+
accept: "*/*",
|
|
251
|
+
},
|
|
252
|
+
body,
|
|
253
|
+
signal,
|
|
254
|
+
});
|
|
255
|
+
if (!res.ok) throw new Error(`GetUserStatus failed: HTTP ${res.status}`);
|
|
256
|
+
|
|
257
|
+
let payload = Buffer.from(await res.arrayBuffer());
|
|
258
|
+
let userStatusBuf: Buffer | undefined;
|
|
259
|
+
let planInfoBuf: Buffer | undefined;
|
|
260
|
+
try {
|
|
261
|
+
userStatusBuf = fieldMsg(payload, 1);
|
|
262
|
+
planInfoBuf = fieldMsg(payload, 2);
|
|
263
|
+
} catch {
|
|
264
|
+
// Edges sometimes return gzipped protobuf
|
|
265
|
+
payload = gunzipSync(payload);
|
|
266
|
+
userStatusBuf = fieldMsg(payload, 1);
|
|
267
|
+
planInfoBuf = fieldMsg(payload, 2);
|
|
268
|
+
}
|
|
269
|
+
if (!userStatusBuf) throw new Error("GetUserStatus: empty user_status in response");
|
|
270
|
+
|
|
271
|
+
const report: UsageReport = {};
|
|
272
|
+
report.email = fieldStr(userStatusBuf, 7)?.trim() || undefined;
|
|
273
|
+
report.userId = fieldStr(userStatusBuf, 36)?.trim() || undefined;
|
|
274
|
+
const userTier = fieldInt(userStatusBuf, 10);
|
|
275
|
+
|
|
276
|
+
report.models = parseModelConfigs(userStatusBuf);
|
|
277
|
+
|
|
278
|
+
const planStatusBuf = fieldMsg(userStatusBuf, 13);
|
|
279
|
+
// PlanInfo: response field 2 is authoritative; plan_status field 1 is a fallback copy.
|
|
280
|
+
const planBuf = planInfoBuf ?? (planStatusBuf ? fieldMsg(planStatusBuf, 1) : undefined);
|
|
281
|
+
if (planBuf) {
|
|
282
|
+
const tier = fieldInt(planBuf, 1);
|
|
283
|
+
report.planName =
|
|
284
|
+
fieldStr(planBuf, 2)?.trim() ||
|
|
285
|
+
(tier !== undefined ? TEAMS_TIER[tier] : undefined) ||
|
|
286
|
+
(userTier !== undefined ? TEAMS_TIER[userTier] : undefined);
|
|
287
|
+
report.billingStrategy = fieldInt(planBuf, 35);
|
|
288
|
+
const devinInfo = fieldMsg(planBuf, 33);
|
|
289
|
+
if (devinInfo) {
|
|
290
|
+
report.orgId = fieldStr(devinInfo, 4)?.trim() || undefined;
|
|
291
|
+
report.orgName = fieldStr(devinInfo, 8)?.trim() || undefined;
|
|
292
|
+
}
|
|
293
|
+
if (!report.orgId) report.orgId = fieldStr(userStatusBuf, 5)?.trim() || undefined;
|
|
294
|
+
|
|
295
|
+
if (planStatusBuf) {
|
|
296
|
+
report.planStartMs = tsMs(parseTimestamp(fieldMsg(planStatusBuf, 2)!));
|
|
297
|
+
report.planEndMs = tsMs(parseTimestamp(fieldMsg(planStatusBuf, 3)!));
|
|
298
|
+
// -1 means the plan grants no bucket / unlimited — normalize to 0.
|
|
299
|
+
const nn = (v: number | undefined) => (v !== undefined && v > 0 ? v : 0);
|
|
300
|
+
const mk = (limit: number | undefined, used?: number, avail?: number) =>
|
|
301
|
+
({ limit: nn(limit), used: nn(used), available: nn(avail) });
|
|
302
|
+
report.prompt = mk(fieldInt(planBuf, 12), fieldInt(planStatusBuf, 6), fieldInt(planStatusBuf, 8));
|
|
303
|
+
report.flow = mk(fieldInt(planBuf, 13), fieldInt(planStatusBuf, 5), fieldInt(planStatusBuf, 9));
|
|
304
|
+
report.flex = mk(fieldInt(planBuf, 14), fieldInt(planStatusBuf, 7), fieldInt(planStatusBuf, 4));
|
|
305
|
+
|
|
306
|
+
const dailyP = fieldInt(planStatusBuf, 14);
|
|
307
|
+
const weeklyP = fieldInt(planStatusBuf, 15);
|
|
308
|
+
const dailyReset = fieldInt(planStatusBuf, 17) ?? 0;
|
|
309
|
+
const weeklyReset = fieldInt(planStatusBuf, 18) ?? 0;
|
|
310
|
+
const hideDaily = fieldBool(planBuf, 36) === true;
|
|
311
|
+
const hideWeekly = fieldBool(planBuf, 37) === true;
|
|
312
|
+
const isQuotaPlan = report.billingStrategy === 2; // BillingStrategy.QUOTA
|
|
313
|
+
// Credit plans leave percents at proto defaults — only surface when
|
|
314
|
+
// server-dated or explicitly quota-billed (omp's devinQuotaApplies).
|
|
315
|
+
if (!hideDaily && dailyP !== undefined && (dailyReset > 0 || isQuotaPlan)) {
|
|
316
|
+
report.dailyQuotaPercent = Math.max(0, Math.min(100, dailyP));
|
|
317
|
+
report.dailyResetUnix = dailyReset;
|
|
318
|
+
}
|
|
319
|
+
if (!hideWeekly && weeklyP !== undefined && (weeklyReset > 0 || isQuotaPlan)) {
|
|
320
|
+
report.weeklyQuotaPercent = Math.max(0, Math.min(100, weeklyP));
|
|
321
|
+
report.weeklyResetUnix = weeklyReset;
|
|
322
|
+
}
|
|
323
|
+
const overageMicros = fieldInt(planStatusBuf, 16) ?? 0;
|
|
324
|
+
if (overageMicros !== 0) report.overageUsd = overageMicros / 1_000_000;
|
|
325
|
+
}
|
|
326
|
+
} else if (userTier !== undefined) {
|
|
327
|
+
report.planName = TEAMS_TIER[userTier];
|
|
328
|
+
}
|
|
329
|
+
return report;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// ── formatting (styled after omp's /usage provider cards) ───────────────────
|
|
333
|
+
|
|
334
|
+
type UsageStatus = "ok" | "warning" | "exhausted" | "unknown";
|
|
335
|
+
|
|
336
|
+
function usageStatus(usedFraction: number | undefined): UsageStatus {
|
|
337
|
+
if (usedFraction === undefined) return "unknown";
|
|
338
|
+
if (usedFraction >= 1) return "exhausted";
|
|
339
|
+
if (usedFraction >= 0.9) return "warning";
|
|
340
|
+
return "ok";
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/** Compact duration like omp's formatDuration: "30m", "2h30m", "3d2h". */
|
|
344
|
+
function formatDuration(ms: number): string {
|
|
345
|
+
if (!Number.isFinite(ms) || ms <= 0) return "0m";
|
|
346
|
+
const MIN = 60_000, HOUR = 60 * MIN, DAY = 24 * HOUR;
|
|
347
|
+
if (ms < HOUR) return `${Math.floor(ms / MIN)}m`;
|
|
348
|
+
if (ms < DAY) {
|
|
349
|
+
const h = Math.floor(ms / HOUR), m = Math.floor((ms % HOUR) / MIN);
|
|
350
|
+
return m > 0 ? `${h}h${m}m` : `${h}h`;
|
|
351
|
+
}
|
|
352
|
+
const d = Math.floor(ms / DAY), h = Math.floor((ms % DAY) / HOUR);
|
|
353
|
+
return h > 0 ? `${d}d${h}h` : `${d}d`;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
interface CardRow {
|
|
357
|
+
label: string;
|
|
358
|
+
/** 0..1 used; undefined → no fraction, show usedText instead. */
|
|
359
|
+
fraction?: number;
|
|
360
|
+
status: UsageStatus;
|
|
361
|
+
resetMs?: number;
|
|
362
|
+
usedText?: string;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/** Normalize a UsageReport into omp-style card rows (sorted most-pressing first). */
|
|
366
|
+
function cardRows(r: UsageReport, nowMs: number): CardRow[] {
|
|
367
|
+
const rows: CardRow[] = [];
|
|
368
|
+
if (r.dailyQuotaPercent !== undefined) {
|
|
369
|
+
const fraction = 1 - r.dailyQuotaPercent / 100;
|
|
370
|
+
const resetMs = r.dailyResetUnix && r.dailyResetUnix * 1000 > nowMs
|
|
371
|
+
? r.dailyResetUnix * 1000 - nowMs : undefined;
|
|
372
|
+
rows.push({ label: "Daily", fraction, status: usageStatus(fraction), resetMs });
|
|
373
|
+
}
|
|
374
|
+
if (r.weeklyQuotaPercent !== undefined) {
|
|
375
|
+
const fraction = 1 - r.weeklyQuotaPercent / 100;
|
|
376
|
+
const resetMs = r.weeklyResetUnix && r.weeklyResetUnix * 1000 > nowMs
|
|
377
|
+
? r.weeklyResetUnix * 1000 - nowMs : undefined;
|
|
378
|
+
rows.push({ label: "Weekly", fraction, status: usageStatus(fraction), resetMs });
|
|
379
|
+
}
|
|
380
|
+
const credits: [string, UsageReport["prompt"]][] = [
|
|
381
|
+
["Prompt credits", r.prompt],
|
|
382
|
+
["Flow credits", r.flow],
|
|
383
|
+
["Flex credits", r.flex],
|
|
384
|
+
];
|
|
385
|
+
for (const [label, b] of credits) {
|
|
386
|
+
if (!b || (b.limit === 0 && b.used === 0 && b.available === 0)) continue;
|
|
387
|
+
if (b.limit > 0) {
|
|
388
|
+
const fraction = b.used / b.limit;
|
|
389
|
+
const resetMs = r.planEndMs && r.planEndMs > nowMs ? r.planEndMs - nowMs : undefined;
|
|
390
|
+
rows.push({
|
|
391
|
+
label, fraction, status: usageStatus(fraction), resetMs,
|
|
392
|
+
usedText: `${b.available.toLocaleString()} left`,
|
|
393
|
+
});
|
|
394
|
+
} else {
|
|
395
|
+
rows.push({ label, status: "unknown", usedText: `${b.available.toLocaleString()} left` });
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
rows.sort((a, b) => (b.fraction ?? -1) - (a.fraction ?? -1));
|
|
399
|
+
return rows;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
const LABEL_W = 14;
|
|
403
|
+
const BAR_W = 20;
|
|
404
|
+
|
|
405
|
+
function miniBar(fraction: number, status: UsageStatus, theme: { fg(c: string, t: string): string }): string {
|
|
406
|
+
const clamped = Math.min(Math.max(fraction, 0), 1);
|
|
407
|
+
const filled = Math.round(clamped * BAR_W);
|
|
408
|
+
const color = status === "exhausted" ? "error" : status === "warning" ? "warning" : "success";
|
|
409
|
+
return theme.fg(color, "█".repeat(filled)) + theme.fg("dim", "░".repeat(BAR_W - filled));
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
function rowLine(row: CardRow, theme: { fg(c: string, t: string): string }): string {
|
|
413
|
+
const label = theme.fg("muted", row.label.padEnd(LABEL_W));
|
|
414
|
+
if (row.fraction === undefined) {
|
|
415
|
+
return ` ${label} ${theme.fg("dim", row.usedText ?? "no data")}`;
|
|
416
|
+
}
|
|
417
|
+
const freePct = Math.max(0, Math.round((1 - row.fraction) * 100));
|
|
418
|
+
const color = row.status === "exhausted" ? "error" : row.status === "warning" ? "warning" : "success";
|
|
419
|
+
const pct = theme.fg(color, `${freePct}% free`.padStart(7));
|
|
420
|
+
const reset = row.resetMs !== undefined ? theme.fg("dim", ` ${formatDuration(row.resetMs)}`) : "";
|
|
421
|
+
return ` ${label} ${miniBar(row.fraction, row.status, theme)} ${pct}${reset}`;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
function fmtDate(ms?: number): string {
|
|
425
|
+
return ms ? new Date(ms).toLocaleDateString(undefined, { month: "short", day: "numeric" }) : "?";
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
function statusSummary(r: UsageReport): string {
|
|
429
|
+
const parts: string[] = [];
|
|
430
|
+
if (r.planName) parts.push(r.planName);
|
|
431
|
+
if (r.dailyQuotaPercent !== undefined) parts.push(`day ${r.dailyQuotaPercent}%`);
|
|
432
|
+
if (r.weeklyQuotaPercent !== undefined) parts.push(`wk ${r.weeklyQuotaPercent}%`);
|
|
433
|
+
if (r.prompt && r.prompt.limit > 0) parts.push(`credits ${r.prompt.available}/${r.prompt.limit}`);
|
|
434
|
+
return parts.length ? `devin: ${parts.join(" · ")}` : "";
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
// ── extension ────────────────────────────────────────────────────────────────
|
|
438
|
+
|
|
439
|
+
export default async function (pi: ExtensionAPI): Promise<void> {
|
|
440
|
+
let lastReport: UsageReport | undefined;
|
|
441
|
+
|
|
442
|
+
pi.registerEntryRenderer("devin-usage", (entry, _opts, theme) => {
|
|
443
|
+
const r = entry.data as UsageReport;
|
|
444
|
+
const now = Date.now();
|
|
445
|
+
const rows = cardRows(r, now);
|
|
446
|
+
const worst = rows.reduce<UsageStatus>((w, row) => {
|
|
447
|
+
const rank = { unknown: 0, ok: 1, warning: 2, exhausted: 3 };
|
|
448
|
+
return rank[row.status] > rank[w] ? row.status : w;
|
|
449
|
+
}, "unknown");
|
|
450
|
+
const dotColor = worst === "exhausted" ? "error" : worst === "warning" ? "warning" : worst === "ok" ? "success" : "dim";
|
|
451
|
+
|
|
452
|
+
const box = new Box(1, 1, (t) => theme.bg("customMessageBg", t));
|
|
453
|
+
const title = theme.bold(`Devin${r.planName ? ` · ${r.planName}` : ""}`);
|
|
454
|
+
const who = [r.email, r.orgName].filter(Boolean).join(" · ");
|
|
455
|
+
box.addChild(new Text(`${theme.fg(dotColor, "●")} ${title}${who ? theme.fg("dim", ` ${who}`) : ""}`));
|
|
456
|
+
for (const row of rows) box.addChild(new Text(rowLine(row, theme)));
|
|
457
|
+
if (rows.length === 0) box.addChild(new Text(theme.fg("dim", " no limits reported")));
|
|
458
|
+
|
|
459
|
+
const meta: string[] = [];
|
|
460
|
+
if (r.planStartMs || r.planEndMs) meta.push(`plan ${fmtDate(r.planStartMs)} → ${fmtDate(r.planEndMs)}`);
|
|
461
|
+
if (r.overageUsd) meta.push(`overage $${r.overageUsd.toFixed(2)}`);
|
|
462
|
+
if (meta.length) box.addChild(new Text(theme.fg("dim", ` ${meta.join(" · ")}`)));
|
|
463
|
+
return box;
|
|
464
|
+
});
|
|
465
|
+
|
|
466
|
+
pi.registerEntryRenderer("devin-models", (entry, _opts, theme) => {
|
|
467
|
+
const models = (entry.data as UsageReport).models ?? [];
|
|
468
|
+
const box = new Box(1, 1, (t) => theme.bg("customMessageBg", t));
|
|
469
|
+
box.addChild(new Text(theme.bold("Devin model cost")));
|
|
470
|
+
if (models.length === 0) {
|
|
471
|
+
box.addChild(new Text(theme.fg("dim", " no model configs in response")));
|
|
472
|
+
return box;
|
|
473
|
+
}
|
|
474
|
+
box.addChild(new Text(theme.fg("dim", " burn rate vs 1× — lower is cheaper")));
|
|
475
|
+
const sorted = [...models].sort((a, b) => (a.creditMultiplier ?? 99) - (b.creditMultiplier ?? 99));
|
|
476
|
+
const nameW = Math.min(34, Math.max(...sorted.map(m => m.label.length)) + 1);
|
|
477
|
+
for (const m of sorted) {
|
|
478
|
+
const label = m.active ? `${m.label} ←` : m.label;
|
|
479
|
+
const name = theme.fg(m.active ? "accent" : "muted", label.padEnd(nameW));
|
|
480
|
+
const mult = m.creditMultiplier !== undefined
|
|
481
|
+
? theme.fg(m.creditMultiplier <= 0.5 ? "success" : m.creditMultiplier <= 1.5 ? "warning" : "error",
|
|
482
|
+
`×${m.creditMultiplier.toFixed(2).replace(/\.?0+$/, "").padStart(5)}`)
|
|
483
|
+
: theme.fg("dim", " —");
|
|
484
|
+
const tier = m.costTier !== undefined && COST_TIER[m.costTier] ? COST_TIER[m.costTier] : "";
|
|
485
|
+
const pricing = m.pricingType !== undefined ? (PRICING_TYPE[m.pricingType] ?? "") : "";
|
|
486
|
+
const extra = [tier, pricing, m.premium ? "premium" : ""].filter(Boolean).join(" · ");
|
|
487
|
+
box.addChild(new Text(` ${name} ${mult}${extra ? theme.fg("dim", ` ${extra}`) : ""}`));
|
|
488
|
+
}
|
|
489
|
+
return box;
|
|
490
|
+
});
|
|
491
|
+
|
|
492
|
+
interface RefreshCtx {
|
|
493
|
+
modelRegistry: {
|
|
494
|
+
getApiKeyForProvider(provider: string): Promise<string | undefined>;
|
|
495
|
+
getAvailable(): { provider: string; id: string }[];
|
|
496
|
+
};
|
|
497
|
+
model?: { provider: string; id: string };
|
|
498
|
+
ui: {
|
|
499
|
+
setStatus(id: string, s?: string): void;
|
|
500
|
+
notify(message: string, kind?: "info" | "warning" | "error"): void;
|
|
501
|
+
};
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
async function refresh(ctx: RefreshCtx): Promise<UsageReport | undefined> {
|
|
505
|
+
const apiKey = await ctx.modelRegistry.getApiKeyForProvider("devin");
|
|
506
|
+
if (!apiKey) {
|
|
507
|
+
ctx.ui.notify("Devin: not signed in. Run /login devin", "warning");
|
|
508
|
+
return undefined;
|
|
509
|
+
}
|
|
510
|
+
const report = await fetchDevinUsage(apiKey);
|
|
511
|
+
// Trim the 200+ server configs to the models actually registered in pi,
|
|
512
|
+
// and flag the currently active one for the card renderer.
|
|
513
|
+
const devinIds = new Set(
|
|
514
|
+
ctx.modelRegistry.getAvailable().filter(m => m.provider === "devin").map(m => m.id),
|
|
515
|
+
);
|
|
516
|
+
if (devinIds.size > 0) {
|
|
517
|
+
report.models = report.models?.filter(m => devinIds.has(m.uid));
|
|
518
|
+
}
|
|
519
|
+
const active = ctx.model;
|
|
520
|
+
if (active?.provider === "devin") {
|
|
521
|
+
for (const m of report.models ?? []) if (m.uid === active.id) m.active = true;
|
|
522
|
+
}
|
|
523
|
+
lastReport = report;
|
|
524
|
+
const summary = statusSummary(report);
|
|
525
|
+
if (summary) ctx.ui.setStatus("devin-usage", summary);
|
|
526
|
+
return report;
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
pi.registerCommand("devin-usage", {
|
|
530
|
+
description: "Show Devin plan, quota and credit usage",
|
|
531
|
+
handler: async (_args, ctx) => {
|
|
532
|
+
try {
|
|
533
|
+
const report = await refresh(ctx);
|
|
534
|
+
if (report) pi.appendEntry("devin-usage", report);
|
|
535
|
+
} catch (e) {
|
|
536
|
+
ctx.ui.notify(`Devin usage: ${e instanceof Error ? e.message : String(e)}`, "error");
|
|
537
|
+
}
|
|
538
|
+
},
|
|
539
|
+
});
|
|
540
|
+
|
|
541
|
+
pi.registerCommand("devin-models", {
|
|
542
|
+
description: "Show Devin model credit multipliers (burn rate)",
|
|
543
|
+
handler: async (_args, ctx) => {
|
|
544
|
+
try {
|
|
545
|
+
const report = lastReport ?? (await refresh(ctx));
|
|
546
|
+
if (report) pi.appendEntry("devin-models", report);
|
|
547
|
+
} catch (e) {
|
|
548
|
+
ctx.ui.notify(`Devin models: ${e instanceof Error ? e.message : String(e)}`, "error");
|
|
549
|
+
}
|
|
550
|
+
},
|
|
551
|
+
});
|
|
552
|
+
|
|
553
|
+
pi.on("session_start", async (_e, ctx) => {
|
|
554
|
+
try {
|
|
555
|
+
await refresh(ctx);
|
|
556
|
+
} catch {
|
|
557
|
+
// best-effort footer only
|
|
558
|
+
}
|
|
559
|
+
});
|
|
560
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "devin-usage",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Devin account plan, quota and credit usage in pi — from the Connect RPC the native Devin CLI issues at startup.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"pi-package"
|
|
7
|
+
],
|
|
8
|
+
"license": "MIT",
|
|
9
|
+
"author": "betim-hodza",
|
|
10
|
+
"repository": {
|
|
11
|
+
"type": "git",
|
|
12
|
+
"url": "git+https://github.com/Betim-Hodza/devin-usage.git"
|
|
13
|
+
},
|
|
14
|
+
"files": [
|
|
15
|
+
"extensions",
|
|
16
|
+
"README.md",
|
|
17
|
+
"LICENSE"
|
|
18
|
+
],
|
|
19
|
+
"pi": {
|
|
20
|
+
"extensions": [
|
|
21
|
+
"./extensions"
|
|
22
|
+
]
|
|
23
|
+
},
|
|
24
|
+
"peerDependencies": {
|
|
25
|
+
"@mariozechner/pi-coding-agent": "*",
|
|
26
|
+
"@mariozechner/pi-tui": "*"
|
|
27
|
+
}
|
|
28
|
+
}
|