feat(proxy): local OpenAI-compatible proxy for OAuth providers (#25969)

Adds 'hermes proxy start' — a local HTTP server that lets external apps
(OpenViking, Karakeep, Open WebUI, ...) use a Hermes-managed provider
subscription as their LLM endpoint. The proxy attaches the user's real
OAuth-resolved credentials to each forwarded request, refreshing them
automatically; the client can send any bearer (it gets stripped).

Ships with one adapter — Nous Portal. The UpstreamAdapter ABC and
registry in hermes_cli/proxy/adapters/ are designed for additional
OAuth providers to plug in by name without server changes.

Commands:
  hermes proxy start [--provider nous] [--host 127.0.0.1] [--port 8645]
  hermes proxy status
  hermes proxy providers

Allowed Portal paths: /v1/chat/completions, /v1/completions,
/v1/embeddings, /v1/models. Anything else returns 404 with a clear
error pointing at the allowed list.

aiohttp is gated like gateway/platforms/api_server.py (try-import,
clean runtime error if missing). No new core dependency.

Tests: 24 unit tests + 1 separate E2E that spawns the real subprocess
and verifies the upstream receives the right bearer with the client's
header stripped.
This commit is contained in:
Teknium 2026-05-14 15:40:48 -07:00 committed by GitHub
parent 34fc94d1f4
commit ccb5aae0d2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 1466 additions and 1 deletions

View file

@ -1452,6 +1452,17 @@ def cmd_gateway(args):
gateway_command(args)
def cmd_proxy(args):
"""Local OpenAI-compatible proxy to OAuth providers."""
# Lazy import — pulls in aiohttp, which is gated behind an extras install
# for users who don't run the proxy or the messaging gateway.
from hermes_cli.proxy.cli import cmd_proxy as _cmd_proxy
rc = _cmd_proxy(args)
if isinstance(rc, int) and rc != 0:
raise SystemExit(rc)
def cmd_whatsapp(args):
"""Set up WhatsApp: choose mode, configure, install bridge, pair via QR."""
_require_tty("whatsapp")
@ -9385,7 +9396,7 @@ _BUILTIN_SUBCOMMANDS = frozenset(
"config", "cron", "curator", "dashboard", "debug", "doctor",
"dump", "fallback", "gateway", "hooks", "import", "insights",
"kanban", "login", "logout", "logs", "lsp", "mcp", "memory",
"model", "pairing", "plugins", "profile", "sessions", "setup",
"model", "pairing", "plugins", "profile", "proxy", "sessions", "setup",
"skills", "slack", "status", "tools", "uninstall", "update",
"version", "webhook", "whatsapp", "chat",
# Help-ish invocations — plugin commands not being listed in
@ -9727,6 +9738,51 @@ def main():
help="Skip the confirmation prompt",
)
# =========================================================================
# proxy command — local OpenAI-compatible proxy that attaches the user's
# OAuth-authenticated provider credentials to outbound requests. Lets
# external apps (OpenViking, Karakeep, Open WebUI, ...) ride a logged-in
# subscription without copy-pasting static API keys.
# =========================================================================
proxy_parser = subparsers.add_parser(
"proxy",
help="Local OpenAI-compatible proxy to OAuth providers",
description=(
"Run a local HTTP server that forwards OpenAI-compatible requests "
"to an OAuth-authenticated provider (e.g. Nous Portal). External "
"apps can point at the proxy with any bearer token; the proxy "
"attaches your real credentials."
),
)
proxy_subparsers = proxy_parser.add_subparsers(dest="proxy_command")
proxy_start = proxy_subparsers.add_parser(
"start", help="Run the proxy in the foreground"
)
proxy_start.add_argument(
"--provider",
default="nous",
help="Upstream provider (default: nous). See `hermes proxy providers`.",
)
proxy_start.add_argument(
"--host",
default=None,
help="Bind address (default: 127.0.0.1). Use 0.0.0.0 to expose on LAN.",
)
proxy_start.add_argument(
"--port",
type=int,
default=None,
help="Bind port (default: 8645)",
)
proxy_subparsers.add_parser(
"status", help="Show which proxy upstreams are ready"
)
proxy_subparsers.add_parser(
"providers", help="List available proxy upstream providers"
)
proxy_parser.set_defaults(func=cmd_proxy)
gateway_parser.set_defaults(func=cmd_gateway)
# =========================================================================