utim-cli 1.47.0__tar.gz → 1.47.2__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {utim_cli-1.47.0 → utim_cli-1.47.2}/CHANGELOG.md +29 -0
- {utim_cli-1.47.0/utim_cli.egg-info → utim_cli-1.47.2}/PKG-INFO +1 -1
- {utim_cli-1.47.0 → utim_cli-1.47.2}/pyproject.toml +1 -1
- {utim_cli-1.47.0 → utim_cli-1.47.2}/utim_cli/__init__.py +48 -1
- {utim_cli-1.47.0 → utim_cli-1.47.2}/utim_cli/_version.py +1 -1
- {utim_cli-1.47.0 → utim_cli-1.47.2}/utim_cli/config.py +6 -0
- {utim_cli-1.47.0 → utim_cli-1.47.2}/utim_cli/orchestrator.py +2 -0
- {utim_cli-1.47.0 → utim_cli-1.47.2}/utim_cli/reflection.py +13 -7
- {utim_cli-1.47.0 → utim_cli-1.47.2}/utim_cli/server/db.py +80 -18
- {utim_cli-1.47.0 → utim_cli-1.47.2}/utim_cli/server/exchange_rate.py +22 -8
- {utim_cli-1.47.0 → utim_cli-1.47.2}/utim_cli/server/models.py +27 -0
- {utim_cli-1.47.0 → utim_cli-1.47.2}/utim_cli/server/pricing_updater.py +40 -22
- {utim_cli-1.47.0 → utim_cli-1.47.2}/utim_cli/server/router.py +7 -5
- {utim_cli-1.47.0 → utim_cli-1.47.2}/utim_cli/server/routes/__init__.py +2 -1
- {utim_cli-1.47.0 → utim_cli-1.47.2}/utim_cli/server/routes/completion_routes.py +220 -62
- {utim_cli-1.47.0 → utim_cli-1.47.2}/utim_cli/server/routes/credit_routes.py +57 -19
- {utim_cli-1.47.0 → utim_cli-1.47.2}/utim_cli/server/routes/quota_routes.py +315 -124
- utim_cli-1.47.2/utim_cli/server/routes/referral_routes.py +248 -0
- {utim_cli-1.47.0 → utim_cli-1.47.2}/utim_cli/server/routes/share_routes.py +58 -2
- {utim_cli-1.47.0 → utim_cli-1.47.2}/utim_cli/share.py +75 -19
- {utim_cli-1.47.0 → utim_cli-1.47.2}/utim_cli/share_tui.py +135 -13
- {utim_cli-1.47.0 → utim_cli-1.47.2}/utim_cli/tools.py +18 -1
- {utim_cli-1.47.0 → utim_cli-1.47.2}/utim_cli/tui/mcp_dialog.py +10 -8
- {utim_cli-1.47.0 → utim_cli-1.47.2}/utim_cli/tui/model_dialog.py +13 -2
- utim_cli-1.47.2/utim_cli/tui/quota_dialog.py +36 -0
- {utim_cli-1.47.0 → utim_cli-1.47.2}/utim_cli/tui/update_dialog.py +11 -4
- {utim_cli-1.47.0 → utim_cli-1.47.2}/utim_cli/utim.py +99 -15
- {utim_cli-1.47.0 → utim_cli-1.47.2/utim_cli.egg-info}/PKG-INFO +1 -1
- {utim_cli-1.47.0 → utim_cli-1.47.2}/utim_cli.egg-info/SOURCES.txt +2 -0
- {utim_cli-1.47.0 → utim_cli-1.47.2}/LICENSE +0 -0
- {utim_cli-1.47.0 → utim_cli-1.47.2}/MANIFEST.in +0 -0
- {utim_cli-1.47.0 → utim_cli-1.47.2}/README.md +0 -0
- {utim_cli-1.47.0 → utim_cli-1.47.2}/setup.cfg +0 -0
- {utim_cli-1.47.0 → utim_cli-1.47.2}/setup.py +0 -0
- {utim_cli-1.47.0 → utim_cli-1.47.2}/utim_cli/agent.py +0 -0
- {utim_cli-1.47.0 → utim_cli-1.47.2}/utim_cli/auth.py +0 -0
- {utim_cli-1.47.0 → utim_cli-1.47.2}/utim_cli/backup.py +0 -0
- {utim_cli-1.47.0 → utim_cli-1.47.2}/utim_cli/blender_agent.py +0 -0
- {utim_cli-1.47.0 → utim_cli-1.47.2}/utim_cli/bootstrap.py +0 -0
- {utim_cli-1.47.0 → utim_cli-1.47.2}/utim_cli/client_utils.py +0 -0
- {utim_cli-1.47.0 → utim_cli-1.47.2}/utim_cli/constants.py +0 -0
- {utim_cli-1.47.0 → utim_cli-1.47.2}/utim_cli/context_pruner.py +0 -0
- {utim_cli-1.47.0 → utim_cli-1.47.2}/utim_cli/doctor.py +0 -0
- {utim_cli-1.47.0 → utim_cli-1.47.2}/utim_cli/knowledge_graph.py +0 -0
- {utim_cli-1.47.0 → utim_cli-1.47.2}/utim_cli/local_db.py +0 -0
- {utim_cli-1.47.0 → utim_cli-1.47.2}/utim_cli/logger.py +0 -0
- {utim_cli-1.47.0 → utim_cli-1.47.2}/utim_cli/mcp_clean_wrapper.py +0 -0
- {utim_cli-1.47.0 → utim_cli-1.47.2}/utim_cli/mcp_client.py +0 -0
- {utim_cli-1.47.0 → utim_cli-1.47.2}/utim_cli/mcp_registry.json +0 -0
- {utim_cli-1.47.0 → utim_cli-1.47.2}/utim_cli/report.py +0 -0
- {utim_cli-1.47.0 → utim_cli-1.47.2}/utim_cli/scrapy_search.py +0 -0
- {utim_cli-1.47.0 → utim_cli-1.47.2}/utim_cli/server/__init__.py +0 -0
- {utim_cli-1.47.0 → utim_cli-1.47.2}/utim_cli/server/auth.py +0 -0
- {utim_cli-1.47.0 → utim_cli-1.47.2}/utim_cli/server/email_utils.py +0 -0
- {utim_cli-1.47.0 → utim_cli-1.47.2}/utim_cli/server/firebase.py +0 -0
- {utim_cli-1.47.0 → utim_cli-1.47.2}/utim_cli/server/history.py +0 -0
- {utim_cli-1.47.0 → utim_cli-1.47.2}/utim_cli/server/logging_config.py +0 -0
- {utim_cli-1.47.0 → utim_cli-1.47.2}/utim_cli/server/rate_limit.py +0 -0
- {utim_cli-1.47.0 → utim_cli-1.47.2}/utim_cli/server/routes/auth_routes.py +0 -0
- {utim_cli-1.47.0 → utim_cli-1.47.2}/utim_cli/server/routes/feedback_routes.py +0 -0
- {utim_cli-1.47.0 → utim_cli-1.47.2}/utim_cli/server/routes/session_routes.py +0 -0
- {utim_cli-1.47.0 → utim_cli-1.47.2}/utim_cli/server/server.py +0 -0
- {utim_cli-1.47.0 → utim_cli-1.47.2}/utim_cli/situational_scoring.py +0 -0
- {utim_cli-1.47.0 → utim_cli-1.47.2}/utim_cli/state.py +0 -0
- {utim_cli-1.47.0 → utim_cli-1.47.2}/utim_cli/tui/__init__.py +0 -0
- {utim_cli-1.47.0 → utim_cli-1.47.2}/utim_cli/tui/feedback_dialog.py +0 -0
- {utim_cli-1.47.0 → utim_cli-1.47.2}/utim_cli/tui/history_dialog.py +0 -0
- {utim_cli-1.47.0 → utim_cli-1.47.2}/utim_cli/tui/resume_dialog.py +0 -0
- {utim_cli-1.47.0 → utim_cli-1.47.2}/utim_cli/tui/tools_dialog.py +0 -0
- {utim_cli-1.47.0 → utim_cli-1.47.2}/utim_cli/utilities.py +0 -0
- {utim_cli-1.47.0 → utim_cli-1.47.2}/utim_cli/vector_memory.py +0 -0
- {utim_cli-1.47.0 → utim_cli-1.47.2}/utim_cli/workspace.py +0 -0
- {utim_cli-1.47.0 → utim_cli-1.47.2}/utim_cli.egg-info/dependency_links.txt +0 -0
- {utim_cli-1.47.0 → utim_cli-1.47.2}/utim_cli.egg-info/entry_points.txt +0 -0
- {utim_cli-1.47.0 → utim_cli-1.47.2}/utim_cli.egg-info/requires.txt +0 -0
- {utim_cli-1.47.0 → utim_cli-1.47.2}/utim_cli.egg-info/top_level.txt +0 -0
|
@@ -1,5 +1,34 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [1.47.2] - 2026-07-18
|
|
4
|
+
|
|
5
|
+
### Fixed
|
|
6
|
+
- **UnboundLocalError in Command Output Renderer**: Pre-initialized stdout and stderr lines lists in `_render_result` to prevent runtime exception on commands yielding empty output.
|
|
7
|
+
- **Chunk-Boundary Unicode Decode Errors**: Upgraded `read_file` tool to slice trailing incomplete bytes during UTF-8 checks, preventing decoding failures on split multi-byte character boundaries.
|
|
8
|
+
- **Reflection Engine Skill Creation Guardrail**: Restructured reflection engine to only create new skill files when at least 3 substantial guidelines (each >= 40 characters) are suggested, preventing clutter from useless "one-liner" skills.
|
|
9
|
+
- **Console Status Spinner Fallback**: Patched the `console.status` spinner in `utim.py` to check for legacy Windows CMD/conhost and non-UTF-8 environments, dynamically falling back to ASCII `line` spinners instead of unicode dots.
|
|
10
|
+
- **Support Chat Model Casing**: Updated frontend support assistant to target `openrouter/free` exclusively to maintain service reliability.
|
|
11
|
+
|
|
12
|
+
## [1.47.1] - 2026-07-18
|
|
13
|
+
|
|
14
|
+
### Added
|
|
15
|
+
- **Referral Program**: Launched a full referral system. Referrers earn a 2% per-plan discount for each referred user who purchases a subscription. Discounts stack per-plan up to 100% (fully free). Every payment renewal by a referee adds another 2%. Referral dashboard available at `/referral` with live discount breakdown, progress bars, copy-ready referral link/code, and a leaderboard.
|
|
16
|
+
- **Instant Server-Side Deletion**: Connected client share deletion to a new `DELETE /shares/delete/{share_id}` server endpoint to destroy shared zip packages and metadata instantly when removed from the CLI dashboard.
|
|
17
|
+
- **Custom Exclude Checklist Option**: Embedded a dynamic `[ Add custom files/folders... ]` checkbox directly into the share wizard to prompt for custom omits only when selected, rather than asking unconditionally.
|
|
18
|
+
- **Exclusion Path Root Validation**: Added validation to reject and display warnings for typed/pasted custom paths that fall outside the current workspace root directory.
|
|
19
|
+
- **Ctrl+D Dashboard Shortcut**: Resolved key collision where pressing `D` got typed in the default-focused search bar by binding `Ctrl+D` globally to delete the active share.
|
|
20
|
+
|
|
21
|
+
### Fixed
|
|
22
|
+
- **Content Security Policy Alignment**: Added `https://ipapi.co` and `https://analytics.google.com` to Netlify's `connect-src` CSP header rules, allowing the website frontend to complete geolocated IP checking and analytics telemetry during startup without throwing console errors or blocking the AI chat component.
|
|
23
|
+
- **Unicode Markdown Rendering Fallbacks**: Patched Rich's markdown rule, table, and list item components to detect legacy Command Prompt terminals and fallback to safe ASCII representations (`*` for bullets, `-` for rules, and `+--+` for table grids) instead of throwing UnicodeEncodeErrors or printing replacement boxes.
|
|
24
|
+
- **Unicode Fallback Spinners**: Re-engineered all loading/thinking spinners and progress bar characters to detect legacy Windows CMD/conhost terminals and non-UTF-8 console environments, falling back to clean ASCII patterns (`['|', '/', '-', '\\']` and `[#---]`) to prevent rendering boxes/question marks.
|
|
25
|
+
- **Graceful Prompt-Toolkit Dialog Exits**: Introduced a safe-exit mechanism to catch and prevent unhandled `Application.exit()` loop exceptions.
|
|
26
|
+
- **NameError in share_tui.py**: Added typing imports to fix NameError in interactive dialogs.
|
|
27
|
+
- **Server upload limit**: Raised file upload threshold from 25MB to 1GB and enforced plan-specific limits.
|
|
28
|
+
- **Bonus quota tracking**: Fixed regular quota deduction when prioritizing bonus credit quota.
|
|
29
|
+
- **Bonus limit display**: Standardized bonus quota percent relative to maximum allowed plan capacity and added available vs max credits indicator.
|
|
30
|
+
- **Quota preference override**: Fixed schema bug to honor X-Preferred-Quota client header.
|
|
31
|
+
|
|
3
32
|
## [1.47.0] - 2026-07-17
|
|
4
33
|
|
|
5
34
|
### Added
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "utim-cli"
|
|
7
|
-
version = "1.47.
|
|
7
|
+
version = "1.47.2"
|
|
8
8
|
description = "UTIM – Universal Terminal Intelligence Manager. A powerful agentic AI coding assistant for your terminal."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.9"
|
|
@@ -5,7 +5,7 @@ try:
|
|
|
5
5
|
from utim_cli._version import VERSION as __version__
|
|
6
6
|
except ImportError:
|
|
7
7
|
# Fallback during first-install / editable-install edge cases
|
|
8
|
-
__version__ = "1.47.
|
|
8
|
+
__version__ = "1.47.2"
|
|
9
9
|
|
|
10
10
|
import builtins, os, pathlib, urllib.parse, re
|
|
11
11
|
|
|
@@ -65,3 +65,50 @@ def _utim_print(*args, **kwargs):
|
|
|
65
65
|
|
|
66
66
|
# Override the global print for this package
|
|
67
67
|
builtins.print = _utim_print
|
|
68
|
+
|
|
69
|
+
# ── Monkeypatch Rich for Legacy Windows Terminals ──
|
|
70
|
+
try:
|
|
71
|
+
import rich.rule
|
|
72
|
+
import rich.markdown
|
|
73
|
+
import rich.table
|
|
74
|
+
import rich.box
|
|
75
|
+
import rich.segment
|
|
76
|
+
import sys
|
|
77
|
+
|
|
78
|
+
_IS_LEGACY_WIN = (sys.platform == "win32" and "WT_SESSION" not in os.environ)
|
|
79
|
+
is_legacy = _IS_LEGACY_WIN or getattr(sys.stdout, 'encoding', '').lower() not in ('utf-8', 'utf8', 'cp65001')
|
|
80
|
+
|
|
81
|
+
if is_legacy:
|
|
82
|
+
# 1. Patch Rule.__init__ to use '-' instead of '─'
|
|
83
|
+
_original_rule_init = rich.rule.Rule.__init__
|
|
84
|
+
def _patched_rule_init(self, title="", *, characters="─", style="rule.line", end="\n", align="center"):
|
|
85
|
+
if characters == "─":
|
|
86
|
+
characters = "-"
|
|
87
|
+
_original_rule_init(self, title, characters=characters, style=style, end=end, align=align)
|
|
88
|
+
rich.rule.Rule.__init__ = _patched_rule_init
|
|
89
|
+
|
|
90
|
+
# 2. Patch Table.__init__ to force ASCII box borders
|
|
91
|
+
_original_table_init = rich.table.Table.__init__
|
|
92
|
+
def _patched_table_init(self, *args, **kwargs):
|
|
93
|
+
if "box" in kwargs and kwargs["box"] is not None:
|
|
94
|
+
kwargs["box"] = rich.box.ASCII
|
|
95
|
+
elif len(args) > 0:
|
|
96
|
+
args = list(args)
|
|
97
|
+
if args[0] is not None:
|
|
98
|
+
args[0] = rich.box.ASCII
|
|
99
|
+
_original_table_init(self, *args, **kwargs)
|
|
100
|
+
rich.table.Table.__init__ = _patched_table_init
|
|
101
|
+
|
|
102
|
+
# 3. Patch ListItem.render_bullet to use '*' instead of '•'
|
|
103
|
+
_original_render_bullet = rich.markdown.ListItem.render_bullet
|
|
104
|
+
def _patched_render_bullet(self, console, options):
|
|
105
|
+
from rich.segment import Segment
|
|
106
|
+
for item in _original_render_bullet(self, console, options):
|
|
107
|
+
if hasattr(item, "text") and item.text == " \u2022 ":
|
|
108
|
+
yield Segment(" * ", item.style, item.control)
|
|
109
|
+
else:
|
|
110
|
+
yield item
|
|
111
|
+
rich.markdown.ListItem.render_bullet = _patched_render_bullet
|
|
112
|
+
|
|
113
|
+
except Exception:
|
|
114
|
+
pass
|
|
@@ -76,6 +76,7 @@ class Config:
|
|
|
76
76
|
"Chrome/120.0.0.0 Safari/537.36"
|
|
77
77
|
)
|
|
78
78
|
_original_request = _req_mod.Session.request
|
|
79
|
+
config_instance = self
|
|
79
80
|
|
|
80
81
|
def _ua_patched_request(self, method, url, *args, **kwargs):
|
|
81
82
|
# Merge headers so we never overwrite a caller-supplied User-Agent
|
|
@@ -85,6 +86,11 @@ class Config:
|
|
|
85
86
|
else:
|
|
86
87
|
hdrs = dict(hdrs)
|
|
87
88
|
hdrs.setdefault("User-Agent", _BROWSER_UA)
|
|
89
|
+
|
|
90
|
+
# Append user's preferred quota preference to headers
|
|
91
|
+
pref = config_instance.get("preferred_quota", "regular")
|
|
92
|
+
hdrs.setdefault("X-Preferred-Quota", pref)
|
|
93
|
+
|
|
88
94
|
kwargs["headers"] = hdrs
|
|
89
95
|
|
|
90
96
|
primary_host = "api.utim.dev"
|
|
@@ -969,15 +969,24 @@ def apply_skill_modifications(skill_modifications: Dict[str, List[str]]):
|
|
|
969
969
|
|
|
970
970
|
for skill_path in paths_to_write:
|
|
971
971
|
try:
|
|
972
|
-
|
|
973
|
-
|
|
972
|
+
skill_exists = skill_path.exists()
|
|
973
|
+
|
|
974
|
+
# Filter rules by length and substance based on whether the skill already exists
|
|
975
|
+
min_len = 25 if skill_exists else 40
|
|
976
|
+
valid_rules = [r.strip() for r in new_rules if r and len(r.strip()) >= min_len]
|
|
977
|
+
|
|
978
|
+
# Enforce strict guardrail for new skills: must have at least 3 substantial rules
|
|
979
|
+
if not skill_exists and len(valid_rules) < 3:
|
|
980
|
+
continue # Ignore creation of this new skill entirely
|
|
974
981
|
|
|
975
982
|
content = ""
|
|
976
|
-
if
|
|
983
|
+
if skill_exists:
|
|
977
984
|
with open(skill_path, 'r', encoding='utf-8') as f:
|
|
978
985
|
content = f.read()
|
|
979
986
|
|
|
980
987
|
if not content:
|
|
988
|
+
# Ensure parent directory exists before writing
|
|
989
|
+
skill_path.parent.mkdir(parents=True, exist_ok=True)
|
|
981
990
|
# Initialize a new skill md template
|
|
982
991
|
title_name = skill_name.replace('-', ' ').title()
|
|
983
992
|
content = f"""---
|
|
@@ -992,10 +1001,7 @@ description: Automatically learned guidelines for {title_name}.
|
|
|
992
1001
|
|
|
993
1002
|
# Append new rules avoiding duplicates
|
|
994
1003
|
rules_to_add = []
|
|
995
|
-
for
|
|
996
|
-
rule_clean = rule.strip()
|
|
997
|
-
if not rule_clean:
|
|
998
|
-
continue
|
|
1004
|
+
for rule_clean in valid_rules:
|
|
999
1005
|
# Check if already present in content (case-insensitive substring check)
|
|
1000
1006
|
if rule_clean.lower() not in content.lower():
|
|
1001
1007
|
rules_to_add.append(rule_clean)
|
|
@@ -54,7 +54,10 @@ class User(Base):
|
|
|
54
54
|
created_at = Column(DateTime, default=datetime.datetime.utcnow, nullable=False)
|
|
55
55
|
last_project_folder = Column(Text, nullable=True)
|
|
56
56
|
firebase_uid = Column(String(128), nullable=True, index=True)
|
|
57
|
+
referrer_id = Column(String(36), ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
|
|
58
|
+
referral_code = Column(String(50), unique=True, nullable=False, default=lambda: uuid.uuid4().hex[:8])
|
|
57
59
|
|
|
60
|
+
referrer = relationship("User", remote_side=[id], backref="referees")
|
|
58
61
|
credits = relationship("Credit", back_populates="user", uselist=False, cascade="all, delete-orphan")
|
|
59
62
|
conversations = relationship("Conversation", back_populates="user", cascade="all, delete-orphan")
|
|
60
63
|
transactions = relationship("Transaction", back_populates="user", cascade="all, delete-orphan")
|
|
@@ -434,6 +437,20 @@ class EmailTracking(Base):
|
|
|
434
437
|
updated_at = Column(DateTime, default=datetime.datetime.utcnow, onupdate=datetime.datetime.utcnow, nullable=False)
|
|
435
438
|
|
|
436
439
|
|
|
440
|
+
class UsedEmailBonus(Base):
|
|
441
|
+
"""Tracks email addresses that have claimed a first purchase bonus for specific plans.
|
|
442
|
+
|
|
443
|
+
Importantly, this table does NOT have a foreign key to the users table, and is not cascading.
|
|
444
|
+
This prevents users from deleting their accounts and creating new ones to re-claim bonuses.
|
|
445
|
+
"""
|
|
446
|
+
__tablename__ = "used_email_bonuses"
|
|
447
|
+
|
|
448
|
+
id = Column(Integer, primary_key=True, autoincrement=True)
|
|
449
|
+
email = Column(String(255), nullable=False, index=True)
|
|
450
|
+
plan_id = Column(String(50), nullable=False)
|
|
451
|
+
claimed_at = Column(DateTime, default=datetime.datetime.utcnow, nullable=False)
|
|
452
|
+
|
|
453
|
+
|
|
437
454
|
# ── Bootstrap ─────────────────────────────────────────────────────────────────
|
|
438
455
|
|
|
439
456
|
def init_db(silent: bool = True) -> None:
|
|
@@ -458,6 +475,14 @@ def init_db(silent: bool = True) -> None:
|
|
|
458
475
|
# Run manual migrations for missing columns
|
|
459
476
|
# Each ALTER runs in its OWN transaction so one failure doesn't block the rest.
|
|
460
477
|
from sqlalchemy import text, inspect as sa_inspect
|
|
478
|
+
|
|
479
|
+
# Ensure referral_discounts table exists (defined in referral_routes, not imported into Base yet)
|
|
480
|
+
try:
|
|
481
|
+
from .routes.referral_routes import ReferralDiscount
|
|
482
|
+
Base.metadata.create_all(bind=engine, tables=[ReferralDiscount.__table__])
|
|
483
|
+
except Exception as _ref_tbl_exc:
|
|
484
|
+
_print(f"[DB INIT] referral_discounts table init note: {_ref_tbl_exc}")
|
|
485
|
+
|
|
461
486
|
is_postgres = DATABASE_URL.startswith("postgresql")
|
|
462
487
|
|
|
463
488
|
try:
|
|
@@ -498,6 +523,8 @@ def init_db(silent: bool = True) -> None:
|
|
|
498
523
|
_print("[DB INIT] Postgres detected. Running ALTER TABLE migrations if needed.")
|
|
499
524
|
_run_migration("credits", "free_monthly_used", "ALTER TABLE credits ADD COLUMN IF NOT EXISTS free_monthly_used FLOAT DEFAULT 0.0 NOT NULL", "postgres add free_monthly_used")
|
|
500
525
|
_run_migration("users", "firebase_uid", "ALTER TABLE users ADD COLUMN IF NOT EXISTS firebase_uid VARCHAR(128)", "postgres add firebase_uid")
|
|
526
|
+
_run_migration("users", "referrer_id", "ALTER TABLE users ADD COLUMN IF NOT EXISTS referrer_id VARCHAR(36)", "postgres add referrer_id")
|
|
527
|
+
_run_migration("users", "referral_code", "ALTER TABLE users ADD COLUMN IF NOT EXISTS referral_code VARCHAR(50)", "postgres add referral_code")
|
|
501
528
|
_run_migration("feedbacks", "chat_history", "ALTER TABLE feedbacks ADD COLUMN IF NOT EXISTS chat_history TEXT", "postgres add chat_history")
|
|
502
529
|
else:
|
|
503
530
|
# SQLite: no IF NOT EXISTS on ADD COLUMN, swallow duplicate errors
|
|
@@ -512,6 +539,8 @@ def init_db(silent: bool = True) -> None:
|
|
|
512
539
|
_run_migration("email_tracking", "bonus_email_sent_at", "ALTER TABLE email_tracking ADD COLUMN bonus_email_sent_at TIMESTAMP", "sqlite add bonus_email_sent_at")
|
|
513
540
|
_run_migration("users", "last_project_folder", "ALTER TABLE users ADD COLUMN last_project_folder TEXT", "sqlite add last_project_folder")
|
|
514
541
|
_run_migration("users", "firebase_uid", "ALTER TABLE users ADD COLUMN firebase_uid VARCHAR(128)", "sqlite add firebase_uid")
|
|
542
|
+
_run_migration("users", "referrer_id", "ALTER TABLE users ADD COLUMN referrer_id VARCHAR(36)", "sqlite add referrer_id")
|
|
543
|
+
_run_migration("users", "referral_code", "ALTER TABLE users ADD COLUMN referral_code VARCHAR(50)", "sqlite add referral_code")
|
|
515
544
|
_run_migration("feedbacks", "chat_history", "ALTER TABLE feedbacks ADD COLUMN chat_history TEXT", "sqlite add feedbacks chat_history")
|
|
516
545
|
|
|
517
546
|
# Seed the plans if they don't exist
|
|
@@ -536,8 +565,8 @@ def init_db(silent: bool = True) -> None:
|
|
|
536
565
|
id="hobby",
|
|
537
566
|
name="hobby",
|
|
538
567
|
display_name="Hobby",
|
|
539
|
-
price_inr=
|
|
540
|
-
credits_per_month=
|
|
568
|
+
price_inr=700,
|
|
569
|
+
credits_per_month=4000,
|
|
541
570
|
allowed_models="all",
|
|
542
571
|
max_context_k=256
|
|
543
572
|
),
|
|
@@ -545,8 +574,8 @@ def init_db(silent: bool = True) -> None:
|
|
|
545
574
|
id="pro",
|
|
546
575
|
name="pro",
|
|
547
576
|
display_name="Pro",
|
|
548
|
-
price_inr=
|
|
549
|
-
credits_per_month=
|
|
577
|
+
price_inr=2500,
|
|
578
|
+
credits_per_month=18000,
|
|
550
579
|
allowed_models="all",
|
|
551
580
|
max_context_k=1024
|
|
552
581
|
),
|
|
@@ -554,7 +583,7 @@ def init_db(silent: bool = True) -> None:
|
|
|
554
583
|
id="max",
|
|
555
584
|
name="max",
|
|
556
585
|
display_name="Max",
|
|
557
|
-
price_inr=
|
|
586
|
+
price_inr=5500,
|
|
558
587
|
credits_per_month=45000,
|
|
559
588
|
allowed_models="all",
|
|
560
589
|
max_context_k=1024
|
|
@@ -581,7 +610,7 @@ def init_db(silent: bool = True) -> None:
|
|
|
581
610
|
id="ultimate",
|
|
582
611
|
name="ultimate",
|
|
583
612
|
display_name="Ultimate Core",
|
|
584
|
-
price_inr=
|
|
613
|
+
price_inr=11000,
|
|
585
614
|
credits_per_month=90000,
|
|
586
615
|
allowed_models="all",
|
|
587
616
|
max_context_k=1024
|
|
@@ -590,15 +619,22 @@ def init_db(silent: bool = True) -> None:
|
|
|
590
619
|
db.add_all(plans)
|
|
591
620
|
db.commit()
|
|
592
621
|
else:
|
|
593
|
-
# Update existing plan
|
|
622
|
+
# Update existing plan details
|
|
594
623
|
pro_plan = db.query(Plan).filter(Plan.id == "pro").first()
|
|
595
|
-
if pro_plan
|
|
596
|
-
pro_plan.
|
|
624
|
+
if pro_plan:
|
|
625
|
+
if pro_plan.credits_per_month != 18000:
|
|
626
|
+
pro_plan.credits_per_month = 18000
|
|
627
|
+
if pro_plan.price_inr != 2500:
|
|
628
|
+
pro_plan.price_inr = 2500
|
|
597
629
|
|
|
598
|
-
# ── Live migration: fix Hobby plan's stale allowed_models stub ──
|
|
599
630
|
hobby_plan = db.query(Plan).filter(Plan.id == "hobby").first()
|
|
600
|
-
if hobby_plan
|
|
601
|
-
hobby_plan.
|
|
631
|
+
if hobby_plan:
|
|
632
|
+
if hobby_plan.credits_per_month != 4000:
|
|
633
|
+
hobby_plan.credits_per_month = 4000
|
|
634
|
+
if hobby_plan.allowed_models != "all":
|
|
635
|
+
hobby_plan.allowed_models = "all"
|
|
636
|
+
if hobby_plan.price_inr != 700:
|
|
637
|
+
hobby_plan.price_inr = 700
|
|
602
638
|
|
|
603
639
|
max_plan = db.query(Plan).filter(Plan.id == "max").first()
|
|
604
640
|
if not max_plan:
|
|
@@ -606,7 +642,7 @@ def init_db(silent: bool = True) -> None:
|
|
|
606
642
|
id="max",
|
|
607
643
|
name="max",
|
|
608
644
|
display_name="Max",
|
|
609
|
-
price_inr=
|
|
645
|
+
price_inr=5500,
|
|
610
646
|
credits_per_month=45000,
|
|
611
647
|
allowed_models="all",
|
|
612
648
|
max_context_k=1024
|
|
@@ -614,8 +650,8 @@ def init_db(silent: bool = True) -> None:
|
|
|
614
650
|
else:
|
|
615
651
|
if max_plan.credits_per_month != 45000:
|
|
616
652
|
max_plan.credits_per_month = 45000
|
|
617
|
-
if max_plan.price_inr !=
|
|
618
|
-
max_plan.price_inr =
|
|
653
|
+
if max_plan.price_inr != 5500:
|
|
654
|
+
max_plan.price_inr = 5500
|
|
619
655
|
|
|
620
656
|
ultimate_plan = db.query(Plan).filter(Plan.id == "ultimate").first()
|
|
621
657
|
if not ultimate_plan:
|
|
@@ -623,7 +659,7 @@ def init_db(silent: bool = True) -> None:
|
|
|
623
659
|
id="ultimate",
|
|
624
660
|
name="ultimate",
|
|
625
661
|
display_name="Ultimate Core",
|
|
626
|
-
price_inr=
|
|
662
|
+
price_inr=11000,
|
|
627
663
|
credits_per_month=90000,
|
|
628
664
|
allowed_models="all",
|
|
629
665
|
max_context_k=1024
|
|
@@ -631,8 +667,19 @@ def init_db(silent: bool = True) -> None:
|
|
|
631
667
|
else:
|
|
632
668
|
if ultimate_plan.credits_per_month != 90000:
|
|
633
669
|
ultimate_plan.credits_per_month = 90000
|
|
634
|
-
if ultimate_plan.price_inr !=
|
|
635
|
-
ultimate_plan.price_inr =
|
|
670
|
+
if ultimate_plan.price_inr != 11000:
|
|
671
|
+
ultimate_plan.price_inr = 11000
|
|
672
|
+
|
|
673
|
+
# Generate referral codes for existing users that don't have one
|
|
674
|
+
try:
|
|
675
|
+
users_without_code = db.query(User).filter(User.referral_code == None).all()
|
|
676
|
+
if users_without_code:
|
|
677
|
+
_print(f"[DB INIT] Generating referral codes for {len(users_without_code)} users...")
|
|
678
|
+
for u in users_without_code:
|
|
679
|
+
u.referral_code = uuid.uuid4().hex[:8]
|
|
680
|
+
except Exception as ref_exc:
|
|
681
|
+
_print(f"[DB INIT] Referral code migration check failed: {ref_exc}")
|
|
682
|
+
|
|
636
683
|
db.commit()
|
|
637
684
|
except Exception as exc:
|
|
638
685
|
db.rollback()
|
|
@@ -650,3 +697,18 @@ def get_db():
|
|
|
650
697
|
yield db
|
|
651
698
|
finally:
|
|
652
699
|
db.close()
|
|
700
|
+
|
|
701
|
+
|
|
702
|
+
def get_max_bonus_limit(plan_id: str) -> float:
|
|
703
|
+
pid = (plan_id or "free").lower().strip()
|
|
704
|
+
if pid == "free":
|
|
705
|
+
return 20000.0
|
|
706
|
+
elif pid == "hobby":
|
|
707
|
+
return 50000.0
|
|
708
|
+
elif pid == "pro":
|
|
709
|
+
return 1500000.0
|
|
710
|
+
elif pid in ("max", "team"):
|
|
711
|
+
return 3000000.0
|
|
712
|
+
elif pid in ("ultimate", "enterprise"):
|
|
713
|
+
return 4500000.0
|
|
714
|
+
return 20000.0
|
|
@@ -13,32 +13,46 @@ class ExchangeRateStore:
|
|
|
13
13
|
|
|
14
14
|
@classmethod
|
|
15
15
|
def fetch_live_rate(cls) -> float:
|
|
16
|
+
# 1. Try ER-API (reliable, free, updated daily)
|
|
16
17
|
try:
|
|
17
|
-
|
|
18
|
-
response = requests.get("https://api.frankfurter.app/latest?from=USD&to=INR", timeout=10)
|
|
18
|
+
response = requests.get("https://open.er-api.com/v6/latest/USD", timeout=5)
|
|
19
19
|
if response.status_code == 200:
|
|
20
20
|
data = response.json()
|
|
21
21
|
rate = data.get("rates", {}).get("INR")
|
|
22
22
|
if rate:
|
|
23
23
|
cls.USD_TO_INR = float(rate)
|
|
24
|
-
logger.info(f"Successfully fetched live exchange rate: {cls.USD_TO_INR}")
|
|
24
|
+
logger.info(f"Successfully fetched live exchange rate (er-api): {cls.USD_TO_INR}")
|
|
25
25
|
return cls.USD_TO_INR
|
|
26
26
|
except Exception as e:
|
|
27
|
-
logger.
|
|
27
|
+
logger.warning(f"Failed to fetch live exchange rate from ER-API: {e}")
|
|
28
28
|
|
|
29
|
+
# 2. Try ExchangeRate-API V4 (reliable, free fallback)
|
|
29
30
|
try:
|
|
30
|
-
|
|
31
|
-
response = requests.get("https://api.exchangerate.fun/latest", timeout=10)
|
|
31
|
+
response = requests.get("https://api.exchangerate-api.com/v4/latest/USD", timeout=5)
|
|
32
32
|
if response.status_code == 200:
|
|
33
33
|
data = response.json()
|
|
34
34
|
rate = data.get("rates", {}).get("INR")
|
|
35
35
|
if rate:
|
|
36
36
|
cls.USD_TO_INR = float(rate)
|
|
37
|
-
logger.info(f"Successfully fetched live exchange rate (
|
|
37
|
+
logger.info(f"Successfully fetched live exchange rate (exchangerate-api): {cls.USD_TO_INR}")
|
|
38
38
|
return cls.USD_TO_INR
|
|
39
39
|
except Exception as e:
|
|
40
|
-
logger.
|
|
40
|
+
logger.warning(f"Failed to fetch live exchange rate from ExchangeRate-API: {e}")
|
|
41
41
|
|
|
42
|
+
# 3. Try Frankfurter API
|
|
43
|
+
try:
|
|
44
|
+
response = requests.get("https://api.frankfurter.app/latest?from=USD&to=INR", timeout=5)
|
|
45
|
+
if response.status_code == 200:
|
|
46
|
+
data = response.json()
|
|
47
|
+
rate = data.get("rates", {}).get("INR")
|
|
48
|
+
if rate:
|
|
49
|
+
cls.USD_TO_INR = float(rate)
|
|
50
|
+
logger.info(f"Successfully fetched live exchange rate (frankfurter): {cls.USD_TO_INR}")
|
|
51
|
+
return cls.USD_TO_INR
|
|
52
|
+
except Exception as e:
|
|
53
|
+
logger.warning(f"Failed to fetch live exchange rate from Frankfurter: {e}")
|
|
54
|
+
|
|
55
|
+
logger.error(f"All live exchange rate APIs failed. Using fallback rate: {cls.USD_TO_INR}")
|
|
42
56
|
return cls.USD_TO_INR
|
|
43
57
|
|
|
44
58
|
@classmethod
|
|
@@ -1116,6 +1116,33 @@ MODEL_REGISTRY: dict[MODEL_ID, ModelEntry] = {
|
|
|
1116
1116
|
capabilities=["chat", "code"],
|
|
1117
1117
|
tags=["premium"],
|
|
1118
1118
|
),
|
|
1119
|
+
"thinkingmachines/inkling": ModelEntry(
|
|
1120
|
+
model_id="thinkingmachines/inkling",
|
|
1121
|
+
provider="openrouter",
|
|
1122
|
+
cost_input_per_1k=0.00105,
|
|
1123
|
+
cost_output_per_1k=0.0042525,
|
|
1124
|
+
context_window=1_048_576,
|
|
1125
|
+
capabilities=["chat", "code"],
|
|
1126
|
+
tags=["premium"],
|
|
1127
|
+
),
|
|
1128
|
+
"moonshotai/kimi-k3": ModelEntry(
|
|
1129
|
+
model_id="moonshotai/kimi-k3",
|
|
1130
|
+
provider="openrouter",
|
|
1131
|
+
cost_input_per_1k=0.00315,
|
|
1132
|
+
cost_output_per_1k=0.01575,
|
|
1133
|
+
context_window=1_000_000,
|
|
1134
|
+
capabilities=["chat", "reasoning"],
|
|
1135
|
+
tags=["premium", "reasoning"],
|
|
1136
|
+
),
|
|
1137
|
+
"meta/muse-spark-1.1": ModelEntry(
|
|
1138
|
+
model_id="meta/muse-spark-1.1",
|
|
1139
|
+
provider="openrouter",
|
|
1140
|
+
cost_input_per_1k=0.0013125,
|
|
1141
|
+
cost_output_per_1k=0.0044625,
|
|
1142
|
+
context_window=1_048_576,
|
|
1143
|
+
capabilities=["chat", "code", "multimodal"],
|
|
1144
|
+
tags=["premium", "multimodal"],
|
|
1145
|
+
),
|
|
1119
1146
|
}
|
|
1120
1147
|
|
|
1121
1148
|
# Default model used when the user does not specify one
|
|
@@ -22,37 +22,55 @@ def fetch_and_update_pricing() -> bool:
|
|
|
22
22
|
logger.error("No model data returned from OpenRouter")
|
|
23
23
|
return False
|
|
24
24
|
|
|
25
|
-
# Create a map of model_id ->
|
|
25
|
+
# Create a map of model_id -> data
|
|
26
26
|
openrouter_models = {}
|
|
27
27
|
for m in models_data:
|
|
28
28
|
m_id = m.get("id")
|
|
29
29
|
pricing = m.get("pricing")
|
|
30
|
-
|
|
31
|
-
|
|
30
|
+
context_length = m.get("context_length")
|
|
31
|
+
if m_id:
|
|
32
|
+
openrouter_models[m_id] = {
|
|
33
|
+
"pricing": pricing,
|
|
34
|
+
"context_length": context_length
|
|
35
|
+
}
|
|
32
36
|
|
|
33
37
|
updated_count = 0
|
|
34
38
|
for model_id, entry in MODEL_REGISTRY.items():
|
|
39
|
+
if "free" in entry.tags:
|
|
40
|
+
entry.cost_input_per_1k = 0.0
|
|
41
|
+
entry.cost_output_per_1k = 0.0
|
|
42
|
+
continue
|
|
35
43
|
if model_id in openrouter_models:
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
44
|
+
m_data = openrouter_models[model_id]
|
|
45
|
+
pricing = m_data.get("pricing")
|
|
46
|
+
context_length = m_data.get("context_length")
|
|
47
|
+
|
|
48
|
+
if context_length is not None:
|
|
49
|
+
try:
|
|
50
|
+
entry.context_window = int(context_length)
|
|
51
|
+
except Exception as ce:
|
|
52
|
+
logger.error(f"Error parsing context_length for {model_id}: {ce}")
|
|
53
|
+
|
|
54
|
+
if pricing:
|
|
55
|
+
try:
|
|
56
|
+
# prompt and completion are prices in USD per token
|
|
57
|
+
prompt_str = pricing.get("prompt", "0.0")
|
|
58
|
+
completion_str = pricing.get("completion", "0.0")
|
|
59
|
+
|
|
60
|
+
prompt_usd = float(prompt_str)
|
|
61
|
+
completion_usd = float(completion_str)
|
|
62
|
+
|
|
63
|
+
# Convert to USD per 1M tokens (credits per 1K tokens) and add 5% markup fee
|
|
64
|
+
cost_input = prompt_usd * 1_000_000 * 1.05
|
|
65
|
+
cost_output = completion_usd * 1_000_000 * 1.05
|
|
66
|
+
|
|
67
|
+
entry.cost_input_per_1k = cost_input
|
|
68
|
+
entry.cost_output_per_1k = cost_output
|
|
69
|
+
updated_count += 1
|
|
70
|
+
except Exception as e:
|
|
71
|
+
logger.error(f"Error parsing pricing for model {model_id}: {e}")
|
|
54
72
|
|
|
55
|
-
logger.info(f"Successfully updated pricing for {updated_count} models (with
|
|
73
|
+
logger.info(f"Successfully updated pricing for {updated_count} models (with 5% markup).")
|
|
56
74
|
return True
|
|
57
75
|
except Exception as e:
|
|
58
76
|
logger.error(f"Error updating pricing from OpenRouter: {e}")
|
|
@@ -26,7 +26,7 @@ from .logging_config import configure_logging, RequestLoggingMiddleware
|
|
|
26
26
|
from .rate_limit import limiter
|
|
27
27
|
from slowapi import _rate_limit_exceeded_handler
|
|
28
28
|
from slowapi.errors import RateLimitExceeded
|
|
29
|
-
from .routes import auth_router, credit_router, session_router, completion_router, quota_router, share_router, feedback_router
|
|
29
|
+
from .routes import auth_router, credit_router, session_router, completion_router, quota_router, share_router, feedback_router, referral_router
|
|
30
30
|
from .models import list_models
|
|
31
31
|
from .auth import get_admin_user
|
|
32
32
|
|
|
@@ -89,14 +89,14 @@ from fastapi.responses import JSONResponse
|
|
|
89
89
|
class LimitUploadSize(BaseHTTPMiddleware):
|
|
90
90
|
async def dispatch(self, request, call_next):
|
|
91
91
|
if request.headers.get("content-length"):
|
|
92
|
-
if int(request.headers["content-length"]) >
|
|
92
|
+
if int(request.headers["content-length"]) > 1_073_741_824: # 1GB limit
|
|
93
93
|
# Consume stream to prevent abrupt connection closure / SSLEOFError on client
|
|
94
94
|
try:
|
|
95
95
|
async for _ in request.stream():
|
|
96
96
|
pass
|
|
97
97
|
except Exception:
|
|
98
98
|
pass
|
|
99
|
-
return JSONResponse({"detail": "Request too large (max
|
|
99
|
+
return JSONResponse({"detail": "Request too large (max 1GB)"}, status_code=413)
|
|
100
100
|
return await call_next(request)
|
|
101
101
|
|
|
102
102
|
app.add_middleware(LimitUploadSize)
|
|
@@ -112,6 +112,7 @@ app.include_router(completion_router)
|
|
|
112
112
|
app.include_router(quota_router)
|
|
113
113
|
app.include_router(share_router)
|
|
114
114
|
app.include_router(feedback_router)
|
|
115
|
+
app.include_router(referral_router)
|
|
115
116
|
|
|
116
117
|
# ── Support Chatbot Endpoint ──────────────────────────────────────────────────
|
|
117
118
|
|
|
@@ -189,8 +190,9 @@ async def support_chat(request: Request, req: SupportChatRequest):
|
|
|
189
190
|
import json
|
|
190
191
|
ALLOWED_FREE_MODELS = {
|
|
191
192
|
"poolside/laguna-xs.2:free",
|
|
192
|
-
"google/gemma-2-9b-it:free",
|
|
193
193
|
"cohere/north-mini-code:free",
|
|
194
|
+
"google/gemma-4-31b-it:free",
|
|
195
|
+
"google/gemma-4-26b-a4b-it:free",
|
|
194
196
|
"openrouter/free"
|
|
195
197
|
}
|
|
196
198
|
|
|
@@ -210,7 +212,7 @@ async def support_chat(request: Request, req: SupportChatRequest):
|
|
|
210
212
|
|
|
211
213
|
# Generate candidate models starting with the requested one
|
|
212
214
|
candidate_models = [model_name]
|
|
213
|
-
for fallback in ["openrouter/free", "
|
|
215
|
+
for fallback in ["openrouter/free", "cohere/north-mini-code:free", "google/gemma-4-31b-it:free", "poolside/laguna-xs.2:free"]:
|
|
214
216
|
if fallback not in candidate_models:
|
|
215
217
|
candidate_models.append(fallback)
|
|
216
218
|
|
|
@@ -5,6 +5,7 @@ from .completion_routes import router as completion_router
|
|
|
5
5
|
from .quota_routes import router as quota_router
|
|
6
6
|
from .share_routes import router as share_router
|
|
7
7
|
from .feedback_routes import router as feedback_router
|
|
8
|
+
from .referral_routes import router as referral_router
|
|
8
9
|
|
|
9
|
-
__all__ = ["auth_router", "credit_router", "session_router", "completion_router", "quota_router", "share_router", "feedback_router"]
|
|
10
|
+
__all__ = ["auth_router", "credit_router", "session_router", "completion_router", "quota_router", "share_router", "feedback_router", "referral_router"]
|
|
10
11
|
|