@jeffreyjyz/reqshape 0.0.0-stage → 0.1.1
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 +18 -0
- package/README.md +149 -2
- package/package.json +31 -5
- package/src/cli/flags.ts +108 -0
- package/src/cli/options.ts +55 -0
- package/src/cli/parse.ts +101 -0
- package/src/cli/run.ts +142 -0
- package/src/constants/ansi.ts +4 -0
- package/src/constants/asks.ts +14 -0
- package/src/constants/keywords.ts +91 -0
- package/src/constants/layout.ts +6 -0
- package/src/constants/providers.ts +2 -0
- package/src/index.ts +11 -0
- package/src/market/entries.ts +89 -0
- package/src/market/project.ts +61 -0
- package/src/market/rates.ts +103 -0
- package/src/market/sources.ts +62 -0
- package/src/measure/asks.ts +83 -0
- package/src/measure/filter.ts +116 -0
- package/src/measure/profile.ts +114 -0
- package/src/measure/rows.ts +125 -0
- package/src/measure/sides.ts +28 -0
- package/src/measure/stats.ts +47 -0
- package/src/measure/store.ts +93 -0
- package/src/types.ts +115 -0
- package/src/view/format.ts +66 -0
- package/src/view/json.ts +51 -0
- package/src/view/render.ts +96 -0
- package/src/view/sections.ts +119 -0
- package/src/view/table.ts +61 -0
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import type { Ask, Bucket, Profile, Req, Shape, Stats } from "~/types.ts";
|
|
2
|
+
import { sideProfiles } from "./sides.ts";
|
|
3
|
+
import { emptyProfile, FIELDS, mean, reqProfile, stats } from "./stats.ts";
|
|
4
|
+
|
|
5
|
+
const BUCKETS: Array<{ label: string; holds: (position: number) => boolean }> =
|
|
6
|
+
[
|
|
7
|
+
{ label: "1", holds: (position) => position === 1 },
|
|
8
|
+
{ label: "2-5", holds: (position) => position >= 2 && position <= 5 },
|
|
9
|
+
{ label: "6-20", holds: (position) => position >= 6 && position <= 20 },
|
|
10
|
+
{
|
|
11
|
+
label: "21-100",
|
|
12
|
+
holds: (position) => position >= 21 && position <= 100,
|
|
13
|
+
},
|
|
14
|
+
{ label: "101+", holds: (position) => position >= 101 },
|
|
15
|
+
];
|
|
16
|
+
|
|
17
|
+
const STAT_FIELDS = { ...FIELDS, cost: (req: Req) => req.cost };
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* One vote per conversation: average within each session, then across them, so
|
|
21
|
+
* a single vast session cannot outweigh every short chat in the history.
|
|
22
|
+
*/
|
|
23
|
+
function sessionProfile(sessions: Req[][]): Profile {
|
|
24
|
+
const profile = emptyProfile();
|
|
25
|
+
for (const [field, read] of Object.entries(FIELDS)) {
|
|
26
|
+
profile[field as keyof Profile] = mean(
|
|
27
|
+
sessions.map((session) => mean(session.map(read))),
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
return profile;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function bucketsOf(reqs: Req[]): Bucket[] {
|
|
34
|
+
const buckets: Bucket[] = [];
|
|
35
|
+
for (const { label, holds } of BUCKETS) {
|
|
36
|
+
const inside = reqs.filter((req) => holds(req.position));
|
|
37
|
+
if (inside.length === 0) continue;
|
|
38
|
+
buckets.push({
|
|
39
|
+
label,
|
|
40
|
+
reqs: inside.length,
|
|
41
|
+
input: mean(inside.map(FIELDS.input)),
|
|
42
|
+
output: mean(inside.map(FIELDS.output)),
|
|
43
|
+
cacheRead: mean(inside.map(FIELDS.cacheRead)),
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
return buckets;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function modelsOf(reqs: Req[]): {
|
|
50
|
+
list: Array<{ name: string; reqs: number }>;
|
|
51
|
+
total: number;
|
|
52
|
+
} {
|
|
53
|
+
const counts = new Map<string, number>();
|
|
54
|
+
for (const req of reqs) {
|
|
55
|
+
const name = req.model || "unknown";
|
|
56
|
+
counts.set(name, (counts.get(name) ?? 0) + 1);
|
|
57
|
+
}
|
|
58
|
+
return {
|
|
59
|
+
list: [...counts.entries()]
|
|
60
|
+
.map(([name, count]) => ({ name, reqs: count }))
|
|
61
|
+
.sort((a, b) => b.reqs - a.reqs)
|
|
62
|
+
.slice(0, 8),
|
|
63
|
+
total: counts.size,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Measure the kept asks. Every req carries equal weight by default, because a
|
|
69
|
+
* req is the unit you are billed in; the per-conversation figure is measured
|
|
70
|
+
* alongside it, since the two can disagree by a factor of four.
|
|
71
|
+
*/
|
|
72
|
+
export function buildShape(asks: Ask[]): Shape {
|
|
73
|
+
const reqs: Req[] = [];
|
|
74
|
+
const bySession = new Map<string, Req[]>();
|
|
75
|
+
const directories = new Set<string>();
|
|
76
|
+
let first = 0;
|
|
77
|
+
let last = 0;
|
|
78
|
+
|
|
79
|
+
for (const ask of asks) {
|
|
80
|
+
const session = bySession.get(ask.sessionID) ?? [];
|
|
81
|
+
bySession.set(ask.sessionID, session);
|
|
82
|
+
for (const req of ask.reqs) {
|
|
83
|
+
reqs.push(req);
|
|
84
|
+
session.push(req);
|
|
85
|
+
}
|
|
86
|
+
if (ask.directory) directories.add(ask.directory);
|
|
87
|
+
if (ask.time > 0) {
|
|
88
|
+
first = first === 0 ? ask.time : Math.min(first, ask.time);
|
|
89
|
+
last = Math.max(last, ask.time);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const record = {} as Record<keyof Profile | "cost", Stats>;
|
|
94
|
+
for (const [field, read] of Object.entries(STAT_FIELDS)) {
|
|
95
|
+
record[field as keyof Profile | "cost"] = stats(reqs.map(read));
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const models = modelsOf(reqs);
|
|
99
|
+
return {
|
|
100
|
+
reqs: reqs.length,
|
|
101
|
+
asks: asks.length,
|
|
102
|
+
sessions: bySession.size,
|
|
103
|
+
projects: directories.size,
|
|
104
|
+
first,
|
|
105
|
+
last,
|
|
106
|
+
stats: record,
|
|
107
|
+
perReq: reqProfile(reqs),
|
|
108
|
+
perSession: sessionProfile([...bySession.values()]),
|
|
109
|
+
sides: sideProfiles(reqs),
|
|
110
|
+
buckets: bucketsOf(reqs),
|
|
111
|
+
modelTotal: models.total,
|
|
112
|
+
models: models.list,
|
|
113
|
+
};
|
|
114
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import type { Database } from "bun:sqlite";
|
|
2
|
+
|
|
3
|
+
export interface RawTokens {
|
|
4
|
+
input?: number;
|
|
5
|
+
output?: number;
|
|
6
|
+
reasoning?: number;
|
|
7
|
+
cache?: { read?: number; write?: number };
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** One row of the store, reduced to what the profiler needs. */
|
|
11
|
+
export interface RawMessage {
|
|
12
|
+
sessionID: string;
|
|
13
|
+
/** Normalised: `user`, `assistant`, or a marker such as `synthetic`. */
|
|
14
|
+
kind: string;
|
|
15
|
+
time: number;
|
|
16
|
+
/** Prompt text, when the row carries any. */
|
|
17
|
+
text: string;
|
|
18
|
+
model: string;
|
|
19
|
+
/** Provider id, as `model.providerID` (v2) or `providerID` (legacy). */
|
|
20
|
+
provider: string;
|
|
21
|
+
/** Absent while a turn is still streaming. */
|
|
22
|
+
tokens: RawTokens | null;
|
|
23
|
+
cost: number;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function object(value: unknown): Record<string, unknown> {
|
|
27
|
+
return value !== null && typeof value === "object"
|
|
28
|
+
? (value as Record<string, unknown>)
|
|
29
|
+
: {};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function text(value: unknown): string {
|
|
33
|
+
return typeof value === "string" ? value : "";
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function numberOr(value: unknown): number {
|
|
37
|
+
return typeof value === "number" && Number.isFinite(value) ? value : 0;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function parse(data: string): Record<string, unknown> | null {
|
|
41
|
+
try {
|
|
42
|
+
return object(JSON.parse(data));
|
|
43
|
+
} catch {
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Does this store use v2's message table? */
|
|
49
|
+
export function hasV2(db: Database): boolean {
|
|
50
|
+
return Boolean(
|
|
51
|
+
db
|
|
52
|
+
.query(
|
|
53
|
+
"SELECT 1 AS found FROM sqlite_master WHERE type = 'table' AND name = 'session_message'",
|
|
54
|
+
)
|
|
55
|
+
.get(),
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* v2 rows: `type` names the row, `data.model.id` the model, and only a
|
|
61
|
+
* completed turn carries `tokens`.
|
|
62
|
+
*/
|
|
63
|
+
export function readV2(db: Database): RawMessage[] {
|
|
64
|
+
const rows = db
|
|
65
|
+
.query(
|
|
66
|
+
`SELECT session_id, type, time_created, data FROM session_message
|
|
67
|
+
ORDER BY session_id, seq`,
|
|
68
|
+
)
|
|
69
|
+
.all() as Array<{
|
|
70
|
+
session_id: string;
|
|
71
|
+
type: string;
|
|
72
|
+
time_created: number;
|
|
73
|
+
data: string;
|
|
74
|
+
}>;
|
|
75
|
+
const messages: RawMessage[] = [];
|
|
76
|
+
for (const row of rows) {
|
|
77
|
+
const data = parse(row.data);
|
|
78
|
+
if (!data) continue;
|
|
79
|
+
messages.push({
|
|
80
|
+
sessionID: row.session_id,
|
|
81
|
+
kind: row.type,
|
|
82
|
+
time: row.time_created,
|
|
83
|
+
text: text(data.text),
|
|
84
|
+
model: text(object(data.model).id),
|
|
85
|
+
provider: text(object(data.model).providerID),
|
|
86
|
+
tokens: (data.tokens as RawTokens | undefined) ?? null,
|
|
87
|
+
cost: numberOr(data.cost),
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
return messages;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Pre-v2 rows: `role` instead of `type`, and a flat `modelID`. */
|
|
94
|
+
export function readLegacy(db: Database): RawMessage[] {
|
|
95
|
+
const rows = db
|
|
96
|
+
.query(
|
|
97
|
+
`SELECT session_id, time_created, data FROM message
|
|
98
|
+
ORDER BY session_id, time_created`,
|
|
99
|
+
)
|
|
100
|
+
.all() as Array<{
|
|
101
|
+
session_id: string;
|
|
102
|
+
time_created: number;
|
|
103
|
+
data: string;
|
|
104
|
+
}>;
|
|
105
|
+
const messages: RawMessage[] = [];
|
|
106
|
+
for (const row of rows) {
|
|
107
|
+
const data = parse(row.data);
|
|
108
|
+
if (!data) continue;
|
|
109
|
+
const content = data.content;
|
|
110
|
+
messages.push({
|
|
111
|
+
sessionID: row.session_id,
|
|
112
|
+
kind: text(data.role),
|
|
113
|
+
time: row.time_created,
|
|
114
|
+
text:
|
|
115
|
+
text(data.text) || (typeof content === "string" ? content : ""),
|
|
116
|
+
model: text(data.modelID),
|
|
117
|
+
// The legacy payload records `providerID` too; hardcoding "" here
|
|
118
|
+
// made every legacy session emit no `sides` at all.
|
|
119
|
+
provider: text(data.providerID),
|
|
120
|
+
tokens: (data.tokens as RawTokens | undefined) ?? null,
|
|
121
|
+
cost: numberOr(data.cost),
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
return messages;
|
|
125
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { CC_PREFIXES } from "~/constants/providers.ts";
|
|
2
|
+
import type { Req, Side, SideShape } from "~/types.ts";
|
|
3
|
+
import { reqProfile } from "./stats.ts";
|
|
4
|
+
|
|
5
|
+
/** Which plan a provider id's traffic belongs to; null when it is neither. */
|
|
6
|
+
export function sideOf(providerID: string): Side | null {
|
|
7
|
+
const id = providerID.toLowerCase();
|
|
8
|
+
if (CC_PREFIXES.some((prefix) => id.startsWith(prefix))) return "cc";
|
|
9
|
+
if (id.startsWith("opencode")) return "oc";
|
|
10
|
+
return null;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* A profile per side. The two plans do not serve the same request — cache read
|
|
15
|
+
* per req differs by an order of magnitude between them — so pricing each on
|
|
16
|
+
* its own measured traffic is the point. A shared profile would hide that.
|
|
17
|
+
* Providers belonging to neither side (routers, local models) are left out
|
|
18
|
+
* rather than averaged into one.
|
|
19
|
+
*/
|
|
20
|
+
export function sideProfiles(reqs: Req[]): Partial<Record<Side, SideShape>> {
|
|
21
|
+
const sides: Partial<Record<Side, SideShape>> = {};
|
|
22
|
+
for (const side of ["oc", "cc"] as const) {
|
|
23
|
+
const inside = reqs.filter((req) => sideOf(req.provider) === side);
|
|
24
|
+
if (inside.length === 0) continue;
|
|
25
|
+
sides[side] = { reqs: inside.length, profile: reqProfile(inside) };
|
|
26
|
+
}
|
|
27
|
+
return sides;
|
|
28
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import type { Profile, Req, Stats } from "~/types.ts";
|
|
2
|
+
|
|
3
|
+
export function mean(values: number[]): number {
|
|
4
|
+
if (values.length === 0) return 0;
|
|
5
|
+
let sum = 0;
|
|
6
|
+
for (const value of values) sum += value;
|
|
7
|
+
return sum / values.length;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** One reader per measured field, so the metrics never drift apart anywhere. */
|
|
11
|
+
export const FIELDS = {
|
|
12
|
+
input: (req: Req) => req.tokensIn,
|
|
13
|
+
output: (req: Req) => req.output,
|
|
14
|
+
reasoning: (req: Req) => req.reasoning,
|
|
15
|
+
cacheRead: (req: Req) => req.cacheRead,
|
|
16
|
+
cacheWrite: (req: Req) => req.cacheWrite,
|
|
17
|
+
} satisfies Record<keyof Profile, (req: Req) => number>;
|
|
18
|
+
|
|
19
|
+
export function emptyProfile(): Profile {
|
|
20
|
+
return { input: 0, output: 0, reasoning: 0, cacheRead: 0, cacheWrite: 0 };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** The mean of every measured field across these reqs. */
|
|
24
|
+
export function reqProfile(reqs: Req[]): Profile {
|
|
25
|
+
const profile = emptyProfile();
|
|
26
|
+
for (const [field, read] of Object.entries(FIELDS)) {
|
|
27
|
+
profile[field as keyof Profile] = mean(reqs.map(read));
|
|
28
|
+
}
|
|
29
|
+
return profile;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Nearest-rank percentiles: the value at or below which that share of reqs fall. */
|
|
33
|
+
export function stats(values: number[]): Stats {
|
|
34
|
+
if (values.length === 0) return { mean: 0, p10: 0, p50: 0, p90: 0, max: 0 };
|
|
35
|
+
const sorted = [...values].sort((a, b) => a - b);
|
|
36
|
+
const at = (p: number): number =>
|
|
37
|
+
sorted[
|
|
38
|
+
Math.min(sorted.length - 1, Math.floor((sorted.length - 1) * p))
|
|
39
|
+
] ?? 0;
|
|
40
|
+
return {
|
|
41
|
+
mean: mean(values),
|
|
42
|
+
p10: at(0.1),
|
|
43
|
+
p50: at(0.5),
|
|
44
|
+
p90: at(0.9),
|
|
45
|
+
max: sorted[sorted.length - 1] ?? 0,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { Database } from "bun:sqlite";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { hasV2, type RawMessage, readLegacy, readV2, text } from "./rows.ts";
|
|
5
|
+
|
|
6
|
+
/** Default location of opencode's store. */
|
|
7
|
+
export function defaultStorePath(): string {
|
|
8
|
+
if (process.env.OPENCODE_DB) return process.env.OPENCODE_DB;
|
|
9
|
+
const base =
|
|
10
|
+
process.env.XDG_DATA_HOME ?? join(homedir(), ".local", "share");
|
|
11
|
+
return join(base, "opencode", "opencode.db");
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface SessionRow {
|
|
15
|
+
child: boolean;
|
|
16
|
+
directory: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface Store {
|
|
20
|
+
layout: "v2" | "legacy";
|
|
21
|
+
sessions: Map<string, SessionRow>;
|
|
22
|
+
messages: RawMessage[];
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* opencode v2 appends to `session_message` and stopped writing the legacy
|
|
27
|
+
* `message` table at the migration, so both layouts are read here. Read-only:
|
|
28
|
+
* a running opencode is untouched.
|
|
29
|
+
*/
|
|
30
|
+
export function readStore(path: string): Store {
|
|
31
|
+
let db: Database;
|
|
32
|
+
try {
|
|
33
|
+
db = new Database(path, { readonly: true });
|
|
34
|
+
} catch {
|
|
35
|
+
throw new Error(`no opencode store at ${path}`);
|
|
36
|
+
}
|
|
37
|
+
try {
|
|
38
|
+
const v2 = hasV2(db);
|
|
39
|
+
return {
|
|
40
|
+
layout: v2 ? "v2" : "legacy",
|
|
41
|
+
sessions: readSessions(db),
|
|
42
|
+
messages: v2 ? readV2(db) : readLegacy(db),
|
|
43
|
+
};
|
|
44
|
+
} catch (error) {
|
|
45
|
+
throw new Error(
|
|
46
|
+
`cannot read ${path}: ${error instanceof Error ? error.message : String(error)}`,
|
|
47
|
+
);
|
|
48
|
+
} finally {
|
|
49
|
+
db.close();
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Session metadata moved to its own typed table in v2, while `session` still
|
|
55
|
+
* holds rows from before the migration — only reading both attributes every
|
|
56
|
+
* session, and this session's own directory lives in the v2 table alone. v2
|
|
57
|
+
* wins on a collision. Directories and subagent flags are context rather than
|
|
58
|
+
* the point: a store with neither table still profiles.
|
|
59
|
+
*/
|
|
60
|
+
function readSessions(db: Database): Map<string, SessionRow> {
|
|
61
|
+
const sessions = new Map<string, SessionRow>();
|
|
62
|
+
for (const table of ["session", "session_v2"] as const) {
|
|
63
|
+
for (const row of readSessionTable(db, table)) {
|
|
64
|
+
if (!row.id) continue;
|
|
65
|
+
sessions.set(row.id, {
|
|
66
|
+
child: Boolean(row.parent_id),
|
|
67
|
+
directory: text(row.directory),
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return sessions;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function readSessionTable(
|
|
75
|
+
db: Database,
|
|
76
|
+
table: "session" | "session_v2",
|
|
77
|
+
): Array<{
|
|
78
|
+
id?: string;
|
|
79
|
+
parent_id?: string | null;
|
|
80
|
+
directory?: string | null;
|
|
81
|
+
}> {
|
|
82
|
+
try {
|
|
83
|
+
return db
|
|
84
|
+
.query(`SELECT id, parent_id, directory FROM ${table}`)
|
|
85
|
+
.all() as Array<{
|
|
86
|
+
id?: string;
|
|
87
|
+
parent_id?: string | null;
|
|
88
|
+
directory?: string | null;
|
|
89
|
+
}>;
|
|
90
|
+
} catch {
|
|
91
|
+
return [];
|
|
92
|
+
}
|
|
93
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One model call. This is the same unit the opencode sidebar counts as a
|
|
3
|
+
* "req": a single `type = 'assistant'` row in opencode's store, i.e. one
|
|
4
|
+
* round trip to a provider.
|
|
5
|
+
*/
|
|
6
|
+
export interface Req {
|
|
7
|
+
/** Provider model id as the store recorded it, e.g. `deepseek/deepseek-v4.1-flash`. */
|
|
8
|
+
model: string;
|
|
9
|
+
/** Provider id as the store recorded it, e.g. `command-code-openai`. */
|
|
10
|
+
provider: string;
|
|
11
|
+
tokensIn: number;
|
|
12
|
+
output: number;
|
|
13
|
+
reasoning: number;
|
|
14
|
+
cacheRead: number;
|
|
15
|
+
cacheWrite: number;
|
|
16
|
+
/** Provider-reported spend; 0 for subscription models the harness did not price. */
|
|
17
|
+
cost: number;
|
|
18
|
+
/** 1-based position of this req within its session — the axis context grows along. */
|
|
19
|
+
position: number;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** What opened an ask. Only `user` is a prompt a person typed. */
|
|
23
|
+
export type AskKind =
|
|
24
|
+
| "user"
|
|
25
|
+
| "synthetic"
|
|
26
|
+
| "compaction"
|
|
27
|
+
| "shell"
|
|
28
|
+
| "system"
|
|
29
|
+
| "orphan";
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* One prompt and the reqs it drove. An agentic ask is many reqs: the model
|
|
33
|
+
* calls a tool, is handed the result, and is called again until it stops.
|
|
34
|
+
*/
|
|
35
|
+
export interface Ask {
|
|
36
|
+
sessionID: string;
|
|
37
|
+
directory: string;
|
|
38
|
+
/** Subagent session (`session.parent_id` is set). */
|
|
39
|
+
child: boolean;
|
|
40
|
+
kind: AskKind;
|
|
41
|
+
/** The prompt that opened it; empty for non-user kinds. */
|
|
42
|
+
text: string;
|
|
43
|
+
/** Epoch ms of the opening prompt. */
|
|
44
|
+
time: number;
|
|
45
|
+
reqs: Req[];
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** A distribution in five numbers. */
|
|
49
|
+
export interface Stats {
|
|
50
|
+
mean: number;
|
|
51
|
+
p10: number;
|
|
52
|
+
p50: number;
|
|
53
|
+
p90: number;
|
|
54
|
+
max: number;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** A per-req token vector — what one request costs to serve. */
|
|
58
|
+
export interface Profile {
|
|
59
|
+
input: number;
|
|
60
|
+
output: number;
|
|
61
|
+
reasoning: number;
|
|
62
|
+
cacheRead: number;
|
|
63
|
+
cacheWrite: number;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* The two plans the pricing compares. Traffic divides between them by provider
|
|
68
|
+
* id, and each side serves a different shape of request.
|
|
69
|
+
*/
|
|
70
|
+
export type Side = "oc" | "cc";
|
|
71
|
+
|
|
72
|
+
/** One plan's own traffic, measured on its own. */
|
|
73
|
+
export interface SideShape {
|
|
74
|
+
reqs: number;
|
|
75
|
+
profile: Profile;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Reqs grouped by how far into their session they sit. */
|
|
79
|
+
export interface Bucket {
|
|
80
|
+
label: string;
|
|
81
|
+
reqs: number;
|
|
82
|
+
input: number;
|
|
83
|
+
output: number;
|
|
84
|
+
cacheRead: number;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export interface DropCount {
|
|
88
|
+
reason: string;
|
|
89
|
+
asks: number;
|
|
90
|
+
reqs: number;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** The measured profile, ready to be priced. */
|
|
94
|
+
export interface Shape {
|
|
95
|
+
/** Reqs behind the profile, after filtering. */
|
|
96
|
+
reqs: number;
|
|
97
|
+
asks: number;
|
|
98
|
+
sessions: number;
|
|
99
|
+
projects: number;
|
|
100
|
+
/** Epoch ms span of the kept reqs. */
|
|
101
|
+
first: number;
|
|
102
|
+
last: number;
|
|
103
|
+
stats: Record<keyof Profile | "cost", Stats>;
|
|
104
|
+
/** Every req weighted equally — the default. */
|
|
105
|
+
perReq: Profile;
|
|
106
|
+
/** Mean of per-ask means, so one vast session cannot set the profile. */
|
|
107
|
+
perSession: Profile;
|
|
108
|
+
/** Per-side means, so each plan can be priced on its own traffic. */
|
|
109
|
+
sides: Partial<Record<Side, SideShape>>;
|
|
110
|
+
buckets: Bucket[];
|
|
111
|
+
/** Distinct models seen, before `models` is capped for display. */
|
|
112
|
+
modelTotal: number;
|
|
113
|
+
/** Top models by req count, capped at 8. */
|
|
114
|
+
models: Array<{ name: string; reqs: number }>;
|
|
115
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { ANSI } from "~/constants/ansi.ts";
|
|
2
|
+
|
|
3
|
+
let colored = Boolean(process.stdout.isTTY) && !process.env.NO_COLOR;
|
|
4
|
+
|
|
5
|
+
export function setColorMode(on: boolean): void {
|
|
6
|
+
colored = on;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function wrap(code: string, text: string): string {
|
|
10
|
+
return colored ? `\u001b[${code}m${text}\u001b[0m` : text;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export const bold = (text: string): string => wrap("1", text);
|
|
14
|
+
export const dim = (text: string): string => wrap("2", text);
|
|
15
|
+
|
|
16
|
+
/** Pad to a visible width, truncating with an ellipsis rather than wrapping. */
|
|
17
|
+
export function pad(
|
|
18
|
+
text: string,
|
|
19
|
+
width: number,
|
|
20
|
+
align: "left" | "right" = "left",
|
|
21
|
+
): string {
|
|
22
|
+
const visible = text.replace(ANSI, "").length;
|
|
23
|
+
if (visible > width) return `${text.slice(0, Math.max(0, width - 1))}…`;
|
|
24
|
+
const fill = " ".repeat(width - visible);
|
|
25
|
+
return align === "right" ? fill + text : text + fill;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function fmtCount(n: number): string {
|
|
29
|
+
if (!Number.isFinite(n)) return "∞";
|
|
30
|
+
if (n >= 1e9) return `${(n / 1e9).toFixed(1)}B`;
|
|
31
|
+
if (n >= 1e6) return `${(n / 1e6).toFixed(1)}M`;
|
|
32
|
+
if (n >= 1e3) return `${(n / 1e3).toFixed(1)}K`;
|
|
33
|
+
if (n >= 10) return n.toFixed(0);
|
|
34
|
+
return n.toFixed(1);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** A count that is a count: `9 asks`, never `9.0 asks`, and grouped when long. */
|
|
38
|
+
export function fmtInt(n: number): string {
|
|
39
|
+
if (!Number.isFinite(n)) return "∞";
|
|
40
|
+
return n.toLocaleString("en-US", { maximumFractionDigits: 0 });
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** The vendor prefix is noise in a report about shape: `deepseek/deepseek-v4.1-flash`. */
|
|
44
|
+
export function prettyModel(id: string): string {
|
|
45
|
+
const trimmed = id.trim();
|
|
46
|
+
const slash = trimmed.indexOf("/");
|
|
47
|
+
return slash === -1 ? trimmed || "unknown" : trimmed.slice(slash + 1);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Fixed-point and trailing-zero trimmed; scientific notation reads terribly here. */
|
|
51
|
+
export function fmtUsd(n: number): string {
|
|
52
|
+
if (n === 0) return "free";
|
|
53
|
+
if (!Number.isFinite(n)) return "∞";
|
|
54
|
+
const digits = n >= 0.01 ? 4 : n >= 1e-6 ? 8 : 11;
|
|
55
|
+
return `$${n.toFixed(digits).replace(/0+$/, "").replace(/\.$/, "")}`;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function fmtDate(ms: number): string {
|
|
59
|
+
if (!ms) return "—";
|
|
60
|
+
return new Date(ms).toISOString().slice(0, 10);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** `1.2K/21K` — a percentile pair. */
|
|
64
|
+
export function fmtPair(low: number, high: number): string {
|
|
65
|
+
return `${fmtCount(low)}/${fmtCount(high)}`;
|
|
66
|
+
}
|
package/src/view/json.ts
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import type { Projection } from "~/market/project.ts";
|
|
2
|
+
import { providerName } from "~/market/rates.ts";
|
|
3
|
+
import type { DropCount, Shape } from "~/types.ts";
|
|
4
|
+
|
|
5
|
+
export interface JsonInput {
|
|
6
|
+
shape: Shape;
|
|
7
|
+
projections: Projection[];
|
|
8
|
+
drops: DropCount[];
|
|
9
|
+
weight: "turn" | "session";
|
|
10
|
+
layout: string;
|
|
11
|
+
/** The token vector every projection was priced against. */
|
|
12
|
+
profile: Record<string, number>;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Machine-readable output. `requestsPerMonth` is null when a model costs
|
|
17
|
+
* nothing per request, since JSON has no infinity — that is unbounded, not
|
|
18
|
+
* unknown.
|
|
19
|
+
*/
|
|
20
|
+
export function renderJson(input: JsonInput): string {
|
|
21
|
+
const payload = {
|
|
22
|
+
weight: input.weight,
|
|
23
|
+
store: input.layout,
|
|
24
|
+
profile: input.profile,
|
|
25
|
+
sides: input.shape.sides,
|
|
26
|
+
shape: input.shape,
|
|
27
|
+
drops: input.drops,
|
|
28
|
+
models: input.projections.map((projection) => ({
|
|
29
|
+
key: projection.entry.key,
|
|
30
|
+
model: projection.entry.model,
|
|
31
|
+
provider: projection.entry.provider,
|
|
32
|
+
providerName: providerName(projection.entry.provider),
|
|
33
|
+
plan: projection.entry.plan,
|
|
34
|
+
pricing: {
|
|
35
|
+
input: projection.entry.input,
|
|
36
|
+
output: projection.entry.output,
|
|
37
|
+
cacheRead: projection.entry.cacheRead,
|
|
38
|
+
cacheWrite: projection.entry.cacheWrite,
|
|
39
|
+
},
|
|
40
|
+
allowance: projection.entry.allowance,
|
|
41
|
+
costPerRequest: projection.costPerReq,
|
|
42
|
+
requestsPerMonth: projection.requestsPerMonth,
|
|
43
|
+
requestsPerFiveHour: projection.requestsPerFiveHour,
|
|
44
|
+
requestsPerWeek: projection.requestsPerWeek,
|
|
45
|
+
cacheAtInput: projection.cacheAtInput,
|
|
46
|
+
writeAtInput: projection.writeAtInput,
|
|
47
|
+
free: projection.entry.free,
|
|
48
|
+
})),
|
|
49
|
+
};
|
|
50
|
+
return `${JSON.stringify(payload, null, 2)}\n`;
|
|
51
|
+
}
|