mirror of
https://github.com/block/buzz.git
synced 2026-10-27 04:30:52 +00:00
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:
@@ -57,15 +57,34 @@ pub(crate) async fn ensure_client_node_for_model(
|
||||
{
|
||||
let runtime = state.mesh_llm_runtime.lock().await;
|
||||
if let Some(runtime) = runtime.as_ref() {
|
||||
let status = runtime.status().await.map_err(|error| error.to_string())?;
|
||||
return match status.mode {
|
||||
Some(mesh_llm::MeshNodeMode::Client) => Ok(status),
|
||||
Some(mesh_llm::MeshNodeMode::Serve) => Err(
|
||||
"this desktop is currently sharing compute; stop sharing before using relay mesh as a client"
|
||||
.to_string(),
|
||||
),
|
||||
None => Ok(status),
|
||||
};
|
||||
// A running runtime — in any mode — is the mesh's local OpenAI
|
||||
// ingress on `9337`. mesh-llm's router already resolves the
|
||||
// requested model to a local, remote, or split target at request
|
||||
// time (see `route_missing_local_model` -> `hosts_for_model`), so
|
||||
// "serving" and "using the mesh as a client" are not mutually
|
||||
// exclusive: a serve node can host model A and route model B to a
|
||||
// peer through the same ingress. Hand the agent the existing
|
||||
// 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> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,29 @@ import type { MeshModelOption, MeshNodeStatus } from "@/shared/api/tauriMesh";
|
||||
import { classifyModelRef, modelRefHintLabel } from "../classifyModelRef";
|
||||
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.
|
||||
*
|
||||
@@ -31,8 +54,12 @@ export function MeshComputeSettingsCard() {
|
||||
const [installedModels, setInstalledModels] = React.useState<
|
||||
MeshModelOption[]
|
||||
>([]);
|
||||
const [modelInput, setModelInput] = React.useState("");
|
||||
const [maxVramGb, setMaxVramGb] = React.useState<string>("");
|
||||
const [modelInput, setModelInput] = React.useState(() =>
|
||||
readDraft(MODEL_DRAFT_STORAGE_KEY),
|
||||
);
|
||||
const [maxVramGb, setMaxVramGb] = React.useState<string>(() =>
|
||||
readDraft(MAX_VRAM_DRAFT_STORAGE_KEY),
|
||||
);
|
||||
const [advancedOpen, setAdvancedOpen] = React.useState(false);
|
||||
const [actionInFlight, setActionInFlight] = React.useState(false);
|
||||
const [actionError, setActionError] = React.useState<string | null>(null);
|
||||
@@ -63,6 +90,7 @@ export function MeshComputeSettingsCard() {
|
||||
React.useEffect(() => {
|
||||
if (status?.state === "running" && status.modelId && modelInput === "") {
|
||||
setModelInput(status.modelId);
|
||||
writeDraft(MODEL_DRAFT_STORAGE_KEY, status.modelId);
|
||||
}
|
||||
}, [status?.state, status?.modelId, modelInput]);
|
||||
|
||||
@@ -153,7 +181,11 @@ export function MeshComputeSettingsCard() {
|
||||
</legend>
|
||||
<Input
|
||||
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"
|
||||
value={modelInput}
|
||||
/>
|
||||
@@ -177,7 +209,10 @@ export function MeshComputeSettingsCard() {
|
||||
<li key={m.id}>
|
||||
<button
|
||||
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"
|
||||
>
|
||||
{m.name ?? m.id}
|
||||
@@ -217,7 +252,11 @@ export function MeshComputeSettingsCard() {
|
||||
data-testid="mesh-share-compute-vram"
|
||||
id="mesh-vram"
|
||||
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"
|
||||
value={maxVramGb}
|
||||
/>
|
||||
|
||||
@@ -89,6 +89,30 @@ test("Share compute starts and stops a serve node", async ({ page }) => {
|
||||
.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 ({
|
||||
page,
|
||||
}) => {
|
||||
|
||||
6
justfile
6
justfile
@@ -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/sprout-$TARGET"
|
||||
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
|
||||
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
|
||||
source ../scripts/instance-env.sh
|
||||
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)
|
||||
staging *ARGS: _ensure-sidecar-stubs
|
||||
@@ -279,7 +279,7 @@ staging *ARGS: _ensure-sidecar-stubs
|
||||
source ../scripts/instance-env.sh
|
||||
export SPROUT_RELAY_URL="wss://sprout-oss.stage.blox.sqprod.co"
|
||||
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)
|
||||
desktop-dev:
|
||||
|
||||
Reference in New Issue
Block a user