Repository navigation
LLM appliance: hub daemon, mesh chat, shared join widget, CI/CD to httpeers.net - #4
Merged
Merged
Conversation
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…t suites Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
…y only when it has one Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…over the session store Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…page, shared sessions Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…red: no UI yet) Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…ngs and model dialogs, session list Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…esh shows inline Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…requests apps/demos depends on it via catalog:, so a standalone install failed with ERR_PNPM_CATALOG_ENTRY_NOT_FOUND_FOR_SPEC; 0.9.0 is on npm, the same range as webrun-files. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
ActionBarPrimitive.Reload is enabled on every assistant message, not just the last one, but onReload ignored the clicked message's parentId and always called controller.regenerate() — replacing the LATEST reply even when an earlier one was clicked. Add ChatController.regenerateFrom(index): truncates to messages.slice(0, index + 1) (the message at index must be a user message, otherwise a no-op) and streams a new reply, the same truncation semantics edit() already has. Thread.tsx's onReload now maps the clicked message's parentId through indexOfId to call regenerateFrom, falling back to regenerate() when the id can't be resolved. Covered by two new chat-controller unit tests and a new smoke step between the edit step and the Stop step. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… dialog Spec 6.1 allows Save even when Test fails, so a mistyped base URL routinely opens the non-dismissible model dialog with a failing list fetch, and the header Settings button is inert behind the overlay — the only way out was typing an arbitrary model id, which got persisted. ModelDialog now takes a required onChangeConnection prop and always renders a "Change connection" button; ChatApp wires it to open the settings dialog. showModels already yields while settingsOpen is true, so the (dismissible, since the step is "models") settings dialog appears, and cancelling it returns to the model dialog. Covered by a new smoke step after "changing the base URL asks for a model again". Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… this repo does not contain Eight workspace:* references in five packages named webrun-wire packages, so httpeers installed only inside an assembly and a standalone install failed with ERR_PNPM_WORKSPACE_PKG_NOT_FOUND. Each is now a caret range on the version already published to npm, which equals the local webrun-wire checkout; inside an assembly linkWorkspacePackages still links the local package. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…t included The committed lockfile listed 7 of 15 importers. Regenerated with pnpm install --lockfile-only in the member root; importers only added, none removed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…en WebRTC is impossible - runOnLimitedConnection on Libp2pLinkInit, ServeTransportInit, CreateRemoteInit and ServePeerInit, forwarded to connect/serveConnections - reachHubRelayed(node, relayAddr, hubPeerId) dials <relay>/p2p-circuit/p2p/<hub> and returns the limited connection without closing it - with the flag set, libp2pLink opens streams on the kept limited connection: libp2p 3.3.8 does not reuse a limited connection for a dial, and the conformance test measured a second circuit per call without this - LimitedConnectionError maps to PeerLimitedConnectionError, kind "limited-connection" (502 at the edge) - conformance: relay-fallback.test.ts over the in-process relay, with a relay-only member (no webRTC, no tcp) and a limited circuit asserted Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…e opt-ins - ServePeerInit: serveOnLimitedConnection (serving only) and callOnLimitedConnection (boolean | per-target predicate, outbound only, covering call and the router's forwarding); the combined flag is gone - Libp2pLinkInit takes the same pair; CreateRemoteInit takes callOnLimitedConnection; ServeTransportInit keeps runOnLimitedConnection (serving). The kept-circuit-first stream selection applies only to targets the predicate allows - reachHubRelayed documents that an existing unlimited connection is returned as is; error comments cite symbols, not line numbers - conformance: the member calls the hub with a hub-only predicate; new cases prove its serving side stays closed to the circuit, with an opted-in control Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…de fails
startMember tries reachHub once per join; on any failure it keeps a relay
circuit to the hub (reachHubRelayed) and calls the hub over it. In relay mode
it skips reserveOnHub, the reservation supervisor and leaveRelay. The member
always calls with callOnLimitedConnection for its hub only; its serving side
stays closed to limited connections.
- createRouteEnsurer never dials the hub (hubRoute(hub, hub) cannot work, and
on the fallback it was a doomed dial before every edge call to the hub).
- The keepalive takes a relinkHub; a relay-joined member re-links WebRTC
first, then a new circuit, and a kept circuit counts as open. One re-link
at a time.
- MemberHandle.hubLink() and SessionState.hubLink ("direct" | "relay", null
unless live); HubLink exported.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A relay-joined member's keepalive re-link called the WebRTC-first join path, which could land it "direct" with no reservation, supervisor or wake watch. Once a run is in relay mode, every re-link is now reachHubRelayed only, so hubLink is fixed for the life of a handle (documented on HubLink/hubLink()). Tests: the re-link case uses a WebRTC-capable member against a hub that cannot upgrade and asserts no /webrtc dial on re-link; a relay-mode member refuses a call the hub turns around over the circuit; a WebRTC-capable member against such a hub joins in relay mode and keeps calling (join time, circuit count and call times logged). The harness gains webRTCUpgrade and shares hubHoldsCircuitTo. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…service modules, a local door apps/hub (@statewalker/httpeers-hub-daemon): startDaemon builds the hub the conformance harness builds (circuit + WebRTC listen, membershipGater, hubRelayService, relay reservation kept by superviseRelay), with the key in hub.key, hub.env rewritten on every start, members and invitations in fileStorage, rules.dl created from the mesh protocol defaults, and revocations persisted to revocations.json and enforced on the hub's own endpoints. It serves on limited connections so relay-fallback members reach it. Service modules mount at /<id> on the mesh (caller "mesh") and at /peers/<hubPeerId>/<id>/ on the local door (caller "local"), which never goes through withAccess. The standalone lockfile gains the new importer. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ations - HUB_LOCAL_DOOR_HOST (default 0.0.0.0) is threaded to the door, which reports the address it bound; a host-networked hub sets 127.0.0.1. - One createMonotonicClock is shared by createHub, mintToken and the revocation file, so a token cannot tie a revocation's timestamp. - Daemon.revocationsFlushed() is the durability handle for a revoke. - A malformed revocation entry refuses the start instead of being dropped. - main.ts flushes revocations, then stops, within an 8 s deadline. - A module's advertisement id must equal its mount id. - Tests: the door's bind address, the config field, a malformed entry, the advertisement check, and a revoked member's token refused before and after a restart. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Implements spec §5.4: one handler (`createAdminApi`) serving every `/hub/api/*` route — mesh, roles, invitations, members, revoke, and its own OpenAPI 3.1 document — mounted at `/hub` on the mesh behind `withAccess` (capability `std:mesh.admin`) and served directly on the local door. The `/hub/api` policy is appended to every hub's rules at load time, like a module's own rules, so an existing `rules.dl` written before this task grants it too, with nothing to migrate — skipped only when a rules file has no `std:mesh.admin` concept at all, so it can't turn a valid custom rules file into a hub that fails to start. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two review fixes to Task 4's admin REST API: - loadOrCreateRules now console.warn's when a rules.dl derives no std:mesh.admin capability, so the admin API silently going dark on the mesh (local door only) is no longer a silent event. Behaviour is unchanged -- still fail-closed, never fails the start. - DELETE /hub/api/members/:peerId guards decodeURIComponent: a malformed percent-escape in the peerId now returns 400 with the API's usual JSON error shape instead of throwing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…path, curated OpenAPI, key minting Adds the `llm` service module (spec §4.2, §5.1-§5.3, §5.6): a measured passthrough to LiteLLM under `/peers/<hubPeerId>/llm` (path prefixing, header hygiene, origin-agnostic Location/body rewriting for the sentinel PROXY_BASE_URL, streamed bodies, upstream-unreachable mapping), a curated `/llm/openapi.json`, `/llm/openapi.full.json` proxied to LiteLLM's own document, and `/llm/keys` minting a scoped key with the master key never exposed to a caller. `main.ts` registers it for `HUB_SERVICES=llm` and refuses to start without both `HUB_LLM_UPSTREAM` and `LITELLM_MASTER_KEY`. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…sthrough Important: /llm/keys sent the master key as Authorization, which the appliance's litellm_key_header_name: x-litellm-api-key setting means real LiteLLM ignores (spike Q3, measured) — it now sends x-litellm-api-key: Bearer <masterKey> and never Authorization. Hardening, from review: - keys.ts forwards the caller's abort signal, never follows a redirect (the master key must not reach wherever a 3xx points), and maps a 2xx response with no string `key` to a 502 upstream-error. - passthrough.ts drops content-encoding/content-length when present (undici decompresses gzip/br transparently but leaves both headers describing the encoded form); the unreachable/opaque-redirect marker cases get distinct kinds (upstream-unreachable vs upstream-redirect) and never name the internal upstream URL. - rewriteLocation compares origins case-insensitively and requires a real boundary (/, ?, # or end of string) after the prefix, so one host is never mistaken for another that merely shares a numeric prefix. - llmModule normalizes HUB_LLM_UPSTREAM once (strips trailing slashes) so passthrough and keys always agree on the upstream target. - openapi.ts: /keys no longer declares the llmKey security scheme — it is authorized by the mesh's own app:llm.admin capability, not a caller x-litellm-api-key. - modulesFor moved out of main.ts into modules.ts, and the entry-point guard added in review round 0 is removed: comparing import.meta.url to argv[1] silently skips main() under a symlinked entrypoint. main.ts now always runs main() when executed, with no guard to get wrong. - Test-only: negative rule cases (near-miss resource strings) refused for a member. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A static page (`apps/hub/ui/`, plain DOM TypeScript + Vite, no biscuit-wasm and no ServiceWorker) served by the local door at `/`, porting the demos hub page's markup and behaviour onto `/hub/api/*` instead of an in-tab `Hub` object: mesh id/relay/services, a mint- invitation flow (roles select, link, QR via `@statewalker/httpeers-qr`), a polled members table with revoke, and the `LiteLLM dashboard` link. - The admin API's contract is unchanged. Direct-WebRTC vs relay is derived PER MEMBER from `GET /hub/api/members`'s existing `addrs` (`/webrtc` vs `/p2p-circuit`), shown as a table column rather than one hub-wide fact, since the mesh can genuinely mix both at once. - The QR encodes the invitation LINK, not the blob (a deliberate deviation from the demos page, which encodes the blob because a tab-hosted hub has no join page for a bare invitation id to resolve against — this hub has a real one, and the link already carries it). - `local-door.ts` gains `createStaticUiHandler(root)`: `/` -> index.html, content types by extension, 404 for anything missing, and a boundary check after `decodeURIComponent` so a percent-encoded `..` cannot walk out of `dist-ui/` (a literal `..` is already collapsed by `new URL` before the handler runs; the percent-encoded form is not, which is what the check is actually for). Wired as the local door's `ui` fallback, so `daemon.ts` needed no changes. - `test:ui` (not part of `test`): a Playwright script against a real in-process daemon and loopback relay — mints an invitation, sees the link and QR, sees the members table, checks the dashboard link's href. New unit tests cover the static server directly (content types, 404, both traversal shapes). - `build` now also runs the new `build:ui`; `typecheck`/`lint` cover `ui/` too. New deps: `@statewalker/httpeers-qr` (workspace:*, not webrun-wire), `playwright`/`vite` (devDependencies, versions already used by apps/demos and apps/llm-chat). Lockfile updated via the assembly's own pnpm install; no other package touched. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Brings in PR #2 (httpeers-access evaluates Biscuit with @statewalker/webrun-biscuit ^0.2.1; the demos lose the wasm loader and aliases) and upstream's standalone-install fixes (f562bb5, fdaf536, 6915a12), which make the same dependency changes as this branch's 29e758c/53cf92b/976d2f0. Only pnpm-lock.yaml conflicted. It was not hand-merged: upstream's side was taken and regenerated with `pnpm install --lockfile-only` in the member workspace. Its importers are the union of both parents (upstream's 14 plus apps/hub and apps/llm-chat); no overrides, no link outside the repo. package.json files and pnpm-workspace.yaml merged cleanly: both sides had made identical edits apart from httpeers-access's, which only upstream touched. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
After the merge of native TypeScript Biscuit, three comments in apps/hub still described the wasm engine: the LLM module's note on where its policies were checked to parse, and the admin UI's two notes on why it needs no wasm alias. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…aefik in one compose file Task 7: apps/hub/Dockerfile (multi-stage, pnpm deploy --legacy --config.node-linker=hoisted so node-datachannel resolves at the image root) and deploy/llm-appliance/ (compose.yml, compose.test.yml, compose.host.yml, litellm-entrypoint.sh + config.yaml, Traefik's file-provider dynamic config, scripts/health.sh, a fake-LLM smoke test). Verified end to end: image builds, the full stack comes up healthy, scripts/health.sh all PASS, a minted key streams a chat completion through Traefik -> hub -> LiteLLM -> fake-llm ending in [DONE], and a hub restart keeps the same hubPeerId with LiteLLM still serving under the same root path. A root .dockerignore (**/node_modules/ etc.) is required, not cosmetic: this repo is checked out nested inside a multirepo assembly, so its own node_modules holds symlinks computed relative to the assembly root: copying them into the image (a bare `node_modules/` dockerignore pattern only matches at the context root, not every depth) breaks packages/httpeers-qr's build with an unresolvable module. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…d reuses the chat
- The key header is configurable: ChatConfig/EndpointConfig.apiKeyHeader (default
authorization), value always "Bearer <key>"; applyEndpoint keeps it, ModelDialog and
SettingsDialog's Test pass it.
- mesh.html -> src/pages/mesh.tsx: createSession with edge key "peers", join from ?join= or a
pasted invitation, find the llm openapi-service advert, discoverLlm reads openapi.json
(relative server -> baseUrl, llmKey -> header, x-httpeers-entry -> dashboard), store the
"mesh" config keeping a stored key, a Key step (Use key / Request a key with the 403
message), then ChatApp with the link mode and an admin-only LiteLLM dashboard link.
- Vite multi-page build; scripts/copy-assets.mjs copies sw.js; no Biscuit wasm.
- The boundary walk follows dynamic import("./x.js"); only pages/mesh.tsx reaches httpeers.
- scripts/mesh-smoke.mjs drives an admin and a member against a running appliance.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…st; ship only runtime files Critical: compose.host.yml's Traefik still listened on the base file's bare :8080, which is safe only under bridge networking (where Docker's 127.0.0.1:8080:8080 publish does the restricting). Under network_mode: host there is no publish step, so Traefik bound the basic-auth-only admin door to every interface on the host. Fixed by overriding the entrypoint address to 127.0.0.1:8080 in that variant. Verified the override renders correctly on the actually-installed Compose (v2.3.3) via `docker compose config`; also documented (in the file's own comments and a new README warning) that the same old Compose silently fails to clear networks:/ports: via !reset, so `up`-ing this variant needs a newer Compose or a hand-edited base file — not exercised beyond `config`, per instruction. Minor: apps/hub/package.json now declares files: [dist, dist-ui], so `pnpm deploy --prod` no longer ships src/, tests/, ui/ or tsconfig.* into the runtime image. Rebuilt and confirmed `docker run ... ls /app` lists only dist/dist-ui/node_modules/package.json and node-datachannel still loads; did not recreate the running hub container since the standalone image check already verified it, and re-ran scripts/health.sh against the still-running (previous-image) stack to confirm nothing regressed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
- Discovery targets handle.hubPeerId only; an llm advert from any other peer is ignored. - discoverLlm refuses a service base, OpenAI base or dashboard URL outside <edge><hubPeerId>/llm/ (absolute, protocol-relative, ../ or encoded escapes, another peer, a query), so a hostile document cannot receive the key. - meshConfig keeps a stored key only for the same endpoint. - A superseded discovery run (old handle, repeated Try again) writes no config and no stage; a key arriving for a superseded run is dropped. - The dashboard link is rel="noopener noreferrer". Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…oin widget Each page's #join-form and #session-controls markup, their render() branches and their three listeners are replaced by one #mesh-join section the widget mounts into, fed from render(). The pages gain QR scanning (camera or picture) and "Leave this mesh". render() keeps its name and its own fields (#state, #peer-id, the mesh view). The Playwright smokes fill .hp-join-input and press .hp-join-submit instead of #invite and #join. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…base) to take minutes Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… the public Actions log Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…te Postgres Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…B_CONTAINER=remote) Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Measured on the demos hub's own invitation QR: html5-qrcode's scanFile found nothing in a clean 684 px screenshot, nor at 600 or 480 px, and read it only at 400 px and below -- smaller than any phone screenshot. On "no-qr" the default scanner now draws the picture at its own size (capped at 1600), 800 and 400 px and runs decodeQr (jsQR, loaded lazily). Every size from 300 to 3000 px now decodes, including a blurred, tilted 3000 px copy, and scanning the hub page's real QR joins its mesh live. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…nd the demos Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ut, admin tunnel, measured E2E The README gains 'On the httpeers.net server' (what runs where, the forced-command deploy and why, the one-time setup, rollback); the e2e script and README gain the remote-hub mode and model-agnostic reply completion; RESULTS.md records the all-PASS run against the server; the root README's layout lists the apps and the appliance. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The pipeline was exercised end to end from this branch (runs 35341105672 to the merge deploy); from here on only main deploys. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… write refilled Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Pure and fetch-injected: the admin check (GET /hub/api/roles, 403/404 final, a hiccup retried), the mesh-view hint that decides whether to ask at all, the member/admin roles offered, the 1 h / 1 d / 7 d lifetimes and the request body. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Member or admin (with a warning), expiry, then the link, its QR code, Share (navigator.share) and Copy; pending invitations with role and time left, never their ids. Shown only while live and once the hub's roles call succeeds; in the compact widget it sits in the Mesh menu. invite: false leaves it out. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Anchored to its button, the menu -- now tall with the Invite panel -- ran off the left edge of a 390 px screen when the button sat mid-header. Below 30rem it is fixed to the bottom, 70vh at most and scrolling, leaving the header and the toggle that closes it in view. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… usage Who sees it and why (live, the view's admin hint, then GET /hub/api/roles), what it sends, which roles it offers (member and admin; the hub's hidden role is left to the door), how the secret is kept off logs and the screen, and how an admin invites from mesh.html without a tunnel. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…s it cannot use
libp2p's relay server holds at most 15 reservations, for two hours each, and
keeps one after its holder disconnects. A hub on those defaults filled after
fifteen distinct peers and then refused every member with RESERVATION_REFUSED,
which a member reports as a failed join.
- hubRelayService sizes the store at HUB_MAX_RESERVATIONS (4096), overridable
with { maxReservations }; the per-circuit data limits are unchanged.
- It releases a reservation when its holder's last connection closes.
- releaseReservation(relay, peer) frees a revoked member's slot at once.
tests/hub-relay.test.ts reproduces the refusal (5 of 20 connected members
refused before the fix) and pins the release on disconnect and on revocation.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
libp2p reports every failed reservation as 'Some configured addresses failed to be listened on', with the relay's status only in the text. reserveOnHub replaced that with one blanket message naming PERMISSION_DENIED and UnsupportedProtocolError whatever happened, so a hub whose reservation store was full (RESERVATION_REFUSED) read in production as 'this peer is not a member'. It now throws a HubReservationError carrying the relay status and a refusal kind (store-full, resource-limit, not-a-member, no-relay, no-answer, other), with a message that says which. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ot a peer id A revocation must not fail half-way because the subject cannot be parsed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…lay slot - loadConfig reads HUB_MAX_RESERVATIONS (positive integer, optional; unset keeps hubRelayService's HUB_MAX_RESERVATIONS, 4096) into createHubNode. - createHubNode returns its typed services, so the daemon can reach the relay. - The admin revoke calls releaseReservation after removing and revoking. daemon.test.ts pins it end to end: with one slot, a member joins, is revoked through the door while still connected, and a second member still joins (RESERVATION_REFUSED without the release). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…e data limits Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…optional) Empty is unset, so the hub keeps its default (4096) unless .env sets it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…r's refusal mesh-harness takes maxRelayReservations, so a hub's relay store can be filled by a real member. member-reservation-fallback.test.ts: with one slot taken, the next page-shaped member must still join -- in relay mode, with the hub's RESERVATION_REFUSED as its note, one kept circuit on both ends, no reservation supervisor left running -- and a non-member's reserveOnHub must read PERMISSION_DENIED. Fails on llm-appliance exactly as production did. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ad of failing it After a direct (WebRTC) link and a successful resume/redeem, a hub whose relay store was full refused the reservation and startMember failed the whole join, while a member that had fallen back at the upgrade worked. Now a capacity refusal (RESERVATION_REFUSED, RESOURCE_LIMIT_EXCEEDED) or no answer at all hangs up the WebRTC link, opens the kept relay circuit and continues in relay mode: no supervisor, no leaveRelay, the relay-mode re-link -- the same state as the upgrade fallback. PERMISSION_DENIED, a hub with no relay service and any other status still fail the join (fallsBackToRelay). MemberHandle.hubLinkNote says why; the session shows it as the live phase's note, so the join widget reads 'Connected (relay)' with the reason. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ow reports Member README gains a 'Direct or relay' section; libp2p README lists reachHubRelayed and HubReservationError and explains the refusal kinds; conformance README names the relay-mode suites; the appliance troubleshooting entry gains the full-store cause. Test counts brought up to date. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…mbers, released on disconnect and revoke Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… a member to relay mode Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> # Conflicts: # packages/httpeers-libp2p/README.md
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What this is
The LLM appliance, the mesh chat that uses it, and now its production deployment:
apps/hub: the Node hub daemon. It has a persistent identity, rules, service modules (llm, a LiteLLM passthrough that mints keys), a local door, an admin REST API and a local admin UI.apps/llm-chat: a chat for any OpenAI-compatible API. It runs standalone (index.html) or over the mesh (mesh.html: join, discover the hub's LLM service, chat).deploy/llm-appliance: hub, LiteLLM, Postgres and Traefik in compose. It uses the relay-circuit fallback when WebRTC fails..github/workflows/llm-appliance.ymlpublishesghcr.io/statewalker/httpeers-hub(the image carries the appliance's compose files under/appliance). A forced-command SSH key then deploys it to/opt/httpeers-llmon the server, with verification and rollback.compose.server.yml,litellm/config.server.yaml).packages/httpeers-join: one shared join widget, used bymesh.htmland by the demos' app, images and proxy pages.CI/CD mechanism
maintouching the hub, the packages,deploy/llm-applianceor the lockfile: publish pushessha-<commit>andlatest. Then deploy runs, gated onvars.LLM_DEPLOY_ENABLED == 'true', and SSHes in withLLM_DEPLOY_SSH_KEY.restrict,command="/opt/httpeers-llm/bin/deploy.sh". It can only pass a commit sha./applianceintoreleases/<sha>, and runscompose up --wait. It then checks that the peerId is unchanged, thathealth.shpasses, and that LiteLLM lists models. When a check fails it rolls back.deploy/llm-appliance/README.md, "On the httpeers.net server".New configuration (names only): secret
LLM_DEPLOY_SSH_KEY, variablesLLM_DEPLOY_ENABLED=trueandDEPLOY_KNOWN_HOSTS. It reuses the existing secretsDEPLOY_HOSTandDEPLOY_USER. The OpenRouter key lives only in the server's/opt/httpeers-llm/.env(mode 600).Merging this deploys. It republishes the hub image as
latestand redeploys the appliance frommain. The lockfile changes, so the relay and sites workflows also rebuild and redeploy (a brief relay reconnect).Verified
compose.server.yml; one failed on a Traefik readiness race, where the rollback path ran and the fix is indeploy.sh; the rest passed.deploy/llm-appliance/e2e/RESULTS.md): all six steps pass against the server, throughhttps://llm-chat.httpeers.net/mesh.htmlandrelay.httpeers.net, with browsers on a different network from the server:gpt-4o-minireply;dist/);join-smokefor the demos;mesh.html, joined direct in 1.25 s.Not in this PR
hub.andapp.).*.p.httpeers.netis Session origins: {name}.p.httpeers.net shell, demo, infra #3.apps/hub/tests/daemon.test.tscleanup withENOTEMPTYonhub/state, which means a state write landed afterstop()resolved. The cleanup now retries, and the check passes. Whether the daemon should await its pending state writes instop()is left open.🤖 Generated with Claude Code