diff --git a/.github/workflows/update-model-multipliers.yml b/.github/workflows/update-model-multipliers.yml new file mode 100644 index 0000000..f485f91 --- /dev/null +++ b/.github/workflows/update-model-multipliers.yml @@ -0,0 +1,65 @@ +name: Update model multipliers + +on: + schedule: + # Run daily at 08:10 UTC, shortly after the source repo typically publishes. + - cron: '10 8 * * *' + workflow_dispatch: + push: + branches: + - main + paths: + - .github/workflows/update-model-multipliers.yml + - scripts/update-model-multipliers.py + +permissions: + contents: write + pull-requests: write + +jobs: + update-model-multipliers: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Set up Python + uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + with: + python-version: '3.x' + + - name: Check for updates and generate model multipliers + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: python scripts/update-model-multipliers.py + + - name: Create pull request + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + shell: bash + run: | + if [[ -n $(git status --porcelain) ]]; then + echo "Changes detected, creating PR" >> $GITHUB_STEP_SUMMARY + + # Configure git + git config user.name 'github-actions[bot]' + git config user.email 'github-actions[bot]@users.noreply.github.com' + + # Create a unique branch + BRANCH_NAME="update/model-multipliers-$(date +%Y%m%d%H%M%S)" + git checkout -b "$BRANCH_NAME" + + # Commit and push + git add src/lib/model-multipliers.generated.ts + git commit -m ":memo: Update model multipliers from rajbos/github-copilot-model-notifier" + git push origin "$BRANCH_NAME" + + # Create PR + gh pr create \ + --title "Update model multipliers from rajbos/github-copilot-model-notifier" \ + --body "Automated update of model multipliers from the latest release of [rajbos/github-copilot-model-notifier](https://github.com/rajbos/github-copilot-model-notifier/releases/latest). Generated by GitHub Actions." \ + --head "$BRANCH_NAME" \ + --base main + else + echo "No changes detected." >> $GITHUB_STEP_SUMMARY + fi diff --git a/scripts/update-model-multipliers.py b/scripts/update-model-multipliers.py new file mode 100644 index 0000000..c9890f6 --- /dev/null +++ b/scripts/update-model-multipliers.py @@ -0,0 +1,242 @@ +#!/usr/bin/env python3 +""" +Regenerate `src/lib/model-multipliers.generated.ts` from the latest release of +rajbos/github-copilot-model-notifier. + +The source repo publishes a markdown table of current models in the release body +under a `### Current Models` heading. This script parses that table and fully +overwrites the generated TypeScript file. The companion file +`model-multipliers.legacy.ts` contains hand-maintained backward-compat entries +and is never touched here. + +Usage: + python scripts/update-model-multipliers.py + +Env: + GITHUB_TOKEN Optional. Used to authenticate the GitHub API request. +""" + +from __future__ import annotations + +import json +import os +import re +import sys +import urllib.error +import urllib.request +from pathlib import Path + +RELEASE_URL = ( + "https://api.github.com/repos/rajbos/" + "github-copilot-model-notifier/releases/latest" +) +REPO_ROOT = Path(__file__).resolve().parent.parent +GENERATED_PATH = REPO_ROOT / "src" / "lib" / "model-multipliers.generated.ts" + + +def fetch_latest_release() -> dict: + """Fetch the latest release JSON from the source repo.""" + headers = { + "Accept": "application/vnd.github+json", + "User-Agent": "github-copilot-premium-reqs-usage-updater", + } + token = os.environ.get("GITHUB_TOKEN") + if token: + headers["Authorization"] = f"Bearer {token}" + + req = urllib.request.Request(RELEASE_URL, headers=headers) + try: + with urllib.request.urlopen(req, timeout=30) as resp: + return json.loads(resp.read().decode("utf-8")) + except urllib.error.HTTPError as e: + sys.stderr.write( + f"HTTP error {e.code} fetching latest release: {e.reason}\n" + ) + raise + + +def parse_models_table(body: str) -> dict[str, float]: + """Parse the `### Current Models` markdown table from the release body. + + Returns a mapping of model name -> paid multiplier (float). + Multiplier 'Not applicable' is mapped to 0. + """ + if not body: + raise ValueError("Release body is empty; cannot parse models table.") + + m = re.search( + r"###\s+Current Models\s*\n(.+?)(?=\n#{1,6}\s|\Z)", + body, + re.DOTALL, + ) + if not m: + raise ValueError( + "Could not find '### Current Models' section in release body." + ) + section = m.group(1) + + models: dict[str, float] = {} + for line in section.splitlines(): + line = line.strip() + if not line.startswith("|") or not line.endswith("|"): + continue + cells = [c.strip() for c in line.strip("|").split("|")] + if len(cells) < 3: + continue + if cells[0].lower() == "model": + continue + if set(cells[0]) <= set("-: "): + continue + + name = cells[0] + raw_mult = cells[2] + if raw_mult.lower() == "not applicable": + mult: float = 0.0 + else: + try: + mult = float(raw_mult) + except ValueError: + sys.stderr.write( + f"Warning: skipping {name!r} - " + f"unparseable multiplier {raw_mult!r}\n" + ) + continue + models[name] = mult + + if not models: + raise ValueError("Parsed zero models from release body.") + return models + + +def format_multiplier(value: float) -> str: + """Render a multiplier as JS/TS literal: drop .0 for whole numbers.""" + if value == int(value): + return str(int(value)) + return repr(value) + + +def js_string_literal(s: str) -> str: + """Render a string as a single-quoted JS/TS literal.""" + escaped = s.replace("\\", "\\\\").replace("'", "\\'") + return f"'{escaped}'" + + +def render_generated_file(models: dict[str, float], tag_name: str) -> str: + sorted_names = sorted(models.keys(), key=str.lower) + default_names = [n for n in sorted_names if models[n] == 0] + + lines = [ + "// AUTO-GENERATED FILE — DO NOT EDIT BY HAND.", + "//", + "// Source: https://github.com/rajbos/github-copilot-model-notifier (latest release)", + "// Updated by: scripts/update-model-multipliers.py (run daily via GitHub Actions)", + "//", + "// To make manual changes, edit `model-multipliers.legacy.ts` instead.", + "", + f"export const CURRENT_MODELS_SOURCE_RELEASE = {js_string_literal(tag_name)};", + "", + "export const CURRENT_MODEL_MULTIPLIERS: Record = {", + ] + for name in sorted_names: + lines.append( + f" {js_string_literal(name)}: {format_multiplier(models[name])}," + ) + lines.append("};") + lines.append("") + lines.append( + "// Models with a 0x multiplier (free) are treated as \"Default\" " + "and grouped together." + ) + lines.append("export const CURRENT_DEFAULT_MODELS: string[] = [") + for name in default_names: + lines.append(f" {js_string_literal(name)},") + lines.append("];") + lines.append("") # trailing newline + return "\n".join(lines) + + +def parse_existing_models(content: str) -> dict[str, float]: + """Parse the existing CURRENT_MODEL_MULTIPLIERS object for a diff summary.""" + m = re.search( + r"CURRENT_MODEL_MULTIPLIERS[^=]*=\s*\{(.*?)\};", + content, + re.DOTALL, + ) + if not m: + return {} + body = m.group(1) + models: dict[str, float] = {} + entry_re = re.compile(r"'((?:\\'|[^'])*)'\s*:\s*([0-9.]+)") + for line in body.splitlines(): + line = line.split("//", 1)[0] + em = entry_re.search(line) + if em: + name = em.group(1).replace("\\'", "'") + try: + models[name] = float(em.group(2)) + except ValueError: + continue + return models + + +def print_diff_summary( + old: dict[str, float], new: dict[str, float], tag_name: str +) -> None: + added = sorted(set(new) - set(old), key=str.lower) + removed = sorted(set(old) - set(new), key=str.lower) + changed = sorted( + (n for n in set(new) & set(old) if old[n] != new[n]), key=str.lower + ) + + print(f"Source release: {tag_name}") + if not (added or removed or changed): + print("No model changes detected.") + return + if added: + print("Added:") + for n in added: + print(f" + {n} = {format_multiplier(new[n])}") + if removed: + print("Removed:") + for n in removed: + print(f" - {n} (was {format_multiplier(old[n])})") + if changed: + print("Changed:") + for n in changed: + print( + f" ~ {n}: {format_multiplier(old[n])} -> " + f"{format_multiplier(new[n])}" + ) + + +def main() -> int: + release = fetch_latest_release() + tag_name = release.get("tag_name", "") + body = release.get("body") or "" + + new_models = parse_models_table(body) + + old_content = ( + GENERATED_PATH.read_text(encoding="utf-8") + if GENERATED_PATH.exists() + else "" + ) + old_models = parse_existing_models(old_content) + + new_content = render_generated_file(new_models, tag_name) + + if new_content == old_content: + print(f"Source release: {tag_name}") + print( + f"{GENERATED_PATH.relative_to(REPO_ROOT)} is already up to date." + ) + return 0 + + GENERATED_PATH.write_text(new_content, encoding="utf-8") + print_diff_summary(old_models, new_models, tag_name) + print(f"Updated {GENERATED_PATH.relative_to(REPO_ROOT)}") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/src/lib/model-multipliers.generated.ts b/src/lib/model-multipliers.generated.ts new file mode 100644 index 0000000..2b911e7 --- /dev/null +++ b/src/lib/model-multipliers.generated.ts @@ -0,0 +1,35 @@ +// AUTO-GENERATED FILE — DO NOT EDIT BY HAND. +// +// Source: https://github.com/rajbos/github-copilot-model-notifier (latest release) +// Updated by: scripts/update-model-multipliers.py (run daily via GitHub Actions) +// +// To make manual changes, edit `model-multipliers.legacy.ts` instead. + +export const CURRENT_MODELS_SOURCE_RELEASE = 'models-2026-04-25-082151'; + +export const CURRENT_MODEL_MULTIPLIERS: Record = { + 'Claude Haiku 4.5': 0.33, + 'Claude Opus 4.5': 3, + 'Claude Opus 4.6': 3, + 'Claude Sonnet 4': 1, + 'Claude Sonnet 4.5': 1, + 'Claude Sonnet 4.6': 1, + 'Gemini 2.5 Pro': 1, + 'Gemini 3 Flash': 0.5, + 'Gemini 3.1 Pro': 1, + 'GPT-4.1': 0, + 'GPT-5 mini': 0, + 'GPT-5.2': 1, + 'GPT-5.2-Codex': 1, + 'GPT-5.3-Codex': 1, + 'GPT-5.4': 1, + 'GPT-5.4 mini': 0.33, + 'GPT-5.4 nano': 0.25, + 'GPT-5.5': 7.5 +}; + +// Models with a 0x multiplier (free) are treated as "Default" and grouped together. +export const CURRENT_DEFAULT_MODELS: string[] = [ + 'GPT-4.1', + 'GPT-5 mini', +]; diff --git a/src/lib/model-multipliers.legacy.ts b/src/lib/model-multipliers.legacy.ts new file mode 100644 index 0000000..1877719 --- /dev/null +++ b/src/lib/model-multipliers.legacy.ts @@ -0,0 +1,35 @@ +// Backward-compatibility model multipliers. +// +// These entries cover legacy/historical model identifiers that may still appear +// in older GitHub Copilot CSV exports. They are maintained by hand and are +// intentionally NOT touched by the auto-update workflow. +// +// Current (live) model multipliers live in `model-multipliers.generated.ts` +// and are refreshed daily from rajbos/github-copilot-model-notifier. +export const LEGACY_MODEL_MULTIPLIERS: Record = { + 'gpt-4o-2024-11-20': 0, + 'gpt-4.1-2025-04-14': 0, + 'gpt-4o': 0, + 'gpt-4.1': 0, + 'gpt-4.5': 50, + 'gpt-4.1-vision': 0, + 'claude-sonnet-3.5': 1, + 'claude-sonnet-3.7': 1, + 'claude-sonnet-3.7-thinking': 1.25, + 'claude-sonnet-4': 1, + 'claude-opus-4': 10, + 'gemini-2.0-flash': 0.25, + 'gemini-2.5-pro': 1, + 'o1': 10, + 'o3': 1, + 'o3-mini': 0.33, + 'o3-mini-2025-01-31': 0.33, + 'o4-mini': 0.33, + 'o4-mini-2025-04-16': 0.33, +}; + +// Legacy default model identifiers (always grouped under "Default"). +export const LEGACY_DEFAULT_MODELS: string[] = [ + 'gpt-4o-2024-11-20', + 'gpt-4.1-2025-04-14', +]; diff --git a/src/lib/utils.ts b/src/lib/utils.ts index e29caff..c6ffbe0 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -1,6 +1,8 @@ import { clsx, type ClassValue } from "clsx" import { twMerge } from "tailwind-merge" import { defaultServerMainFields } from "vite"; +import { CURRENT_MODEL_MULTIPLIERS, CURRENT_DEFAULT_MODELS } from "./model-multipliers.generated"; +import { LEGACY_MODEL_MULTIPLIERS, LEGACY_DEFAULT_MODELS } from "./model-multipliers.legacy"; export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)) @@ -338,70 +340,26 @@ export const PLAN_MONTHLY_LIMITS = { [COPILOT_PLANS.ENTERPRISE]: 1000 } as const; -// Model multipliers based on GitHub documentation (for paid plans) +// Model multipliers based on GitHub documentation (for paid plans). // Uses display names as they appear in GitHub Copilot exports. +// +// The current models are sourced from rajbos/github-copilot-model-notifier and +// refreshed daily by `scripts/update-model-multipliers.py`. Legacy entries +// (older lowercase / dated names) are kept for backward compatibility with +// historical CSV exports. Edit those in `model-multipliers.legacy.ts`. +// +// Legacy entries are spread first so that, in case of a name collision, the +// current generated value wins. export const MODEL_MULTIPLIERS: Record = { - // Current default models (0x multiplier for paid plans) - 'GPT-4.1': 0, - 'GPT-4o': 0, - 'GPT-5 mini': 0, - 'Raptor mini': 0, - - // OpenAI models - 'GPT-5.1': 1, - 'GPT-5.1-Codex': 1, - 'GPT-5.1-Codex-Mini': 0.33, - 'GPT-5.1-Codex-Max': 1, - 'GPT-5.2': 1, - 'GPT-5.2-Codex': 1, - 'GPT-5.3-Codex': 1, - 'GPT-5.4': 1, - 'GPT-5.4 mini': 0.33, - - // Anthropic models - 'Claude Haiku 4.5': 0.33, - 'Claude Sonnet 4': 1, - 'Claude Sonnet 4.5': 1, - 'Claude Sonnet 4.6': 1, - 'Claude Opus 4.5': 3, - 'Claude Opus 4.6': 3, - 'Claude Opus 4.6 (fast mode) (preview)': 30, - - // Google models - 'Gemini 2.5 Pro': 1, - 'Gemini 3 Flash': 0.33, - 'Gemini 3 Pro': 1, - 'Gemini 3.1 Pro': 1, - - // xAI and other models - 'Grok Code Fast 1': 0.25, - 'Goldeneye': 1, - - // Backward compatibility for older exports - 'gpt-4o-2024-11-20': 0, - 'gpt-4.1-2025-04-14': 0, - 'gpt-4o': 0, - 'gpt-4.1': 0, - 'gpt-4.5': 50, - 'gpt-4.1-vision': 0, - 'claude-sonnet-3.5': 1, - 'claude-sonnet-3.7': 1, - 'claude-sonnet-3.7-thinking': 1.25, - 'claude-sonnet-4': 1, - 'claude-opus-4': 10, - 'gemini-2.0-flash': 0.25, - 'gemini-2.5-pro': 1, - 'o1': 10, - 'o3': 1, - 'o3-mini': 0.33, - 'o3-mini-2025-01-31': 0.33, - 'o4-mini': 0.33, - 'o4-mini-2025-04-16': 0.33, - // Add other models as needed - fallback to 1x for unknown models + ...LEGACY_MODEL_MULTIPLIERS, + ...CURRENT_MODEL_MULTIPLIERS, }; -// Default models that should be grouped -export const DEFAULT_MODELS = ['GPT-4o', 'GPT-4.1', 'GPT-5 mini', 'gpt-4o-2024-11-20', 'gpt-4.1-2025-04-14']; +// Default models that should be grouped under "Default" in the UI. +export const DEFAULT_MODELS: string[] = [ + ...CURRENT_DEFAULT_MODELS, + ...LEGACY_DEFAULT_MODELS, +]; function normalizeModelName(model: string): string { return model.replace(/^Auto:\s*/, '').trim();