Both wired against features the iron-proxy author (@mslipper) confirmed on
PR #30179 — and both verified present in the pinned v0.39.0 source.
Header-auth providers (match_headers):
- New _HEADER_AUTH_PROVIDERS: Anthropic native (x-api-key), Azure OpenAI
(api-key on *.openai.azure.com / *.cognitiveservices / *.services.ai),
Gemini (x-goog-api-key + ?key= query param via match_query).
- TokenMapping grows match_headers + alias_env_names; per-provider header
sets flow into the secrets rules; mappings.json roundtrips them
(legacy files load with the Authorization default).
- GEMINI_API_KEY / GOOGLE_API_KEY collapse into ONE mapping (two
require-rules on the same host would reject each other); the sandbox
gets the token under both names, and the proxy child env mirrors the
alias into the canonical name when only the alias is set.
- Docker backend injects alias env names alongside canonical ones.
- The fail-closed tier is now empty, so fail_on_uncovered_providers and
discover_blocked_providers are deleted (dead toggle otherwise);
_NON_BEARER_PROVIDERS shrinks to genuinely-unswappable signature auth
(AWS SigV4, GCP service-account OAuth) — warn-only, as before.
Management API (hot reload):
- Generated proxy.yaml enables the v0.39 management listener: loopback
only at tunnel_port+2, bearer key from HERMES_IRON_PROXY_MGMT_KEY.
- Key minted at setup (management.token, 0600); start_proxy injects it
(v0.39 refuses to start when api_key_env is empty).
- hermes egress reload -> POST /v1/reload: re-reads proxy.yaml and
atomically swaps the pipeline; 422 leaves the running ruleset
untouched; actionable errors for not-running / pre-management config /
key mismatch. Secrets changes still require restart (daemon env is
read at spawn) — the CLI says so.
Validation: 218/218 unit+CLI+docker tests; 3/3 gated live E2E against the
real v0.39.0 binary (Authorization swap, x-api-key swap, live reload with
token rotation on the same pid). Docs updated.
Two review findings on the #57922 salvage:
1. Stale inline comment at the login-exchange site still claimed the token
endpoint uses the claude-code/ UA prefix and 404s claude-cli/ — now
contradicts the axios/ fix. Repointed it at _OAUTH_TOKEN_USER_AGENT.
2. The inherited Path.home test isolation on the three TestRefreshOauthToken
tests only stubbed the ~/.claude *file* source, not the macOS Keychain.
_refresh_oauth_token re-reads read_claude_code_credentials() (keychain
first) in its adopt-already-refreshed branch, so on any macOS dev/CI runner
with real Claude Code creds the branch short-circuits and the 3 tests fail.
Stub read_claude_code_credentials -> None so the tests are hermetic.
(The remaining TestResolveAnthropicToken/TestResolveWithRefresh/TestRunOauthSetupToken
failures on macOS are the same pre-existing keychain-leak class on origin/main,
unrelated to this OAuth-UA fix, and pass in CI — left out of scope.)
hermes auth add anthropic fails 100% at token exchange with HTTP 429 while
Claude Code /login succeeds through the same client_id/redirect/scope. The
discriminator is the User-Agent on the /v1/oauth/token request.
Verified live against platform.claude.com (throwaway code, nothing burned):
claude-code/2.1.200 (external, cli) -> 429 rate_limit (Hermes, blocked)
Mozilla/5.0 -> 429 rate_limit
axios/1.7.9 -> 400 invalid_grant (reached validation)
node / empty / SDK-style UAs -> 400 invalid_grant
Anthropic now rate-limits token-endpoint requests whose UA starts with
claude-code/ (the anti-abuse net for Max-sub-as-API-key). This is the same
prefix-block shape that #48534 first hit on claude-cli/, then #56263 dodged by
switching to claude-code/ — which held ~2 weeks and is now blocked too. Bumping
_CLAUDE_CODE_VERSION_FALLBACK cannot help; the gate is prefix-based.
Fix: shared _OAUTH_TOKEN_USER_AGENT (axios/) on the token endpoint only — the
two refresh POSTs (refresh_anthropic_oauth_pure) and the login exchange POST
(run_hermes_oauth_login_pure). The real Claude Code CLI exchanges the auth code
with a bare axios client, NOT its claude-code/ inference UA.
The INFERENCE client (build_anthropic_kwargs, /v1/messages) is deliberately left
on claude-code/ + x-app: cli — that fingerprint is required there and is NOT
throttled on the messages API. Two endpoints, opposite UA requirements.
Also isolate two _refresh_oauth_token tests from live ~/.claude creds and update
the UA regression tests to assert the split (token endpoint uses a
non-claude-code UA while inference keeps claude-code/).
Verified E2E: Hermes' own login path now returns 400 (past the 429 wall)
instead of 429, using the real _OAUTH_TOKEN_USER_AGENT constant against the live
platform.claude.com token endpoint.
Salvaged from #57922 (authorize-host + scope changes dropped as non-load-bearing;
they only add a redirect hop back to claude.ai and the UA fix alone clears 429).
repair_message_sequence Pass 1 registered only tc.get("id") when building
the set of known assistant tool_call ids, then matched tool results against
it by tool_call_id. In the Codex Responses format an assistant tool_call
carries both id (fc_...) and a distinct call_id (call_...); a tool result's
tool_call_id may be keyed on either depending on which builder produced it.
Registering only id made a valid tool result whose tool_call_id matched
call_id look orphaned, so the pass dropped it and left the assistant
tool_call unanswered -- producing HTTP 400 on strict providers (DeepSeek,
Kimi): 'Messages with role tool must be a response to a preceding message
with tool_calls'. Long-running sessions that persisted such a sequence were
permanently broken, re-sending the orphan every turn.
Register both id and call_id for each assistant tool_call so a result
matching either key is recognized, consistent with
AIAgent._get_tool_call_id_static and the compressor's _sanitize_tool_pairs.
Apply the same call_id||id precedence to the corrupted-args sanitizer's
existing-result scan / stub insertion, which had the identical mismatch.
Adds 3 regression tests covering the codex id!=call_id case (match on
call_id, match on only call_id, match on id when both present).
On X11 a window's PID comes from the optional _NET_WM_PID property, so
cua-driver's list_windows legitimately returns pid: null for windows that
don't set it (desktop root, panels, override-redirect popups). capture()
and focus_app() coerced every entry via int(w["pid"]) inside a list
comprehension, so a single null-pid window raised TypeError and aborted
the whole enumeration before any screenshot — capture was impossible on
any X11 desktop with even one such window.
Route both ingestion sites through a new _ingest_windows() helper that
skips entries lacking a usable pid/window_id (uncapturable anyway) and
coerces the rest.
Adds tests/tools/test_computer_use_null_pid_windows.py covering the
helper's filtering/coercion and an end-to-end capture() regression that
reproduced the crash.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The #22773 heuristic classified any telegram:<positive_chat_id>:<numeric_thread_id>
cron target as a Bot API channel Direct-Messages topic and routed it via
direct_messages_topic_id, which nulls message_thread_id. A normal forum-style
topic inside a private chat has the identical shape, so every such cron
delivery landed in General instead of the target thread (#52060). It also
means the only way to address a genuine channel DM topic was this same
ambiguous guess.
Disambiguate with the real runtime signal instead: probe the live adapter's
get_chat_info once and route via direct_messages_topic_id only when the chat
is actually a channel; everything else (private forum topic, forum supergroup,
group) and any probe failure fall back to message_thread_id — parity with
0.16.0 and with live reply routing. This fixes forum-topic delivery AND makes
genuine channel DM-topic cron delivery work correctly.
PR #57601's original branch added a top-level reasoning_effort emit to the
LEGACY build_kwargs path (agent/transports/chat_completions.py), but
provider=custom resolves to CustomProfile (plugins/model-providers/custom/),
so chat_completion_helpers takes the profile path and returns early — the
added branch was unreachable dead code for every custom endpoint.
Move the fix to its real site, CustomProfile.build_api_kwargs_extras(), and
follow the DeepSeek/Zai profile precedent:
- disabled -> extra_body.think = False (unchanged)
- enabled + effort -> TOP-LEVEL reasoning_effort (the OpenAI-compatible
format GLM-5.2/ARK expect), passed through verbatim
incl. max/xhigh
- enabled + no effort -> omit, so the endpoint's server default applies
(avoids silently forcing 'medium' as the original
branch did)
Deliberately does NOT force think=True on enable — that flag is Ollama-only
and risks a 400 on GLM/vLLM endpoints that don't recognize it; thinking is
already server-default-on for these backends.
Verified end-to-end through the real profile dispatch (temp HERMES_HOME):
custom+high -> reasoning_effort=high; custom+max -> reasoning_effort=max;
custom+none -> think=False; custom+unset -> nothing; num_ctx composes.
Adds tests/plugins/model_providers/test_custom_profile.py (13 cases).
Addresses the custom-provider half of #55276.
Co-authored-by: huanshan5195 <huanshan5195@users.noreply.github.com>
Follow-up to salvaged #57601. Adding "max" to VALID_REASONING_EFFORTS
made parse_reasoning_effort("max") valid, so:
- test_unknown_levels_return_none no longer lists "max" (it is now valid;
auto-covered by test_each_valid_level which iterates the tuple).
- test_known_supported_levels_are_documented and the parse_reasoning_effort
docstring now include "max" so the doc-sync guard actually protects it.
Follow-up to the salvaged SSH-tilde-cwd fix. The predicate
"backend == ssh and (cwd == ~ or cwd.startswith(~/))" was inlined at
each expanduser guard site, which is how the test simulator drifted from
production (it grew an SSH guard on a top-level-alias branch that has no
production counterpart).
- Add tools/terminal_tool._is_ssh_remote_tilde_cwd(backend, cwd) as the
single source of truth (case/whitespace-tolerant).
- Use it in _get_env_config and the gateway config bridge.
- Test simulator imports the real helper instead of re-implementing the
predicate; revert the phantom SSH guard on the top-level-alias branch
(production maps top-level cwd: to a plain env var, not TERMINAL_CWD via
an SSH-guarded path — that branch tested nothing real).
Follow-up to the salvaged #58000 fix.
- Extract _raise_if_non_interactive(lead) so the shared 'hermes mcp login'
next-step wording lives in one place across both OAuth boundaries
(_redirect_handler, _wait_for_callback), rather than two copy-pasted
inline raises. Boundary-specific lead sentences preserved verbatim, so
existing message-match tests stay green.
- Add a positive-control test asserting the guard does NOT over-fire on the
interactive path (valid/refreshable tokens keep working), satisfying an
explicit regression-coverage line from #57836.
Add TestNonInteractiveFailFastAtCallbackBoundary: the callback boundary must
reject before binding a listener and without entering the poll loop, the guard
must hold even when a (stale) token file exists on disk, the redirect handler
must not print a URL or open a browser, and both boundaries must point users at
`hermes mcp login`.
Mark the existing timeout test and the SSH-hint redirect tests interactive so
they exercise their intended paths rather than short-circuiting on the new
non-interactive guard.
The salvaged max-output/pressure fix set conversation_history=None after
the new pre-API compaction. That is only correct for legacy session-
rotation. Under the default in-place compaction (compression.in_place:
True), archive_and_compact inserts the compacted rows into the session DB
directly without stamping them with the intrinsic persisted-marker, so a
subsequent flush with conversation_history=None re-appends them — doubling
the active context and retriggering compression (the early-persist
duplicate-row trap).
Use conversation_history_after_compression(agent, messages), matching the
two existing compaction sites (post-response should_compress and the
turn-prologue preflight), which returns None for rotation and
list(messages) for in-place so the compacted dicts are skipped by identity.
Adds a regression test with a real SessionDB + real archive_and_compact
that asserts the compacted summary row is persisted exactly once (fails
with 2 copies on the None variant).
Follow-up to the salvaged #57845 fix. _can_carry_marker used
any(isinstance(part, dict)) but _apply_cache_marker only marks the LAST
content part, so a list whose last element is a non-dict passed the carrier
gate yet received no marker — wasting one of the four breakpoints. Tighten
the predicate to require content[-1] to be a dict (mirroring the apply
logic) and add a regression test. Flagged by a 3-agent review.
Gateway users can now search resumable sessions from messaging surfaces:
/sessions search <query> (alias: find) matches titles and session ids —
including every title/id in a row's forward compression chain, so a
compressed-away title still surfaces its live tip — plus a
punctuation-normalized variant so 'an94' matches 'AN-94'.
Implemented by generalizing the existing id_query chain-filter in
SessionDB.list_sessions_rich into a combined SQL-level filter (search
stays ORDER BY last-active + LIMIT at SQL level), threading a
search_query through the shared query_session_listing helper, and
teaching parse_session_listing_args to split off a search query.
Search results pass through the existing _resume_row_visible guard
unchanged: origin scoping, admin-only 'all', and the fail-closed
legacy-row posture from the July 1 hardening are preserved exactly.
Over-fetch (50) before the visibility cut so origin-invisible matches
can't starve the page.
Salvages the feature direction of PR #57595 by @GodsBoy with a minimal
implementation that keeps the resume authorization surface untouched.
Post-merge follow-ups + several review rounds + a hub-search rework, folded together.
Merge-scuff restores (a stale-base refactor had reverted two live-on-main fixes):
- gateway: SessionStore compression-tip healing + its regression test.
- desktop: messaging session/transcript polling in desktop-controller
(MESSAGING_POLL / ACTIVE_MESSAGING_SESSION_POLL, refreshMessagingSessions,
refreshActiveMessagingTranscript, the richer sameCronSignature) so inbound
platform traffic updates live again instead of freezing until manual refresh.
Profile-switch isolation (epoch/close/guard on every profile-scoped async):
- Hub store clears + in-flight runHubAction bails (and swallows the post-switch
404 instead of a phantom toast); hub preview/scan/search/sources profile-scoped.
- MCP: probe/auth epoch guards, dirty-draft reset, sidebar mutations blocked
until config resettles AND every persist re-checks the epoch post-await;
profilePending clears on config settle incl. error; logs re-key on profile.
- Model settings reload on switch and epoch-guard setModelAssignment /
saveMoaModels / API-key activation.
- Config draft resets + cancels its autosave on switch; skill editor/archive and
star-map node dialogs close on switch; openSkillEditor / star-map openEdit
discard stale fetches; tool-usage analytics loads are profile-guarded/keyed.
Correctness + UX:
- Unique per-skill action names for hub install AND uninstall; hub/catalog rows
flip only on a clean exit_code; catalog install polls the background bootstrap
to completion, reconciles the mcp.json draft (no dropped server), and fails
loudly on non-zero exit; MCP catalog query keyed by profile.
- /test reports needs-auth for anonymous auth:oauth servers; /auth snapshots +
restores tokens on a failed re-auth and clears the full 300s callback window.
- config-settings shows a retry on load failure; CodeEditor/JsonDocumentEditor
go read-only while saving so edits typed mid-save aren't dropped.
- Deep-link highlighter deletes its param only after a successful scroll.
- Restored the PageSearchShell trailing slot → Artifacts refresh button/spinner.
- /settings?tab=mcp redirect keeps server=.
Progressive hub search: fan out one query per backend-searchable source
(index-covered API sources stay unsearchable → no ~70-call GitHub re-hammer),
merge/dedupe by trust as each lands, per-source spinner overlaid on the dimmed
chip — results stream in without blocking on the slowest, no layout shift.
test(web): /api/skills list carries usage + provenance (CI contract).
from a live blocked-sites pass (no PII):
- posture shift: blind opt-out is the DEFAULT, not a fallback -- submit on every site with an
accessible removal channel even without first confirming a listing (own identifiers to the broker's
own official channel = still least-disclosure). guided flows double as the authoritative search.
- blocked-form rule: when a form is automation-hostile (hard captcha / cloudflare / datadome /
slide-to-verify), default to the broker's CITED rights-email rather than recording blocked.
- captcha policy clarified: never defeat behavioral/token/slider challenges; ok to read a static
distorted-text or plain-arithmetic captcha on the subject's own opt-out; stop if the whole
submission is rejected after a correct answer (fingerprinting the automation, not grading it).
- intelius/peopleconnect: delete-wipes-suppression is field-confirmed -- a deletion-complete email
means the suppression is gone and the subject re-lists cluster-wide; re-run suppression and verify
the Control step reads "suppressed". guided-mode session persists; DOB is an <input type=date>.
- new records: addresses.json (intelius front-end, cluster-covered) and socialcatfish.json
(cited rights-email lane + automation-hostile form).
- new references/site-playbooks.md: per-site game-plan matrix (8 blocked-tail sites), the meta-search
no-op skip-list (idcrawl/lullar/yasni/webmii/namesdir/itools/skipease), and the infopay /
peopleconnect backend clusters. OSINT-list triage taxonomy added to methods.md.
- state-machine.md: fixed doc drift + documented submitted->not_found illegal (resolves as
awaiting_processing), blocked->submitted via action_selected, operator_manual_check, --evidence & pitfall.
tests: standalone 99, PR 97 (+1 cluster-coverage regression); ruff + windows-footguns clean.
Route the app off its hand-rolled helpers onto lib/{text,time,format,json-format}
and the new primitives, plus assorted small tidy-ups:
- compactNumber for counts/tokens; normalize/capitalize/asText at the many
filter/label sites; shared Intl date/time formatters; row-hover + framed
editor adoption; scrollbar-gutter + padding parity on list surfaces.
- Messaging/Artifacts/Cron search hints + narrow-viewport tab dropdown;
floating-pet adopts useOnProfileSwitch; number formatting in statusbar,
command-center, agents.
- Electron: native overlay width + backend spawn tidy.
- Settings > Keys: credential fields read as plain subtext (all-unset) until
the group is focused or expanded, then take full input chrome with no
horizontal/vertical shift; inline Remove (trash) + Save mirror SearchField's
trailing-clear pattern instead of a floating hint that overlapped the card;
Esc still cancels. Drops the now-dead or/escToCancel i18n keys.
- Shared TabDropdown/ResponsiveTabs (components/ui): PageSearchShell and the
Command Center log file/level filters reuse the one narrow-width collapse.
- OverlayNav: data-driven pane nav — persistent rail on wide, a single dropdown
riding the titlebar strip on narrow; Settings and Command Center adopt it, and
the mobile dropdown carries the same section icons as the rail. Fixes narrow
vertical centering, redundant mobile section titles, gateway-status wrap, and
Panel master/detail stacking.
- OverlayIconButton is now the titlebar ghost button, matching the close X at
every size. Settings sub-view nav opens section + sub-view in one navigate so
API-keys/accounts actually open on narrow.
- Settings > Model: cube icon (was the {} namespace glyph) and a DOM-shaped
skeleton in place of the centered spinner.
- Command palette / session switcher clear the macOS traffic lights on small
screens.
- Prettier/eslint sweep across the touched files.
phase-2 work (sending webmail, clearing session-bound gates like peopleconnect guided-mode) needs
the operator's own logged-in browser, not a cloud browser. new `pdd.py cdp`:
- finds chrome/chromium/brave/edge (macos/linux/windows), launches it detached on a dedicated debug
profile ($HERMES_HOME/chrome-debug) with --remote-debugging-port, waits for the port, prints the
CDP endpoint (webSocketDebuggerUrl)
- `--check`: report whether a debug browser is already live (never double-launches)
- `--print`: emit the exact command for the operator to run themselves
- doctor, SKILL.md, and methods.md all point at it
- windows-safe detach (start_new_session on posix, DETACHED_PROCESS on windows); stdlib only
tests: standalone 98, PR 96 (+6 cdp); ruff + windows-footguns clean.
from a live run (NY subject, 43 brokers):
- fanout default 8->5 (8+ batches time out)
- setup/doctor read $HERMES_HOME/.env so creds hermes already loads are detected
- new `show <subject> <broker>`: reads back case state+evidence for cheap parent re-verify
- intelius: requires.dob + 5-step guided-mode gate; planner pre-warns when dob is missing
- rehold.json: property-record != PII (an address-only match is not_found, not removable)
- tps/fps: match_signal_notes tell the scanner to ignore SEO-templated titles
- methods.md: browser backends (scan vs execute + operator chrome over CDP), property/SEO callouts
- doctor: warn when browser email-mode pairs with a cloud scan backend (needs operator chrome/CDP)
- ledger: found->not_found retract (false-positive), blocked->human_task_queued
- autopilot: indirect-exposure web-form fallback; drop a stray f-string
tests: standalone 92 pass; ruff clean.
A plugin-registered WebSearchProvider with no built-in provider credentials
must light up web_search / web_extract and be discoverable by the backend
selectors. Covers check_web_api_key(), _get_backend(), _is_backend_available()
registry delegation, per-capability extract selection (#32698), and that the
web_search / web_extract tool registry entries are not filtered out.
Tests contributed by @m0n5t3r (PR #28652, issue #28651).
Asserts the behavior contract that run_one_job installs a profile secret
scope around run_job under multiplexing (so resolve_runtime_provider's
get_secret does not fail-close with UnscopedSecretError) and tears it
down afterward. Mutation-verified: fails on unmodified main with the
exact UnscopedSecretError, passes with the fix.
Fold the xAI video credential-read guard into the same shared
agent.file_safety.raise_if_read_blocked chokepoint this PR introduces for
the image providers, so the whole image+video bug class is covered by one
enforced boundary. Consolidates the parallel salvage of #57695 (xAI
image+video) into this PR; #57727 is now redundant and will be closed.
- video_gen/xai: guard _image_ref_to_xai_url and _video_ref_to_xai_url
(the video image + video byte-read chokepoints) via the shared helper.
- Regression tests: symlinked auth.json with .png/.mp4 names are blocked
across both video read paths (mutation-checked).
Follow-up to the per-provider guards. Three improvements from review:
1. Extract agent.file_safety.raise_if_read_blocked() as a single shared
chokepoint and route the OpenAI, OpenRouter, and (newly) xAI image
providers through it, replacing the 3x-duplicated inline try/except.
Fixes the whole bug class: xai/_xai_image_field read a model-supplied
local path via open() with no guard — the same vulnerability the PR
fixed for OpenAI/OpenRouter, in a sibling provider it missed.
2. Strengthen the regression tests from pass-on-any-ValueError to true
security invariants: spy open()/read_bytes() and assert the blocked
credential is NEVER read; add negative controls (legit local image
still loads; remote/data: URIs pass through unguarded) so a
block-everything regression can't pass.
3. Guard is best-effort by design (defense-in-depth, not a security
boundary) — documented on the shared helper.
- agent/file_safety.py: raise_if_read_blocked()
- plugins/image_gen/{openai,openrouter,xai}: route through helper
- tests: no-read spies + negative controls across all three providers
Five follow-ups to #57659 from post-merge review:
1. install.ps1: gateway scheduled-task re-enable now runs in a finally
(a thrown Remove-Item/uv venv failure previously stranded the user's
gateway autostart disabled), and tasks that were already disabled
before the install are no longer blindly re-enabled.
2. The venv-python holder guard is no longer bypassed by plain --force
(which the desktop bootstrap passes on every update while its lock
probe only checks hermes.exe/app.asar). New explicit --force-venv is
the escape hatch; --force keeps bypassing only the hermes.exe shim
guard.
3. _detect_venv_python_processes now also catches uv/base-interpreter
trampolines whose exe is outside the venv, via cmdline (venv path or
'-m hermes_cli.main' tied to this install root) and cwd.
4. Missing venv python is now UNHEALTHY on managed installs
(.hermes-bootstrap-complete / .update-incomplete markers) so the
repair lane runs instead of 'Already up to date!'; the repair branch
recreates the venv first when it's gone entirely. Dev checkouts keep
reporting healthy.
5. install.ps1 comment no longer claims a Startup-folder disarm the
code doesn't perform (logon-only, not a mid-install respawner).
The salvaged tests from #53754 predate _handle_vision_analyze becoming
async and the native fast path; await the handler and force the legacy
aux path so the model-resolution assertion is actually exercised.
_handlers for vision_analyze and video_analyze read model name from
config.yaml (auxiliary.vision.model / auxiliary.video.model) before
falling back to AUXILIARY_VISION_MODEL / AUXILIARY_VIDEO_MODEL env
vars. Matches the existing config-first pattern for timeout and
temperature in the same file.
Fixes#53749
A custom:<name> main provider resolves at runtime to the bare provider id
"custom". In the vision auto-detect chain, the main-provider branch called
resolve_provider_client("custom", ...) WITHOUT explicit_base_url/api_key,
so it returned (None, None) ("no endpoint credentials found") and the whole
chain fell through to OpenRouter/Nous. A user on a custom endpoint with no
aggregator configured then got "No LLM provider configured for task=vision
provider=auto" on every image, even though their main model fully supports
vision.
Recover the live endpoint that set_runtime_main() records each turn
(_RUNTIME_MAIN_BASE_URL/_API_KEY/_API_MODE) and forward it to Step 1, with
a fallback to _resolve_custom_runtime() for non-gateway callers. Mirrors the
existing explicit-base_url branch directly above.
Adds TestResolveVisionCustomProvider covering custom, custom:<name>, and the
no-runtime fallback path.
When model.provider is set to custom:<name>, _supports_vision_override()
previously tried only the runtime provider key ('custom') and the raw
config value ('custom:my-proxy'). It did not try the stripped name
('my-proxy'), which is the actual key under providers: in config.yaml.
This caused native image routing to fall back to text mode even when the
user explicitly declared supports_vision: true on the named provider's
model entry.
Fixes#39963
A user with tts.openai.model set to a direct-OpenAI model (e.g. tts-1-hd)
but no VOICE_TOOLS_OPENAI_KEY/OPENAI_API_KEY (or with tts.use_gateway)
routes TTS through the managed Nous audio gateway, which only proxies
gpt-4o-mini-tts. The request 400s with:
VALIDATION_ERROR: Unsupported managed OpenAI speech model
{'model': 'tts-1-hd', 'supportedModels': ['gpt-4o-mini-tts']}
_resolve_openai_audio_client_config now reports whether it resolved the
managed gateway; _generate_openai_tts coerces the model to a
managed-supported one (logging a warning that points at the direct-key
escape hatch) unless the user redirected base_url to their own endpoint.
Direct-key users keep their tts-1/tts-1-hd preference unchanged.
Follow-up to #57507: .ENV / .Env.local on case-insensitive filesystem
mounts slipped past the guard. Lowercase the name before matching and
add a regression test. Addresses egilewski's open review note.
`_dispatch_sync` gathers the mautrix per-event handler tasks with a bare
`asyncio.gather(*tasks)`. Without `return_exceptions=True`, the first handler
that raises aborts the gather, so the sibling events in the same sync response
are dropped unprocessed — the exception propagates up to the sync loop, which
logs a single "sync error" and moves on. The invite/redaction gathers a few
lines above already use `return_exceptions=True`.
Use `return_exceptions=True` and log each failing handler, so one bad event no
longer takes out the rest of its batch and per-event failures stay visible.
Regression test: a batch with one failing and one succeeding handler no longer
raises, the good handler still runs, and the failure is logged (mutation-
verified — reverting re-raises RuntimeError out of _dispatch_sync).