"""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" # Flat USD/1M-token fallback rates used by turn_cost_usd() when a model isn't # in the price table. Owned here (not usage_tracker.DEFAULT_PRICING) so this # module never needs to import usage_tracker — usage_tracker imports this # module instead, keeping the dependency one-directional. Values match # usage_tracker.DEFAULT_PRICING's price_per_mtok_in_usd/out_usd exactly. _FALLBACK_RATE_IN_USD = 0.5 _FALLBACK_RATE_OUT_USD = 1.5 # ---- 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: """Định dạng số tiền kèm ký hiệu tiền tệ, số chữ số thập phân theo từng loại tiền (VND 0, JPY 1, USD 4). """ 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]: """Nhóm cấu hình ``model_pricing``; tự tạo nếu chưa có.""" return config.data.setdefault("model_pricing", {}) def list_entries(config) -> List[Dict[str, Any]]: """Danh sách dòng đơn giá đã lưu (bản sao, sửa không ảnh hưởng cấu hình).""" return list(_bucket(config).get("entries", []) or []) def save_entries(config, entries: List[Dict[str, Any]]) -> None: """Ghi lại toàn bộ bảng đơn giá và đồng bộ sang bộ tính chi phí. Đồng bộ ngay tại đây để Tổng quan và Dashboard không hiện số tiền tính theo bảng giá cũ. """ _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]: """Chuẩn hoá một dòng đơn giá về đúng khuôn lưu trữ, điền mặc định cho ô trống.""" 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: usage = (getattr(config, "data", {}) or {}).get("usage") or {} rates = { "in": float(usage.get("price_per_mtok_in_usd", _FALLBACK_RATE_IN_USD)), "out": float(usage.get("price_per_mtok_out_usd", _FALLBACK_RATE_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: """Thêm một dòng đơn giá; đã có model đó thì THAY THẾ chứ không thêm trùng.""" 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]]: """Đọc các dòng từ file Excel (lấy giá trị đã tính, không lấy công thức).""" 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]]: """Đọc các dòng từ file CSV, chấp nhận BOM của Excel.""" 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