dsh-model-arena 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/CHANGELOG.md +5 -0
- package/LICENSE +21 -0
- package/README.md +29 -0
- package/cordis.patch.yml +4 -0
- package/lib/arena-view.d.ts +6 -0
- package/lib/arena-view.js +17 -0
- package/lib/arena.d.ts +36 -0
- package/lib/arena.js +87 -0
- package/lib/arena.web.js +29 -0
- package/lib/bin.d.ts +2 -0
- package/lib/bin.js +3 -0
- package/lib/cli.d.ts +1 -0
- package/lib/cli.js +70 -0
- package/lib/client/index.d.ts +4 -0
- package/lib/client/index.js +16 -0
- package/lib/client/view.d.ts +2 -0
- package/lib/client/view.js +9 -0
- package/lib/diff.d.ts +11 -0
- package/lib/diff.js +32 -0
- package/lib/index.d.ts +24 -0
- package/lib/index.js +19 -0
- package/lib/routes.d.ts +5 -0
- package/lib/routes.js +51 -0
- package/lib/scoring.d.ts +5 -0
- package/lib/scoring.js +35 -0
- package/lib/store.d.ts +13 -0
- package/lib/store.js +50 -0
- package/lib/types.d.ts +46 -0
- package/lib/types.js +2 -0
- package/package.json +80 -0
package/CHANGELOG.md
ADDED
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 hj01857655
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# dsh-model-arena
|
|
2
|
+
|
|
3
|
+
Run the same prompt through every model you have, and see the difference without squinting.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```sh
|
|
8
|
+
dsh plugin --profile web add dsh-model-arena
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## What it does
|
|
12
|
+
|
|
13
|
+
- **Run a prompt through N models.** Same prompt, identical parameters, outputs collected side by side.
|
|
14
|
+
- **Compare.** Line-level diff between any two model outputs.
|
|
15
|
+
- **Score.** Auto-scorer (length, latency, reference match) + manual user ratings (1-5).
|
|
16
|
+
- **Benchmark suites.** Save a named set of prompts + models, re-run after changes, get a regression report.
|
|
17
|
+
- **Panel.** Settings page listing recent runs with drill-down to comparison view.
|
|
18
|
+
|
|
19
|
+
## CLI
|
|
20
|
+
|
|
21
|
+
```sh
|
|
22
|
+
dsh-model-arena list # list recent runs
|
|
23
|
+
dsh-model-arena compare <run> <modelA> <modelB> # diff two models in a run
|
|
24
|
+
dsh-model-arena rate <run> --model M --rating 4 # rate a model output
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## License
|
|
28
|
+
|
|
29
|
+
MIT
|
package/cordis.patch.yml
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { PanelPayload, ArenaRun } from './types.js';
|
|
2
|
+
/** Route paths shared between host and client. */
|
|
3
|
+
export declare const ARENA_PANEL_PATH = "/api/arena.panel";
|
|
4
|
+
export declare const ARENA_RUN_PATH = "/api/arena.run";
|
|
5
|
+
export declare const ARENA_RATE_PATH = "/api/arena.rate";
|
|
6
|
+
export declare function panelState(runs: ArenaRun[]): PanelPayload;
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/** Route paths shared between host and client. */
|
|
2
|
+
export const ARENA_PANEL_PATH = '/api/arena.panel';
|
|
3
|
+
export const ARENA_RUN_PATH = '/api/arena.run';
|
|
4
|
+
export const ARENA_RATE_PATH = '/api/arena.rate';
|
|
5
|
+
export function panelState(runs) {
|
|
6
|
+
return {
|
|
7
|
+
recentRuns: runs
|
|
8
|
+
.sort((a, b) => b.timestamp - a.timestamp)
|
|
9
|
+
.slice(0, 20)
|
|
10
|
+
.map((r) => ({
|
|
11
|
+
id: r.id,
|
|
12
|
+
prompt: r.prompt,
|
|
13
|
+
timestamp: r.timestamp,
|
|
14
|
+
modelCount: r.results.length,
|
|
15
|
+
})),
|
|
16
|
+
};
|
|
17
|
+
}
|
package/lib/arena.d.ts
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { ArenaRun, ModelResult, Suite, RegressionReport, Score } from './types.js';
|
|
2
|
+
export declare class Arena {
|
|
3
|
+
private projectDir;
|
|
4
|
+
private store;
|
|
5
|
+
constructor(projectDir: string);
|
|
6
|
+
/** Save a completed run (results already collected from model calls). */
|
|
7
|
+
saveRun(prompt: string, results: ModelResult[], reference?: string): ArenaRun;
|
|
8
|
+
getRun(id: string): ArenaRun | null;
|
|
9
|
+
listRuns(): {
|
|
10
|
+
id: string;
|
|
11
|
+
prompt: string;
|
|
12
|
+
timestamp: number;
|
|
13
|
+
modelCount: number;
|
|
14
|
+
}[];
|
|
15
|
+
/** Compare two models' outputs within a run. */
|
|
16
|
+
compare(runId: string, modelA: string, modelB: string): {
|
|
17
|
+
modelA: string;
|
|
18
|
+
modelB: string;
|
|
19
|
+
outputA: string;
|
|
20
|
+
outputB: string;
|
|
21
|
+
diff: import("./diff.js").DiffLine[];
|
|
22
|
+
summary: {
|
|
23
|
+
added: number;
|
|
24
|
+
removed: number;
|
|
25
|
+
changed: number;
|
|
26
|
+
};
|
|
27
|
+
scoreA: Score;
|
|
28
|
+
scoreB: Score;
|
|
29
|
+
} | null;
|
|
30
|
+
/** Set a user rating for a model in a run. */
|
|
31
|
+
rate(runId: string, model: string, rating: number): ArenaRun | null;
|
|
32
|
+
/** Save a benchmark suite. */
|
|
33
|
+
saveSuite(suite: Suite): void;
|
|
34
|
+
/** Generate a regression report between two runs of the same suite. */
|
|
35
|
+
regression(suiteName: string, baselineRunId: string, currentRunId: string): RegressionReport | null;
|
|
36
|
+
}
|
package/lib/arena.js
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { ArenaStore } from './store.js';
|
|
2
|
+
import { autoScore } from './scoring.js';
|
|
3
|
+
import { lineDiff, diffSummary } from './diff.js';
|
|
4
|
+
export class Arena {
|
|
5
|
+
projectDir;
|
|
6
|
+
store;
|
|
7
|
+
constructor(projectDir) {
|
|
8
|
+
this.projectDir = projectDir;
|
|
9
|
+
this.store = new ArenaStore(projectDir);
|
|
10
|
+
}
|
|
11
|
+
/** Save a completed run (results already collected from model calls). */
|
|
12
|
+
saveRun(prompt, results, reference) {
|
|
13
|
+
const models = results.map((r) => r.model);
|
|
14
|
+
const id = ArenaStore.generateId(prompt, models);
|
|
15
|
+
const scores = autoScore(results, reference);
|
|
16
|
+
const run = { id, prompt, timestamp: Date.now(), results, scores };
|
|
17
|
+
this.store.saveRun(run);
|
|
18
|
+
return run;
|
|
19
|
+
}
|
|
20
|
+
getRun(id) {
|
|
21
|
+
return this.store.getRun(id);
|
|
22
|
+
}
|
|
23
|
+
listRuns() {
|
|
24
|
+
return this.store.listRuns().map((r) => ({
|
|
25
|
+
id: r.id,
|
|
26
|
+
prompt: r.prompt,
|
|
27
|
+
timestamp: r.timestamp,
|
|
28
|
+
modelCount: r.results.length,
|
|
29
|
+
}));
|
|
30
|
+
}
|
|
31
|
+
/** Compare two models' outputs within a run. */
|
|
32
|
+
compare(runId, modelA, modelB) {
|
|
33
|
+
const run = this.store.getRun(runId);
|
|
34
|
+
if (!run)
|
|
35
|
+
return null;
|
|
36
|
+
const a = run.results.find((r) => r.model === modelA);
|
|
37
|
+
const b = run.results.find((r) => r.model === modelB);
|
|
38
|
+
if (!a || !b)
|
|
39
|
+
return null;
|
|
40
|
+
return {
|
|
41
|
+
modelA: a.model,
|
|
42
|
+
modelB: b.model,
|
|
43
|
+
outputA: a.output,
|
|
44
|
+
outputB: b.output,
|
|
45
|
+
diff: lineDiff(a.output, b.output),
|
|
46
|
+
summary: diffSummary(a.output, b.output),
|
|
47
|
+
scoreA: run.scores[modelA],
|
|
48
|
+
scoreB: run.scores[modelB],
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
/** Set a user rating for a model in a run. */
|
|
52
|
+
rate(runId, model, rating) {
|
|
53
|
+
const run = this.store.getRun(runId);
|
|
54
|
+
if (!run)
|
|
55
|
+
return null;
|
|
56
|
+
if (!run.scores[model])
|
|
57
|
+
run.scores[model] = {};
|
|
58
|
+
run.scores[model].userRating = rating;
|
|
59
|
+
this.store.saveRun(run);
|
|
60
|
+
return run;
|
|
61
|
+
}
|
|
62
|
+
/** Save a benchmark suite. */
|
|
63
|
+
saveSuite(suite) {
|
|
64
|
+
this.store.saveSuite(suite);
|
|
65
|
+
}
|
|
66
|
+
/** Generate a regression report between two runs of the same suite. */
|
|
67
|
+
regression(suiteName, baselineRunId, currentRunId) {
|
|
68
|
+
const baseline = this.store.getRun(baselineRunId);
|
|
69
|
+
const current = this.store.getRun(currentRunId);
|
|
70
|
+
if (!baseline || !current)
|
|
71
|
+
return null;
|
|
72
|
+
const changes = [];
|
|
73
|
+
for (const cr of current.results) {
|
|
74
|
+
const br = baseline.results.find((r) => r.model === cr.model);
|
|
75
|
+
if (br && br.output !== cr.output) {
|
|
76
|
+
changes.push({
|
|
77
|
+
model: cr.model,
|
|
78
|
+
prompt: current.prompt,
|
|
79
|
+
improved: (current.scores[cr.model]?.referenceMatch ?? 0) > (baseline.scores[cr.model]?.referenceMatch ?? 0),
|
|
80
|
+
baselineOutput: br.output,
|
|
81
|
+
currentOutput: cr.output,
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return { suiteName, baselineRunId, currentRunId, changes };
|
|
86
|
+
}
|
|
87
|
+
}
|
package/lib/arena.web.js
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// src/client/view.tsx
|
|
2
|
+
function renderPanel(payload) {
|
|
3
|
+
const runRows = payload.recentRuns.map((r) => `<tr><td>${r.id}</td><td>${r.modelCount}</td><td>${new Date(r.timestamp).toLocaleString()}</td><td>${r.prompt.slice(0, 60)}...</td></tr>`).join("");
|
|
4
|
+
return `<div class="arena-panel">
|
|
5
|
+
<h2>Model Arena</h2>
|
|
6
|
+
${runRows ? `<table class="arena-table"><thead><tr><th>Run</th><th>Models</th><th>Date</th><th>Prompt</th></tr></thead><tbody>${runRows}</tbody></table>` : "<p>No runs yet.</p>"}
|
|
7
|
+
</div>`;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
// src/client/index.tsx
|
|
11
|
+
var inject = ["@deepseek-ai/dsh-client-ui-settings", "@deepseek-ai/dsh-client-connection"];
|
|
12
|
+
function apply(ctx) {
|
|
13
|
+
ctx.inject(inject, (settings, connection) => {
|
|
14
|
+
const s = settings;
|
|
15
|
+
const c = connection;
|
|
16
|
+
s.section("arena", {
|
|
17
|
+
title: "Model Arena",
|
|
18
|
+
render: async () => {
|
|
19
|
+
const res = await c.fetch("/api/arena.panel");
|
|
20
|
+
const payload = await res.json();
|
|
21
|
+
return renderPanel(payload);
|
|
22
|
+
}
|
|
23
|
+
});
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
export {
|
|
27
|
+
apply,
|
|
28
|
+
inject
|
|
29
|
+
};
|
package/lib/bin.d.ts
ADDED
package/lib/bin.js
ADDED
package/lib/cli.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function run(argv: string[]): number;
|
package/lib/cli.js
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { parseArgs } from 'node:util';
|
|
2
|
+
import { Arena } from './arena.js';
|
|
3
|
+
export function run(argv) {
|
|
4
|
+
const { values, positionals } = parseArgs({
|
|
5
|
+
args: argv,
|
|
6
|
+
options: {
|
|
7
|
+
'models': { type: 'string' },
|
|
8
|
+
'reference': { type: 'string' },
|
|
9
|
+
'rating': { type: 'string' },
|
|
10
|
+
'model': { type: 'string' },
|
|
11
|
+
'suite': { type: 'string' },
|
|
12
|
+
},
|
|
13
|
+
allowPositionals: true,
|
|
14
|
+
});
|
|
15
|
+
const projectDir = process.cwd();
|
|
16
|
+
const arena = new Arena(projectDir);
|
|
17
|
+
const cmd = positionals[0] ?? 'list';
|
|
18
|
+
switch (cmd) {
|
|
19
|
+
case 'list': {
|
|
20
|
+
const runs = arena.listRuns();
|
|
21
|
+
if (runs.length === 0) {
|
|
22
|
+
console.log('No arena runs found.');
|
|
23
|
+
}
|
|
24
|
+
else {
|
|
25
|
+
for (const r of runs) {
|
|
26
|
+
console.log(`${r.id} ${r.modelCount} models ${new Date(r.timestamp).toLocaleString()} ${r.prompt.slice(0, 60)}...`);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
return 0;
|
|
30
|
+
}
|
|
31
|
+
case 'compare': {
|
|
32
|
+
const runId = positionals[1] ?? '';
|
|
33
|
+
const modelA = positionals[2] ?? '';
|
|
34
|
+
const modelB = positionals[3] ?? '';
|
|
35
|
+
const result = arena.compare(runId, modelA, modelB);
|
|
36
|
+
if (!result) {
|
|
37
|
+
console.log('Run or models not found.');
|
|
38
|
+
return 1;
|
|
39
|
+
}
|
|
40
|
+
console.log(`Comparing ${result.modelA} vs ${result.modelB}:`);
|
|
41
|
+
console.log(`Diff: +${result.summary.added} -${result.summary.removed} lines`);
|
|
42
|
+
for (const d of result.diff) {
|
|
43
|
+
const prefix = d.type === 'added' ? '+' : d.type === 'removed' ? '-' : ' ';
|
|
44
|
+
console.log(`${prefix} ${d.text}`);
|
|
45
|
+
}
|
|
46
|
+
return 0;
|
|
47
|
+
}
|
|
48
|
+
case 'rate': {
|
|
49
|
+
const runId = positionals[1] ?? '';
|
|
50
|
+
const model = values.model ?? '';
|
|
51
|
+
const rating = values.rating ? parseInt(values.rating, 10) : 0;
|
|
52
|
+
const result = arena.rate(runId, model, rating);
|
|
53
|
+
if (!result) {
|
|
54
|
+
console.log('Run not found.');
|
|
55
|
+
return 1;
|
|
56
|
+
}
|
|
57
|
+
console.log(`Rated ${model} as ${rating}/5 in run ${runId}`);
|
|
58
|
+
return 0;
|
|
59
|
+
}
|
|
60
|
+
case 'help':
|
|
61
|
+
default:
|
|
62
|
+
console.log('Usage: dsh-model-arena <command> [options]');
|
|
63
|
+
console.log('');
|
|
64
|
+
console.log('Commands:');
|
|
65
|
+
console.log(' list List recent runs');
|
|
66
|
+
console.log(' compare <run> <modelA> <modelB> Compare two models in a run');
|
|
67
|
+
console.log(' rate <run> --model M --rating N Rate a model output (1-5)');
|
|
68
|
+
return 0;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { renderPanel } from './view.js';
|
|
2
|
+
export const inject = ['@deepseek-ai/dsh-client-ui-settings', '@deepseek-ai/dsh-client-connection'];
|
|
3
|
+
export function apply(ctx) {
|
|
4
|
+
ctx.inject(inject, (settings, connection) => {
|
|
5
|
+
const s = settings;
|
|
6
|
+
const c = connection;
|
|
7
|
+
s.section('arena', {
|
|
8
|
+
title: 'Model Arena',
|
|
9
|
+
render: async () => {
|
|
10
|
+
const res = await c.fetch('/api/arena.panel');
|
|
11
|
+
const payload = await res.json();
|
|
12
|
+
return renderPanel(payload);
|
|
13
|
+
},
|
|
14
|
+
});
|
|
15
|
+
});
|
|
16
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export function renderPanel(payload) {
|
|
2
|
+
const runRows = payload.recentRuns
|
|
3
|
+
.map((r) => `<tr><td>${r.id}</td><td>${r.modelCount}</td><td>${new Date(r.timestamp).toLocaleString()}</td><td>${r.prompt.slice(0, 60)}...</td></tr>`)
|
|
4
|
+
.join('');
|
|
5
|
+
return `<div class="arena-panel">
|
|
6
|
+
<h2>Model Arena</h2>
|
|
7
|
+
${runRows ? `<table class="arena-table"><thead><tr><th>Run</th><th>Models</th><th>Date</th><th>Prompt</th></tr></thead><tbody>${runRows}</tbody></table>` : '<p>No runs yet.</p>'}
|
|
8
|
+
</div>`;
|
|
9
|
+
}
|
package/lib/diff.d.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/** Simple line-level diff for comparing model outputs. */
|
|
2
|
+
export interface DiffLine {
|
|
3
|
+
type: 'same' | 'added' | 'removed';
|
|
4
|
+
text: string;
|
|
5
|
+
}
|
|
6
|
+
export declare function lineDiff(a: string, b: string): DiffLine[];
|
|
7
|
+
export declare function diffSummary(a: string, b: string): {
|
|
8
|
+
added: number;
|
|
9
|
+
removed: number;
|
|
10
|
+
changed: number;
|
|
11
|
+
};
|
package/lib/diff.js
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
export function lineDiff(a, b) {
|
|
2
|
+
const linesA = a.split('\n');
|
|
3
|
+
const linesB = b.split('\n');
|
|
4
|
+
const result = [];
|
|
5
|
+
const maxLen = Math.max(linesA.length, linesB.length);
|
|
6
|
+
for (let i = 0; i < maxLen; i++) {
|
|
7
|
+
const la = linesA[i];
|
|
8
|
+
const lb = linesB[i];
|
|
9
|
+
if (la === lb) {
|
|
10
|
+
result.push({ type: 'same', text: la ?? '' });
|
|
11
|
+
}
|
|
12
|
+
else {
|
|
13
|
+
if (la !== undefined)
|
|
14
|
+
result.push({ type: 'removed', text: la });
|
|
15
|
+
if (lb !== undefined)
|
|
16
|
+
result.push({ type: 'added', text: lb });
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
return result;
|
|
20
|
+
}
|
|
21
|
+
export function diffSummary(a, b) {
|
|
22
|
+
const diff = lineDiff(a, b);
|
|
23
|
+
let added = 0;
|
|
24
|
+
let removed = 0;
|
|
25
|
+
for (const d of diff) {
|
|
26
|
+
if (d.type === 'added')
|
|
27
|
+
added++;
|
|
28
|
+
if (d.type === 'removed')
|
|
29
|
+
removed++;
|
|
30
|
+
}
|
|
31
|
+
return { added, removed, changed: added + removed };
|
|
32
|
+
}
|
package/lib/index.d.ts
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { Context } from '@deepseek-ai/cordis';
|
|
2
|
+
import { Arena } from './arena.js';
|
|
3
|
+
export declare const name = "dsh-model-arena";
|
|
4
|
+
export interface ArenaService {
|
|
5
|
+
saveRun(prompt: string, results: {
|
|
6
|
+
model: string;
|
|
7
|
+
output: string;
|
|
8
|
+
promptTokens: number;
|
|
9
|
+
completionTokens: number;
|
|
10
|
+
latencyMs: number;
|
|
11
|
+
error?: string;
|
|
12
|
+
}[], reference?: string): ReturnType<Arena['saveRun']>;
|
|
13
|
+
getRun(id: string): ReturnType<Arena['getRun']>;
|
|
14
|
+
listRuns(): ReturnType<Arena['listRuns']>;
|
|
15
|
+
compare(runId: string, modelA: string, modelB: string): ReturnType<Arena['compare']>;
|
|
16
|
+
rate(runId: string, model: string, rating: number): ReturnType<Arena['rate']>;
|
|
17
|
+
saveSuite(suite: {
|
|
18
|
+
name: string;
|
|
19
|
+
prompts: string[];
|
|
20
|
+
models: string[];
|
|
21
|
+
}): void;
|
|
22
|
+
regression(suiteName: string, baselineRunId: string, currentRunId: string): ReturnType<Arena['regression']>;
|
|
23
|
+
}
|
|
24
|
+
export declare function apply(ctx: Context): void;
|
package/lib/index.js
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { resolve } from 'node:path';
|
|
2
|
+
import { Arena } from './arena.js';
|
|
3
|
+
import { registerArenaRoutes } from './routes.js';
|
|
4
|
+
export const name = 'dsh-model-arena';
|
|
5
|
+
export function apply(ctx) {
|
|
6
|
+
const root = resolve(process.cwd());
|
|
7
|
+
const arena = new Arena(root);
|
|
8
|
+
const service = {
|
|
9
|
+
saveRun: (prompt, results, reference) => arena.saveRun(prompt, results, reference),
|
|
10
|
+
getRun: (id) => arena.getRun(id),
|
|
11
|
+
listRuns: () => arena.listRuns(),
|
|
12
|
+
compare: (runId, modelA, modelB) => arena.compare(runId, modelA, modelB),
|
|
13
|
+
rate: (runId, model, rating) => arena.rate(runId, model, rating),
|
|
14
|
+
saveSuite: (suite) => arena.saveSuite(suite),
|
|
15
|
+
regression: (suiteName, baselineRunId, currentRunId) => arena.regression(suiteName, baselineRunId, currentRunId),
|
|
16
|
+
};
|
|
17
|
+
ctx.provide('arena', service);
|
|
18
|
+
registerArenaRoutes(ctx, service);
|
|
19
|
+
}
|
package/lib/routes.d.ts
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { Context } from '@deepseek-ai/cordis';
|
|
2
|
+
import type { ArenaService } from './index.js';
|
|
3
|
+
import { ARENA_PANEL_PATH, ARENA_RUN_PATH, ARENA_RATE_PATH } from './arena-view.js';
|
|
4
|
+
export { ARENA_PANEL_PATH, ARENA_RUN_PATH, ARENA_RATE_PATH };
|
|
5
|
+
export declare function registerArenaRoutes(ctx: Context, arena: ArenaService): void;
|
package/lib/routes.js
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { ARENA_PANEL_PATH, ARENA_RUN_PATH, ARENA_RATE_PATH } from './arena-view.js';
|
|
2
|
+
export { ARENA_PANEL_PATH, ARENA_RUN_PATH, ARENA_RATE_PATH };
|
|
3
|
+
export function registerArenaRoutes(ctx, arena) {
|
|
4
|
+
ctx.inject(['connection'], (connectionCtx) => {
|
|
5
|
+
const connection = connectionCtx.connection;
|
|
6
|
+
connection.fetch.register({
|
|
7
|
+
path: ARENA_PANEL_PATH,
|
|
8
|
+
methods: ['GET'],
|
|
9
|
+
requestBody: 'buffered',
|
|
10
|
+
fetch: () => Promise.resolve(Response.json(arena.listRuns(), {
|
|
11
|
+
headers: { 'cache-control': 'no-store' },
|
|
12
|
+
})),
|
|
13
|
+
});
|
|
14
|
+
connection.fetch.register({
|
|
15
|
+
path: ARENA_RUN_PATH,
|
|
16
|
+
methods: ['GET'],
|
|
17
|
+
requestBody: 'buffered',
|
|
18
|
+
fetch: async (request) => {
|
|
19
|
+
const url = new URL(request.url);
|
|
20
|
+
const id = url.searchParams.get('id');
|
|
21
|
+
if (!id)
|
|
22
|
+
return Response.json({ error: 'missing id' }, { status: 400 });
|
|
23
|
+
const run = arena.getRun(id);
|
|
24
|
+
if (!run)
|
|
25
|
+
return Response.json({ error: 'run not found' }, { status: 404 });
|
|
26
|
+
return Response.json(run, { headers: { 'cache-control': 'no-store' } });
|
|
27
|
+
},
|
|
28
|
+
});
|
|
29
|
+
connection.fetch.register({
|
|
30
|
+
path: ARENA_RATE_PATH,
|
|
31
|
+
methods: ['POST'],
|
|
32
|
+
requestBody: 'buffered',
|
|
33
|
+
fetch: async (request) => {
|
|
34
|
+
let body;
|
|
35
|
+
try {
|
|
36
|
+
body = await request.json();
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
return Response.json({ error: 'request body must be JSON' }, { status: 400 });
|
|
40
|
+
}
|
|
41
|
+
if (!body.runId || !body.model || body.rating === undefined) {
|
|
42
|
+
return Response.json({ error: 'missing runId, model, or rating' }, { status: 400 });
|
|
43
|
+
}
|
|
44
|
+
const run = arena.rate(body.runId, body.model, body.rating);
|
|
45
|
+
if (!run)
|
|
46
|
+
return Response.json({ error: 'run not found' }, { status: 404 });
|
|
47
|
+
return Response.json(run, { headers: { 'cache-control': 'no-store' } });
|
|
48
|
+
},
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
}
|
package/lib/scoring.d.ts
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { ModelResult, Score } from './types.js';
|
|
2
|
+
/** Score outputs automatically based on simple metrics. */
|
|
3
|
+
export declare function autoScore(results: ModelResult[], reference?: string): Record<string, Score>;
|
|
4
|
+
/** Simple character-level similarity (Jaccard on character bigrams). */
|
|
5
|
+
export declare function similarity(a: string, b: string): number;
|
package/lib/scoring.js
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/** Score outputs automatically based on simple metrics. */
|
|
2
|
+
export function autoScore(results, reference) {
|
|
3
|
+
const scores = {};
|
|
4
|
+
const maxLen = Math.max(...results.map((r) => r.output.length), 1);
|
|
5
|
+
const minLatency = Math.min(...results.map((r) => r.latencyMs), 1);
|
|
6
|
+
for (const r of results) {
|
|
7
|
+
const score = {
|
|
8
|
+
lengthScore: r.output.length / maxLen,
|
|
9
|
+
latencyScore: minLatency / r.latencyMs,
|
|
10
|
+
};
|
|
11
|
+
if (reference) {
|
|
12
|
+
score.referenceMatch = similarity(r.output, reference);
|
|
13
|
+
}
|
|
14
|
+
scores[r.model] = score;
|
|
15
|
+
}
|
|
16
|
+
return scores;
|
|
17
|
+
}
|
|
18
|
+
/** Simple character-level similarity (Jaccard on character bigrams). */
|
|
19
|
+
export function similarity(a, b) {
|
|
20
|
+
if (a === b)
|
|
21
|
+
return 1;
|
|
22
|
+
if (a.length < 2 || b.length < 2)
|
|
23
|
+
return 0;
|
|
24
|
+
const bigramsA = new Set();
|
|
25
|
+
for (let i = 0; i < a.length - 1; i++)
|
|
26
|
+
bigramsA.add(a.slice(i, i + 2));
|
|
27
|
+
const bigramsB = new Set();
|
|
28
|
+
for (let i = 0; i < b.length - 1; i++)
|
|
29
|
+
bigramsB.add(b.slice(i, i + 2));
|
|
30
|
+
let intersection = 0;
|
|
31
|
+
for (const bg of bigramsA)
|
|
32
|
+
if (bigramsB.has(bg))
|
|
33
|
+
intersection++;
|
|
34
|
+
return intersection / (bigramsA.size + bigramsB.size - intersection);
|
|
35
|
+
}
|
package/lib/store.d.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { ArenaRun, Suite } from './types.js';
|
|
2
|
+
export declare class ArenaStore {
|
|
3
|
+
private readonly projectDir;
|
|
4
|
+
private readonly arenaDir;
|
|
5
|
+
constructor(projectDir: string);
|
|
6
|
+
private ensureDir;
|
|
7
|
+
saveRun(run: ArenaRun): void;
|
|
8
|
+
getRun(id: string): ArenaRun | null;
|
|
9
|
+
listRuns(): ArenaRun[];
|
|
10
|
+
saveSuite(suite: Suite): void;
|
|
11
|
+
getSuite(name: string): Suite | null;
|
|
12
|
+
static generateId(prompt: string, models: string[]): string;
|
|
13
|
+
}
|
package/lib/store.js
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { writeFileSync, readFileSync, existsSync, mkdirSync, readdirSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { createHash } from 'node:crypto';
|
|
4
|
+
export class ArenaStore {
|
|
5
|
+
projectDir;
|
|
6
|
+
arenaDir;
|
|
7
|
+
constructor(projectDir) {
|
|
8
|
+
this.projectDir = projectDir;
|
|
9
|
+
this.arenaDir = join(projectDir, '.arena');
|
|
10
|
+
}
|
|
11
|
+
ensureDir() {
|
|
12
|
+
if (!existsSync(this.arenaDir))
|
|
13
|
+
mkdirSync(this.arenaDir, { recursive: true });
|
|
14
|
+
}
|
|
15
|
+
saveRun(run) {
|
|
16
|
+
this.ensureDir();
|
|
17
|
+
writeFileSync(join(this.arenaDir, `${run.id}.json`), JSON.stringify(run, null, 2), 'utf8');
|
|
18
|
+
}
|
|
19
|
+
getRun(id) {
|
|
20
|
+
const path = join(this.arenaDir, `${id}.json`);
|
|
21
|
+
if (!existsSync(path))
|
|
22
|
+
return null;
|
|
23
|
+
return JSON.parse(readFileSync(path, 'utf8'));
|
|
24
|
+
}
|
|
25
|
+
listRuns() {
|
|
26
|
+
if (!existsSync(this.arenaDir))
|
|
27
|
+
return [];
|
|
28
|
+
return readdirSync(this.arenaDir)
|
|
29
|
+
.filter((f) => f.endsWith('.json'))
|
|
30
|
+
.map((f) => JSON.parse(readFileSync(join(this.arenaDir, f), 'utf8')))
|
|
31
|
+
.sort((a, b) => b.timestamp - a.timestamp);
|
|
32
|
+
}
|
|
33
|
+
saveSuite(suite) {
|
|
34
|
+
this.ensureDir();
|
|
35
|
+
writeFileSync(join(this.arenaDir, `suite-${suite.name}.json`), JSON.stringify(suite, null, 2), 'utf8');
|
|
36
|
+
}
|
|
37
|
+
getSuite(name) {
|
|
38
|
+
const path = join(this.arenaDir, `suite-${name}.json`);
|
|
39
|
+
if (!existsSync(path))
|
|
40
|
+
return null;
|
|
41
|
+
return JSON.parse(readFileSync(path, 'utf8'));
|
|
42
|
+
}
|
|
43
|
+
static generateId(prompt, models) {
|
|
44
|
+
const hash = createHash('sha256');
|
|
45
|
+
hash.update(prompt);
|
|
46
|
+
hash.update(models.join(','));
|
|
47
|
+
hash.update(Date.now().toString());
|
|
48
|
+
return hash.digest('hex').slice(0, 12);
|
|
49
|
+
}
|
|
50
|
+
}
|
package/lib/types.d.ts
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
export interface ModelResult {
|
|
2
|
+
model: string;
|
|
3
|
+
output: string;
|
|
4
|
+
promptTokens: number;
|
|
5
|
+
completionTokens: number;
|
|
6
|
+
latencyMs: number;
|
|
7
|
+
error?: string;
|
|
8
|
+
}
|
|
9
|
+
export interface Score {
|
|
10
|
+
userRating?: number;
|
|
11
|
+
lengthScore?: number;
|
|
12
|
+
latencyScore?: number;
|
|
13
|
+
referenceMatch?: number;
|
|
14
|
+
}
|
|
15
|
+
export interface ArenaRun {
|
|
16
|
+
id: string;
|
|
17
|
+
prompt: string;
|
|
18
|
+
timestamp: number;
|
|
19
|
+
results: ModelResult[];
|
|
20
|
+
scores: Record<string, Score>;
|
|
21
|
+
}
|
|
22
|
+
export interface Suite {
|
|
23
|
+
name: string;
|
|
24
|
+
prompts: string[];
|
|
25
|
+
models: string[];
|
|
26
|
+
}
|
|
27
|
+
export interface RegressionReport {
|
|
28
|
+
suiteName: string;
|
|
29
|
+
baselineRunId: string;
|
|
30
|
+
currentRunId: string;
|
|
31
|
+
changes: {
|
|
32
|
+
model: string;
|
|
33
|
+
prompt: string;
|
|
34
|
+
improved: boolean;
|
|
35
|
+
baselineOutput: string;
|
|
36
|
+
currentOutput: string;
|
|
37
|
+
}[];
|
|
38
|
+
}
|
|
39
|
+
export interface PanelPayload {
|
|
40
|
+
recentRuns: {
|
|
41
|
+
id: string;
|
|
42
|
+
prompt: string;
|
|
43
|
+
timestamp: number;
|
|
44
|
+
modelCount: number;
|
|
45
|
+
}[];
|
|
46
|
+
}
|
package/lib/types.js
ADDED
package/package.json
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "dsh-model-arena",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Run the same prompt through every model you have, and see the difference without squinting.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "lib/index.js",
|
|
7
|
+
"types": "lib/index.d.ts",
|
|
8
|
+
"bin": {
|
|
9
|
+
"dsh-model-arena": "lib/bin.js"
|
|
10
|
+
},
|
|
11
|
+
"exports": {
|
|
12
|
+
".": {
|
|
13
|
+
"types": "./lib/index.d.ts",
|
|
14
|
+
"default": "./lib/index.js"
|
|
15
|
+
},
|
|
16
|
+
"./client": {
|
|
17
|
+
"types": "./lib/client.d.ts",
|
|
18
|
+
"default": "./lib/arena.web.js"
|
|
19
|
+
},
|
|
20
|
+
"./package.json": "./package.json"
|
|
21
|
+
},
|
|
22
|
+
"files": [
|
|
23
|
+
"lib",
|
|
24
|
+
"cordis.patch.yml",
|
|
25
|
+
"README.md",
|
|
26
|
+
"CHANGELOG.md"
|
|
27
|
+
],
|
|
28
|
+
"keywords": [
|
|
29
|
+
"dsh",
|
|
30
|
+
"dsh-plugin",
|
|
31
|
+
"deepseek-harness",
|
|
32
|
+
"model",
|
|
33
|
+
"comparison",
|
|
34
|
+
"arena",
|
|
35
|
+
"benchmark"
|
|
36
|
+
],
|
|
37
|
+
"dsh": {
|
|
38
|
+
"bundle": {
|
|
39
|
+
"patch": "./cordis.patch.yml"
|
|
40
|
+
},
|
|
41
|
+
"client": {
|
|
42
|
+
"platform": "web",
|
|
43
|
+
"inject": [
|
|
44
|
+
"@deepseek-ai/dsh-client-ui-settings",
|
|
45
|
+
"@deepseek-ai/dsh-client-connection"
|
|
46
|
+
]
|
|
47
|
+
}
|
|
48
|
+
},
|
|
49
|
+
"license": "MIT",
|
|
50
|
+
"author": "hj01857655",
|
|
51
|
+
"repository": {
|
|
52
|
+
"type": "git",
|
|
53
|
+
"url": "git+https://github.com/hj01857655/dsh-model-arena.git"
|
|
54
|
+
},
|
|
55
|
+
"bugs": {
|
|
56
|
+
"url": "https://github.com/hj01857655/dsh-model-arena/issues"
|
|
57
|
+
},
|
|
58
|
+
"homepage": "https://github.com/hj01857655/dsh-model-arena#readme",
|
|
59
|
+
"engines": {
|
|
60
|
+
"node": ">=20"
|
|
61
|
+
},
|
|
62
|
+
"peerDependencies": {
|
|
63
|
+
"@deepseek-ai/cordis": "^4.0.1"
|
|
64
|
+
},
|
|
65
|
+
"devDependencies": {
|
|
66
|
+
"@deepseek-ai/cordis": "^4.0.2",
|
|
67
|
+
"@types/node": "^22.20.3",
|
|
68
|
+
"@types/react": "^19.3.0",
|
|
69
|
+
"esbuild": "^0.28.2",
|
|
70
|
+
"react": "^19.3.0",
|
|
71
|
+
"react-dom": "^19.3.0",
|
|
72
|
+
"typescript": "^5.6.0"
|
|
73
|
+
},
|
|
74
|
+
"scripts": {
|
|
75
|
+
"build": "tsc -p tsconfig.json && node scripts/bundle-client.mjs",
|
|
76
|
+
"typecheck": "tsc -p tsconfig.json --noEmit && tsc -p tsconfig.client.json",
|
|
77
|
+
"test": "tsc -p tsconfig.json && node --test tests/*.test.mjs",
|
|
78
|
+
"prepublishOnly": "npm run build && npm test"
|
|
79
|
+
}
|
|
80
|
+
}
|