utim-cli 2.5.0__tar.gz → 2.5.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 (206) hide show
  1. {utim_cli-2.5.0 → utim_cli-2.5.2}/CHANGELOG.md +12 -0
  2. {utim_cli-2.5.0/utim_cli.egg-info → utim_cli-2.5.2}/PKG-INFO +1 -1
  3. {utim_cli-2.5.0 → utim_cli-2.5.2}/pyproject.toml +1 -1
  4. {utim_cli-2.5.0 → utim_cli-2.5.2}/utim/__init__.py +1 -1
  5. {utim_cli-2.5.0 → utim_cli-2.5.2}/utim_cli/__init__.py +1 -1
  6. {utim_cli-2.5.0 → utim_cli-2.5.2}/utim_cli/_version.py +1 -1
  7. {utim_cli-2.5.0 → utim_cli-2.5.2}/utim_cli/client_utils.py +91 -8
  8. {utim_cli-2.5.0 → utim_cli-2.5.2}/utim_cli/context_pruner.py +256 -40
  9. {utim_cli-2.5.0 → utim_cli-2.5.2}/utim_cli/orchestrator.py +110 -54
  10. {utim_cli-2.5.0 → utim_cli-2.5.2}/utim_cli/orchestrator_helpers.py +46 -3
  11. {utim_cli-2.5.0 → utim_cli-2.5.2}/utim_cli/orchestrator_llm_transport.py +20 -4
  12. {utim_cli-2.5.0 → utim_cli-2.5.2}/utim_cli/orchestrator_turn_history.py +88 -38
  13. {utim_cli-2.5.0 → utim_cli-2.5.2}/utim_cli/server/auth.py +11 -0
  14. {utim_cli-2.5.0 → utim_cli-2.5.2}/utim_cli/server/cli_auth.py +19 -2
  15. utim_cli-2.5.2/utim_cli/server/crypto_utils.py +219 -0
  16. {utim_cli-2.5.0 → utim_cli-2.5.2}/utim_cli/server/db.py +119 -17
  17. {utim_cli-2.5.0 → utim_cli-2.5.2}/utim_cli/server/firewall.py +4 -0
  18. {utim_cli-2.5.0 → utim_cli-2.5.2}/utim_cli/server/hobby_throttler.py +8 -8
  19. {utim_cli-2.5.0 → utim_cli-2.5.2}/utim_cli/server/models.py +312 -78
  20. {utim_cli-2.5.0 → utim_cli-2.5.2}/utim_cli/server/plan_integrity.py +94 -33
  21. {utim_cli-2.5.0 → utim_cli-2.5.2}/utim_cli/server/router.py +89 -70
  22. {utim_cli-2.5.0 → utim_cli-2.5.2}/utim_cli/server/routes/admin_providers_routes.py +61 -0
  23. {utim_cli-2.5.0 → utim_cli-2.5.2}/utim_cli/server/routes/admin_separated_models_routes.py +989 -719
  24. {utim_cli-2.5.0 → utim_cli-2.5.2}/utim_cli/server/routes/api_models_public_routes.py +14 -32
  25. {utim_cli-2.5.0 → utim_cli-2.5.2}/utim_cli/server/routes/completion_routes.py +438 -155
  26. {utim_cli-2.5.0 → utim_cli-2.5.2}/utim_cli/server/routes/credit_routes.py +139 -0
  27. {utim_cli-2.5.0 → utim_cli-2.5.2}/utim_cli/server/utils.py +871 -862
  28. utim_cli-2.5.2/utim_cli/tui/verbosity_dialog.py +221 -0
  29. {utim_cli-2.5.0 → utim_cli-2.5.2}/utim_cli/utim.py +39 -0
  30. {utim_cli-2.5.0 → utim_cli-2.5.2/utim_cli.egg-info}/PKG-INFO +1 -1
  31. {utim_cli-2.5.0 → utim_cli-2.5.2}/utim_cli.egg-info/SOURCES.txt +3 -1
  32. {utim_cli-2.5.0 → utim_cli-2.5.2}/LICENSE +0 -0
  33. {utim_cli-2.5.0 → utim_cli-2.5.2}/MANIFEST.in +0 -0
  34. {utim_cli-2.5.0 → utim_cli-2.5.2}/README.md +0 -0
  35. {utim_cli-2.5.0 → utim_cli-2.5.2}/setup.cfg +0 -0
  36. {utim_cli-2.5.0 → utim_cli-2.5.2}/setup.py +0 -0
  37. {utim_cli-2.5.0 → utim_cli-2.5.2}/utim_cli/_chip_test.py +0 -0
  38. {utim_cli-2.5.0 → utim_cli-2.5.2}/utim_cli/_verify_repro.py +0 -0
  39. {utim_cli-2.5.0 → utim_cli-2.5.2}/utim_cli/_verify_syntax.py +0 -0
  40. {utim_cli-2.5.0 → utim_cli-2.5.2}/utim_cli/_wizard_smoke_test.py +0 -0
  41. {utim_cli-2.5.0 → utim_cli-2.5.2}/utim_cli/agent.py +0 -0
  42. {utim_cli-2.5.0 → utim_cli-2.5.2}/utim_cli/architecture_indexer.py +0 -0
  43. {utim_cli-2.5.0 → utim_cli-2.5.2}/utim_cli/ask_helper.py +0 -0
  44. {utim_cli-2.5.0 → utim_cli-2.5.2}/utim_cli/auth.py +0 -0
  45. {utim_cli-2.5.0 → utim_cli-2.5.2}/utim_cli/backup.py +0 -0
  46. {utim_cli-2.5.0 → utim_cli-2.5.2}/utim_cli/billing.py +0 -0
  47. {utim_cli-2.5.0 → utim_cli-2.5.2}/utim_cli/blender_agent.py +0 -0
  48. {utim_cli-2.5.0 → utim_cli-2.5.2}/utim_cli/bootstrap.py +0 -0
  49. {utim_cli-2.5.0 → utim_cli-2.5.2}/utim_cli/brain.py +0 -0
  50. {utim_cli-2.5.0 → utim_cli-2.5.2}/utim_cli/builtin/skills/utim-guide/SKILL.md +0 -0
  51. {utim_cli-2.5.0 → utim_cli-2.5.2}/utim_cli/config.py +0 -0
  52. {utim_cli-2.5.0 → utim_cli-2.5.2}/utim_cli/constants.py +0 -0
  53. {utim_cli-2.5.0 → utim_cli-2.5.2}/utim_cli/context_inspector.py +0 -0
  54. {utim_cli-2.5.0 → utim_cli-2.5.2}/utim_cli/crawler/__init__.py +0 -0
  55. {utim_cli-2.5.0 → utim_cli-2.5.2}/utim_cli/crawler/engine.py +0 -0
  56. {utim_cli-2.5.0 → utim_cli-2.5.2}/utim_cli/crawler/markdown_distiller.py +0 -0
  57. {utim_cli-2.5.0 → utim_cli-2.5.2}/utim_cli/dependency_manager.py +0 -0
  58. {utim_cli-2.5.0 → utim_cli-2.5.2}/utim_cli/doctor.py +0 -0
  59. {utim_cli-2.5.0 → utim_cli-2.5.2}/utim_cli/evidence_registry.py +0 -0
  60. {utim_cli-2.5.0 → utim_cli-2.5.2}/utim_cli/harbor.py +0 -0
  61. {utim_cli-2.5.0 → utim_cli-2.5.2}/utim_cli/knowledge_graph.py +0 -0
  62. {utim_cli-2.5.0 → utim_cli-2.5.2}/utim_cli/local_db.py +0 -0
  63. {utim_cli-2.5.0 → utim_cli-2.5.2}/utim_cli/logger.py +0 -0
  64. {utim_cli-2.5.0 → utim_cli-2.5.2}/utim_cli/mcp_clean_wrapper.py +0 -0
  65. {utim_cli-2.5.0 → utim_cli-2.5.2}/utim_cli/mcp_client.py +0 -0
  66. {utim_cli-2.5.0 → utim_cli-2.5.2}/utim_cli/mcp_registry.json +0 -0
  67. {utim_cli-2.5.0 → utim_cli-2.5.2}/utim_cli/models.txt +0 -0
  68. {utim_cli-2.5.0 → utim_cli-2.5.2}/utim_cli/orchestrator_tool_exec.py +0 -0
  69. {utim_cli-2.5.0 → utim_cli-2.5.2}/utim_cli/personalities.py +0 -0
  70. {utim_cli-2.5.0 → utim_cli-2.5.2}/utim_cli/platform_installer.py +0 -0
  71. {utim_cli-2.5.0 → utim_cli-2.5.2}/utim_cli/plugins/__init__.py +0 -0
  72. {utim_cli-2.5.0 → utim_cli-2.5.2}/utim_cli/plugins/hooks.py +0 -0
  73. {utim_cli-2.5.0 → utim_cli-2.5.2}/utim_cli/plugins/loader.py +0 -0
  74. {utim_cli-2.5.0 → utim_cli-2.5.2}/utim_cli/plugins/manager.py +0 -0
  75. {utim_cli-2.5.0 → utim_cli-2.5.2}/utim_cli/plugins/registry.py +0 -0
  76. {utim_cli-2.5.0 → utim_cli-2.5.2}/utim_cli/plugins/runtime.py +0 -0
  77. {utim_cli-2.5.0 → utim_cli-2.5.2}/utim_cli/plugins/schema.py +0 -0
  78. {utim_cli-2.5.0 → utim_cli-2.5.2}/utim_cli/plugins/tool_loader.py +0 -0
  79. {utim_cli-2.5.0 → utim_cli-2.5.2}/utim_cli/reflection.py +0 -0
  80. {utim_cli-2.5.0 → utim_cli-2.5.2}/utim_cli/report.py +0 -0
  81. {utim_cli-2.5.0 → utim_cli-2.5.2}/utim_cli/scrapy_search.py +0 -0
  82. {utim_cli-2.5.0 → utim_cli-2.5.2}/utim_cli/sdk/__init__.py +0 -0
  83. {utim_cli-2.5.0 → utim_cli-2.5.2}/utim_cli/sdk/config.py +0 -0
  84. {utim_cli-2.5.0 → utim_cli-2.5.2}/utim_cli/sdk/context.py +0 -0
  85. {utim_cli-2.5.0 → utim_cli-2.5.2}/utim_cli/sdk/events.py +0 -0
  86. {utim_cli-2.5.0 → utim_cli-2.5.2}/utim_cli/sdk/handlers.py +0 -0
  87. {utim_cli-2.5.0 → utim_cli-2.5.2}/utim_cli/sdk/session.py +0 -0
  88. {utim_cli-2.5.0 → utim_cli-2.5.2}/utim_cli/sdk/sidecar.py +0 -0
  89. {utim_cli-2.5.0 → utim_cli-2.5.2}/utim_cli/server/__init__.py +0 -0
  90. {utim_cli-2.5.0 → utim_cli-2.5.2}/utim_cli/server/admin_auth.py +0 -0
  91. {utim_cli-2.5.0 → utim_cli-2.5.2}/utim_cli/server/api_batcher.py +0 -0
  92. {utim_cli-2.5.0 → utim_cli-2.5.2}/utim_cli/server/api_models_db.py +0 -0
  93. {utim_cli-2.5.0 → utim_cli-2.5.2}/utim_cli/server/attribution.py +0 -0
  94. {utim_cli-2.5.0 → utim_cli-2.5.2}/utim_cli/server/audit_log.py +0 -0
  95. {utim_cli-2.5.0 → utim_cli-2.5.2}/utim_cli/server/batch_processor.py +0 -0
  96. {utim_cli-2.5.0 → utim_cli-2.5.2}/utim_cli/server/captcha.py +0 -0
  97. {utim_cli-2.5.0 → utim_cli-2.5.2}/utim_cli/server/concurrency.py +0 -0
  98. {utim_cli-2.5.0 → utim_cli-2.5.2}/utim_cli/server/docs_md/SECRET_PROVISIONING.md +0 -0
  99. {utim_cli-2.5.0 → utim_cli-2.5.2}/utim_cli/server/docs_md/about.md +0 -0
  100. {utim_cli-2.5.0 → utim_cli-2.5.2}/utim_cli/server/docs_md/changelog.md +0 -0
  101. {utim_cli-2.5.0 → utim_cli-2.5.2}/utim_cli/server/docs_md/docs.md +0 -0
  102. {utim_cli-2.5.0 → utim_cli-2.5.2}/utim_cli/server/docs_md/features.md +0 -0
  103. {utim_cli-2.5.0 → utim_cli-2.5.2}/utim_cli/server/docs_md/license.md +0 -0
  104. {utim_cli-2.5.0 → utim_cli-2.5.2}/utim_cli/server/docs_md/pricing.md +0 -0
  105. {utim_cli-2.5.0 → utim_cli-2.5.2}/utim_cli/server/docs_md/privacy.md +0 -0
  106. {utim_cli-2.5.0 → utim_cli-2.5.2}/utim_cli/server/docs_md/refund.md +0 -0
  107. {utim_cli-2.5.0 → utim_cli-2.5.2}/utim_cli/server/docs_md/support.md +0 -0
  108. {utim_cli-2.5.0 → utim_cli-2.5.2}/utim_cli/server/docs_md/terms.md +0 -0
  109. {utim_cli-2.5.0 → utim_cli-2.5.2}/utim_cli/server/email_utils.py +0 -0
  110. {utim_cli-2.5.0 → utim_cli-2.5.2}/utim_cli/server/exchange_rate.py +0 -0
  111. {utim_cli-2.5.0 → utim_cli-2.5.2}/utim_cli/server/firebase.py +0 -0
  112. {utim_cli-2.5.0 → utim_cli-2.5.2}/utim_cli/server/fix_duplicate_users.py +0 -0
  113. {utim_cli-2.5.0 → utim_cli-2.5.2}/utim_cli/server/history.py +0 -0
  114. {utim_cli-2.5.0 → utim_cli-2.5.2}/utim_cli/server/key_rotator.py +0 -0
  115. {utim_cli-2.5.0 → utim_cli-2.5.2}/utim_cli/server/local_llm.py +0 -0
  116. {utim_cli-2.5.0 → utim_cli-2.5.2}/utim_cli/server/logging_config.py +0 -0
  117. {utim_cli-2.5.0 → utim_cli-2.5.2}/utim_cli/server/maintenance.py +0 -0
  118. {utim_cli-2.5.0 → utim_cli-2.5.2}/utim_cli/server/micro_batcher.py +0 -0
  119. {utim_cli-2.5.0 → utim_cli-2.5.2}/utim_cli/server/model_agent.py +0 -0
  120. {utim_cli-2.5.0 → utim_cli-2.5.2}/utim_cli/server/platform_billing.py +0 -0
  121. {utim_cli-2.5.0 → utim_cli-2.5.2}/utim_cli/server/pricing_updater.py +0 -0
  122. {utim_cli-2.5.0 → utim_cli-2.5.2}/utim_cli/server/provider_balance.py +0 -0
  123. {utim_cli-2.5.0 → utim_cli-2.5.2}/utim_cli/server/provider_discovery.py +0 -0
  124. {utim_cli-2.5.0 → utim_cli-2.5.2}/utim_cli/server/provider_router.py +0 -0
  125. {utim_cli-2.5.0 → utim_cli-2.5.2}/utim_cli/server/provision_build.py +0 -0
  126. {utim_cli-2.5.0 → utim_cli-2.5.2}/utim_cli/server/rate_limit.py +0 -0
  127. {utim_cli-2.5.0 → utim_cli-2.5.2}/utim_cli/server/request_batcher.py +0 -0
  128. {utim_cli-2.5.0 → utim_cli-2.5.2}/utim_cli/server/request_queue.py +0 -0
  129. {utim_cli-2.5.0 → utim_cli-2.5.2}/utim_cli/server/rewards_engine.py +0 -0
  130. {utim_cli-2.5.0 → utim_cli-2.5.2}/utim_cli/server/routes/__init__.py +0 -0
  131. {utim_cli-2.5.0 → utim_cli-2.5.2}/utim_cli/server/routes/admin_bonus_routes.py +0 -0
  132. {utim_cli-2.5.0 → utim_cli-2.5.2}/utim_cli/server/routes/admin_db_routes.py +0 -0
  133. {utim_cli-2.5.0 → utim_cli-2.5.2}/utim_cli/server/routes/api_gateway_extensions_routes.py +0 -0
  134. {utim_cli-2.5.0 → utim_cli-2.5.2}/utim_cli/server/routes/api_key_routes.py +0 -0
  135. {utim_cli-2.5.0 → utim_cli-2.5.2}/utim_cli/server/routes/api_models_admin_routes.py +0 -0
  136. {utim_cli-2.5.0 → utim_cli-2.5.2}/utim_cli/server/routes/auth_routes.py +0 -0
  137. {utim_cli-2.5.0 → utim_cli-2.5.2}/utim_cli/server/routes/billing_routes.py +0 -0
  138. {utim_cli-2.5.0 → utim_cli-2.5.2}/utim_cli/server/routes/feedback_routes.py +0 -0
  139. {utim_cli-2.5.0 → utim_cli-2.5.2}/utim_cli/server/routes/marketplace_routes.py +0 -0
  140. {utim_cli-2.5.0 → utim_cli-2.5.2}/utim_cli/server/routes/provider_compat_routes.py +0 -0
  141. {utim_cli-2.5.0 → utim_cli-2.5.2}/utim_cli/server/routes/quota_routes.py +0 -0
  142. {utim_cli-2.5.0 → utim_cli-2.5.2}/utim_cli/server/routes/quota_share_routes.py +0 -0
  143. {utim_cli-2.5.0 → utim_cli-2.5.2}/utim_cli/server/routes/referral_routes.py +0 -0
  144. {utim_cli-2.5.0 → utim_cli-2.5.2}/utim_cli/server/routes/rewards_routes.py +0 -0
  145. {utim_cli-2.5.0 → utim_cli-2.5.2}/utim_cli/server/routes/security_routes.py +0 -0
  146. {utim_cli-2.5.0 → utim_cli-2.5.2}/utim_cli/server/routes/session_routes.py +0 -0
  147. {utim_cli-2.5.0 → utim_cli-2.5.2}/utim_cli/server/routes/share_routes.py +0 -0
  148. {utim_cli-2.5.0 → utim_cli-2.5.2}/utim_cli/server/routes/wallet_routes.py +0 -0
  149. {utim_cli-2.5.0 → utim_cli-2.5.2}/utim_cli/server/routes/webhook_routes.py +0 -0
  150. {utim_cli-2.5.0 → utim_cli-2.5.2}/utim_cli/server/security_alerts.py +0 -0
  151. {utim_cli-2.5.0 → utim_cli-2.5.2}/utim_cli/server/server.py +0 -0
  152. {utim_cli-2.5.0 → utim_cli-2.5.2}/utim_cli/server/signup_guard.py +0 -0
  153. {utim_cli-2.5.0 → utim_cli-2.5.2}/utim_cli/server/storage_nodes.py +0 -0
  154. {utim_cli-2.5.0 → utim_cli-2.5.2}/utim_cli/share.py +0 -0
  155. {utim_cli-2.5.0 → utim_cli-2.5.2}/utim_cli/share_tui.py +0 -0
  156. {utim_cli-2.5.0 → utim_cli-2.5.2}/utim_cli/situational_scoring.py +0 -0
  157. {utim_cli-2.5.0 → utim_cli-2.5.2}/utim_cli/state.py +0 -0
  158. {utim_cli-2.5.0 → utim_cli-2.5.2}/utim_cli/subagent_manager.py +0 -0
  159. {utim_cli-2.5.0 → utim_cli-2.5.2}/utim_cli/syntax_fix.py +0 -0
  160. {utim_cli-2.5.0 → utim_cli-2.5.2}/utim_cli/task_dispatcher.py +0 -0
  161. {utim_cli-2.5.0 → utim_cli-2.5.2}/utim_cli/task_logger.py +0 -0
  162. {utim_cli-2.5.0 → utim_cli-2.5.2}/utim_cli/tools.py +0 -0
  163. {utim_cli-2.5.0 → utim_cli-2.5.2}/utim_cli/tools_grep.py +0 -0
  164. {utim_cli-2.5.0 → utim_cli-2.5.2}/utim_cli/tui/__init__.py +0 -0
  165. {utim_cli-2.5.0 → utim_cli-2.5.2}/utim_cli/tui/compression_dialog.py +0 -0
  166. {utim_cli-2.5.0 → utim_cli-2.5.2}/utim_cli/tui/feedback_dialog.py +0 -0
  167. {utim_cli-2.5.0 → utim_cli-2.5.2}/utim_cli/tui/history_dialog.py +0 -0
  168. {utim_cli-2.5.0 → utim_cli-2.5.2}/utim_cli/tui/marketplace_app_state.py +0 -0
  169. {utim_cli-2.5.0 → utim_cli-2.5.2}/utim_cli/tui/marketplace_dialog.py +0 -0
  170. {utim_cli-2.5.0 → utim_cli-2.5.2}/utim_cli/tui/marketplace_layout_engine.py +0 -0
  171. {utim_cli-2.5.0 → utim_cli-2.5.2}/utim_cli/tui/mcp_dialog.py +0 -0
  172. {utim_cli-2.5.0 → utim_cli-2.5.2}/utim_cli/tui/miniagents_dialog.py +0 -0
  173. {utim_cli-2.5.0 → utim_cli-2.5.2}/utim_cli/tui/model_dialog.py +0 -0
  174. {utim_cli-2.5.0 → utim_cli-2.5.2}/utim_cli/tui/personality_dialog.py +0 -0
  175. {utim_cli-2.5.0 → utim_cli-2.5.2}/utim_cli/tui/plugins_dialog.py +0 -0
  176. {utim_cli-2.5.0 → utim_cli-2.5.2}/utim_cli/tui/publish_dialog.py +0 -0
  177. {utim_cli-2.5.0 → utim_cli-2.5.2}/utim_cli/tui/quota_dialog.py +0 -0
  178. {utim_cli-2.5.0 → utim_cli-2.5.2}/utim_cli/tui/quota_redeem_dialog.py +0 -0
  179. {utim_cli-2.5.0 → utim_cli-2.5.2}/utim_cli/tui/quota_share_dialog.py +0 -0
  180. {utim_cli-2.5.0 → utim_cli-2.5.2}/utim_cli/tui/resume_dialog.py +0 -0
  181. {utim_cli-2.5.0 → utim_cli-2.5.2}/utim_cli/tui/rewards_tui.py +0 -0
  182. {utim_cli-2.5.0 → utim_cli-2.5.2}/utim_cli/tui/skills_dialog.py +0 -0
  183. {utim_cli-2.5.0 → utim_cli-2.5.2}/utim_cli/tui/subagents_dialog.py +0 -0
  184. {utim_cli-2.5.0 → utim_cli-2.5.2}/utim_cli/tui/tasks_dialog.py +0 -0
  185. {utim_cli-2.5.0 → utim_cli-2.5.2}/utim_cli/tui/thinking_display.py +0 -0
  186. {utim_cli-2.5.0 → utim_cli-2.5.2}/utim_cli/tui/tools_dialog.py +0 -0
  187. {utim_cli-2.5.0 → utim_cli-2.5.2}/utim_cli/tui/update_dialog.py +0 -0
  188. {utim_cli-2.5.0 → utim_cli-2.5.2}/utim_cli/update_checker.py +0 -0
  189. {utim_cli-2.5.0 → utim_cli-2.5.2}/utim_cli/utilities.py +0 -0
  190. {utim_cli-2.5.0 → utim_cli-2.5.2}/utim_cli/utimmodel.txt +0 -0
  191. {utim_cli-2.5.0 → utim_cli-2.5.2}/utim_cli/vector_memory.py +0 -0
  192. {utim_cli-2.5.0 → utim_cli-2.5.2}/utim_cli/voice.py +0 -0
  193. {utim_cli-2.5.0 → utim_cli-2.5.2}/utim_cli/voice_coding.py +0 -0
  194. {utim_cli-2.5.0 → utim_cli-2.5.2}/utim_cli/voice_events.py +0 -0
  195. {utim_cli-2.5.0 → utim_cli-2.5.2}/utim_cli/voice_handler.py +0 -0
  196. {utim_cli-2.5.0 → utim_cli-2.5.2}/utim_cli/voice_instant.py +0 -0
  197. {utim_cli-2.5.0 → utim_cli-2.5.2}/utim_cli/voice_orchestrator.py +0 -0
  198. {utim_cli-2.5.0 → utim_cli-2.5.2}/utim_cli/voice_session.py +0 -0
  199. {utim_cli-2.5.0 → utim_cli-2.5.2}/utim_cli/voice_stt.py +0 -0
  200. {utim_cli-2.5.0 → utim_cli-2.5.2}/utim_cli/voice_tts.py +0 -0
  201. {utim_cli-2.5.0 → utim_cli-2.5.2}/utim_cli/wheel.py +0 -0
  202. {utim_cli-2.5.0 → utim_cli-2.5.2}/utim_cli/workspace.py +0 -0
  203. {utim_cli-2.5.0 → utim_cli-2.5.2}/utim_cli.egg-info/dependency_links.txt +0 -0
  204. {utim_cli-2.5.0 → utim_cli-2.5.2}/utim_cli.egg-info/entry_points.txt +0 -0
  205. {utim_cli-2.5.0 → utim_cli-2.5.2}/utim_cli.egg-info/requires.txt +0 -0
  206. {utim_cli-2.5.0 → utim_cli-2.5.2}/utim_cli.egg-info/top_level.txt +0 -0
@@ -1,5 +1,17 @@
1
1
  # Changelog
2
2
 
3
+ ## [2.5.2] - 2026-10-18
4
+
5
+ ### 🐛 Fixes
6
+ - **Paid Plan Model Gating & Aliases**: Fixed preflight model registry check in `completion_routes.py` so paid subscribers (Hobby/Pro/Max/Ultra) are never blocked by free-plan error notices. Added `ag/` prefix stripping, separated model tables lookup, and canonical OpenRouter routing for `ag/gemini-3.8-flash-high`.
7
+
8
+ ## [2.5.1] - 2026-10-18
9
+
10
+ ### 🐛 Fixes
11
+ - **Compression Empty-Response Spam**: `_call_compression_model_with_fallback` now always sets `last_error` on empty/stripped content and logs `model=… routing=… empty_hits=…` diagnostics — eliminates the endless `[COMPRESSION FALLBACK FAILURE] Last error: None` loop and surfaces the real reason (429 / empty content / bad shape).
12
+ - **Admin Portal CORS**: `router.py` now always allows `Origin: null` (downloaded `file://` HTML) and `allow_headers=["*"]` + `max_age=600` so `X-UTIM-Master-Key` / `Authorization` preflights for `/api/admin/*` and `/models` no longer fail with `No 'Access-Control-Allow-Origin'`.
13
+ - **Admin Firewall Bypass**: `firewall.py: _FIREWALL_SKIP_PREFIXES` and `cli_auth.py: PUBLIC_PREFIXES` now include `/admin`, `/admin.html`, `/admin_models.html`, `/static` so the browser-served admin panel bypasses proxy-abuse / burst throttling and CLI HMAC `401` (API auth via master-key session is still enforced).
14
+
3
15
  ## [2.5.0] - 2026-10-18
4
16
 
5
17
  ### ✨ Credit System → Unlimited UX + Internal Throttling
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: utim-cli
3
- Version: 2.5.0
3
+ Version: 2.5.2
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.5.0"
7
+ version = "2.5.2"
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.5.0"
17
+ __version__ = "2.5.2"
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.5.0"
8
+ __version__ = "2.5.2"
9
9
 
10
10
 
11
11
  import builtins, os, pathlib, urllib.parse, re
@@ -11,6 +11,6 @@
11
11
  # from here so every surface stays in sync automatically.
12
12
  # ─────────────────────────────────────────────────────────────────────────────
13
13
 
14
- VERSION = "2.5.0"
14
+ VERSION = "2.5.2"
15
15
 
16
16
 
@@ -166,7 +166,7 @@ def get_server_model_catalog(timeout: float = 5.0) -> dict | None:
166
166
  )
167
167
  if resp.status_code == 200:
168
168
  data = resp.json()
169
- if isinstance(data, dict) and data:
169
+ if isinstance(data, dict):
170
170
  _catalog_cache = data
171
171
  _catalog_cache_ts = now
172
172
  return _catalog_cache
@@ -201,7 +201,7 @@ def fetch_remote_models_catalog(timeout: float = 3.0) -> dict:
201
201
  resp = requests.get(url, timeout=timeout)
202
202
  if resp.status_code == 200:
203
203
  models_list = resp.json()
204
- if models_list and isinstance(models_list, list):
204
+ if isinstance(models_list, list):
205
205
  from utim_cli.constants import APINEX_MODEL_IDS
206
206
  valid_models = [
207
207
  m for m in models_list
@@ -261,7 +261,7 @@ def fetch_remote_models_catalog(timeout: float = 3.0) -> dict:
261
261
  resp = requests.get(url, params=params, headers=get_auth_headers(), timeout=timeout)
262
262
  if resp.status_code == 200:
263
263
  data = resp.json()
264
- if data and isinstance(data, dict):
264
+ if isinstance(data, dict):
265
265
  from utim_cli.constants import APINEX_MODEL_IDS
266
266
  filtered_cat = {
267
267
  k: [
@@ -281,7 +281,7 @@ def fetch_remote_models_catalog(timeout: float = 3.0) -> dict:
281
281
  try:
282
282
  from utim_cli.server.models import get_model_catalog
283
283
  data = get_model_catalog()
284
- if data:
284
+ if isinstance(data, dict):
285
285
  _REMOTE_CATALOG_CACHE = data
286
286
  _REMOTE_CATALOG_CACHE_TIME = now
287
287
  return data
@@ -312,7 +312,7 @@ def _build_free_models_fallback_chain(now: float) -> list[str]:
312
312
  resp = _req.get(f"{SERVER_URL.rstrip('/')}/models", timeout=3.0)
313
313
  if resp.status_code == 200:
314
314
  models_list = resp.json()
315
- if models_list and isinstance(models_list, list):
315
+ if isinstance(models_list, list):
316
316
  from utim_cli.constants import APINEX_MODEL_IDS
317
317
  for m in models_list:
318
318
  mid = str(m.get("model_id") or "")
@@ -407,6 +407,86 @@ def resolve_model_provider_endpoint(model_id: Optional[str]) -> Optional[Dict[st
407
407
  except Exception:
408
408
  pass
409
409
 
410
+ # 1b. Server ModelEndpointDB and Separated Models (configured via admin_models.html)
411
+ try:
412
+ from utim_cli.server.db import SessionLocal, ModelEndpointDB, SEPARATED_MODEL_CLASSES
413
+ sdb = SessionLocal()
414
+ try:
415
+ cand_ids = {clean_id, clean_id.lower()}
416
+ stripped = clean_id
417
+ for pref in ("am/", "api/", "ag/"):
418
+ if clean_id.startswith(pref):
419
+ stripped = clean_id[len(pref):].strip()
420
+ break
421
+ cand_ids.add(stripped)
422
+ cand_ids.add(stripped.lower())
423
+ for pref in ("am/", "api/", "ag/"):
424
+ cand_ids.add(f"{pref}{stripped}")
425
+ cand_ids.add(f"{pref}{stripped}".lower())
426
+ if "/" in stripped:
427
+ base_part = stripped.split("/")[-1].strip()
428
+ cand_ids.add(base_part)
429
+ cand_ids.add(base_part.lower())
430
+ for pref in ("am/", "api/", "ag/"):
431
+ cand_ids.add(f"{pref}{base_part}")
432
+ cand_ids.add(f"{pref}{base_part}".lower())
433
+ if "/" in clean_id:
434
+ base_raw = clean_id.split("/")[-1].strip()
435
+ cand_ids.add(base_raw)
436
+ cand_ids.add(base_raw.lower())
437
+ for pref in ("am/", "api/", "ag/"):
438
+ cand_ids.add(f"{pref}{base_raw}")
439
+ cand_ids.add(f"{pref}{base_raw}".lower())
440
+
441
+ ep = sdb.query(ModelEndpointDB).filter(
442
+ ModelEndpointDB.model_id.in_(cand_ids),
443
+ (ModelEndpointDB.is_active == True) | (ModelEndpointDB.is_active.is_(None)),
444
+ ModelEndpointDB.endpoint_url.isnot(None),
445
+ ModelEndpointDB.endpoint_url != ""
446
+ ).order_by(ModelEndpointDB.priority.asc()).first()
447
+ if ep and ep.endpoint_url:
448
+ base = ep.endpoint_url.rstrip("/")
449
+ url = base if base.endswith("/chat/completions") else f"{base}/chat/completions"
450
+ keys = ep.get_api_keys()
451
+ key = keys[0] if keys else ""
452
+ raw_up = (getattr(ep, "upstream_model_id", None) or "").strip()
453
+ target_m = raw_up if raw_up else (ep.model_id or clean_id)
454
+ return {
455
+ "url": url,
456
+ "base_url": base,
457
+ "api_key": key,
458
+ "auth_style": "bearer",
459
+ "target_model": target_m,
460
+ "provider_name": ep.provider_id or "custom",
461
+ }
462
+
463
+ for s_cls in SEPARATED_MODEL_CLASSES:
464
+ m_row = sdb.query(s_cls).filter(
465
+ s_cls.model_id.in_(cand_ids),
466
+ (s_cls.is_active == True) | (s_cls.is_active.is_(None)),
467
+ s_cls.endpoint_url.isnot(None),
468
+ s_cls.endpoint_url != ""
469
+ ).first()
470
+ if m_row and getattr(m_row, "endpoint_url", None):
471
+ base = m_row.endpoint_url.rstrip("/")
472
+ url = base if base.endswith("/chat/completions") else f"{base}/chat/completions"
473
+ keys = m_row.get_api_keys() if hasattr(m_row, "get_api_keys") else []
474
+ key = keys[0] if keys else ""
475
+ raw_up = (getattr(m_row, "upstream_model_id", None) or "").strip()
476
+ target_m = raw_up if raw_up else (m_row.model_id or clean_id)
477
+ return {
478
+ "url": url,
479
+ "base_url": base,
480
+ "api_key": key,
481
+ "auth_style": "bearer",
482
+ "target_model": target_m,
483
+ "provider_name": getattr(m_row, "provider", "custom") or "custom",
484
+ }
485
+ finally:
486
+ sdb.close()
487
+ except Exception:
488
+ pass
489
+
410
490
  # 2. Server ApiModel database (upstream provider attached in admin_models.html)
411
491
  try:
412
492
  from utim_cli.server.api_models_db import ApiSessionLocal, ApiModel, find_canonical_model
@@ -902,12 +982,15 @@ def sanitize_tools_for_api(tools: Optional[List[Dict[str, Any]]], model_id: Opti
902
982
  if k in disallowed_keys:
903
983
  continue
904
984
 
905
- # Standardize JSON schema type names to lowercase
985
+ _valid = {"string", "number", "integer", "boolean", "array", "object", "null"}
906
986
  if k == "type":
907
987
  if isinstance(v, str):
908
- v = TYPE_MAP.get(v, v.lower())
988
+ _t = TYPE_MAP.get(v, v.lower())
989
+ v = _t if _t in _valid else "string"
909
990
  elif isinstance(v, list):
910
- v = [TYPE_MAP.get(item, item.lower()) if isinstance(item, str) else item for item in v]
991
+ _lst = [TYPE_MAP.get(item, item.lower()) if isinstance(item, str) else item for item in v]
992
+ _lst = [x for x in _lst if x in _valid]
993
+ v = _lst[0] if len(_lst)==1 else (_lst if _lst else "string")
911
994
 
912
995
  if isinstance(v, dict):
913
996
  cleaned[k] = _clean_schema_node(v)
@@ -1883,15 +1883,45 @@ def is_continuation_prompt(prompt_or_msg: Any) -> bool:
1883
1883
  return False
1884
1884
 
1885
1885
 
1886
+ class _HybridHistory(list):
1887
+ """Backward-compatible dual view: behaves as List[Dict] for new pipeline
1888
+ and as Dict {"role":"user","content":...} for legacy tests/detectors.
1889
+
1890
+ New orchestrator code treats the result as a list of native messages
1891
+ (preserves tool/assistant schemas). Legacy tests index with ["content"] to
1892
+ fetch a single squashed SESSION HISTORY artifact.
1893
+ """
1894
+ def __init__(self, messages: List[Dict], squashed_text: str):
1895
+ super().__init__(messages)
1896
+ self._squashed_text = squashed_text
1897
+ self._dict_view = {"role": "user", "content": squashed_text}
1898
+ def __getitem__(self, key):
1899
+ if isinstance(key, str):
1900
+ return self._dict_view[key]
1901
+ return super().__getitem__(key)
1902
+ def get(self, key, default=None):
1903
+ return self._dict_view.get(key, default)
1904
+ def __contains__(self, key):
1905
+ # str containment should check dict keys when key is str and length short,
1906
+ # otherwise fall back to list membership.
1907
+ if isinstance(key, str) and key in ("role", "content"):
1908
+ return True
1909
+ return super().__contains__(key)
1910
+
1911
+
1886
1912
  def build_compacted_history_message(past_messages: List[Dict]) -> List[Dict]:
1887
- """Structurally compress all previous turns while maintaining native tool/assistant schemas.
1888
-
1889
- This fulfills the original architecture design: response-end compression is controlled by
1890
- compression levels, but operates purely structurally, never squashing context into a single
1891
- massive user message.
1913
+ """Progressive structural compression + legacy squashed artifact.
1914
+
1915
+ Returns a _HybridHistory which is simultaneously:
1916
+ - a List[Dict] of native messages (progressively compressed, newest most
1917
+ detail, oldest most compressed) for the efficient pipeline, and
1918
+ - a dict-like {"role":"user","content": squashed_SESSION_HISTORY} for
1919
+ legacy expectations and single-message fallback paths.
1920
+ This keeps context loss at zero (prompts verbatim, tool outputs ratio-
1921
+ compressed) while never sending the full unbounded history.
1892
1922
  """
1893
1923
  if not past_messages:
1894
- return []
1924
+ return _HybridHistory([], "")
1895
1925
 
1896
1926
  # Seed _COMPRESSION_PARAMS from config on the very first call
1897
1927
  if _COMPRESSION_PARAMS.get("level_id") == "medium":
@@ -1934,7 +1964,7 @@ def build_compacted_history_message(past_messages: List[Dict]) -> List[Dict]:
1934
1964
  turns.append((current_user_prompt, current_turn_msgs))
1935
1965
 
1936
1966
  if not turns:
1937
- return []
1967
+ return _HybridHistory([], "")
1938
1968
 
1939
1969
  ind_total = len(turns)
1940
1970
  structurally_compressed_history: List[Dict] = []
@@ -1974,10 +2004,58 @@ def build_compacted_history_message(past_messages: List[Dict]) -> List[Dict]:
1974
2004
  tname = m_copy.get("name", "tool")
1975
2005
  if len(content) > 300:
1976
2006
  m_copy["content"] = compress_tool_content_by_ratio(content, keep_ratio=keep_ratio, tool_name=tname)
1977
-
2007
+
1978
2008
  structurally_compressed_history.append(m_copy)
1979
2009
 
1980
- return structurally_compressed_history
2010
+ # ── Build legacy squashed artifact for tests / fallback detectors ──────
2011
+ level_id = _COMPRESSION_PARAMS.get("level_id", "medium")
2012
+ level_label = level_id.upper().replace("_", " ") # VERY LOW / VERY HIGH etc.
2013
+ merge_count = int(_COMPRESSION_PARAMS.get("merge_count", 5))
2014
+ squashed_lines: List[str] = []
2015
+ # Header must contain the level label verbatim for test detection
2016
+ squashed_lines.append(f"### SESSION HISTORY & PREVIOUS MILESTONES — {level_label} — {ind_total} turns")
2017
+
2018
+ # Merged earliest turns block (when merge_count > 0)
2019
+ remaining_start = 0
2020
+ if merge_count > 0 and ind_total > 0:
2021
+ effective_merge = min(merge_count, ind_total) if ind_total <= merge_count else merge_count
2022
+ # For the 2-turn continuation case we keep at least one newest turn separate
2023
+ if ind_total == 2 and merge_count >= 2:
2024
+ effective_merge = 1
2025
+ if effective_merge > 0:
2026
+ try:
2027
+ squashed_lines.append(_merge_oldest_turns(turns, effective_merge))
2028
+ except Exception:
2029
+ # Fallback simple merge line if helper fails
2030
+ squashed_lines.append("[Merged earliest turns — oldest context, highest compression]")
2031
+ remaining_start = effective_merge
2032
+
2033
+ # Per-turn summaries for remaining (or all if no merge) — ensures Turn N markers
2034
+ for idx in range(remaining_start, ind_total):
2035
+ prompt, msgs = turns[idx]
2036
+ # Preserve prompt verbatim inside quotes — continuation prompts were already folded out
2037
+ clean_prompt = re.sub(r"^\[status:\s*(?:done|pending|aborted)\]\s*", "", prompt.strip(), flags=re.IGNORECASE)
2038
+ # One-line outcome summary
2039
+ try:
2040
+ outcome = summarize_single_turn_outcome(msgs, tier="low")
2041
+ except Exception:
2042
+ # Fallback: join assistant/tool snippets
2043
+ parts = []
2044
+ for m in msgs[:4]:
2045
+ c = m.get("content") or ""
2046
+ if isinstance(c, list):
2047
+ c = " ".join(p.get("text","") for p in c if isinstance(p, dict))
2048
+ c = str(c).strip()
2049
+ if c:
2050
+ parts.append(c[:120])
2051
+ outcome = " | ".join(parts) if parts else "completed"
2052
+ outcome = outcome.replace("\n", " ").strip()
2053
+ if len(outcome) > 260:
2054
+ outcome = outcome[:257] + "…"
2055
+ squashed_lines.append(f"Turn {idx+1}: \"{clean_prompt}\" → {outcome}")
2056
+
2057
+ squashed_text = "\n".join(squashed_lines)
2058
+ return _HybridHistory(structurally_compressed_history, squashed_text)
1981
2059
 
1982
2060
 
1983
2061
 
@@ -2133,6 +2211,17 @@ def score_message_importance(message: Dict, llm_key: str = None) -> float:
2133
2211
  return max(min_floor, min(1.0, score))
2134
2212
 
2135
2213
 
2214
+ def _is_valid_custom_entry(entry: Any) -> bool:
2215
+ """Validate that a custom/BYOK entry has a usable base_url."""
2216
+ if not isinstance(entry, dict):
2217
+ return False
2218
+ base = str(entry.get("base_url") or "").strip()
2219
+ if not base:
2220
+ return False
2221
+ low = base.lower()
2222
+ return low.startswith("http://") or low.startswith("https://")
2223
+
2224
+
2136
2225
  def _resolve_compression_routing(model_id: str) -> str:
2137
2226
  """Mirror LLMTransportMixin routing so compression hits the same endpoint as the main agent.
2138
2227
 
@@ -2144,20 +2233,42 @@ def _resolve_compression_routing(model_id: str) -> str:
2144
2233
  source set by ``/model`` and used by ``LLMTransportMixin._call_llm``),
2145
2234
  then falls back to persisted ``main_model_source`` so background callers
2146
2235
  without an Orchestrator instance still get the right answer.
2236
+ FIX: Validates custom entries have a real base_url — bare claude-opus-5.5
2237
+ with a broken/empty custom entry must NOT be forced to "custom".
2147
2238
  """
2148
2239
  try:
2149
2240
  from utim_cli.config import config as _cfg
2150
2241
  from utim_cli.client_utils import is_byok_or_custom_model as _is_byok
2151
- # 1. Custom / BYOK — must go direct to its base_url
2152
- if _is_byok(model_id):
2153
- return "custom"
2242
+ # 1. Custom / BYOK — must have valid base_url, otherwise fall through
2243
+ _is_byok_flag = False
2244
+ try:
2245
+ _is_byok_flag = bool(_is_byok(model_id))
2246
+ except Exception:
2247
+ _is_byok_flag = False
2248
+ if _is_byok_flag:
2249
+ _m_lower = str(model_id or "").lower()
2250
+ # Explicit prefix (byok/, custom/, local/, ollama/ etc.) → always custom
2251
+ if _m_lower.startswith(("byok/", "custom/", "custom-", "local/", "ollama/", "lmstudio/", "vllm/")):
2252
+ return "custom"
2253
+ # Config-based BYOK — only custom if entry has a usable base_url
2254
+ try:
2255
+ _entry = _cfg.get_custom_model(model_id)
2256
+ if _is_valid_custom_entry(_entry):
2257
+ return "custom"
2258
+ except Exception:
2259
+ pass
2260
+ # Invalid/empty custom entry → fall through to utim/openrouter
2154
2261
  # Dedicated external providers (vyceai/apinex/atria) are always direct
2155
2262
  _low = str(model_id or "").lower()
2156
2263
  _pref = _low.split("/")[0] if "/" in _low else _low
2157
2264
  if _pref in ("vyceai", "apinex", "atria") or _low.startswith(("vyceai", "apinex", "atria")):
2158
2265
  return "custom"
2159
- if _cfg.get_custom_model(model_id) is not None:
2160
- return "custom"
2266
+ try:
2267
+ _entry2 = _cfg.get_custom_model(model_id)
2268
+ if _is_valid_custom_entry(_entry2):
2269
+ return "custom"
2270
+ except Exception:
2271
+ pass
2161
2272
  # 2. Per-model source recorded by the picker (mirrors _get_configured_model_source)
2162
2273
  # Prefer the live orchestrator value (authoritative) over persisted config
2163
2274
  src = None
@@ -2213,7 +2324,6 @@ def _call_compression_model_with_fallback(messages: List[Dict], llm_key: str, ma
2213
2324
  return "[Content previously summarized - skipped]"
2214
2325
  _summarized_content_hashes.add(content_hash)
2215
2326
 
2216
- # Model fallback architecture removed: use only primary model
2217
2327
  if not primary_model:
2218
2328
  try:
2219
2329
  from utim_cli.config import config
@@ -2223,21 +2333,26 @@ def _call_compression_model_with_fallback(messages: List[Dict], llm_key: str, ma
2223
2333
  if not primary_model:
2224
2334
  primary_model = "nvidia/nemotron-3.5-lightning:free"
2225
2335
 
2336
+ from utim_cli.constants import DEFAULT_MODEL as _DEFAULT_FALLBACK_MODEL
2226
2337
  models_to_try = [primary_model]
2227
-
2228
- # Pre-resolve routing once so every retry uses the same provider
2229
- _routing = _resolve_compression_routing(primary_model)
2338
+ # Lightweight fallback chain: if primary (e.g. custom claude-opus-5.5) returns
2339
+ # empty content twice, try a cheap free model instead of hard-failing.
2340
+ # Deduplicate strictly — DEFAULT_MODEL may equal primary_model.
2341
+ for _fb in (_DEFAULT_FALLBACK_MODEL, "nvidia/nemotron-3.5-lightning:free", "openrouter/free"):
2342
+ if _fb and _fb not in models_to_try:
2343
+ models_to_try.append(_fb)
2344
+ if len(models_to_try) >= 3:
2345
+ break
2230
2346
 
2231
2347
  last_error = None
2348
+ empty_hits = 0
2232
2349
  for i, model in enumerate(models_to_try):
2350
+ _routing = _resolve_compression_routing(model)
2351
+ _empty_hits_for_model = 0
2233
2352
  model_retries = 1 if model == primary_model else 2
2234
2353
  for attempt in range(model_retries + 1):
2235
2354
  try:
2236
2355
  from utim_cli.client_utils import proxy_openrouter_request
2237
- # _routing is injected so proxy_openrouter_request does not
2238
- # re-heuristic to the wrong provider. We pass it via a
2239
- # side-channel key that proxy recognises and strips before
2240
- # forwarding (``_force_source``).
2241
2356
  payload = {
2242
2357
  "model": model,
2243
2358
  "messages": messages,
@@ -2251,56 +2366,157 @@ def _call_compression_model_with_fallback(messages: List[Dict], llm_key: str, ma
2251
2366
  is_reflection=False
2252
2367
  )
2253
2368
  if resp.status_code == 429:
2254
- # On 429 rate limit, immediately try fallback without blocking
2369
+ last_error = f"Model {model} rate limited (429)"
2255
2370
  break
2256
2371
  resp.raise_for_status()
2257
-
2372
+
2258
2373
  if resp.status_code == 200:
2259
- result = resp.json()["choices"][0]["message"]["content"]
2260
- if not result or not result.strip():
2374
+ try:
2375
+ body = resp.json()
2376
+ except Exception as je:
2377
+ last_error = f"Model {model} returned non-JSON 200: {je}"
2378
+ break
2379
+ try:
2380
+ result = body["choices"][0]["message"]["content"]
2381
+ except Exception as ke:
2382
+ last_error = f"Model {model} bad 200 shape {list(body.keys()) if isinstance(body, dict) else type(body).__name__}: {ke} body={str(body)[:600]}"
2383
+ break
2384
+ if not result or not str(result).strip():
2385
+ empty_hits += 1
2386
+ _empty_hits_for_model += 1
2387
+ last_error = f"Model {model} returned empty content (hit {_empty_hits_for_model}/2)"
2388
+ if _empty_hits_for_model >= 2:
2389
+ break
2261
2390
  continue
2262
2391
  # Strip thinking tags
2263
- result = re.sub(r"<think(?:ing)?>.*?</think(?:ing)?>", "", result, flags=re.DOTALL)
2392
+ result = re.sub(r"<think(?:ing)?>.*?</think(?:ing)?>", "", str(result), flags=re.DOTALL)
2264
2393
  result = result.strip()
2265
2394
  if not result:
2395
+ empty_hits += 1
2396
+ _empty_hits_for_model += 1
2397
+ last_error = f"Model {model} content empty after stripping thinking tags (hit {_empty_hits_for_model}/2)"
2398
+ if _empty_hits_for_model >= 2:
2399
+ break
2266
2400
  continue
2267
-
2401
+
2268
2402
  # FIX #1: Hallucination detection - reject summaries with suspicious patterns
2403
+ hallucinated = False
2269
2404
  hallucination_patterns = [
2270
2405
  r"\[.*added.*\]", # Made-up additions like "[feature added]"
2271
2406
  r"\[.*created.*\]", # Made-up creations
2272
2407
  r"I have (?:created|modified|deleted|implemented)", # False claims of action
2273
2408
  r"Successfully (?:added|removed|integrated)", # False success claims
2274
2409
  ]
2275
-
2276
- # Check if summary makes claims not in source
2277
2410
  for pattern in hallucination_patterns:
2278
2411
  if re.search(pattern, result, re.I) and content_hint:
2279
- # Check if the claim is actually in the original content
2280
2412
  if not re.search(pattern.replace(r"\[", r"[^[]*").replace(r"\]", r"[^]]*"), content_hint, re.I):
2281
2413
  print(f"[HALLUCINATION PREVENTED] Pattern '{pattern}' detected in summary but not in source", file=__import__('sys'))
2282
- # Try next model instead of returning bad summary
2283
- continue
2284
-
2414
+ last_error = f"Model {model} hallucination rejected pattern={pattern}"
2415
+ hallucinated = True
2416
+ break
2417
+ if hallucinated:
2418
+ continue
2419
+
2285
2420
  return result
2286
-
2287
- last_error = f"Model {model} returned status {resp.status_code}"
2421
+
2422
+ last_error = f"Model {model} returned status {resp.status_code}: {str(getattr(resp, 'content_str', '') or '')[:600]}"
2288
2423
  break # Not 200 and not 429, break attempt loop and try next model
2289
2424
 
2290
2425
  except requests.exceptions.HTTPError as e:
2291
2426
  code = e.response.status_code if e.response is not None else 0
2427
+ body_snip = ""
2428
+ try:
2429
+ body_snip = (e.response.text[:600] if getattr(e, "response", None) is not None and hasattr(e.response, "text") else str(e)[:600])
2430
+ except Exception:
2431
+ body_snip = str(e)[:600]
2432
+ last_error = f"HTTPError {code} model={model}: {body_snip}"
2292
2433
  if code == 429 and attempt < model_retries:
2293
2434
  import time
2294
2435
  time.sleep(5 * (attempt + 1))
2295
2436
  continue
2296
- last_error = e
2297
2437
  break
2298
2438
  except Exception as e:
2299
- last_error = e
2439
+ last_error = f"{type(e).__name__} model={model}: {e}"
2300
2440
  break
2301
-
2302
- # Log the failure for debugging
2441
+
2442
+ # ── Cross-routing retry for custom primary that returned empty twice ─────
2443
+ # If primary was custom and emptied, retry same model via openrouter once
2444
+ # before giving up — covers broken/empty custom base_url case.
2445
+ if empty_hits >= 2 and last_error and "empty content" in last_error.lower():
2446
+ try:
2447
+ _primary_routing = _resolve_compression_routing(primary_model)
2448
+ except Exception:
2449
+ _primary_routing = "custom"
2450
+ if _primary_routing == "custom":
2451
+ _alt_routing = "openrouter"
2452
+ try:
2453
+ from utim_cli.config import config as _cfg2
2454
+ _k = _cfg2.get("api_key") or __import__("os").getenv("UTIM_API_KEY") or ""
2455
+ _is_utim2 = bool(_k) and not str(_k).startswith(("sk-or-", "sk-ant-", "sk-proj-", "Bearer "))
2456
+ if _is_utim2:
2457
+ _alt_routing = "utim"
2458
+ except Exception:
2459
+ pass
2460
+ # Only retry if we haven't already tried this alt routing via fallback models
2461
+ if _alt_routing not in [_resolve_compression_routing(m) for m in models_to_try[1:]]:
2462
+ try:
2463
+ from utim_cli.client_utils import proxy_openrouter_request as _proxy2
2464
+ _alt_resp = _proxy2(
2465
+ json_data={"model": primary_model, "messages": messages, "max_tokens": max_tokens, "_force_source": _alt_routing},
2466
+ stream=False, timeout=(15, 180), is_reflection=False
2467
+ )
2468
+ if _alt_resp.status_code == 200:
2469
+ try:
2470
+ _alt_body = _alt_resp.json()
2471
+ _alt_result = _alt_body["choices"][0]["message"]["content"]
2472
+ _alt_result = re.sub(r"<think(?:ing)?>.*?</think(?:ing)?>", "", str(_alt_result or ""), flags=re.DOTALL).strip()
2473
+ if _alt_result:
2474
+ return _alt_result
2475
+ except Exception:
2476
+ pass
2477
+ last_error = f"Cross-routing retry via {_alt_routing} also failed (status={getattr(_alt_resp, 'status_code', '?')}) | {last_error}"
2478
+ except Exception as _ce:
2479
+ last_error = f"Cross-routing retry error: {_ce} | {last_error}"
2480
+
2481
+ # ── Deterministic local fallback — never return None ─────────────────────
2482
+ # All LLM attempts failed. Return a truncated deterministic summary so the
2483
+ # caller never drops context entirely and never spams the failure log.
2484
+ _local = None
2485
+ try:
2486
+ if content_hint and str(content_hint).strip():
2487
+ _local = str(content_hint).strip()
2488
+ else:
2489
+ # Build from messages
2490
+ _parts = []
2491
+ for _m in (messages or [])[-12:]:
2492
+ _c = str(_m.get("content") or "").strip()
2493
+ if _c:
2494
+ _parts.append(f"{_m.get('role','user')}: {_c[:600]}")
2495
+ _local = "\n---\n".join(_parts) if _parts else ""
2496
+ if _local:
2497
+ # Truncate to ~max_tokens*4 chars
2498
+ _cap = max(600, int(max_tokens * 3.5))
2499
+ if len(_local) > _cap:
2500
+ _local = _local[:_cap] + "… [truncated]"
2501
+ _local = "[LOCAL FALLBACK SUMMARY] " + _local
2502
+ import sys as _sys2
2503
+ diag2 = f"model={primary_model} routing={_diag_routing if '_diag_routing' in locals() else 'unknown'} empty_hits={empty_hits}"
2504
+ print(f"[COMPRESSION LOCAL FALLBACK] LLM fallback failed ({last_error} | {diag2}) — using deterministic summary.", file=_sys2.stderr)
2505
+ return _local
2506
+ except Exception:
2507
+ pass
2508
+
2509
+ # True hard failure — log with full diagnostics
2303
2510
  import sys
2511
+ try:
2512
+ _diag_routing = _resolve_compression_routing(primary_model)
2513
+ except Exception:
2514
+ _diag_routing = _routing if '_routing' in locals() else "unknown"
2515
+ diag = f"model={primary_model} routing={_diag_routing} empty_hits={empty_hits}"
2516
+ if last_error is None:
2517
+ last_error = f"No attempt produced a result ({diag})"
2518
+ else:
2519
+ last_error = f"{last_error} ({diag})"
2304
2520
  print(f"[COMPRESSION FALLBACK FAILURE] All models failed. Last error: {last_error}", file=sys.stderr)
2305
2521
  return None
2306
2522