Skip to content

feat: claude-max — Claude subscription as a drop-in Anthropic endpoint (proxy) - #2

Open
snagnever wants to merge 10 commits into
mainfrom
feat/claude-max-proxy
Open

feat: claude-max — Claude subscription as a drop-in Anthropic endpoint (proxy)#2
snagnever wants to merge 10 commits into
mainfrom
feat/claude-max-proxy

Conversation

@snagnever

Copy link
Copy Markdown
Owner

Summary

Adds claude-max: a local Anthropic-Messages-API-compatible HTTP server backed by the Claude Agent SDK + a Claude subscription, so hermes uses the subscription as seamlessly as api.anthropic.com — with hermes' own agent loop, tools, aux tasks, vision, streaming, and usage accounting all working unchanged.

Branched off main (independent of the claude-agent deep-integration runtime on the other branch/PR #1) so the two subscription approaches can be evaluated separately:

  • claude-agent — hands the whole turn to a Claude Code subprocess (Claude Code owns the tool loop; hermes' own tools/aux limited).
  • claude-max (this PR) — an API facade; hermes keeps its native agent loop and just sees an Anthropic endpoint.

Usage

/model opus --provider claude-max      # shows as "Claude Max (subscription proxy)"

Selecting the provider auto-starts the proxy. Requires the claude CLI (logged in) + pip install 'hermes-agent[claude-max]'. Manual control: hermes claude-max serve|start|stop|status.

Architecture (hermes_cli/claude_max/)

  • server — aiohttp origin server: POST /v1/messages (stream + non-stream), static GET /v1/models (max_input_tokens=200000 so probes don't hang), /health; Anthropic-shaped errors; loopback-only bind.
  • bridge — one ClaudeSDKClient per conversation. Request tools are registered as in-process MCP tools whose handlers block until hermes returns the matching tool_result on the next request → the stateless Anthropic tool loop drives the stateful SDK. tools=[] + setting_sources=[] isolate it; streaming reuses the SDK's raw Anthropic stream events (sanitized to spec).
  • sessions — conversation→session registry: Flow A (tool continuation, matched by pending tool_use ids), Flow B (next user turn, matched by prior-prefix fingerprint), Flow C (cache miss → flatten prior history into a cold-start prompt). TTL + LRU eviction; per-session lock.
  • translator — Anthropic request → SDK options; raw SDK stream → clean Anthropic SSE / final message (strips non-standard fields, echoes model, un-prefixes mcp__hermes__ tool names); usage + error mapping.
  • manager — auto-start: health-probe → preflight (claude CLI + SDK + aiohttp) → detached python -m …server → PID + health poll.
  • glue — provider profile + [claude-max] extra; runtime_provider short-circuit (auto-start + no-key anthropic_messages runtime); picker overlay/label/aliases; validation probe skip.

Design de-risking (Task 0 spike, real SDK)

scripts/claude_max_spike.py + CLAUDE_MAX_SPIKE_FINDINGS.md validated: raw StreamEvent fidelity + fields to strip, mcp__hermes__ tool prefixing, blocking MCP handler across the tool_use response boundary (90s, no timeout via MCP_TOOL_TIMEOUT), sequential parallel-tool execution, interrupt recovery, system-prompt isolation.

Testing

  • 46 unit tests — hermetic (self-contained fake SDK + aiohttp TestClient; no SDK/binary/network).
  • Live E2E against the real subscription: /v1/models, non-stream completion, anthropic python SDK streaming + full tool round-trip (the exact transport hermes uses), and hermes -z --provider claude-max auto-start reaching Claude. Two bugs were caught and fixed via E2E (tool-name correlation for the empty-input stream event; quota→429 mapping).

Caveats

No temperature/top_p (SDK doesn't expose them). Cold-start (proxy restart / compacted history) falls back to flattening prior turns (thinking continuity lost, cache metrics reset) — normal turn-by-turn stays pinned. Localhost-only, unauthenticated socket (loopback is the trust boundary). Subscription limits surface as rate_limit_error (429).

🤖 Generated with Claude Code

snagnever added 10 commits July 4, 2026 17:35
…alidated

Validates the claude-agent-sdk behaviors the claude-max proxy depends on:
stream fidelity + non-standard fields to strip, mcp__hermes__ tool prefixing,
blocking MCP handler across the tool_use response boundary (90s, no timeout),
sequential parallel-tool execution, interrupt recovery, system-prompt isolation.
Gate PASSED: primary blocking-handler tool bridge is viable, no fallback needed.
New claude-max provider (anthropic_messages against a local proxy base_url),
static models catalog (max_input_tokens=200000, opus/sonnet/haiku + aliases),
[claude-max] optional extra (claude-agent-sdk + aiohttp) + provider.claude_max
lazy dep. Package hermes_cli.claude_max is import-light (constants + leaf catalog).
Origin server (not a forwarder): create_app with /v1/messages (stub until
wired), /v1/models (static catalog), /health. Anthropic-shaped error JSON via
middleware; loopback-only bind guard; python -m entrypoint for the auto-start
manager; AppKey-typed app state. Tests via aiohttp TestClient.
…SE, errors)

build_options_inputs (system join, tools→mcp__hermes__ allowed names, silently
accept temperature/top_p); StreamTranslator + MessageAccumulator sanitize raw
SDK events to the Anthropic spec (strip diagnostics/stop_details/caller/
context_management/iterations, echo requested model, unprefix tool_use names,
assemble input_json_delta); map_result_error (auth/rate-limit/api heuristics).
…streaming

ToolBridge: MCP handlers block on futures until the tool_result arrives on the
next request; correlates (name,args)->tool_use_id, stores results delivered
before sequential handlers run, abort_all on close/interrupt. ClaudeBridge:
builds isolated tool-less options (tools=[], setting_sources=[], MCP timeout
knobs, caller tools via create_sdk_mcp_server) and segments the persistent
receive_response iterator into per-HTTP-response chunks (ends at tool_use
message_stop or final ResultMessage). Self-contained fake SDK models the
emit->await-handler->continue flow. 6 tests incl. full tool round-trip.
Fingerprint over normalized (system, messages) with cache_control/thinking
signatures stripped and string-vs-block content unified. match(): Flow A
(tool_result ids ⊆ a session's pending → deliver), Flow B (prior-prefix
fingerprint match → query next user turn), Flow C (miss → new session,
flatten prior history into a synthetic prompt). record_response updates
history_fp + pending_tool_ids; TTL + LRU eviction close bridges. 9 tests.
handle_messages: registry.match → per-session lock → Flow A deliver /
Flow B+C query → drive one response chunk. Peeks the first event to return an
Anthropic error JSON (auth etc.) before committing to a stream; true SSE
streaming via StreamTranslator or aggregation via MessageAccumulator; updates
session history_fp + pending_tool_ids after each chunk; keeps the session on
client disconnect (hermes reconnects with tool results). app.build_app wires
the registry + real ClaudeBridge factory. 4 full-app tests incl. tool
round-trip across two requests + 401 auth mapping.
manager.ensure_server_running: health-probe → preflight (claude CLI + SDK +
aiohttp) → detached Popen(python -m ...server) → PID record + health poll;
stop/status; own check_claude_binary. runtime_provider short-circuits
claude-max (+aliases) to auto-start + return the anthropic_messages runtime
(no key). providers overlay + label + aliases so /model lists 'Claude Max';
validate_requested_model skips the probe. hermes claude-max serve|start|stop|
status subcommand. 8 tests; provider/picker regression green.
Live E2E against the real subscription: /v1/models, non-stream, anthropic-SDK
streaming, and the full tool round-trip all work; hermes -z --provider
claude-max auto-starts + reaches Claude. Two fixes surfaced by E2E:
- correlate blocking handlers to tool_use ids by tool NAME FIFO (the stream's
  content_block_start has empty input; real args arrive via input_json_delta),
  which was causing the tool continuation to hang.
- map subscription usage/quota exhaustion to a 429 rate_limit_error.
Adds plugin README (claude-max vs claude-agent positioning, caveats) +
cli-config example. 46 unit tests green.
The picker only surfaces providers with detectable credentials, but
claude-max's auth is in the Claude Code CLI (keychain), not an env var — so
it was filtered out. Detect availability via check_claude_binary and populate
its model list from the profile's fallback_models. Hidden without the CLI.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant