desktop(mesh-llm): let a serving node route a different model (#833)

Signed-off-by: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@sprout-oss.stage.blox.sqprod.co>
Signed-off-by: npub1mprnacetjua2xx3p5eddmhxyk6wv929ymm5py8kd2xfxurxahspqqlgyta <d8473ee32b973aa31a21a65adddcc4b69cc2a8a4dee8121ecd51926e0cddbc02@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: npub1mprnacetjua2xx3p5eddmhxyk6wv929ymm5py8kd2xfxurxahspqqlgyta <d8473ee32b973aa31a21a65adddcc4b69cc2a8a4dee8121ecd51926e0cddbc02@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
tlongwell-block
2026-06-03 14:30:46 -04:00
committed by GitHub
parent b4f8f40333
commit 9f0c22a43a
4 changed files with 176 additions and 17 deletions

View File

@@ -57,15 +57,34 @@ pub(crate) async fn ensure_client_node_for_model(
{ {
let runtime = state.mesh_llm_runtime.lock().await; let runtime = state.mesh_llm_runtime.lock().await;
if let Some(runtime) = runtime.as_ref() { if let Some(runtime) = runtime.as_ref() {
let status = runtime.status().await.map_err(|error| error.to_string())?; // A running runtime — in any mode — is the mesh's local OpenAI
return match status.mode { // ingress on `9337`. mesh-llm's router already resolves the
Some(mesh_llm::MeshNodeMode::Client) => Ok(status), // requested model to a local, remote, or split target at request
Some(mesh_llm::MeshNodeMode::Serve) => Err( // time (see `route_missing_local_model` -> `hosts_for_model`), so
"this desktop is currently sharing compute; stop sharing before using relay mesh as a client" // "serving" and "using the mesh as a client" are not mutually
.to_string(), // exclusive: a serve node can host model A and route model B to a
), // peer through the same ingress. Hand the agent the existing
None => Ok(status), // runtime; the router decides routability per request rather than
}; // this preflight second-guessing it (a `/v1/models` check here
// would race model gossip and wrongly reject freshly-discovered
// remote/split models).
//
// If the caller selected a specific target, still dial it: that is
// how the runtime joins the chosen peer's mesh. Skipping it would
// let a serve runtime not yet connected to that target fail its
// first inference while the frontend has already signalled the
// peer to expect us.
if let Some(endpoint_addr) = endpoint_addr
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
{
runtime
.dial_endpoint_addr(endpoint_addr)
.await
.map_err(|error| format!("mesh dial failed: {error}"))?;
}
return runtime.status().await.map_err(|error| error.to_string());
} }
} }
@@ -197,3 +216,80 @@ pub fn mesh_agent_preset(
) -> CmdResult<mesh_llm::MeshAgentPreset> { ) -> CmdResult<mesh_llm::MeshAgentPreset> {
mesh_llm::agent_preset(request) mesh_llm::agent_preset(request)
} }
#[cfg(all(test, feature = "mesh-llm"))]
mod tests {
use super::*;
use crate::app_state::build_app_state;
/// Acceptance-critical regression for dropping the serve-vs-client guard.
///
/// Before this change, `ensure_client_node_for_model` hard-errored whenever
/// the running runtime was in `Serve` mode ("stop sharing before using
/// relay mesh as a client"). That forbade the exact thing a user should be
/// able to do: host model A while pointing an agent at a different model B
/// through the same `9337` ingress.
///
/// This test starts a real serve runtime and asserts that a follow-up
/// preflight for a *different* model:
/// 1. does NOT reject on mode, and
/// 2. returns the existing runtime's status (same `9337` ingress), so the
/// agent keeps talking to the running node and mesh-llm's router
/// resolves the model per request.
///
/// Hardware-gated (`#[ignore]`): loads a real model. Run with:
/// cargo test -p sprout-desktop --features mesh-llm \
/// ensure_serve_runtime_serves_other_model -- --ignored --nocapture
#[tokio::test]
#[ignore = "loads a real model; run manually with --ignored"]
async fn ensure_serve_runtime_serves_other_model() {
const HOSTED_MODEL: &str = "jc-builds/SmolLM2-135M-Instruct-Q4_K_M-GGUF:Q4_K_M";
const OTHER_MODEL: &str = "some/other-model-not-hosted-locally:Q4_K_M";
let state = build_app_state();
// Start a serve runtime hosting HOSTED_MODEL — this is the "Share
// compute" path.
let serve = mesh_llm::DesktopMeshRuntime::start(mesh_llm::StartMeshNodeRequest {
mode: mesh_llm::MeshNodeMode::Serve,
model_id: Some(HOSTED_MODEL.to_string()),
max_vram_gb: None,
join_token: None,
})
.await
.expect("serve runtime should start");
let serve_status = serve.status().await.expect("serve status");
let serve_base = serve_status.api_base_url.clone();
assert_eq!(serve_status.mode, Some(mesh_llm::MeshNodeMode::Serve));
{
let mut runtime = state.mesh_llm_runtime.lock().await;
*runtime = Some(serve);
}
// Preflight for a DIFFERENT model with no explicit target. Old code:
// Err(...sharing compute...). New code: reuse the running ingress.
let status = ensure_client_node_for_model(&state, OTHER_MODEL, None)
.await
.expect("serve runtime must not reject a different-model preflight");
// It returns the SAME running node — agents keep using A's 9337, and
// the router decides routability for OTHER_MODEL per request.
assert_eq!(
status.mode,
Some(mesh_llm::MeshNodeMode::Serve),
"preflight should reuse the existing serve runtime, not spin up a client"
);
assert_eq!(
status.api_base_url, serve_base,
"agent must be pointed at the existing serve node's ingress"
);
// Clean up the runtime.
let taken = state.mesh_llm_runtime.lock().await.take();
if let Some(runtime) = taken {
let _ = runtime.stop().await;
}
}
}

View File

@@ -14,6 +14,29 @@ import type { MeshModelOption, MeshNodeStatus } from "@/shared/api/tauriMesh";
import { classifyModelRef, modelRefHintLabel } from "../classifyModelRef"; import { classifyModelRef, modelRefHintLabel } from "../classifyModelRef";
import { useMeshNodeStatus } from "../hooks/useMeshNodeStatus"; import { useMeshNodeStatus } from "../hooks/useMeshNodeStatus";
const MODEL_DRAFT_STORAGE_KEY = "sprout.mesh-compute.share.model.v1";
const MAX_VRAM_DRAFT_STORAGE_KEY = "sprout.mesh-compute.share.max-vram-gb.v1";
function readDraft(key: string): string {
try {
return window.localStorage.getItem(key) ?? "";
} catch {
return "";
}
}
function writeDraft(key: string, value: string): void {
try {
if (value === "") {
window.localStorage.removeItem(key);
} else {
window.localStorage.setItem(key, value);
}
} catch {
// Ignore unavailable/full storage; the input still works for this session.
}
}
/** /**
* Settings → Compute → Share compute. * Settings → Compute → Share compute.
* *
@@ -31,8 +54,12 @@ export function MeshComputeSettingsCard() {
const [installedModels, setInstalledModels] = React.useState< const [installedModels, setInstalledModels] = React.useState<
MeshModelOption[] MeshModelOption[]
>([]); >([]);
const [modelInput, setModelInput] = React.useState(""); const [modelInput, setModelInput] = React.useState(() =>
const [maxVramGb, setMaxVramGb] = React.useState<string>(""); readDraft(MODEL_DRAFT_STORAGE_KEY),
);
const [maxVramGb, setMaxVramGb] = React.useState<string>(() =>
readDraft(MAX_VRAM_DRAFT_STORAGE_KEY),
);
const [advancedOpen, setAdvancedOpen] = React.useState(false); const [advancedOpen, setAdvancedOpen] = React.useState(false);
const [actionInFlight, setActionInFlight] = React.useState(false); const [actionInFlight, setActionInFlight] = React.useState(false);
const [actionError, setActionError] = React.useState<string | null>(null); const [actionError, setActionError] = React.useState<string | null>(null);
@@ -63,6 +90,7 @@ export function MeshComputeSettingsCard() {
React.useEffect(() => { React.useEffect(() => {
if (status?.state === "running" && status.modelId && modelInput === "") { if (status?.state === "running" && status.modelId && modelInput === "") {
setModelInput(status.modelId); setModelInput(status.modelId);
writeDraft(MODEL_DRAFT_STORAGE_KEY, status.modelId);
} }
}, [status?.state, status?.modelId, modelInput]); }, [status?.state, status?.modelId, modelInput]);
@@ -153,7 +181,11 @@ export function MeshComputeSettingsCard() {
</legend> </legend>
<Input <Input
data-testid="mesh-share-compute-model" data-testid="mesh-share-compute-model"
onChange={(e) => setModelInput(e.target.value)} onChange={(e) => {
const next = e.target.value;
setModelInput(next);
writeDraft(MODEL_DRAFT_STORAGE_KEY, next);
}}
placeholder="Qwen3-8B-Q4_K_M or hf://meshllm/qwen3-8b@main" placeholder="Qwen3-8B-Q4_K_M or hf://meshllm/qwen3-8b@main"
value={modelInput} value={modelInput}
/> />
@@ -177,7 +209,10 @@ export function MeshComputeSettingsCard() {
<li key={m.id}> <li key={m.id}>
<button <button
className="rounded border border-border/60 bg-muted/20 px-2 py-0.5 text-xs hover:bg-muted/40" className="rounded border border-border/60 bg-muted/20 px-2 py-0.5 text-xs hover:bg-muted/40"
onClick={() => setModelInput(m.id)} onClick={() => {
setModelInput(m.id);
writeDraft(MODEL_DRAFT_STORAGE_KEY, m.id);
}}
type="button" type="button"
> >
{m.name ?? m.id} {m.name ?? m.id}
@@ -217,7 +252,11 @@ export function MeshComputeSettingsCard() {
data-testid="mesh-share-compute-vram" data-testid="mesh-share-compute-vram"
id="mesh-vram" id="mesh-vram"
inputMode="decimal" inputMode="decimal"
onChange={(e) => setMaxVramGb(e.target.value)} onChange={(e) => {
const next = e.target.value;
setMaxVramGb(next);
writeDraft(MAX_VRAM_DRAFT_STORAGE_KEY, next);
}}
placeholder="No limit" placeholder="No limit"
value={maxVramGb} value={maxVramGb}
/> />

View File

@@ -89,6 +89,30 @@ test("Share compute starts and stops a serve node", async ({ page }) => {
.toContain("mesh_stop_node"); .toContain("mesh_stop_node");
}); });
test("Share compute model draft persists across reload", async ({ page }) => {
await gotoApp(page);
await openSettings(page, "compute");
const model = page.getByTestId("mesh-share-compute-model");
await expect(model).toBeVisible({ timeout: 10_000 });
await model.fill("unsloth/Qwen3.6-35B-A3B-GGUF@main:UD-Q4_K_S");
await page.getByText("Advanced").click();
await page.getByTestId("mesh-share-compute-vram").fill("42");
await page.reload({ waitUntil: "domcontentloaded" });
await expect(page.getByTestId("open-agents-view")).toBeVisible({
timeout: 10_000,
});
await openSettings(page, "compute");
await expect(page.getByTestId("mesh-share-compute-model")).toHaveValue(
"unsloth/Qwen3.6-35B-A3B-GGUF@main:UD-Q4_K_S",
);
await page.getByText("Advanced").click();
await expect(page.getByTestId("mesh-share-compute-vram")).toHaveValue("42");
});
test("Run-on-relay-mesh ensures the client node BEFORE spawning the agent", async ({ test("Run-on-relay-mesh ensures the client node BEFORE spawning the agent", async ({
page, page,
}) => { }) => {

View File

@@ -181,7 +181,7 @@ desktop-release-build target="aarch64-apple-darwin":
touch "desktop/src-tauri/binaries/git-credential-nostr-$TARGET" touch "desktop/src-tauri/binaries/git-credential-nostr-$TARGET"
touch "desktop/src-tauri/binaries/sprout-$TARGET" touch "desktop/src-tauri/binaries/sprout-$TARGET"
pnpm install pnpm install
cd {{desktop_dir}} && TAURI_CLI_EXTRA_CARGO_ARGS="--features=mesh-llm" pnpm tauri build --target {{target}} cd {{desktop_dir}} && pnpm tauri build --features mesh-llm --target {{target}}
# Run desktop checks suitable for CI / pre-push # Run desktop checks suitable for CI / pre-push
desktop-ci: desktop-check desktop-test desktop-tauri-fmt-check desktop-build desktop-tauri-check desktop-tauri-test desktop-ci: desktop-check desktop-test desktop-tauri-fmt-check desktop-build desktop-tauri-check desktop-tauri-test
@@ -263,7 +263,7 @@ dev *ARGS: _ensure-sidecar-stubs
[[ -d node_modules ]] || pnpm install [[ -d node_modules ]] || pnpm install
source ../scripts/instance-env.sh source ../scripts/instance-env.sh
echo "Starting on Vite port ${SPROUT_VITE_PORT}, relay ${SPROUT_RELAY_URL}" echo "Starting on Vite port ${SPROUT_VITE_PORT}, relay ${SPROUT_RELAY_URL}"
TAURI_CLI_EXTRA_CARGO_ARGS="--features=mesh-llm" pnpm exec tauri dev --config "$SPROUT_TAURI_CONFIG" {{ARGS}} pnpm exec tauri dev --features mesh-llm --config "$SPROUT_TAURI_CONFIG" {{ARGS}}
# Run the desktop app against the internal staging relay (installs deps + builds agent tools automatically) # Run the desktop app against the internal staging relay (installs deps + builds agent tools automatically)
staging *ARGS: _ensure-sidecar-stubs staging *ARGS: _ensure-sidecar-stubs
@@ -279,7 +279,7 @@ staging *ARGS: _ensure-sidecar-stubs
source ../scripts/instance-env.sh source ../scripts/instance-env.sh
export SPROUT_RELAY_URL="wss://sprout-oss.stage.blox.sqprod.co" export SPROUT_RELAY_URL="wss://sprout-oss.stage.blox.sqprod.co"
echo "Starting staging on Vite port ${SPROUT_VITE_PORT}, relay ${SPROUT_RELAY_URL}" echo "Starting staging on Vite port ${SPROUT_VITE_PORT}, relay ${SPROUT_RELAY_URL}"
TAURI_CLI_EXTRA_CARGO_ARGS="--features=mesh-llm" pnpm exec tauri dev --config "$SPROUT_TAURI_CONFIG" {{ARGS}} pnpm exec tauri dev --features mesh-llm --config "$SPROUT_TAURI_CONFIG" {{ARGS}}
# Run the desktop frontend dev server (port derived from worktree) # Run the desktop frontend dev server (port derived from worktree)
desktop-dev: desktop-dev: