"""LLM model price list — a rich, importable/exportable table shown in the Monitoring Overview. Each entry mirrors the vendor price sheet columns: Model name · Context length · Max output token · Input price · Input Unit · Output price · Output Unit Prices carry their own currency (parsed from the ₫ / ¥ / $ symbol, or the config default) and are converted to the display currency (VND / JPY / USD) using the same USD↔VND↔JPY rates the usage tracker uses. Stored in the app config under ``model_pricing.entries`` so it persists and can be edited by hand, imported from a template, or auto-linked from the providers. """ from __future__ import annotations import copy import csv from pathlib import Path from typing import Any, Dict, List, Optional # Template columns, in order (exact vendor-sheet layout). COLUMNS = ["Model name", "Context length", "Max output token", "Input price", "Input Unit", "Output price", "Output Unit"] _EXAMPLE_ROW = ["FPT.AI-KIE-v1.7", "33k", "33k", "20.359 ₫", "Million tokens", "20.359 ₫", "Million tokens"] _SYMBOL_CCY = {"₫": "VND", "vnd": "VND", "đ": "VND", "¥": "JPY", "jpy": "JPY", "yen": "JPY", "$": "USD", "usd": "USD"} _DEFAULT_UNIT = "Million tokens" # ---- currency ------------------------------------------------------------ def _rates(config) -> Dict[str, float]: """1 USD = X . Reuses the usage tracker's editable rates.""" usage = (getattr(config, "data", {}) or {}).get("usage", {}) if config else {} return {"USD": 1.0, "VND": float(usage.get("usd_to_vnd", 25000.0) or 25000.0), "JPY": float(usage.get("usd_to_jpy", 150.0) or 150.0)} def convert(amount: float, from_ccy: str, to_ccy: str, config=None) -> float: """Convert ``amount`` from one supported currency to another.""" rates = _rates(config) frm = rates.get((from_ccy or "USD").upper(), 1.0) to = rates.get((to_ccy or "USD").upper(), 1.0) if frm <= 0: return amount return amount / frm * to _SYMBOLS = {"VND": "₫", "JPY": "¥", "USD": "$"} _DIGITS = {"VND": 0, "JPY": 1, "USD": 4} def format_price(amount: float, ccy: str) -> str: ccy = (ccy or "USD").upper() return f"{amount:,.{_DIGITS.get(ccy, 2)}f} {_SYMBOLS.get(ccy, '')}".strip() def parse_price(text: Any) -> tuple: """Parse a price cell like ``"20.359 ₫"`` / ``"$0.15"`` → ``(amount, ccy)``. ``ccy`` is ``None`` when no symbol is present (caller supplies a default). VND is treated as integer thousands (``20.359`` → ``20359``); other currencies use ``.`` as the decimal separator.""" if text is None: return 0.0, None s = str(text).strip() if not s: return 0.0, None ccy = None low = s.lower() for sym, code in _SYMBOL_CCY.items(): if sym in low: ccy = code break # strip everything but digits and separators cleaned = "".join(ch for ch in s if ch.isdigit() or ch in ".,") if not cleaned: return 0.0, ccy try: if ccy == "VND": return float(cleaned.replace(".", "").replace(",", "")), ccy return float(cleaned.replace(",", "")), ccy except ValueError: return 0.0, ccy # ---- store --------------------------------------------------------------- def _bucket(config) -> Dict[str, Any]: return config.data.setdefault("model_pricing", {}) def list_entries(config) -> List[Dict[str, Any]]: return list(_bucket(config).get("entries", []) or []) def save_entries(config, entries: List[Dict[str, Any]]) -> None: _bucket(config)["entries"] = [dict(e) for e in entries] sync_to_usage(config) # keep the cost engine (Overview + Dashboard) in sync def _norm_entry(model: str, ctx_len: str = "", max_out: str = "", in_price=0.0, in_ccy: Optional[str] = None, in_unit: str = _DEFAULT_UNIT, out_price=0.0, out_ccy: Optional[str] = None, out_unit: str = _DEFAULT_UNIT, default_ccy: str = "USD") -> Dict[str, Any]: return { "model": str(model).strip(), "context_length": str(ctx_len).strip(), "max_output": str(max_out).strip(), "input_price": float(in_price or 0.0), "input_ccy": (in_ccy or default_ccy).upper(), "input_unit": (in_unit or _DEFAULT_UNIT).strip(), "output_price": float(out_price or 0.0), "output_ccy": (out_ccy or default_ccy).upper(), "output_unit": (out_unit or _DEFAULT_UNIT).strip(), } def usd_rates_for(model: str, config) -> Optional[Dict[str, float]]: """USD price per 1M tokens for ``model`` from the price table (converting the entry's own currency to USD), or None when the model isn't in the table.""" for e in list_entries(config): if e.get("model") == model: return { "in": convert(float(e.get("input_price", 0) or 0), e.get("input_ccy", "USD"), "USD", config), "out": convert(float(e.get("output_price", 0) or 0), e.get("output_ccy", "USD"), "USD", config), } return None def turn_cost_usd(model: str, in_tok: int, out_tok: int, config) -> float: """Cost (USD) of a turn — uses the model's row in the price table when present, else the usage tracker's flat fallback rates. Auto-updates when the user switches models (a different model → its own row / rates).""" rates = usd_rates_for(model, config) if rates is None: from . import usage_tracker as ut p = {**ut.DEFAULT_PRICING, **((getattr(config, "data", {}) or {}).get("usage") or {})} rates = {"in": float(p["price_per_mtok_in_usd"]), "out": float(p["price_per_mtok_out_usd"])} return (in_tok or 0) / 1e6 * rates["in"] + (out_tok or 0) / 1e6 * rates["out"] def sync_to_usage(config) -> Dict[str, Dict[str, float]]: """Push this table's per-model USD rates into the usage tracker's ``usage.model_prices`` map, so token-cost TOTALS on the Monitoring Overview cards AND the Dashboard chart are computed straight from THIS price table (and update automatically whenever it is imported/edited). Only rows that carry a non-zero price are pushed; unpriced models fall back to the flat ``price_per_mtok_*`` rates in the usage tracker.""" if config is None: return {} usage = config.data.setdefault("usage", {}) table: Dict[str, Dict[str, float]] = {} for e in list_entries(config): model = str(e.get("model", "")).strip() if not model: continue in_usd = convert(float(e.get("input_price", 0) or 0), e.get("input_ccy", "USD"), "USD", config) out_usd = convert(float(e.get("output_price", 0) or 0), e.get("output_ccy", "USD"), "USD", config) if in_usd <= 0 and out_usd <= 0: continue # unpriced row → leave this model to the flat fallback table[model] = {"in": in_usd, "out": out_usd} usage["model_prices"] = table return table def format_tokens(n: int) -> str: """Compact token count: 108 · 2.0k · 104.8k · 3.29M.""" n = int(n or 0) if n >= 1_000_000: return f"{n / 1e6:.2f}M" if n >= 1000: return f"{n / 1000:.1f}k" return str(n) def add_entry(config, entry: Dict[str, Any]) -> None: entries = list_entries(config) entries = [e for e in entries if e.get("model") != entry.get("model")] # replace same model entries.append(entry) save_entries(config, entries) def entry_from_row(cells: List[Any], default_ccy: str = "USD"): """Build an entry from a template row (list in COLUMNS order). Returns None for a blank/header row.""" cells = list(cells) + [None] * (len(COLUMNS) - len(cells)) model = str(cells[0] or "").strip() if not model or model.lower() == COLUMNS[0].lower(): return None in_amt, in_ccy = parse_price(cells[3]) out_amt, out_ccy = parse_price(cells[5]) return _norm_entry(model, cells[1] or "", cells[2] or "", in_amt, in_ccy, str(cells[4] or _DEFAULT_UNIT), out_amt, out_ccy, str(cells[6] or _DEFAULT_UNIT), default_ccy=default_ccy) # ---- import / export ----------------------------------------------------- def export_template(path: str | Path) -> Path: """Write the fill-in price template (headers + 1 example row) as .xlsx.""" from openpyxl import Workbook from openpyxl.styles import Font, PatternFill wb = Workbook() ws = wb.active ws.title = "Pricing" ws.append(COLUMNS) for cell in ws[1]: cell.font = Font(bold=True, color="FFFFFF") cell.fill = PatternFill("solid", fgColor="F37021") ws.append(_EXAMPLE_ROW) for col, header in enumerate(COLUMNS, 1): ws.column_dimensions[ws.cell(row=1, column=col).column_letter].width = max(16, len(header) + 2) path = Path(path) wb.save(str(path)) return path def import_table(path: str | Path, default_ccy: str = "USD") -> List[Dict[str, Any]]: """Parse a filled template (.xlsx / .csv) into entries. Raises ValueError on an unusable file; blank rows are skipped.""" p = Path(path) ext = p.suffix.lower() if ext in (".xlsx", ".xls", ".xlsm"): rows = _rows_from_xlsx(p) elif ext == ".csv": rows = _rows_from_csv(p) else: raise ValueError(f"Unsupported file type '{ext}'. Use .xlsx or .csv.") entries = [] for r in rows: e = entry_from_row(r, default_ccy=default_ccy) if e is not None: entries.append(e) if not entries: raise ValueError("No price rows found — fill in the template first.") return entries def _rows_from_xlsx(path: Path) -> List[List[Any]]: from openpyxl import load_workbook try: wb = load_workbook(str(path), data_only=True) except Exception as exc: # noqa: BLE001 raise ValueError(f"Cannot read Excel file: {exc}") from exc ws = wb["Pricing"] if "Pricing" in wb.sheetnames else wb.active return [list(r) for r in ws.iter_rows(min_row=1, values_only=True)] def _rows_from_csv(path: Path) -> List[List[Any]]: try: text = path.read_text(encoding="utf-8-sig") except OSError as exc: raise ValueError(f"Cannot read CSV file: {exc}") from exc return [list(r) for r in csv.reader(text.splitlines())] # ---- auto-link from the providers --------------------------------------- def auto_link(ctx, default_ccy: str = "USD") -> List[Dict[str, Any]]: """Fetch the live model list from the configured providers and add a row for each NEW model (blank prices, to be filled in). Returns the merged list and saves it. Best-effort: unreachable providers are simply skipped.""" from . import preview_ai try: by_provider = preview_ai.fetch_live_models(ctx) or {} except Exception: # noqa: BLE001 by_provider = {} models = [] for lst in by_provider.values(): models.extend(lst or []) entries = list_entries(ctx.config) have = {e.get("model") for e in entries} for m in sorted(set(models)): if m and m not in have: entries.append(_norm_entry(m, default_ccy=default_ccy)) have.add(m) save_entries(ctx.config, entries) return entries