@whatcanirun/cli 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/README.md +91 -0
- package/package.json +37 -0
- package/src/auth/login.ts +129 -0
- package/src/auth/token.ts +58 -0
- package/src/bundle/create.ts +164 -0
- package/src/bundle/validate.ts +82 -0
- package/src/cli.ts +23 -0
- package/src/commands/auth.ts +75 -0
- package/src/commands/index.ts +7 -0
- package/src/commands/run.ts +286 -0
- package/src/commands/show.ts +67 -0
- package/src/commands/submit.ts +64 -0
- package/src/commands/update.ts +24 -0
- package/src/commands/validate.ts +43 -0
- package/src/commands/version.ts +19 -0
- package/src/device/detect.ts +109 -0
- package/src/model/resolve.ts +301 -0
- package/src/runtime/llamacpp.ts +187 -0
- package/src/runtime/mlx.ts +190 -0
- package/src/runtime/resolve.ts +29 -0
- package/src/runtime/types.ts +40 -0
- package/src/upload/client.ts +56 -0
- package/src/utils/id.ts +77 -0
- package/src/utils/log.ts +125 -0
package/README.md
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# @whatcanirun/cli
|
|
2
|
+
|
|
3
|
+
Standardized local LLM inference benchmarks.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
bun install
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Usage
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
# Run a benchmark
|
|
15
|
+
whatcanirun run --model ./models/llama-3.2-1b.gguf --runtime llama.cpp
|
|
16
|
+
|
|
17
|
+
# Run with options
|
|
18
|
+
whatcanirun run \
|
|
19
|
+
--model ./models/llama-3.2-1b.gguf \
|
|
20
|
+
--runtime llama.cpp \
|
|
21
|
+
--scenario chat_long_v1 \
|
|
22
|
+
--quant q4_k_m \
|
|
23
|
+
--trials 10 \
|
|
24
|
+
--warmups 3
|
|
25
|
+
|
|
26
|
+
# Run without uploading
|
|
27
|
+
whatcanirun run --model ./model.gguf --runtime mlx --no-submit
|
|
28
|
+
|
|
29
|
+
# Upload a previously saved bundle
|
|
30
|
+
whatcanirun submit ./bundles/bundle-abc123.zip
|
|
31
|
+
|
|
32
|
+
# Validate a bundle
|
|
33
|
+
whatcanirun validate ./bundles/bundle-abc123.zip
|
|
34
|
+
|
|
35
|
+
# Inspect device, runtime, or model
|
|
36
|
+
whatcanirun show device
|
|
37
|
+
whatcanirun show runtime llama.cpp
|
|
38
|
+
whatcanirun show model ./models/llama-3.2-1b.gguf
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
The short alias `wcir` is also available.
|
|
42
|
+
|
|
43
|
+
## Supported Runtimes
|
|
44
|
+
|
|
45
|
+
| Runtime | Flag |
|
|
46
|
+
| ---------- | ------------ |
|
|
47
|
+
| llama.cpp | `llama.cpp` |
|
|
48
|
+
| MLX | `mlx` |
|
|
49
|
+
| vLLM | `vllm` |
|
|
50
|
+
|
|
51
|
+
## Scenarios
|
|
52
|
+
|
|
53
|
+
| ID | Description |
|
|
54
|
+
| ---------------- | ------------------------ |
|
|
55
|
+
| `chat_short_v1` | Short chat completion |
|
|
56
|
+
| `chat_long_v1` | Long chat completion |
|
|
57
|
+
|
|
58
|
+
## Canonical Runs
|
|
59
|
+
|
|
60
|
+
A run is considered **canonical** when all of these hold:
|
|
61
|
+
|
|
62
|
+
- `batch_size = 1`
|
|
63
|
+
- `temperature = 0`
|
|
64
|
+
- `top_p = 1`
|
|
65
|
+
- `trials >= 5`
|
|
66
|
+
- `warmups >= 2`
|
|
67
|
+
|
|
68
|
+
## Build
|
|
69
|
+
|
|
70
|
+
```bash
|
|
71
|
+
# Bundle for Bun
|
|
72
|
+
bun run build
|
|
73
|
+
|
|
74
|
+
# Compile to standalone binary
|
|
75
|
+
bun run build:bin
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
## Lint & Format
|
|
79
|
+
|
|
80
|
+
```bash
|
|
81
|
+
bun run lint
|
|
82
|
+
bunx prettier --check .
|
|
83
|
+
bunx prettier --write .
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
## Development
|
|
87
|
+
|
|
88
|
+
```bash
|
|
89
|
+
bun run dev # Runs src/cli.ts directly
|
|
90
|
+
bun test # Run tests
|
|
91
|
+
```
|
package/package.json
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@whatcanirun/cli",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "Standardized local LLM inference benchmarks",
|
|
6
|
+
"bin": {
|
|
7
|
+
"whatcanirun": "./src/cli.ts",
|
|
8
|
+
"wcir": "./src/cli.ts"
|
|
9
|
+
},
|
|
10
|
+
"scripts": {
|
|
11
|
+
"dev": "bun run src/cli.ts",
|
|
12
|
+
"build": "bun build src/cli.ts --outdir dist --target bun",
|
|
13
|
+
"build:bin": "bun build src/cli.ts --compile --outfile dist/whatcanirun",
|
|
14
|
+
"test": "bun test",
|
|
15
|
+
"lint": "eslint"
|
|
16
|
+
},
|
|
17
|
+
"dependencies": {
|
|
18
|
+
"@whatcanirun/shared": "workspace:*",
|
|
19
|
+
"citty": "^0.1.6",
|
|
20
|
+
"smol-toml": "^1.3.1"
|
|
21
|
+
},
|
|
22
|
+
"devDependencies": {
|
|
23
|
+
"@trivago/prettier-plugin-sort-imports": "^6.0.2",
|
|
24
|
+
"@types/bun": "^1.3.2",
|
|
25
|
+
"eslint": "^9",
|
|
26
|
+
"eslint-plugin-prettier": "^5.5.5",
|
|
27
|
+
"prettier": "^3",
|
|
28
|
+
"typescript": "^5",
|
|
29
|
+
"typescript-eslint": "^8"
|
|
30
|
+
},
|
|
31
|
+
"files": [
|
|
32
|
+
"src"
|
|
33
|
+
],
|
|
34
|
+
"publishConfig": {
|
|
35
|
+
"access": "public"
|
|
36
|
+
}
|
|
37
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { randomBytes } from 'node:crypto';
|
|
2
|
+
|
|
3
|
+
import { type AuthData, saveAuth } from './token';
|
|
4
|
+
|
|
5
|
+
// -----------------------------------------------------------------------------
|
|
6
|
+
// Constants
|
|
7
|
+
// -----------------------------------------------------------------------------
|
|
8
|
+
|
|
9
|
+
const API_BASE = process.env.WCIR_API_URL || 'https://whatcani.run';
|
|
10
|
+
|
|
11
|
+
// -----------------------------------------------------------------------------
|
|
12
|
+
// Functions
|
|
13
|
+
// -----------------------------------------------------------------------------
|
|
14
|
+
|
|
15
|
+
export async function loginViaBrowser(): Promise<AuthData> {
|
|
16
|
+
const state = randomBytes(32).toString('hex');
|
|
17
|
+
|
|
18
|
+
return new Promise((resolve, reject) => {
|
|
19
|
+
const server = Bun.serve({
|
|
20
|
+
port: 0,
|
|
21
|
+
fetch(req) {
|
|
22
|
+
const url = new URL(req.url);
|
|
23
|
+
|
|
24
|
+
if (url.pathname !== '/callback') {
|
|
25
|
+
return new Response('Not found', { status: 404 });
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const code = url.searchParams.get('code');
|
|
29
|
+
const returnedState = url.searchParams.get('state');
|
|
30
|
+
|
|
31
|
+
if (returnedState !== state) {
|
|
32
|
+
return new Response(page('Authentication failed', 'State mismatch. Please try again.'), {
|
|
33
|
+
headers: { 'Content-Type': 'text/html' },
|
|
34
|
+
status: 400,
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if (!code) {
|
|
39
|
+
return new Response(page('Authentication failed', 'Missing authorization code.'), {
|
|
40
|
+
headers: { 'Content-Type': 'text/html' },
|
|
41
|
+
status: 400,
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Exchange the code for a CLI token server-side.
|
|
46
|
+
exchangeCode(code)
|
|
47
|
+
.then((authData) => {
|
|
48
|
+
saveAuth(authData);
|
|
49
|
+
setTimeout(() => {
|
|
50
|
+
server.stop();
|
|
51
|
+
resolve(authData);
|
|
52
|
+
}, 100);
|
|
53
|
+
})
|
|
54
|
+
.catch((err) => {
|
|
55
|
+
setTimeout(() => {
|
|
56
|
+
server.stop();
|
|
57
|
+
reject(err);
|
|
58
|
+
}, 100);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
return new Response(page('Authenticated', 'You can close this tab.'), {
|
|
62
|
+
headers: { 'Content-Type': 'text/html' },
|
|
63
|
+
});
|
|
64
|
+
},
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
const port = server.port;
|
|
68
|
+
const loginUrl = `${API_BASE}/cli-auth?port=${port}&state=${state}`;
|
|
69
|
+
|
|
70
|
+
// Open browser (await to prevent zombie process).
|
|
71
|
+
const cmd = process.platform === 'darwin' ? 'open' : 'xdg-open';
|
|
72
|
+
const browserProc = Bun.spawn([cmd, loginUrl], { stdout: 'ignore', stderr: 'ignore' });
|
|
73
|
+
browserProc.exited.catch(() => {});
|
|
74
|
+
|
|
75
|
+
console.log(`If the browser didn't open, visit: ${loginUrl}`);
|
|
76
|
+
|
|
77
|
+
// Timeout after 5 minutes.
|
|
78
|
+
const timeout = setTimeout(() => {
|
|
79
|
+
server.stop();
|
|
80
|
+
reject(new Error('Login timed out. Please try again.'));
|
|
81
|
+
}, 300_000);
|
|
82
|
+
timeout.unref();
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// -----------------------------------------------------------------------------
|
|
87
|
+
// Helpers
|
|
88
|
+
// -----------------------------------------------------------------------------
|
|
89
|
+
|
|
90
|
+
async function exchangeCode(code: string): Promise<AuthData> {
|
|
91
|
+
const res = await fetch(`${API_BASE}/api/v0/auth/cli-exchange`, {
|
|
92
|
+
method: 'POST',
|
|
93
|
+
headers: { 'Content-Type': 'application/json' },
|
|
94
|
+
body: JSON.stringify({ code }),
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
if (!res.ok) {
|
|
98
|
+
const body = await res.text();
|
|
99
|
+
throw new Error(`Code exchange failed (${res.status}): ${body}`);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return (await res.json()) as AuthData;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function escapeHtml(s: string): string {
|
|
106
|
+
return s
|
|
107
|
+
.replace(/&/g, '&')
|
|
108
|
+
.replace(/</g, '<')
|
|
109
|
+
.replace(/>/g, '>')
|
|
110
|
+
.replace(/"/g, '"');
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function page(title: string, message: string): string {
|
|
114
|
+
const t = escapeHtml(title);
|
|
115
|
+
const m = escapeHtml(message);
|
|
116
|
+
return `<!DOCTYPE html>
|
|
117
|
+
<html>
|
|
118
|
+
<head><title>${t}</title>
|
|
119
|
+
<style>
|
|
120
|
+
body { font-family: system-ui, sans-serif; display: flex; justify-content: center;
|
|
121
|
+
align-items: center; min-height: 100vh; margin: 0; background: #0a0a0a; color: #fafafa; }
|
|
122
|
+
.card { text-align: center; }
|
|
123
|
+
h1 { font-size: 1.5rem; margin-bottom: 0.5rem; }
|
|
124
|
+
p { color: #a1a1aa; }
|
|
125
|
+
</style>
|
|
126
|
+
</head>
|
|
127
|
+
<body><div class="card"><h1>${t}</h1><p>${m}</p></div></body>
|
|
128
|
+
</html>`;
|
|
129
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { homedir } from 'node:os';
|
|
3
|
+
import { dirname, join } from 'node:path';
|
|
4
|
+
|
|
5
|
+
// -----------------------------------------------------------------------------
|
|
6
|
+
// Types
|
|
7
|
+
// -----------------------------------------------------------------------------
|
|
8
|
+
|
|
9
|
+
export interface AuthUser {
|
|
10
|
+
id: string;
|
|
11
|
+
name: string;
|
|
12
|
+
email: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface AuthData {
|
|
16
|
+
token: string;
|
|
17
|
+
user: AuthUser;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// -----------------------------------------------------------------------------
|
|
21
|
+
// Constants
|
|
22
|
+
// -----------------------------------------------------------------------------
|
|
23
|
+
|
|
24
|
+
const AUTH_FILE = join(homedir(), '.whatcanirun', 'auth.json');
|
|
25
|
+
|
|
26
|
+
// -----------------------------------------------------------------------------
|
|
27
|
+
// Functions
|
|
28
|
+
// -----------------------------------------------------------------------------
|
|
29
|
+
|
|
30
|
+
export function getAuth(): AuthData | null {
|
|
31
|
+
if (!existsSync(AUTH_FILE)) return null;
|
|
32
|
+
try {
|
|
33
|
+
const data = JSON.parse(readFileSync(AUTH_FILE, 'utf-8')) as AuthData;
|
|
34
|
+
if (data.token && data.user?.id) return data;
|
|
35
|
+
return null;
|
|
36
|
+
} catch (e: unknown) {
|
|
37
|
+
console.warn(
|
|
38
|
+
`Warning: could not parse ${AUTH_FILE}: ${e instanceof Error ? e.message : String(e)}. ` +
|
|
39
|
+
'Try deleting it and running `whatcanirun auth login` again.'
|
|
40
|
+
);
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function getToken(): string | null {
|
|
46
|
+
return getAuth()?.token ?? null;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function saveAuth(data: AuthData): void {
|
|
50
|
+
mkdirSync(dirname(AUTH_FILE), { recursive: true });
|
|
51
|
+
writeFileSync(AUTH_FILE, JSON.stringify(data, null, 2) + '\n', { mode: 0o600 });
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function clearAuth(): void {
|
|
55
|
+
if (existsSync(AUTH_FILE)) {
|
|
56
|
+
unlinkSync(AUTH_FILE);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type AggregateMetrics,
|
|
3
|
+
type DerivedMetrics,
|
|
4
|
+
type Manifest,
|
|
5
|
+
type Results,
|
|
6
|
+
type ResultTrial,
|
|
7
|
+
SCHEMA_VERSION,
|
|
8
|
+
} from '@whatcanirun/shared';
|
|
9
|
+
import { existsSync, mkdirSync } from 'fs';
|
|
10
|
+
import { join, resolve } from 'path';
|
|
11
|
+
|
|
12
|
+
import type { DeviceInfo } from '../device/detect';
|
|
13
|
+
import { formatSysinfo } from '../device/detect';
|
|
14
|
+
import type { ModelInfo } from '../model/resolve';
|
|
15
|
+
import type { BenchResult, RuntimeInfo } from '../runtime/types';
|
|
16
|
+
import { bundleFilename, generateBundleId } from '../utils/id';
|
|
17
|
+
|
|
18
|
+
// -----------------------------------------------------------------------------
|
|
19
|
+
// Types
|
|
20
|
+
// -----------------------------------------------------------------------------
|
|
21
|
+
|
|
22
|
+
export interface BundleOpts {
|
|
23
|
+
outputDir: string;
|
|
24
|
+
device: DeviceInfo;
|
|
25
|
+
runtimeInfo: RuntimeInfo;
|
|
26
|
+
model: ModelInfo;
|
|
27
|
+
bench: BenchResult;
|
|
28
|
+
metrics: DerivedMetrics;
|
|
29
|
+
notes?: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// -----------------------------------------------------------------------------
|
|
33
|
+
// Function
|
|
34
|
+
// -----------------------------------------------------------------------------
|
|
35
|
+
|
|
36
|
+
export async function createBundle(opts: BundleOpts): Promise<string> {
|
|
37
|
+
const bundleId = generateBundleId({
|
|
38
|
+
runtime: opts.runtimeInfo.name,
|
|
39
|
+
model: opts.model.display_name,
|
|
40
|
+
});
|
|
41
|
+
const now = new Date();
|
|
42
|
+
const filename = bundleFilename(bundleId);
|
|
43
|
+
|
|
44
|
+
if (!existsSync(opts.outputDir)) {
|
|
45
|
+
mkdirSync(opts.outputDir, { recursive: true });
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const manifest: Manifest = {
|
|
49
|
+
schema_version: SCHEMA_VERSION,
|
|
50
|
+
bundle_id: bundleId,
|
|
51
|
+
created_at: now.toISOString(),
|
|
52
|
+
canonical: false,
|
|
53
|
+
harness: {
|
|
54
|
+
version: '0.1.0',
|
|
55
|
+
git_sha: await getGitSha(),
|
|
56
|
+
},
|
|
57
|
+
device: {
|
|
58
|
+
cpu: opts.device.cpu_model,
|
|
59
|
+
gpu: opts.device.gpu_model,
|
|
60
|
+
ram_gb: opts.device.ram_gb,
|
|
61
|
+
os_name: opts.device.os_name,
|
|
62
|
+
os_version: opts.device.os_version,
|
|
63
|
+
},
|
|
64
|
+
runtime: {
|
|
65
|
+
name: opts.runtimeInfo.name,
|
|
66
|
+
version: opts.runtimeInfo.version,
|
|
67
|
+
build_flags: opts.runtimeInfo.build_flags,
|
|
68
|
+
},
|
|
69
|
+
model: {
|
|
70
|
+
display_name: opts.model.display_name,
|
|
71
|
+
format: opts.model.format,
|
|
72
|
+
artifact_sha256: opts.model.artifact_sha256,
|
|
73
|
+
source: opts.model.source,
|
|
74
|
+
file_size_bytes: opts.model.file_size_bytes,
|
|
75
|
+
parameters: opts.model.parameters,
|
|
76
|
+
quant: opts.model.quant ?? undefined,
|
|
77
|
+
architecture: opts.model.architecture,
|
|
78
|
+
},
|
|
79
|
+
context_length: opts.bench.promptTokens + opts.bench.completionTokens,
|
|
80
|
+
notes: opts.notes,
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
const trials: ResultTrial[] = opts.bench.trials.map((t) => ({
|
|
84
|
+
input_tokens: opts.bench.promptTokens,
|
|
85
|
+
output_tokens: opts.bench.completionTokens,
|
|
86
|
+
ttft_ms: t.promptTps > 0 ? (opts.bench.promptTokens / t.promptTps) * 1000 : 0,
|
|
87
|
+
total_ms: 0,
|
|
88
|
+
decode_tps: t.generationTps,
|
|
89
|
+
weighted_tps:
|
|
90
|
+
opts.bench.promptTokens + opts.bench.completionTokens > 0
|
|
91
|
+
? (opts.bench.promptTokens * t.promptTps + opts.bench.completionTokens * t.generationTps) /
|
|
92
|
+
(opts.bench.promptTokens + opts.bench.completionTokens)
|
|
93
|
+
: 0,
|
|
94
|
+
peak_rss_mb: Math.round(t.peakMemoryGb * 1024 * 10) / 10,
|
|
95
|
+
exit_status: 'ok',
|
|
96
|
+
}));
|
|
97
|
+
|
|
98
|
+
const aggregate: AggregateMetrics = {
|
|
99
|
+
ttft_p50_ms: opts.metrics.ttftP50Ms,
|
|
100
|
+
ttft_p95_ms: opts.metrics.ttftP95Ms,
|
|
101
|
+
decode_tps_mean: opts.metrics.decodeTpsMean,
|
|
102
|
+
weighted_tps_mean: opts.metrics.weightedTpsMean,
|
|
103
|
+
idle_rss_mb: 0,
|
|
104
|
+
peak_rss_mb: opts.metrics.peakRssMb,
|
|
105
|
+
trials_passed: opts.bench.trials.length,
|
|
106
|
+
trials_total: opts.bench.trials.length,
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
const results: Results = { trials, aggregate };
|
|
110
|
+
|
|
111
|
+
const sysinfo = formatSysinfo(opts.device);
|
|
112
|
+
|
|
113
|
+
// Create a temporary directory for bundle contents.
|
|
114
|
+
const tmpDir = join(opts.outputDir, `.tmp_${bundleId}`);
|
|
115
|
+
mkdirSync(tmpDir, { recursive: true });
|
|
116
|
+
|
|
117
|
+
// Write files with deterministic formatting.
|
|
118
|
+
await Bun.write(join(tmpDir, 'manifest.json'), JSON.stringify(manifest, null, 2) + '\n');
|
|
119
|
+
await Bun.write(join(tmpDir, 'results.json'), JSON.stringify(results, null, 2) + '\n');
|
|
120
|
+
await Bun.write(join(tmpDir, 'sysinfo.txt'), sysinfo + '\n');
|
|
121
|
+
|
|
122
|
+
// Create deterministic zip using system zip command.
|
|
123
|
+
const outputPath = resolve(opts.outputDir, filename);
|
|
124
|
+
const zipProc = Bun.spawn(
|
|
125
|
+
['zip', '-rX', outputPath, 'manifest.json', 'results.json', 'sysinfo.txt'],
|
|
126
|
+
{
|
|
127
|
+
cwd: tmpDir,
|
|
128
|
+
stdout: 'ignore',
|
|
129
|
+
stderr: 'pipe',
|
|
130
|
+
}
|
|
131
|
+
);
|
|
132
|
+
const zipCode = await zipProc.exited;
|
|
133
|
+
if (zipCode !== 0) {
|
|
134
|
+
const stderr = await new Response(zipProc.stderr).text();
|
|
135
|
+
throw new Error(`Failed to create bundle zip: ${stderr.trim() || `exit code ${zipCode}`}`);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Clean up temp dir.
|
|
139
|
+
const rmProc = Bun.spawn(['rm', '-rf', tmpDir], {
|
|
140
|
+
stdout: 'ignore',
|
|
141
|
+
stderr: 'ignore',
|
|
142
|
+
});
|
|
143
|
+
await rmProc.exited;
|
|
144
|
+
|
|
145
|
+
return outputPath;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// -----------------------------------------------------------------------------
|
|
149
|
+
// Helpers
|
|
150
|
+
// -----------------------------------------------------------------------------
|
|
151
|
+
|
|
152
|
+
async function getGitSha(): Promise<string> {
|
|
153
|
+
try {
|
|
154
|
+
const proc = Bun.spawn(['git', 'rev-parse', '--short', 'HEAD'], {
|
|
155
|
+
stdout: 'pipe',
|
|
156
|
+
stderr: 'ignore',
|
|
157
|
+
});
|
|
158
|
+
const sha = (await new Response(proc.stdout).text()).trim();
|
|
159
|
+
await proc.exited;
|
|
160
|
+
return sha || 'unknown';
|
|
161
|
+
} catch {
|
|
162
|
+
return 'unknown';
|
|
163
|
+
}
|
|
164
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { validateManifest, validateResults } from '@whatcanirun/shared';
|
|
2
|
+
import { mkdtempSync, rmSync } from 'fs';
|
|
3
|
+
import { tmpdir } from 'os';
|
|
4
|
+
import { join } from 'path';
|
|
5
|
+
|
|
6
|
+
// -----------------------------------------------------------------------------
|
|
7
|
+
// Types
|
|
8
|
+
// -----------------------------------------------------------------------------
|
|
9
|
+
|
|
10
|
+
export interface ValidationResult {
|
|
11
|
+
valid: boolean;
|
|
12
|
+
errors: string[];
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// -----------------------------------------------------------------------------
|
|
16
|
+
// Function
|
|
17
|
+
// -----------------------------------------------------------------------------
|
|
18
|
+
|
|
19
|
+
export async function validateBundle(bundlePath: string): Promise<ValidationResult> {
|
|
20
|
+
const errors: string[] = [];
|
|
21
|
+
|
|
22
|
+
// Extract to temp directory.
|
|
23
|
+
const tmpDir = mkdtempSync(join(tmpdir(), 'whatcanirun-validate-'));
|
|
24
|
+
|
|
25
|
+
try {
|
|
26
|
+
const proc = Bun.spawn(['unzip', '-o', bundlePath, '-d', tmpDir], {
|
|
27
|
+
stdout: 'ignore',
|
|
28
|
+
stderr: 'pipe',
|
|
29
|
+
});
|
|
30
|
+
const code = await proc.exited;
|
|
31
|
+
if (code !== 0) {
|
|
32
|
+
const stderr = await new Response(proc.stderr).text();
|
|
33
|
+
errors.push(`Failed to extract bundle: ${stderr.trim()}`);
|
|
34
|
+
return { valid: false, errors };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Check required files.
|
|
38
|
+
const requiredFiles = ['manifest.json', 'results.json', 'sysinfo.txt'];
|
|
39
|
+
|
|
40
|
+
for (const file of requiredFiles) {
|
|
41
|
+
const f = Bun.file(join(tmpDir, file));
|
|
42
|
+
if (!(await f.exists())) {
|
|
43
|
+
errors.push(`Missing required file: ${file}`);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (errors.length > 0) {
|
|
48
|
+
return { valid: false, errors };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Validate manifest.
|
|
52
|
+
let manifest: unknown;
|
|
53
|
+
try {
|
|
54
|
+
manifest = JSON.parse(await Bun.file(join(tmpDir, 'manifest.json')).text());
|
|
55
|
+
} catch (e: unknown) {
|
|
56
|
+
errors.push(`Invalid manifest.json: ${e instanceof Error ? e.message : String(e)}`);
|
|
57
|
+
return { valid: false, errors };
|
|
58
|
+
}
|
|
59
|
+
errors.push(...validateManifest(manifest));
|
|
60
|
+
|
|
61
|
+
// Validate results
|
|
62
|
+
let results: unknown;
|
|
63
|
+
try {
|
|
64
|
+
results = JSON.parse(await Bun.file(join(tmpDir, 'results.json')).text());
|
|
65
|
+
} catch (e: unknown) {
|
|
66
|
+
errors.push(`Invalid results.json: ${e instanceof Error ? e.message : String(e)}`);
|
|
67
|
+
return { valid: false, errors };
|
|
68
|
+
}
|
|
69
|
+
errors.push(...validateResults(results));
|
|
70
|
+
|
|
71
|
+
// Check artifact hash presence
|
|
72
|
+
const m = manifest as Record<string, unknown>;
|
|
73
|
+
const model = m.model as Record<string, unknown> | undefined;
|
|
74
|
+
if (!model?.artifact_sha256) {
|
|
75
|
+
errors.push('Missing model `artifact_sha256`.');
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return { valid: errors.length === 0, errors };
|
|
79
|
+
} finally {
|
|
80
|
+
rmSync(tmpDir, { recursive: true, force: true });
|
|
81
|
+
}
|
|
82
|
+
}
|
package/src/cli.ts
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
import { defineCommand, runMain } from 'citty';
|
|
3
|
+
|
|
4
|
+
import { auth, run, show, submit, update, validate, version } from './commands';
|
|
5
|
+
|
|
6
|
+
const main = defineCommand({
|
|
7
|
+
meta: {
|
|
8
|
+
name: 'whatcanirun',
|
|
9
|
+
version: '0.1.0',
|
|
10
|
+
description: 'Standardized local LLM inference benchmarks',
|
|
11
|
+
},
|
|
12
|
+
subCommands: {
|
|
13
|
+
auth,
|
|
14
|
+
run,
|
|
15
|
+
show,
|
|
16
|
+
submit,
|
|
17
|
+
update,
|
|
18
|
+
validate,
|
|
19
|
+
version,
|
|
20
|
+
},
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
runMain(main);
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { defineCommand } from 'citty';
|
|
2
|
+
|
|
3
|
+
import { loginViaBrowser } from '../auth/login';
|
|
4
|
+
import { clearAuth, getAuth } from '../auth/token';
|
|
5
|
+
import * as log from '../utils/log';
|
|
6
|
+
|
|
7
|
+
const login = defineCommand({
|
|
8
|
+
meta: {
|
|
9
|
+
name: 'login',
|
|
10
|
+
description: 'Authenticate with whatcani.run',
|
|
11
|
+
},
|
|
12
|
+
async run() {
|
|
13
|
+
const existing = getAuth();
|
|
14
|
+
if (existing) {
|
|
15
|
+
log.info(`Already logged in as ${existing.user.name} (${existing.user.email}).`);
|
|
16
|
+
log.info('Run `whatcanirun auth logout` first to switch accounts.');
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
log.info('Opening browser to sign in...');
|
|
21
|
+
try {
|
|
22
|
+
const auth = await loginViaBrowser();
|
|
23
|
+
log.blank();
|
|
24
|
+
log.success(`Logged in as ${auth.user.name} (${auth.user.email}).`);
|
|
25
|
+
} catch (e: unknown) {
|
|
26
|
+
log.error(e instanceof Error ? e.message : String(e));
|
|
27
|
+
process.exit(1);
|
|
28
|
+
}
|
|
29
|
+
},
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
const logout = defineCommand({
|
|
33
|
+
meta: {
|
|
34
|
+
name: 'logout',
|
|
35
|
+
description: 'Remove stored credentials',
|
|
36
|
+
},
|
|
37
|
+
run() {
|
|
38
|
+
const existing = getAuth();
|
|
39
|
+
if (!existing) {
|
|
40
|
+
log.info('Not logged in.');
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
clearAuth();
|
|
44
|
+
log.success('Logged out.');
|
|
45
|
+
},
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
const status = defineCommand({
|
|
49
|
+
meta: {
|
|
50
|
+
name: 'status',
|
|
51
|
+
description: 'Show current authentication status',
|
|
52
|
+
},
|
|
53
|
+
run() {
|
|
54
|
+
const auth = getAuth();
|
|
55
|
+
if (auth) {
|
|
56
|
+
log.label('Logged in as', `${auth.user.name} (${auth.user.email})`);
|
|
57
|
+
} else {
|
|
58
|
+
log.info('Not logged in. Run `whatcanirun auth login` to authenticate.');
|
|
59
|
+
}
|
|
60
|
+
},
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
const command = defineCommand({
|
|
64
|
+
meta: {
|
|
65
|
+
name: 'auth',
|
|
66
|
+
description: 'Manage authentication',
|
|
67
|
+
},
|
|
68
|
+
subCommands: {
|
|
69
|
+
login,
|
|
70
|
+
logout,
|
|
71
|
+
status,
|
|
72
|
+
},
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
export default command;
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export { default as auth } from './auth';
|
|
2
|
+
export { default as run } from './run';
|
|
3
|
+
export { default as show } from './show';
|
|
4
|
+
export { default as submit } from './submit';
|
|
5
|
+
export { default as update } from './update';
|
|
6
|
+
export { default as validate } from './validate';
|
|
7
|
+
export { default as version } from './version';
|