diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 873fbec..2fcf95b 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -15,6 +15,7 @@ "strict": false, "skills": [ "./skills/webup-buddy-reroll", + "./skills/webup-model-price", "./skills/webup-statusline" ] } diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5d1877a..8638845 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,7 +6,7 @@ on: pull_request: jobs: - verify-statusline: + verify-skills: strategy: matrix: os: [ubuntu-latest, macos-latest, windows-latest] @@ -16,6 +16,21 @@ jobs: - uses: oven-sh/setup-bun@v2 + - name: Install jq on Windows + if: runner.os == 'Windows' + run: | + choco install jq -y + "C:\ProgramData\chocolatey\bin" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append + shell: powershell + + - name: Verify model price skill + run: python3 scripts/verify_model_price.py + shell: bash + + - name: Verify statusline replay fixture + run: python3 scripts/verify_statusline_replay.py + shell: bash + - name: Verify statusline generation run: python3 scripts/verify_context.py shell: bash diff --git a/README.ja.md b/README.ja.md index 611651a..672fe24 100644 --- a/README.ja.md +++ b/README.ja.md @@ -21,6 +21,23 @@ npx skills add webup/skills-cc -s webup-statusline -g ## 🎮 スキル一覧 +### 💵 webup-model-price + +[`models.dev`](https://models.dev) からモデル価格を取得して正規化します。provider 指定、複数プラットフォーム比較、catalog キャッシュ、token 数からの費用見積もりに対応します。 + +```bash +# Anthropic Claude の価格 +/webup-model-price claude-opus-4-7 + +# 完全一致する全プロバイダーの価格を比較 +npx -y bun skills/webup-model-price/scripts/price.mjs --model claude-opus-4-7 --all + +# token 数から費用を見積もる +npx -y bun skills/webup-model-price/scripts/price.mjs --provider anthropic --model claude-opus-4-7 --input-tokens 100000 --output-tokens 10000 +``` + +ステータスラインのコスト再計算でも使えるほか、単体で価格確認や provider 比較にも使えます。 + ### 📊 webup-statusline カスタム Claude Code ステータスラインを生成・インストール。**カラム** と **テーマ** を選ぶだけ。`context` と `effort` の 2 カラムは **レベルに応じて色が変わる** ので、一目で状況が分かります。 @@ -74,7 +91,7 @@ npx skills add webup/skills-cc -s webup-statusline -g |--------|----------|----------| | `model` | アクティブモデル名 | 常時 | | `context` | コンテキスト進捗バー + % — **残量に応じて色が変化** | 常時 | -| `cost` | セッション API 支出 `$X.XX`(ゴールド、例:`$0.42`) | `cost.total_cost_usd` が四捨五入して ≥ $0.01 の時 | +| `cost` | 現在モデルの models.dev 単価で再計算したセッション支出 `$X.XX`(ゴールド、例:`$0.42`)。再計算できない場合は Claude Code の `cost.total_cost_usd` にフォールバック | 表示値が四捨五入して ≥ $0.01 の時 | | `effort` | 推論努力レベル — **強度で色分け**(`low`/`medium`/`high`/`xhigh`/`max` 対応) | `~/.claude/settings.json` で `effortLevel` が設定されている時 | | `style` | 出力スタイル名(例:`Explanatory`、`Learning`)— Claude のブランドカラーに合わせたパープル | `output_style.name` が `default` 以外の時 | | `dir` | リポジトリディレクトリ名(ワークツリー内では元リポジトリ名) | 常時 | @@ -107,7 +124,7 @@ npx skills add webup/skills-cc -s webup-statusline -g **effort アイコンの上書き**は `--effort-icon` で。プリセット:`arrow`(`↯`、デフォルト)、`bolt`(`ϟ`)、`flash`(`⚡`)、`reason`(`∴`)、`dot`(`◉`)、`none`(非表示)。任意の文字も受け付けます。 -> ⚠️ **注意:** 生成されたスクリプトは JSON 解析に `jq` が必要です。Windows では WinGet や scoop でインストールされた jq のパスを自動検出します。それでも jq が見つからない場合は、手動でディレクトリを PATH に追加してください。スキルは `~/.claude/scripts/statusline.sh` を自動生成し `~/.claude/settings.json` を更新します。Claude Code を再起動すると反映されます。 +> ⚠️ **注意:** 生成されたスクリプトは JSON 解析に `jq` が必要です。Windows では WinGet、Chocolatey、scoop でインストールされた jq のパスを自動検出します。それでも jq が見つからない場合は、手動でディレクトリを PATH に追加してください。スキルは `~/.claude/scripts/statusline.sh` を自動生成し `~/.claude/settings.json` を更新します。Claude Code を再起動すると反映されます。`cost` カラムは `prompt_id` ごとの再計算結果をローカル状態ディレクトリに記録し、ステータスラインの再描画で二重計上しないようにします。 ### 🎰 webup-buddy-reroll diff --git a/README.md b/README.md index 39f7a3d..71568db 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,23 @@ npx skills add webup/skills-cc -s webup-statusline -g ## 🎮 Skills +### 💵 webup-model-price + +Look up normalized model prices from [`models.dev`](https://models.dev), with provider-aware search, cached catalog data, and token-cost estimation. + +```bash +# Anthropic-hosted Claude price +/webup-model-price claude-opus-4-7 + +# Compare all providers/platforms with an exact match +npx -y bun skills/webup-model-price/scripts/price.mjs --model claude-opus-4-7 --all + +# Estimate usage cost from token counts +npx -y bun skills/webup-model-price/scripts/price.mjs --provider anthropic --model claude-opus-4-7 --input-tokens 100000 --output-tokens 10000 +``` + +This skill powers the statusline cost recalculation path while staying useful on its own for model price checks and provider comparisons. + ### 📊 webup-statusline Generate and install a custom Claude Code status line. Pick your **columns**, pick a **theme** — that's it. Two columns (`context` and `effort`) also **change color based on level**, so the bar tells you at a glance where you stand. @@ -74,7 +91,7 @@ Healthy session — 88% remaining, `effortLevel: medium`, default output style ( |--------|---------------|--------------| | `model` | Active model name | Always | | `context` | Context window progress bar + percentage — **color scales with remaining capacity** | Always | -| `cost` | Session API spend as `$X.XX` in gold (e.g. `$0.42`) | When `cost.total_cost_usd` rounds to ≥ $0.01 | +| `cost` | Recalculated session spend as `$X.XX` in gold (e.g. `$0.42`) using the current model's models.dev price, with Claude Code's `cost.total_cost_usd` as fallback | When the displayed value rounds to ≥ $0.01 | | `effort` | Reasoning effort level — **colored by intensity** (supports `low`/`medium`/`high`/`xhigh`/`max`) | When `effortLevel` is set in `~/.claude/settings.json` | | `style` | Output style name (e.g. `Explanatory`, `Learning`) — shown in purple to match Claude's brand hue | When `output_style.name` is anything other than `default` | | `dir` | Repo directory basename (original repo when inside a worktree) | Always | @@ -107,7 +124,7 @@ The `context` column intentionally has no prefix icon in any theme — the color **Override the effort icon** via `--effort-icon`. Presets: `arrow` (`↯`, default), `bolt` (`ϟ`), `flash` (`⚡`), `reason` (`∴`), `dot` (`◉`), `none` (hide). A raw character is also accepted. -> ⚠️ **Note:** The generated script requires `jq` for JSON parsing. On Windows, it auto-detects jq installed via WinGet or scoop; if jq is still not found, add its directory to your PATH manually. The skill writes to `~/.claude/scripts/statusline.sh` and updates `~/.claude/settings.json` — restart Claude Code to see it. +> ⚠️ **Note:** The generated script requires `jq` for JSON parsing. On Windows, it auto-detects jq installed via WinGet, Chocolatey, or scoop; if jq is still not found, add its directory to your PATH manually. The skill writes to `~/.claude/scripts/statusline.sh` and updates `~/.claude/settings.json` — restart Claude Code to see it. ### 🎰 webup-buddy-reroll diff --git a/README.zh.md b/README.zh.md index 4d71114..7b830b3 100644 --- a/README.zh.md +++ b/README.zh.md @@ -21,6 +21,23 @@ npx skills add webup/skills-cc -s webup-statusline -g ## 🎮 技能列表 +### 💵 webup-model-price + +从 [`models.dev`](https://models.dev) 查询并标准化模型价格,支持指定 provider、跨平台比较、缓存 catalog,以及根据 token 数估算费用。 + +```bash +# 查询 Anthropic Claude 价格 +/webup-model-price claude-opus-4-7 + +# 查看所有精确匹配的平台价格 +npx -y bun skills/webup-model-price/scripts/price.mjs --model claude-opus-4-7 --all + +# 根据 token 数估算费用 +npx -y bun skills/webup-model-price/scripts/price.mjs --provider anthropic --model claude-opus-4-7 --input-tokens 100000 --output-tokens 10000 +``` + +这个技能会被状态栏的费用重算逻辑复用,也可以单独用于查价和比较平台价格。 + ### 📊 webup-statusline 生成并安装自定义 Claude Code 状态栏。选择 **字段** 和 **主题**,仅此而已。其中两个字段(`context` 和 `effort`)会 **随级别动态换色**,一眼看清当前状态。 @@ -74,7 +91,7 @@ npx skills add webup/skills-cc -s webup-statusline -g |------|----------|----------| | `model` | 活跃模型名 | 始终显示 | | `context` | 上下文进度条 + 百分比 —— **颜色随剩余容量变化** | 始终显示 | -| `cost` | 会话累计消费 `$X.XX`(金色,例如 `$0.42`) | 当 `cost.total_cost_usd` 四舍五入后 ≥ $0.01 | +| `cost` | 按当前模型的 models.dev 单价重算会话消费 `$X.XX`(金色,例如 `$0.42`),无法重算时回退到 Claude Code 的 `cost.total_cost_usd` | 当显示值四舍五入后 ≥ $0.01 | | `effort` | 推理努力度 —— **按强度着色**(支持 `low`/`medium`/`high`/`xhigh`/`max`) | 当 `~/.claude/settings.json` 中设置了 `effortLevel` | | `style` | 输出样式名(例如 `Explanatory`、`Learning`)—— 紫色呼应 Claude 品牌色 | 当 `output_style.name` 不是 `default` 时 | | `dir` | 仓库目录名(在工作树中显示原仓库名) | 始终显示 | @@ -107,7 +124,7 @@ npx skills add webup/skills-cc -s webup-statusline -g **替换 effort 图标** 用 `--effort-icon`。预设值:`arrow`(`↯`,默认)、`bolt`(`ϟ`)、`flash`(`⚡`)、`reason`(`∴`)、`dot`(`◉`)、`none`(隐藏)。也可直接传入任意字符。 -> ⚠️ **注意:** 生成的脚本需要 `jq` 解析 JSON。在 Windows 下,脚本会自动检测 WinGet 和 scoop 安装的 jq 路径;若仍无法找到 jq,请手动将其目录添加到 PATH。本技能会自动写入 `~/.claude/scripts/statusline.sh` 并更新 `~/.claude/settings.json` —— 重启 Claude Code 即可生效。 +> ⚠️ **注意:** 生成的脚本需要 `jq` 解析 JSON。在 Windows 下,脚本会自动检测 WinGet、Chocolatey 和 scoop 安装的 jq 路径;若仍无法找到 jq,请手动将其目录添加到 PATH。本技能会自动写入 `~/.claude/scripts/statusline.sh` 并更新 `~/.claude/settings.json` —— 重启 Claude Code 即可生效。`cost` 字段会把每个 `prompt_id` 的重算费用记录到本地状态目录,避免状态栏重复刷新时重复计费。 ### 🎰 webup-buddy-reroll diff --git a/scripts/verify_context.py b/scripts/verify_context.py index 43e7a54..7561137 100644 --- a/scripts/verify_context.py +++ b/scripts/verify_context.py @@ -5,6 +5,7 @@ import sys import tempfile import os +import json THEMES = ["gruvbox", "dracula", "robbyrussell", "minimal"] ELEMENT_SETS = [ @@ -13,6 +14,7 @@ "model,context,cost,effort,style,git,dir,worktree,vim", ] GENERATOR = "skills/webup-statusline/scripts/generate.mjs" +PRICE_FIXTURE = os.path.abspath("tests/fixtures/models-dev-catalog.json") IS_WINDOWS = sys.platform == "win32" @@ -45,6 +47,106 @@ def bash_syntax_ok(script): os.unlink(path) +def run_statusline_script(script, payload, env): + fd, path = tempfile.mkstemp(suffix=".sh") + try: + with os.fdopen(fd, "w", encoding="utf-8", newline="\n") as f: + f.write(script) + return subprocess.run( + ["bash", path], + input=json.dumps(payload), + capture_output=True, + encoding="utf-8", + errors="replace", + env={**os.environ, **env}, + ) + finally: + os.unlink(path) + + +def statusline_payload(model_id, display_name, prompt_id): + return { + "session_id": "verify-model-switch", + "prompt_id": prompt_id, + "model": { + "id": model_id, + "display_name": display_name, + }, + "workspace": { + "current_dir": os.getcwd(), + }, + "cost": { + "total_cost_usd": 0.01, + }, + "context_window": { + "remaining_percentage": 90, + "total_input_tokens": 100000, + "total_output_tokens": 10000, + "context_window_size": 200000, + "current_usage": { + "input_tokens": 100000, + "output_tokens": 10000, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 0, + }, + }, + } + + +def model_switch_cost_ok(script): + """Verify cost is recomputed from per-model prices and repeated refreshes do not double-count.""" + if IS_WINDOWS: + return True, "(skipped on Windows — Linux/macOS CI covers execution)" + + with tempfile.TemporaryDirectory() as state_dir: + env = { + "WEBUP_MODEL_PRICE_CATALOG": PRICE_FIXTURE, + "WEBUP_STATUSLINE_STATE_DIR": state_dir, + } + + missing = run_statusline_script( + script, + statusline_payload("unknown-model", "Unknown Model", "prompt-missing"), + env, + ) + if missing.returncode != 0: + return False, f"missing-price fallback render failed: {missing.stderr.strip()}" + if "$0.01" not in missing.stdout: + return False, f"missing-price fallback expected Claude Code $0.01, got: {missing.stdout!r}" + + first = run_statusline_script( + script, + statusline_payload("claude-sonnet-4-6", "Claude Sonnet 4.6", "prompt-1"), + env, + ) + if first.returncode != 0: + return False, f"first render failed: {first.stderr.strip()}" + if "$0.45" not in first.stdout: + return False, f"first render expected $0.45, got: {first.stdout!r}" + + second = run_statusline_script( + script, + statusline_payload("claude-opus-4-7", "Claude Opus 4.7", "prompt-2"), + env, + ) + if second.returncode != 0: + return False, f"second render failed: {second.stderr.strip()}" + if "$1.20" not in second.stdout: + return False, f"second render expected $1.20, got: {second.stdout!r}" + + repeat = run_statusline_script( + script, + statusline_payload("claude-opus-4-7", "Claude Opus 4.7", "prompt-2"), + env, + ) + if repeat.returncode != 0: + return False, f"repeat render failed: {repeat.stderr.strip()}" + if "$1.20" not in repeat.stdout: + return False, f"repeat render expected $1.20 without double-counting, got: {repeat.stdout!r}" + + return True, "" + + def main(): errors = [] for theme in THEMES: @@ -92,6 +194,11 @@ def main(): if el == "vim" and "vim.mode" not in script: errors.append(f"FAIL missing vim field in {label}") + if theme == "minimal" and elements == "model,context,cost,effort,style,git,dir,worktree,vim": + ok, detail = model_switch_cost_ok(script) + if not ok: + errors.append(f"FAIL model switch cost ledger {label}: {detail}") + if errors: for e in errors: print(e, file=sys.stderr) diff --git a/scripts/verify_model_price.py b/scripts/verify_model_price.py new file mode 100644 index 0000000..28f83a5 --- /dev/null +++ b/scripts/verify_model_price.py @@ -0,0 +1,97 @@ +#!/usr/bin/env python3 +"""Verify models.dev price lookup behavior.""" + +import json +import subprocess +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +PRICE = ROOT / "skills/webup-model-price/scripts/price.mjs" +FIXTURE = ROOT / "tests/fixtures/models-dev-catalog.json" + + +def run_price(*args): + return subprocess.run( + ["bun", str(PRICE), "--catalog", str(FIXTURE), *args], + capture_output=True, + encoding="utf-8", + errors="replace", + ) + + +def assert_ok(result, label): + if result.returncode != 0: + raise AssertionError(f"{label} failed:\nSTDOUT={result.stdout}\nSTDERR={result.stderr}") + + +def main(): + errors = [] + + try: + r = run_price("--provider", "anthropic", "--model", "claude-opus-4-7") + assert_ok(r, "anthropic exact lookup") + data = json.loads(r.stdout) + assert data["provider"] == "anthropic" + assert data["model"] == "claude-opus-4-7" + assert data["costPerMillion"] == { + "input": 5, + "output": 25, + "cacheRead": 0.5, + "cacheWrite": 6.25, + } + except Exception as exc: + errors.append(f"FAIL exact provider lookup: {exc}") + + try: + r = run_price("--model", "claude-opus-4-7", "--all") + assert_ok(r, "all provider lookup") + data = json.loads(r.stdout) + providers = [item["provider"] for item in data["matches"]] + assert providers == ["anthropic", "openrouter", "mirror"], providers + except Exception as exc: + errors.append(f"FAIL all provider lookup: {exc}") + + try: + r = run_price("--model", "anthropic/claude-opus-4.7", "--all") + assert_ok(r, "provider-prefixed lookup") + data = json.loads(r.stdout) + assert [item["provider"] for item in data["matches"]] == ["openrouter"] + except Exception as exc: + errors.append(f"FAIL provider-prefixed lookup: {exc}") + + try: + r = run_price("--model", "claude-opus-4-7", "--no-default-provider") + if r.returncode == 0: + raise AssertionError("ambiguous lookup unexpectedly succeeded") + if "ambiguous" not in r.stderr.lower() or "anthropic" not in r.stderr or "openrouter" not in r.stderr: + raise AssertionError(f"unexpected ambiguity message: {r.stderr}") + except Exception as exc: + errors.append(f"FAIL ambiguous lookup: {exc}") + + try: + r = run_price( + "--provider", "anthropic", + "--model", "claude-opus-4-7", + "--input-tokens", "100000", + "--output-tokens", "10000", + "--cache-read-tokens", "20000", + "--cache-write-tokens", "5000", + ) + assert_ok(r, "cost estimate") + data = json.loads(r.stdout) + assert data["estimatedCostUsd"] == 0.79125, data["estimatedCostUsd"] + except Exception as exc: + errors.append(f"FAIL cost estimate: {exc}") + + if errors: + for error in errors: + print(error, file=sys.stderr) + print(f"\n{len(errors)} check(s) failed", file=sys.stderr) + sys.exit(1) + + print("Model price lookup checks passed") + + +if __name__ == "__main__": + main() diff --git a/scripts/verify_statusline_replay.py b/scripts/verify_statusline_replay.py new file mode 100644 index 0000000..e902902 --- /dev/null +++ b/scripts/verify_statusline_replay.py @@ -0,0 +1,109 @@ +#!/usr/bin/env python3 +"""Replay Claude Code-shaped statusline payloads through the generated script.""" + +import json +import os +import subprocess +import sys +import shutil +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +GENERATOR = ROOT / "skills/webup-statusline/scripts/generate.mjs" +PRICE_FIXTURE = Path("tests/fixtures/models-dev-catalog.json") +EVENT_FIXTURE = ROOT / "tests/fixtures/statusline-events.jsonl" + + +def bash_command(*args): + if os.name == "nt": + git_bash = Path(os.environ.get("ProgramFiles", r"C:\Program Files")) / "Git/bin/bash.exe" + if git_bash.exists(): + return [str(git_bash), *args] + return ["bash", *args] + + +def run(cmd, **kwargs): + return subprocess.run( + cmd, + capture_output=True, + encoding="utf-8", + errors="replace", + cwd=ROOT, + **kwargs, + ) + + +def main(): + generated = run([ + "bun", + str(GENERATOR), + "--elements", + "model,context,cost", + "--theme", + "minimal", + ]) + if generated.returncode != 0: + print(generated.stderr, file=sys.stderr) + return generated.returncode + + tmp = ROOT / ".tmp-statusline-replay" + if tmp.exists(): + shutil.rmtree(tmp) + try: + tmp.mkdir() + script_path = tmp / "statusline.sh" + state_dir = Path(".tmp-statusline-replay/state") + debug_dump = Path(".tmp-statusline-replay/statusline-debug.jsonl") + script_path.write_text(generated.stdout, encoding="utf-8", newline="\n") + + env = { + **os.environ, + "WEBUP_MODEL_PRICE_CATALOG": PRICE_FIXTURE.as_posix(), + "WEBUP_STATUSLINE_STATE_DIR": state_dir.as_posix(), + "WEBUP_STATUSLINE_DEBUG_DUMP": debug_dump.as_posix(), + } + + expected_costs = ["$0.45", "$1.20", "$1.20"] + events = EVENT_FIXTURE.read_text(encoding="utf-8").splitlines() + for index, (event, expected) in enumerate(zip(events, expected_costs), start=1): + result = run(bash_command(script_path.relative_to(ROOT).as_posix()), input=event, env=env) + if result.returncode != 0: + traced = run(bash_command("-x", script_path.relative_to(ROOT).as_posix()), input=event, env=env) + print( + f"event {index} failed with exit {result.returncode}\n" + f"STDOUT={result.stdout!r}\n" + f"STDERR={result.stderr!r}\n" + f"TRACE STDOUT={traced.stdout!r}\n" + f"TRACE STDERR={traced.stderr!r}", + file=sys.stderr, + ) + return result.returncode + if expected not in result.stdout: + print(f"event {index} expected {expected}, got {result.stdout!r}", file=sys.stderr) + return 1 + + debug_dump_abs = ROOT / debug_dump + if not debug_dump_abs.exists(): + print("debug dump was not created", file=sys.stderr) + return 1 + + dumped = debug_dump_abs.read_text(encoding="utf-8").splitlines() + if len(dumped) != len(events): + print(f"expected {len(events)} debug dump lines, got {len(dumped)}", file=sys.stderr) + return 1 + + for index, line in enumerate(dumped, start=1): + payload = json.loads(line) + if "prompt_id" not in payload or "model" not in payload: + print(f"debug dump line {index} is not replayable: {payload}", file=sys.stderr) + return 1 + + print("Statusline replay checks passed") + return 0 + finally: + if tmp.exists(): + shutil.rmtree(tmp) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/skills/webup-model-price/SKILL.md b/skills/webup-model-price/SKILL.md new file mode 100644 index 0000000..7105604 --- /dev/null +++ b/skills/webup-model-price/SKILL.md @@ -0,0 +1,74 @@ +--- +name: webup-model-price +description: Look up and normalize latest model pricing from models.dev, with provider-aware search, cached catalog data, and token cost estimation. Use when Codex needs model unit prices, to compare pricing across providers/platforms, to estimate model usage cost from token counts, or to support Claude Code/statusline cost calculations. +--- + +# Model Price Lookup + +Query `models.dev` pricing data and return a normalized JSON shape that other skills can consume. + +## Script Directory + +All scripts are in this skill's `scripts/` directory. + +| Script | Purpose | +|--------|---------| +| `scripts/price.mjs` | Fetch/cache `models.dev` catalog data, look up provider-specific model prices, and optionally estimate token cost | + +## Usage + +```bash +npx -y bun ${SKILL_DIR}/scripts/price.mjs --provider anthropic --model claude-opus-4-7 +npx -y bun ${SKILL_DIR}/scripts/price.mjs --model claude-opus-4-7 --all +npx -y bun ${SKILL_DIR}/scripts/price.mjs --provider anthropic --model claude-opus-4-7 --input-tokens 100000 --output-tokens 10000 +``` + +## Options + +| Flag | Default | Description | +|------|---------|-------------| +| `--model ` | required | Model ID to look up, such as `claude-opus-4-7` or a provider-prefixed ID | +| `--provider ` | none | Exact provider/platform key from `models.dev`, such as `anthropic`, `openrouter`, or `bedrock` | +| `--all` | off | Return every provider with an exact model match | +| `--no-default-provider` | off | Disable the default Anthropic lookup and require ambiguity resolution | +| `--catalog ` | none | Read a local `models.dev` catalog JSON file, mainly for tests/offline use | +| `--cache-dir ` | `$XDG_CACHE_HOME/webup-model-price` or `~/.cache/webup-model-price` | Cache directory for downloaded catalog data | +| `--refresh` | off | Force a fresh catalog download | +| `--ttl-seconds ` | `86400` | Cache freshness window | +| `--input-tokens ` | `0` | Fresh input tokens for cost estimation | +| `--output-tokens ` | `0` | Output tokens for cost estimation | +| `--cache-read-tokens ` | `0` | Cache read tokens for cost estimation | +| `--cache-write-tokens ` | `0` | Cache creation/write tokens for cost estimation | + +## Provider Handling + +Prefer exact provider lookup when the calling context knows the platform: + +```bash +npx -y bun ${SKILL_DIR}/scripts/price.mjs --provider anthropic --model claude-opus-4-7 +``` + +Use `--all` for comparison across platforms. Without `--provider`, the script first tries `anthropic` because Claude Code uses Anthropic-hosted model IDs. If that misses, it searches all providers. If several providers match, it exits with an ambiguity message and tells the caller to pass `--provider` or `--all`. + +## Output Shape + +Single-provider lookup returns: + +```json +{ + "source": "models.dev", + "provider": "anthropic", + "providerName": "Anthropic", + "model": "claude-opus-4-7", + "displayName": "Claude Opus 4.7", + "updatedAt": "2026-04-16", + "costPerMillion": { + "input": 5, + "output": 25, + "cacheRead": 0.5, + "cacheWrite": 6.25 + } +} +``` + +When token counts are provided, the result also includes `usageTokens` and `estimatedCostUsd`. diff --git a/skills/webup-model-price/agents/openai.yaml b/skills/webup-model-price/agents/openai.yaml new file mode 100644 index 0000000..46f5b7e --- /dev/null +++ b/skills/webup-model-price/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Model Price" + short_description: "Lookup normalized models.dev prices" + default_prompt: "Use $webup-model-price to look up the latest per-token price for claude-opus-4-7." diff --git a/skills/webup-model-price/scripts/price.mjs b/skills/webup-model-price/scripts/price.mjs new file mode 100644 index 0000000..ee80497 --- /dev/null +++ b/skills/webup-model-price/scripts/price.mjs @@ -0,0 +1,232 @@ +#!/usr/bin/env bun + +import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from 'fs' +import { join } from 'path' +import { homedir } from 'os' + +const MODELS_DEV_CATALOG_URL = 'https://models.dev/catalog.json' +const DEFAULT_PROVIDER = 'anthropic' + +const args = process.argv.slice(2) + +function getArg(name, fallback = '') { + const index = args.indexOf(`--${name}`) + return index >= 0 && index + 1 < args.length ? args[index + 1] : fallback +} + +function hasFlag(name) { + return args.includes(`--${name}`) +} + +function numberArg(name) { + const raw = getArg(name, '0') + const value = Number(raw) + if (!Number.isFinite(value) || value < 0) { + throw new Error(`--${name} must be a non-negative number`) + } + return value +} + +function cacheBaseDir() { + if (process.env.XDG_CACHE_HOME) return process.env.XDG_CACHE_HOME + return join(homedir(), '.cache') +} + +function cacheDir() { + return getArg('cache-dir', join(cacheBaseDir(), 'webup-model-price')) +} + +function ttlMs() { + const seconds = Number(getArg('ttl-seconds', '86400')) + if (!Number.isFinite(seconds) || seconds < 0) { + throw new Error('--ttl-seconds must be a non-negative number') + } + return seconds * 1000 +} + +function readJson(path) { + return JSON.parse(readFileSync(path, 'utf8')) +} + +function cachedCatalogPath() { + return join(cacheDir(), 'catalog.json') +} + +function cacheIsFresh(path) { + if (!existsSync(path)) return false + return Date.now() - statSync(path).mtimeMs <= ttlMs() +} + +async function loadCatalog() { + const catalogPath = getArg('catalog') + if (catalogPath) return readJson(catalogPath) + + const path = cachedCatalogPath() + if (!hasFlag('refresh') && cacheIsFresh(path)) return readJson(path) + + try { + const response = await fetch(MODELS_DEV_CATALOG_URL) + if (!response.ok) throw new Error(`HTTP ${response.status}`) + const text = await response.text() + const parsed = JSON.parse(text) + mkdirSync(cacheDir(), { recursive: true }) + writeFileSync(path, JSON.stringify(parsed)) + return parsed + } catch (error) { + if (existsSync(path)) return readJson(path) + throw new Error(`Unable to fetch ${MODELS_DEV_CATALOG_URL}: ${error.message}`) + } +} + +function unique(values) { + return [...new Set(values.filter(Boolean))] +} + +function modelCandidates(providerId, model) { + const candidates = [model] + if (model.startsWith(`${providerId}/`)) { + candidates.push(model.slice(providerId.length + 1)) + } + if (providerId === DEFAULT_PROVIDER && model.startsWith(`${DEFAULT_PROVIDER}/`)) { + candidates.push(model.slice(DEFAULT_PROVIDER.length + 1)) + } + return unique(candidates) +} + +function normalizeCost(cost = {}) { + const input = Number(cost.input ?? 0) + const output = Number(cost.output ?? 0) + const cacheRead = Number(cost.cache_read ?? cost.cacheRead ?? input) + const cacheWrite = Number(cost.cache_write ?? cost.cacheWrite ?? input) + if (![input, output, cacheRead, cacheWrite].every(Number.isFinite)) return null + return { input, output, cacheRead, cacheWrite } +} + +function findInProvider(providerId, providerData, model) { + const models = providerData?.models ?? {} + for (const candidate of modelCandidates(providerId, model)) { + if (models[candidate]?.cost) { + return { providerId, providerData, modelKey: candidate, modelData: models[candidate] } + } + } + for (const [modelKey, modelData] of Object.entries(models)) { + if (modelData?.id === model && modelData?.cost) { + return { providerId, providerData, modelKey, modelData } + } + } + return null +} + +function findAll(catalog, model) { + const providers = catalog.providers ?? {} + const matches = [] + for (const [providerId, providerData] of Object.entries(providers)) { + const match = findInProvider(providerId, providerData, model) + if (match) matches.push(match) + } + return matches +} + +function roundUsd(value) { + return Number(value.toFixed(8)) +} + +function buildResult(match, usage) { + const costPerMillion = normalizeCost(match.modelData.cost) + const result = { + source: 'models.dev', + provider: match.providerId, + providerName: match.providerData.name ?? match.providerId, + model: match.modelData.id ?? match.modelKey, + modelKey: match.modelKey, + displayName: match.modelData.name ?? match.modelData.id ?? match.modelKey, + updatedAt: match.modelData.last_updated ?? match.modelData.updated_at ?? null, + costPerMillion, + } + + const usageTotal = usage.input + usage.output + usage.cacheRead + usage.cacheWrite + if (usageTotal > 0) { + result.usageTokens = usage + result.estimatedCostUsd = roundUsd( + ( + usage.input * costPerMillion.input + + usage.output * costPerMillion.output + + usage.cacheRead * costPerMillion.cacheRead + + usage.cacheWrite * costPerMillion.cacheWrite + ) / 1000000, + ) + } + + return result +} + +function usageArgs() { + return { + input: numberArg('input-tokens'), + output: numberArg('output-tokens'), + cacheRead: numberArg('cache-read-tokens'), + cacheWrite: numberArg('cache-write-tokens'), + } +} + +function printJson(value) { + process.stdout.write(`${JSON.stringify(value, null, 2)}\n`) +} + +function fail(message, code = 1) { + process.stderr.write(`${message}\n`) + process.exit(code) +} + +async function main() { + const model = getArg('model') + if (!model) fail('--model is required') + + const provider = getArg('provider') + const allProviders = hasFlag('all') + const noDefaultProvider = hasFlag('no-default-provider') + const catalog = await loadCatalog() + const usage = usageArgs() + + if (provider) { + const providerData = catalog.providers?.[provider] + if (!providerData) fail(`Provider not found: ${provider}`) + const match = findInProvider(provider, providerData, model) + if (!match) fail(`Model not found for provider ${provider}: ${model}`, 2) + printJson(buildResult(match, usage)) + return + } + + const matches = findAll(catalog, model) + if (allProviders) { + printJson({ + source: 'models.dev', + model, + matches: matches.map((match) => buildResult(match, usage)), + }) + return + } + + if (!noDefaultProvider) { + const defaultProvider = catalog.providers?.[DEFAULT_PROVIDER] + const defaultMatch = defaultProvider ? findInProvider(DEFAULT_PROVIDER, defaultProvider, model) : null + if (defaultMatch) { + printJson(buildResult(defaultMatch, usage)) + return + } + } + + if (matches.length === 1) { + printJson(buildResult(matches[0], usage)) + return + } + + if (matches.length > 1) { + const providers = matches.map((match) => match.providerId).join(', ') + fail(`Ambiguous model ${model} matched providers: ${providers}. Pass --provider or --all.`, 2) + } + + fail(`Model not found: ${model}`, 2) +} + +main().catch((error) => fail(error.message)) diff --git a/skills/webup-statusline/SKILL.md b/skills/webup-statusline/SKILL.md index 76cc9a3..609a68f 100644 --- a/skills/webup-statusline/SKILL.md +++ b/skills/webup-statusline/SKILL.md @@ -1,6 +1,6 @@ --- name: webup-statusline -description: Generate and install a custom Claude Code status line with selectable columns (model, context, effort level, git, dir, worktree, vim) and a color theme. Context and effort elements color-change based on level. Triggers on "status line", "statusline", "customize status", "status bar", "effort level display", "状态栏", "ステータスライン", or similar. +description: Generate and install a custom Claude Code status line with selectable columns (model, context, recalculated cost, effort level, git, dir, worktree, vim) and a color theme. Context and effort elements color-change based on level; cost can be recalculated from models.dev prices when token usage is available. Triggers on "status line", "statusline", "customize status", "status bar", "cost display", "effort level display", "状态栏", "ステータスライン", or similar. --- # Status Line Generator @@ -29,7 +29,7 @@ This skill generates a bash script tailored to your preferences and installs it ## Prerequisites -- **jq** — required by the generated status line script to parse JSON input from Claude Code. On Windows, the script auto-detects jq installed via WinGet or scoop; if jq is still not found, add its directory to your PATH manually. +- **jq** — required by the generated status line script to parse JSON input from Claude Code. On Windows, the script auto-detects jq installed via WinGet, Chocolatey, or scoop; if jq is still not found, add its directory to your PATH manually. - **Bun** — required to run the generator. Use `npx -y bun` if not installed globally. ## Usage @@ -57,7 +57,7 @@ npx -y bun ${SKILL_DIR}/scripts/generate.mjs --elements model,context,effort,git |--------|-------------|-------------| | `model` | Active model name (e.g. "Opus 4.7") | `model.display_name` | | `context` | Progress bar + percentage — **color changes with remaining capacity** | `context_window.remaining_percentage` | -| `cost` | Session API spend formatted as `$X.XX` in gold — hidden when rounds to `$0.00` | `cost.total_cost_usd` from input JSON | +| `cost` | Recalculated session spend formatted as `$X.XX` in gold — hidden when rounds to `$0.00` | `model.id` + `context_window.current_usage` + `models.dev` Anthropic prices, falling back to `cost.total_cost_usd` | | `effort` | Reasoning effort level — **color changes with level** | `effortLevel` in `~/.claude/settings.local.json` → `~/.claude/settings.json` | | `style` | Output style name (e.g. Explanatory, Learning) — hidden when "default" | `output_style.name` from input JSON | | `git` | Git branch name (yellow when dirty) | `worktree.branch` → git CLI | @@ -168,7 +168,7 @@ Unspecified fields use defaults: `model,context,effort,git,dir` columns, `gruvbo ``` ◈ Opus 4.7 | [■■■■■■■■■■□□□□□□□□□□] 51% | $0.42 | ↯ high | ❋ Explanatory | ⌂ clawmaster | ⊕ worktree:46a6 | ⎇ feat/xyz ``` -(bar yellow — 49% remaining; `$0.42` gold session spend next to the bar; effort "high" bold red; purple `❋ Explanatory` sits between effort and dir; context carries no prefix icon — the bar is already visual enough) +(bar yellow — 49% remaining; `$0.42` gold recalculated spend next to the bar; effort "high" bold red; purple `❋ Explanatory` sits between effort and dir; context carries no prefix icon — the bar is already visual enough) **Gruvbox Dark** (model + context + effort + dir + git), remaining=88%, effort=medium: ``` @@ -186,5 +186,9 @@ Claude Opus 4.7 · low · skills-cc · main - Generated script is saved to `~/.claude/scripts/statusline.sh` - Running the skill again overwrites the existing script — just re-run to change theme or columns -- The script uses `jq` to parse JSON input — make sure it's installed. On Windows, the script auto-detects WinGet and scoop jq paths; if jq is still not found, add it to PATH manually. +- The script uses `jq` to parse JSON input — make sure it's installed. On Windows, the script auto-detects WinGet, Chocolatey, and scoop jq paths; if jq is still not found, add it to PATH manually. - Git dirty detection uses `--no-optional-locks` to avoid interfering with other git operations +- The `cost` column keeps a per-session ledger in `${XDG_STATE_HOME:-~/.local/state}/webup-statusline` keyed by `session_id` and `prompt_id`, so repeated statusline refreshes do not double-count a prompt. +- Pricing data is read from `${XDG_CACHE_HOME:-~/.cache}/webup-model-price/catalog.json`; when missing or stale, the generated script tries to refresh `https://models.dev/catalog.json` with `curl` and otherwise falls back to Claude Code's `cost.total_cost_usd`. +- Advanced overrides: `WEBUP_MODEL_PRICE_PROVIDER`, `WEBUP_MODEL_PRICE_CATALOG`, `WEBUP_MODEL_PRICE_CACHE_DIR`, `WEBUP_MODEL_PRICE_TTL_SECONDS`, `WEBUP_STATUSLINE_STATE_DIR`, and `WEBUP_STATUSLINE_DEBUG_DUMP`. +- Set `WEBUP_STATUSLINE_DEBUG_DUMP=/tmp/cc-statusline.jsonl` during a real Claude Code session to capture compact statusline payloads for local replay tests. diff --git a/skills/webup-statusline/scripts/generate.mjs b/skills/webup-statusline/scripts/generate.mjs index ad784c6..45f3eaa 100644 --- a/skills/webup-statusline/scripts/generate.mjs +++ b/skills/webup-statusline/scripts/generate.mjs @@ -167,6 +167,7 @@ function buildScript() { ' for _jq_dir in \\', ' "/c/Users/$USERNAME/AppData/Local/Microsoft/WinGet/Links" \\', ' "/c/Users/$USERNAME/AppData/Local/Microsoft/WinGet/Packages/jqlang.jq_Microsoft.Winget.Source_8wekyb3d8bbwe" \\', + ' "/c/ProgramData/chocolatey/bin" \\', ' "$HOME/scoop/shims" \\', ' ; do', ' if [ -d "$_jq_dir" ] && { [ -x "$_jq_dir/jq" ] || [ -x "$_jq_dir/jq.exe" ]; }; then', @@ -209,8 +210,18 @@ function buildScript() { p('# Read JSON from stdin') p('input=$(cat)') p('') + p('# Optional debug capture for replaying real Claude Code statusline payloads') + p('if [ -n "${WEBUP_STATUSLINE_DEBUG_DUMP:-}" ]; then') + p(' _debug_dir=$(dirname "$WEBUP_STATUSLINE_DEBUG_DUMP")') + p(' mkdir -p "$_debug_dir" 2>/dev/null || true') + p(' printf "%s\\n" "$input" | jq -c . >> "$WEBUP_STATUSLINE_DEBUG_DUMP" 2>/dev/null || true') + p('fi') + p('') // Extract fields based on selected elements + if (elements.includes('model') || elements.includes('cost')) { + p("model_id=$(echo \"$input\" | jq -r '.model.id // \"\"')") + } if (elements.includes('model')) { p("model=$(echo \"$input\" | jq -r '.model.display_name // \"\"')") } @@ -243,6 +254,12 @@ function buildScript() { if (elements.includes('cost')) { // total_cost_usd is a float; jq returns empty string when missing p("cost_usd=$(echo \"$input\" | jq -r '.cost.total_cost_usd // empty')") + p("session_id=$(echo \"$input\" | jq -r '.session_id // empty')") + p("prompt_id=$(echo \"$input\" | jq -r '.prompt_id // empty')") + p("usage_input_tokens=$(echo \"$input\" | jq -r '.context_window.current_usage.input_tokens // 0')") + p("usage_output_tokens=$(echo \"$input\" | jq -r '.context_window.current_usage.output_tokens // 0')") + p("usage_cache_write_tokens=$(echo \"$input\" | jq -r '.context_window.current_usage.cache_creation_input_tokens // 0')") + p("usage_cache_read_tokens=$(echo \"$input\" | jq -r '.context_window.current_usage.cache_read_input_tokens // 0')") } if (elements.includes('worktree') || elements.includes('git')) { p("worktree_name=$(echo \"$input\" | jq -r '.worktree.name // empty')") @@ -338,6 +355,95 @@ function buildScript() { p('') } + if (elements.includes('cost')) { + p('# Recalculate prompt cost from current model pricing when usage data is available') + p('display_cost_usd="$cost_usd"') + p('price_provider="${WEBUP_MODEL_PRICE_PROVIDER:-anthropic}"') + p('price_catalog_file="${WEBUP_MODEL_PRICE_CATALOG:-}"') + p('usage_total=$((usage_input_tokens + usage_output_tokens + usage_cache_write_tokens + usage_cache_read_tokens))') + p('') + p('if [ -z "$price_catalog_file" ]; then') + p(' price_cache_dir="${WEBUP_MODEL_PRICE_CACHE_DIR:-${XDG_CACHE_HOME:-$HOME/.cache}/webup-model-price}"') + p(' price_catalog_file="$price_cache_dir/catalog.json"') + p(' price_ttl="${WEBUP_MODEL_PRICE_TTL_SECONDS:-86400}"') + p(' mkdir -p "$price_cache_dir" 2>/dev/null || true') + p(' price_now=$(date +%s)') + p(' price_mtime=0') + p(' if [ -f "$price_catalog_file" ]; then') + p(' price_mtime=$(stat -f %m "$price_catalog_file" 2>/dev/null || stat -c %Y "$price_catalog_file" 2>/dev/null || echo 0)') + p(' fi') + p(' if [ ! -f "$price_catalog_file" ] || [ $((price_now - price_mtime)) -gt "$price_ttl" ]; then') + p(' if command -v curl >/dev/null 2>&1; then') + p(' price_tmp="${price_catalog_file}.$$"') + p(' if curl -fsSL --max-time 2 https://models.dev/catalog.json > "$price_tmp" 2>/dev/null; then') + p(' mv "$price_tmp" "$price_catalog_file" 2>/dev/null || true') + p(' else') + p(' rm -f "$price_tmp" 2>/dev/null || true') + p(' fi') + p(' fi') + p(' fi') + p('fi') + p('') + p('price_json=""') + p('if [ -n "$model_id" ] && [ -f "$price_catalog_file" ]; then') + p(' price_json=$(jq -c --arg provider "$price_provider" --arg model "$model_id" \'') + p(' def keys_for($provider; $model):') + p(' ([$model]') + p(' + (if ($model | startswith($provider + "/")) then [($model | ltrimstr($provider + "/"))] else [] end)') + p(' + (if ($provider == "anthropic" and ($model | startswith("anthropic/"))) then [($model | ltrimstr("anthropic/"))] else [] end))') + p(' | unique;') + p(' (.providers[$provider] // empty) as $p') + p(' | if ($p == null) then empty else') + p(' first(keys_for($provider; $model)[] as $key') + p(' | $p.models[$key]?') + p(' | select(.cost)') + p(' | {input:(.cost.input // 0), output:(.cost.output // 0), cache_read:(.cost.cache_read // .cost.input // 0), cache_write:(.cost.cache_write // .cost.input // 0)})') + p(' end') + p(' \' "$price_catalog_file" 2>/dev/null)') + p('fi') + p('') + p('if [ -n "$price_json" ] && [ -n "$session_id" ] && [ -n "$prompt_id" ] && [ "$usage_total" -gt 0 ]; then') + p(' price_input=$(echo "$price_json" | jq -r \'.input // 0\')') + p(' price_output=$(echo "$price_json" | jq -r \'.output // 0\')') + p(' price_cache_read=$(echo "$price_json" | jq -r \'.cache_read // 0\')') + p(' price_cache_write=$(echo "$price_json" | jq -r \'.cache_write // 0\')') + p(' event_cost_usd=$(awk \\') + p(' -v input_tokens="$usage_input_tokens" \\') + p(' -v output_tokens="$usage_output_tokens" \\') + p(' -v cache_read_tokens="$usage_cache_read_tokens" \\') + p(' -v cache_write_tokens="$usage_cache_write_tokens" \\') + p(' -v input_price="$price_input" \\') + p(' -v output_price="$price_output" \\') + p(' -v cache_read_price="$price_cache_read" \\') + p(' -v cache_write_price="$price_cache_write" \\') + p(' \'BEGIN { printf "%.8f", ((input_tokens*input_price) + (output_tokens*output_price) + (cache_read_tokens*cache_read_price) + (cache_write_tokens*cache_write_price)) / 1000000 }\')') + p(' state_dir="${WEBUP_STATUSLINE_STATE_DIR:-${XDG_STATE_HOME:-$HOME/.local/state}/webup-statusline}"') + p(' mkdir -p "$state_dir" 2>/dev/null || true') + p(' safe_session_id=$(printf "%s" "$session_id" | tr -c "A-Za-z0-9_.-" "_")') + p(' ledger_file="$state_dir/cost-${safe_session_id}.jsonl"') + p(' if [ ! -f "$ledger_file" ] || ! jq -e --arg prompt "$prompt_id" \'select(.prompt_id == $prompt)\' "$ledger_file" >/dev/null 2>&1; then') + p(' jq -cn \\') + p(' --arg prompt_id "$prompt_id" \\') + p(' --arg model_id "$model_id" \\') + p(' --arg provider "$price_provider" \\') + p(' --argjson cost_usd "$event_cost_usd" \\') + p(' --argjson input_tokens "$usage_input_tokens" \\') + p(' --argjson output_tokens "$usage_output_tokens" \\') + p(' --argjson cache_read_tokens "$usage_cache_read_tokens" \\') + p(' --argjson cache_write_tokens "$usage_cache_write_tokens" \\') + p(' \'{prompt_id:$prompt_id, model_id:$model_id, provider:$provider, cost_usd:$cost_usd, input_tokens:$input_tokens, output_tokens:$output_tokens, cache_read_tokens:$cache_read_tokens, cache_write_tokens:$cache_write_tokens}\' \\') + p(' >> "$ledger_file" 2>/dev/null || true') + p(' fi') + p(' if [ -f "$ledger_file" ]; then') + p(' ledger_total_usd=$(jq -s \'map(.cost_usd // 0) | add // 0\' "$ledger_file" 2>/dev/null)') + p(' if [ -n "$ledger_total_usd" ] && [ "$ledger_total_usd" != "null" ]; then') + p(' display_cost_usd="$ledger_total_usd"') + p(' fi') + p(' fi') + p('fi') + p('') + } + // Build output p('# Assemble') p('parts=()') @@ -367,8 +473,8 @@ function buildScript() { const costi = icons.cost ? `${icons.cost} ` : '' // Format with 2 decimals; hide when missing or when the rounded value // would display as $0.00 (threshold 0.005 rounds up to $0.01) - p('if [ -n "$cost_usd" ]; then') - p(' cost_formatted=$(awk -v v="$cost_usd" \'BEGIN { if (v+0 >= 0.005) printf "$%.2f", v+0 }\')') + p('if [ -n "$display_cost_usd" ]; then') + p(' cost_formatted=$(awk -v v="$display_cost_usd" \'BEGIN { if (v+0 >= 0.005) printf "$%.2f", v+0 }\')') p(' if [ -n "$cost_formatted" ]; then') p(` parts+=("\${C_COST}${costi}\${cost_formatted}\${RST}")`) p(' fi') diff --git a/tests/fixtures/models-dev-catalog.json b/tests/fixtures/models-dev-catalog.json new file mode 100644 index 0000000..067748d --- /dev/null +++ b/tests/fixtures/models-dev-catalog.json @@ -0,0 +1,89 @@ +{ + "models": { + "anthropic/claude-sonnet-4-6": { + "id": "anthropic/claude-sonnet-4-6", + "name": "Claude Sonnet 4.6", + "last_updated": "2026-03-13" + }, + "anthropic/claude-opus-4-7": { + "id": "anthropic/claude-opus-4-7", + "name": "Claude Opus 4.7", + "last_updated": "2026-04-16" + } + }, + "providers": { + "anthropic": { + "id": "anthropic", + "name": "Anthropic", + "models": { + "claude-sonnet-4-6": { + "id": "claude-sonnet-4-6", + "name": "Claude Sonnet 4.6", + "last_updated": "2026-03-13", + "cost": { + "input": 3, + "output": 15, + "cache_read": 0.3, + "cache_write": 3.75 + } + }, + "claude-opus-4-7": { + "id": "claude-opus-4-7", + "name": "Claude Opus 4.7", + "last_updated": "2026-04-16", + "cost": { + "input": 5, + "output": 25, + "cache_read": 0.5, + "cache_write": 6.25 + } + } + } + }, + "openrouter": { + "id": "openrouter", + "name": "OpenRouter", + "models": { + "anthropic/claude-opus-4.7": { + "id": "anthropic/claude-opus-4.7", + "name": "Claude Opus 4.7", + "last_updated": "2026-04-16", + "cost": { + "input": 6, + "output": 30, + "cache_read": 0.6, + "cache_write": 7.5 + } + }, + "claude-opus-4-7": { + "id": "claude-opus-4-7", + "name": "Claude Opus 4.7", + "last_updated": "2026-04-16", + "cost": { + "input": 6.5, + "output": 32.5, + "cache_read": 0.65, + "cache_write": 8.125 + } + } + } + }, + "mirror": { + "id": "mirror", + "name": "Mirror Provider", + "models": { + "claude-opus-4-7": { + "id": "claude-opus-4-7", + "name": "Claude Opus 4.7", + "last_updated": "2026-04-16", + "cost": { + "input": 4, + "output": 20, + "cache_read": 0.4, + "cache_write": 5 + } + } + } + } + } +} diff --git a/tests/fixtures/statusline-events.jsonl b/tests/fixtures/statusline-events.jsonl new file mode 100644 index 0000000..7faea98 --- /dev/null +++ b/tests/fixtures/statusline-events.jsonl @@ -0,0 +1,3 @@ +{"session_id":"verify-replay","prompt_id":"prompt-1","model":{"id":"claude-sonnet-4-6","display_name":"Claude Sonnet 4.6"},"workspace":{"current_dir":"/tmp/skills-cc"},"cost":{"total_cost_usd":0.01},"context_window":{"remaining_percentage":90,"total_input_tokens":100000,"total_output_tokens":10000,"context_window_size":200000,"current_usage":{"input_tokens":100000,"output_tokens":10000,"cache_creation_input_tokens":0,"cache_read_input_tokens":0}}} +{"session_id":"verify-replay","prompt_id":"prompt-2","model":{"id":"claude-opus-4-7","display_name":"Claude Opus 4.7"},"workspace":{"current_dir":"/tmp/skills-cc"},"cost":{"total_cost_usd":0.01},"context_window":{"remaining_percentage":90,"total_input_tokens":100000,"total_output_tokens":10000,"context_window_size":200000,"current_usage":{"input_tokens":100000,"output_tokens":10000,"cache_creation_input_tokens":0,"cache_read_input_tokens":0}}} +{"session_id":"verify-replay","prompt_id":"prompt-2","model":{"id":"claude-opus-4-7","display_name":"Claude Opus 4.7"},"workspace":{"current_dir":"/tmp/skills-cc"},"cost":{"total_cost_usd":0.01},"context_window":{"remaining_percentage":90,"total_input_tokens":100000,"total_output_tokens":10000,"context_window_size":200000,"current_usage":{"input_tokens":100000,"output_tokens":10000,"cache_creation_input_tokens":0,"cache_read_input_tokens":0}}}