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;
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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,
|
||||||
}) => {
|
}) => {
|
||||||
|
|||||||
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/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:
|
||||||
|
|||||||
Reference in New Issue
Block a user