multacd 2.0.0b1__tar.gz → 2.0.0b2__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 (194) hide show
  1. {multacd-2.0.0b1 → multacd-2.0.0b2}/.github/workflows/release.yml +3 -3
  2. {multacd-2.0.0b1 → multacd-2.0.0b2}/CHANGELOG.md +10 -0
  3. {multacd-2.0.0b1 → multacd-2.0.0b2}/PKG-INFO +2 -3
  4. {multacd-2.0.0b1 → multacd-2.0.0b2}/README.md +1 -1
  5. {multacd-2.0.0b1 → multacd-2.0.0b2}/core/agent_loop.py +1 -0
  6. multacd-2.0.0b2/core/llm_client.py +367 -0
  7. multacd-2.0.0b2/core/providers/__init__.py +137 -0
  8. multacd-2.0.0b2/core/providers/__main__.py +38 -0
  9. multacd-2.0.0b2/core/providers/anthropic.py +329 -0
  10. multacd-2.0.0b2/core/providers/openai_compat.py +301 -0
  11. {multacd-2.0.0b1 → multacd-2.0.0b2}/core/version.py +1 -1
  12. {multacd-2.0.0b1 → multacd-2.0.0b2}/docs/configuration.md +1 -1
  13. {multacd-2.0.0b1 → multacd-2.0.0b2}/docs/index.md +1 -1
  14. {multacd-2.0.0b1 → multacd-2.0.0b2}/multacd.spec +8 -23
  15. {multacd-2.0.0b1 → multacd-2.0.0b2}/pyproject.toml +1 -2
  16. {multacd-2.0.0b1 → multacd-2.0.0b2}/requirements.txt +0 -1
  17. {multacd-2.0.0b1 → multacd-2.0.0b2}/scripts/install.sh +6 -6
  18. {multacd-2.0.0b1 → multacd-2.0.0b2}/tests/unit/test_binary.py +3 -1
  19. {multacd-2.0.0b1 → multacd-2.0.0b2}/tests/unit/test_entrypoint.py +3 -4
  20. {multacd-2.0.0b1 → multacd-2.0.0b2}/tui/screens/main_screen.py +6 -0
  21. {multacd-2.0.0b1 → multacd-2.0.0b2}/tui/widgets/info_panel.py +2 -0
  22. multacd-2.0.0b1/core/llm_client.py +0 -377
  23. {multacd-2.0.0b1 → multacd-2.0.0b2}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
  24. {multacd-2.0.0b1 → multacd-2.0.0b2}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
  25. {multacd-2.0.0b1 → multacd-2.0.0b2}/.github/pull_request_template.md +0 -0
  26. {multacd-2.0.0b1 → multacd-2.0.0b2}/.github/workflows/test.yml +0 -0
  27. {multacd-2.0.0b1 → multacd-2.0.0b2}/.gitignore +0 -0
  28. {multacd-2.0.0b1 → multacd-2.0.0b2}/CONTRIBUTING.md +0 -0
  29. {multacd-2.0.0b1 → multacd-2.0.0b2}/DESIGN.md +0 -0
  30. {multacd-2.0.0b1 → multacd-2.0.0b2}/LICENSE +0 -0
  31. {multacd-2.0.0b1 → multacd-2.0.0b2}/briefing/__init__.py +0 -0
  32. {multacd-2.0.0b1 → multacd-2.0.0b2}/briefing/generator.py +0 -0
  33. {multacd-2.0.0b1 → multacd-2.0.0b2}/briefing/privacy.py +0 -0
  34. {multacd-2.0.0b1 → multacd-2.0.0b2}/briefing/sources/__init__.py +0 -0
  35. {multacd-2.0.0b1 → multacd-2.0.0b2}/briefing/sources/git_source.py +0 -0
  36. {multacd-2.0.0b1 → multacd-2.0.0b2}/briefing/sources/news_source.py +0 -0
  37. {multacd-2.0.0b1 → multacd-2.0.0b2}/briefing/sources/todo_source.py +0 -0
  38. {multacd-2.0.0b1 → multacd-2.0.0b2}/core/__init__.py +0 -0
  39. {multacd-2.0.0b1 → multacd-2.0.0b2}/core/clipboard.py +0 -0
  40. {multacd-2.0.0b1 → multacd-2.0.0b2}/core/codebase.py +0 -0
  41. {multacd-2.0.0b1 → multacd-2.0.0b2}/core/config.py +0 -0
  42. {multacd-2.0.0b1 → multacd-2.0.0b2}/core/mode_manager.py +0 -0
  43. {multacd-2.0.0b1 → multacd-2.0.0b2}/core/permissions.py +0 -0
  44. {multacd-2.0.0b1 → multacd-2.0.0b2}/core/plugin_loader.py +0 -0
  45. {multacd-2.0.0b1 → multacd-2.0.0b2}/core/prompt_composer.py +0 -0
  46. {multacd-2.0.0b1 → multacd-2.0.0b2}/core/research/__init__.py +0 -0
  47. {multacd-2.0.0b1 → multacd-2.0.0b2}/core/research/bus.py +0 -0
  48. {multacd-2.0.0b1 → multacd-2.0.0b2}/core/research/orchestrator.py +0 -0
  49. {multacd-2.0.0b1 → multacd-2.0.0b2}/core/research/query_generator.py +0 -0
  50. {multacd-2.0.0b1 → multacd-2.0.0b2}/core/updater.py +0 -0
  51. {multacd-2.0.0b1 → multacd-2.0.0b2}/daemon/__init__.py +0 -0
  52. {multacd-2.0.0b1 → multacd-2.0.0b2}/daemon/ipc.py +0 -0
  53. {multacd-2.0.0b1 → multacd-2.0.0b2}/daemon/process.py +0 -0
  54. {multacd-2.0.0b1 → multacd-2.0.0b2}/docs/install.md +0 -0
  55. {multacd-2.0.0b1 → multacd-2.0.0b2}/docs/mkdocs.yml +0 -0
  56. {multacd-2.0.0b1 → multacd-2.0.0b2}/docs/modes/coding.md +0 -0
  57. {multacd-2.0.0b1 → multacd-2.0.0b2}/docs/modes/personal.md +0 -0
  58. {multacd-2.0.0b1 → multacd-2.0.0b2}/docs/modes/research.md +0 -0
  59. {multacd-2.0.0b1 → multacd-2.0.0b2}/docs/plugins.md +0 -0
  60. {multacd-2.0.0b1 → multacd-2.0.0b2}/docs/quickstart.md +0 -0
  61. {multacd-2.0.0b1 → multacd-2.0.0b2}/main.py +0 -0
  62. {multacd-2.0.0b1 → multacd-2.0.0b2}/memory/__init__.py +0 -0
  63. {multacd-2.0.0b1 → multacd-2.0.0b2}/memory/context.py +0 -0
  64. {multacd-2.0.0b1 → multacd-2.0.0b2}/memory/store.py +0 -0
  65. {multacd-2.0.0b1 → multacd-2.0.0b2}/plan/PLAN-phase1.md +0 -0
  66. {multacd-2.0.0b1 → multacd-2.0.0b2}/plan/PLAN-phase2.md +0 -0
  67. {multacd-2.0.0b1 → multacd-2.0.0b2}/plugins/README.md +0 -0
  68. {multacd-2.0.0b1 → multacd-2.0.0b2}/plugins/example_plugin.py +0 -0
  69. {multacd-2.0.0b1 → multacd-2.0.0b2}/roadmap/ROADMAP.md +0 -0
  70. {multacd-2.0.0b1 → multacd-2.0.0b2}/scheduler/__init__.py +0 -0
  71. {multacd-2.0.0b1 → multacd-2.0.0b2}/scheduler/engine.py +0 -0
  72. {multacd-2.0.0b1 → multacd-2.0.0b2}/scheduler/jobs/__init__.py +0 -0
  73. {multacd-2.0.0b1 → multacd-2.0.0b2}/scheduler/jobs/briefing_job.py +0 -0
  74. {multacd-2.0.0b1 → multacd-2.0.0b2}/scheduler/jobs/research_job.py +0 -0
  75. {multacd-2.0.0b1 → multacd-2.0.0b2}/scripts/install.ps1 +0 -0
  76. {multacd-2.0.0b1 → multacd-2.0.0b2}/search_providers/__init__.py +0 -0
  77. {multacd-2.0.0b1 → multacd-2.0.0b2}/search_providers/__main__.py +0 -0
  78. {multacd-2.0.0b1 → multacd-2.0.0b2}/search_providers/base.py +0 -0
  79. {multacd-2.0.0b1 → multacd-2.0.0b2}/search_providers/brave.py +0 -0
  80. {multacd-2.0.0b1 → multacd-2.0.0b2}/search_providers/duckduckgo.py +0 -0
  81. {multacd-2.0.0b1 → multacd-2.0.0b2}/search_providers/exa.py +0 -0
  82. {multacd-2.0.0b1 → multacd-2.0.0b2}/search_providers/serpapi.py +0 -0
  83. {multacd-2.0.0b1 → multacd-2.0.0b2}/search_providers/tavily.py +0 -0
  84. {multacd-2.0.0b1 → multacd-2.0.0b2}/soul.md +0 -0
  85. {multacd-2.0.0b1 → multacd-2.0.0b2}/tests/__init__.py +0 -0
  86. {multacd-2.0.0b1 → multacd-2.0.0b2}/tests/conftest.py +0 -0
  87. {multacd-2.0.0b1 → multacd-2.0.0b2}/tests/integration/__init__.py +0 -0
  88. {multacd-2.0.0b1 → multacd-2.0.0b2}/tests/integration/test_agent_loop.py +0 -0
  89. {multacd-2.0.0b1 → multacd-2.0.0b2}/tests/test_selftests.py +0 -0
  90. {multacd-2.0.0b1 → multacd-2.0.0b2}/tests/unit/__init__.py +0 -0
  91. {multacd-2.0.0b1 → multacd-2.0.0b2}/tests/unit/test_config.py +0 -0
  92. {multacd-2.0.0b1 → multacd-2.0.0b2}/tests/unit/test_docs.py +0 -0
  93. {multacd-2.0.0b1 → multacd-2.0.0b2}/tests/unit/test_github.py +0 -0
  94. {multacd-2.0.0b1 → multacd-2.0.0b2}/tests/unit/test_installer.py +0 -0
  95. {multacd-2.0.0b1 → multacd-2.0.0b2}/tests/unit/test_permissions.py +0 -0
  96. {multacd-2.0.0b1 → multacd-2.0.0b2}/tests/unit/test_plugin_loader.py +0 -0
  97. {multacd-2.0.0b1 → multacd-2.0.0b2}/tests/unit/test_privacy_filter.py +0 -0
  98. {multacd-2.0.0b1 → multacd-2.0.0b2}/tests/unit/test_search_providers/__init__.py +0 -0
  99. {multacd-2.0.0b1 → multacd-2.0.0b2}/tests/unit/test_search_providers/test_tavily.py +0 -0
  100. {multacd-2.0.0b1 → multacd-2.0.0b2}/tests/unit/test_tools/__init__.py +0 -0
  101. {multacd-2.0.0b1 → multacd-2.0.0b2}/tests/unit/test_tools/test_bash.py +0 -0
  102. {multacd-2.0.0b1 → multacd-2.0.0b2}/tests/unit/test_tools/test_read_file.py +0 -0
  103. {multacd-2.0.0b1 → multacd-2.0.0b2}/tests/unit/test_tools/test_web_scrape.py +0 -0
  104. {multacd-2.0.0b1 → multacd-2.0.0b2}/tests/unit/test_tools/test_write_file.py +0 -0
  105. {multacd-2.0.0b1 → multacd-2.0.0b2}/tests/unit/test_updater.py +0 -0
  106. {multacd-2.0.0b1 → multacd-2.0.0b2}/tg/__init__.py +0 -0
  107. {multacd-2.0.0b1 → multacd-2.0.0b2}/tg/access_control.py +0 -0
  108. {multacd-2.0.0b1 → multacd-2.0.0b2}/tg/agent.py +0 -0
  109. {multacd-2.0.0b1 → multacd-2.0.0b2}/tg/bot.py +0 -0
  110. {multacd-2.0.0b1 → multacd-2.0.0b2}/tg/file_handler.py +0 -0
  111. {multacd-2.0.0b1 → multacd-2.0.0b2}/tg/formatter.py +0 -0
  112. {multacd-2.0.0b1 → multacd-2.0.0b2}/tg/handlers.py +0 -0
  113. {multacd-2.0.0b1 → multacd-2.0.0b2}/tools/__init__.py +0 -0
  114. {multacd-2.0.0b1 → multacd-2.0.0b2}/tools/agent/__init__.py +0 -0
  115. {multacd-2.0.0b1 → multacd-2.0.0b2}/tools/agent/ask.py +0 -0
  116. {multacd-2.0.0b1 → multacd-2.0.0b2}/tools/agent/skill.py +0 -0
  117. {multacd-2.0.0b1 → multacd-2.0.0b2}/tools/agent/task.py +0 -0
  118. {multacd-2.0.0b1 → multacd-2.0.0b2}/tools/agent/todo_write.py +0 -0
  119. {multacd-2.0.0b1 → multacd-2.0.0b2}/tools/code/__init__.py +0 -0
  120. {multacd-2.0.0b1 → multacd-2.0.0b2}/tools/code/lint_python.py +0 -0
  121. {multacd-2.0.0b1 → multacd-2.0.0b2}/tools/code/run_python.py +0 -0
  122. {multacd-2.0.0b1 → multacd-2.0.0b2}/tools/code/run_tests.py +0 -0
  123. {multacd-2.0.0b1 → multacd-2.0.0b2}/tools/codebase/__init__.py +0 -0
  124. {multacd-2.0.0b1 → multacd-2.0.0b2}/tools/codebase/scan_codebase.py +0 -0
  125. {multacd-2.0.0b1 → multacd-2.0.0b2}/tools/common.py +0 -0
  126. {multacd-2.0.0b1 → multacd-2.0.0b2}/tools/filesystem/__init__.py +0 -0
  127. {multacd-2.0.0b1 → multacd-2.0.0b2}/tools/filesystem/apply_patch.py +0 -0
  128. {multacd-2.0.0b1 → multacd-2.0.0b2}/tools/filesystem/delete_file.py +0 -0
  129. {multacd-2.0.0b1 → multacd-2.0.0b2}/tools/filesystem/edit_file.py +0 -0
  130. {multacd-2.0.0b1 → multacd-2.0.0b2}/tools/filesystem/glob.py +0 -0
  131. {multacd-2.0.0b1 → multacd-2.0.0b2}/tools/filesystem/grep.py +0 -0
  132. {multacd-2.0.0b1 → multacd-2.0.0b2}/tools/filesystem/list_dir.py +0 -0
  133. {multacd-2.0.0b1 → multacd-2.0.0b2}/tools/filesystem/move_file.py +0 -0
  134. {multacd-2.0.0b1 → multacd-2.0.0b2}/tools/filesystem/multi_edit.py +0 -0
  135. {multacd-2.0.0b1 → multacd-2.0.0b2}/tools/filesystem/read_file.py +0 -0
  136. {multacd-2.0.0b1 → multacd-2.0.0b2}/tools/filesystem/read_many_files.py +0 -0
  137. {multacd-2.0.0b1 → multacd-2.0.0b2}/tools/filesystem/write_file.py +0 -0
  138. {multacd-2.0.0b1 → multacd-2.0.0b2}/tools/git/__init__.py +0 -0
  139. {multacd-2.0.0b1 → multacd-2.0.0b2}/tools/git/_helper.py +0 -0
  140. {multacd-2.0.0b1 → multacd-2.0.0b2}/tools/git/git_add.py +0 -0
  141. {multacd-2.0.0b1 → multacd-2.0.0b2}/tools/git/git_branch.py +0 -0
  142. {multacd-2.0.0b1 → multacd-2.0.0b2}/tools/git/git_checkout.py +0 -0
  143. {multacd-2.0.0b1 → multacd-2.0.0b2}/tools/git/git_commit.py +0 -0
  144. {multacd-2.0.0b1 → multacd-2.0.0b2}/tools/git/git_diff.py +0 -0
  145. {multacd-2.0.0b1 → multacd-2.0.0b2}/tools/git/git_log.py +0 -0
  146. {multacd-2.0.0b1 → multacd-2.0.0b2}/tools/git/git_merge.py +0 -0
  147. {multacd-2.0.0b1 → multacd-2.0.0b2}/tools/git/git_pull.py +0 -0
  148. {multacd-2.0.0b1 → multacd-2.0.0b2}/tools/git/git_push.py +0 -0
  149. {multacd-2.0.0b1 → multacd-2.0.0b2}/tools/git/git_status.py +0 -0
  150. {multacd-2.0.0b1 → multacd-2.0.0b2}/tools/memory/__init__.py +0 -0
  151. {multacd-2.0.0b1 → multacd-2.0.0b2}/tools/memory/forget.py +0 -0
  152. {multacd-2.0.0b1 → multacd-2.0.0b2}/tools/memory/recall.py +0 -0
  153. {multacd-2.0.0b1 → multacd-2.0.0b2}/tools/memory/remember.py +0 -0
  154. {multacd-2.0.0b1 → multacd-2.0.0b2}/tools/personal/__init__.py +0 -0
  155. {multacd-2.0.0b1 → multacd-2.0.0b2}/tools/personal/cancel_job.py +0 -0
  156. {multacd-2.0.0b1 → multacd-2.0.0b2}/tools/personal/daemon_status.py +0 -0
  157. {multacd-2.0.0b1 → multacd-2.0.0b2}/tools/personal/generate_briefing.py +0 -0
  158. {multacd-2.0.0b1 → multacd-2.0.0b2}/tools/personal/get_jobs.py +0 -0
  159. {multacd-2.0.0b1 → multacd-2.0.0b2}/tools/personal/schedule_job.py +0 -0
  160. {multacd-2.0.0b1 → multacd-2.0.0b2}/tools/personal/send_telegram.py +0 -0
  161. {multacd-2.0.0b1 → multacd-2.0.0b2}/tools/personal/user_manager.py +0 -0
  162. {multacd-2.0.0b1 → multacd-2.0.0b2}/tools/registry.py +0 -0
  163. {multacd-2.0.0b1 → multacd-2.0.0b2}/tools/research/__init__.py +0 -0
  164. {multacd-2.0.0b1 → multacd-2.0.0b2}/tools/research/deep_research.py +0 -0
  165. {multacd-2.0.0b1 → multacd-2.0.0b2}/tools/research/export_research.py +0 -0
  166. {multacd-2.0.0b1 → multacd-2.0.0b2}/tools/research/quick_research.py +0 -0
  167. {multacd-2.0.0b1 → multacd-2.0.0b2}/tools/research/web_scrape.py +0 -0
  168. {multacd-2.0.0b1 → multacd-2.0.0b2}/tools/research/web_search.py +0 -0
  169. {multacd-2.0.0b1 → multacd-2.0.0b2}/tools/shell/__init__.py +0 -0
  170. {multacd-2.0.0b1 → multacd-2.0.0b2}/tools/shell/bash.py +0 -0
  171. {multacd-2.0.0b1 → multacd-2.0.0b2}/tools/streaming.py +0 -0
  172. {multacd-2.0.0b1 → multacd-2.0.0b2}/tools/web/__init__.py +0 -0
  173. {multacd-2.0.0b1 → multacd-2.0.0b2}/tools/web/web_fetch.py +0 -0
  174. {multacd-2.0.0b1 → multacd-2.0.0b2}/tui/__init__.py +0 -0
  175. {multacd-2.0.0b1 → multacd-2.0.0b2}/tui/app.py +0 -0
  176. {multacd-2.0.0b1 → multacd-2.0.0b2}/tui/icons.py +0 -0
  177. {multacd-2.0.0b1 → multacd-2.0.0b2}/tui/screens/__init__.py +0 -0
  178. {multacd-2.0.0b1 → multacd-2.0.0b2}/tui/screens/setup_wizard.py +0 -0
  179. {multacd-2.0.0b1 → multacd-2.0.0b2}/tui/themes/__init__.py +0 -0
  180. {multacd-2.0.0b1 → multacd-2.0.0b2}/tui/themes/__main__.py +0 -0
  181. {multacd-2.0.0b1 → multacd-2.0.0b2}/tui/tokens.py +0 -0
  182. {multacd-2.0.0b1 → multacd-2.0.0b2}/tui/widgets/__init__.py +0 -0
  183. {multacd-2.0.0b1 → multacd-2.0.0b2}/tui/widgets/chat_panel.py +0 -0
  184. {multacd-2.0.0b1 → multacd-2.0.0b2}/tui/widgets/confirm_dialog.py +0 -0
  185. {multacd-2.0.0b1 → multacd-2.0.0b2}/tui/widgets/diff_viewer.py +0 -0
  186. {multacd-2.0.0b1 → multacd-2.0.0b2}/tui/widgets/file_tree.py +0 -0
  187. {multacd-2.0.0b1 → multacd-2.0.0b2}/tui/widgets/input_bar.py +0 -0
  188. {multacd-2.0.0b1 → multacd-2.0.0b2}/tui/widgets/model_selector.py +0 -0
  189. {multacd-2.0.0b1 → multacd-2.0.0b2}/tui/widgets/permission_popup.py +0 -0
  190. {multacd-2.0.0b1 → multacd-2.0.0b2}/tui/widgets/slash_palette.py +0 -0
  191. {multacd-2.0.0b1 → multacd-2.0.0b2}/tui/widgets/sources_panel.py +0 -0
  192. {multacd-2.0.0b1 → multacd-2.0.0b2}/tui/widgets/status_bar.py +0 -0
  193. {multacd-2.0.0b1 → multacd-2.0.0b2}/tui/widgets/thinking_bar.py +0 -0
  194. {multacd-2.0.0b1 → multacd-2.0.0b2}/tui/widgets/tool_activity.py +0 -0
@@ -94,14 +94,14 @@ jobs:
94
94
  Linux / macOS (download dulu biar gagalnya kelihatan):
95
95
 
96
96
  ```bash
97
- curl -fSL https://raw.githubusercontent.com/prototypeall850-creator/multacd/main/scripts/install.sh -o /tmp/m-install.sh && bash /tmp/m-install.sh
97
+ curl -fSL https://raw.githubusercontent.com/prototypeall850-creator/multacd/main/scripts/install.sh -o ./m-install.sh && bash ./m-install.sh && rm ./m-install.sh
98
98
  ```
99
99
 
100
100
  Termux (binary rilis tidak kompatibel — pakai pip):
101
101
 
102
102
  ```bash
103
- pkg install -y python rust git binutils
104
- pip install "git+https://github.com/prototypeall850-creator/multacd"
103
+ pkg install -y python git
104
+ pip install "multacd==2.0.0b2" --pre
105
105
  ```
106
106
 
107
107
  Windows (PowerShell):
@@ -2,6 +2,16 @@
2
2
 
3
3
  Format: Keep a Changelog. Versi: Semantic Versioning.
4
4
 
5
+ ## [2.0.0b2] - 2026-09-12
6
+
7
+ Beta v2 kedua: cabut LiteLLM, ganti provider native (httpx, 2 adapter
8
+ SSE: OpenAI-compatible + Anthropic) — install pip Termux ringan tanpa
9
+ Rust, binary susut 91MB ke 56MB. Cost estimasi lokal ala OpenCode
10
+ tampil di info panel. Interface stream_completion tidak berubah.
11
+
12
+ Install beta: `pip install --pre "multacd==2.0.0b2"` (PyPI pre-release)
13
+ atau binary dari halaman GitHub prerelease.
14
+
5
15
  ## [2.0.0b1] - 2026-09-12
6
16
 
7
17
  Beta v2 buat test Termux: redesign TUI (tanpa emoticon, responsif),
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: multacd
3
- Version: 2.0.0b1
3
+ Version: 2.0.0b2
4
4
  Summary: Agentic TUI for coding, research, and personal automation
5
5
  Project-URL: Homepage, https://github.com/prototypeall850-creator/multacd
6
6
  Project-URL: Issues, https://github.com/prototypeall850-creator/multacd/issues
@@ -20,7 +20,6 @@ Requires-Dist: apscheduler>=3.10.0
20
20
  Requires-Dist: beautifulsoup4>=4.12.0
21
21
  Requires-Dist: exa-py>=1.0.0
22
22
  Requires-Dist: httpx>=0.27.0
23
- Requires-Dist: litellm>=1.40.0
24
23
  Requires-Dist: markdownify>=0.12.0
25
24
  Requires-Dist: pydantic>=2.0
26
25
  Requires-Dist: python-telegram-bot>=21.0
@@ -38,7 +37,7 @@ Description-Content-Type: text/markdown
38
37
  # ⚡ multacd
39
38
 
40
39
  Agentic TUI untuk coding, research, dan personal automation — dalam satu terminal.
41
- Satu config BYOK untuk 100+ LLM provider via LiteLLM.
40
+ Satu config BYOK untuk LLM favoritmu via provider native (ringan, Termux aman).
42
41
 
43
42
  ```
44
43
  > /code baca main.py lalu jelaskan cara kerjanya
@@ -1,7 +1,7 @@
1
1
  # ⚡ multacd
2
2
 
3
3
  Agentic TUI untuk coding, research, dan personal automation — dalam satu terminal.
4
- Satu config BYOK untuk 100+ LLM provider via LiteLLM.
4
+ Satu config BYOK untuk LLM favoritmu via provider native (ringan, Termux aman).
5
5
 
6
6
  ```
7
7
  > /code baca main.py lalu jelaskan cara kerjanya
@@ -85,6 +85,7 @@ class AgentUsage:
85
85
 
86
86
  prompt_tokens: int = 0
87
87
  completion_tokens: int = 0
88
+ cost_usd: float = 0.0 # estimasi lokal (tabel harga provider)
88
89
 
89
90
 
90
91
  @dataclass
@@ -0,0 +1,367 @@
1
+ """LLM client — wrapper provider native: streaming + tool call + retry.
2
+
3
+ Backend: core/providers (OpenAI-compatible + Anthropic via httpx).
4
+ Tanpa LiteLLM — ringan buat Termux (tanpa kompilasi Rust).
5
+
6
+ Dipakai oleh agent loop (Step 7) lewat async generator:
7
+
8
+ client = setup_client(config)
9
+ async for event in client.stream_completion(messages, tools):
10
+ if isinstance(event, StreamText):
11
+ ... tampilkan event.content ...
12
+ elif isinstance(event, StreamDone):
13
+ ... event.text = teks penuh, event.tool_calls = [...] ...
14
+
15
+ Test cepat tanpa API key (mock httpx, tanpa network):
16
+ python -m core.llm_client
17
+ Test live (butuh ~/.multacd/config.yaml yang valid):
18
+ python -m core.llm_client --live "hello"
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import argparse
24
+ import asyncio
25
+ from collections.abc import AsyncIterator
26
+ from dataclasses import dataclass, field
27
+ from typing import Any
28
+
29
+ from core.config import Config, load_config
30
+ from core.providers import (
31
+ ProviderAuthError,
32
+ ProviderConnectionError,
33
+ ProviderError,
34
+ ProviderNotFoundError,
35
+ ProviderRateLimitError,
36
+ resolve_provider,
37
+ )
38
+
39
+ MAX_CONNECTION_RETRIES = 3
40
+ MAX_RATE_LIMIT_RETRIES = 3
41
+ RETRY_BASE_DELAY = 2.0 # detik, exponential backoff
42
+ REQUEST_TIMEOUT = 120.0
43
+
44
+
45
+ class LLMError(Exception):
46
+ """Error LLM dengan pesan yang friendly — tampilkan langsung ke user."""
47
+
48
+
49
+ @dataclass
50
+ class ToolCallRequest:
51
+ """Satu tool call yang diminta LLM (sudah dirakit dari fragment streaming)."""
52
+
53
+ id: str
54
+ name: str
55
+ arguments: dict[str, Any]
56
+
57
+
58
+ @dataclass
59
+ class StreamText:
60
+ """Potongan teks baru dari LLM (tampilkan real-time ke TUI)."""
61
+
62
+ content: str
63
+
64
+
65
+ @dataclass
66
+ class StreamDone:
67
+ """Stream selesai: teks penuh + daftar tool call (bisa kosong).
68
+
69
+ Token diisi dari chunk `usage` provider (resmi, bukan estimasi).
70
+ 0 = provider tidak melapor (streamingบาง provider tak kirim usage).
71
+ """
72
+
73
+ text: str
74
+ tool_calls: list[ToolCallRequest] = field(default_factory=list)
75
+ prompt_tokens: int = 0
76
+ completion_tokens: int = 0
77
+ cost_usd: float = 0.0 # estimasi lokal (tabel harga provider)
78
+
79
+
80
+ StreamEvent = StreamText | StreamDone
81
+
82
+
83
+ def setup_client(config: Config) -> LLMClient:
84
+ """Init client dari config (LiteLLM stateless — ini bungkus kwargs + retry)."""
85
+ return LLMClient(config)
86
+
87
+
88
+ class LLMClient:
89
+ def __init__(self, config: Config) -> None:
90
+ self.config = config
91
+
92
+ async def stream_completion(
93
+ self,
94
+ messages: list[dict[str, Any]],
95
+ tools: list[dict[str, Any]] | None = None,
96
+ ) -> AsyncIterator[StreamEvent]:
97
+ """Kirim messages ke LLM, yield StreamText lalu diakhiri satu StreamDone.
98
+
99
+ Routing provider by config.model (lihat resolve_provider).
100
+ Retry hanya dilakukan jika stream BELUM menghasilkan teks apapun
101
+ (aman — tidak ada duplikasi output). Gagal di tengah stream
102
+ langsung raise LLMError.
103
+ """
104
+ try:
105
+ spec = resolve_provider(self.config.model, self.config.api_base)
106
+ except ProviderError as e:
107
+ raise LLMError(str(e)) from e
108
+ if spec.kind == "anthropic":
109
+ from core.providers.anthropic import stream_chat
110
+ else:
111
+ from core.providers.openai_compat import stream_chat
112
+
113
+ conn_attempts = 0
114
+ rate_attempts = 0
115
+ while True:
116
+ yielded_any_text = False
117
+ try:
118
+ async for event in stream_chat(
119
+ spec, self.config.api_key, messages, tools,
120
+ self.config.max_tokens, self.config.temperature,
121
+ REQUEST_TIMEOUT):
122
+ if isinstance(event, StreamText) and event.content:
123
+ yielded_any_text = True
124
+ yield event
125
+ return
126
+ except ProviderRateLimitError as e:
127
+ rate_attempts += 1
128
+ if rate_attempts > MAX_RATE_LIMIT_RETRIES or yielded_any_text:
129
+ raise LLMError(
130
+ "Rate limit dari provider (429) — "
131
+ f"sudah retry {MAX_RATE_LIMIT_RETRIES}x, masih dibatasi. "
132
+ "Tunggu sebentar lalu coba lagi."
133
+ ) from e
134
+ await asyncio.sleep(RETRY_BASE_DELAY * (2 ** (rate_attempts - 1)))
135
+ except ProviderConnectionError as e:
136
+ conn_attempts += 1
137
+ if conn_attempts > MAX_CONNECTION_RETRIES or yielded_any_text:
138
+ raise LLMError(
139
+ "Gagal konek ke LLM provider "
140
+ f"({MAX_CONNECTION_RETRIES}x percobaan). "
141
+ "Cek koneksi internet / api_base di config."
142
+ ) from e
143
+ await asyncio.sleep(RETRY_BASE_DELAY * conn_attempts)
144
+ except ProviderAuthError as e:
145
+ raise LLMError(
146
+ "API key ditolak provider. Cek `api_key` di ~/.multacd/config.yaml "
147
+ "(BYOK — pastikan key cocok dengan `model` yang dipilih)."
148
+ ) from e
149
+ except ProviderNotFoundError as e:
150
+ raise LLMError(
151
+ f"Model `{self.config.model}` tidak ditemukan oleh provider. "
152
+ "Cek penulisan `model` di config (cth: "
153
+ "`anthropic/claude-sonnet-4-6`, `openai/gpt-4o`)."
154
+ ) from e
155
+ except LLMError:
156
+ raise
157
+ except ProviderError as e:
158
+ raise LLMError(f"LLM error ({type(e).__name__}): {e}") from e
159
+ except Exception as e:
160
+ raise LLMError(f"LLM error tak terduga ({type(e).__name__}): {e}") from e
161
+
162
+ async def complete(
163
+ self,
164
+ messages: list[dict[str, Any]],
165
+ tools: list[dict[str, Any]] | None = None,
166
+ ) -> StreamDone:
167
+ """Non-streaming: kumpulkan stream jadi satu hasil (buat test/debug)."""
168
+ full: StreamDone | None = None
169
+ async for event in self.stream_completion(messages, tools):
170
+ if isinstance(event, StreamDone):
171
+ full = event
172
+ assert full is not None
173
+ return full
174
+
175
+
176
+ # ── Self-test tanpa network (mock httpx stream) ────────────────────────────
177
+
178
+ class _FakeSSE:
179
+ """Resp SSE palsu: aiter_lines + aread + status_code."""
180
+
181
+ def __init__(self, lines: list[str], status: int = 200,
182
+ body: bytes = b"") -> None:
183
+ self._lines = lines
184
+ self.status_code = status
185
+ self._body = body
186
+
187
+ async def aiter_lines(self): # type: ignore[no-untyped-def]
188
+ for ln in self._lines:
189
+ yield ln
190
+
191
+ async def aread(self) -> bytes:
192
+ return self._body
193
+
194
+
195
+ class _FakeCtx:
196
+ def __init__(self, resp: _FakeSSE) -> None:
197
+ self._resp = resp
198
+
199
+ async def __aenter__(self) -> _FakeSSE:
200
+ return self._resp
201
+
202
+ async def __aexit__(self, *a: Any) -> None:
203
+ return None
204
+
205
+
206
+ class _FakeClient:
207
+ """Pengganti httpx.AsyncClient buat test (tanpa network)."""
208
+
209
+ def __init__(self, resp: _FakeSSE) -> None:
210
+ self._resp = resp
211
+ self.seen: dict[str, Any] = {}
212
+
213
+ def stream(self, method: str, url: str, **kw: Any) -> _FakeCtx:
214
+ self.seen = {"method": method, "url": url, **kw}
215
+ return _FakeCtx(self._resp)
216
+
217
+ async def aclose(self) -> None:
218
+ pass
219
+
220
+
221
+ def _sse_text(*chunks: str, usage: dict[str, int] | None = None) -> list[str]:
222
+ import json as _json
223
+ lines = [f"data: {_json.dumps({'choices': [{'delta': {'content': c}}]})}"
224
+ for c in chunks]
225
+ if usage is not None:
226
+ lines.append(f"data: {_json.dumps({'usage': usage})}")
227
+ lines.append("data: [DONE]")
228
+ return lines
229
+
230
+
231
+ async def _self_test() -> None:
232
+ from unittest.mock import patch as _patch
233
+
234
+ import httpx as _httpx
235
+
236
+ # ANTI-TRAP: file ini jalan sebagai __main__ saat `python -m`, sementara
237
+ # adapter import `core.llm_client` terpisah (objek class ganda!).
238
+ # Semua isinstance di test ini WAJIB pakai salinan kanonis itu.
239
+ import core.llm_client as _canon
240
+
241
+ cfg = Config(model="openai/gpt-4o", api_key="sk-x")
242
+ client = LLMClient(cfg)
243
+
244
+ async def _drain(c: LLMClient) -> StreamDone:
245
+ async for ev in c.stream_completion([{"role": "user", "content": "hi"}]):
246
+ if isinstance(ev, _canon.StreamDone):
247
+ return ev
248
+ raise AssertionError("unreachable")
249
+
250
+ def _client_for(resp: _FakeSSE) -> Any:
251
+ fake = _FakeClient(resp)
252
+ return _patch("httpx.AsyncClient", return_value=fake), fake
253
+
254
+ # 1. Teks streaming utuh + usage menempel
255
+ lines = _sse_text("Halo, ", "dunia!",
256
+ usage={"prompt_tokens": 120, "completion_tokens": 30})
257
+ p, fake = _client_for(_FakeSSE(lines))
258
+ with p:
259
+ done = await _drain(client)
260
+ assert done.text == "Halo, dunia!" and done.tool_calls == [], done
261
+ assert (done.prompt_tokens, done.completion_tokens) == (120, 30), done
262
+ assert done.cost_usd > 0 # gpt-4o ada harga
263
+ assert fake.seen["url"] == "https://api.openai.com/v1/chat/completions"
264
+ assert fake.seen["json"]["model"] == "gpt-4o"
265
+
266
+ # 2. Tool call terpecah + tanpa usage → 0
267
+ import json as _json
268
+ _half = _json.dumps({"command": "ls"})
269
+ _frag1 = {"choices": [{"delta": {"tool_calls": [
270
+ {"index": 0, "id": "c1",
271
+ "function": {"name": "bash", "arguments": _half[:14]}}]}}]}
272
+ _frag2 = {"choices": [{"delta": {"tool_calls": [
273
+ {"index": 0, "function": {"arguments": _half[14:]}}]}}]}
274
+ lines = [f"data: {_json.dumps(_frag1)}",
275
+ f"data: {_json.dumps(_frag2)}", "data: [DONE]"]
276
+ p, _ = _client_for(_FakeSSE(lines))
277
+ with p:
278
+ done = await _drain(client)
279
+ assert len(done.tool_calls) == 1, done.tool_calls
280
+ assert done.tool_calls[0].arguments == {"command": "ls"}, done.tool_calls[0]
281
+ assert done.prompt_tokens == 0 and done.cost_usd == 0.0
282
+
283
+ # 3. Auth error → LLMError (pesan sama kayak dulu)
284
+ p, _ = _client_for(_FakeSSE([], status=401, body=b"bad key"))
285
+ with p:
286
+ try:
287
+ await _drain(client)
288
+ except LLMError as e:
289
+ assert "API key" in str(e), e
290
+ else:
291
+ raise AssertionError("auth error harus jadi LLMError")
292
+
293
+ # 4. Not-found → LLMError
294
+ p, _ = _client_for(_FakeSSE([], status=404, body=b"nope"))
295
+ with p:
296
+ try:
297
+ await _drain(client)
298
+ except LLMError as e:
299
+ assert "tidak ditemukan" in str(e), e
300
+ else:
301
+ raise AssertionError("not-found harus jadi LLMError")
302
+
303
+ # 5. Koneksi putus 3x → retry lalu LLMError
304
+ calls = {"n": 0}
305
+
306
+ def _flaky(self: Any, *a: Any, **k: Any) -> Any:
307
+ calls["n"] += 1
308
+ raise _httpx.ConnectError("down")
309
+
310
+ p, _ = _client_for(_FakeSSE([]))
311
+ with p, _patch.object(_FakeClient, "stream", _flaky), _patch(
312
+ "core.llm_client.asyncio.sleep", return_value=None):
313
+ try:
314
+ await _drain(client)
315
+ except LLMError as e:
316
+ assert "Gagal konek" in str(e), e
317
+ else:
318
+ raise AssertionError("conn error harus jadi LLMError")
319
+ assert calls["n"] == MAX_CONNECTION_RETRIES + 1, calls
320
+
321
+ # 6. Routing anthropic → endpoint messages + x-api-key
322
+ cfg_a = Config(model="anthropic/claude-haiku-4-5", api_key="sk-ant")
323
+ client_a = LLMClient(cfg_a)
324
+ lines = ["event: content_block_delta",
325
+ 'data: {"delta": {"type": "text_delta", "text": "hai"}}',
326
+ "event: message_stop", "data: {}"]
327
+ p, fake = _client_for(_FakeSSE(lines))
328
+ with p:
329
+ async for ev in client_a.stream_completion(
330
+ [{"role": "user", "content": "hi"}]):
331
+ if isinstance(ev, _canon.StreamDone):
332
+ assert ev.text == "hai", ev
333
+ assert fake.seen["url"] == "https://api.anthropic.com/v1/messages"
334
+ assert fake.seen["headers"]["x-api-key"] == "sk-ant"
335
+
336
+ # 7. Model ngawur → LLMError jelas (tanpa network)
337
+ cfg_bad = Config(model="ngawur/xyz", api_key="k")
338
+ try:
339
+ await _drain(LLMClient(cfg_bad))
340
+ except LLMError as e:
341
+ assert "tidak dikenali" in str(e), e
342
+ else:
343
+ raise AssertionError("model ngawur harus ditolak")
344
+
345
+ print("✅ llm_client self-test OK (native adapter + retry)")
346
+
347
+
348
+ async def _live_test(prompt: str) -> None:
349
+ cfg = load_config()
350
+ client = setup_client(cfg)
351
+ print(f"model: {cfg.model} — streaming...\n---")
352
+ async for event in client.stream_completion([{"role": "user", "content": prompt}]):
353
+ if isinstance(event, StreamText):
354
+ print(event.content, end="", flush=True)
355
+ elif isinstance(event, StreamDone):
356
+ print(f"\n---\n✅ done. tool_calls={len(event.tool_calls)}")
357
+
358
+
359
+ if __name__ == "__main__":
360
+ parser = argparse.ArgumentParser()
361
+ parser.add_argument("--live", metavar="PROMPT", default=None,
362
+ help="test live ke provider (butuh config valid)")
363
+ args = parser.parse_args()
364
+ if args.live:
365
+ asyncio.run(_live_test(args.live))
366
+ else:
367
+ asyncio.run(_self_test())
@@ -0,0 +1,137 @@
1
+ """Provider LLM native — pengganti LiteLLM (lihat core/llm_client.py).
2
+
3
+ Dua adapter menutup 6 provider wizard (full OpenAI-compatible + Anthropic).
4
+ Ringan: cuma butuh httpx (wheel murni, Termux aman, tanpa Rust).
5
+
6
+ from core.providers import ProviderError, estimate_cost, resolve_provider
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from dataclasses import dataclass, field
12
+
13
+ # ── Error provider-agnostik (adapter raise, llm_client mapping ke LLMError) ──
14
+
15
+ class ProviderError(Exception):
16
+ """Base error provider (pesan sudah user-friendly)."""
17
+
18
+
19
+ class ProviderAuthError(ProviderError):
20
+ """401/403 — key ditolak."""
21
+
22
+
23
+ class ProviderNotFoundError(ProviderError):
24
+ """404 — model/endpoint tidak ada."""
25
+
26
+
27
+ class ProviderRateLimitError(ProviderError):
28
+ """429 — dibatasi, boleh retry."""
29
+
30
+
31
+ class ProviderConnectionError(ProviderError):
32
+ """Network/timeout — boleh retry."""
33
+
34
+
35
+ @dataclass
36
+ class ProviderSpec:
37
+ """Hasil routing: mau ngomong ke mana, sebagai model apa."""
38
+
39
+ kind: str # "openai" | "anthropic"
40
+ chat_url: str # endpoint penuh (.../chat/completions atau .../messages)
41
+ api_key: str
42
+ model: str # id native provider (tanpa prefix config)
43
+ headers: dict[str, str] = field(default_factory=dict)
44
+
45
+
46
+ def resolve_provider(config_model: str, api_base: str | None = None) -> ProviderSpec:
47
+ """Routing 'anthropic/claude-x' → adapter + URL + model native.
48
+
49
+ Tak dikenal → OpenAI-compatible dengan api_base wajib (error jelas),
50
+ JANGAN diam-diam ke URL ngawur.
51
+ """
52
+ cur = (config_model or "").strip()
53
+ low = cur.lower()
54
+ prov, _, name = low.partition("/")
55
+ if not name: # tanpa prefix ("gpt-4o") → tebak dari nama
56
+ prov, name = "", low
57
+
58
+ if prov == "anthropic" or name.startswith("claude-"):
59
+ base = (api_base or "https://api.anthropic.com").rstrip("/")
60
+ return ProviderSpec(
61
+ kind="anthropic", chat_url=base + "/v1/messages",
62
+ api_key="", model=name or cur,
63
+ headers={"anthropic-version": "2023-06-01"},
64
+ )
65
+ if prov == "gemini" or name.startswith("gemini-"):
66
+ return ProviderSpec(
67
+ kind="openai",
68
+ chat_url="https://generativelanguage.googleapis.com/v1beta/openai/chat/completions",
69
+ api_key="", model=name or cur, headers={},
70
+ )
71
+ if prov == "groq":
72
+ return ProviderSpec(
73
+ kind="openai", chat_url="https://api.groq.com/openai/v1/chat/completions",
74
+ api_key="", model=name or cur, headers={},
75
+ )
76
+ if prov == "deepseek" or name.startswith("deepseek-"):
77
+ return ProviderSpec(
78
+ kind="openai", chat_url="https://api.deepseek.com/chat/completions",
79
+ api_key="", model=name or cur, headers={},
80
+ )
81
+ if prov == "ollama":
82
+ base = (api_base or "http://localhost:11434").rstrip("/")
83
+ return ProviderSpec(
84
+ kind="openai", chat_url=base + "/v1/chat/completions",
85
+ api_key="", model=name or cur, headers={},
86
+ )
87
+ if prov == "openai" or name.startswith(("gpt-", "o1", "o3", "chatgpt-")):
88
+ return ProviderSpec(
89
+ kind="openai", chat_url="https://api.openai.com/v1/chat/completions",
90
+ api_key="", model=name or cur, headers={},
91
+ )
92
+ if prov == "custom" or api_base:
93
+ base = (api_base or "").rstrip("/")
94
+ if not base:
95
+ raise ProviderError(
96
+ "Provider custom butuh `api_base` di config "
97
+ "(endpoint OpenAI-compatible, mis. https://host:8000/v1).")
98
+ return ProviderSpec(
99
+ kind="openai", chat_url=base + "/chat/completions",
100
+ api_key="", model=name or cur, headers={},
101
+ )
102
+ raise ProviderError(
103
+ f"Model `{cur}` tidak dikenali. Format: provider/nama "
104
+ "(cth: openai/gpt-4o, anthropic/claude-sonnet-4-6, ollama/llama3.2).")
105
+
106
+
107
+ # ── Harga per 1M token (USD, ESTIMASI — cek berkala, provider suka ubah) ──
108
+ # Dipakai estimate_cost ala OpenCode (hitung lokal, tanpa server).
109
+ # Format: (substring_model, harga_input, harga_output). Urutan = prioritas.
110
+ PRICES: tuple[tuple[str, float, float], ...] = (
111
+ ("opus", 15.0, 75.0),
112
+ ("sonnet", 3.0, 15.0),
113
+ ("haiku", 0.8, 4.0),
114
+ ("gpt-4o-mini", 0.15, 0.6),
115
+ ("gpt-4o", 2.5, 10.0),
116
+ ("o3", 2.0, 8.0),
117
+ ("o1", 15.0, 60.0),
118
+ ("gemini-2.5-pro", 1.25, 10.0),
119
+ ("gemini-2.0-flash", 0.1, 0.4),
120
+ ("gemini", 0.5, 1.5),
121
+ ("llama-3.3-70b", 0.59, 0.79),
122
+ ("llama-3.1-8b", 0.05, 0.08),
123
+ ("llama", 0.2, 0.2),
124
+ ("qwen", 0.2, 0.2),
125
+ ("deepseek", 0.55, 2.19),
126
+ )
127
+
128
+
129
+ def estimate_cost(model: str, prompt_tokens: int, completion_tokens: int) -> float:
130
+ """Cost USD (est). Lokal/tak dikenal → 0.0 (jujur: bukan asal)."""
131
+ low = (model or "").lower()
132
+ if "ollama" in low or "localhost" in low or "local" in low:
133
+ return 0.0 # jalan di mesin sendiri = gratis
134
+ for sub, pin, pout in PRICES:
135
+ if sub in low:
136
+ return (prompt_tokens * pin + completion_tokens * pout) / 1_000_000
137
+ return 0.0
@@ -0,0 +1,38 @@
1
+ """Self-test paket providers (python -m core.providers)."""
2
+
3
+ from core.providers import ProviderError, estimate_cost, resolve_provider
4
+
5
+ s = resolve_provider("anthropic/claude-sonnet-4-6")
6
+ assert (s.kind, s.model) == ("anthropic", "claude-sonnet-4-6"), s
7
+ assert s.chat_url == "https://api.anthropic.com/v1/messages"
8
+ s = resolve_provider("groq/llama-3.3-70b-versatile")
9
+ assert s.kind == "openai" and "groq" in s.chat_url and s.model == "llama-3.3-70b-versatile"
10
+ s = resolve_provider("openai/gpt-4o")
11
+ assert s.chat_url == "https://api.openai.com/v1/chat/completions"
12
+ s = resolve_provider("gemini/gemini-2.0-flash")
13
+ assert "googleapis" in s.chat_url and s.model == "gemini-2.0-flash"
14
+ s = resolve_provider("ollama/llama3.2")
15
+ assert s.chat_url == "http://localhost:11434/v1/chat/completions"
16
+ s = resolve_provider("gpt-4o-mini") # tanpa prefix → openai
17
+ assert s.model == "gpt-4o-mini" and "openai.com" in s.chat_url
18
+ s = resolve_provider("custom/mymodel", api_base="https://h:8000/v1")
19
+ assert s.chat_url == "https://h:8000/v1/chat/completions"
20
+ s = resolve_provider("deepseek/deepseek-chat")
21
+ assert s.chat_url == "https://api.deepseek.com/chat/completions"
22
+ try:
23
+ resolve_provider("ngawur/xyz")
24
+ except ProviderError as e:
25
+ assert "tidak dikenali" in str(e)
26
+ else:
27
+ raise AssertionError("model ngawur harus ditolak")
28
+ try:
29
+ resolve_provider("custom/mymodel")
30
+ except ProviderError as e:
31
+ assert "api_base" in str(e)
32
+ else:
33
+ raise AssertionError("custom tanpa base harus ditolak")
34
+ # Cost: 1000 in + 500 out sonnet = 3*1k + 15*0.5k per 1M
35
+ assert abs(estimate_cost("claude-sonnet-4-6", 1000, 500) - 0.0105) < 1e-9
36
+ assert estimate_cost("ollama/llama3.2", 9999, 9999) == 0.0
37
+ assert estimate_cost("??", 10, 10) == 0.0
38
+ print("✅ providers self-test OK (routing + cost)")