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.
Files changed (76) hide show
  1. {utim_cli-1.47.0 → utim_cli-1.47.2}/CHANGELOG.md +29 -0
  2. {utim_cli-1.47.0/utim_cli.egg-info → utim_cli-1.47.2}/PKG-INFO +1 -1
  3. {utim_cli-1.47.0 → utim_cli-1.47.2}/pyproject.toml +1 -1
  4. {utim_cli-1.47.0 → utim_cli-1.47.2}/utim_cli/__init__.py +48 -1
  5. {utim_cli-1.47.0 → utim_cli-1.47.2}/utim_cli/_version.py +1 -1
  6. {utim_cli-1.47.0 → utim_cli-1.47.2}/utim_cli/config.py +6 -0
  7. {utim_cli-1.47.0 → utim_cli-1.47.2}/utim_cli/orchestrator.py +2 -0
  8. {utim_cli-1.47.0 → utim_cli-1.47.2}/utim_cli/reflection.py +13 -7
  9. {utim_cli-1.47.0 → utim_cli-1.47.2}/utim_cli/server/db.py +80 -18
  10. {utim_cli-1.47.0 → utim_cli-1.47.2}/utim_cli/server/exchange_rate.py +22 -8
  11. {utim_cli-1.47.0 → utim_cli-1.47.2}/utim_cli/server/models.py +27 -0
  12. {utim_cli-1.47.0 → utim_cli-1.47.2}/utim_cli/server/pricing_updater.py +40 -22
  13. {utim_cli-1.47.0 → utim_cli-1.47.2}/utim_cli/server/router.py +7 -5
  14. {utim_cli-1.47.0 → utim_cli-1.47.2}/utim_cli/server/routes/__init__.py +2 -1
  15. {utim_cli-1.47.0 → utim_cli-1.47.2}/utim_cli/server/routes/completion_routes.py +220 -62
  16. {utim_cli-1.47.0 → utim_cli-1.47.2}/utim_cli/server/routes/credit_routes.py +57 -19
  17. {utim_cli-1.47.0 → utim_cli-1.47.2}/utim_cli/server/routes/quota_routes.py +315 -124
  18. utim_cli-1.47.2/utim_cli/server/routes/referral_routes.py +248 -0
  19. {utim_cli-1.47.0 → utim_cli-1.47.2}/utim_cli/server/routes/share_routes.py +58 -2
  20. {utim_cli-1.47.0 → utim_cli-1.47.2}/utim_cli/share.py +75 -19
  21. {utim_cli-1.47.0 → utim_cli-1.47.2}/utim_cli/share_tui.py +135 -13
  22. {utim_cli-1.47.0 → utim_cli-1.47.2}/utim_cli/tools.py +18 -1
  23. {utim_cli-1.47.0 → utim_cli-1.47.2}/utim_cli/tui/mcp_dialog.py +10 -8
  24. {utim_cli-1.47.0 → utim_cli-1.47.2}/utim_cli/tui/model_dialog.py +13 -2
  25. utim_cli-1.47.2/utim_cli/tui/quota_dialog.py +36 -0
  26. {utim_cli-1.47.0 → utim_cli-1.47.2}/utim_cli/tui/update_dialog.py +11 -4
  27. {utim_cli-1.47.0 → utim_cli-1.47.2}/utim_cli/utim.py +99 -15
  28. {utim_cli-1.47.0 → utim_cli-1.47.2/utim_cli.egg-info}/PKG-INFO +1 -1
  29. {utim_cli-1.47.0 → utim_cli-1.47.2}/utim_cli.egg-info/SOURCES.txt +2 -0
  30. {utim_cli-1.47.0 → utim_cli-1.47.2}/LICENSE +0 -0
  31. {utim_cli-1.47.0 → utim_cli-1.47.2}/MANIFEST.in +0 -0
  32. {utim_cli-1.47.0 → utim_cli-1.47.2}/README.md +0 -0
  33. {utim_cli-1.47.0 → utim_cli-1.47.2}/setup.cfg +0 -0
  34. {utim_cli-1.47.0 → utim_cli-1.47.2}/setup.py +0 -0
  35. {utim_cli-1.47.0 → utim_cli-1.47.2}/utim_cli/agent.py +0 -0
  36. {utim_cli-1.47.0 → utim_cli-1.47.2}/utim_cli/auth.py +0 -0
  37. {utim_cli-1.47.0 → utim_cli-1.47.2}/utim_cli/backup.py +0 -0
  38. {utim_cli-1.47.0 → utim_cli-1.47.2}/utim_cli/blender_agent.py +0 -0
  39. {utim_cli-1.47.0 → utim_cli-1.47.2}/utim_cli/bootstrap.py +0 -0
  40. {utim_cli-1.47.0 → utim_cli-1.47.2}/utim_cli/client_utils.py +0 -0
  41. {utim_cli-1.47.0 → utim_cli-1.47.2}/utim_cli/constants.py +0 -0
  42. {utim_cli-1.47.0 → utim_cli-1.47.2}/utim_cli/context_pruner.py +0 -0
  43. {utim_cli-1.47.0 → utim_cli-1.47.2}/utim_cli/doctor.py +0 -0
  44. {utim_cli-1.47.0 → utim_cli-1.47.2}/utim_cli/knowledge_graph.py +0 -0
  45. {utim_cli-1.47.0 → utim_cli-1.47.2}/utim_cli/local_db.py +0 -0
  46. {utim_cli-1.47.0 → utim_cli-1.47.2}/utim_cli/logger.py +0 -0
  47. {utim_cli-1.47.0 → utim_cli-1.47.2}/utim_cli/mcp_clean_wrapper.py +0 -0
  48. {utim_cli-1.47.0 → utim_cli-1.47.2}/utim_cli/mcp_client.py +0 -0
  49. {utim_cli-1.47.0 → utim_cli-1.47.2}/utim_cli/mcp_registry.json +0 -0
  50. {utim_cli-1.47.0 → utim_cli-1.47.2}/utim_cli/report.py +0 -0
  51. {utim_cli-1.47.0 → utim_cli-1.47.2}/utim_cli/scrapy_search.py +0 -0
  52. {utim_cli-1.47.0 → utim_cli-1.47.2}/utim_cli/server/__init__.py +0 -0
  53. {utim_cli-1.47.0 → utim_cli-1.47.2}/utim_cli/server/auth.py +0 -0
  54. {utim_cli-1.47.0 → utim_cli-1.47.2}/utim_cli/server/email_utils.py +0 -0
  55. {utim_cli-1.47.0 → utim_cli-1.47.2}/utim_cli/server/firebase.py +0 -0
  56. {utim_cli-1.47.0 → utim_cli-1.47.2}/utim_cli/server/history.py +0 -0
  57. {utim_cli-1.47.0 → utim_cli-1.47.2}/utim_cli/server/logging_config.py +0 -0
  58. {utim_cli-1.47.0 → utim_cli-1.47.2}/utim_cli/server/rate_limit.py +0 -0
  59. {utim_cli-1.47.0 → utim_cli-1.47.2}/utim_cli/server/routes/auth_routes.py +0 -0
  60. {utim_cli-1.47.0 → utim_cli-1.47.2}/utim_cli/server/routes/feedback_routes.py +0 -0
  61. {utim_cli-1.47.0 → utim_cli-1.47.2}/utim_cli/server/routes/session_routes.py +0 -0
  62. {utim_cli-1.47.0 → utim_cli-1.47.2}/utim_cli/server/server.py +0 -0
  63. {utim_cli-1.47.0 → utim_cli-1.47.2}/utim_cli/situational_scoring.py +0 -0
  64. {utim_cli-1.47.0 → utim_cli-1.47.2}/utim_cli/state.py +0 -0
  65. {utim_cli-1.47.0 → utim_cli-1.47.2}/utim_cli/tui/__init__.py +0 -0
  66. {utim_cli-1.47.0 → utim_cli-1.47.2}/utim_cli/tui/feedback_dialog.py +0 -0
  67. {utim_cli-1.47.0 → utim_cli-1.47.2}/utim_cli/tui/history_dialog.py +0 -0
  68. {utim_cli-1.47.0 → utim_cli-1.47.2}/utim_cli/tui/resume_dialog.py +0 -0
  69. {utim_cli-1.47.0 → utim_cli-1.47.2}/utim_cli/tui/tools_dialog.py +0 -0
  70. {utim_cli-1.47.0 → utim_cli-1.47.2}/utim_cli/utilities.py +0 -0
  71. {utim_cli-1.47.0 → utim_cli-1.47.2}/utim_cli/vector_memory.py +0 -0
  72. {utim_cli-1.47.0 → utim_cli-1.47.2}/utim_cli/workspace.py +0 -0
  73. {utim_cli-1.47.0 → utim_cli-1.47.2}/utim_cli.egg-info/dependency_links.txt +0 -0
  74. {utim_cli-1.47.0 → utim_cli-1.47.2}/utim_cli.egg-info/entry_points.txt +0 -0
  75. {utim_cli-1.47.0 → utim_cli-1.47.2}/utim_cli.egg-info/requires.txt +0 -0
  76. {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
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: utim-cli
3
- Version: 1.47.0
3
+ Version: 1.47.2
4
4
  Summary: UTIM – Universal Terminal Intelligence Manager. A powerful agentic AI coding assistant for your terminal.
5
5
  License: MIT
6
6
  Project-URL: Homepage, https://utim.dev
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "utim-cli"
7
- version = "1.47.0"
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.0"
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
@@ -11,4 +11,4 @@
11
11
  # from here so every surface stays in sync automatically.
12
12
  # ─────────────────────────────────────────────────────────────────────────────
13
13
 
14
- VERSION = "1.47.0"
14
+ VERSION = "1.47.2"
@@ -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"
@@ -2330,6 +2330,8 @@ class Orchestrator:
2330
2330
  stdout_section = ""
2331
2331
  stderr_section = ""
2332
2332
  current_section = None
2333
+ stdout_lines = []
2334
+ stderr_lines = []
2333
2335
 
2334
2336
  for line in raw_output.splitlines():
2335
2337
  if line.startswith("[exit_code:"):
@@ -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
- # Ensure parent directory exists
973
- skill_path.parent.mkdir(parents=True, exist_ok=True)
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 skill_path.exists():
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 rule in new_rules:
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=499,
540
- credits_per_month=3500,
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=1999,
549
- credits_per_month=15000,
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=4999,
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=9999,
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 prices if needed
622
+ # Update existing plan details
594
623
  pro_plan = db.query(Plan).filter(Plan.id == "pro").first()
595
- if pro_plan and pro_plan.price_inr != 1999:
596
- pro_plan.price_inr = 1999
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 and hobby_plan.allowed_models != "all":
601
- hobby_plan.allowed_models = "all"
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=4999,
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 != 4999:
618
- max_plan.price_inr = 4999
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=9999,
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 != 9999:
635
- ultimate_plan.price_inr = 9999
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
- # Try Frankfurter API (reliable, free, no key)
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.error(f"Failed to fetch live exchange rate from Frankfurter: {e}")
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
- # Fallback: haxqer free api
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 (fallback haxqer): {cls.USD_TO_INR}")
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.error(f"Failed to fetch live exchange rate from haxqer: {e}")
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 -> pricing
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
- if m_id and pricing:
31
- openrouter_models[m_id] = pricing
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
- pricing = openrouter_models[model_id]
37
- try:
38
- # prompt and completion are prices in USD per token
39
- prompt_str = pricing.get("prompt", "0.0")
40
- completion_str = pricing.get("completion", "0.0")
41
-
42
- prompt_usd = float(prompt_str)
43
- completion_usd = float(completion_str)
44
-
45
- # Convert to USD per 1M tokens (credits per 1K tokens) and add 2% markup fee
46
- cost_input = prompt_usd * 1_000_000 * 1.02
47
- cost_output = completion_usd * 1_000_000 * 1.02
48
-
49
- entry.cost_input_per_1k = cost_input
50
- entry.cost_output_per_1k = cost_output
51
- updated_count += 1
52
- except Exception as e:
53
- logger.error(f"Error parsing pricing for model {model_id}: {e}")
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 2% markup).")
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"]) > 25_000_000: # 25MB limit
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 25MB)"}, status_code=413)
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", "google/gemma-2-9b-it:free", "cohere/north-mini-code:free", "poolside/laguna-xs.2: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