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.
- {multacd-2.0.0b1 → multacd-2.0.0b2}/.github/workflows/release.yml +3 -3
- {multacd-2.0.0b1 → multacd-2.0.0b2}/CHANGELOG.md +10 -0
- {multacd-2.0.0b1 → multacd-2.0.0b2}/PKG-INFO +2 -3
- {multacd-2.0.0b1 → multacd-2.0.0b2}/README.md +1 -1
- {multacd-2.0.0b1 → multacd-2.0.0b2}/core/agent_loop.py +1 -0
- multacd-2.0.0b2/core/llm_client.py +367 -0
- multacd-2.0.0b2/core/providers/__init__.py +137 -0
- multacd-2.0.0b2/core/providers/__main__.py +38 -0
- multacd-2.0.0b2/core/providers/anthropic.py +329 -0
- multacd-2.0.0b2/core/providers/openai_compat.py +301 -0
- {multacd-2.0.0b1 → multacd-2.0.0b2}/core/version.py +1 -1
- {multacd-2.0.0b1 → multacd-2.0.0b2}/docs/configuration.md +1 -1
- {multacd-2.0.0b1 → multacd-2.0.0b2}/docs/index.md +1 -1
- {multacd-2.0.0b1 → multacd-2.0.0b2}/multacd.spec +8 -23
- {multacd-2.0.0b1 → multacd-2.0.0b2}/pyproject.toml +1 -2
- {multacd-2.0.0b1 → multacd-2.0.0b2}/requirements.txt +0 -1
- {multacd-2.0.0b1 → multacd-2.0.0b2}/scripts/install.sh +6 -6
- {multacd-2.0.0b1 → multacd-2.0.0b2}/tests/unit/test_binary.py +3 -1
- {multacd-2.0.0b1 → multacd-2.0.0b2}/tests/unit/test_entrypoint.py +3 -4
- {multacd-2.0.0b1 → multacd-2.0.0b2}/tui/screens/main_screen.py +6 -0
- {multacd-2.0.0b1 → multacd-2.0.0b2}/tui/widgets/info_panel.py +2 -0
- multacd-2.0.0b1/core/llm_client.py +0 -377
- {multacd-2.0.0b1 → multacd-2.0.0b2}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
- {multacd-2.0.0b1 → multacd-2.0.0b2}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
- {multacd-2.0.0b1 → multacd-2.0.0b2}/.github/pull_request_template.md +0 -0
- {multacd-2.0.0b1 → multacd-2.0.0b2}/.github/workflows/test.yml +0 -0
- {multacd-2.0.0b1 → multacd-2.0.0b2}/.gitignore +0 -0
- {multacd-2.0.0b1 → multacd-2.0.0b2}/CONTRIBUTING.md +0 -0
- {multacd-2.0.0b1 → multacd-2.0.0b2}/DESIGN.md +0 -0
- {multacd-2.0.0b1 → multacd-2.0.0b2}/LICENSE +0 -0
- {multacd-2.0.0b1 → multacd-2.0.0b2}/briefing/__init__.py +0 -0
- {multacd-2.0.0b1 → multacd-2.0.0b2}/briefing/generator.py +0 -0
- {multacd-2.0.0b1 → multacd-2.0.0b2}/briefing/privacy.py +0 -0
- {multacd-2.0.0b1 → multacd-2.0.0b2}/briefing/sources/__init__.py +0 -0
- {multacd-2.0.0b1 → multacd-2.0.0b2}/briefing/sources/git_source.py +0 -0
- {multacd-2.0.0b1 → multacd-2.0.0b2}/briefing/sources/news_source.py +0 -0
- {multacd-2.0.0b1 → multacd-2.0.0b2}/briefing/sources/todo_source.py +0 -0
- {multacd-2.0.0b1 → multacd-2.0.0b2}/core/__init__.py +0 -0
- {multacd-2.0.0b1 → multacd-2.0.0b2}/core/clipboard.py +0 -0
- {multacd-2.0.0b1 → multacd-2.0.0b2}/core/codebase.py +0 -0
- {multacd-2.0.0b1 → multacd-2.0.0b2}/core/config.py +0 -0
- {multacd-2.0.0b1 → multacd-2.0.0b2}/core/mode_manager.py +0 -0
- {multacd-2.0.0b1 → multacd-2.0.0b2}/core/permissions.py +0 -0
- {multacd-2.0.0b1 → multacd-2.0.0b2}/core/plugin_loader.py +0 -0
- {multacd-2.0.0b1 → multacd-2.0.0b2}/core/prompt_composer.py +0 -0
- {multacd-2.0.0b1 → multacd-2.0.0b2}/core/research/__init__.py +0 -0
- {multacd-2.0.0b1 → multacd-2.0.0b2}/core/research/bus.py +0 -0
- {multacd-2.0.0b1 → multacd-2.0.0b2}/core/research/orchestrator.py +0 -0
- {multacd-2.0.0b1 → multacd-2.0.0b2}/core/research/query_generator.py +0 -0
- {multacd-2.0.0b1 → multacd-2.0.0b2}/core/updater.py +0 -0
- {multacd-2.0.0b1 → multacd-2.0.0b2}/daemon/__init__.py +0 -0
- {multacd-2.0.0b1 → multacd-2.0.0b2}/daemon/ipc.py +0 -0
- {multacd-2.0.0b1 → multacd-2.0.0b2}/daemon/process.py +0 -0
- {multacd-2.0.0b1 → multacd-2.0.0b2}/docs/install.md +0 -0
- {multacd-2.0.0b1 → multacd-2.0.0b2}/docs/mkdocs.yml +0 -0
- {multacd-2.0.0b1 → multacd-2.0.0b2}/docs/modes/coding.md +0 -0
- {multacd-2.0.0b1 → multacd-2.0.0b2}/docs/modes/personal.md +0 -0
- {multacd-2.0.0b1 → multacd-2.0.0b2}/docs/modes/research.md +0 -0
- {multacd-2.0.0b1 → multacd-2.0.0b2}/docs/plugins.md +0 -0
- {multacd-2.0.0b1 → multacd-2.0.0b2}/docs/quickstart.md +0 -0
- {multacd-2.0.0b1 → multacd-2.0.0b2}/main.py +0 -0
- {multacd-2.0.0b1 → multacd-2.0.0b2}/memory/__init__.py +0 -0
- {multacd-2.0.0b1 → multacd-2.0.0b2}/memory/context.py +0 -0
- {multacd-2.0.0b1 → multacd-2.0.0b2}/memory/store.py +0 -0
- {multacd-2.0.0b1 → multacd-2.0.0b2}/plan/PLAN-phase1.md +0 -0
- {multacd-2.0.0b1 → multacd-2.0.0b2}/plan/PLAN-phase2.md +0 -0
- {multacd-2.0.0b1 → multacd-2.0.0b2}/plugins/README.md +0 -0
- {multacd-2.0.0b1 → multacd-2.0.0b2}/plugins/example_plugin.py +0 -0
- {multacd-2.0.0b1 → multacd-2.0.0b2}/roadmap/ROADMAP.md +0 -0
- {multacd-2.0.0b1 → multacd-2.0.0b2}/scheduler/__init__.py +0 -0
- {multacd-2.0.0b1 → multacd-2.0.0b2}/scheduler/engine.py +0 -0
- {multacd-2.0.0b1 → multacd-2.0.0b2}/scheduler/jobs/__init__.py +0 -0
- {multacd-2.0.0b1 → multacd-2.0.0b2}/scheduler/jobs/briefing_job.py +0 -0
- {multacd-2.0.0b1 → multacd-2.0.0b2}/scheduler/jobs/research_job.py +0 -0
- {multacd-2.0.0b1 → multacd-2.0.0b2}/scripts/install.ps1 +0 -0
- {multacd-2.0.0b1 → multacd-2.0.0b2}/search_providers/__init__.py +0 -0
- {multacd-2.0.0b1 → multacd-2.0.0b2}/search_providers/__main__.py +0 -0
- {multacd-2.0.0b1 → multacd-2.0.0b2}/search_providers/base.py +0 -0
- {multacd-2.0.0b1 → multacd-2.0.0b2}/search_providers/brave.py +0 -0
- {multacd-2.0.0b1 → multacd-2.0.0b2}/search_providers/duckduckgo.py +0 -0
- {multacd-2.0.0b1 → multacd-2.0.0b2}/search_providers/exa.py +0 -0
- {multacd-2.0.0b1 → multacd-2.0.0b2}/search_providers/serpapi.py +0 -0
- {multacd-2.0.0b1 → multacd-2.0.0b2}/search_providers/tavily.py +0 -0
- {multacd-2.0.0b1 → multacd-2.0.0b2}/soul.md +0 -0
- {multacd-2.0.0b1 → multacd-2.0.0b2}/tests/__init__.py +0 -0
- {multacd-2.0.0b1 → multacd-2.0.0b2}/tests/conftest.py +0 -0
- {multacd-2.0.0b1 → multacd-2.0.0b2}/tests/integration/__init__.py +0 -0
- {multacd-2.0.0b1 → multacd-2.0.0b2}/tests/integration/test_agent_loop.py +0 -0
- {multacd-2.0.0b1 → multacd-2.0.0b2}/tests/test_selftests.py +0 -0
- {multacd-2.0.0b1 → multacd-2.0.0b2}/tests/unit/__init__.py +0 -0
- {multacd-2.0.0b1 → multacd-2.0.0b2}/tests/unit/test_config.py +0 -0
- {multacd-2.0.0b1 → multacd-2.0.0b2}/tests/unit/test_docs.py +0 -0
- {multacd-2.0.0b1 → multacd-2.0.0b2}/tests/unit/test_github.py +0 -0
- {multacd-2.0.0b1 → multacd-2.0.0b2}/tests/unit/test_installer.py +0 -0
- {multacd-2.0.0b1 → multacd-2.0.0b2}/tests/unit/test_permissions.py +0 -0
- {multacd-2.0.0b1 → multacd-2.0.0b2}/tests/unit/test_plugin_loader.py +0 -0
- {multacd-2.0.0b1 → multacd-2.0.0b2}/tests/unit/test_privacy_filter.py +0 -0
- {multacd-2.0.0b1 → multacd-2.0.0b2}/tests/unit/test_search_providers/__init__.py +0 -0
- {multacd-2.0.0b1 → multacd-2.0.0b2}/tests/unit/test_search_providers/test_tavily.py +0 -0
- {multacd-2.0.0b1 → multacd-2.0.0b2}/tests/unit/test_tools/__init__.py +0 -0
- {multacd-2.0.0b1 → multacd-2.0.0b2}/tests/unit/test_tools/test_bash.py +0 -0
- {multacd-2.0.0b1 → multacd-2.0.0b2}/tests/unit/test_tools/test_read_file.py +0 -0
- {multacd-2.0.0b1 → multacd-2.0.0b2}/tests/unit/test_tools/test_web_scrape.py +0 -0
- {multacd-2.0.0b1 → multacd-2.0.0b2}/tests/unit/test_tools/test_write_file.py +0 -0
- {multacd-2.0.0b1 → multacd-2.0.0b2}/tests/unit/test_updater.py +0 -0
- {multacd-2.0.0b1 → multacd-2.0.0b2}/tg/__init__.py +0 -0
- {multacd-2.0.0b1 → multacd-2.0.0b2}/tg/access_control.py +0 -0
- {multacd-2.0.0b1 → multacd-2.0.0b2}/tg/agent.py +0 -0
- {multacd-2.0.0b1 → multacd-2.0.0b2}/tg/bot.py +0 -0
- {multacd-2.0.0b1 → multacd-2.0.0b2}/tg/file_handler.py +0 -0
- {multacd-2.0.0b1 → multacd-2.0.0b2}/tg/formatter.py +0 -0
- {multacd-2.0.0b1 → multacd-2.0.0b2}/tg/handlers.py +0 -0
- {multacd-2.0.0b1 → multacd-2.0.0b2}/tools/__init__.py +0 -0
- {multacd-2.0.0b1 → multacd-2.0.0b2}/tools/agent/__init__.py +0 -0
- {multacd-2.0.0b1 → multacd-2.0.0b2}/tools/agent/ask.py +0 -0
- {multacd-2.0.0b1 → multacd-2.0.0b2}/tools/agent/skill.py +0 -0
- {multacd-2.0.0b1 → multacd-2.0.0b2}/tools/agent/task.py +0 -0
- {multacd-2.0.0b1 → multacd-2.0.0b2}/tools/agent/todo_write.py +0 -0
- {multacd-2.0.0b1 → multacd-2.0.0b2}/tools/code/__init__.py +0 -0
- {multacd-2.0.0b1 → multacd-2.0.0b2}/tools/code/lint_python.py +0 -0
- {multacd-2.0.0b1 → multacd-2.0.0b2}/tools/code/run_python.py +0 -0
- {multacd-2.0.0b1 → multacd-2.0.0b2}/tools/code/run_tests.py +0 -0
- {multacd-2.0.0b1 → multacd-2.0.0b2}/tools/codebase/__init__.py +0 -0
- {multacd-2.0.0b1 → multacd-2.0.0b2}/tools/codebase/scan_codebase.py +0 -0
- {multacd-2.0.0b1 → multacd-2.0.0b2}/tools/common.py +0 -0
- {multacd-2.0.0b1 → multacd-2.0.0b2}/tools/filesystem/__init__.py +0 -0
- {multacd-2.0.0b1 → multacd-2.0.0b2}/tools/filesystem/apply_patch.py +0 -0
- {multacd-2.0.0b1 → multacd-2.0.0b2}/tools/filesystem/delete_file.py +0 -0
- {multacd-2.0.0b1 → multacd-2.0.0b2}/tools/filesystem/edit_file.py +0 -0
- {multacd-2.0.0b1 → multacd-2.0.0b2}/tools/filesystem/glob.py +0 -0
- {multacd-2.0.0b1 → multacd-2.0.0b2}/tools/filesystem/grep.py +0 -0
- {multacd-2.0.0b1 → multacd-2.0.0b2}/tools/filesystem/list_dir.py +0 -0
- {multacd-2.0.0b1 → multacd-2.0.0b2}/tools/filesystem/move_file.py +0 -0
- {multacd-2.0.0b1 → multacd-2.0.0b2}/tools/filesystem/multi_edit.py +0 -0
- {multacd-2.0.0b1 → multacd-2.0.0b2}/tools/filesystem/read_file.py +0 -0
- {multacd-2.0.0b1 → multacd-2.0.0b2}/tools/filesystem/read_many_files.py +0 -0
- {multacd-2.0.0b1 → multacd-2.0.0b2}/tools/filesystem/write_file.py +0 -0
- {multacd-2.0.0b1 → multacd-2.0.0b2}/tools/git/__init__.py +0 -0
- {multacd-2.0.0b1 → multacd-2.0.0b2}/tools/git/_helper.py +0 -0
- {multacd-2.0.0b1 → multacd-2.0.0b2}/tools/git/git_add.py +0 -0
- {multacd-2.0.0b1 → multacd-2.0.0b2}/tools/git/git_branch.py +0 -0
- {multacd-2.0.0b1 → multacd-2.0.0b2}/tools/git/git_checkout.py +0 -0
- {multacd-2.0.0b1 → multacd-2.0.0b2}/tools/git/git_commit.py +0 -0
- {multacd-2.0.0b1 → multacd-2.0.0b2}/tools/git/git_diff.py +0 -0
- {multacd-2.0.0b1 → multacd-2.0.0b2}/tools/git/git_log.py +0 -0
- {multacd-2.0.0b1 → multacd-2.0.0b2}/tools/git/git_merge.py +0 -0
- {multacd-2.0.0b1 → multacd-2.0.0b2}/tools/git/git_pull.py +0 -0
- {multacd-2.0.0b1 → multacd-2.0.0b2}/tools/git/git_push.py +0 -0
- {multacd-2.0.0b1 → multacd-2.0.0b2}/tools/git/git_status.py +0 -0
- {multacd-2.0.0b1 → multacd-2.0.0b2}/tools/memory/__init__.py +0 -0
- {multacd-2.0.0b1 → multacd-2.0.0b2}/tools/memory/forget.py +0 -0
- {multacd-2.0.0b1 → multacd-2.0.0b2}/tools/memory/recall.py +0 -0
- {multacd-2.0.0b1 → multacd-2.0.0b2}/tools/memory/remember.py +0 -0
- {multacd-2.0.0b1 → multacd-2.0.0b2}/tools/personal/__init__.py +0 -0
- {multacd-2.0.0b1 → multacd-2.0.0b2}/tools/personal/cancel_job.py +0 -0
- {multacd-2.0.0b1 → multacd-2.0.0b2}/tools/personal/daemon_status.py +0 -0
- {multacd-2.0.0b1 → multacd-2.0.0b2}/tools/personal/generate_briefing.py +0 -0
- {multacd-2.0.0b1 → multacd-2.0.0b2}/tools/personal/get_jobs.py +0 -0
- {multacd-2.0.0b1 → multacd-2.0.0b2}/tools/personal/schedule_job.py +0 -0
- {multacd-2.0.0b1 → multacd-2.0.0b2}/tools/personal/send_telegram.py +0 -0
- {multacd-2.0.0b1 → multacd-2.0.0b2}/tools/personal/user_manager.py +0 -0
- {multacd-2.0.0b1 → multacd-2.0.0b2}/tools/registry.py +0 -0
- {multacd-2.0.0b1 → multacd-2.0.0b2}/tools/research/__init__.py +0 -0
- {multacd-2.0.0b1 → multacd-2.0.0b2}/tools/research/deep_research.py +0 -0
- {multacd-2.0.0b1 → multacd-2.0.0b2}/tools/research/export_research.py +0 -0
- {multacd-2.0.0b1 → multacd-2.0.0b2}/tools/research/quick_research.py +0 -0
- {multacd-2.0.0b1 → multacd-2.0.0b2}/tools/research/web_scrape.py +0 -0
- {multacd-2.0.0b1 → multacd-2.0.0b2}/tools/research/web_search.py +0 -0
- {multacd-2.0.0b1 → multacd-2.0.0b2}/tools/shell/__init__.py +0 -0
- {multacd-2.0.0b1 → multacd-2.0.0b2}/tools/shell/bash.py +0 -0
- {multacd-2.0.0b1 → multacd-2.0.0b2}/tools/streaming.py +0 -0
- {multacd-2.0.0b1 → multacd-2.0.0b2}/tools/web/__init__.py +0 -0
- {multacd-2.0.0b1 → multacd-2.0.0b2}/tools/web/web_fetch.py +0 -0
- {multacd-2.0.0b1 → multacd-2.0.0b2}/tui/__init__.py +0 -0
- {multacd-2.0.0b1 → multacd-2.0.0b2}/tui/app.py +0 -0
- {multacd-2.0.0b1 → multacd-2.0.0b2}/tui/icons.py +0 -0
- {multacd-2.0.0b1 → multacd-2.0.0b2}/tui/screens/__init__.py +0 -0
- {multacd-2.0.0b1 → multacd-2.0.0b2}/tui/screens/setup_wizard.py +0 -0
- {multacd-2.0.0b1 → multacd-2.0.0b2}/tui/themes/__init__.py +0 -0
- {multacd-2.0.0b1 → multacd-2.0.0b2}/tui/themes/__main__.py +0 -0
- {multacd-2.0.0b1 → multacd-2.0.0b2}/tui/tokens.py +0 -0
- {multacd-2.0.0b1 → multacd-2.0.0b2}/tui/widgets/__init__.py +0 -0
- {multacd-2.0.0b1 → multacd-2.0.0b2}/tui/widgets/chat_panel.py +0 -0
- {multacd-2.0.0b1 → multacd-2.0.0b2}/tui/widgets/confirm_dialog.py +0 -0
- {multacd-2.0.0b1 → multacd-2.0.0b2}/tui/widgets/diff_viewer.py +0 -0
- {multacd-2.0.0b1 → multacd-2.0.0b2}/tui/widgets/file_tree.py +0 -0
- {multacd-2.0.0b1 → multacd-2.0.0b2}/tui/widgets/input_bar.py +0 -0
- {multacd-2.0.0b1 → multacd-2.0.0b2}/tui/widgets/model_selector.py +0 -0
- {multacd-2.0.0b1 → multacd-2.0.0b2}/tui/widgets/permission_popup.py +0 -0
- {multacd-2.0.0b1 → multacd-2.0.0b2}/tui/widgets/slash_palette.py +0 -0
- {multacd-2.0.0b1 → multacd-2.0.0b2}/tui/widgets/sources_panel.py +0 -0
- {multacd-2.0.0b1 → multacd-2.0.0b2}/tui/widgets/status_bar.py +0 -0
- {multacd-2.0.0b1 → multacd-2.0.0b2}/tui/widgets/thinking_bar.py +0 -0
- {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
|
|
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
|
|
104
|
-
pip install "
|
|
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.
|
|
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
|
|
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
|
|
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
|
|
@@ -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)")
|