From 2ba3da55b199698205a05b86cbb61a37f4841300 Mon Sep 17 00:00:00 2001 From: Grant Date: Tue, 12 May 2026 10:52:57 -0500 Subject: [PATCH] 0.1.0:3 - Show Public Key layout + /api/endpoints service-discovery - showPublicKey now uses result.group: install command and raw key are each their own one-click copy box; description is brief - /api/endpoints returns stable shape { vllm, parakeet, magpie } with base_url + model + ready, for other LAN services to consume without hardcoding Spark IPs - health.py: parakeet/magpie now also expose base_url - README: documented /api/endpoints shape --- README.md | 16 ++++- image/app/health.py | 26 ++++++-- image/app/server.py | 31 +++++++++ package/startos/actions/showPublicKey.ts | 80 ++++++++++++++++-------- package/startos/versions/v0_1_0.ts | 4 +- 5 files changed, 121 insertions(+), 36 deletions(-) diff --git a/README.md b/README.md index a19b659..9e1c92c 100644 --- a/README.md +++ b/README.md @@ -70,8 +70,22 @@ To sideload onto your Start9: `make install` (needs `host:` set in `~/.startos/c - `known-issues.md` — known quirks and workarounds - `LICENSE` — MIT +## Service discovery API + +Other services on your LAN can hit `GET /api/endpoints` to learn where the current model lives without hardcoding Spark IPs. Stable JSON shape: + +```json +{ + "vllm": { "ready": true, "base_url": "http://:8888/v1", "model": "RedHatAI/Qwen3.6-35B-A3B-NVFP4", "openai_compat": true }, + "parakeet":{ "ready": true, "base_url": "http://:8000", "kind": "stt", "model": "nvidia/parakeet-tdt-0.6b-v3" }, + "magpie": { "ready": false, "base_url": "http://:9000", "kind": "tts" } +} +``` + +`base_url` is filled in whenever Configure Sparks has been completed (even if the underlying service isn't currently up). Pair the URL with `ready: true` to safely route traffic. + ## Status **v0.1** — local-only, single-cluster, no auth (trusts LAN). Five LLMs in the catalog: qwen3-vl (cluster), gemma4, qwen36, plus two legacy entries. Magpie surfaces red until its container is fixed. -v0.2 backlog (in `runbook.md` / commits): Parakeet/Magpie lifecycle controls, configurable flag tiers in UI, Open WebUI integration, magpie-tts fix. +v0.2 in progress: service-discovery API, magpie crash fix, Parakeet/Magpie lifecycle, model download driving, spark-vllm-docker update checks, configurable flag tiers. diff --git a/image/app/health.py b/image/app/health.py index 698f623..9b4d948 100644 --- a/image/app/health.py +++ b/image/app/health.py @@ -30,24 +30,38 @@ async def check_vllm(settings: Settings) -> dict: async def check_parakeet(settings: Settings) -> dict: + base_url = ( + f"http://{settings.spark2_host}:{settings.parakeet_port}" + if settings.spark2_host + else None + ) if not settings.spark2_host: - return {"ok": False, "error": "spark2 not configured"} + return {"ok": False, "error": "spark2 not configured", "base_url": base_url} try: async with httpx.AsyncClient(timeout=_TIMEOUT) as c: r = await c.get(f"http://{settings.spark2_host}:{settings.parakeet_port}/health") r.raise_for_status() - return {"ok": True, "detail": r.json()} + return {"ok": True, "detail": r.json(), "base_url": base_url} except Exception as e: - return {"ok": False, "error": str(e)} + return {"ok": False, "error": str(e), "base_url": base_url} async def check_magpie(settings: Settings) -> dict: + base_url = ( + f"http://{settings.spark2_host}:{settings.magpie_port}" + if settings.spark2_host + else None + ) if not settings.spark2_host: - return {"ok": False, "error": "spark2 not configured"} + return {"ok": False, "error": "spark2 not configured", "base_url": base_url} try: async with httpx.AsyncClient(timeout=_TIMEOUT) as c: r = await c.get(f"http://{settings.spark2_host}:{settings.magpie_port}/v1/health/ready") r.raise_for_status() - return {"ok": True, "detail": r.json() if r.headers.get("content-type", "").startswith("application/json") else r.text} + return { + "ok": True, + "detail": r.json() if r.headers.get("content-type", "").startswith("application/json") else r.text, + "base_url": base_url, + } except Exception as e: - return {"ok": False, "error": str(e)} + return {"ok": False, "error": str(e), "base_url": base_url} diff --git a/image/app/server.py b/image/app/server.py index dfc077d..9eea6ec 100644 --- a/image/app/server.py +++ b/image/app/server.py @@ -48,6 +48,37 @@ async def get_models() -> dict: } +@app.get("/api/endpoints") +async def get_endpoints() -> dict: + """Service-discovery summary. Stable shape; other apps on the LAN can poll this + to learn the OpenAI-compatible vLLM endpoint, the Parakeet STT endpoint, and the + Magpie TTS endpoint without needing to know the individual Spark IPs.""" + vllm, parakeet, magpie = await asyncio.gather( + check_vllm(settings), + check_parakeet(settings), + check_magpie(settings), + ) + return { + "vllm": { + "ready": bool(vllm.get("ok")), + "base_url": vllm.get("base_url"), + "model": vllm.get("current_model"), + "openai_compat": True, + }, + "parakeet": { + "ready": bool(parakeet.get("ok")), + "base_url": parakeet.get("base_url"), + "kind": "stt", + "model": (parakeet.get("detail") or {}).get("model") if isinstance(parakeet.get("detail"), dict) else None, + }, + "magpie": { + "ready": bool(magpie.get("ok")), + "base_url": magpie.get("base_url"), + "kind": "tts", + }, + } + + @app.get("/api/status") async def get_status() -> dict: vllm, parakeet, magpie = await asyncio.gather( diff --git a/package/startos/actions/showPublicKey.ts b/package/startos/actions/showPublicKey.ts index dfd77aa..a6c1cd5 100644 --- a/package/startos/actions/showPublicKey.ts +++ b/package/startos/actions/showPublicKey.ts @@ -44,39 +44,65 @@ export const showPublicKey = sdk.Action.withoutInput( hosts.push({ user: cfg.spark2_user, host: cfg.spark2_host }) } - let message: string - if (hosts.length > 0) { - const sshLines = hosts - .map( - (h) => - `ssh ${h.user}@${h.host} "mkdir -p ~/.ssh && echo '$KEY' >> ~/.ssh/authorized_keys && chmod 600 ~/.ssh/authorized_keys"`, - ) - .join('\n') - - const oneLiner = `KEY='${key}'\n${sshLines}` - message = - 'Paste this block into any machine that already has SSH access to your Sparks (your laptop or one of the Sparks itself). It grants this Spark Control service permission to log in.\n\n' + - oneLiner + - '\n\nYou will be prompted for the SSH password of each Spark once. After both commands succeed, run "Test Connection" or open the Web Interface to verify.' - } else { - message = - 'Run the "Configure Sparks" action first, then come back to this one — once your Spark hostnames are saved, this message will include a ready-to-paste install command.\n\nFor reference, the raw public key is:\n\n' + - key + if (hosts.length === 0) { + // Not yet configured — show just the raw key with instructions to come back. + return { + version: '1' as const, + title: 'SSH Public Key', + message: + 'Run the "Configure Sparks" action first to enter your Spark hostnames. Once that\'s saved, re-run this action and it will produce a ready-to-paste install command for granting access.', + result: { + type: 'single' as const, + name: 'Public Key', + description: + 'The bare ed25519 public key line. You can also wait until Configure Sparks is filled in to get a complete install command.', + value: key, + masked: false, + copyable: true, + qr: false, + }, + } } + const sshLines = hosts + .map( + (h) => + `ssh ${h.user}@${h.host} "mkdir -p ~/.ssh && echo '$KEY' >> ~/.ssh/authorized_keys && chmod 600 ~/.ssh/authorized_keys"`, + ) + .join('\n') + const oneLiner = `KEY='${key}'\n${sshLines}` + return { version: '1' as const, title: 'SSH Public Key', - message, + message: + 'Run the command below on any machine that already has SSH access to your Sparks (typically your laptop). You will be prompted for the SSH password of each Spark once. After it completes, open the Web Interface to verify.', result: { - type: 'single' as const, - name: 'Raw Public Key', - description: - 'The bare public key line. Copy the full message above for the ready-to-paste install command.', - value: key, - masked: false, - copyable: true, - qr: false, + type: 'group' as const, + name: 'Install command and raw key', + description: null, + value: [ + { + type: 'single' as const, + name: 'Install command', + description: + 'Copy this entire block and paste it into a terminal on your laptop. It will append the key to ~/.ssh/authorized_keys on each Spark.', + value: oneLiner, + masked: false, + copyable: true, + qr: false, + }, + { + type: 'single' as const, + name: 'Raw public key', + description: + 'Just the public key on its own, in case you prefer to install it manually.', + value: key, + masked: false, + copyable: true, + qr: false, + }, + ], }, } }, diff --git a/package/startos/versions/v0_1_0.ts b/package/startos/versions/v0_1_0.ts index 4164f07..f5a6188 100644 --- a/package/startos/versions/v0_1_0.ts +++ b/package/startos/versions/v0_1_0.ts @@ -1,10 +1,10 @@ import { VersionInfo, IMPOSSIBLE } from '@start9labs/start-sdk' export const v0_1_0 = VersionInfo.of({ - version: '0.1.0:2', + version: '0.1.0:3', releaseNotes: { en_US: - 'Fully generic SSH user fields (no suggested default); generic host placeholders in Configure Sparks; Show Public Key emits a ready-to-paste install command using your configured hostnames; dashboard shows the OpenAI-compatible base URL + current model ID with one-click copy and a curl example.', + 'Show Public Key: install command moved to its own copy box (cleaner than mixing it into the description). New /api/endpoints route for service discovery — other services on your LAN can GET it to learn vLLM/Parakeet/Magpie base URLs and current model without hardcoding Spark IPs.', }, migrations: { up: async ({ effects }) => {},