Repository navigation
Expand file tree
/
Copy path__init__.py
More file actions
293 lines (248 loc) · 11.4 KB
/
Copy path__init__.py
File metadata and controls
293 lines (248 loc) · 11.4 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
"""Muse Code subscription provider — Muse chat models billed to the monthly sub.
No third-party CLI required. Authentication is a Meta device-code login
(performed once via ``muse_code_login.py``): the device flow mints a
stable, account-bound inference key that this provider reads from a local
credential cache. The flow parameters are compatible with the published
behavior of oh-my-pi (MIT-licensed; see NOTICE).
Resolution order: an explicitly configured ``MUSE_CODE_SUB_TOKEN`` wins;
otherwise the cached key from the login step. Any failure is a silent
miss (provider simply shows as unconfigured); misses are debug-logged
without secret material.
Credential cache: ``$HERMES_HOME/muse-code-sub.json`` (override with
``MUSE_CODE_SUB_CREDENTIALS``).
Inference goes through a loopback wire adapter (``muse_adapter.py``): Meta
refuses subscription requests whose flat tool names collide with Muse Code's
builtin names, so the adapter aliases exactly those names on the wire and
restores them in the response. If the adapter cannot bind a loopback port,
the provider keeps the direct endpoint and logs a warning.
The Hermes imports are guarded: outside the Hermes runtime (pytest
collection, linters) the module stays importable and registers nothing.
"""
import json
import logging
import os
logger = logging.getLogger(__name__)
ENV_VAR = "MUSE_CODE_SUB_TOKEN"
CREDENTIALS_ENV_VAR = "MUSE_CODE_SUB_CREDENTIALS"
CREDENTIALS_FILENAME = "muse-code-sub.json"
# Direct upstream and fallback endpoint; the profile normally points at the
# loopback wire adapter started by _adapter_base_url().
DEFAULT_BASE_URL = "https://api.meta.ai/v1"
# The subscription key is exported into the process environment because
# Hermes resolves api_key providers from env_vars (providers/base.py and
# agent/client_lifecycle.py); there is no provider callback for a raw key.
# Only values written here are touched on refresh; an explicit user value wins.
_ENV_KEY_OWNED: str | None = None
try:
from providers import register_provider
from providers.base import ProviderProfile
except ImportError:
_HERMES_AVAILABLE = False
_start_adapter = None
else:
_HERMES_AVAILABLE = True
try:
from .muse_adapter import start_adapter as _start_adapter
except ImportError: # pragma: no cover - only a broken install layout
_start_adapter = None
def default_credentials_path() -> str:
"""Location of the device-login credential cache."""
override = os.getenv(CREDENTIALS_ENV_VAR, "").strip()
if override:
return override
home = os.getenv("HERMES_HOME", "").strip() or os.path.join(
os.path.expanduser("~"), ".hermes"
)
return os.path.join(home, CREDENTIALS_FILENAME)
def read_cached_key(path: str | None = None) -> str:
"""Return the cached subscription key, or "" when absent/unusable."""
try:
with open(path or default_credentials_path(), encoding="utf-8") as fh:
blob = json.load(fh)
except Exception:
return ""
if not isinstance(blob, dict):
return ""
key = blob.get("apiKey", "")
return key.strip() if isinstance(key, str) and key.strip() else ""
def _ensure_subscription_key() -> None:
"""Keep the provider env var aligned with the login cache.
Refresh the value when the cache changed, remove it when the cache is gone,
and never overwrite a value the user set explicitly.
"""
global _ENV_KEY_OWNED
explicit = os.getenv(ENV_VAR, "").strip()
if explicit and explicit != _ENV_KEY_OWNED:
_ENV_KEY_OWNED = None
return
key = read_cached_key()
if key:
if key != _ENV_KEY_OWNED:
os.environ[ENV_VAR] = key
_ENV_KEY_OWNED = key
elif _ENV_KEY_OWNED is not None:
os.environ.pop(ENV_VAR, None)
_ENV_KEY_OWNED = None
def _adapter_base_url() -> str:
"""Start the loopback wire adapter once and return the provider base URL.
Meta refuses subscription requests whose flat tool names collide with Muse
Code's builtin names; ``muse_adapter`` aliases exactly those names on the
wire and restores them in the response. Starting is idempotent, so repeated
imports and registrations reuse the same server. If no loopback port can be
bound, keep the direct endpoint (the pre-0.6 behavior) and warn: only the
exception type is logged, never credentials or payloads.
"""
if _start_adapter is None:
return DEFAULT_BASE_URL
try:
adapter = _start_adapter(log=logger.debug)
except Exception as error: # noqa: BLE001 - a bind failure must not break import
logger.warning(
"Muse Code wire adapter unavailable (%s); using %s directly",
type(error).__name__,
DEFAULT_BASE_URL,
)
return DEFAULT_BASE_URL
return adapter.base_url
def _refresh_base_url() -> None:
"""Point the registered profile at the adapter, or at the fallback endpoint."""
base_url = _adapter_base_url()
if muse_code.base_url != base_url:
muse_code.base_url = base_url
def register(ctx) -> None:
"""PluginManager entry point (also required by `hermes plugins validate`).
Provider registration already happened at import; re-read the cache so
a login that completed after import is picked up, and retry the wire
adapter in case the first bind failed. Stdlib-only, so the admission probe
can run it in a bare interpreter.
"""
if _HERMES_AVAILABLE:
_ensure_subscription_key()
_refresh_base_url()
# Meta Model API (Muse) wire vocabulary, vendored from Hermes core
# (agent/reasoning_effort.py, META_AI_EFFORTS @ Hermes v0.22.0): Meta
# rejects ``none``. Catalog rule 9 forbids third-party plugins from
# importing private core modules, so the two names the bundled meta-ai
# profile imports are copied here instead of imported. Like base_url and
# api_mode below, this is a Meta wire fact the plugin already pins; if
# Meta's accepted levels ever change, update this tuple (and the pin test).
_META_WIRE_EFFORTS = ("minimal", "low", "medium", "high", "xhigh")
# Canonical low→high ladder, vendored alongside the tuple above: clamp
# targets resolve against this ordering.
_EFFORT_LADDER = ("none", "minimal", "low", "medium", "high", "xhigh", "max", "ultra")
def _clamp_effort(effort, supported):
"""Clamp a requested effort onto a wire's supported levels.
Same contract as Hermes core ``clamp_effort`` for this call shape
(no vendor overrides on the Meta wire): verbatim when supported,
unknown, or empty; else the nearest weaker supported level, never
escalating cost; else the weakest supported level. ``none`` is
never a degradation target.
"""
requested = str(effort or "").strip().lower()
if not requested or not supported:
return effort
supported_norm = [lvl for lvl in (str(s).strip().lower() for s in supported) if lvl in _EFFORT_LADDER]
if not supported_norm or requested in supported_norm:
return effort
if requested not in _EFFORT_LADDER:
return effort
candidates = [level for level in supported_norm if level != "none"]
if not candidates:
return effort
requested_idx = _EFFORT_LADDER.index(requested)
below = [level for level in candidates if _EFFORT_LADDER.index(level) < requested_idx]
return max(below, key=_EFFORT_LADDER.index) if below else min(candidates, key=_EFFORT_LADDER.index)
def _classify_client_restriction(error, *, status_code, error_code, message, body, model):
"""Distinguish Meta's client restriction from a revoked credential."""
if status_code != 403:
return None
text = str(message or "").lower()
if error_code == "client_attestation_denied" or (
"muse code subscriptions cover requests from the muse code client only" in text
):
return {
"reason": "provider_policy_blocked",
"retryable": False,
"should_rotate_credential": False,
}
return None
def _run_browser_login(*, open_browser=True):
from .muse_code_login import main
return main([], open_browser=open_browser)
def _auth_handler(action, args):
if action != "add":
return False
if _run_browser_login(open_browser=not getattr(args, "no_browser", False)) != 0:
raise SystemExit("Muse Code login failed.")
_ensure_subscription_key()
return True
if _HERMES_AVAILABLE:
_ensure_subscription_key()
# Intentional parity copy of the bundled `meta-ai` provider profile: the
# wire quirks below (Responses API, reasoning_effort mapping, vision
# limits) must stay in sync with it; do not "simplify" them away.
# The effort clamp is vendored above (catalog rule 9 forbids importing
# it); keep it in sync with agent/reasoning_effort.py too.
class MuseCodeSubscriptionProfile(ProviderProfile):
"""Meta Model API via the Muse Code subscription."""
# Dynamic discovery: admit every model id the live account catalog
# returns, except the endpoint families verified as non-chat
# (segmentation, image, voice). New chat models appear without a
# plugin update; only a brand-new non-chat family needs an entry here.
_NON_CHAT_PREFIXES = ("sam-", "muse-image-", "muse-voice-")
def fetch_models(
self,
*,
api_key: str | None = None,
base_url: str | None = None,
timeout: float = 8.0,
) -> list[str] | None:
_ensure_subscription_key()
live = super().fetch_models(
api_key=api_key, base_url=base_url, timeout=timeout
)
if live is None:
return None
return [
m
for m in live
if not any(m.startswith(p) for p in self._NON_CHAT_PREFIXES)
]
def build_api_kwargs_extras(
self,
*,
reasoning_config: dict | None = None,
supports_reasoning: bool = False,
**context,
):
rc = reasoning_config or {}
effort = str(rc.get("effort") or "").strip().lower()
if rc.get("enabled") is False or effort == "none":
mapped = "minimal"
else:
clamped = _clamp_effort(effort, _META_WIRE_EFFORTS)
mapped = clamped if clamped in _META_WIRE_EFFORTS else "medium"
return {}, {"reasoning_effort": mapped}
muse_code = MuseCodeSubscriptionProfile(
name="muse-code",
display_name="Muse Code (subscription)",
description="Muse chat models billed to the local Muse Code monthly login",
signup_url="https://github.com/TheStreamCode/hermes-muse-code#login-once",
env_vars=(ENV_VAR,),
# Loopback wire adapter in front of api.meta.ai; DEFAULT_BASE_URL when
# the adapter could not bind and the provider runs direct.
base_url=_adapter_base_url(),
auth_type="api_key",
auth_handler=_auth_handler,
classify_api_error=_classify_client_restriction,
# Responses API engages Muse prompt caching; same wire as meta-ai.
api_mode="codex_responses",
supports_vision=True,
supports_vision_tool_messages=False,
default_max_tokens=16384,
# default_aux_model intentionally unset (= main model): the bundled
# profile's contributor-tier default would silently opt background
# compression into data training.
fallback_models=("muse-spark-1.3",),
)
register_provider(muse_code)