@quadiusai/dsh-greenharness 0.0.0-stage → 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/GETTING-STARTED.md +76 -0
- package/README.md +147 -2
- package/bench/BREV.md +127 -0
- package/bench/lib/patch.mjs +60 -0
- package/bench/mock-openai-server.mjs +210 -0
- package/bench/presets/mock-chat-template.json +40 -0
- package/bench/presets/mock.json +32 -0
- package/bench/presets/nemo-brev.json +40 -0
- package/bench/run-case.mjs +242 -0
- package/bench/run-mock-suite.mjs +85 -0
- package/bench/scenarios/loop-stuck.json +11 -0
- package/bench/scenarios/subtract-fix.json +12 -0
- package/bench/tasks/subtract-bug/TASK.md +2 -0
- package/bench/tasks/subtract-bug/package.json +9 -0
- package/bench/tasks/subtract-bug/src/math.mjs +8 -0
- package/bench/tasks/subtract-bug/test/math.test.mjs +11 -0
- package/bench/tasks/subtract-bug/test/noise.test.mjs +9 -0
- package/cordis.patch.yml +4 -0
- package/package.json +42 -4
- package/src/config.mjs +100 -0
- package/src/control.mjs +160 -0
- package/src/core/ansi.mjs +15 -0
- package/src/core/cascade.mjs +81 -0
- package/src/core/distill.mjs +128 -0
- package/src/core/ledger.mjs +21 -0
- package/src/core/loop-guard.mjs +52 -0
- package/src/core/patterns.mjs +26 -0
- package/src/core/pricing.mjs +23 -0
- package/src/core/read-index.mjs +74 -0
- package/src/core/route.mjs +41 -0
- package/src/core/verify.mjs +89 -0
- package/src/index.mjs +232 -0
- package/src/runtime.mjs +297 -0
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# GreenHarness: Getting Started
|
|
2
|
+
|
|
3
|
+
GreenHarness makes your DSH agent cheaper to run. It shortens huge command output, stops repeated work, and can hand easy steps to a cheap model and hard steps to a strong one. You don't have to configure anything for it to start saving.
|
|
4
|
+
|
|
5
|
+
## 1. Install (one time)
|
|
6
|
+
|
|
7
|
+
Open DSH's web page, or a terminal, and run:
|
|
8
|
+
|
|
9
|
+
```
|
|
10
|
+
dsh plugin --profile web add @quadiusai/dsh-greenharness
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Then start `dsh web` as usual. That's it. GreenHarness is now on.
|
|
14
|
+
|
|
15
|
+
## 2. Add your API keys and pick your models (no files to edit)
|
|
16
|
+
|
|
17
|
+
Use DSH's own screens:
|
|
18
|
+
|
|
19
|
+
1. In DSH web, open **Settings → Models**.
|
|
20
|
+
2. Add your provider (for example your NVIDIA/Brev endpoint) and paste your API key. DSH stores it for you.
|
|
21
|
+
3. Use DSH's model chooser to pick the **cheap (small)** model for normal chats.
|
|
22
|
+
4. Tell GreenHarness which model is the **strong (large)** one by typing in the chat box:
|
|
23
|
+
|
|
24
|
+
```
|
|
25
|
+
/greenharness large <provider> <model>
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
Example: `/greenharness large nemo-large lightning-30b`
|
|
29
|
+
|
|
30
|
+
The cheap model is whatever DSH is already using. GreenHarness remembers it automatically. If you want to set it yourself: `/greenharness small <provider> <model>`.
|
|
31
|
+
|
|
32
|
+
If you skip step 4, GreenHarness still saves tokens. It just won't switch models.
|
|
33
|
+
|
|
34
|
+
## 3. See it working
|
|
35
|
+
|
|
36
|
+
Type this in the chat box any time:
|
|
37
|
+
|
|
38
|
+
```
|
|
39
|
+
/greenharness
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
You get a live summary like:
|
|
43
|
+
|
|
44
|
+
```
|
|
45
|
+
GreenHarness is ON
|
|
46
|
+
|
|
47
|
+
Tokens saved so far: about 1,240
|
|
48
|
+
(long outputs shortened: 3, repeated reads replaced: 2, loops stopped: 0)
|
|
49
|
+
Work done by the cheap model: [██████████████░░░░░░] 70%
|
|
50
|
+
Model calls: 12. Switches between models: 2.
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
- **Tokens saved** is an estimate (about 4 characters per token) of text GreenHarness kept out of the model.
|
|
54
|
+
- **The bar** shows how much of the work the cheap model did. A fuller bar means a cheaper run.
|
|
55
|
+
- Type `/greenharness` again to refresh the numbers.
|
|
56
|
+
|
|
57
|
+
## 4. Handy commands
|
|
58
|
+
|
|
59
|
+
| Type this | What it does |
|
|
60
|
+
|---|---|
|
|
61
|
+
| `/greenharness` | Show the summary above |
|
|
62
|
+
| `/greenharness off` / `on` | Pause or resume everything |
|
|
63
|
+
| `/greenharness large <provider> <model>` | Pick the strong model (turns switching on) |
|
|
64
|
+
| `/greenharness routing off` | Stop switching models |
|
|
65
|
+
| `/greenharness distill off` | Stop shortening long output |
|
|
66
|
+
| `/greenharness help` | List every command |
|
|
67
|
+
|
|
68
|
+
Changes last until DSH restarts.
|
|
69
|
+
|
|
70
|
+
## 5. If something looks wrong
|
|
71
|
+
- **No `/greenharness` command:** run the install line again, then restart `dsh web`.
|
|
72
|
+
- **"Not set" for the strong model:** run `/greenharness large ...` (step 2).
|
|
73
|
+
- **Needs Node 22.19 or newer.**
|
|
74
|
+
|
|
75
|
+
## Honest limits
|
|
76
|
+
The summary is text in the chat. DSH doesn't offer plugins a way to add their own panel, so there's no separate graphical dashboard. Savings numbers are estimates. The `/greenharness` command in the web chat has been tested only through unit tests and DSH start-up, not by clicking through the live page.
|
package/README.md
CHANGED
|
@@ -1,3 +1,148 @@
|
|
|
1
|
-
#
|
|
1
|
+
# GreenHarness
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
A DeepSeek Harness (dsh) plugin that makes multi-step agent runs cheaper. It routes each model call between a small and a large model using the outcome of the project's own tests. It shortens and deduplicates tool output before the model reads it, and it stops an agent from repeating a failing command.
|
|
4
|
+
|
|
5
|
+
Built for AEGIS 2026, problem 2 (agentic cost and efficiency), team Skyline.
|
|
6
|
+
|
|
7
|
+
New to terminals or agents? Start with [`GETTING-STARTED.md`](GETTING-STARTED.md).
|
|
8
|
+
|
|
9
|
+
## Quick install
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
dsh plugin --profile web add @quadiusai/dsh-greenharness
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
That is the whole setup. The package declares itself as a DSH bundle, so DSH turns it on in that profile by itself, and every setting has a default. Restart `dsh web`, then type `/greenharness` in the chat:
|
|
16
|
+
|
|
17
|
+
| Command | What it does |
|
|
18
|
+
|---|---|
|
|
19
|
+
| `/greenharness` | Status: what is on, and what it has done in this process. |
|
|
20
|
+
| `/greenharness on` / `off` | Resume or pause every mechanism. |
|
|
21
|
+
| `/greenharness large <provider> <model> [effort]` | Turn on model routing. The small model is whatever DSH already uses; this one is the large model that failing tests escalate to. |
|
|
22
|
+
| `/greenharness small <provider> <model> [effort]` | Set the small model explicitly. |
|
|
23
|
+
| `/greenharness routing on` / `off` | Switch routing without losing the models. |
|
|
24
|
+
| `/greenharness distill` / `dedupe` / `loop` `on` / `off` | Switch one mechanism. |
|
|
25
|
+
|
|
26
|
+
With no setup, GreenHarness shortens long command output, replaces repeated file reads with a pointer, and blocks a repeated failing command. These need only one model. Routing between two models starts when you set a large model. Command settings last until DSH restarts; to make them permanent, put the same values in the plugin's config (`tiers`, `distill`, and so on, see Configuration).
|
|
27
|
+
|
|
28
|
+
**Status of this install path.** Checked: the package installs into a fresh web profile with the command above (from a packed tarball), DSH lists it as an active bundle, `dsh web` boots with it and no activation warning, and the command logic has unit tests. **Not checked:** typing `/greenharness` in the web chat itself, and installing from the npm registry (the package is not published yet; publish it with `npm publish --access public`).
|
|
29
|
+
|
|
30
|
+
## Status
|
|
31
|
+
|
|
32
|
+
- **Plugin:** four mechanisms wired to DSH 0.2.1-rc.2 headless. 59 unit tests pass on Node 24.22.
|
|
33
|
+
- **Verified end to end against a scripted mock server** (plumbing, not a model). Routing, distillation, read dedupe and loop breaking all reach the model. The thinking-switch request body is correct on both tiers.
|
|
34
|
+
- **Not measured: any real NeMo model.** This session had no GPU and no Brev access, so no quality or cost numbers exist yet. The live runbook is [`bench/BREV.md`](bench/BREV.md).
|
|
35
|
+
|
|
36
|
+
## How it works
|
|
37
|
+
|
|
38
|
+
| Mechanism | What it does | DSH hook | Ledger record |
|
|
39
|
+
|---|---|---|---|
|
|
40
|
+
| Cascade | A verifier command (test runner, type checker, linter) that fails moves the next model call to the large tier. Passes twice in a row, after a minimum number of calls, move it back. No judge model is called. | `agent/request` (route), `tools/post-execute` (outcome) | `route`, `request` |
|
|
41
|
+
| Distillation | Bash output keeps failure, summary and safety lines plus nearby context. It drops progress frames and library stack frames, and collapses repeated lines. The full output goes to the spill store, and the model gets a pointer to it. | `tools/post-execute` | `tool` with action `distill` |
|
|
42
|
+
| Read dedupe | A read whose bytes match an earlier read still in context returns a pointer instead of the file again. An edit or write forgets the path, and compaction forgets everything. | `tools/post-execute` | `tool` with action `dedupe` |
|
|
43
|
+
| Loop breaker | A bash command that failed `threshold` times in a row with the same failure, and no file change since, is denied with a reason. The tier goes to large. | `tools/pre-execute` | `loop-break` |
|
|
44
|
+
|
|
45
|
+
### Hook facts that shaped the design
|
|
46
|
+
|
|
47
|
+
These come from DSH 0.2.1-rc.2's typings and source.
|
|
48
|
+
|
|
49
|
+
- **Routing** uses the `agent/request` waterfall. A listener that calls `await next()` receives the config DSH would use, and it returns a replacement to switch the model. DSH seeds each call from the last logged request header, so the route is applied on every call.
|
|
50
|
+
- **Notice removal.** DSH adds a "model changed" notice to the conversation whenever its default selection differs from the last request. Per-step routing triggers that on every large step, and the notice names the wrong model. GreenHarness removes it. Its listener is attached to each agent at `agent/created`, with `prepend`, so it runs outside DSH's own notice listener.
|
|
51
|
+
- **No `agent.model.select()`.** An earlier build called it. The call threw, the error was swallowed, and the unit tests passed only because they used a fake. Plugin failures are now written to the ledger as `error` records.
|
|
52
|
+
|
|
53
|
+
## Configuration
|
|
54
|
+
|
|
55
|
+
Settings are validated when the plugin loads. A missing tier, a bad integer or an invalid regex fails loudly.
|
|
56
|
+
|
|
57
|
+
| Key | Default | Meaning |
|
|
58
|
+
|---|---|---|
|
|
59
|
+
| `tiers.small`, `tiers.large` | required | `provider`, `model`, and `reasoningEffort` (empty means the provider default). |
|
|
60
|
+
| `cascade.escalateAfterFailures` | 1 | Consecutive verifier failures before moving to large. |
|
|
61
|
+
| `cascade.deescalateAfterPasses` | 2 | Consecutive verifier passes before moving back to small. |
|
|
62
|
+
| `cascade.minStepsPerTier` | 2 | Minimum model calls in a tier before it can change. |
|
|
63
|
+
| `verifier.patterns` | test runners, type checkers, linters | Regexes that mark a bash command as a verifier. |
|
|
64
|
+
| `distill.enabled`, `maxChars`, `contextLines` | true, 6000, 2 | Distillation switch, output budget (at least 500), and context lines around failures. |
|
|
65
|
+
| `readDedupe.enabled` | true | Read dedupe switch. |
|
|
66
|
+
| `loopBreaker.enabled`, `threshold` | true, 3 | Loop breaker switch and repeat count (at least 2). |
|
|
67
|
+
| `ledger.path`, `ledger.pricing` | `.greenharness/ledger.jsonl`, none | Where the JSONL ledger goes, and optional per-provider prices in dollars per million tokens. |
|
|
68
|
+
|
|
69
|
+
The routes themselves (provider URL, model ids, thinking switch) live in the patch overlay. `bench/presets/` shows the format.
|
|
70
|
+
|
|
71
|
+
## Develop and benchmark
|
|
72
|
+
|
|
73
|
+
Everything below is for working on the plugin or running the benchmark; ordinary use needs only the quick install above.
|
|
74
|
+
|
|
75
|
+
Requirements: Node.js 22.19 or newer (the highest minimum any DSH package declares; the mock benchmark ran on Node 24.22) and DSH `0.2.1-rc.2`. Install DSH in its own folder with `npm install @deepseek-ai/dsh@0.2.1-rc.2` (about two minutes here), then point `DSH_BIN` at its `node_modules/.bin/dsh`.
|
|
76
|
+
|
|
77
|
+
1. `npm install` in this folder. It also installs the plugin's two peer dependencies, `@deepseek-ai/cordis` and `@deepseek-ai/schemastery`, at the versions DSH uses. Run it first: without it the plugin does not load.
|
|
78
|
+
2. Unit tests: `npm test` (59 tests).
|
|
79
|
+
3. Mock suite (every scenario under every arm, about 35 seconds): `DSH_BIN=<dsh> node bench/run-mock-suite.mjs`
|
|
80
|
+
4. One case: `DSH_BIN=<dsh> node bench/run-case.mjs --task subtract-bug --arm greenharness --scenario subtract-fix --out runs/one`
|
|
81
|
+
5. Live NeMo models on Brev: [`bench/BREV.md`](bench/BREV.md).
|
|
82
|
+
|
|
83
|
+
Each case copies the task to a fresh workspace and starts a fresh DSH home. The verdict comes from re-running `node --test` in the workspace, not from the agent's claim. Each case writes `summary.json`, `ledger.jsonl`, `patch.yml`, `stdout.txt`, `stderr.txt` and the workspace. If something went wrong (DSH did not start or exited with an error, or GreenHarness recorded no model calls), `summary.json` lists it under `problems`, the reason goes to stderr, and the command exits with code 1.
|
|
84
|
+
|
|
85
|
+
## Metrics
|
|
86
|
+
|
|
87
|
+
- **Model calls per tier**: `ledger.tiers`, `ledger.models`.
|
|
88
|
+
- **Prompt tokens**: `inputTokens` (uncached) plus `cacheReadTokens` plus `cacheWriteTokens`. Output tokens are reported separately.
|
|
89
|
+
- **Tool output**: `rawChars` against `outChars` on each `tool` record. Distillation and dedupe show up here.
|
|
90
|
+
- **Verdict**: the independent suite re-run (`suite.pass`, `suite.fail`).
|
|
91
|
+
- **Cost**: tokens times the configured price, or GPU-hours times an hourly rate. Not automated for self-hosted GPUs.
|
|
92
|
+
|
|
93
|
+
## Results so far (mock, plumbing only)
|
|
94
|
+
|
|
95
|
+
These come from `bench/run-mock-suite.mjs`. The mock replays a fixed script, so these numbers show what the plugin changes in the prompt. They say nothing about a real model's pass rate. Token counts are characters divided by four, over the message history only. Token estimates shift by a few percent between installs and folder layouts, so compare the rows, tier splits and pass/fail results rather than the last digit.
|
|
96
|
+
|
|
97
|
+
| scenario | arm | agent calls | served by | prompt tokens (est.) | cached (est.) | suite | distilled | deduped | route changes | loop denied | errors |
|
|
98
|
+
|---|---|---:|---|---:|---:|---|---:|---:|---:|---|---:|
|
|
99
|
+
| loop-stuck | greenharness | 6 | 1 small / 5 large | 9269 | 6244 | 301 pass / 1 fail | 3 | 0 | 1 | yes | 0 |
|
|
100
|
+
| loop-stuck | always-large | 6 | 0 small / 6 large | 46520 | 30947 | 301 pass / 1 fail | 0 | 0 | 0 | no | 0 |
|
|
101
|
+
| loop-stuck | always-small | 6 | 6 small / 0 large | 46520 | 30952 | 301 pass / 1 fail | 0 | 0 | 0 | no | 0 |
|
|
102
|
+
| subtract-fix | greenharness | 7 | 2 small / 5 large | 10064 | 5523 | 302 pass / 0 fail | 3 | 1 | 2 | no | 0 |
|
|
103
|
+
| subtract-fix | always-large | 7 | 0 small / 7 large | 32024 | 22068 | 302 pass / 0 fail | 0 | 0 | 0 | no | 0 |
|
|
104
|
+
| subtract-fix | always-small | 7 | 7 small / 0 large | 32023 | 22073 | 302 pass / 0 fail | 0 | 0 | 0 | no | 0 |
|
|
105
|
+
|
|
106
|
+
In the subtract scenario, GreenHarness sent about 3.2 times fewer estimated prompt tokens than either single-model arm. In the loop scenario, it denied the repeats after three identical failures. The `always-*` arms have no verifier and no loop breaker, so they always run the same script to the end.
|
|
107
|
+
|
|
108
|
+
## Limitations
|
|
109
|
+
|
|
110
|
+
- **No real model yet.** Routing and the thinking switch are verified against the mock only. The thinking switch's wire format is confirmed; whether the NeMo server honours it is not (see the toggle test in `bench/BREV.md`).
|
|
111
|
+
- **Escalation on the first failure.** A failing task spends most of its calls on the large tier. Savings come from passing runs staying small and from smaller tool output on every tier. `escalateAfterFailures` and `minStepsPerTier` are the first knobs to tune.
|
|
112
|
+
- **Cold caches after a switch.** The first call on a tier after a switch pays a full prefill, because that model's prefix cache is cold. The mock models caches per model, but real servers will differ.
|
|
113
|
+
- **Side request.** Every run has one tools-free request, sent right after the first agent call. The mock logs it as a side call. The ledger does not record it, so its tokens are missing from the ledger totals. It ran on the default route, before the first escalation in every run. Whether GreenHarness routes it has not been checked.
|
|
114
|
+
- **Distillation can hide a line the model needed.** The full output is one retrieval away, but the model has to ask for it.
|
|
115
|
+
- **One task so far.** `subtract-bug` is one failing test plus 300 passing filler tests. The 20–30 task set from the deck is not built.
|
|
116
|
+
- **Developer preview.** DSH 0.2.1-rc.2 is pre-release. The hook names above come from its typings in this version and may change.
|
|
117
|
+
- **Bolt E4B is unverified.** Its model id, serve command and thinking switch are not confirmed (see `bench/BREV.md`).
|
|
118
|
+
|
|
119
|
+
## Compared with NeMo Switchyard
|
|
120
|
+
|
|
121
|
+
As its README describes it, Switchyard is a routing proxy on port 4000. It decides per request and per step with a classifier, and it escalates with an LLM judge.
|
|
122
|
+
|
|
123
|
+
GreenHarness differs in four ways:
|
|
124
|
+
|
|
125
|
+
- **Verifier, not judge.** It sits inside the agent and reads the result of the test or compiler run, so escalation costs no extra model call.
|
|
126
|
+
- **Compress once.** Distillation and dedupe act on tool results when they are written. A proxy sees the whole request on every call, so it would have to rewrite history it already sent, which breaks the prompt cache prefix.
|
|
127
|
+
- **Session state.** The loop breaker keys on command fingerprints and resets on file changes. A stateless proxy does not hold that state.
|
|
128
|
+
- **Host-bound.** It runs only inside DSH. Switchyard works with any client that speaks the API.
|
|
129
|
+
|
|
130
|
+
## Files
|
|
131
|
+
|
|
132
|
+
- `GETTING-STARTED.md`: step by step, for someone new to terminals and agents.
|
|
133
|
+
- `src/index.mjs`: the Cordis plugin. Config schema, hook wiring, error reporting.
|
|
134
|
+
- `src/control.mjs`: the `/greenharness` command (parsing and replies).
|
|
135
|
+
- `src/runtime.mjs`: cascade, distillation, dedupe, loop breaker and ledger records. No DSH imports.
|
|
136
|
+
- `src/core/`: pure logic. `cascade`, `verify`, `distill`, `patterns`, `read-index`, `loop-guard`, `route`, `ledger`, `pricing`.
|
|
137
|
+
- `src/config.mjs`: settings normalization and validation.
|
|
138
|
+
- `test/`: 59 unit tests.
|
|
139
|
+
- `bench/run-case.mjs`: one case (task, arm, preset) through DSH headless, with the independent re-run and `summary.json`.
|
|
140
|
+
- `bench/run-mock-suite.mjs`: every scenario under every arm, as one table.
|
|
141
|
+
- `bench/mock-openai-server.mjs`: scripted OpenAI-compatible server. It logs what each request contained.
|
|
142
|
+
- `bench/presets/`: `mock`, `mock-chat-template`, `nemo-brev`.
|
|
143
|
+
- `bench/scenarios/`: scripts for the mock. `bench/tasks/subtract-bug/`: the fixture.
|
|
144
|
+
- `bench/BREV.md`: live runbook, with what is verified and what is not.
|
|
145
|
+
|
|
146
|
+
## License
|
|
147
|
+
|
|
148
|
+
MIT. Add a LICENSE file before publishing.
|
package/bench/BREV.md
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
# Running GreenHarness against NeMo models on Brev
|
|
2
|
+
|
|
3
|
+
This runbook is the live half of the benchmark. The mock runs in `bench/run-mock-suite.mjs` check plumbing only.
|
|
4
|
+
|
|
5
|
+
## What is verified and what is not
|
|
6
|
+
|
|
7
|
+
| Item | Status |
|
|
8
|
+
|---|---|
|
|
9
|
+
| Plugin routing, distillation, read dedupe, loop breaker | Verified against the scripted mock (`npm run bench:mock`, 47 unit tests). |
|
|
10
|
+
| Thinking switch sent to the server | Verified in the request body the mock received: `chat_template_kwargs.enable_thinking` is `false` on the small tier and `true` on the large tier. Whether the NeMo server honours it is **not** verified (step 4). |
|
|
11
|
+
| Lightning 30B-A3B serve command | Taken from the vLLM Day-0 post (2026-08-10). The post says it assumes one H100. Not run here. |
|
|
12
|
+
| Bolt E4B model id, serve command, thinking switch | **Not verified.** One source we read (DataLearner, dated 2026-10-19) says the model was not yet released, names no repo or serve command, and lists no thinking mode. Another note from earlier research says its thinking budget is configurable. Check the model card before you trust the small tier's numbers. |
|
|
13
|
+
| Prices | Not set. The ledger records tokens; cost needs prices (see step 6). |
|
|
14
|
+
|
|
15
|
+
No NeMo model was run in this session. Nothing below has been measured on NeMo.
|
|
16
|
+
|
|
17
|
+
## 1. Instance
|
|
18
|
+
|
|
19
|
+
Start a Brev instance with one H100 80 GB, or an equivalent card, for the large tier. Connect:
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
brev shell <instance>
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Check the current CLI syntax with `brev --help` and `brev port-forward --help`.
|
|
26
|
+
|
|
27
|
+
## 2. Large tier (port 8000): Nemotron 3.5 Lightning 30B-A3B, BF16
|
|
28
|
+
|
|
29
|
+
Serve command, copied from the vLLM Day-0 post:
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
vllm serve nvidia/NVIDIA-Nemotron-3.5-Lightning-30B-A3B-BF16 \
|
|
33
|
+
--max-num-seqs 256 \
|
|
34
|
+
--max-num-batched-tokens 32768 \
|
|
35
|
+
--enable-prefix-caching \
|
|
36
|
+
--async-scheduling \
|
|
37
|
+
--mamba-backend flashinfer \
|
|
38
|
+
--moe-backend humming \
|
|
39
|
+
--linear-backend humming \
|
|
40
|
+
--mamba-ssu-algorithm horizontal \
|
|
41
|
+
--mamba-cache-mode align \
|
|
42
|
+
--mamba-ssm-cache-dtype float16 \
|
|
43
|
+
--enable-mamba-cache-stochastic-rounding \
|
|
44
|
+
--mamba-cache-philox-rounds 5 \
|
|
45
|
+
--reasoning-parser nemotron_v3 \
|
|
46
|
+
--tool-call-parser qwen3_coder \
|
|
47
|
+
--enable-auto-tool-choice \
|
|
48
|
+
--host 0.0.0.0 \
|
|
49
|
+
--port 8000
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
Use a vLLM release from 2026-08-10 or later. The NVFP4 build (`nvidia/NVIDIA-Nemotron-3.5-Lightning-30B-A3B-NVFP4`) is smaller, but check that your card supports its kernels before you use it.
|
|
53
|
+
|
|
54
|
+
## 3. Small tier (port 8001): Nemotron 3.5 Bolt E4B
|
|
55
|
+
|
|
56
|
+
**Not verified.** Get the model id and serve command from the model card once it is published, then run it on port 8001. The preset assumes:
|
|
57
|
+
|
|
58
|
+
- model id `nvidia/NVIDIA-Nemotron-3.5-Bolt-E4B-4GB`
|
|
59
|
+
- an OpenAI-compatible endpoint at `http://localhost:8001/v1`
|
|
60
|
+
|
|
61
|
+
If the card gives a vLLM command, use the same pattern as step 2 with `--port 8001`. If it ships only as GGUF, serve it with llama.cpp's OpenAI-compatible server (`llama-server`) on port 8001 and check the model name it reports.
|
|
62
|
+
|
|
63
|
+
Co-hosting both on one 80 GB card is possible only if you cap each server's memory (`--gpu-memory-utilization`, for example 0.80 for the large tier and what is left for the small one). Test it; otherwise use a second instance.
|
|
64
|
+
|
|
65
|
+
## 4. Toggle test (do this before any benchmark)
|
|
66
|
+
|
|
67
|
+
The preset turns the thinking switch on or off per tier with `chat_template_kwargs.enable_thinking`. Confirm the server honours it on each endpoint: the same prompt should produce a reasoning trace only when the flag is `true`.
|
|
68
|
+
|
|
69
|
+
```bash
|
|
70
|
+
for flag in true false; do
|
|
71
|
+
curl -s http://localhost:8000/v1/chat/completions -H 'content-type: application/json' -d "{
|
|
72
|
+
\"model\": \"nvidia/NVIDIA-Nemotron-3.5-Lightning-30B-A3B-BF16\",
|
|
73
|
+
\"messages\": [{\"role\": \"user\", \"content\": \"What is 17 times 23? Answer with the number only.\"}],
|
|
74
|
+
\"chat_template_kwargs\": {\"enable_thinking\": $flag}, \"max_tokens\": 800
|
|
75
|
+
}" | head -c 1500; echo; echo ---
|
|
76
|
+
done
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
Compare `completion_tokens` and any reasoning field in the two replies. If the flag makes no difference, the server ignores it: change the `compat` block in `bench/presets/nemo-brev.json` before you benchmark. Repeat the test on port 8001 for the small model.
|
|
80
|
+
|
|
81
|
+
## 5. Port-forward to this machine
|
|
82
|
+
|
|
83
|
+
```bash
|
|
84
|
+
brev port-forward <instance> --port 8000:8000
|
|
85
|
+
brev port-forward <instance> --port 8001:8001
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
Check that `curl http://127.0.0.1:8000/v1/models` lists the large model.
|
|
89
|
+
|
|
90
|
+
## 6. Run the three arms
|
|
91
|
+
|
|
92
|
+
Install the plugin's dependencies and DSH first (see the README), then:
|
|
93
|
+
|
|
94
|
+
```bash
|
|
95
|
+
export DSH_BIN=dsh # or the path to your dsh binary
|
|
96
|
+
export NEMO_API_KEY=unused # local vLLM does not check keys
|
|
97
|
+
node bench/run-case.mjs --task subtract-bug --arm greenharness --preset nemo-brev --out runs/brev/greenharness
|
|
98
|
+
node bench/run-case.mjs --task subtract-bug --arm always-large --preset nemo-brev --out runs/brev/always-large
|
|
99
|
+
node bench/run-case.mjs --task subtract-bug --arm always-small --preset nemo-brev --out runs/brev/always-small
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
Each run writes `summary.json`. Read these fields:
|
|
103
|
+
|
|
104
|
+
- `suite.pass` / `suite.fail`: an independent `node --test` re-run in the workspace. This is the verdict.
|
|
105
|
+
- `ledger.tiers` and `ledger.models`: calls per tier.
|
|
106
|
+
- `ledger.usage`: `inputTokens` (uncached), `cacheReadTokens`, `cacheWriteTokens`, `outputTokens`. Prompt tokens are the sum of the first three.
|
|
107
|
+
- `ledger.tool_actions`: how often distillation and dedupe fired.
|
|
108
|
+
- `ledger.route_changes`: each tier change and its reason.
|
|
109
|
+
- `problems`: anything that went wrong (DSH failed, or the plugin recorded no model calls). Read it first; an empty list means the run is usable.
|
|
110
|
+
- `wallSeconds`: whole-run wall time.
|
|
111
|
+
|
|
112
|
+
Run each arm at least three times. Model output varies between runs.
|
|
113
|
+
|
|
114
|
+
## 7. Cost
|
|
115
|
+
|
|
116
|
+
The ledger records tokens, not money. You can get money in one of two ways:
|
|
117
|
+
|
|
118
|
+
- Per-token prices: set `ledger.pricing` in the plugin config (`inputPerMTok`, `cachedInputPerMTok`, `outputPerMTok`, keyed by provider name). `costUsd` in each request record then follows.
|
|
119
|
+
- Self-hosted GPU time: multiply each arm's wall time by your instance's hourly rate, then compare arms. This does not split time by tier. Do that by reading the vLLM request log, which is not automated here.
|
|
120
|
+
|
|
121
|
+
Report which of the two you used. Neither price source has been checked for these models.
|
|
122
|
+
|
|
123
|
+
## Troubleshooting
|
|
124
|
+
|
|
125
|
+
- `TRANSPORT: Connection error` in `stderr.txt`: the port-forward is down, or the server is not up.
|
|
126
|
+
- A `greenharness:` error in `ledger.jsonl` (`"type":"error"`): the plugin caught a failure in one hook. The message says which.
|
|
127
|
+
- Suite fails with `not run` in the table: the summary was not written. Read `stderr.txt` in the run folder.
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Renders the DSH patch overlay for one benchmark variant. The overlay is JSON, which is valid
|
|
3
|
+
* YAML, so DSH's loader reads it with `--patch`. It mounts the OpenAI-compatible model routes
|
|
4
|
+
* (`@deepseek-ai/dsh-llm-pi-ai`), points the default agent at the small tier, and mounts
|
|
5
|
+
* GreenHarness with the tier map. A baseline variant omits the GreenHarness row, so the same
|
|
6
|
+
* routes serve both arms of the comparison.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* @typedef {{ id: string, name?: string, contextWindow?: number, reasoningEfforts?: Record<string, string | null> }} RouteModel
|
|
11
|
+
* @typedef {{ baseURL: string, apiKeyEnv?: string, api?: string, compat?: Record<string, unknown>, defaultMaxTokens?: number, displayName?: string, models: RouteModel[] }} RouteSpec
|
|
12
|
+
* @typedef {{ provider: string, model: string, reasoningEffort?: string }} TierSpec
|
|
13
|
+
*
|
|
14
|
+
* @param {object} options
|
|
15
|
+
* @param {Record<string, RouteSpec>} options.routes - model routes keyed by route name.
|
|
16
|
+
* @param {{ small: TierSpec, large: TierSpec }} options.tiers - the cascade's two tiers.
|
|
17
|
+
* @param {boolean} options.greenharness - mount the plugin (false for the baseline arm).
|
|
18
|
+
* @param {string} [options.pluginEntry] - absolute path of the plugin's src/index.mjs (required when greenharness is true).
|
|
19
|
+
* @param {string} [options.ledgerPath] - absolute path of the JSONL ledger for this run.
|
|
20
|
+
* @param {Record<string, unknown>} [options.pluginOptions] - extra GreenHarness config (cascade, distill, ...).
|
|
21
|
+
* @returns {string} the overlay text.
|
|
22
|
+
*/
|
|
23
|
+
export function renderPatch({ routes, tiers, greenharness, pluginEntry, ledgerPath, pluginOptions = {} }) {
|
|
24
|
+
const rows = [
|
|
25
|
+
// `llm-pi-ai` is already mounted by the base bundle; this row configures that instance.
|
|
26
|
+
// Self-hosted OpenAI-compatible servers ignore the key unless one is configured, but pi-ai
|
|
27
|
+
// requires a key reference on hand-declared routes, so each route names one.
|
|
28
|
+
{ id: 'llm-pi-ai', config: { providers: Object.fromEntries(Object.entries(routes).map(([name, route]) => [name, { apiKeyEnv: 'NEMO_API_KEY', ...route }])) } },
|
|
29
|
+
{
|
|
30
|
+
id: 'agent-default-model',
|
|
31
|
+
config: { provider: tiers.small.provider, model: tiers.small.model },
|
|
32
|
+
},
|
|
33
|
+
]
|
|
34
|
+
if (greenharness) {
|
|
35
|
+
if (!pluginEntry) throw new Error('renderPatch: pluginEntry (absolute path to src/index.mjs) is required when greenharness is true')
|
|
36
|
+
rows.push({
|
|
37
|
+
insert: [{
|
|
38
|
+
id: 'greenharness',
|
|
39
|
+
name: pluginEntry,
|
|
40
|
+
config: {
|
|
41
|
+
tiers: { small: tiers.small, large: tiers.large },
|
|
42
|
+
ledger: { path: ledgerPath ?? '.greenharness/ledger.jsonl', ...(pluginOptions.ledger ?? {}) },
|
|
43
|
+
...withoutKey(pluginOptions, 'ledger'),
|
|
44
|
+
},
|
|
45
|
+
}],
|
|
46
|
+
})
|
|
47
|
+
}
|
|
48
|
+
return `${JSON.stringify(rows, null, 2)}\n`
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* @template T
|
|
53
|
+
* @param {Record<string, T>} object
|
|
54
|
+
* @param {string} key
|
|
55
|
+
*/
|
|
56
|
+
function withoutKey(object, key) {
|
|
57
|
+
const copy = { ...object }
|
|
58
|
+
delete copy[key]
|
|
59
|
+
return copy
|
|
60
|
+
}
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Scripted OpenAI-compatible chat-completions server for offline tests.
|
|
4
|
+
*
|
|
5
|
+
* It replays a fixed list of assistant steps (tool calls or text), one per agent call, and logs
|
|
6
|
+
* every request so a test can check which model served it, what tool results the model saw,
|
|
7
|
+
* and whether the shared prompt prefix would have hit a KV/prompt cache. Calls without tools
|
|
8
|
+
* (side calls such as title generation) get a fixed reply and do not consume a step. Usage is
|
|
9
|
+
* estimated from the serialized message history (about four characters per token). It is a
|
|
10
|
+
* plumbing double, not a model: use it to verify routing and context edits, never to measure
|
|
11
|
+
* quality.
|
|
12
|
+
*
|
|
13
|
+
* Usage: node bench/mock-openai-server.mjs --script <steps.json> --log <requests.jsonl> [--port 8123]
|
|
14
|
+
*/
|
|
15
|
+
import { createServer } from 'node:http'
|
|
16
|
+
import { appendFileSync, mkdirSync, readFileSync } from 'node:fs'
|
|
17
|
+
import { dirname } from 'node:path'
|
|
18
|
+
import { fileURLToPath } from 'node:url'
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* @typedef {{ tool_calls?: Array<{ name: string, arguments: Record<string, unknown> }>, content?: string }} Step
|
|
22
|
+
* @typedef {{ steps: Step[] }} Script
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Start the server.
|
|
27
|
+
* @param {{ port?: number, steps: Step[], logPath?: string }} options
|
|
28
|
+
* @returns {Promise<{ url: string, close(): Promise<void>, requests: number }>}
|
|
29
|
+
*/
|
|
30
|
+
export function startMockServer({ port = 0, steps, logPath }) {
|
|
31
|
+
let requestCount = 0
|
|
32
|
+
let agentCalls = 0
|
|
33
|
+
const previousMessages = new Map()
|
|
34
|
+
|
|
35
|
+
const server = createServer(async (req, res) => {
|
|
36
|
+
if (req.method !== 'POST' || !req.url?.endsWith('/chat/completions')) {
|
|
37
|
+
res.writeHead(404, { 'content-type': 'application/json' })
|
|
38
|
+
res.end(JSON.stringify({ error: 'mock only serves POST /v1/chat/completions' }))
|
|
39
|
+
return
|
|
40
|
+
}
|
|
41
|
+
const body = JSON.parse(await readBody(req))
|
|
42
|
+
requestCount += 1
|
|
43
|
+
const index = requestCount - 1
|
|
44
|
+
// Agent calls declare tools; side calls (for example, title generation) do not. Only agent
|
|
45
|
+
// calls consume scripted steps, so a side call cannot shift the script.
|
|
46
|
+
const agentCall = Array.isArray(body.tools) && body.tools.length > 0
|
|
47
|
+
const agentStep = agentCall ? agentCalls : null
|
|
48
|
+
if (agentCall) agentCalls += 1
|
|
49
|
+
const step = agentCall
|
|
50
|
+
? (agentStep < steps.length ? steps[agentStep] : { content: 'Done.' })
|
|
51
|
+
: { content: 'ok' }
|
|
52
|
+
const toolCalls = (step.tool_calls ?? []).map((call, i) => ({
|
|
53
|
+
id: `call_${index}_${i}`,
|
|
54
|
+
name: call.name,
|
|
55
|
+
arguments: JSON.stringify(call.arguments ?? {}),
|
|
56
|
+
}))
|
|
57
|
+
|
|
58
|
+
const serialized = JSON.stringify(body.messages ?? [])
|
|
59
|
+
const model = String(body.model ?? 'unknown')
|
|
60
|
+
const previous = previousMessages.get(model) ?? ''
|
|
61
|
+
const promptTokens = Math.ceil(serialized.length / 4)
|
|
62
|
+
const cachedTokens = Math.ceil(commonPrefixLength(previous, serialized) / 4)
|
|
63
|
+
previousMessages.set(model, serialized)
|
|
64
|
+
const completionText = toolCalls.length > 0 ? JSON.stringify(toolCalls.map(c => c.name)) : (step.content ?? '')
|
|
65
|
+
const completionTokens = Math.max(1, Math.ceil(completionText.length / 4))
|
|
66
|
+
const usage = {
|
|
67
|
+
prompt_tokens: promptTokens,
|
|
68
|
+
completion_tokens: completionTokens,
|
|
69
|
+
total_tokens: promptTokens + completionTokens,
|
|
70
|
+
prompt_tokens_details: { cached_tokens: cachedTokens },
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const messages = body.messages ?? []
|
|
74
|
+
logRequest(logPath, {
|
|
75
|
+
n: requestCount, agentCall, agentStep: agentCall ? agentStep + 1 : null,
|
|
76
|
+
model, stream: body.stream === true, messageCount: messages.length,
|
|
77
|
+
roles: messages.map(m => m.role),
|
|
78
|
+
noticeCount: messages.filter(m => contentToText(m.content).includes('[model changed:')).length,
|
|
79
|
+
promptTokens, cachedTokens, bodyKeys: Object.keys(body).sort(),
|
|
80
|
+
chatTemplateKwargs: body.chat_template_kwargs ?? null,
|
|
81
|
+
reasoningEffort: body.reasoning_effort ?? body.reasoning?.effort ?? null,
|
|
82
|
+
thinkingBudget: body.thinking_token_budget ?? body.thinking_budget ?? null,
|
|
83
|
+
toolMessages: messages.filter(m => m.role === 'tool').map(m => contentToText(m.content)),
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
const finish = toolCalls.length > 0 ? 'tool_calls' : 'stop'
|
|
87
|
+
if (body.stream !== true) {
|
|
88
|
+
res.writeHead(200, { 'content-type': 'application/json' })
|
|
89
|
+
res.end(JSON.stringify({
|
|
90
|
+
id: `chatcmpl-${index}`, object: 'chat.completion', created: now(), model,
|
|
91
|
+
choices: [{
|
|
92
|
+
index: 0, finish_reason: finish,
|
|
93
|
+
message: {
|
|
94
|
+
role: 'assistant', content: step.content ?? null,
|
|
95
|
+
...(toolCalls.length > 0 ? { tool_calls: toolCalls.map(toOpenAiToolCall) } : {}),
|
|
96
|
+
},
|
|
97
|
+
}],
|
|
98
|
+
usage,
|
|
99
|
+
}))
|
|
100
|
+
return
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const base = { id: `chatcmpl-${index}`, object: 'chat.completion.chunk', created: now(), model }
|
|
104
|
+
const chunks = [{ ...base, choices: [{ index: 0, delta: { role: 'assistant', content: '' }, finish_reason: null }] }]
|
|
105
|
+
if (step.content) chunks.push({ ...base, choices: [{ index: 0, delta: { content: step.content }, finish_reason: null }] })
|
|
106
|
+
toolCalls.forEach((call, i) => chunks.push({
|
|
107
|
+
...base,
|
|
108
|
+
choices: [{ index: 0, delta: { tool_calls: [{ index: i, ...toOpenAiToolCall(call) }] }, finish_reason: null }],
|
|
109
|
+
}))
|
|
110
|
+
chunks.push({ ...base, choices: [{ index: 0, delta: {}, finish_reason: finish }] })
|
|
111
|
+
chunks.push({ ...base, choices: [], usage })
|
|
112
|
+
|
|
113
|
+
res.writeHead(200, { 'content-type': 'text/event-stream', 'cache-control': 'no-cache', connection: 'keep-alive' })
|
|
114
|
+
for (const chunk of chunks) res.write(`data: ${JSON.stringify(chunk)}\n\n`)
|
|
115
|
+
res.write('data: [DONE]\n\n')
|
|
116
|
+
res.end()
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
return new Promise((resolve) => {
|
|
120
|
+
server.listen(port, '127.0.0.1', () => {
|
|
121
|
+
const address = server.address()
|
|
122
|
+
const actualPort = typeof address === 'object' && address !== null ? address.port : port
|
|
123
|
+
resolve({
|
|
124
|
+
url: `http://127.0.0.1:${actualPort}/v1`,
|
|
125
|
+
get requests() { return requestCount },
|
|
126
|
+
close: () => new Promise((done) => {
|
|
127
|
+
server.closeAllConnections?.()
|
|
128
|
+
server.close(() => done())
|
|
129
|
+
}),
|
|
130
|
+
})
|
|
131
|
+
})
|
|
132
|
+
})
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* @param {{ id: string, name: string, arguments: string }} call
|
|
137
|
+
*/
|
|
138
|
+
function toOpenAiToolCall(call) {
|
|
139
|
+
return { id: call.id, type: 'function', function: { name: call.name, arguments: call.arguments } }
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* @param {import('node:http').IncomingMessage} req
|
|
144
|
+
* @returns {Promise<string>}
|
|
145
|
+
*/
|
|
146
|
+
function readBody(req) {
|
|
147
|
+
return new Promise((resolve, reject) => {
|
|
148
|
+
let data = ''
|
|
149
|
+
req.setEncoding('utf8')
|
|
150
|
+
req.on('data', (chunk) => { data += chunk })
|
|
151
|
+
req.on('end', () => resolve(data))
|
|
152
|
+
req.on('error', reject)
|
|
153
|
+
})
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* @param {string} a
|
|
158
|
+
* @param {string} b
|
|
159
|
+
*/
|
|
160
|
+
function commonPrefixLength(a, b) {
|
|
161
|
+
const limit = Math.min(a.length, b.length)
|
|
162
|
+
let i = 0
|
|
163
|
+
while (i < limit && a.charCodeAt(i) === b.charCodeAt(i)) i += 1
|
|
164
|
+
return i
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* @param {unknown} content
|
|
169
|
+
*/
|
|
170
|
+
function contentToText(content) {
|
|
171
|
+
if (typeof content === 'string') return content
|
|
172
|
+
if (Array.isArray(content)) return content.map(part => (part && typeof part === 'object' && 'text' in part ? String(part.text) : '')).join('')
|
|
173
|
+
return ''
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* @param {string | undefined} path
|
|
178
|
+
* @param {Record<string, unknown>} record
|
|
179
|
+
*/
|
|
180
|
+
function logRequest(path, record) {
|
|
181
|
+
if (!path) return
|
|
182
|
+
mkdirSync(dirname(path), { recursive: true })
|
|
183
|
+
appendFileSync(path, `${JSON.stringify({ ts: new Date().toISOString(), ...record })}\n`)
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function now() {
|
|
187
|
+
return Math.floor(Date.now() / 1000)
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* @param {string[]} argv
|
|
192
|
+
*/
|
|
193
|
+
function parseArgs(argv) {
|
|
194
|
+
const options = { port: 8123, script: undefined, log: undefined }
|
|
195
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
196
|
+
if (argv[i] === '--port') options.port = Number(argv[++i])
|
|
197
|
+
else if (argv[i] === '--script') options.script = argv[++i]
|
|
198
|
+
else if (argv[i] === '--log') options.log = argv[++i]
|
|
199
|
+
}
|
|
200
|
+
return options
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const isMain = process.argv[1] !== undefined && fileURLToPath(import.meta.url) === process.argv[1]
|
|
204
|
+
if (isMain) {
|
|
205
|
+
const options = parseArgs(process.argv.slice(2))
|
|
206
|
+
/** @type {Script} */
|
|
207
|
+
const script = options.script ? JSON.parse(readFileSync(options.script, 'utf8')) : { steps: [] }
|
|
208
|
+
const server = await startMockServer({ port: options.port, steps: script.steps, logPath: options.log })
|
|
209
|
+
console.log(`mock OpenAI server on ${server.url} with ${script.steps.length} scripted steps`)
|
|
210
|
+
}
|