From 3fee617d9d4fee3222a0654771a5fffce4a583a2 Mon Sep 17 00:00:00 2001 From: ttz69 Date: Tue, 28 Jul 2026 00:03:46 +0700 Subject: [PATCH 01/16] Add files via upload --- openrouter-price-tracker.py | 312 ++++++++++++++++++++++++++++++++++++ 1 file changed, 312 insertions(+) create mode 100644 openrouter-price-tracker.py diff --git a/openrouter-price-tracker.py b/openrouter-price-tracker.py new file mode 100644 index 0000000..c99b4ab --- /dev/null +++ b/openrouter-price-tracker.py @@ -0,0 +1,312 @@ +""" +title: Token Price Checker +description: Check OpenRouter model token prices (per 1M), detect price changes (⬆/⬇), and find comparable models. Commands: /price, /price comparable , /balance, /cheapest. +author: Your Name +version: 0.3 +requirements: requests +""" + +import os, json, asyncio, difflib, requests +from datetime import datetime, timedelta +from typing import Optional, Callable, Awaitable +from pydantic import BaseModel, Field + +OPENROUTER_SINGLE = "https://openrouter.ai/api/v1/model/{}" +OPENROUTER_ALL = "https://openrouter.ai/api/v1/models" +OPENROUTER_KEY = "https://openrouter.ai/api/v1/key" +CACHE_FILE = os.path.join(os.getcwd(), ".cache", "token_prices_v2.json") +FULL_CACHE = os.path.join(os.getcwd(), ".cache", "openrouter_models_cache.json") +CACHE_HOURS = 6; FUZZY_CUTOFF = 0.6 + +def _load(p): + if not os.path.exists(p): return {} + try: + with open(p, "r") as f: return json.load(f) + except: return {} + +def _save(p, d): + os.makedirs(os.path.dirname(p), exist_ok=True) + with open(p, "w") as f: json.dump(d, f, indent=2) + +def _per_1m(s): + if not s: return None + try: + v = float(s) + return None if v < 0 else round(v * 1_000_000, 4) + except: return None + +def _fmt(v, arrow=""): + if v is None: return "—" + if v == 0: return "**Free**" + return f"{arrow}${v:.4f}" + +def _change_tag(old, new): + """Returns (arrow_char, change_text).""" + if old is None or new is None: return ("", "") + if old == 0 and new > 0: return ("⬆", " **⬆ FORMERLY FREE**") + d = new - old + if d > 0.001: return ("⬆", f" **⬆ +${d:.4f}**") + if d < -0.001: return ("⬇", f" **⬇ {d:.4f}**") + return ("", "") + +def _fetch_one(mid): + try: + r = requests.get(OPENROUTER_SINGLE.format(mid), timeout=15) + r.raise_for_status() + return r.json().get("data") + except: return None + +def _fetch_all(force=False): + c = _load(FULL_CACHE) + if not force and c.get("fetched_at"): + try: + if datetime.now() - datetime.fromisoformat(c["fetched_at"]) < timedelta(hours=CACHE_HOURS): + return c.get("models", []) + except: pass + try: + r = requests.get(OPENROUTER_ALL, timeout=60, stream=True) + r.raise_for_status() + data = r.json().get("data", []) + _save(FULL_CACHE, {"fetched_at": datetime.now().isoformat(), "models": data}) + return data + except: return c.get("models", []) + +def _resolve_model(name, all_models): + if not name: return None + name = name.strip() + lookup = {m["id"]: m for m in all_models} + if name in lookup: return name + matches = difflib.get_close_matches(name, list(lookup.keys()), n=5, cutoff=FUZZY_CUTOFF) + return matches[0] if matches else None + +def _fmt_table(rows, headers): + lines = ["| " + " | ".join(headers) + " |"] + lines.append("| " + " | ".join(["---"] * len(headers)) + " |") + for row in rows: lines.append("| " + " | ".join(str(c) for c in row) + " |") + return "\n".join(lines) + +FOOTER = ("\n---\n" + "Available commands:\n" + "- /price comparable - find similar models at lower cost\n" + "- /balance - check your OpenRouter credit balance\n" + "- /cheapest - show top 10 cheapest models") + + +class Tools: + class Valves(BaseModel): + preselected_models: str = Field( + default="openai/gpt-4o,openai/gpt-4o-mini,anthropic/claude-sonnet-4.6,anthropic/claude-haiku-4.5,google/gemini-2.5-flash,google/gemini-2.5-pro,meta-llama/llama-3.1-70b-instruct,mistralai/mistral-small-3.1-24b-instruct,deepseek/deepseek-chat,qwen/qwen-plus", + description="Comma-separated list of OpenRouter model IDs to track") + performance_tolerance: int = Field(default=10, description="Performance tolerance (points)") + openrouter_api_key: str = Field(default="", description="OpenRouter API key for /balance") + + class UserValves(BaseModel): + openrouter_api_key: str = Field(default="", description="Overrides the tool-level key for /balance") + + def __init__(self): + self.citation = True + self.valves = self.Valves() + self.user_valves = self.UserValves() + + async def fetch_prices(self, message="", __event_emitter__=None, __user__=None): + """Check prices (/price), compare (/price comparable ), balance (/balance), cheapest (/cheapest).""" + api_key = self.valves.openrouter_api_key + uk = (__user__ or {}).get("valves") or self.user_valves + uk_key = getattr(uk, "openrouter_api_key", "") or self.user_valves.openrouter_api_key + if uk_key: api_key = uk_key + + if __event_emitter__: + await __event_emitter__({"type": "status", "data": {"description": "Working...", "done": False}}) + await asyncio.sleep(0) + + parts = message.strip().split() + cmd = parts[0].lower() if parts else "/price" + sub = parts[1].lower() if len(parts) >= 2 else "" + + try: + if cmd in ("/balance", "balance"): return self._check_balance(api_key) + if cmd in ("/cheapest", "cheapest"): return self._cheapest() + if cmd in ("/price", "price", "prices"): + if sub in ("comparable", "compare", "similar"): + return self._comparable(" ".join(parts[2:]) if len(parts) >= 3 else "") + return self._price_report(api_key) + return self._price_report(api_key) + finally: + if __event_emitter__: + await __event_emitter__({"type": "status", "data": {"description": "Done", "done": True, "hidden": True}}) + + def _price_report(self, api_key): + cache = _load(CACHE_FILE) + now = datetime.now().isoformat(timespec="minutes") + mids = [m.strip() for m in self.valves.preselected_models.split(",") if m.strip()] + L = [f"## Token Prices\n{now}\n"] + rows, upd, errs = [], {}, [] + + for mid in mids: + m = _fetch_one(mid) + if not m: errs.append(mid); continue + p = m.get("pricing", {}) + inp = _per_1m(p.get("prompt")) + out = _per_1m(p.get("completion")) + c = cache.get(mid, {}) + op = c.get("prompt_1m"); oc = c.get("completion_1m") + + inp_arrow, pi = _change_tag(op, inp) + out_arrow, co = _change_tag(oc, out) + + change_text = "" + parts_chg = [] + if pi: parts_chg.append(f"In{pi}") + if co: parts_chg.append(f"Out{co}") + if parts_chg: change_text = ", ".join(parts_chg) + + rows.append([mid, _fmt(inp, inp_arrow + " " if inp_arrow else ""), + _fmt(out, out_arrow + " " if out_arrow else ""), change_text]) + upd[mid] = {"prompt_1m": inp, "completion_1m": out, "last_updated": now} + + if rows: L.append(_fmt_table(rows, ["Model", "Input /1M", "Output /1M", "Change"])) + if errs: L.append(f"\nErrors: {', '.join(errs)}") + _save(CACHE_FILE, upd) + + chg = [] + for mid, cur in upd.items(): + o = cache.get(mid, {}) + if not o: continue + for k, lbl in [("prompt_1m", "Input"), ("completion_1m", "Output")]: + ov, nv = o.get(k), cur.get(k) + if ov is not None and nv is not None: + if ov == 0 and nv > 0: chg.append(f"- {mid} {lbl}: Free -> ${nv:.4f} ⬆") + elif nv > ov + 0.0001: chg.append(f"- {mid} {lbl}: ${ov:.4f} -> ${nv:.4f} (+${nv-ov:.4f})") + elif nv < ov - 0.0001: chg.append(f"- {mid} {lbl}: ${ov:.4f} -> ${nv:.4f} ({nv-ov:+.4f})") + L.append("\n### Changes\n" + ("\n".join(chg) if chg else "None.")) + L.append(FOOTER) + return "\n".join(L) + + def _cheapest(self): + fc = _load(FULL_CACHE) + models = fc.get("models", []) + if not models: + return "## Cheapest Models\nNo cached data. Run /price first.\n" + FOOTER + priced = [] + for m in models: + p = m.get("pricing", {}) + inp = _per_1m(p.get("prompt")); out = _per_1m(p.get("completion")) + if inp is not None and out is not None: + priced.append((round(inp * 0.75 + out * 0.25, 4), m["id"], inp, out)) + priced.sort(key=lambda x: x[0]) + L = ["## Top 10 Cheapest\n"] + L.append(_fmt_table( + [[i, mid, f"${inp:.4f}", f"${out:.4f}", f"${b:.4f}"] for i, (b, mid, inp, out) in enumerate(priced[:10], 1)], + ["#", "Model", "Input /1M", "Output /1M", "Blended"])) + L.append(FOOTER) + return "\n".join(L) + + def _comparable(self, hint): + all_m = _fetch_all() + tol = self.valves.performance_tolerance + if not all_m: return "Error: Could not fetch model list." + lookup = {m["id"]: m for m in all_m} + resolved = _resolve_model(hint, all_m) if hint else None + if not resolved: + L = ["## Comparable Models\n"] + if hint: L.append(f"No match for '{hint}'. Models with benchmarks:\n") + bm = [] + for m in all_m: + aa = (m.get("benchmarks") or {}).get("artificial_analysis") or {} + if aa.get("intelligence_index") is not None: + bm.append([m["id"], aa.get("intelligence_index","—"), aa.get("coding_index","—")]) + if len(bm) >= 20: break + if bm: L.append(_fmt_table(bm, ["Model", "Intel", "Coding"])) + L.append("\nUsage: /price comparable gpt-4o") + return "\n".join(L) + + ref = lookup[resolved] + aa = (ref.get("benchmarks") or {}).get("artificial_analysis") or {} + ri = aa.get("intelligence_index"); rc = aa.get("coding_index") + if ri is None: return f"Error: {resolved} has no benchmark data." + + rp = ref.get("pricing", {}) + rpi = _per_1m(rp.get("prompt")) or 0 + rco = _per_1m(rp.get("completion")) or 0 + rb = rpi * 0.75 + rco * 0.25 + + L = [f"## Comparable to {resolved}\n"] + L.append(f"Reference: Intel {ri}") + if rc is not None: L[-1] += f", Coding {rc}" + L[-1] += f" | Price: In ${rpi:.4f} / Out ${rco:.4f} / Blended ${rb:.4f}\n" + + cand = [] + for m in all_m: + if m["id"] == resolved: continue + a = (m.get("benchmarks") or {}).get("artificial_analysis") or {} + i = a.get("intelligence_index") + if i is None or abs(i - ri) > tol: continue + if rc is not None: + c = a.get("coding_index") + if c is not None and abs(c - rc) > tol: continue + p = m.get("pricing", {}) + pi = _per_1m(p.get("prompt")); co = _per_1m(p.get("completion")) + if pi is None or co is None: continue + cand.append((pi * 0.75 + co * 0.25, m["id"], pi, co, i, a.get("coding_index","—"))) + + cand.sort(key=lambda x: x[0]) + if not cand: + L.append(f"No comparable models within +/-{tol} points.") + L.append(FOOTER) + return "\n".join(L) + + cheap = [c for c in cand if c[0] <= rb] + pricey = [c for c in cand if c[0] > rb] + + if cheap: + L.append(f"\n### Cheaper or Equal (<= ${rb:.4f}) - {len(cheap)} found") + L.append(_fmt_table( + [[mid, f"${pi:.4f}", f"${co:.4f}", f"${b:.4f}", i, c] for b, mid, pi, co, i, c in cheap[:15]], + ["Model", "In/1M", "Out/1M", "Blended", "Intel", "Coding"])) + if pricey: + L.append(f"\n### More Expensive (> ${rb:.4f}) - {len(pricey)} found") + L.append(_fmt_table( + [[mid, f"${pi:.4f}", f"${co:.4f}", f"${b:.4f}", i, c] for b, mid, pi, co, i, c in pricey[:10]], + ["Model", "In/1M", "Out/1M", "Blended", "Intel", "Coding"])) + if hint and hint != resolved: + L.append(f"\nNote: '{hint}' matched to {resolved}") + + L.append(FOOTER) + return "\n".join(L) + + def _check_balance(self, api_key): + if not api_key and self.valves.openrouter_api_key: api_key = self.valves.openrouter_api_key + if not api_key: + return ("## OpenRouter Balance\n\nNo API key configured.\n" + "Set it in: Workspace > Tools > Edit > openrouter_api_key\n" + "Or: User Settings > User Valves > openrouter_api_key\n" + "Get a key: https://openrouter.ai/keys\n" + FOOTER) + try: + r = requests.get(OPENROUTER_KEY, headers={"Authorization": f"Bearer {api_key}"}, timeout=15) + if r.status_code == 401: return "## OpenRouter Balance\n\nInvalid API key.\n" + FOOTER + r.raise_for_status() + kd = r.json().get("data", {}) + bal = None + try: + r2 = requests.get("https://openrouter.ai/api/v1/credits", + headers={"Authorization": f"Bearer {api_key}"}, timeout=10) + if r2.status_code == 200: + cd = r2.json().get("data", {}) + t = cd.get("total_credits"); u = cd.get("total_usage") + if t is not None and u is not None: bal = round(t - u, 2) + except: pass + except Exception as e: return f"## OpenRouter Balance\n\nError: {e}\n" + FOOTER + + rows = [["Key Label", kd.get("label", "—")]] + if bal is not None: rows.append(["Account Balance", f"${bal:.2f}"]) + else: + lr = kd.get("limit_remaining") + rows.append(["Key Limit", f"${lr:.2f}" if lr is not None else "No limit set"]) + rows.append(["Total Spent", f"${kd.get('usage',0):.2f}"]) + rows.append(["Spent Today", f"${kd.get('usage_daily',0):.2f}"]) + rows.append(["Spent This Month", f"${kd.get('usage_monthly',0):.2f}"]) + rows.append(["Free Tier", "Yes" if kd.get("is_free_tier") else "No"]) + L = ["## OpenRouter Balance\n", _fmt_table(rows, ["Field", "Value"])] + if bal is not None and bal < 1.0: L.append("\nLow balance!") + L.append(FOOTER) + return "\n".join(L) \ No newline at end of file From 8c5f6d825910a38aa14b4467c2be6ff5ebdbe96c Mon Sep 17 00:00:00 2001 From: ttz69 Date: Tue, 28 Jul 2026 00:09:52 +0700 Subject: [PATCH 02/16] Update README.md --- README.md | 23 ++++++----------------- 1 file changed, 6 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index cef39c9..c3ec69f 100644 --- a/README.md +++ b/README.md @@ -1,18 +1,7 @@ -# open-webui/functions 🚀 +A simple Openrouter price tracker and balance checker -Curated custom functions approved by the Open WebUI core team. - -- ✅ High-quality, reliable, and ready to use -- ⚡ Easy integration with your Open WebUI projects - - -Check out these links for more information and help with Functions: - -- 🛠️ [Plugins Overview](https://docs.openwebui.com/features/plugin/) -- 🧰 [Functions](https://docs.openwebui.com/features/plugin/functions/) -- 🚰 [Pipe Function](https://docs.openwebui.com/features/plugin/functions/pipe) -- 🪄 [Filter Function](https://docs.openwebui.com/features/plugin/functions/filter) -- 🎬 [Action Function](https://docs.openwebui.com/features/plugin/functions/action) - - -Looking for more? Discover community-contributed functions at [openwebui.com](http://openwebui.com/) 🌐 +Commands in chat: +/price: Pulls current prices for all models added to the tool's respective Valve and marks changes in price +/balance: Checks your current Openrouter balance (requires you to enter you Openrouter API key in the Valve) +/cheapest: lists the 10 currently cheapest models on Openrouter +/price compare : compares prices of similar models (fuzzy input tolerated) From fc3ac50242f9b7eaac1cb6d4699650ab11372228 Mon Sep 17 00:00:00 2001 From: ttz69 Date: Tue, 28 Jul 2026 00:11:08 +0700 Subject: [PATCH 03/16] Update README.md --- README.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index c3ec69f..5eed4da 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ A simple Openrouter price tracker and balance checker Commands in chat: -/price: Pulls current prices for all models added to the tool's respective Valve and marks changes in price -/balance: Checks your current Openrouter balance (requires you to enter you Openrouter API key in the Valve) -/cheapest: lists the 10 currently cheapest models on Openrouter -/price compare : compares prices of similar models (fuzzy input tolerated) +- /price: Pulls current prices for all models added to the tool's respective Valve and marks changes in price +- /balance: Checks your current Openrouter balance (requires you to enter you Openrouter API key in the Valve) +- /cheapest: lists the 10 currently cheapest models on Openrouter +- /price compare : compares prices of similar models (fuzzy input tolerated) From 6d6f9035365bcf44903d8972b89c804a38329a2a Mon Sep 17 00:00:00 2001 From: ttz69 Date: Tue, 28 Jul 2026 00:42:28 +0700 Subject: [PATCH 04/16] Update README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 5eed4da..7074f7b 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -A simple Openrouter price tracker and balance checker +A simple Openrouter price tracker and balance checker tool for Open WebUI Commands in chat: - /price: Pulls current prices for all models added to the tool's respective Valve and marks changes in price From 2f2b6ed7c6e249e83046c75ae7dbb5ff1390baaa Mon Sep 17 00:00:00 2001 From: ttz69 Date: Tue, 28 Jul 2026 00:43:18 +0700 Subject: [PATCH 05/16] Update README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 7074f7b..5f7e399 100644 --- a/README.md +++ b/README.md @@ -4,4 +4,4 @@ Commands in chat: - /price: Pulls current prices for all models added to the tool's respective Valve and marks changes in price - /balance: Checks your current Openrouter balance (requires you to enter you Openrouter API key in the Valve) - /cheapest: lists the 10 currently cheapest models on Openrouter -- /price compare : compares prices of similar models (fuzzy input tolerated) +- /price compare model-name: compares prices of similar models (fuzzy input tolerated) From 77e3b934777c85e61f876d5d7efc3a399e5ee758 Mon Sep 17 00:00:00 2001 From: ttz69 Date: Tue, 28 Jul 2026 00:44:22 +0700 Subject: [PATCH 06/16] Update README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 5f7e399..3ed8316 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ A simple Openrouter price tracker and balance checker tool for Open WebUI -Commands in chat: +Commands in chat (or use natural language): - /price: Pulls current prices for all models added to the tool's respective Valve and marks changes in price - /balance: Checks your current Openrouter balance (requires you to enter you Openrouter API key in the Valve) - /cheapest: lists the 10 currently cheapest models on Openrouter From 7f9cf03d708f61481cad9b91ec42fd7e2bd9eda6 Mon Sep 17 00:00:00 2001 From: ttz69 Date: Tue, 28 Jul 2026 00:44:44 +0700 Subject: [PATCH 07/16] Update README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 3ed8316..62d2dae 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ A simple Openrouter price tracker and balance checker tool for Open WebUI -Commands in chat (or use natural language): +Commands in chat - or use natural language: - /price: Pulls current prices for all models added to the tool's respective Valve and marks changes in price - /balance: Checks your current Openrouter balance (requires you to enter you Openrouter API key in the Valve) - /cheapest: lists the 10 currently cheapest models on Openrouter From 42a462a1a5240e68e2e26a27db47749c7d8c7775 Mon Sep 17 00:00:00 2001 From: ttz69 Date: Tue, 28 Jul 2026 00:44:59 +0700 Subject: [PATCH 08/16] Update README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 62d2dae..3ed8316 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ A simple Openrouter price tracker and balance checker tool for Open WebUI -Commands in chat - or use natural language: +Commands in chat (or use natural language): - /price: Pulls current prices for all models added to the tool's respective Valve and marks changes in price - /balance: Checks your current Openrouter balance (requires you to enter you Openrouter API key in the Valve) - /cheapest: lists the 10 currently cheapest models on Openrouter From 433466ed87299eb51f76dea9c973380c55cb9362 Mon Sep 17 00:00:00 2001 From: ttz69 Date: Tue, 28 Jul 2026 03:19:39 +0700 Subject: [PATCH 09/16] Update README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 3ed8316..a7b0755 100644 --- a/README.md +++ b/README.md @@ -4,4 +4,4 @@ Commands in chat (or use natural language): - /price: Pulls current prices for all models added to the tool's respective Valve and marks changes in price - /balance: Checks your current Openrouter balance (requires you to enter you Openrouter API key in the Valve) - /cheapest: lists the 10 currently cheapest models on Openrouter -- /price compare model-name: compares prices of similar models (fuzzy input tolerated) +- /price compared model-name: compares prices of similar models (fuzzy input tolerated) From e4e1facf3f2c9c6c752a89783db76e9aae612c3b Mon Sep 17 00:00:00 2001 From: ttz69 Date: Tue, 28 Jul 2026 03:20:04 +0700 Subject: [PATCH 10/16] Update README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index a7b0755..9a30bae 100644 --- a/README.md +++ b/README.md @@ -4,4 +4,4 @@ Commands in chat (or use natural language): - /price: Pulls current prices for all models added to the tool's respective Valve and marks changes in price - /balance: Checks your current Openrouter balance (requires you to enter you Openrouter API key in the Valve) - /cheapest: lists the 10 currently cheapest models on Openrouter -- /price compared model-name: compares prices of similar models (fuzzy input tolerated) +- /price compared : compares prices of similar models (fuzzy input tolerated) From 03aec37ee58b6181fea6592b5e6d1331660b3591 Mon Sep 17 00:00:00 2001 From: ttz69 Date: Tue, 28 Jul 2026 03:20:21 +0700 Subject: [PATCH 11/16] Update README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 9a30bae..4cd7758 100644 --- a/README.md +++ b/README.md @@ -4,4 +4,4 @@ Commands in chat (or use natural language): - /price: Pulls current prices for all models added to the tool's respective Valve and marks changes in price - /balance: Checks your current Openrouter balance (requires you to enter you Openrouter API key in the Valve) - /cheapest: lists the 10 currently cheapest models on Openrouter -- /price compared : compares prices of similar models (fuzzy input tolerated) +- /price compared (model-name): compares prices of similar models (fuzzy input tolerated) From f1a0b4d3da99bb00ac321f15800ab489d5e81315 Mon Sep 17 00:00:00 2001 From: ttz69 Date: Tue, 28 Jul 2026 03:22:54 +0700 Subject: [PATCH 12/16] Update README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 4cd7758..9f1aecc 100644 --- a/README.md +++ b/README.md @@ -4,4 +4,4 @@ Commands in chat (or use natural language): - /price: Pulls current prices for all models added to the tool's respective Valve and marks changes in price - /balance: Checks your current Openrouter balance (requires you to enter you Openrouter API key in the Valve) - /cheapest: lists the 10 currently cheapest models on Openrouter -- /price compared (model-name): compares prices of similar models (fuzzy input tolerated) +- /price comparable (model-name): compares prices of similar models (fuzzy input tolerated) From 0b381e3a9c997e6ac1e0fcb5b5791c98c2f7cba2 Mon Sep 17 00:00:00 2001 From: ttz69 Date: Tue, 28 Jul 2026 03:28:09 +0700 Subject: [PATCH 13/16] Update README.md --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index 9f1aecc..8185c1b 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,7 @@ A simple Openrouter price tracker and balance checker tool for Open WebUI +Sometimes a bit clunky, particularly on smaller models, but generally it should work + Commands in chat (or use natural language): - /price: Pulls current prices for all models added to the tool's respective Valve and marks changes in price - /balance: Checks your current Openrouter balance (requires you to enter you Openrouter API key in the Valve) From db6cf5156baf3cf736caac0f72b3ef4bfe0bcb30 Mon Sep 17 00:00:00 2001 From: ttz69 Date: Wed, 29 Jul 2026 20:34:38 +0700 Subject: [PATCH 14/16] Update openrouter-price-tracker.py - Added ability to pass Openrouter API key via environment variable in the docker run command (-e OPENROUTER_API_KEY="sk-..."), so user can enter $OPENROUTER_API_KEY instead of the actual API key in the Valve - fixed a bug with the caching of prices --- openrouter-price-tracker.py | 361 +++++++++++++++++++++++++----------- 1 file changed, 249 insertions(+), 112 deletions(-) diff --git a/openrouter-price-tracker.py b/openrouter-price-tracker.py index c99b4ab..9441201 100644 --- a/openrouter-price-tracker.py +++ b/openrouter-price-tracker.py @@ -2,7 +2,7 @@ title: Token Price Checker description: Check OpenRouter model token prices (per 1M), detect price changes (⬆/⬇), and find comparable models. Commands: /price, /price comparable , /balance, /cheapest. author: Your Name -version: 0.3 +version: 0.3.2 requirements: requests """ @@ -16,107 +16,169 @@ OPENROUTER_KEY = "https://openrouter.ai/api/v1/key" CACHE_FILE = os.path.join(os.getcwd(), ".cache", "token_prices_v2.json") FULL_CACHE = os.path.join(os.getcwd(), ".cache", "openrouter_models_cache.json") -CACHE_HOURS = 6; FUZZY_CUTOFF = 0.6 +CACHE_HOURS = 6 +FUZZY_CUTOFF = 0.6 + def _load(p): - if not os.path.exists(p): return {} + if not os.path.exists(p): + return {} try: - with open(p, "r") as f: return json.load(f) - except: return {} + with open(p, "r") as f: + return json.load(f) + except: + return {} + def _save(p, d): os.makedirs(os.path.dirname(p), exist_ok=True) - with open(p, "w") as f: json.dump(d, f, indent=2) + with open(p, "w") as f: + json.dump(d, f, indent=2) + def _per_1m(s): - if not s: return None + if not s: + return None try: v = float(s) return None if v < 0 else round(v * 1_000_000, 4) - except: return None + except: + return None + def _fmt(v, arrow=""): - if v is None: return "—" - if v == 0: return "**Free**" + if v is None: + return "—" + if v == 0: + return "**Free**" return f"{arrow}${v:.4f}" + def _change_tag(old, new): """Returns (arrow_char, change_text).""" - if old is None or new is None: return ("", "") - if old == 0 and new > 0: return ("⬆", " **⬆ FORMERLY FREE**") + if old is None or new is None: + return ("", "") + if old == 0 and new > 0: + return ("⬆", " **⬆ FORMERLY FREE**") d = new - old - if d > 0.001: return ("⬆", f" **⬆ +${d:.4f}**") - if d < -0.001: return ("⬇", f" **⬇ {d:.4f}**") + if d > 0.001: + return ("⬆", f" **⬆ +${d:.4f}**") + if d < -0.001: + return ("⬇", f" **⬇ {d:.4f}**") return ("", "") + def _fetch_one(mid): try: r = requests.get(OPENROUTER_SINGLE.format(mid), timeout=15) r.raise_for_status() return r.json().get("data") - except: return None + except: + return None + def _fetch_all(force=False): c = _load(FULL_CACHE) if not force and c.get("fetched_at"): try: - if datetime.now() - datetime.fromisoformat(c["fetched_at"]) < timedelta(hours=CACHE_HOURS): + if datetime.now() - datetime.fromisoformat(c["fetched_at"]) < timedelta( + hours=CACHE_HOURS + ): return c.get("models", []) - except: pass + except: + pass try: r = requests.get(OPENROUTER_ALL, timeout=60, stream=True) r.raise_for_status() data = r.json().get("data", []) _save(FULL_CACHE, {"fetched_at": datetime.now().isoformat(), "models": data}) return data - except: return c.get("models", []) + except: + return c.get("models", []) + def _resolve_model(name, all_models): - if not name: return None + if not name: + return None name = name.strip() lookup = {m["id"]: m for m in all_models} - if name in lookup: return name - matches = difflib.get_close_matches(name, list(lookup.keys()), n=5, cutoff=FUZZY_CUTOFF) + if name in lookup: + return name + matches = difflib.get_close_matches( + name, list(lookup.keys()), n=5, cutoff=FUZZY_CUTOFF + ) return matches[0] if matches else None + def _fmt_table(rows, headers): lines = ["| " + " | ".join(headers) + " |"] lines.append("| " + " | ".join(["---"] * len(headers)) + " |") - for row in rows: lines.append("| " + " | ".join(str(c) for c in row) + " |") + for row in rows: + lines.append("| " + " | ".join(str(c) for c in row) + " |") return "\n".join(lines) -FOOTER = ("\n---\n" + +FOOTER = ( + "\n---\n" "Available commands:\n" "- /price comparable - find similar models at lower cost\n" "- /balance - check your OpenRouter credit balance\n" - "- /cheapest - show top 10 cheapest models") + "- /cheapest - show top 10 cheapest models" +) class Tools: class Valves(BaseModel): preselected_models: str = Field( default="openai/gpt-4o,openai/gpt-4o-mini,anthropic/claude-sonnet-4.6,anthropic/claude-haiku-4.5,google/gemini-2.5-flash,google/gemini-2.5-pro,meta-llama/llama-3.1-70b-instruct,mistralai/mistral-small-3.1-24b-instruct,deepseek/deepseek-chat,qwen/qwen-plus", - description="Comma-separated list of OpenRouter model IDs to track") - performance_tolerance: int = Field(default=10, description="Performance tolerance (points)") - openrouter_api_key: str = Field(default="", description="OpenRouter API key for /balance") + description="Comma-separated list of OpenRouter model IDs to track", + ) + performance_tolerance: int = Field( + default=10, description="Performance tolerance (points)" + ) + openrouter_api_key: str = Field( + default="", + description="OpenRouter API key for /balance. Use $ENV_VAR_NAME to load from a Docker environment variable instead of the raw key.", + ) class UserValves(BaseModel): - openrouter_api_key: str = Field(default="", description="Overrides the tool-level key for /balance") + openrouter_api_key: str = Field( + default="", + description="Overrides the tool-level key for /balance. Use $ENV_VAR_NAME to load from a Docker environment variable.", + ) def __init__(self): self.citation = True self.valves = self.Valves() self.user_valves = self.UserValves() + @staticmethod + def _resolve_key(raw_key): + if not raw_key: + return "" + s = raw_key.strip() + if s.startswith("$"): + env_name = s[1:] + env_val = os.environ.get(env_name) + if env_val: + return env_val + return raw_key + return raw_key + async def fetch_prices(self, message="", __event_emitter__=None, __user__=None): """Check prices (/price), compare (/price comparable ), balance (/balance), cheapest (/cheapest).""" - api_key = self.valves.openrouter_api_key + api_key = self._resolve_key(self.valves.openrouter_api_key) + uk = (__user__ or {}).get("valves") or self.user_valves - uk_key = getattr(uk, "openrouter_api_key", "") or self.user_valves.openrouter_api_key - if uk_key: api_key = uk_key + uk_raw = getattr(uk, "openrouter_api_key", "") or self.user_valves.openrouter_api_key + uk_key = self._resolve_key(uk_raw) + if uk_key: + api_key = uk_key if __event_emitter__: - await __event_emitter__({"type": "status", "data": {"description": "Working...", "done": False}}) + await __event_emitter__( + {"type": "status", "data": {"description": "Working...", "done": False}} + ) await asyncio.sleep(0) parts = message.strip().split() @@ -124,130 +186,173 @@ async def fetch_prices(self, message="", __event_emitter__=None, __user__=None): sub = parts[1].lower() if len(parts) >= 2 else "" try: - if cmd in ("/balance", "balance"): return self._check_balance(api_key) - if cmd in ("/cheapest", "cheapest"): return self._cheapest() + if cmd in ("/balance", "balance"): + return self._check_balance(api_key) + if cmd in ("/cheapest", "cheapest"): + return self._cheapest() if cmd in ("/price", "price", "prices"): if sub in ("comparable", "compare", "similar"): - return self._comparable(" ".join(parts[2:]) if len(parts) >= 3 else "") + return self._comparable( + " ".join(parts[2:]) if len(parts) >= 3 else "" + ) return self._price_report(api_key) return self._price_report(api_key) finally: if __event_emitter__: - await __event_emitter__({"type": "status", "data": {"description": "Done", "done": True, "hidden": True}}) + await __event_emitter__( + { + "type": "status", + "data": {"description": "Done", "done": True, "hidden": True}, + } + ) def _price_report(self, api_key): cache = _load(CACHE_FILE) now = datetime.now().isoformat(timespec="minutes") - mids = [m.strip() for m in self.valves.preselected_models.split(",") if m.strip()] + mids = [ + m.strip() for m in self.valves.preselected_models.split(",") if m.strip() + ] L = [f"## Token Prices\n{now}\n"] rows, upd, errs = [], {}, [] for mid in mids: m = _fetch_one(mid) - if not m: errs.append(mid); continue + if not m: + errs.append(mid) + continue p = m.get("pricing", {}) inp = _per_1m(p.get("prompt")) out = _per_1m(p.get("completion")) c = cache.get(mid, {}) - op = c.get("prompt_1m"); oc = c.get("completion_1m") + op = c.get("prompt_1m") + oc = c.get("completion_1m") inp_arrow, pi = _change_tag(op, inp) out_arrow, co = _change_tag(oc, out) change_text = "" parts_chg = [] - if pi: parts_chg.append(f"In{pi}") - if co: parts_chg.append(f"Out{co}") - if parts_chg: change_text = ", ".join(parts_chg) - - rows.append([mid, _fmt(inp, inp_arrow + " " if inp_arrow else ""), - _fmt(out, out_arrow + " " if out_arrow else ""), change_text]) + if pi: + parts_chg.append(f"In{pi}") + if co: + parts_chg.append(f"Out{co}") + if parts_chg: + change_text = ", ".join(parts_chg) + + rows.append( + [ + mid, + _fmt(inp, inp_arrow + " " if inp_arrow else ""), + _fmt(out, out_arrow + " " if out_arrow else ""), + change_text, + ] + ) upd[mid] = {"prompt_1m": inp, "completion_1m": out, "last_updated": now} - if rows: L.append(_fmt_table(rows, ["Model", "Input /1M", "Output /1M", "Change"])) - if errs: L.append(f"\nErrors: {', '.join(errs)}") + if rows: + L.append(_fmt_table(rows, ["Model", "Input /1M", "Output /1M", "Change"])) + if errs: + L.append(f"\nErrors: {', '.join(errs)}") _save(CACHE_FILE, upd) chg = [] for mid, cur in upd.items(): o = cache.get(mid, {}) - if not o: continue + if not o: + continue for k, lbl in [("prompt_1m", "Input"), ("completion_1m", "Output")]: ov, nv = o.get(k), cur.get(k) if ov is not None and nv is not None: - if ov == 0 and nv > 0: chg.append(f"- {mid} {lbl}: Free -> ${nv:.4f} ⬆") - elif nv > ov + 0.0001: chg.append(f"- {mid} {lbl}: ${ov:.4f} -> ${nv:.4f} (+${nv-ov:.4f})") - elif nv < ov - 0.0001: chg.append(f"- {mid} {lbl}: ${ov:.4f} -> ${nv:.4f} ({nv-ov:+.4f})") + if ov == 0 and nv > 0: + chg.append(f"- {mid} {lbl}: Free -> ${nv:.4f} ⬆") + elif nv > ov + 0.0001: + chg.append( + f"- {mid} {lbl}: ${ov:.4f} -> ${nv:.4f} (+${nv-ov:.4f})" + ) + elif nv < ov - 0.0001: + chg.append( + f"- {mid} {lbl}: ${ov:.4f} -> ${nv:.4f} ({nv-ov:+.4f})" + ) L.append("\n### Changes\n" + ("\n".join(chg) if chg else "None.")) L.append(FOOTER) return "\n".join(L) def _cheapest(self): - fc = _load(FULL_CACHE) - models = fc.get("models", []) + # FIXED: Use _fetch_all() instead of raw _load(FULL_CACHE) + # so it auto-refreshes the cache — works regardless of command order [1] + models = _fetch_all() if not models: - return "## Cheapest Models\nNo cached data. Run /price first.\n" + FOOTER + return "## Cheapest Models\nCould not fetch model list from OpenRouter.\n" + FOOTER priced = [] for m in models: p = m.get("pricing", {}) - inp = _per_1m(p.get("prompt")); out = _per_1m(p.get("completion")) + inp = _per_1m(p.get("prompt")) + out = _per_1m(p.get("completion")) if inp is not None and out is not None: priced.append((round(inp * 0.75 + out * 0.25, 4), m["id"], inp, out)) priced.sort(key=lambda x: x[0]) L = ["## Top 10 Cheapest\n"] - L.append(_fmt_table( - [[i, mid, f"${inp:.4f}", f"${out:.4f}", f"${b:.4f}"] for i, (b, mid, inp, out) in enumerate(priced[:10], 1)], - ["#", "Model", "Input /1M", "Output /1M", "Blended"])) + L.append( + _fmt_table( + [ + [i, mid, f"${inp:.4f}", f"${out:.4f}", f"${b:.4f}"] + for i, (b, mid, inp, out) in enumerate(priced[:10], 1) + ], + ["#", "Model", "Input /1M", "Output /1M", "Blended"], + ) + ) L.append(FOOTER) return "\n".join(L) def _comparable(self, hint): all_m = _fetch_all() tol = self.valves.performance_tolerance - if not all_m: return "Error: Could not fetch model list." + if not all_m: + return "Error: Could not fetch model list." lookup = {m["id"]: m for m in all_m} resolved = _resolve_model(hint, all_m) if hint else None if not resolved: L = ["## Comparable Models\n"] - if hint: L.append(f"No match for '{hint}'. Models with benchmarks:\n") - bm = [] - for m in all_m: - aa = (m.get("benchmarks") or {}).get("artificial_analysis") or {} - if aa.get("intelligence_index") is not None: - bm.append([m["id"], aa.get("intelligence_index","—"), aa.get("coding_index","—")]) - if len(bm) >= 20: break - if bm: L.append(_fmt_table(bm, ["Model", "Intel", "Coding"])) - L.append("\nUsage: /price comparable gpt-4o") + L.append( + "Provide a model name, e.g. /price comparable gpt-4o\n" + ) + L.append(FOOTER) return "\n".join(L) - ref = lookup[resolved] - aa = (ref.get("benchmarks") or {}).get("artificial_analysis") or {} - ri = aa.get("intelligence_index"); rc = aa.get("coding_index") - if ri is None: return f"Error: {resolved} has no benchmark data." - - rp = ref.get("pricing", {}) - rpi = _per_1m(rp.get("prompt")) or 0 - rco = _per_1m(rp.get("completion")) or 0 - rb = rpi * 0.75 + rco * 0.25 + r = lookup[resolved] + rp = r.get("pricing", {}) + rpi = _per_1m(rp.get("prompt")) + rco = _per_1m(rp.get("completion")) + rb = round(rpi * 0.75 + rco * 0.25, 4) if rpi is not None and rco is not None else None + ba = (r.get("benchmarks") or {}).get("artificial_analysis") or {} + ri = ba.get("intelligence_index") + rc = ba.get("coding_index") - L = [f"## Comparable to {resolved}\n"] - L.append(f"Reference: Intel {ri}") - if rc is not None: L[-1] += f", Coding {rc}" - L[-1] += f" | Price: In ${rpi:.4f} / Out ${rco:.4f} / Blended ${rb:.4f}\n" + L = [f"## Comparable Models\n"] + L.append(f"**Reference:** `{resolved}`") + if rpi is not None and rco is not None: + L[-1] += f" | Price: In ${rpi:.4f} / Out ${rco:.4f} / Blended ${rb:.4f}\n" cand = [] for m in all_m: - if m["id"] == resolved: continue + if m["id"] == resolved: + continue a = (m.get("benchmarks") or {}).get("artificial_analysis") or {} i = a.get("intelligence_index") - if i is None or abs(i - ri) > tol: continue + if i is None or abs(i - ri) > tol: + continue if rc is not None: c = a.get("coding_index") - if c is not None and abs(c - rc) > tol: continue + if c is not None and abs(c - rc) > tol: + continue p = m.get("pricing", {}) - pi = _per_1m(p.get("prompt")); co = _per_1m(p.get("completion")) - if pi is None or co is None: continue - cand.append((pi * 0.75 + co * 0.25, m["id"], pi, co, i, a.get("coding_index","—"))) + pi = _per_1m(p.get("prompt")) + co = _per_1m(p.get("completion")) + if pi is None or co is None: + continue + cand.append( + (pi * 0.75 + co * 0.25, m["id"], pi, co, i, a.get("coding_index", "—")) + ) cand.sort(key=lambda x: x[0]) if not cand: @@ -255,19 +360,31 @@ def _comparable(self, hint): L.append(FOOTER) return "\n".join(L) - cheap = [c for c in cand if c[0] <= rb] - pricey = [c for c in cand if c[0] > rb] + cheap = [c for c in cand if c[0] <= rb] if rb is not None else [] + pricey = [c for c in cand if c[0] > rb] if rb is not None else cand if cheap: L.append(f"\n### Cheaper or Equal (<= ${rb:.4f}) - {len(cheap)} found") - L.append(_fmt_table( - [[mid, f"${pi:.4f}", f"${co:.4f}", f"${b:.4f}", i, c] for b, mid, pi, co, i, c in cheap[:15]], - ["Model", "In/1M", "Out/1M", "Blended", "Intel", "Coding"])) + L.append( + _fmt_table( + [ + [mid, f"${pi:.4f}", f"${co:.4f}", f"${b:.4f}", i, c] + for b, mid, pi, co, i, c in cheap[:15] + ], + ["Model", "In/1M", "Out/1M", "Blended", "Intel", "Coding"], + ) + ) if pricey: L.append(f"\n### More Expensive (> ${rb:.4f}) - {len(pricey)} found") - L.append(_fmt_table( - [[mid, f"${pi:.4f}", f"${co:.4f}", f"${b:.4f}", i, c] for b, mid, pi, co, i, c in pricey[:10]], - ["Model", "In/1M", "Out/1M", "Blended", "Intel", "Coding"])) + L.append( + _fmt_table( + [ + [mid, f"${pi:.4f}", f"${co:.4f}", f"${b:.4f}", i, c] + for b, mid, pi, co, i, c in pricey[:10] + ], + ["Model", "In/1M", "Out/1M", "Blended", "Intel", "Coding"], + ) + ) if hint and hint != resolved: L.append(f"\nNote: '{hint}' matched to {resolved}") @@ -275,38 +392,58 @@ def _comparable(self, hint): return "\n".join(L) def _check_balance(self, api_key): - if not api_key and self.valves.openrouter_api_key: api_key = self.valves.openrouter_api_key if not api_key: - return ("## OpenRouter Balance\n\nNo API key configured.\n" - "Set it in: Workspace > Tools > Edit > openrouter_api_key\n" - "Or: User Settings > User Valves > openrouter_api_key\n" - "Get a key: https://openrouter.ai/keys\n" + FOOTER) + api_key = self._resolve_key(self.valves.openrouter_api_key) + if not api_key: + return ( + "## OpenRouter Balance\n\nNo API key configured.\n" + "Set it in: Workspace > Tools > Edit > openrouter_api_key\n" + "Or: User Settings > User Valves > openrouter_api_key\n" + "Or: Set a Docker environment variable and use $VAR_NAME\n" + "Get a key: https://openrouter.ai/keys\n" + FOOTER + ) try: - r = requests.get(OPENROUTER_KEY, headers={"Authorization": f"Bearer {api_key}"}, timeout=15) - if r.status_code == 401: return "## OpenRouter Balance\n\nInvalid API key.\n" + FOOTER + r = requests.get( + OPENROUTER_KEY, + headers={"Authorization": f"Bearer {api_key}"}, + timeout=15, + ) + if r.status_code == 401: + return "## OpenRouter Balance\n\nInvalid API key.\n" + FOOTER r.raise_for_status() kd = r.json().get("data", {}) bal = None try: - r2 = requests.get("https://openrouter.ai/api/v1/credits", - headers={"Authorization": f"Bearer {api_key}"}, timeout=10) + r2 = requests.get( + "https://openrouter.ai/api/v1/credits", + headers={"Authorization": f"Bearer {api_key}"}, + timeout=10, + ) if r2.status_code == 200: cd = r2.json().get("data", {}) - t = cd.get("total_credits"); u = cd.get("total_usage") - if t is not None and u is not None: bal = round(t - u, 2) - except: pass - except Exception as e: return f"## OpenRouter Balance\n\nError: {e}\n" + FOOTER + t = cd.get("total_credits") + u = cd.get("total_usage") + if t is not None and u is not None: + bal = round(t - u, 2) + except: + pass + except Exception as e: + return f"## OpenRouter Balance\n\nError: {e}\n" + FOOTER rows = [["Key Label", kd.get("label", "—")]] - if bal is not None: rows.append(["Account Balance", f"${bal:.2f}"]) + if bal is not None: + rows.append(["Account Balance", f"${bal:.2f}"]) else: lr = kd.get("limit_remaining") - rows.append(["Key Limit", f"${lr:.2f}" if lr is not None else "No limit set"]) + rows.append( + ["Key Limit", f"${lr:.2f}" if lr is not None else "No limit set"] + ) rows.append(["Total Spent", f"${kd.get('usage',0):.2f}"]) rows.append(["Spent Today", f"${kd.get('usage_daily',0):.2f}"]) rows.append(["Spent This Month", f"${kd.get('usage_monthly',0):.2f}"]) rows.append(["Free Tier", "Yes" if kd.get("is_free_tier") else "No"]) L = ["## OpenRouter Balance\n", _fmt_table(rows, ["Field", "Value"])] - if bal is not None and bal < 1.0: L.append("\nLow balance!") + if bal is not None and bal < 1.0: + L.append("\nLow balance!") L.append(FOOTER) - return "\n".join(L) \ No newline at end of file + return "\n".join(L) From 3a2ee230be86d5dd99a515f38fcb993a0cf8cd75 Mon Sep 17 00:00:00 2001 From: ttz69 Date: Wed, 29 Jul 2026 20:42:05 +0700 Subject: [PATCH 15/16] Update README.md Updated instruxtions for environment variable for API key --- README.md | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 8185c1b..8786ea5 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,14 @@ A simple Openrouter price tracker and balance checker tool for Open WebUI -Sometimes a bit clunky, particularly on smaller models, but generally it should work +Sometimes a bit clunky, particularly on smaller models, but generally it should work fine Commands in chat (or use natural language): - /price: Pulls current prices for all models added to the tool's respective Valve and marks changes in price -- /balance: Checks your current Openrouter balance (requires you to enter you Openrouter API key in the Valve) +- /balance: Checks your current Openrouter balance (requires you to enter you Openrouter API key or environment varible "$OPENROUTER_API_KEY" in the Valve) - /cheapest: lists the 10 currently cheapest models on Openrouter - /price comparable (model-name): compares prices of similar models (fuzzy input tolerated) + +Note: +If you are running Open WebUi in a Docker container and prefer to not enter your Openrouter API key directly into the Valve you can pass an environment variable with your Openrouter API key in the docker run command: +docker run -e OPENROUTER_API_KEY="" +Then enter $OPENROUTER_API_KEY into the API key valve of the tool. From b23234e3fd7734131c41ac6489e42551345b541c Mon Sep 17 00:00:00 2001 From: ttz69 Date: Wed, 29 Jul 2026 20:43:58 +0700 Subject: [PATCH 16/16] Update README.md Typo fx --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 8786ea5..e246416 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ A simple Openrouter price tracker and balance checker tool for Open WebUI -Sometimes a bit clunky, particularly on smaller models, but generally it should work fine +Sometimes a bit clunky on smaller models, but generally it should work fine Commands in chat (or use natural language): - /price: Pulls current prices for all models added to the tool's respective Valve and marks changes in price