@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
package/LICENSE
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2024-2026 Jeffrey JYZ
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and
|
|
6
|
+
associated documentation files (the "Software"), to deal in the Software without restriction, including
|
|
7
|
+
without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
8
|
+
copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the
|
|
9
|
+
following conditions:
|
|
10
|
+
|
|
11
|
+
The above copyright notice and this permission notice shall be included in all copies or substantial
|
|
12
|
+
portions of the Software.
|
|
13
|
+
|
|
14
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT
|
|
15
|
+
LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO
|
|
16
|
+
EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
|
|
17
|
+
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE
|
|
18
|
+
USE OR OTHER DEALINGS IN THE SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,150 @@
|
|
|
1
|
-
#
|
|
1
|
+
# reqshape
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Measure the shape of your requests from opencode's own history, then price that shape against any model.
|
|
4
|
+
|
|
5
|
+
[](https://github.com/JeffreyJYZ/cmdcode-tools/actions/workflows/ci.yml) [](https://www.npmjs.com/package/@jeffreyjyz/reqshape) [](LICENSE)
|
|
6
|
+
|
|
7
|
+
## What it is
|
|
8
|
+
|
|
9
|
+
A **req** is one model call — the same unit the opencode sidebar counts: a single `assistant` row in opencode's store. An agentic ask is many reqs, because the model is called again after every tool result.
|
|
10
|
+
|
|
11
|
+
`reqshape` reads that history, drops the requests that ask for nothing, averages the rest, and hands you the answer as a token vector: *one of my requests is 8.2K input, 309 output, 139 reasoning, 248K cache read*. Then it prices that vector on every model OpenCode Go and CommandCode sell, and divides each plan's allowance and window caps by the result.
|
|
12
|
+
|
|
13
|
+
`mpc` answers the same question with a fixed assumption (800 in / 50K cache / 200 out). This answers it with your actual traffic — and usually disagrees, because context is re-read on every call and yours is deep.
|
|
14
|
+
|
|
15
|
+
## Install
|
|
16
|
+
|
|
17
|
+
Install the published CLI globally:
|
|
18
|
+
|
|
19
|
+
```sh
|
|
20
|
+
bun add -g @jeffreyjyz/reqshape
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
Or run it from a checkout — Bun runs the TypeScript entry directly, or link the binary:
|
|
24
|
+
|
|
25
|
+
```sh
|
|
26
|
+
bun install
|
|
27
|
+
bun run src/index.ts --help
|
|
28
|
+
bun link
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## Usage
|
|
32
|
+
|
|
33
|
+
```sh
|
|
34
|
+
reqshape
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
```text
|
|
38
|
+
reqshape 7.3K reqs · every req weighted equally
|
|
39
|
+
measured from the opencode v2 store · priced by mpc
|
|
40
|
+
|
|
41
|
+
MEASURED 1,084 asks · 38 sessions · 12 projects · 2026-07-10 → 2026-09-29
|
|
42
|
+
DROPPED synthetic prompt 260 · trivial prompt 138 · system prompt 130 · empty prompt 14 · compaction prompt 9 · shell prompt 7
|
|
43
|
+
|
|
44
|
+
PER REQ input 8.2K · output 309 · reasoning 139 · cache read 248.0K · cache write 8.1
|
|
45
|
+
p10/p90 input 67/9.2K · output 46/701 · cache read 1.9K/648.7K
|
|
46
|
+
one vote per conversation instead: cache read 64.0K · input 10.4K · output 216
|
|
47
|
+
|
|
48
|
+
PER SIDE each side priced on its own traffic
|
|
49
|
+
OpenCode Go input 7.2K output 296 cache read 134.8K 3,684 reqs
|
|
50
|
+
CommandCode input 3.7K output 337 cache read 397.0K 3,360 reqs
|
|
51
|
+
|
|
52
|
+
POSITION how far into its session the req sat — every call re-reads the context
|
|
53
|
+
pos 1 cache read 579 input 8.5K output 75 28 reqs
|
|
54
|
+
pos 2-5 cache read 9.9K input 4.9K output 180 107 reqs
|
|
55
|
+
pos 6-20 cache read 21.5K input 8.2K output 279 356 reqs
|
|
56
|
+
pos 21-100 cache read 62.2K input 10.9K output 388 1,043 reqs
|
|
57
|
+
pos 101+ cache read 301.5K input 7.8K output 301 5,737 reqs
|
|
58
|
+
|
|
59
|
+
MODELS deepseek-v4.1-flash 2.8K · minimax-m3 1.7K · glm-5.2 1.4K · best-coding 447 · hy3-free 391 · big-pickle 140 …2 more
|
|
60
|
+
|
|
61
|
+
PROJECTED what the plan's allowance buys at the measured shape
|
|
62
|
+
|
|
63
|
+
MODEL PLAN $/req req/mo req/5h req/wk
|
|
64
|
+
────────────────────────── ─────────── ──────────── ────────── ────────── ──────────
|
|
65
|
+
Jev CC GOAT $0.00034461 58.0K 11.6K 29.0K
|
|
66
|
+
Muse Spark 1.3 Contributor OC Go $0.00140623 42.7K 8.5K 21.3K
|
|
67
|
+
MiMo V2.6 Flash OC Go $0.00197864 30.3K 6.1K 15.2K
|
|
68
|
+
DeepSeek V4.1 Flash OC Go $0.0022531 26.6K 5.3K 13.3K
|
|
69
|
+
...
|
|
70
|
+
Grok 4.6 OC Go $0.1431 105 21 52
|
|
71
|
+
|
|
72
|
+
CC account 1,898 reqs this period · GOAT · ends 2026-10-27
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
## Why "cache read" is the whole story
|
|
76
|
+
|
|
77
|
+
Every request re-sends the conversation, and providers bill the re-sent part as cached tokens. So cache read is not a property of how you prompt; it is a function of **how far into the conversation you are**:
|
|
78
|
+
|
|
79
|
+
| position in its session | reqs | cache read / req |
|
|
80
|
+
| --- | --- | --- |
|
|
81
|
+
| 1st | 28 | 579 |
|
|
82
|
+
| 2–5 | 107 | 9.9K |
|
|
83
|
+
| 6–20 | 356 | 21.5K |
|
|
84
|
+
| 21–100 | 1,043 | 62.2K |
|
|
85
|
+
| 101+ | 5,737 | 301.5K |
|
|
86
|
+
|
|
87
|
+
The first request of a conversation is essentially free to re-send; the hundredth re-sends 300K tokens. On this history **78% of all requests sit at 101+**, which is why the per-request average (248K) is four times the per-conversation average (64K) — one enormous session otherwise sets the profile.
|
|
88
|
+
|
|
89
|
+
`--weight turn` (the default) treats every req as one data point, because that is the unit you are billed in. `--weight session` gives each conversation one vote and prices that instead. Both are printed either way, so you can see the gap.
|
|
90
|
+
|
|
91
|
+
A model with no published cache-read rate is billed for that context at its **input** rate, and flagged with `*`. This is the single biggest swing in the table: a model with no caching support is not slightly more expensive for this traffic, it is unusable.
|
|
92
|
+
|
|
93
|
+
## Noise filtering
|
|
94
|
+
|
|
95
|
+
"hi", "thanks", "ok", "continue" are real requests but say nothing about the work you do. `reqshape` drops an ask — the prompt *and* every req it drove — when:
|
|
96
|
+
|
|
97
|
+
- its prompt normalises to a keyword (`hi`, `hey there`, `thanks`, `great`, `lgtm`, `continue please`, …) — edit the list with `--keywords`
|
|
98
|
+
- its prompt is nothing but emoji or punctuation (`--min-chars` adds a length floor)
|
|
99
|
+
- it produced almost no output (`--min-output N`)
|
|
100
|
+
|
|
101
|
+
Structural noise is always dropped: auto-generated `synthetic` prompts, the `compaction` summary turn, `shell` commands, and `system` rows are not requests you made. `--keep-trivial` turns off the prompt filters and keeps everything else the same, and `--explain` prints every reason with its count — nothing is silently discarded.
|
|
102
|
+
|
|
103
|
+
## Options
|
|
104
|
+
|
|
105
|
+
| flag | default | meaning |
|
|
106
|
+
| --- | --- | --- |
|
|
107
|
+
| `--weight <mode>` | `turn` | `turn` (every req equal) or `session` (one vote per conversation) |
|
|
108
|
+
| `--sessions <mode>` | `all` | `all` includes subagent sessions; `user` drops them |
|
|
109
|
+
| `--keywords <list>` | built-in list | comma-separated prompts to treat as noise (replaces the list) |
|
|
110
|
+
| `--min-chars <n>` | `0` | also drop prompts shorter than this |
|
|
111
|
+
| `--min-output <n>` | `0` | also drop asks that produced fewer output tokens |
|
|
112
|
+
| `--keep-trivial` | off | keep every prompt; only structural noise is dropped |
|
|
113
|
+
| `--since <date>` | all time | only asks on or after this date |
|
|
114
|
+
| `--project <dir>` | all | only sessions whose directory contains this |
|
|
115
|
+
| `--model <text>` | all | only model rows whose name contains this |
|
|
116
|
+
| `--limit <n>` | all | cap the number of model rows |
|
|
117
|
+
| `--sort <key>` | `reqmo` | `reqmo` (most requests first), `cost`, `name` |
|
|
118
|
+
| `--explain` | off | list every drop reason rather than the top six |
|
|
119
|
+
| `--format <mode>` | `text` | `text` or `json` |
|
|
120
|
+
| `--db <path>` | opencode's store | where the history lives (`OPENCODE_DB`) |
|
|
121
|
+
| `--mpc <bin>` | `mpc` | catalogue source (`MPC_BIN`) |
|
|
122
|
+
| `--cmduse <bin>` | `cmduse` | account line (`CMDUSE_BIN`) |
|
|
123
|
+
| `--no-account` | off | skip the `cmduse` account line |
|
|
124
|
+
| `--no-color` | off | plain output (also honours `NO_COLOR`) |
|
|
125
|
+
|
|
126
|
+
### A model nobody sells
|
|
127
|
+
|
|
128
|
+
```sh
|
|
129
|
+
reqshape --in 0.15 --out 0.6 --cache-read 0.003 --budget 60 --five-hour 12 --weekly 30
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
`--budget` is the monthly allowance; `--five-hour` and `--weekly` are the plan's caps, and without them those two columns show `—` rather than inventing a window. Omit `--cache-read` and the context is billed at the input rate, flagged `*`.
|
|
133
|
+
|
|
134
|
+
## Where the numbers come from
|
|
135
|
+
|
|
136
|
+
- **Your traffic** — opencode's own store, read-only (`~/.local/share/opencode/opencode.db`). v2's `session_message` is read when present, the pre-v2 `message` table otherwise, and session metadata is joined across `session` and `session_v2`.
|
|
137
|
+
- **Model rates, allowances and window caps** — `mpc --json`. The 5-hour and weekly caps are read back from mpc's own figures, so the provider's window rule is not restated here where it could drift.
|
|
138
|
+
- **The account line** — `cmduse -1 --json`, purely as context for how much of your usage this profile covers. If it cannot answer, the line is simply absent.
|
|
139
|
+
|
|
140
|
+
### Per side, and back into `mpc`
|
|
141
|
+
|
|
142
|
+
`--format json` carries `sides.oc` and `sides.cc`: the same measured profile split by whose traffic it is (requests to `opencode*` vs the `CC_PREFIXES` ids), with each side's own request count. `mpc --shape measured` reads exactly that payload and prices each plan on its side's shape, so its estimated `req/mo` is "how many of *my* requests fit" instead of a fixed 800-in / 50K-cache / 200-out assumption.
|
|
143
|
+
|
|
144
|
+
Runs take about 20 seconds: both `mpc` and `cmduse` go to the network.
|
|
145
|
+
|
|
146
|
+
## Links
|
|
147
|
+
|
|
148
|
+
- [Source (GitHub)](https://github.com/JeffreyJYZ/cmdcode-tools/tree/main/reqshape)
|
|
149
|
+
- [npm: @jeffreyjyz/reqshape](https://www.npmjs.com/package/@jeffreyjyz/reqshape)
|
|
150
|
+
- [MIT license](LICENSE)
|
package/package.json
CHANGED
|
@@ -1,6 +1,32 @@
|
|
|
1
1
|
{
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
2
|
+
"name": "@jeffreyjyz/reqshape",
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "Measure the shape of your requests from opencode's own history, then price it against any model",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"bin": {
|
|
8
|
+
"reqshape": "src/index.ts"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"src",
|
|
12
|
+
"LICENSE"
|
|
13
|
+
],
|
|
14
|
+
"publishConfig": {
|
|
15
|
+
"access": "public"
|
|
16
|
+
},
|
|
17
|
+
"scripts": {
|
|
18
|
+
"start": "bun run src/index.ts",
|
|
19
|
+
"test": "bun test",
|
|
20
|
+
"fmt": "biome format --write .",
|
|
21
|
+
"check": "biome check --write .",
|
|
22
|
+
"typecheck": "tsc --noEmit"
|
|
23
|
+
},
|
|
24
|
+
"devDependencies": {
|
|
25
|
+
"@biomejs/biome": "^2.5.13",
|
|
26
|
+
"@types/bun": "latest",
|
|
27
|
+
"typescript": "^7"
|
|
28
|
+
},
|
|
29
|
+
"dependencies": {
|
|
30
|
+
"cac": "^7.0.0"
|
|
31
|
+
}
|
|
32
|
+
}
|
package/src/cli/flags.ts
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import type { CustomRates } from "~/market/rates.ts";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* cac prints --help and --version itself and only sets `run = false`, which is
|
|
5
|
+
* already false for us, so the tool would otherwise carry on and print a report
|
|
6
|
+
* after the help text. This reports that the question is already answered.
|
|
7
|
+
*/
|
|
8
|
+
export function answeredByCac(flags: Record<string, unknown>): boolean {
|
|
9
|
+
return flags.help === true || flags.version === true;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* mri coerces a numeric-looking value to a number, `""` to `0` and a valueless
|
|
14
|
+
* flag to `true`, so a string option is read through here rather than trusted
|
|
15
|
+
* to arrive as a string.
|
|
16
|
+
*/
|
|
17
|
+
export function str(value: unknown): string | undefined {
|
|
18
|
+
if (value === undefined || value === null || typeof value === "boolean") {
|
|
19
|
+
return undefined;
|
|
20
|
+
}
|
|
21
|
+
return String(value);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function number(value: unknown, flag: string): number {
|
|
25
|
+
if (typeof value === "boolean") {
|
|
26
|
+
throw new Error(`--${flag} expects a number, got a bare flag`);
|
|
27
|
+
}
|
|
28
|
+
if (value === undefined || value === null) return 0;
|
|
29
|
+
const parsed = Number(value);
|
|
30
|
+
if (!Number.isFinite(parsed) || parsed < 0) {
|
|
31
|
+
throw new Error(
|
|
32
|
+
`--${flag} expects a number, got ${JSON.stringify(value)}`,
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
return parsed;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function optionalNumber(value: unknown, flag: string): number | null {
|
|
39
|
+
if (value === undefined || value === null || value === "") return null;
|
|
40
|
+
return number(value, flag);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function oneOf<T extends string>(
|
|
44
|
+
value: unknown,
|
|
45
|
+
allowed: readonly T[],
|
|
46
|
+
flag: string,
|
|
47
|
+
): T | undefined {
|
|
48
|
+
if (value === undefined || value === null) return undefined;
|
|
49
|
+
const text = String(value);
|
|
50
|
+
if (!(allowed as readonly string[]).includes(text)) {
|
|
51
|
+
throw new Error(
|
|
52
|
+
`--${flag} expects ${allowed.join(" | ")}, got "${text}"`,
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
return text as T;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function sinceOf(value: unknown): number {
|
|
59
|
+
if (value === undefined || value === null || value === "") return 0;
|
|
60
|
+
const parsed = Date.parse(String(value));
|
|
61
|
+
if (Number.isNaN(parsed)) {
|
|
62
|
+
throw new Error(`--since expects a date, got ${JSON.stringify(value)}`);
|
|
63
|
+
}
|
|
64
|
+
return parsed;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** `--keywords ""` and a bare `--keywords` both mean "no keyword list at all". */
|
|
68
|
+
export function keywordList(value: unknown): string[] | undefined {
|
|
69
|
+
if (value === undefined || value === null) return undefined;
|
|
70
|
+
if (value === true || value === 0 || value === "") return [];
|
|
71
|
+
return String(value)
|
|
72
|
+
.split(",")
|
|
73
|
+
.map((word) => word.trim().toLowerCase())
|
|
74
|
+
.filter(Boolean);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Custom rates only make sense with something to price them against. */
|
|
78
|
+
export function customOf(flags: Record<string, unknown>): CustomRates | null {
|
|
79
|
+
// Any custom-model flag counts as "this run is about a custom model". The
|
|
80
|
+
// window caps and label were previously ignored on their own (`customOf`
|
|
81
|
+
// returned null and they vanished); now they demand the rates + budget too.
|
|
82
|
+
const touched =
|
|
83
|
+
flags.in !== undefined ||
|
|
84
|
+
flags.out !== undefined ||
|
|
85
|
+
flags.cacheRead !== undefined ||
|
|
86
|
+
flags.cacheWrite !== undefined ||
|
|
87
|
+
flags.budget !== undefined ||
|
|
88
|
+
flags.fiveHour !== undefined ||
|
|
89
|
+
flags.weekly !== undefined ||
|
|
90
|
+
flags.label !== undefined;
|
|
91
|
+
if (!touched) return null;
|
|
92
|
+
const budget = number(flags.budget, "budget");
|
|
93
|
+
if (budget <= 0) {
|
|
94
|
+
throw new Error("--budget <usd> is required alongside custom rates");
|
|
95
|
+
}
|
|
96
|
+
return {
|
|
97
|
+
label: typeof flags.label === "string" ? flags.label : "custom model",
|
|
98
|
+
input: number(flags.in, "in"),
|
|
99
|
+
output: number(flags.out, "out"),
|
|
100
|
+
cacheRead: optionalNumber(flags.cacheRead, "cache-read"),
|
|
101
|
+
cacheWrite: optionalNumber(flags.cacheWrite, "cache-write"),
|
|
102
|
+
budget,
|
|
103
|
+
// cac camelCases `--five-hour` to `fiveHour`; a name whose dash is
|
|
104
|
+
// followed by a digit (`--cap-5h`) would stay hyphenated and be unreadable.
|
|
105
|
+
cap5h: optionalNumber(flags.fiveHour, "five-hour"),
|
|
106
|
+
capWeek: optionalNumber(flags.weekly, "weekly"),
|
|
107
|
+
};
|
|
108
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { DEFAULT_KEYWORDS } from "~/constants/keywords.ts";
|
|
2
|
+
import type { CustomRates } from "~/market/rates.ts";
|
|
3
|
+
import { defaultStorePath } from "~/measure/store.ts";
|
|
4
|
+
|
|
5
|
+
export interface Options {
|
|
6
|
+
/** `user` keeps only prompts you typed; `all` includes subagent sessions. */
|
|
7
|
+
sessions: "user" | "all";
|
|
8
|
+
/** Which mean the projection prices: the per-req mean, or one vote per conversation. */
|
|
9
|
+
weight: "turn" | "session";
|
|
10
|
+
keepTrivial: boolean;
|
|
11
|
+
keywords: string[];
|
|
12
|
+
minChars: number;
|
|
13
|
+
minOutput: number;
|
|
14
|
+
/** Epoch ms floor; 0 keeps everything. */
|
|
15
|
+
since: number;
|
|
16
|
+
project: string;
|
|
17
|
+
model: string;
|
|
18
|
+
limit: number;
|
|
19
|
+
sort: "reqmo" | "cost" | "name";
|
|
20
|
+
format: "text" | "json";
|
|
21
|
+
explain: boolean;
|
|
22
|
+
account: boolean;
|
|
23
|
+
/** cac already printed --help or --version; there is nothing left to do. */
|
|
24
|
+
handled: boolean;
|
|
25
|
+
color: "auto" | "always" | "never";
|
|
26
|
+
db: string;
|
|
27
|
+
mpc: string;
|
|
28
|
+
cmduse: string;
|
|
29
|
+
custom: CustomRates | null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function defaultOptions(): Options {
|
|
33
|
+
return {
|
|
34
|
+
sessions: "all",
|
|
35
|
+
weight: "turn",
|
|
36
|
+
keepTrivial: false,
|
|
37
|
+
keywords: [...DEFAULT_KEYWORDS],
|
|
38
|
+
minChars: 0,
|
|
39
|
+
minOutput: 0,
|
|
40
|
+
since: 0,
|
|
41
|
+
project: "",
|
|
42
|
+
model: "",
|
|
43
|
+
limit: 0,
|
|
44
|
+
sort: "reqmo",
|
|
45
|
+
format: "text",
|
|
46
|
+
explain: false,
|
|
47
|
+
account: true,
|
|
48
|
+
handled: false,
|
|
49
|
+
color: "auto",
|
|
50
|
+
db: defaultStorePath(),
|
|
51
|
+
mpc: process.env.MPC_BIN ?? "mpc",
|
|
52
|
+
cmduse: process.env.CMDUSE_BIN ?? "cmduse",
|
|
53
|
+
custom: null,
|
|
54
|
+
};
|
|
55
|
+
}
|
package/src/cli/parse.ts
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { cac } from "cac";
|
|
2
|
+
import pkg from "../../package.json" with { type: "json" };
|
|
3
|
+
import {
|
|
4
|
+
answeredByCac,
|
|
5
|
+
customOf,
|
|
6
|
+
keywordList,
|
|
7
|
+
number,
|
|
8
|
+
oneOf,
|
|
9
|
+
sinceOf,
|
|
10
|
+
str,
|
|
11
|
+
} from "./flags.ts";
|
|
12
|
+
import { defaultOptions, type Options } from "./options.ts";
|
|
13
|
+
|
|
14
|
+
export function parseArgs(argv: string[]): Options {
|
|
15
|
+
const cli = cac("reqshape");
|
|
16
|
+
cli.option("--sessions <mode>", "all | user — user drops subagent sessions")
|
|
17
|
+
.option(
|
|
18
|
+
"--weight <mode>",
|
|
19
|
+
"turn | session — which mean the projection prices",
|
|
20
|
+
)
|
|
21
|
+
.option("--keep-trivial", "profile every prompt, noise included")
|
|
22
|
+
.option(
|
|
23
|
+
"--keywords <list>",
|
|
24
|
+
"comma-separated prompts counted as noise (replaces the default list)",
|
|
25
|
+
)
|
|
26
|
+
.option(
|
|
27
|
+
"--min-chars <n>",
|
|
28
|
+
"also drop prompts shorter than this (0 = off)",
|
|
29
|
+
)
|
|
30
|
+
.option(
|
|
31
|
+
"--min-output <n>",
|
|
32
|
+
"also drop asks producing fewer output tokens (0 = off)",
|
|
33
|
+
)
|
|
34
|
+
.option("--since <date>", "only asks on or after this date")
|
|
35
|
+
.option("--project <dir>", "only sessions under this directory")
|
|
36
|
+
.option("--model <text>", "only model rows whose name contains this")
|
|
37
|
+
.option("--limit <n>", "cap the number of model rows")
|
|
38
|
+
.option("--sort <key>", "reqmo | cost | name")
|
|
39
|
+
.option("--in <rate>", "custom model: input $/M tokens")
|
|
40
|
+
.option("--out <rate>", "custom model: output $/M tokens")
|
|
41
|
+
.option("--cache-read <rate>", "custom model: cache-read $/M tokens")
|
|
42
|
+
.option("--cache-write <rate>", "custom model: cache-write $/M tokens")
|
|
43
|
+
.option("--budget <usd>", "custom model: monthly allowance")
|
|
44
|
+
.option("--five-hour <usd>", "custom model: five-hour cap")
|
|
45
|
+
.option("--weekly <usd>", "custom model: weekly cap")
|
|
46
|
+
.option("--label <name>", "custom model: display name")
|
|
47
|
+
.option(
|
|
48
|
+
"--db <path>",
|
|
49
|
+
"opencode store (default ~/.local/share/opencode/opencode.db)",
|
|
50
|
+
)
|
|
51
|
+
.option("--mpc <bin>", "mpc binary (else MPC_BIN)")
|
|
52
|
+
.option("--cmduse <bin>", "cmduse binary (else CMDUSE_BIN)")
|
|
53
|
+
.option("--no-account", "skip the cmduse account line")
|
|
54
|
+
.option("--color <mode>", "auto | always | never")
|
|
55
|
+
.option("--explain", "list every drop reason rather than the top few")
|
|
56
|
+
.option("--format <mode>", "text | json");
|
|
57
|
+
cli.help();
|
|
58
|
+
cli.version(pkg.version);
|
|
59
|
+
cli.example("reqshape");
|
|
60
|
+
cli.example("reqshape --weight session --sort cost");
|
|
61
|
+
cli.example("reqshape --in 0.15 --out 0.6 --cache-read 0.003 --budget 60");
|
|
62
|
+
|
|
63
|
+
const { options: flags } = cli.parse(["node", "reqshape", ...argv], {
|
|
64
|
+
run: false,
|
|
65
|
+
});
|
|
66
|
+
const raw = (flags ?? {}) as Record<string, unknown>;
|
|
67
|
+
const options = defaultOptions();
|
|
68
|
+
|
|
69
|
+
options.sessions =
|
|
70
|
+
oneOf(raw.sessions, ["all", "user"], "sessions") ?? options.sessions;
|
|
71
|
+
options.weight =
|
|
72
|
+
oneOf(raw.weight, ["turn", "session"], "weight") ?? options.weight;
|
|
73
|
+
options.sort =
|
|
74
|
+
oneOf(raw.sort, ["reqmo", "cost", "name"], "sort") ?? options.sort;
|
|
75
|
+
options.format =
|
|
76
|
+
oneOf(raw.format, ["text", "json"], "format") ?? options.format;
|
|
77
|
+
// cac negates a declared flag itself, so --no-color arrives as `false`.
|
|
78
|
+
options.color =
|
|
79
|
+
raw.color === false
|
|
80
|
+
? "never"
|
|
81
|
+
: (oneOf(raw.color, ["auto", "always", "never"], "color") ??
|
|
82
|
+
options.color);
|
|
83
|
+
|
|
84
|
+
options.keepTrivial = raw.keepTrivial === true;
|
|
85
|
+
options.keywords = keywordList(raw.keywords) ?? options.keywords;
|
|
86
|
+
options.minChars = number(raw.minChars, "min-chars");
|
|
87
|
+
options.minOutput = number(raw.minOutput, "min-output");
|
|
88
|
+
options.limit = number(raw.limit, "limit");
|
|
89
|
+
options.since = sinceOf(raw.since);
|
|
90
|
+
options.project = str(raw.project) ?? "";
|
|
91
|
+
options.model = str(raw.model) ?? "";
|
|
92
|
+
options.explain = raw.explain === true;
|
|
93
|
+
options.account = raw.account !== false;
|
|
94
|
+
options.handled = answeredByCac(raw);
|
|
95
|
+
options.db = str(raw.db) ?? options.db;
|
|
96
|
+
options.mpc = str(raw.mpc) ?? options.mpc;
|
|
97
|
+
options.cmduse = str(raw.cmduse) ?? options.cmduse;
|
|
98
|
+
options.custom = customOf(raw);
|
|
99
|
+
|
|
100
|
+
return options;
|
|
101
|
+
}
|
package/src/cli/run.ts
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { entriesOf } from "~/market/entries.ts";
|
|
2
|
+
import { type Projection, projectAll } from "~/market/project.ts";
|
|
3
|
+
import { customEntry, type RateEntry } from "~/market/rates.ts";
|
|
4
|
+
import { loadAccount, loadMpc } from "~/market/sources.ts";
|
|
5
|
+
import { buildAsks } from "~/measure/asks.ts";
|
|
6
|
+
import { filterAsks } from "~/measure/filter.ts";
|
|
7
|
+
import { buildShape } from "~/measure/profile.ts";
|
|
8
|
+
import { readStore } from "~/measure/store.ts";
|
|
9
|
+
import type { Profile } from "~/types.ts";
|
|
10
|
+
import { setColorMode } from "~/view/format.ts";
|
|
11
|
+
import { renderJson } from "~/view/json.ts";
|
|
12
|
+
import { renderText } from "~/view/render.ts";
|
|
13
|
+
import type { Options } from "./options.ts";
|
|
14
|
+
import { parseArgs } from "./parse.ts";
|
|
15
|
+
|
|
16
|
+
function colorEnabled(options: Options): boolean {
|
|
17
|
+
if (options.color === "always") return true;
|
|
18
|
+
if (options.color === "never") return false;
|
|
19
|
+
return Boolean(process.stdout.isTTY) && !process.env.NO_COLOR;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Most requests per month first. Free models tie rather than compare as NaN. */
|
|
23
|
+
function byRequestsPerMonth(a: Projection, b: Projection): number {
|
|
24
|
+
if (a.requestsPerMonth === b.requestsPerMonth) return 0;
|
|
25
|
+
return a.requestsPerMonth > b.requestsPerMonth ? -1 : 1;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function sortProjections(
|
|
29
|
+
projections: Projection[],
|
|
30
|
+
sort: Options["sort"],
|
|
31
|
+
): Projection[] {
|
|
32
|
+
const sorted = [...projections];
|
|
33
|
+
if (sort === "cost") {
|
|
34
|
+
return sorted.sort((a, b) => a.costPerReq - b.costPerReq);
|
|
35
|
+
}
|
|
36
|
+
if (sort === "name") {
|
|
37
|
+
return sorted.sort((a, b) =>
|
|
38
|
+
a.entry.model.localeCompare(b.entry.model),
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
return sorted.sort(byRequestsPerMonth);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* The catalogue comes from mpc, which already knows both plans' pricing,
|
|
46
|
+
* allowances and window rules. A custom model is priced alongside it, and
|
|
47
|
+
* carries the run when mpc is unavailable.
|
|
48
|
+
*/
|
|
49
|
+
function ratesOf(options: Options): RateEntry[] {
|
|
50
|
+
let entries: RateEntry[] = [];
|
|
51
|
+
try {
|
|
52
|
+
entries = entriesOf(loadMpc(options.mpc));
|
|
53
|
+
} catch (error) {
|
|
54
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
55
|
+
if (!options.custom) {
|
|
56
|
+
throw new Error(
|
|
57
|
+
`${detail}\nPass --in/--out/--budget to price a custom model instead.`,
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
process.stderr.write(
|
|
61
|
+
`reqshape: ${detail}\n pricing the custom model only\n`,
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
if (options.custom) entries.push(customEntry(options.custom));
|
|
65
|
+
return entries;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export async function run(argv: string[]): Promise<number> {
|
|
69
|
+
const options = parseArgs(argv);
|
|
70
|
+
// cac has already written the help or version text.
|
|
71
|
+
if (options.handled) return 0;
|
|
72
|
+
setColorMode(colorEnabled(options));
|
|
73
|
+
|
|
74
|
+
const store = readStore(options.db);
|
|
75
|
+
const asks = buildAsks(store.messages, store.sessions);
|
|
76
|
+
const { kept, drops } = filterAsks(asks, {
|
|
77
|
+
sessions: options.sessions,
|
|
78
|
+
keywords: new Set(options.keywords),
|
|
79
|
+
minChars: options.minChars,
|
|
80
|
+
minOutput: options.minOutput,
|
|
81
|
+
keepTrivial: options.keepTrivial,
|
|
82
|
+
since: options.since,
|
|
83
|
+
project: options.project,
|
|
84
|
+
});
|
|
85
|
+
const shape = buildShape(kept);
|
|
86
|
+
|
|
87
|
+
if (shape.reqs === 0) {
|
|
88
|
+
const why =
|
|
89
|
+
drops.map((drop) => `${drop.reason} ${drop.asks}`).join(" · ") ||
|
|
90
|
+
"no requests found";
|
|
91
|
+
process.stderr.write(
|
|
92
|
+
`reqshape: nothing to price — no requests survived the filters (${why})\n`,
|
|
93
|
+
);
|
|
94
|
+
return 1;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const profile: Profile =
|
|
98
|
+
options.weight === "session" ? shape.perSession : shape.perReq;
|
|
99
|
+
|
|
100
|
+
let entries = ratesOf(options);
|
|
101
|
+
const needle = options.model.trim().toLowerCase();
|
|
102
|
+
if (needle) {
|
|
103
|
+
entries = entries.filter((entry) =>
|
|
104
|
+
entry.model.toLowerCase().includes(needle),
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
const total = entries.length;
|
|
108
|
+
let projections = sortProjections(
|
|
109
|
+
projectAll(entries, profile),
|
|
110
|
+
options.sort,
|
|
111
|
+
);
|
|
112
|
+
if (options.limit > 0) projections = projections.slice(0, options.limit);
|
|
113
|
+
const account = options.account ? loadAccount(options.cmduse) : null;
|
|
114
|
+
|
|
115
|
+
if (options.format === "json") {
|
|
116
|
+
process.stdout.write(
|
|
117
|
+
renderJson({
|
|
118
|
+
shape,
|
|
119
|
+
projections,
|
|
120
|
+
drops,
|
|
121
|
+
weight: options.weight,
|
|
122
|
+
layout: store.layout,
|
|
123
|
+
profile: { ...profile },
|
|
124
|
+
}),
|
|
125
|
+
);
|
|
126
|
+
return 0;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
process.stdout.write(
|
|
130
|
+
renderText({
|
|
131
|
+
shape,
|
|
132
|
+
projections,
|
|
133
|
+
drops,
|
|
134
|
+
weight: options.weight,
|
|
135
|
+
account,
|
|
136
|
+
layout: store.layout,
|
|
137
|
+
total,
|
|
138
|
+
explain: options.explain,
|
|
139
|
+
}),
|
|
140
|
+
);
|
|
141
|
+
return 0;
|
|
142
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { AskKind } from "~/types.ts";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Row kinds that *open* a new ask. Only a prompt the user typed does.
|
|
5
|
+
*
|
|
6
|
+
* `synthetic` / `system` / `compaction` / `shell` rows are interjections: a
|
|
7
|
+
* system-reminder lands after the prompt and before its answer, so treating one
|
|
8
|
+
* as a boundary filed the answer's reqs under the interjection, which the
|
|
9
|
+
* filter then discarded along with the whole response. They now continue the
|
|
10
|
+
* ask they interrupted.
|
|
11
|
+
*/
|
|
12
|
+
export const BOUNDARY: Record<string, AskKind> = {
|
|
13
|
+
user: "user",
|
|
14
|
+
};
|