@archeagle087/costcrew 0.0.0-stage → 0.4.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.
Files changed (55) hide show
  1. package/.github/workflows/ci.yml +21 -0
  2. package/.github/workflows/npm-release.yml +107 -0
  3. package/.github/workflows/prices-monthly.yml +58 -0
  4. package/.github/workflows/release.yml +45 -0
  5. package/Cargo.lock +980 -0
  6. package/Cargo.toml +22 -0
  7. package/LICENSE +21 -0
  8. package/README.md +255 -2
  9. package/assets/advise.png +0 -0
  10. package/assets/board.png +0 -0
  11. package/assets/catalog.png +0 -0
  12. package/assets/demo.png +0 -0
  13. package/assets/help.png +0 -0
  14. package/assets/mascot.png +0 -0
  15. package/assets/prices.png +0 -0
  16. package/assets/scan.png +0 -0
  17. package/assets/spend.png +0 -0
  18. package/assets/ui.png +0 -0
  19. package/bin.js +43 -0
  20. package/data/prices.json +1 -0
  21. package/dist/costcrew-linux-x86_64.tar.gz +0 -0
  22. package/dist/costcrew-macos-aarch64.tar.gz +0 -0
  23. package/dist/costcrew-macos-x86_64.tar.gz +0 -0
  24. package/dist/costcrew-windows-x86_64.zip +0 -0
  25. package/install.ps1 +38 -0
  26. package/install.sh +56 -0
  27. package/package.json +18 -4
  28. package/packaging/npm/bin.js +43 -0
  29. package/packaging/npm/package.json +20 -0
  30. package/packaging/winget/0.4.0/Arch087146.costcrew.installer.yaml +15 -0
  31. package/packaging/winget/0.4.0/Arch087146.costcrew.locale.en-US.yaml +28 -0
  32. package/packaging/winget/0.4.0/Arch087146.costcrew.yaml +5 -0
  33. package/packaging/winget/README.md +31 -0
  34. package/scripts/render_screen.py +221 -0
  35. package/src/advise.rs +154 -0
  36. package/src/args.rs +239 -0
  37. package/src/board.rs +227 -0
  38. package/src/catalog.rs +136 -0
  39. package/src/demo.rs +79 -0
  40. package/src/fetch.rs +155 -0
  41. package/src/main.rs +371 -0
  42. package/src/price.rs +218 -0
  43. package/src/prices.rs +182 -0
  44. package/src/report.rs +194 -0
  45. package/src/scan.rs +593 -0
  46. package/src/spend.rs +452 -0
  47. package/src/ui.rs +367 -0
  48. package/work/costcrew-darwin-arm64/costcrew +0 -0
  49. package/work/costcrew-darwin-arm64/package.json +9 -0
  50. package/work/costcrew-darwin-x64/costcrew +0 -0
  51. package/work/costcrew-darwin-x64/package.json +9 -0
  52. package/work/costcrew-linux-x64/costcrew +0 -0
  53. package/work/costcrew-linux-x64/package.json +9 -0
  54. package/work/costcrew-win32-x64/costcrew.exe +0 -0
  55. package/work/costcrew-win32-x64/package.json +9 -0
package/install.sh ADDED
@@ -0,0 +1,56 @@
1
+ #!/bin/sh
2
+ # costcrew installer — curl -fsSL https://raw.githubusercontent.com/Arch087146/costcrew/main/install.sh | sh
3
+ set -eu
4
+
5
+ REPO="Arch087146/costcrew"
6
+ VERSION="${COSTCREW_VERSION:-latest}"
7
+
8
+ # pick the asset for this machine
9
+ OS="$(uname -s)"
10
+ ARCH="$(uname -m)"
11
+ case "$OS" in
12
+ Darwin) OS_TAG="macos" ;;
13
+ Linux) OS_TAG="linux" ;;
14
+ *) echo "unsupported OS: $OS (windows users: use install.ps1)" >&2; exit 1 ;;
15
+ esac
16
+ case "$ARCH" in
17
+ arm64 | aarch64) ARCH_TAG="aarch64" ;;
18
+ x86_64 | amd64) ARCH_TAG="x86_64" ;;
19
+ *) echo "unsupported arch: $ARCH" >&2; exit 1 ;;
20
+ esac
21
+ ASSET="costcrew-${OS_TAG}-${ARCH_TAG}.tar.gz"
22
+
23
+ if [ "$VERSION" = "latest" ]; then
24
+ URL="https://github.com/${REPO}/releases/latest/download/${ASSET}"
25
+ else
26
+ URL="https://github.com/${REPO}/releases/download/${VERSION}/${ASSET}"
27
+ fi
28
+
29
+ # install destination: writable dir on PATH, else ~/.local/bin
30
+ if [ -n "${COSTCREW_INSTALL_DIR:-}" ]; then
31
+ DEST="$COSTCREW_INSTALL_DIR"
32
+ elif [ -w /usr/local/bin ] 2>/dev/null; then
33
+ DEST="/usr/local/bin"
34
+ else
35
+ DEST="${HOME}/.local/bin"
36
+ mkdir -p "$DEST"
37
+ case ":$PATH:" in
38
+ *":$DEST:"*) ;;
39
+ *) echo "note: add $DEST to your PATH: export PATH=\"\$HOME/.local/bin:\$PATH\"" ;;
40
+ esac
41
+ fi
42
+
43
+ TMP="$(mktemp -d)"
44
+ trap 'rm -rf "$TMP"' EXIT
45
+
46
+ echo "fetching ${ASSET} ..."
47
+ curl -fsSL "$URL" -o "$TMP/costcrew.tar.gz"
48
+ tar xzf "$TMP/costcrew.tar.gz" -C "$TMP"
49
+
50
+ mv "$TMP/costcrew" "$DEST/costcrew"
51
+ chmod +x "$DEST/costcrew"
52
+
53
+ echo
54
+ echo "installed: $(command -v costcrew 2>/dev/null || echo "$DEST/costcrew")"
55
+ "$DEST/costcrew" --version
56
+ echo "run costcrew to open the app · costcrew prices · costcrew spend"
package/package.json CHANGED
@@ -1,6 +1,20 @@
1
1
  {
2
2
  "name": "@archeagle087/costcrew",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.4.0",
4
+ "description": "The AI coding price database, plus what you spend on it — interactive terminal app. Bundled 465+ model prices, your spend ledger, arena leaderboard priced elo-per-dollar.",
5
+ "bin": {
6
+ "costcrew": "bin.js"
7
+ },
8
+ "optionalDependencies": {
9
+ "@archeagle087/costcrew-win32-x64": "0.4.0",
10
+ "@archeagle087/costcrew-linux-x64": "0.4.0",
11
+ "@archeagle087/costcrew-darwin-arm64": "0.4.0",
12
+ "@archeagle087/costcrew-darwin-x64": "0.4.0"
13
+ },
14
+ "os": ["win32", "linux", "darwin"],
15
+ "cpu": ["x64", "arm64"],
16
+ "keywords": ["ai", "llm", "cost", "tokens", "pricing", "claude", "opencode", "cli"],
17
+ "license": "MIT",
18
+ "repository": "github:Arch087146/costcrew",
19
+ "homepage": "https://github.com/Arch087146/costcrew#readme"
20
+ }
@@ -0,0 +1,43 @@
1
+ #!/usr/bin/env node
2
+ // costcrew npm wrapper: finds the platform binary installed as an
3
+ // optionalDependency and execs it with all arguments passed through.
4
+ "use strict";
5
+ const { spawnSync } = require("child_process");
6
+ const path = require("path");
7
+ const fs = require("fs");
8
+
9
+ const PLATFORMS = {
10
+ "win32-x64": "@archeagle087/costcrew-win32-x64/costcrew.exe",
11
+ "linux-x64": "@archeagle087/costcrew-linux-x64/costcrew",
12
+ "darwin-arm64": "@archeagle087/costcrew-darwin-arm64/costcrew",
13
+ "darwin-x64": "@archeagle087/costcrew-darwin-x64/costcrew",
14
+ };
15
+
16
+ const key = `${process.platform}-${process.arch}`;
17
+ const rel = PLATFORMS[key];
18
+ if (!rel) {
19
+ console.error(`costcrew: no prebuilt binary for ${key}`);
20
+ console.error("build from source: https://github.com/Arch087146/costcrew");
21
+ process.exit(1);
22
+ }
23
+
24
+ function resolveBinary() {
25
+ const direct = path.join(__dirname, "..", rel);
26
+ if (fs.existsSync(direct)) return direct;
27
+ // npm can hoist/reshape optionalDependencies — fall back to resolution
28
+ const [pkg, file] = rel.split("/");
29
+ try {
30
+ const dir = path.dirname(require.resolve(`${pkg}/package.json`, { paths: [__dirname] }));
31
+ return path.join(dir, file);
32
+ } catch {
33
+ return direct; // let spawn fail with a clear ENOENT
34
+ }
35
+ }
36
+
37
+ const bin = resolveBinary();
38
+ const result = spawnSync(bin, process.argv.slice(2), { stdio: "inherit" });
39
+ if (result.error) {
40
+ console.error(`costcrew: failed to launch ${bin}: ${result.error.message}`);
41
+ process.exit(1);
42
+ }
43
+ process.exit(result.status ?? 0);
@@ -0,0 +1,20 @@
1
+ {
2
+ "name": "@archeagle087/costcrew",
3
+ "version": "0.4.0",
4
+ "description": "The AI coding price database, plus what you spend on it — interactive terminal app. Bundled 465+ model prices, your spend ledger, arena leaderboard priced elo-per-dollar.",
5
+ "bin": {
6
+ "costcrew": "bin.js"
7
+ },
8
+ "optionalDependencies": {
9
+ "@archeagle087/costcrew-win32-x64": "0.4.0",
10
+ "@archeagle087/costcrew-linux-x64": "0.4.0",
11
+ "@archeagle087/costcrew-darwin-arm64": "0.4.0",
12
+ "@archeagle087/costcrew-darwin-x64": "0.4.0"
13
+ },
14
+ "os": ["win32", "linux", "darwin"],
15
+ "cpu": ["x64", "arm64"],
16
+ "keywords": ["ai", "llm", "cost", "tokens", "pricing", "claude", "opencode", "cli"],
17
+ "license": "MIT",
18
+ "repository": "github:Arch087146/costcrew",
19
+ "homepage": "https://github.com/Arch087146/costcrew#readme"
20
+ }
@@ -0,0 +1,15 @@
1
+ PackageIdentifier: Arch087146.costcrew
2
+ PackageVersion: 0.4.0
3
+ Installers:
4
+ - Architecture: x64
5
+ InstallerType: zip
6
+ InstallerUrl: https://github.com/Arch087146/costcrew/releases/download/v0.4.0/costcrew-windows-x86_64.zip
7
+ InstallerSha256: 9B4F651A49D49AA338E68C008F33E4CC6D00E50D576026D05E81CB33385367CE
8
+ NestedInstallerType: portable
9
+ NestedInstallerFiles:
10
+ - RelativeFilePath: costcrew.exe
11
+ PortableCommandAlias: costcrew
12
+ InstallerSwitches:
13
+ Portable: ''
14
+ ManifestType: installer
15
+ ManifestVersion: 1.9.0
@@ -0,0 +1,28 @@
1
+ PackageIdentifier: Arch087146.costcrew
2
+ PackageVersion: 0.4.0
3
+ PackageLocale: en-US
4
+ Publisher: Arch087146
5
+ PublisherUrl: https://github.com/Arch087146
6
+ PackageName: costcrew
7
+ PackageUrl: https://github.com/Arch087146/costcrew
8
+ License: MIT
9
+ LicenseUrl: https://github.com/Arch087146/costcrew/blob/main/LICENSE
10
+ ShortDescription: The AI coding price database, plus what you spend on it.
11
+ Description: |-
12
+ One binary, two halves. First: a bundled database of what every AI model
13
+ costs - every input/output/cache rate per 1M tokens, works offline, no API
14
+ key, nothing to configure. Second: your own spend, recorded by you, totaled
15
+ by month and service, plus provider-reported truth from your API keys.
16
+ Type `costcrew` for the interactive app; flags like `costcrew prices claude`
17
+ work for scripting.
18
+ Moniker: costcrew
19
+ Tags:
20
+ - ai
21
+ - llm
22
+ - cost
23
+ - pricing
24
+ - tokens
25
+ - cli
26
+ - rust
27
+ ManifestType: defaultLocale
28
+ ManifestVersion: 1.9.0
@@ -0,0 +1,5 @@
1
+ PackageIdentifier: Arch087146.costcrew
2
+ PackageVersion: 0.4.0
3
+ DefaultLocale: en-US
4
+ ManifestType: version
5
+ ManifestVersion: 1.9.0
@@ -0,0 +1,31 @@
1
+ # winget manifests
2
+
3
+ These three files submit costcrew to the official winget community repo.
4
+
5
+ ## How to submit (one-time per release)
6
+
7
+ 1. Fork https://github.com/microsoft/winget-pkgs
8
+ 2. Copy the `0.4.0/` folder (all three YAML files) to
9
+ `manifests/a/Arch087146/costcrew/0.4.0/` in your fork.
10
+ 3. Open a PR against `microsoft/winget-pkgs` with title
11
+ `Add: Arch087146.costcrew version 0.4.0`.
12
+ 4. The PR checks will run winget's manifest validator automatically.
13
+ Once a maintainer approves, the package goes live and
14
+ `winget install Arch087146.costcrew` works.
15
+
16
+ ## Updating hashes
17
+
18
+ `InstallerSha256` in `Arch087146.costcrew.installer.yaml` is the SHA256 of
19
+ `costcrew-windows-x86_64.zip` from release v0.4.0. When a new version is
20
+ released, compute it with:
21
+
22
+ ```powershell
23
+ Get-FileHash .\costcrew-windows-x86_64.zip -Algorithm SHA256
24
+ ```
25
+
26
+ ## Validating locally (optional)
27
+
28
+ ```sh
29
+ winget install Microsoft.Winget.SourceValidation
30
+ winget validate --manifest .\0.4.0\
31
+ ```
@@ -0,0 +1,221 @@
1
+ #!/usr/bin/env python3
2
+ """Render costcrew output to a terminal-style PNG screenshot.
3
+
4
+ Parses the ANSI sequences costcrew actually emits (so screenshots are always
5
+ genuine), draws them in Consolas on a terminal window with chrome.
6
+
7
+ Usage:
8
+ python scripts/render_screen.py assets/demo.png "costcrew demo" -- demo
9
+ python scripts/render_screen.py assets/scan.png "costcrew scan --since 30d" -- scan --since 30d
10
+ python scripts/render_screen.py assets/help.png "costcrew --help" -- --help
11
+
12
+ Commit the PNGs under assets/ — they are pure program output, no hand edits.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import re
18
+ import subprocess
19
+ import sys
20
+ from pathlib import Path
21
+
22
+ from PIL import Image, ImageDraw, ImageFont
23
+
24
+ ROOT = Path(__file__).resolve().parent.parent
25
+ EXE = ROOT / "target" / "release" / "costcrew.exe"
26
+
27
+ FG = (230, 237, 243)
28
+ DIM = (139, 148, 158)
29
+ COLORS = {31: (240, 87, 79), 32: (63, 185, 80), 33: (210, 153, 34), 36: (121, 192, 255)}
30
+ BRIGHT = {31: (255, 123, 114), 32: (86, 211, 100), 33: (227, 179, 65)}
31
+ BG = (13, 17, 23)
32
+ BAR_BG = (30, 34, 43)
33
+ DOTS = [(255, 95, 87), (254, 188, 46), (40, 200, 64)]
34
+
35
+ # xterm 256-color palette for 38;5;N codes (banner gradient etc.)
36
+ _BASE16 = [
37
+ (0, 0, 0), (205, 49, 49), (63, 185, 80), (210, 153, 34),
38
+ (121, 192, 255), (188, 63, 188), (97, 214, 214), (230, 237, 243),
39
+ (139, 148, 158), (255, 123, 114), (86, 211, 100), (227, 179, 65),
40
+ (163, 217, 255), (255, 128, 255), (154, 237, 237), (255, 255, 255),
41
+ ]
42
+ _STEPS = [0, 95, 135, 175, 215, 255]
43
+
44
+ def _pal256(n: int) -> tuple[int, int, int]:
45
+ if n < 16:
46
+ return _BASE16[n]
47
+ if n < 232:
48
+ n -= 16
49
+ r, g, b = (n // 36) % 6, (n // 6) % 6, n % 6
50
+ return (_STEPS[r], _STEPS[g], _STEPS[b])
51
+ g = 8 + (n - 232) * 10
52
+ return (g, g, g)
53
+
54
+ ANSI_RE = re.compile(r"\x1b\[([0-9;]*)m")
55
+
56
+ Cell = tuple[tuple[int, int, int], bool, str] # (color, bold, char)
57
+
58
+
59
+ def tokenize_cli(cli_text: str) -> list[Cell]:
60
+ """Tokenizer for a plain (non-ANSI) command line: whole command in bright FG."""
61
+ return [(FG, True, ch) for ch in cli_text]
62
+
63
+
64
+ def tokenize_ansi(text: str) -> list[Cell]:
65
+ """Tokenize ANSI text: returns (color, bold, char), newline chars included."""
66
+ out: list[Cell] = []
67
+ color = FG
68
+ bold = False
69
+ pos = 0
70
+ for m in ANSI_RE.finditer(text):
71
+ for ch in text[pos : m.start()]:
72
+ out.append((color, bold, ch))
73
+ pos = m.end()
74
+ codes = [int(x) for x in m.group(1).split(";") if x != ""]
75
+ i = 0
76
+ while i < len(codes):
77
+ c = codes[i]
78
+ if c == 38 and i + 2 < len(codes) and codes[i + 1] == 5:
79
+ color = _pal256(codes[i + 2])
80
+ i += 3
81
+ continue
82
+ if c == 0:
83
+ color, bold = FG, False
84
+ elif c == 1:
85
+ bold = True
86
+ elif c == 2:
87
+ color = DIM
88
+ elif c == 22:
89
+ bold = False
90
+ elif c in COLORS:
91
+ base = BRIGHT.get(c) if (bold and c in BRIGHT) else COLORS[c]
92
+ color = base
93
+ i += 1
94
+ out.extend((color, bold, ch) for ch in text[pos:])
95
+ return out
96
+
97
+
98
+ def split_rows(cells: list[Cell]) -> list[list[Cell]]:
99
+ rows: list[list[Cell]] = []
100
+ row: list[Cell] = []
101
+ for cell in cells:
102
+ if cell[2] == "\n":
103
+ rows.append(row)
104
+ row = []
105
+ else:
106
+ row.append(cell)
107
+ if row:
108
+ rows.append(row)
109
+ return rows
110
+
111
+
112
+ def render(rows: list[list[Cell]], prompt: list[Cell], out_path: Path, title: str):
113
+ font = ImageFont.truetype(r"C:\Windows\Fonts\consola.ttf", 26)
114
+ font_bold = ImageFont.truetype(r"C:\Windows\Fonts\consolab.ttf", 26)
115
+ cell_w = font.getlength("0")
116
+ cell_h = 34
117
+ pad = 34
118
+ bar_h = 58
119
+ gap = 12
120
+
121
+ grid = [prompt] + [[]] + rows # prompt line, blank line, then output rows
122
+ max_len = max(len(r) for r in grid)
123
+ width = int(max_len * cell_w) + pad * 2
124
+ height = bar_h + gap + cell_h + gap + len(grid[2:]) * cell_h + pad
125
+
126
+ img = Image.new("RGB", (width, height), BG)
127
+ d = ImageDraw.Draw(img)
128
+
129
+ # title bar + traffic lights
130
+ d.rectangle([0, 0, width, bar_h], fill=BAR_BG)
131
+ for i, tc in enumerate(DOTS):
132
+ cx = 22 + i * 38
133
+ cy = bar_h // 2
134
+ d.ellipse([cx - 11, cy - 11, cx + 11, cy + 11], fill=tc)
135
+ tf = ImageFont.truetype(r"C:\Windows\Fonts\segoeui.ttf", 22)
136
+ tw = d.textlength(title, font=tf)
137
+ d.text(((width - tw) / 2, (bar_h - 24) / 2 + 2), title, font=tf, fill=(200, 205, 215))
138
+
139
+ # prompt + output
140
+ y = bar_h + gap
141
+ for row in grid:
142
+ x = 0.0
143
+ for color, bold, ch in row:
144
+ f = font_bold if bold else font
145
+ # block glyphs fill their whole cell (PIL + Consolas leaves seams)
146
+ if ch == "█":
147
+ d.rectangle([pad + x, y, pad + x + cell_w - 1, y + cell_h - 1], fill=color)
148
+ elif ch == "▀":
149
+ d.rectangle([pad + x, y, pad + x + cell_w - 1, y + cell_h / 2 - 1], fill=color)
150
+ elif ch == "▄":
151
+ d.rectangle([pad + x, y + cell_h / 2, pad + x + cell_w - 1, y + cell_h - 1], fill=color)
152
+ elif ch == "▌":
153
+ d.rectangle([pad + x, y, pad + x + cell_w / 2 - 1, y + cell_h - 1], fill=color)
154
+ elif ch == "▐":
155
+ d.rectangle([pad + x + cell_w / 2, y, pad + x + cell_w - 1, y + cell_h - 1], fill=color)
156
+ else:
157
+ d.text((pad + x, y), ch, font=f, fill=color)
158
+ x += cell_w
159
+ y += cell_h
160
+
161
+ # rounded corners
162
+ mask = Image.new("L", (width, height), 0)
163
+ ImageDraw.Draw(mask).rounded_rectangle([0, 0, width - 1, height - 1], radius=16, fill=255)
164
+ rgba = img.convert("RGBA")
165
+ rgba.putalpha(mask)
166
+ rgba.save(out_path)
167
+ print(f"saved {out_path} ({width}x{height})")
168
+
169
+
170
+ def main():
171
+ if len(sys.argv) < 4 or sys.argv.count("--") != 1:
172
+ sys.exit(__doc__)
173
+ out_path = Path(sys.argv[1])
174
+ out_path.parent.mkdir(exist_ok=True)
175
+ title = sys.argv[2]
176
+
177
+ max_lines = None
178
+ if "--max-lines" in sys.argv:
179
+ i = sys.argv.index("--max-lines")
180
+ max_lines = int(sys.argv[i + 1])
181
+ del sys.argv[i : i + 2]
182
+
183
+ # --from-file: read captured ANSI text instead of running the exe.
184
+ # Used for the interactive app: drive_ui captures its real output
185
+ # while the app is alive, then we render that capture.
186
+ from_file = None
187
+ if "--from-file" in sys.argv:
188
+ i = sys.argv.index("--from-file")
189
+ from_file = Path(sys.argv[i + 1])
190
+ del sys.argv[i : i + 2]
191
+
192
+ run_args = sys.argv[sys.argv.index("--") + 1 :]
193
+
194
+ if from_file:
195
+ body = from_file.read_text(encoding="utf-8") + "\x1b[0m"
196
+ else:
197
+ proc = subprocess.run(
198
+ [str(EXE), *run_args],
199
+ capture_output=True,
200
+ text=True,
201
+ encoding="utf-8",
202
+ errors="replace",
203
+ )
204
+ body = (proc.stdout if proc.stdout.strip() else proc.stderr) + "\x1b[0m"
205
+
206
+ prompt = [
207
+ *[(DIM, False, ch) for ch in "PS C:\\sources\\costcrew> "],
208
+ *tokenize_cli(title),
209
+ (FG, False, "\n"),
210
+ ]
211
+ cells = prompt + tokenize_ansi(body)
212
+ rows = split_rows(cells)
213
+ if max_lines:
214
+ rows = rows[: max_lines + 1] # prompt row + N body rows
215
+ rows.append([]) # ellipsis hint line
216
+ rows.append([(DIM, False, ch) for ch in f" ... full output: {title}"])
217
+ render(rows[1:], rows[0], out_path, title)
218
+
219
+
220
+ if __name__ == "__main__":
221
+ main()
package/src/advise.rs ADDED
@@ -0,0 +1,154 @@
1
+ //! `costcrew advise` — should you have used a cheaper model?
2
+ //!
3
+ //! Honest heuristic mode: recomputes your ACTUAL token mix under cheaper
4
+ //! alternatives that publish benchmark tiers at/above the model you used.
5
+ //! Quality scores are approximate tiers from public evals — the goal is to
6
+ //! surface waste, not to write your model-selection policy.
7
+
8
+ use crate::price::PriceDB;
9
+ use crate::report::*;
10
+ use crate::scan::Row;
11
+
12
+ /// Approximate coding-benchmark tier per model family (5 = frontier).
13
+ /// Rounded, contested, and deliberately conservative: advise only suggests
14
+ /// swapping when the *candidate's tier is >= the model you used*.
15
+ const TIERS: &[(&str, u8)] = &[
16
+ ("claude-opus", 5),
17
+ ("gpt-5", 5),
18
+ ("grok-4", 5),
19
+ ("gemini-3", 5),
20
+ ("kimi-k2", 4),
21
+ ("claude-sonnet", 4),
22
+ ("glm-5", 4),
23
+ ("deepseek-reasoner", 4),
24
+ ("glm-4.6", 3),
25
+ ("deepseek-chat", 3),
26
+ ("qwen", 3),
27
+ ("llama-4", 3),
28
+ ("mistral-large", 3),
29
+ ("gemini-2", 3),
30
+ ("gpt-4.1", 3),
31
+ ("gpt-4o", 3),
32
+ ("o3", 4),
33
+ ("claude-haiku", 3),
34
+ ("mistral-small", 2),
35
+ ("devstral", 2),
36
+ ("codestral", 2),
37
+ ("mistral", 2),
38
+ ("minimax", 2),
39
+ ("command", 2),
40
+ ("phi", 2),
41
+ ];
42
+
43
+ /// Candidate pool for downgrades — cheapest-first, all tiers.
44
+ const CANDIDATES: &[&str] = &[
45
+ "claude-haiku-4",
46
+ "glm-4.6",
47
+ "deepseek-chat",
48
+ "qwen3",
49
+ "mistral-small",
50
+ "devstral",
51
+ "kimi-k2",
52
+ "glm-5",
53
+ "deepseek-reasoner",
54
+ "claude-sonnet-4",
55
+ "gemini-3",
56
+ "gpt-5",
57
+ "claude-opus-4",
58
+ ];
59
+
60
+ fn tier_of(model: &str) -> u8 {
61
+ let m = model.to_lowercase();
62
+ for (k, t) in TIERS {
63
+ if m.starts_with(k) {
64
+ return *t;
65
+ }
66
+ }
67
+ 0
68
+ }
69
+
70
+ /// What this row's token mix would cost on an alternative model.
71
+ fn est_cost(row: &Row, alt: &str, db: &PriceDB) -> Option<f64> {
72
+ let p = db.lookup(alt)?;
73
+ Some(
74
+ (row.input_tokens as f64 / 1e6) * p.input
75
+ + (row.output_tokens as f64 / 1e6) * p.output
76
+ + (row.cache_read_tokens as f64 / 1e6) * p.cache_read
77
+ + (row.cache_write_tokens as f64 / 1e6) * p.cache_write
78
+ + p.per_hour * (row.duration_secs / 3600.0),
79
+ )
80
+ }
81
+
82
+ pub fn print_advise(rows: &[Row], db: &PriceDB, since_days: u32) {
83
+ println!();
84
+ println!(
85
+ " {BOLD}costcrew advise{RESET} {DIM}· cheaper-model verdicts · last {since_days}d{RESET}"
86
+ );
87
+ println!();
88
+
89
+ if !rows.iter().any(|r| r.known_price && r.requests > 0) {
90
+ println!(" {DIM}nothing priced to advise on — run `costcrew` first.{RESET}");
91
+ return;
92
+ }
93
+
94
+ let mut suggested_ok = 0usize;
95
+ for r in rows.iter().filter(|r| r.known_price && r.cost > 0.01) {
96
+ let my_tier = tier_of(&r.model);
97
+ if my_tier == 0 {
98
+ // don't pretend to know about unknown-model quality
99
+ continue;
100
+ }
101
+ let mut alts: Vec<(f64, &str, u8)> = CANDIDATES
102
+ .iter()
103
+ .filter(|c| tier_of(c) >= my_tier)
104
+ .filter_map(|c| est_cost(r, c, db).map(|cost| (cost, *c, tier_of(c))))
105
+ .filter(|(cost, _, _)| *cost < r.cost * 0.9)
106
+ .collect();
107
+ alts.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal));
108
+
109
+ if let Some((alt_cost, alt_name, alt_tier)) = alts.first().copied() {
110
+ let save = r.cost - alt_cost;
111
+ let pct = (1.0 - alt_cost / r.cost).max(0.0) * 100.0;
112
+ let tier_note = if alt_tier == my_tier {
113
+ "same tier"
114
+ } else {
115
+ "higher tier"
116
+ };
117
+ println!(
118
+ " {YELLOW}{:>30}{RESET} -> {GREEN}{:<22}{RESET} {DIM}({} · probable save ${:.2} ({:.0}%)){RESET}",
119
+ truncate(&r.model, 30),
120
+ alt_name,
121
+ tier_note,
122
+ save,
123
+ pct
124
+ );
125
+ println!(
126
+ " {:>30} {DIM} same work would have cost ${:.2} instead of ${:.2}{RESET}",
127
+ "", alt_cost, r.cost
128
+ );
129
+ suggested_ok += 1;
130
+ }
131
+ }
132
+
133
+ if suggested_ok == 0 {
134
+ println!(" {GREEN}✓ no waste pattern detected in the last {since_days}d — your tiers match your spend.{RESET}");
135
+ } else {
136
+ println!();
137
+ println!(
138
+ " {DIM}heuristic: tiers from public coding evals, costs = your exact token mix{RESET}"
139
+ );
140
+ println!(
141
+ " {DIM}at those prices. quality isn't guaranteed — run one task as an A/B and{RESET}"
142
+ );
143
+ println!(" {DIM}compare before rewiring your whole workflow.{RESET}");
144
+ }
145
+ println!();
146
+ }
147
+
148
+ fn truncate(s: &str, max: usize) -> String {
149
+ if s.len() <= max {
150
+ s.to_string()
151
+ } else {
152
+ format!("{}…", &s[..max - 1])
153
+ }
154
+ }