fix: reconcile LLM API keys after out-of-band llmPorts edits (PATCH route + load-time) - #76
Conversation
…oute + load-time) MiaAI-Lab#25/MiaAI-Lab#26 added per-port LLM API keys and syncLlmApiKeysToPorts, but the sync is only called from the two llm-ports PUT routes. Two paths still rename llmPorts underneath the encrypted secrets store: - PATCH /api/sparks/:id carrying llmPorts - a direct sparks.json edit (config edited in the container / on disk) Either leaves the key stored on the old port while the port the probe now uses has none, so LLM probes hit the backend keyless and 401 until someone re-enters the key by hand. - PATCH /api/sparks/:id: capture prev llmPorts when the body carries llmPorts (validated as in the PUT route) and run syncLlmApiKeysToPorts after the update, then restart the monitor from the re-read spark. - SparkRegistry: reconcile once at load. Only the unambiguous shape — exactly one orphaned keyed port and exactly one configured port without a key — moves the key; every other mismatch warns and changes nothing. Load never prunes stored key material. - tests: load reconcile move / retain / aligned shapes plus the syncLlmApiKeysToPorts rails, with a synthetic key and temp SPARKS_JSON_PATH / SPARKS_SECRETS_PATH. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
c3fefed to
754aabb
Compare
Needs work — LLM API key reconcile on PATCH + loadProduction Readiness: NO-GO Real bug, good catch. syncLlmApiKeysToPorts only ran on PUT llm-ports; PATCH and raw sparks.json could rename ports under the encrypted key store. Load-time reconcile is correctly conservative (move only 1↔1; never delete key material at load). Critical: PATCH sync is gated on patchedPorts.length > 0, but updateSpark/_normalizeLlmPorts still applies llmPorts for [] and legacy non-array shapes. Arm sync on hasOwnProperty(..., "llmPorts") and sync against post-normalize ports (or reject empty the way PUT does). Tests for the registry rails are solid; add one PATCH-path case for empty/non-array once the gate is fixed. — Vincent |
…-normalize ports Review (vincenzopalazzo): the PATCH sync was gated on the request body normalizing to a non-empty port list, but SparkRegistry.updateSpark() applies llmPorts: [] (-> [LLM_PORT]) and the legacy scalar shape too, so those paths could still orphan a key under the old port. Move the logic into SparkRegistry.patchSpark(): armed on hasOwnProperty(body, 'llmPorts') regardless of shape, and synced against the ports the stored spark actually ends up with. The route now delegates to it. Tests cover [] -> default port, legacy scalar, and a body without llmPorts (keys untouched). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Signed-off-by: Seth For Privacy <40500387+sethforprivacy@users.noreply.github.com>
|
Thanks Vincent — agreed, and fixed in e062c4e.
🤖 Addressed by Claude Code |
MikeGibbsOnyx
left a comment
There was a problem hiding this comment.
Verified as the actual merge result with current main: 201/201 tests pass, TypeScript check passes, production build passes. Reviewed key migration/reconciliation paths: load-time handling is non-destructive for ambiguous mappings, PATCH reconciliation uses normalized post-update ports, and tests confirm secrets are never emitted in warnings.
Follow-up to #25/#26 (per-port LLM API keys +
syncLlmApiKeysToPorts), rebased onmainat v1.8.6 (cc44d35).Problem
syncLlmApiKeysToPortsis called only from the twollm-portsPUT routes, so two paths still renamellmPortsunderneath the encrypted secrets store:PATCH /api/sparks/:idwithllmPortsin the body — updates the spark, never syncs the keys.sparks.jsonedit (config changed in the container or on disk) — never goes through a route at all.Either way the key stays stored on the old port while the port the probe now uses has none, so the LLM collector probes the backend keyless. On a keyed vLLM backend that is a hard 401, silently, until someone notices and re-enters the key in the UI.
We hit path 2 moving one lane from
:8888to:8899on a monitored node: 1,394 keylessGET /v1/models→ 401 in a single hour from the dashboard, with the dashboard reporting the node's LLM panel as unavailable rather than as an auth failure. The other two monitored nodes, untouched, stayed green throughout — which is what made it non-obvious.Fix
PATCH route — when the body carries
llmPorts(validated the same way as thePUT /api/sparks/:id/llm-portsroute), capture the pre-update ports and runsyncLlmApiKeysToPortsafterupdateSpark, then restart the monitor from the re-read spark so the collector picks up the moved key. Bodies withoutllmPortsare untouched.Load-time reconcile —
SparkRegistryreconciles once during load, deliberately conservative:Warnings carry port numbers only, never key values. On our fleet the reconcile is quiet except for warn-only lines about pre-existing nodes that have ports configured but no key provisioned — no mutation, correct behavior.
Tests
New
server/sparks/__tests__/SparkRegistry.llmApiKeys.test.js(8 tests,node:test, synthetic key, tempSPARKS_JSON_PATH/SPARKS_SECRETS_PATH, registry constructed through its real load path so the reconcile provably runs): the move shape, the migration warning text, the ambiguous shape both retaining the key and warning without leaking it, the aligned shape staying silent, and thesyncLlmApiKeysToPortsrails (rename move, no-op, prune-on-removal).npm ci && npm teston this branch: 177 tests, 176 pass, 1 fail — the failure isserver/collectors/__tests__/showcasePrompts.test.js, which asserts on apickShowcasePrompts("structural"source string that DecodeBench no longer uses. It fails identically on unmodifiedmainatcc44d35(7/8 in that file) and looks like what #67 is for. Everything else, including all 8 new tests, is green.Deployed from this diff on a 3-node fleet since 2026-09-01; probes read
auth=keyedon the renamed port with no manual key re-entry.🤖 Generated with Claude Code