hermes-agent-kit 0.2.1__py3-none-any.whl
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.
- hermes_agent_kit-0.2.1.dist-info/METADATA +139 -0
- hermes_agent_kit-0.2.1.dist-info/RECORD +23 -0
- hermes_agent_kit-0.2.1.dist-info/WHEEL +5 -0
- hermes_agent_kit-0.2.1.dist-info/entry_points.txt +2 -0
- hermes_agent_kit-0.2.1.dist-info/licenses/LICENSE +21 -0
- hermes_agent_kit-0.2.1.dist-info/top_level.txt +1 -0
- hermes_kit/__init__.py +1 -0
- hermes_kit/bridge.py +168 -0
- hermes_kit/cli.py +240 -0
- hermes_kit/hooks/cost_tracker/HOOK.yaml +5 -0
- hermes_kit/hooks/cost_tracker/cost_tracker.yaml +1 -0
- hermes_kit/hooks/cost_tracker/handler.py +30 -0
- hermes_kit/hooks/fallback/HOOK.yaml +4 -0
- hermes_kit/hooks/fallback/fallback_chain.yaml +5 -0
- hermes_kit/hooks/fallback/handler.py +23 -0
- hermes_kit/hooks/rate_limiter/HOOK.yaml +5 -0
- hermes_kit/hooks/rate_limiter/handler.py +48 -0
- hermes_kit/hooks/rate_limiter/rate_limits.yaml +8 -0
- hermes_kit/hooks/router/HOOK.yaml +5 -0
- hermes_kit/hooks/router/handler.py +47 -0
- hermes_kit/hooks/router/topic_router.yaml +7 -0
- hermes_kit/lib/__init__.py +0 -0
- hermes_kit/lib/hermes_api.py +6 -0
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: hermes-agent-kit
|
|
3
|
+
Version: 0.2.1
|
|
4
|
+
Summary: Production hardening pack for Hermes Agent — per-topic model routing, fallback chains, rate limiting, and cost tracking via gateway hooks
|
|
5
|
+
License: MIT
|
|
6
|
+
Requires-Python: >=3.11
|
|
7
|
+
Description-Content-Type: text/markdown
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Requires-Dist: pyyaml>=6
|
|
10
|
+
Dynamic: license-file
|
|
11
|
+
|
|
12
|
+
# hermes-kit
|
|
13
|
+
|
|
14
|
+
Production hardening pack for [Hermes Agent](https://github.com/NousResearch/hermes-agent).
|
|
15
|
+
|
|
16
|
+
Self-hosted Hermes gateways are powerful but built for single-user setups. Multi-user deployments hit walls: no per-topic model routing, API failures surface as hard errors, one heavy user can burn your API budget with no alert.
|
|
17
|
+
|
|
18
|
+
hermes-kit fills these gaps with production-grade hooks.
|
|
19
|
+
|
|
20
|
+
> ⚠️ **How it works**: hermes-kit monkey-patches Hermes Agent's internal model resolver at runtime. This is intentionally fragile — Hermes Agent updates may break your setup. We're working on an upstream PR to replace the patch with native hook return values. Until then, test after every Hermes upgrade.
|
|
21
|
+
|
|
22
|
+
## Prerequisites
|
|
23
|
+
|
|
24
|
+
- Python ≥ 3.11
|
|
25
|
+
- [Hermes Agent](https://github.com/NousResearch/hermes-agent) installed
|
|
26
|
+
- A configured gateway (Telegram, Discord, etc.)
|
|
27
|
+
|
|
28
|
+
## Install
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
pip install hermes-agent-kit
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## Quickstart
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
# Install all hooks in one command
|
|
38
|
+
hermes-kit install router fallback rate-limiter cost-tracker
|
|
39
|
+
|
|
40
|
+
# Verify
|
|
41
|
+
hermes-kit doctor
|
|
42
|
+
|
|
43
|
+
# Start gateway with bridge auto-patched
|
|
44
|
+
hermes-kit gateway run --accept-hooks
|
|
45
|
+
|
|
46
|
+
# If new users get "I don't recognize you":
|
|
47
|
+
GATEWAY_ALLOW_ALL_USERS=true hermes-kit gateway run --accept-hooks
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
Hooks land in `~/.hermes/hooks/<name>/`. Hermes discovers them on restart.
|
|
51
|
+
|
|
52
|
+
## Modules
|
|
53
|
+
|
|
54
|
+
### router — Per-Topic Model Routing
|
|
55
|
+
|
|
56
|
+
Route Telegram topics to different AI models. Finance chat uses Qwen, coding chat uses DeepSeek, everything else falls back to GPT-4o-mini.
|
|
57
|
+
|
|
58
|
+
**Via CLI:**
|
|
59
|
+
```bash
|
|
60
|
+
hermes-kit router set-default --model opencode-go/gpt-4o-mini
|
|
61
|
+
hermes-kit router add 42 --model opencode-go/deepseek-v4-pro
|
|
62
|
+
hermes-kit router show
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
**Via YAML** (`~/.hermes/hooks/router/topic_router.yaml`):
|
|
66
|
+
```yaml
|
|
67
|
+
default:
|
|
68
|
+
model: "opencode-go/gpt-4o-mini"
|
|
69
|
+
|
|
70
|
+
topics:
|
|
71
|
+
"42":
|
|
72
|
+
model: "opencode-go/deepseek-v4-pro"
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
**Multi-provider** — route specific topics to native providers:
|
|
76
|
+
```bash
|
|
77
|
+
hermes-kit router add 42 --model gpt-4o --provider openai
|
|
78
|
+
hermes-kit router add 7 --model claude-sonnet-4-20250514 --provider anthropic
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
Hermes resolves API keys from `~/.hermes/.env` (`OPENAI_API_KEY`, `ANTHROPIC_API_KEY`, etc.). See [providers guide](docs/providers.md) for all supported providers and model IDs.
|
|
82
|
+
|
|
83
|
+
### fallback — Automatic Fallback Chain
|
|
84
|
+
|
|
85
|
+
Define a chain of models to try when the primary fails.
|
|
86
|
+
|
|
87
|
+
**Via YAML** (`~/.hermes/hooks/fallback/fallback_chain.yaml`):
|
|
88
|
+
```yaml
|
|
89
|
+
chains:
|
|
90
|
+
global:
|
|
91
|
+
- "opencode-go/deepseek-v4-pro" # primary
|
|
92
|
+
- "opencode-go/claude-sonnet-4" # fallback
|
|
93
|
+
- "opencode-go/gpt-4o-mini" # last resort
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
After a failure, call `hermes_kit.bridge.retry_with_fallback(session_key)` to advance to the next model.
|
|
97
|
+
|
|
98
|
+
### rate-limiter — Per-User Rate Limiting
|
|
99
|
+
|
|
100
|
+
Prevent a single user or chat from draining your API budget.
|
|
101
|
+
|
|
102
|
+
**Via YAML** (`~/.hermes/hooks/rate-limiter/rate_limits.yaml`):
|
|
103
|
+
```yaml
|
|
104
|
+
limits:
|
|
105
|
+
global:
|
|
106
|
+
max_messages_per_window: 100
|
|
107
|
+
window_seconds: 3600
|
|
108
|
+
per_user:
|
|
109
|
+
"123456789":
|
|
110
|
+
max_messages_per_window: 50
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
> ⚠️ Rate limiter currently tracks usage but does not block messages. Enforcement is planned for an upcoming release.
|
|
114
|
+
|
|
115
|
+
### cost-tracker — Real-Time Cost Tracking
|
|
116
|
+
|
|
117
|
+
Track token costs per session and alert when thresholds are exceeded.
|
|
118
|
+
|
|
119
|
+
**Via YAML** (`~/.hermes/hooks/cost-tracker/cost_tracker.yaml`):
|
|
120
|
+
```yaml
|
|
121
|
+
alert_threshold_usd: 1.0
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
Set to `0` to disable alerts but continue tracking.
|
|
125
|
+
|
|
126
|
+
## Docs
|
|
127
|
+
|
|
128
|
+
- [Quickstart](docs/quickstart.md) — agent-driven and manual install
|
|
129
|
+
- [Providers](docs/providers.md) — supported AI providers and model lists
|
|
130
|
+
- Manual setup per module:
|
|
131
|
+
- [Router](docs/manual/router.md) — per-topic model routing
|
|
132
|
+
- [Fallback](docs/manual/fallback.md) — automatic retry chains
|
|
133
|
+
- [Rate Limiter](docs/manual/rate-limiter.md) — per-user quotas
|
|
134
|
+
- [Cost Tracker](docs/manual/cost-tracker.md) — budget alerts
|
|
135
|
+
- [Troubleshooting](docs/troubleshooting.md) — common issues
|
|
136
|
+
|
|
137
|
+
## License
|
|
138
|
+
|
|
139
|
+
MIT
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
hermes_agent_kit-0.2.1.dist-info/licenses/LICENSE,sha256=gbOQIITSiUtvLsO_NrQePHVPJQ3VMzi0IGIs5MWauGc,1062
|
|
2
|
+
hermes_kit/__init__.py,sha256=MWwqX2Yqp-kKZRorkcUHHdbMRCd4eYKKmPhFVrMXHMg,66
|
|
3
|
+
hermes_kit/bridge.py,sha256=i6ZAgNDrEhGj1x9Pmgh3C0wqUbhr4ynNl42A75V5K7U,5252
|
|
4
|
+
hermes_kit/cli.py,sha256=dQuNqkECaYDsEdUsG89HMqDNEWLWLoYVfV1OHRZPvyA,7081
|
|
5
|
+
hermes_kit/hooks/cost_tracker/HOOK.yaml,sha256=gf-t2oRmRqicBQLWKDPAMTFn4LSFCPIX-pH9d3NaAYA,142
|
|
6
|
+
hermes_kit/hooks/cost_tracker/cost_tracker.yaml,sha256=UiKQ2yLuaWI7J7SN4s5jgZp9_dx00h9HJdbmDkEjxy8,25
|
|
7
|
+
hermes_kit/hooks/cost_tracker/handler.py,sha256=GCutxEh35CeingZPxavp-HIhiy9ZbWsG40-M0YHZFeY,1101
|
|
8
|
+
hermes_kit/hooks/fallback/HOOK.yaml,sha256=CmQARDcxjkE0tCcKsNTZMd0hf_JerNAN6FSZwyqtKo4,108
|
|
9
|
+
hermes_kit/hooks/fallback/fallback_chain.yaml,sha256=Uv5ayi9AkrmRySoeplK1j7oettBPufCf5qQ2RC4r7Is,130
|
|
10
|
+
hermes_kit/hooks/fallback/handler.py,sha256=qTPQWtfhnph3KgNlQTLSRi5RjsxSPykU53sS_MLfiFk,601
|
|
11
|
+
hermes_kit/hooks/rate_limiter/HOOK.yaml,sha256=BZ8R1Dvq4jgBqIr734uJRO6b3TOvWhGwh4uhYO7Kask,136
|
|
12
|
+
hermes_kit/hooks/rate_limiter/handler.py,sha256=K6xt0kr-6_FgCcoJSw3IX3fyGzDO-NRBCCoPVeuE9IQ,1433
|
|
13
|
+
hermes_kit/hooks/rate_limiter/rate_limits.yaml,sha256=3CwSafKtKAA3pleNb7PnNEhkGxL9QlbDFBUhRpGkUpY,174
|
|
14
|
+
hermes_kit/hooks/router/HOOK.yaml,sha256=5smejjub_n1vUfaMAsWWdfWBHXAiGXjYaZJLQQJngpQ,135
|
|
15
|
+
hermes_kit/hooks/router/handler.py,sha256=GrR2PQOr61OcRr9WuP1dQUOf3kC2i3MXhqT0fV1yYpA,1304
|
|
16
|
+
hermes_kit/hooks/router/topic_router.yaml,sha256=UwcdJSGilb4zkm0xAQLfKkLn6zdFaWdiOrLF-Q13AVk,129
|
|
17
|
+
hermes_kit/lib/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
18
|
+
hermes_kit/lib/hermes_api.py,sha256=GEd8DNGH256zr9D_7p80kcvA9QD_0zNz-sBHa_kVqlI,167
|
|
19
|
+
hermes_agent_kit-0.2.1.dist-info/METADATA,sha256=KjFy9nWVtpVmBX_13rkhpjpGkN2mT-xBukZ6C80AxKk,4333
|
|
20
|
+
hermes_agent_kit-0.2.1.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
21
|
+
hermes_agent_kit-0.2.1.dist-info/entry_points.txt,sha256=JM-aLWkod-nGmOnUWMvY0h3cGmHU9SJTDK8m4nkoi6A,51
|
|
22
|
+
hermes_agent_kit-0.2.1.dist-info/top_level.txt,sha256=s9-u5hHpWuLxsybiueBHXzhJHMyZ-jGXB0ASvmkDcR0,11
|
|
23
|
+
hermes_agent_kit-0.2.1.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 srmdn
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
hermes_kit
|
hermes_kit/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""hermes-kit — Production hardening hooks for Hermes Agent."""
|
hermes_kit/bridge.py
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
from typing import Optional
|
|
2
|
+
import time
|
|
3
|
+
|
|
4
|
+
_model_overrides: dict[str, dict[str, Optional[str]]] = {}
|
|
5
|
+
_fallback_chains: dict[str, list[str]] = {}
|
|
6
|
+
_fallback_index: dict[str, int] = {}
|
|
7
|
+
|
|
8
|
+
_rate_counters: dict[str, int] = {}
|
|
9
|
+
_rate_windows: dict[str, float] = {}
|
|
10
|
+
_rate_limited: set[str] = set()
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def set_override(session_key: str, model: str, provider: Optional[str] = None) -> None:
|
|
14
|
+
_model_overrides[session_key] = {
|
|
15
|
+
"model": model,
|
|
16
|
+
"provider": provider,
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def get_override(session_key: str) -> Optional[dict]:
|
|
21
|
+
return _model_overrides.get(session_key)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def clear_override(session_key: str) -> None:
|
|
25
|
+
_model_overrides.pop(session_key, None)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def set_fallback_chain(session_key: str, chain: list[str]) -> None:
|
|
29
|
+
_fallback_chains[session_key] = chain
|
|
30
|
+
_fallback_index[session_key] = 0
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def get_fallback_chain(session_key: str) -> list[str] | None:
|
|
34
|
+
return _fallback_chains.get(session_key)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def advance_fallback(session_key: str) -> None:
|
|
38
|
+
if session_key in _fallback_index:
|
|
39
|
+
_fallback_index[session_key] += 1
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def get_current_fallback(session_key: str) -> str | None:
|
|
43
|
+
chain = _fallback_chains.get(session_key)
|
|
44
|
+
if not chain:
|
|
45
|
+
return None
|
|
46
|
+
idx = _fallback_index.get(session_key, 0)
|
|
47
|
+
if idx < len(chain):
|
|
48
|
+
return chain[idx]
|
|
49
|
+
return None
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def retry_with_fallback(session_key: str) -> str | None:
|
|
53
|
+
advance_fallback(session_key)
|
|
54
|
+
model = get_current_fallback(session_key)
|
|
55
|
+
if model:
|
|
56
|
+
set_override(session_key, model=model)
|
|
57
|
+
return model
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def reset_rate_counter(session_key: str) -> None:
|
|
61
|
+
_rate_counters[session_key] = 0
|
|
62
|
+
_rate_windows[session_key] = time.time()
|
|
63
|
+
_rate_limited.discard(session_key)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def increment_rate_counter(session_key: str) -> int:
|
|
67
|
+
_rate_counters[session_key] = _rate_counters.get(session_key, 0) + 1
|
|
68
|
+
if session_key not in _rate_windows:
|
|
69
|
+
_rate_windows[session_key] = time.time()
|
|
70
|
+
return _rate_counters[session_key]
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def get_rate_window_start(session_key: str) -> float:
|
|
74
|
+
return _rate_windows.get(session_key, 0.0)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def set_rate_limited(session_key: str) -> None:
|
|
78
|
+
_rate_limited.add(session_key)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def is_rate_limited(session_key: str) -> bool:
|
|
82
|
+
return session_key in _rate_limited
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
_session_costs: dict[str, dict[str, float]] = {}
|
|
86
|
+
_cost_pricing: dict[str, tuple[float, float]] = {
|
|
87
|
+
"gpt-4o": (2.50, 10.00),
|
|
88
|
+
"gpt-4o-mini": (0.15, 0.60),
|
|
89
|
+
"claude-sonnet-4": (3.00, 15.00),
|
|
90
|
+
"deepseek-chat": (0.14, 0.28),
|
|
91
|
+
"qwen-3.6-plus": (0.40, 0.80),
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def track_cost(session_key: str, model: str, prompt_tokens: int, completion_tokens: int) -> None:
|
|
96
|
+
if session_key not in _session_costs:
|
|
97
|
+
_session_costs[session_key] = {}
|
|
98
|
+
if model not in _session_costs[session_key]:
|
|
99
|
+
_session_costs[session_key][model] = 0.0
|
|
100
|
+
|
|
101
|
+
input_price, output_price = _cost_pricing.get(model, (0.0, 0.0))
|
|
102
|
+
cost = (prompt_tokens / 1_000_000) * input_price + (completion_tokens / 1_000_000) * output_price
|
|
103
|
+
_session_costs[session_key][model] += cost
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def get_session_cost(session_key: str) -> float:
|
|
107
|
+
if session_key not in _session_costs:
|
|
108
|
+
return 0.0
|
|
109
|
+
return sum(_session_costs[session_key].values())
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def get_session_cost_breakdown(session_key: str) -> dict[str, float]:
|
|
113
|
+
return _session_costs.get(session_key, {})
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def reset_session_cost(session_key: str) -> None:
|
|
117
|
+
_session_costs.pop(session_key, None)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def alert_cost_exceeded(session_key: str, total: float, threshold: float) -> None:
|
|
121
|
+
print(f"[hermes-kit] COST ALERT: session {session_key} total ${total:.4f} exceeds threshold ${threshold:.2f}")
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
_RUNTIME_KEYS = ("provider", "api_key", "base_url", "api_mode")
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _apply_override(override: dict, model: str, runtime_kwargs: dict) -> tuple[str, dict]:
|
|
128
|
+
model = override.get("model", model)
|
|
129
|
+
for key in _RUNTIME_KEYS:
|
|
130
|
+
val = override.get(key)
|
|
131
|
+
if val is not None:
|
|
132
|
+
runtime_kwargs[key] = val
|
|
133
|
+
return model, runtime_kwargs
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def patch_gateway_resolver() -> None:
|
|
137
|
+
import inspect
|
|
138
|
+
from gateway.run import GatewayRunner
|
|
139
|
+
|
|
140
|
+
original = GatewayRunner._resolve_session_agent_runtime
|
|
141
|
+
original_is_async = inspect.iscoroutinefunction(original)
|
|
142
|
+
|
|
143
|
+
if original_is_async:
|
|
144
|
+
async def patched_resolver(self, *args, **kwargs):
|
|
145
|
+
model, runtime_kwargs = await original(self, *args, **kwargs)
|
|
146
|
+
session_key = kwargs.get("session_key") or (args[0] if args else None)
|
|
147
|
+
if session_key:
|
|
148
|
+
override = get_override(session_key)
|
|
149
|
+
if override:
|
|
150
|
+
model, runtime_kwargs = _apply_override(override, model, runtime_kwargs)
|
|
151
|
+
return model, runtime_kwargs
|
|
152
|
+
else:
|
|
153
|
+
def patched_resolver(self, *args, **kwargs):
|
|
154
|
+
model, runtime_kwargs = original(self, *args, **kwargs)
|
|
155
|
+
session_key = kwargs.get("session_key") or (args[0] if args else None)
|
|
156
|
+
if session_key:
|
|
157
|
+
override = get_override(session_key)
|
|
158
|
+
if override:
|
|
159
|
+
model, runtime_kwargs = _apply_override(override, model, runtime_kwargs)
|
|
160
|
+
return model, runtime_kwargs
|
|
161
|
+
|
|
162
|
+
GatewayRunner._resolve_session_agent_runtime = patched_resolver
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
try:
|
|
166
|
+
patch_gateway_resolver()
|
|
167
|
+
except ImportError:
|
|
168
|
+
pass
|
hermes_kit/cli.py
ADDED
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
import shutil
|
|
2
|
+
import sys
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
import yaml
|
|
6
|
+
|
|
7
|
+
from hermes_kit.lib.hermes_api import get_hooks_dir
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
HOOKS_SRC = Path(__file__).parent / "hooks"
|
|
11
|
+
|
|
12
|
+
_hook_dir_names = [d.name for d in HOOKS_SRC.iterdir() if d.is_dir()]
|
|
13
|
+
|
|
14
|
+
_hook_names: dict[str, str] = {}
|
|
15
|
+
_hook_dirs: dict[str, str] = {}
|
|
16
|
+
for _dir in _hook_dir_names:
|
|
17
|
+
_hook_yaml = HOOKS_SRC / _dir / "HOOK.yaml"
|
|
18
|
+
if _hook_yaml.exists():
|
|
19
|
+
_name = yaml.safe_load(_hook_yaml.read_text()).get("name", _dir)
|
|
20
|
+
else:
|
|
21
|
+
_name = _dir
|
|
22
|
+
_hook_names[_dir] = _name
|
|
23
|
+
_hook_dirs[_name] = _dir
|
|
24
|
+
|
|
25
|
+
AVAILABLE_HOOKS = sorted(_hook_dirs.keys())
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def install(hook_name: str) -> None:
|
|
29
|
+
dir_name = _hook_dirs.get(hook_name)
|
|
30
|
+
if not dir_name:
|
|
31
|
+
print(f"Unknown hook: {hook_name}. Available: {', '.join(AVAILABLE_HOOKS)}")
|
|
32
|
+
sys.exit(1)
|
|
33
|
+
|
|
34
|
+
src = HOOKS_SRC / dir_name
|
|
35
|
+
dest = Path(get_hooks_dir()) / hook_name
|
|
36
|
+
if dest.exists():
|
|
37
|
+
print(f"Hook '{hook_name}' already installed. Use 'hermes-kit reinstall {hook_name}' to overwrite.")
|
|
38
|
+
return
|
|
39
|
+
|
|
40
|
+
shutil.copytree(src, dest)
|
|
41
|
+
print(f"Installed '{hook_name}' → {dest}")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def doctor() -> None:
|
|
45
|
+
hooks_dir = Path(get_hooks_dir())
|
|
46
|
+
if not hooks_dir.exists():
|
|
47
|
+
print("OK: No hooks installed yet. Run 'hermes-kit install <name>' to add hooks.")
|
|
48
|
+
return
|
|
49
|
+
|
|
50
|
+
installed = [d.name for d in hooks_dir.iterdir() if d.is_dir()]
|
|
51
|
+
if not installed:
|
|
52
|
+
print("OK: Hooks directory exists but is empty.")
|
|
53
|
+
return
|
|
54
|
+
|
|
55
|
+
for name in installed:
|
|
56
|
+
hook_dir = hooks_dir / name
|
|
57
|
+
hook_yaml = hook_dir / "HOOK.yaml"
|
|
58
|
+
handler = hook_dir / "handler.py"
|
|
59
|
+
|
|
60
|
+
status = []
|
|
61
|
+
if hook_yaml.exists():
|
|
62
|
+
status.append("HOOK.yaml ✓")
|
|
63
|
+
else:
|
|
64
|
+
status.append("HOOK.yaml ✗ (missing)")
|
|
65
|
+
if handler.exists():
|
|
66
|
+
status.append("handler.py ✓")
|
|
67
|
+
else:
|
|
68
|
+
status.append("handler.py ✗ (missing)")
|
|
69
|
+
|
|
70
|
+
print(f" {name}: {' '.join(status)}")
|
|
71
|
+
|
|
72
|
+
print("Done.")
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def gateway_run() -> None:
|
|
76
|
+
from hermes_kit.bridge import patch_gateway_resolver
|
|
77
|
+
|
|
78
|
+
patch_gateway_resolver()
|
|
79
|
+
|
|
80
|
+
sys.argv = ["hermes", "gateway", "run"] + sys.argv[3:]
|
|
81
|
+
|
|
82
|
+
from hermes_cli.main import main as hermes_main
|
|
83
|
+
|
|
84
|
+
hermes_main()
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _router_config_path() -> Path:
|
|
88
|
+
return Path(get_hooks_dir()) / "router" / "topic_router.yaml"
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _read_router_config() -> dict:
|
|
92
|
+
path = _router_config_path()
|
|
93
|
+
if path.exists():
|
|
94
|
+
return yaml.safe_load(path.read_text()) or {}
|
|
95
|
+
return {}
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _write_router_config(config: dict) -> None:
|
|
99
|
+
path = _router_config_path()
|
|
100
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
101
|
+
path.write_text(yaml.dump(config, default_flow_style=False, sort_keys=False))
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def router_add(topic_id: str, model: str, provider: str | None = None) -> None:
|
|
105
|
+
config = _read_router_config()
|
|
106
|
+
topics = config.get("topics") or {}
|
|
107
|
+
entry: dict[str, str] = {"model": model}
|
|
108
|
+
if provider:
|
|
109
|
+
entry["provider"] = provider
|
|
110
|
+
topics[topic_id] = entry
|
|
111
|
+
config["topics"] = topics
|
|
112
|
+
_write_router_config(config)
|
|
113
|
+
provider_msg = f" (provider: {provider})" if provider else ""
|
|
114
|
+
print(f"Added topic '{topic_id}' → {model}{provider_msg}")
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def router_remove(topic_id: str) -> None:
|
|
118
|
+
config = _read_router_config()
|
|
119
|
+
topics = config.get("topics") or {}
|
|
120
|
+
if topic_id in topics:
|
|
121
|
+
del topics[topic_id]
|
|
122
|
+
_write_router_config(config)
|
|
123
|
+
print(f"Removed topic '{topic_id}'.")
|
|
124
|
+
else:
|
|
125
|
+
print(f"Topic '{topic_id}' not found.")
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def router_show() -> None:
|
|
129
|
+
config = _read_router_config()
|
|
130
|
+
default = config.get("default") or {}
|
|
131
|
+
topics = config.get("topics") or {}
|
|
132
|
+
|
|
133
|
+
if default:
|
|
134
|
+
line = f"Default: {default.get('model', 'not set')}"
|
|
135
|
+
if default.get("provider"):
|
|
136
|
+
line += f" (provider: {default['provider']})"
|
|
137
|
+
print(line)
|
|
138
|
+
if topics:
|
|
139
|
+
if default:
|
|
140
|
+
print()
|
|
141
|
+
for tid, route in topics.items():
|
|
142
|
+
line = f" {tid} → {route.get('model', 'unknown')}"
|
|
143
|
+
if route.get("provider"):
|
|
144
|
+
line += f" (provider: {route['provider']})"
|
|
145
|
+
print(line)
|
|
146
|
+
if not default and not topics:
|
|
147
|
+
print("No routing configured.")
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def router_set_default(model: str, provider: str | None = None) -> None:
|
|
151
|
+
config = _read_router_config()
|
|
152
|
+
entry: dict[str, str] = {"model": model}
|
|
153
|
+
if provider:
|
|
154
|
+
entry["provider"] = provider
|
|
155
|
+
config["default"] = entry
|
|
156
|
+
_write_router_config(config)
|
|
157
|
+
provider_msg = f" (provider: {provider})" if provider else ""
|
|
158
|
+
print(f"Default model: {model}{provider_msg}")
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _parse_flag(flag: str, args: list[str]) -> str | None:
|
|
162
|
+
try:
|
|
163
|
+
idx = args.index(flag)
|
|
164
|
+
return args[idx + 1]
|
|
165
|
+
except (ValueError, IndexError):
|
|
166
|
+
return None
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def main() -> None:
|
|
170
|
+
if len(sys.argv) < 2:
|
|
171
|
+
print("Usage: hermes-kit <install|doctor|list|gateway run|router>")
|
|
172
|
+
sys.exit(1)
|
|
173
|
+
|
|
174
|
+
cmd = sys.argv[1]
|
|
175
|
+
|
|
176
|
+
if cmd == "install":
|
|
177
|
+
if len(sys.argv) < 3:
|
|
178
|
+
print(f"Usage: hermes-kit install <hook> [<hook> ...]. Available: {', '.join(AVAILABLE_HOOKS)}")
|
|
179
|
+
sys.exit(1)
|
|
180
|
+
for hook_name in sys.argv[2:]:
|
|
181
|
+
install(hook_name)
|
|
182
|
+
|
|
183
|
+
elif cmd == "doctor":
|
|
184
|
+
doctor()
|
|
185
|
+
|
|
186
|
+
elif cmd == "list":
|
|
187
|
+
print("Available hooks:")
|
|
188
|
+
for h in AVAILABLE_HOOKS:
|
|
189
|
+
installed = " (installed)" if (Path(get_hooks_dir()) / h).exists() else ""
|
|
190
|
+
print(f" {h}{installed}")
|
|
191
|
+
|
|
192
|
+
elif cmd == "gateway":
|
|
193
|
+
if len(sys.argv) < 3 or sys.argv[2] != "run":
|
|
194
|
+
print("Usage: hermes-kit gateway run [--accept-hooks] [-- ...]")
|
|
195
|
+
sys.exit(1)
|
|
196
|
+
gateway_run()
|
|
197
|
+
|
|
198
|
+
elif cmd == "router":
|
|
199
|
+
if len(sys.argv) < 3:
|
|
200
|
+
print("Usage: hermes-kit router <add|remove|show|set-default> [args]")
|
|
201
|
+
sys.exit(1)
|
|
202
|
+
|
|
203
|
+
subcmd = sys.argv[2]
|
|
204
|
+
args = sys.argv[3:]
|
|
205
|
+
|
|
206
|
+
if subcmd == "show":
|
|
207
|
+
router_show()
|
|
208
|
+
|
|
209
|
+
elif subcmd == "add":
|
|
210
|
+
topic_id = args[0] if args else None
|
|
211
|
+
model = _parse_flag("--model", args)
|
|
212
|
+
if not topic_id or not model:
|
|
213
|
+
print("Usage: hermes-kit router add <topic-id> --model <model> [--provider <provider>]")
|
|
214
|
+
sys.exit(1)
|
|
215
|
+
router_add(topic_id, model, provider=_parse_flag("--provider", args))
|
|
216
|
+
|
|
217
|
+
elif subcmd == "remove":
|
|
218
|
+
if not args:
|
|
219
|
+
print("Usage: hermes-kit router remove <topic-id>")
|
|
220
|
+
sys.exit(1)
|
|
221
|
+
router_remove(args[0])
|
|
222
|
+
|
|
223
|
+
elif subcmd == "set-default":
|
|
224
|
+
model = _parse_flag("--model", args)
|
|
225
|
+
if not model:
|
|
226
|
+
print("Usage: hermes-kit router set-default --model <model> [--provider <provider>]")
|
|
227
|
+
sys.exit(1)
|
|
228
|
+
router_set_default(model, provider=_parse_flag("--provider", args))
|
|
229
|
+
|
|
230
|
+
else:
|
|
231
|
+
print(f"Unknown router command: {subcmd}")
|
|
232
|
+
sys.exit(1)
|
|
233
|
+
|
|
234
|
+
else:
|
|
235
|
+
print(f"Unknown command: {cmd}")
|
|
236
|
+
sys.exit(1)
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
if __name__ == "__main__":
|
|
240
|
+
main()
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
alert_threshold_usd: 1.0
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import yaml
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from hermes_kit import bridge
|
|
4
|
+
|
|
5
|
+
_ALERT_THRESHOLD: float = 0.0
|
|
6
|
+
_hook_dir = Path(__file__).parent
|
|
7
|
+
_cfg_path = _hook_dir / "cost_tracker.yaml"
|
|
8
|
+
if _cfg_path.exists():
|
|
9
|
+
raw = yaml.safe_load(_cfg_path.read_text()) or {}
|
|
10
|
+
_ALERT_THRESHOLD = raw.get("alert_threshold_usd", 0.0)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
async def handle(event_type: str, context: dict) -> None:
|
|
14
|
+
session_key = context.get("session_key")
|
|
15
|
+
if not session_key:
|
|
16
|
+
return
|
|
17
|
+
|
|
18
|
+
if event_type == "agent:step":
|
|
19
|
+
usage = context.get("usage", {})
|
|
20
|
+
if usage:
|
|
21
|
+
prompt_tokens = usage.get("prompt_tokens", 0)
|
|
22
|
+
completion_tokens = usage.get("completion_tokens", 0)
|
|
23
|
+
model = context.get("model", "unknown")
|
|
24
|
+
bridge.track_cost(session_key, model, prompt_tokens, completion_tokens)
|
|
25
|
+
|
|
26
|
+
elif event_type == "agent:end":
|
|
27
|
+
total = bridge.get_session_cost(session_key)
|
|
28
|
+
if total > 0 and _ALERT_THRESHOLD > 0 and total > _ALERT_THRESHOLD:
|
|
29
|
+
bridge.alert_cost_exceeded(session_key, total, _ALERT_THRESHOLD)
|
|
30
|
+
bridge.reset_session_cost(session_key)
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import yaml
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from hermes_kit import bridge
|
|
4
|
+
|
|
5
|
+
_CHAINS: dict[str, list[str]] = {}
|
|
6
|
+
_hook_dir = Path(__file__).parent
|
|
7
|
+
_chain_path = _hook_dir / "fallback_chain.yaml"
|
|
8
|
+
if _chain_path.exists():
|
|
9
|
+
raw = yaml.safe_load(_chain_path.read_text()) or {}
|
|
10
|
+
_CHAINS = raw.get("chains", {})
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
async def handle(event_type: str, context: dict) -> None:
|
|
14
|
+
if event_type != "agent:start":
|
|
15
|
+
return
|
|
16
|
+
|
|
17
|
+
session_key = context.get("session_key")
|
|
18
|
+
if not session_key:
|
|
19
|
+
return
|
|
20
|
+
|
|
21
|
+
chain = _CHAINS.get("global")
|
|
22
|
+
if chain:
|
|
23
|
+
bridge.set_fallback_chain(session_key, chain)
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import time
|
|
2
|
+
import yaml
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from hermes_kit import bridge
|
|
5
|
+
|
|
6
|
+
_LIMITS: dict = {}
|
|
7
|
+
_hook_dir = Path(__file__).parent
|
|
8
|
+
_limits_path = _hook_dir / "rate_limits.yaml"
|
|
9
|
+
if _limits_path.exists():
|
|
10
|
+
raw = yaml.safe_load(_limits_path.read_text()) or {}
|
|
11
|
+
_LIMITS = raw.get("limits", {})
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _get_limit(user_id: str) -> dict | None:
|
|
15
|
+
per_user = _LIMITS.get("per_user", {})
|
|
16
|
+
if user_id in per_user:
|
|
17
|
+
return per_user[user_id]
|
|
18
|
+
return _LIMITS.get("global")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _is_within_window(window_start: float, window_seconds: int) -> bool:
|
|
22
|
+
return (time.time() - window_start) < window_seconds
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
async def handle(event_type: str, context: dict) -> None:
|
|
26
|
+
session_key = context.get("session_key")
|
|
27
|
+
if not session_key:
|
|
28
|
+
return
|
|
29
|
+
|
|
30
|
+
limit = _get_limit(context.get("user_id", ""))
|
|
31
|
+
if not limit:
|
|
32
|
+
return
|
|
33
|
+
|
|
34
|
+
if event_type == "session:start":
|
|
35
|
+
bridge.reset_rate_counter(session_key)
|
|
36
|
+
|
|
37
|
+
elif event_type == "agent:step":
|
|
38
|
+
count = bridge.increment_rate_counter(session_key)
|
|
39
|
+
window_seconds = limit.get("window_seconds", 3600)
|
|
40
|
+
max_messages = limit.get("max_messages_per_window", 100)
|
|
41
|
+
|
|
42
|
+
window_start = bridge.get_rate_window_start(session_key)
|
|
43
|
+
if not _is_within_window(window_start, window_seconds):
|
|
44
|
+
bridge.reset_rate_counter(session_key)
|
|
45
|
+
return
|
|
46
|
+
|
|
47
|
+
if count > max_messages:
|
|
48
|
+
bridge.set_rate_limited(session_key)
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import yaml
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from hermes_kit import bridge
|
|
4
|
+
|
|
5
|
+
_ROUTING: dict[str, dict] = {}
|
|
6
|
+
_DEFAULT: dict | None = None
|
|
7
|
+
_hook_dir = Path(__file__).parent
|
|
8
|
+
_routing_path = _hook_dir / "topic_router.yaml"
|
|
9
|
+
if _routing_path.exists():
|
|
10
|
+
raw = yaml.safe_load(_routing_path.read_text()) or {}
|
|
11
|
+
_ROUTING = raw.get("topics", {})
|
|
12
|
+
_DEFAULT = raw.get("default")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _extract_routing_id(context: dict) -> str | None:
|
|
16
|
+
session_key = context.get("session_key", "")
|
|
17
|
+
chat_id = context.get("chat_id")
|
|
18
|
+
if not session_key:
|
|
19
|
+
return None
|
|
20
|
+
|
|
21
|
+
parts = session_key.split(":")
|
|
22
|
+
if len(parts) >= 5:
|
|
23
|
+
return parts[4]
|
|
24
|
+
|
|
25
|
+
return chat_id
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
async def handle(event_type: str, context: dict) -> None:
|
|
29
|
+
session_key = context.get("session_key")
|
|
30
|
+
if not session_key:
|
|
31
|
+
return
|
|
32
|
+
|
|
33
|
+
if event_type == "session:start":
|
|
34
|
+
routing_id = _extract_routing_id(context)
|
|
35
|
+
if routing_id:
|
|
36
|
+
route = _ROUTING.get(routing_id)
|
|
37
|
+
if not route:
|
|
38
|
+
route = _DEFAULT
|
|
39
|
+
if route:
|
|
40
|
+
bridge.set_override(
|
|
41
|
+
session_key,
|
|
42
|
+
model=route["model"],
|
|
43
|
+
provider=route.get("provider"),
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
elif event_type == "session:reset":
|
|
47
|
+
bridge.clear_override(session_key)
|
|
File without changes
|