utim-cli 2.3.18__tar.gz → 2.3.19b0__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 (167) hide show
  1. {utim_cli-2.3.18 → utim_cli-2.3.19b0}/CHANGELOG.md +15 -0
  2. {utim_cli-2.3.18/utim_cli.egg-info → utim_cli-2.3.19b0}/PKG-INFO +1 -1
  3. {utim_cli-2.3.18 → utim_cli-2.3.19b0}/pyproject.toml +1 -1
  4. {utim_cli-2.3.18 → utim_cli-2.3.19b0}/utim/__init__.py +1 -1
  5. {utim_cli-2.3.18 → utim_cli-2.3.19b0}/utim_cli/__init__.py +1 -1
  6. {utim_cli-2.3.18 → utim_cli-2.3.19b0}/utim_cli/_version.py +5 -1
  7. {utim_cli-2.3.18 → utim_cli-2.3.19b0}/utim_cli/auth.py +15 -1
  8. {utim_cli-2.3.18 → utim_cli-2.3.19b0}/utim_cli/client_utils.py +51 -4
  9. {utim_cli-2.3.18 → utim_cli-2.3.19b0}/utim_cli/orchestrator.py +7 -2
  10. {utim_cli-2.3.18 → utim_cli-2.3.19b0}/utim_cli/orchestrator_helpers.py +12 -1
  11. {utim_cli-2.3.18 → utim_cli-2.3.19b0}/utim_cli/orchestrator_llm_transport.py +6 -14
  12. {utim_cli-2.3.18 → utim_cli-2.3.19b0}/utim_cli/plugins/manager.py +27 -0
  13. {utim_cli-2.3.18 → utim_cli-2.3.19b0}/utim_cli/plugins/tool_loader.py +8 -0
  14. {utim_cli-2.3.18 → utim_cli-2.3.19b0}/utim_cli/server/auth.py +38 -27
  15. {utim_cli-2.3.18 → utim_cli-2.3.19b0}/utim_cli/server/models.py +7 -10
  16. {utim_cli-2.3.18 → utim_cli-2.3.19b0}/utim_cli/server/routes/auth_routes.py +4 -0
  17. {utim_cli-2.3.18 → utim_cli-2.3.19b0}/utim_cli/server/routes/completion_routes.py +9 -9
  18. {utim_cli-2.3.18 → utim_cli-2.3.19b0}/utim_cli/server/routes/quota_routes.py +42 -8
  19. {utim_cli-2.3.18 → utim_cli-2.3.19b0}/utim_cli/server/routes/rewards_routes.py +14 -5
  20. {utim_cli-2.3.18 → utim_cli-2.3.19b0}/utim_cli/tools.py +45 -41
  21. {utim_cli-2.3.18 → utim_cli-2.3.19b0}/utim_cli/tui/model_dialog.py +109 -66
  22. {utim_cli-2.3.18 → utim_cli-2.3.19b0}/utim_cli/tui/plugins_dialog.py +24 -0
  23. {utim_cli-2.3.18 → utim_cli-2.3.19b0}/utim_cli/utim.py +35 -22
  24. {utim_cli-2.3.18 → utim_cli-2.3.19b0/utim_cli.egg-info}/PKG-INFO +1 -1
  25. {utim_cli-2.3.18 → utim_cli-2.3.19b0}/LICENSE +0 -0
  26. {utim_cli-2.3.18 → utim_cli-2.3.19b0}/MANIFEST.in +0 -0
  27. {utim_cli-2.3.18 → utim_cli-2.3.19b0}/README.md +0 -0
  28. {utim_cli-2.3.18 → utim_cli-2.3.19b0}/setup.cfg +0 -0
  29. {utim_cli-2.3.18 → utim_cli-2.3.19b0}/setup.py +0 -0
  30. {utim_cli-2.3.18 → utim_cli-2.3.19b0}/utim_cli/_verify_repro.py +0 -0
  31. {utim_cli-2.3.18 → utim_cli-2.3.19b0}/utim_cli/_verify_syntax.py +0 -0
  32. {utim_cli-2.3.18 → utim_cli-2.3.19b0}/utim_cli/_wizard_smoke_test.py +0 -0
  33. {utim_cli-2.3.18 → utim_cli-2.3.19b0}/utim_cli/agent.py +0 -0
  34. {utim_cli-2.3.18 → utim_cli-2.3.19b0}/utim_cli/ask_helper.py +0 -0
  35. {utim_cli-2.3.18 → utim_cli-2.3.19b0}/utim_cli/backup.py +0 -0
  36. {utim_cli-2.3.18 → utim_cli-2.3.19b0}/utim_cli/billing.py +0 -0
  37. {utim_cli-2.3.18 → utim_cli-2.3.19b0}/utim_cli/blender_agent.py +0 -0
  38. {utim_cli-2.3.18 → utim_cli-2.3.19b0}/utim_cli/bootstrap.py +0 -0
  39. {utim_cli-2.3.18 → utim_cli-2.3.19b0}/utim_cli/brain.py +0 -0
  40. {utim_cli-2.3.18 → utim_cli-2.3.19b0}/utim_cli/builtin/skills/utim-guide/SKILL.md +0 -0
  41. {utim_cli-2.3.18 → utim_cli-2.3.19b0}/utim_cli/config.py +0 -0
  42. {utim_cli-2.3.18 → utim_cli-2.3.19b0}/utim_cli/constants.py +0 -0
  43. {utim_cli-2.3.18 → utim_cli-2.3.19b0}/utim_cli/context_pruner.py +0 -0
  44. {utim_cli-2.3.18 → utim_cli-2.3.19b0}/utim_cli/crawler/__init__.py +0 -0
  45. {utim_cli-2.3.18 → utim_cli-2.3.19b0}/utim_cli/crawler/engine.py +0 -0
  46. {utim_cli-2.3.18 → utim_cli-2.3.19b0}/utim_cli/crawler/markdown_distiller.py +0 -0
  47. {utim_cli-2.3.18 → utim_cli-2.3.19b0}/utim_cli/dependency_manager.py +0 -0
  48. {utim_cli-2.3.18 → utim_cli-2.3.19b0}/utim_cli/doctor.py +0 -0
  49. {utim_cli-2.3.18 → utim_cli-2.3.19b0}/utim_cli/evidence_registry.py +0 -0
  50. {utim_cli-2.3.18 → utim_cli-2.3.19b0}/utim_cli/harbor.py +0 -0
  51. {utim_cli-2.3.18 → utim_cli-2.3.19b0}/utim_cli/knowledge_graph.py +0 -0
  52. {utim_cli-2.3.18 → utim_cli-2.3.19b0}/utim_cli/local_db.py +0 -0
  53. {utim_cli-2.3.18 → utim_cli-2.3.19b0}/utim_cli/logger.py +0 -0
  54. {utim_cli-2.3.18 → utim_cli-2.3.19b0}/utim_cli/mcp_clean_wrapper.py +0 -0
  55. {utim_cli-2.3.18 → utim_cli-2.3.19b0}/utim_cli/mcp_client.py +0 -0
  56. {utim_cli-2.3.18 → utim_cli-2.3.19b0}/utim_cli/mcp_registry.json +0 -0
  57. {utim_cli-2.3.18 → utim_cli-2.3.19b0}/utim_cli/models.txt +0 -0
  58. {utim_cli-2.3.18 → utim_cli-2.3.19b0}/utim_cli/orchestrator_tool_exec.py +0 -0
  59. {utim_cli-2.3.18 → utim_cli-2.3.19b0}/utim_cli/orchestrator_turn_history.py +0 -0
  60. {utim_cli-2.3.18 → utim_cli-2.3.19b0}/utim_cli/personalities.py +0 -0
  61. {utim_cli-2.3.18 → utim_cli-2.3.19b0}/utim_cli/platform_installer.py +0 -0
  62. {utim_cli-2.3.18 → utim_cli-2.3.19b0}/utim_cli/plugins/__init__.py +0 -0
  63. {utim_cli-2.3.18 → utim_cli-2.3.19b0}/utim_cli/plugins/hooks.py +0 -0
  64. {utim_cli-2.3.18 → utim_cli-2.3.19b0}/utim_cli/plugins/loader.py +0 -0
  65. {utim_cli-2.3.18 → utim_cli-2.3.19b0}/utim_cli/plugins/registry.py +0 -0
  66. {utim_cli-2.3.18 → utim_cli-2.3.19b0}/utim_cli/plugins/runtime.py +0 -0
  67. {utim_cli-2.3.18 → utim_cli-2.3.19b0}/utim_cli/plugins/schema.py +0 -0
  68. {utim_cli-2.3.18 → utim_cli-2.3.19b0}/utim_cli/reflection.py +0 -0
  69. {utim_cli-2.3.18 → utim_cli-2.3.19b0}/utim_cli/report.py +0 -0
  70. {utim_cli-2.3.18 → utim_cli-2.3.19b0}/utim_cli/scrapy_search.py +0 -0
  71. {utim_cli-2.3.18 → utim_cli-2.3.19b0}/utim_cli/sdk/__init__.py +0 -0
  72. {utim_cli-2.3.18 → utim_cli-2.3.19b0}/utim_cli/sdk/config.py +0 -0
  73. {utim_cli-2.3.18 → utim_cli-2.3.19b0}/utim_cli/sdk/context.py +0 -0
  74. {utim_cli-2.3.18 → utim_cli-2.3.19b0}/utim_cli/sdk/events.py +0 -0
  75. {utim_cli-2.3.18 → utim_cli-2.3.19b0}/utim_cli/sdk/handlers.py +0 -0
  76. {utim_cli-2.3.18 → utim_cli-2.3.19b0}/utim_cli/sdk/session.py +0 -0
  77. {utim_cli-2.3.18 → utim_cli-2.3.19b0}/utim_cli/sdk/sidecar.py +0 -0
  78. {utim_cli-2.3.18 → utim_cli-2.3.19b0}/utim_cli/server/__init__.py +0 -0
  79. {utim_cli-2.3.18 → utim_cli-2.3.19b0}/utim_cli/server/admin_auth.py +0 -0
  80. {utim_cli-2.3.18 → utim_cli-2.3.19b0}/utim_cli/server/attribution.py +0 -0
  81. {utim_cli-2.3.18 → utim_cli-2.3.19b0}/utim_cli/server/audit_log.py +0 -0
  82. {utim_cli-2.3.18 → utim_cli-2.3.19b0}/utim_cli/server/batch_processor.py +0 -0
  83. {utim_cli-2.3.18 → utim_cli-2.3.19b0}/utim_cli/server/captcha.py +0 -0
  84. {utim_cli-2.3.18 → utim_cli-2.3.19b0}/utim_cli/server/cli_auth.py +0 -0
  85. {utim_cli-2.3.18 → utim_cli-2.3.19b0}/utim_cli/server/concurrency.py +0 -0
  86. {utim_cli-2.3.18 → utim_cli-2.3.19b0}/utim_cli/server/db.py +0 -0
  87. {utim_cli-2.3.18 → utim_cli-2.3.19b0}/utim_cli/server/docs_md/SECRET_PROVISIONING.md +0 -0
  88. {utim_cli-2.3.18 → utim_cli-2.3.19b0}/utim_cli/server/docs_md/about.md +0 -0
  89. {utim_cli-2.3.18 → utim_cli-2.3.19b0}/utim_cli/server/docs_md/changelog.md +0 -0
  90. {utim_cli-2.3.18 → utim_cli-2.3.19b0}/utim_cli/server/docs_md/docs.md +0 -0
  91. {utim_cli-2.3.18 → utim_cli-2.3.19b0}/utim_cli/server/docs_md/features.md +0 -0
  92. {utim_cli-2.3.18 → utim_cli-2.3.19b0}/utim_cli/server/docs_md/license.md +0 -0
  93. {utim_cli-2.3.18 → utim_cli-2.3.19b0}/utim_cli/server/docs_md/pricing.md +0 -0
  94. {utim_cli-2.3.18 → utim_cli-2.3.19b0}/utim_cli/server/docs_md/privacy.md +0 -0
  95. {utim_cli-2.3.18 → utim_cli-2.3.19b0}/utim_cli/server/docs_md/refund.md +0 -0
  96. {utim_cli-2.3.18 → utim_cli-2.3.19b0}/utim_cli/server/docs_md/support.md +0 -0
  97. {utim_cli-2.3.18 → utim_cli-2.3.19b0}/utim_cli/server/docs_md/terms.md +0 -0
  98. {utim_cli-2.3.18 → utim_cli-2.3.19b0}/utim_cli/server/email_utils.py +0 -0
  99. {utim_cli-2.3.18 → utim_cli-2.3.19b0}/utim_cli/server/exchange_rate.py +0 -0
  100. {utim_cli-2.3.18 → utim_cli-2.3.19b0}/utim_cli/server/firebase.py +0 -0
  101. {utim_cli-2.3.18 → utim_cli-2.3.19b0}/utim_cli/server/fix_duplicate_users.py +0 -0
  102. {utim_cli-2.3.18 → utim_cli-2.3.19b0}/utim_cli/server/history.py +0 -0
  103. {utim_cli-2.3.18 → utim_cli-2.3.19b0}/utim_cli/server/key_rotator.py +0 -0
  104. {utim_cli-2.3.18 → utim_cli-2.3.19b0}/utim_cli/server/local_llm.py +0 -0
  105. {utim_cli-2.3.18 → utim_cli-2.3.19b0}/utim_cli/server/logging_config.py +0 -0
  106. {utim_cli-2.3.18 → utim_cli-2.3.19b0}/utim_cli/server/micro_batcher.py +0 -0
  107. {utim_cli-2.3.18 → utim_cli-2.3.19b0}/utim_cli/server/model_agent.py +0 -0
  108. {utim_cli-2.3.18 → utim_cli-2.3.19b0}/utim_cli/server/pricing_updater.py +0 -0
  109. {utim_cli-2.3.18 → utim_cli-2.3.19b0}/utim_cli/server/provision_build.py +0 -0
  110. {utim_cli-2.3.18 → utim_cli-2.3.19b0}/utim_cli/server/rate_limit.py +0 -0
  111. {utim_cli-2.3.18 → utim_cli-2.3.19b0}/utim_cli/server/rewards_engine.py +0 -0
  112. {utim_cli-2.3.18 → utim_cli-2.3.19b0}/utim_cli/server/router.py +0 -0
  113. {utim_cli-2.3.18 → utim_cli-2.3.19b0}/utim_cli/server/routes/__init__.py +0 -0
  114. {utim_cli-2.3.18 → utim_cli-2.3.19b0}/utim_cli/server/routes/admin_bonus_routes.py +0 -0
  115. {utim_cli-2.3.18 → utim_cli-2.3.19b0}/utim_cli/server/routes/admin_db_routes.py +0 -0
  116. {utim_cli-2.3.18 → utim_cli-2.3.19b0}/utim_cli/server/routes/billing_routes.py +0 -0
  117. {utim_cli-2.3.18 → utim_cli-2.3.19b0}/utim_cli/server/routes/credit_routes.py +0 -0
  118. {utim_cli-2.3.18 → utim_cli-2.3.19b0}/utim_cli/server/routes/feedback_routes.py +0 -0
  119. {utim_cli-2.3.18 → utim_cli-2.3.19b0}/utim_cli/server/routes/marketplace_routes.py +0 -0
  120. {utim_cli-2.3.18 → utim_cli-2.3.19b0}/utim_cli/server/routes/quota_share_routes.py +0 -0
  121. {utim_cli-2.3.18 → utim_cli-2.3.19b0}/utim_cli/server/routes/referral_routes.py +0 -0
  122. {utim_cli-2.3.18 → utim_cli-2.3.19b0}/utim_cli/server/routes/security_routes.py +0 -0
  123. {utim_cli-2.3.18 → utim_cli-2.3.19b0}/utim_cli/server/routes/session_routes.py +0 -0
  124. {utim_cli-2.3.18 → utim_cli-2.3.19b0}/utim_cli/server/routes/share_routes.py +0 -0
  125. {utim_cli-2.3.18 → utim_cli-2.3.19b0}/utim_cli/server/server.py +0 -0
  126. {utim_cli-2.3.18 → utim_cli-2.3.19b0}/utim_cli/server/storage_nodes.py +0 -0
  127. {utim_cli-2.3.18 → utim_cli-2.3.19b0}/utim_cli/server/utils.py +0 -0
  128. {utim_cli-2.3.18 → utim_cli-2.3.19b0}/utim_cli/share.py +0 -0
  129. {utim_cli-2.3.18 → utim_cli-2.3.19b0}/utim_cli/share_tui.py +0 -0
  130. {utim_cli-2.3.18 → utim_cli-2.3.19b0}/utim_cli/situational_scoring.py +0 -0
  131. {utim_cli-2.3.18 → utim_cli-2.3.19b0}/utim_cli/state.py +0 -0
  132. {utim_cli-2.3.18 → utim_cli-2.3.19b0}/utim_cli/subagent_manager.py +0 -0
  133. {utim_cli-2.3.18 → utim_cli-2.3.19b0}/utim_cli/task_dispatcher.py +0 -0
  134. {utim_cli-2.3.18 → utim_cli-2.3.19b0}/utim_cli/task_logger.py +0 -0
  135. {utim_cli-2.3.18 → utim_cli-2.3.19b0}/utim_cli/tools_grep.py +0 -0
  136. {utim_cli-2.3.18 → utim_cli-2.3.19b0}/utim_cli/tui/__init__.py +0 -0
  137. {utim_cli-2.3.18 → utim_cli-2.3.19b0}/utim_cli/tui/feedback_dialog.py +0 -0
  138. {utim_cli-2.3.18 → utim_cli-2.3.19b0}/utim_cli/tui/history_dialog.py +0 -0
  139. {utim_cli-2.3.18 → utim_cli-2.3.19b0}/utim_cli/tui/marketplace_app_state.py +0 -0
  140. {utim_cli-2.3.18 → utim_cli-2.3.19b0}/utim_cli/tui/marketplace_dialog.py +0 -0
  141. {utim_cli-2.3.18 → utim_cli-2.3.19b0}/utim_cli/tui/marketplace_layout_engine.py +0 -0
  142. {utim_cli-2.3.18 → utim_cli-2.3.19b0}/utim_cli/tui/mcp_dialog.py +0 -0
  143. {utim_cli-2.3.18 → utim_cli-2.3.19b0}/utim_cli/tui/miniagents_dialog.py +0 -0
  144. {utim_cli-2.3.18 → utim_cli-2.3.19b0}/utim_cli/tui/personality_dialog.py +0 -0
  145. {utim_cli-2.3.18 → utim_cli-2.3.19b0}/utim_cli/tui/publish_dialog.py +0 -0
  146. {utim_cli-2.3.18 → utim_cli-2.3.19b0}/utim_cli/tui/quota_dialog.py +0 -0
  147. {utim_cli-2.3.18 → utim_cli-2.3.19b0}/utim_cli/tui/quota_redeem_dialog.py +0 -0
  148. {utim_cli-2.3.18 → utim_cli-2.3.19b0}/utim_cli/tui/quota_share_dialog.py +0 -0
  149. {utim_cli-2.3.18 → utim_cli-2.3.19b0}/utim_cli/tui/resume_dialog.py +0 -0
  150. {utim_cli-2.3.18 → utim_cli-2.3.19b0}/utim_cli/tui/rewards_tui.py +0 -0
  151. {utim_cli-2.3.18 → utim_cli-2.3.19b0}/utim_cli/tui/skills_dialog.py +0 -0
  152. {utim_cli-2.3.18 → utim_cli-2.3.19b0}/utim_cli/tui/subagents_dialog.py +0 -0
  153. {utim_cli-2.3.18 → utim_cli-2.3.19b0}/utim_cli/tui/tasks_dialog.py +0 -0
  154. {utim_cli-2.3.18 → utim_cli-2.3.19b0}/utim_cli/tui/thinking_display.py +0 -0
  155. {utim_cli-2.3.18 → utim_cli-2.3.19b0}/utim_cli/tui/tools_dialog.py +0 -0
  156. {utim_cli-2.3.18 → utim_cli-2.3.19b0}/utim_cli/tui/update_dialog.py +0 -0
  157. {utim_cli-2.3.18 → utim_cli-2.3.19b0}/utim_cli/update_checker.py +0 -0
  158. {utim_cli-2.3.18 → utim_cli-2.3.19b0}/utim_cli/utilities.py +0 -0
  159. {utim_cli-2.3.18 → utim_cli-2.3.19b0}/utim_cli/utimmodel.txt +0 -0
  160. {utim_cli-2.3.18 → utim_cli-2.3.19b0}/utim_cli/vector_memory.py +0 -0
  161. {utim_cli-2.3.18 → utim_cli-2.3.19b0}/utim_cli/wheel.py +0 -0
  162. {utim_cli-2.3.18 → utim_cli-2.3.19b0}/utim_cli/workspace.py +0 -0
  163. {utim_cli-2.3.18 → utim_cli-2.3.19b0}/utim_cli.egg-info/SOURCES.txt +0 -0
  164. {utim_cli-2.3.18 → utim_cli-2.3.19b0}/utim_cli.egg-info/dependency_links.txt +0 -0
  165. {utim_cli-2.3.18 → utim_cli-2.3.19b0}/utim_cli.egg-info/entry_points.txt +0 -0
  166. {utim_cli-2.3.18 → utim_cli-2.3.19b0}/utim_cli.egg-info/requires.txt +0 -0
  167. {utim_cli-2.3.18 → utim_cli-2.3.19b0}/utim_cli.egg-info/top_level.txt +0 -0
@@ -1,5 +1,20 @@
1
1
  # Changelog
2
2
 
3
+ ## [2.3.19-beta] - 2026-09-03
4
+
5
+ ### 🚀 Beta Release: Tier Enforcement & Model Access Fixes
6
+ - **Websearch tier gate**: agentic websearch (LLM query synthesis + synthesis) is now paid-plan only; free-tier users always get the fast non-agentic path
7
+ - **Tool registry cleanup**: removed `analyzeimage` from callable tools — vision models receive image bytes directly via `readfile`, non-vision models get internal auto-descriptions
8
+ - **APINEX models ungated**: APINEX models are now available on every plan (paid included) as free models
9
+ - Version tagged as **beta** (`2.3.19-beta`) for beta-channel upload
10
+
11
+ ## [2.3.19] - 2026-09-02
12
+
13
+ ### 🛠️ Model Dialog Tier Sync & Free Model Preservation
14
+ - Synchronized user plan resolution on account switch to prevent paid models leaking on free accounts
15
+ - Fixed free models disappearing when bonus quota is active
16
+ - Removed default selected model on fresh startup and added configuration prompts
17
+
3
18
  ## [2.3.18] - 2026-09-02
4
19
 
5
20
  ### 🎁 New Free Models Added & Tool Sanitization Fixes
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: utim-cli
3
- Version: 2.3.18
3
+ Version: 2.3.19b0
4
4
  Summary: UTIM – Universal Terminal Intelligence Manager. A powerful agentic AI coding assistant for your terminal.
5
5
  License: Emend AI Proprietary EULA
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 = "2.3.18"
7
+ version = "2.3.19-beta"
8
8
 
9
9
  description = "UTIM – Universal Terminal Intelligence Manager. A powerful agentic AI coding assistant for your terminal."
10
10
  readme = "README.md"
@@ -14,7 +14,7 @@ Quickstart:
14
14
  try:
15
15
  from utim_cli._version import VERSION as __version__
16
16
  except ImportError:
17
- __version__ = "2.3.18"
17
+ __version__ = "2.3.19-beta"
18
18
 
19
19
  # Developer-friendly core classes (similar to `import genai` / `import openai`)
20
20
  from utim_cli.sdk.session import UtimSession as Session
@@ -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__ = "2.3.18"
8
+ __version__ = "2.3.19-beta"
9
9
 
10
10
 
11
11
  import builtins, os, pathlib, urllib.parse, re
@@ -11,6 +11,10 @@
11
11
  # from here so every surface stays in sync automatically.
12
12
  # ─────────────────────────────────────────────────────────────────────────────
13
13
 
14
- VERSION = "2.3.18"
14
+ # Beta release: "-beta" is the machine-valid spelling of "2.3.19 beta"
15
+ # (npm semver prerelease tag + PEP 440 pre-release segment; a space would
16
+ # break npm publish and pip). parse_version() strips the suffix so update
17
+ # checks still compare on the numeric core 2.3.19.
18
+ VERSION = "2.3.19-beta"
15
19
 
16
20
 
@@ -495,8 +495,22 @@ def login(restart: bool = False) -> None:
495
495
  config.set("token", "device_flow")
496
496
  config.set("email", body.get("email", ""))
497
497
  config.set("uid", body.get("user_id", ""))
498
- config.set("firebase_uid", body.get("firebase_uid", ""))
499
498
  config.set("name", body.get("display_name") or (body.get("email", "") or "").split("@")[0])
499
+ plan_name = body.get("plan")
500
+ if (not plan_name or plan_name == "free") and body.get("api_key"):
501
+ try:
502
+ _vr = requests.get(
503
+ f"{SERVER_URL}/api/user-plan",
504
+ headers={"X-API-Key": body["api_key"]},
505
+ timeout=3.0,
506
+ )
507
+ if _vr.status_code == 200:
508
+ _pdata = _vr.json() or {}
509
+ plan_name = _pdata.get("plan") or _pdata.get("plan_name")
510
+ except Exception:
511
+ pass
512
+ clean_plan = str(plan_name or "free").lower().removesuffix(" plan").strip()
513
+ config.set("user_plan", clean_plan)
500
514
  if body.get("api_key"):
501
515
  config.set("api_key", body["api_key"])
502
516
  raw_print("")
@@ -64,6 +64,44 @@ def get_server_url() -> str:
64
64
  return "https://api.utim.dev"
65
65
 
66
66
 
67
+ def is_paid_plan(plan_name: Optional[str]) -> bool:
68
+ """Return True if the given plan name represents a paid subscription tier."""
69
+ if not plan_name:
70
+ return False
71
+ p = str(plan_name).lower().removesuffix(" plan").strip()
72
+ return p not in ("free", "free tier", "", "none", "null", "guest", "undefined")
73
+
74
+
75
+ def is_user_paid(force_refresh: bool = False) -> bool:
76
+ """Helper to check if the current user has an active paid subscription tier."""
77
+ try:
78
+ from utim_cli.config import config
79
+ api_key = config.get("api_key")
80
+ cached_plan = config.get("user_plan")
81
+
82
+ if not force_refresh and cached_plan and is_paid_plan(cached_plan):
83
+ return True
84
+
85
+ if not api_key:
86
+ return is_paid_plan(cached_plan)
87
+
88
+ try:
89
+ from utim_cli.orchestrator_helpers import get_cached_quota
90
+ quota = get_cached_quota(api_key, force_refresh=force_refresh)
91
+ if quota:
92
+ plan = quota.get("plan") or quota.get("plan_name")
93
+ if plan:
94
+ clean_plan = str(plan).lower().removesuffix(" plan").strip()
95
+ config.set("user_plan", clean_plan)
96
+ return is_paid_plan(clean_plan)
97
+ except Exception:
98
+ pass
99
+
100
+ return is_paid_plan(cached_plan)
101
+ except Exception:
102
+ return False
103
+
104
+
67
105
  def _sanitize_json(obj):
68
106
  """Recursively scrub a payload for values that break strict JSON parsers.
69
107
 
@@ -181,10 +219,10 @@ def fetch_remote_models_catalog(timeout: float = 3.0) -> dict:
181
219
  params = {}
182
220
  try:
183
221
  from utim_cli.config import config
184
- _plan = (config.get("user_plan") or "all")
185
- params["tier"] = str(_plan).lower().strip()
222
+ _plan = str(config.get("user_plan") or "free").lower().strip()
223
+ params["tier"] = _plan
186
224
  except Exception:
187
- params["tier"] = "all"
225
+ params["tier"] = "free"
188
226
  resp = requests.get(url, params=params, headers=get_auth_headers(), timeout=timeout)
189
227
  if resp.status_code == 200:
190
228
  data = resp.json()
@@ -1026,10 +1064,19 @@ def repair_and_normalize_tool_call(tc: dict) -> dict:
1026
1064
  raw_name = str(fn.get("name", "")).strip()
1027
1065
  raw_args = fn.get("arguments", "{}")
1028
1066
 
1029
- # 1. Clean and normalize tool name
1067
+ raw_name_orig = raw_name
1030
1068
  for known in KNOWN_TOOLS:
1031
1069
  if raw_name.startswith(known):
1070
+ remainder = raw_name[len(known):].strip()
1032
1071
  raw_name = known
1072
+ if remainder and (not str(raw_args).strip() or str(raw_args).strip() == "{}"):
1073
+ attrs = re.findall(r'(\w+)\s*=\s*(?:"((?:[^"\\]|\\.)*)"|\'((?:[^\'\\]|\\.)*)\'|(\S+))', remainder)
1074
+ if attrs:
1075
+ parsed_attrs = {}
1076
+ for _k, _v1, _v2, _v3 in attrs:
1077
+ _v = _v1 or _v2 or _v3 or ""
1078
+ parsed_attrs[_k] = _v
1079
+ raw_args = parsed_attrs
1033
1080
  break
1034
1081
  if raw_name == known + known:
1035
1082
  raw_name = known
@@ -139,9 +139,9 @@ class Orchestrator(LLMTransportMixin, TurnHistoryMixin, ToolExecutionMixin):
139
139
  pass
140
140
  self.server_url = "https://api.utim.dev"
141
141
  self.session_id: Optional[str] = None
142
- # Primary model — load from config or fall back to DEFAULT_MODEL
142
+ # Primary model — load from config (no default model selected on first startup)
143
143
  saved_main_model = config.get("main_model") or config.get("model")
144
- self.model_id: str = saved_main_model if saved_main_model else DEFAULT_MODEL
144
+ self.model_id: Optional[str] = saved_main_model if saved_main_model else None
145
145
  self._current_line_len = 0
146
146
  self.tool_results: List[Dict[str, Any]] = []
147
147
  self.turn_step_timings: List[Dict[str, Any]] = []
@@ -501,6 +501,11 @@ class Orchestrator(LLMTransportMixin, TurnHistoryMixin, ToolExecutionMixin):
501
501
  self._subagent_tool_override = override_tools
502
502
  max_iterations = 500 # Continuous execution mode with milestone nudges instead of hard pauses
503
503
 
504
+ # If no model is configured for the main agent, prompt the user to select one
505
+ if not self.model_id or not str(self.model_id).strip():
506
+ self.console.print('\nPlease select a model by running "/model Then main agent"\n')
507
+ return
508
+
504
509
  self.turn_step_timings = []
505
510
  self._tool_failure_counts = {}
506
511
  self._tool_name_failure_counts = {}
@@ -244,7 +244,13 @@ def get_cached_quota(api_key: str, force_refresh: bool = False) -> Optional[Dict
244
244
  session = get_http_session()
245
245
  resp = session.get(f"{SERVER_URL}/quota", headers={"X-API-Key": api_key}, timeout=2.0)
246
246
  if resp.status_code == 200:
247
- _QUOTA_CACHE.update({"data": resp.json(), "timestamp": time.time(), "api_key": api_key})
247
+ data = resp.json()
248
+ _QUOTA_CACHE.update({"data": data, "timestamp": time.time(), "api_key": api_key})
249
+ plan = data.get("plan") or data.get("plan_name")
250
+ if plan:
251
+ from utim_cli.config import config
252
+ clean_plan = str(plan).lower().removesuffix(" plan").strip()
253
+ config.set("user_plan", clean_plan)
248
254
  except Exception:
249
255
  pass
250
256
  threading.Thread(target=_bg_quota_refresh, daemon=True).start()
@@ -263,6 +269,11 @@ def get_cached_quota(api_key: str, force_refresh: bool = False) -> Optional[Dict
263
269
  data = resp.json()
264
270
  _QUOTA_CACHE.clear()
265
271
  _QUOTA_CACHE.update({"data": data, "timestamp": now, "api_key": api_key})
272
+ plan = data.get("plan") or data.get("plan_name")
273
+ if plan:
274
+ from utim_cli.config import config
275
+ clean_plan = str(plan).lower().removesuffix(" plan").strip()
276
+ config.set("user_plan", clean_plan)
266
277
  return data
267
278
  except Exception:
268
279
  pass
@@ -802,18 +802,8 @@ class LLMTransportMixin:
802
802
  self.model_source = "utim"
803
803
  config.set("main_model_source", "utim")
804
804
  else:
805
- # Paid plan (plan != free): gate APINEX models
806
- if is_apinex:
807
- fallback_model = "anthropic/claude-3-5-sonnet-20241022"
808
- self.console.print(f"\n[bold yellow]APINEX model '{chosen_model}' is exclusively for the Free plan.[/bold yellow]")
809
- self.console.print(f" Switching to your plan's model: '{fallback_model}'.\n")
810
- if override_model:
811
- override_model = fallback_model
812
- else:
813
- self.model_id = fallback_model
814
- self.model_source = "utim"
815
- config.set("main_model_source", "utim")
816
- elif models_allowed != ["all"] and chosen_model not in models_allowed and free_bonus_balance <= 0.0:
805
+ # Paid plan: APINEX models are free gateway models — allowed on every plan
806
+ if models_allowed != ["all"] and chosen_model not in models_allowed and not is_apinex and free_bonus_balance <= 0.0:
817
807
  fallback_model = DEFAULT_MODEL
818
808
  self.console.print(f"\n[bold yellow]Model '{chosen_model}' is gated under your current '{quota.get('display_name', 'Free')}' plan.[/bold yellow]")
819
809
  self.console.print(f" Downgrading to default allowed model: '{fallback_model}' for this request.")
@@ -1314,8 +1304,10 @@ class LLMTransportMixin:
1314
1304
  # NOTE: last_content_time is updated further below, only when
1315
1305
  # actual content or tool-call data is parsed from the chunk.
1316
1306
 
1317
- # Clean and normalize the raw line
1318
- line_str = raw_line.strip()
1307
+ if isinstance(raw_line, bytes):
1308
+ line_str = raw_line.decode("utf-8", errors="ignore").strip()
1309
+ else:
1310
+ line_str = str(raw_line).strip()
1319
1311
  if not line_str:
1320
1312
  continue
1321
1313
 
@@ -21,6 +21,7 @@ from pathlib import Path
21
21
  from typing import Any, Dict, List, Optional, Set
22
22
 
23
23
  from utim_cli.config import get_utim_dir, config
24
+ from utim_cli.client_utils import is_user_paid
24
25
  from utim_cli.plugins.schema import Plugin, PluginManifest
25
26
  from utim_cli.plugins.registry import get_registry_plugins, find_registry_plugin
26
27
  from utim_cli.plugins import loader as staged_loader
@@ -149,6 +150,8 @@ class PluginManager:
149
150
  # ── Enable / Disable / Toggle ────────────────────────────────────────────
150
151
 
151
152
  def enable_plugin(self, name: str) -> bool:
153
+ if not is_user_paid():
154
+ return False
152
155
  plugin = self.get_plugin(name)
153
156
  if not plugin:
154
157
  return False
@@ -171,6 +174,8 @@ class PluginManager:
171
174
  return True
172
175
 
173
176
  def toggle_plugin(self, name: str) -> bool:
177
+ if not is_user_paid():
178
+ return False
174
179
  return self.disable_plugin(name) if self.is_enabled(name) else self.enable_plugin(name)
175
180
 
176
181
  # ── Installation ─────────────────────────────────────────────────────────
@@ -191,6 +196,9 @@ class PluginManager:
191
196
  def install_plugin(self, identifier: str, force: bool = False, plugin_type: str = "auto") -> Dict[str, Any]:
192
197
  """Install a plugin from a registry name, git URL, or local path."""
193
198
  result: Dict[str, Any] = {"ok": False, "plugin": identifier, "message": ""}
199
+ if not is_user_paid():
200
+ result["message"] = "Plugins are only available on paid plans (Hobby plan or higher). Please upgrade your subscription at https://utim.dev/upgrade"
201
+ return result
194
202
  meta = find_registry_plugin(identifier)
195
203
  git_url = self._parse_git_url(identifier)
196
204
 
@@ -280,6 +288,12 @@ class PluginManager:
280
288
  • Any other page -> knowledge plugin (scraped markdown becomes
281
289
  a SKILL.md the model gets injected with)
282
290
  """
291
+ if not is_user_paid():
292
+ return {
293
+ "ok": False,
294
+ "message": "Plugins are only available on paid plans (Hobby plan or higher). Please upgrade your subscription at https://utim.dev/upgrade",
295
+ }
296
+
283
297
  def _say(msg: str) -> None:
284
298
  if progress_cb:
285
299
  progress_cb(msg)
@@ -392,6 +406,9 @@ class PluginManager:
392
406
 
393
407
  Returns (ok: bool, message: str, path: Optional[Path]).
394
408
  """
409
+ if not is_user_paid():
410
+ return False, "Plugins are only available on paid plans (Hobby plan or higher). Please upgrade your subscription at https://utim.dev/upgrade", None
411
+
395
412
  clean = re.sub(r"[^a-zA-Z0-9_\-]", "-", name.strip().lower()).strip("-")
396
413
  if not clean:
397
414
  return False, "Invalid plugin name", None
@@ -469,6 +486,8 @@ class PluginManager:
469
486
  # ── Aggregation (effectors) ──────────────────────────────────────────────
470
487
 
471
488
  def get_active_skills(self) -> Dict[str, Dict[str, Any]]:
489
+ if not is_user_paid():
490
+ return {}
472
491
  skills: Dict[str, Dict[str, Any]] = {}
473
492
  for p in self.list_plugins():
474
493
  if p.enabled and p.is_valid:
@@ -480,6 +499,8 @@ class PluginManager:
480
499
  return skills
481
500
 
482
501
  def get_active_rules(self) -> List[Dict[str, Any]]:
502
+ if not is_user_paid():
503
+ return []
483
504
  rules: List[Dict[str, Any]] = []
484
505
  for p in self.list_plugins():
485
506
  if p.enabled and p.is_valid:
@@ -487,6 +508,8 @@ class PluginManager:
487
508
  return rules
488
509
 
489
510
  def get_active_mcp_servers(self) -> Dict[str, Dict[str, Any]]:
511
+ if not is_user_paid():
512
+ return {}
490
513
  servers: Dict[str, Dict[str, Any]] = {}
491
514
  for p in self.list_plugins():
492
515
  if p.enabled and p.is_valid:
@@ -494,6 +517,8 @@ class PluginManager:
494
517
  return servers
495
518
 
496
519
  def get_active_tools(self) -> List[Dict[str, Any]]:
520
+ if not is_user_paid():
521
+ return []
497
522
  tools: List[Dict[str, Any]] = []
498
523
  for p in self.list_plugins():
499
524
  if p.enabled and p.is_valid:
@@ -511,6 +536,8 @@ class PluginManager:
511
536
  (string/dict commands from manifests) go through hooks.py in
512
537
  parallel with per-hook timeouts.
513
538
  """
539
+ if not is_user_paid():
540
+ return []
514
541
  from utim_cli.plugins import hooks as hook_engine
515
542
  payload = payload or {}
516
543
  output: Dict[str, Any] = {}
@@ -53,6 +53,14 @@ def load_plugin_tools(force_refresh: bool = False) -> Tuple[List[Dict], Dict[str
53
53
  if not force_refresh and _TOOL_CACHE is not None and (now - _TOOL_CACHE[2]) < _TOOL_CACHE_TTL:
54
54
  return _TOOL_CACHE[0], _TOOL_CACHE[1]
55
55
 
56
+ try:
57
+ from utim_cli.client_utils import is_user_paid
58
+ if not is_user_paid():
59
+ _TOOL_CACHE = ([], {}, now)
60
+ return [], {}
61
+ except Exception:
62
+ pass
63
+
56
64
  from utim_cli.plugins.manager import get_plugin_manager
57
65
 
58
66
  schemas: List[Dict] = []
@@ -678,38 +678,49 @@ def _resolve_user_plan_name(user) -> str:
678
678
  Safe to call without a DB session — falls back to ``"free"`` on any error
679
679
  so that catalog gating cannot leak paid models to anonymous callers.
680
680
 
681
- Expiration-aware: an ``active`` subscription whose ``end_date`` has passed
681
+ Expiration-aware: an ``active`` subscription whose ``current_period_end`` has passed
682
682
  (and which was never renewed) is treated as ``free`` — we do not rely on a
683
683
  background job to flip the status to ``expired``.
684
684
  """
685
+ if not user:
686
+ return "free"
685
687
  try:
686
- from .db import SessionLocal, UserSubscription
688
+ from .db import SessionLocal, UserSubscription, Plan
687
689
  from datetime import datetime, timezone
688
- subs = (
689
- SessionLocal()
690
- .query(UserSubscription)
691
- .filter(UserSubscription.user_id == user.id, UserSubscription.status == "active")
692
- .all()
693
- )
694
- now = datetime.now(timezone.utc)
695
- best = None
696
- best_end = None
697
- for sub in subs:
698
- if not (sub.plan and getattr(sub.plan, "name", None)):
699
- continue
700
- end = getattr(sub, "end_date", None)
701
- # Normalise tz-aware end_date to naive UTC (or vice-versa) so the
702
- # comparison never raises TypeError on mixed aware/naive values.
703
- if end is not None and getattr(end, "tzinfo", None) is not None:
704
- end = end.astimezone(timezone.utc).replace(tzinfo=None)
705
- # Skip subscriptions that already expired.
706
- if end is not None and end < now.replace(tzinfo=None):
707
- continue
708
- if best is None or (end is not None and (best_end is None or end > best_end)):
709
- best = sub
710
- best_end = end
711
- if best is not None:
712
- return str(best.plan.name).lower()
690
+ db = SessionLocal()
691
+ try:
692
+ subs = (
693
+ db.query(UserSubscription)
694
+ .filter(UserSubscription.user_id == user.id, UserSubscription.status == "active")
695
+ .order_by(UserSubscription.updated_at.desc(), UserSubscription.created_at.desc())
696
+ .all()
697
+ )
698
+ now = datetime.now(timezone.utc)
699
+ best = None
700
+ best_end = None
701
+ for sub in subs:
702
+ plan = sub.plan
703
+ if not plan and sub.plan_id:
704
+ plan = db.query(Plan).filter((Plan.id == sub.plan_id) | (Plan.name == sub.plan_id)).first()
705
+ if not plan or not getattr(plan, "name", None):
706
+ continue
707
+ end = getattr(sub, "current_period_end", None) or getattr(sub, "end_date", None)
708
+ # Normalise tz-aware end date to naive UTC so the comparison never raises TypeError.
709
+ if end is not None and getattr(end, "tzinfo", None) is not None:
710
+ end = end.astimezone(timezone.utc).replace(tzinfo=None)
711
+ # Skip subscriptions that already expired.
712
+ if end is not None and end < now.replace(tzinfo=None):
713
+ continue
714
+ plan_name = str(plan.name).lower().strip()
715
+ if plan_name != "free":
716
+ return plan_name
717
+ if best is None or (end is not None and (best_end is None or end > best_end)):
718
+ best = plan_name
719
+ best_end = end
720
+ if best is not None:
721
+ return best
722
+ finally:
723
+ db.close()
713
724
  except Exception:
714
725
  pass
715
726
  return "free"
@@ -2919,12 +2919,12 @@ def _tier_derived_allowlist(plan_name: str) -> set:
2919
2919
  return set()
2920
2920
  out = set()
2921
2921
  for mid, entry in (MODEL_REGISTRY or {}).items():
2922
- # APINEX models are exclusively for free plan users; gate for paid plans
2923
- if norm_name != "free" and (getattr(entry, "provider", "").lower() == "apinex" or mid in APINEX_MODEL_IDS):
2924
- continue
2922
+ # APINEX models are free gateway models — included on every plan
2925
2923
  rank = _MODEL_TIER_RANK.get(_model_tier_of(entry), 2) # default mid
2926
2924
  if rank in keep:
2927
2925
  out.add(mid)
2926
+ # Always append APINEX gateway models (zero-cost, free on all plans)
2927
+ out.update(APINEX_MODEL_IDS)
2928
2928
  return out
2929
2929
 
2930
2930
 
@@ -3141,8 +3141,8 @@ def get_model_catalog(user_tier: str = "all") -> dict:
3141
3141
  # ── Per-tier gating ────────────────────────────────────────────────────
3142
3142
  # Each plan has a `Plan.allowed_models` column ("free" | "all" | comma-list).
3143
3143
  # We use it to:
3144
- # 1) Free users → see `is_free` items and all APINEX models (exclusive to free tier)
3145
- # 2) Paid users (hobby, pro, max, ultimate, etc.) → APINEX models are hidden; paid models shown
3144
+ # 1) Free users → see `is_free` items and all APINEX models (free gateway models)
3145
+ # 2) Paid users (hobby, pro, max, ultimate, etc.) → paid models shown; APINEX models also available
3146
3146
  # 3) "all" sentinel / admin view → all models returned
3147
3147
  normalized_tier = (user_tier or "all").strip().lower()
3148
3148
  is_free_user = normalized_tier in ("free", "free_tier", "default")
@@ -3158,7 +3158,7 @@ def get_model_catalog(user_tier: str = "all") -> dict:
3158
3158
  if is_free_user:
3159
3159
  def _is_free_item(it: dict) -> bool:
3160
3160
  # Free users see:
3161
- # 1) items with provider == "apinex" (APINEX models are exclusively for free plan)
3161
+ # 1) items with provider == "apinex" (free gateway models, zero cost)
3162
3162
  # 2) standard free models (is_free or tier == "free" or ending with ":free")
3163
3163
  is_apx = (str(it.get("provider", "")).lower() == "apinex")
3164
3164
  return is_apx or bool(it.get("is_free")) or (it.get("tier") == "free") or str(it.get("model_id", "")).endswith(":free")
@@ -3167,10 +3167,7 @@ def get_model_catalog(user_tier: str = "all") -> dict:
3167
3167
  payload[key] = [it for it in payload[key] if _is_free_item(it)]
3168
3168
  else:
3169
3169
  # Non-free users:
3170
- # For paid plan tiers (hobby, pro, max, ultimate, enterprise), APINEX models must NOT show up
3171
- if not is_all_user:
3172
- for key in ("main_agent", "plan_project", "subagent_text", "analyze_image", "image_gen", "all_text"):
3173
- payload[key] = [it for it in payload[key] if str(it.get("provider", "")).lower() != "apinex"]
3170
+ # APINEX models are free gateway models (zero cost) — they remain available on paid plans too.
3174
3171
 
3175
3172
  # Paid plan: look up the Plan row's `allowed_models`
3176
3173
  try:
@@ -287,6 +287,8 @@ async def device_poll(device_code: str, request: Request, db: Session = Depends(
287
287
  if code.status == "authorized":
288
288
  # Return the api_key and burn the code immediately (one-time use)
289
289
  user = db.query(User).filter(User.id == code.user_id).first()
290
+ from ..auth import _resolve_user_plan_name
291
+ user_plan = _resolve_user_plan_name(user) if user else "free"
290
292
  result = {
291
293
  "status": "authorized",
292
294
  "api_key": code.api_key,
@@ -295,6 +297,8 @@ async def device_poll(device_code: str, request: Request, db: Session = Depends(
295
297
  "user_id": code.user_id,
296
298
  "firebase_uid": user.firebase_uid if user else "",
297
299
  "credits": user.credits.balance if user and user.credits else 0,
300
+ "plan": user_plan,
301
+ "is_subscribed": user_plan != "free",
298
302
  }
299
303
  # Invalidate: delete the row so this device_code can never be reused
300
304
  db.delete(code)
@@ -80,7 +80,7 @@ def is_model_allowed(model_id: str, allowed_models_str: str, bonus_balance: floa
80
80
  """Check if a model is allowed for the user's plan.
81
81
 
82
82
  If bonus_balance > 0 on any plan (including free), all models are unlocked.
83
- APINEX models are exclusively available on the Free plan and gated on paid plans.
83
+ APINEX models are free gateway models (zero cost) and are allowed on every plan.
84
84
  """
85
85
  allowed_tokens = [t.strip() for t in allowed_models_str.split(",")]
86
86
  is_free_plan = "free" in allowed_tokens and len(allowed_tokens) == 1
@@ -91,12 +91,9 @@ def is_model_allowed(model_id: str, allowed_models_str: str, bonus_balance: floa
91
91
  or (entry and getattr(entry, "provider", "").lower() == "apinex")
92
92
  )
93
93
 
94
- # 1. APINEX models: allowed on Free plan, gated on paid plans
94
+ # 1. APINEX models: free gateway models — allowed on ALL plans (free + paid)
95
95
  if is_apinex_model:
96
- if is_free_plan or "free" in allowed_tokens:
97
- return True
98
- # Paid plan: APINEX models are gated for paid users
99
- return False
96
+ return True
100
97
 
101
98
  if bonus_balance > 0.0:
102
99
  return True
@@ -204,12 +201,14 @@ def _effective_allowed_models(plan_name: str, plan_allowed_models: str) -> str:
204
201
  keep = ranks_for_plan.get(name, {0, 1, 2, 3, 4})
205
202
  out = []
206
203
  for mid, entry in (MODEL_REGISTRY or {}).items():
207
- # APINEX models are exclusively for free plan users; gate for paid plans
208
- if name != "free" and (getattr(entry, "provider", "").lower() == "apinex" or mid in APINEX_MODEL_IDS):
209
- continue
204
+ # APINEX models are free gateway models — included on every plan
210
205
  entry_rank = _MODEL_TIER_RANK.get(_model_tier_of(entry), 2) # default mid
211
206
  if entry_rank in keep:
212
207
  out.append(mid)
208
+ # Always append APINEX gateway models (zero-cost, free on all plans)
209
+ for ap_id in APINEX_MODEL_IDS:
210
+ if ap_id not in out:
211
+ out.append(ap_id)
213
212
  if out:
214
213
  return ",".join(out)
215
214
  except Exception:
@@ -1412,6 +1411,7 @@ def deduct_credits_unallocated_first(db: Session, user: User, credits_cost: floa
1412
1411
  if unallocated > 0.0:
1413
1412
  take = min(cost_to_charge, unallocated)
1414
1413
  sub_row.unallocated_deducted = unallocated_deducted + take
1414
+ charged_to_monthly += take
1415
1415
  cost_to_charge -= take
1416
1416
 
1417
1417
  # 3. credit.balance (Quota Bank / Rollover)