diff --git a/scripts/update-model-multipliers.py b/scripts/update-model-multipliers.py index c9890f6..edcd81b 100644 --- a/scripts/update-model-multipliers.py +++ b/scripts/update-model-multipliers.py @@ -1,31 +1,36 @@ #!/usr/bin/env python3 """ -Regenerate `src/lib/model-multipliers.generated.ts` from the latest release of -rajbos/github-copilot-model-notifier. +Regenerate `src/lib/model-multipliers.generated.ts` from the live model data +published by 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. +Model data is read from `data/models.json` in that repo (always at `main`), +which contains structured fields including `multiplier_paid` and +`multiplier_free`. The latest GitHub release is still fetched for its +`tag_name` so we can record a meaningful provenance comment. + +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. + GITHUB_TOKEN Optional. Used to authenticate GitHub API requests. """ from __future__ import annotations import json import os -import re import sys import urllib.error import urllib.request from pathlib import Path +MODELS_JSON_URL = ( + "https://raw.githubusercontent.com/rajbos/" + "github-copilot-model-notifier/main/data/models.json" +) RELEASE_URL = ( "https://api.github.com/repos/rajbos/" "github-copilot-model-notifier/releases/latest" @@ -33,83 +38,80 @@ REPO_ROOT = Path(__file__).resolve().parent.parent GENERATED_PATH = REPO_ROOT / "src" / "lib" / "model-multipliers.generated.ts" +# Sentinel: model not available on this plan +NOT_APPLICABLE: float = -1.0 + -def fetch_latest_release() -> dict: - """Fetch the latest release JSON from the source repo.""" - headers = { - "Accept": "application/vnd.github+json", +def _fetch(url: str, *, github_api: bool = False) -> bytes: + """Fetch *url* and return the raw response body.""" + headers: dict[str, str] = { "User-Agent": "github-copilot-premium-reqs-usage-updater", } - token = os.environ.get("GITHUB_TOKEN") - if token: - headers["Authorization"] = f"Bearer {token}" + if github_api: + headers["Accept"] = "application/vnd.github+json" + token = os.environ.get("GITHUB_TOKEN") + if token: + headers["Authorization"] = f"Bearer {token}" - req = urllib.request.Request(RELEASE_URL, headers=headers) + req = urllib.request.Request(url, headers=headers) try: with urllib.request.urlopen(req, timeout=30) as resp: - return json.loads(resp.read().decode("utf-8")) + return resp.read() except urllib.error.HTTPError as e: - sys.stderr.write( - f"HTTP error {e.code} fetching latest release: {e.reason}\n" - ) + sys.stderr.write(f"HTTP error {e.code} fetching {url}: {e.reason}\n") raise -def parse_models_table(body: str) -> dict[str, float]: - """Parse the `### Current Models` markdown table from the release body. +def fetch_tag_name() -> str: + """Return the tag_name of the latest release (used for provenance only).""" + data = json.loads(_fetch(RELEASE_URL, github_api=True).decode("utf-8")) + return data.get("tag_name", "") - 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." +def parse_multiplier(raw: str, model_name: str, field: str) -> float: + """Convert a raw multiplier string to a float. + + Rules: + "" -> 1.0 (no data yet; treat as 1 premium request) + "Not applicable" -> -1.0 (model not available on this plan) + "0", "0.33", … -> the numeric value + """ + raw = raw.strip() + if raw == "": + return 1.0 + if raw.lower() == "not applicable": + return NOT_APPLICABLE + try: + return float(raw) + except ValueError: + sys.stderr.write( + f"Warning: {model_name!r} {field}={raw!r} unparseable, defaulting to 1\n" ) - section = m.group(1) + return 1.0 - 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 + +def fetch_models() -> dict[str, tuple[float, float]]: + """Fetch data/models.json and return {name: (paid, free)} mappings.""" + raw = _fetch(MODELS_JSON_URL).decode("utf-8") + data: dict = json.loads(raw) + + models: dict[str, tuple[float, float]] = {} + for name, info in data.items(): + paid = parse_multiplier( + info.get("multiplier_paid", ""), name, "multiplier_paid" + ) + free = parse_multiplier( + info.get("multiplier_free", ""), name, "multiplier_free" + ) + models[name] = (paid, free) if not models: - raise ValueError("Parsed zero models from release body.") + raise ValueError("Fetched zero models from data/models.json.") return models def format_multiplier(value: float) -> str: - """Render a multiplier as JS/TS literal: drop .0 for whole numbers.""" + """Render a multiplier as a JS/TS literal.""" if value == int(value): return str(int(value)) return repr(value) @@ -121,31 +123,66 @@ def js_string_literal(s: str) -> str: return f"'{escaped}'" -def render_generated_file(models: dict[str, float], tag_name: str) -> str: +def render_generated_file( + models: dict[str, tuple[float, 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] + # Default models: paid multiplier == 0 (included in subscription) + default_names = [n for n in sorted_names if models[n][0] == 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)", + "// Source: https://github.com/rajbos/github-copilot-model-notifier" + " (data/models.json)", + "// 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)};", + f"export const CURRENT_MODELS_SOURCE_RELEASE =" + f" {js_string_literal(tag_name)};", "", - "export const CURRENT_MODEL_MULTIPLIERS: Record = {", + "// Multipliers for paid plans (Business / Enterprise / Pro / Pro+).", + "// -1 = not available on this plan; 0 = included (free); >0 = premium.", + "export const CURRENT_MODEL_MULTIPLIERS_PAID:" + " Record = {", ] for name in sorted_names: lines.append( - f" {js_string_literal(name)}: {format_multiplier(models[name])}," + f" {js_string_literal(name)}:" + f" {format_multiplier(models[name][0])}," + ) + lines.append("};") + lines.append("") + lines.append( + "// Multipliers for Copilot Free plan." + ) + lines.append( + "// -1 = not available on free plan; 0 = included; >0 = premium." + ) + lines.append( + "export const CURRENT_MODEL_MULTIPLIERS_FREE:" + " Record = {" + ) + for name in sorted_names: + lines.append( + f" {js_string_literal(name)}:" + f" {format_multiplier(models[name][1])}," ) lines.append("};") lines.append("") lines.append( - "// Models with a 0x multiplier (free) are treated as \"Default\" " - "and grouped together." + "// Backward-compat alias — defaults to paid-plan multipliers." + ) + lines.append( + "export const CURRENT_MODEL_MULTIPLIERS =" + " CURRENT_MODEL_MULTIPLIERS_PAID;" + ) + lines.append("") + lines.append( + "// Models with a 0x paid multiplier are included in the subscription" + ' and grouped as "Default".' ) lines.append("export const CURRENT_DEFAULT_MODELS: string[] = [") for name in default_names: @@ -155,18 +192,26 @@ def render_generated_file(models: dict[str, float], tag_name: str) -> str: return "\n".join(lines) -def parse_existing_models(content: str) -> dict[str, float]: - """Parse the existing CURRENT_MODEL_MULTIPLIERS object for a diff summary.""" +def parse_existing_paid(content: str) -> dict[str, float]: + """Parse the existing CURRENT_MODEL_MULTIPLIERS_PAID block for a diff.""" + import re m = re.search( - r"CURRENT_MODEL_MULTIPLIERS[^=]*=\s*\{(.*?)\};", + r"CURRENT_MODEL_MULTIPLIERS_PAID[^=]*=\s*\{(.*?)\};", content, re.DOTALL, ) + if not m: + # Fall back to the old single-block format + m = re.search( + r"CURRENT_MODEL_MULTIPLIERS[^=P][^=]*=\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.]+)") + entry_re = re.compile(r"'((?:\\'|[^'])*)'\s*:\s*(-?[0-9.]+)") for line in body.splitlines(): line = line.split("//", 1)[0] em = entry_re.search(line) @@ -180,53 +225,53 @@ def parse_existing_models(content: str) -> dict[str, float]: def print_diff_summary( - old: dict[str, float], new: dict[str, float], tag_name: str + old: dict[str, float], new: dict[str, tuple[float, float]], tag_name: str ) -> None: - added = sorted(set(new) - set(old), key=str.lower) - removed = sorted(set(old) - set(new), key=str.lower) + new_paid = {n: v[0] for n, v in new.items()} + added = sorted(set(new_paid) - set(old), key=str.lower) + removed = sorted(set(old) - set(new_paid), key=str.lower) changed = sorted( - (n for n in set(new) & set(old) if old[n] != new[n]), key=str.lower + (n for n in set(new_paid) & set(old) if old[n] != new_paid[n]), + key=str.lower, ) - print(f"Source release: {tag_name}") + print(f"Source: {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])}") + print(f" + {n} (paid={format_multiplier(new_paid[n])}," + f" free={format_multiplier(new[n][1])})") if removed: print("Removed:") for n in removed: - print(f" - {n} (was {format_multiplier(old[n])})") + print(f" - {n} (was paid={format_multiplier(old[n])})") if changed: print("Changed:") for n in changed: print( - f" ~ {n}: {format_multiplier(old[n])} -> " - f"{format_multiplier(new[n])}" + f" ~ {n}: paid {format_multiplier(old[n])}" + f" -> {format_multiplier(new_paid[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) + tag_name = fetch_tag_name() + new_models = fetch_models() old_content = ( GENERATED_PATH.read_text(encoding="utf-8") if GENERATED_PATH.exists() else "" ) - old_models = parse_existing_models(old_content) + old_models = parse_existing_paid(old_content) new_content = render_generated_file(new_models, tag_name) if new_content == old_content: - print(f"Source release: {tag_name}") + print(f"Source: {tag_name}") print( f"{GENERATED_PATH.relative_to(REPO_ROOT)} is already up to date." ) diff --git a/src/App.tsx b/src/App.tsx index d8fb58b..e02b2cd 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1509,7 +1509,7 @@ function App() {
Expected Cost (no limit): @@ -1944,19 +1944,6 @@ function App() { )} - - - @@ -1966,7 +1953,6 @@ function App() { {item.totalRequests.toLocaleString(undefined, {maximumFractionDigits: 8, minimumFractionDigits: 0})} {item.compliantRequests.toLocaleString(undefined, {maximumFractionDigits: 8, minimumFractionDigits: 0})} {item.exceedingRequests.toLocaleString(undefined, {maximumFractionDigits: 8, minimumFractionDigits: 0})} - {item.multiplier}x ))} @@ -1982,7 +1968,6 @@ function App() { {modelSummary.reduce((sum, item) => sum + item.exceedingRequests, 0).toLocaleString(undefined, {maximumFractionDigits: 8, minimumFractionDigits: 0})} - diff --git a/src/lib/model-multipliers.generated.ts b/src/lib/model-multipliers.generated.ts index bc3557f..4c1dbe5 100644 --- a/src/lib/model-multipliers.generated.ts +++ b/src/lib/model-multipliers.generated.ts @@ -1,39 +1,71 @@ // AUTO-GENERATED FILE — DO NOT EDIT BY HAND. // -// Source: https://github.com/rajbos/github-copilot-model-notifier (latest release) +// Source: https://github.com/rajbos/github-copilot-model-notifier (data/models.json) // 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-05-29-091242'; +export const CURRENT_MODELS_SOURCE_RELEASE = 'models-2026-06-10-091603'; -export const CURRENT_MODEL_MULTIPLIERS: Record = { - 'Claude Haiku 4.5': 0.33, - 'Claude Opus 4.5': 3, - 'Claude Opus 4.6': 3, - 'Claude Opus 4.6 (fast mode) (preview)': 30, - 'Claude Opus 4.7': 15, +// Multipliers for paid plans (Business / Enterprise / Pro / Pro+). +// -1 = not available on this plan; 0 = included (free); >0 = premium. +export const CURRENT_MODEL_MULTIPLIERS_PAID: Record = { + 'Claude Fable 5': 1, + 'Claude Haiku 4.5': 1, + 'Claude Opus 4.5': 1, + 'Claude Opus 4.6': 1, + 'Claude Opus 4.6 (fast mode) (preview)': 1, + 'Claude Opus 4.7': 1, + 'Claude Opus 4.8': 1, 'Claude Sonnet 4.5': 1, 'Claude Sonnet 4.6': 1, 'Gemini 2.5 Pro': 1, - 'Gemini 3 Flash': 0.33, + 'Gemini 3 Flash': 1, 'Gemini 3.1 Pro': 1, - 'Gemini 3.5 Flash': 14, - 'GPT-4.1': 0, - 'GPT-5 mini': 0, - 'GPT-5.2': 1, - 'GPT-5.2-Codex': 1, + 'Gemini 3.5 Flash': 1, + 'GPT-5 mini': 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, - 'Raptor mini': 0, + 'GPT-5.4 mini': 1, + 'GPT-5.4 nano': 1, + 'GPT-5.5': 1, + 'MAI-Code-1-Flash': 1, + 'MAI-Code-1-Flash[^mai-code-1-flash]': 1, + 'Qwen2.5': 1, + 'Raptor mini': 1, }; -// Models with a 0x multiplier (free) are treated as "Default" and grouped together. +// Multipliers for Copilot Free plan. +// -1 = not available on free plan; 0 = included; >0 = premium. +export const CURRENT_MODEL_MULTIPLIERS_FREE: Record = { + 'Claude Fable 5': 1, + 'Claude Haiku 4.5': 1, + 'Claude Opus 4.5': 1, + 'Claude Opus 4.6': 1, + 'Claude Opus 4.6 (fast mode) (preview)': 1, + 'Claude Opus 4.7': 1, + 'Claude Opus 4.8': 1, + 'Claude Sonnet 4.5': 1, + 'Claude Sonnet 4.6': 1, + 'Gemini 2.5 Pro': 1, + 'Gemini 3 Flash': 1, + 'Gemini 3.1 Pro': 1, + 'Gemini 3.5 Flash': 1, + 'GPT-5 mini': 1, + 'GPT-5.3-Codex': 1, + 'GPT-5.4': 1, + 'GPT-5.4 mini': 1, + 'GPT-5.4 nano': 1, + 'GPT-5.5': 1, + 'MAI-Code-1-Flash': 1, + 'MAI-Code-1-Flash[^mai-code-1-flash]': 1, + 'Qwen2.5': 1, + 'Raptor mini': 1, +}; + +// Backward-compat alias — defaults to paid-plan multipliers. +export const CURRENT_MODEL_MULTIPLIERS = CURRENT_MODEL_MULTIPLIERS_PAID; + +// Models with a 0x paid multiplier are included in the subscription and grouped as "Default". export const CURRENT_DEFAULT_MODELS: string[] = [ - 'GPT-4.1', - 'GPT-5 mini', - 'Raptor mini', ]; diff --git a/src/lib/model-multipliers.legacy.ts b/src/lib/model-multipliers.legacy.ts index 1877719..0191643 100644 --- a/src/lib/model-multipliers.legacy.ts +++ b/src/lib/model-multipliers.legacy.ts @@ -1,33 +1,62 @@ -// Backward-compatibility model multipliers. +// Backward-compatibility model entries. // // 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 = { +// Values: -1 = not available on this plan; 0 = included (free); >0 = premium. +// +// Current (live) models live in `model-multipliers.generated.ts` and are +// refreshed daily from rajbos/github-copilot-model-notifier (data/models.json). +export const LEGACY_MODEL_MULTIPLIERS_PAID: 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.5': 1, 'gpt-4.1-vision': 0, 'claude-sonnet-3.5': 1, 'claude-sonnet-3.7': 1, - 'claude-sonnet-3.7-thinking': 1.25, + 'claude-sonnet-3.7-thinking': 1, 'claude-sonnet-4': 1, - 'claude-opus-4': 10, - 'gemini-2.0-flash': 0.25, + 'claude-opus-4': 1, + 'gemini-2.0-flash': 1, 'gemini-2.5-pro': 1, - 'o1': 10, + 'o1': 1, 'o3': 1, - 'o3-mini': 0.33, - 'o3-mini-2025-01-31': 0.33, - 'o4-mini': 0.33, - 'o4-mini-2025-04-16': 0.33, + 'o3-mini': 1, + 'o3-mini-2025-01-31': 1, + 'o4-mini': 1, + 'o4-mini-2025-04-16': 1, +}; + +// Free-plan multipliers for legacy models. +// Most older premium models were not available on Copilot Free (-1). +export const LEGACY_MODEL_MULTIPLIERS_FREE: Record = { + 'gpt-4o-2024-11-20': 1, // Copilot Free default model + 'gpt-4.1-2025-04-14': 1, // Copilot Free default model + 'gpt-4o': 1, + 'gpt-4.1': 1, + 'gpt-4.5': -1, + 'gpt-4.1-vision': -1, + 'claude-sonnet-3.5': -1, + 'claude-sonnet-3.7': -1, + 'claude-sonnet-3.7-thinking': -1, + 'claude-sonnet-4': -1, + 'claude-opus-4': -1, + 'gemini-2.0-flash': 1, + 'gemini-2.5-pro': -1, + 'o1': -1, + 'o3': -1, + 'o3-mini': 1, + 'o3-mini-2025-01-31': 1, + 'o4-mini': 1, + 'o4-mini-2025-04-16': 1, }; +// Backward-compat alias. +export const LEGACY_MODEL_MULTIPLIERS = LEGACY_MODEL_MULTIPLIERS_PAID; + // Legacy default model identifiers (always grouped under "Default"). export const LEGACY_DEFAULT_MODELS: string[] = [ 'gpt-4o-2024-11-20', diff --git a/src/lib/utils.ts b/src/lib/utils.ts index a264b7a..c97201f 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -1,8 +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"; +import { CURRENT_MODEL_MULTIPLIERS_PAID, CURRENT_MODEL_MULTIPLIERS_FREE, CURRENT_DEFAULT_MODELS } from "./model-multipliers.generated"; +import { LEGACY_MODEL_MULTIPLIERS_PAID, LEGACY_MODEL_MULTIPLIERS_FREE, LEGACY_DEFAULT_MODELS } from "./model-multipliers.legacy"; export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)) @@ -425,21 +425,30 @@ export const PLAN_MONTHLY_LIMITS = { [COPILOT_PLANS.ENTERPRISE]: 1000 } as const; -// Model multipliers based on GitHub documentation (for paid plans). -// Uses display names as they appear in GitHub Copilot exports. +// Model premium-request indicators. Values: -1 = not available on this plan; +// 0 = included in subscription (free); >0 = costs premium requests. // -// 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`. +// The current models are sourced from rajbos/github-copilot-model-notifier +// (data/models.json) 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 = { - ...LEGACY_MODEL_MULTIPLIERS, - ...CURRENT_MODEL_MULTIPLIERS, +export const MODEL_MULTIPLIERS_PAID: Record = { + ...LEGACY_MODEL_MULTIPLIERS_PAID, + ...CURRENT_MODEL_MULTIPLIERS_PAID, }; +export const MODEL_MULTIPLIERS_FREE: Record = { + ...LEGACY_MODEL_MULTIPLIERS_FREE, + ...CURRENT_MODEL_MULTIPLIERS_FREE, +}; + +// Backward-compat alias — defaults to paid-plan multipliers. +export const MODEL_MULTIPLIERS: Record = MODEL_MULTIPLIERS_PAID; + // Default models that should be grouped under "Default" in the UI. export const DEFAULT_MODELS: string[] = [ ...CURRENT_DEFAULT_MODELS, @@ -450,8 +459,15 @@ function normalizeModelName(model: string): string { return model.replace(/^Auto:\s*/, '').trim(); } -function getModelMultiplier(model: string): number { - return MODEL_MULTIPLIERS[normalizeModelName(model)] ?? 1; +/** + * Returns the request multiplier for a model. + * -1 (not available on this plan) is treated as 0 for calculations (no cost + * assigned to models that can't be used on the given plan). + */ +export function getModelMultiplier(model: string, plan: 'paid' | 'free' = 'paid'): number { + const table = plan === 'free' ? MODEL_MULTIPLIERS_FREE : MODEL_MULTIPLIERS_PAID; + const value = table[normalizeModelName(model)] ?? 1; + return value < 0 ? 0 : value; // -1 (not available) → treat as 0 for cost math } function isDefaultModel(model: string): boolean { @@ -934,7 +950,7 @@ export function getProjectedUsersExceedingQuotaDetails(data: CopilotUsageData[], * 1. Find the day their cumulative requests hit the limit (budget exhaustion day). * 2. Compute daily average requests per model, excluding the last usage day (to avoid partial-day skew). * 3. Project those requests over the remaining days after the exhaustion day. - * 4. Apply each model's cost multiplier and sum the cost at $0.04/PRU. + * 4. Sum the cost at $0.04/PRU (free/default models excluded). */ export function getExpectedExcessCost(data: CopilotUsageData[], plan: string = COPILOT_PLANS.BUSINESS): number { if (!data.length) return 0; @@ -1004,15 +1020,14 @@ export function getExpectedExcessCost(data: CopilotUsageData[], plan: string = C const projectedExcess = projectedMonthlyTotal - planLimit; if (projectedExcess <= 0 || projectedMonthlyTotal <= 0) return; - // Allocate only the projected amount above the free plan quota across models, - // then apply each model multiplier to compute cost. + // Allocate only the projected amount above the free plan quota across models. Object.entries(projectedModelTotals).forEach(([model, projectedTotalForModel]) => { const multiplier = getModelMultiplier(model); if (multiplier === 0) return; const modelShare = projectedTotalForModel / projectedMonthlyTotal; const projectedExcessForModel = projectedExcess * modelShare; - totalExpectedCost += projectedExcessForModel * multiplier * EXCESS_REQUEST_COST; + totalExpectedCost += projectedExcessForModel * EXCESS_REQUEST_COST; }); }); diff --git a/src/test/model-info-limits.test.ts b/src/test/model-info-limits.test.ts index 3656811..aceadb6 100644 --- a/src/test/model-info-limits.test.ts +++ b/src/test/model-info-limits.test.ts @@ -45,14 +45,14 @@ describe('Model Info and Limits Feature', () => { } ]; - it('should calculate plan limits based on model multipliers', () => { + it('should identify free (default) vs premium models', () => { const result = getModelUsageSummary(mockData); const defaultGroup = result.find(item => item.model === 'Default (GPT-4o, GPT-4.1)'); expect(defaultGroup).toBeDefined(); if (defaultGroup) { - expect(defaultGroup.multiplier).toBe(0); + expect(defaultGroup.multiplier).toBe(0); // 0 = free/included in subscription expect(defaultGroup.individualPlanLimit).toBe(50); // Constant plan limit, not Infinity expect(defaultGroup.businessPlanLimit).toBe(300); // Constant plan limit, not Infinity expect(defaultGroup.enterprisePlanLimit).toBe(1000); // Constant plan limit, not Infinity @@ -105,7 +105,7 @@ describe('Model Info and Limits Feature', () => { const o3Model = result.find(item => item.model === 'o3-mini-2025-01-31'); if (o3Model) { - // 10 exceeding requests * $0.04 = $0.40 + // 10 exceeding requests × $0.04/PRU = $0.40 expect(o3Model.excessCost).toBe(10 * EXCESS_REQUEST_COST); } }); @@ -141,7 +141,7 @@ describe('Model Info and Limits Feature', () => { }); }); - it('should handle unknown models with default multiplier', () => { + it('should handle unknown models with default premium indicator', () => { const unknownModelData: CopilotUsageData[] = [ { timestamp: new Date('2025-01-01T10:00:00Z'), @@ -157,9 +157,9 @@ describe('Model Info and Limits Feature', () => { expect(result).toHaveLength(1); expect(result[0].model).toBe('unknown-model-2025'); - expect(result[0].multiplier).toBe(1); // Default multiplier - expect(result[0].individualPlanLimit).toBe(50); // 50 / 1 - expect(result[0].businessPlanLimit).toBe(300); // 300 / 1 + expect(result[0].multiplier).toBe(1); // Unknown models treated as premium (1 PRU each) + expect(result[0].individualPlanLimit).toBe(50); + expect(result[0].businessPlanLimit).toBe(300); }); it('should sort results by total requests descending', () => { diff --git a/src/test/model-summary-total.test.tsx b/src/test/model-summary-total.test.tsx index a01a45d..c1138d6 100644 --- a/src/test/model-summary-total.test.tsx +++ b/src/test/model-summary-total.test.tsx @@ -11,7 +11,6 @@ describe('Model Summary Total Row', () => { totalRequests: 1000, compliantRequests: 800, exceedingRequests: 200, - multiplier: 1, excessCost: 50 }, { @@ -19,7 +18,6 @@ describe('Model Summary Total Row', () => { totalRequests: 500, compliantRequests: 400, exceedingRequests: 100, - multiplier: 2, excessCost: 40 } ]; @@ -38,7 +36,6 @@ describe('Model Summary Total Row', () => { Total Requests Compliant Exceeding - Multiplier Excess Cost @@ -49,7 +46,6 @@ describe('Model Summary Total Row', () => { {item.totalRequests.toLocaleString()} {item.compliantRequests.toLocaleString()} {item.exceedingRequests.toLocaleString()} - {item.multiplier}x ${item.excessCost.toFixed(2)} ))} @@ -67,7 +63,6 @@ describe('Model Summary Total Row', () => { {expectedExceedingRequests.toLocaleString()} - @@ -78,7 +73,7 @@ describe('Model Summary Total Row', () => { expect(totalRow).toBeTruthy(); const cells = totalRow?.querySelectorAll('td'); - expect(cells).toHaveLength(6); + expect(cells).toHaveLength(5); // Check the total values in the cells - use dynamic expectations to match locale formatting expect(cells?.[0]?.textContent).toBe('Total'); @@ -86,7 +81,6 @@ describe('Model Summary Total Row', () => { expect(cells?.[2]?.textContent).toBe(expectedCompliantRequests.toLocaleString()); expect(cells?.[3]?.textContent).toBe(expectedExceedingRequests.toLocaleString()); expect(cells?.[4]?.textContent).toBe('—'); - expect(cells?.[5]?.textContent).toBe('—'); }); it('should handle empty model summary data', () => { @@ -100,7 +94,6 @@ describe('Model Summary Total Row', () => { Total Requests Compliant Exceeding - Multiplier Excess Cost @@ -111,7 +104,6 @@ describe('Model Summary Total Row', () => { {item.totalRequests.toLocaleString()} {item.compliantRequests.toLocaleString()} {item.exceedingRequests.toLocaleString()} - {item.multiplier}x ${item.excessCost.toFixed(2)} ))} @@ -123,7 +115,6 @@ describe('Model Summary Total Row', () => { 0 0 -