comodor 0.10.2__tar.gz → 0.11.0__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.
- {comodor-0.10.2 → comodor-0.11.0}/PKG-INFO +1 -1
- {comodor-0.10.2 → comodor-0.11.0}/src/comodor/_version.py +2 -2
- comodor-0.11.0/src/comodor/providers/discover.py +240 -0
- {comodor-0.10.2 → comodor-0.11.0}/src/comodor/setup.py +78 -5
- {comodor-0.10.2 → comodor-0.11.0}/src/comodor/web/commands.py +23 -17
- {comodor-0.10.2 → comodor-0.11.0}/src/comodor/web/index.html +40 -0
- {comodor-0.10.2 → comodor-0.11.0}/src/comodor/web/server.py +66 -0
- {comodor-0.10.2 → comodor-0.11.0}/src/comodor/web/session.py +184 -0
- {comodor-0.10.2 → comodor-0.11.0}/src/comodor/web/ui.css +127 -0
- {comodor-0.10.2 → comodor-0.11.0}/src/comodor/web/ui.js +458 -8
- comodor-0.11.0/tests/test_discover.py +229 -0
- {comodor-0.10.2 → comodor-0.11.0}/tests/test_web.py +330 -37
- {comodor-0.10.2 → comodor-0.11.0}/.gitignore +0 -0
- {comodor-0.10.2 → comodor-0.11.0}/LICENSE +0 -0
- {comodor-0.10.2 → comodor-0.11.0}/README.md +0 -0
- {comodor-0.10.2 → comodor-0.11.0}/docs/README.md +0 -0
- {comodor-0.10.2 → comodor-0.11.0}/pyproject.toml +0 -0
- {comodor-0.10.2 → comodor-0.11.0}/src/comodor/__init__.py +0 -0
- {comodor-0.10.2 → comodor-0.11.0}/src/comodor/__main__.py +0 -0
- {comodor-0.10.2 → comodor-0.11.0}/src/comodor/agent/__init__.py +0 -0
- {comodor-0.10.2 → comodor-0.11.0}/src/comodor/agent/context.py +0 -0
- {comodor-0.10.2 → comodor-0.11.0}/src/comodor/agent/loop.py +0 -0
- {comodor-0.10.2 → comodor-0.11.0}/src/comodor/agent/prompts.py +0 -0
- {comodor-0.10.2 → comodor-0.11.0}/src/comodor/agent/spawn.py +0 -0
- {comodor-0.10.2 → comodor-0.11.0}/src/comodor/agent/tokens.py +0 -0
- {comodor-0.10.2 → comodor-0.11.0}/src/comodor/browser/__init__.py +0 -0
- {comodor-0.10.2 → comodor-0.11.0}/src/comodor/browser/cdp.py +0 -0
- {comodor-0.10.2 → comodor-0.11.0}/src/comodor/browser/launch.py +0 -0
- {comodor-0.10.2 → comodor-0.11.0}/src/comodor/browser/page.py +0 -0
- {comodor-0.10.2 → comodor-0.11.0}/src/comodor/catalogue.py +0 -0
- {comodor-0.10.2 → comodor-0.11.0}/src/comodor/cli.py +0 -0
- {comodor-0.10.2 → comodor-0.11.0}/src/comodor/config.py +0 -0
- {comodor-0.10.2 → comodor-0.11.0}/src/comodor/desktop/__init__.py +0 -0
- {comodor-0.10.2 → comodor-0.11.0}/src/comodor/desktop/guard.py +0 -0
- {comodor-0.10.2 → comodor-0.11.0}/src/comodor/desktop/keys.py +0 -0
- {comodor-0.10.2 → comodor-0.11.0}/src/comodor/desktop/overlay.py +0 -0
- {comodor-0.10.2 → comodor-0.11.0}/src/comodor/desktop/png.py +0 -0
- {comodor-0.10.2 → comodor-0.11.0}/src/comodor/desktop/screen.py +0 -0
- {comodor-0.10.2 → comodor-0.11.0}/src/comodor/desktop/win32.py +0 -0
- {comodor-0.10.2 → comodor-0.11.0}/src/comodor/doctor.py +0 -0
- {comodor-0.10.2 → comodor-0.11.0}/src/comodor/events.py +0 -0
- {comodor-0.10.2 → comodor-0.11.0}/src/comodor/help.py +0 -0
- {comodor-0.10.2 → comodor-0.11.0}/src/comodor/learning/__init__.py +0 -0
- {comodor-0.10.2 → comodor-0.11.0}/src/comodor/learning/associations.py +0 -0
- {comodor-0.10.2 → comodor-0.11.0}/src/comodor/learning/bm25.py +0 -0
- {comodor-0.10.2 → comodor-0.11.0}/src/comodor/learning/hotindex.py +0 -0
- {comodor-0.10.2 → comodor-0.11.0}/src/comodor/learning/memory.py +0 -0
- {comodor-0.10.2 → comodor-0.11.0}/src/comodor/learning/progress.py +0 -0
- {comodor-0.10.2 → comodor-0.11.0}/src/comodor/learning/reflect.py +0 -0
- {comodor-0.10.2 → comodor-0.11.0}/src/comodor/learning/rules.py +0 -0
- {comodor-0.10.2 → comodor-0.11.0}/src/comodor/learning/signals.py +0 -0
- {comodor-0.10.2 → comodor-0.11.0}/src/comodor/learning/store.py +0 -0
- {comodor-0.10.2 → comodor-0.11.0}/src/comodor/learning/writer.py +0 -0
- {comodor-0.10.2 → comodor-0.11.0}/src/comodor/mcp/__init__.py +0 -0
- {comodor-0.10.2 → comodor-0.11.0}/src/comodor/mcp/catalogue.py +0 -0
- {comodor-0.10.2 → comodor-0.11.0}/src/comodor/mcp/commands.py +0 -0
- {comodor-0.10.2 → comodor-0.11.0}/src/comodor/mcp/http.py +0 -0
- {comodor-0.10.2 → comodor-0.11.0}/src/comodor/mcp/manager.py +0 -0
- {comodor-0.10.2 → comodor-0.11.0}/src/comodor/mcp/protocol.py +0 -0
- {comodor-0.10.2 → comodor-0.11.0}/src/comodor/migrate.py +0 -0
- {comodor-0.10.2 → comodor-0.11.0}/src/comodor/net/__init__.py +0 -0
- {comodor-0.10.2 → comodor-0.11.0}/src/comodor/net/http.py +0 -0
- {comodor-0.10.2 → comodor-0.11.0}/src/comodor/net/sse.py +0 -0
- {comodor-0.10.2 → comodor-0.11.0}/src/comodor/paths.py +0 -0
- {comodor-0.10.2 → comodor-0.11.0}/src/comodor/providers/__init__.py +0 -0
- {comodor-0.10.2 → comodor-0.11.0}/src/comodor/providers/anthropic.py +0 -0
- {comodor-0.10.2 → comodor-0.11.0}/src/comodor/providers/base.py +0 -0
- {comodor-0.10.2 → comodor-0.11.0}/src/comodor/providers/caching.py +0 -0
- {comodor-0.10.2 → comodor-0.11.0}/src/comodor/providers/fake.py +0 -0
- {comodor-0.10.2 → comodor-0.11.0}/src/comodor/providers/gateway.py +0 -0
- {comodor-0.10.2 → comodor-0.11.0}/src/comodor/providers/openai_compat.py +0 -0
- {comodor-0.10.2 → comodor-0.11.0}/src/comodor/providers/registry.py +0 -0
- {comodor-0.10.2 → comodor-0.11.0}/src/comodor/safety/__init__.py +0 -0
- {comodor-0.10.2 → comodor-0.11.0}/src/comodor/safety/checkpoints.py +0 -0
- {comodor-0.10.2 → comodor-0.11.0}/src/comodor/safety/permissions.py +0 -0
- {comodor-0.10.2 → comodor-0.11.0}/src/comodor/safety/redact.py +0 -0
- {comodor-0.10.2 → comodor-0.11.0}/src/comodor/session/__init__.py +0 -0
- {comodor-0.10.2 → comodor-0.11.0}/src/comodor/session/search.py +0 -0
- {comodor-0.10.2 → comodor-0.11.0}/src/comodor/session/store.py +0 -0
- {comodor-0.10.2 → comodor-0.11.0}/src/comodor/skills/__init__.py +0 -0
- {comodor-0.10.2 → comodor-0.11.0}/src/comodor/skills/catalogue.py +0 -0
- {comodor-0.10.2 → comodor-0.11.0}/src/comodor/skills/commands.py +0 -0
- {comodor-0.10.2 → comodor-0.11.0}/src/comodor/skills/examples.py +0 -0
- {comodor-0.10.2 → comodor-0.11.0}/src/comodor/skills/loader.py +0 -0
- {comodor-0.10.2 → comodor-0.11.0}/src/comodor/skills/propose.py +0 -0
- {comodor-0.10.2 → comodor-0.11.0}/src/comodor/skills/registry.py +0 -0
- {comodor-0.10.2 → comodor-0.11.0}/src/comodor/tools/__init__.py +0 -0
- {comodor-0.10.2 → comodor-0.11.0}/src/comodor/tools/base.py +0 -0
- {comodor-0.10.2 → comodor-0.11.0}/src/comodor/tools/browse.py +0 -0
- {comodor-0.10.2 → comodor-0.11.0}/src/comodor/tools/browser.py +0 -0
- {comodor-0.10.2 → comodor-0.11.0}/src/comodor/tools/computer.py +0 -0
- {comodor-0.10.2 → comodor-0.11.0}/src/comodor/tools/delegate.py +0 -0
- {comodor-0.10.2 → comodor-0.11.0}/src/comodor/tools/fs.py +0 -0
- {comodor-0.10.2 → comodor-0.11.0}/src/comodor/tools/history.py +0 -0
- {comodor-0.10.2 → comodor-0.11.0}/src/comodor/tools/mcp.py +0 -0
- {comodor-0.10.2 → comodor-0.11.0}/src/comodor/tools/overflow.py +0 -0
- {comodor-0.10.2 → comodor-0.11.0}/src/comodor/tools/registry.py +0 -0
- {comodor-0.10.2 → comodor-0.11.0}/src/comodor/tools/search.py +0 -0
- {comodor-0.10.2 → comodor-0.11.0}/src/comodor/tools/shell.py +0 -0
- {comodor-0.10.2 → comodor-0.11.0}/src/comodor/tools/skills.py +0 -0
- {comodor-0.10.2 → comodor-0.11.0}/src/comodor/tools/todo.py +0 -0
- {comodor-0.10.2 → comodor-0.11.0}/src/comodor/tools/web.py +0 -0
- {comodor-0.10.2 → comodor-0.11.0}/src/comodor/ui/__init__.py +0 -0
- {comodor-0.10.2 → comodor-0.11.0}/src/comodor/ui/app.py +0 -0
- {comodor-0.10.2 → comodor-0.11.0}/src/comodor/ui/banner.py +0 -0
- {comodor-0.10.2 → comodor-0.11.0}/src/comodor/ui/bidi.py +0 -0
- {comodor-0.10.2 → comodor-0.11.0}/src/comodor/ui/chooser.py +0 -0
- {comodor-0.10.2 → comodor-0.11.0}/src/comodor/ui/clipboard.py +0 -0
- {comodor-0.10.2 → comodor-0.11.0}/src/comodor/ui/console.py +0 -0
- {comodor-0.10.2 → comodor-0.11.0}/src/comodor/ui/input/__init__.py +0 -0
- {comodor-0.10.2 → comodor-0.11.0}/src/comodor/ui/input/keys.py +0 -0
- {comodor-0.10.2 → comodor-0.11.0}/src/comodor/ui/input/reader.py +0 -0
- {comodor-0.10.2 → comodor-0.11.0}/src/comodor/ui/layout.py +0 -0
- {comodor-0.10.2 → comodor-0.11.0}/src/comodor/ui/markdown.py +0 -0
- {comodor-0.10.2 → comodor-0.11.0}/src/comodor/ui/screen.py +0 -0
- {comodor-0.10.2 → comodor-0.11.0}/src/comodor/ui/theme.py +0 -0
- {comodor-0.10.2 → comodor-0.11.0}/src/comodor/ui/widgets/__init__.py +0 -0
- {comodor-0.10.2 → comodor-0.11.0}/src/comodor/ui/widgets/buttons.py +0 -0
- {comodor-0.10.2 → comodor-0.11.0}/src/comodor/ui/widgets/chat.py +0 -0
- {comodor-0.10.2 → comodor-0.11.0}/src/comodor/ui/widgets/history.py +0 -0
- {comodor-0.10.2 → comodor-0.11.0}/src/comodor/ui/widgets/overlay.py +0 -0
- {comodor-0.10.2 → comodor-0.11.0}/src/comodor/ui/widgets/panel.py +0 -0
- {comodor-0.10.2 → comodor-0.11.0}/src/comodor/ui/widgets/progress.py +0 -0
- {comodor-0.10.2 → comodor-0.11.0}/src/comodor/ui/widgets/prompt.py +0 -0
- {comodor-0.10.2 → comodor-0.11.0}/src/comodor/ui/widgets/statusbar.py +0 -0
- {comodor-0.10.2 → comodor-0.11.0}/src/comodor/ui/widgets/toast.py +0 -0
- {comodor-0.10.2 → comodor-0.11.0}/src/comodor/uninstall.py +0 -0
- {comodor-0.10.2 → comodor-0.11.0}/src/comodor/update.py +0 -0
- {comodor-0.10.2 → comodor-0.11.0}/src/comodor/web/__init__.py +0 -0
- {comodor-0.10.2 → comodor-0.11.0}/src/comodor/web/vazirmatn-OFL.txt +0 -0
- {comodor-0.10.2 → comodor-0.11.0}/src/comodor/web/vazirmatn.woff2 +0 -0
- {comodor-0.10.2 → comodor-0.11.0}/src/comodor/workspace.py +0 -0
- {comodor-0.10.2 → comodor-0.11.0}/tests/conftest.py +0 -0
- {comodor-0.10.2 → comodor-0.11.0}/tests/support/fake_mcp_server.py +0 -0
- {comodor-0.10.2 → comodor-0.11.0}/tests/test_agent_loop.py +0 -0
- {comodor-0.10.2 → comodor-0.11.0}/tests/test_app.py +0 -0
- {comodor-0.10.2 → comodor-0.11.0}/tests/test_associations.py +0 -0
- {comodor-0.10.2 → comodor-0.11.0}/tests/test_banner.py +0 -0
- {comodor-0.10.2 → comodor-0.11.0}/tests/test_bidi.py +0 -0
- {comodor-0.10.2 → comodor-0.11.0}/tests/test_browser.py +0 -0
- {comodor-0.10.2 → comodor-0.11.0}/tests/test_caching.py +0 -0
- {comodor-0.10.2 → comodor-0.11.0}/tests/test_chooser.py +0 -0
- {comodor-0.10.2 → comodor-0.11.0}/tests/test_computer_tool.py +0 -0
- {comodor-0.10.2 → comodor-0.11.0}/tests/test_config_save.py +0 -0
- {comodor-0.10.2 → comodor-0.11.0}/tests/test_copy_and_bands.py +0 -0
- {comodor-0.10.2 → comodor-0.11.0}/tests/test_delegate.py +0 -0
- {comodor-0.10.2 → comodor-0.11.0}/tests/test_desktop.py +0 -0
- {comodor-0.10.2 → comodor-0.11.0}/tests/test_doctor.py +0 -0
- {comodor-0.10.2 → comodor-0.11.0}/tests/test_help.py +0 -0
- {comodor-0.10.2 → comodor-0.11.0}/tests/test_history.py +0 -0
- {comodor-0.10.2 → comodor-0.11.0}/tests/test_import_command.py +0 -0
- {comodor-0.10.2 → comodor-0.11.0}/tests/test_input.py +0 -0
- {comodor-0.10.2 → comodor-0.11.0}/tests/test_layout.py +0 -0
- {comodor-0.10.2 → comodor-0.11.0}/tests/test_learning.py +0 -0
- {comodor-0.10.2 → comodor-0.11.0}/tests/test_markdown.py +0 -0
- {comodor-0.10.2 → comodor-0.11.0}/tests/test_mcp.py +0 -0
- {comodor-0.10.2 → comodor-0.11.0}/tests/test_mcp_http.py +0 -0
- {comodor-0.10.2 → comodor-0.11.0}/tests/test_migrate.py +0 -0
- {comodor-0.10.2 → comodor-0.11.0}/tests/test_overflow.py +0 -0
- {comodor-0.10.2 → comodor-0.11.0}/tests/test_overlay.py +0 -0
- {comodor-0.10.2 → comodor-0.11.0}/tests/test_performance.py +0 -0
- {comodor-0.10.2 → comodor-0.11.0}/tests/test_prefix_stability.py +0 -0
- {comodor-0.10.2 → comodor-0.11.0}/tests/test_progress.py +0 -0
- {comodor-0.10.2 → comodor-0.11.0}/tests/test_propose.py +0 -0
- {comodor-0.10.2 → comodor-0.11.0}/tests/test_providers.py +0 -0
- {comodor-0.10.2 → comodor-0.11.0}/tests/test_real_browser.py +0 -0
- {comodor-0.10.2 → comodor-0.11.0}/tests/test_real_desktop.py +0 -0
- {comodor-0.10.2 → comodor-0.11.0}/tests/test_real_web_ui.py +0 -0
- {comodor-0.10.2 → comodor-0.11.0}/tests/test_reflex.py +0 -0
- {comodor-0.10.2 → comodor-0.11.0}/tests/test_run_loop.py +0 -0
- {comodor-0.10.2 → comodor-0.11.0}/tests/test_setup.py +0 -0
- {comodor-0.10.2 → comodor-0.11.0}/tests/test_setup_import.py +0 -0
- {comodor-0.10.2 → comodor-0.11.0}/tests/test_skill_budget.py +0 -0
- {comodor-0.10.2 → comodor-0.11.0}/tests/test_skill_catalogue.py +0 -0
- {comodor-0.10.2 → comodor-0.11.0}/tests/test_skills.py +0 -0
- {comodor-0.10.2 → comodor-0.11.0}/tests/test_switching_model.py +0 -0
- {comodor-0.10.2 → comodor-0.11.0}/tests/test_tools.py +0 -0
- {comodor-0.10.2 → comodor-0.11.0}/tests/test_uninstall.py +0 -0
- {comodor-0.10.2 → comodor-0.11.0}/tests/test_update.py +0 -0
|
@@ -18,7 +18,7 @@ version_tuple: tuple[int | str, ...]
|
|
|
18
18
|
commit_id: str | None
|
|
19
19
|
__commit_id__: str | None
|
|
20
20
|
|
|
21
|
-
__version__ = version = '0.
|
|
22
|
-
__version_tuple__ = version_tuple = (0,
|
|
21
|
+
__version__ = version = '0.11.0'
|
|
22
|
+
__version_tuple__ = version_tuple = (0, 11, 0)
|
|
23
23
|
|
|
24
24
|
__commit_id__ = commit_id = None
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
"""Credentials and models this machine already has.
|
|
2
|
+
|
|
3
|
+
Setting up an agent means finding an API key, which means a billing page, a
|
|
4
|
+
new secret to keep, and a decision about which provider to pay. That is the
|
|
5
|
+
tedious part, and for a lot of people it is unnecessary: the machine already
|
|
6
|
+
has what is needed and nobody asked it.
|
|
7
|
+
|
|
8
|
+
Two kinds of "already", and they are found differently.
|
|
9
|
+
|
|
10
|
+
**Something running here.** Ollama and LM Studio serve an OpenAI-compatible
|
|
11
|
+
endpoint on a known port and need no key at all. If one is running, the
|
|
12
|
+
correct first offer is not a list of billing pages — it is "this is here, and
|
|
13
|
+
it has these three models". Found by asking the port, because a binary on disk
|
|
14
|
+
is not a server that is up, and the question `/v1/models` answers is exactly
|
|
15
|
+
"are you there, and what can you do".
|
|
16
|
+
|
|
17
|
+
**A key already in the environment.** `OPENROUTER_API_KEY` and its siblings
|
|
18
|
+
are read and applied by the configuration layer already; what was missing was
|
|
19
|
+
saying so. Somebody who exported a key an hour ago should be told it was
|
|
20
|
+
found, not asked to paste it again.
|
|
21
|
+
|
|
22
|
+
Three rules, because this runs on the first-run path and a first run must not
|
|
23
|
+
hang or lie:
|
|
24
|
+
|
|
25
|
+
* **Fast or absent.** A probe that takes two seconds is a setup wizard that
|
|
26
|
+
pauses for no reason on every machine without a local model. The timeout is
|
|
27
|
+
short, the probes run together, and a slow answer is the same as no answer.
|
|
28
|
+
* **Never fatal.** Every failure here is a thing not offered, never an error.
|
|
29
|
+
* **Nothing is claimed that was not seen.** A runtime is reported only if it
|
|
30
|
+
answered, and its models are the ones it listed rather than the ones the
|
|
31
|
+
catalogue guesses.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
from __future__ import annotations
|
|
35
|
+
|
|
36
|
+
import os
|
|
37
|
+
import threading
|
|
38
|
+
from dataclasses import dataclass, field
|
|
39
|
+
from typing import Iterable
|
|
40
|
+
|
|
41
|
+
from .. import catalogue
|
|
42
|
+
|
|
43
|
+
#: Long enough for a server on this machine, short enough that two of them not
|
|
44
|
+
#: being there is not a pause anybody notices. Local means local: a runtime
|
|
45
|
+
#: that needs more than this to say hello is not one to put in front of
|
|
46
|
+
#: somebody on their first run.
|
|
47
|
+
PROBE_TIMEOUT = (0.35, 1.5)
|
|
48
|
+
|
|
49
|
+
#: How many models to name. The rest are still there; this is a first
|
|
50
|
+
#: impression, not a catalogue.
|
|
51
|
+
SHOW_MODELS = 8
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass
|
|
55
|
+
class Running:
|
|
56
|
+
"""A model server answering on this machine."""
|
|
57
|
+
|
|
58
|
+
provider: str
|
|
59
|
+
label: str
|
|
60
|
+
base_url: str
|
|
61
|
+
models: list[str] = field(default_factory=list)
|
|
62
|
+
|
|
63
|
+
@property
|
|
64
|
+
def usable(self) -> bool:
|
|
65
|
+
"""Answering is not enough — it has to have something loaded.
|
|
66
|
+
|
|
67
|
+
Ollama with no models pulled answers `/v1/models` with an empty list.
|
|
68
|
+
Offering it would be offering a provider that fails on the first
|
|
69
|
+
request, which is worse than not offering it.
|
|
70
|
+
"""
|
|
71
|
+
return bool(self.models)
|
|
72
|
+
|
|
73
|
+
@property
|
|
74
|
+
def summary(self) -> str:
|
|
75
|
+
if not self.models:
|
|
76
|
+
return "running, but no models are installed yet"
|
|
77
|
+
shown = ", ".join(self.models[:3])
|
|
78
|
+
more = len(self.models) - 3
|
|
79
|
+
return f"{shown}{f', and {more} more' if more > 0 else ''}"
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@dataclass
|
|
83
|
+
class Held:
|
|
84
|
+
"""A provider whose key is already in the environment."""
|
|
85
|
+
|
|
86
|
+
provider: str
|
|
87
|
+
label: str
|
|
88
|
+
variable: str
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def local_specs() -> list[catalogue.ProviderSpec]:
|
|
92
|
+
"""The catalogue entries that describe something running on this machine."""
|
|
93
|
+
return [spec for spec in catalogue.CATALOGUE
|
|
94
|
+
if not spec.needs_key and spec.base_url
|
|
95
|
+
and ("localhost" in spec.base_url or "127.0.0.1" in spec.base_url)]
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def running_here(specs: Iterable[catalogue.ProviderSpec] | None = None,
|
|
99
|
+
timeout: tuple[float, float] = PROBE_TIMEOUT) -> list[Running]:
|
|
100
|
+
"""Ask each local port whether anything is listening, all at once.
|
|
101
|
+
|
|
102
|
+
Together rather than one after another: two runtimes that are both absent
|
|
103
|
+
would otherwise cost two timeouts, and the whole point is that somebody
|
|
104
|
+
with no local model never notices this ran.
|
|
105
|
+
"""
|
|
106
|
+
found: list[Running] = []
|
|
107
|
+
lock = threading.Lock()
|
|
108
|
+
|
|
109
|
+
def ask(spec: catalogue.ProviderSpec) -> None:
|
|
110
|
+
models = _models_at(spec.base_url, timeout)
|
|
111
|
+
if models is None:
|
|
112
|
+
return
|
|
113
|
+
with lock:
|
|
114
|
+
found.append(Running(provider=spec.id, label=spec.label,
|
|
115
|
+
base_url=spec.base_url,
|
|
116
|
+
models=models[:SHOW_MODELS]))
|
|
117
|
+
|
|
118
|
+
threads = [threading.Thread(target=ask, args=(spec,), daemon=True)
|
|
119
|
+
for spec in (specs if specs is not None else local_specs())]
|
|
120
|
+
for thread in threads:
|
|
121
|
+
thread.start()
|
|
122
|
+
# Joined with a ceiling of their own, so a socket that neither answers nor
|
|
123
|
+
# refuses cannot hold up a first run.
|
|
124
|
+
deadline = sum(timeout) + 0.5
|
|
125
|
+
for thread in threads:
|
|
126
|
+
thread.join(timeout=deadline)
|
|
127
|
+
|
|
128
|
+
found.sort(key=lambda item: (not item.usable, item.provider))
|
|
129
|
+
return found
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _port_is_open(base_url: str, deadline: float) -> bool:
|
|
133
|
+
"""Is anything accepting connections there, on either address family?
|
|
134
|
+
|
|
135
|
+
Asked with a socket before the HTTP client is built, because this is the
|
|
136
|
+
expensive half. `localhost` resolves to both `::1` and `127.0.0.1`, and a
|
|
137
|
+
closed port on Windows drops the connection attempt rather than refusing
|
|
138
|
+
it — so a sequential probe pays the timeout twice for a machine that has
|
|
139
|
+
no local model, which is most machines.
|
|
140
|
+
|
|
141
|
+
Both families are tried at once. Assuming IPv4 would be faster and would
|
|
142
|
+
miss a runtime bound only to `::1`, and something running that does not
|
|
143
|
+
get found is the one outcome this whole module exists to avoid.
|
|
144
|
+
"""
|
|
145
|
+
import socket
|
|
146
|
+
from urllib.parse import urlsplit
|
|
147
|
+
|
|
148
|
+
parts = urlsplit(base_url)
|
|
149
|
+
host, port = parts.hostname or "localhost", parts.port
|
|
150
|
+
if not port:
|
|
151
|
+
port = 443 if parts.scheme == "https" else 80
|
|
152
|
+
|
|
153
|
+
try:
|
|
154
|
+
addresses = socket.getaddrinfo(host, port, type=socket.SOCK_STREAM)
|
|
155
|
+
except OSError:
|
|
156
|
+
return False
|
|
157
|
+
|
|
158
|
+
open_ports: list[bool] = []
|
|
159
|
+
lock = threading.Lock()
|
|
160
|
+
|
|
161
|
+
def knock(family: int, address: tuple) -> None:
|
|
162
|
+
try:
|
|
163
|
+
with socket.socket(family, socket.SOCK_STREAM) as sock:
|
|
164
|
+
sock.settimeout(deadline)
|
|
165
|
+
sock.connect(address)
|
|
166
|
+
except OSError:
|
|
167
|
+
return
|
|
168
|
+
with lock:
|
|
169
|
+
open_ports.append(True)
|
|
170
|
+
|
|
171
|
+
knocks = [threading.Thread(target=knock, args=(family, address), daemon=True)
|
|
172
|
+
for family, _, _, _, address in addresses]
|
|
173
|
+
for thread in knocks:
|
|
174
|
+
thread.start()
|
|
175
|
+
for thread in knocks:
|
|
176
|
+
thread.join(timeout=deadline + 0.2)
|
|
177
|
+
return bool(open_ports)
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _models_at(base_url: str, timeout: tuple[float, float]) -> list[str] | None:
|
|
181
|
+
"""What this endpoint says it can run, or None if nothing answered."""
|
|
182
|
+
from ..net import http
|
|
183
|
+
|
|
184
|
+
if not _port_is_open(base_url, deadline=timeout[0]):
|
|
185
|
+
return None
|
|
186
|
+
|
|
187
|
+
# No retries. A refused connection to a port on this machine is a complete
|
|
188
|
+
# answer the first time, and the default policy turns "nothing is running"
|
|
189
|
+
# into three attempts and two backoffs — which is the whole cost of this,
|
|
190
|
+
# paid by everybody who has no local model.
|
|
191
|
+
session = http.Session(retry=http.Retry(total=0, retry_on_connection_error=False))
|
|
192
|
+
try:
|
|
193
|
+
response = session.get(f"{base_url.rstrip('/')}/models", timeout=timeout)
|
|
194
|
+
if response.status_code >= 400:
|
|
195
|
+
return None
|
|
196
|
+
payload = response.json()
|
|
197
|
+
except Exception:
|
|
198
|
+
# Refused, timed out, answered with something that is not JSON: all of
|
|
199
|
+
# them mean the same thing here, which is that there is nothing to
|
|
200
|
+
# offer.
|
|
201
|
+
return None
|
|
202
|
+
finally:
|
|
203
|
+
try:
|
|
204
|
+
session.close()
|
|
205
|
+
except Exception:
|
|
206
|
+
pass
|
|
207
|
+
|
|
208
|
+
entries = payload.get("data", payload) if isinstance(payload, dict) else payload
|
|
209
|
+
models: list[str] = []
|
|
210
|
+
for entry in entries or []:
|
|
211
|
+
if isinstance(entry, dict) and entry.get("id"):
|
|
212
|
+
models.append(str(entry["id"]))
|
|
213
|
+
elif isinstance(entry, str):
|
|
214
|
+
models.append(entry)
|
|
215
|
+
return sorted(models)
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def keys_in_the_environment() -> list[Held]:
|
|
219
|
+
"""Providers whose key is already exported.
|
|
220
|
+
|
|
221
|
+
The configuration layer reads and applies these on its own; this is only
|
|
222
|
+
so the wizard can say which ones it found. Somebody who exported a key an
|
|
223
|
+
hour ago should be told, not asked to paste it again.
|
|
224
|
+
|
|
225
|
+
The value is never returned — only which variable held it. A first-run
|
|
226
|
+
screen has no business quoting a secret back at the room.
|
|
227
|
+
"""
|
|
228
|
+
held: list[Held] = []
|
|
229
|
+
for spec in catalogue.CATALOGUE:
|
|
230
|
+
if not spec.env_key:
|
|
231
|
+
continue
|
|
232
|
+
if os.environ.get(spec.env_key, "").strip():
|
|
233
|
+
held.append(Held(provider=spec.id, label=spec.label,
|
|
234
|
+
variable=spec.env_key))
|
|
235
|
+
return held
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def anything_ready() -> tuple[list[Running], list[Held]]:
|
|
239
|
+
"""Both kinds, for a caller that wants to open with what is already here."""
|
|
240
|
+
return running_here(), keys_in_the_environment()
|
|
@@ -95,6 +95,7 @@ class SetupWizard:
|
|
|
95
95
|
"""
|
|
96
96
|
if self._keys:
|
|
97
97
|
self.console.clear()
|
|
98
|
+
self._crown()
|
|
98
99
|
self._recap()
|
|
99
100
|
self.console.print()
|
|
100
101
|
self.console.print(
|
|
@@ -104,6 +105,29 @@ class SetupWizard:
|
|
|
104
105
|
)
|
|
105
106
|
)
|
|
106
107
|
|
|
108
|
+
def _crown(self) -> None:
|
|
109
|
+
"""The wordmark, at the top of whatever is about to be drawn.
|
|
110
|
+
|
|
111
|
+
Every question clears the screen, so this is a header rather than a
|
|
112
|
+
repetition — without it each step arrives on a blank terminal with
|
|
113
|
+
nothing on it naming the program doing the asking.
|
|
114
|
+
|
|
115
|
+
It shrinks on a short terminal as well as on a narrow one. Five rows
|
|
116
|
+
of logo on a twenty-row screen is five rows the questions do not get,
|
|
117
|
+
which is the same trade the width rule already makes.
|
|
118
|
+
"""
|
|
119
|
+
from .ui.banner import TAGLINE, wordmark
|
|
120
|
+
|
|
121
|
+
size = self.console.size
|
|
122
|
+
if size.height < 22 or size.width < 51:
|
|
123
|
+
self.console.print(Text.assemble(
|
|
124
|
+
("Comodor", self.theme.style("accent", bold=True)),
|
|
125
|
+
(f" {TAGLINE}", self.theme.style("dim")),
|
|
126
|
+
))
|
|
127
|
+
return
|
|
128
|
+
self.console.print(wordmark(self.theme, size.width))
|
|
129
|
+
self.console.print(Text(f" {TAGLINE}", style=self.theme.style("dim")))
|
|
130
|
+
|
|
107
131
|
def _recap(self) -> None:
|
|
108
132
|
"""The questions already answered, one quiet line each."""
|
|
109
133
|
if not self._done:
|
|
@@ -368,6 +392,38 @@ class SetupWizard:
|
|
|
368
392
|
self.console.print(Text(f" not imported: {note}",
|
|
369
393
|
style=self.theme.style("dim")))
|
|
370
394
|
|
|
395
|
+
def _already_here(self) -> dict[str, str]:
|
|
396
|
+
"""What this machine can already do, said before anything is asked.
|
|
397
|
+
|
|
398
|
+
A local runtime that is up needs no key and no account, and an
|
|
399
|
+
exported key is one somebody found an hour ago. Opening with eighteen
|
|
400
|
+
billing pages when the answer is a port that is already open is asking
|
|
401
|
+
a question that has been answered.
|
|
402
|
+
|
|
403
|
+
Returns a note per provider, for the list to show beside it. Never
|
|
404
|
+
raises: this is on the path that draws the first screen anybody sees.
|
|
405
|
+
"""
|
|
406
|
+
try:
|
|
407
|
+
from .providers import discover
|
|
408
|
+
|
|
409
|
+
running = discover.running_here()
|
|
410
|
+
exported = discover.keys_in_the_environment()
|
|
411
|
+
except Exception:
|
|
412
|
+
return {}
|
|
413
|
+
|
|
414
|
+
notes: dict[str, str] = {}
|
|
415
|
+
for item in running:
|
|
416
|
+
notes[item.provider] = (f"running here — {item.summary}"
|
|
417
|
+
if item.usable else
|
|
418
|
+
"running here, but no models installed yet")
|
|
419
|
+
for item in exported:
|
|
420
|
+
notes.setdefault(item.provider, f"key already in ${item.variable}")
|
|
421
|
+
|
|
422
|
+
# Returned rather than announced. The list leads with these and names
|
|
423
|
+
# their models, so a sentence above it saying the same thing is the
|
|
424
|
+
# same fact twice — and the second telling is the one nobody reads.
|
|
425
|
+
return notes
|
|
426
|
+
|
|
371
427
|
def _ask_skills(self, step: int, total: int) -> list[str]:
|
|
372
428
|
"""Offer the library, once, at the only moment it is not an interruption.
|
|
373
429
|
|
|
@@ -419,9 +475,10 @@ class SetupWizard:
|
|
|
419
475
|
return chosen
|
|
420
476
|
|
|
421
477
|
def _banner(self) -> None:
|
|
478
|
+
|
|
479
|
+
self.console.print()
|
|
480
|
+
self._crown()
|
|
422
481
|
body = Text.assemble(
|
|
423
|
-
("Comodor", self.theme.style("accent", bold=True)),
|
|
424
|
-
(" — it learns the way you correct it.\n\n", self.theme.style("text")),
|
|
425
482
|
("A few questions, once. The answers are saved to\n",
|
|
426
483
|
self.theme.style("dim")),
|
|
427
484
|
(str(self.config.paths.config_file), self.theme.style("value")),
|
|
@@ -430,7 +487,6 @@ class SetupWizard:
|
|
|
430
487
|
("/settings", self.theme.style("accent")),
|
|
431
488
|
(".", self.theme.style("dim")),
|
|
432
489
|
)
|
|
433
|
-
self.console.print()
|
|
434
490
|
self.console.print(Panel(body, box=self.theme.box,
|
|
435
491
|
border_style=self.theme.style("border"),
|
|
436
492
|
padding=(1, 2)))
|
|
@@ -440,6 +496,7 @@ class SetupWizard:
|
|
|
440
496
|
self.console.print(
|
|
441
497
|
Text(" You can add more later; this is just the one to start with.\n",
|
|
442
498
|
style=self.theme.style("dim")))
|
|
499
|
+
here = self._already_here()
|
|
443
500
|
# A provider that already has a key — imported a moment ago, or found
|
|
444
501
|
# in the environment — leads. The key is the expensive part of setting
|
|
445
502
|
# this up, and offering a default that needs a new one, immediately
|
|
@@ -449,11 +506,24 @@ class SetupWizard:
|
|
|
449
506
|
entry = self.config.providers.get(spec.id)
|
|
450
507
|
return bool(entry is not None and entry.api_key)
|
|
451
508
|
|
|
509
|
+
def usable_now(spec: catalogue.ProviderSpec) -> bool:
|
|
510
|
+
"""Nothing left to find before this one can answer.
|
|
511
|
+
|
|
512
|
+
A runtime already up on this machine is the same argument as a key
|
|
513
|
+
already set, taken further: it needs no key at all.
|
|
514
|
+
"""
|
|
515
|
+
return keyed(spec) or spec.id in here
|
|
516
|
+
|
|
452
517
|
offered = list(catalogue.offered())
|
|
453
|
-
ready = [spec for spec in offered if
|
|
454
|
-
rest = [spec for spec in offered if not
|
|
518
|
+
ready = [spec for spec in offered if usable_now(spec)]
|
|
519
|
+
rest = [spec for spec in offered if not usable_now(spec)]
|
|
455
520
|
|
|
456
521
|
def blurb(spec: catalogue.ProviderSpec) -> str:
|
|
522
|
+
# What was found about this one outranks what the catalogue says
|
|
523
|
+
# about it: "running here, three models" is the answer, and the
|
|
524
|
+
# sales line is what you read when there is no answer yet.
|
|
525
|
+
if spec.id in here:
|
|
526
|
+
return here[spec.id]
|
|
457
527
|
if not keyed(spec):
|
|
458
528
|
return spec.blurb
|
|
459
529
|
why = "key imported" if spec.label in self.imported.keys \
|
|
@@ -669,6 +739,9 @@ class SetupWizard:
|
|
|
669
739
|
return done
|
|
670
740
|
|
|
671
741
|
def finish(self, config: Config) -> None:
|
|
742
|
+
if self._keys:
|
|
743
|
+
self.console.clear()
|
|
744
|
+
self._crown()
|
|
672
745
|
entry = config.active()
|
|
673
746
|
body = Text.assemble(
|
|
674
747
|
("Ready.\n\n", self.theme.style("good", bold=True)),
|
|
@@ -66,12 +66,17 @@ def run(config: Config, args: argparse.Namespace) -> int:
|
|
|
66
66
|
|
|
67
67
|
|
|
68
68
|
def _configured_or_explain(config: Config) -> Config | None:
|
|
69
|
-
"""A usable configuration, or
|
|
69
|
+
"""A usable configuration, or one the page will finish setting up.
|
|
70
70
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
71
|
+
This used to refuse to start. That was right while the page had nowhere to
|
|
72
|
+
type a key, and it made the browser interface unreachable for the person
|
|
73
|
+
most likely to want it - somebody whose Comodor is on a server or in a
|
|
74
|
+
container, with no terminal in front of them.
|
|
75
|
+
|
|
76
|
+
So the terminal is still offered the wizard, because it is a nicer place
|
|
77
|
+
to answer questions, and declining it no longer stops anything: the page
|
|
78
|
+
asks instead. What decides whether a key may be typed there is where the
|
|
79
|
+
request comes from, which the server checks per request.
|
|
75
80
|
"""
|
|
76
81
|
if not config.needs_setup:
|
|
77
82
|
return config
|
|
@@ -89,25 +94,26 @@ def _configured_or_explain(config: Config) -> Config | None:
|
|
|
89
94
|
try:
|
|
90
95
|
config = run_setup(config)
|
|
91
96
|
except (KeyboardInterrupt, EOFError):
|
|
92
|
-
|
|
97
|
+
# Cancelled, not refused. The page can finish the job, and
|
|
98
|
+
# stopping here would leave somebody with nothing at all.
|
|
99
|
+
print("\nSetup cancelled — you can finish it in the browser.",
|
|
93
100
|
file=sys.stderr)
|
|
94
|
-
|
|
95
|
-
return None if config.needs_setup else config
|
|
101
|
+
return config
|
|
96
102
|
|
|
97
|
-
# Nobody
|
|
98
|
-
# the
|
|
99
|
-
|
|
100
|
-
|
|
103
|
+
# Nobody at a terminal: a container, a service unit, a pipeline. It starts
|
|
104
|
+
# anyway and the page asks. Said out loud, because a URL that opens onto a
|
|
105
|
+
# form is a surprise worth warning about.
|
|
106
|
+
print("No provider is configured yet — the page will ask when you open it.",
|
|
107
|
+
file=sys.stderr)
|
|
101
108
|
print(file=sys.stderr)
|
|
102
|
-
print("
|
|
109
|
+
print(" To skip that, pass a key in as an environment variable:",
|
|
103
110
|
file=sys.stderr)
|
|
104
111
|
print(" -e ANTHROPIC_API_KEY=... -e OPENAI_API_KEY=...",
|
|
105
112
|
file=sys.stderr)
|
|
106
|
-
print(" or mount a config file at "
|
|
107
|
-
f"{config.paths.config_file}.", file=sys.stderr)
|
|
108
|
-
print(" Anywhere with a terminal, `comodor setup` asks a few questions.",
|
|
113
|
+
print(f" or mount a config file at {config.paths.config_file}.",
|
|
109
114
|
file=sys.stderr)
|
|
110
|
-
|
|
115
|
+
print(file=sys.stderr)
|
|
116
|
+
return config
|
|
111
117
|
|
|
112
118
|
|
|
113
119
|
def banner_shown(server: Server) -> bool:
|
|
@@ -35,6 +35,28 @@
|
|
|
35
35
|
stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">
|
|
36
36
|
<path d="M10 4.5v11M4.5 10h11"/>
|
|
37
37
|
</symbol>
|
|
38
|
+
<symbol id="i-rule" viewBox="0 0 20 20" fill="none" stroke="currentColor"
|
|
39
|
+
stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">
|
|
40
|
+
<path d="M4.5 3.5h11v13h-11z"/><path d="M7 7h6M7 10h6M7 13h3.5"/>
|
|
41
|
+
</symbol>
|
|
42
|
+
<symbol id="i-off" viewBox="0 0 20 20" fill="none" stroke="currentColor"
|
|
43
|
+
stroke-width="1.6" stroke-linecap="round">
|
|
44
|
+
<circle cx="10" cy="10" r="6.6"/><path d="m5.6 5.6 8.8 8.8"/>
|
|
45
|
+
</symbol>
|
|
46
|
+
<symbol id="i-on" viewBox="0 0 20 20" fill="none" stroke="currentColor"
|
|
47
|
+
stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
|
48
|
+
<path d="m4.5 10.4 3.4 3.4 7.6-7.6"/>
|
|
49
|
+
</symbol>
|
|
50
|
+
<symbol id="i-pin" viewBox="0 0 20 20" fill="none" stroke="currentColor"
|
|
51
|
+
stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
|
52
|
+
<path d="M12.4 2.6 17.4 7.6l-2.5 1.2-1.6 4.4-5.5-5.5 4.4-1.6z"/>
|
|
53
|
+
<path d="M7.8 12.2 3.4 16.6"/>
|
|
54
|
+
</symbol>
|
|
55
|
+
<symbol id="i-export" viewBox="0 0 20 20" fill="none" stroke="currentColor"
|
|
56
|
+
stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">
|
|
57
|
+
<path d="M10 13V3.5M6.5 7 10 3.5 13.5 7"/>
|
|
58
|
+
<path d="M3.5 12.5v3a1 1 0 0 0 1 1h11a1 1 0 0 0 1-1v-3"/>
|
|
59
|
+
</symbol>
|
|
38
60
|
<symbol id="i-close" viewBox="0 0 20 20" fill="none" stroke="currentColor"
|
|
39
61
|
stroke-width="1.8" stroke-linecap="round">
|
|
40
62
|
<path d="m5.5 5.5 9 9M14.5 5.5l-9 9"/>
|
|
@@ -143,6 +165,8 @@
|
|
|
143
165
|
<div class="tabs" role="tablist" aria-label="Sidebar">
|
|
144
166
|
<button role="tab" id="tab-chat" aria-selected="true"
|
|
145
167
|
aria-controls="panel-chat">Chat</button>
|
|
168
|
+
<button role="tab" id="tab-rules" aria-selected="false"
|
|
169
|
+
aria-controls="panel-rules">Rules</button>
|
|
146
170
|
<button role="tab" id="tab-admin" aria-selected="false"
|
|
147
171
|
aria-controls="panel-admin">Admin</button>
|
|
148
172
|
</div>
|
|
@@ -164,6 +188,20 @@
|
|
|
164
188
|
<div id="chats" role="list"></div>
|
|
165
189
|
</div>
|
|
166
190
|
|
|
191
|
+
<div class="panel" id="panel-rules" role="tabpanel" aria-labelledby="tab-rules">
|
|
192
|
+
<form class="finder" id="rule-form">
|
|
193
|
+
<label class="field">
|
|
194
|
+
<svg aria-hidden="true"><use href="#i-rule"/></svg>
|
|
195
|
+
<input id="rule-text" type="text" maxlength="300"
|
|
196
|
+
placeholder="Always run the tests before saying done"
|
|
197
|
+
autocomplete="off" aria-label="Write a rule">
|
|
198
|
+
</label>
|
|
199
|
+
<button class="ghost-btn" type="submit" id="rule-add" title="Add this rule"
|
|
200
|
+
aria-label="Add this rule"><svg><use href="#i-new"/></svg></button>
|
|
201
|
+
</form>
|
|
202
|
+
<div id="rules-body"></div>
|
|
203
|
+
</div>
|
|
204
|
+
|
|
167
205
|
<div class="panel" id="panel-admin" role="tabpanel" aria-labelledby="tab-admin">
|
|
168
206
|
<div id="admin-body"></div>
|
|
169
207
|
</div>
|
|
@@ -181,6 +219,8 @@
|
|
|
181
219
|
<div id="log" tabindex="0" aria-label="Conversation">
|
|
182
220
|
<div class="stream" id="stream" aria-live="polite"></div>
|
|
183
221
|
</div>
|
|
222
|
+
|
|
223
|
+
<section id="setup" hidden aria-label="Set Comodor up"></section>
|
|
184
224
|
</main>
|
|
185
225
|
|
|
186
226
|
<!-- --------------------------------------------------------------- foot -->
|
|
@@ -45,6 +45,8 @@ from .session import Session
|
|
|
45
45
|
|
|
46
46
|
#: Loopback names, for deciding whether a warning is owed.
|
|
47
47
|
LOCAL = ("127.0.0.1", "::1", "localhost")
|
|
48
|
+
#: Addresses a request can arrive *from* and still be on this machine.
|
|
49
|
+
LOOPBACK = ("127.0.0.1", "::1", "::ffff:127.0.0.1")
|
|
48
50
|
COOKIE = "comodor_token"
|
|
49
51
|
#: A header no cross-origin form or image can set, so its presence proves the
|
|
50
52
|
#: request came from our own page rather than from somebody else's.
|
|
@@ -180,6 +182,25 @@ def _handler_for(server: Server) -> type[BaseHTTPRequestHandler]:
|
|
|
180
182
|
def _json(self, status: int, payload: Any) -> None:
|
|
181
183
|
self._send(status, json.dumps(payload).encode("utf-8"))
|
|
182
184
|
|
|
185
|
+
def _from_this_machine(self) -> bool:
|
|
186
|
+
"""Whether this request never crossed a network.
|
|
187
|
+
|
|
188
|
+
Asked of the request, not of the bind: a server listening on every
|
|
189
|
+
address still answers loopback, and a rule about the bind would
|
|
190
|
+
have refused that. What it decides is whether an API key may be
|
|
191
|
+
typed into the page - there is no TLS here, and a key is a
|
|
192
|
+
credential with a bill attached.
|
|
193
|
+
|
|
194
|
+
A container is allowed as well. Its loopback is not the operator's
|
|
195
|
+
machine, but the operator chose how to publish the port and was
|
|
196
|
+
told at startup what that choice means.
|
|
197
|
+
"""
|
|
198
|
+
try:
|
|
199
|
+
where = self.client_address[0]
|
|
200
|
+
except (AttributeError, IndexError):
|
|
201
|
+
return False
|
|
202
|
+
return where in LOOPBACK or server.contained
|
|
203
|
+
|
|
183
204
|
def _token(self) -> str:
|
|
184
205
|
cookies = self.headers.get("Cookie") or ""
|
|
185
206
|
for part in cookies.split(";"):
|
|
@@ -280,6 +301,17 @@ def _handler_for(server: Server) -> type[BaseHTTPRequestHandler]:
|
|
|
280
301
|
self._json(200, server.session.admin())
|
|
281
302
|
return
|
|
282
303
|
|
|
304
|
+
if route == "/api/rules":
|
|
305
|
+
self._json(200, server.session.rules())
|
|
306
|
+
return
|
|
307
|
+
|
|
308
|
+
if route == "/api/setup":
|
|
309
|
+
offer = server.session.offer()
|
|
310
|
+
offer["may_enter_a_key"] = self._from_this_machine()
|
|
311
|
+
offer["port"] = server.port
|
|
312
|
+
self._json(200, offer)
|
|
313
|
+
return
|
|
314
|
+
|
|
283
315
|
if route == "/api/events":
|
|
284
316
|
try:
|
|
285
317
|
cursor = int((query.get("cursor") or ["0"])[0])
|
|
@@ -366,6 +398,40 @@ def _handler_for(server: Server) -> type[BaseHTTPRequestHandler]:
|
|
|
366
398
|
server.stop()
|
|
367
399
|
return
|
|
368
400
|
|
|
401
|
+
if route == "/api/setup":
|
|
402
|
+
if not self._from_this_machine():
|
|
403
|
+
self._json(403, {
|
|
404
|
+
"ok": False,
|
|
405
|
+
"error": "There is no TLS here, so a key typed into "
|
|
406
|
+
"this page would cross the network in the "
|
|
407
|
+
"clear. Set it up on the machine Comodor is "
|
|
408
|
+
"running on, or reach this page through an "
|
|
409
|
+
"SSH tunnel."})
|
|
410
|
+
return
|
|
411
|
+
done, why = server.session.set_up(
|
|
412
|
+
str(body.get("provider") or ""),
|
|
413
|
+
api_key=str(body.get("api_key") or ""),
|
|
414
|
+
model=str(body.get("model") or ""),
|
|
415
|
+
base_url=str(body.get("base_url") or ""))
|
|
416
|
+
self._json(200 if done else 400,
|
|
417
|
+
{"ok": done, "error": why,
|
|
418
|
+
"state": server.session.state()})
|
|
419
|
+
return
|
|
420
|
+
|
|
421
|
+
if route == "/api/rules":
|
|
422
|
+
action = str(body.get("action") or "")
|
|
423
|
+
if action == "export":
|
|
424
|
+
done, why, where = server.session.export_rules()
|
|
425
|
+
self._json(200 if done else 500,
|
|
426
|
+
{"ok": done, "error": why, "path": where})
|
|
427
|
+
return
|
|
428
|
+
done, why, extra = server.session.rule(
|
|
429
|
+
action, id=body.get("id"),
|
|
430
|
+
statement=body.get("statement"))
|
|
431
|
+
self._json(200 if done else 400,
|
|
432
|
+
{"ok": done, "error": why, "rule": extra})
|
|
433
|
+
return
|
|
434
|
+
|
|
369
435
|
if route == "/api/setting":
|
|
370
436
|
done, why = server.session.setting(str(body.get("key") or ""),
|
|
371
437
|
body.get("value"))
|